Skip to content

Add Operator.rebalanceStake - #1094

Merged
hpihkala merged 1 commit into
mainfrom
rebalanceStake
Oct 10, 2026
Merged

hpihkala merged 1 commit into
mainfrom
rebalanceStake

Conversation

@hpihkala

Copy link
Copy Markdown
Contributor

Summary

Adds Operator.rebalanceStake, which moves stake between Sponsorships in one transaction: it reduces stake in a list of Sponsorships (a target of zero unstakes completely), pays out the undelegation queue from the freed DATA, and then stakes into another list of Sponsorships. It behaves like calling reduceStakeTo and then stake for each entry, with two differences:

  • The corresponding arrays must be the same length (ArrayLengthMismatch otherwise).
  • Before any stake is touched, it checks that none of the Sponsorships being joined are already at their max operator count and reverts with SponsorshipFull. Joining is racy — another Operator can take the last slot while the transaction is pending — so failing here saves the gas that the reductions would have spent. Sponsorships the Operator is already staked in are skipped, since increasing an existing stake does not count against the limit.

Sponsorship.isFull() and Sponsorship.maxOperatorCount() back that check. The limit lives only in MaxOperatorsJoinPolicy's namespaced storage, so maxOperatorCount() reads that slot directly (zero means no limit). Sponsorships deployed from the current template have no isFull(); the check catches that and falls through to staking. Whether an address is a factory-deployed Sponsorship at all is still checked later, inside StakeModule._stake.

The loop lives in NodeModule._rebalanceStake and delegatecalls StakeModule for the actual staking. StakeModule is at the contract size limit, and every module carries Operator's bytecode. version() stays at 4: this ships in the same deployment as the Operator drain hotfix.

For a move across four Sponsorships (two reductions, two stakes), rebalanceStake used 968k gas against 1,524k for the equivalent separate transactions.

@jtakalai jtakalai left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Probably a useful addition 👍

}

// fail fast, before spending gas on the reductions: staking into a full Sponsorship would only revert at the very end
_checkCanJoin(stakeOnSponsorships);

@jtakalai jtakalai Oct 10, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

this check seems to cause quite a lot of added code into Sponsorship (not to mention integrating max operators policy into main contract, meaning maybe the rest of the module (= the max operator count limitation feature) should live in the main contract instead of a separate module...). Considering a reverted transaction would mean no gas is spent anyway, "failing fast" doesn't really mean gas savings (unless the revert is caught somewhere? On a quick glance it looks like line 111 would pass on the revert.

@hpihkala hpihkala Oct 10, 2026 •

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.

Considering a reverted transaction would mean no gas is spent anyway, "failing fast" doesn't really mean gas savings (unless the revert is caught somewhere?

@jtakalai I don't understand this part. On a failed transaction, the sender does pay for all gas spent up until the failure. In a race to join a sponsorship, the sponsorship may well become full in between submitting the join transaction and getting it included in a block, so this pre-check would reduce gas wasted in a fairly typical case (if max operators is set on the sponsorship).

Example calculation: if rebalanceStake runs two reduceStakeTos (each ~100k+ gas: Sponsorship allocation-policy updates, token transfers, events), pays out the queue, and then hits error_tooManyOperators in the join policy on the stake, the operator's wallet is charged for all of that work and gets nothing for it. With the pre-check, the same failure costs only the isFull() staticcalls (~3–10k per sponsorship) plus the base fee.

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.

maybe [...] the max operator count limitation feature) should live in the main contract instead of a separate module

I agree, it probably should. However, folding the functionality into Sponsorship proper would necessitate a breaking change in the SponsorshipFactory's API (maxOperators becomes a proper argument). Better avoid it now if possible.

Base automatically changed from other-fixes to main October 10, 2026 16:17
@hpihkala
hpihkala merged commit ffc15b4 into main Oct 10, 2026
2 checks passed
@hpihkala
hpihkala deleted the rebalanceStake branch October 10, 2026 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants