Skip to content

feat: index staking positions, nominations, validators and eras - #357

Open
prashantasdeveloper wants to merge 4 commits into
redesign/08-coveragefrom
redesign/09-staking
Open

prashantasdeveloper wants to merge 4 commits into
redesign/08-coveragefrom
redesign/09-staking

Conversation

@prashantasdeveloper

Copy link
Copy Markdown
Contributor

Summary

Plan 07 — Staking. Turns the staking pallet's raw event log into queryable position state on
top 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/unbonding mirror the same
    staking.ledger chain read AccountBalance.bonded already uses — a second view onto that
    number, never an independently accumulated one that could drift.
  • Nomination — one row per identity→validator nomination, diffed against the target set on
    every nominate call (targets no longer nominated close, matching ones stay open, new ones
    open).
  • Validator — commission/blocked (ValidatorPrefsSet), permissioning
    (PermissionedIdentityAdded/Removed), and active-set membership (StakersElected).
  • Era — opened by StakersElected (which resolves the era index and elected validator set
    from chain storage, since the event itself carries neither) and closed by EraPaid
    (payout/remainder/total staked).
  • StakingEvent gains eraIndex/position, reusing the same PayoutStarted-fed cache
    PolyxEntry.eraIndex already relies on.

Also retires two defects in the existing mapStakingEvent.ts log path along the way: B1
(extract8xStakingAmount, a value-shape guessing heuristic) and B3 (an unhandled v8 staking
event silently dropped amount with no record — now an IndexerAnomaly).

Staking Fixes

  • handleNominated never persisted the StakingPosition it created — a dangling relation on any
    stash's first nomination.
  • A failed session.validators()/era-set read at StakersElected collapsed to "nobody elected,"
    deactivating every validator on a transient RPC error instead of leaving the prior set alone.
  • Era resolution was reading the wrong storage. Verified against pallet-staking's actual
    try_trigger_new_era source: StakersElected fires before ActiveEra/session.validators()
    update, so both were still reporting the outgoing era/set at that moment. Switched to
    staking.currentEra() (bumped in the same block) and staking.erasStakers(eraIndex) keys (the
    newly-elected set, populated in that same block).
  • project.ts ran handleStakingEvent before the handler that creates StakingPosition for
    Bonded/Nominated — so StakingEvent.position was null on a stash's very first bond or
    nomination. Reordered.
  • get8xStakingEventDetails read .stash unconditionally before its switch, which would throw
    (crash the handler) instead of recording the anomaly it advertises, for any future event without
    a stash field.
  • Chilled/Kicked had 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-v8
    occurrence during a full resync.
  • Non-null Account relations (StakingPosition.stash/.controller, Validator.account,
    Nomination.validator) were written from raw addresses with no guarantee the Account row
    existed — fixed via ledgerAccount.
  • Minor: a stale JSDoc comment left behind by extract8xStakingAmount's deletion; isChilled
    never cleared on re-nomination/re-validation; a ledger-read-fallback delta that could go
    negative; a Nomination id collision risk within a batched block.

Consumer impact

Purely additive. StakingEvent is unchanged apart from two new nullable fields; the portal's
stakingEvents query (eventId: {in: [Reward, Rewarded]}) is untouched. New capability: current
bonded/unbonding/nominations per account, validator status, and per-era reward aggregation via
groupedAggregates(groupBy: [ERA_INDEX]) on StakingEvent — none expressible before.

Test plan

  • yarn codegen
  • yarn typecheck
  • yarn lint
  • yarn build
  • yarn test:unit (602 tests, 57 suites)
  • 10-index-per-entity cap checked on every new/changed entity

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`.
@prashantasdeveloper
prashantasdeveloper requested a review from a team as a code owner September 15, 2026 08:23
@prashantasdeveloper
prashantasdeveloper added this pull request to stack #358 September 15, 2026 08:23
@sonarqubecloud

Copy link
Copy Markdown

@F-OBrien F-OBrien left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.identity is set at creation and never re-stamped — the identityId writes in mapStakingEvent.ts are on StakingEvent. Still true as of redesign/11-holdings-review.
  • applyLedgerOrFallback degrades silently, and leaves unlocking untouched on the fallback path, so unbonding stops agreeing with the chunk list.
  • handleStakersElected returns silently on an unreadable era index, leaving the prior era's validators active — inconsistent with the deliberate, documented handling of the failed readEraValidators a 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.

Comment thread schema.graphql
type StakingPosition @entity {
id: ID! # stash address
stash: Account! @index
controller: Account

@F-OBrien F-OBrien Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread schema.graphql
id: ID! # stash address
stash: Account! @index
controller: Account
identity: Identity @index

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread schema.graphql
untouched, and rows for newly-added targets are created
"""
type Nomination @entity @compositeIndexes(fields: [["position", "eraIndex"]]) {
id: ID! # stash/validator/padId(blockId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants