From 3ace7f3c112ab7f89161c52a9ad20d0b94289579 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:38:58 -0700 Subject: [PATCH] fix(accounts): declare unpriceable positions instead of dropping them silently /api/v1/accounts/{ss58}/positions published position_count 0, total_stake_alpha 0 and NO degraded block for coldkeys that demonstrably hold positions in the ledger it reads. Coldkey 5Df7xwEP... is the first row of chain.nominator_positions (hotkey 5FyVinYp..., netuid 18, share_fraction 1) and the route answered zero -- indistinguishable from "this account delegates nothing". buildAccountPositions prices each position off the live `neurons` table and skips any it cannot price. That skip is CORRECT and stays: #9066 removed the unpriced_* escape hatches, so a position with no known stake must not be published with a fabricated 0 stake_tao. What was missing is any signal that it happened. `neurons` carries only currently-registered neurons while the position ledger is a snapshot, so the overlap is much worse than it looks: sampling eight distinct hotkeys from the live ledger, only ONE was present in `neurons`. `neurons` itself is healthy -- 129 netuids, 30,085 rows, captured minutes ago -- so this is not a stale-tier problem that will heal on its own. Add POSITIONS_DEGRADED_UNPRICEABLE, set whenever ANY row was dropped for want of a price. Any, not all: a partially-priced total understates the account just as silently and is harder to notice, because the payload looks healthy for carrying positions. A malformed row is deliberately NOT counted -- that is bad data, not an unpriced holding, and conflating them would report a pricing problem that is not happening. The existing annotator's snapshot_predates_stake_activity still wins when both apply. Both mean "do not trust this total"; that one says why more usefully and carries the two provenance stamps this pure builder cannot see. When the annotator does not contradict the zero, this marker survives its pass. No schema change: degraded.reason is a free-form string, and the block is absent on every trustworthy answer, so a consumer that ignores it reads exactly what it read before. Four mutations are each caught: reverting to the silent skip, marking only all-zero results, marking when nothing was dropped, and counting malformed rows as unpriceable. Refs #9305 --- src/account-nominator-positions.ts | 47 ++++++++- tests/account-nominator-positions.test.ts | 115 ++++++++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/src/account-nominator-positions.ts b/src/account-nominator-positions.ts index 5b112971a3..9e00b858e3 100644 --- a/src/account-nominator-positions.ts +++ b/src/account-nominator-positions.ts @@ -109,6 +109,29 @@ export const POSITIONS_DEGRADED_TIER_UNAVAILABLE = "tier_unavailable"; export const POSITIONS_DEGRADED_SNAPSHOT_PREDATES_ACTIVITY = "snapshot_predates_stake_activity"; +/** + * The ledger HAS rows for this coldkey, but one or more of them could not be + * priced, so they are absent from `positions` and from `total_stake_alpha`. + * + * Positions are priced off the live `neurons` table, which carries only + * CURRENTLY-registered neurons, while the position ledger is a snapshot. A + * hotkey that has since deregistered -- or that the ledger saw and `neurons` + * has not -- prices to nothing, and the position is excluded rather than + * reported with a fabricated 0 stake_tao (#9066's rule: no unpriced values). + * + * Excluding it is right. Saying nothing about it was not. Sampling eight + * distinct hotkeys from the live ledger, only ONE was present in `neurons`, so + * a coldkey whose positions all price to nothing published + * `position_count: 0, total_stake_alpha: 0` with no marker at all -- a + * confident zero over real holdings, indistinguishable from "delegates + * nothing" (#9305). + * + * Fires whenever ANY row was dropped, not only when all of them were: a + * partially-priced total understates the account's real position just as + * silently, it is merely harder to notice. + */ +export const POSITIONS_DEGRADED_UNPRICEABLE = "positions_unpriceable"; + export interface AccountPositionsDegraded { reason: string; /** The LEDGER's own capture stamp, not this account's -- present even when @@ -152,6 +175,10 @@ export function buildAccountPositions( const positions: AccountNominatorPosition[] = []; let totalStakeAlpha = 0; let latestCapturedAt: number | null = null; + // Rows the ledger holds that no live `neurons` row could price. Counted + // rather than merely skipped so the payload can say so -- see + // POSITIONS_DEGRADED_UNPRICEABLE. + let unpriceable = 0; for (const row of rows) { const hotkey = typeof row?.hotkey === "string" ? row.hotkey : null; @@ -160,7 +187,10 @@ export function buildAccountPositions( if (!hotkey || netuid == null || fraction == null) continue; const hotkeyStake = stakeByKey.get(`${hotkey}|${netuid}`); - if (hotkeyStake == null) continue; + if (hotkeyStake == null) { + unpriceable += 1; + continue; + } const stakeTao = roundTao(fraction * hotkeyStake); if (stakeTao == null) continue; @@ -190,7 +220,7 @@ export function buildAccountPositions( a.netuid - b.netuid, ); - return { + const result: AccountPositionsResult = { schema_version: 1, ss58, captured_at: latestCapturedAt != null ? toIso(latestCapturedAt) : null, @@ -198,6 +228,19 @@ export function buildAccountPositions( total_stake_alpha: roundTao(totalStakeAlpha) ?? 0, positions, }; + // The two provenance stamps belong to the LEDGER and to this coldkey's chain + // activity, neither of which this pure builder can see -- the callers that + // do have them attach the stronger `snapshot_predates_stake_activity` reason + // over this one when it also applies, which is the right precedence: both + // say "do not trust this total", and that one says why more usefully. + if (unpriceable > 0) { + result.degraded = { + reason: POSITIONS_DEGRADED_UNPRICEABLE, + snapshot_captured_at: null, + latest_stake_event_at: null, + }; + } + return result; } // Distinct, order-stable, non-empty hotkeys referenced by a coldkey's diff --git a/tests/account-nominator-positions.test.ts b/tests/account-nominator-positions.test.ts index 7470f70b6c..646d964da9 100644 --- a/tests/account-nominator-positions.test.ts +++ b/tests/account-nominator-positions.test.ts @@ -3,6 +3,9 @@ import assert from "node:assert/strict"; import { NOMINATOR_POSITION_INSERT_COLUMNS, + POSITIONS_DEGRADED_SNAPSHOT_PREDATES_ACTIVITY, + POSITIONS_DEGRADED_UNPRICEABLE, + annotatePositionsSnapshot, buildAccountPositions, distinctHotkeys, stakeByHotkeyNetuid, @@ -134,6 +137,118 @@ describe("distinctHotkeys", () => { }); }); +describe("unpriceable positions are declared, not silently dropped (#9305)", () => { + // Positions are priced off the live `neurons` table, which carries only + // CURRENTLY-registered neurons, while the position ledger is a snapshot. A + // hotkey that has since deregistered prices to nothing. Excluding it is + // right -- #9066 forbids publishing unpriced values -- but saying nothing + // about it published `position_count: 0, total_stake_alpha: 0` over real + // ledger rows, indistinguishable from "this account delegates nothing". + // + // Live measurement behind this: sampling eight distinct hotkeys from + // chain.nominator_positions, only ONE was present in `neurons`. + const ROW = (hotkey: string, netuid: number) => ({ + coldkey: "5Cold", + hotkey, + netuid, + share_fraction: 0.5, + captured_at: 1_780_000_000_000, + }); + + test("a coldkey whose every position is unpriceable is marked degraded", () => { + const data = buildAccountPositions([ROW("5Gone", 18)], new Map(), "5Cold"); + assert.equal(data.position_count, 0); + assert.equal(data.total_stake_alpha, 0); + assert.equal( + data.degraded?.reason, + POSITIONS_DEGRADED_UNPRICEABLE, + "a zero over real ledger rows must not read as a measurement", + ); + }); + + test("a PARTIALLY priced coldkey is marked too, not just an all-zero one", () => { + // The understatement is the same defect and is harder to notice: the + // payload looks healthy because it carries positions. + const data = buildAccountPositions( + [ROW("5Hk1", 3), ROW("5Gone", 18)], + new Map([["5Hk1|3", 1000]]), + "5Cold", + ); + assert.equal(data.position_count, 1); + assert.equal(data.total_stake_alpha, 500); + assert.equal(data.degraded?.reason, POSITIONS_DEGRADED_UNPRICEABLE); + }); + + test("a fully priced coldkey carries NO degraded block", () => { + // The field's contract is that it is absent on every trustworthy answer, + // so a consumer ignoring it reads exactly what it read before. + const data = buildAccountPositions( + [ROW("5Hk1", 3)], + new Map([["5Hk1|3", 1000]]), + "5Cold", + ); + assert.equal(data.position_count, 1); + assert.equal(data.degraded, undefined); + }); + + test("an account with no ledger rows at all is not marked", () => { + // Nothing was dropped, so nothing is being hidden. That zero IS a + // measurement, and labelling it would cry wolf on every empty account. + const data = buildAccountPositions([], new Map(), "5Cold"); + assert.equal(data.position_count, 0); + assert.equal(data.degraded, undefined); + }); + + test("a malformed row is not counted as unpriceable", () => { + // A row with no hotkey/netuid/fraction is bad data, not an unpriced + // holding -- conflating them would report a pricing problem that is not + // happening. + const data = buildAccountPositions( + [{ coldkey: "5Cold", hotkey: null, netuid: 3, share_fraction: 0.5 }], + new Map(), + "5Cold", + ); + assert.equal(data.position_count, 0); + assert.equal(data.degraded, undefined); + }); + + test("the snapshot annotator's stronger reason wins when both apply", () => { + // Both mean "do not trust this total"; `snapshot_predates_stake_activity` + // says why more usefully, and carries the two stamps this pure builder + // cannot see. + const built = buildAccountPositions([ROW("5Gone", 18)], new Map(), "5Cold"); + assert.equal(built.degraded?.reason, POSITIONS_DEGRADED_UNPRICEABLE); + const annotated = annotatePositionsSnapshot(built, { + snapshotCapturedAtMs: 1_780_000_000_000, + latestStakeEventMs: 1_785_000_000_000, + }); + assert.equal( + annotated.degraded?.reason, + POSITIONS_DEGRADED_SNAPSHOT_PREDATES_ACTIVITY, + ); + assert.equal( + annotated.degraded?.latest_stake_event_at, + new Date(1_785_000_000_000).toISOString(), + ); + }); + + test("the unpriceable reason survives an annotator pass that does not contradict", () => { + // The annotator only replaces `degraded` when a newer stake event + // contradicts the zero. Otherwise this marker must not be erased. + const built = buildAccountPositions([ROW("5Gone", 18)], new Map(), "5Cold"); + const annotated = annotatePositionsSnapshot(built, { + snapshotCapturedAtMs: 1_785_000_000_000, + latestStakeEventMs: null, + }); + assert.equal(annotated.degraded?.reason, POSITIONS_DEGRADED_UNPRICEABLE); + assert.equal( + annotated.captured_at, + new Date(1_785_000_000_000).toISOString(), + "the ledger stamp is still attached", + ); + }); +}); + describe("buildAccountPositions", () => { test("joins share_fraction against live neurons stake_tao to produce stake_tao", () => { const data = buildAccountPositions(