diff --git a/contracts/scripts/DeployLocal.sol b/contracts/scripts/DeployLocal.sol index 4f943f6246..afefef0ef8 100644 --- a/contracts/scripts/DeployLocal.sol +++ b/contracts/scripts/DeployLocal.sol @@ -16,6 +16,7 @@ import {ChannelID, ParaID, OperatingMode} from "../src/Types.sol"; import {SafeNativeTransfer} from "../src/utils/SafeTransfer.sol"; import {stdJson} from "forge-std/StdJson.sol"; import {UD60x18, ud60x18} from "prb/math/src/UD60x18.sol"; +import {HelloWorld} from "../test/mocks/HelloWorld.sol"; contract DeployLocal is Script { using SafeNativeTransfer for address payable; @@ -105,6 +106,8 @@ contract DeployLocal is Script { payable(bridgeHubAgent).safeNativeTransfer(initialDeposit); payable(assetHubAgent).safeNativeTransfer(initialDeposit); + // Deploy HelloWorld for testing transact + new HelloWorld(); // Deploy MockGatewayV2 for testing new MockGatewayV2(); diff --git a/contracts/scripts/FundAgent.sol b/contracts/scripts/FundAgent.sol index bda15cda17..d0c2569442 100644 --- a/contracts/scripts/FundAgent.sol +++ b/contracts/scripts/FundAgent.sol @@ -31,12 +31,15 @@ contract FundAgent is Script { bytes32 bridgeHubAgentID = vm.envBytes32("BRIDGE_HUB_AGENT_ID"); bytes32 assetHubAgentID = vm.envBytes32("ASSET_HUB_AGENT_ID"); + bytes32 penpalAgentID = vm.envBytes32("PENPAL_AGENT_ID"); address bridgeHubAgent = IGateway(gatewayAddress).agentOf(bridgeHubAgentID); address assetHubAgent = IGateway(gatewayAddress).agentOf(assetHubAgentID); + address penpalAgent = IGateway(gatewayAddress).agentOf(penpalAgentID); payable(bridgeHubAgent).safeNativeTransfer(initialDeposit); payable(assetHubAgent).safeNativeTransfer(initialDeposit); + payable(penpalAgent).safeNativeTransfer(initialDeposit); vm.stopBroadcast(); } diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index c9cdaa885e..719b0ed18c 100644 --- a/contracts/src/AgentExecutor.sol +++ b/contracts/src/AgentExecutor.sol @@ -7,12 +7,14 @@ import {SubstrateTypes} from "./SubstrateTypes.sol"; import {IERC20} from "./interfaces/IERC20.sol"; import {SafeTokenTransfer, SafeNativeTransfer} from "./utils/SafeTransfer.sol"; +import {Call} from "./utils/Call.sol"; /// @title Code which will run within an `Agent` using `delegatecall`. /// @dev This is a singleton contract, meaning that all agents will execute the same code. contract AgentExecutor { using SafeTokenTransfer for IERC20; using SafeNativeTransfer for address payable; + using Call for address; /// @dev Execute a message which originated from the Polkadot side of the bridge. In other terms, /// the `data` parameter is constructed by the BridgeHub parachain. @@ -36,4 +38,10 @@ contract AgentExecutor { function _transferToken(address token, address recipient, uint128 amount) internal { IERC20(token).safeTransfer(recipient, amount); } + + /// @dev Call a contract at the given address, with provided bytes as payload. + /// The safeCall here performs a low level call without copying any returndata for the return bomb attack + function executeCall(address target, bytes memory payload, uint64 maxDispatchGas) external returns (bool) { + return target.safeCall(maxDispatchGas, 0, payload); + } } diff --git a/contracts/src/Assets.sol b/contracts/src/Assets.sol index 53a79eaedc..1262df6650 100644 --- a/contracts/src/Assets.sol +++ b/contracts/src/Assets.sol @@ -42,11 +42,12 @@ library Assets { IERC20(token).safeTransferFrom(sender, agent, amount); } - function sendTokenCosts(address token, ParaID destinationChain, uint128 destinationChainFee, uint128 maxDestinationChainFee) - external - view - returns (Costs memory costs) - { + function sendTokenCosts( + address token, + ParaID destinationChain, + uint128 destinationChainFee, + uint128 maxDestinationChainFee + ) external view returns (Costs memory costs) { AssetsStorage.Layout storage $ = AssetsStorage.layout(); TokenInfo storage info = $.tokenRegistry[token]; if (!info.isRegistered) { diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 130fee2352..3afa503364 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -17,7 +17,8 @@ import { Command, MultiAddress, Ticket, - Costs + Costs, + AgentExecuteCommand } from "./Types.sol"; import {Upgrade} from "./Upgrade.sol"; import {IGateway} from "./interfaces/IGateway.sol"; @@ -39,7 +40,8 @@ import { SetOperatingModeParams, TransferNativeFromAgentParams, SetTokenTransferFeesParams, - SetPricingParametersParams + SetPricingParametersParams, + TransactCallParams } from "./Params.sol"; import {CoreStorage} from "./storage/CoreStorage.sol"; @@ -94,6 +96,7 @@ contract Gateway is IGateway, IInitializable, IUpgradable { error AgentExecutionFailed(bytes returndata); error InvalidAgentExecutionPayload(); error InvalidConstructorParams(); + error AgentTransactCallFailed(); // Message handlers can only be dispatched by the gateway itself modifier onlySelf() { @@ -165,6 +168,9 @@ contract Gateway is IGateway, IInitializable, IUpgradable { bool success = true; + uint256 gasUsedForTransact; + address agentForTransact; + // Dispatch message to a handler if (message.command == Command.AgentExecute) { try Gateway(this).agentExecute{gas: maxDispatchGas}(message.params) {} @@ -211,21 +217,17 @@ contract Gateway is IGateway, IInitializable, IUpgradable { catch { success = false; } + } else if (message.command == Command.Transact) { + try Gateway(this).transact{gas: maxDispatchGas}(message.params) returns (address _agent, uint256 _gasUsed) { + gasUsedForTransact = _gasUsed; + agentForTransact = _agent; + } catch { + success = false; + } } - // Calculate a gas refund, capped to protect against huge spikes in `tx.gasprice` - // that could drain funds unnecessarily. During these spikes, relayers should back off. - uint256 gasUsed = _transactionBaseGas() + (startGas - gasleft()); - uint256 refund = gasUsed * Math.min(tx.gasprice, message.maxFeePerGas); - - // Add the reward to the refund amount. If the sum is more than the funds available - // in the channel agent, then reduce the total amount - uint256 amount = Math.min(refund + message.reward, address(channel.agent).balance); - - // Do the payment if there funds available in the agent - if (amount > _dustThreshold()) { - _transferNativeFromAgent(channel.agent, payable(msg.sender), amount); - } + // Refund relayer + _refundRelayer(message, startGas, address(channel.agent), agentForTransact, gasUsedForTransact); emit IGateway.InboundMessageDispatched(message.channelID, message.nonce, message.id, success); } @@ -381,6 +383,25 @@ contract Gateway is IGateway, IInitializable, IUpgradable { emit PricingParametersChanged(); } + // @dev Transact + function transact(bytes calldata data) external onlySelf returns (address, uint256) { + uint256 gasLeftBefore = gasleft(); + + TransactCallParams memory params = abi.decode(data, (TransactCallParams)); + address agent = _ensureAgent(params.agentID); + bytes memory call = + abi.encodeCall(AgentExecutor.executeCall, (params.target, params.payload, params.maxDispatchGas)); + (, bytes memory result) = Agent(payable(agent)).invoke(AGENT_EXECUTOR, call); + (bool success) = abi.decode(result, (bool)); + if (!success) { + revert AgentTransactCallFailed(); + } + emit Transacted(params.agentID, params.target, keccak256(params.payload)); + + uint256 gasUsed = gasLeftBefore - gasleft(); + return (agent, gasUsed); + } + /** * Assets */ @@ -416,7 +437,9 @@ contract Gateway is IGateway, IInitializable, IUpgradable { uint128 amount ) external payable { _submitOutbound( - Assets.sendToken(token, msg.sender, destinationChain, destinationAddress, destinationFee, MAX_DESTINATION_FEE, amount) + Assets.sendToken( + token, msg.sender, destinationChain, destinationAddress, destinationFee, MAX_DESTINATION_FEE, amount + ) ); } @@ -541,6 +564,39 @@ contract Gateway is IGateway, IInitializable, IUpgradable { return 21000 * tx.gasprice; } + /// @dev Refund relayer from both channel agent and transact agent + function _refundRelayer( + InboundMessage calldata message, + uint256 startGas, + address agent, + address agentForTransact, + uint256 gasUsedForTransact + ) internal { + // Calculate a gas refund, capped to protect against huge spikes in `tx.gasprice` + // that could drain funds unnecessarily. During these spikes, relayers should back off. + uint256 gasUsed = _transactionBaseGas() + (startGas - gasleft()); + if (message.command == Command.Transact) { + // User agent pays for the transact dispatch + uint256 transactFee = Math.min(gasUsedForTransact * tx.gasprice, address(agentForTransact).balance); + if (transactFee > _dustThreshold()) { + _transferNativeFromAgent(agentForTransact, payable(msg.sender), transactFee); + } + // gas used for the channel agent should not include the cost for transact + gasUsed = gasUsed - gasUsedForTransact; + } + + uint256 refund = gasUsed * Math.min(tx.gasprice, message.maxFeePerGas); + + // Add the reward to the refund amount. If the sum is more than the funds available + // in the channel agent, then reduce the total amount + uint256 amount = Math.min(refund + message.reward, agent.balance); + + // Do the payment if there funds available in the agent + if (amount > _dustThreshold()) { + _transferNativeFromAgent(agent, payable(msg.sender), amount); + } + } + /** * Upgrades */ diff --git a/contracts/src/Params.sol b/contracts/src/Params.sol index fc02989152..687e95173c 100644 --- a/contracts/src/Params.sol +++ b/contracts/src/Params.sol @@ -82,3 +82,15 @@ struct SetPricingParametersParams { /// @dev Fee multiplier UD60x18 multiplier; } + +// Payload for TransactCall +struct TransactCallParams { + /// @dev The agent ID of the consensus system + bytes32 agentID; + /// @dev The target contract + address target; + /// @dev Payload of the call + bytes payload; + /// @dev Max gas cost of the call + uint64 maxDispatchGas; +} diff --git a/contracts/src/Types.sol b/contracts/src/Types.sol index 93d41bc0f5..2deaf690ad 100644 --- a/contracts/src/Types.sol +++ b/contracts/src/Types.sol @@ -84,7 +84,8 @@ enum Command { SetOperatingMode, TransferNativeFromAgent, SetTokenTransferFees, - SetPricingParameters + SetPricingParameters, + Transact } enum AgentExecuteCommand { diff --git a/contracts/src/interfaces/IGateway.sol b/contracts/src/interfaces/IGateway.sol index 5bcb2fd0dd..0c8acd3323 100644 --- a/contracts/src/interfaces/IGateway.sol +++ b/contracts/src/interfaces/IGateway.sol @@ -35,6 +35,8 @@ interface IGateway { // Emitted when funds are withdrawn from an agent event AgentFundsWithdrawn(bytes32 indexed agentID, address indexed recipient, uint256 amount); + event Transacted(bytes32 indexed agentID, address indexed target, bytes32 payloadHash); + /** * Getters */ diff --git a/contracts/src/utils/BytesLib.sol b/contracts/src/utils/BytesLib.sol new file mode 100644 index 0000000000..5c597de9ac --- /dev/null +++ b/contracts/src/utils/BytesLib.sol @@ -0,0 +1,534 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2023 Snowfork + +pragma solidity 0.8.23; + +library BytesLib { + function concat(bytes memory _preBytes, bytes memory _postBytes) internal pure returns (bytes memory) { + bytes memory tempBytes; + + assembly { + // Get a location of some free memory and store it in tempBytes as + // Solidity does for memory variables. + tempBytes := mload(0x40) + + // Store the length of the first bytes array at the beginning of + // the memory for tempBytes. + let length := mload(_preBytes) + mstore(tempBytes, length) + + // Maintain a memory counter for the current write location in the + // temp bytes array by adding the 32 bytes for the array length to + // the starting location. + let mc := add(tempBytes, 0x20) + // Stop copying when the memory counter reaches the length of the + // first bytes array. + let end := add(mc, length) + + for { + // Initialize a copy counter to the start of the _preBytes data, + // 32 bytes into its memory. + let cc := add(_preBytes, 0x20) + } lt(mc, end) { + // Increase both counters by 32 bytes each iteration. + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { + // Write the _preBytes data into the tempBytes memory 32 bytes + // at a time. + mstore(mc, mload(cc)) + } + + // Add the length of _postBytes to the current length of tempBytes + // and store it as the new length in the first 32 bytes of the + // tempBytes memory. + length := mload(_postBytes) + mstore(tempBytes, add(length, mload(tempBytes))) + + // Move the memory counter back from a multiple of 0x20 to the + // actual end of the _preBytes data. + mc := end + // Stop copying when the memory counter reaches the new combined + // length of the arrays. + end := add(mc, length) + + for { let cc := add(_postBytes, 0x20) } lt(mc, end) { + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { mstore(mc, mload(cc)) } + + // Update the free-memory pointer by padding our last write location + // to 32 bytes: add 31 bytes to the end of tempBytes to move to the + // next 32 byte block, then round down to the nearest multiple of + // 32. If the sum of the length of the two arrays is zero then add + // one before rounding down to leave a blank 32 bytes (the length block with 0). + mstore( + 0x40, + and( + add(add(end, iszero(add(length, mload(_preBytes)))), 31), + not(31) // Round down to the nearest 32 bytes. + ) + ) + } + + return tempBytes; + } + + function concatStorage(bytes storage _preBytes, bytes memory _postBytes) internal { + assembly { + // Read the first 32 bytes of _preBytes storage, which is the length + // of the array. (We don't need to use the offset into the slot + // because arrays use the entire slot.) + let fslot := sload(_preBytes.slot) + // Arrays of 31 bytes or less have an even value in their slot, + // while longer arrays have an odd value. The actual length is + // the slot divided by two for odd values, and the lowest order + // byte divided by two for even values. + // If the slot is even, bitwise and the slot with 255 and divide by + // two to get the length. If the slot is odd, bitwise and the slot + // with -1 and divide by two. + let slength := div(and(fslot, sub(mul(0x100, iszero(and(fslot, 1))), 1)), 2) + let mlength := mload(_postBytes) + let newlength := add(slength, mlength) + // slength can contain both the length and contents of the array + // if length < 32 bytes so let's prepare for that + // v. http://solidity.readthedocs.io/en/latest/miscellaneous.html#layout-of-state-variables-in-storage + switch add(lt(slength, 32), lt(newlength, 32)) + case 2 { + // Since the new array still fits in the slot, we just need to + // update the contents of the slot. + // uint256(bytes_storage) = uint256(bytes_storage) + uint256(bytes_memory) + new_length + sstore( + _preBytes.slot, + // all the modifications to the slot are inside this + // next block + add( + // we can just add to the slot contents because the + // bytes we want to change are the LSBs + fslot, + add( + mul( + div( + // load the bytes from memory + mload(add(_postBytes, 0x20)), + // zero all bytes to the right + exp(0x100, sub(32, mlength)) + ), + // and now shift left the number of bytes to + // leave space for the length in the slot + exp(0x100, sub(32, newlength)) + ), + // increase length by the double of the memory + // bytes length + mul(mlength, 2) + ) + ) + ) + } + case 1 { + // The stored value fits in the slot, but the combined value + // will exceed it. + // get the keccak hash to get the contents of the array + mstore(0x0, _preBytes.slot) + let sc := add(keccak256(0x0, 0x20), div(slength, 32)) + + // save new length + sstore(_preBytes.slot, add(mul(newlength, 2), 1)) + + // The contents of the _postBytes array start 32 bytes into + // the structure. Our first read should obtain the `submod` + // bytes that can fit into the unused space in the last word + // of the stored array. To get this, we read 32 bytes starting + // from `submod`, so the data we read overlaps with the array + // contents by `submod` bytes. Masking the lowest-order + // `submod` bytes allows us to add that value directly to the + // stored value. + + let submod := sub(32, slength) + let mc := add(_postBytes, submod) + let end := add(_postBytes, mlength) + let mask := sub(exp(0x100, submod), 1) + + sstore( + sc, + add( + and(fslot, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00), + and(mload(mc), mask) + ) + ) + + for { + mc := add(mc, 0x20) + sc := add(sc, 1) + } lt(mc, end) { + sc := add(sc, 1) + mc := add(mc, 0x20) + } { sstore(sc, mload(mc)) } + + mask := exp(0x100, sub(mc, end)) + + sstore(sc, mul(div(mload(mc), mask), mask)) + } + default { + // get the keccak hash to get the contents of the array + mstore(0x0, _preBytes.slot) + // Start copying to the last used word of the stored array. + let sc := add(keccak256(0x0, 0x20), div(slength, 32)) + + // save new length + sstore(_preBytes.slot, add(mul(newlength, 2), 1)) + + // Copy over the first `submod` bytes of the new data as in + // case 1 above. + let slengthmod := mod(slength, 32) + let mlengthmod := mod(mlength, 32) + let submod := sub(32, slengthmod) + let mc := add(_postBytes, submod) + let end := add(_postBytes, mlength) + let mask := sub(exp(0x100, submod), 1) + + sstore(sc, add(sload(sc), and(mload(mc), mask))) + + for { + sc := add(sc, 1) + mc := add(mc, 0x20) + } lt(mc, end) { + sc := add(sc, 1) + mc := add(mc, 0x20) + } { sstore(sc, mload(mc)) } + + mask := exp(0x100, sub(mc, end)) + + sstore(sc, mul(div(mload(mc), mask), mask)) + } + } + } + + function slice(bytes memory _bytes, uint256 _start, uint256 _length) internal pure returns (bytes memory) { + require(_length + 31 >= _length, "slice_overflow"); + require(_bytes.length >= _start + _length, "slice_outOfBounds"); + + bytes memory tempBytes; + + assembly { + switch iszero(_length) + case 0 { + // Get a location of some free memory and store it in tempBytes as + // Solidity does for memory variables. + tempBytes := mload(0x40) + + // The first word of the slice result is potentially a partial + // word read from the original array. To read it, we calculate + // the length of that partial word and start copying that many + // bytes into the array. The first word we copy will start with + // data we don't care about, but the last `lengthmod` bytes will + // land at the beginning of the contents of the new array. When + // we're done copying, we overwrite the full first word with + // the actual length of the slice. + let lengthmod := and(_length, 31) + + // The multiplication in the next line is necessary + // because when slicing multiples of 32 bytes (lengthmod == 0) + // the following copy loop was copying the origin's length + // and then ending prematurely not copying everything it should. + let mc := add(add(tempBytes, lengthmod), mul(0x20, iszero(lengthmod))) + let end := add(mc, _length) + + for { + // The multiplication in the next line has the same exact purpose + // as the one above. + let cc := add(add(add(_bytes, lengthmod), mul(0x20, iszero(lengthmod))), _start) + } lt(mc, end) { + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { mstore(mc, mload(cc)) } + + mstore(tempBytes, _length) + + //update free-memory pointer + //allocating the array padded to 32 bytes like the compiler does now + mstore(0x40, and(add(mc, 31), not(31))) + } + //if we want a zero-length slice let's just return a zero-length array + default { + tempBytes := mload(0x40) + //zero out the 32 bytes slice we are about to return + //we need to do it because Solidity does not garbage collect + mstore(tempBytes, 0) + + mstore(0x40, add(tempBytes, 0x20)) + } + } + + return tempBytes; + } + + function toAddress(bytes memory _bytes, uint256 _start) internal pure returns (address) { + require(_bytes.length >= _start + 20, "toAddress_outOfBounds"); + address tempAddress; + + assembly { + tempAddress := div(mload(add(add(_bytes, 0x20), _start)), 0x1000000000000000000000000) + } + + return tempAddress; + } + + function toUint8(bytes memory _bytes, uint256 _start) internal pure returns (uint8) { + require(_bytes.length >= _start + 1, "toUint8_outOfBounds"); + uint8 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0x1), _start)) + } + + return tempUint; + } + + function toUint16(bytes memory _bytes, uint256 _start) internal pure returns (uint16) { + require(_bytes.length >= _start + 2, "toUint16_outOfBounds"); + uint16 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0x2), _start)) + } + + return tempUint; + } + + function toUint32(bytes memory _bytes, uint256 _start) internal pure returns (uint32) { + require(_bytes.length >= _start + 4, "toUint32_outOfBounds"); + uint32 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0x4), _start)) + } + + return tempUint; + } + + function toUint64(bytes memory _bytes, uint256 _start) internal pure returns (uint64) { + require(_bytes.length >= _start + 8, "toUint64_outOfBounds"); + uint64 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0x8), _start)) + } + + return tempUint; + } + + function toUint96(bytes memory _bytes, uint256 _start) internal pure returns (uint96) { + require(_bytes.length >= _start + 12, "toUint96_outOfBounds"); + uint96 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0xc), _start)) + } + + return tempUint; + } + + function toUint128(bytes memory _bytes, uint256 _start) internal pure returns (uint128) { + require(_bytes.length >= _start + 16, "toUint128_outOfBounds"); + uint128 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0x10), _start)) + } + + return tempUint; + } + + function toUint256(bytes memory _bytes, uint256 _start) internal pure returns (uint256) { + require(_bytes.length >= _start + 32, "toUint256_outOfBounds"); + uint256 tempUint; + + assembly { + tempUint := mload(add(add(_bytes, 0x20), _start)) + } + + return tempUint; + } + + function toBytes32(bytes memory _bytes, uint256 _start) internal pure returns (bytes32) { + require(_bytes.length >= _start + 32, "toBytes32_outOfBounds"); + bytes32 tempBytes32; + + assembly { + tempBytes32 := mload(add(add(_bytes, 0x20), _start)) + } + + return tempBytes32; + } + + function equal(bytes memory _preBytes, bytes memory _postBytes) internal pure returns (bool) { + bool success = true; + + assembly { + let length := mload(_preBytes) + + // if lengths don't match the arrays are not equal + switch eq(length, mload(_postBytes)) + case 1 { + // cb is a circuit breaker in the for loop since there's + // no said feature for inline assembly loops + // cb = 1 - don't breaker + // cb = 0 - break + let cb := 1 + + let mc := add(_preBytes, 0x20) + let end := add(mc, length) + + for { let cc := add(_postBytes, 0x20) } + // the next line is the loop condition: + // while(uint256(mc < end) + cb == 2) + eq(add(lt(mc, end), cb), 2) { + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { + // if any of these checks fails then arrays are not equal + if iszero(eq(mload(mc), mload(cc))) { + // unsuccess: + success := 0 + cb := 0 + } + } + } + default { + // unsuccess: + success := 0 + } + } + + return success; + } + + function equal_nonAligned(bytes memory _preBytes, bytes memory _postBytes) internal pure returns (bool) { + bool success = true; + + assembly { + let length := mload(_preBytes) + + // if lengths don't match the arrays are not equal + switch eq(length, mload(_postBytes)) + case 1 { + // cb is a circuit breaker in the for loop since there's + // no said feature for inline assembly loops + // cb = 1 - don't breaker + // cb = 0 - break + let cb := 1 + + let endMinusWord := add(_preBytes, length) + let mc := add(_preBytes, 0x20) + let cc := add(_postBytes, 0x20) + + for { + // the next line is the loop condition: + // while(uint256(mc < endWord) + cb == 2) + } eq(add(lt(mc, endMinusWord), cb), 2) { + mc := add(mc, 0x20) + cc := add(cc, 0x20) + } { + // if any of these checks fails then arrays are not equal + if iszero(eq(mload(mc), mload(cc))) { + // unsuccess: + success := 0 + cb := 0 + } + } + + // Only if still successful + // For <1 word tail bytes + if gt(success, 0) { + // Get the remainder of length/32 + // length % 32 = AND(length, 32 - 1) + let numTailBytes := and(length, 0x1f) + let mcRem := mload(mc) + let ccRem := mload(cc) + for { let i := 0 } + // the next line is the loop condition: + // while(uint256(i < numTailBytes) + cb == 2) + eq(add(lt(i, numTailBytes), cb), 2) { i := add(i, 1) } { + if iszero(eq(byte(i, mcRem), byte(i, ccRem))) { + // unsuccess: + success := 0 + cb := 0 + } + } + } + } + default { + // unsuccess: + success := 0 + } + } + + return success; + } + + function equalStorage(bytes storage _preBytes, bytes memory _postBytes) internal view returns (bool) { + bool success = true; + + assembly { + // we know _preBytes_offset is 0 + let fslot := sload(_preBytes.slot) + // Decode the length of the stored array like in concatStorage(). + let slength := div(and(fslot, sub(mul(0x100, iszero(and(fslot, 1))), 1)), 2) + let mlength := mload(_postBytes) + + // if lengths don't match the arrays are not equal + switch eq(slength, mlength) + case 1 { + // slength can contain both the length and contents of the array + // if length < 32 bytes so let's prepare for that + // v. http://solidity.readthedocs.io/en/latest/miscellaneous.html#layout-of-state-variables-in-storage + if iszero(iszero(slength)) { + switch lt(slength, 32) + case 1 { + // blank the last byte which is the length + fslot := mul(div(fslot, 0x100), 0x100) + + if iszero(eq(fslot, mload(add(_postBytes, 0x20)))) { + // unsuccess: + success := 0 + } + } + default { + // cb is a circuit breaker in the for loop since there's + // no said feature for inline assembly loops + // cb = 1 - don't breaker + // cb = 0 - break + let cb := 1 + + // get the keccak hash to get the contents of the array + mstore(0x0, _preBytes.slot) + let sc := keccak256(0x0, 0x20) + + let mc := add(_postBytes, 0x20) + let end := add(mc, mlength) + + // the next line is the loop condition: + // while(uint256(mc < end) + cb == 2) + for {} eq(add(lt(mc, end), cb), 2) { + sc := add(sc, 1) + mc := add(mc, 0x20) + } { + if iszero(eq(sload(sc), mload(mc))) { + // unsuccess: + success := 0 + cb := 0 + } + } + } + } + } + default { + // unsuccess: + success := 0 + } + } + + return success; + } +} diff --git a/contracts/src/utils/Call.sol b/contracts/src/utils/Call.sol index b3b5733c26..2b58e64004 100644 --- a/contracts/src/utils/Call.sol +++ b/contracts/src/utils/Call.sol @@ -22,4 +22,26 @@ library Call { } } } + + /// @notice Perform a low level call without copying any returndata + /// @param _target Address to call + /// @param _gas Amount of gas to pass to the call + /// @param _value Amount of value to pass to the call + /// @param _calldata Calldata to pass to the call + function safeCall(address _target, uint256 _gas, uint256 _value, bytes memory _calldata) internal returns (bool) { + bool _success; + assembly { + _success := + call( + _gas, // gas + _target, // recipient + _value, // ether value + add(_calldata, 32), // inloc + mload(_calldata), // inlen + 0, // outloc + 0 // outlen + ) + } + return _success; + } } diff --git a/contracts/test/Gateway.t.sol b/contracts/test/Gateway.t.sol index 74fb60a9c1..5ae867adc4 100644 --- a/contracts/test/Gateway.t.sol +++ b/contracts/test/Gateway.t.sol @@ -23,7 +23,6 @@ import {SubstrateTypes} from "./../src/SubstrateTypes.sol"; import {MultiAddress} from "../src/MultiAddress.sol"; import {Channel, InboundMessage, OperatingMode, ParaID, Command, ChannelID, MultiAddress} from "../src/Types.sol"; - import {NativeTransferFailed} from "../src/utils/SafeTransfer.sol"; import {PricingStorage} from "../src/storage/PricingStorage.sol"; @@ -36,7 +35,8 @@ import { SetOperatingModeParams, TransferNativeFromAgentParams, SetTokenTransferFeesParams, - SetPricingParametersParams + SetPricingParametersParams, + TransactCallParams } from "../src/Params.sol"; import { @@ -51,6 +51,8 @@ import { import {WETH9} from "canonical-weth/WETH9.sol"; import {UD60x18, ud60x18, convert} from "prb/math/src/UD60x18.sol"; +import {HelloWorld} from "./mocks/HelloWorld.sol"; +import {IERC20} from "../src/interfaces/IERC20.sol"; contract GatewayTest is Test { ParaID public bridgeHubParaID = ParaID.wrap(1001); @@ -96,15 +98,15 @@ contract GatewayTest is Test { UD60x18 public exchangeRate = ud60x18(0.0025e18); UD60x18 public multiplier = ud60x18(1e18); + HelloWorld helloWorld; + + event SaidHello(string indexed message); + event Transfer(address indexed src, address indexed dst, uint256 wad); + function setUp() public { AgentExecutor executor = new AgentExecutor(); gatewayLogic = new MockGateway( - address(0), - address(executor), - bridgeHubParaID, - bridgeHubAgentID, - foreignTokenDecimals, - maxDestinationFee + address(0), address(executor), bridgeHubParaID, bridgeHubAgentID, foreignTokenDecimals, maxDestinationFee ); Gateway.Config memory config = Gateway.Config({ mode: OperatingMode.Normal, @@ -123,6 +125,8 @@ contract GatewayTest is Test { SetOperatingModeParams memory params = SetOperatingModeParams({mode: OperatingMode.Normal}); MockGateway(address(gateway)).setOperatingModePublic(abi.encode(params)); + helloWorld = new HelloWorld(); + bridgeHubAgent = IGateway(address(gateway)).agentOf(bridgeHubAgentID); assetHubAgent = IGateway(address(gateway)).agentOf(assetHubAgentID); @@ -857,6 +861,78 @@ contract GatewayTest is Test { IGateway(address(gateway)).quoteSendTokenFee(address(token), destPara, maxDestinationFee + 1); vm.expectRevert(Assets.InvalidDestinationFee.selector); - IGateway(address(gateway)).sendToken{value: fee}(address(token), destPara, recipientAddress32, maxDestinationFee + 1, 1); + IGateway(address(gateway)).sendToken{value: fee}( + address(token), destPara, recipientAddress32, maxDestinationFee + 1, 1 + ); + } + + function testAgentExecutionTransactSuccess() public { + bytes memory payload = abi.encodeWithSignature("sayHello(string)", "World"); + + TransactCallParams memory params = TransactCallParams({ + agentID: assetHubAgentID, + target: address(helloWorld), + payload: payload, + maxDispatchGas: 100000 + }); + + // Expect the HelloWorld contract to emit `SaidHello` + vm.expectEmit(true, false, false, false); + emit SaidHello("Hello there, World"); + + MockGateway(address(gateway)).transactPublic(abi.encode(params)); + } + + function testAgentExecutionTransactFail() public { + bytes memory payload = abi.encodeWithSignature("revertUnauthorized()"); + + TransactCallParams memory params = TransactCallParams({ + agentID: assetHubAgentID, + target: address(helloWorld), + payload: payload, + maxDispatchGas: 100000 + }); + + vm.expectRevert(abi.encodeWithSelector(Gateway.AgentTransactCallFailed.selector)); + + MockGateway(address(gateway)).transactPublic(abi.encode(params)); + } + + function testAgentExecutionTransactRetBomb() public { + bytes memory payload = abi.encodeWithSignature("retBomb()"); + + TransactCallParams memory params = TransactCallParams({ + agentID: assetHubAgentID, + target: address(helloWorld), + payload: payload, + maxDispatchGas: 100000 + }); + + vm.expectRevert(abi.encodeWithSelector(Gateway.AgentTransactCallFailed.selector)); + + MockGateway(address(gateway)).transactPublic(abi.encode(params)); + } + + function testAgentTransferERC20WithTransactShouldBeAllowed() public { + testRegisterToken(); + + token.transfer(address(assetHubAgent), 200); + + uint256 balanceBefore = IERC20(address(token)).balanceOf(account1); + + uint256 amount = 50; + bytes memory payload = abi.encodeCall(IERC20.transfer, (account1, amount)); + + TransactCallParams memory params = TransactCallParams({ + agentID: assetHubAgentID, + target: address(token), + payload: payload, + maxDispatchGas: 100000 + }); + + MockGateway(address(gateway)).transactPublic(abi.encode(params)); + + uint256 balanceAfter = IERC20(address(token)).balanceOf(account1); + assertEq(balanceBefore + amount, balanceAfter); } } diff --git a/contracts/test/mocks/HelloWorld.sol b/contracts/test/mocks/HelloWorld.sol new file mode 100644 index 0000000000..62baac6408 --- /dev/null +++ b/contracts/test/mocks/HelloWorld.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.23; + +contract HelloWorld { + event SaidHello(string indexed message); + + error Unauthorized(); + + function sayHello(string memory _text) public { + string memory fullMessage = string(abi.encodePacked("Hello there, ", _text)); + emit SaidHello(fullMessage); + } + + function revertUnauthorized() public pure { + revert Unauthorized(); + } + + function retBomb() public pure returns (bytes memory) { + assembly { + return(1, 3000000) + } + } +} diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index 2dbe3e53ae..d5a5bd64f0 100644 --- a/contracts/test/mocks/MockGateway.sol +++ b/contracts/test/mocks/MockGateway.sol @@ -20,14 +20,7 @@ contract MockGateway is Gateway { uint8 foreignTokenDecimals, uint128 maxDestinationFee ) - Gateway( - beefyClient, - agentExecutor, - bridgeHubParaID, - bridgeHubHubAgentID, - foreignTokenDecimals, - maxDestinationFee - ) + Gateway(beefyClient, agentExecutor, bridgeHubParaID, bridgeHubHubAgentID, foreignTokenDecimals, maxDestinationFee) {} function agentExecutePublic(bytes calldata params) external { @@ -83,4 +76,8 @@ contract MockGateway is Gateway { function setPricingParametersPublic(bytes calldata params) external { this.setPricingParameters(params); } + + function transactPublic(bytes calldata params) external { + this.transact(params); + } } diff --git a/contracts/test/mocks/MockGatewayV2.sol b/contracts/test/mocks/MockGatewayV2.sol index 31329282e9..f71a2518c9 100644 --- a/contracts/test/mocks/MockGatewayV2.sol +++ b/contracts/test/mocks/MockGatewayV2.sol @@ -19,7 +19,7 @@ library AdditionalStorage { } // Used to test upgrades. -contract MockGatewayV2 is IInitializable { +contract MockGatewayV2 is IInitializable { // Reinitialize gateway with some additional storage fields function initialize(bytes memory params) external { AdditionalStorage.Layout storage $ = AdditionalStorage.layout(); diff --git a/rfc/transact_from_substrate_to_ethereum.md b/rfc/transact_from_substrate_to_ethereum.md new file mode 100644 index 0000000000..22ce84e19d --- /dev/null +++ b/rfc/transact_from_substrate_to_ethereum.md @@ -0,0 +1,70 @@ +# RFC: Transact from Substrate to Ethereum + + +## Summary + +This RFC proposes the feature to call transact from Substrate to Ethereum through our bridge, including two PRs separately with +- https://github.com/Snowfork/snowbridge/pull/1145 for solidity +- https://github.com/Snowfork/polkadot-sdk/pull/116 for substrate + +## Explanation + +We use penpal for the integration, basically it works as follows: + +On penpal end user call the custom extrinsic [transact_to_ethereum](https://github.com/Snowfork/polkadot-sdk/blob/d8e4424de5a38c9bfcbb4d1920ef5ad873460a35/cumulus/parachains/runtimes/testing/penpal/src/pallets/transact_helper.rs#L92), the parameters of the extrinsic are: + +- `target` is the contract address +- `call` is abi-encoded call data +- `gas_limit` is the max gas limit for the call + +It requires penpal to extend the `XcmRouter` to route xcm destination to Ethereum through our bridge, i.e. a [SovereignPaidRemoteExporter](https://github.com/Snowfork/polkadot-sdk/blob/d8e4424de5a38c9bfcbb4d1920ef5ad873460a35/cumulus/parachains/runtimes/testing/penpal/src/xcm_config.rs#L376) for the reference. + +Worth to note it's the responsibility of the end user to [create a pre-funded agent](https://github.com/Snowfork/polkadot-sdk/blob/d8e4424de5a38c9bfcbb4d1920ef5ad873460a35/cumulus/parachains/integration-tests/emulated/tests/bridges/bridge-hub-rococo/src/tests/snowbridge.rs#L641) for the execution on Ethereum and the [fee config in BridgeTable](https://github.com/Snowfork/polkadot-sdk/blob/d8e4424de5a38c9bfcbb4d1920ef5ad873460a35/cumulus/parachains/runtimes/testing/penpal/src/xcm_config.rs#L441) should only cover the delivery cost on BridgeHub. + +The xcm sent to BH as following: + +``` +instructions: [ + WithdrawAsset(Assets([Asset { id: AssetId(Location { parents: 1, interior: Here }), fun: Fungible(4000000000) }])), + BuyExecution { fees: Asset { id: AssetId(Location { parents: 1, interior: Here }), fun: Fungible(4000000000) }, weight_limit: Unlimited }, + SetAppendix(Xcm([DepositAsset { assets: Wild(AllCounted(1)), beneficiary: Location { parents: 1, interior: X1([Parachain(2000)]) } }])), + ExportMessage { + network: Ethereum { chain_id: 11155111 }, + destination: Here, + xcm: Xcm([ + DescendOrigin(X1([AccountId32 { network: None, id: [212, 53, 147, 199, 21, 253, 211, 28, 97, 20, 26, 189, 4, 169, 159, 214, 130, 44, 133, 88, 133, 76, 205, 227, 154, 86, 132, 231, 165, 109, 162, 125] }])), + Transact { + origin_kind: SovereignAccount, + require_weight_at_most: Weight { ref_time: 0, proof_size: 0 }, + call:"0xee9170abfbf9421ad6dd07f6bdec9d89f2b581e02000071468656c6c6f80380100000000002037c77c800200000000000000000000" + }, + SetTopic([246, 212, 97, 180, 175, 81, 117, 103, 224, 221, 218, 113, 3, 208, 44, 190, 29, 179, 253, 192, 17, 9, 96, 195, 56, 123, 82, 145, 102, 55, 31, 171]) + ]) + } +] +``` + +The top-level `WithdrawAsset` and `BuyExecution` will withdraw relay token from sovereign account of penpal as fee to pay for the delivery cost on BridgeHub. + +What we really care about is the internal xcm in `ExportMessage`, the `Transact` in it is actually the encoded [TransactInfo](https://github.com/Snowfork/polkadot-sdk/blob/d8e4424de5a38c9bfcbb4d1920ef5ad873460a35/bridges/snowbridge/primitives/core/src/outbound.rs#L442) which represents the call on Ethereum and a custom agent derived from the previous `DescendOrigin` which represents the original user who is supposed to execute the transact. + +With [the convert logic in outbound-router](https://github.com/Snowfork/polkadot-sdk/blob/d8e4424de5a38c9bfcbb4d1920ef5ad873460a35/bridges/snowbridge/primitives/router/src/outbound/mod.rs#L204) it will be converted into a simple `Command` which will be relayed and finally executed on Ethereum. + +Worth to note that the sovereign of Penpal only pays(DOT) for the [delivery(local fee) portion](https://github.com/Snowfork/polkadot-sdk/blob/d8e4424de5a38c9bfcbb4d1920ef5ad873460a35/bridges/snowbridge/primitives/router/src/outbound/mod.rs#L111-L114) to the Treasury on BH. There is no refund for the remote fee portion in this case. + +There is a E2E test [transact_from_penpal_to_ethereum](https://github.com/Snowfork/polkadot-sdk/blob/d8e4424de5a38c9bfcbb4d1920ef5ad873460a35/cumulus/parachains/integration-tests/emulated/tests/bridges/bridge-hub-rococo/src/tests/snowbridge.rs#L567) for demonstration. + +Finally on Ethereum based on the `Command` the specified agent will [execute the call](https://github.com/Snowfork/snowbridge/blob/606e867b7badc6d356c8f4b56e6b81ee0eb27811/contracts/src/Gateway.sol#L393). + +## Fee flow + +- User represents a user who kicks off an extrinsic on the parachain. +- Parachain represents the source parachain, its sovereign or its agent depending on context. + +Sequence|Where|Who|What +-|-|-|- +1|Parachain|User|pays(DOT, Native) to node to execute custom extrinsic; pays (DOT) to Treasury for both delivery cost on BH and execution cost on Ethereum. +2|Bridge Hub|Parachain|pays(DOT) to Treasury Account for delivery(local portion), only check remote fee passed as expected without charging +3|Gateway|Relayer|pays(ETH) to validate and execute message. +4|Gateway|Parachain Agent|pays(ETH) to relayer for delivery(reward+refund) and execution(except for the gas used to dispatch the transact payload). +5|Gateway|User Agent|pays(ETH) to relayer for the transact dispatch. diff --git a/smoketest/make-bindings.sh b/smoketest/make-bindings.sh index 0cc826c3f6..28b7b23c9b 100755 --- a/smoketest/make-bindings.sh +++ b/smoketest/make-bindings.sh @@ -6,7 +6,7 @@ mkdir -p src/contracts # Generate Rust bindings for contracts forge bind --module --overwrite \ - --select 'IGateway|IUpgradable|WETH9|MockGatewayV2' \ + --select 'IGateway|IUpgradable|WETH9|MockGatewayV2|HelloWorld' \ --bindings-path src/contracts \ --root ../contracts diff --git a/smoketest/tests/transact_from_penpal_to_ethereum.rs b/smoketest/tests/transact_from_penpal_to_ethereum.rs new file mode 100644 index 0000000000..9abf1bac6b --- /dev/null +++ b/smoketest/tests/transact_from_penpal_to_ethereum.rs @@ -0,0 +1,83 @@ +use ethers::{ + abi::{Abi, Token}, + prelude::{Address, Middleware, Provider, Ws}, +}; +use futures::StreamExt; +use hex_literal::hex; +use snowbridge_smoketest::{ + contracts::hello_world::{HelloWorld, SaidHelloFilter}, + helper::*, + parachains::penpal::api as PenpalApi, +}; +use std::{ops::Deref, sync::Arc}; +use subxt::{ + ext::sp_core::{sr25519::Pair, Pair as PairT}, + tx::PairSigner, +}; + +const HELLO_WORLD_CONTRACT: [u8; 20] = hex!("EE9170ABFbf9421Ad6DD07F6BDec9D89F2B581E0"); + +#[tokio::test] +async fn transact_from_penpal_to_ethereum() { + let test_clients = initial_clients().await.expect("initialize clients"); + + let ethereum_client = *(test_clients.ethereum_client.clone()); + let penpal_client = *(test_clients.penpal_client.clone()); + + let hello_world = HelloWorld::new(HELLO_WORLD_CONTRACT, ethereum_client.clone()); + let contract_abi: Abi = hello_world.abi().clone(); + let function = contract_abi.function("sayHello").unwrap(); + let encoded_data = + function.encode_input(&[Token::String("Hello!".to_string())]).unwrap(); + + println!("data is {}", hex::encode(encoded_data.clone())); + + let extrinsic_call = PenpalApi::transact_helper::calls::TransactionApi.transact_to_ethereum( + HELLO_WORLD_CONTRACT.into(), + encoded_data, + 4_000_000_000, + 80_000, + ); + + let owner: Pair = Pair::from_string("//Bob", None).expect("cannot create keypair"); + let signer: PairSigner = PairSigner::new(owner); + + let _ = penpal_client + .tx() + .sign_and_submit_then_watch_default(&extrinsic_call, &signer) + .await + .expect("send through xcm call."); + + wait_for_arbitrary_transact_event(&test_clients.ethereum_client, HELLO_WORLD_CONTRACT).await; +} + +pub async fn wait_for_arbitrary_transact_event( + ethereum_client: &Box>>, + contract_address: [u8; 20], +) { + let addr: Address = contract_address.into(); + let contract = HelloWorld::new(addr, (*ethereum_client).deref().clone()); + + let wait_for_blocks = 300; + let mut stream = ethereum_client.subscribe_blocks().await.unwrap().take(wait_for_blocks); + + let mut ethereum_event_found = false; + while let Some(block) = stream.next().await { + if let Ok(events) = contract + .event::() + .at_block_hash(block.hash.unwrap()) + .query() + .await + { + for _ in events { + println!("Event found at ethereum block {:?}", block.number.unwrap()); + ethereum_event_found = true; + break + } + } + if ethereum_event_found { + break + } + } + assert!(ethereum_event_found); +} diff --git a/web/packages/test/scripts/set-env.sh b/web/packages/test/scripts/set-env.sh index eedc166d5e..9ee64a8469 100755 --- a/web/packages/test/scripts/set-env.sh +++ b/web/packages/test/scripts/set-env.sh @@ -47,9 +47,11 @@ assethub_ws_url="${ASSET_HUB_WS_URL:-ws://127.0.0.1:12144}" assethub_seed="${ASSET_HUB_SEED:-//Alice}" export ASSET_HUB_PARAID="${ASSET_HUB_PARAID:-1000}" export ASSET_HUB_AGENT_ID="${ASSET_HUB_AGENT_ID:-0x81c5ab2571199e3188135178f3c2c8e2d268be1313d029b30f534fa579b69b79}" - export ASSET_HUB_CHANNEL_ID="0xc173fac324158e77fb5840738a1a541f633cbec8884c6a601c567d2b376a0539" + +export PENPAL_AGENT_ID="5097ee1101e90c3aadb882858c59a22108668021ec81bce9f4930155e5c21e59" export PENPAL_CHANNEL_ID="0xa69fbbae90bb6096d59b1930bbcfc8a3ef23959d226b1861deb7ad8fb06c6fa3" + export PRIMARY_GOVERNANCE_CHANNEL_ID="0x0000000000000000000000000000000000000000000000000000000000000001" export SECONDARY_GOVERNANCE_CHANNEL_ID="0x0000000000000000000000000000000000000000000000000000000000000002"