diff --git a/contracts/Arbitrum_SpokePool.sol b/contracts/Arbitrum_SpokePool.sol new file mode 100644 index 000000000..32baf1934 --- /dev/null +++ b/contracts/Arbitrum_SpokePool.sol @@ -0,0 +1,117 @@ +//SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.0; + +import "./SpokePool.sol"; +import "./SpokePoolInterface.sol"; + +interface StandardBridgeLike { + function outboundTransfer( + address _l1Token, + address _to, + uint256 _amount, + bytes calldata _data + ) external payable returns (bytes memory); +} + +/** + * @notice AVM specific SpokePool. + * @dev Uses AVM cross-domain-enabled logic for access control. + */ + +contract Arbitrum_SpokePool is SpokePoolInterface, SpokePool { + // Address of the Arbitrum L2 token gateway. + address public l2GatewayRouter; + + // Admin controlled mapping of arbitrum tokens to L1 counterpart. L1 counterpart addresses + // are neccessary to bridge tokens to L1. + mapping(address => address) public whitelistedTokens; + + event ArbitrumTokensBridged(address indexed l1Token, address target, uint256 numberOfTokensBridged); + event SetL2GatewayRouter(address indexed newL2GatewayRouter); + event WhitelistedTokens(address indexed l2Token, address indexed l1Token); + + constructor( + address _l2GatewayRouter, + address _crossDomainAdmin, + address _hubPool, + address _wethAddress, + address timerAddress + ) SpokePool(_crossDomainAdmin, _hubPool, _wethAddress, timerAddress) { + _setL2GatewayRouter(_l2GatewayRouter); + } + + modifier onlyFromCrossDomainAdmin() { + require(msg.sender == _applyL1ToL2Alias(crossDomainAdmin), "ONLY_COUNTERPART_GATEWAY"); + _; + } + + /************************************** + * CROSS-CHAIN ADMIN FUNCTIONS * + **************************************/ + + function setL2GatewayRouter(address newL2GatewayRouter) public onlyFromCrossDomainAdmin nonReentrant { + _setL2GatewayRouter(newL2GatewayRouter); + } + + function whitelistToken(address l2Token, address l1Token) public onlyFromCrossDomainAdmin nonReentrant { + _whitelistToken(l2Token, l1Token); + } + + function setCrossDomainAdmin(address newCrossDomainAdmin) public override onlyFromCrossDomainAdmin nonReentrant { + _setCrossDomainAdmin(newCrossDomainAdmin); + } + + function setHubPool(address newHubPool) public override onlyFromCrossDomainAdmin nonReentrant { + _setHubPool(newHubPool); + } + + function setEnableRoute( + address originToken, + uint32 destinationChainId, + bool enable + ) public override onlyFromCrossDomainAdmin nonReentrant { + _setEnableRoute(originToken, destinationChainId, enable); + } + + function setDepositQuoteTimeBuffer(uint32 buffer) public override onlyFromCrossDomainAdmin nonReentrant { + _setDepositQuoteTimeBuffer(buffer); + } + + function initializeRelayerRefund(bytes32 relayerRepaymentDistributionRoot, bytes32 slowRelayRoot) + public + override + onlyFromCrossDomainAdmin + nonReentrant + { + _initializeRelayerRefund(relayerRepaymentDistributionRoot, slowRelayRoot); + } + + /************************************** + * INTERNAL FUNCTIONS * + **************************************/ + + function _bridgeTokensToHubPool(DestinationDistributionLeaf memory distributionLeaf) internal override { + StandardBridgeLike(l2GatewayRouter).outboundTransfer( + whitelistedTokens[distributionLeaf.l2TokenAddress], // _l1Token. Address of the L1 token to bridge over. + hubPool, // _to. Withdraw, over the bridge, to the l1 hub pool contract. + distributionLeaf.amountToReturn, // _amount. + "" // _data. We don't need to send any data for the bridging action. + ); + emit ArbitrumTokensBridged(address(0), hubPool, distributionLeaf.amountToReturn); + } + + function _setL2GatewayRouter(address _l2GatewayRouter) internal { + l2GatewayRouter = _l2GatewayRouter; + emit SetL2GatewayRouter(l2GatewayRouter); + } + + function _whitelistToken(address _l2Token, address _l1Token) internal { + whitelistedTokens[_l2Token] = _l1Token; + emit WhitelistedTokens(_l2Token, _l1Token); + } + + // l1 addresses are transformed during l1->l2 calls. See https://developer.offchainlabs.com/docs/l1_l2_messages#address-aliasing for more information. + function _applyL1ToL2Alias(address l1Address) internal pure returns (address l2Address) { + l2Address = address(uint160(l1Address) + uint160(0x1111000000000000000000000000000000001111)); + } +} diff --git a/contracts/HubPool.sol b/contracts/HubPool.sol index 0c593707d..46f75413d 100644 --- a/contracts/HubPool.sol +++ b/contracts/HubPool.sol @@ -143,6 +143,7 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { int256[] runningBalance, address indexed caller ); + event SpokePoolAdminFunctionTriggered(uint256 indexed chainId, bytes message); event RelayerRefundDisputed(address indexed disputer, uint256 requestTime, bytes disputedAncillaryData); @@ -167,6 +168,12 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { * ADMIN FUNCTIONS * *************************************************/ + // This function has permission to call onlyFromCrossChainAdmin functions on the SpokePool, so its imperative + // that this contract only allows the owner to call this method directly or indirectly. + function relaySpokePoolAdminFunction(uint256 chainId, bytes memory functionData) public onlyOwner nonReentrant { + _relaySpokePoolAdminFunction(chainId, functionData); + } + function setProtocolFeeCapture(address newProtocolFeeCaptureAddress, uint256 newProtocolFeeCapturePct) public onlyOwner @@ -211,9 +218,16 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { address originToken, address destinationToken ) public onlyOwner { - // Note that this method makes no L1->L1 call to whitelist the route. The assumption is that the origin chain's - // SpokePool Owner will call setEnableRoute to enable the route. This removes the need for an L1->L2 call. whitelistedRoutes[originToken][destinationChainId] = destinationToken; + relaySpokePoolAdminFunction( + destinationChainId, + abi.encodeWithSignature( + "setEnableRoute(address,uint32,bool)", + originToken, + uint32(destinationChainId), + true + ) + ); emit WhitelistRoute(destinationChainId, originToken, destinationToken); } @@ -509,7 +523,6 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { uint256[] memory bundleLpFees ) internal { AdapterInterface adapter = crossChainContracts[chainId].adapter; - require(address(adapter) != address(0), "Adapter not set for target chain"); for (uint32 i = 0; i < l1Tokens.length; i++) { // Validate the L1 -> L2 token route is whitelisted. If it is not then the output of the bridging action @@ -637,6 +650,15 @@ contract HubPool is HubPoolInterface, Testable, Lockable, MultiCaller, Ownable { if (protocolFeesCaptured > 0) unclaimedAccumulatedProtocolFees[l1Token] += protocolFeesCaptured; } + function _relaySpokePoolAdminFunction(uint256 chainId, bytes memory functionData) internal { + AdapterInterface adapter = crossChainContracts[chainId].adapter; + adapter.relayMessage( + crossChainContracts[chainId].spokePool, // target. This should be the spokePool on the L2. + functionData + ); + emit SpokePoolAdminFunctionTriggered(chainId, functionData); + } + // If functionCallStackOriginatesFromOutsideThisContract is true then this was called by the callback function // by dropping ETH onto the contract. In this case, deposit the ETH into WETH. This would happen if ETH was sent // over the optimism bridge, for example. If false then this was set as a result of unwinding LP tokens, with the diff --git a/contracts/Optimism_SpokePool.sol b/contracts/Optimism_SpokePool.sol index 6dcbbb5cf..f2c04c465 100644 --- a/contracts/Optimism_SpokePool.sol +++ b/contracts/Optimism_SpokePool.sol @@ -6,9 +6,6 @@ import "./interfaces/WETH9.sol"; 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"; @@ -17,7 +14,7 @@ import "./SpokePoolInterface.sol"; * @dev Uses OVM cross-domain-enabled logic for access control. */ -contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool, Ownable { +contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool { // "l1Gas" parameter used in call to bridge tokens from this contract back to L1 via `IL2ERC20Bridge`. uint32 public l1Gas = 5_000_000; @@ -26,6 +23,7 @@ contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool address public l2Eth; event OptimismTokensBridged(address indexed l2Token, address target, uint256 numberOfTokensBridged, uint256 l1Gas); + event SetL1Gas(uint32 indexed newL1Gas); constructor( address _l1EthWrapper, @@ -39,24 +37,14 @@ contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool SpokePool(_crossDomainAdmin, _hubPool, _wethAddress, 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. - * @dev This should be set to the address of the L1 contract that ultimately relays a cross-domain message, which - * is expected to be the Optimism_Adapter. - * @dev Only callable by the existing admin via the Optimism cross domain messenger. - * @param newCrossDomainAdmin address of the new L1 admin contract. - */ + function setL1GasLimit(uint32 newl1Gas) public onlyFromCrossDomainAccount(crossDomainAdmin) { + _setL1GasLimit(newl1Gas); + } + function setCrossDomainAdmin(address newCrossDomainAdmin) public override @@ -96,6 +84,15 @@ contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool _initializeRelayerRefund(relayerRepaymentDistributionRoot, slowRelayRoot); } + /************************************** + * INTERNAL FUNCTIONS * + **************************************/ + + function _setL1GasLimit(uint32 _l1Gas) internal { + l1Gas = _l1Gas; + emit SetL1Gas(l1Gas); + } + function _bridgeTokensToHubPool(DestinationDistributionLeaf memory distributionLeaf) internal override { // If the token being bridged is WETH then we need to first unwrap it to ETH and then send ETH over the // canonical bridge. On Optimism, this is address 0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000. @@ -105,8 +102,8 @@ contract Optimism_SpokePool is CrossDomainEnabled, SpokePoolInterface, SpokePool } 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 withdraw contract. - distributionLeaf.amountToReturn, // _amount. Send the full balance of the deposit box to bridge. + hubPool, // _to. Withdraw, over the bridge, to the l1 pool contract. + distributionLeaf.amountToReturn, // _amount. l1Gas, // _l1Gas. Unused, but included for potential forward compatibility considerations "" // _data. We don't need to send any data for the bridging action. ); diff --git a/contracts/chain-adapters/L1_Adapter.sol b/contracts/chain-adapters/L1_Adapter.sol new file mode 100644 index 000000000..1616d2808 --- /dev/null +++ b/contracts/chain-adapters/L1_Adapter.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +import "./Base_Adapter.sol"; +import "../interfaces/AdapterInterface.sol"; +import "../interfaces/WETH9.sol"; + +import "@uma/core/contracts/common/implementation/Lockable.sol"; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract L1_Adapter is Base_Adapter, Lockable { + using SafeERC20 for IERC20; + + constructor(address _hubPool) Base_Adapter(_hubPool) {} + + function relayMessage(address target, bytes memory message) external payable override nonReentrant onlyHubPool { + _executeCall(target, message); + emit MessageRelayed(target, message); + } + + function relayTokens( + address l1Token, + address l2Token, // l2Token is unused for L1. + uint256 amount, + address to + ) external payable override nonReentrant onlyHubPool { + IERC20(l1Token).safeTransfer(to, amount); + emit TokensRelayed(l1Token, l2Token, amount, to); + } + + // Note: this snippet of code is copied from Governor.sol. + function _executeCall(address to, bytes memory data) private { + // Note: this snippet of code is copied from Governor.sol and modified to not include any "value" field. + // solhint-disable-next-line no-inline-assembly + + bool success; + assembly { + let inputData := add(data, 0x20) + let inputDataSize := mload(data) + // Hardcode value to be 0 for relayed governance calls in order to avoid addressing complexity of bridging + // value cross-chain. + success := call(gas(), to, 0, inputData, inputDataSize, 0, 0) + } + require(success, "execute call failed"); + } + + receive() external payable {} +} diff --git a/test/HubPool.Admin.ts b/test/HubPool.Admin.ts index bf05e0840..980058f90 100644 --- a/test/HubPool.Admin.ts +++ b/test/HubPool.Admin.ts @@ -2,13 +2,13 @@ import { getContractFactory, SignerWithAddress, seedWallet, expect, Contract, et import { destinationChainId, bondAmount, zeroAddress, mockTreeRoot, mockSlowRelayFulfillmentRoot } from "./constants"; import { hubPoolFixture } from "./HubPool.Fixture"; -let hubPool: Contract, weth: Contract, usdc: Contract; +let hubPool: Contract, weth: Contract, usdc: Contract, mockSpoke: Contract, mockAdapter: Contract; let owner: SignerWithAddress, other: SignerWithAddress; describe("HubPool Admin functions", function () { beforeEach(async function () { [owner, other] = await ethers.getSigners(); - ({ weth, hubPool, usdc } = await hubPoolFixture()); + ({ weth, hubPool, usdc, mockAdapter, mockSpoke } = await hubPoolFixture()); }); it("Can add L1 token to whitelisted lpTokens mapping", async function () { @@ -35,6 +35,7 @@ describe("HubPool Admin functions", function () { await expect(hubPool.connect(other).disableL1TokenForLiquidityProvision(weth.address)).to.be.reverted; }); it("Can whitelist route for deposits and rebalances", async function () { + await hubPool.setCrossChainContracts(destinationChainId, mockAdapter.address, mockSpoke.address); await expect(hubPool.whitelistRoute(destinationChainId, weth.address, usdc.address)) .to.emit(hubPool, "WhitelistRoute") .withArgs(destinationChainId, weth.address, usdc.address); diff --git a/test/HubPool.Fixture.ts b/test/HubPool.Fixture.ts index ed25cfc10..fb6df6a1f 100644 --- a/test/HubPool.Fixture.ts +++ b/test/HubPool.Fixture.ts @@ -54,7 +54,19 @@ export const hubPoolFixture = hre.deployments.createFixture(async ({ ethers }) = await hubPool.whitelistRoute(repaymentChainId, dai.address, l2Dai); await hubPool.whitelistRoute(repaymentChainId, usdc.address, l2Usdc); - return { weth, usdc, dai, hubPool, mockAdapter, mockSpoke, l2Weth, l2Dai, l2Usdc, ...parentFixtureOutput }; + return { + weth, + usdc, + dai, + hubPool, + mockAdapter, + mockSpoke, + l2Weth, + l2Dai, + l2Usdc, + crossChainAdmin, + ...parentFixtureOutput, + }; }); export async function enableTokensForLP(owner: Signer, hubPool: Contract, weth: Contract, tokens: Contract[]) { diff --git a/test/HubPool.RefundExecution.ts b/test/HubPool.RefundExecution.ts index 6af936004..6fff414f0 100644 --- a/test/HubPool.RefundExecution.ts +++ b/test/HubPool.RefundExecution.ts @@ -63,9 +63,10 @@ describe("HubPool Relayer Refund Execution", function () { // Check the mockAdapter was called with the correct arguments for each method. const relayMessageEvents = await mockAdapter.queryFilter(mockAdapter.filters.RelayMessageCalled()); - expect(relayMessageEvents.length).to.equal(1); // Exactly one message send from L1->L2. - expect(relayMessageEvents[0].args?.target).to.equal(mockSpoke.address); - expect(relayMessageEvents[0].args?.message).to.equal( + expect(relayMessageEvents.length).to.equal(4); // Exactly four message send from L1->L2. 3 for each whitelist route + // and 1 for the initiateRelayerRefund. + expect(relayMessageEvents[relayMessageEvents.length - 1].args?.target).to.equal(mockSpoke.address); + expect(relayMessageEvents[relayMessageEvents.length - 1].args?.message).to.equal( mockSpoke.interface.encodeFunctionData("initializeRelayerRefund", [ consts.mockDestinationDistributionRoot, consts.mockSlowRelayFulfillmentRoot, diff --git a/test/chain-adapters/Arbitrum_Adapter.ts b/test/chain-adapters/Arbitrum_Adapter.ts index 768160430..1a8a5a555 100644 --- a/test/chain-adapters/Arbitrum_Adapter.ts +++ b/test/chain-adapters/Arbitrum_Adapter.ts @@ -1,6 +1,6 @@ import * as consts from "../constants"; import { ethers, expect, Contract, FakeContract, SignerWithAddress, createFake, toWei } from "../utils"; -import { getContractFactory, seedWallet } from "../utils"; +import { getContractFactory, seedWallet, randomAddress } from "../utils"; import { hubPoolFixture, enableTokensForLP } from "../HubPool.Fixture"; import { constructSingleChainTree } from "../MerkleLib.utils"; @@ -72,6 +72,24 @@ describe("Arbitrum Chain Adapter", function () { await arbitrumAdapter.connect(owner).setL2RefundL2Address(liquidityProvider.address); expect(await arbitrumAdapter.callStatic.l2RefundL2Address()).to.equal(liquidityProvider.address); }); + it("relayMessage calls spoke pool functions", async function () { + const newAdmin = randomAddress(); + const functionCallData = mockSpoke.interface.encodeFunctionData("setCrossDomainAdmin", [newAdmin]); + expect(await hubPool.relaySpokePoolAdminFunction(arbitrumChainId, functionCallData)) + .to.emit(arbitrumAdapter, "MessageRelayed") + .withArgs(mockSpoke.address, functionCallData); + expect(l1Inbox.createRetryableTicket).to.have.been.calledThrice; + expect(l1Inbox.createRetryableTicket).to.have.been.calledWith( + mockSpoke.address, + 0, + consts.sampleL2MaxSubmissionCost, + owner.address, + owner.address, + consts.sampleL2Gas, + consts.sampleL2GasPrice, + functionCallData + ); + }); it("Correctly calls appropriate arbitrum bridge functions when making ERC20 cross chain calls", async function () { // Create an action that will send an L1->L2 tokens transfer and bundle. For this, create a relayer repayment bundle // and check that at it's finalization the L2 bridge contracts are called as expected. @@ -97,7 +115,8 @@ describe("Arbitrum Chain Adapter", function () { consts.sampleL2GasPrice, "0x" ); - expect(l1Inbox.createRetryableTicket).to.have.been.calledOnce; // only 1 L1->L2 message sent. + expect(l1Inbox.createRetryableTicket).to.have.been.calledThrice; // only 1 L1->L2 message sent. Note that the two + // whitelist transactions already sent two messages. expect(l1Inbox.createRetryableTicket).to.have.been.calledWith( mockSpoke.address, 0, diff --git a/test/chain-adapters/L1_Adapter.ts b/test/chain-adapters/L1_Adapter.ts new file mode 100644 index 000000000..d596ec8c4 --- /dev/null +++ b/test/chain-adapters/L1_Adapter.ts @@ -0,0 +1,65 @@ +import * as consts from "../constants"; +import { ethers, expect, Contract, SignerWithAddress, randomAddress } from "../utils"; +import { getContractFactory, seedWallet } from "../utils"; +import { hubPoolFixture, enableTokensForLP } from "../HubPool.Fixture"; +import { constructSingleChainTree } from "../MerkleLib.utils"; + +let hubPool: Contract, l1Adapter: Contract, weth: Contract, dai: Contract, mockSpoke: Contract, timer: Contract; +let owner: SignerWithAddress, + dataWorker: SignerWithAddress, + liquidityProvider: SignerWithAddress, + crossChainAdmin: SignerWithAddress; + +const l1ChainId = 1; + +describe("L1 Chain Adapter", function () { + beforeEach(async function () { + [owner, dataWorker, liquidityProvider] = await ethers.getSigners(); + ({ weth, dai, hubPool, mockSpoke, timer, crossChainAdmin } = await hubPoolFixture()); + await seedWallet(dataWorker, [dai], weth, consts.amountToLp); + await seedWallet(liquidityProvider, [dai], weth, consts.amountToLp.mul(10)); + + await enableTokensForLP(owner, hubPool, weth, [weth, dai]); + await weth.connect(liquidityProvider).approve(hubPool.address, consts.amountToLp); + await hubPool.connect(liquidityProvider).addLiquidity(weth.address, consts.amountToLp); + await weth.connect(dataWorker).approve(hubPool.address, consts.bondAmount.mul(10)); + await dai.connect(liquidityProvider).approve(hubPool.address, consts.amountToLp); + await hubPool.connect(liquidityProvider).addLiquidity(dai.address, consts.amountToLp); + await dai.connect(dataWorker).approve(hubPool.address, consts.bondAmount.mul(10)); + + l1Adapter = await (await getContractFactory("L1_Adapter", owner)).deploy(hubPool.address); + + await hubPool.setCrossChainContracts(l1ChainId, l1Adapter.address, mockSpoke.address); + + await hubPool.whitelistRoute(l1ChainId, weth.address, weth.address); + + await hubPool.whitelistRoute(l1ChainId, dai.address, dai.address); + }); + + it("relayMessage calls spoke pool functions", async function () { + expect(await mockSpoke.crossDomainAdmin()).to.equal(crossChainAdmin.address); + const newAdmin = randomAddress(); + const functionCallData = mockSpoke.interface.encodeFunctionData("setCrossDomainAdmin", [newAdmin]); + expect(await hubPool.relaySpokePoolAdminFunction(l1ChainId, functionCallData)) + .to.emit(l1Adapter, "MessageRelayed") + .withArgs(mockSpoke.address, functionCallData); + + expect(await mockSpoke.crossDomainAdmin()).to.equal(newAdmin); + }); + it("Correctly transfers tokens when executing pool rebalance", async function () { + const { leafs, tree, tokensSendToL2 } = await constructSingleChainTree(dai, 1, l1ChainId); + await hubPool + .connect(dataWorker) + .initiateRelayerRefund( + [3117], + 1, + tree.getHexRoot(), + consts.mockDestinationDistributionRoot, + consts.mockSlowRelayFulfillmentRoot + ); + await timer.setCurrentTime(Number(await timer.getCurrentTime()) + consts.refundProposalLiveness); + expect(await hubPool.connect(dataWorker).executeRelayerRefund(leafs[0], tree.getHexProof(leafs[0]))) + .to.emit(l1Adapter, "TokensRelayed") + .withArgs(dai.address, dai.address, tokensSendToL2, mockSpoke.address); + }); +}); diff --git a/test/chain-adapters/Optimism_Adapter.ts b/test/chain-adapters/Optimism_Adapter.ts index 7a4694ed7..fa73e5525 100644 --- a/test/chain-adapters/Optimism_Adapter.ts +++ b/test/chain-adapters/Optimism_Adapter.ts @@ -7,7 +7,7 @@ import { mockSlowRelayFulfillmentRoot, } from "./../constants"; import { ethers, expect, Contract, FakeContract, SignerWithAddress, createFake } from "../utils"; -import { getContractFactory, seedWallet } from "../utils"; +import { getContractFactory, seedWallet, randomAddress } from "../utils"; import { hubPoolFixture, enableTokensForLP } from "../HubPool.Fixture"; import { constructSingleChainTree } from "../MerkleLib.utils"; @@ -51,6 +51,18 @@ describe("Optimism Chain Adapter", function () { await optimismAdapter.connect(owner).setL2GasLimit(sampleL2Gas + 1); expect(await optimismAdapter.callStatic.l2GasLimit()).to.equal(sampleL2Gas + 1); }); + it("relayMessage calls spoke pool functions", async function () { + const newAdmin = randomAddress(); + const functionCallData = mockSpoke.interface.encodeFunctionData("setCrossDomainAdmin", [newAdmin]); + expect(await hubPool.relaySpokePoolAdminFunction(optimismChainId, functionCallData)) + .to.emit(optimismAdapter, "MessageRelayed") + .withArgs(mockSpoke.address, functionCallData); + expect(l1CrossDomainMessenger.sendMessage).to.have.been.calledWith( + mockSpoke.address, + functionCallData, + sampleL2Gas + ); + }); it("Correctly calls appropriate Optimism bridge functions when making ERC20 cross chain calls", async function () { // Create an action that will send an L1->L2 tokens transfer and bundle. For this, create a relayer repayment bundle // and check that at it's finalization the L2 bridge contracts are called as expected.