Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 22 additions & 18 deletions contract/r/gnoswap/staker/v1/external_incentive.gno
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package v1
import (
"chain"
"chain/runtime"
"math"
"time"

prbac "gno.land/p/gnoswap/rbac"
Expand Down Expand Up @@ -238,27 +237,32 @@ func (s *stakerV1) endExternalIncentive(resolver *PoolResolver, incentiveResolve
)
}

totalReward := int64(0)
// refund = unclaimableReward + remainder + collectedPenalties
incentivesResolver := resolver.IncentivesResolver()
unclaimableReward := incentivesResolver.calculateUnclaimableReward(incentiveResolver.IncentiveId())

// calculate total external reward for the incentive
s.getDeposits().IterateByPoolPath(0, math.MaxUint64, incentiveResolver.TargetPoolPath(), func(positionId uint64, deposit *sr.Deposit) bool {
depositResolver := NewDepositResolver(deposit)
lastCollectTime := depositResolver.ExternalRewardLastCollectTime(incentiveResolver.IncentiveId())
duration := safeSubInt64(incentiveResolver.EndTimestamp(), incentiveResolver.StartTimestamp())
distributable := safeMulInt64(incentiveResolver.RewardPerSecond(), duration)
remainder := safeSubInt64(incentiveResolver.TotalRewardAmount(), distributable)

if lastCollectTime > incentiveResolver.EndTimestamp() {
return false
}

rewardState := resolver.RewardStateOf(deposit)
calculatedTotalReward := rewardState.calculateCollectableExternalReward(lastCollectTime, currentTime, incentiveResolver.ExternalIncentive)
totalReward = safeAddInt64(totalReward, calculatedTotalReward)
consumed := safeSubInt64(incentiveResolver.TotalRewardAmount(), incentiveResolver.RewardAmount())
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(),
),
)
}

return false
})
refund := safeAddInt64(safeAddInt64(unclaimableReward, remainder), collectedPenalties)

// calculate refund amount is the difference between the incentive reward amount and the total external reward
refund := safeSubInt64(incentiveResolver.TotalRewardAmount(), totalReward)
refund = safeSubInt64(refund, incentiveResolver.DistributedRewardAmount())
maxRefund := safeSubInt64(incentiveResolver.TotalRewardAmount(), incentiveResolver.DistributedRewardAmount())
if refund > maxRefund {
refund = maxRefund
}

return incentiveResolver.ExternalIncentive, refund, nil
}
Expand Down
69 changes: 42 additions & 27 deletions contract/r/gnoswap/staker/v1/external_incentive_test.gno
Original file line number Diff line number Diff line change
Expand Up @@ -1194,13 +1194,14 @@ func TestRefundCalculationWithPartialCollections(t *testing.T) {
userA := testutils.TestAddress("userA")
userB := testutils.TestAddress("userB")

currentTime := time.Now().Unix()
startTime := currentTime - 86400*10 // 10 days ago
endTime := currentTime - 86400 // ended 1 day ago
baseTime := time.Now().Unix()
poolCreateTime := baseTime
startTime := baseTime + 86400
endTime := startTime + 86400*9
totalRewardAmount := int64(1_000_000_000)

// Create pool
pool := sr.NewPool(poolPath, startTime)
pool := sr.NewPool(poolPath, poolCreateTime)
s.getPools().set(poolPath, pool)

// Create incentive
Expand All @@ -1215,7 +1216,7 @@ func TestRefundCalculationWithPartialCollections(t *testing.T) {
creator,
100_000_000,
runtime.ChainHeight(),
startTime-100,
poolCreateTime,
)

poolResolver := NewPoolResolver(pool)
Expand Down Expand Up @@ -1244,58 +1245,72 @@ func TestRefundCalculationWithPartialCollections(t *testing.T) {
},
}

stakeTimeA := startTime + 100
stakeTimeB := startTime + 200

// Create deposits for User A and User B
depositA := sr.NewDeposit(userA, poolPath, u256.NewUint(1000), startTime+100, -10000, 10000, warmups)
depositB := sr.NewDeposit(userB, poolPath, u256.NewUint(1000), startTime+200, -10000, 10000, warmups)
depositA := sr.NewDeposit(userA, poolPath, u256.NewUint(1000), stakeTimeA, -10000, 10000, warmups)
depositB := sr.NewDeposit(userB, poolPath, u256.NewUint(1000), stakeTimeB, -10000, 10000, warmups)

s.getDeposits().set(1, depositA)
s.getDeposits().set(2, depositB)

depositResolverA := NewDepositResolver(depositA)
depositResolverB := NewDepositResolver(depositB)

