Skip to content
Quantix Security Labs

Reward accounting in Move staking: settlement order and precision

Why a late stake into an old Tamarind position collected rewards it never earned, and how the same analysis showed that index rounding was small enough to accept.
HighPrecision and roundingResearch assessment · 8 min read
Illustration: a staircase of reward-index steps; a late stake jumps from the first step to the top, with fine rounding residue in a tray below.

The case at a glance

Tamarind Staking

Aptos · StakingSecurity profileResearch case · Quantix Protocol Research

During the case
B67.0
Current profile
BB74.8
To containment
4 h 30 min
To verified fix
16.1 days
  1. 03Problem

    The core invariant is that every position’s rewards are settled at the index value in force when its shares change.

    Read section
  2. 06Vulnerability

    HighPrecision and rounding

    Settlement was treated as something that happens on claim, not as a precondition for changing shares.

    Read section
  3. 08Remediation

    1. 01Settle the position at the current index before any share change
    2. 02Apply the same fix in the Sui port
    3. 03Prove the settlement specification on every build

    and 1 more steps

    Read section
  4. 09Outcome

    TS-01 and TS-02 were fixed and verified, and the settlement and cooldown specifications are proved on the remediation commit.

    Risk Index B → BB, +7.8 points

    Read section

Background

Tamarind Staking lets users stake the native asset on Aptos for a receipt token; a Sui port shares the design. Validator rewards arrive through distribute, which raises a global reward_per_share index. Each position stores reward_debt, the index value at which it was last settled, and pending rewards are shares × (index − reward_debt) / PRECISION.

The pattern is standard and efficient: one multiplication and one division per distribution instead of a loop over stakers. Its correctness depends on two things — the order in which the index and balances change, and the precision of the index.

Architecture

staking_pool holds stake and receipt shares; reward_index keeps the accumulated reward per share and settles claims; unstake_queue processes cooldowns. Parameters are changed by the holder of the admin_cap capability.

Figure 1Tamarind Staking: components and paths involved in this case

Tamarind Staking: architecture relevant to this case6 elements and 5 connections. Highlighted components are directly involved in the case; dashed red arrows are attack paths, dotted brass arrows are control paths.stake()distribute()settle before share changerequest_unstake()parametersStakersstake, split, claim,unstakestaking_poolStake, unstake andreceipt-token accountingreward_indexReward-per-shareaccumulation and claimsunstake_queueCooldown and withdrawalprocessingReward distributorcalls distribute()admin_capCapability object forparameter updates
Protocol componentInvolved in this caseExternal partyAttack pathControl path

Elements:

  • Stakers (external party): stake, split, claim, unstake
  • staking_pool (protocol component, involved in this case): Stake, unstake and receipt-token accounting
  • reward_index (protocol component, involved in this case): Reward-per-share accumulation and claims
  • unstake_queue (protocol component): Cooldown and withdrawal processing
  • Reward distributor (external party): calls distribute()
  • admin_cap (protocol component): Capability object for parameter updates

Connections:

  • Stakers to staking_pool: stake() (data or call flow)
  • Reward distributor to reward_index: distribute() (data or call flow)
  • staking_pool to reward_index: settle before share change (data or call flow)
  • staking_pool to unstake_queue: request_unstake() (data or call flow)
  • admin_cap to staking_pool: parameters (control path)

Components in the Tamarind Staking profile

  • staking_pool(involved in this case)

    Stake, unstake and receipt-token accounting

  • reward_index(involved in this case)

    Reward-per-share accumulation and claims

  • unstake_queue

    Cooldown and withdrawal processing

  • admin_cap

    Capability object for parameter updates

Security challenge

The core invariant is that every position’s rewards are settled at the index value in force when its shares change. The review set out to prove it with the Move Prover and to quantify any rounding the design leaves behind.

Attack surface

The review traced every function that reads or writes the index or a position’s shares.

