diff --git a/contracts/chain-adapters/Arbitrum_Adapter.sol b/contracts/chain-adapters/Arbitrum_Adapter.sol index b2012977b..d06f59c5f 100644 --- a/contracts/chain-adapters/Arbitrum_Adapter.sol +++ b/contracts/chain-adapters/Arbitrum_Adapter.sol @@ -3,6 +3,9 @@ pragma solidity ^0.8.0; import "../interfaces/AdapterInterface.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + interface ArbitrumL1InboxLike { function createRetryableTicket( address destAddr, @@ -25,6 +28,8 @@ interface ArbitrumL1ERC20GatewayLike { uint256 _gasPriceBid, bytes calldata _data ) external payable returns (bytes memory); + + function getGateway(address _token) external view returns (address); } /** @@ -35,35 +40,37 @@ interface ArbitrumL1ERC20GatewayLike { * that call this contract's logic guard against reentrancy. */ contract Arbitrum_Adapter is AdapterInterface { + using SafeERC20 for IERC20; + // Amount of ETH allocated to pay for the base submission fee. The base submission fee is a parameter unique to // retryable transactions; the user is charged the base submission fee to cover the storage costs of keeping their // ticket’s calldata in the retry buffer. (current base submission fee is queryable via // ArbRetryableTx.getSubmissionPrice). ArbRetryableTicket precompile interface exists at L2 address // 0x000000000000000000000000000000000000006E. - uint256 public immutable l2MaxSubmissionCost = 0.1e18; + uint256 public immutable l2MaxSubmissionCost = 0.01e18; // L2 Gas price bid for immediate L2 execution attempt (queryable via standard eth*gasPrice RPC) - uint256 public immutable l2GasPrice = 10e9; // 10 gWei + uint256 public immutable l2GasPrice = 5e9; // 5 gWei // Gas limit for immediate L2 execution attempt (can be estimated via NodeInterface.estimateRetryableTicket). // NodeInterface precompile interface exists at L2 address 0x00000000000000000000000000000000000000C8 - uint32 public immutable l2GasLimit = 5_000_000; + uint32 public immutable l2GasLimit = 2_000_000; // This address on L2 receives extra ETH that is left over after relaying a message via the inbox. address public immutable l2RefundL2Address; ArbitrumL1InboxLike public immutable l1Inbox; - ArbitrumL1ERC20GatewayLike public immutable l1ERC20Gateway; + ArbitrumL1ERC20GatewayLike public immutable l1ERC20GatewayRouter; /** * @notice Constructs new Adapter. * @param _l1ArbitrumInbox Inbox helper contract to send messages to Arbitrum. - * @param _l1ERC20Gateway ERC20 gateway contract to send tokens to Arbitrum. + * @param _l1ERC20GatewayRouter ERC20 gateway router contract to send tokens to Arbitrum. */ - constructor(ArbitrumL1InboxLike _l1ArbitrumInbox, ArbitrumL1ERC20GatewayLike _l1ERC20Gateway) { + constructor(ArbitrumL1InboxLike _l1ArbitrumInbox, ArbitrumL1ERC20GatewayLike _l1ERC20GatewayRouter) { l1Inbox = _l1ArbitrumInbox; - l1ERC20Gateway = _l1ERC20Gateway; + l1ERC20GatewayRouter = _l1ERC20GatewayRouter; l2RefundL2Address = msg.sender; } @@ -75,9 +82,8 @@ contract Arbitrum_Adapter is AdapterInterface { * @param target Contract on Arbitrum that will receive message. * @param message Data to send to target. */ - function relayMessage(address target, bytes calldata message) external payable override { - uint256 requiredL1CallValue = getL1CallValue(); - require(address(this).balance >= requiredL1CallValue, "Insufficient ETH balance"); + function relayMessage(address target, bytes memory message) external payable override { + uint256 requiredL1CallValue = _contractHasSufficientEthBalance(); l1Inbox.createRetryableTicket{ value: requiredL1CallValue }( target, // destAddr destination L2 contract address @@ -95,6 +101,8 @@ contract Arbitrum_Adapter is AdapterInterface { /** * @notice Bridge tokens to Arbitrum. + * @notice This contract must hold at least getL1CallValue() amount of ETH to send a message via the Inbox + * successfully, or the message will get stuck. * @param l1Token L1 token to deposit. * @param l2Token L2 token to receive. * @param amount Amount of L1 tokens to deposit and L2 tokens to receive. @@ -106,7 +114,29 @@ contract Arbitrum_Adapter is AdapterInterface { uint256 amount, address to ) external payable override { - l1ERC20Gateway.outboundTransfer(l1Token, to, amount, l2GasLimit, l2GasPrice, ""); + uint256 requiredL1CallValue = _contractHasSufficientEthBalance(); + + // Approve the gateway, not the router, to spend the hub pool's balance. The gateway, which is different + // per L1 token, will temporarily escrow the tokens to be bridged and pull them from this contract. + address erc20Gateway = l1ERC20GatewayRouter.getGateway(l1Token); + IERC20(l1Token).safeIncreaseAllowance(erc20Gateway, amount); + + // `outboundTransfer` expects that the caller includes a bytes message as the last param that includes the + // maxSubmissionCost to use when creating an L2 retryable ticket: https://github.com/OffchainLabs/arbitrum/blob/e98d14873dd77513b569771f47b5e05b72402c5e/packages/arb-bridge-peripherals/contracts/tokenbridge/ethereum/gateway/L1GatewayRouter.sol#L232 + bytes memory data = abi.encode(l2MaxSubmissionCost, ""); + + // Note: outboundTransfer() will ultimately create a retryable ticket and set this contract's address as the + // refund address. This means that the excess ETH to pay for the L2 transaction will be sent to the aliased + // contract address on L2 and lost. + l1ERC20GatewayRouter.outboundTransfer{ value: requiredL1CallValue }( + l1Token, + to, + amount, + l2GasLimit, + l2GasPrice, + data + ); + emit TokensRelayed(l1Token, l2Token, amount, to); } @@ -117,4 +147,9 @@ contract Arbitrum_Adapter is AdapterInterface { function getL1CallValue() public pure returns (uint256) { return l2MaxSubmissionCost + l2GasPrice * l2GasLimit; } + + function _contractHasSufficientEthBalance() internal view returns (uint256 requiredL1CallValue) { + requiredL1CallValue = getL1CallValue(); + require(address(this).balance >= requiredL1CallValue, "Insufficient ETH balance"); + } } diff --git a/contracts/chain-adapters/Optimism_Adapter.sol b/contracts/chain-adapters/Optimism_Adapter.sol index a4292ec16..e9c1e1839 100644 --- a/contracts/chain-adapters/Optimism_Adapter.sol +++ b/contracts/chain-adapters/Optimism_Adapter.sol @@ -21,7 +21,7 @@ import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; */ contract Optimism_Adapter is CrossDomainEnabled, AdapterInterface { using SafeERC20 for IERC20; - uint32 public immutable l2GasLimit = 5_000_000; + uint32 public immutable l2GasLimit = 2_000_000; WETH9 public immutable l1Weth; diff --git a/contracts/test/ArbitrumMocks.sol b/contracts/test/ArbitrumMocks.sol new file mode 100644 index 000000000..9b2ab711b --- /dev/null +++ b/contracts/test/ArbitrumMocks.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.0; + +contract ArbitrumMockErc20GatewayRouter { + function outboundTransfer( + address _token, + address _to, + uint256 _amount, + uint256 _maxGas, + uint256 _gasPriceBid, + bytes calldata _data + ) external payable returns (bytes memory) { + return _data; + } + + function getGateway(address _token) external view returns (address) { + return address(this); + } +} diff --git a/scripts/buildSampleTree.ts b/scripts/buildSampleTree.ts index 75acc73a1..de7bca7aa 100644 --- a/scripts/buildSampleTree.ts +++ b/scripts/buildSampleTree.ts @@ -2,7 +2,7 @@ // test net. // @dev Modify constants to modify merkle leaves. Command: `yarn hardhat run ./scripts/buildSampleTree.ts` -import { toWei, toBN, toBNWei, getParamType, defaultAbiCoder, keccak256 } from "../test/utils"; +import { toBN, getParamType, defaultAbiCoder, keccak256, toBNWeiWithDecimals } from "../test/utils"; import { MerkleTree } from "../utils/MerkleTree"; import { RelayData } from "../test/fixtures/SpokePool.Fixture"; @@ -15,13 +15,14 @@ const RELAYER_REFUND_LEAF_COUNT = 1; const SLOW_RELAY_LEAF_COUNT = 1; const POOL_REBALANCE_NET_SEND_AMOUNT = 0.1; // Amount of tokens to send from HubPool to SpokePool const RELAYER_REFUND_AMOUNT_TO_RETURN = 0.1; // Amount of tokens to send from SpokePool to HubPool -const L1_TOKEN = "0xd0A1E359811322d97991E03f863a0C30C2cF029C"; -const L2_TOKEN = "0x4200000000000000000000000000000000000006"; +const L1_TOKEN = "0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b"; +const L2_TOKEN = "0x1E77ad77925Ac0075CF61Fb76bA35D884985019d"; +const DECIMALS = 6; const RELAYER_REFUND_ADDRESS_TO_REFUND = "0x9a8f92a830a5cb89a3816e3d267cb7791c16b04d"; const RELAYER_REFUND_AMOUNT_TO_REFUND = 0.1; // Amount of tokens to send out of SpokePool to relayer refund recipient const SLOW_RELAY_RECIPIENT_ADDRESS = "0x9a8f92a830a5cb89a3816e3d267cb7791c16b04d"; const SLOW_RELAY_AMOUNT = 0.1; // Amount of tokens to send out of SpokePool to slow relay recipient address -const SPOKE_POOL_CHAIN_ID = 69; +const SPOKE_POOL_CHAIN_ID = 421611; function tuplelifyLeaf(leaf: Object) { return JSON.stringify( @@ -40,9 +41,9 @@ async function main() { for (let i = 0; i < POOL_REBALANCE_LEAF_COUNT; i++) { leaves.push({ chainId: toBN(SPOKE_POOL_CHAIN_ID), - bundleLpFees: [toBNWei(0.1)], - netSendAmounts: [toBNWei(POOL_REBALANCE_NET_SEND_AMOUNT)], - runningBalances: [toWei(0)], + bundleLpFees: [toBN(0)], + netSendAmounts: [toBNWeiWithDecimals(POOL_REBALANCE_NET_SEND_AMOUNT, DECIMALS)], + runningBalances: [toBN(0)], groupIndex: toBN(0), leafId: toBN(i), l1Tokens: [L1_TOKEN], @@ -81,9 +82,9 @@ async function main() { const leaves: RelayerRefundLeaf[] = []; for (let i = 0; i < RELAYER_REFUND_LEAF_COUNT; i++) { leaves.push({ - amountToReturn: toBNWei(RELAYER_REFUND_AMOUNT_TO_RETURN), + amountToReturn: toBNWeiWithDecimals(RELAYER_REFUND_AMOUNT_TO_RETURN, DECIMALS), chainId: toBN(SPOKE_POOL_CHAIN_ID), - refundAmounts: [toBNWei(RELAYER_REFUND_AMOUNT_TO_REFUND)], + refundAmounts: [toBNWeiWithDecimals(RELAYER_REFUND_AMOUNT_TO_REFUND, DECIMALS)], leafId: toBN(i), l2TokenAddress: L2_TOKEN, refundAddresses: [RELAYER_REFUND_ADDRESS_TO_REFUND], @@ -125,7 +126,7 @@ async function main() { depositor: SLOW_RELAY_RECIPIENT_ADDRESS, recipient: SLOW_RELAY_RECIPIENT_ADDRESS, destinationToken: L2_TOKEN, - amount: toBNWei(SLOW_RELAY_AMOUNT).toString(), + amount: toBNWeiWithDecimals(SLOW_RELAY_AMOUNT, DECIMALS).toString(), originChainId: SPOKE_POOL_CHAIN_ID.toString(), destinationChainId: SPOKE_POOL_CHAIN_ID.toString(), realizedLpFeePct: "0", diff --git a/scripts/setupArbitrumSpokePool.ts b/scripts/setupArbitrumSpokePool.ts index 3a2220e24..cea9c8ba7 100644 --- a/scripts/setupArbitrumSpokePool.ts +++ b/scripts/setupArbitrumSpokePool.ts @@ -1,6 +1,7 @@ // @notice Logs ABI-encoded function data that can be relayed from HubPool to ArbitrumSpokePool to set it up. -import { getContractFactory, ethers } from "../test/utils"; +import { getContractFactory, ethers, hre } from "../test/utils"; +import * as consts from "../test/constants"; async function main() { const [signer] = await ethers.getSigners(); @@ -12,6 +13,13 @@ async function main() { "0xc778417e063141139fce010982780140aa0cd5ab", // L1 WETH ]); console.log(`(WETH) whitelistToken: `, whitelistWeth); + + // USDC is also not verified on the rinkeby explorer so we should approve it to be spent by the spoke pool. + const ERC20 = await getContractFactory("ExpandedERC20", { signer }); + const usdc = await ERC20.attach("0x4dbcdf9b62e891a7cec5a2568c3f4faf9e8abe2b"); + const deployedHubPool = await hre.deployments.get("HubPool"); + const approval = await usdc.approve(deployedHubPool.address, consts.maxUint256); + console.log(`Approved USDC to be spent by HubPool @ ${deployedHubPool.address}: `, approval.hash); } main().then( diff --git a/test/chain-adapters/Arbitrum_Adapter.ts b/test/chain-adapters/Arbitrum_Adapter.ts index 3b3ed9a60..bc20e5b00 100644 --- a/test/chain-adapters/Arbitrum_Adapter.ts +++ b/test/chain-adapters/Arbitrum_Adapter.ts @@ -1,16 +1,26 @@ import * as consts from "../constants"; -import { ethers, expect, Contract, FakeContract, SignerWithAddress, createFake, toWei, hre } from "../utils"; +import { + ethers, + expect, + Contract, + FakeContract, + SignerWithAddress, + createFake, + toWei, + hre, + defaultAbiCoder, + toBN, +} from "../utils"; import { getContractFactory, seedWallet, randomAddress } from "../utils"; import { hubPoolFixture, enableTokensForLP } from "../fixtures/HubPool.Fixture"; import { constructSingleChainTree } from "../MerkleLib.utils"; let hubPool: Contract, arbitrumAdapter: Contract, weth: Contract, dai: Contract, timer: Contract, mockSpoke: Contract; -let l2Weth: string, l2Dai: string; +let l2Weth: string, l2Dai: string, gatewayAddress: string; let owner: SignerWithAddress, dataWorker: SignerWithAddress, liquidityProvider: SignerWithAddress; -let l1ERC20Gateway: FakeContract, l1Inbox: FakeContract; +let l1ERC20GatewayRouter: FakeContract, l1Inbox: FakeContract; const arbitrumChainId = 42161; -let l1ChainId: number; describe("Arbitrum Chain Adapter", function () { beforeEach(async function () { @@ -28,12 +38,13 @@ describe("Arbitrum Chain Adapter", function () { await dai.connect(dataWorker).approve(hubPool.address, consts.bondAmount.mul(10)); l1Inbox = await createFake("Inbox"); - l1ERC20Gateway = await createFake("TokenGateway"); - l1ChainId = Number(await hre.getChainId()); + l1ERC20GatewayRouter = await createFake("ArbitrumMockErc20GatewayRouter"); + gatewayAddress = randomAddress(); + l1ERC20GatewayRouter.getGateway.returns(gatewayAddress); arbitrumAdapter = await ( await getContractFactory("Arbitrum_Adapter", owner) - ).deploy(l1Inbox.address, l1ERC20Gateway.address); + ).deploy(l1Inbox.address, l1ERC20GatewayRouter.address); // Seed the HubPool some funds so it can send L1->L2 messages. await hubPool.connect(liquidityProvider).loadEthForL2Calls({ value: toWei("1") }); @@ -48,9 +59,10 @@ describe("Arbitrum Chain Adapter", function () { const newAdmin = randomAddress(); const functionCallData = mockSpoke.interface.encodeFunctionData("setCrossDomainAdmin", [newAdmin]); - expect(await hubPool.relaySpokePoolAdminFunction(arbitrumChainId, functionCallData)) - .to.emit(arbitrumAdapter.attach(hubPool.address), "MessageRelayed") - .withArgs(mockSpoke.address, functionCallData); + expect(await hubPool.relaySpokePoolAdminFunction(arbitrumChainId, functionCallData)).to.changeEtherBalances( + [l1Inbox], + [toBN(consts.sampleL2MaxSubmissionCost).add(toBN(consts.sampleL2Gas).mul(consts.sampleL2GasPrice))] + ); expect(l1Inbox.createRetryableTicket).to.have.been.calledOnce; expect(l1Inbox.createRetryableTicket).to.have.been.calledWith( mockSpoke.address, @@ -71,16 +83,27 @@ describe("Arbitrum Chain Adapter", function () { .connect(dataWorker) .proposeRootBundle([3117], 1, tree.getHexRoot(), consts.mockRelayerRefundRoot, consts.mockSlowRelayRoot); await timer.setCurrentTime(Number(await timer.getCurrentTime()) + consts.refundProposalLiveness + 1); - await hubPool.connect(dataWorker).executeRootBundle(...Object.values(leaves[0]), tree.getHexProof(leaves[0])); + expect( + await hubPool.connect(dataWorker).executeRootBundle(...Object.values(leaves[0]), tree.getHexProof(leaves[0])) + ).to.changeEtherBalances( + [l1ERC20GatewayRouter], + [toBN(consts.sampleL2MaxSubmissionCost).add(toBN(consts.sampleL2Gas).mul(consts.sampleL2GasPrice))] + ); + // The correct functions should have been called on the arbitrum contracts. - expect(l1ERC20Gateway.outboundTransfer).to.have.been.calledOnce; // One token transfer over the canonical bridge. - expect(l1ERC20Gateway.outboundTransfer).to.have.been.calledWith( + expect(l1ERC20GatewayRouter.outboundTransfer).to.have.been.calledOnce; // One token transfer over the canonical bridge. + + // Adapter should have approved gateway to spend its ERC20. + expect(await dai.allowance(hubPool.address, gatewayAddress)).to.equal(tokensSendToL2); + + const message = defaultAbiCoder.encode(["uint256", "bytes"], [consts.sampleL2MaxSubmissionCost, "0x"]); + expect(l1ERC20GatewayRouter.outboundTransfer).to.have.been.calledWith( dai.address, mockSpoke.address, tokensSendToL2, consts.sampleL2Gas, consts.sampleL2GasPrice, - "0x" + message ); expect(l1Inbox.createRetryableTicket).to.have.been.calledOnce; // only 1 L1->L2 message sent. expect(l1Inbox.createRetryableTicket).to.have.been.calledWith( diff --git a/test/chain-adapters/Optimism_Adapter.ts b/test/chain-adapters/Optimism_Adapter.ts index 94de74984..0a4b689b2 100644 --- a/test/chain-adapters/Optimism_Adapter.ts +++ b/test/chain-adapters/Optimism_Adapter.ts @@ -16,13 +16,11 @@ let owner: SignerWithAddress, dataWorker: SignerWithAddress, liquidityProvider: let l1CrossDomainMessenger: FakeContract, l1StandardBridge: FakeContract; const optimismChainId = 10; -let l1ChainId: number; describe("Optimism Chain Adapter", function () { beforeEach(async function () { [owner, dataWorker, liquidityProvider] = await ethers.getSigners(); ({ weth, dai, l2Weth, l2Dai, hubPool, mockSpoke, timer, mockAdapter } = await hubPoolFixture()); - l1ChainId = Number(await hre.getChainId()); await seedWallet(dataWorker, [dai], weth, amountToLp); await seedWallet(liquidityProvider, [dai], weth, amountToLp.mul(10)); diff --git a/test/constants.ts b/test/constants.ts index bd4681948..dcd098b40 100644 --- a/test/constants.ts +++ b/test/constants.ts @@ -74,8 +74,9 @@ export const amountToReturn = toWei("1"); export const mockTreeRoot = createRandomBytes32(); -export const sampleL2Gas = 5000000; +// Following should match variables set in Arbitrum_Adapter +export const sampleL2Gas = 2000000; -export const sampleL2MaxSubmissionCost = toWei("0.1"); +export const sampleL2MaxSubmissionCost = toWei("0.01"); -export const sampleL2GasPrice = 10e9; // 10 gWei +export const sampleL2GasPrice = 5e9; diff --git a/test/utils.ts b/test/utils.ts index c58b26bec..f940834e7 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -104,8 +104,14 @@ export function getAllFilesInPath(dirPath: string, arrayOfFiles: string[] = []): export const toWei = (num: string | number | BigNumber) => ethers.utils.parseEther(num.toString()); +export const toWeiWithDecimals = (num: string | number | BigNumber, decimals: number) => + ethers.utils.parseUnits(num.toString(), decimals); + export const toBNWei = (num: string | number | BigNumber) => BigNumber.from(toWei(num)); +export const toBNWeiWithDecimals = (num: string | number | BigNumber, decimals: number) => + BigNumber.from(toWeiWithDecimals(num, decimals)); + export const fromWei = (num: string | number | BigNumber) => ethers.utils.formatUnits(num.toString()); export const toBN = (num: string | number | BigNumber) => {