diff --git a/lukso-lsp17contractextension-contracts-0.16.0-rc.0.tgz b/lukso-lsp17contractextension-contracts-0.16.0-rc.0.tgz new file mode 100644 index 0000000..b4e9a85 Binary files /dev/null and b/lukso-lsp17contractextension-contracts-0.16.0-rc.0.tgz differ diff --git a/lukso-lsp8-contracts-0.16.0-rc-0.tgz b/lukso-lsp8-contracts-0.16.0-rc-0.tgz new file mode 100644 index 0000000..8c83074 Binary files /dev/null and b/lukso-lsp8-contracts-0.16.0-rc-0.tgz differ diff --git a/package.json b/package.json index 0aa5c04..9178e9e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "@erc725/smart-contracts-v8": "erc725-smart-contracts-v8-rc0.tgz", "@hyperlane-xyz/core": "^5.3.0", "@lukso/lsp4-contracts": "lukso-lsp4-contracts-0.16.0-rc.0.tgz", - "@lukso/lsp7-contracts": "lukso-lsp7-contracts-0.16.0-rc.0.tgz" + "@lukso/lsp7-contracts": "lukso-lsp7-contracts-0.16.0-rc.0.tgz", + "@lukso/lsp8-contracts": "lukso-lsp8-contracts-0.16.0-rc-0.tgz", + "@lukso/lsp17contractextension-contracts": "lukso-lsp17contractextension-contracts-0.16.0-rc.0.tgz" }, "devDependencies": { "forge-std": "github:foundry-rs/forge-std#v1.8.1", diff --git a/src/HypLSP8.sol b/src/HypLSP8.sol new file mode 100644 index 0000000..a225943 --- /dev/null +++ b/src/HypLSP8.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.19; + +import { TokenRouter } from "@hyperlane-xyz/core/contracts/token/libs/TokenRouter.sol"; + +import { LSP8IdentifiableDigitalAssetInitAbstract } from + "@lukso/lsp8-contracts/contracts/LSP8IdentifiableDigitalAssetInitAbstract.sol"; + +import { _LSP4_TOKEN_TYPE_TOKEN } from "@lukso/lsp4-contracts/contracts/LSP4Constants.sol"; + +import { _LSP8_TOKENID_FORMAT_NUMBER } from "@lukso/lsp8-contracts/contracts/LSP8Constants.sol"; + +/** + * @title LSP8 version of the Hyperlane ERC721 Token Router + * @dev https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/solidity/contracts/token/HypERC721.sol + */ +contract HypLSP8 is LSP8IdentifiableDigitalAssetInitAbstract, TokenRouter { + constructor(address _mailbox) TokenRouter(_mailbox) { } + + /** + * @notice Initializes the Hyperlane router, LSP8 metadata, and mints initial supply to deployer. + * @param _mintAmount The amount of NFTs to mint to `msg.sender`. + * @param _name The name of the token. + * @param _symbol The symbol of the token. + */ + function initialize( + uint256 _mintAmount, + address _hook, + address _interchainSecurityModule, + address _owner, + string memory _name, + string memory _symbol + ) + external + initializer + { + _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); + address owner = msg.sender; + _transferOwnership(owner); + + LSP8IdentifiableDigitalAssetInitAbstract._initialize( + _name, _symbol, owner, _LSP4_TOKEN_TYPE_TOKEN, _LSP8_TOKENID_FORMAT_NUMBER + ); + + for (uint256 i = 0; i < _mintAmount; i++) { + _mint(owner, bytes32(i), true, ""); + } + } + + function balanceOf(address _account) + public + view + virtual + override(TokenRouter, LSP8IdentifiableDigitalAssetInitAbstract) + returns (uint256) + { + return LSP8IdentifiableDigitalAssetInitAbstract.balanceOf(_account); + } + + /** + * @dev Asserts `msg.sender` is owner and burns `_tokenId`. + * @inheritdoc TokenRouter + */ + function _transferFromSender(uint256 _tokenId) internal virtual override returns (bytes memory) { + bytes32 tokenIdAsBytes32 = bytes32(_tokenId); + require(tokenOwnerOf(tokenIdAsBytes32) == msg.sender, "!owner"); + _burn(tokenIdAsBytes32, ""); + return bytes(""); // no metadata + } + + /** + * @dev Mints `_tokenId` to `_recipient`. + * @inheritdoc TokenRouter + */ + function _transferTo( + address _recipient, + uint256 _tokenId, + bytes calldata // no metadata + ) + internal + virtual + override + { + _mint(_recipient, bytes32(_tokenId), true, ""); + } +} diff --git a/src/HypLSP8Collateral.sol b/src/HypLSP8Collateral.sol new file mode 100644 index 0000000..6ec028b --- /dev/null +++ b/src/HypLSP8Collateral.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.19; + +import { TokenRouter } from "@hyperlane-xyz/core/contracts/token/libs/TokenRouter.sol"; + +import { TokenMessage } from "@hyperlane-xyz/core/contracts/token/libs/TokenMessage.sol"; + +import { ILSP8IdentifiableDigitalAsset as ILSP8 } from + "@lukso/lsp8-contracts/contracts/ILSP8IdentifiableDigitalAsset.sol"; + +/** + * @title Hyperlane LSP8 Token Collateral that wraps an existing LSP8 with remote transfer functionality. + * @author Abacus Works + */ +contract HypLSP8Collateral is TokenRouter { + ILSP8 public immutable wrappedToken; + + /** + * @notice Constructor + * @param lsp8 Address of the token to keep as collateral + */ + constructor(address lsp8, address _mailbox) TokenRouter(_mailbox) { + wrappedToken = ILSP8(lsp8); + } + + /** + * @notice Initializes the Hyperlane router + * @param _hook The post-dispatch hook contract. + * @param _interchainSecurityModule The interchain security module contract. + * @param _owner The this contract. + */ + function initialize(address _hook, address _interchainSecurityModule, address _owner) public virtual initializer { + _MailboxClient_initialize(_hook, _interchainSecurityModule, _owner); + } + + function ownerOf(uint256 _tokenId) external view returns (address) { + return ILSP8(wrappedToken).tokenOwnerOf(bytes32(_tokenId)); + } + + /** + * @dev Returns the balance of `_account` for `wrappedToken`. + * @inheritdoc TokenRouter + */ + function balanceOf(address _account) external view override returns (uint256) { + return ILSP8(wrappedToken).balanceOf(_account); + } + + /** + * @dev Transfers `_tokenId` of `wrappedToken` from `msg.sender` to this contract. + * @inheritdoc TokenRouter + */ + function _transferFromSender(uint256 _tokenId) internal virtual override returns (bytes memory) { + wrappedToken.transfer(msg.sender, address(this), bytes32(_tokenId), true, ""); + return bytes(""); // no metadata + } + + /** + * @dev Transfers `_tokenId` of `wrappedToken` from this contract to `_recipient`. + * @inheritdoc TokenRouter + */ + function _transferTo( + address _recipient, + uint256 _tokenId, + bytes calldata // no metadata + ) + internal + override + { + wrappedToken.transfer(address(this), _recipient, bytes32(_tokenId), true, ""); + } +} diff --git a/test/HypLSP8.t.sol b/test/HypLSP8.t.sol new file mode 100644 index 0000000..e406201 --- /dev/null +++ b/test/HypLSP8.t.sol @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.13; + +import { Test } from "forge-std/src/Test.sol"; + +import { TypeCasts } from "@hyperlane-xyz/core/contracts/libs/TypeCasts.sol"; +import { TestMailbox } from "@hyperlane-xyz/core/contracts/test/TestMailbox.sol"; +import { TestPostDispatchHook } from "@hyperlane-xyz/core/contracts/test/TestPostDispatchHook.sol"; +import { TestInterchainGasPaymaster } from "@hyperlane-xyz/core/contracts/test/TestInterchainGasPaymaster.sol"; +import { GasRouter } from "@hyperlane-xyz/core/contracts/client/GasRouter.sol"; +import { TokenRouter } from "@hyperlane-xyz/core/contracts/token/libs/TokenRouter.sol"; + +// Mock + contracts to test +import { HypLSP8 } from "../src/HypLSP8.sol"; +import { HypLSP8Collateral } from "../src/HypLSP8Collateral.sol"; +import { LSP8Mock } from "./LSP8Mock.sol"; + +abstract contract HypTokenTest is Test { + using TypeCasts for address; + + uint256 internal constant INITIAL_SUPPLY = 10; + string internal constant NAME = "Hyperlane NFTs"; + string internal constant SYMBOL = "HNFT"; + + address internal ALICE = makeAddr("alice"); + address internal BOB = makeAddr("bob"); + address internal OWNER = makeAddr("owner"); + uint32 internal constant ORIGIN = 11; + uint32 internal constant DESTINATION = 22; + bytes32 internal constant TOKEN_ID = bytes32(uint256(1)); + string internal constant URI = "http://example.com/token/"; + + LSP8Mock internal localPrimaryToken; + LSP8Mock internal remotePrimaryToken; + TestMailbox internal localMailbox; + TestMailbox internal remoteMailbox; + TokenRouter internal localToken; + HypLSP8 internal remoteToken; + TestPostDispatchHook internal noopHook; + + function setUp() public virtual { + localMailbox = new TestMailbox(ORIGIN); + remoteMailbox = new TestMailbox(DESTINATION); + + localPrimaryToken = new LSP8Mock(NAME, SYMBOL, OWNER); + + noopHook = new TestPostDispatchHook(); + localMailbox.setDefaultHook(address(noopHook)); + localMailbox.setRequiredHook(address(noopHook)); + + vm.deal(ALICE, 1 ether); + } + + function _deployRemoteToken() internal { + remoteToken = new HypLSP8(address(remoteMailbox)); + vm.prank(OWNER); + remoteToken.initialize(0, address(noopHook), address(0), OWNER, NAME, SYMBOL); + vm.prank(OWNER); + remoteToken.enrollRemoteRouter(ORIGIN, address(localToken).addressToBytes32()); + } + + function _processTransfers(address _recipient, bytes32 _tokenId) internal { + vm.prank(address(remoteMailbox)); + remoteToken.handle( + ORIGIN, address(localToken).addressToBytes32(), abi.encodePacked(_recipient.addressToBytes32(), _tokenId) + ); + } + + function _performRemoteTransfer(uint256 _msgValue, bytes32 _tokenId) internal { + vm.prank(ALICE); + localToken.transferRemote{ value: _msgValue }(DESTINATION, BOB.addressToBytes32(), uint256(_tokenId)); + + _processTransfers(BOB, _tokenId); + assertEq(remoteToken.balanceOf(BOB), 1); + } +} + +contract HypLSP8Test is HypTokenTest { + using TypeCasts for address; + + HypLSP8 internal lsp8Token; + + function setUp() public override { + super.setUp(); + + localToken = new HypLSP8(address(localMailbox)); + lsp8Token = HypLSP8(payable(address(localToken))); + + vm.prank(OWNER); + lsp8Token.initialize(INITIAL_SUPPLY, address(noopHook), address(0), OWNER, NAME, SYMBOL); + + vm.prank(OWNER); + lsp8Token.enrollRemoteRouter(DESTINATION, address(remoteToken).addressToBytes32()); + + // Give accounts some ETH for gas + vm.deal(OWNER, 1 ether); + vm.deal(ALICE, 1 ether); + vm.deal(BOB, 1 ether); + + // Transfer some tokens to ALICE for testing + vm.prank(OWNER); + lsp8Token.transfer(OWNER, ALICE, TOKEN_ID, true, ""); + + _deployRemoteToken(); + } + + function testInitialize_revert_ifAlreadyInitialized() public { + vm.expectRevert("Initializable: contract is already initialized"); + lsp8Token.initialize(INITIAL_SUPPLY, address(noopHook), address(0), OWNER, NAME, SYMBOL); + } + + function testTotalSupply() public { + assertEq(lsp8Token.totalSupply(), INITIAL_SUPPLY); + } + + function testTokenOwnerOf() public { + assertEq(lsp8Token.tokenOwnerOf(TOKEN_ID), ALICE); + } + + function testLocalTransfer() public { + vm.prank(ALICE); + lsp8Token.transfer(ALICE, BOB, TOKEN_ID, true, ""); + assertEq(lsp8Token.tokenOwnerOf(TOKEN_ID), BOB); + assertEq(lsp8Token.balanceOf(ALICE), 0); + assertEq(lsp8Token.balanceOf(BOB), 1); + } + + function testRemoteTransferHere() public { + vm.prank(OWNER); + remoteToken.enrollRemoteRouter(DESTINATION, address(remoteToken).addressToBytes32()); + + _performRemoteTransfer(25_000, TOKEN_ID); + assertEq(lsp8Token.balanceOf(ALICE), 0); + } + + function testRemoteTransfer_revert_unauthorizedOperator() public { + vm.prank(OWNER); + vm.expectRevert("!owner"); + localToken.transferRemote{ value: 25_000 }(DESTINATION, BOB.addressToBytes32(), uint256(TOKEN_ID)); + } + + function testRemoteTransfer_revert_invalidTokenId() public { + bytes32 invalidTokenId = bytes32(uint256(999)); + vm.expectRevert(abi.encodeWithSignature("LSP8NonExistentTokenId(bytes32)", invalidTokenId)); + _performRemoteTransfer(25_000, invalidTokenId); + } +} + +contract HypLSP8CollateralTest is HypTokenTest { + using TypeCasts for address; + + HypLSP8Collateral internal lsp8Collateral; + + function setUp() public override { + super.setUp(); + + localToken = new HypLSP8Collateral(address(localPrimaryToken), address(localMailbox)); + lsp8Collateral = HypLSP8Collateral(address(localToken)); + + vm.prank(OWNER); + lsp8Collateral.initialize(address(noopHook), address(0), OWNER); + + // Give accounts some ETH for gas + vm.deal(OWNER, 1 ether); + vm.deal(ALICE, 1 ether); + vm.deal(BOB, 1 ether); + + // Mint test tokens + vm.startPrank(OWNER); + localPrimaryToken.mint(OWNER, TOKEN_ID, true, ""); + localPrimaryToken.transfer(OWNER, ALICE, TOKEN_ID, true, ""); + vm.stopPrank(); + + _deployRemoteToken(); + + // Enroll routers for both chains + vm.prank(OWNER); + lsp8Collateral.enrollRemoteRouter(DESTINATION, address(remoteToken).addressToBytes32()); + } + + function testRemoteTransfer() public { + vm.prank(ALICE); + localPrimaryToken.authorizeOperator(address(lsp8Collateral), TOKEN_ID, ""); + _performRemoteTransfer(25_000, TOKEN_ID); + + assertEq(localPrimaryToken.tokenOwnerOf(TOKEN_ID), address(lsp8Collateral)); + } + + function testRemoteTransfer_revert_unauthorized() public { + vm.expectRevert( + abi.encodeWithSignature("LSP8NotTokenOperator(bytes32,address)", TOKEN_ID, address(lsp8Collateral)) + ); + vm.prank(BOB); + localToken.transferRemote{ value: 25_000 }(DESTINATION, BOB.addressToBytes32(), uint256(TOKEN_ID)); + } + + function testRemoteTransfer_revert_invalidTokenId() public { + bytes32 invalidTokenId = bytes32(uint256(999)); + vm.expectRevert(abi.encodeWithSignature("LSP8NonExistentTokenId(bytes32)", invalidTokenId)); + _performRemoteTransfer(25_000, invalidTokenId); + } +} diff --git a/test/LSP8Mock.sol b/test/LSP8Mock.sol new file mode 100644 index 0000000..8ca0998 --- /dev/null +++ b/test/LSP8Mock.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.13; + +import { LSP8IdentifiableDigitalAsset } from "@lukso/lsp8-contracts/contracts/LSP8IdentifiableDigitalAsset.sol"; + +contract LSP8Mock is LSP8IdentifiableDigitalAsset { + constructor( + string memory name_, + string memory symbol_, + address owner_ + ) + LSP8IdentifiableDigitalAsset(name_, symbol_, owner_, 0, 0) + { } + + function mint(address to, bytes32 tokenId, bool force, bytes memory data) public { + _mint(to, tokenId, force, data); + } + + function mintBatch( + address[] memory to, + bytes32[] memory tokenIds, + bool[] memory force, + bytes[] memory data + ) + public + { + require( + to.length == tokenIds.length && tokenIds.length == force.length && force.length == data.length, + "LSP8Mock: array length mismatch" + ); + + for (uint256 i = 0; i < to.length; i++) { + _mint(to[i], tokenIds[i], force[i], data[i]); + } + } + + function mintIdRange(address to, uint256 startId, uint256 amount, bool force, bytes memory data) public { + for (uint256 i = 0; i < amount; i++) { + bytes32 tokenId = bytes32(startId + i); + _mint(to, tokenId, force, data); + } + } + + function burn(bytes32 tokenId, bytes memory data) public { + _burn(tokenId, data); + } + + function burnBatch(bytes32[] memory tokenIds, bytes[] memory data) public { + require(tokenIds.length == data.length, "LSP8Mock: array length mismatch"); + + for (uint256 i = 0; i < tokenIds.length; i++) { + _burn(tokenIds[i], data[i]); + } + } + + function setData(bytes32 tokenId, bytes32 dataKey, bytes memory dataValue) public { + _setData(dataKey, dataValue); + } + + // Override for testing purposes - allows easy token ID verification + function tokenURI(bytes32 tokenId) public pure returns (string memory) { + return "TEST-BASE-URI"; + } + + // Helper function for testing + function exists(bytes32 tokenId) public view returns (bool) { + return _exists(tokenId); + } +}