Skip to content

Commit 8e27826

Browse files
committed
fix(ui): recover pool global state the bulk read didn't carry
Moving pool state to a single accountInformation read on the registry left two gaps where a pool the bulk request missed stayed missed. `processPoolData` fell back on the presence of the entry, not on what it held. A pool whose state came back partial decodes to `{}`, which is truthy, so it kept the empty entry and returned no lastPayout - which the Status column renders as "Payouts stopped". `algodVer` lost its fallback entirely. It came off LocalPoolInfo, and StakingPoolInfo now reads it only from the bulk map; the per-pool read inside the metrics query keeps lastPayout and discards the algodVer it fetched. Any pool the map lacks reported its node version as "--" for the rest of the session. Both now gate on `isPoolGlobalStateComplete`. It tests lastPayout rather than the field being displayed, because lastPayout is written when the pool is created - stakingPool.algo.ts sets it to the creation round as the first epoch's baseline - so every pool has one and its absence can only mean an incomplete read. Gating on algodVer instead would have been wrong: it is only written once the node daemon reports in, and 88 of MainNet's 283 pools legitimately have none, so keying on it would re-read a third of all pools on every visit and still show "--". The 1020 fixture claimed a missing lastPayout stood for "a pool that has never paid out", which the contract does not do. It now models the case that does occur - a pool whose daemon has never reported, so no algodVer - and the tests around it cover the fallback firing on an incomplete entry and staying put on a complete one.
1 parent 0547d4f commit 8e27826

5 files changed

Lines changed: 98 additions & 14 deletions

File tree

ui/src/api/contracts.spec.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { HttpResponse, http } from 'msw'
2-
import { fetchPoolGlobalStates, processPoolData } from '@/api/contracts'
2+
import { fetchPoolGlobalStates, isPoolGlobalStateComplete, processPoolData } from '@/api/contracts'
33
import { LocalPoolInfo } from '@/interfaces/validator'
44
import {
55
LAST_ROUND,
@@ -48,8 +48,8 @@ describe('fetchPoolGlobalStates', () => {
4848
lastPayout: 1000n,
4949
algodVer: '3.23.1 rel/stable [34171a94] : v0.8.2 [c58270f]',
5050
})
51-
// A pool that has never paid out simply has no lastPayout key
52-
expect(states.get(1020n)?.lastPayout).toBeUndefined()
51+
// A pool whose node daemon has never reported has no algodVer, but still has lastPayout
52+
expect(states.get(1020n)).toEqual({ lastPayout: 1080n })
5353
})
5454