poolResolver.modifyDeposit(i256.NewInt(1000), startTime+100, 0)
poolResolver.HistoricalTick().Set(startTime+100, int32(0))
poolResolver.modifyDeposit(i256.NewInt(1000), startTime+200, 0)
poolResolver.HistoricalTick().Set(startTime+200, int32(0))
poolResolver.modifyDeposit(i256.NewInt(1000), stakeTimeA, 0)
poolResolver.HistoricalTick().Set(stakeTimeA, int32(0))
poolResolver.modifyDeposit(i256.NewInt(1000), stakeTimeB, 0)
poolResolver.HistoricalTick().Set(stakeTimeB, int32(0))

// User A collects 300
// Simulate CollectReward for User A (raw earned = 300)
// Production flow: rewardAmount -= raw, distributedRewardAmount += reward only
collectedA := int64(300)
depositResolverA.addCollectedExternalReward(incentiveId, collectedA)
depositResolverA.updateExternalRewardLastCollectTime(incentiveId, endTime-1000)

// Update DistributedRewardAmount (simulating CollectReward)
incentiveResolver := NewExternalIncentiveResolver(incentive)
incentive.SetRewardAmount(incentive.RewardAmount() - collectedA)
incentiveResolver.addDistributedRewardAmount(collectedA)

// User B collects 200
// Simulate CollectReward for User B (raw earned = 200)
collectedB := int64(200)
depositResolverB.addCollectedExternalReward(incentiveId, collectedB)
depositResolverB.updateExternalRewardLastCollectTime(incentiveId, endTime-500)

// Update DistributedRewardAmount (simulating CollectReward)
incentive.SetRewardAmount(incentive.RewardAmount() - collectedB)
incentiveResolver.addDistributedRewardAmount(collectedB)

// Verify DistributedRewardAmount = 500
if incentiveResolver.DistributedRewardAmount() != 500 {
t.Errorf("Expected DistributedRewardAmount 500, got %d", incentiveResolver.DistributedRewardAmount())
}

// Verify DistributedRewardAmount remains 500 after unstakes
if incentiveResolver.DistributedRewardAmount() != 500 {
t.Errorf("After unstakes, DistributedRewardAmount should still be 500, got %d", incentiveResolver.DistributedRewardAmount())
}

// Calculate refund
// remain deposit A reward: 1286000
// remain deposit B reward: 643000
_, refund, err := s.endExternalIncentive(poolResolver, incentiveResolver, creator, endTime+100)
uassert.NoError(t, err)

// Verify refund amount
expectedRefund := int64(999035000)
if refund != expectedRefund {
t.Errorf("Expected refund %d, got %d", expectedRefund, refund)
duration := endTime - startTime
rewardPerSecond := totalRewardAmount / duration
remainder := totalRewardAmount - rewardPerSecond*duration

expectedUnclaimable := rewardPerSecond * (stakeTimeA - startTime)
expectedRefund := expectedUnclaimable + remainder

maxRefund := totalRewardAmount - int64(500)
if expectedRefund > maxRefund {
expectedRefund = maxRefund
}

numDeposits := int64(2)
numWarmups := int64(len(warmups))
tolerance := numDeposits * numWarmups * 2
diff := refund - expectedRefund
if diff < 0 {
diff = -diff
}
if diff > tolerance {
t.Errorf("Refund %d deviates from expected %d by %d (tolerance %d = %d deposits * %d warmups * 2)",
refund, expectedRefund, diff, tolerance, numDeposits, numWarmups)
}

// Verify refund is not negative
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"
"time"

i256 "gno.land/p/gnoswap/int256"
testutils "gno.land/p/nt/testutils/v0"
sr "gno.land/r/gnoswap/staker"
)
Expand Down Expand Up @@ -156,6 +157,54 @@ func TestIncentivesUpdate(t *testing.T) {
}
}

// Test that zero-liquidity windows are tracked via modifyDeposit and reflected in calculateUnclaimableReward.
func TestCalculateUnclaimableReward_TracksZeroLiquidityTransitions(t *testing.T) {
poolPath := "test_pool_unclaimable_tracking"
creator := testutils.TestAddress("creator")

currentTime := time.Now().Unix()
pool := sr.NewPool(poolPath, currentTime)
poolResolver := NewPoolResolver(pool)
incentives := poolResolver.IncentivesResolver()

startTime := currentTime + 10
endTime := startTime + 100
rewardAmount := int64(1000) // rewardPerSecond = 10

incentive := sr.NewExternalIncentive(
"test_incentive_tracking",
poolPath,
GNS_PATH,
rewardAmount,
startTime,
endTime,
creator,
100,
runtime.ChainHeight(),
currentTime,
)
incentives.create(incentive)

stakeTimeA := startTime + 20
zeroTime := startTime + 60
stakeTimeB := startTime + 80

// zero -> positive: end unclaimable
poolResolver.modifyDeposit(i256.NewInt(1000), stakeTimeA, 0)
// positive -> zero: start unclaimable
poolResolver.modifyDeposit(i256.NewInt(-1000), zeroTime, 0)
// zero -> positive: end unclaimable
poolResolver.modifyDeposit(i256.NewInt(1000), stakeTimeB, 0)

expectedDuration := (stakeTimeA - startTime) + (stakeTimeB - zeroTime)
expected := expectedDuration * incentive.RewardPerSecond()

got := incentives.calculateUnclaimableReward(incentive.IncentiveId())
if got != expected {
t.Fatalf("unclaimable reward mismatch: got %d, want %d", got, expected)
}
}

// Test startUnclaimablePeriod function
func TestStartUnclaimablePeriod(t *testing.T) {
tests := []struct {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,17 +398,17 @@ func endExternalIncentiveAndVerifyNoDust() {
ufmt.Printf("[EXPECTED] creator receives all remaining reward token: %d\n", stakerQuxBefore)
ufmt.Printf("[EXPECTED] creator receives fixed GNS deposit: %d\n", depositGnsAmount)

// Verify total distribution
// Total accounting: collected + refunded + staker residual = initial reward
ufmt.Printf("[EXPECTED] Total reward distribution:\n")
ufmt.Printf(" - Initial reward amount: %d QUX\n", REWARD_AMOUNT)
ufmt.Printf(" - Rewards collected by staker: %d QUX\n", totalRewardsCollected)
undistributedRewardRefunded := refundQuxAmount
ufmt.Printf(" - Undistributed reward refunded: %d QUX\n", undistributedRewardRefunded)
ufmt.Printf(" - Reward refunded to creator: %d QUX\n", refundQuxAmount)
ufmt.Printf(" - Residual in staker: %d QUX\n", stakerQuxAfter)

totalDistributed := totalRewardsCollected + undistributedRewardRefunded
ufmt.Printf(" - Total accounted for: %d QUX\n", totalDistributed)
totalAccounted := totalRewardsCollected + refundQuxAmount + stakerQuxAfter
ufmt.Printf(" - Total accounted for: %d QUX\n", totalAccounted)

if totalDistributed == REWARD_AMOUNT {
if totalAccounted == REWARD_AMOUNT {
println("[EXPECTED] all rewards accounted for with no loss")
}
}
Expand Down Expand Up @@ -492,19 +492,20 @@ func positionIdFrom(positionId int) grc721.TokenID {
// - Creator GNS (deposit): 0
// - Admin total rewards collected (QUX): 6263999997
// [INFO] after ending incentive:
// - Creator QUX: 1512000003
// - Staker contract QUX: 0
// - Creator QUX: 1512000000
// - Staker contract QUX: 3
// - Creator GNS (deposit): 1000000000
// - Admin QUX balances: 99998487998997
// [RESULT] refund breakdown:
// - Total refunded reward token (QUX): 1512000003
// - Total reduced from staker QUX: 1512000003
// - Total refunded reward token (QUX): 1512000000
// - Total reduced from staker QUX: 1512000000
// - Refunded GNS deposit: 1000000000
// [EXPECTED] creator receives all remaining reward token: 1512000003
// [EXPECTED] creator receives fixed GNS deposit: 1000000000
// [EXPECTED] Total reward distribution:
// - Initial reward amount: 7776000000 QUX
// - Rewards collected by staker: 6263999997 QUX
// - Undistributed reward refunded: 1512000003 QUX
// - Reward refunded to creator: 1512000000 QUX
// - Residual in staker: 3 QUX
// - Total accounted for: 7776000000 QUX
// [EXPECTED] all rewards accounted for with no loss
16 changes: 10 additions & 6 deletions contract/r/scenario/staker/external_incentive_drain_filetest.gno
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,14 @@ func endExternalGnsIncentive() {
stakerGnsAfterFirstEnd := gns.BalanceOf(stakerAddr)
creatorGnsAfterFirstEnd := gns.BalanceOf(externalCreatorUser)

creatorReceived := creatorGnsAfterFirstEnd - creatorGnsBeforeFirstEnd

ufmt.Printf("[INFO] FIRST END - Staker GNS balance before: %d\n", stakerGnsBeforeFirstEnd)
ufmt.Printf("[INFO] FIRST END - Staker GNS balance after: %d\n", stakerGnsAfterFirstEnd)
ufmt.Printf("[INFO] FIRST END - Creator GNS balance before: %d\n", creatorGnsBeforeFirstEnd)
ufmt.Printf("[INFO] FIRST END - Creator GNS balance after: %d\n", creatorGnsAfterFirstEnd)
ufmt.Printf("[INFO] FIRST END - Creator received: %d GNS\n", creatorGnsAfterFirstEnd-creatorGnsBeforeFirstEnd)
ufmt.Printf("[INFO] FIRST END - Creator received: %d GNS\n", creatorReceived)
ufmt.Printf("[INFO] FIRST END - Staker residual: %d GNS\n", stakerGnsAfterFirstEnd)
}

func demonstrateVulnerability() {
Expand Down Expand Up @@ -311,18 +314,19 @@ func positionIdFrom(positionId int) grc721.TokenID {
// [INFO] skip blocks until external incentive ends
// [INFO] end external incentive FIRST TIME and collect remaining GNS
// [INFO] FIRST END - Staker GNS balance before: 20000000000
// [INFO] FIRST END - Staker GNS balance after: 18996826213
// [INFO] FIRST END - Staker GNS balance after: 18996826215
// [INFO] FIRST END - Creator GNS balance before: 0
// [INFO] FIRST END - Creator GNS balance after: 1003173787
// [INFO] FIRST END - Creator received: 1003173787 GNS
// [INFO] FIRST END - Creator GNS balance after: 1003173785
// [INFO] FIRST END - Creator received: 1003173785 GNS
// [INFO] FIRST END - Staker residual: 18996826215 GNS
//
// [SCENARIO] 8. FIX VERIFICATION: Verify EndExternalIncentive cannot be called multiple times
// [FIX VERIFICATION] Attempting to call EndExternalIncentive multiple times
// [EXPECTED] Second call should fail with 'cannot end non existent incentive' error
//
// [SCENARIO] 9. Final verification
// [RESULT] Final balances after fix:
// - Staker GNS balance: 18996826213
// - Creator GNS balance: 1003173787
// - Staker GNS balance: 18996826215
// - Creator GNS balance: 1003173785
// [VERIFIED] Staker was NOT drained - only one GNS deposit was refunded
// [VERIFIED] The vulnerability has been successfully fixed
Original file line number Diff line number Diff line change
Expand Up @@ -277,22 +277,21 @@ func endExternalIncentiveAndVerifyDustRefund() {
panic(ufmt.Sprintf("Expected GNS refund of %d, but got %d", depositGnsAmount, gnsRefunded))
}

// Calculate expected dust
// Verify refund = unclaimable reward + division remainder
rewardAmount := int64(10000000007)
duration := int64(90 * 24 * 60 * 60)
expectedDust := rewardAmount - (rewardAmount/duration)*duration
rewardPerSecond := rewardAmount / duration
remainder := rewardAmount - rewardPerSecond*duration
unclaimablePortion := barRefunded - remainder

// Verify that dust was included in the refund
// The refund should be at least the dust amount
if barRefunded < expectedDust {
panic(ufmt.Sprintf("Expected refund to include at least dust amount of %d, but got %d", expectedDust, barRefunded))
ufmt.Printf("[VERIFY] refund: %d = unclaimable(%d) + remainder(%d)\n", barRefunded, unclaimablePortion, remainder)

if barRefunded < remainder {
panic(ufmt.Sprintf("Refund %d is less than division remainder %d", barRefunded, remainder))
}

// Check if the refund amount makes sense (dust + unallocated rewards)
if barRefunded > 0 && barRefunded >= expectedDust {
ufmt.Printf("[SUCCESS] Dust refund mechanism verified - dust amount (%d) properly included in total refund (%d)!\n", expectedDust, barRefunded)
} else {
panic(ufmt.Sprintf("Unexpected refund amount: %d (expected dust: %d)", barRefunded, expectedDust))
if rewardPerSecond > 0 && unclaimablePortion%rewardPerSecond != 0 {
panic(ufmt.Sprintf("Unclaimable portion %d is not a multiple of rewardPerSecond %d", unclaimablePortion, rewardPerSecond))
}
}

Expand Down Expand Up @@ -334,10 +333,10 @@ func positionIdFrom(tokenId uint64) grc721.TokenID {
// [INFO] bar balance before ending: 100000000
// [INFO] gns balance before ending: 0
// [INFO] end external incentive
// [INFO] bar balance after ending: 100070440
// [INFO] bar balance after ending: 100070437
// [INFO] gns balance after ending: 1000000000
// [RESULT] bar tokens refunded: 70440
// [RESULT] bar tokens refunded: 70437
// [RESULT] gns tokens refunded: 1000000000
// [SUCCESS] Dust refund mechanism verified - dust amount (64007) properly included in total refund (70440)!
// [VERIFY] refund: 70437 = unclaimable(6430) + remainder(64007)
//
// [SUCCESS] Test completed - rewardDust mechanism works correctly!
Loading
Loading