Skip to content

Comments

Beefy client wrapper contract#1668

Open
claravanstaden wants to merge 34 commits intomainfrom
clara/beefy-wrapper-contract
Open

Beefy client wrapper contract#1668
claravanstaden wants to merge 34 commits intomainfrom
clara/beefy-wrapper-contract

Conversation

@claravanstaden
Copy link
Contributor

@claravanstaden claravanstaden commented Jan 7, 2026

  • Adds an BeefyClientWrapper.sol contract to refund gas for all beefy consensus transactions (SubmitInitial, CommitRandao, SubmitFinal) once a successful SubmitFinal has been submitted.
  • The wrapper contract is optional, i.e. it does not need to be deployed. If it is not configured, the BeefyClient will be used as normal.
  • Relayers check highestPendingBlock in the wrapper contract if a submission would result in a gas refund. If the highestPendingBlock is stale for 40 minutes, submit a new session.

Resolves: SNO-1668

@codecov
Copy link

codecov bot commented Jan 7, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.84%. Comparing base (7ec4f8d) to head (52f92e9).
⚠️ Report is 10 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1668      +/-   ##
==========================================
+ Coverage   81.58%   82.84%   +1.25%     
==========================================
  Files          22       23       +1     
  Lines         994     1067      +73     
  Branches      184      197      +13     
==========================================
+ Hits          811      884      +73     
  Misses        166      166              
  Partials       17       17              
