diff --git a/src/views/yield-dtf/staking/atoms.ts b/src/views/yield-dtf/staking/atoms.ts
index 03938f314..b77c7160e 100644
--- a/src/views/yield-dtf/staking/atoms.ts
+++ b/src/views/yield-dtf/staking/atoms.ts
@@ -8,8 +8,14 @@ import {
rTokenAtom,
rTokenConfigurationAtom,
rTokenStateAtom,
+ stRsrBalanceAtom,
walletAtom,
} from '@/state/atoms'
+import {
+ AccountStakeRecord,
+ calculateStakeLots,
+ calculateStakeRewards,
+} from './stake-accounting'
export const unstakeDelayAtom = atom((get) => {
const params = get(rTokenConfigurationAtom)
@@ -55,7 +61,7 @@ export const pendingRSRSummaryAtom = atom<{
)
})
-const accountStakeHistoryAtom = atomWithLoadable(async (get) => {
+const accountStakeLotsAtom = atomWithLoadable(async (get) => {
const gqlClient = get(gqlClientAtom)
const wallet = get(walletAtom)
const rToken = get(rTokenAtom)
@@ -67,7 +73,12 @@ const accountStakeHistoryAtom = atomWithLoadable(async (get) => {
const request: any = await gqlClient.request(
gql`
query getAccountStakeHistory($id: String!) {
- accountStakeRecords(orderBy: blockNumber, where: { account: $id }) {
+ accountStakeRecords(
+ first: 1000
+ orderBy: blockNumber
+ orderDirection: asc
+ where: { account: $id }
+ ) {
exchangeRate
amount
rsrAmount
@@ -82,83 +93,22 @@ const accountStakeHistoryAtom = atomWithLoadable(async (get) => {
return null
}
- let stakes: [number, number, number][] = []
- let totalRewardBalance = 0
-
- for (const record of request.accountStakeRecords as {
- exchangeRate: string
- amount: string
- rsrAmount: bigint
- isStake: string | boolean
- }[]) {
- const recordAmount = Number(record.amount)
- const recordExchangeRate = Number(record.exchangeRate)
-
- if (record.isStake === 'true' || record.isStake === true) {
- stakes.push([recordAmount, recordExchangeRate, Number(record.rsrAmount)])
- } else {
- let stakesRewarded = 0
- let unstake = recordAmount
-
- // Calculate current stake rewards
- for (let i = 0; i < stakes.length; i++) {
- const [stakeAmount, stakeExchangeRate, stakeRsrAmount] = stakes[i]
- // Calculate rewards from this stake and keep going
- const snapshotRsrAmount =
- Math.min(unstake, stakeAmount) * stakeExchangeRate
- const currentRsrAmount =
- Math.min(unstake, stakeAmount) * recordExchangeRate
-
- // Count rewards
- totalRewardBalance += currentRsrAmount - snapshotRsrAmount
-
- if (stakeAmount > unstake) {
- stakes[i] = [
- stakeAmount - unstake,
- stakeExchangeRate,
- stakeRsrAmount - snapshotRsrAmount,
- ]
- break
- } else if (stakeAmount === unstake) {
- stakesRewarded++
- break
- } else {
- // Continue counting rewards
- unstake = unstake - stakeAmount
- stakesRewarded++
- }
- }
- // Remove accrued stakes
- stakes = stakes.slice(stakesRewarded)
- }
- }
-
- return {
- stakes,
- totalRewardBalance,
- }
+ return calculateStakeLots(request.accountStakeRecords as AccountStakeRecord[])
})
const exchangeRateAtom = atom((get) => get(rTokenStateAtom).exchangeRate)
// TODO: Check re-renders on exchangeRateUpdate improve memo
export const accountCurrentPositionAtom = atom((get) => {
- const stakeHistory = get(accountStakeHistoryAtom)
+ const lots = get(accountStakeLotsAtom)
const exchangeRate = get(exchangeRateAtom)
+ const stRsrBalance = get(stRsrBalanceAtom)
- let stBalance = 0
- let rsrBalance = 0
-
- if (!stakeHistory) {
+ if (!lots) {
return 0
}
- for (const [stakeAmount, _, stakeRsrAmount] of stakeHistory.stakes) {
- stBalance += stakeAmount
- rsrBalance += stakeRsrAmount
- }
-
- return stBalance * exchangeRate - rsrBalance
+ return calculateStakeRewards(lots, exchangeRate, +stRsrBalance.balance)
})
export const rateAtom = atom((get) => {
diff --git a/src/views/yield-dtf/staking/components/stake-position.tsx b/src/views/yield-dtf/staking/components/stake-position.tsx
index 4a43c8489..654be9a95 100644
--- a/src/views/yield-dtf/staking/components/stake-position.tsx
+++ b/src/views/yield-dtf/staking/components/stake-position.tsx
@@ -16,12 +16,7 @@ const StakePosition = ({ className }: StakePositionProps) => {
const rate = useAtomValue(rateAtom)
const balance = useAtomValue(stRsrBalanceAtom)
const rsrPrice = useAtomValue(rsrPriceAtom)
- let rewards = useAtomValue(accountCurrentPositionAtom)
-
- // Prevent the case when the user withdraws and rewards get stuck awaiting for subgraph
- if (!balance.value && rewards) {
- rewards = 0
- }
+ const rewards = useAtomValue(accountCurrentPositionAtom)
return (
diff --git a/src/views/yield-dtf/staking/stake-accounting.ts b/src/views/yield-dtf/staking/stake-accounting.ts
new file mode 100644
index 000000000..aa8e36ba5
--- /dev/null
+++ b/src/views/yield-dtf/staking/stake-accounting.ts
@@ -0,0 +1,87 @@
+export interface AccountStakeRecord {
+ exchangeRate: string
+ amount: string
+ rsrAmount: string
+ isStake: string | boolean
+}
+
+// Open stake, in stRSR, together with the RSR that was paid for it
+export interface StakeLot {
+ amount: number
+ rsrAmount: number
+}
+
+const isStakeRecord = (record: AccountStakeRecord) =>
+ record.isStake === true || record.isStake === 'true'
+
+/**
+ * stRSR is fungible, so an unstake burns a proportional slice of every open
+ * stake rather than a specific one: unstaking x% of the position removes x%
+ * of both its stRSR and its RSR cost basis, leaving the remaining rewards at
+ * (100 - x)% of what they were.
+ */
+export const calculateStakeLots = (
+ records: AccountStakeRecord[]
+): StakeLot[] => {
+ let lots: StakeLot[] = []
+
+ for (const record of records) {
+ const amount = Number(record.amount)
+
+ if (isStakeRecord(record)) {
+ lots.push({ amount, rsrAmount: Number(record.rsrAmount) })
+ continue
+ }
+
+ const staked = lots.reduce((total, lot) => total + lot.amount, 0)
+
+ if (!staked) {
+ continue
+ }
+
+ const remainingShare = Math.max(1 - amount / staked, 0)
+
+ if (!remainingShare) {
+ lots = []
+ continue
+ }
+
+ lots = lots.map(({ amount, rsrAmount }) => ({
+ amount: amount * remainingShare,
+ rsrAmount: rsrAmount * remainingShare,
+ }))
+ }
+
+ return lots
+}
+
+/**
+ * RSR accrued by the open position: what it is worth now minus what it cost.
+ *
+ * Stake records go missing from the subgraph — accounts that unstaked
+ * everything can still have open stakes according to their records — so the
+ * reconstruction is trusted only up to the stRSR the account actually holds.
+ * Rewards are scaled by the share of the reconstructed position still held,
+ * and stRSR the records cannot account for accrues nothing.
+ */
+export const calculateStakeRewards = (
+ lots: StakeLot[],
+ exchangeRate: number,
+ stRsrBalance: number
+) => {
+ const staked = lots.reduce((total, lot) => total + lot.amount, 0)
+
+ if (!staked) {
+ return 0
+ }
+
+ const heldShare = Math.min(stRsrBalance / staked, 1)
+
+ return (
+ lots.reduce(
+ (rewards, { amount, rsrAmount }) =>
+ rewards + amount * exchangeRate - rsrAmount,
+ 0
+ ) * heldShare
+ )
+}
diff --git a/src/views/yield-dtf/staking/tests/stake-accounting.test.ts b/src/views/yield-dtf/staking/tests/stake-accounting.test.ts
new file mode 100644
index 000000000..a6014b1a2
--- /dev/null
+++ b/src/views/yield-dtf/staking/tests/stake-accounting.test.ts
@@ -0,0 +1,255 @@
+import { describe, expect, it } from 'vitest'
+import {
+ AccountStakeRecord,
+ calculateStakeLots,
+ calculateStakeRewards,
+} from '../stake-accounting'
+
+// Mainnet eUSD (0xa0d69e286b938e21cbf7e51d71f6a4c8918f482f) stRSR exchange rate
+const EUSD_EXCHANGE_RATE = 1.244143154429324799
+
+const totalStaked = (records: AccountStakeRecord[]) =>
+ calculateStakeLots(records).reduce((total, lot) => total + lot.amount, 0)
+
+// Rewards of an account still holding every stRSR its records account for
+const rewards = (records: AccountStakeRecord[], exchangeRate: number) =>
+ calculateStakeRewards(
+ calculateStakeLots(records),
+ exchangeRate,
+ totalStaked(records)
+ )
+
+// accountStakeRecords of 0x34a5e1bcda39f63d8937f324ebd2cfda542ccbc9 on eUSD:
+// a single stake followed by an unstake of ~42% of it
+const singleStakeThenPartialUnstake: AccountStakeRecord[] = [
+ {
+ exchangeRate: '1.059352369827342793',
+ amount: '35302145.976314919150553913',
+ rsrAmount: '37397412',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.151888699462179604',
+ amount: '15000000',
+ rsrAmount: '17112997.674930621046463892',
+ isStake: false,
+ },
+]
+
+// accountStakeRecords of 0xcfc0805e42589d04a5ab4bcaff49f81d5210e065 on eUSD:
+// seven stakes, an unstake of ~48% of the position, then two more stakes
+const stakesAroundUnstake: AccountStakeRecord[] = [
+ {
+ exchangeRate: '1.189888393685152873',
+ amount: '10891233.227153233216585439',
+ rsrAmount: '12959352.009907724372934168',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.194633486414740868',
+ amount: '3415251.6372068416071641',
+ rsrAmount: '4079973.970340058223310731',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.201737637618299924',
+ amount: '24512731.835489596197586702',
+ rsrAmount: '29457872.447552157598656256',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.205646425580189764',
+ amount: '35570647.262567941235833602',
+ rsrAmount: '42885623.72768879745574547',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.221916268384512936',
+ amount: '8752076.504239112215456527',
+ rsrAmount: '10694304.662675625636786109',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.2383605237070746',
+ amount: '11283474.185022347426831686',
+ rsrAmount: '13973009.00099953',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.239956337547501046',
+ amount: '41251776.729468318672594981',
+ rsrAmount: '51150401.990798764853331774',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.240360158212865953',
+ amount: '65677185.129126589509041339',
+ rsrAmount: '81463363.737739143568304617',
+ isStake: false,
+ },
+ {
+ exchangeRate: '1.241276361599260083',
+ amount: '4869006.612171078293273314',
+ rsrAmount: '6043782.81215845303309987',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.244143154429324799',
+ amount: '16075320.535902307588826974',
+ rsrAmount: '20000000',
+ isStake: true,
+ },
+]
+
+// accountStakeRecords of 0xcde4bfa44a874fe9e482caa4e2fe09996498a683 on eUSD:
+// the account holds no stRSR on chain, but the subgraph never recorded the
+// unstakes that emptied it
+const unrecordedUnstakes: AccountStakeRecord[] = [
+ {
+ exchangeRate: '0.988395296534066387',
+ amount: '7951003.174021738097224151',
+ rsrAmount: '7858734.139930518545479687',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.115840233388174727',
+ amount: '62934.195902751771393208',
+ rsrAmount: '70224.507844219810045868',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.171691014375621023',
+ amount: '1202090.605488673480292603',
+ rsrAmount: '1408478.760916428298611293',
+ isStake: false,
+ },
+ {
+ exchangeRate: '1.171787176200367621',
+ amount: '546093.757112913310820019',
+ rsrAmount: '639905.661587989010458533',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.171787176200367621',
+ amount: '81914.063566936996623002',
+ rsrAmount: '95985.849238198516286138',
+ isStake: false,
+ },
+]
+
+// accountStakeRecords of 0x58915ae59cb0d6c7664b2d90deb3726b721367c3 on eUSD:
+// staked at parity, fully unstaked later
+const fullUnstake: AccountStakeRecord[] = [
+ {
+ exchangeRate: '1',
+ amount: '2000000',
+ rsrAmount: '2000000',
+ isStake: true,
+ },
+ {
+ exchangeRate: '1.025436163475435016',
+ amount: '2000000',
+ rsrAmount: '2039612.947908902052482434',
+ isStake: false,
+ },
+]
+
+describe('calculateStakeLots', () => {
+ it('reproduces the stRSR balance held on chain', () => {
+ // stRSR balances of both accounts as reported by the subgraph
+ expect(totalStaked(singleStakeThenPartialUnstake)).toBeCloseTo(
+ 20302145.976314919,
+ 6
+ )
+ expect(totalStaked(stakesAroundUnstake)).toBeCloseTo(90944333.40009418, 6)
+ })
+
+ it('drops every lot on a full unstake', () => {
+ expect(calculateStakeLots(fullUnstake)).toEqual([])
+ expect(rewards(fullUnstake, EUSD_EXCHANGE_RATE)).toBe(0)
+ })
+
+ it('leaves the stake of an in-progress unstake out of the position', () => {
+ // stRSR is burnt when the unstake is queued, so the RSR waiting out the
+ // cooldown no longer counts towards the position or its rewards
+ const [stake, unstake] = singleStakeThenPartialUnstake
+
+ expect(totalStaked(singleStakeThenPartialUnstake)).toBeCloseTo(
+ Number(stake.amount) - Number(unstake.amount),
+ 6
+ )
+ })
+
+ it('never leaves a lot behind when the unstake exceeds the tracked history', () => {
+ const [stake, unstake] = singleStakeThenPartialUnstake
+
+ expect(
+ calculateStakeLots([stake, { ...unstake, amount: '100000000' }])
+ ).toEqual([])
+ })
+
+ it('reads isStake as either a boolean or a string', () => {
+ const asStrings = stakesAroundUnstake.map((record) => ({
+ ...record,
+ isStake: String(record.isStake),
+ }))
+
+ expect(totalStaked(asStrings)).toBe(totalStaked(stakesAroundUnstake))
+ })
+})
+
+describe('calculateStakeRewards', () => {
+ it('keeps rewards proportional to the share of the position still staked', () => {
+ const [stake, unstake] = singleStakeThenPartialUnstake
+ const stakedShare = 1 - Number(unstake.amount) / Number(stake.amount)
+
+ expect(
+ rewards(singleStakeThenPartialUnstake, EUSD_EXCHANGE_RATE)
+ ).toBeCloseTo(rewards([stake], EUSD_EXCHANGE_RATE) * stakedShare, 6)
+ expect(
+ rewards(singleStakeThenPartialUnstake, EUSD_EXCHANGE_RATE)
+ ).toBeCloseTo(3751649.484067209, 6)
+ })
+
+ it('keeps the rewards of earlier stakes when a later stake is unstaked', () => {
+ // Matching the unstake against whole stakes oldest-first used to close the
+ // cheapest lots outright and report 781_861 RSR for this account
+ expect(rewards(stakesAroundUnstake, EUSD_EXCHANGE_RATE)).toBeCloseTo(
+ 1871984.7830356685,
+ 6
+ )
+ })
+
+ it('has no rewards without stakes', () => {
+ expect(rewards([], EUSD_EXCHANGE_RATE)).toBe(0)
+ })
+
+ it('has no rewards once the stRSR is gone, even if records say otherwise', () => {
+ const lots = calculateStakeLots(unrecordedUnstakes)
+
+ // The records leave stake open and would report ~1.7M RSR of rewards
+ expect(lots.length).toBeGreaterThan(0)
+ expect(calculateStakeRewards(lots, EUSD_EXCHANGE_RATE, 0)).toBe(0)
+ })
+
+ it('scales rewards down to the stRSR actually held', () => {
+ const lots = calculateStakeLots(stakesAroundUnstake)
+ const held = lots.reduce((total, lot) => total + lot.amount, 0) / 4
+
+ expect(calculateStakeRewards(lots, EUSD_EXCHANGE_RATE, held)).toBeCloseTo(
+ 1871984.7830356685 / 4,
+ 6
+ )
+ })
+
+ it('does not credit rewards to stRSR the records cannot explain', () => {
+ // Cancelling an unstake mints stRSR back; until it shows up in the records
+ // it has no cost basis to earn rewards against
+ const lots = calculateStakeLots(stakesAroundUnstake)
+ const held = lots.reduce((total, lot) => total + lot.amount, 0)
+
+ expect(
+ calculateStakeRewards(lots, EUSD_EXCHANGE_RATE, held * 3)
+ ).toBeCloseTo(calculateStakeRewards(lots, EUSD_EXCHANGE_RATE, held), 6)
+ })
+})
diff --git a/src/views/yield-dtf/staking/tests/stake-calculation.test.ts b/src/views/yield-dtf/staking/tests/stake-calculation.test.ts
deleted file mode 100644
index 82770bccc..000000000
--- a/src/views/yield-dtf/staking/tests/stake-calculation.test.ts
+++ /dev/null
@@ -1,360 +0,0 @@
-import { describe, it, expect } from 'vitest'
-
-/**
- * This file tests the stake/unstake LIFO matching logic used in accountStakeHistoryAtom.
- *
- * The logic is extracted here for testing. The actual atom at atoms.ts uses this same algorithm
- * but wrapped in async GraphQL fetching.
- *
- * BUG VERIFICATION: The isStake field from subgraph is a string ("true"/"false"), not boolean.
- * The current implementation uses `if (record.isStake)` which treats "false" as truthy.
- */
-
-type StakeRecord = {
- exchangeRate: string
- amount: string
- rsrAmount: bigint
- isStake: string // This is a string from the subgraph, not boolean!
-}
-
-// This is the exact logic from accountStakeHistoryAtom, extracted for testing
-function calculateStakeHistory(records: StakeRecord[]) {
- let stakes: [number, number, number][] = []
- let totalRewardBalance = 0
-
- for (const record of records) {
- const recordAmount = Number(record.amount)
- const recordExchangeRate = Number(record.exchangeRate)
-
- // CURRENT IMPLEMENTATION - potentially buggy
- // This treats "false" string as truthy
- if (record.isStake) {
- stakes.push([recordAmount, recordExchangeRate, Number(record.rsrAmount)])
- } else {
- let stakesRewarded = 0
- let unstake = recordAmount
-
- for (let i = 0; i < stakes.length; i++) {
- const [stakeAmount, stakeExchangeRate, stakeRsrAmount] = stakes[i]
- const snapshotRsrAmount =
- Math.min(unstake, stakeAmount) * stakeExchangeRate
- const currentRsrAmount =
- Math.min(unstake, stakeAmount) * recordExchangeRate
-
- totalRewardBalance += currentRsrAmount - snapshotRsrAmount
-
- if (stakeAmount > unstake) {
- stakes[i] = [
- stakeAmount - unstake,
- stakeExchangeRate,
- stakeRsrAmount - snapshotRsrAmount,
- ]
- break
- } else if (stakeAmount === unstake) {
- stakesRewarded++
- break
- } else {
- unstake = unstake - stakeAmount
- stakesRewarded++
- }
- }
- stakes = stakes.slice(stakesRewarded)
- }
- }
-
- return { stakes, totalRewardBalance }
-}
-
-// FIXED version for comparison
-function calculateStakeHistoryFixed(records: StakeRecord[]) {
- let stakes: [number, number, number][] = []
- let totalRewardBalance = 0
-
- for (const record of records) {
- const recordAmount = Number(record.amount)
- const recordExchangeRate = Number(record.exchangeRate)
-
- // FIXED: Compare string value explicitly
- if (record.isStake === 'true' || (record.isStake as any) === true) {
- stakes.push([recordAmount, recordExchangeRate, Number(record.rsrAmount)])
- } else {
- let stakesRewarded = 0
- let unstake = recordAmount
-
- for (let i = 0; i < stakes.length; i++) {
- const [stakeAmount, stakeExchangeRate, stakeRsrAmount] = stakes[i]
- const snapshotRsrAmount =
- Math.min(unstake, stakeAmount) * stakeExchangeRate
- const currentRsrAmount =
- Math.min(unstake, stakeAmount) * recordExchangeRate
-
- totalRewardBalance += currentRsrAmount - snapshotRsrAmount
-
- if (stakeAmount > unstake) {
- stakes[i] = [
- stakeAmount - unstake,
- stakeExchangeRate,
- stakeRsrAmount - snapshotRsrAmount,
- ]
- break
- } else if (stakeAmount === unstake) {
- stakesRewarded++
- break
- } else {
- unstake = unstake - stakeAmount
- stakesRewarded++
- }
- }
- stakes = stakes.slice(stakesRewarded)
- }
- }
-
- return { stakes, totalRewardBalance }
-}
-
-describe('Stake LIFO Calculation Logic', () => {
- describe('basic stake/unstake flow', () => {
- it('handles single stake without unstake', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'true',
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
-
- expect(result.stakes.length).toBe(1)
- expect(result.stakes[0][0]).toBe(100) // amount
- expect(result.totalRewardBalance).toBe(0)
- })
-
- it('calculates rewards on full unstake', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'true',
- },
- {
- // Exchange rate increased - user earned rewards
- exchangeRate: '1.1',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'false',
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
-
- expect(result.stakes.length).toBe(0) // All unstaked
- // Rewards: 100 * 1.1 - 100 * 1.0 = 10
- expect(result.totalRewardBalance).toBeCloseTo(10, 5)
- })
-
- it('handles partial unstake (LIFO matching)', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'true',
- },
- {
- exchangeRate: '1.1',
- amount: '50', // Partial unstake
- rsrAmount: 50n,
- isStake: 'false',
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
-
- expect(result.stakes.length).toBe(1)
- expect(result.stakes[0][0]).toBe(50) // 100 - 50 remaining
- // Rewards on 50 tokens: 50 * 1.1 - 50 * 1.0 = 5
- expect(result.totalRewardBalance).toBeCloseTo(5, 5)
- })
-
- it('handles multiple stakes with full unstake spanning entries', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '50',
- rsrAmount: 50n,
- isStake: 'true',
- },
- {
- exchangeRate: '1.0',
- amount: '50',
- rsrAmount: 50n,
- isStake: 'true',
- },
- {
- // Unstake 75 - spans first stake (50) + part of second (25)
- exchangeRate: '1.2',
- amount: '75',
- rsrAmount: 75n,
- isStake: 'false',
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
-
- expect(result.stakes.length).toBe(1)
- expect(result.stakes[0][0]).toBe(25) // 100 - 75 remaining
- // Rewards: 75 * 1.2 - 75 * 1.0 = 15
- expect(result.totalRewardBalance).toBeCloseTo(15, 5)
- })
- })
-
- describe('BUG: isStake string type handling', () => {
- it('BUG TEST: treats isStake="false" correctly as unstake', () => {
- // This test verifies the bug where "false" string is truthy
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'true',
- },
- {
- exchangeRate: '1.1',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'false', // String "false", not boolean false
- },
- ]
-
- // Current buggy implementation
- const buggyResult = calculateStakeHistory(records)
-
- // Fixed implementation
- const fixedResult = calculateStakeHistoryFixed(records)
-
- // BUG: The buggy version treats "false" as truthy, so it ADDS another stake
- // instead of processing an unstake
- expect(buggyResult.stakes.length).toBe(2) // Bug: added as stake
- expect(fixedResult.stakes.length).toBe(0) // Fixed: processed as unstake
-
- // Bug: No rewards calculated because unstake wasn't processed
- expect(buggyResult.totalRewardBalance).toBe(0)
- expect(fixedResult.totalRewardBalance).toBeCloseTo(10, 5) // Correct rewards
- })
-
- it('handles boolean true correctly', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '100',
- rsrAmount: 100n,
- isStake: true as any, // Some implementations might send boolean
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
- expect(result.stakes.length).toBe(1)
- })
-
- it('handles boolean false correctly', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'true',
- },
- {
- exchangeRate: '1.1',
- amount: '100',
- rsrAmount: 100n,
- isStake: false as any, // Some implementations might send boolean
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
- expect(result.stakes.length).toBe(0)
- expect(result.totalRewardBalance).toBeCloseTo(10, 5)
- })
- })
-
- describe('edge cases', () => {
- it('handles empty records array', () => {
- const result = calculateStakeHistoryFixed([])
-
- expect(result.stakes.length).toBe(0)
- expect(result.totalRewardBalance).toBe(0)
- })
-
- it('handles exact stake-unstake match', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'true',
- },
- {
- exchangeRate: '1.0', // Same rate - no rewards
- amount: '100',
- rsrAmount: 100n,
- isStake: 'false',
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
-
- expect(result.stakes.length).toBe(0)
- expect(result.totalRewardBalance).toBe(0) // No change in exchange rate
- })
-
- it('handles negative rewards (exchange rate dropped)', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '100',
- rsrAmount: 100n,
- isStake: 'true',
- },
- {
- exchangeRate: '0.9', // Rate dropped - loss
- amount: '100',
- rsrAmount: 100n,
- isStake: 'false',
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
-
- expect(result.stakes.length).toBe(0)
- // Loss: 100 * 0.9 - 100 * 1.0 = -10
- expect(result.totalRewardBalance).toBeCloseTo(-10, 5)
- })
-
- it('handles large amounts without precision loss', () => {
- const records: StakeRecord[] = [
- {
- exchangeRate: '1.0',
- amount: '1000000000', // 1 billion
- rsrAmount: 1000000000n,
- isStake: 'true',
- },
- {
- exchangeRate: '1.000001', // Small increase
- amount: '1000000000',
- rsrAmount: 1000000000n,
- isStake: 'false',
- },
- ]
-
- const result = calculateStakeHistoryFixed(records)
-
- expect(result.stakes.length).toBe(0)
- // Rewards: 1B * 1.000001 - 1B * 1.0 = 1000
- expect(result.totalRewardBalance).toBeCloseTo(1000, 0)
- })
- })
-})