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) - }) - }) -})