feat: index staking positions, nominations, validators and eras - #357
prashantasdeveloper wants to merge 4 commits into
Conversation
New `StakingPosition` entity tracking bonded/unbonding/unlocking per stash, sourced from the same `staking.ledger` chain read `AccountBalance.bonded` already uses — a second view onto that number, never an independently accumulated one. Also migrates `mapStakingEvent.ts` off positional `params[i]` destructuring onto the decode layer, fixing two defects along the way: B1 (`extract8xStakingAmount`, a value-shape heuristic, retired along with its test) and B3 (an unhandled v8 staking event silently dropped `amount` with no record — now recorded as an `IndexerAnomaly`).
New `Nomination` entity. `Nominated` (pre-v8 `staking`, v8+ `validators`) now opens a row per target and closes rows for targets no longer nominated, diffing against the currently-open set rather than replacing it wholesale. `Chilled`/`Kicked` close nominations and mark `StakingPosition.isChilled`.
New `Validator` entity. `ValidatorPrefsSet` upserts commission/blocked (pre-v8 and v8+, same shape both eras) and marks the corresponding `StakingPosition` as a validator. `PermissionedIdentityAdded`/`Removed` name an identity rather than a stash, so `isPermissioned` only updates `Validator` rows that already exist for that identity. `SlashReported` — a validator-offence report, not yet a completed slash — is logged through the existing `StakingEvent` path rather than a new entity field.
New `Era` entity. `StakersElected` carries no payload in either era, so the era index and elected validator set are resolved from chain storage — `staking.currentEra()` and `staking.erasStakers(eraIndex)` keys, not `activeEra()`/`session.validators()`, which are still on the outgoing era/set at the moment this event fires. It marks exactly the resolved validator set active, clearing validators no longer elected. `EraPaid` closes the era with the payout figures and `staking.erasTotalStake`. `StakingEvent` gains `eraIndex`/`position`, populated the same way `PolyxEntry.eraIndex` already is — reusing `mapPolyxLedger.ts`'s `currentPayoutEra` cache rather than a second one — and reward/slash events now roll up into `StakingPosition.totalRewarded`/`totalSlashed`.
|
There was a problem hiding this comment.
Reviewed the schema, the shapes and all of the mapping/util implementations, cross-checked against #359 so nothing already fixed there is raised again.
The staking model is well shaped. Era uses startEvent/endEvent rather than the provenance pair, and Nomination uses validFrom/validTo intervals like IdentityKey — so nomination history is first-class instead of overwritten. All five new entities are genuinely populated; no always-null fields. utils/staking.ts is the strongest code in this series: one shared readStakingLedger behind both the balance ledger and the position split, and readRewardDestination explains exactly why it avoids the generated accessors. The shapes are all discontinuedAt(LAST_V7, …), staking is already in CAPTURED_MODULES, and they're documented in event-shape-verification.md — so unlike the pallets added in #356, this domain is covered by the arity contract test.
Four things worth addressing, all inline:
StakingPosition.identityis set at creation and never re-stamped — theidentityIdwrites inmapStakingEvent.tsare onStakingEvent. Still true as ofredesign/11-holdings-review.applyLedgerOrFallbackdegrades silently, and leavesunlockinguntouched on the fallback path, sounbondingstops agreeing with the chunk list.handleStakersElectedreturns silently on an unreadable era index, leaving the prior era's validators active — inconsistent with the deliberate, documented handling of the failedreadEraValidatorsa few lines below.Nomination: the id comment is out of date, and the[position, eraIndex]composite covers a column the handler can leave null.
One comment is a withdrawal rather than an ask: the controller staleness I'd flagged is already fixed in #359, along with several things I hadn't found — the pre-v7 StakingElection/EraPayout routing (testnet had no Era or Validator row before v7), isPermissioned defaulting to false for every validator, the ledger rewrite on compounded rewards and slashes, and getAllByFields returning unhydrated rows that threw on .save(). Worth confirming 357 and 358 land together, since 357 alone carries those.
| type StakingPosition @entity { | ||
| id: ID! # stash address | ||
| stash: Account! @index | ||
| controller: Account |
There was a problem hiding this comment.
EDIT: Withdrawing this — already fixed in #359 (redesign/10-throughput), and more completely than I'd framed it.
There, the payee and controller caches become per-block rather than process-lifetime (F6), and mapStakingPosition.ts re-resolves the controller on every position update and re-stamps it when it changes. The docstring there also names a consequence I'd missed: a stale controller made staking.ledger(oldController) read empty, which readStakingLock reports as a bond of 0 — clearing the stash's staking lock, not just mislabelling the controller.
The per-block scope is also the right answer for sync speed. A payout block fires many Rewarded events at once and they still share one read per stash; the cache only repeats across blocks, where api is bound to the block being indexed anyway, so an entry can't outlive the block it was true for.
One thing worth confirming rather than fixing: these merge as a stack, so 357 in isolation ships a field that 358 then corrects. Fine if they land together — worth a thought only if 357 could ever be merged or deployed on its own.
| id: ID! # stash address | ||
| stash: Account! @index | ||
| controller: Account | ||
| identity: Identity @index |
There was a problem hiding this comment.
identity is set once, at row creation, from account.identityId, and never re-stamped — still true as of redesign/11-holdings-review, so unlike the controller issue this one isn't fixed downstream. The identityId writes in mapStakingEvent.ts are on the StakingEvent entity, not the position.
On whether it could be derived instead: partly, but not the part that matters.
Traversal already works with no stored field — stakingPosition { stash { identity { did } } } answers "whose position is this". What can't be derived is the filter: SubQuery generates filters only over an entity's own columns, and @derivedFrom on Identity isn't possible here because StakingPosition relates to Account, not Identity. So "all staking positions under my identity" — the query you described — needs this column. It earns its place.
On current vs historical: re-stamping gives you both, so they aren't in tension. Under historical mode each _block_range row preserves the value that was current at that block, so a maintained identityId reads as the identity at the time for any historical query and as the current one at head. Leaving it write-once gives "the identity at first bond" forever, which is neither. The per-event record already exists separately on StakingEvent.identity, stamped from the account at that event.
So: keep the field, re-stamp it on update, no schema change. Cost is an Account.get on the bond/unbond/withdraw path — a store read on a cold path, not a chain read on the payout path, since rewards update totalRewarded through mapStakingEvent rather than here.
| const snapshot = await readStakingLedger(stash); | ||
|
|
||
| if (snapshot) { | ||
| position.bonded = snapshot.active; |
There was a problem hiding this comment.
Two things about the fallback path here.
It degrades silently. When the chain read fails the function accumulates deltas instead, and nothing records which path produced the stored value — so a consumer can't tell an authoritative bonded from an approximated one, and neither can we when investigating a discrepancy later. reconcilePolyx records BalanceReconciliationDrift for exactly this class of divergence; an anomaly on the fallback would be consistent with that and with the "unknown is recorded, never guessed" line the review docs take.
unlocking isn't maintained on that path. It's only assigned from snapshot.unlocking (line 80). On the fallback, bonded and unbonding move but unlocking keeps whatever it had, so unbonding stops agreeing with the sum of the chunk list and the row is internally inconsistent. handlePositionUnbonded is the clearest case: it adds to unbonding without adding the corresponding chunk.
Leaving unlocking untouched is arguably right — the fallback genuinely doesn't know the chunk boundaries — but then it needs to be explicit, because the current code reads as though all three fields move together.
| untouched, and rows for newly-added targets are created | ||
| """ | ||
| type Nomination @entity @compositeIndexes(fields: [["position", "eraIndex"]]) { | ||
| id: ID! # stash/validator/padId(blockId) |
There was a problem hiding this comment.
Two small things on this entity.
The id comment is out of date. It says stash/validator/padId(blockId), but nominationId is called with blockEventId, which is padId(block)/padId(eventIdx).
The composite index covers a nullable column. eraIndex is left null when staking.activeEra() can't be read (documented at the top of mapNomination.ts), so a "nominations as of era N" query served by [position, eraIndex] silently omits those rows. That may well be acceptable — but if the null case is expected to be rare, an anomaly when it happens would make it visible; if it's expected to be common, the index is less useful than it looks.
|
|
||
| const eraIndex = await readCurrentEraIndex(); | ||
|
|
||
| if (eraIndex === undefined) { |
There was a problem hiding this comment.
This returns silently, and the consequence is larger than the one line suggests: the era boundary is missed entirely, so no Era row is written and the previous era's validators keep isActive: true until the next election that does resolve. Nothing records that it happened, so the gap in the Era sequence is the only trace, and it looks the same as an era that simply wasn't indexed yet.
What stands out is the contrast with the validators === undefined check a few lines below, which makes exactly this kind of call deliberately and explains it — "a failed read means 'unknown,' not 'nobody elected'" — and keeps the Era row even when the rest can't be completed.
Suggest the same treatment here: record an IndexerAnomaly so a missed boundary is visible rather than inferred from a hole in the sequence. readCurrentEraIndex failing is exactly the "unknown is recorded, never guessed" case the review docs describe.



Summary
Plan 07 — Staking. Turns the
stakingpallet's raw event log into queryable position state ontop of what Phase 4 (POLYX ledger) already built:
StakingPosition— current bonded/unbonding/unlocking per stash, reward destination,validator/chilled status, and reward/slash totals.
bonded/unbondingmirror the samestaking.ledgerchain readAccountBalance.bondedalready uses — a second view onto thatnumber, never an independently accumulated one that could drift.
Nomination— one row per identity→validator nomination, diffed against the target set onevery
nominatecall (targets no longer nominated close, matching ones stay open, new onesopen).
Validator— commission/blocked (ValidatorPrefsSet), permissioning(
PermissionedIdentityAdded/Removed), and active-set membership (StakersElected).Era— opened byStakersElected(which resolves the era index and elected validator setfrom chain storage, since the event itself carries neither) and closed by
EraPaid(payout/remainder/total staked).
StakingEventgainseraIndex/position, reusing the samePayoutStarted-fed cachePolyxEntry.eraIndexalready relies on.Also retires two defects in the existing
mapStakingEvent.tslog path along the way: B1(
extract8xStakingAmount, a value-shape guessing heuristic) and B3 (an unhandled v8 stakingevent silently dropped
amountwith no record — now anIndexerAnomaly).Staking Fixes
handleNominatednever persisted theStakingPositionit created — a dangling relation on anystash's first nomination.
session.validators()/era-set read atStakersElectedcollapsed to "nobody elected,"deactivating every validator on a transient RPC error instead of leaving the prior set alone.
pallet-staking's actualtry_trigger_new_erasource:StakersElectedfires beforeActiveEra/session.validators()update, so both were still reporting the outgoing era/set at that moment. Switched to
staking.currentEra()(bumped in the same block) andstaking.erasStakers(eraIndex)keys (thenewly-elected set, populated in that same block).
project.tsranhandleStakingEventbefore the handler that createsStakingPositionforBonded/Nominated— soStakingEvent.positionwas null on a stash's very first bond ornomination. Reordered.
get8xStakingEventDetailsread.stashunconditionally before itsswitch, which would throw(crash the handler) instead of recording the anomaly it advertises, for any future event without
a
stashfield.Chilled/Kickedhad no pre-v8 decode shape — verified against Polymesh's actual v7.4.0 source(
Chilled{stash},Kicked{nominator,stash}); missing shapes meant a crash on the first pre-v8occurrence during a full resync.
Accountrelations (StakingPosition.stash/.controller,Validator.account,Nomination.validator) were written from raw addresses with no guarantee theAccountrowexisted — fixed via
ledgerAccount.extract8xStakingAmount's deletion;isChillednever cleared on re-nomination/re-validation; a ledger-read-fallback delta that could go
negative; a
Nominationid collision risk within a batched block.Consumer impact
Purely additive.
StakingEventis unchanged apart from two new nullable fields; the portal'sstakingEventsquery (eventId: {in: [Reward, Rewarded]}) is untouched. New capability: currentbonded/unbonding/nominations per account, validator status, and per-era reward aggregation via
groupedAggregates(groupBy: [ERA_INDEX])onStakingEvent— none expressible before.Test plan
yarn codegenyarn typecheckyarn lintyarn buildyarn test:unit(602 tests, 57 suites)