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
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.
| Surface | Exposure | Control |
|---|---|---|
stake into an existing position | Shares added before settlement (TS-01) | settle() first in every share-changing function |
request_unstake | Shares removed before settlement forfeit accrued rewards (TS-01) | Same fix |
split of a queued position | Unlock time not carried to the new position (TS-02) | Unlock time travels with the shares |
| Index precision | Truncated remainder never redistributed (TS-04) | Quantified; periodic sweep of dust to the treasury |
batch_claim | Unbounded 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 staker (adversary) to staking_pool: stake(1 token). reward_debt = index₀ (attack step)
- Distributor (off-chain) to reward_index: distribute() × 90 epochs. index rises (normal step)
- Late staker (adversary) to staking_pool: stake(1,000,000) into same position. no settle; debt still index₀ (attack step)
- Late staker (adversary) to reward_index: claim(). shares × (index − index₀) (attack step)
- reward_index (on-chain) to Late staker: rewards owed to long-term stakers (attack step)
| Late stake (tokens) | Claimable immediately (tokens) | Share of period rewards | Correct amount |
|---|---|---|---|
| 100,000 | 234.37 | 0.21% | 0 |
| 1,000,000 | 2,343.74 | 2.08% | 0 |
| 4,800,000 | 11,249.99 | 10.00% | 0 |
| Index precision | Index increment | Dust per distribution (tokens) | Dust per year (tokens) |
|---|---|---|---|
| 10^6 | 26 | 2.00000000 | 730.000000 |
| 10^9 | 26,041 | 0.03200000 | 11.680000 |
| 10^12 (reviewed) | 26,041,666 | 0.00003200 | 0.011680 |
| 10^18 | 26,041,666,666,666 | 0.00000001 | 0.000003 |
Timeline
Assessment begins on the Aptos modules
Detection
Prover counterexample for settlement (TS-01)
Containment
New stakes paused through the admin capability
Fix
Remediation commit adds settle() to share-changing functions
Verification
Specification proved; Sui parity checked
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
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.
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.
| Component | Weight | Range | During case | Today | Score points |
|---|---|---|---|---|---|
| Code Security | 30% | 53.0 | 76.0 | +6.9 | |
| Operational Security | 20% | 74.0 | 74.0 | 0.0 | |
| Governance Risk | 15% | 70.0 | 70.0 | 0.0 | |
| Economic Security | 15% | 72.0 | 78.0 | +0.9 | |
| Infrastructure Resilience | 10% | 76.0 | 76.0 | 0.0 | |
| Transparency | 10% | 74.0 | 74.0 | 0.0 | |
| Quantix Risk Index | 100% | 67.0 | 74.8 | +7.8 |
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.
- 01Settle the position at the current index before any share change
- 02Apply the same fix in the Sui port
- 03Prove the settlement specification on every build
- 04Quantify index dust and sweep it periodically
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
- 01
Settle accumulators before any balance change, in every function that changes one.
- 02
Write the accounting rule as a specification the prover checks on every build.
- 03
Quantify rounding before deciding whether to fix it; not all dust is a vulnerability.
- 04
Check ports for parity whenever a fix lands in one implementation.