Attack surface examined in Tamarind Staking
SurfaceExposureControl
stake into an existing positionShares added before settlement (TS-01)settle() first in every share-changing function
request_unstakeShares removed before settlement forfeit accrued rewards (TS-01)Same fix
split of a queued positionUnlock time not carried to the new position (TS-02)Unlock time travels with the shares
Index precisionTruncated remainder never redistributed (TS-04)Quantified; periodic sweep of dust to the treasury
batch_claimUnbounded iteration (TS-03)Bounded batch size; partially resolved

Technical investigation

The Prover specification “a position’s reward_debt equals the current index after any stake, unstake or claim” failed on the reviewed commit. The counterexample was the attack itself: open a tiny position early, wait, then stake a large amount into the same position and claim.

stake increased the position’s shares without first paying out pending rewards or moving reward_debt to the current index. The new shares were therefore paid every increment of the index since the position was last settled, as if they had been staked from the start. The first table quantifies this at production scale.

The same analysis covered precision. With PRECISION of 10^12 and 48 million tokens staked, the truncated remainder of a distribution is a few thousand base units. The second table, computed from the index formula, shows why the team acknowledged TS-04 instead of changing the arithmetic: at the reviewed precision the dust is about a hundredth of a token per year.

Figure 2Late stake collects rewards accrued before it arrived (TS-01)

Late stake collects rewards accrued before it arrived (TS-01)Late stakerstaking_poolreward_indexDistributor1. stake(1 token)reward_debt = index₀2. distribute() × 90 epochsindex rises3. stake(1,000,000) into same positionno settle; debt still index₀4. claim()shares × (index − index₀)5. rewards owed to long-term stakers
Ordinary stepAttack step
  1. Late staker (adversary) to staking_pool: stake(1 token). reward_debt = index₀ (attack step)
  2. Distributor (off-chain) to reward_index: distribute() × 90 epochs. index rises (normal step)
  3. Late staker (adversary) to staking_pool: stake(1,000,000) into same position. no settle; debt still index₀ (attack step)
  4. Late staker (adversary) to reward_index: claim(). shares × (index − index₀) (attack step)
  5. reward_index (on-chain) to Late staker: rewards owed to long-term stakers (attack step)
A position opened with one token at the start of the period receives a late stake after 90 daily distributions of 1,250 tokens, with 48,000,000 tokens staked and shares at 1:1. Computed from the reward-per-share index at the reviewed precision of 10^12.
Late stake (tokens)Claimable immediately (tokens)Share of period rewardsCorrect amount
100,000234.370.21%0
1,000,0002,343.742.08%0
4,800,00011,249.9910.00%0
Reward left undistributed by truncation in one distribution of 1,250 tokens over 48,000,000 staked tokens (8 decimals), by index precision (TS-04).
Index precisionIndex incrementDust per distribution (tokens)Dust per year (tokens)
10^6262.00000000730.000000
10^926,0410.0320000011.680000
10^12 (reviewed)26,041,6660.000032000.011680
10^1826,041,666,666,6660.000000010.000003

Timeline

  1. Assessment begins on the Aptos modules

  2. Detection

    Prover counterexample for settlement (TS-01)

  3. Containment

    New stakes paused through the admin capability

  4. Fix

    Remediation commit adds settle() to share-changing functions

  5. Verification

    Specification proved; Sui parity checked

  6. Assessment report published

Root cause

Settlement was treated as something that happens on claim, not as a precondition for changing shares. Every function that changes a position’s shares must first settle it at the current index; stake, request_unstake, split and merge did not.

