diff --git a/deploy/L2UpdateOFTRateLimitsSpell.sol b/deploy/L2UpdateOFTRateLimitsSpell.sol new file mode 100644 index 0000000..3a75ff2 --- /dev/null +++ b/deploy/L2UpdateOFTRateLimitsSpell.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.22; + +struct RateLimitConfig { + uint32 eid; + uint48 window; + uint256 limit; +} + +interface OFTAdapterLike { + function setRateLimits(RateLimitConfig[] calldata inbound, RateLimitConfig[] calldata outbound) external; + function peers(uint32 eid) external view returns (bytes32); +} + +/** + * @title L2UpdateOFTRateLimitsSpell + * @notice L2 spell for updating the rate limits of an OFT adapter. + * Deployed once per L2, and it will be delegate called by L2GovernanceRelay when bridged from core spell. + */ +contract L2UpdateOFTRateLimitsSpell { + + function execute( + uint32 dstEid, + address oftAdapter, + uint48 inboundWindow, + uint256 inboundLimit, + uint48 outboundWindow, + uint256 outboundLimit + ) external { + OFTAdapterLike oft = OFTAdapterLike(oftAdapter); + + // Sanity check + require(oft.peers(dstEid) != bytes32(0), "LZUpdateRateLimits/no-peer-set-for-dstEid"); + + RateLimitConfig[] memory inboundCfg = new RateLimitConfig[](1); + RateLimitConfig[] memory outboundCfg = new RateLimitConfig[](1); + + inboundCfg[0] = RateLimitConfig(dstEid, inboundWindow, inboundLimit); + outboundCfg[0] = RateLimitConfig(dstEid, outboundWindow, outboundLimit); + oft.setRateLimits(inboundCfg, outboundCfg); + } +} diff --git a/deploy/LZHelpers.sol b/deploy/LZHelpers.sol new file mode 100644 index 0000000..80e90e7 --- /dev/null +++ b/deploy/LZHelpers.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.22; + +interface ChainlogLike { + function getAddress(bytes32) external view returns (address); +} + +struct TxParams { + uint32 dstEid; + bytes32 dstTarget; + bytes dstCallData; + bytes extraOptions; +} + +struct MessagingFee { + uint256 nativeFee; + uint256 lzTokenFee; +} + +interface GovOAppSenderLike { + function quoteTx(TxParams calldata params, bool payInLzToken) external view returns (MessagingFee memory); +} + +interface L2GovernanceRelayLike { + function relay(address target, bytes calldata data) external; +} + +interface L2UpdateOFTRateLimitsSpellLike { + function execute( + uint32 dstEid, + address oftAdapter, + uint48 inboundWindow, + uint256 inboundLimit, + uint48 outboundWindow, + uint256 outboundLimit + ) external; +} + +interface L1GovernanceRelayLike { + function relayEVM( + uint32 dstEid, + address l2GovernanceRelay, + address target, + bytes calldata targetData, + bytes calldata extraOptions, + MessagingFee calldata fee, + address refundAddress + ) external payable; +} + +library LZHelpers { + + ChainlogLike internal constant chainlog = ChainlogLike(0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F); + + function relayUpdateOFTRateLimits( + uint32 dstEid, + address l2GovernanceRelay, + address l2Spell, + uint128 l2GasAmount, + address oftAdapter, + uint32 rateLimitDstEid, + uint48 inboundWindow, + uint256 inboundLimit, + uint48 outboundWindow, + uint256 outboundLimit + ) internal { + require(l2GasAmount > 0, "RelayUpdateOFTRateLimits/L2-gas-amount-0"); + + bytes memory spellData = abi.encodeCall( + L2UpdateOFTRateLimitsSpellLike.execute, + (rateLimitDstEid, oftAdapter, inboundWindow, inboundLimit, outboundWindow, outboundLimit) + ); + + // Equivalent to OptionsBuilder.newOptions().addExecutorLzReceiveOption(optionsGas, 0) + // source: https://github.com/LayerZero-Labs/LayerZero-v2/blob/9c741e7f9790639537b1710a203bcdfd73b0b9ac/packages/layerzero-v2/evm/oapp/contracts/oapp/libs/OptionsBuilder.sol#L139-L145 + bytes memory extraOptions = abi.encodePacked( + hex"0003", // OPTIONS_TYPE_3 + uint8(1), // WORKER_ID (executor) + uint16(16 + 1), // uint128 gas amount (16 bytes) + uint8 option type (1 byte) + uint8(1), // OPTION_TYPE_LZRECEIVE + l2GasAmount + ); + + TxParams memory txParams = TxParams({ + dstEid: dstEid, + dstTarget: bytes32(uint256(uint160(address(l2GovernanceRelay)))), + dstCallData: abi.encodeCall(L2GovernanceRelayLike.relay, (address(l2Spell), spellData)), + extraOptions: extraOptions + }); + + MessagingFee memory fee = GovOAppSenderLike(chainlog.getAddress("LZ_GOV_SENDER")).quoteTx(txParams, false); + + require(address(this).balance >= fee.nativeFee, "RelayUpdateOFTRateLimits/Insufficient-ETH"); + L1GovernanceRelayLike(chainlog.getAddress("LZ_GOV_RELAY")).relayEVM{value: fee.nativeFee}( + dstEid, + l2GovernanceRelay, + address(l2Spell), + spellData, + extraOptions, + fee, + address(this) + ); + } +} diff --git a/foundry.toml b/foundry.toml index 0ae0c6f..9500550 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,12 +2,16 @@ src = "deploy" out = "out" libs = ["lib"] -via_ir = true +# via_ir = true +optimizer = false +optimizer_runs = 200 + remappings = [ 'xchain-helpers/=lib/xchain-helpers/src/', '@layerzerolabs/lz-evm-protocol-v2/=lib/xchain-helpers/lib/LayerZero-v2/packages/layerzero-v2/evm/protocol/', '@layerzerolabs/lz-evm-messagelib-v2/=lib/xchain-helpers/lib/LayerZero-v2/packages/layerzero-v2/evm/messagelib/', - 'layerzerolabs/oapp-evm/=lib/xchain-helpers/lib/devtools/packages/oapp-evm/', + '@layerzerolabs/oapp-evm/=lib/xchain-helpers/lib/devtools/packages/oapp-evm/', + '@layerzerolabs/script-devtools-evm-foundry/=lib/xchain-helpers/lib/devtools/packages/script-devtools-evm-foundry/', '@layerzerolabs/oft-evm/=lib/xchain-helpers/lib/devtools/packages/oft-evm/', '@openzeppelin/contracts/=lib/xchain-helpers/lib/openzeppelin-contracts/contracts/', 'openzeppelin-contracts/contracts/=lib/xchain-helpers/lib/openzeppelin-contracts/contracts/', diff --git a/script/SkyLinkGasProfiler.s.sol b/script/SkyLinkGasProfiler.s.sol new file mode 100644 index 0000000..d943e4f --- /dev/null +++ b/script/SkyLinkGasProfiler.s.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; +import { L2UpdateOFTRateLimitsSpell } from "deploy/L2UpdateOFTRateLimitsSpell.sol"; + +import { GasProfilerScript, TestParams } from "@layerzerolabs/script-devtools-evm-foundry/scripts/GasProfiling/GasProfiler.s.sol"; + +contract SkyLinkGasProfiler is GasProfilerScript { + + uint32 constant ETH_EID = 30101; + bytes32 constant ETH_GOV_SENDER = bytes32(uint256(uint160(0x27FC1DD771817b53bE48Dc28789533BEa53C9CCA))); + + GasProfilerScript public gasProfiler = new GasProfilerScript(); + + /// @notice Profile rate limits update spell lzReceive gas + /// @dev Run with: REMOTE_RPC_URL= forge script script/SkyLinkGasProfiler.s.sol:SkyLinkGasProfiler --sig "run_rate_limits_update_spell(uint32,address,address,address)" + function run_rate_limits_update_spell(uint32 dstEid, address receiver, address remoteEndpoint, address usdsOftAddress) external { + bytes[] memory payloads = new bytes[](1); + + // payload for calling rate limit spell + payloads[0] = abi.encodeCall( + L2UpdateOFTRateLimitsSpell.execute, + (ETH_EID, usdsOftAddress, 24 hours, 50_000_000, 24 hours, 50_000_000) + ); + + TestParams memory params = TestParams({ + srcEid: ETH_EID, + sender: ETH_GOV_SENDER, + dstEid: dstEid, + receiver: receiver, + payloads: payloads, + msgValue: 0, + numOfRuns: 5 + }); + + gasProfiler.run_lzReceive(_getRemoteRpcUrl(), remoteEndpoint, params); + } + + function _getRemoteRpcUrl() private view returns (string memory) { + return vm.envString("REMOTE_RPC_URL"); + } +} diff --git a/test/L2UpdateOFTRateLimitsSpell.t.sol b/test/L2UpdateOFTRateLimitsSpell.t.sol new file mode 100644 index 0000000..09cfafb --- /dev/null +++ b/test/L2UpdateOFTRateLimitsSpell.t.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.22; + +import "forge-std/Test.sol"; + +import { L2UpdateOFTRateLimitsSpell } from "deploy/L2UpdateOFTRateLimitsSpell.sol"; +import { LZHelpers, ChainlogLike } from "deploy/LZHelpers.sol"; + +import { Bridge } from "xchain-helpers/testing/Bridge.sol"; +import { Domain, DomainHelpers } from "xchain-helpers/testing/Domain.sol"; +import { LZBridgeTesting } from "xchain-helpers/testing/bridges/LZBridgeTesting.sol"; + +interface OAppReadLike { + function peers(uint32 eid) external view returns (bytes32); + function owner() external view returns (address); +} + +interface OFTReadLike is OAppReadLike { + function outboundRateLimits(uint32 eid) external view returns (uint128 lastUpdated, uint48 window, uint256 amountInFlight, uint256 limit); + function inboundRateLimits(uint32 eid) external view returns (uint128 lastUpdated, uint48 window, uint256 amountInFlight, uint256 limit); +} + +/*** Test contract ***/ +contract LZUpdateRateLimitsTest is Test { + + using DomainHelpers for *; + using LZBridgeTesting for *; + + ChainlogLike constant chainlog = ChainlogLike(0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F); + + uint48 inboundWindow = 24 hours; + uint256 inboundLimit = 50_000_000 ether; + uint48 outboundWindow = 24 hours; + uint256 outboundLimit = 50_000_000 ether; + + address MCD_PAUSE_PROXY; + address LZ_GOV_SENDER; + address LZ_GOV_RELAY; + address USDS_OFT; + + address govOAppReceiver; + address l2GovRelay; + address usdsMintBurn; + L2UpdateOFTRateLimitsSpell l2Spell; + + uint32 constant ETHEREUM_EID = 30101; + uint32 constant AVALANCHE_EID = 30106; + + Domain mainnet; + Domain avalanche; + Bridge bridge; + + function setUp() public { + // Set up the bridge (pinned to the block after ethereum <> avalanche wire is done from spell) + mainnet = getChain("mainnet").createSelectFork(24871364); + avalanche = getChain("avalanche").createFork(82186129); + bridge = mainnet.createLZBridge(avalanche); + + mainnet.selectFork(); + + MCD_PAUSE_PROXY = chainlog.getAddress("MCD_PAUSE_PROXY"); + LZ_GOV_SENDER = chainlog.getAddress("LZ_GOV_SENDER"); + LZ_GOV_RELAY = chainlog.getAddress("LZ_GOV_RELAY"); + USDS_OFT = chainlog.getAddress("USDS_OFT"); + + // Set up remote addresses + govOAppReceiver = bytes32ToAddress(OAppReadLike(LZ_GOV_SENDER).peers(AVALANCHE_EID)); + usdsMintBurn = bytes32ToAddress(OFTReadLike(USDS_OFT).peers(AVALANCHE_EID)); + + avalanche.selectFork(); + // Deploy the L2 spell + l2Spell = new L2UpdateOFTRateLimitsSpell(); + + // Set up remote addresses from avalanche + l2GovRelay = OAppReadLike(govOAppReceiver).owner(); + + mainnet.selectFork(); + } + + function test_setRateLimits() public { + // Donate some ETH to pay LZ fee + vm.deal(address(MCD_PAUSE_PROXY), 0.00003 ether); + + // Check state before relay + avalanche.selectFork(); + ( , uint48 inWindowBefore, , uint256 inLimitBefore) = OFTReadLike(usdsMintBurn).inboundRateLimits(ETHEREUM_EID); + ( , uint48 outWindowBefore, , uint256 outLimitBefore) = OFTReadLike(usdsMintBurn).outboundRateLimits(ETHEREUM_EID); + assertNotEq(inLimitBefore, inboundLimit); + assertNotEq(outLimitBefore, outboundLimit); + // Check rateLimits window values are not already set + assertEq(inWindowBefore, inboundWindow); + assertEq(outWindowBefore, outboundWindow); + + // Execute core spell and relay messages + mainnet.selectFork(); + vm.startPrank(MCD_PAUSE_PROXY); + LZHelpers.relayUpdateOFTRateLimits( + AVALANCHE_EID, + l2GovRelay, + address(l2Spell), + uint128(100_000), + usdsMintBurn, + ETHEREUM_EID, + inboundWindow, + inboundLimit, + outboundWindow, + outboundLimit + ); + vm.stopPrank(); + bridge.relayMessagesToDestination(true, LZ_GOV_SENDER, address(govOAppReceiver)); + + // Check updated rate limits on the OFT adapter + ( , uint48 inWindow, , uint256 inLimit) = OFTReadLike(usdsMintBurn).inboundRateLimits(ETHEREUM_EID); + ( , uint48 outWindow, , uint256 outLimit) = OFTReadLike(usdsMintBurn).outboundRateLimits(ETHEREUM_EID); + assertEq(inWindow, inboundWindow); + assertEq(inLimit, inboundLimit); + assertEq(outWindow, outboundWindow); + assertEq(outLimit, outboundLimit); + } + + function test_setRateLimits_revertsIfNoPeerSet() public { + uint32 baseEid = 30184; + // Switch to Avalanche and test the spell directly + avalanche.selectFork(); + + // Pre-condition: peer is not set + assertEq(OFTReadLike(usdsMintBurn).peers(baseEid), bytes32(0), "peer must not be set"); + + // Expect the spell to revert when peer is not set + vm.expectRevert("LZUpdateRateLimits/no-peer-set-for-dstEid"); + vm.prank(l2GovRelay); + l2Spell.execute(baseEid, usdsMintBurn, inboundWindow, inboundLimit, outboundWindow, outboundLimit); + } + + function bytes32ToAddress(bytes32 b) internal pure returns (address) { + return address(uint160(uint256(b))); + } +}