diff --git a/contracts/src/Agent.sol b/contracts/src/Agent.sol index 6873064134..3239661d93 100644 --- a/contracts/src/Agent.sol +++ b/contracts/src/Agent.sol @@ -2,10 +2,12 @@ // SPDX-FileCopyrightText: 2023 Snowfork pragma solidity 0.8.23; +import {IERC721Receiver} from "openzeppelin/token/ERC721/IERC721Receiver.sol"; + /// @title An agent contract that acts on behalf of a consensus system on Polkadot /// @dev Instances of this contract act as an agents for arbitrary consensus systems on Polkadot. These consensus systems /// can include toplevel parachains as as well as nested consensus systems within a parachain. -contract Agent { +contract Agent is IERC721Receiver { error Unauthorized(); /// @dev The unique ID for this agent, derived from the MultiLocation of the corresponding consensus system on Polkadot @@ -32,4 +34,8 @@ contract Agent { } return executor.delegatecall(data); } + + function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) { + return this.onERC721Received.selector; + } } diff --git a/contracts/src/AgentExecutor.sol b/contracts/src/AgentExecutor.sol index c9cdaa885e..aeda3f2d52 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 {IERC721} from "openzeppelin/token/ERC721/IERC721.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. @@ -22,6 +23,9 @@ contract AgentExecutor { if (command == AgentExecuteCommand.TransferToken) { (address token, address recipient, uint128 amount) = abi.decode(params, (address, address, uint128)); _transferToken(token, recipient, amount); + } else if (command == AgentExecuteCommand.TransferNftToken) { + (address token, address recipient, uint128 tokenId) = abi.decode(params, (address, address, uint128)); + _transferNftToken(token, recipient, tokenId); } } @@ -36,4 +40,9 @@ contract AgentExecutor { function _transferToken(address token, address recipient, uint128 amount) internal { IERC20(token).safeTransfer(recipient, amount); } + + /// @dev Transfer Nft to `recipient`. Only callable via `execute`. + function _transferNftToken(address token, address recipient, uint128 tokenId) internal { + IERC721(token).safeTransferFrom(address(this), recipient, uint256(tokenId)); + } } diff --git a/contracts/src/Assets.sol b/contracts/src/Assets.sol index 53a79eaedc..22f0af68df 100644 --- a/contracts/src/Assets.sol +++ b/contracts/src/Assets.sol @@ -12,6 +12,8 @@ import {SubstrateTypes} from "./SubstrateTypes.sol"; import {ParaID, MultiAddress, Ticket, Costs} from "./Types.sol"; import {Address} from "./utils/Address.sol"; +import {IERC721} from "openzeppelin/token/ERC721/IERC721.sol"; + /// @title Library for implementing Ethereum->Polkadot ERC20 transfers. library Assets { using Address for address; @@ -42,11 +44,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) { @@ -188,4 +191,62 @@ library Assets { emit IGateway.TokenRegistrationSent(token); } + + /// @dev Registers a nft token + /// @param token The Nft token address. + function registerNftToken(address token) external returns (Ticket memory ticket) { + if (!token.isContract()) { + revert InvalidToken(); + } + + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + + TokenInfo storage info = $.tokenRegistry[token]; + info.isRegistered = true; + info.isNft = true; + + ticket.dest = $.assetHubParaID; + ticket.costs = _registerTokenCosts(); + ticket.payload = SubstrateTypes.RegisterNftToken(token, $.assetHubCreateAssetFee); + + emit IGateway.TokenRegistrationSent(token); + } + + function sendNftToken(address token, uint128 tokenId, address sender, MultiAddress calldata destinationAddress) + external + returns (Ticket memory ticket) + { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + + TokenInfo storage info = $.tokenRegistry[token]; + if (!info.isRegistered) { + revert TokenNotRegistered(); + } + + // Lock the funds into AssetHub's agent contract + _transferNftToAgent($.assetHubAgent, token, sender, tokenId); + + ticket.dest = $.assetHubParaID; + ticket.costs = _sendNftTokenCosts(); + + ticket.payload = SubstrateTypes.SendNftTokenToAssetHubAddress32( + token, destinationAddress.asAddress32(), tokenId, $.assetHubReserveTransferFee + ); + emit IGateway.TokenSent(token, sender, $.assetHubParaID, destinationAddress, tokenId); + } + + /// @dev transfer Nft token from the sender to the specified agent + function _transferNftToAgent(address agent, address token, address sender, uint256 tokenId) internal { + if (!token.isContract()) { + revert InvalidToken(); + } + + IERC721(token).safeTransferFrom(sender, agent, tokenId); + } + + function _sendNftTokenCosts() internal view returns (Costs memory costs) { + AssetsStorage.Layout storage $ = AssetsStorage.layout(); + costs.foreign = $.assetHubReserveTransferFee; + costs.native = 0; + } } diff --git a/contracts/src/Gateway.sol b/contracts/src/Gateway.sol index 130fee2352..a62ea10dcc 100644 --- a/contracts/src/Gateway.sol +++ b/contracts/src/Gateway.sol @@ -44,7 +44,7 @@ import { import {CoreStorage} from "./storage/CoreStorage.sol"; import {PricingStorage} from "./storage/PricingStorage.sol"; -import {AssetsStorage} from "./storage/AssetsStorage.sol"; +import {AssetsStorage, TokenInfo} from "./storage/AssetsStorage.sol"; import {UD60x18, ud60x18, convert} from "prb/math/src/UD60x18.sol"; @@ -416,7 +416,9 @@ contract Gateway is IGateway, IInitializable, IUpgradable { uint128 amount ) external payable { _submitOutbound( - Assets.sendToken(token, msg.sender, destinationChain, destinationAddress, destinationFee, MAX_DESTINATION_FEE, amount) + Assets.sendToken( + token, msg.sender, destinationChain, destinationAddress, destinationFee, MAX_DESTINATION_FEE, amount + ) ); } @@ -613,4 +615,18 @@ contract Gateway is IGateway, IInitializable, IUpgradable { assets.assetHubCreateAssetFee = config.assetHubCreateAssetFee; assets.assetHubReserveTransferFee = config.assetHubReserveTransferFee; } + + // Register an Ethereum-native nft token on AssetHub + function registerNftToken(address token) external payable { + _submitOutbound(Assets.registerNftToken(token)); + } + + function tokenInfo(address token) external view returns (TokenInfo memory) { + return AssetsStorage.layout().tokenRegistry[token]; + } + + // Send an Ethereum-native token to AssetHub + function sendNftToken(address token, uint128 tokenId, MultiAddress calldata destinationAddress) external payable { + _submitOutbound(Assets.sendNftToken(token, tokenId, msg.sender, destinationAddress)); + } } diff --git a/contracts/src/SubstrateTypes.sol b/contracts/src/SubstrateTypes.sol index af817ac1a1..5eac5ea82e 100644 --- a/contracts/src/SubstrateTypes.sol +++ b/contracts/src/SubstrateTypes.sol @@ -133,4 +133,40 @@ library SubstrateTypes { ScaleCodec.encodeU128(xcmFee) ); } + + /** + * @dev SCALE-encodes `router_primitives::inbound::VersionedMessage` containing payload + * `RegisterNftToken::Create` + */ + // solhint-disable-next-line func-name-mixedcase + function RegisterNftToken(address token, uint128 fee) internal view returns (bytes memory) { + return bytes.concat( + bytes1(0x00), + ScaleCodec.encodeU64(uint64(block.chainid)), + bytes1(0x02), + SubstrateTypes.H160(token), + ScaleCodec.encodeU128(fee) + ); + } + + /** + * @dev SCALE-encodes `router_primitives::inbound::VersionedMessage` containing payload + * `NftTokensMessage::Mint` + */ + // destination is AccountID32 address on AssetHub + function SendNftTokenToAssetHubAddress32(address token, bytes32 recipient, uint128 tokenId, uint128 fee) + internal + view + returns (bytes memory) + { + return bytes.concat( + bytes1(0x00), + ScaleCodec.encodeU64(uint64(block.chainid)), + bytes1(0x03), + SubstrateTypes.H160(token), + ScaleCodec.encodeU128(tokenId), + recipient, + ScaleCodec.encodeU128(fee) + ); + } } diff --git a/contracts/src/Types.sol b/contracts/src/Types.sol index 93d41bc0f5..bd20e5a7d3 100644 --- a/contracts/src/Types.sol +++ b/contracts/src/Types.sol @@ -88,7 +88,8 @@ enum Command { } enum AgentExecuteCommand { - TransferToken + TransferToken, + TransferNftToken } /// @dev Application-level costs for a message @@ -107,5 +108,6 @@ struct Ticket { struct TokenInfo { bool isRegistered; - bytes31 __padding; + bool isNft; + bytes30 __padding; } diff --git a/contracts/src/interfaces/IGateway.sol b/contracts/src/interfaces/IGateway.sol index 5bcb2fd0dd..87b8ff2092 100644 --- a/contracts/src/interfaces/IGateway.sol +++ b/contracts/src/interfaces/IGateway.sol @@ -102,4 +102,10 @@ interface IGateway { uint128 destinationFee, uint128 amount ) external payable; + + /// @dev Register an Nft token and create a wrapped derivative on AssetHub in the `ForeignUniques` pallet. + function registerNftToken(address token) external payable; + + /// @dev Send Nft tokens to parachain `destinationChain` and deposit into account `destinationAddress` + function sendNftToken(address token, uint128 tokenId, MultiAddress calldata destinationAddress) external payable; } diff --git a/contracts/test/Gateway.t.sol b/contracts/test/Gateway.t.sol index 74fb60a9c1..3a24fb9768 100644 --- a/contracts/test/Gateway.t.sol +++ b/contracts/test/Gateway.t.sol @@ -23,7 +23,6 @@ import {SubstrateTypes} from "./../src/SubstrateTypes.sol"; import {MultiAddress} from "../src/MultiAddress.sol"; import {Channel, InboundMessage, OperatingMode, ParaID, Command, ChannelID, MultiAddress} from "../src/Types.sol"; - import {NativeTransferFailed} from "../src/utils/SafeTransfer.sol"; import {PricingStorage} from "../src/storage/PricingStorage.sol"; @@ -51,8 +50,11 @@ import { import {WETH9} from "canonical-weth/WETH9.sol"; import {UD60x18, ud60x18, convert} from "prb/math/src/UD60x18.sol"; +import {MockNft} from "./mocks/MockNft.sol"; +import {TokenInfo} from "../src/Types.sol"; +import {IERC721Receiver} from "openzeppelin/token/ERC721/IERC721Receiver.sol"; -contract GatewayTest is Test { +contract GatewayTest is Test, IERC721Receiver { ParaID public bridgeHubParaID = ParaID.wrap(1001); bytes32 public bridgeHubAgentID = keccak256("1001"); address public bridgeHubAgent; @@ -70,6 +72,7 @@ contract GatewayTest is Test { GatewayProxy public gateway; WETH9 public token; + MockNft public nftToken; address public account1; address public account2; @@ -99,12 +102,7 @@ contract GatewayTest is Test { function setUp() public { AgentExecutor executor = new AgentExecutor(); gatewayLogic = new MockGateway( - address(0), - address(executor), - bridgeHubParaID, - bridgeHubAgentID, - foreignTokenDecimals, - maxDestinationFee + address(0), address(executor), bridgeHubParaID, bridgeHubAgentID, foreignTokenDecimals, maxDestinationFee ); Gateway.Config memory config = Gateway.Config({ mode: OperatingMode.Normal, @@ -145,6 +143,8 @@ contract GatewayTest is Test { recipientAddress32 = multiAddressFromBytes32(keccak256("recipient")); recipientAddress20 = multiAddressFromBytes20(bytes20(keccak256("recipient"))); + + nftToken = new MockNft(); } function makeCreateAgentCommand() public pure returns (Command, bytes memory) { @@ -177,6 +177,10 @@ contract GatewayTest is Test { fallback() external payable {} receive() external payable {} + function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) { + return this.onERC721Received.selector; + } + /** * Message Verification */ @@ -857,6 +861,60 @@ contract GatewayTest is Test { IGateway(address(gateway)).quoteSendTokenFee(address(token), destPara, maxDestinationFee + 1); vm.expectRevert(Assets.InvalidDestinationFee.selector); - IGateway(address(gateway)).sendToken{value: fee}(address(token), destPara, recipientAddress32, maxDestinationFee + 1, 1); + IGateway(address(gateway)).sendToken{value: fee}( + address(token), destPara, recipientAddress32, maxDestinationFee + 1, 1 + ); + } + + function testRegisterNftToken() public { + vm.expectEmit(false, false, false, true); + emit IGateway.TokenRegistrationSent(address(nftToken)); + + vm.expectEmit(true, false, false, false); + emit IGateway.OutboundMessageAccepted(assetHubParaID.into(), 1, messageID, bytes("")); + + IGateway(address(gateway)).registerNftToken{value: 2 ether}(address(nftToken)); + + TokenInfo memory info = MockGateway(address(gateway)).tokenInfo(address(nftToken)); + assertEq(info.isNft, true); + } + + function testSendNftTokenToAssetHub() public { + // Mint token(id:0) and approve gateway to use + uint128 tokenId = 0; + nftToken.mint(address(this)); + nftToken.approve(address(gateway), uint256(tokenId)); + + // register token first + uint256 fee = IGateway(address(gateway)).quoteRegisterTokenFee(); + IGateway(address(gateway)).registerNftToken{value: fee}(address(nftToken)); + + // Expect the gateway to emit `TokenSent` & `OutboundMessageAccepted` + ParaID destPara = assetHubParaID; + fee = IGateway(address(gateway)).quoteSendTokenFee(address(nftToken), destPara, 1); + vm.expectEmit(true, true, false, true); + emit IGateway.TokenSent(address(nftToken), address(this), destPara, recipientAddress32, tokenId); + vm.expectEmit(true, false, false, false); + emit IGateway.OutboundMessageAccepted(assetHubParaID.into(), 1, messageID, bytes("")); + + IGateway(address(gateway)).sendNftToken{value: fee}(address(nftToken), tokenId, recipientAddress32); + } + + function testAgentTransferNft() public { + testSendNftTokenToAssetHub(); + uint128 tokenId = 0; + + AgentExecuteParams memory params = AgentExecuteParams({ + agentID: assetHubAgentID, + payload: abi.encode( + AgentExecuteCommand.TransferNftToken, abi.encode(address(nftToken), address(account1), tokenId) + ) + }); + + bytes memory encodedParams = abi.encode(params); + MockGateway(address(gateway)).agentExecutePublic(encodedParams); + // assert token transfer to account1 + address owner = nftToken.ownerOf(tokenId); + assertEq(owner, account1); } } diff --git a/contracts/test/mocks/MockGateway.sol b/contracts/test/mocks/MockGateway.sol index 2dbe3e53ae..f6fe62f84a 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 { diff --git a/contracts/test/mocks/MockGatewayV2.sol b/contracts/test/mocks/MockGatewayV2.sol index 31329282e9..f71a2518c9 100644 --- a/contracts/test/mocks/MockGatewayV2.sol +++ b/contracts/test/mocks/MockGatewayV2.sol @@ -19,7 +19,7 @@ library AdditionalStorage { } // Used to test upgrades. -contract MockGatewayV2 is IInitializable { +contract MockGatewayV2 is IInitializable { // Reinitialize gateway with some additional storage fields function initialize(bytes memory params) external { AdditionalStorage.Layout storage $ = AdditionalStorage.layout(); diff --git a/contracts/test/mocks/MockNft.sol b/contracts/test/mocks/MockNft.sol new file mode 100644 index 0000000000..790452f618 --- /dev/null +++ b/contracts/test/mocks/MockNft.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.23; + +import "openzeppelin/token/ERC721/ERC721.sol"; +import "openzeppelin/access/Ownable.sol"; + +contract MockNft is ERC721, Ownable { + uint256 public totalMints = 0; + + constructor() ERC721("MyToken", "MTK") Ownable() {} + + function mint(address to) public { + uint256 tokenId = totalMints; + totalMints++; + _safeMint(to, tokenId); + } +}