Flag Coverage Δ
solidity 82.84% <100.00%> (+1.25%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@yrong
Copy link
Contributor

yrong commented Jan 8, 2026

Cool. I assume that initially we would top up the wrapper contract ourselves, and that we would only refund the BEEFY relayer for the transaction cost of the consensus update.

We may also want to allow end users to tip for a message that is included in a relay-chain block but has not yet been finalized by BEEFY.

The tip storage could be a map from relayBlockNumber → tipFee. BEEFY relayers would monitor both this tip storage and the Beefy finality updates. Once a BEEFY commitment advances past the corresponding relayBlockNumber—making the message verifiable—they would compete to submit the consensus update and claim the tip.

I assume this would be the main incentive mechanism for the consensus relay: relayers receive additional rewards from end users only when there is a message to relay.

@claravanstaden
Copy link
Contributor Author

We may also want to allow end users to tip for a message that is included in a relay-chain block but has not yet been finalized by BEEFY.

@yrong good idea! Added in 39bf3fc, let me know what you think.

@alistair-singh
Copy link
Contributor

I think this can be done way simpler. You just need a refund target, which is a number in blocks, say 300 blocks (30 minutes). You always refund, but scale it according to how much progress the relayer made. If a relayer chooses to relay every 30 blocks, they will only get 10% of gas returned as refund. If a relayer waits 300 blocks or more, it gets 100% of its gas as refund. There is no round robin, relayers must monitor open tickets and see if they will get a refund based on the pending tickets. If there is a race condition where two relayers compete, this scaled refund makes sure each one gets paid a portion based on progress made. Remember relayers can abandon tickets if there is a race condition where another relayer competes with them.

This same idea can be extended to rewards/tips. You could choose a reward target, which is a number in blocks, say 2400 (4 hours). You refund as per the above logic until 300 block refund target, for every block over, up untill 2400 you scale by amount of blocks progressed and pay a portion of the reward. So if a relayer makes 600 blocks progress, you give them a full refund for meeting the refund target (300/300), and then a 12.5% portion of the reward (300/2400).

@claravanstaden
Copy link
Contributor Author

@alistair-singh this sounds cool, and is simpler. Relayers might frequently lose gas spent on submitInitial, if more than one relayer submits at the same time. What do you think about this? And do we remove the relayer whitelist then?

* @dev Forwards BeefyClient submissions and refunds gas costs to whitelisted relayers.
* Implements soft round-robin scheduling to prevent competition.
*/
contract BeefyClientWrapper is IInitializable, IUpgradable {
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure why we need proxy pattern and upgradable here. We may need it, but we can get away with pointing the wrapper at the Gateway contract instead of the Beefy contract. It can read the current beefy client from the Gateway. This means when we upgrade the Beefy client on the gateway, the wrapper is automatically picks up the new Beefy Instance.

Since this is a wrapper we should also strive to rather throw it away and deploy a new wrapper if we ever have to upgrade it, avoiding forms of governance if possible. We would only really need to migrate any Eth held by the contract. So Proxy Pattern seems to heavy for this requirement.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't have a proxy pattern at first, but because this contract holds state I figured it would be better, esp if we want to change some of the config. I don't have a strong opinion about this either way.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed proxy.

error AlreadyInitialized();
error TicketAlreadyActive();

address public owner;
Copy link
Contributor

Choose a reason for hiding this comment

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

Who is the owner? Does our team own this key?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, could be our SAFE multisig.

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

@claravanstaden claravanstaden Feb 6, 2026

Choose a reason for hiding this comment

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

The tradeoff is that deposited funds in the contract can be never be withdrawn. Is this OK? Say we deposit funds and then discover a bug, we can move the funds to a new contract. Seems less than ideal to me. It does need some state, for example, to capture the gas spent in a session.

Copy link
Contributor

Choose a reason for hiding this comment

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

I’d expect the funds deposited in this contract to be very limited. Given the low gas prices these days, even a small initial deposit can last a long time, so we wouldn’t need to recharge it frequently.

Even if the contract were somehow exploited, we could simply abandon it and deploy a new, fixed version. The state impact would be limited to the ongoing session, which is also acceptable.

Ideally, we’d like to remove all permission-related calls if possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Made contract permissionless: e89647e

@alistair-singh
Copy link
Contributor

alistair-singh commented Jan 8, 2026

@alistair-singh this sounds cool, and is simpler. Relayers might frequently lose gas spent on submitInitial, if more than one relayer submits at the same time. What do you think about this? And do we remove the relayer whitelist then?

I think it might happen occasionally, but we need some system where we can tune the parameters to encourage all the relayers to pipeline consensus updates as opposed to use manually configuring and whitelisting relayers. I dont think it needs to be a perfect system either, we must trust relayers to act in their best interest. At worst, they will abandon the ticket and post another.

This edge case is something that can be addressed in the wrapper as well, by reverting early if some condition is not met. For example you can do something like compare and swap operation. e.g. The current latest ticket open is for block X, I want to relay block X+300, assuring my 100% refund, when I submit initial to claim a ticket via the wrapper i submit both X and X+300, and the submit initial in the wrapper can revert if X has changed onchain. So it still costs, but less than submit initial. The only time X will change onchain is if two relayers submit and it gets included in the same block. We can introduce random in the relayer to offset this in practise, so generate a random number N between 1-20, and the relay block X+300+N. So relayers never try to relay at the exact interval for example.

We could even do something like we do for message relayers and co-ordinate round robin offchain.

@yrong
Copy link
Contributor

yrong commented Jan 11, 2026

encourage all the relayers to pipeline consensus updates as opposed to use manually configuring and whitelisting relayers

Agreed. If the goal is simply to have multiple BEEFY relayers cooperate to update the light client, then on-chain or off-chain round-robin (RR) coordination may not be necessary.

As we’ve observed with the Flashbots RPC, multiple relayers can submit updates concurrently, and only one will succeed—the others will fail without incurring any fees.

This effectively provides a form of round-robin behavior at the RPC level.

# Conflicts:
#	relayer/contracts/beefy_client.go
#	web/packages/test/scripts/start-relayer.sh
@claravanstaden claravanstaden marked this pull request as ready for review February 6, 2026 07:47
Comment on lines 16 to 17
event FundsDeposited(address indexed depositor, uint256 amount);
event FundsWithdrawn(address indexed recipient, uint256 amount);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perhaps these are not necessary.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree, I don't think these events are nessessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines 145 to 160
function submitFiatShamir(
IBeefyClient.Commitment calldata commitment,
uint256[] calldata bitfield,
IBeefyClient.ValidatorProof[] calldata proofs,
IBeefyClient.MMRLeaf calldata leaf,
bytes32[] calldata leafProof,
uint256 leafProofOrder
) external {
beefyClient.submitFiatShamir(commitment, bitfield, proofs, leaf, leafProof, leafProofOrder);

// Clear highest pending block if light client has caught up
if (beefyClient.latestBeefyBlock() >= highestPendingBlock) {
highestPendingBlock = 0;
highestPendingBlockTimestamp = 0;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Why we aren't refunding for Fiat-Shamir?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought the user covers the cost of the transaction in a separate flow?

Copy link
Contributor

@yrong yrong Feb 10, 2026

Choose a reason for hiding this comment

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

We might consider switching to Fiat–Shamir as the default update mechanism in future. If that’s the case, we would need to add refund support for it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point, ae6dc9e

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refunding Fiat Shamir while an interactive protocol session is on-going will cause the interactive protocol relayer to forfeit their gas refund. Is that alright? Otherwise we should refund both the Fiat Shamir and the interactive protocol relayer for submissions up to that point, and then clear they ticket?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Adding in here: 8b8ec69

Copy link
Contributor Author

@claravanstaden claravanstaden Feb 10, 2026

Choose a reason for hiding this comment

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

A relayer could game this by submitting a SubmitInitial and then a FiatShamir though, and get refunded for both.

Copy link
Contributor

@yrong yrong Feb 13, 2026

Choose a reason for hiding this comment

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

I’d prefer to keep things simple and not include 8b8ec69. In my opinion, refunding an unfinished interactive update seems unnecessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tend to agree, reverted in 27b975b.

error AlreadyInitialized();
error TicketAlreadyActive();

address public owner;
Copy link
Contributor

Choose a reason for hiding this comment

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

Comment on lines 200 to 201
uint256 totalGasUsed = currentGas + previousGas;
uint256 effectiveGasPrice = tx.gasprice < maxGasPrice ? tx.gasprice : maxGasPrice;
Copy link
Contributor

Choose a reason for hiding this comment

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

Calculation is incorrect. I assumes that the gasprice for the submitInitial is the same as the gas price for the submitFinal. You need to snapshot the previousGas and gasPrice at time of submitInitial

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch too! Fixed here: 7605eac

uint256 public highestPendingBlockTimestamp;

constructor(
address _beefyClient,
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens when the beefyClient/gateway is updated? We should rather get the beefyClient address from the Gateway by initializing the Wrapper with the GatewayProxy address, that way after an upgrade, this automatically points to the new implementations.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, addressed in d650ff2.

function _refundWithProgress(uint256 startGas, uint256 previousGas, uint256 progress) internal {
if (progress < refundTarget) {
return;
}
Copy link
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
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
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.

* Credited cost is forfeited when clearing a ticket.
*/
function clearTicket(bytes32 commitmentHash) external {
if (ticketOwner[commitmentHash] != msg.sender) {
Copy link
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.

There is no real incentive for a relayer to call this method if the credited cost is forfeit. So maybe remove this method completely or credit the relayer. If we revert on submitInitial if the initial does not make enough progress, then we also dont need this method.

}

if (refundAmount > 0 && address(this).balance >= refundAmount) {
(bool success,) = payable(msg.sender).call{value: refundAmount}("");
Copy link
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
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.

uint256[] calldata bitfield,
IBeefyClient.ValidatorProof calldata proof
) external {
uint256 startGas = gasleft();
Copy link
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.

Comment on lines 5 to 24
interface IBeefyClient {
/* Types */

struct PayloadItem {
bytes2 payloadID;
bytes data;
}

struct Commitment {
uint32 blockNumber;
uint64 validatorSetID;
PayloadItem[] payload;
}

struct ValidatorProof {
uint8 v;
bytes32 r;
bytes32 s;
uint256 index;
address account;
Copy link
Contributor

Choose a reason for hiding this comment

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

We’re duplicating types here with BeefyClient. It might be better to extract the shared types, or alternatively, I’d prefer the wrapper to import BeefyClient directly rather than adding a new interface.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in 52f92e9.

Comment on lines +76 to +78
if (ticketOwner[commitmentHash] != address(0)) {
revert TicketAlreadyOwned();
}
Copy link
Contributor

@yrong yrong Feb 22, 2026

Choose a reason for hiding this comment

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

This check is necessary to prevent race conditions. However, there is a scenario where a malicious relayer could submit submitInitial but never complete the second phase (submitFinal).

In that case, an honest/canonical relayer should ideally be smart enough to skip this commitment and move on to a more recent one. Alternatively, we could include timing information so that expired tickets can be overwritten after a timeout period.

For example:

struct PendingTicket {    
address owner;    
uint256 creditedCost;   // needed for refund calculation   
uint32 blockNumber;     // optional, from commitment; helps relayers query    
uint64 createdAt;       // optional, block.timestamp when submitInitial was executed, useful for timeout-based handling
}

mapping(bytes32 => PendingTicket) public pendingTickets;

This would allow timeout-based handling and prevent stalled tickets from blocking progress indefinitely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants