diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index a243cd8739..39ec86d0a7 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -16,6 +16,8 @@ jobs: build: runs-on: snowbridge-runner timeout-minutes: 15 + env: + MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} steps: - uses: actions/checkout@v1 with: diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index cf64828996..c3ed7e2f81 100644 --- a/contracts/src/AgentExecutor.sol +++ b/contracts/src/AgentExecutor.sol @@ -7,6 +7,7 @@ import {SubstrateTypes} from "./SubstrateTypes.sol"; import {IERC20} from "./interfaces/IERC20.sol"; import {SafeTokenTransfer, SafeNativeTransfer} from "./utils/SafeTransfer.sol"; +import {Gateway} from "./Gateway.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. @@ -14,17 +15,6 @@ contract AgentExecutor { using SafeTokenTransfer for IERC20; using SafeNativeTransfer for address payable; - /// @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. - /// - function execute(bytes memory data) external { - (AgentExecuteCommand command, bytes memory params) = abi.decode(data, (AgentExecuteCommand, bytes)); - if (command == AgentExecuteCommand.TransferToken) { - (address token, address recipient, uint128 amount) = abi.decode(params, (address, address, uint128)); - _transferToken(token, recipient, amount); - } - } - /// @dev Transfer ether to `recipient`. Unlike `_transferToken` This logic is not nested within `execute`, /// as the gateway needs to control an agent's ether balance directly. /// @@ -32,6 +22,11 @@ contract AgentExecutor { recipient.safeNativeTransfer(amount); } + /// @dev Transfer ERC20 to `recipient`. Only callable via `execute`. + function transferToken(address token, address recipient, uint128 amount) external { + _transferToken(token, recipient, amount); + } + /// @dev Transfer ERC20 to `recipient`. Only callable via `execute`. function _transferToken(address token, address recipient, uint128 amount) internal { IERC20(token).safeTransfer(recipient, amount); diff --git a/contracts/src/Assets.sol b/contracts/src/Assets.sol index 9e6364245b..c88ead8846 100644 --- a/contracts/src/Assets.sol +++ b/contracts/src/Assets.sol @@ -8,9 +8,15 @@ import {IGateway} from "./interfaces/IGateway.sol"; import {SafeTokenTransferFrom} from "./utils/SafeTransfer.sol"; import {AssetsStorage, TokenInfo} from "./storage/AssetsStorage.sol"; +import {CoreStorage} from "./storage/CoreStorage.sol"; + import {SubstrateTypes} from "./SubstrateTypes.sol"; import {ParaID, MultiAddress, Ticket, Costs} from "./Types.sol"; import {Address} from "./utils/Address.sol"; +import {AgentExecutor} from "./AgentExecutor.sol"; +import {Agent} from "./Agent.sol"; +import {Call} from "./utils/Call.sol"; +import {Token} from "./Token.sol"; /// @title Library for implementing Ethereum->Polkadot ERC20 transfers. library Assets { @@ -24,6 +30,10 @@ library Assets { error TokenNotRegistered(); error Unsupported(); error InvalidDestinationFee(); + error AgentDoesNotExist(); + error TokenAlreadyRegistered(); + error TokenMintFailed(); + error TokenTransferFailed(); function isTokenRegistered(address token) external view returns (bool) { return AssetsStorage.layout().tokenRegistry[token].isRegistered; @@ -42,11 +52,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) { @@ -98,10 +109,40 @@ library Assets { AssetsStorage.Layout storage $ = AssetsStorage.layout(); TokenInfo storage info = $.tokenRegistry[token]; + if (!info.isRegistered) { revert TokenNotRegistered(); } + if (info.foreignID == bytes32(0)) { + return _sendNativeToken( + token, sender, destinationChain, destinationAddress, destinationChainFee, maxDestinationChainFee, amount + ); + } else { + return _sendForeignToken( + info.foreignID, + token, + sender, + destinationChain, + destinationAddress, + destinationChainFee, + maxDestinationChainFee, + amount + ); + } + } + + function _sendNativeToken( + address token, + address sender, + ParaID destinationChain, + MultiAddress calldata destinationAddress, + uint128 destinationChainFee, + uint128 maxDestinationChainFee, + uint128 amount + ) internal returns (Ticket memory ticket) { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + // Lock the funds into AssetHub's agent contract _transferToAgent($.assetHubAgent, token, sender, amount); @@ -153,6 +194,98 @@ library Assets { emit IGateway.TokenSent(token, sender, destinationChain, destinationAddress, amount); } + function _sendForeignTokenCosts( + ParaID destinationChain, + uint128 destinationChainFee, + uint128 maxDestinationChainFee + ) internal view returns (Costs memory costs) { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + if ($.assetHubParaID == destinationChain) { + costs.foreign = $.assetHubReserveTransferFee; + } else { + // Reduce the ability for users to perform arbitrage by exploiting a + // favourable exchange rate. For example supplying Ether + // and gaining a more valuable amount of DOT on the destination chain. + // + // Also prevents users from mistakenly sending more fees than would be required + // which has negative effects like draining AssetHub's sovereign account. + // + // For safety, `maxDestinationChainFee` should be less valuable + // than the gas cost to send tokens. + if (destinationChainFee > maxDestinationChainFee) { + revert InvalidDestinationFee(); + } + + // If the final destination chain is not AssetHub, then the fee needs to additionally + // include the cost of executing an XCM on the final destination parachain. + costs.foreign = $.assetHubReserveTransferFee + destinationChainFee; + } + // We don't charge any extra fees beyond delivery costs + costs.native = 0; + } + + // @dev Transfer Polkadot-native tokens back to Polkadot + function _sendForeignToken( + bytes32 foreignID, + address token, + address sender, + ParaID destinationChain, + MultiAddress calldata destinationAddress, + uint128 destinationChainFee, + uint128 maxDestinationChainFee, + uint128 amount + ) internal returns (Ticket memory ticket) { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + + Token(token).burn(sender, amount); + + ticket.dest = $.assetHubParaID; + ticket.costs = _sendForeignTokenCosts(destinationChain, destinationChainFee, maxDestinationChainFee); + + // Construct a message payload + if (destinationChain == $.assetHubParaID) { + // The funds will be minted into the receiver's account on AssetHub + if (destinationAddress.isAddress32()) { + // The receiver has a 32-byte account ID + ticket.payload = SubstrateTypes.SendForeignTokenToAssetHubAddress32( + foreignID, destinationAddress.asAddress32(), $.assetHubReserveTransferFee, amount + ); + } else { + // AssetHub does not support 20-byte account IDs + revert Unsupported(); + } + } else { + if (destinationChainFee == 0) { + revert InvalidDestinationFee(); + } + if (destinationAddress.isAddress32()) { + // The receiver has a 32-byte account ID + ticket.payload = SubstrateTypes.SendForeignTokenToAddress32( + foreignID, + destinationChain, + destinationAddress.asAddress32(), + $.assetHubReserveTransferFee, + destinationChainFee, + amount + ); + } else if (destinationAddress.isAddress20()) { + // The receiver has a 20-byte account ID + ticket.payload = SubstrateTypes.SendForeignTokenToAddress20( + foreignID, + destinationChain, + destinationAddress.asAddress20(), + $.assetHubReserveTransferFee, + destinationChainFee, + amount + ); + } else { + revert Unsupported(); + } + } + + emit IGateway.TokenSent(token, sender, destinationChain, destinationAddress, amount); + } + function registerTokenCosts() external view returns (Costs memory costs) { return _registerTokenCosts(); } @@ -188,4 +321,58 @@ library Assets { emit IGateway.TokenRegistrationSent(token); } + + // @dev Register a new fungible Polkadot token for an agent + function registerForeignToken(bytes32 foreignTokenID, string memory name, string memory symbol, uint8 decimals) + external + { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + if ($.tokenAddressOf[foreignTokenID] != address(0)) { + revert TokenAlreadyRegistered(); + } + Token token = new Token(name, symbol, decimals); + TokenInfo memory info = TokenInfo({isRegistered: true, foreignID: foreignTokenID}); + + $.tokenAddressOf[foreignTokenID] = address(token); + $.tokenRegistry[address(token)] = info; + + emit IGateway.ForeignTokenRegistered(foreignTokenID, address(token)); + } + + // @dev Mint foreign token from Polkadot + function mintForeignToken(bytes32 foreignTokenID, address recipient, uint256 amount) external { + address token = _ensureTokenAddressOf(foreignTokenID); + Token(token).mint(recipient, amount); + } + + // @dev Transfer ERC20 to `recipient` + function transferNativeToken(address executor, address agent, address token, address recipient, uint128 amount) + external + { + bytes memory call = abi.encodeCall(AgentExecutor.transferToken, (token, recipient, amount)); + (bool success,) = Agent(payable(agent)).invoke(executor, call); + if (!success) { + revert TokenTransferFailed(); + } + } + + // @dev Get token address by tokenID + function tokenAddressOf(bytes32 tokenID) external view returns (address) { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + return $.tokenAddressOf[tokenID]; + } + + // @dev Get token address by tokenID + function _ensureTokenAddressOf(bytes32 tokenID) internal view returns (address) { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + if ($.tokenAddressOf[tokenID] == address(0)) { + revert TokenNotRegistered(); + } + return $.tokenAddressOf[tokenID]; + } + + function _isTokenRegistered(address token) internal view returns (bool) { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + return $.tokenRegistry[token].isRegistered; + } } diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 81e1b46acb..d4ac703db6 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -17,7 +17,9 @@ import { Command, MultiAddress, Ticket, - Costs + Costs, + TokenInfo, + AgentExecuteCommand } from "./Types.sol"; import {Upgrade} from "./Upgrade.sol"; import {IGateway} from "./interfaces/IGateway.sol"; @@ -31,15 +33,18 @@ import {Math} from "./utils/Math.sol"; import {ScaleCodec} from "./utils/ScaleCodec.sol"; import { + AgentExecuteParams, UpgradeParams, CreateAgentParams, - AgentExecuteParams, CreateChannelParams, UpdateChannelParams, SetOperatingModeParams, TransferNativeFromAgentParams, SetTokenTransferFeesParams, - SetPricingParametersParams + SetPricingParametersParams, + RegisterForeignTokenParams, + MintForeignTokenParams, + TransferNativeTokenParams } from "./Params.sol"; import {CoreStorage} from "./storage/CoreStorage.sol"; @@ -95,6 +100,8 @@ contract Gateway is IGateway, IInitializable, IUpgradable { error AgentExecutionFailed(bytes returndata); error InvalidAgentExecutionPayload(); error InvalidConstructorParams(); + error AlreadyInitialized(); + error TokenNotRegistered(); // Message handlers can only be dispatched by the gateway itself modifier onlySelf() { @@ -104,6 +111,15 @@ contract Gateway is IGateway, IInitializable, IUpgradable { _; } + // handler functions are privileged from agent only + modifier onlyAgent(bytes32 agentID) { + bytes32 _agentID = _ensureAgentAddress(msg.sender); + if (_agentID != agentID) { + revert Unauthorized(); + } + _; + } + constructor( address beefyClient, address agentExecutor, @@ -212,6 +228,21 @@ contract Gateway is IGateway, IInitializable, IUpgradable { catch { success = false; } + } else if (message.command == Command.TransferNativeToken) { + try Gateway(this).transferNativeToken{gas: maxDispatchGas}(message.params) {} + catch { + success = false; + } + } else if (message.command == Command.RegisterForeignToken) { + try Gateway(this).registerForeignToken{gas: maxDispatchGas}(message.params) {} + catch { + success = false; + } + } else if (message.command == Command.MintForeignToken) { + try Gateway(this).mintForeignToken{gas: maxDispatchGas}(message.params) {} + catch { + success = false; + } } // Calculate a gas refund, capped to protect against huge spikes in `tx.gasprice` @@ -275,11 +306,11 @@ contract Gateway is IGateway, IInitializable, IUpgradable { revert InvalidAgentExecutionPayload(); } - bytes memory call = abi.encodeCall(AgentExecutor.execute, params.payload); - - (bool success, bytes memory returndata) = Agent(payable(agent)).invoke(AGENT_EXECUTOR, call); - if (!success) { - revert AgentExecutionFailed(returndata); + (AgentExecuteCommand command, bytes memory commandParams) = + abi.decode(params.payload, (AgentExecuteCommand, bytes)); + if (command == AgentExecuteCommand.TransferToken) { + (address token, address recipient, uint128 amount) = abi.decode(commandParams, (address, address, uint128)); + Assets.transferNativeToken(AGENT_EXECUTOR, agent, token, recipient, amount); } } @@ -385,6 +416,25 @@ contract Gateway is IGateway, IInitializable, IUpgradable { /** * Assets */ + // @dev Register a new fungible Polkadot token for an agent + function registerForeignToken(bytes calldata data) external onlySelf { + RegisterForeignTokenParams memory params = abi.decode(data, (RegisterForeignTokenParams)); + Assets.registerForeignToken(params.foreignTokenID, params.name, params.symbol, params.decimals); + } + + // @dev Mint foreign token from polkadot + function mintForeignToken(bytes calldata data) external onlySelf { + MintForeignTokenParams memory params = abi.decode(data, (MintForeignTokenParams)); + Assets.mintForeignToken(params.foreignTokenID, params.recipient, params.amount); + } + + // @dev Transfer Ethereum native token back from polkadot + function transferNativeToken(bytes calldata data) external onlySelf { + TransferNativeTokenParams memory params = abi.decode(data, (TransferNativeTokenParams)); + address agent = _ensureAgent(params.agentID); + Assets.transferNativeToken(AGENT_EXECUTOR, agent, params.token, params.recipient, params.amount); + } + function isTokenRegistered(address token) external view returns (bool) { return Assets.isTokenRegistered(token); } @@ -416,11 +466,16 @@ contract Gateway is IGateway, IInitializable, IUpgradable { uint128 destinationFee, uint128 amount ) external payable { - _submitOutbound( - Assets.sendToken( - token, msg.sender, destinationChain, destinationAddress, destinationFee, MAX_DESTINATION_FEE, amount - ) + Ticket memory ticket = Assets.sendToken( + token, msg.sender, destinationChain, destinationAddress, destinationFee, MAX_DESTINATION_FEE, amount ); + + _submitOutbound(ticket); + } + + // @dev Get token address by tokenID + function tokenAddressOf(bytes32 tokenID) external view returns (address) { + return Assets.tokenAddressOf(tokenID); } /** @@ -479,7 +534,17 @@ contract Gateway is IGateway, IInitializable, IUpgradable { // Ensure outbound messaging is allowed _ensureOutboundMessagingEnabled(channel); - uint256 fee = _calculateFee(ticket.costs); + uint256 fee; + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + if (ticket.dest == $.assetHubParaID) { + fee = _calculateFee(ticket.costs); + } else { + // The assumption here is that no matter what the fee token is, usually the xcm execution cost on substrate chain is very tiny + // as demonstrated in https://www.notion.so/snowfork/Gateway-Parameters-0cf913d089374027a86721883306ee61 + // the DELIVERY_COST on BH and TRANSFER_TOKEN_FEE are all no more than 0.1 DOT so the total cost as 0.2 DOT equals 0.00048 ETH. + // Consider a 5x buffer a hard code 0.002 ETH should be pretty enough to cover both costs + fee = 2000000000000000; + } // Ensure the user has enough funds for this message to be accepted if (msg.value < fee) { @@ -527,6 +592,14 @@ contract Gateway is IGateway, IInitializable, IUpgradable { } } + /// @dev Ensure that the specified address is an valid agent + function _ensureAgentAddress(address agent) internal view returns (bytes32 agentID) { + agentID = CoreStorage.layout().agentAddresses[agent]; + if (agentID == bytes32(0)) { + revert AgentDoesNotExist(); + } + } + /// @dev Invoke some code within an agent function _invokeOnAgent(address agent, bytes memory data) internal returns (bytes memory) { (bool success, bytes memory returndata) = (Agent(payable(agent)).invoke(AGENT_EXECUTOR, data)); @@ -586,6 +659,7 @@ contract Gateway is IGateway, IInitializable, IUpgradable { // Initialize agent for BridgeHub address bridgeHubAgent = address(new Agent(BRIDGE_HUB_AGENT_ID)); core.agents[BRIDGE_HUB_AGENT_ID] = bridgeHubAgent; + core.agentAddresses[bridgeHubAgent] = BRIDGE_HUB_AGENT_ID; // Initialize channel for primary governance track core.channels[PRIMARY_GOVERNANCE_CHANNEL_ID] = @@ -598,6 +672,7 @@ contract Gateway is IGateway, IInitializable, IUpgradable { // Initialize agent for for AssetHub address assetHubAgent = address(new Agent(config.assetHubAgentID)); core.agents[config.assetHubAgentID] = assetHubAgent; + core.agentAddresses[assetHubAgent] = config.assetHubAgentID; // Initialize channel for AssetHub core.channels[config.assetHubParaID.into()] = @@ -622,26 +697,4 @@ contract Gateway is IGateway, IInitializable, IUpgradable { OperatorStorage.Layout storage operatorStorage = OperatorStorage.layout(); operatorStorage.operator = config.rescueOperator; } - - /// @dev Temporary rescue ability for the initial bootstrapping phase of the bridge - function rescue(address impl, bytes32 implCodeHash, bytes calldata initializerParams) external { - OperatorStorage.Layout storage operatorStorage = OperatorStorage.layout(); - if (msg.sender != operatorStorage.operator) { - revert Unauthorized(); - } - Upgrade.upgrade(impl, implCodeHash, initializerParams); - } - - function dropRescueAbility() external { - OperatorStorage.Layout storage operatorStorage = OperatorStorage.layout(); - if (msg.sender != operatorStorage.operator) { - revert Unauthorized(); - } - operatorStorage.operator = address(0); - } - - function rescueOperator() external view returns (address) { - OperatorStorage.Layout storage operatorStorage = OperatorStorage.layout(); - return operatorStorage.operator; - } } diff --git a/contracts/src/Params.sol b/contracts/src/Params.sol index c3fc3d5d8f..882c2c7856 100644 --- a/contracts/src/Params.sol +++ b/contracts/src/Params.sol @@ -82,3 +82,37 @@ struct SetPricingParametersParams { /// @dev Fee multiplier UD60x18 multiplier; } + +// Payload for RegisterForeignToken +struct RegisterForeignTokenParams { + /// @dev The token ID (hash of stable location id of token) + bytes32 foreignTokenID; + /// @dev The name of the token + string name; + /// @dev The symbol of the token + string symbol; + /// @dev The decimal of the token + uint8 decimals; +} + +// Payload for MintForeignToken +struct MintForeignTokenParams { + /// @dev The token ID + bytes32 foreignTokenID; + /// @dev The address of the recipient + address recipient; + /// @dev The amount to mint with + uint256 amount; +} + +// Payload for TransferToken +struct TransferNativeTokenParams { + /// @dev The agent ID of the consensus system + bytes32 agentID; + /// @dev The token address + address token; + /// @dev The address of the recipient + address recipient; + /// @dev The amount to mint with + uint128 amount; +} diff --git a/contracts/src/SubstrateTypes.sol b/contracts/src/SubstrateTypes.sol index 6ae981e1c0..296f32ce57 100644 --- a/contracts/src/SubstrateTypes.sol +++ b/contracts/src/SubstrateTypes.sol @@ -133,4 +133,67 @@ library SubstrateTypes { ScaleCodec.encodeU128(xcmFee) ); } + + function SendForeignTokenToAssetHubAddress32(bytes32 tokenID, bytes32 recipient, uint128 xcmFee, uint128 amount) + internal + view + returns (bytes memory) + { + return bytes.concat( + bytes1(0x00), + ScaleCodec.encodeU64(uint64(block.chainid)), + bytes1(0x02), + tokenID, + bytes1(0x00), + recipient, + ScaleCodec.encodeU128(amount), + ScaleCodec.encodeU128(xcmFee) + ); + } + + // destination is AccountID32 address + function SendForeignTokenToAddress32( + bytes32 tokenID, + ParaID paraID, + bytes32 recipient, + uint128 xcmFee, + uint128 destinationXcmFee, + uint128 amount + ) internal view returns (bytes memory) { + return bytes.concat( + bytes1(0x00), + ScaleCodec.encodeU64(uint64(block.chainid)), + bytes1(0x02), + tokenID, + bytes1(0x01), + ScaleCodec.encodeU32(uint32(ParaID.unwrap(paraID))), + recipient, + ScaleCodec.encodeU128(destinationXcmFee), + ScaleCodec.encodeU128(amount), + ScaleCodec.encodeU128(xcmFee) + ); + } + + // destination is AccountID20 address + function SendForeignTokenToAddress20( + bytes32 tokenID, + ParaID paraID, + bytes20 recipient, + uint128 xcmFee, + uint128 destinationXcmFee, + uint128 amount + ) internal view returns (bytes memory) { + return bytes.concat( + bytes1(0x00), + ScaleCodec.encodeU64(uint64(block.chainid)), + bytes1(0x02), + tokenID, + bytes1(0x02), + ScaleCodec.encodeU32(uint32(ParaID.unwrap(paraID))), + recipient, + ScaleCodec.encodeU128(destinationXcmFee), + ScaleCodec.encodeU128(amount), + ScaleCodec.encodeU128(xcmFee) + ); + } } diff --git a/contracts/src/Token.sol b/contracts/src/Token.sol new file mode 100644 index 0000000000..f66a9e34be --- /dev/null +++ b/contracts/src/Token.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2023 Axelar Network +// SPDX-FileCopyrightText: 2023 Snowfork + +pragma solidity 0.8.25; + +import {IERC20} from "./interfaces/IERC20.sol"; +import {IERC20Permit} from "./interfaces/IERC20Permit.sol"; +import {TokenLib} from "./TokenLib.sol"; + +/** + * @dev Implementation of the {IERC20} interface. + * + * This implementation is agnostic to the way tokens are created. This means + * that a supply mechanism has to be added in a derived contract using {_mint}. + * This supply mechanism has been added in {ERC20Permit-mint}. + * + * We have followed general OpenZeppelin guidelines: functions revert instead + * of returning `false` on failure. This behavior is conventional and does + * not conflict with the expectations of ERC20 applications. + * + * Additionally, an {Approval} event is emitted on calls to {transferFrom}. + * This allows applications to reconstruct the allowance for all accounts just + * by listening to these events. Other implementations of the EIP may not emit + * these events, as it isn't required by the specification. + * + * Finally, the non-standard {decreaseAllowance} and {increaseAllowance} + * functions have been added to mitigate the well-known issues around setting + * allowances. See {IERC20-approve}. + */ +contract Token is IERC20, IERC20Permit { + using TokenLib for TokenLib.Token; + + address public immutable GATEWAY; + bytes32 public immutable DOMAIN_SEPARATOR; + uint8 public immutable decimals; + + string public name; + string public symbol; + TokenLib.Token token; + + error Unauthorized(); + + /** + * @dev Sets the values for {name}, {symbol}, and {decimals}. + */ + constructor(string memory _name, string memory _symbol, uint8 _decimals) { + name = _name; + symbol = _symbol; + decimals = _decimals; + GATEWAY = msg.sender; + DOMAIN_SEPARATOR = keccak256( + abi.encode( + TokenLib.DOMAIN_TYPE_SIGNATURE_HASH, + keccak256(bytes(_name)), + keccak256(bytes("1")), + block.chainid, + address(this) + ) + ); + } + + modifier onlyGateway() { + if (msg.sender != GATEWAY) { + revert Unauthorized(); + } + _; + } + + /** + * @dev Creates `amount` tokens and assigns them to `account`, increasing + * the total supply. Can only be called by the owner. + * + * Emits a {Transfer} event with `from` set to the zero address. + * + * Requirements: + * + * - `account` cannot be the zero address. + */ + function mint(address account, uint256 amount) external onlyGateway { + token.mint(account, amount); + } + + /** + * @dev Destroys `amount` tokens from the account. + */ + function burn(address account, uint256 amount) external onlyGateway { + token.burn(account, amount); + } + + /** + * @dev See {IERC20-transfer}. + * + * Requirements: + * + * - `recipient` cannot be the zero address. + * - the caller must have a balance of at least `amount`. + */ + function transfer(address recipient, uint256 amount) external returns (bool) { + return token.transfer(msg.sender, recipient, amount); + } + + /** + * @dev See {IERC20-approve}. + * + * NOTE: Prefer the {increaseAllowance} and {decreaseAllowance} methods, as + * they aren't vulnerable to the frontrunning attack described here: + * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + * See {IERC20-approve}. + * + * NOTE: If `amount` is the maximum `uint256`, the allowance is not updated on + * `transferFrom`. This is semantically equivalent to an infinite approval. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function approve(address spender, uint256 amount) external returns (bool) { + return token.approve(msg.sender, spender, amount); + } + + /** + * @dev See {IERC20-transferFrom}. + * + * Emits an {Approval} event indicating the updated allowance. This is not + * required by the EIP. See the note at the beginning of {ERC20}. + * + * Requirements: + * + * - `sender` and `recipient` cannot be the zero address. + * - `sender` must have a balance of at least `amount`. + * - the caller must have allowance for ``sender``'s tokens of at least + * `amount`. + */ + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool) { + return token.transferFrom(sender, recipient, amount); + } + + /** + * @dev Atomically increases the allowance granted to `spender` by the caller. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {IERC20-approve}. + * + * Emits an {Approval} event indicating the updated allowance. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function increaseAllowance(address spender, uint256 addedValue) external returns (bool) { + return token.increaseAllowance(spender, addedValue); + } + + /** + * @dev Atomically decreases the allowance granted to `spender` by the caller. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {IERC20-approve}. + * + * Emits an {Approval} event indicating the updated allowance. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - `spender` must have allowance for the caller of at least + * `subtractedValue`. + */ + function decreaseAllowance(address spender, uint256 subtractedValue) external returns (bool) { + return token.decreaseAllowance(spender, subtractedValue); + } + + function permit(address issuer, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external + { + token.permit(DOMAIN_SEPARATOR, issuer, spender, value, deadline, v, r, s); + } + + function balanceOf(address account) external view returns (uint256) { + return token.balance[account]; + } + + function nonces(address account) external view returns (uint256) { + return token.nonces[account]; + } + + function totalSupply() external view returns (uint256) { + return token.totalSupply; + } + + function allowance(address owner, address spender) external view returns (uint256) { + return token.allowance[owner][spender]; + } +} diff --git a/contracts/src/TokenLib.sol b/contracts/src/TokenLib.sol new file mode 100644 index 0000000000..de21ea679f --- /dev/null +++ b/contracts/src/TokenLib.sol @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2023 Axelar Network +// SPDX-FileCopyrightText: 2023 Snowfork + +pragma solidity 0.8.25; + +import {IERC20} from "./interfaces/IERC20.sol"; +import {IERC20Permit} from "./interfaces/IERC20Permit.sol"; + +library TokenLib { + // keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') + bytes32 internal constant DOMAIN_TYPE_SIGNATURE_HASH = + bytes32(0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f); + + // keccak256('Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)') + bytes32 internal constant PERMIT_SIGNATURE_HASH = + bytes32(0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9); + + string internal constant EIP191_PREFIX_FOR_EIP712_STRUCTURED_DATA = "\x19\x01"; + + struct Token { + mapping(address account => uint256) balance; + mapping(address account => mapping(address spender => uint256)) allowance; + mapping(address token => uint256) nonces; + uint256 totalSupply; + } + + /** + * @dev See {IERC20-transfer}. + * + * Requirements: + * + * - `recipient` cannot be the zero address. + * - the caller must have a balance of at least `amount`. + */ + function transfer(Token storage token, address sender, address recipient, uint256 amount) external returns (bool) { + _transfer(token, sender, recipient, amount); + return true; + } + + /** + * @dev Creates `amount` tokens and assigns them to `account`, increasing + * the total supply. + * + * Emits a {Transfer} event with `from` set to the zero address. + * + * Requirements: + * + * - `to` cannot be the zero address. + */ + function mint(Token storage token, address account, uint256 amount) external { + if (account == address(0)) { + revert IERC20.InvalidAccount(); + } + + _update(token, address(0), account, amount); + } + + /** + * @dev Destroys `amount` tokens from `account`, reducing the + * total supply. + * + * Emits a {Transfer} event with `to` set to the zero address. + * + * Requirements: + * + * - `account` cannot be the zero address. + * - `account` must have at least `amount` tokens. + */ + function burn(Token storage token, address account, uint256 amount) external { + if (account == address(0)) { + revert IERC20.InvalidAccount(); + } + + _update(token, account, address(0), amount); + } + + /** + * @dev See {IERC20-approve}. + * + * NOTE: Prefer the {increaseAllowance} and {decreaseAllowance} methods, as + * they aren't vulnerable to the frontrunning attack described here: + * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + * See {IERC20-approve}. + * + * NOTE: If `amount` is the maximum `uint256`, the allowance is not updated on + * `transferFrom`. This is semantically equivalent to an infinite approval. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function approve(Token storage token, address owner, address spender, uint256 amount) external returns (bool) { + _approve(token, owner, spender, amount); + return true; + } + + /** + * @dev See {IERC20-transferFrom}. + * + * Emits an {Approval} event indicating the updated allowance. This is not + * required by the EIP. See the note at the beginning of {ERC20}. + * + * Requirements: + * + * - `sender` and `recipient` cannot be the zero address. + * - `sender` must have a balance of at least `amount`. + * - the caller must have allowance for ``sender``'s tokens of at least + * `amount`. + */ + function transferFrom(Token storage token, address sender, address recipient, uint256 amount) + external + returns (bool) + { + uint256 _allowance = token.allowance[sender][msg.sender]; + + if (_allowance != type(uint256).max) { + if (_allowance < amount) { + revert IERC20.InsufficientAllowance(msg.sender, _allowance, amount); + } + unchecked { + _approve(token, sender, msg.sender, _allowance - amount); + } + } + + _transfer(token, sender, recipient, amount); + + return true; + } + + /** + * @dev Atomically increases the allowance granted to `spender` by the caller. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {IERC20-approve}. + * + * Emits an {Approval} event indicating the updated allowance. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function increaseAllowance(Token storage token, address spender, uint256 addedValue) external returns (bool) { + uint256 _allowance = token.allowance[msg.sender][spender]; + if (_allowance != type(uint256).max) { + _approve(token, msg.sender, spender, _allowance + addedValue); + } + return true; + } + + /** + * @dev Atomically decreases the allowance granted to `spender` by the caller. + * + * This is an alternative to {approve} that can be used as a mitigation for + * problems described in {IERC20-approve}. + * + * Emits an {Approval} event indicating the updated allowance. + * + * Requirements: + * + * - `spender` cannot be the zero address. + * - `spender` must have allowance for the caller of at least + * `subtractedValue`. + */ + function decreaseAllowance(Token storage token, address spender, uint256 subtractedValue) external returns (bool) { + uint256 _allowance = token.allowance[msg.sender][spender]; + if (_allowance != type(uint256).max) { + if (_allowance < subtractedValue) { + revert IERC20.InsufficientAllowance(msg.sender, _allowance, subtractedValue); + } + unchecked { + _approve(token, msg.sender, spender, _allowance - subtractedValue); + } + } + return true; + } + + function permit( + Token storage token, + bytes32 domainSeparator, + address issuer, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + if (block.timestamp > deadline) revert IERC20Permit.PermitExpired(); + + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + revert IERC20Permit.InvalidS(); + } + + if (v != 27 && v != 28) revert IERC20Permit.InvalidV(); + + bytes32 digest = keccak256( + abi.encodePacked( + EIP191_PREFIX_FOR_EIP712_STRUCTURED_DATA, + domainSeparator, + keccak256(abi.encode(PERMIT_SIGNATURE_HASH, issuer, spender, value, token.nonces[issuer]++, deadline)) + ) + ); + + address recoveredAddress = ecrecover(digest, v, r, s); + + if (recoveredAddress != issuer) revert IERC20Permit.InvalidSignature(); + + // _approve will revert if issuer is address(0x0) + _approve(token, issuer, spender, value); + } + + /** + * @dev Moves tokens `amount` from `sender` to `recipient`. + * + * This is internal function is equivalent to {transfer}, and can be used to + * e.g. implement automatic token fees, slashing mechanisms, etc. + * + * Emits a {Transfer} event. + * + * Requirements: + * + * - `sender` cannot be the zero address. + * - `recipient` cannot be the zero address. + * - `sender` must have a balance of at least `amount`. + */ + function _transfer(Token storage token, address sender, address recipient, uint256 amount) internal { + if (sender == address(0) || recipient == address(0)) { + revert IERC20.InvalidAccount(); + } + + _update(token, sender, recipient, amount); + } + + /** + * @dev Sets `amount` as the allowance of `spender` over the `owner` s tokens. + * + * This internal function is equivalent to `approve`, and can be used to + * e.g. set automatic allowances for certain subsystems, etc. + * + * Emits an {Approval} event. + * + * Requirements: + * + * - `owner` cannot be the zero address. + * - `spender` cannot be the zero address. + */ + function _approve(Token storage token, address owner, address spender, uint256 amount) internal { + if (owner == address(0) || spender == address(0)) { + revert IERC20.InvalidAccount(); + } + + token.allowance[owner][spender] = amount; + emit IERC20.Approval(owner, spender, amount); + } + + /** + * @dev Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from` + * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding + * this function. + * + * Emits a {Transfer} event. + */ + function _update(Token storage token, address from, address to, uint256 value) internal { + if (from == address(0)) { + // Overflow check required: The rest of the code assumes that totalSupply never overflows + token.totalSupply += value; + } else { + uint256 fromBalance = token.balance[from]; + if (fromBalance < value) { + revert IERC20.InsufficientBalance(from, fromBalance, value); + } + unchecked { + // Overflow not possible: value <= fromBalance <= totalSupply. + token.balance[from] = fromBalance - value; + } + } + + if (to == address(0)) { + unchecked { + // Overflow not possible: value <= totalSupply or value <= fromBalance <= totalSupply. + token.totalSupply -= value; + } + } else { + unchecked { + // Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256. + token.balance[to] += value; + } + } + + emit IERC20.Transfer(from, to, value); + } +} diff --git a/contracts/src/Types.sol b/contracts/src/Types.sol index 30005cffbf..02e8bd0acc 100644 --- a/contracts/src/Types.sol +++ b/contracts/src/Types.sol @@ -84,9 +84,13 @@ enum Command { SetOperatingMode, TransferNativeFromAgent, SetTokenTransferFees, - SetPricingParameters + SetPricingParameters, + TransferNativeToken, + RegisterForeignToken, + MintForeignToken } +/// @dev DEPRECATED enum AgentExecuteCommand { TransferToken } @@ -107,4 +111,5 @@ struct Ticket { struct TokenInfo { bool isRegistered; + bytes32 foreignID; } diff --git a/contracts/src/interfaces/IERC20.sol b/contracts/src/interfaces/IERC20.sol index c07302fd40..5e921d62db 100644 --- a/contracts/src/interfaces/IERC20.sol +++ b/contracts/src/interfaces/IERC20.sol @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2023 Axelar Network +// SPDX-FileCopyrightText: 2023 Snowfork pragma solidity 0.8.25; @@ -8,6 +9,8 @@ pragma solidity 0.8.25; */ interface IERC20 { error InvalidAccount(); + error InsufficientBalance(address sender, uint256 balance, uint256 needed); + error InsufficientAllowance(address spender, uint256 allowance, uint256 needed); /** * @dev Returns the amount of tokens in existence. diff --git a/contracts/src/interfaces/IERC20Permit.sol b/contracts/src/interfaces/IERC20Permit.sol new file mode 100644 index 0000000000..bcd8163669 --- /dev/null +++ b/contracts/src/interfaces/IERC20Permit.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2023 Axelar Network +// SPDX-FileCopyrightText: 2023 Snowfork + +pragma solidity 0.8.25; + +interface IERC20Permit { + error PermitExpired(); + error InvalidS(); + error InvalidV(); + error InvalidSignature(); + + function DOMAIN_SEPARATOR() external view returns (bytes32); + + function nonces(address account) external view returns (uint256); + + function permit(address issuer, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external; +} diff --git a/contracts/src/interfaces/IGateway.sol b/contracts/src/interfaces/IGateway.sol index 4a8ae777c7..37e725727a 100644 --- a/contracts/src/interfaces/IGateway.sol +++ b/contracts/src/interfaces/IGateway.sol @@ -35,14 +35,22 @@ interface IGateway { // Emitted when funds are withdrawn from an agent event AgentFundsWithdrawn(bytes32 indexed agentID, address indexed recipient, uint256 amount); + // Emitted when foreign token from polkadot registed + event ForeignTokenRegistered(bytes32 indexed tokenID, address token); + /** * Getters */ function operatingMode() external view returns (OperatingMode); + function channelOperatingModeOf(ChannelID channelID) external view returns (OperatingMode); + function channelNoncesOf(ChannelID channelID) external view returns (uint64, uint64); + function agentOf(bytes32 agentID) external view returns (address); + function pricingParameters() external view returns (UD60x18, uint128); + function implementation() external view returns (address); /** diff --git a/contracts/src/storage/AssetsStorage.sol b/contracts/src/storage/AssetsStorage.sol index 9a04a609d6..57b5bab21a 100644 --- a/contracts/src/storage/AssetsStorage.sol +++ b/contracts/src/storage/AssetsStorage.sol @@ -6,6 +6,7 @@ import {TokenInfo, ParaID} from "../Types.sol"; library AssetsStorage { struct Layout { + // Native token registry by token address mapping(address token => TokenInfo) tokenRegistry; address assetHubAgent; ParaID assetHubParaID; @@ -15,6 +16,8 @@ library AssetsStorage { uint128 assetHubReserveTransferFee; // Extra fee for registering a token, to discourage spamming (Ether) uint256 registerTokenFee; + // Foreign token registry by token ID + mapping(bytes32 foreignID => address) tokenAddressOf; } bytes32 internal constant SLOT = keccak256("org.snowbridge.storage.assets"); diff --git a/contracts/src/storage/CoreStorage.sol b/contracts/src/storage/CoreStorage.sol index 6a125743e2..1f8b24a705 100644 --- a/contracts/src/storage/CoreStorage.sol +++ b/contracts/src/storage/CoreStorage.sol @@ -12,6 +12,8 @@ library CoreStorage { mapping(ChannelID channelID => Channel) channels; // Agents mapping(bytes32 agentID => address) agents; + // Agent addresses + mapping(address agent => bytes32 agentID) agentAddresses; } bytes32 internal constant SLOT = keccak256("org.snowbridge.storage.core"); diff --git a/contracts/src/upgrades/rococo/RococoGatewayV2.sol b/contracts/src/upgrades/GatewayV2.sol similarity index 69% rename from contracts/src/upgrades/rococo/RococoGatewayV2.sol rename to contracts/src/upgrades/GatewayV2.sol index 746cc3cd1a..e6d87a7754 100644 --- a/contracts/src/upgrades/rococo/RococoGatewayV2.sol +++ b/contracts/src/upgrades/GatewayV2.sol @@ -2,14 +2,10 @@ // SPDX-FileCopyrightText: 2023 Snowfork pragma solidity 0.8.25; -import "../../Gateway.sol"; +import "../Gateway.sol"; -import {UD60x18, convert} from "prb/math/src/UD60x18.sol"; -import {PricingStorage} from "../../storage/PricingStorage.sol"; - -contract RococoGatewayV2 is Gateway { +contract GatewayV2 is Gateway { constructor( - address recoveryOperator, address beefyClient, address agentExecutor, ParaID bridgeHubParaID, @@ -32,9 +28,5 @@ contract RococoGatewayV2 is Gateway { if (ERC1967.load() == address(0)) { revert Unauthorized(); } - - PricingStorage.Layout storage pricing = PricingStorage.layout(); - - pricing.multiplier = abi.decode(data, (UD60x18)); } } diff --git a/contracts/test/Gateway.t.sol b/contracts/test/Gateway.t.sol index d54c6bd31b..22776dc47e 100644 --- a/contracts/test/Gateway.t.sol +++ b/contracts/test/Gateway.t.sol @@ -25,6 +25,9 @@ import {Channel, InboundMessage, OperatingMode, ParaID, Command, ChannelID, Mult import {NativeTransferFailed} from "../src/utils/SafeTransfer.sol"; import {PricingStorage} from "../src/storage/PricingStorage.sol"; +import {IERC20} from "../src/interfaces/IERC20.sol"; +import {TokenLib} from "../src/TokenLib.sol"; +import {Token} from "../src/Token.sol"; import { UpgradeParams, @@ -35,7 +38,10 @@ import { SetOperatingModeParams, TransferNativeFromAgentParams, SetTokenTransferFeesParams, - SetPricingParametersParams + SetPricingParametersParams, + RegisterForeignTokenParams, + TransferNativeTokenParams, + MintForeignTokenParams } from "../src/Params.sol"; import { @@ -52,12 +58,15 @@ import {WETH9} from "canonical-weth/WETH9.sol"; import {UD60x18, ud60x18, convert} from "prb/math/src/UD60x18.sol"; contract GatewayTest is Test { - ParaID public bridgeHubParaID = ParaID.wrap(1001); - bytes32 public bridgeHubAgentID = keccak256("1001"); + // Emitted when token minted/burnt/transfered + event Transfer(address indexed from, address indexed to, uint256 value); + + ParaID public bridgeHubParaID = ParaID.wrap(1013); + bytes32 public bridgeHubAgentID = 0x03170a2e7597b7b7e3d84c05391d139a62b157e78786d8c082f29dcf4c111314; address public bridgeHubAgent; - ParaID public assetHubParaID = ParaID.wrap(1002); - bytes32 public assetHubAgentID = keccak256("1002"); + ParaID public assetHubParaID = ParaID.wrap(1000); + bytes32 public assetHubAgentID = 0x81c5ab2571199e3188135178f3c2c8e2d268be1313d029b30f534fa579b69b79; address public assetHubAgent; address public relayer; @@ -95,6 +104,9 @@ contract GatewayTest is Test { UD60x18 public exchangeRate = ud60x18(0.0025e18); UD60x18 public multiplier = ud60x18(1e18); + // tokenID for DOT + bytes32 public dotTokenID; + function setUp() public { AgentExecutor executor = new AgentExecutor(); gatewayLogic = new MockGateway( @@ -140,6 +152,8 @@ contract GatewayTest is Test { recipientAddress32 = multiAddressFromBytes32(keccak256("recipient")); recipientAddress20 = multiAddressFromBytes20(bytes20(keccak256("recipient"))); + + dotTokenID = bytes32(uint256(1)); } function makeCreateAgentCommand() public pure returns (Command, bytes memory) { @@ -170,6 +184,7 @@ contract GatewayTest is Test { } fallback() external payable {} + receive() external payable {} /** @@ -345,23 +360,23 @@ contract GatewayTest is Test { function testAgentExecution() public { token.transfer(address(assetHubAgent), 200); - AgentExecuteParams memory params = AgentExecuteParams({ + TransferNativeTokenParams memory params = TransferNativeTokenParams({ agentID: assetHubAgentID, - payload: abi.encode(AgentExecuteCommand.TransferToken, abi.encode(address(token), address(account2), 10)) + token: address(token), + recipient: account2, + amount: 10 }); bytes memory encodedParams = abi.encode(params); - MockGateway(address(gateway)).agentExecutePublic(encodedParams); + MockGateway(address(gateway)).transferNativeTokenPublic(encodedParams); } function testAgentExecutionBadOrigin() public { - AgentExecuteParams memory params = AgentExecuteParams({ - agentID: bytes32(0), - payload: abi.encode(keccak256("transferNativeToken"), abi.encode(address(token), address(this), 1)) - }); + TransferNativeFromAgentParams memory params = + TransferNativeFromAgentParams({agentID: bytes32(0), recipient: address(this), amount: 1}); vm.expectRevert(Gateway.AgentDoesNotExist.selector); - MockGateway(address(gateway)).agentExecutePublic(abi.encode(params)); + MockGateway(address(gateway)).transferNativeFromAgentPublic(abi.encode(params)); } function testAgentExecutionBadPayload() public { @@ -714,9 +729,6 @@ contract GatewayTest is Test { // Handler functions should not be externally callable function testHandlersNotExternallyCallable() public { - vm.expectRevert(Gateway.Unauthorized.selector); - Gateway(address(gateway)).agentExecute(""); - vm.expectRevert(Gateway.Unauthorized.selector); Gateway(address(gateway)).createAgent(""); @@ -857,50 +869,107 @@ contract GatewayTest is Test { ); } - function testRescuebyTrustedOperator() public { - // Upgrade to this new logic contract - MockGatewayV2 newLogic = new MockGatewayV2(); + function testRegisterForeignToken() public { + RegisterForeignTokenParams memory params = + RegisterForeignTokenParams({foreignTokenID: dotTokenID, name: "DOT", symbol: "DOT", decimals: 10}); - address impl = address(newLogic); - bytes32 implCodeHash = address(newLogic).codehash; - bytes memory initParams = abi.encode(42); + vm.expectEmit(true, true, false, false); + emit IGateway.ForeignTokenRegistered(bytes32(uint256(1)), address(0)); - // Expect the gateway to emit `Upgraded` - vm.expectEmit(true, false, false, false); - emit IUpgradable.Upgraded(address(newLogic)); + MockGateway(address(gateway)).registerForeignTokenPublic(abi.encode(params)); + } - hoax(0x4B8a782D4F03ffcB7CE1e95C5cfe5BFCb2C8e967); - Gateway(address(gateway)).rescue(impl, implCodeHash, initParams); + function testRegisterForeignTokenDuplicateFail() public { + testRegisterForeignToken(); - // Verify that the MockGatewayV2.initialize was called - assertEq(MockGatewayV2(address(gateway)).getValue(), 42); + RegisterForeignTokenParams memory params = + RegisterForeignTokenParams({foreignTokenID: dotTokenID, name: "DOT", symbol: "DOT", decimals: 10}); + + vm.expectRevert(Assets.TokenAlreadyRegistered.selector); + + MockGateway(address(gateway)).registerForeignTokenPublic(abi.encode(params)); } - function testRescuebyPublicFails() public { - // Upgrade to this new logic contract - MockGatewayV2 newLogic = new MockGatewayV2(); + function testMintForeignToken() public { + testRegisterForeignToken(); - address impl = address(newLogic); - bytes32 implCodeHash = address(newLogic).codehash; - bytes memory initParams = abi.encode(42); + uint256 amount = 1000; - vm.expectRevert(Gateway.Unauthorized.selector); - Gateway(address(gateway)).rescue(impl, implCodeHash, initParams); + MintForeignTokenParams memory params = + MintForeignTokenParams({foreignTokenID: bytes32(uint256(1)), recipient: account1, amount: amount}); + + vm.expectEmit(true, true, false, false); + emit Transfer(address(0), account1, 1000); + + MockGateway(address(gateway)).mintForeignTokenPublic(abi.encode(params)); + + address dotToken = MockGateway(address(gateway)).tokenAddressOf(dotTokenID); + + uint256 balance = Token(dotToken).balanceOf(account1); + + assertEq(balance, amount); } - function testDropRescueAbility() public { - // Upgrade to this new logic contract - MockGatewayV2 newLogic = new MockGatewayV2(); + function testMintNotRegisteredTokenWillFail() public { + MintForeignTokenParams memory params = + MintForeignTokenParams({foreignTokenID: bytes32(uint256(1)), recipient: account1, amount: 1000}); + + vm.expectRevert(Assets.TokenNotRegistered.selector); - address impl = address(newLogic); - bytes32 implCodeHash = address(newLogic).codehash; - bytes memory initParams = abi.encode(42); + MockGateway(address(gateway)).mintForeignTokenPublic(abi.encode(params)); + } - hoax(0x4B8a782D4F03ffcB7CE1e95C5cfe5BFCb2C8e967); - Gateway(address(gateway)).dropRescueAbility(); + function testSendRelayTokenToAssetHub() public { + // Register and then mint some DOT to account1 + testMintForeignToken(); - vm.expectRevert(Gateway.Unauthorized.selector); - hoax(0x4B8a782D4F03ffcB7CE1e95C5cfe5BFCb2C8e967); - Gateway(address(gateway)).rescue(impl, implCodeHash, initParams); + address dotToken = MockGateway(address(gateway)).tokenAddressOf(dotTokenID); + + ParaID destPara = assetHubParaID; + + vm.prank(account1); + + vm.expectEmit(true, true, false, true); + emit IGateway.TokenSent(address(dotToken), account1, destPara, recipientAddress32, 1); + + // Expect the gateway to emit `OutboundMessageAccepted` + vm.expectEmit(true, false, false, false); + emit IGateway.OutboundMessageAccepted(assetHubParaID.into(), 1, messageID, bytes("")); + + IGateway(address(gateway)).sendToken{value: 0.1 ether}(address(dotToken), destPara, recipientAddress32, 1, 1); + } + + function testSendNotRegisteredTokenWillFail() public { + ParaID destPara = assetHubParaID; + + vm.expectRevert(Assets.TokenNotRegistered.selector); + + IGateway(address(gateway)).sendToken{value: 0.1 ether}(address(0x0), destPara, recipientAddress32, 1, 1); + } + + function testSendTokenFromNotMintedAccountWillFail() public { + testRegisterForeignToken(); + + address dotToken = MockGateway(address(gateway)).tokenAddressOf(dotTokenID); + + ParaID destPara = assetHubParaID; + + vm.prank(account1); + + vm.expectRevert(abi.encodeWithSelector(IERC20.InsufficientBalance.selector, account1, 0, 1)); + + IGateway(address(gateway)).sendToken{value: 0.1 ether}(address(dotToken), destPara, recipientAddress32, 1, 1); + } + + function testLegacyAgentExecutionForCompatibility() public { + token.transfer(address(assetHubAgent), 200); + + AgentExecuteParams memory params = AgentExecuteParams({ + agentID: assetHubAgentID, + payload: abi.encode(AgentExecuteCommand.TransferToken, abi.encode(address(token), address(account2), 10)) + }); + + bytes memory encodedParams = abi.encode(params); + MockGateway(address(gateway)).agentExecutePublic(encodedParams); } } diff --git a/contracts/test/Upgrade202408.t.sol b/contracts/test/Upgrade202408.t.sol new file mode 100644 index 0000000000..889e9b8f64 --- /dev/null +++ b/contracts/test/Upgrade202408.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.25; + +import {Test} from "forge-std/Test.sol"; +import {Strings} from "openzeppelin/utils/Strings.sol"; +import {console} from "forge-std/console.sol"; + +import {IGateway} from "../src/interfaces/IGateway.sol"; +import {IInitializable} from "../src/interfaces/IInitializable.sol"; +import {IUpgradable} from "../src/interfaces/IUpgradable.sol"; +import {IGateway} from "../src/interfaces/IGateway.sol"; +import {GatewayV2} from "../src/upgrades/GatewayV2.sol"; +import {Shell} from "../src/Shell.sol"; +import {Upgrade} from "../src/Upgrade.sol"; +import {AgentExecutor} from "../src/AgentExecutor.sol"; +import {UpgradeParams} from "../src/Params.sol"; +import {MockGatewayV2} from "./mocks/MockGatewayV2.sol"; +import {Verification} from "../src/Verification.sol"; +import {ParaID, InboundMessage, Command, ChannelID} from "../src/Types.sol"; + +function dot(uint32 value) pure returns (uint128) { + return value * (10 ** 10); +} + +contract Upgrade202408 is Test { + uint256 mainnetFork; + + // Address of GatewayProxy.sol + address public constant GATEWAY_ADDR = 0x27ca963C279c93801941e1eB8799c23f407d68e7; + + // Address of Verification.sol library + address public constant VERIFICATION_ADDR = 0x515c0817005b2F3383B7D8837d6DCc15c0d71C56; + + bytes32[] public proof = [bytes32(0x2f9ee6cfdf244060dc28aa46347c5219e303fc95062dd672b4e406ca5c29764b)]; + bytes public parachainHeaderProof = bytes("validProof"); + + function setUp() public { + mainnetFork = vm.createFork(vm.envString("MAINNET_RPC_URL")); + } + + function makeMockProof() public pure returns (Verification.Proof memory) { + return Verification.Proof({ + header: Verification.ParachainHeader({ + parentHash: bytes32(0), + number: 0, + stateRoot: bytes32(0), + extrinsicsRoot: bytes32(0), + digestItems: new Verification.DigestItem[](0) + }), + headProof: Verification.HeadProof({pos: 0, width: 0, proof: new bytes32[](0)}), + leafPartial: Verification.MMRLeafPartial({ + version: 0, + parentNumber: 0, + parentHash: bytes32(0), + nextAuthoritySetID: 0, + nextAuthoritySetLen: 0, + nextAuthoritySetRoot: 0 + }), + leafProof: new bytes32[](0), + leafProofOrder: 0 + }); + } + + function testUpgrade202408() public { + vm.selectFork(mainnetFork); + + AgentExecutor executor = new AgentExecutor(); + + GatewayV2 impl = new GatewayV2( + 0x6eD05bAa904df3DE117EcFa638d4CB84e1B8A00C, + address(executor), + ParaID.wrap(1002), + 0x03170a2e7597b7b7e3d84c05391d139a62b157e78786d8c082f29dcf4c111314, + 10, + dot(2) + ); + + /// Mock call to Verification.verifyCommitment to bypass BEEFY verification + vm.mockCall(VERIFICATION_ADDR, abi.encodeWithSelector(Verification.verifyCommitment.selector), abi.encode(true)); + + UpgradeParams memory params = + UpgradeParams({impl: address(impl), implCodeHash: address(impl).codehash, initParams: bytes("")}); + + IGateway(GATEWAY_ADDR).submitV1( + InboundMessage( + ChannelID.wrap(0x0000000000000000000000000000000000000000000000000000000000000001), + 3, + Command.Upgrade, + abi.encode(params), + 500_000, + 1 ether, + 1 ether, + keccak256("cabbage") + ), + proof, + makeMockProof() + ); + } +} diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index 8ea404168f..4356bb9876 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,16 @@ contract MockGateway is Gateway { function setPricingParametersPublic(bytes calldata params) external { this.setPricingParameters(params); } + + function registerForeignTokenPublic(bytes calldata params) external { + this.registerForeignToken(params); + } + + function mintForeignTokenPublic(bytes calldata params) external { + this.mintForeignToken(params); + } + + function transferNativeTokenPublic(bytes calldata params) external { + this.transferNativeToken(params); + } } diff --git a/contracts/test/mocks/MockGatewayV2.sol b/contracts/test/mocks/MockGatewayV2.sol index 20bede12bf..d994c909a5 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/relayer/contracts/gateway.go b/relayer/contracts/gateway.go index aa7842c263..ff6723fc6c 100644 --- a/relayer/contracts/gateway.go +++ b/relayer/contracts/gateway.go @@ -91,7 +91,7 @@ type VerificationProof struct { // GatewayMetaData contains all meta data concerning the Gateway contract. var GatewayMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"function\",\"name\":\"agentOf\",\"inputs\":[{\"name\":\"agentID\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"channelNoncesOf\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"internalType\":\"ChannelID\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"channelOperatingModeOf\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"internalType\":\"ChannelID\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumOperatingMode\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"implementation\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isTokenRegistered\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"operatingMode\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumOperatingMode\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"pricingParameters\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"UD60x18\"},{\"name\":\"\",\"type\":\"uint128\",\"internalType\":\"uint128\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"quoteRegisterTokenFee\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"quoteSendTokenFee\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"destinationChain\",\"type\":\"uint32\",\"internalType\":\"ParaID\"},{\"name\":\"destinationFee\",\"type\":\"uint128\",\"internalType\":\"uint128\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"registerToken\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"sendToken\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"destinationChain\",\"type\":\"uint32\",\"internalType\":\"ParaID\"},{\"name\":\"destinationAddress\",\"type\":\"tuple\",\"internalType\":\"structMultiAddress\",\"components\":[{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumKind\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"destinationFee\",\"type\":\"uint128\",\"internalType\":\"uint128\"},{\"name\":\"amount\",\"type\":\"uint128\",\"internalType\":\"uint128\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"submitV1\",\"inputs\":[{\"name\":\"message\",\"type\":\"tuple\",\"internalType\":\"structInboundMessage\",\"components\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"internalType\":\"ChannelID\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"command\",\"type\":\"uint8\",\"internalType\":\"enumCommand\"},{\"name\":\"params\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"maxDispatchGas\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"maxFeePerGas\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"reward\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"id\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"leafProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"headerProof\",\"type\":\"tuple\",\"internalType\":\"structVerification.Proof\",\"components\":[{\"name\":\"header\",\"type\":\"tuple\",\"internalType\":\"structVerification.ParachainHeader\",\"components\":[{\"name\":\"parentHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"number\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stateRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"extrinsicsRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"digestItems\",\"type\":\"tuple[]\",\"internalType\":\"structVerification.DigestItem[]\",\"components\":[{\"name\":\"kind\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"consensusEngineID\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}]},{\"name\":\"headProof\",\"type\":\"tuple\",\"internalType\":\"structVerification.HeadProof\",\"components\":[{\"name\":\"pos\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"width\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"leafPartial\",\"type\":\"tuple\",\"internalType\":\"structVerification.MMRLeafPartial\",\"components\":[{\"name\":\"version\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"parentNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"parentHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"nextAuthoritySetID\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"nextAuthoritySetLen\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"nextAuthoritySetRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"leafProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"leafProofOrder\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AgentCreated\",\"inputs\":[{\"name\":\"agentID\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"agent\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AgentFundsWithdrawn\",\"inputs\":[{\"name\":\"agentID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelCreated\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"ChannelID\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelUpdated\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"ChannelID\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"InboundMessageDispatched\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"ChannelID\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"messageID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"success\",\"type\":\"bool\",\"indexed\":false,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OperatingModeChanged\",\"inputs\":[{\"name\":\"mode\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumOperatingMode\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutboundMessageAccepted\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"ChannelID\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"messageID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"payload\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PricingParametersChanged\",\"inputs\":[],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TokenRegistrationSent\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TokenSent\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"sender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"destinationChain\",\"type\":\"uint32\",\"indexed\":true,\"internalType\":\"ParaID\"},{\"name\":\"destinationAddress\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structMultiAddress\",\"components\":[{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumKind\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"amount\",\"type\":\"uint128\",\"indexed\":false,\"internalType\":\"uint128\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TokenTransferFeesChanged\",\"inputs\":[],\"anonymous\":false}]", + ABI: "[{\"type\":\"function\",\"name\":\"agentOf\",\"inputs\":[{\"name\":\"agentID\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"channelNoncesOf\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"internalType\":\"ChannelID\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"channelOperatingModeOf\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"internalType\":\"ChannelID\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumOperatingMode\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"implementation\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"isTokenRegistered\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"operatingMode\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumOperatingMode\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"pricingParameters\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"UD60x18\"},{\"name\":\"\",\"type\":\"uint128\",\"internalType\":\"uint128\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"quoteRegisterTokenFee\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"quoteSendTokenFee\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"destinationChain\",\"type\":\"uint32\",\"internalType\":\"ParaID\"},{\"name\":\"destinationFee\",\"type\":\"uint128\",\"internalType\":\"uint128\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"registerToken\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"sendToken\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"destinationChain\",\"type\":\"uint32\",\"internalType\":\"ParaID\"},{\"name\":\"destinationAddress\",\"type\":\"tuple\",\"internalType\":\"structMultiAddress\",\"components\":[{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumKind\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"destinationFee\",\"type\":\"uint128\",\"internalType\":\"uint128\"},{\"name\":\"amount\",\"type\":\"uint128\",\"internalType\":\"uint128\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"submitV1\",\"inputs\":[{\"name\":\"message\",\"type\":\"tuple\",\"internalType\":\"structInboundMessage\",\"components\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"internalType\":\"ChannelID\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"command\",\"type\":\"uint8\",\"internalType\":\"enumCommand\"},{\"name\":\"params\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"maxDispatchGas\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"maxFeePerGas\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"reward\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"id\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"leafProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"headerProof\",\"type\":\"tuple\",\"internalType\":\"structVerification.Proof\",\"components\":[{\"name\":\"header\",\"type\":\"tuple\",\"internalType\":\"structVerification.ParachainHeader\",\"components\":[{\"name\":\"parentHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"number\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"stateRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"extrinsicsRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"digestItems\",\"type\":\"tuple[]\",\"internalType\":\"structVerification.DigestItem[]\",\"components\":[{\"name\":\"kind\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"consensusEngineID\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}]},{\"name\":\"headProof\",\"type\":\"tuple\",\"internalType\":\"structVerification.HeadProof\",\"components\":[{\"name\":\"pos\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"width\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"proof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}]},{\"name\":\"leafPartial\",\"type\":\"tuple\",\"internalType\":\"structVerification.MMRLeafPartial\",\"components\":[{\"name\":\"version\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"parentNumber\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"parentHash\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"nextAuthoritySetID\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"nextAuthoritySetLen\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"nextAuthoritySetRoot\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"leafProof\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"},{\"name\":\"leafProofOrder\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"AgentCreated\",\"inputs\":[{\"name\":\"agentID\",\"type\":\"bytes32\",\"indexed\":false,\"internalType\":\"bytes32\"},{\"name\":\"agent\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"AgentFundsWithdrawn\",\"inputs\":[{\"name\":\"agentID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelCreated\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"ChannelID\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelUpdated\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"ChannelID\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ForeignTokenRegistered\",\"inputs\":[{\"name\":\"tokenID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"token\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"InboundMessageDispatched\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"ChannelID\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"messageID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"success\",\"type\":\"bool\",\"indexed\":false,\"internalType\":\"bool\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OperatingModeChanged\",\"inputs\":[{\"name\":\"mode\",\"type\":\"uint8\",\"indexed\":false,\"internalType\":\"enumOperatingMode\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"OutboundMessageAccepted\",\"inputs\":[{\"name\":\"channelID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"ChannelID\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"},{\"name\":\"messageID\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"payload\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"PricingParametersChanged\",\"inputs\":[],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TokenRegistrationSent\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":false,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TokenSent\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"sender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"destinationChain\",\"type\":\"uint32\",\"indexed\":true,\"internalType\":\"ParaID\"},{\"name\":\"destinationAddress\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structMultiAddress\",\"components\":[{\"name\":\"kind\",\"type\":\"uint8\",\"internalType\":\"enumKind\"},{\"name\":\"data\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"amount\",\"type\":\"uint128\",\"indexed\":false,\"internalType\":\"uint128\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TokenTransferFeesChanged\",\"inputs\":[],\"anonymous\":false}]", } // GatewayABI is the input ABI used to generate the binding from. @@ -1161,6 +1161,151 @@ func (_Gateway *GatewayFilterer) ParseChannelUpdated(log types.Log) (*GatewayCha return event, nil } +// GatewayForeignTokenRegisteredIterator is returned from FilterForeignTokenRegistered and is used to iterate over the raw logs and unpacked data for ForeignTokenRegistered events raised by the Gateway contract. +type GatewayForeignTokenRegisteredIterator struct { + Event *GatewayForeignTokenRegistered // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *GatewayForeignTokenRegisteredIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(GatewayForeignTokenRegistered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(GatewayForeignTokenRegistered) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *GatewayForeignTokenRegisteredIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *GatewayForeignTokenRegisteredIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// GatewayForeignTokenRegistered represents a ForeignTokenRegistered event raised by the Gateway contract. +type GatewayForeignTokenRegistered struct { + TokenID [32]byte + Token common.Address + Raw types.Log // Blockchain specific contextual infos +} + +// FilterForeignTokenRegistered is a free log retrieval operation binding the contract event 0x57f58171b8777633d03aff1e7408b96a3d910c93a7ce433a8cb7fb837dc306a6. +// +// Solidity: event ForeignTokenRegistered(bytes32 indexed tokenID, address token) +func (_Gateway *GatewayFilterer) FilterForeignTokenRegistered(opts *bind.FilterOpts, tokenID [][32]byte) (*GatewayForeignTokenRegisteredIterator, error) { + + var tokenIDRule []interface{} + for _, tokenIDItem := range tokenID { + tokenIDRule = append(tokenIDRule, tokenIDItem) + } + + logs, sub, err := _Gateway.contract.FilterLogs(opts, "ForeignTokenRegistered", tokenIDRule) + if err != nil { + return nil, err + } + return &GatewayForeignTokenRegisteredIterator{contract: _Gateway.contract, event: "ForeignTokenRegistered", logs: logs, sub: sub}, nil +} + +// WatchForeignTokenRegistered is a free log subscription operation binding the contract event 0x57f58171b8777633d03aff1e7408b96a3d910c93a7ce433a8cb7fb837dc306a6. +// +// Solidity: event ForeignTokenRegistered(bytes32 indexed tokenID, address token) +func (_Gateway *GatewayFilterer) WatchForeignTokenRegistered(opts *bind.WatchOpts, sink chan<- *GatewayForeignTokenRegistered, tokenID [][32]byte) (event.Subscription, error) { + + var tokenIDRule []interface{} + for _, tokenIDItem := range tokenID { + tokenIDRule = append(tokenIDRule, tokenIDItem) + } + + logs, sub, err := _Gateway.contract.WatchLogs(opts, "ForeignTokenRegistered", tokenIDRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(GatewayForeignTokenRegistered) + if err := _Gateway.contract.UnpackLog(event, "ForeignTokenRegistered", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseForeignTokenRegistered is a log parse operation binding the contract event 0x57f58171b8777633d03aff1e7408b96a3d910c93a7ce433a8cb7fb837dc306a6. +// +// Solidity: event ForeignTokenRegistered(bytes32 indexed tokenID, address token) +func (_Gateway *GatewayFilterer) ParseForeignTokenRegistered(log types.Log) (*GatewayForeignTokenRegistered, error) { + event := new(GatewayForeignTokenRegistered) + if err := _Gateway.contract.UnpackLog(event, "ForeignTokenRegistered", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} + // GatewayInboundMessageDispatchedIterator is returned from FilterInboundMessageDispatched and is used to iterate over the raw logs and unpacked data for InboundMessageDispatched events raised by the Gateway contract. type GatewayInboundMessageDispatchedIterator struct { Event *GatewayInboundMessageDispatched // Event containing the contract specifics and raw log diff --git a/rfc/polkadot-native-assets.md b/rfc/polkadot-native-assets.md new file mode 100644 index 0000000000..3177733bd1 --- /dev/null +++ b/rfc/polkadot-native-assets.md @@ -0,0 +1,134 @@ +# RFC: Introduce Polkadot-native assets to Ethereum + + +## Summary + +This RFC proposes the feature to introduce Polkadot-native assets to Ethereum through our bridge, including two PRs separately with https://github.com/Snowfork/snowbridge/pull/1155 for solidity and https://github.com/Snowfork/polkadot-sdk/pull/128 for substrate. + + +## Explanation + +We use native token on Penpal for the integration and the basic work flow includes steps as following: + +### 1. Register Polkadot-native assets as ERC20 + +First by adding a [dispatchable](https://github.com/Snowfork/polkadot-sdk/blob/2d8f3b13cf61c3ce8e5ea15438c4cfbfe3a26722/bridges/snowbridge/pallets/system/src/lib.rs#L604) to `EthereumControl` pallet to register new Polkadot-native assets called via XCM, this dispatchable will send a message over the bridge to the agent of the Parachain. + +On Ethereum the agent will [instantiate a new ERC20 token](https://github.com/Snowfork/snowbridge/blob/07545cf7e8f0321e4ab89d7f5eb52bc85ab3d4c1/contracts/src/Assets.sol#L259) representing the Polkadot-native asset. + +There is a E2E test [register_penpal_native_token](https://github.com/Snowfork/polkadot-sdk/blob/5ad44df00259f53bc0dac8ea76f085540fdb23a4/cumulus/parachains/integration-tests/emulated/tests/bridges/bridge-hub-rococo/src/tests/snowbridge.rs#L571) for demonstration. + +### 2. Send Polkadot-native assets via [reserve_transfer_assets](https://github.com/Snowfork/polkadot-sdk/blob/2d8f3b13cf61c3ce8e5ea15438c4cfbfe3a26722/polkadot/xcm/pallet-xcm/src/lib.rs#L1027) + +First it requires the source parachain to extend the `XcmRouter` to route xcm with destination to Ethereum through our bridge on BH, [config](https://github.com/Snowfork/polkadot-sdk/blob/5ad44df00259f53bc0dac8ea76f085540fdb23a4/cumulus/parachains/runtimes/testing/penpal/src/xcm_config.rs#L401) on Penpal for the reference. + +Worth to note that the [fee config in BridgeTable](https://github.com/Snowfork/polkadot-sdk/blob/5ad44df00259f53bc0dac8ea76f085540fdb23a4/cumulus/parachains/runtimes/testing/penpal/src/xcm_config.rs#L465-L468) should cover the total execution cost on BridgeHub and Ethereum in DOT. + +There is a E2E test [send_penpal_native_token_to_ethereum](https://github.com/Snowfork/polkadot-sdk/blob/5ad44df00259f53bc0dac8ea76f085540fdb23a4/cumulus/parachains/integration-tests/emulated/tests/bridges/bridge-hub-rococo/src/tests/snowbridge.rs#L655) for demonstration. + +Check the xcm executed on penpal it shows that the native token has been reserved to the sovereign account of Ethereum. + +``` +2024-04-22T05:08:45.117066Z TRACE xcm::process_instruction: === TransferAsset { assets: Assets([Asset { id: AssetId(Location { parents: 0, interior: Here }), fun: Fungible(100000000000) }]), beneficiary: Location { parents: 2, interior: X1([GlobalConsensus(Ethereum { chain_id: 11155111 })]) } } +2024-04-22T05:08:45.117087Z TRACE xcm::fungible_adapter: internal_transfer_asset what: Asset { id: AssetId(Location { parents: 0, interior: Here }), fun: Fungible(100000000000) }, from: Location { parents: 0, interior: X1([AccountId32 { network: Some(Rococo), 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] }]) }, to: Location { parents: 2, interior: X1([GlobalConsensus(Ethereum { chain_id: 11155111 })]) } +2024-04-22T05:08:45.117476Z TRACE xcm::execute: result: Ok(()) +``` + + +The xcm forwarded to BridgeHub as following: +``` +instructions: [ + WithdrawAsset(Assets([Asset { id: AssetId(Location { parents: 1, interior: Here }), fun: Fungible(4200000000000) }])), + BuyExecution { fees: Asset { id: AssetId(Location { parents: 1, interior: Here }), fun: Fungible(4200000000000) }, 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([ + ReserveAssetDeposited(Assets([Asset { id: AssetId(Location { parents: 1, interior: X2([GlobalConsensus(Rococo), Parachain(2000)]) }), fun: Fungible(100000000000) }])), + ClearOrigin, + BuyExecution { fees: Asset { id: AssetId(Location { parents: 1, interior: X2([GlobalConsensus(Rococo), Parachain(2000)]) }), fun: Fungible(100000000000) }, weight_limit: Unlimited }, + DepositAsset { assets: Wild(AllCounted(1)), beneficiary: Location { parents: 0, interior: X1([AccountKey20 { network: None, key: [68, 165, 126, 226, 242, 252, 203, 133, 253, 162, 176, 177, 142, 189, 13, 141, 35, 51, 112, 14] }]) } }, + SetTopic([156, 140, 10, 35, 194, 14, 239, 123, 235, 56, 179, 99, 185, 189, 107, 206, 228, 222, 106, 10, 227, 75, 47, 41, 171, 186, 195, 157, 172, 237, 251, 50]) + ]) + } +] +``` + +So the top-level `WithdrawAsset` will withdraw relay token from sovereign account of Penpal as fee to pay for the execution cost on both BridgeHub and Ethereum. + +What we really care about is the internal xcm in `ExportMessage` with [the convert logic in outbound-router](https://github.com/Snowfork/polkadot-sdk/blob/5a4f3af6932cfcbae98435cb16f98a2ee8db4812/bridges/snowbridge/primitives/router/src/outbound/mod.rs#L318) it will be converted into a simple `Command` which will be relayed and finally executed on Ethereum. + +On Ethereum side based on the `Command` the Agent will [mint foreign token to the recipient](https://github.com/Snowfork/snowbridge/blob/07545cf7e8f0321e4ab89d7f5eb52bc85ab3d4c1/contracts/src/Assets.sol#L269) to finish the whole flow. + +#### 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|Penpal|User| For `reserve_transfer_assets` pays(DOT, Native) to node to execute custom extrinsic; pays (DOT) to Treasury for both delivery cost on BH and execution cost on Ethereum(i.e. `EthereumBaseFee`). +2|Bridge Hub|Parachain|Pays(DOT) to Treasury Account for delivery(local fee), pays(DOT) to Parachain sovereign for delivery(remote fee), essentially a refund. Remote fee converted to ETH here. +3|Gateway|Relayer|pays(ETH) to validate and execute message. +4|Gateway|Parachain Agent|pays(ETH) to relayer for delivery(reward+refund) and execution. + + +### 3. Send Polkadot-native assets back from Ethereum to Substrate via [sendToken](https://github.com/Snowfork/snowbridge/blob/07545cf7e8f0321e4ab89d7f5eb52bc85ab3d4c1/contracts/src/Gateway.sol#L463) + +So first on Ethereum the Agent will [burn foreign token](https://github.com/Snowfork/snowbridge/blob/07545cf7e8f0321e4ab89d7f5eb52bc85ab3d4c1/contracts/src/Assets.sol#L235) and the [payload](https://github.com/Snowfork/snowbridge/blob/07545cf7e8f0321e4ab89d7f5eb52bc85ab3d4c1/contracts/src/Assets.sol#L220) will be relayed and finally executed on BridgeHub. + +Then on BridgeHub with [the convert logic in inbound-router](https://github.com/Snowfork/polkadot-sdk/blob/5ad44df00259f53bc0dac8ea76f085540fdb23a4/bridges/snowbridge/primitives/router/src/inbound/mod.rs#L354) it will be converted into a [xcm](https://github.com/Snowfork/polkadot-sdk/blob/5ad44df00259f53bc0dac8ea76f085540fdb23a4/bridges/snowbridge/primitives/router/src/inbound/mod.rs#L392-L399) which will be sent to the destination chain. + +There is a E2E test [send_penpal_native_token_from_ethereum](https://github.com/Snowfork/polkadot-sdk/blob/5ad44df00259f53bc0dac8ea76f085540fdb23a4/cumulus/parachains/integration-tests/emulated/tests/bridges/bridge-hub-rococo/src/tests/snowbridge.rs#L759) with the xcm forwarded to Penpal as following: + + +``` + instructions: [ + DescendOrigin(X1([PalletInstance(80)])), + UniversalOrigin(GlobalConsensus(Ethereum { chain_id: 11155111 })), + WithdrawAsset(Assets([Asset { id: AssetId(Location { parents: 0, interior: Here }), fun: Fungible(8000000000) }, Asset { id: AssetId(Location { parents: 1, interior: X2([GlobalConsensus(Rococo), Parachain(2000)]) }), fun: Fungible(100000000000) }])), + BuyExecution { fees: Asset { id: AssetId(Location { parents: 0, interior: Here }), fun: Fungible(8000000000) }, weight_limit: Unlimited }, + ClearOrigin, + DepositAsset { assets: Wild(AllCounted(2)), beneficiary: Location { parents: 0, interior: X1([AccountId32 { network: None, id: [142, 175, 4, 21, 22, 135, 115, 99, 38, 201, 254, 161, 126, 37, 252, 82, 135, 97, 54, 147, 201, 18, 144, 156, 178, 38, 170, 71, 148, 242, 106, 72] }]) } } +] +``` + +Check the xcm executed on Penpal it shows that the native token has been withdraw from the sovereign account of Ethereum and then deposit to the beneficiary as expected. + +``` +2024-04-22T05:23:54.708694Z TRACE xcm::process: origin: Some(Location { parents: 1, interior: X1([Parachain(1013)]) }), total_surplus/refunded: Weight { ref_time: 0, proof_size: 0 }/Weight { ref_time: 0, proof_size: 0 }, error_handler_weight: Weight { ref_time: 0, proof_size: 0 } +2024-04-22T05:23:54.708707Z TRACE xcm::process_instruction: === DescendOrigin(X1([PalletInstance(80)])) +2024-04-22T05:23:54.708718Z TRACE xcm::process_instruction: === UniversalOrigin(GlobalConsensus(Ethereum { chain_id: 11155111 })) +2024-04-22T05:23:54.708804Z TRACE xcm::process_instruction: === WithdrawAsset(Assets([Asset { id: AssetId(Location { parents: 0, interior: Here }), fun: Fungible(8000000000) }, Asset { id: AssetId(Location { parents: 1, interior: X2([GlobalConsensus(Rococo), Parachain(2000)]) }), fun: Fungible(100000000000) }])) +2024-04-22T05:23:54.708815Z TRACE xcm::ensure_can_subsume_assets: worst_case_holding_len: 2, holding_limit: 64 +2024-04-22T05:23:54.708845Z TRACE xcm::fungible_adapter: withdraw_asset what: Asset { id: AssetId(Location { parents: 0, interior: Here }), fun: Fungible(8000000000) }, who: Location { parents: 2, interior: X1([GlobalConsensus(Ethereum { chain_id: 11155111 })]) } +2024-04-22T05:23:54.708956Z TRACE xcm::fungible_adapter: withdraw_asset what: Asset { id: AssetId(Location { parents: 1, interior: X2([GlobalConsensus(Rococo), Parachain(2000)]) }), fun: Fungible(100000000000) }, who: Location { parents: 2, interior: X1([GlobalConsensus(Ethereum { chain_id: 11155111 })]) } +2024-04-22T05:23:54.708966Z TRACE xcm::fungible_adapter: withdraw_asset what: Asset { id: AssetId(Location { parents: 1, interior: X2([GlobalConsensus(Rococo), Parachain(2000)]) }), fun: Fungible(100000000000) }, who: Location { parents: 2, interior: X1([GlobalConsensus(Ethereum { chain_id: 11155111 })]) } +2024-04-22T05:23:54.709091Z TRACE xcm::process_instruction: === BuyExecution { fees: Asset { id: AssetId(Location { parents: 0, interior: Here }), fun: Fungible(8000000000) }, weight_limit: Limited(Weight { ref_time: 7000000000, proof_size: 458752 }) } +2024-04-22T05:23:54.709110Z TRACE xcm::weight: UsingComponents::buy_weight weight: Weight { ref_time: 7000000000, proof_size: 458752 }, payment: AssetsInHolding { fungible: {AssetId(Location { parents: 0, interior: Here }): 8000000000}, non_fungible: {} }, context: XcmContext { origin: Some(Location { parents: 2, interior: X1([GlobalConsensus(Ethereum { chain_id: 11155111 })]) }), message_id: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], topic: None } +2024-04-22T05:23:54.709147Z TRACE xcm::weight: UsingComponents::buy_weight weight: Weight { ref_time: 7000000000, proof_size: 458752 }, payment: AssetsInHolding { fungible: {AssetId(Location { parents: 0, interior: Here }): 8000000000}, non_fungible: {} }, context: XcmContext { origin: Some(Location { parents: 2, interior: X1([GlobalConsensus(Ethereum { chain_id: 11155111 })]) }), message_id: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], topic: None } +2024-04-22T05:23:54.709166Z TRACE xcm::process_instruction: === ClearOrigin +2024-04-22T05:23:54.709174Z TRACE xcm::process_instruction: === DepositAsset { assets: Wild(AllCounted(2)), beneficiary: Location { parents: 0, interior: X1([AccountId32 { network: None, id: [142, 175, 4, 21, 22, 135, 115, 99, 38, 201, 254, 161, 126, 37, 252, 82, 135, 97, 54, 147, 201, 18, 144, 156, 178, 38, 170, 71, 148, 242, 106, 72] }]) } } +2024-04-22T05:23:54.709191Z TRACE xcm::fungible_adapter: deposit_asset what: Asset { id: AssetId(Location { parents: 0, interior: Here }), fun: Fungible(3412480000) }, who: Location { parents: 0, interior: X1([AccountId32 { network: None, id: [142, 175, 4, 21, 22, 135, 115, 99, 38, 201, 254, 161, 126, 37, 252, 82, 135, 97, 54, 147, 201, 18, 144, 156, 178, 38, 170, 71, 148, 242, 106, 72] }]) } +2024-04-22T05:23:54.709350Z TRACE xcm::fungible_adapter: deposit_asset what: Asset { id: AssetId(Location { parents: 1, interior: X2([GlobalConsensus(Rococo), Parachain(2000)]) }), fun: Fungible(100000000000) }, who: Location { parents: 0, interior: X1([AccountId32 { network: None, id: [142, 175, 4, 21, 22, 135, 115, 99, 38, 201, 254, 161, 126, 37, 252, 82, 135, 97, 54, 147, 201, 18, 144, 156, 178, 38, 170, 71, 148, 242, 106, 72] }]) } +2024-04-22T05:23:54.709362Z TRACE xcm::fungible_adapter: deposit_asset what: Asset { id: AssetId(Location { parents: 1, interior: X2([GlobalConsensus(Rococo), Parachain(2000)]) }), fun: Fungible(100000000000) }, who: Location { parents: 0, interior: X1([AccountId32 { network: None, id: [142, 175, 4, 21, 22, 135, 115, 99, 38, 201, 254, 161, 126, 37, 252, 82, 135, 97, 54, 147, 201, 18, 144, 156, 178, 38, 170, 71, 148, 242, 106, 72] }]) } +2024-04-22T05:23:54.709459Z TRACE xcm::process_instruction: === SetTopic([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) +2024-04-22T05:23:54.709470Z TRACE xcm::execute: result: Ok(()) +2024-04-22T05:23:54.709477Z TRACE xcm::refund_surplus: total_surplus: Weight { ref_time: 0, proof_size: 0 }, total_refunded: Weight { ref_time: 0, proof_size: 0 }, current_surplus: Weight { ref_time: 0, proof_size: 0 } +2024-04-22T05:23:54.709484Z TRACE xcm::refund_surplus: total_refunded: Weight { ref_time: 0, proof_size: 0 } +2024-04-22T05:23:54.709546Z TRACE xcm::process-message: XCM message execution complete, used weight: Weight(ref_time: 7000000000, proof_size: 458752) +``` + +#### Fee Flow + +- dApp is represents `msg.sender` or its sovereign depending on context. +- Parachain represents the target parachain, its sovereign or its agent depending on context. +- Ethereum Sovereign represents `Location{parent:2,interior:[GlobalConsensus(Ethereum)]}` + +Sequence|Where|Who|What +-|-|-|- +1|Gateway|dApp|pays(ETH, converted to DOT here) Parachain Agent for both delivery cost on BH and execution cost on destination(DOT,Native). +2|Bridge Hub|Relayer|pays(DOT) node for execution +3|Bridge Hub|Parachain Sovereign|pays(DOT) Relayer for delivery (refund+reward) +4|Parachain|Ethereum Sovereign|pays(DOT, Native) for execution only. diff --git a/smoketest/make-bindings.sh b/smoketest/make-bindings.sh index 4c65f38af0..525d151e6d 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|ERC20' \ --bindings-path src/contracts \ --root ../contracts diff --git a/smoketest/src/constants.rs b/smoketest/src/constants.rs index 7c76208866..5394d848e8 100644 --- a/smoketest/src/constants.rs +++ b/smoketest/src/constants.rs @@ -22,9 +22,14 @@ pub const ETHEREUM_KEY: &str = "0x5e002a1af63fd31f1c25258f3082dc889762664cb8f218 pub const ETHEREUM_ADDRESS: [u8; 20] = hex!("90A987B944Cb1dCcE5564e5FDeCD7a54D3de27Fe"); // The deployment addresses of the following contracts are stable in our E2E env, unless we modify -// the order in contracts are deployed in DeployLocal.sol. -pub const GATEWAY_PROXY_CONTRACT: [u8; 20] = hex!("EDa338E4dC46038493b885327842fD3E301CaB39"); -pub const WETH_CONTRACT: [u8; 20] = hex!("87d1f7fdfEe7f651FaBc8bFCB6E086C278b77A7d"); +// the order in contracts are deployed in DeployScript.sol. +pub const GATEWAY_PROXY_CONTRACT: [u8; 20] = hex!("87d1f7fdfEe7f651FaBc8bFCB6E086C278b77A7d"); +pub const WETH_CONTRACT: [u8; 20] = hex!("774667629726ec1FaBEbCEc0D9139bD1C8f72a23"); +pub const AGENT_EXECUTOR_CONTRACT: [u8; 20] = hex!("Fc97A6197dc90bef6bbEFD672742Ed75E9768553"); + +pub const ERC20_DOT_CONTRACT: [u8; 20] = hex!("B8C39CbCe8106c8415472e3AAe88Eb694Cc70B57"); +pub const ERC20_DOT_TOKEN_ID: [u8; 32] = + hex!("fb3d635c7cb573d1b9e9bff4a64ab4f25190d29b6fd8db94c605a218a23fa9ad"); // Agent for bridge hub parachain 1002 pub const BRIDGE_HUB_AGENT_ID: [u8; 32] = diff --git a/smoketest/src/helper.rs b/smoketest/src/helper.rs index eb2be1ce09..e0b668e11d 100644 --- a/smoketest/src/helper.rs +++ b/smoketest/src/helper.rs @@ -3,10 +3,7 @@ use crate::{ contracts::i_gateway, parachains::{ bridgehub::{self, api::runtime_types::snowbridge_core::outbound::v1::OperatingMode}, - penpal::{ - api::{runtime_types as penpalTypes, runtime_types::xcm::VersionedLocation}, - {self}, - }, + penpal::{self, api::runtime_types as penpalTypes}, relaychain, relaychain::api::runtime_types::{ pallet_xcm::pallet::Call as RelaychainPalletXcmCall, @@ -37,13 +34,12 @@ use ethers::{ }; use futures::StreamExt; use penpalTypes::{ - pallet_xcm::pallet::Call, - penpal_runtime::RuntimeCall, - staging_xcm::v3::multilocation::MultiLocation, - xcm::{ - v3::{junction::Junction, junctions::Junctions}, - VersionedXcm, + penpal_runtime::RuntimeCall as PenpalRuntimeCall, + staging_xcm::v4::{ + junction::Junction as PenpalJunction, junctions::Junctions as PenpalJunctions, + location::Location as PenpalLocation, }, + xcm::{VersionedLocation as PenpalVersionedLocation, VersionedXcm as PenpalVersionedXcm}, }; use std::{ops::Deref, sync::Arc, time::Duration}; use subxt::{ @@ -160,7 +156,7 @@ pub async fn wait_for_ethereum_event(ethereum_client: &Box>, - message: Box, + message: Box, ) -> Result> { - let dest = Box::new(VersionedLocation::V3(MultiLocation { + let dest = Box::new(PenpalVersionedLocation::V4(PenpalLocation { parents: 1, - interior: Junctions::X1(Junction::Parachain(BRIDGE_HUB_PARA_ID)), + interior: PenpalJunctions::X1([PenpalJunction::Parachain(BRIDGE_HUB_PARA_ID)]), })); let sudo_call = penpal::api::sudo::calls::TransactionApi::sudo( &penpal::api::sudo::calls::TransactionApi, - RuntimeCall::PolkadotXcm(Call::send { dest, message }), + PenpalRuntimeCall::PolkadotXcm(penpalTypes::pallet_xcm::pallet::Call::send { + dest, + message, + }), ); let owner = Pair::from_string("//Alice", None).expect("cannot create keypair"); diff --git a/smoketest/tests/register_polkadot_token.rs b/smoketest/tests/register_polkadot_token.rs new file mode 100644 index 0000000000..48271c83fd --- /dev/null +++ b/smoketest/tests/register_polkadot_token.rs @@ -0,0 +1,67 @@ +use snowbridge_smoketest::{ + contracts::i_gateway::ForeignTokenRegisteredFilter, + helper::*, + parachains::{ + bridgehub, + bridgehub::api::{ + ethereum_system::events::RegisterToken, + runtime_types, + runtime_types::{ + bounded_collections::bounded_vec::BoundedVec, staging_xcm::v4::junction::NetworkId, + xcm::VersionedLocation, + }, + }, + }, +}; +use subxt_signer::sr25519::dev; + +#[tokio::test] +async fn register_polkadot_token() { + let test_clients = initial_clients().await.expect("initialize clients"); + + type Junctions = runtime_types::staging_xcm::v4::junctions::Junctions; + type Junction = runtime_types::staging_xcm::v4::junction::Junction; + let asset = VersionedLocation::V4(runtime_types::staging_xcm::v4::location::Location { + parents: 1, + interior: Junctions::X1([Junction::GlobalConsensus(NetworkId::Westend)]), + }); + let metadata = runtime_types::snowbridge_core::AssetMetadata { + name: BoundedVec( + "wnd" + .as_bytes() + .to_vec() + .iter() + .chain([1_u8; 29].to_vec().iter()) + .map(|v| *v) + .collect::>(), + ), + symbol: BoundedVec( + "wnd" + .as_bytes() + .to_vec() + .iter() + .chain([1_u8; 29].to_vec().iter()) + .map(|v| *v) + .collect::>(), + ), + decimals: 12, + }; + let call = + bridgehub::api::ethereum_system::calls::TransactionApi.register_token(asset, metadata); + + let result = test_clients + .bridge_hub_client + .tx() + .sign_and_submit_then_watch_default(&call, &dev::bob()) + .await + .expect("send register call.") + .wait_for_finalized_success() + .await + .expect("call success"); + + println!("call issued at bridgehub block hash {:?}", result.extrinsic_hash()); + + wait_for_bridgehub_event::(&test_clients.bridge_hub_client).await; + + wait_for_ethereum_event::(&test_clients.ethereum_client).await; +} diff --git a/smoketest/tests/send_polkadot_token.rs b/smoketest/tests/send_polkadot_token.rs new file mode 100644 index 0000000000..a81e662e05 --- /dev/null +++ b/smoketest/tests/send_polkadot_token.rs @@ -0,0 +1,83 @@ +use ethers::{core::types::Address, prelude::U256, utils::parse_units}; +use futures::StreamExt; +use snowbridge_smoketest::{ + constants::*, + contracts::i_gateway, + helper::{initial_clients, print_event_log_for_unit_tests}, + parachains::assethub::api::balances::events::Minted, +}; +use subxt::utils::AccountId32; + +#[tokio::test] +async fn send_polkadot_token() { + let test_clients = initial_clients().await.expect("initialize clients"); + let ethereum_client = *(test_clients.ethereum_signed_client.clone()); + let assethub = *(test_clients.asset_hub_client.clone()); + + let gateway_addr: Address = GATEWAY_PROXY_CONTRACT.into(); + let gateway = i_gateway::IGateway::new(gateway_addr, ethereum_client.clone()); + + let token: Address = ERC20_DOT_CONTRACT.into(); + + let destination_fee = 400_000_000; + let fee: U256 = parse_units("0.01", "ether").unwrap().into(); + + let amount = 500_000_000; + + let receipt = gateway + .send_token( + token, + ASSET_HUB_PARA_ID, + i_gateway::MultiAddress { kind: 1, data: (*BOB_PUBLIC).into() }, + destination_fee, + amount, + ) + .value(fee) + .send() + .await + .unwrap() + .await + .unwrap() + .unwrap(); + + println!( + "receipt transaction hash: {:#?}, transaction block: {:#?}", + hex::encode(receipt.transaction_hash), + receipt.block_number + ); + + // Log for OutboundMessageAccepted + let outbound_message_accepted_log = receipt.logs.last().unwrap(); + + // print log for unit tests + print_event_log_for_unit_tests(outbound_message_accepted_log); + + assert_eq!(receipt.status.unwrap().as_u64(), 1u64); + + let wait_for_blocks = 500; + let mut blocks = assethub + .blocks() + .subscribe_finalized() + .await + .expect("block subscription") + .take(wait_for_blocks); + + let expected_owner: AccountId32 = (*BOB_PUBLIC).into(); + + let mut event_found = false; + while let Some(Ok(block)) = blocks.next().await { + println!("Polling assethub block {} for mint event.", block.number()); + + let events = block.events().await.unwrap(); + for event_wrapped in events.find::() { + println!("event found in block {}.", block.number()); + let event = event_wrapped.unwrap(); + assert_eq!(event.who, expected_owner); + event_found = true; + } + if event_found { + break + } + } + assert!(event_found) +} diff --git a/smoketest/tests/transfer_polkadot_token.rs b/smoketest/tests/transfer_polkadot_token.rs new file mode 100644 index 0000000000..f9c3b5a763 --- /dev/null +++ b/smoketest/tests/transfer_polkadot_token.rs @@ -0,0 +1,103 @@ +use assethub::api::polkadot_xcm::calls::TransactionApi; +use ethers::{ + addressbook::Address, + prelude::Middleware, + providers::{Provider, Ws}, +}; +use futures::StreamExt; +use snowbridge_smoketest::{ + constants::*, + contracts::{erc20, erc20::TransferFilter}, + helper::AssetHubConfig, + parachains::assethub::{ + api::runtime_types::{ + staging_xcm::v3::multilocation::MultiLocation, + xcm::{ + v3::{ + junction::{Junction, NetworkId}, + junctions::Junctions, + multiasset::{AssetId, Fungibility, MultiAsset, MultiAssets}, + }, + VersionedAssets, VersionedLocation, + }, + }, + {self}, + }, +}; +use std::{sync::Arc, time::Duration}; +use subxt::OnlineClient; +use subxt_signer::sr25519::dev; + +#[tokio::test] +async fn transfer_polkadot_token() { + let ethereum_provider = Provider::::connect(ETHEREUM_API) + .await + .unwrap() + .interval(Duration::from_millis(10u64)); + + let ethereum_client = Arc::new(ethereum_provider); + + let assethub: OnlineClient = + OnlineClient::from_url(ASSET_HUB_WS_URL).await.unwrap(); + + let amount: u128 = 1_000_000_000; + let assets = VersionedAssets::V3(MultiAssets(vec![MultiAsset { + id: AssetId::Concrete(MultiLocation { parents: 1, interior: Junctions::Here }), + fun: Fungibility::Fungible(amount), + }])); + + let destination = VersionedLocation::V3(MultiLocation { + parents: 2, + interior: Junctions::X1(Junction::GlobalConsensus(NetworkId::Ethereum { + chain_id: ETHEREUM_CHAIN_ID, + })), + }); + + let beneficiary = VersionedLocation::V3(MultiLocation { + parents: 0, + interior: Junctions::X1(Junction::AccountKey20 { + network: None, + key: ETHEREUM_ADDRESS.into(), + }), + }); + + let signer = dev::bob(); + + let token_transfer_call = + TransactionApi.reserve_transfer_assets(destination, beneficiary, assets, 0); + + let _ = assethub + .tx() + .sign_and_submit_then_watch_default(&token_transfer_call, &signer) + .await + .expect("call success"); + + let erc20_dot_address: Address = ERC20_DOT_CONTRACT.into(); + let erc20_dot = erc20::ERC20::new(erc20_dot_address, ethereum_client.clone()); + + let wait_for_blocks = 500; + let mut stream = ethereum_client.subscribe_blocks().await.unwrap().take(wait_for_blocks); + + let mut transfer_event_found = false; + while let Some(block) = stream.next().await { + println!("Polling ethereum block {:?} for transfer event", block.number.unwrap()); + if let Ok(transfers) = erc20_dot + .event::() + .at_block_hash(block.hash.unwrap()) + .query() + .await + { + for transfer in transfers { + println!("Transfer event found at ethereum block {:?}", block.number.unwrap()); + println!("from {:?}", transfer.from); + println!("to {:?}", transfer.to); + assert_eq!(transfer.value, amount.into()); + transfer_event_found = true; + } + } + if transfer_event_found { + break + } + } + assert!(transfer_event_found); +} diff --git a/web/packages/test/scripts/set-env.sh b/web/packages/test/scripts/set-env.sh index e016fc6653..a63621c5e4 100755 --- a/web/packages/test/scripts/set-env.sh +++ b/web/packages/test/scripts/set-env.sh @@ -112,7 +112,7 @@ export REMOTE_REWARD="${REMOTE_REWARD:-1000000000000000}" export BRIDGE_HUB_INITIAL_DEPOSIT="${ETH_BRIDGE_HUB_INITIAL_DEPOSIT:-10000000000000000000}" export GATEWAY_STORAGE_KEY="${GATEWAY_STORAGE_KEY:-0xaed97c7854d601808b98ae43079dafb3}" -export GATEWAY_PROXY_CONTRACT="${GATEWAY_PROXY_CONTRACT:-0xEDa338E4dC46038493b885327842fD3E301CaB39}" +export GATEWAY_PROXY_CONTRACT="${GATEWAY_PROXY_CONTRACT:-0x87d1f7fdfEe7f651FaBc8bFCB6E086C278b77A7d}" address_for() { jq -r ".contracts.${1}.address" "$output_dir/contracts.json"