diff --git a/contracts/Optimism_SpokePool.sol b/contracts/Optimism_SpokePool.sol index cd6d36bc6..3a01867b8 100644 --- a/contracts/Optimism_SpokePool.sol +++ b/contracts/Optimism_SpokePool.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.0; import "@eth-optimism/contracts/libraries/bridge/CrossDomainEnabled.sol"; import "@eth-optimism/contracts/libraries/constants/Lib_PredeployAddresses.sol"; +import "@eth-optimism/contracts/L2/messaging/IL2ERC20Bridge.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; import "./SpokePool.sol"; import "./SpokePoolInterface.sol"; @@ -11,27 +13,33 @@ import "./SpokePoolInterface.sol"; * @dev Uses OVM cross-domain-enabled logic for access control. */ -contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool { - // Address of the L1 contract that acts as the owner of this SpokePool. - address public override crossDomainAdmin; +contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool, Ownable { + // "l1Gas" parameter used in call to bridge tokens from this contract back to L1 via `IL2ERC20Bridge`. + uint32 l1Gas = 6_000_000; - event SetXDomainAdmin(address indexed newAdmin); + event OptimismTokensBridged(address indexed l2Token, address target, uint256 numberOfTokensBridged, uint256 l1Gas); constructor( address _crossDomainAdmin, + address _hubPool, address _wethAddress, uint64 _depositQuoteTimeBuffer, address timerAddress ) CrossDomainEnabled(Lib_PredeployAddresses.L2_CROSS_DOMAIN_MESSENGER) - SpokePool(_wethAddress, _depositQuoteTimeBuffer, timerAddress) - { - _setCrossDomainAdmin(_crossDomainAdmin); - } + SpokePool(_crossDomainAdmin, _hubPool, _wethAddress, _depositQuoteTimeBuffer, timerAddress) + {} /************************************** * ADMIN FUNCTIONS * **************************************/ + function setL1GasLimit(uint32 newl1Gas) public onlyOwner nonReentrant { + l1Gas = newl1Gas; + } + + /************************************** + * CROSS-CHAIN ADMIN FUNCTIONS * + **************************************/ /** * @notice Changes the L1 contract that can trigger admin functions on this contract. @@ -44,19 +52,29 @@ contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool public override onlyFromCrossDomainAccount(crossDomainAdmin) + nonReentrant { _setCrossDomainAdmin(newCrossDomainAdmin); } + function setHubPool(address newHubPool) public override onlyFromCrossDomainAccount(crossDomainAdmin) nonReentrant { + _setHubPool(newHubPool); + } + function setEnableRoute( address originToken, uint256 destinationChainId, bool enable - ) public override onlyFromCrossDomainAccount(crossDomainAdmin) { + ) public override onlyFromCrossDomainAccount(crossDomainAdmin) nonReentrant { _setEnableRoute(originToken, destinationChainId, enable); } - function setDepositQuoteTimeBuffer(uint64 buffer) public override onlyFromCrossDomainAccount(crossDomainAdmin) { + function setDepositQuoteTimeBuffer(uint64 buffer) + public + override + onlyFromCrossDomainAccount(crossDomainAdmin) + nonReentrant + { _setDepositQuoteTimeBuffer(buffer); } @@ -64,17 +82,20 @@ contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool public override onlyFromCrossDomainAccount(crossDomainAdmin) + nonReentrant { _initializeRelayerRefund(relayerRepaymentDistributionProof); } - /************************************** - * INTERNAL FUNCTIONS * - **************************************/ - - function _setCrossDomainAdmin(address newCrossDomainAdmin) internal { - require(newCrossDomainAdmin != address(0), "Bad bridge router address"); - crossDomainAdmin = newCrossDomainAdmin; - emit SetXDomainAdmin(crossDomainAdmin); + function _bridgeTokensToHubPool(MerkleLib.DestinationDistribution memory distributionLeaf) internal override { + // TODO: Handle WETH token unwrapping + IL2ERC20Bridge(Lib_PredeployAddresses.L2_STANDARD_BRIDGE).withdrawTo( + distributionLeaf.l2TokenAddress, // _l2Token. Address of the L2 token to bridge over. + hubPool, // _to. Withdraw, over the bridge, to the l1 pool contract. + distributionLeaf.amountToReturn, // _amount. Send the full balance of the deposit box to bridge. + l1Gas, // _l1Gas. Unused, but included for potential forward compatibility considerations + "" // _data. We don't need to send any data for the bridging action. + ); + emit OptimismTokensBridged(distributionLeaf.l2TokenAddress, hubPool, distributionLeaf.amountToReturn, l1Gas); } } diff --git a/contracts/SpokePool.sol b/contracts/SpokePool.sol index d9a4fc2a1..278d482ba 100644 --- a/contracts/SpokePool.sol +++ b/contracts/SpokePool.sol @@ -11,6 +11,7 @@ import "@uma/core/contracts/common/implementation/Testable.sol"; import "@uma/core/contracts/common/implementation/Lockable.sol"; import "@uma/core/contracts/common/implementation/MultiCaller.sol"; import "./MerkleLib.sol"; +import "./SpokePoolInterface.sol"; interface WETH9Like { function withdraw(uint256 wad) external; @@ -26,10 +27,16 @@ interface WETH9Like { * on the destination chain. Locked source chain tokens are later sent over the canonical token bridge to L1. * @dev This contract is designed to be deployed to L2's, not mainnet. */ -abstract contract SpokePool is Testable, Lockable, MultiCaller { +abstract contract SpokePool is SpokePoolInterface, Testable, Lockable, MultiCaller { using SafeERC20 for IERC20; using Address for address; + // Address of the L1 contract that acts as the owner of this SpokePool. + address public crossDomainAdmin; + + // Address of the L1 contract that will send tokens to and receive tokens from this contract. + address public hubPool; + // Timestamp when contract was constructed. Relays cannot have a quote time before this. uint64 public deploymentTime; @@ -52,7 +59,7 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { bytes32 distributionRoot; // This is a 2D bitmap tracking which leafs in the relayer refund root have been claimed, with max size of // 256x256 leaves per root. - mapping(uint256 => uint256) claimsBitmap; + mapping(uint256 => uint256) claimedBitmap; } RelayerRefund[] public relayerRefunds; @@ -75,6 +82,8 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { /**************************************** * EVENTS * ****************************************/ + event SetXDomainAdmin(address indexed newAdmin); + event SetHubPool(address indexed newHubPool); event EnabledDepositRoute(address indexed originToken, uint256 indexed destinationChainId, bool enabled); event SetDepositQuoteTimeBuffer(uint64 newBuffer); event FundsDeposited( @@ -103,12 +112,33 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { address recipient ); event InitializedRelayerRefund(uint256 indexed relayerRefundId, bytes32 relayerRepaymentDistributionProof); + event DistributedRelayerRefund( + uint256 indexed relayerRefundId, + uint256 indexed leafId, + uint256 chainId, + uint256 amountToReturn, + uint256[] refundAmounts, + address l2TokenAddress, + address[] refundAddresses, + address indexed caller + ); + event TokensBridged( + uint256 indexed leafId, + uint256 indexed chainId, + uint256 amountToReturn, + address indexed l2TokenAddress, + address caller + ); constructor( + address _crossDomainAdmin, + address _hubPool, address _wethAddress, uint64 _depositQuoteTimeBuffer, address timerAddress ) Testable(timerAddress) { + _setCrossDomainAdmin(_crossDomainAdmin); + _setHubPool(_hubPool); deploymentTime = uint64(getCurrentTime()); depositQuoteTimeBuffer = _depositQuoteTimeBuffer; weth = WETH9Like(_wethAddress); @@ -127,6 +157,18 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { * ADMIN FUNCTIONS * **************************************/ + function _setCrossDomainAdmin(address newCrossDomainAdmin) internal { + require(newCrossDomainAdmin != address(0), "Bad bridge router address"); + crossDomainAdmin = newCrossDomainAdmin; + emit SetXDomainAdmin(crossDomainAdmin); + } + + function _setHubPool(address newHubPool) internal { + require(newHubPool != address(0), "Bad hub pool address"); + hubPool = newHubPool; + emit SetHubPool(hubPool); + } + function _setEnableRoute( address originToken, uint256 destinationChainId, @@ -156,7 +198,7 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { address recipient, uint64 relayerFeePct, uint64 quoteTimestamp - ) public payable onlyEnabledRoute(originToken, destinationChainId) { + ) public payable onlyEnabledRoute(originToken, destinationChainId) nonReentrant { // We limit the relay fees to prevent the user spending all their funds on fees. require(relayerFeePct <= 0.5e18, "invalid relayer fee"); // Note We assume that L2 timing cannot be compared accurately and consistently to L1 timing. Therefore, @@ -210,7 +252,7 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { uint256 totalRelayAmount, uint256 maxTokensToSend, uint256 repaymentChain - ) public { + ) public nonReentrant { // Each relay attempt is mapped to the hash of data uniquely identifying it, which includes the deposit data // such as the origin chain ID and the deposit ID, and the data in a relay attempt such as who the recipient // is, which chain and currency the recipient wants to receive funds on, and the relay fees. @@ -244,11 +286,7 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { uint256 maxTokensToSend, uint256 repaymentChain, bytes memory depositorSignature - ) - public - // public methods but I couldn't figure out a way to pass this in without encounering a stack too deep error. - nonReentrant - { + ) public nonReentrant { // Grouping the signature validation logic into brackets to address stack too deep error. { // Depositor should have signed a hash of the relayer fee % to update to and information uniquely identifying @@ -302,8 +340,59 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { function distributeRelayerRefund( uint256 relayerRefundId, MerkleLib.DestinationDistribution memory distributionLeaf, - bytes32[] memory inclusionProof - ) public {} + bytes32[] memory proof + ) public override nonReentrant { + // Check integrity of leaf structure: + require(distributionLeaf.chainId == chainId(), "Invalid chainId"); + require(distributionLeaf.refundAddresses.length == distributionLeaf.refundAmounts.length, "invalid leaf"); + + // Grab distribution root stored at `relayerRefundId`. + RelayerRefund storage refund = relayerRefunds[relayerRefundId]; + + // Check that `inclusionProof` proves that `distributionLeaf` is contained within the distribution root. + // Note: This should revert if the `distributionRoot` is uninitialized. + require(MerkleLib.verifyRelayerDistribution(refund.distributionRoot, distributionLeaf, proof), "Bad Proof"); + + // Verify the leafId in the leaf has not yet been claimed. + require(!MerkleLib.isClaimed(refund.claimedBitmap, distributionLeaf.leafId), "Already claimed"); + + // Set leaf as claimed in bitmap. + MerkleLib.setClaimed(refund.claimedBitmap, distributionLeaf.leafId); + + // For each relayerRefundAddress in relayerRefundAddresses, send the associated refundAmount for the L2 token address. + // Note: Even if the L2 token is not enabled on this spoke pool, we should still refund relayers. + for (uint32 i = 0; i < distributionLeaf.refundAmounts.length; i++) { + uint256 amount = distributionLeaf.refundAmounts[i]; + if (amount > 0) + IERC20(distributionLeaf.l2TokenAddress).safeTransfer(distributionLeaf.refundAddresses[i], amount); + } + + // If `distributionLeaf.amountToReturn` is positive, then send L2 --> L1 message to bridge tokens back via + // chain-specific bridging method. + if (distributionLeaf.amountToReturn > 0) { + // Do we need to perform any check about the last time that funds were bridged from L2 to L1? + _bridgeTokensToHubPool(distributionLeaf); + + emit TokensBridged( + distributionLeaf.leafId, + distributionLeaf.chainId, + distributionLeaf.amountToReturn, + distributionLeaf.l2TokenAddress, + msg.sender + ); + } + + emit DistributedRelayerRefund( + relayerRefundId, + distributionLeaf.leafId, + distributionLeaf.chainId, + distributionLeaf.amountToReturn, + distributionLeaf.refundAmounts, + distributionLeaf.l2TokenAddress, + distributionLeaf.refundAddresses, + msg.sender + ); + } /************************************** * VIEW FUNCTIONS * @@ -317,6 +406,8 @@ abstract contract SpokePool is Testable, Lockable, MultiCaller { * INTERNAL FUNCTIONS * **************************************/ + function _bridgeTokensToHubPool(MerkleLib.DestinationDistribution memory distributionLeaf) internal virtual; + function _computeAmountPreFees(uint256 amount, uint256 feesPct) private pure returns (uint256) { return (1e18 * amount) / (1e18 - feesPct); } diff --git a/contracts/SpokePoolInterface.sol b/contracts/SpokePoolInterface.sol index 3ced7fc93..0fd0c077c 100644 --- a/contracts/SpokePoolInterface.sol +++ b/contracts/SpokePoolInterface.sol @@ -1,11 +1,13 @@ //SPDX-License-Identifier: Unlicense pragma solidity ^0.8.0; -interface SpokePoolInterface { - function crossDomainAdmin() external returns (address); +import "./MerkleLib.sol"; +interface SpokePoolInterface { function setCrossDomainAdmin(address newCrossDomainAdmin) external; + function setHubPool(address newHubPool) external; + function setEnableRoute( address originToken, uint256 destinationChainId, @@ -15,4 +17,10 @@ interface SpokePoolInterface { function setDepositQuoteTimeBuffer(uint64 buffer) external; function initializeRelayerRefund(bytes32 relayerRepaymentDistributionProof) external; + + function distributeRelayerRefund( + uint256 relayerRefundId, + MerkleLib.DestinationDistribution memory distributionLeaf, + bytes32[] memory inclusionProof + ) external; } diff --git a/contracts/test/MockSpokePool.sol b/contracts/test/MockSpokePool.sol index c06af104b..cc14b70af 100644 --- a/contracts/test/MockSpokePool.sol +++ b/contracts/test/MockSpokePool.sol @@ -9,13 +9,21 @@ import "../SpokePoolInterface.sol"; * @notice Implements admin internal methods to test internal logic. */ contract MockSpokePool is SpokePoolInterface, SpokePool { - address public override crossDomainAdmin; - constructor( + address _crossDomainAdmin, + address _hubPool, address _wethAddress, uint64 _depositQuoteTimeBuffer, address timerAddress - ) SpokePool(_wethAddress, _depositQuoteTimeBuffer, timerAddress) {} + ) SpokePool(_crossDomainAdmin, _hubPool, _wethAddress, _depositQuoteTimeBuffer, timerAddress) {} + + function setCrossDomainAdmin(address newCrossDomainAdmin) public override { + _setCrossDomainAdmin(newCrossDomainAdmin); + } + + function setHubPool(address newHubPool) public override { + _setHubPool(newHubPool); + } function setEnableRoute( address originToken, @@ -33,7 +41,5 @@ contract MockSpokePool is SpokePoolInterface, SpokePool { _initializeRelayerRefund(relayerRepaymentDistributionProof); } - function setCrossDomainAdmin(address newCrossDomainAdmin) public override { - crossDomainAdmin = newCrossDomainAdmin; - } + function _bridgeTokensToHubPool(MerkleLib.DestinationDistribution memory distributionLeaf) internal override {} } diff --git a/test/HubPool.Fixture.ts b/test/HubPool.Fixture.ts index 8433a7ad6..63a6636a2 100644 --- a/test/HubPool.Fixture.ts +++ b/test/HubPool.Fixture.ts @@ -1,4 +1,4 @@ -import { TokenRolesEnum, interfaceName } from "@uma/common"; +import { TokenRolesEnum } from "@uma/common"; import { getContractFactory, randomAddress, toBN, fromWei } from "./utils"; import { bondAmount, refundProposalLiveness, finalFee, identifier, repaymentChainId } from "./constants"; @@ -8,7 +8,7 @@ import hre from "hardhat"; import { umaEcosystemFixture } from "./UmaEcosystem.Fixture"; export const hubPoolFixture = hre.deployments.createFixture(async ({ ethers }) => { - const [signer] = await ethers.getSigners(); + const [signer, crossChainAdmin] = await ethers.getSigners(); // This fixture is dependent on the UMA ecosystem fixture. Run it first and grab the output. This is used in the // deployments that follows. The output is spread when returning contract instances from this fixture. @@ -44,8 +44,8 @@ export const hubPoolFixture = hre.deployments.createFixture(async ({ ethers }) = const mockAdapter = await (await getContractFactory("Mock_Adapter", signer)).deploy(); await mockAdapter.transferOwnership(hubPool.address); const mockSpoke = await ( - await getContractFactory("MockSpokePool", signer) - ).deploy(weth.address, 0, parentFixtureOutput.timer.address); + await getContractFactory("MockSpokePool", { signer: signer, libraries: { MerkleLib: merkleLib.address } }) + ).deploy(crossChainAdmin.address, hubPool.address, weth.address, 0, parentFixtureOutput.timer.address); await hubPool.setCrossChainContracts(repaymentChainId, mockAdapter.address, mockSpoke.address); // Deploy mock l2 tokens for each token created before and whitelist the routes. diff --git a/test/MerkleLib.utils.ts b/test/MerkleLib.utils.ts index 951ce05b0..3ad1b757a 100644 --- a/test/MerkleLib.utils.ts +++ b/test/MerkleLib.utils.ts @@ -1,10 +1,9 @@ import { expect } from "chai"; import { getParamType } from "./utils"; -import { merkleLibFixture } from "./MerkleLib.Fixture"; import { MerkleTree } from "../utils/MerkleTree"; import { ethers } from "hardhat"; const { defaultAbiCoder, keccak256 } = ethers.utils; -import { BigNumber, Signer, Contract } from "ethers"; +import { BigNumber, Contract } from "ethers"; export interface PoolRebalance { leafId: BigNumber; @@ -24,6 +23,40 @@ export interface DestinationDistribution { refundAmounts: BigNumber[]; } +export async function buildDestinationDistributionTree(destinationDistributions: DestinationDistribution[]) { + for (let i = 0; i < destinationDistributions.length; i++) { + // The 2 provided parallel arrays must be of equal length. + expect(destinationDistributions[i].refundAddresses.length).to.equal( + destinationDistributions[i].refundAmounts.length + ); + } + + const paramType = await getParamType("MerkleLib", "verifyRelayerDistribution", "distribution"); + const hashFn = (input: DestinationDistribution) => keccak256(defaultAbiCoder.encode([paramType!], [input])); + return new MerkleTree(destinationDistributions, hashFn); +} + +export function buildDestinationDistributionLeafs( + destinationChainIds: number[], + amountsToReturn: BigNumber[], + l2Tokens: Contract[], + refundAddresses: string[][], + refundAmounts: BigNumber[][] +): DestinationDistribution[] { + return Array(destinationChainIds.length) + .fill(0) + .map((_, i) => { + return { + leafId: BigNumber.from(i), + chainId: BigNumber.from(destinationChainIds[i]), + amountToReturn: amountsToReturn[i], + l2TokenAddress: l2Tokens[i].address, + refundAddresses: refundAddresses[i], + refundAmounts: refundAmounts[i], + }; + }); +} + export async function buildPoolRebalanceTree(poolRebalances: PoolRebalance[]) { for (let i = 0; i < poolRebalances.length; i++) { // The 4 provided parallel arrays must be of equal length. diff --git a/test/Optimism_SpokePool.ts b/test/Optimism_SpokePool.ts index 74ebbcbca..f231433aa 100644 --- a/test/Optimism_SpokePool.ts +++ b/test/Optimism_SpokePool.ts @@ -1 +1,3 @@ // TODO: Test OVM specific functionality and implementation of SpokePool internal methods. +// - Test that onlyCrossDomain modifier is applied correctly. +// - Check that bridging tokens from L2 to L1 works as expected. diff --git a/test/SpokePool.Fixture.ts b/test/SpokePool.Fixture.ts index 062160c95..e46c89f16 100644 --- a/test/SpokePool.Fixture.ts +++ b/test/SpokePool.Fixture.ts @@ -13,7 +13,7 @@ import hre from "hardhat"; const { defaultAbiCoder, keccak256, arrayify } = utils; export const spokePoolFixture = hre.deployments.createFixture(async ({ ethers }) => { - const [deployerWallet] = await ethers.getSigners(); + const [deployerWallet, crossChainAdmin, hubPool] = await ethers.getSigners(); // Useful contracts. const timer = await (await getContractFactory("Timer", deployerWallet)).deploy(); @@ -31,9 +31,10 @@ export const spokePoolFixture = hre.deployments.createFixture(async ({ ethers }) await destErc20.addMember(TokenRolesEnum.MINTER, deployerWallet.address); // Deploy the pool + const merkleLib = await (await getContractFactory("MerkleLib", deployerWallet)).deploy(); const spokePool = await ( - await getContractFactory("MockSpokePool", deployerWallet) - ).deploy(weth.address, depositQuoteTimeBuffer, timer.address); + await getContractFactory("MockSpokePool", { signer: deployerWallet, libraries: { MerkleLib: merkleLib.address } }) + ).deploy(crossChainAdmin.address, hubPool.address, weth.address, depositQuoteTimeBuffer, timer.address); return { timer, weth, erc20, spokePool, unwhitelistedErc20, destErc20 }; }); diff --git a/test/SpokePool.RefundExecution.ts b/test/SpokePool.RefundExecution.ts new file mode 100644 index 000000000..79e39d8a2 --- /dev/null +++ b/test/SpokePool.RefundExecution.ts @@ -0,0 +1,122 @@ +import { expect } from "chai"; +import { Contract } from "ethers"; +import { ethers } from "hardhat"; + +import { SignerWithAddress, seedContract, toBN } from "./utils"; +import * as consts from "./constants"; +import { spokePoolFixture } from "./SpokePool.Fixture"; +import { buildDestinationDistributionLeafs, buildDestinationDistributionTree } from "./MerkleLib.utils"; + +let spokePool: Contract, destErc20: Contract, weth: Contract; +let dataWorker: SignerWithAddress, relayer: SignerWithAddress, rando: SignerWithAddress; + +let destinationChainId: number; + +async function constructSimpleTree(l2Token: Contract, destinationChainId: number) { + const leafs = buildDestinationDistributionLeafs( + [destinationChainId, destinationChainId], // Destination chain ID. + [consts.amountToReturn, toBN(0)], // amountToReturn. + [l2Token, l2Token], // l2Token. + [[relayer.address, rando.address], []], // refundAddresses. + [[consts.amountToRelay, consts.amountToRelay], []] // refundAmounts. + ); + const leafsRefundAmount = leafs + .map((leaf) => leaf.refundAmounts.reduce((bn1, bn2) => bn1.add(bn2), toBN(0))) + .reduce((bn1, bn2) => bn1.add(bn2), toBN(0)); + const tree = await buildDestinationDistributionTree(leafs); + + return { + leafs, + leafsRefundAmount, + tree, + }; +} +describe("SpokePool Relayer Refund Execution", function () { + beforeEach(async function () { + [dataWorker, relayer, rando] = await ethers.getSigners(); + ({ destErc20, spokePool, weth } = await spokePoolFixture()); + destinationChainId = Number(await spokePool.chainId()); + + // Send funds to SpokePool. + await seedContract(spokePool, dataWorker, [destErc20], weth, consts.amountHeldByPool); + }); + + it("Execute relayer refund correctly sends tokens to recipients", async function () { + const { leafs, leafsRefundAmount, tree } = await constructSimpleTree(destErc20, destinationChainId); + + // Store new tree. + await spokePool.connect(dataWorker).initializeRelayerRefund( + tree.getHexRoot() // distribution root. Generated from the merkle tree constructed before. + ); + + // Distribute the first leaf. + await spokePool.connect(dataWorker).distributeRelayerRefund(0, leafs[0], tree.getHexProof(leafs[0])); + + // Relayers should be refunded + expect(await destErc20.balanceOf(spokePool.address)).to.equal(consts.amountHeldByPool.sub(leafsRefundAmount)); + expect(await destErc20.balanceOf(relayer.address)).to.equal(consts.amountToRelay); + expect(await destErc20.balanceOf(rando.address)).to.equal(consts.amountToRelay); + + // TODO: Test token bridging logic. + + // Check events. + let relayTokensEvents = await spokePool.queryFilter(spokePool.filters.DistributedRelayerRefund()); + expect(relayTokensEvents[0].args?.l2TokenAddress).to.equal(destErc20.address); + expect(relayTokensEvents[0].args?.leafId).to.equal(0); + expect(relayTokensEvents[0].args?.chainId).to.equal(destinationChainId); + expect(relayTokensEvents[0].args?.amountToReturn).to.equal(consts.amountToReturn); + expect(relayTokensEvents[0].args?.refundAmounts).to.deep.equal([consts.amountToRelay, consts.amountToRelay]); + expect(relayTokensEvents[0].args?.refundAddresses).to.deep.equal([relayer.address, rando.address]); + expect(relayTokensEvents[0].args?.caller).to.equal(dataWorker.address); + + // Should emit TokensBridged event if amountToReturn is positive. + let tokensBridgedEvents = await spokePool.queryFilter(spokePool.filters.TokensBridged()); + expect(tokensBridgedEvents.length).to.equal(1); + + // Does not attempt to bridge tokens if amountToReturn is 0. Execute a leaf where amountToReturn is 0. + await spokePool.connect(dataWorker).distributeRelayerRefund(0, leafs[1], tree.getHexProof(leafs[1])); + // Show that a second DistributedRelayRefund event was emitted but not a second TokensBridged event. + relayTokensEvents = await spokePool.queryFilter(spokePool.filters.DistributedRelayerRefund()); + expect(relayTokensEvents.length).to.equal(2); + tokensBridgedEvents = await spokePool.queryFilter(spokePool.filters.TokensBridged()); + expect(tokensBridgedEvents.length).to.equal(1); + }); + it("Execution rejects invalid leaf, tree, proof combinations", async function () { + const { leafs, tree } = await constructSimpleTree(destErc20, destinationChainId); + await spokePool.connect(dataWorker).initializeRelayerRefund( + tree.getHexRoot() // distribution root. Generated from the merkle tree constructed before. + ); + + // Take the valid root but change some element within it. This will change the hash of the leaf + // and as such the contract should reject it for not being included within the merkle tree for the valid proof. + const badLeaf = { ...leafs[0], chainId: 13371 }; + await expect(spokePool.connect(dataWorker).distributeRelayerRefund(0, badLeaf, tree.getHexProof(leafs[0]))).to.be + .reverted; + + // Reverts if the distribution root index is incorrect. + await expect(spokePool.connect(dataWorker).distributeRelayerRefund(1, leafs[0], tree.getHexProof(leafs[0]))).to.be + .reverted; + }); + it("Cannot refund leaf with chain ID for another network", async function () { + // Create tree for another chain ID + const { leafs, tree } = await constructSimpleTree(destErc20, 13371); + await spokePool.connect(dataWorker).initializeRelayerRefund( + tree.getHexRoot() // distribution root. Generated from the merkle tree constructed before. + ); + + // Root is valid and leaf is contained in tree, but chain ID doesn't match pool's chain ID. + await expect(spokePool.connect(dataWorker).distributeRelayerRefund(0, leafs[0], tree.getHexProof(leafs[0]))).to.be + .reverted; + }); + it("Execution rejects double claimed leafs", async function () { + const { leafs, tree } = await constructSimpleTree(destErc20, destinationChainId); + await spokePool.connect(dataWorker).initializeRelayerRefund( + tree.getHexRoot() // distribution root. Generated from the merkle tree constructed before. + ); + + // First claim should be fine. Second claim should be reverted as you cant double claim a leaf. + await spokePool.connect(dataWorker).distributeRelayerRefund(0, leafs[0], tree.getHexProof(leafs[0])); + await expect(spokePool.connect(dataWorker).distributeRelayerRefund(0, leafs[0], tree.getHexProof(leafs[0]))).to.be + .reverted; + }); +}); diff --git a/test/SpokePool.RefundInitialization.ts b/test/SpokePool.RefundInitialization.ts index dfc46a2da..d5ca68e36 100644 --- a/test/SpokePool.RefundInitialization.ts +++ b/test/SpokePool.RefundInitialization.ts @@ -6,15 +6,15 @@ import { spokePoolFixture } from "./SpokePool.Fixture"; import { mockDestinationDistributionRoot } from "./constants"; let spokePool: Contract; -let caller: SignerWithAddress; +let dataWorker: SignerWithAddress; describe("SpokePool Initialize Relayer Refund Logic", async function () { beforeEach(async function () { - [caller] = await ethers.getSigners(); + [dataWorker] = await ethers.getSigners(); ({ spokePool } = await spokePoolFixture()); }); it("Initializing root stores root and emits event", async function () { - await expect(spokePool.connect(caller).initializeRelayerRefund(mockDestinationDistributionRoot)) + await expect(spokePool.connect(dataWorker).initializeRelayerRefund(mockDestinationDistributionRoot)) .to.emit(spokePool, "InitializedRelayerRefund") .withArgs(0, mockDestinationDistributionRoot); expect(await spokePool.relayerRefunds(0)).to.equal(mockDestinationDistributionRoot); diff --git a/test/constants.ts b/test/constants.ts index a5ffd96ce..d206e5e11 100644 --- a/test/constants.ts +++ b/test/constants.ts @@ -58,4 +58,10 @@ export const mockPoolRebalanceRoot = createRandomBytes32(); export const mockDestinationDistributionRoot = createRandomBytes32(); +// Amount of tokens to seed SpokePool with at beginning of relayer refund distribution tests +export const amountHeldByPool = amountToRelay.mul(4); + +// Amount of tokens to bridge back to L1 from SpokePool in relayer refund distribution tests +export const amountToReturn = toWei("1"); + export const mockTreeRoot = createRandomBytes32(); diff --git a/test/utils.ts b/test/utils.ts index cc94e2a44..271a51f86 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -55,6 +55,18 @@ export async function seedWallet( if (weth) await weth.connect(walletToFund).deposit({ value: amountToSeedWith }); } +export async function seedContract( + contract: Contract, + walletToFund: Signer, + tokens: Contract[], + weth: Contract | undefined, + amountToSeedWith: number | BigNumber +) { + await seedWallet(walletToFund, tokens, weth, amountToSeedWith); + for (const token of tokens) await token.connect(walletToFund).transfer(contract.address, amountToSeedWith); + if (weth) await weth.connect(walletToFund).transfer(contract.address, amountToSeedWith); +} + export function randomBigNumber() { return ethers.BigNumber.from(ethers.utils.randomBytes(31)); }