Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6657bb2
beefy client wrapper
claravanstaden Jan 7, 2026
6bd9ca8
relayer changes
claravanstaden Jan 7, 2026
919f503
remove receive impl
claravanstaden Jan 8, 2026
5828412
refund gas at end of interactive protocol
claravanstaden Jan 8, 2026
e43ab5f
set max refund amount
claravanstaden Jan 8, 2026
39bf3fc
add block tips
claravanstaden Jan 8, 2026
c7da3c1
changes
claravanstaden Jan 14, 2026
70b59ff
Merge branch 'main' into clara/beefy-wrapper-contract
claravanstaden Feb 4, 2026
1d3e81d
simplify
claravanstaden Feb 4, 2026
7c94d09
cleanup
claravanstaden Feb 4, 2026
9b8fd88
remove proxy
claravanstaden Feb 4, 2026
d1f0576
add fiat shamir back
claravanstaden Feb 4, 2026
6228a56
revert fiat shamir
claravanstaden Feb 4, 2026
f9b4923
relayer tweaks
claravanstaden Feb 4, 2026
e919c32
use beefy client directly
claravanstaden Feb 5, 2026
70ac4b0
more tests
claravanstaden Feb 5, 2026
e3112a2
more tests and update outdated comment
claravanstaden Feb 5, 2026
f086bcf
add removed fiat shamir back
claravanstaden Feb 5, 2026
e81b1f9
testing fixes
claravanstaden Feb 6, 2026
f7b5746
tweaks and more tests
claravanstaden Feb 6, 2026
7128a44
cleanup
claravanstaden Feb 6, 2026
d650ff2
read beefy contract address from gateway
claravanstaden Feb 6, 2026
7605eac
use gas cost at tx time
claravanstaden Feb 6, 2026
e3df622
remove unnecessary method
claravanstaden Feb 10, 2026
ae6dc9e
refund fiat shamir
claravanstaden Feb 10, 2026
ed51ff9
remove unnecessary events
claravanstaden Feb 10, 2026
e89647e
make permission-less contract
claravanstaden Feb 10, 2026
8b8ec69
refund interactive relayer
claravanstaden Feb 10, 2026
cd32cc8
fix reentrancy
claravanstaden Feb 10, 2026
27b975b
Revert "refund interactive relayer"
claravanstaden Feb 13, 2026
f035883
Merge branch 'main' into clara/beefy-wrapper-contract
claravanstaden Feb 19, 2026
86fc1ea
deplpy script
claravanstaden Feb 19, 2026
94c4e39
tweaks
claravanstaden Feb 23, 2026
52f92e9
remove interface and fix tests
claravanstaden Feb 23, 2026
c98fe64
pending ticket
claravanstaden Feb 23, 2026
e4e89df
fix e2e
claravanstaden Feb 23, 2026
a49579c
session timeout changes
claravanstaden Feb 23, 2026
cc34f24
Merge branch 'main' into clara/beefy-wrapper-contract
claravanstaden Mar 12, 2026
7bc1ec5
sweep and merge damage
claravanstaden Mar 12, 2026
632302e
tweaks
claravanstaden Mar 13, 2026
412a85a
tweaks
claravanstaden Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions contracts/scripts/DeployBeefyClientWrapper.sol
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();
}
}
14 changes: 14 additions & 0 deletions contracts/scripts/DeployLocal.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pragma solidity 0.8.34;
import {WETH9} from "canonical-weth/WETH9.sol";
import {Script} from "forge-std/Script.sol";
import {BeefyClient} from "../src/BeefyClient.sol";
import {BeefyClientWrapper} from "../src/BeefyClientWrapper.sol";
import {IGatewayV1} from "../src/v1/IGateway.sol";
import {GatewayProxy} from "../src/GatewayProxy.sol";
import {Gateway} from "../src/Gateway.sol";
Expand Down Expand Up @@ -80,6 +81,19 @@ contract DeployLocal is Script {

GatewayProxy gateway = new GatewayProxy(address(gatewayLogic), abi.encode(config));

// Deploy BeefyClientWrapper (after GatewayProxy so we can pass its address)
BeefyClientWrapper beefyClientWrapper = new BeefyClientWrapper(
address(gateway),
deployer,
vm.envUint("BEEFY_WRAPPER_MAX_GAS_PRICE"),
vm.envUint("BEEFY_WRAPPER_MAX_REFUND_AMOUNT"),
vm.envUint("BEEFY_WRAPPER_REFUND_TARGET"),
vm.envOr("BEEFY_WRAPPER_TICKET_TIMEOUT", uint256(15 minutes))
);

// Fund wrapper for refunds
payable(address(beefyClientWrapper)).call{value: vm.envUint("BEEFY_WRAPPER_INITIAL_DEPOSIT")}("");

// Deploy WETH for testing
WETH9 weth = new WETH9();

Expand Down
14 changes: 14 additions & 0 deletions contracts/scripts/deploy-beefy-wrapper.sh
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
259 changes: 259 additions & 0 deletions contracts/src/BeefyClientWrapper.sol
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();
Copy link
Copy Markdown
Contributor

@alistair-singh alistair-singh Feb 19, 2026

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.

submitInitial costs like 130k gas, plus storage costs for ticket, credit etc. So I think its quite substantial.

Copy link
Copy Markdown
Contributor

@yrong yrong Feb 24, 2026

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:

if (block.timestamp < ticket.createdAt + ticketTimeout) {
revert TicketAlreadyOwned();
}

it should not incur any additional cost to the relayer in practice.

I’m also curious what refundTarget we 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.


// 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check may not be necessary. As I understand it, refundTarget only impacts the refund process (i.e., whether a refund is enforced), while the relayer can still choose to relay the commitment even without a refund or reward in some scenarios.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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}("");
Copy link
Copy Markdown
Contributor

@alistair-singh alistair-singh Feb 19, 2026

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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:

  • Stateful contract owner (can move funds if we deploy a different contract)
  • Proxy pattern (to upgrade)
  • No owner, but needs to hold funds (because the whole point of this contract is to refund)

I understood we want the latter.

if (success) {
emit SubmissionRefunded(msg.sender, progress, refundAmount);
}
Comment thread
claravanstaden marked this conversation as resolved.
}
}

receive() external payable {}
}
Loading
Loading