5555
it('warns and returns what it got when algod truncates the created app list', async () => {
@@ -109,10 +109,38 @@ describe('processPoolData', () => {
109109
expect(paths).toContain('/v2/applications/1011')
110110
})
111111

112-
it('leaves lastPayout undefined for a pool that has never paid out', async () => {
112+
it('recovers a pool whose bulk entry came back without lastPayout', async () => {
113+
const paths = trackRequests()
114+
115+
// Truthy, so a presence check would accept it and leave lastPayout undefined - which the
116+
// Status column renders as "payouts stopped"
113117
const poolData = await processPoolData(poolFixture(1020n), { algodVer: '3.23.1' })
114118

115-
expect(poolData.lastPayout).toBeUndefined()
119+
expect(poolData.lastPayout).toBe(1080n)
120+
expect(paths).toContain('/v2/applications/1020')
121+
})
122+
123+
it('does not re-read a complete entry that has no algodVer', async () => {
124+
const paths = trackRequests()
125+
126+
const poolData = await processPoolData(poolFixture(1020n), { lastPayout: 1080n })
127+
128+
expect(poolData.lastPayout).toBe(1080n)
116129
expect(poolData.balance).toBe(AVAILABLE_BALANCE)
130+
expect(paths.some((path) => path.startsWith('/v2/applications/'))).toBe(false)
131+
})
132+
})
133+
134+
describe('isPoolGlobalStateComplete', () => {
135+
it('accepts an entry carrying lastPayout, with or without algodVer', () => {
136+
expect(isPoolGlobalStateComplete({ lastPayout: 1000n })).toBe(true)
137+
expect(isPoolGlobalStateComplete({ lastPayout: 0n, algodVer: '3.23.1' })).toBe(true)
138+
})
139+
140+
it('rejects a missing or partially decoded entry', () => {
141+
expect(isPoolGlobalStateComplete(undefined)).toBe(false)
142+
expect(isPoolGlobalStateComplete({})).toBe(false)
143+
// algodVer alone is not enough - lastPayout is what every pool is guaranteed to have
144+
expect(isPoolGlobalStateComplete({ algodVer: '3.23.1' })).toBe(false)
117145
})
118146
})

ui/src/api/contracts.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,24 @@ function decodePoolGlobalState(globalState: algosdk.modelsv2.TealKeyValue[] = []
179179
return state
180180
}
181181

182+
/**
183+
* Whether a pool's global state was read completely.
184+
*
185+
* `lastPayout` is written when the pool is created - `stakingPool.algo.ts` sets it to the
186+
* creation round to establish the first epoch's baseline - so every pool has it, and all 283
187+
* on MainNet do. Its absence therefore means the read didn't carry this pool's state, not that
188+
* the pool is new, and the caller should recover it with a per-pool request.
189+
*
190+
* `algodVer` is deliberately not part of this test: it is only written once the node daemon
191+
* reports in, so a pool can legitimately lack it (88 of those 283 do) and re-reading won't
192+
* produce one.
193+
*/
194+
export function isPoolGlobalStateComplete(
195+
state: PoolGlobalState | undefined,
196+
): state is PoolGlobalState {
197+
return state?.lastPayout !== undefined
198+
}
199+
182200
/**
183201
* Every staking pool's global state in a single request.
184202
*
@@ -235,8 +253,9 @@ export function createBaseValidator({
235253
}
236254

237255
/**
238-
* @param globalState - the pool's entry from {@link fetchPoolGlobalStates}. Omitted only when
239-
* the bulk read didn't carry this pool, which costs one request to recover.
256+
* @param globalState - the pool's entry from {@link fetchPoolGlobalStates}. Absent or
257+
* incomplete only when the bulk read didn't carry this pool, which costs one request to
258+
* recover.
240259
*/
241260
export async function processPoolData(
242261
pool: LocalPoolInfo,
@@ -247,7 +266,10 @@ export async function processPoolData(
247266
// Define the promises for the async operations
248267
const balancePromise = fetchAccountBalance(poolAddress.toString(), true)
249268

250-
const globalStatePromise = globalState
269+
// Tested on the field rather than on the entry: a pool whose state came back partial decodes
270+
// to `{}`, which is truthy, and would otherwise skip the fallback and surface as a missing
271+
// lastPayout - which the Status column renders as "payouts stopped".
272+
const globalStatePromise = isPoolGlobalStateComplete(globalState)
251273
? Promise.resolve(globalState)
252274
: fetchPoolGlobalState(pool.poolAppId)
253275

ui/src/api/queries.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
fetchAllValidatorData,
99
fetchMbrAmounts,
1010
fetchPoolApy,
11+
fetchPoolGlobalState,
1112
fetchPoolGlobalStates,
1213
fetchProtocolConstraints,
1314
fetchStakedInfoForPool,
@@ -155,6 +156,21 @@ export const poolGlobalStatesQueryOptions = queryOptions({
155156
refetchOnWindowFocus: false,
156157
})
157158

159+
/**
160+
* One pool's global state, for recovering a pool the bulk read above didn't carry - algod caps
161+
* the resources it returns per account, and the request can fail outright. Callers gate this on
162+
* `isPoolGlobalStateComplete`, so on the normal path it never runs.
163+
*/
164+
export const poolGlobalStateQueryOptions = (poolAppId: bigint) =>
165+
queryOptions({
166+
queryKey: ['pool-global-state', String(poolAppId)],
167+
queryFn: () => fetchPoolGlobalState(poolAppId),
168+
enabled: !!poolAppId,
169+
staleTime: METRICS_STALE_TIME,
170+
refetchInterval: POOL_GLOBAL_STATES_REFETCH_INTERVAL,
171+
refetchOnWindowFocus: false,
172+
})
173+
158174
const NO_POOL_GLOBAL_STATES: ReadonlyMap<bigint, PoolGlobalState> = new Map()
159175

160176
export interface ValidatorMetricsInput {

ui/src/components/ValidatorDetails/StakingPoolInfo.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { useQuery } from '@tanstack/react-query'
22
import { ProgressBar } from '@tremor/react'
33
import { Copy } from 'lucide-react'
4-
import { nfdLookupQueryOptions, poolGlobalStatesQueryOptions } from '@/api/queries'
4+
import { isPoolGlobalStateComplete } from '@/api/contracts'
5+
import {
6+
nfdLookupQueryOptions,
7+
poolGlobalStateQueryOptions,
8+
poolGlobalStatesQueryOptions,
9+
} from '@/api/queries'
510
import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount'
611
import { Loading } from '@/components/Loading'
712
import { NfdDisplay } from '@/components/NfdDisplay'
@@ -38,9 +43,21 @@ export function StakingPoolInfo({
3843
// Not stored in the validator box. Comes from the same bulk read of every pool's global
3944
// state that the dashboard's metrics use, so getting here is normally a cache hit.
4045
const poolGlobalStatesQuery = useQuery(poolGlobalStatesQueryOptions)
41-
const algodVersion = poolInfo
42-
? poolGlobalStatesQuery.data?.get(poolInfo.poolAppId)?.algodVer
43-
: undefined
46+
const bulkPoolState = poolInfo ? poolGlobalStatesQuery.data?.get(poolInfo.poolAppId) : undefined
47+
48+
// The bulk read can come back without this pool, and can fail outright. Recover just this one
49+
// rather than reporting the node as version "--" for the rest of the session. Gated on
50+
// lastPayout, not on algodVer: a pool whose daemon has never reported has no algodVer to find,
51+
// and re-reading it every time would put back the per-pool request the bulk read removed.
52+
const poolStateIncomplete =
53+
!!poolInfo && !poolGlobalStatesQuery.isPending && !isPoolGlobalStateComplete(bulkPoolState)
54+
55+
const poolGlobalStateQuery = useQuery({
56+
...poolGlobalStateQueryOptions(poolInfo?.poolAppId ?? 0n),
57+
enabled: poolStateIncomplete,
58+
})
59+
60+
const algodVersion = (poolStateIncomplete ? poolGlobalStateQuery.data : bulkPoolState)?.algodVer
4461

4562
const numPools = validator.state.numPools
4663
const maxStakePerPool = calculateMaxAlgoPerPool(validator, constraints)

ui/src/utils/tests/fixtures/applications.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ function stakingPool(appId: number, globalState: TealKeyValue[]): Application {
4141
export const appFixtures: FixtureData = {
4242
'1010': stakingPool(1010, [bytesValue('algodVer', ALGOD_VERSION), uintValue('lastPayout', 1000)]),
4343
'1011': stakingPool(1011, [bytesValue('algodVer', ALGOD_VERSION), uintValue('lastPayout', 1050)]),
44-
// No lastPayout: stands in for a pool that has never paid out
45-
'1020': stakingPool(1020, [bytesValue('algodVer', ALGOD_VERSION)]),
44+
// No algodVer: the node daemon has never reported one. Every pool has lastPayout from
45+
// creation, so this - not a missing lastPayout - is what a never-updated pool looks like.
46+
'1020': stakingPool(1020, [uintValue('lastPayout', 1080)]),
4647
}

0 commit comments

Comments
 (0)