Class
Precision and rounding
Severity
High
Components involved
staking_poolreward_index
Findings
TS-01TS-04
sources/staking_pool.move (reviewed commit)move
public entry fun stake(user: &signer, amount: u64) acquires Pool, Position {    let pool = borrow_global_mut<Pool>(@tamarind);    let pos = borrow_global_mut<Position>(signer::address_of(user));    let shares = shares_for(pool, amount);    pos.shares = pos.shares + shares;     // reward_debt still at the old index    coin::merge(&mut pool.reserve, coin::withdraw<AptosCoin>(user, amount));} // sources/rewards.movepublic fun pending(idx: &RewardIndex, pos: &Position): u128 {    (pos.shares as u128) * (idx.reward_per_share - pos.reward_debt) / PRECISION}

Risk evaluation

Both high-severity findings and the medium and low items were open at the start of the assessment. Economic Security is reduced because rewards could be redirected from long-term stakers. The after state is the current profile, which still reflects the partially resolved and open items.

67.0BElevated risk
During the caseB67.0
74.8BBElevated risk
Current profileBB74.8
CodeSecurityOperationalSecurityGovernanceRiskEconomicSecurityInfrastructureResilienceTransparencyDuring the case, Code Security: 53During the case, Operational Security: 74During the case, Governance Risk: 70During the case, Economic Security: 72During the case, Infrastructure Resilience: 76During the case, Transparency: 74Current profile, Code Security: 76Current profile, Operational Security: 74Current profile, Governance Risk: 70Current profile, Economic Security: 78Current profile, Infrastructure Resilience: 76Current profile, Transparency: 74
During the caseCurrent profile

The score moves from 67.0 (B) to 74.8 (BB), a change of +7.8 points. The largest single contribution is Code Security: restoring it from 53.0 to 76.0 alone moves the score from 67.0 to 73.9.

Basis for the case state: TS-01, TS-02, TS-03, TS-04 and TS-06 open at the start of the assessment.
ComponentWeightRangeDuring caseTodayScore points
Code Security30%53.076.0+6.9
Operational Security20%74.074.00.0
Governance Risk15%70.070.00.0
Economic Security15%72.078.0+0.9
Infrastructure Resilience10%76.076.00.0
Transparency10%74.074.00.0
Quantix Risk Index100%67.074.8+7.8
During the caseCurrent profileBasis for the case state: TS-01, TS-02, TS-03, TS-04 and TS-06 open at the start of the assessment.

Scores use the six weighted components of the Quantix Risk Index. Open findings reduce Code Security by a fixed penalty per severity; the current profile is the protocol’s entry in Quantix Intelligence.

Mitigation strategy

An internal settle now runs first in stake, request_unstake, split and merge: it credits pending rewards and moves reward_debt to the current index. The Prover specification that failed during the review is proved on the remediation commit, and the fix was checked for parity in the Sui port.

For precision, the team kept PRECISION at 10^12 and sweeps undistributed dust to the treasury periodically, a decision the quantified table supports.

  1. 01Settle the position at the current index before any share change
  2. 02Apply the same fix in the Sui port
  3. 03Prove the settlement specification on every build
  4. 04Quantify index dust and sweep it periodically
sources/staking_pool.move and rewards.move (remediation commit)move
public entry fun stake(user: &signer, amount: u64) acquires Pool, Position, RewardIndex {    let pool = borrow_global_mut<Pool>(@tamarind);    let pos = borrow_global_mut<Position>(signer::address_of(user));    rewards::settle(pos);                 // credit pending, reward_debt = index    pos.shares = pos.shares + shares_for(pool, amount);    coin::merge(&mut pool.reserve, coin::withdraw<AptosCoin>(user, amount));} public(friend) fun settle(pos: &mut Position) acquires RewardIndex {    let idx = borrow_global<RewardIndex>(@tamarind);    pos.unclaimed = pos.unclaimed + (pending(idx, pos) as u64);    pos.reward_debt = idx.reward_per_share;}

Security outcome

TS-01 and TS-02 were fixed and verified, and the settlement and cooldown specifications are proved on the remediation commit. TS-04 was acknowledged on the strength of the quantified dust.

Batch claims remain partially resolved and an admin capability that can change the cooldown immediately remains open; both are visible in the protocol’s risk profile.

Detection to containment
4 h 30 min
Detection to first fix
12.9 days
Detection to verified fix
16.1 days
Risk Index change
B → BB (+7.8)

Lessons learned

  1. 01

    Settle accumulators before any balance change, in every function that changes one.

  2. 02

    Write the accounting rule as a specification the prover checks on every build.

  3. 03

    Quantify rounding before deciding whether to fix it; not all dust is a vulnerability.

  4. 04

    Check ports for parity whenever a fix lands in one implementation.

Have a problem like this one?

Tell us what you are building and where it runs. We will reply with the review plan we would use for your architecture.