Skip to content

fix(staker): optimize EndExternalIncentive refund calculation#1232

Open
notJoon wants to merge 7 commits intomainfrom
fix/staker-end-external-incentive-dos
Open

fix(staker): optimize EndExternalIncentive refund calculation#1232
notJoon wants to merge 7 commits intomainfrom
fix/staker-end-external-incentive-dos

Conversation

@notJoon
Copy link
Copy Markdown
Member

@notJoon notJoon commented Apr 2, 2026

Description

Replace the O(n) deposit iteration in endExternalIncentive with an O(1) refund calculation based on unclaimable period tracking.

Changes

Before:

EndExternalIncentive iterated over every deposit in the pool to compute uncollected rewards. An attacker could create thousands of minimal staked positions, making the transaction prohibitively expensive and blocking the incentive creator from recovering their remaining rewards and GNS deposit.

After:

refund = unclaimableReward + remainder + collectedPenalties
  • unclaimableReward: rewards for zero-liquidity periods (via calculateUnclaimableReward, already tracked by modifyDeposit)
  • remainder: integer division truncation from rewardPerSecond (L-14 dust)
  • collectedPenalties: warmup penalties retained in staker during CollectReward, derived from existing accounting fields without iteration: (totalRewardAmount - rewardAmount) - distributedRewardAmount
  • A maxRefund cap (totalRewardAmount - distributedRewardAmount) prevents over-refund

Trade-off

Per-deposit MulDiv floor rounding dust (2–5 units per incentive) now remains in the staker contract instead of being returned to the creator. This is because computing per-deposit rounding requires the iteration we're removing. The amounts are negligible and the direction is conservative (users' collectable rewards are never affected).

Related Works

Summary by CodeRabbit

  • Bug Fixes

    • More efficient O(1) external-incentive refund calculation with tighter bounds, proper handling of unclaimable amounts, remainders and collected penalties, and clamped refunds.
  • Tests

    • Converted tests to deterministic forward-looking timelines.
    • Added coverage for zero-liquidity transitions.
    • Relaxed refund assertions to allow realistic tolerances.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7dbe4ce4-e805-4259-bde2-49bca5060e79

📥 Commits

Reviewing files that changed from the base of the PR and between 7abadfe and 8f67c53.

📒 Files selected for processing (1)
  • contract/r/gnoswap/staker/v1/external_incentive.gno
🚧 Files skipped from review as they are similar to previous changes (1)
  • contract/r/gnoswap/staker/v1/external_incentive.gno

Walkthrough

Replaced an O(n) per-deposit refund loop in external incentives with an O(1) incentive-level refund calculation (duration, RewardPerSecond, distributable, remainder, collected penalties, and unclaimableReward; result clamped). Updated tests to use deterministic forward timelines, tolerance-based assertions, and added a zero-liquidity transition test.

Changes

Cohort / File(s) Summary
Refund Calculation Refactor
contract/r/gnoswap/staker/v1/external_incentive.gno
Removed per-deposit iteration; implemented O(1) refund computation using calculateUnclaimableReward, duration, distributable, remainder, and collected penalties; clamped refund; removed math import.
Test Adjustments
contract/r/gnoswap/staker/v1/external_incentive_test.gno
Rewrote TestRefundCalculationWithPartialCollections to use deterministic forward timeline, explicit stake times, update distributed reward during simulation, and replace exact equality with a tolerance-based refund assertion.
New Unit Test
contract/r/gnoswap/staker/v1/reward_calculation_incentives_test.gno
Added TestCalculateUnclaimableReward_TracksZeroLiquidityTransitions to assert unclaimable reward equals sum of non-zero-liquidity windows × RewardPerSecond; added i256 import used by test.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: optimizing the EndExternalIncentive refund calculation from O(n) to O(1), which is the main focus of the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/staker-end-external-incentive-dos

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

notJoon added 3 commits April 2, 2026 14:35
`EndExtenalIncentive`

Replace the O(N) deposit scan with an O(1) calculation:
refund = calculateUnclaimableReward(incentiveId) + remainder

where `unclaimableReward` covers zero-liquidity periods and remainder
captures the integer division truncation from `rewardPerSecond`.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
contract/r/gnoswap/staker/v1/external_incentive_test.gno (1)

