From 8265c68292604aa4d0e153f6f5733c98dec16613 Mon Sep 17 00:00:00 2001 From: shotaro <10378902+shotaronowhere@users.noreply.github.com> Date: Wed, 21 Feb 2024 21:39:07 -0700 Subject: [PATCH] feat: immutable gas refunder --- src/bridge/GasRefunderOpt.sol | 146 ++++++++++++++++++ .../sequencerInboxForceInclude.spec.ts | 133 ++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/bridge/GasRefunderOpt.sol diff --git a/src/bridge/GasRefunderOpt.sol b/src/bridge/GasRefunderOpt.sol new file mode 100644 index 00000000..564388cf --- /dev/null +++ b/src/bridge/GasRefunderOpt.sol @@ -0,0 +1,146 @@ +// Copyright 2023-2024, Offchain Labs, Inc. +// For license information, see https://github.com/OffchainLabs/nitro-contracts/blob/main/LICENSE +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.7; + +import "../libraries/IGasRefunder.sol"; + +import "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @notice Optimized version of GasRefunder intended especially for use by sequencer inbox batch posters. + * @dev This contract allows any refundee as long as the caller is the allowedContract. + */ +contract GasRefunderOpt is IGasRefunder, Ownable { + address public immutable allowedContract; + uint256 public immutable maxRefundeeBalance; + uint256 public immutable extraGasMargin; + uint256 public immutable calldataCost; + uint256 public immutable maxGasTip; + uint256 public immutable maxGasCost; + uint256 public immutable maxSingleGasUsage; + + enum RefundDenyReason { + CONTRACT_NOT_ALLOWED, + REFUNDEE_NOT_ALLOWED, + REFUNDEE_ABOVE_MAX_BALANCE, + OUT_OF_FUNDS + } + + event SuccessfulRefundedGasCosts(uint256 gas, uint256 gasPrice, uint256 amountPaid); + + event FailedRefundedGasCosts(uint256 gas, uint256 gasPrice, uint256 amountPaid); + + event RefundGasCostsDenied( + address indexed refundee, + address indexed contractAddress, + RefundDenyReason indexed reason, + uint256 gas + ); + event Deposited(address sender, uint256 amount); + event Withdrawn(address initiator, address destination, uint256 amount); + + constructor( + address _allowedContract, + uint256 _maxRefundeeBalance, + uint256 _extraGasMargin, + uint256 _calldataCost, + uint256 _maxGasTip, + uint256 _maxGasCost, + uint256 _maxSingleGasUsage + ) Ownable() { + allowedContract = _allowedContract; + maxRefundeeBalance = _maxRefundeeBalance; + extraGasMargin = _extraGasMargin; + calldataCost = _calldataCost; + maxGasTip = _maxGasTip; + maxGasCost = _maxGasCost; + maxSingleGasUsage = _maxSingleGasUsage; + } + + receive() external payable { + emit Deposited(msg.sender, msg.value); + } + + function withdraw(address payable destination, uint256 amount) external onlyOwner { + // It's expected that destination is an EOA + (bool success, ) = destination.call{value: amount}(""); + require(success, "WITHDRAW_FAILED"); + emit Withdrawn(msg.sender, destination, amount); + } + + function onGasSpent( + address payable refundee, + uint256 gasUsed, + uint256 calldataSize + ) external override returns (bool success) { + uint256 startGasLeft = gasleft(); + + uint256 ownBalance = address(this).balance; + + if (ownBalance == 0) { + emit RefundGasCostsDenied(refundee, msg.sender, RefundDenyReason.OUT_OF_FUNDS, gasUsed); + return false; + } + + if (allowedContract != msg.sender) { + emit RefundGasCostsDenied( + refundee, + msg.sender, + RefundDenyReason.CONTRACT_NOT_ALLOWED, + gasUsed + ); + return false; + } + + uint256 estGasPrice = block.basefee + maxGasTip; + if (tx.gasprice < estGasPrice) { + estGasPrice = tx.gasprice; + } + if (maxGasCost != 0 && estGasPrice > maxGasCost) { + estGasPrice = maxGasCost; + } + + uint256 refundeeBalance = refundee.balance; + + // Add in a bit of a buffer for the tx costs not measured with gasleft + gasUsed += startGasLeft + extraGasMargin + (calldataSize * calldataCost); + // Split this up into two statements so that gasleft() comes after the extra arithmetic + gasUsed -= gasleft(); + + if (maxSingleGasUsage != 0 && gasUsed > maxSingleGasUsage) { + gasUsed = maxSingleGasUsage; + } + + uint256 refundAmount = estGasPrice * gasUsed; + if (maxRefundeeBalance != 0 && refundeeBalance + refundAmount > maxRefundeeBalance) { + if (refundeeBalance > maxRefundeeBalance) { + // The refundee is already above their max balance + // emit RefundGasCostsDenied( + // refundee, + // msg.sender, + // RefundDenyReason.REFUNDEE_ABOVE_MAX_BALANCE, + // gasUsed + // ); + return false; + } else { + refundAmount = maxRefundeeBalance - refundeeBalance; + } + } + + if (refundAmount > ownBalance) { + refundAmount = ownBalance; + } + + // It's expected that refundee is an EOA + // solhint-disable-next-line avoid-low-level-calls + (success, ) = refundee.call{value: refundAmount}(""); + + if (success) { + emit SuccessfulRefundedGasCosts(gasUsed, estGasPrice, refundAmount); + } else { + emit FailedRefundedGasCosts(gasUsed, estGasPrice, refundAmount); + } + } +} diff --git a/test/contract/sequencerInboxForceInclude.spec.ts b/test/contract/sequencerInboxForceInclude.spec.ts index fcaf2055..2e420b6d 100644 --- a/test/contract/sequencerInboxForceInclude.spec.ts +++ b/test/contract/sequencerInboxForceInclude.spec.ts @@ -23,6 +23,8 @@ import { expect } from 'chai' import { Bridge, Bridge__factory, + GasRefunder__factory, + GasRefunderOpt__factory, Inbox, Inbox__factory, MessageTester, @@ -246,6 +248,13 @@ describe('SequencerInboxForceInclude', async () => { const bridgeFac = (await ethers.getContractFactory( 'Bridge' )) as Bridge__factory + const gasRefunderFac = (await ethers.getContractFactory( + 'GasRefunder' + )) as GasRefunder__factory + const GasRefunderOptFac = (await ethers.getContractFactory( + 'GasRefunderOpt' + )) as GasRefunderOpt__factory + const bridgeTemplate = await bridgeFac.deploy() const transparentUpgradeableProxyFac = (await ethers.getContractFactory( 'TransparentUpgradeableProxy' @@ -266,6 +275,7 @@ describe('SequencerInboxForceInclude', async () => { adminAddr, '0x' ) + const bridge = await bridgeFac.attach(bridgeProxy.address).connect(user) const bridgeAdmin = await bridgeFac .attach(bridgeProxy.address) @@ -295,6 +305,35 @@ describe('SequencerInboxForceInclude', async () => { await bridgeAdmin.setDelayedInbox(inbox.address, true) await bridgeAdmin.setSequencerInbox(sequencerInbox.address) + const gasRefunder = await gasRefunderFac.deploy() + await gasRefunder.deployed() + await (await gasRefunder.allowContracts([sequencerInbox.address])).wait() + await ( + await gasRefunder.allowRefundees([await batchPoster.getAddress()]) + ).wait() + await (await gasRefunder.setExtraGasMargin(35000)).wait() + + const GasRefunderOpt = await GasRefunderOptFac.deploy( + sequencerInbox.address, + 0, // no limit + 35000, // extra gas margin + 16, // gas per calldata byte + 2e9, // maxGasTip 2 gwei + 120e9, // maxGasCost 120 gwei + 2e6 // maxSingleGasUsage 2 million gas + ) + await GasRefunderOpt.deployed() + + await accounts[0].sendTransaction({ + value: ethers.utils.parseEther('10000.0'), + to: gasRefunder.address, + }) + + await accounts[0].sendTransaction({ + value: ethers.utils.parseEther('10000.0'), + to: GasRefunderOpt.address, + }) + await ( await sequencerInbox .connect(rollupOwner) @@ -317,6 +356,8 @@ describe('SequencerInboxForceInclude', async () => { bridgeProxy, rollup, rollupOwner, + GasRefunderOpt, + gasRefunder, } } @@ -357,6 +398,98 @@ describe('SequencerInboxForceInclude', async () => { ).wait() }) + it('can add batch and refund gas', async () => { + const { + user, + inbox, + bridge, + messageTester, + sequencerInbox, + batchPoster, + gasRefunder, + } = await setupSequencerInbox() + + await sendDelayedTx( + user, + inbox, + bridge, + messageTester, + 1000000, + 21000000000, + 0, + await user.getAddress(), + BigNumber.from(10), + '0x1010' + ) + const messagesRead = await bridge.delayedMessageCount() + const seqReportedMessageSubCount = + await bridge.sequencerReportedSubMessageCount() + const balBefore = await batchPoster.getBalance() + const txn = await ( + await sequencerInbox + .connect(batchPoster) + .functions[ + 'addSequencerL2BatchFromOrigin(uint256,bytes,uint256,address,uint256,uint256)' + ]( + 0, + data, + messagesRead, + gasRefunder.address, + seqReportedMessageSubCount, + seqReportedMessageSubCount.add(10), + { gasLimit: 10000000 } + ) + ).wait() + // console.log('Mutable Gas Refunder', txn.gasUsed.toNumber()) + expect((await batchPoster.getBalance()).gt(balBefore), 'Refund not enough') + }) + + it('can add batch and refund gas', async () => { + const { + user, + inbox, + bridge, + messageTester, + sequencerInbox, + batchPoster, + GasRefunderOpt, + } = await setupSequencerInbox() + + await sendDelayedTx( + user, + inbox, + bridge, + messageTester, + 1000000, + 21000000000, + 0, + await user.getAddress(), + BigNumber.from(10), + '0x1010' + ) + const messagesRead = await bridge.delayedMessageCount() + const seqReportedMessageSubCount = + await bridge.sequencerReportedSubMessageCount() + const balBefore = await batchPoster.getBalance() + const txn = await ( + await sequencerInbox + .connect(batchPoster) + .functions[ + 'addSequencerL2BatchFromOrigin(uint256,bytes,uint256,address,uint256,uint256)' + ]( + 0, + data, + messagesRead, + GasRefunderOpt.address, + seqReportedMessageSubCount, + seqReportedMessageSubCount.add(10), + { gasLimit: 10000000 } + ) + ).wait() + // console.log('Sequencer Gas Refunder', txn.gasUsed.toNumber()) + expect((await batchPoster.getBalance()).gt(balBefore), 'Refund not enough') + }) + it('can force-include', async () => { const { user, inbox, bridge, messageTester, sequencerInbox } = await setupSequencerInbox()