diff --git a/.changeset/gentle-shrimps-hug.md b/.changeset/gentle-shrimps-hug.md new file mode 100644 index 0000000000000..3ca21e7d82261 --- /dev/null +++ b/.changeset/gentle-shrimps-hug.md @@ -0,0 +1,5 @@ +--- +'@eth-optimism/contracts-periphery': patch +--- + +ERC721 bridge from Eth Mainnet to Optimism diff --git a/integration-tests/contracts/FakeL2StandardERC721.sol b/integration-tests/contracts/FakeL2StandardERC721.sol new file mode 100644 index 0000000000000..30d1a2e898fa5 --- /dev/null +++ b/integration-tests/contracts/FakeL2StandardERC721.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +contract FakeL2StandardERC721 is ERC721 { + + address public immutable l1Token; + address public immutable l2Bridge; + + constructor(address _l1Token, address _l2Bridge) ERC721("FakeERC721", "FAKE") { + l1Token = _l1Token; + l2Bridge = _l2Bridge; + } + + function mint(address to, uint256 tokenId) public { + _mint(to, tokenId); + } + + // Burn will be called by the L2 Bridge to burn the NFT we are bridging to L1 + function burn(address, uint256) external {} +} diff --git a/integration-tests/package.json b/integration-tests/package.json index 3a1f7696c7ddf..b9a141c3ab16c 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -29,7 +29,8 @@ }, "devDependencies": { "@babel/eslint-parser": "^7.5.4", - "@eth-optimism/contracts": "0.5.27", + "@eth-optimism/contracts": "^0.5.26", + "@eth-optimism/contracts-periphery": "^0.1.1", "@eth-optimism/core-utils": "0.8.6", "@eth-optimism/sdk": "1.1.8", "@ethersproject/abstract-provider": "^5.6.1", diff --git a/integration-tests/test/nft-bridge.spec.ts b/integration-tests/test/nft-bridge.spec.ts new file mode 100644 index 0000000000000..3ff1abaf5bf3f --- /dev/null +++ b/integration-tests/test/nft-bridge.spec.ts @@ -0,0 +1,286 @@ +/* Imports: External */ +import { Contract, ContractFactory, utils, Wallet } from 'ethers' +import { ethers } from 'hardhat' +import { predeploys } from '@eth-optimism/contracts' +import Artifact__TestERC721 from '@eth-optimism/contracts-periphery/artifacts/contracts/testing/helpers/TestERC721.sol/TestERC721.json' +import Artifact__L1ERC721Bridge from '@eth-optimism/contracts-periphery/artifacts/contracts/L1/messaging/L1ERC721Bridge.sol/L1ERC721Bridge.json' +import Artifact__L2ERC721Bridge from '@eth-optimism/contracts-periphery/artifacts/contracts/L2/messaging/L2ERC721Bridge.sol/L2ERC721Bridge.json' +import Artifact__L2StandardERC721Factory from '@eth-optimism/contracts-periphery/artifacts/contracts/L2/messaging/L2StandardERC721Factory.sol/L2StandardERC721Factory.json' +import Artifact__L2StandardERC721 from '@eth-optimism/contracts-periphery/artifacts/contracts/standards/L2StandardERC721.sol/L2StandardERC721.json' + +/* Imports: Internal */ +import { expect } from './shared/setup' +import { OptimismEnv } from './shared/env' +import { withdrawalTest } from './shared/utils' + +const TOKEN_ID: number = 1 +const FINALIZATION_GAS: number = 1_200_000 +const NON_NULL_BYTES: string = '0x1111' + +describe('ERC721 Bridge', () => { + let env: OptimismEnv + before(async () => { + env = await OptimismEnv.new() + }) + + let aliceWalletL1: Wallet + let aliceWalletL2: Wallet + let aliceAddress: string + let bobWalletL1: Wallet + let bobWalletL2: Wallet + let bobAddress: string + before(async () => { + const alice = Wallet.createRandom() + aliceWalletL1 = alice.connect(env.l1Wallet.provider) + aliceWalletL2 = alice.connect(env.l2Wallet.provider) + aliceAddress = aliceWalletL1.address + + const tx = await env.l2Wallet.sendTransaction({ + to: aliceAddress, + value: utils.parseEther('0.01'), + }) + await tx.wait() + + bobWalletL1 = env.l1Wallet + bobWalletL2 = env.l2Wallet + bobAddress = env.l1Wallet.address + }) + + let Factory__L1ERC721: ContractFactory + let Factory__L1ERC721Bridge: ContractFactory + let Factory__L2ERC721Bridge: ContractFactory + let Factory__L2StandardERC721Factory: ContractFactory + before(async () => { + Factory__L1ERC721 = await ethers.getContractFactory( + Artifact__TestERC721.abi, + Artifact__TestERC721.bytecode, + bobWalletL1 + ) + Factory__L1ERC721Bridge = await ethers.getContractFactory( + Artifact__L1ERC721Bridge.abi, + Artifact__L1ERC721Bridge.bytecode, + bobWalletL1 + ) + Factory__L2ERC721Bridge = await ethers.getContractFactory( + Artifact__L2ERC721Bridge.abi, + Artifact__L2ERC721Bridge.bytecode, + bobWalletL2 + ) + Factory__L2StandardERC721Factory = await ethers.getContractFactory( + Artifact__L2StandardERC721Factory.abi, + Artifact__L2StandardERC721Factory.bytecode, + bobWalletL2 + ) + }) + + let L1ERC721: Contract + let L1ERC721Bridge: Contract + let L2ERC721Bridge: Contract + let L2StandardERC721Factory: Contract + let L2StandardERC721: Contract + beforeEach(async () => { + L1ERC721 = await Factory__L1ERC721.deploy() + await L1ERC721.deployed() + + L2ERC721Bridge = await Factory__L2ERC721Bridge.deploy( + predeploys.L2CrossDomainMessenger + ) + await L2ERC721Bridge.deployed() + + L1ERC721Bridge = await Factory__L1ERC721Bridge.deploy( + env.messenger.contracts.l1.L1CrossDomainMessenger.address, + L2ERC721Bridge.address + ) + await L1ERC721Bridge.deployed() + + L2StandardERC721Factory = await Factory__L2StandardERC721Factory.deploy( + L2ERC721Bridge.address + ) + await L2StandardERC721Factory.deployed() + + // Create a L2 Standard ERC721 with the Standard ERC721 Factory + const tx = await L2StandardERC721Factory.createStandardL2ERC721( + L1ERC721.address, + 'L2ERC721', + 'L2' + ) + await tx.wait() + + // Retrieve the deployed L2 Standard ERC721 + const L2StandardERC721Address = + await L2StandardERC721Factory.standardERC721Mapping(L1ERC721.address) + L2StandardERC721 = await ethers.getContractAt( + Artifact__L2StandardERC721.abi, + L2StandardERC721Address + ) + await L2StandardERC721.deployed() + + // Initialize the L2 bridge contract + const tx1 = await L2ERC721Bridge.initialize(L1ERC721Bridge.address) + await tx1.wait() + + // Mint an L1 ERC721 to Bob on L1 + const tx2 = await L1ERC721.mint(bobAddress, TOKEN_ID) + await tx2.wait() + + // Approve the L1 Bridge to operate the NFT + const tx3 = await L1ERC721.approve(L1ERC721Bridge.address, TOKEN_ID) + await tx3.wait() + }) + + it('depositERC721', async () => { + await env.messenger.waitForMessageReceipt( + await L1ERC721Bridge.depositERC721( + L1ERC721.address, + L2StandardERC721.address, + TOKEN_ID, + FINALIZATION_GAS, + NON_NULL_BYTES + ) + ) + + // The L1 Bridge now owns the L1 NFT + expect(await L1ERC721.ownerOf(TOKEN_ID)).to.equal(L1ERC721Bridge.address) + + // Bob owns the NFT on L2 + expect(await L2StandardERC721.ownerOf(TOKEN_ID)).to.equal(bobAddress) + }) + + it('depositERC721To', async () => { + await env.messenger.waitForMessageReceipt( + await L1ERC721Bridge.depositERC721To( + L1ERC721.address, + L2StandardERC721.address, + aliceAddress, + TOKEN_ID, + FINALIZATION_GAS, + NON_NULL_BYTES + ) + ) + + // The L1 Bridge now owns the L1 NFT + expect(await L1ERC721.ownerOf(TOKEN_ID)).to.equal(L1ERC721Bridge.address) + + // Alice owns the NFT on L2 + expect(await L2StandardERC721.ownerOf(TOKEN_ID)).to.equal(aliceAddress) + }) + + withdrawalTest('withdrawERC721', async () => { + // Deposit an NFT into L2 so that there's something to withdraw + await env.messenger.waitForMessageReceipt( + await L1ERC721Bridge.depositERC721( + L1ERC721.address, + L2StandardERC721.address, + TOKEN_ID, + FINALIZATION_GAS, + NON_NULL_BYTES + ) + ) + + // First, check that the L1 Bridge now owns the L1 NFT + expect(await L1ERC721.ownerOf(TOKEN_ID)).to.equal(L1ERC721Bridge.address) + + // Also check that Bob owns the NFT on L2 initially + expect(await L2StandardERC721.ownerOf(TOKEN_ID)).to.equal(bobAddress) + + const tx = await L2ERC721Bridge.withdrawERC721( + L2StandardERC721.address, + TOKEN_ID, + 0, + NON_NULL_BYTES + ) + await tx.wait() + await env.relayXDomainMessages(tx) + + // L1 NFT has been sent back to Bob + expect(await L1ERC721.ownerOf(TOKEN_ID)).to.equal(bobAddress) + + // L2 NFT is burned + await expect(L2StandardERC721.ownerOf(TOKEN_ID)).to.be.reverted + }) + + withdrawalTest('withdrawERC721To', async () => { + // Deposit an NFT into L2 so that there's something to withdraw + await env.messenger.waitForMessageReceipt( + await L1ERC721Bridge.depositERC721( + L1ERC721.address, + L2StandardERC721.address, + TOKEN_ID, + FINALIZATION_GAS, + NON_NULL_BYTES + ) + ) + + // First, check that the L1 Bridge now owns the L1 NFT + expect(await L1ERC721.ownerOf(TOKEN_ID)).to.equal(L1ERC721Bridge.address) + + // Also check that Bob owns the NFT on L2 initially + expect(await L2StandardERC721.ownerOf(TOKEN_ID)).to.equal(bobAddress) + + const tx = await L2ERC721Bridge.withdrawERC721To( + L2StandardERC721.address, + aliceAddress, + TOKEN_ID, + 0, + NON_NULL_BYTES + ) + await tx.wait() + await env.relayXDomainMessages(tx) + + // L1 NFT has been sent to Alice + expect(await L1ERC721.ownerOf(TOKEN_ID)).to.equal(aliceAddress) + + // L2 NFT is burned + await expect(L2StandardERC721.ownerOf(TOKEN_ID)).to.be.reverted + }) + + withdrawalTest( + 'should not allow an arbitrary L2 NFT to be withdrawn in exchange for a legitimate L1 NFT', + async () => { + // First, deposit the legitimate L1 NFT. + await env.messenger.waitForMessageReceipt( + await L1ERC721Bridge.depositERC721( + L1ERC721.address, + L2StandardERC721.address, + TOKEN_ID, + FINALIZATION_GAS, + NON_NULL_BYTES + ) + ) + // Check that the L1 Bridge owns the L1 NFT initially + expect(await L1ERC721.ownerOf(TOKEN_ID)).to.equal(L1ERC721Bridge.address) + + // Deploy a fake L2 ERC721, which: + // - Returns the address of the legitimate L1 token from its l1Token() getter. + // - Allows the L2 bridge to call its burn() function. + const FakeL2StandardERC721 = await ( + await ethers.getContractFactory('FakeL2StandardERC721', bobWalletL2) + ).deploy(L1ERC721.address, L2ERC721Bridge.address) + await FakeL2StandardERC721.deployed() + + // Use the fake contract to mint Alice an NFT with the same token ID + const tx = await FakeL2StandardERC721.mint(aliceAddress, TOKEN_ID) + await tx.wait() + + // Check that Alice owns the NFT from the fake ERC721 contract + expect(await FakeL2StandardERC721.ownerOf(TOKEN_ID)).to.equal( + aliceAddress + ) + + // Alice withdraws the NFT from the fake contract to L1, hoping to receive the legitimate L1 NFT. + const withdrawalTx = await L2ERC721Bridge.connect( + aliceWalletL2 + ).withdrawERC721( + FakeL2StandardERC721.address, + TOKEN_ID, + 0, + NON_NULL_BYTES + ) + await withdrawalTx.wait() + await env.relayXDomainMessages(withdrawalTx) + + // The legitimate NFT on L1 is still held in the bridge. + expect(await L1ERC721.ownerOf(TOKEN_ID)).to.equal(L1ERC721Bridge.address) + } + ) +}) diff --git a/packages/contracts-periphery/contracts/L1/messaging/IL1ERC721Bridge.sol b/packages/contracts-periphery/contracts/L1/messaging/IL1ERC721Bridge.sol new file mode 100644 index 0000000000000..5d06cc9e67db1 --- /dev/null +++ b/packages/contracts-periphery/contracts/L1/messaging/IL1ERC721Bridge.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity >0.5.0 <0.9.0; + +/** + * @title IL1ERC721Bridge + */ +interface IL1ERC721Bridge { + /********** + * Events * + **********/ + + event ERC721DepositInitiated( + address indexed _l1Token, + address indexed _l2Token, + address indexed _from, + address _to, + uint256 _tokenId, + bytes _data + ); + + event ERC721WithdrawalFinalized( + address indexed _l1Token, + address indexed _l2Token, + address indexed _from, + address _to, + uint256 _tokenId, + bytes _data + ); + + /******************** + * Public Functions * + ********************/ + + /** + * @dev get the address of the corresponding L2 bridge contract. + * @return Address of the corresponding L2 bridge contract. + */ + function l2ERC721Bridge() external returns (address); + + /** + * @dev deposit the ERC721 token to the caller on L2. + * @param _l1Token Address of the L1 ERC721 we are depositing + * @param _l2Token Address of the L1 respective L2 ERC721 + * @param _tokenId Token ID of the ERC721 to deposit + * @param _l2Gas Gas limit required to complete the deposit on L2. + * @param _data Optional data to forward to L2. This data is provided + * solely as a convenience for external contracts. Aside from enforcing a maximum + * length, these contracts provide no guarantees about its content. + */ + function depositERC721( + address _l1Token, + address _l2Token, + uint256 _tokenId, + uint32 _l2Gas, + bytes calldata _data + ) external; + + /** + * @dev deposit the ERC721 token to a recipient on L2. + * @param _l1Token Address of the L1 ERC721 we are depositing + * @param _l2Token Address of the L1 respective L2 ERC721 + * @param _to L2 address to credit the withdrawal to. + * @param _tokenId Token ID of the ERC721 to deposit. + * @param _l2Gas Gas limit required to complete the deposit on L2. + * @param _data Optional data to forward to L2. This data is provided + * solely as a convenience for external contracts. Aside from enforcing a maximum + * length, these contracts provide no guarantees about its content. + */ + function depositERC721To( + address _l1Token, + address _l2Token, + address _to, + uint256 _tokenId, + uint32 _l2Gas, + bytes calldata _data + ) external; + + /************************* + * Cross-chain Functions * + *************************/ + + /** + * @dev Complete a withdrawal from L2 to L1, and send the ERC721 token to the recipient on L1 + * This call will fail if the initialized withdrawal from L2 has not been finalized. + * + * @param _l1Token Address of L1 token to finalizeWithdrawal for. + * @param _l2Token Address of L2 token where withdrawal was initiated. + * @param _from L2 address initiating the transfer. + * @param _to L1 address to credit the withdrawal to. + * @param _tokenId Token ID of the ERC721 to deposit. + * @param _data Data provided by the sender on L2. This data is provided + * solely as a convenience for external contracts. Aside from enforcing a maximum + * length, these contracts provide no guarantees about its content. + */ + function finalizeERC721Withdrawal( + address _l1Token, + address _l2Token, + address _from, + address _to, + uint256 _tokenId, + bytes calldata _data + ) external; +} diff --git a/packages/contracts-periphery/contracts/L1/messaging/L1ERC721Bridge.sol b/packages/contracts-periphery/contracts/L1/messaging/L1ERC721Bridge.sol new file mode 100644 index 0000000000000..6365a2d048b50 --- /dev/null +++ b/packages/contracts-periphery/contracts/L1/messaging/L1ERC721Bridge.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +/* Interface Imports */ +import { IL1ERC721Bridge } from "./IL1ERC721Bridge.sol"; +import { IL2ERC721Bridge } from "../../L2/messaging/IL2ERC721Bridge.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +/* Library Imports */ +import { + CrossDomainEnabled +} from "@eth-optimism/contracts/contracts/libraries/bridge/CrossDomainEnabled.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +/** + * @title L1ERC721Bridge + * @dev The L1 ERC721 Bridge is a contract which stores deposited L1 NFTs that are in use + * on L2. It synchronizes a corresponding L2 Bridge, informing it of deposits and listening + * to it for newly finalized withdrawals. + */ +contract L1ERC721Bridge is IL1ERC721Bridge, CrossDomainEnabled { + /******************************** + * External Contract References * + ********************************/ + + address public l2ERC721Bridge; + + // Maps L1 token to L2 token to token ID to a boolean indicating if the token is deposited + mapping(address => mapping(address => mapping(uint256 => bool))) public deposits; + + /*************** + * Constructor * + ***************/ + + // This contract lives behind a proxy, so the constructor parameters will go unused. + constructor(address _l1messenger, address _l2ERC721Bridge) CrossDomainEnabled(address(0)) { + _initialize(_l1messenger, _l2ERC721Bridge); + } + + /****************** + * Initialization * + ******************/ + + /** + * @param _l1messenger L1 Messenger address being used for cross-chain communications. + * @param _l2ERC721Bridge L2 ERC721 bridge address. + */ + function _initialize(address _l1messenger, address _l2ERC721Bridge) internal { + messenger = _l1messenger; + l2ERC721Bridge = _l2ERC721Bridge; + } + + /************** + * Depositing * + **************/ + + /** + * @inheritdoc IL1ERC721Bridge + */ + function depositERC721( + address _l1Token, + address _l2Token, + uint256 _tokenId, + uint32 _l2Gas, + bytes calldata _data + ) external virtual { + // Modifier requiring sender to be EOA. This check could be bypassed by a malicious + // contract via initcode, but it takes care of the user error we want to avoid. + require(!Address.isContract(msg.sender), "Account not EOA"); + + _initiateERC721Deposit(_l1Token, _l2Token, msg.sender, msg.sender, _tokenId, _l2Gas, _data); + } + + /** + * @inheritdoc IL1ERC721Bridge + */ + function depositERC721To( + address _l1Token, + address _l2Token, + address _to, + uint256 _tokenId, + uint32 _l2Gas, + bytes calldata _data + ) external virtual { + _initiateERC721Deposit(_l1Token, _l2Token, msg.sender, _to, _tokenId, _l2Gas, _data); + } + + /** + * @dev Performs the logic for deposits by informing the L2 Deposited Token + * contract of the deposit and calling a handler to lock the L1 NFT. (e.g. transferFrom) + * + * @param _l1Token Address of the L1 ERC721 we are depositing + * @param _l2Token Address of the L1 respective L2 ERC721 + * @param _from Account to pull the deposit from on L1 + * @param _to Account to give the deposit to on L2 + * @param _tokenId Token ID of the ERC721 to deposit. + * @param _l2Gas Gas limit required to complete the deposit on L2. + * @param _data Optional data to forward to L2. This data is provided + * solely as a convenience for external contracts. Aside from enforcing a maximum + * length, these contracts provide no guarantees about its content. + */ + function _initiateERC721Deposit( + address _l1Token, + address _l2Token, + address _from, + address _to, + uint256 _tokenId, + uint32 _l2Gas, + bytes calldata _data + ) internal { + // When a deposit is initiated on L1, the L1 Bridge transfers the NFT to itself for future + // withdrawals. + // slither-disable-next-line reentrancy-events, reentrancy-benign + IERC721(_l1Token).transferFrom(_from, address(this), _tokenId); + + // Construct calldata for _l2Token.finalizeERC721Deposit(_to, _tokenId) + bytes memory message = abi.encodeWithSelector( + IL2ERC721Bridge.finalizeERC721Deposit.selector, + _l1Token, + _l2Token, + _from, + _to, + _tokenId, + _data + ); + + // Send calldata into L2 + // slither-disable-next-line reentrancy-events, reentrancy-benign + sendCrossDomainMessage(l2ERC721Bridge, _l2Gas, message); + + // slither-disable-next-line reentrancy-benign + deposits[_l1Token][_l2Token][_tokenId] = true; + + // slither-disable-next-line reentrancy-events + emit ERC721DepositInitiated(_l1Token, _l2Token, _from, _to, _tokenId, _data); + } + + /************************* + * Cross-chain Functions * + *************************/ + + /** + * @inheritdoc IL1ERC721Bridge + */ + function finalizeERC721Withdrawal( + address _l1Token, + address _l2Token, + address _from, + address _to, + uint256 _tokenId, + bytes calldata _data + ) external onlyFromCrossDomainAccount(l2ERC721Bridge) { + // Checks that the L1/L2 token pair has a token ID that is escrowed in the L1 Bridge + require( + deposits[_l1Token][_l2Token][_tokenId] == true, + "Token ID is not escrowed in the L1 Bridge" + ); + + deposits[_l1Token][_l2Token][_tokenId] = false; + + // When a withdrawal is finalized on L1, the L1 Bridge transfers the NFT to the withdrawer + // slither-disable-next-line reentrancy-events + IERC721(_l1Token).transferFrom(address(this), _to, _tokenId); + + // slither-disable-next-line reentrancy-events + emit ERC721WithdrawalFinalized(_l1Token, _l2Token, _from, _to, _tokenId, _data); + } +} diff --git a/packages/contracts-periphery/contracts/L2/messaging/IL2ERC721Bridge.sol b/packages/contracts-periphery/contracts/L2/messaging/IL2ERC721Bridge.sol new file mode 100644 index 0000000000000..67772c5dbe584 --- /dev/null +++ b/packages/contracts-periphery/contracts/L2/messaging/IL2ERC721Bridge.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +/** + * @title IL2ERC721Bridge + */ +interface IL2ERC721Bridge { + /********** + * Events * + **********/ + + event ERC721WithdrawalInitiated( + address indexed _l1Token, + address indexed _l2Token, + address indexed _from, + address _to, + uint256 _tokenId, + bytes _data + ); + + event ERC721DepositFinalized( + address indexed _l1Token, + address indexed _l2Token, + address indexed _from, + address _to, + uint256 _tokenId, + bytes _data + ); + + event ERC721DepositFailed( + address indexed _l1Token, + address indexed _l2Token, + address indexed _from, + address _to, + uint256 _tokenId, + bytes _data + ); + + /******************** + * Public Functions * + ********************/ + + /** + * @dev get the address of the corresponding L1 bridge contract. + * @return Address of the corresponding L1 bridge contract. + */ + function l1ERC721Bridge() external returns (address); + + /** + * @dev initiate a withdraw of an NFT to the caller's account on L1 + * @param _l2Token Address of L2 token where withdrawal was initiated. + * @param _tokenId Token ID to withdraw. + * @param _l1Gas Unused, but included for potential forward compatibility considerations. + * @param _data Optional data to forward to L1. This data is provided + * solely as a convenience for external contracts. Aside from enforcing a maximum + * length, these contracts provide no guarantees about its content. + */ + function withdrawERC721( + address _l2Token, + uint256 _tokenId, + uint32 _l1Gas, + bytes calldata _data + ) external; + + /** + * @dev initiate a withdrawal of an NFT to a recipient's account on L1. + * @param _l2Token Address of L2 token where withdrawal is initiated. + * @param _to L1 adress to send the withdrawal to. + * @param _tokenId Token ID to withdraw. + * @param _l1Gas Unused, but included for potential forward compatibility considerations. + * @param _data Optional data to forward to L1. This data is provided + * solely as a convenience for external contracts. Aside from enforcing a maximum + * length, these contracts provide no guarantees about its content. + */ + function withdrawERC721To( + address _l2Token, + address _to, + uint256 _tokenId, + uint32 _l1Gas, + bytes calldata _data + ) external; + + /************************* + * Cross-chain Functions * + *************************/ + + /** + * @dev Complete a deposit from L1 to L2, and send ERC721 token to the recipient on L2. + * This call will fail if it did not originate from a corresponding deposit in + * L1ERC721Bridge. + * @param _l1Token Address for the l1 token this is called with + * @param _l2Token Address for the l2 token this is called with + * @param _from Account to pull the deposit from on L2. + * @param _to Address to receive the withdrawal at + * @param _tokenId Token ID to withdraw + * @param _data Data provider by the sender on L1. This data is provided + * solely as a convenience for external contracts. Aside from enforcing a maximum + * length, these contracts provide no guarantees about its content. + */ + function finalizeERC721Deposit( + address _l1Token, + address _l2Token, + address _from, + address _to, + uint256 _tokenId, + bytes calldata _data + ) external; +} diff --git a/packages/contracts-periphery/contracts/L2/messaging/L2ERC721Bridge.sol b/packages/contracts-periphery/contracts/L2/messaging/L2ERC721Bridge.sol new file mode 100644 index 0000000000000..5f4cb1db1bdf5 --- /dev/null +++ b/packages/contracts-periphery/contracts/L2/messaging/L2ERC721Bridge.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +/* Interface Imports */ +import { IL1ERC721Bridge } from "../../L1/messaging/IL1ERC721Bridge.sol"; +import { IL1ERC721Bridge } from "../../L1/messaging/IL1ERC721Bridge.sol"; +import { IL2ERC721Bridge } from "./IL2ERC721Bridge.sol"; + +/* Library Imports */ +import { + CrossDomainEnabled +} from "@eth-optimism/contracts/contracts/libraries/bridge/CrossDomainEnabled.sol"; +import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +/* Contract Imports */ +import { IL2StandardERC721 } from "../../standards/IL2StandardERC721.sol"; + +/** + * @title L2ERC721Bridge + * @dev The L2 ERC721 bridge is a contract which works together with the L1 ERC721 bridge to + * enable ERC721 transitions between L1 and L2. + * This contract acts as a minter for new tokens when it hears about deposits into the L1 ERC721 + * bridge. + * This contract also acts as a burner of the token intended for withdrawal, informing the L1 + * bridge to release the L1 NFT. + */ +contract L2ERC721Bridge is IL2ERC721Bridge, CrossDomainEnabled { + /******************************** + * External Contract References * + ********************************/ + + address public l1ERC721Bridge; + + /*************** + * Constructor * + ***************/ + + /** + * @param _l2CrossDomainMessenger Cross-domain messenger used by this contract. + */ + constructor(address _l2CrossDomainMessenger) CrossDomainEnabled(_l2CrossDomainMessenger) {} + + /****************** + * Initialization * + ******************/ + + /** + * @param _l1ERC721Bridge Address of the L1 bridge deployed to the main chain. + */ + // slither-disable-next-line external-function + function initialize(address _l1ERC721Bridge) public { + require(l1ERC721Bridge == address(0), "Contract has already been initialized."); + l1ERC721Bridge = _l1ERC721Bridge; + } + + /*************** + * Withdrawing * + ***************/ + + /** + * @inheritdoc IL2ERC721Bridge + */ + function withdrawERC721( + address _l2Token, + uint256 _tokenId, + uint32 _l1Gas, + bytes calldata _data + ) external virtual { + // Modifier requiring sender to be EOA. This check could be bypassed by a malicious + // contract via initcode, but it takes care of the user error we want to avoid. + require(!Address.isContract(msg.sender), "Account not EOA"); + + _initiateWithdrawal(_l2Token, msg.sender, msg.sender, _tokenId, _l1Gas, _data); + } + + /** + * @inheritdoc IL2ERC721Bridge + */ + function withdrawERC721To( + address _l2Token, + address _to, + uint256 _tokenId, + uint32 _l1Gas, + bytes calldata _data + ) external virtual { + _initiateWithdrawal(_l2Token, msg.sender, _to, _tokenId, _l1Gas, _data); + } + + /** + * @dev Performs the logic for withdrawals by burning the token and informing + * the L1 token Gateway of the withdrawal. + * @param _l2Token Address of L2 token where withdrawal is initiated. + * @param _from Account to pull the withdrawal from on L2. + * @param _to Account to give the withdrawal to on L1. + * @param _tokenId Token ID of the token to withdraw. + * @param _l1Gas Unused, but included for potential forward compatibility considerations. + * @param _data Optional data to forward to L1. This data is provided + * solely as a convenience for external contracts. Aside from enforcing a maximum + * length, these contracts provide no guarantees about its content. + */ + function _initiateWithdrawal( + address _l2Token, + address _from, + address _to, + uint256 _tokenId, + uint32 _l1Gas, + bytes calldata _data + ) internal { + // Check that the withdrawal is being initiated by the NFT owner + require( + _from == IL2StandardERC721(_l2Token).ownerOf(_tokenId), + "Withdrawal is not being initiated by NFT owner" + ); + + // When a withdrawal is initiated, we burn the withdrawer's NFT to prevent subsequent L2 + // usage + // slither-disable-next-line reentrancy-events + IL2StandardERC721(_l2Token).burn(_from, _tokenId); + + // Construct calldata for l1ERC721Bridge.finalizeERC721Withdrawal(_to, _tokenId) + // slither-disable-next-line reentrancy-events + address l1Token = IL2StandardERC721(_l2Token).l1Token(); + bytes memory message = abi.encodeWithSelector( + IL1ERC721Bridge.finalizeERC721Withdrawal.selector, + l1Token, + _l2Token, + _from, + _to, + _tokenId, + _data + ); + + // Send message to L1 bridge + // slither-disable-next-line reentrancy-events + sendCrossDomainMessage(l1ERC721Bridge, _l1Gas, message); + + // slither-disable-next-line reentrancy-events + emit ERC721WithdrawalInitiated(l1Token, _l2Token, _from, _to, _tokenId, _data); + } + + /************************************ + * Cross-chain Function: Depositing * + ************************************/ + + /** + * @inheritdoc IL2ERC721Bridge + */ + function finalizeERC721Deposit( + address _l1Token, + address _l2Token, + address _from, + address _to, + uint256 _tokenId, + bytes calldata _data + ) external virtual onlyFromCrossDomainAccount(l1ERC721Bridge) { + // Check the target token is compliant and + // verify the deposited token on L1 matches the L2 deposited token representation here + if ( + // slither-disable-next-line reentrancy-events + ERC165Checker.supportsInterface(_l2Token, 0x1d1d8b63) && + _l1Token == IL2StandardERC721(_l2Token).l1Token() + ) { + // When a deposit is finalized, we give the NFT with the same tokenId to the account + // on L2. + // slither-disable-next-line reentrancy-events + IL2StandardERC721(_l2Token).mint(_to, _tokenId); + // slither-disable-next-line reentrancy-events + emit ERC721DepositFinalized(_l1Token, _l2Token, _from, _to, _tokenId, _data); + } else { + // Either the L2 token which is being deposited-into disagrees about the correct address + // of its L1 token, or does not support the correct interface. + // This should only happen if there is a malicious L2 token, or if a user somehow + // specified the wrong L2 token address to deposit into. + // In either case, we stop the process here and construct a withdrawal + // message so that users can get their NFT out in some cases. + // There is no way to prevent malicious token contracts altogether, but this does limit + // user error and mitigate some forms of malicious contract behavior. + bytes memory message = abi.encodeWithSelector( + IL1ERC721Bridge.finalizeERC721Withdrawal.selector, + _l1Token, + _l2Token, + _to, // switched the _to and _from here to bounce back the deposit to the sender + _from, + _tokenId, + _data + ); + + // Send message up to L1 bridge + // slither-disable-next-line reentrancy-events + sendCrossDomainMessage(l1ERC721Bridge, 0, message); + // slither-disable-next-line reentrancy-events + emit ERC721DepositFailed(_l1Token, _l2Token, _from, _to, _tokenId, _data); + } + } +} diff --git a/packages/contracts-periphery/contracts/L2/messaging/L2StandardERC721Factory.sol b/packages/contracts-periphery/contracts/L2/messaging/L2StandardERC721Factory.sol new file mode 100644 index 0000000000000..d80ef469463a0 --- /dev/null +++ b/packages/contracts-periphery/contracts/L2/messaging/L2StandardERC721Factory.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +/* Contract Imports */ +import { L2StandardERC721 } from "../../standards/L2StandardERC721.sol"; + +/** + * @title L2StandardERC721Factory + * @dev Factory contract for creating standard L2 ERC721 representations of L1 ERC721s + * compatible with and working on the NFT bridge. + */ +contract L2StandardERC721Factory { + event StandardL2ERC721Created(address indexed _l1Token, address indexed _l2Token); + + // Address of the L2 ERC721 Bridge. + address public l2ERC721Bridge; + + // Maps an L2 ERC721 token address to a boolean that returns true if the token was created + // with the L2StandardERC721Factory. + mapping(address => bool) public isStandardERC721; + + // Maps an L1 ERC721 to its L2 Standard ERC721 contract, if it exists. This mapping enforces + // that there is one, and only one, L2 Standard ERC721 for each L1 ERC721. The purpose of this + // is to prevent multiple L2 Standard ERC721s from existing for a single L1 ERC721, which + // would result in unnecessary fragmentation, since the Standard ERC721s deployed by this + // factory implement the exact same functionality. This mapping should NOT be interpreted as + // a token list. This is because a custom L2 ERC721 may be recognized by the community as + // the official L2 contract for an L1 ERC721, but the custom contract address wouldn't appear + // in this mapping. An off-chain token list will serve as the official source of truth for + // L2 ERC721s, similar to Optimism's ERC20 token list: + // https://github.com/ethereum-optimism/ethereum-optimism.github.io + mapping(address => address) public standardERC721Mapping; + + constructor(address _l2ERC721Bridge) { + l2ERC721Bridge = _l2ERC721Bridge; + } + + /** + * @dev Creates an instance of the standard ERC721 token on L2. + * @param _l1Token Address of the corresponding L1 token. + * @param _name ERC721 name. + * @param _symbol ERC721 symbol. + */ + function createStandardL2ERC721( + address _l1Token, + string memory _name, + string memory _symbol + ) external { + require(_l1Token != address(0), "Must provide L1 token address"); + + // Only one L2 Standard Token can exist for each L1 Token + require( + standardERC721Mapping[_l1Token] == address(0), + "L2 Standard Token already exists for this L1 Token" + ); + + L2StandardERC721 l2Token = new L2StandardERC721(l2ERC721Bridge, _l1Token, _name, _symbol); + + isStandardERC721[address(l2Token)] = true; + standardERC721Mapping[_l1Token] = address(l2Token); + emit StandardL2ERC721Created(_l1Token, address(l2Token)); + } +} diff --git a/packages/contracts-periphery/contracts/libraries/utils/Lib_Strings.sol b/packages/contracts-periphery/contracts/libraries/utils/Lib_Strings.sol new file mode 100644 index 0000000000000..05ce0acfeafc6 --- /dev/null +++ b/packages/contracts-periphery/contracts/libraries/utils/Lib_Strings.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +/** + * @title Lib_Strings + * @dev This library implements a function to convert an address to an ASCII string. + * It uses the implementation written by tkeber: + * https://ethereum.stackexchange.com/questions/8346/convert-address-to-string/8447#8447 + */ +library Lib_Strings { + /********************** + * Internal Functions * + **********************/ + + /** + * Converts an address to its ASCII string representation. The returned string will be + * lowercase and the 0x prefix will be removed. + * @param _address Address to convert to an ASCII string. + * @return String representation of the address. + */ + function addressToString(address _address) internal pure returns (string memory) { + bytes memory s = new bytes(40); + for (uint256 i = 0; i < 20; i++) { + bytes1 b = bytes1(uint8(uint256(uint160(_address)) / (2**(8 * (19 - i))))); + bytes1 hi = bytes1(uint8(b) / 16); + bytes1 lo = bytes1(uint8(b) - 16 * uint8(hi)); + s[2 * i] = hexCharToAscii(hi); + s[2 * i + 1] = hexCharToAscii(lo); + } + return string(s); + } + + /** + * Converts a hexadecimal character into its ASCII representation. + * @param _byte A single hexadecimal character + * @return ASCII representation of the hexadecimal character. + */ + function hexCharToAscii(bytes1 _byte) internal pure returns (bytes1) { + if (uint8(_byte) < 10) return bytes1(uint8(_byte) + 0x30); + else return bytes1(uint8(_byte) + 0x57); + } +} diff --git a/packages/contracts-periphery/contracts/standards/IL2StandardERC721.sol b/packages/contracts-periphery/contracts/standards/IL2StandardERC721.sol new file mode 100644 index 0000000000000..09973ecc01d54 --- /dev/null +++ b/packages/contracts-periphery/contracts/standards/IL2StandardERC721.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +interface IL2StandardERC721 is IERC721 { + function l1Token() external returns (address); + + function mint(address _to, uint256 _tokenId) external; + + function burn(address _from, uint256 _tokenId) external; + + event Mint(address indexed _account, uint256 _tokenId); + event Burn(address indexed _account, uint256 _tokenId); +} diff --git a/packages/contracts-periphery/contracts/standards/L2StandardERC721.sol b/packages/contracts-periphery/contracts/standards/L2StandardERC721.sol new file mode 100644 index 0000000000000..fd5a4b77a5caa --- /dev/null +++ b/packages/contracts-periphery/contracts/standards/L2StandardERC721.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { Lib_Strings } from "../libraries/utils/Lib_Strings.sol"; +import "./IL2StandardERC721.sol"; + +contract L2StandardERC721 is IL2StandardERC721, ERC721 { + address public l1Token; + address public l2Bridge; + string public baseTokenURI; + + /** + * @param _l2Bridge Address of the L2 standard bridge. + * @param _l1Token Address of the corresponding L1 token. + * @param _name ERC721 name. + * @param _symbol ERC721 symbol. + */ + constructor( + address _l2Bridge, + address _l1Token, + string memory _name, + string memory _symbol + ) ERC721(_name, _symbol) { + l1Token = _l1Token; + l2Bridge = _l2Bridge; + + // Creates a base URI in the format specified by EIP-681: + // https://eips.ethereum.org/EIPS/eip-681 + baseTokenURI = string( + abi.encodePacked( + "ethereum:0x", + Lib_Strings.addressToString(_l1Token), + "@", + Strings.toString(block.chainid), + "/tokenURI?uint256=" + ) + ); + } + + modifier onlyL2Bridge() { + require(msg.sender == l2Bridge, "Only L2 Bridge can mint and burn"); + _; + } + + // slither-disable-next-line external-function + function supportsInterface(bytes4 _interfaceId) + public + view + override(ERC721, IERC165) + returns (bool) + { + bytes4 iface1 = type(IERC165).interfaceId; + bytes4 iface2 = type(IL2StandardERC721).interfaceId; + return + _interfaceId == iface1 || + _interfaceId == iface2 || + super.supportsInterface(_interfaceId); + } + + // slither-disable-next-line external-function + function mint(address _to, uint256 _tokenId) public virtual onlyL2Bridge { + _mint(_to, _tokenId); + + emit Mint(_to, _tokenId); + } + + // slither-disable-next-line external-function + function burn(address _from, uint256 _tokenId) public virtual onlyL2Bridge { + _burn(_tokenId); + + emit Burn(_from, _tokenId); + } + + function _baseURI() internal view virtual override returns (string memory) { + return baseTokenURI; + } +} diff --git a/packages/contracts-periphery/contracts/test-libraries/utils/TestLib_Strings.sol b/packages/contracts-periphery/contracts/test-libraries/utils/TestLib_Strings.sol new file mode 100644 index 0000000000000..520093b3b759d --- /dev/null +++ b/packages/contracts-periphery/contracts/test-libraries/utils/TestLib_Strings.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.9; + +/* Library Imports */ +import { Lib_Strings } from "../../libraries/utils/Lib_Strings.sol"; + +/** + * @title TestLib_Strings + */ +contract TestLib_Strings { + function addressToString(address _address) public pure returns (string memory) { + return Lib_Strings.addressToString(_address); + } + + function hexCharToAscii(bytes1 _byte) public pure returns (bytes1) { + return Lib_Strings.hexCharToAscii(_byte); + } +} diff --git a/packages/contracts-periphery/deploy/nft-bridge/L1ERC721Bridge.ts b/packages/contracts-periphery/deploy/nft-bridge/L1ERC721Bridge.ts new file mode 100644 index 0000000000000..746b35e85dded --- /dev/null +++ b/packages/contracts-periphery/deploy/nft-bridge/L1ERC721Bridge.ts @@ -0,0 +1,22 @@ +/* Imports: External */ +import { DeployFunction } from 'hardhat-deploy/dist/types' + +const deployFn: DeployFunction = async (hre) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const Artifact__L1CrossDomainMessenger = require(`@eth-optimism/contracts/deployments/${hre.network.name}/Proxy__OVM_L1CrossDomainMessenger.json`) + + const { deployer } = await hre.getNamedAccounts() + const L2ERC721Bridge = await hre.companionNetworks['l2'].deployments.get( + 'L2ERC721Bridge' + ) + + await hre.deployments.deploy('L1ERC721Bridge', { + from: deployer, + args: [Artifact__L1CrossDomainMessenger.address, L2ERC721Bridge.address], + log: true, + }) +} + +deployFn.tags = ['L1ERC721Bridge'] + +export default deployFn diff --git a/packages/contracts-periphery/deploy/nft-bridge/L2ERC721Bridge.ts b/packages/contracts-periphery/deploy/nft-bridge/L2ERC721Bridge.ts new file mode 100644 index 0000000000000..81a7b9f6c48b4 --- /dev/null +++ b/packages/contracts-periphery/deploy/nft-bridge/L2ERC721Bridge.ts @@ -0,0 +1,17 @@ +/* Imports: External */ +import { DeployFunction } from 'hardhat-deploy/dist/types' +import { predeploys } from '@eth-optimism/contracts' + +const deployFn: DeployFunction = async (hre) => { + const { deployer } = await hre.getNamedAccounts() + + await hre.deployments.deploy('L2ERC721Bridge', { + from: deployer, + args: [predeploys.L2CrossDomainMessenger], + log: true, + }) +} + +deployFn.tags = ['L2ERC721Bridge'] + +export default deployFn diff --git a/packages/contracts-periphery/deploy/nft-bridge/L2StandardERC721Factory.ts b/packages/contracts-periphery/deploy/nft-bridge/L2StandardERC721Factory.ts new file mode 100644 index 0000000000000..94cb2ca48f1bf --- /dev/null +++ b/packages/contracts-periphery/deploy/nft-bridge/L2StandardERC721Factory.ts @@ -0,0 +1,17 @@ +/* Imports: External */ +import { DeployFunction } from 'hardhat-deploy/dist/types' + +const deployFn: DeployFunction = async (hre) => { + const { deployer } = await hre.getNamedAccounts() + const L2ERC721Bridge = await hre.deployments.get('L2ERC721Bridge') + + await hre.deployments.deploy('L2StandardERC721Factory', { + from: deployer, + args: [L2ERC721Bridge.address], + log: true, + }) +} + +deployFn.tags = ['L2StandardERC721Factory'] + +export default deployFn diff --git a/packages/contracts-periphery/deploy/nft-bridge/initialize-L2ERC721Bridge.ts b/packages/contracts-periphery/deploy/nft-bridge/initialize-L2ERC721Bridge.ts new file mode 100644 index 0000000000000..8963b04c4a4cb --- /dev/null +++ b/packages/contracts-periphery/deploy/nft-bridge/initialize-L2ERC721Bridge.ts @@ -0,0 +1,45 @@ +/* Imports: External */ +import { Contract } from 'ethers' +import { DeployFunction } from 'hardhat-deploy/dist/types' +import { hexStringEquals, awaitCondition } from '@eth-optimism/core-utils' +import { predeploys } from '@eth-optimism/contracts' + +const deployFn: DeployFunction = async (hre) => { + const { deployer } = await hre.getNamedAccounts() + const signer = hre.ethers.provider.getSigner(deployer) + + const L1ERC721Bridge = await hre.companionNetworks['l1'].deployments.get( + 'L1ERC721Bridge' + ) + const Deployment__L2ERC721Bridge = await hre.deployments.get('L2ERC721Bridge') + const L2ERC721Bridge = new Contract( + Deployment__L2ERC721Bridge.address, + Deployment__L2ERC721Bridge.abi, + signer + ) + + const tx = await L2ERC721Bridge.initialize(L1ERC721Bridge.address) + await tx.wait() + + // Ensures that the L2 bridge has been initialized with the correct parameters + await awaitCondition( + async () => { + return ( + hexStringEquals( + await L2ERC721Bridge.messenger(), + predeploys.L2CrossDomainMessenger + ) && + hexStringEquals( + await L2ERC721Bridge.l1ERC721Bridge(), + L1ERC721Bridge.address + ) + ) + }, + 5000, + 100 + ) +} + +deployFn.tags = ['initialize-l2-erc721-bridge'] + +export default deployFn diff --git a/packages/contracts-periphery/hardhat.config.ts b/packages/contracts-periphery/hardhat.config.ts index 51964bfcc9341..43501bd846b5b 100644 --- a/packages/contracts-periphery/hardhat.config.ts +++ b/packages/contracts-periphery/hardhat.config.ts @@ -21,6 +21,9 @@ const config: HardhatUserConfig = { optimism: { chainId: 10, url: 'https://mainnet.optimism.io', + companionNetworks: { + l1: 'mainnet', + }, verify: { etherscan: { apiKey: getenv('OPTIMISTIC_ETHERSCAN_API_KEY'), @@ -30,6 +33,9 @@ const config: HardhatUserConfig = { 'optimism-kovan': { chainId: 69, url: 'https://kovan.optimism.io', + companionNetworks: { + l1: 'kovan', + }, verify: { etherscan: { apiKey: getenv('OPTIMISTIC_ETHERSCAN_API_KEY'), @@ -39,6 +45,9 @@ const config: HardhatUserConfig = { ethereum: { chainId: 1, url: `https://mainnet.infura.io/v3/${getenv('INFURA_PROJECT_ID')}`, + companionNetworks: { + l2: 'optimism', + }, verify: { etherscan: { apiKey: getenv('ETHEREUM_ETHERSCAN_API_KEY'), @@ -66,6 +75,9 @@ const config: HardhatUserConfig = { kovan: { chainId: 42, url: `https://kovan.infura.io/v3/${getenv('INFURA_PROJECT_ID')}`, + companionNetworks: { + l2: 'optimism-kovan', + }, verify: { etherscan: { apiKey: getenv('ETHEREUM_ETHERSCAN_API_KEY'), diff --git a/packages/contracts-periphery/package.json b/packages/contracts-periphery/package.json index f0949caf2a964..bdc9be415d185 100644 --- a/packages/contracts-periphery/package.json +++ b/packages/contracts-periphery/package.json @@ -54,6 +54,7 @@ }, "devDependencies": { "@defi-wonderland/smock": "^2.0.7", + "@eth-optimism/contracts": "^0.5.26", "@eth-optimism/core-utils": "^0.8.6", "@ethersproject/hardware-wallets": "^5.6.1", "@nomiclabs/hardhat-ethers": "^2.0.2", diff --git a/packages/contracts-periphery/test/contracts/L1/messaging/L1ERC721Bridge.spec.ts b/packages/contracts-periphery/test/contracts/L1/messaging/L1ERC721Bridge.spec.ts new file mode 100644 index 0000000000000..6fac674609e0b --- /dev/null +++ b/packages/contracts-periphery/test/contracts/L1/messaging/L1ERC721Bridge.spec.ts @@ -0,0 +1,390 @@ +/* Imports */ +import { ethers } from 'hardhat' +import { Signer, ContractFactory, Contract, constants } from 'ethers' +import { Interface } from 'ethers/lib/utils' +import { + smock, + MockContractFactory, + FakeContract, + MockContract, +} from '@defi-wonderland/smock' +import ICrossDomainMessenger from '@eth-optimism/contracts/artifacts/contracts/libraries/bridge/ICrossDomainMessenger.sol/ICrossDomainMessenger.json' + +import { expect } from '../../../setup' +import { + NON_NULL_BYTES32, + NON_ZERO_ADDRESS, +} from '../../../../../contracts/test/helpers' + +const ERR_INVALID_MESSENGER = 'OVM_XCHAIN: messenger contract unauthenticated' +const ERR_INVALID_X_DOMAIN_MSG_SENDER = + 'OVM_XCHAIN: wrong sender of cross-domain message' +const DUMMY_L2_ERC721_ADDRESS = ethers.utils.getAddress( + '0x' + 'abba'.repeat(10) +) +const DUMMY_L2_BRIDGE_ADDRESS = ethers.utils.getAddress( + '0x' + 'acdc'.repeat(10) +) + +const FINALIZATION_GAS = 1_200_000 + +describe('L1ERC721Bridge', () => { + // init signers + let l1MessengerImpersonator: Signer + let alice: Signer + let bob: Signer + let bobsAddress + let aliceAddress + let tokenId + let aliceInitialBalance + + // we can just make up this string since it's on the "other" Layer + let Factory__L1ERC721: MockContractFactory + let IL2ERC721Bridge: Interface + before(async () => { + ;[l1MessengerImpersonator, alice, bob] = await ethers.getSigners() + + // deploy an ERC721 contract on L1 + Factory__L1ERC721 = await smock.mock( + '@openzeppelin/contracts/token/ERC721/ERC721.sol:ERC721' + ) + + // get an L2ERC721Bridge Interface + IL2ERC721Bridge = (await ethers.getContractFactory('L2ERC721Bridge')) + .interface + + aliceAddress = await alice.getAddress() + bobsAddress = await bob.getAddress() + aliceInitialBalance = 5 + tokenId = 10 + }) + + let L1ERC721: MockContract + let L1ERC721Bridge: Contract + let Fake__L1CrossDomainMessenger: FakeContract + beforeEach(async () => { + // Get a new mock L1 messenger + Fake__L1CrossDomainMessenger = await smock.fake( + new ethers.utils.Interface(ICrossDomainMessenger.abi), + { address: await l1MessengerImpersonator.getAddress() } // This allows us to use an ethers override {from: Fake__L1CrossDomainMessenger.address} to mock calls + ) + + // Deploy the contract under test + L1ERC721Bridge = await ( + await ethers.getContractFactory('L1ERC721Bridge') + ).deploy(Fake__L1CrossDomainMessenger.address, DUMMY_L2_BRIDGE_ADDRESS) + + L1ERC721 = await Factory__L1ERC721.deploy('L1ERC721', 'ERC') + + await L1ERC721.setVariable('_owners', { + [tokenId]: aliceAddress, + }) + await L1ERC721.setVariable('_balances', { + [aliceAddress]: aliceInitialBalance, + }) + }) + + describe('ERC721 deposits', () => { + beforeEach(async () => { + await L1ERC721.connect(alice).approve(L1ERC721Bridge.address, tokenId) + }) + + it('depositERC721() escrows the deposit and sends the correct deposit message', async () => { + // alice calls deposit on the bridge and the L1 bridge calls transferFrom on the token. + // emits an ERC721DepositInitiated event with the correct arguments. + await expect( + L1ERC721Bridge.connect(alice).depositERC721( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + tokenId, + FINALIZATION_GAS, + NON_NULL_BYTES32 + ) + ) + .to.emit(L1ERC721Bridge, 'ERC721DepositInitiated') + .withArgs( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + aliceAddress, + aliceAddress, + tokenId, + NON_NULL_BYTES32 + ) + + const depositCallToMessenger = + Fake__L1CrossDomainMessenger.sendMessage.getCall(0) + + // alice's balance decreases by 1 + const depositerBalance = await L1ERC721.balanceOf(aliceAddress) + expect(depositerBalance).to.equal(aliceInitialBalance - 1) + + // bridge's balance increases by 1 + const bridgeBalance = await L1ERC721.balanceOf(L1ERC721Bridge.address) + expect(bridgeBalance).to.equal(1) + + // Check the correct cross-chain call was sent: + // Message should be sent to the L2 bridge + expect(depositCallToMessenger.args[0]).to.equal(DUMMY_L2_BRIDGE_ADDRESS) + // Message data should be a call telling the L2DepositedERC721 to finalize the deposit + + // the L1 bridge sends the correct message to the L1 messenger + expect(depositCallToMessenger.args[1]).to.equal( + IL2ERC721Bridge.encodeFunctionData('finalizeERC721Deposit', [ + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + aliceAddress, + aliceAddress, + tokenId, + NON_NULL_BYTES32, + ]) + ) + expect(depositCallToMessenger.args[2]).to.equal(FINALIZATION_GAS) + + // Updates the deposits mapping + expect( + await L1ERC721Bridge.deposits( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + tokenId + ) + ).to.equal(true) + }) + + it('depositERC721To() escrows the deposited NFT and sends the correct deposit message', async () => { + // depositor calls deposit on the bridge and the L1 bridge calls transferFrom on the token. + // emits an ERC721DepositInitiated event with the correct arguments. + await expect( + L1ERC721Bridge.connect(alice).depositERC721To( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + bobsAddress, + tokenId, + FINALIZATION_GAS, + NON_NULL_BYTES32 + ) + ) + .to.emit(L1ERC721Bridge, 'ERC721DepositInitiated') + .withArgs( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + aliceAddress, + bobsAddress, + tokenId, + NON_NULL_BYTES32 + ) + + const depositCallToMessenger = + Fake__L1CrossDomainMessenger.sendMessage.getCall(0) + + // alice's balance decreases by 1 + const depositerBalance = await L1ERC721.balanceOf(aliceAddress) + expect(depositerBalance).to.equal(aliceInitialBalance - 1) + + // bridge's balance is increased + const bridgeBalance = await L1ERC721.balanceOf(L1ERC721Bridge.address) + expect(bridgeBalance).to.equal(1) + + // bridge is owner of tokenId + const tokenIdOwner = await L1ERC721.ownerOf(tokenId) + expect(tokenIdOwner).to.equal(L1ERC721Bridge.address) + + // Check the correct cross-chain call was sent: + // Message should be sent to the L2DepositedERC721 on L2 + expect(depositCallToMessenger.args[0]).to.equal(DUMMY_L2_BRIDGE_ADDRESS) + // Message data should be a call telling the L2DepositedERC721 to finalize the deposit + + // the L1 bridge sends the correct message to the L1 messenger + expect(depositCallToMessenger.args[1]).to.equal( + IL2ERC721Bridge.encodeFunctionData('finalizeERC721Deposit', [ + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + aliceAddress, + bobsAddress, + tokenId, + NON_NULL_BYTES32, + ]) + ) + expect(depositCallToMessenger.args[2]).to.equal(FINALIZATION_GAS) + + // Updates the deposits mapping + expect( + await L1ERC721Bridge.deposits( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + tokenId + ) + ).to.equal(true) + }) + + it('cannot depositERC721 from a contract account', async () => { + await expect( + L1ERC721Bridge.depositERC721( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + tokenId, + FINALIZATION_GAS, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith('Account not EOA') + }) + + describe('Handling ERC721.transferFrom() failures that revert', () => { + it('depositERC721(): will revert if ERC721.transferFrom() reverts', async () => { + await expect( + L1ERC721Bridge.connect(bob).depositERC721To( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + bobsAddress, + tokenId, + FINALIZATION_GAS, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith('ERC721: transfer from incorrect owner') + }) + + it('depositERC721To(): will revert if ERC721.transferFrom() reverts', async () => { + await expect( + L1ERC721Bridge.connect(bob).depositERC721To( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + bobsAddress, + tokenId, + FINALIZATION_GAS, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith('ERC721: transfer from incorrect owner') + }) + + it('depositERC721To(): will revert if the L1 ERC721 is zero address', async () => { + await expect( + L1ERC721Bridge.connect(alice).depositERC721To( + constants.AddressZero, + DUMMY_L2_ERC721_ADDRESS, + bobsAddress, + tokenId, + FINALIZATION_GAS, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith('function call to a non-contract account') + }) + + it('depositERC721To(): will revert if the L1 ERC721 has no code', async () => { + await expect( + L1ERC721Bridge.connect(alice).depositERC721To( + bobsAddress, + DUMMY_L2_ERC721_ADDRESS, + bobsAddress, + tokenId, + FINALIZATION_GAS, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith('function call to a non-contract account') + }) + }) + }) + + describe('ERC721 withdrawals', () => { + it('onlyFromCrossDomainAccount: should revert on calls from a non-crossDomainMessenger L1 account', async () => { + await expect( + L1ERC721Bridge.connect(alice).finalizeERC721Withdrawal( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + constants.AddressZero, + constants.AddressZero, + tokenId, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith(ERR_INVALID_MESSENGER) + }) + + it('onlyFromCrossDomainAccount: should revert on calls from the right crossDomainMessenger, but wrong xDomainMessageSender (ie. not the L2DepositedERC721)', async () => { + await expect( + L1ERC721Bridge.finalizeERC721Withdrawal( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + constants.AddressZero, + constants.AddressZero, + tokenId, + NON_NULL_BYTES32, + { + from: Fake__L1CrossDomainMessenger.address, + } + ) + ).to.be.revertedWith(ERR_INVALID_X_DOMAIN_MSG_SENDER) + }) + + describe('withdrawal attempts that pass the onlyFromCrossDomainAccount check', () => { + beforeEach(async () => { + // First Alice will send an NFT so that there's a balance to be withdrawn + await L1ERC721.connect(alice).approve(L1ERC721Bridge.address, tokenId) + + await L1ERC721Bridge.connect(alice).depositERC721( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + tokenId, + FINALIZATION_GAS, + NON_NULL_BYTES32 + ) + + // make sure bridge owns NFT + expect(await L1ERC721.ownerOf(tokenId)).to.equal(L1ERC721Bridge.address) + + Fake__L1CrossDomainMessenger.xDomainMessageSender.returns( + DUMMY_L2_BRIDGE_ADDRESS + ) + }) + + it('should revert if the l1/l2 token pair has a token ID that has not been escrowed in the l1 bridge', async () => { + await expect( + L1ERC721Bridge.finalizeERC721Withdrawal( + L1ERC721.address, + DUMMY_L2_BRIDGE_ADDRESS, // incorrect l2 token address + constants.AddressZero, + constants.AddressZero, + tokenId, + NON_NULL_BYTES32, + { + from: Fake__L1CrossDomainMessenger.address, + } + ) + ).to.be.revertedWith('Token ID is not escrowed in the L1 Bridge') + }) + + it('should credit funds to the withdrawer and not use too much gas', async () => { + // finalizing the withdrawal emits an ERC721DepositInitiated event with the correct arguments. + await expect( + L1ERC721Bridge.finalizeERC721Withdrawal( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + NON_ZERO_ADDRESS, + NON_ZERO_ADDRESS, + tokenId, + NON_NULL_BYTES32, + { from: Fake__L1CrossDomainMessenger.address } + ) + ) + .to.emit(L1ERC721Bridge, 'ERC721WithdrawalFinalized') + .withArgs( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + NON_ZERO_ADDRESS, + NON_ZERO_ADDRESS, + tokenId, + NON_NULL_BYTES32 + ) + + // NFT is transferred to new owner + expect(await L1ERC721.ownerOf(tokenId)).to.equal(NON_ZERO_ADDRESS) + + // deposits state variable is updated + expect( + await L1ERC721Bridge.deposits( + L1ERC721.address, + DUMMY_L2_ERC721_ADDRESS, + tokenId + ) + ).to.equal(false) + }) + }) + }) +}) diff --git a/packages/contracts-periphery/test/contracts/L2/messaging/L2ERC721Bridge.spec.ts b/packages/contracts-periphery/test/contracts/L2/messaging/L2ERC721Bridge.spec.ts new file mode 100644 index 0000000000000..b556bb19ff2c4 --- /dev/null +++ b/packages/contracts-periphery/test/contracts/L2/messaging/L2ERC721Bridge.spec.ts @@ -0,0 +1,407 @@ +/* Imports */ +import { ethers } from 'hardhat' +import { Signer, ContractFactory, Contract, constants } from 'ethers' +import { smock, FakeContract, MockContract } from '@defi-wonderland/smock' +import ICrossDomainMessenger from '@eth-optimism/contracts/artifacts/contracts/libraries/bridge/ICrossDomainMessenger.sol/ICrossDomainMessenger.json' + +import { expect } from '../../../setup' +import { + NON_NULL_BYTES32, + NON_ZERO_ADDRESS, +} from '../../../../../contracts/test/helpers' + +const ERR_ALREADY_INITIALIZED = 'Contract has already been initialized.' +const ERR_INVALID_MESSENGER = 'OVM_XCHAIN: messenger contract unauthenticated' +const ERR_INVALID_X_DOMAIN_MSG_SENDER = + 'OVM_XCHAIN: wrong sender of cross-domain message' +const DUMMY_L1BRIDGE_ADDRESS: string = + '0x1234123412341234123412341234123412341234' +const DUMMY_L1ERC721_ADDRESS: string = + '0x2234223412342234223422342234223422342234' +const ERR_INVALID_WITHDRAWAL: string = + 'Withdrawal is not being initiated by NFT owner' +const ALICE_INITIAL_BALANCE: number = 10 +const TOKEN_ID: number = 10 + +describe('L2ERC721Bridge', () => { + let alice: Signer + let aliceAddress: string + let bob: Signer + let bobsAddress: string + let l2MessengerImpersonator: Signer + let Factory__L1ERC721Bridge: ContractFactory + + before(async () => { + // Create a special signer which will enable us to send messages from the L2Messenger contract + ;[l2MessengerImpersonator, alice, bob] = await ethers.getSigners() + aliceAddress = await alice.getAddress() + bobsAddress = await bob.getAddress() + Factory__L1ERC721Bridge = await ethers.getContractFactory('L1ERC721Bridge') + }) + + let L2ERC721Bridge: Contract + let L2ERC721: Contract + let Fake__L2CrossDomainMessenger: FakeContract + beforeEach(async () => { + // Get a new fake L2 messenger + Fake__L2CrossDomainMessenger = await smock.fake( + new ethers.utils.Interface(ICrossDomainMessenger.abi), + // This allows us to use an ethers override {from: Fake__L2CrossDomainMessenger.address} to mock calls + { address: await l2MessengerImpersonator.getAddress() } + ) + + // Deploy the contract under test + L2ERC721Bridge = await ( + await ethers.getContractFactory('L2ERC721Bridge') + ).deploy(Fake__L2CrossDomainMessenger.address) + + // Initialize the contract + await L2ERC721Bridge.initialize(DUMMY_L1BRIDGE_ADDRESS) + + // Deploy an L2 ERC721 + L2ERC721 = await ( + await ethers.getContractFactory('L2StandardERC721') + ).deploy( + L2ERC721Bridge.address, + DUMMY_L1ERC721_ADDRESS, + 'L2Token', + 'L2T', + { gasLimit: 4_000_000 } // Necessary to avoid an out-of-gas error + ) + }) + + describe('initialize', () => { + it('Should only be callable once', async () => { + await expect( + L2ERC721Bridge.initialize(DUMMY_L1BRIDGE_ADDRESS) + ).to.be.revertedWith(ERR_ALREADY_INITIALIZED) + }) + }) + + // test the transfer flow of moving a token from L1 to L2 + describe('finalizeERC721Deposit', () => { + it('onlyFromCrossDomainAccount: should revert on calls from a non-crossDomainMessenger L2 account', async () => { + await expect( + L2ERC721Bridge.connect(alice).finalizeERC721Deposit( + DUMMY_L1ERC721_ADDRESS, + NON_ZERO_ADDRESS, + NON_ZERO_ADDRESS, + NON_ZERO_ADDRESS, + TOKEN_ID, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith(ERR_INVALID_MESSENGER) + }) + + it('onlyFromCrossDomainAccount: should revert on calls from the right crossDomainMessenger, but wrong xDomainMessageSender (ie. not the L1ERC721Bridge)', async () => { + Fake__L2CrossDomainMessenger.xDomainMessageSender.returns( + NON_ZERO_ADDRESS + ) + + await expect( + L2ERC721Bridge.connect(l2MessengerImpersonator).finalizeERC721Deposit( + DUMMY_L1ERC721_ADDRESS, + NON_ZERO_ADDRESS, + NON_ZERO_ADDRESS, + NON_ZERO_ADDRESS, + TOKEN_ID, + NON_NULL_BYTES32, + { + from: Fake__L2CrossDomainMessenger.address, + } + ) + ).to.be.revertedWith(ERR_INVALID_X_DOMAIN_MSG_SENDER) + }) + + it('should initialize a withdrawal if the L2 token is not compliant', async () => { + // Deploy a non compliant ERC721 + const NonCompliantERC721 = await ( + await ethers.getContractFactory( + '@openzeppelin/contracts/token/ERC721/ERC721.sol:ERC721' + ) + ).deploy('L2Token', 'L2T') + + Fake__L2CrossDomainMessenger.xDomainMessageSender.returns( + DUMMY_L1BRIDGE_ADDRESS + ) + + // A failed attempt to finalize the deposit causes an ERC721DepositFailed event to be emitted. + await expect( + L2ERC721Bridge.connect(l2MessengerImpersonator).finalizeERC721Deposit( + DUMMY_L1ERC721_ADDRESS, + NonCompliantERC721.address, + aliceAddress, + bobsAddress, + TOKEN_ID, + NON_NULL_BYTES32, + { + from: Fake__L2CrossDomainMessenger.address, + } + ) + ) + .to.emit(L2ERC721Bridge, 'ERC721DepositFailed') + .withArgs( + DUMMY_L1ERC721_ADDRESS, + NonCompliantERC721.address, + aliceAddress, + bobsAddress, + TOKEN_ID, + NON_NULL_BYTES32 + ) + + const withdrawalCallToMessenger = + Fake__L2CrossDomainMessenger.sendMessage.getCall(0) + + expect(withdrawalCallToMessenger.args[0]).to.equal(DUMMY_L1BRIDGE_ADDRESS) + expect(withdrawalCallToMessenger.args[1]).to.equal( + Factory__L1ERC721Bridge.interface.encodeFunctionData( + 'finalizeERC721Withdrawal', + [ + DUMMY_L1ERC721_ADDRESS, + NonCompliantERC721.address, + bobsAddress, + aliceAddress, + TOKEN_ID, + NON_NULL_BYTES32, + ] + ) + ) + expect(withdrawalCallToMessenger.args[2]).to.equal(0) + }) + + it('should credit funds to the depositor', async () => { + Fake__L2CrossDomainMessenger.xDomainMessageSender.returns( + DUMMY_L1BRIDGE_ADDRESS + ) + + // Assert that nobody owns the L2 token initially + await expect(L2ERC721.ownerOf(TOKEN_ID)).to.be.revertedWith( + 'ERC721: owner query for nonexistent token' + ) + + // Successfully finalizes the deposit. + const expectedResult = expect( + L2ERC721Bridge.connect(l2MessengerImpersonator).finalizeERC721Deposit( + DUMMY_L1ERC721_ADDRESS, + L2ERC721.address, + aliceAddress, + bobsAddress, + TOKEN_ID, + NON_NULL_BYTES32, + { + from: Fake__L2CrossDomainMessenger.address, + } + ) + ) + + // Depositing causes an ERC721DepositFinalized event to be emitted. + await expectedResult.to + .emit(L2ERC721Bridge, 'ERC721DepositFinalized') + .withArgs( + DUMMY_L1ERC721_ADDRESS, + L2ERC721.address, + aliceAddress, + bobsAddress, + TOKEN_ID, + NON_NULL_BYTES32 + ) + + // Causes a Transfer event to be emitted from the L2 ERC721. + await expectedResult.to + .emit(L2ERC721, 'Transfer') + .withArgs(constants.AddressZero, bobsAddress, TOKEN_ID) + + // Bob is now the owner of the L2 ERC721 + const tokenIdOwner = await L2ERC721.ownerOf(TOKEN_ID) + tokenIdOwner.should.equal(bobsAddress) + }) + }) + + describe('withdrawals', () => { + let Mock__L2Token: MockContract + + beforeEach(async () => { + Mock__L2Token = await ( + await smock.mock('L2StandardERC721') + ).deploy( + L2ERC721Bridge.address, + DUMMY_L1ERC721_ADDRESS, + 'L2Token', + 'L2T', + { gasLimit: 4_000_000 } // Necessary to avoid an out-of-gas error + ) + + await Mock__L2Token.setVariable('_owners', { + [TOKEN_ID]: aliceAddress, + }) + await Mock__L2Token.setVariable('_balances', { + [aliceAddress]: ALICE_INITIAL_BALANCE, + }) + }) + + it('withdrawERC721() reverts when called by non-owner of nft', async () => { + await expect( + L2ERC721Bridge.connect(bob).withdrawERC721( + Mock__L2Token.address, + TOKEN_ID, + 0, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith(ERR_INVALID_WITHDRAWAL) + }) + + it('withdrawERC721() reverts if called by a contract', async () => { + await expect( + L2ERC721Bridge.connect(l2MessengerImpersonator).withdrawERC721( + Mock__L2Token.address, + TOKEN_ID, + 0, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith('Account not EOA') + }) + + it('withdrawERC721() burns and sends the correct withdrawal message', async () => { + // Make sure that alice begins as the NFT owner + expect(await Mock__L2Token.ownerOf(TOKEN_ID)).to.equal(aliceAddress) + + // Initiates a successful withdrawal. + const expectedResult = expect( + L2ERC721Bridge.connect(alice).withdrawERC721( + Mock__L2Token.address, + TOKEN_ID, + 0, + NON_NULL_BYTES32 + ) + ) + + // A successful withdrawal causes an ERC721WithdrawalInitiated event to be emitted from the L2 ERC721 Bridge. + await expectedResult.to + .emit(L2ERC721Bridge, 'ERC721WithdrawalInitiated') + .withArgs( + DUMMY_L1ERC721_ADDRESS, + Mock__L2Token.address, + aliceAddress, + aliceAddress, + TOKEN_ID, + NON_NULL_BYTES32 + ) + + // A withdrawal also causes a Transfer event to be emitted the L2 ERC721, signifying that the L2 token + // has been burnt. + await expectedResult.to + .emit(Mock__L2Token, 'Transfer') + .withArgs(aliceAddress, constants.AddressZero, TOKEN_ID) + + // Assert Alice's balance went down + const aliceBalance = await Mock__L2Token.balanceOf(aliceAddress) + expect(aliceBalance).to.equal(ALICE_INITIAL_BALANCE - 1) + + // Assert that the token isn't owned by anyone + await expect(Mock__L2Token.ownerOf(TOKEN_ID)).to.be.revertedWith( + 'ERC721: owner query for nonexistent token' + ) + + const withdrawalCallToMessenger = + Fake__L2CrossDomainMessenger.sendMessage.getCall(0) + + // Assert the correct cross-chain call was sent: + // Message should be sent to the L1ERC721Bridge on L1 + expect(withdrawalCallToMessenger.args[0]).to.equal(DUMMY_L1BRIDGE_ADDRESS) + // Message data should be a call telling the L1ERC721Bridge to finalize the withdrawal + expect(withdrawalCallToMessenger.args[1]).to.equal( + Factory__L1ERC721Bridge.interface.encodeFunctionData( + 'finalizeERC721Withdrawal', + [ + DUMMY_L1ERC721_ADDRESS, + Mock__L2Token.address, + aliceAddress, + aliceAddress, + TOKEN_ID, + NON_NULL_BYTES32, + ] + ) + ) + // gaslimit should be correct + expect(withdrawalCallToMessenger.args[2]).to.equal(0) + }) + + it('withdrawERC721To() reverts when called by non-owner of nft', async () => { + await expect( + L2ERC721Bridge.connect(bob).withdrawERC721To( + Mock__L2Token.address, + bobsAddress, + TOKEN_ID, + 0, + NON_NULL_BYTES32 + ) + ).to.be.revertedWith(ERR_INVALID_WITHDRAWAL) + }) + + it('withdrawERC721To() burns and sends the correct withdrawal message', async () => { + // Make sure that alice begins as the NFT owner + expect(await Mock__L2Token.ownerOf(TOKEN_ID)).to.equal(aliceAddress) + + // Initiates a successful withdrawal. + const expectedResult = expect( + L2ERC721Bridge.connect(alice).withdrawERC721To( + Mock__L2Token.address, + bobsAddress, + TOKEN_ID, + 0, + NON_NULL_BYTES32 + ) + ) + + // A successful withdrawal causes an ERC721WithdrawalInitiated event to be emitted from the L2 ERC721 Bridge. + await expectedResult.to + .emit(L2ERC721Bridge, 'ERC721WithdrawalInitiated') + .withArgs( + DUMMY_L1ERC721_ADDRESS, + Mock__L2Token.address, + aliceAddress, + bobsAddress, + TOKEN_ID, + NON_NULL_BYTES32 + ) + + // A withdrawal also causes a Transfer event to be emitted the L2 ERC721, signifying that the L2 token + // has been burnt. + await expectedResult.to + .emit(Mock__L2Token, 'Transfer') + .withArgs(aliceAddress, constants.AddressZero, TOKEN_ID) + + // Assert Alice's balance went down + const aliceBalance = await Mock__L2Token.balanceOf(aliceAddress) + expect(aliceBalance).to.equal(ALICE_INITIAL_BALANCE - 1) + + // Assert that the token isn't owned by anyone + await expect(Mock__L2Token.ownerOf(TOKEN_ID)).to.be.revertedWith( + 'ERC721: owner query for nonexistent token' + ) + + const withdrawalCallToMessenger = + Fake__L2CrossDomainMessenger.sendMessage.getCall(0) + + // Assert the correct cross-chain call was sent. + // Message should be sent to the L1ERC721Bridge on L1 + expect(withdrawalCallToMessenger.args[0]).to.equal(DUMMY_L1BRIDGE_ADDRESS) + // The message data should be a call telling the L1ERC721Bridge to finalize the withdrawal + expect(withdrawalCallToMessenger.args[1]).to.equal( + Factory__L1ERC721Bridge.interface.encodeFunctionData( + 'finalizeERC721Withdrawal', + [ + DUMMY_L1ERC721_ADDRESS, + Mock__L2Token.address, + aliceAddress, + bobsAddress, + TOKEN_ID, + NON_NULL_BYTES32, + ] + ) + ) + // gas value is ignored and set to 0. + expect(withdrawalCallToMessenger.args[2]).to.equal(0) + }) + }) +}) diff --git a/packages/contracts-periphery/test/contracts/L2/messaging/L2StandardERC721Factory.spec.ts b/packages/contracts-periphery/test/contracts/L2/messaging/L2StandardERC721Factory.spec.ts new file mode 100644 index 0000000000000..17932675a53e8 --- /dev/null +++ b/packages/contracts-periphery/test/contracts/L2/messaging/L2StandardERC721Factory.spec.ts @@ -0,0 +1,116 @@ +/* External Imports */ +import { ethers } from 'hardhat' +import { Signer, ContractFactory, Contract } from 'ethers' +import { + smock, + MockContractFactory, + MockContract, +} from '@defi-wonderland/smock' + +/* Internal Imports */ +import { expect } from '../../../setup' + +const DUMMY_L2_BRIDGE_ADDRESS: string = ethers.utils.getAddress( + '0x' + 'acdc'.repeat(10) +) + +describe('L2StandardERC721Factory', () => { + let signer: Signer + let Factory__L1ERC721: MockContractFactory + let L1ERC721: MockContract + let L2StandardERC721Factory: Contract + let baseURI: string + let chainId: number + + beforeEach(async () => { + ;[signer] = await ethers.getSigners() + + // deploy an ERC721 contract on L1 + Factory__L1ERC721 = await smock.mock( + '@openzeppelin/contracts/token/ERC721/ERC721.sol:ERC721' + ) + L1ERC721 = await Factory__L1ERC721.deploy('L1ERC721', 'ERC') + + L2StandardERC721Factory = await ( + await ethers.getContractFactory('L2StandardERC721Factory') + ).deploy(DUMMY_L2_BRIDGE_ADDRESS) + + chainId = await signer.getChainId() + baseURI = ''.concat( + 'ethereum:', + L1ERC721.address.toLowerCase(), + '@', + chainId.toString(), + '/tokenURI?uint256=' + ) + }) + + it('should be deployed with the correct constructor argument', async () => { + expect(await L2StandardERC721Factory.l2ERC721Bridge()).to.equal( + DUMMY_L2_BRIDGE_ADDRESS + ) + }) + + it('should be able to create a standard ERC721 contract', async () => { + const tx = await L2StandardERC721Factory.createStandardL2ERC721( + L1ERC721.address, + 'L2ERC721', + 'ERC' + ) + const receipt = await tx.wait() + + // Get the StandardL2ERC721Created event + const erc721CreatedEvent = receipt.events[0] + + // Expect there to be an event emitted for the standard token creation + expect(erc721CreatedEvent.event).to.be.eq('StandardL2ERC721Created') + + // Get the L2 ERC721 address from the emitted event and check it was created correctly + const l2ERC721Address = erc721CreatedEvent.args._l2Token + const L2StandardERC721 = new Contract( + l2ERC721Address, + (await ethers.getContractFactory('L2StandardERC721')).interface, + signer + ) + + expect(await L2StandardERC721.l2Bridge()).to.equal(DUMMY_L2_BRIDGE_ADDRESS) + expect(await L2StandardERC721.l1Token()).to.equal(L1ERC721.address) + expect(await L2StandardERC721.name()).to.equal('L2ERC721') + expect(await L2StandardERC721.symbol()).to.equal('ERC') + expect(await L2StandardERC721.baseTokenURI()).to.equal(baseURI) + + expect( + await L2StandardERC721Factory.isStandardERC721(L2StandardERC721.address) + ).to.equal(true) + expect( + await L2StandardERC721Factory.standardERC721Mapping(L1ERC721.address) + ).to.equal(l2ERC721Address) + }) + + it('should not be able to create a standard token with a 0 address for l1 token', async () => { + await expect( + L2StandardERC721Factory.createStandardL2ERC721( + ethers.constants.AddressZero, + 'L2ERC721', + 'ERC' + ) + ).to.be.revertedWith('Must provide L1 token address') + }) + + it('should not be able create two l2 standard tokens with the same l1 token', async () => { + // The first call will not revert + await L2StandardERC721Factory.createStandardL2ERC721( + L1ERC721.address, + 'L2ERC721', + 'ERC' + ) + + await expect( + L2StandardERC721Factory.createStandardL2ERC721( + L1ERC721.address, + 'L2ERC721', + 'ERC' + ) + ).to.be.revertedWith('L2 Standard Token already exists for this L1 Token') + }) +}) diff --git a/packages/contracts-periphery/test/contracts/libraries/utils/Lib_Strings.spec.ts b/packages/contracts-periphery/test/contracts/libraries/utils/Lib_Strings.spec.ts new file mode 100644 index 0000000000000..6d8b9dbc07204 --- /dev/null +++ b/packages/contracts-periphery/test/contracts/libraries/utils/Lib_Strings.spec.ts @@ -0,0 +1,52 @@ +import { ethers } from 'hardhat' +import { Contract } from 'ethers' + +import { expect } from '../../../setup' +import { deploy } from '../../../helpers' + +const DUMMY_ADDRESS = ethers.utils.getAddress('0x' + 'abba'.repeat(10)) + +describe('Lib_Strings', () => { + let TestLib_Strings: Contract + before(async () => { + TestLib_Strings = await deploy('TestLib_Strings') + }) + + describe('addressToString', () => { + it('should return a string type', () => { + // uses the contract interface to find the function's return type + const returnType = + TestLib_Strings.interface.functions['addressToString(address)'] + .outputs[0].type + + expect(returnType).to.equal('string') + }) + + it('should convert an address to a lowercase ascii string without the 0x prefix', async () => { + const asciiString = DUMMY_ADDRESS.substring(2).toLowerCase() + + expect(await TestLib_Strings.addressToString(DUMMY_ADDRESS)).to.equal( + asciiString + ) + }) + }) + + describe('hexCharToAscii', () => { + for (let hex = 0; hex < 16; hex++) { + it(`should convert the hex character ${hex} to its ascii representation`, async () => { + // converts hex characters to ascii in decimal representation + const asciiDecimal = + hex < 10 + ? hex + 48 // 48 is 0x30 in decimal + : hex + 87 // 87 is 0x57 in decimal + + // converts decimal value to hexadecimal and prepends '0x' + const asciiHexadecimal = '0x' + asciiDecimal.toString(16) + + expect(await TestLib_Strings.hexCharToAscii(hex)).to.equal( + asciiHexadecimal + ) + }) + } + }) +}) diff --git a/packages/contracts-periphery/test/contracts/standards/L2StandardERC721.spec.ts b/packages/contracts-periphery/test/contracts/standards/L2StandardERC721.spec.ts new file mode 100644 index 0000000000000..dcc23fbb8a1c4 --- /dev/null +++ b/packages/contracts-periphery/test/contracts/standards/L2StandardERC721.spec.ts @@ -0,0 +1,118 @@ +/* External Imports */ +import { ethers } from 'hardhat' +import { Signer, Contract } from 'ethers' +import { smock, FakeContract } from '@defi-wonderland/smock' + +/* Internal Imports */ +import { expect } from '../../setup' + +const TOKEN_ID = 10 +const DUMMY_L1ERC721_ADDRESS: string = + '0x2234223412342234223422342234223422342234' + +describe('L2StandardERC721', () => { + let l2BridgeImpersonator: Signer + let alice: Signer + let Fake__L2ERC721Bridge: FakeContract + let L2StandardERC721: Contract + let l2BridgeImpersonatorAddress: string + let aliceAddress: string + let baseUri: string + let chainId: number + + before(async () => { + ;[l2BridgeImpersonator, alice] = await ethers.getSigners() + l2BridgeImpersonatorAddress = await l2BridgeImpersonator.getAddress() + aliceAddress = await alice.getAddress() + + chainId = await alice.getChainId() + baseUri = ''.concat( + 'ethereum:', + DUMMY_L1ERC721_ADDRESS, + '@', + chainId.toString(), + '/tokenURI?uint256=' + ) + + L2StandardERC721 = await ( + await ethers.getContractFactory('L2StandardERC721') + ).deploy( + l2BridgeImpersonatorAddress, + DUMMY_L1ERC721_ADDRESS, + 'L2ERC721', + 'ERC', + { gasLimit: 4_000_000 } // Necessary to avoid an out-of-gas error + ) + + // Get a new fake L2 bridge + Fake__L2ERC721Bridge = await smock.fake( + 'L2ERC721Bridge', + // This allows us to use an ethers override {from: Fake__L2ERC721Bridge.address} to mock calls + { address: await l2BridgeImpersonator.getAddress() } + ) + + // mint an nft to alice + await L2StandardERC721.connect(l2BridgeImpersonator).mint( + aliceAddress, + TOKEN_ID, + { + from: Fake__L2ERC721Bridge.address, + } + ) + }) + + describe('constructor', () => { + it('should be able to create a standard ERC721 contract with the correct parameters', async () => { + expect(await L2StandardERC721.l2Bridge()).to.equal( + l2BridgeImpersonatorAddress + ) + expect(await L2StandardERC721.l1Token()).to.equal(DUMMY_L1ERC721_ADDRESS) + expect(await L2StandardERC721.name()).to.equal('L2ERC721') + expect(await L2StandardERC721.symbol()).to.equal('ERC') + expect(await L2StandardERC721.baseTokenURI()).to.equal(baseUri) + + // alice has been minted an nft + expect(await L2StandardERC721.ownerOf(TOKEN_ID)).to.equal(aliceAddress) + }) + }) + + describe('mint and burn', () => { + it('should not allow anyone but the L2 bridge to mint and burn', async () => { + await expect( + L2StandardERC721.connect(alice).mint(aliceAddress, 100) + ).to.be.revertedWith('Only L2 Bridge can mint and burn') + await expect( + L2StandardERC721.connect(alice).burn(aliceAddress, 100) + ).to.be.revertedWith('Only L2 Bridge can mint and burn') + }) + }) + + describe('supportsInterface', () => { + it('should return the correct interface support', async () => { + const supportsERC165 = await L2StandardERC721.supportsInterface( + 0x01ffc9a7 + ) + expect(supportsERC165).to.be.true + + const supportsL2TokenInterface = await L2StandardERC721.supportsInterface( + 0x1d1d8b63 + ) + expect(supportsL2TokenInterface).to.be.true + + const supportsERC721Interface = await L2StandardERC721.supportsInterface( + 0x80ac58cd + ) + expect(supportsERC721Interface).to.be.true + + const badSupports = await L2StandardERC721.supportsInterface(0xffffffff) + expect(badSupports).to.be.false + }) + }) + + describe('tokenURI', () => { + it('should return the correct token uri', async () => { + const tokenUri = baseUri.concat(TOKEN_ID.toString()) + expect(await L2StandardERC721.tokenURI(TOKEN_ID)).to.equal(tokenUri) + }) + }) +})