Skip to content

Commit 9f02687

Browse files
authored
fix(gov/staker): removeStake not persisting updated reward state (#827)
* fix: add missing tree update logic
1 parent b015313 commit 9f02687

File tree

2 files changed

+116
-44
lines changed

2 files changed

+116
-44
lines changed

contract/r/gnoswap/v1/gov/staker/emission_reward_mananger.gno renamed to contract/r/gnoswap/v1/gov/staker/emission_reward_manager.gno

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
u256 "gno.land/p/gnoswap/uint256"
88
)
99

10+
var errFailedToCastRewardState = "failed to cast rewardStates's element to *EmissionRewardState: %T"
11+
1012
// EmissionRewardManager manages the distribution of emission rewards to stakers.
1113
type EmissionRewardManager struct {
1214
// rewardStates maps address to EmissionRewardState for tracking individual staker rewards
@@ -78,7 +80,7 @@ func (e *EmissionRewardManager) calculateAccumulatedRewardX128PerStake(
7880
currentTimestamp int64,
7981
) (*u256.Uint, error) {
8082
// If we're looking at a past timestamp, return current state
81-
if e.accumulatedTimestamp > currentTimestamp {
83+
if currentTimestamp < e.accumulatedTimestamp {
8284
return e.accumulatedRewardX128PerStake, nil
8385
}
8486

@@ -87,14 +89,16 @@ func (e *EmissionRewardManager) calculateAccumulatedRewardX128PerStake(
8789
return e.accumulatedRewardX128PerStake, nil
8890
}
8991

90-
// Calculate the newly distributed rewards since last update
92+
// Newly distributed rewards since last update
9193
distributedAmountDelta := currentDistributedAmount - e.distributedAmount
92-
distributedAmountDeltaX128 := u256.NewUintFromInt64(distributedAmountDelta)
93-
distributedAmountDeltaX128 = u256.Zero().Lsh(distributedAmountDeltaX128, 128)
94+
if distributedAmountDelta <= 0 {
95+
// Non-positive delta. nothing to do more.
96+
return e.accumulatedRewardX128PerStake, nil
97+
}
9498

95-
// Calculate reward per stake for the new distribution
99+
// Reward per stake for the new distribution
96100
distributedAmountDeltaX128PerStake := u256.Zero().Div(
97-
distributedAmountDeltaX128,
101+
u256.Zero().Lsh(u256.NewUintFromInt64(distributedAmountDelta), 128),
98102
u256.NewUintFromInt64(e.totalStakedAmount),
99103
)
100104

@@ -117,12 +121,12 @@ func (e *EmissionRewardManager) updateAccumulatedRewardX128PerStake(
117121
currentDistributedAmount int64,
118122
currentTimestamp int64,
119123
) error {
120-
// Don't update if we're looking at a past timestamp
121-
if e.accumulatedTimestamp > currentTimestamp {
124+
// DO NOT apply out-of-order timestamps
125+
if currentTimestamp < e.accumulatedTimestamp {
122126
return nil
123127
}
124128

125-
// Don't update if no tokens are staked
129+
// to avoid accumulating a large delta later.
126130
if e.totalStakedAmount == 0 {
127131
return nil
128132
}
@@ -136,7 +140,7 @@ func (e *EmissionRewardManager) updateAccumulatedRewardX128PerStake(
136140
return err
137141
}
138142

139-
e.accumulatedRewardX128PerStake = accumulatedRewardX128PerStake
143+
e.accumulatedRewardX128PerStake = accumulatedRewardX128PerStake.Clone()
140144
e.distributedAmount = currentDistributedAmount
141145
e.accumulatedTimestamp = currentTimestamp
142146

@@ -147,53 +151,49 @@ func (e *EmissionRewardManager) updateAccumulatedRewardX128PerStake(
147151
// This method ensures rewards are properly calculated before the stake change.
148152
// Adds stake for specified address and updates reward calculations.
149153
func (e *EmissionRewardManager) addStake(address string, amount int64, currentTimestamp int64) error {
150-
rewardStateI, ok := e.rewardStates.Get(address)
151-
if !ok {
152-
rewardStateI = NewEmissionRewardState(e.accumulatedRewardX128PerStake)
154+
rewardState, ok, err := e.getRewardState(address)
155+
if err != nil {
156+
return err
153157
}
154-
155-
rewardState, ok := rewardStateI.(*EmissionRewardState)
156158
if !ok {
157-
return ufmt.Errorf(
158-
"failed to cast rewardStates's element to *EmissionRewardState: %T",
159-
rewardStateI,
160-
)
159+
// if the address is unseen, initialize a snapshot to avoid nil deref
160+
rewardState = NewEmissionRewardState(e.accumulatedRewardX128PerStake.Clone())
161161
}
162162

163-
err := rewardState.addStakeWithUpdateRewardDebtX128(amount, e.accumulatedRewardX128PerStake, currentTimestamp)
163+
err = rewardState.addStakeWithUpdateRewardDebtX128(amount, e.accumulatedRewardX128PerStake, currentTimestamp)
164164
if err != nil {
165165
return err
166166
}
167167

168168
e.rewardStates.Set(address, rewardState)
169169
e.totalStakedAmount = e.totalStakedAmount + amount
170-
171170
return nil
172171
}
173172

174173
// removeStake removes a stake for an address and updates their reward state.
175174
// This method ensures rewards are properly calculated before the stake change.
176175
// Removes stake for specified address and updates reward calculations.
177176
func (e *EmissionRewardManager) removeStake(address string, amount int64, currentTimestamp int64) error {
178-
rewardStateI, ok := e.rewardStates.Get(address)
179-
if !ok {
180-
rewardStateI = NewEmissionRewardState(e.accumulatedRewardX128PerStake.Clone())
177+
rewardState, ok, err := e.getRewardState(address)
178+
if err != nil {
179+
return err
181180
}
182-
183-
rewardState, ok := rewardStateI.(*EmissionRewardState)
184181
if !ok {
185-
return ufmt.Errorf(
186-
"failed to cast rewardStates's element to *EmissionRewardState: %T",
187-
rewardStateI,
188-
)
182+
// if the address is unseen, initialize a snapshot to avoid nil deref
183+
rewardState = NewEmissionRewardState(e.accumulatedRewardX128PerStake.Clone())
189184
}
190185

191-
err := rewardState.removeStakeWithUpdateRewardDebtX128(amount, e.accumulatedRewardX128PerStake, currentTimestamp)
186+
err = rewardState.removeStakeWithUpdateRewardDebtX128(amount, e.accumulatedRewardX128PerStake, currentTimestamp)
192187
if err != nil {
193188
return err
194189
}
195190

196-
e.totalStakedAmount = e.totalStakedAmount - amount
191+
// persist updated state
192+
e.rewardStates.Set(address, rewardState)
193+
e.totalStakedAmount -= amount
194+
if e.totalStakedAmount < 0 {
195+
e.totalStakedAmount = 0 // defensive clamp
196+
}
197197

198198
return nil
199199
}
@@ -202,17 +202,9 @@ func (e *EmissionRewardManager) removeStake(address string, amount int64, curren
202202
// This method calculates and returns the amount of rewards claimed.
203203
// Claims available rewards for specified address.
204204
func (e *EmissionRewardManager) claimRewards(address string, currentTimestamp int64) (claimedRewardAmount int64, err error) {
205-
rewardStateI, ok := e.rewardStates.Get(address)
206-
if !ok {
207-
return 0, nil
208-
}
209-
210-
rewardState, ok := rewardStateI.(*EmissionRewardState)
211-
if !ok {
212-
return 0, ufmt.Errorf(
213-
"failed to cast rewardStates's element to *EmissionRewardState: %T",
214-
rewardStateI,
215-
)
205+
rewardState, ok, err := e.getRewardState(address)
206+
if err != nil || !ok {
207+
return 0, err
216208
}
217209

218210
claimedRewardAmount, cErr := rewardState.claimRewardsWithUpdateRewardDebtX128(e.accumulatedRewardX128PerStake, currentTimestamp)
@@ -221,7 +213,6 @@ func (e *EmissionRewardManager) claimRewards(address string, currentTimestamp in
221213
}
222214

223215
e.rewardStates.Set(address, rewardState)
224-
225216
return claimedRewardAmount, nil
226217
}
227218

@@ -237,3 +228,15 @@ func NewEmissionRewardManager() *EmissionRewardManager {
237228
rewardStates: avl.NewTree(),
238229
}
239230
}
231+
232+
func (e *EmissionRewardManager) getRewardState(addr string) (*EmissionRewardState, bool, error) {
233+
ri, ok := e.rewardStates.Get(addr)
234+
if !ok {
235+
return nil, false, nil
236+
}
237+
rs, castOk := ri.(*EmissionRewardState)
238+
if !castOk {
239+
return nil, false, ufmt.Errorf(errFailedToCastRewardState, ri)
240+
}
241+
return rs, true, nil
242+
}

contract/r/gnoswap/v1/gov/staker/emission_reward_manager_test.gno

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,72 @@ func TestEmissionRewardManager_updateAccumulatedRewardX128PerStake(t *testing.T)
392392
})
393393
}
394394
}
395+
396+
// removeStake bug reproduction 1:
397+
// When removeStake is called for a new address (not present in the tree),
398+
// internally a NewEmissionRewardState(...) is created. If rewardStates.Set(...) is missing,
399+
// the new state is not persisted in the tree, and this was a bug.
400+
// With the fixed code, the state should be saved correctly.
401+
func TestEmissionRewardManager_removeStake_persistsStateForNewAddress(t *testing.T) {
402+
manager := NewEmissionRewardManager()
403+
404+
// Prepare: advance global accumulator once for safety
405+
manager.accumulatedRewardX128PerStake = u256.NewUint(0)
406+
manager.updateAccumulatedRewardX128PerStake(0, 10)
407+
408+
// When: calling removeStake on an address that does not exist yet
409+
addr := "user-new"
410+
uassert.Equal(t, manager.totalStakedAmount, int64(0))
411+
uassert.Equal(t, manager.GetTotalStakedAmount(), int64(0))
412+
413+
err := manager.removeStake(addr, 100, 20)
414+
uassert.NoError(t, err)
415+
416+
// Then: state should be persisted in the tree
417+
// (this fails in the buggy version where Set was missing)
418+
if ri, ok := manager.rewardStates.Get(addr); !ok {
419+
t.Fatalf("expected reward state to be persisted for new address after removeStake, but not found (bug)")
420+
} else {
421+
// Type and field checks
422+
rs, castOK := ri.(*EmissionRewardState)
423+
uassert.True(t, castOK)
424+
uassert.NotEqual(t, rs, nil)
425+
uassert.Equal(t, rs.stakedAmount, int64(0)) // clamped to 0, no negative values
426+
// rewardDebtX128 should be a cloned snapshot
427+
uassert.NotEqual(t, rs.rewardDebtX128, nil)
428+
}
429+
}
430+
431+
// removeStake bug reproduction 2 (defensive check):
432+
// After adding stake for an existing address and then removing part of it,
433+
// the updated staked amount must be persisted in the tree.
434+
// In the buggy version, this may rely on pointer aliasing instead of Set persistence.
435+
// This test ensures round-trip read/write correctness.
436+
func TestEmissionRewardManager_removeStake_updatesPersistedState(t *testing.T) {
437+
manager := NewEmissionRewardManager()
438+
439+
addr := "user1"
440+
// Given: add stake → creates and saves state
441+
err := manager.addStake(addr, 1000, 50)
442+
uassert.NoError(t, err)
443+
uassert.Equal(t, manager.GetTotalStakedAmount(), int64(1000))
444+
445+
// When: remove part of the stake
446+
err = manager.removeStake(addr, 300, 100)
447+
uassert.NoError(t, err)
448+
uassert.Equal(t, manager.GetTotalStakedAmount(), int64(700))
449+
450+
// Then: re-read from tree should reflect the reduced stake
451+
ri, ok := manager.rewardStates.Get(addr)
452+
uassert.True(t, ok)
453+
rs, castOK := ri.(*EmissionRewardState)
454+
uassert.True(t, castOK)
455+
uassert.NotEqual(t, rs, nil)
456+
uassert.Equal(t, rs.stakedAmount, int64(700))
457+
458+
// Extra check: subsequent operations should remain consistent
459+
claimed, err := manager.claimRewards(addr, 120)
460+
uassert.NoError(t, err)
461+
// Since no rewards were distributed, claimed amount should be zero
462+
uassert.Equal(t, claimed, int64(0))
463+
}

0 commit comments

Comments
 (0)