1117-1139: Tolerance-based assertion aligns with documented trade-off.

The tolerance formula numDeposits * numWarmups * 2 accounts for per-deposit, per-tier rounding dust mentioned in the PR description.

One minor observation: consider adding a comment explaining the tolerance derivation (2 units max per deposit per warmup tier) to aid future maintainability.

🔧 Optional: Add clarifying comment for tolerance calculation
 numDeposits := int64(2)
 numWarmups := int64(len(warmups))
+// Each deposit can accumulate up to 2 units of MulDiv rounding dust per warmup tier
 tolerance := numDeposits * numWarmups * 2

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c6b0e892-4568-4fc3-bcfe-7d8a87f762f7

📥 Commits

Reviewing files that changed from the base of the PR and between bef2541 and 06c2c32.

⛔ Files ignored due to path filters (6)
  • contract/r/scenario/staker/external_incentive_complete_distribution_filetest.gno is excluded by !**/*filetest.gno
  • contract/r/scenario/staker/external_incentive_drain_filetest.gno is excluded by !**/*filetest.gno
  • contract/r/scenario/staker/external_incentive_dust_refund_filetest.gno is excluded by !**/*filetest.gno
  • contract/r/scenario/staker/single_gns_external_ends_filetest.gno is excluded by !**/*filetest.gno
  • contract/r/scenario/staker/staker_native_create_collect_unstake_filetest.gno is excluded by !**/*filetest.gno
  • contract/r/scenario/staker/staker_native_create_collect_unstake_with_finish_filetest.gno is excluded by !**/*filetest.gno
📒 Files selected for processing (3)
  • contract/r/gnoswap/staker/v1/external_incentive.gno
  • contract/r/gnoswap/staker/v1/external_incentive_test.gno
  • contract/r/gnoswap/staker/v1/reward_calculation_incentives_test.gno

@notJoon notJoon force-pushed the fix/staker-end-external-incentive-dos branch from 06c2c32 to fb87c24 Compare April 2, 2026 05:52
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
contract/r/gnoswap/staker/v1/external_incentive.gno (1)

248-259: [LOW] Rule: Fail closed on broken refund accounting.

Problem: If the derived penalty/refund state goes negative, clamping to 0 hides the mismatch and still finalizes the incentive.

Suggested guard
  consumed := safeSubInt64(incentiveResolver.TotalRewardAmount(), incentiveResolver.RewardAmount())
+ // Invariant: zero-liquidity accrual is returned separately via
+ // `calculateUnclaimableReward`, so `consumed - distributedRewardAmount`
+ // should represent collected penalties only.
  collectedPenalties := safeSubInt64(consumed, incentiveResolver.DistributedRewardAmount())
+ if collectedPenalties < 0 {
+ 	return nil, 0, makeErrorWithDetails(
+ 		errCannotEndIncentive,
+ 		ufmt.Sprintf(
+ 			"invalid incentive accounting: consumed(%d) < distributed(%d)",
+ 			consumed,
+ 			incentiveResolver.DistributedRewardAmount(),
+ 		),
+ 	)
+ }

  refund := safeAddInt64(safeAddInt64(unclaimableReward, remainder), collectedPenalties)

  maxRefund := safeSubInt64(incentiveResolver.TotalRewardAmount(), incentiveResolver.DistributedRewardAmount())
  if refund > maxRefund {
  	refund = maxRefund
  }
- if refund < 0 {
- 	refund = 0
- }

Rationale: A negative collectedPenalties means the distributedRewardAmount / rewardAmount invariant is already broken; aborting is safer than silently zeroing the refund. Based on learnings, distributedRewardAmount must only be incremented by the net reward paid to LPs, while external penalties remain in the staker and are refunded during EndExternalIncentive.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3b74afa4-8965-4633-bf8f-2c68c5b045bf

📥 Commits

Reviewing files that changed from the base of the PR and between fb87c24 and 7abadfe.

📒 Files selected for processing (1)
  • contract/r/gnoswap/staker/v1/external_incentive.gno

@notJoon notJoon added the DO NOT MERGE do not merge this PR label Apr 3, 2026
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 3, 2026

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

Labels

DO NOT MERGE do not merge this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant