-
Notifications
You must be signed in to change notification settings - Fork 152
Beefy client wrapper contract #1668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6657bb2
6bd9ca8
919f503
5828412
e43ab5f
39bf3fc
c7da3c1
70b59ff
1d3e81d
7c94d09
9b8fd88
d1f0576
6228a56
f9b4923
e919c32
70ac4b0
e3112a2
f086bcf
e81b1f9
f7b5746
7128a44
d650ff2
7605eac
e3df622
ae6dc9e
ed51ff9
e89647e
8b8ec69
cd32cc8
27b975b
f035883
86fc1ea
94c4e39
52f92e9
c98fe64
e4e89df
a49579c
cc34f24
7bc1ec5
632302e
412a85a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com> | ||
| pragma solidity 0.8.34; | ||
|
|
||
| import {Script} from "forge-std/Script.sol"; | ||
| import {console} from "forge-std/console.sol"; | ||
| import {BeefyClientWrapper} from "../src/BeefyClientWrapper.sol"; | ||
|
|
||
| contract DeployBeefyClientWrapper is Script { | ||
| struct Config { | ||
| address gateway; | ||
| address sweeper; | ||
| uint256 maxGasPrice; | ||
| uint256 maxRefundAmount; | ||
| uint256 refundTarget; | ||
| uint256 ticketTimeout; | ||
| } | ||
|
|
||
| function readConfig() internal returns (Config memory config) { | ||
| config = Config({ | ||
| gateway: vm.envAddress("GATEWAY_PROXY_ADDRESS"), | ||
| sweeper: vm.envAddress("SWEEPER_ADDRESS"), | ||
| maxGasPrice: vm.envOr("MAX_GAS_PRICE", uint256(100 gwei)), | ||
| maxRefundAmount: vm.envOr("MAX_REFUND_AMOUNT", uint256(0.05 ether)), | ||
| refundTarget: vm.envOr("REFUND_TARGET", uint256(350)), // ~35 min for 100% refund | ||
| ticketTimeout: vm.envOr("TICKET_TIMEOUT", uint256(15 minutes)) | ||
| }); | ||
| } | ||
|
|
||
| function run() public { | ||
| vm.startBroadcast(); | ||
|
|
||
| Config memory config = readConfig(); | ||
|
|
||
| BeefyClientWrapper wrapper = new BeefyClientWrapper( | ||
| config.gateway, | ||
| config.sweeper, | ||
| config.maxGasPrice, | ||
| config.maxRefundAmount, | ||
| config.refundTarget, | ||
| config.ticketTimeout | ||
| ); | ||
|
|
||
| console.log("BeefyClientWrapper:", address(wrapper)); | ||
|
|
||
| vm.stopBroadcast(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| #!/usr/bin/env bash | ||
|
|
||
| set -eux | ||
|
|
||
| forge script scripts/DeployBeefyClientWrapper.sol:DeployBeefyClientWrapper \ | ||
| --chain "${ETH_NETWORK}" \ | ||
| --rpc-url "${ETH_WS_ENDPOINT}" \ | ||
| --private-key "${PRIVATE_KEY}" \ | ||
| --etherscan-api-key "${ETHERSCAN_API_KEY}" \ | ||
| --verifier "etherscan" \ | ||
| --verify \ | ||
| --retries 10 \ | ||
| --broadcast \ | ||
| -vvvvv |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| // SPDX-FileCopyrightText: 2023 Snowfork <hello@snowfork.com> | ||
| pragma solidity 0.8.34; | ||
|
|
||
| import {BeefyClient} from "./BeefyClient.sol"; | ||
|
|
||
| interface IGateway { | ||
| function BEEFY_CLIENT() external view returns (address); | ||
| } | ||
|
|
||
| /** | ||
| * @title BeefyClientWrapper | ||
| * @dev Forwards BeefyClient submissions and refunds gas costs to relayers. | ||
| * Anyone can relay. Refunds are only paid when the relayer advances the light | ||
| * client by at least `refundTarget` blocks, ensuring meaningful progress. | ||
| * | ||
| * The BeefyClient address is resolved dynamically from the Gateway (via GatewayProxy), | ||
| * so after a Gateway upgrade this wrapper automatically points to the new BeefyClient. | ||
| * | ||
| * This contract is permissionless and stateless (aside from in-flight ticket tracking). | ||
| * Configuration is immutable. To change parameters, deploy a new instance. | ||
| */ | ||
| contract BeefyClientWrapper { | ||
| event CostCredited(address indexed relayer, bytes32 indexed commitmentHash, uint256 cost); | ||
| event SubmissionRefunded(address indexed relayer, uint256 progress, uint256 refundAmount); | ||
|
|
||
| event Swept(address indexed to, uint256 amount); | ||
|
|
||
| error InvalidAddress(); | ||
| error NotSweeper(); | ||
| error NotTicketOwner(); | ||
| error TicketAlreadyOwned(); | ||
| error InsufficientProgress(); | ||
| error SweepFailed(); | ||
|
|
||
| struct PendingTicket { | ||
| address owner; | ||
| uint256 creditedCost; | ||
| uint64 createdAt; | ||
| } | ||
|
|
||
| // Base transaction gas cost (intrinsic gas for any Ethereum transaction) | ||
| uint256 private constant BASE_TX_GAS = 21000; | ||
|
|
||
| address public immutable gateway; | ||
| address public immutable sweeper; | ||
|
|
||
| // Ticket tracking (for multi-step submission) | ||
| mapping(bytes32 => PendingTicket) public pendingTickets; | ||
|
|
||
| // Refund configuration (immutable) | ||
| uint256 public immutable maxGasPrice; | ||
| uint256 public immutable maxRefundAmount; | ||
| uint256 public immutable refundTarget; // Blocks of progress for 100% gas refund (e.g., 350 = ~35 min) | ||
| uint256 public immutable ticketTimeout; // Seconds before a pending ticket expires | ||
|
|
||
| // Highest commitment block number currently in progress (helps relayers avoid duplicate work) | ||
| uint256 public highestPendingBlock; | ||
| uint256 public highestPendingBlockTimestamp; | ||
|
|
||
| constructor( | ||
| address _gateway, | ||
| address _sweeper, | ||
| uint256 _maxGasPrice, | ||
| uint256 _maxRefundAmount, | ||
| uint256 _refundTarget, | ||
| uint256 _ticketTimeout | ||
| ) { | ||
| if (_gateway == address(0) || _sweeper == address(0)) { | ||
| revert InvalidAddress(); | ||
| } | ||
|
|
||
| gateway = _gateway; | ||
| sweeper = _sweeper; | ||
| maxGasPrice = _maxGasPrice; | ||
| maxRefundAmount = _maxRefundAmount; | ||
| refundTarget = _refundTarget; | ||
| ticketTimeout = _ticketTimeout; | ||
| } | ||
|
|
||
| /* Beefy Client Proxy Functions */ | ||
|
|
||
| function submitInitial( | ||
| BeefyClient.Commitment calldata commitment, | ||
| uint256[] calldata bitfield, | ||
| BeefyClient.ValidatorProof calldata proof | ||
| ) external { | ||
| uint256 startGas = gasleft(); | ||
|
|
||
| // Revert early if commitment won't make enough progress for a refund | ||
| uint64 latestBeefy = _beefyClient().latestBeefyBlock(); | ||
| if (commitment.blockNumber <= latestBeefy || commitment.blockNumber - latestBeefy < refundTarget) { | ||
| revert InsufficientProgress(); | ||
| } | ||
|
Comment on lines
+92
to
+94
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This check may not be necessary. As I understand it,
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For example, with the current beefy-on-demand relayer, BEEFY commitments are pipelined. A second message may arrive shortly after the first, with the prior consensus update having just been executed. In this case, we would relay it immediately to maintain the 30-minute maximum latency guarantee. Another scenario is when the user opts to cover the cost of the consensus update during message relaying. If the fee bound in the order is sufficient, we should relay the consensus update immediately without delay.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, but as @alistair-singh noted here it prevents relayers submitting unnecessary updates that will not result in a refund: |
||
|
|
||
| // Check if ticket is already owned (prevent race condition between relayers) | ||
| bytes32 commitmentHash = _beefyClient().computeCommitmentHash(commitment); | ||
| PendingTicket storage ticket = pendingTickets[commitmentHash]; | ||
| if (ticket.owner != address(0)) { | ||
| // Allow overwriting only if the ticket has expired | ||
| if (block.timestamp < ticket.createdAt + ticketTimeout) { | ||
| revert TicketAlreadyOwned(); | ||
| } | ||
| // Expired ticket — clear it so a new relayer can take over | ||
| delete pendingTickets[commitmentHash]; | ||
| } | ||
|
|
||
| _beefyClient().submitInitial(commitment, bitfield, proof); | ||
|
|
||
| pendingTickets[commitmentHash] = | ||
| PendingTicket({owner: msg.sender, creditedCost: 0, createdAt: uint64(block.timestamp)}); | ||
|
|
||
| // Track highest pending block so other relayers can check before starting | ||
| if (commitment.blockNumber > highestPendingBlock) { | ||
| highestPendingBlock = commitment.blockNumber; | ||
| highestPendingBlockTimestamp = block.timestamp; | ||
| } | ||
|
|
||
| _creditCost(startGas, commitmentHash); | ||
| } | ||
|
|
||
| function commitPrevRandao(bytes32 commitmentHash) external { | ||
| uint256 startGas = gasleft(); | ||
|
|
||
| if (pendingTickets[commitmentHash].owner != msg.sender) { | ||
| revert NotTicketOwner(); | ||
| } | ||
|
|
||
| _beefyClient().commitPrevRandao(commitmentHash); | ||
|
|
||
| _creditCost(startGas, commitmentHash); | ||
| } | ||
|
|
||
| function submitFinal( | ||
| BeefyClient.Commitment calldata commitment, | ||
| uint256[] calldata bitfield, | ||
| BeefyClient.ValidatorProof[] calldata proofs, | ||
| BeefyClient.MMRLeaf calldata leaf, | ||
| bytes32[] calldata leafProof, | ||
| uint256 leafProofOrder | ||
| ) external { | ||
| uint256 startGas = gasleft(); | ||
|
|
||
| // Capture previous state for progress calculation | ||
| uint64 previousBeefyBlock = _beefyClient().latestBeefyBlock(); | ||
|
|
||
| bytes32 commitmentHash = _beefyClient().computeCommitmentHash(commitment); | ||
| if (pendingTickets[commitmentHash].owner != msg.sender) { | ||
| revert NotTicketOwner(); | ||
| } | ||
|
|
||
| _beefyClient().submitFinal(commitment, bitfield, proofs, leaf, leafProof, leafProofOrder); | ||
|
|
||
| // Calculate progress | ||
| uint256 progress = commitment.blockNumber - previousBeefyBlock; | ||
|
|
||
| // Clear highest pending block if light client has caught up | ||
| if (_beefyClient().latestBeefyBlock() >= highestPendingBlock) { | ||
| highestPendingBlock = 0; | ||
| highestPendingBlockTimestamp = 0; | ||
| } | ||
|
|
||
| uint256 previousCost = pendingTickets[commitmentHash].creditedCost; | ||
| delete pendingTickets[commitmentHash]; | ||
|
|
||
| _refundWithProgress(startGas, previousCost, progress); | ||
| } | ||
|
|
||
| function submitFiatShamir( | ||
| BeefyClient.Commitment calldata commitment, | ||
| uint256[] calldata bitfield, | ||
| BeefyClient.ValidatorProof[] calldata proofs, | ||
| BeefyClient.MMRLeaf calldata leaf, | ||
| bytes32[] calldata leafProof, | ||
| uint256 leafProofOrder | ||
| ) external { | ||
| uint256 startGas = gasleft(); | ||
|
|
||
| // Revert early if commitment won't make enough progress for a refund | ||
| uint64 previousBeefyBlock = _beefyClient().latestBeefyBlock(); | ||
| if (commitment.blockNumber <= previousBeefyBlock || commitment.blockNumber - previousBeefyBlock < refundTarget) { | ||
| revert InsufficientProgress(); | ||
| } | ||
|
|
||
| _beefyClient().submitFiatShamir(commitment, bitfield, proofs, leaf, leafProof, leafProofOrder); | ||
|
|
||
| // Calculate progress | ||
| uint256 progress = commitment.blockNumber - previousBeefyBlock; | ||
|
|
||
| // Clear highest pending block if light client has caught up | ||
| if (_beefyClient().latestBeefyBlock() >= highestPendingBlock) { | ||
| highestPendingBlock = 0; | ||
| highestPendingBlockTimestamp = 0; | ||
| } | ||
|
|
||
| _refundWithProgress(startGas, 0, progress); | ||
| } | ||
|
|
||
| /// @dev Allows the sweeper to withdraw all remaining funds, e.g. when migrating to a new wrapper. | ||
| function sweep(address payable to) external { | ||
| if (msg.sender != sweeper) { | ||
| revert NotSweeper(); | ||
| } | ||
| if (to == address(0)) { | ||
| revert InvalidAddress(); | ||
| } | ||
| uint256 amount = address(this).balance; | ||
| (bool success,) = to.call{value: amount}(""); | ||
| if (!success) { | ||
| revert SweepFailed(); | ||
| } | ||
| emit Swept(to, amount); | ||
| } | ||
|
|
||
| /* Internal Functions */ | ||
|
|
||
| function _beefyClient() internal view returns (BeefyClient) { | ||
| return BeefyClient(IGateway(gateway).BEEFY_CLIENT()); | ||
| } | ||
|
|
||
| function _effectiveGasPrice() internal view returns (uint256) { | ||
| return tx.gasprice < maxGasPrice ? tx.gasprice : maxGasPrice; | ||
| } | ||
|
|
||
| function _creditCost(uint256 startGas, bytes32 commitmentHash) internal { | ||
| uint256 gasUsed = startGas - gasleft() + BASE_TX_GAS; | ||
| uint256 cost = gasUsed * _effectiveGasPrice(); | ||
| pendingTickets[commitmentHash].creditedCost += cost; | ||
| emit CostCredited(msg.sender, commitmentHash, cost); | ||
| } | ||
|
|
||
| /** | ||
| * @dev Calculate and send refund if progress meets threshold. | ||
| * | ||
| * Refund if progress >= refundTarget. | ||
| */ | ||
| function _refundWithProgress(uint256 startGas, uint256 previousCost, uint256 progress) internal { | ||
| if (progress < refundTarget) { | ||
| return; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This means that if another relayer relays a block such that my relayer does not make enough progress, that relayer is "rewarded" with a 100% refund, and my relayer is punished by receiving a 0% refund. It is better the scale the refund by the progress made and refund each relayer accordingly. This way the other relayer is also punished by not receiving the 100% refund.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I moved this to off-chain checking, the relayer checks if there is a session is progress and if their update will be eligible for the refund. If not, they don't start the session. I think this is simpler, and moves the complexity off-chain.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Happy with this being offchain. We should do the same check on submitInitial to avoid race condition and save cost for relayers. |
||
|
|
||
| uint256 currentGas = startGas - gasleft() + BASE_TX_GAS; | ||
| uint256 currentCost = currentGas * _effectiveGasPrice(); | ||
| uint256 refundAmount = previousCost + currentCost; | ||
|
|
||
| if (refundAmount > maxRefundAmount) { | ||
| refundAmount = maxRefundAmount; | ||
| } | ||
|
|
||
| if (refundAmount > 0 && address(this).balance >= refundAmount) { | ||
| (bool success,) = payable(msg.sender).call{value: refundAmount}(""); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is stateful, it means we have to hold funds in this contract which means if we abandon it we also abandon the funds in it. This may be ok though if its a small amount, like if we are only moving $ 100 and we top it up in small increments. But we need to take care because funds in this contract are essentially blackholed. Current prices are like $ 0.06 per submitInitial/submitFinal cycle, so $100 is 166 cycles at current price.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that is intended, I am not really sure what else we can do? The options are:
I understood we want the latter. |
||
| if (success) { | ||
| emit SubmissionRefunded(msg.sender, progress, refundAmount); | ||
| } | ||
|
claravanstaden marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| receive() external payable {} | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we are not payout refunds on clearTickets (abandoned
submitInitials) and we are not scaling refunds based on progress, We should revert here early if its under the minimum block progress. That way in a race condition where two relayers submit, one will fail atleast with minimum cost to itself.submitInitialcosts like 130k gas, plus storage costs for ticket, credit etc. So I think its quite substantial.Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this minimum block progress check might be too strict. To be honest, I’m not too concerned about the relay cost. As mentioned earlier, I assume that with the wrapper enabled we’ll switch to Flashbots RPC, where transactions are only included in a block if they do not revert — meaning relayers do not pay fees for failed transactions.
Since we already have a race-condition prevention check here:
snowbridge/contracts/src/BeefyClientWrapper.sol
Lines 94 to 96 in a49579c
it should not incur any additional cost to the relayer in practice.
I’m also curious what
refundTargetwe plan to set initially for production. My main concern is delivery latency. We’ve committed to a 30-minute delay target, which the beefy-on-demand relayer’s pipelined approach is designed to achieve. Since the two-phase consensus update already takes nearly 30 minutes, I’d prefer not to introduce additional delays by adding this feature.