Skip to content

perf: throughput fixes, v8 POLYX ledger corrections and a working reconciler - #359

Open
prashantasdeveloper wants to merge 10 commits into
redesign/09-stakingfrom
redesign/10-throughput
Open

prashantasdeveloper wants to merge 10 commits into
redesign/09-stakingfrom
redesign/10-throughput

Conversation

@prashantasdeveloper

Copy link
Copy Markdown
Contributor

Phase 11 (throughput) of the redesign series, stacked on #357 (redesign/09-staking). It also carries the ledger and staking fixes #349 and the PRs stacked on it, and from the first full testnet genesis resync of the staking phase.

Throughput (docs/implementation/11-throughput.md)

Most of what the plan listed (§11.2 address caching, §11.3 paging, §11.4 module state, and the core of §11.1) had already landed in earlier phases. This PR does what was left:

  • NFT rows: per-token Nft rows are bulk-written on mint, burn and transfer, instead of being saved one at a time.
  • New NftHolder: a newly created holder is written once per block instead of twice.
  • Custom asset types: getCustomType reads the indexed CustomAssetType. It falls back to a chain read, recording a MissingReferencedEntity anomaly, only when the type isn't indexed.

Fixes

  • Pre-metadata-v14 CorporateAction: these structs have snake_case toJSON keys. Genesis sync crash-looped at testnet block 90,693 (handleCaInitiated), and RecordDateChanged silently cleared recordDate.
  • NftHolder on a same-identity move: an NFT moved between two portfolios of the same identity, within one block, could lose or duplicate ids.
  • getAllByFields rows: it returned raw store records with no .save(), so handleNominated crash-looped at block 555,566. The test helper had been adding a fake .save(), which hid the bug.
  • Missing Subsidy: a paying key authorised through identity.add_authorization never got a Subsidy row. Accepting the authorisation now creates it.
  • Validator.isPermissioned: it was never true. It is now read from chain when the row is created.

POLYX ledger (v8)

Checked against polkadot-sdk at the commit the chain pins (f4d81a0) and against live v8 blocks.

  • Hold reasons: RuntimeHoldReason is a composite enum, so every v8 hold decoded as Unknown and bonded was always 0.
  • Fees: fees were charged twice, because FungibleAdapter already emits a Withdraw and a refund Deposit. protocolFee.FeeCharged had the same problem.
  • New accounts: they were credited twice (Deposit + Endowed). The two events come in either order depending on the call path (Currency::deposit_creating vs fungible::deposit), so they are now matched by adjacency.
  • Rewards: a reward that creates its destination account was credited twice, in both eras.
  • Controller payee: v8 rewards for this payee went to the stash instead of bonded(stash).
  • Staking slashes: these are now taken from the Staking hold, not from free balance.
  • TransferAndHold: it always posted 0.
  • Memos:
    • They were lost or overwritten when a batch had identical transfers.
    • A pre-v8 memo could carry over to a later transfer that had no memo.
  • Relabelled entries: these left the lifetime and rollup totals counting them under their old kind.
  • Pre-v8 testnet identity grant: when a key that already exists gets a new identity, InitialPOLYX is paid with no balance event. It is now credited on DidCreated, using the value from the metadata constant, so this is a no-op on mainnet.

Staking state

  • Payee/controller caches: they lived for the whole process and never cleared, because set_payee and set_controller emit no event. They are now scoped to one block.
  • StakingPosition: it is now refreshed on compounded rewards and on slashes, which change the ledger without emitting Bonded/Unbonded.
  • Seeded freezes: the seeder now splits the frozen amount into holds, the staking lock and a residual lock. Before, all of it went under a genesis lock that nothing ever cleared.

In-flight reconciliation (D11)

This check had never actually run before this PR.

  • Guard: reconcileBlock had a guard that could never pass, because the block handler runs before that block's events. The chain state is now read when the account is queued.
  • Stale comparisons: with --workers, blocks are processed in sparse ranges, so a comparison could check an old chain snapshot against a row that had already moved on. It is now skipped when the row changed after the snapshot.
  • Sampling: it sampled on height % 2000, but the dictionary only processes ~1.6% of heights, so there were 27 comparisons instead of ~5,700. It now takes one sample per 2000 heights per worker.
  • Liveness report: a compared/drifted/skippedStale summary is logged on a timer, so compared=0 is visible. reconcileStats() exposes the same counts.
  • Drift correction:
    • It now rebuilds locks and holds from chain, for both eras.
    • On v8 it reads balances.locks, so a staking lock that is still present between the two lock→hold migration passes isn't renamed to residual.

Misc

  • strict: tsconfig now sets strict: false explicitly, so the in-editor native preview matches CI.
  • Undefined addresses: handleBalanceSet and the hold handlers now guard against an undefined address instead of writing rows keyed undefined.

Upstream event-order issues found (for the chain team)

  1. polkadot-sdk emits Deposit/Endowed in a different order depending on the path: Currency::deposit_creating emits Deposit → NewAccount → Endowed, and fungible::Balanced::deposit emits NewAccount → Endowed → Deposit.
  2. The pre-v8 balances pallet emits no event for deposits, withdrawals or slashes.
  3. The pre-v8 Transfer event repeats the memo that TransferWithMemo already carries.
  4. The two v8 fee paths emit events in different orders: Withdraw → Deposit(refund) → Deposit(dest) → TransactionFeePaid vs Withdraw → FeeCharged → Deposit(dest).
  5. The block author's fee share before v8 (DealWithFees = Author) has no event.
  6. On pre-v8 testnet, the identity grant to an existing key has no event.

Consumer impact

  • schema.graphql: the only change is the Validator.isPermissioned docstring. There is no field or type change.
  • Existing indexes: the ledger values change (bonded, reserved, fees, rewards). Pre-v8 and v8 history is only correct after a full reindex from genesis.

Verification

  • yarn codegen && yarn typecheck && yarn lint && yarn test:unit (660 tests), plus yarn build and check-handlers.
  • Testnet resync: a full genesis-to-tip resync on the dev server caught up at 25,887,561 and followed the tip, with no restarts and no OOM at the v8 boundary. The fixes in the last commit were applied while that sync was running.
    • Pre-v8 blocks: that sync had already indexed them before those fixes went in, so a clean genesis rerun is needed before sign-off.
    • Anomalies: at the end there were 5 MissingReferencedEntity and 43 BalanceReconciliationDrift rows, all looked into.

Known open items

  • Validator fees before v8: the block author's fee share has no event, so validators show less balance than they have on chain (one by ~1M POLYX). Deciding how to fill this in is still open.
  • Nominator gap: nominators are short by about 2–6 POLYX per reward, still unexplained.
  • Controller payee account: v8 StakingEvent.rewardDestinationAccount is empty for the Controller payee.
  • Deferred: turning on strictNullChecks (249 errors) and the opt-in D11 canary.
  • Measurements: the plan's wall-clock and chain-read counts against real blocks are still to be taken.

…two live bugs

- Bulk-write per-token Nft rows on mint/burn/transfer instead of N individual
  saves; getNftHolder no longer double-saves a brand-new holder.
- Read CustomAssetType from the index instead of an unconditional chain read,
  falling back to the chain read (with an IndexerAnomaly) only when missing.
- Fix the in-flight POLYX reconciliation (D11) safety net, which never
  actually ran: reconcileBlock guarded on a block-number match that could
  never pass, since the block handler runs before that block's own events.
  reconcileAccount now snapshots on-chain state at queue time instead.
- Fix NftHolder losing/duplicating ids on a same-block, same-identity,
  cross-portfolio NFT move: two independent getNftHolder calls raced on the
  same unbuffered row's shared nftIds array. Resolve fromRollup once and
  reuse it as toRollup when the DIDs match.
…ON keys)

Francis's testnet genesis sync of redesign/09-staking crash-loops at block 90,693:
handleCaInitiated throws "Cannot convert undefined to a BigInt". Blocks before metadata
v14 (testnet 1-4,397,817, spec 3000-3010; mainnet had the same pre-5.0 gap) decode the
CorporateAction struct via polymesh-types, so toJSON() keys are snake_case
(default_withholding_tax, decl_date, record_date, withholding_tax) instead of the
camelCase a v14+ metadata decode produces. defaultWithholdingTax read undefined and
BigInt(undefined) threw; RecordDateChanged hit the same mismatch but silently cleared
recordDate instead of throwing.

decodeCorporateAction now reads each field with extractNumber/extractString/extractValue
(utils/common.ts), the same snake-then-camel fallback getCaIdValue already uses for
local_id, instead of blindly casting the JSON to the camelCase interface. Unit tests
passed before this because the test fixture builder already produced camelCase JSON -
added a fixture built from the real pre-v14 event at 90,693 (PredictableBenefit, 0%
withholding) plus a RecordDateChanged regression case.
…and endowments, slash pool

Nine defects found by review against the chain source, the installed @subql/node and live v8
blocks. Each was invisible to the unit suite because the fixtures did not match what the chain
emits, so the fixture layer is corrected alongside: `mockCodec` now stringifies the way polkadot-js
does, and `getByFields` queries the in-memory rows instead of always returning [], which is what
left every cross-event pairing path untested.

F8 — v8's RuntimeHoldReason is a composite enum, so the staking hold is `{ staking: 'Staking' }`
and `toString()` gives `'{"staking":"Staking"}'`, matching no member. Every v8 hold decoded as
Unknown, so `bonded` was always 0 and `otherReserved` always equalled `reserved`. Read the outer
variant from toJSON() instead.

F9 — on v8 the fee is already a balance movement by the time the fee event fires: the runtime pays
through FungibleAdapter, emitting balances.Withdraw (indexed as a Burn) and, when the estimate was
high, a refunding Deposit. Posting a debit on the fee event as well charged every fee-paying
account twice. Re-file the withdrawal as the fee instead, and the refund as its credit side.
protocolFee.FeeCharged had the same shape. Runtimes that emit no paired Withdraw are unaffected.

N2 — a deposit into an account that does not exist yet emits Deposit and then Endowed for the same
account and amount, and both were credited, starting the account at twice its balance. Re-file the
deposit's Mint as the Endowment.

F10 — a v8 staking slash is taken from the Staking hold, not from free: `Currency::slash` runs
through `decrease_balance_on_hold` and `DoneSlashHandler = ()`, so staking.Slashed is the only
event. Debit Reserved and lower the hold.

F2 — TransferAndHold names its amount `transferred`, which the shared name list did not carry, so
every one of them posted 0.

F4 — the reconciler's drift correction filed the whole frozen amount under the staking lock on
every chain version and never touched holds, so a corrected v8 account reported an unrelated freeze
as bonded and had SUM(holds) disagree with reserved. It also wrote a 'staking ' lock that the next
Unlocked would clear. Rebuild locks/holds from chain instead, reading balances.holds on v8 and
staking.ledger pre-v8 — captured at queue time, like the rest of the snapshot.

F3 — two equal transfers to one new account in a batch both paired with the same endowment, losing
the second credit; only pair an endowment that has no counterparty yet.

F11 — a memo was written only to the recipient's entry, and two identical transfers in one
extrinsic overwrote each other's. Write it to every entry of the movement, and queue pending memos
per key so each is consumed once, in order.

F12 — relabelling an entry left lifetimeByKind and the totalFeesPaid/totalRewards/totalSlashed
rollups counting it under the kind it no longer had. One relabelEntry helper now moves both.
…n step with the ledger

F6 — the resolved payee and controller were cached for the life of the process and never cleared.
Neither `set_payee` nor `set_controller` emits an event, so there was nothing to invalidate on and
both went stale silently and permanently: a changed payee kept crediting later rewards to the old
destination, and a changed controller made `staking.ledger(oldController)` read empty — which
readStakingLock reports as a bond of 0, clearing the stash's staking lock, and which
StakingPosition reports as bonded: 0. The answer also depended on where the process started, since
each worker and restart began with an empty map.

Both caches are now keyed by block, so a read repeats at most once per block per stash. That still
collapses the repeated reads inside a payout block, which is where they cluster, and bounds
staleness to nothing: `api` targets the block being indexed, so an entry can only be read back
within the block it was true for. `resolveController` also stops caching its stash fallback —
`bonded(stash)` is equally empty for a stash that has not bonded yet, and pinning that answer kept
it wrong for the rest of the block. StakingPosition.controller is refreshed on every update rather
than only at creation.

S1 — StakingPosition.bonded/unbonding are a view onto staking.ledger, but two events rewrite that
ledger without emitting Bonded/Unbonded/Withdrawn: a compounded (RewardDestination::Staked) reward,
which make_payout adds straight onto active/total, and a slash, which do_slash subtracts. Only
refreshing on the three registered events left a compounding stash reporting less than its real
active bond, falling further behind every era, and a slashed stash reporting too much. Both now
re-read through one shared refreshPositionFromLedger; a reward paid to a free balance leaves the
ledger alone and still takes no read.
…under a genesis lock

F7 — the balance seeder put the whole frozen amount into a lock called 'genesis', which nothing
ever lowered. `bonded` is derived from the 'staking ' lock, so a seeded staker's bond was never
reported as bonded; and because `frozen` is the MAX over locks, the 'genesis' entry kept `frozen`
pinned at the seeded amount forever, including after the staker unbonded.

The freeze is now attributed from chain the same way the reconciler's drift correction is, through
the shared applyChainFreezes: holds come from balances.holds on v8, the staking lock from
staking.ledger.total pre-v8, and only the unexplained remainder stays under a neutral id. Which
read applies is decided by the chain rather than a spec-version gate — balances.holds only exists
from v8, so an unreadable one is itself the pre-v8 signal — which matters because plan 10 runs this
same seeder at an arbitrary start block.
…onciler comparisons

Three follow-ups from the review's cross-cutting observations.

Item 9, part 1 — tsconfig sets `strict: false` explicitly. TypeScript's own default is already
false, but an editor running the native preview (tsgo) defaults to true, so the handler layer
showed hundreds of `possibly undefined` errors in-editor that CI never reported, with no way to
tell which was the project's real contract. Stating it settles that. Turning strictNullChecks on
is still wanted but is ~250 errors across 39 files and needs to land as its own ratcheted work,
not as a side effect of which tool someone is running.

Item 9, part 2 — the handful of those errors that were real defects rather than type noise.
`handleBalanceSet` passed a possibly-undefined address straight to `ledgerAccount`, which creates
and saves whatever id it is handed: an undecodable `BalanceSet` would write an Account, an
AccountBalance and a PolyxEntry all keyed `undefined`. It now records a MissingReferencedEntity
anomaly and returns. The three hold handlers had the same unchecked address reaching `adjustHold`,
where it silently no-opped; they now return early, matching what `lockHandler` already did. That
is 260 strictNullChecks errors down to 249, and 44 down to 33 in mapPolyxLedger.ts.

Item 4 — the reconciler counts the comparisons it actually performs, alongside the drifts it
finds, and logs both periodically. The D11 check spent its whole life returning early on a guard
that could never pass, and the empty IndexerAnomaly table that produced was read as "the ledger
reconciles" when it meant "nothing was ever checked" — indistinguishable from the drift count
alone. `compared=0` after a full resync is now a visible defect report, and `reconcileStats()` lets
a verification run assert on the denominator. The deliberately-wrong-balance canary the review also
suggested is left as separate, opt-in work: it writes a knowingly bad value, so it should never be
reachable by default.
… and a working reconciler

A full testnet genesis resync of this branch, with each fix applied to the running sync as it
surfaced. Event orderings below were checked against the runtime source: Polymesh pallets at every
release from v3.3.0 to v7.4.0, and polkadot-sdk at the commit the chain pins (f4d81a0, from
Cargo.lock — not d25e171 as previously noted).

Crashes and missing rows
- getAllByFields returned raw store records with no .save(); handleNominated crashlooped at block
  555,566, and mapValidator/mapEra carried the same call. Rows are now rebuilt into their model.
  mockGetByFields stapled a fake .save() onto rows, which is why the suite never failed.
- A paying key authorised through identity.add_authorization emits no relayer.AuthorizedPayingKey,
  so its Subsidy was never created. Acceptance now creates it.
- Validator.isPermissioned was never true: on_validate requires the identity to be permissioned
  first, so PermissionedIdentityAdded always precedes the row. Now read from chain at creation,
  from validators (v8) or staking (pre-v8).

In-flight reconciliation (D11)
- The flush compared a block-K snapshot against a derived row that had moved on: --workers hands
  out sparse ranges, so the next processed block is rarely K+1, and the correction wrote a stale
  balance. Skipped when updatedEventId is later than the snapshot.
- Sampled on height % 2000, but the dictionary processes ~1.6% of heights: 27 comparisons where
  ~5,700 were intended. Now one sample per 2000 heights per worker, decided in the block handler.
- The liveness report fired on comparisons, so compared=0 printed nothing. Now fires on flushes.
- The correction filed a v8 staker's still-present 'staking ' lock as residual between the two
  migration passes, so the second pass's Unlocked never cleared it — frozen of up to 4.96M POLYX
  against 0 on ten accounts. The lock is now read from balances.locks.

POLYX ledger
- fungible::deposit emits Endowed before Deposit, the reverse of Currency::deposit_creating, so
  a new account was still credited twice on that path. Matched on the adjacent event.
- A reward that creates its destination was credited twice in both eras (Endowed then Rewarded);
  handleReward now re-files that endowment, and one entry per reward rather than every match.
- v8 still pays the deprecated Controller payee to bonded(stash), but as a unit variant it
  decoded to a bare string and went to the stash — high by 141,981,208 on one account.
- Pre-v8 testnet gives new identities InitialPOLYX with no balance event when the key already
  exists; credited on DidCreated (read from the metadata constant, so mainnet is a no-op).
- Pre-v8 Transfer repeats its memo, so the TransferWithMemo stash was never consumed and a later
  memo-less identical transfer inherited it.

Docs: pre-v8 balances never emitted Deposit/Withdraw/Slashed; the v8 Bonded + Held pairing is
verified; Validator.isPermissioned's caveat no longer applies.
@prashantasdeveloper
prashantasdeveloper added this pull request to stack #358 September 16, 2026 17:39
@prashantasdeveloper
prashantasdeveloper requested a review from a team as a code owner September 16, 2026 17:39
- mapStakingEvent: move the reward/slash rollup into applyToPosition, bringing
  handleStakingEvent's cognitive complexity from 20 under the 15 limit (S3776).
- mapPolyxLedger: `.find()` instead of destructuring `.filter()` for the
  unpaired endowment and the memo-less transfer (S7750), an optional chain on
  the pending-memo queue (S6582), and type identity.initialPOLYX as a Codec so
  it is stringified through its own toString (S6551).

No behaviour change.
…e subsidised fees to the payer

Gaps found early in the sign-off genesis resync of 45cd1c9, checked against the Polymesh
pallet source at v3.3.0, v4.1.0, v6.0.0, v7.4.0 and v8.0.0.

Era boundaries before v7.0.0
- The custom staking pallet called StakersElected `StakingElection(ElectionCompute)` and EraPaid
  `EraPayout(EraIndex, Balance, Balance)`, and neither was handled, so testnet had no Era or
  Validator row until v7 while Nomination rows already pointed at validators. `new_era` bumps
  CurrentEra and `select_and_update_validators` writes ErasStakers before depositing
  StakingElection, the same state StakersElected sees, so both now route to the existing
  handlers. EraPayout gets EraPaid's field names.

PIPs deposit locks before v8
- pips locks proposal and vote deposits with `increase_lock`/`reduce_lock(PIPS_LOCK_ID)`, and the
  pre-v8 balances pallet emits no event for either, so those deposits never reached `frozen`:
  accounts reported 0 against exactly the 2,000 POLYX minimum proposal deposit on chain.
  ProposalCreated (community proposer) and Voted now set the 'pips    ' lock from
  balances.locks. ProposalRefund names no depositor and runs after Deposits is cleared, so it
  re-reads the indexed proposer and voters; from v7 a refund can span blocks, and an account not
  refunded yet still reads its lock. v8 is skipped: upstream update_locks emits Locked/Unlocked.
- The reconciler's drift correction and the seeder attribute a pre-v8 pips lock by id as well, so
  a later refund is not left behind by a residual copy of the same amount. The residual is now
  only what exceeds the largest attributed lock, since locks overlap rather than add.

Bridge mints before v7
- bridge.Bridged credits its recipient with `balances::deposit_creating`, which the pre-v8
  balances pallet reports only when it creates the account, so an existing recipient never
  received the POLYX (one testnet account 30,000 short). Bridged now posts the Mint, unless the
  recipient's Endowed of the same amount is one or two events back (the reserve's first-touch
  Endowed(brr, 0) can sit in between). Where the POLYX came from is still not recorded: dropping
  the imbalance draws on the block reward reserve while it has free balance, with no event.

Subsidised fees (both eras)
- protocolFee.FeeCharged and transactionPayment.TransactionFeePaid name the subsidised user, but
  withdraw_fee takes the fee from the subsidiser's paying key (protocol-fee and
  transaction-payment at v5.4.0, v7.4.0, v8.0.0). On v8 the Withdraw names the paying key, so
  matching on the user missed it and debited the user a second time; pre-v8 a subsidised protocol
  fee was debited from a user whose balance never moved (one testnet account 5,500 POLYX low).
  The withdrawal is now also looked for under the user's active Subsidy paying key, and a pre-v8
  protocol fee, which check_subsidy(user, fee, None) subsidises for any call, is debited from it.
  So is a pre-v8 transaction fee for any call but the relayer's own: check_subsidy(user, fee,
  Some(pallet)) rejects the transaction for a pallet it does not subsidise (the pallet list to v6,
  SubsidyCallFilter in v7), and lets only Relayer through unsubsidised.
identity.create_child_identity(ies) emits DidCreated for the child as well, but through
base_create_child_identity, which deposits nothing; only register_did_without_cdd gives the
primary key InitialPOLYX (v6.1.0, v7.4.0). The pre-v8 grant handler credited the child's key
100,000 POLYX it never received (testnet block 16,194,076). The child is told apart by the
ParentDid it stores before the event, read back from chain; runtimes before v6.1 have no child
identities and no ParentDid.
@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 full diff hunk by hunk, plus the surrounding code in mapPolyxLedger, reconcilePolyx, mapNfts, the staking handlers, utils/staking, utils/common and seed/accountBalance. Everything below was re-checked against redesign/11-holdings-review and redesign/12-review-fixes, so none of it is already fixed downstream.

Most of this verifies cleanly, including the parts that were hardest to follow: the hold-reason composite-enum decode, the Endowed/Deposit adjacency pairing, the memo queue, the direction-aware rollupDelta netting fee against refund, the v8 slash-from-hold path (postTransition doesn't touch holds, so the extra adjustHold isn't a double count), the getAllByFields hydration, the same-identity NftHolder aliasing fix, and the EraPayout/StakingElection routing. The per-block cache rework is the right shape — it still collapses the reads within a payout block, which is where they cluster, while bounding staleness to a block.

Five things inline, roughly in order of how much they matter:

  • seed/accountBalance.ts — holds === undefined is doing double duty as "pre-v8 runtime" and "we skipped the read", and the read is skipped whenever reserved === 0. reconcilePolyx.ts does the same attribution with an explicit is8xChain(block); the seed should match it.
  • applyChainFreezes — v8 balances migrate lazily, so balances.holds returning [] can mean "not materialised yet" rather than "no holds". Measured on testnet 8001020: of 337 accounts with reserved > 0, two are unmigrated and return an empty holds list, so the index records a non-zero reserved with no breakdown. Small, self-correcting when touched, and the fix needs no extra chain read — details inline.
  • refreshPositionFromLedger — reassigns controllerId without ledgerAccount, unlike getOrCreatePosition, so a set_controller to a never-indexed account leaves the relation dangling.
  • refileFeeWithdrawal — picks the fee withdrawal by amount, so a deliberate burn by the payer in the same extrinsic can be relabelled as the fee. Narrow, and possibly the best available rule — worth a line either way.
  • handleTransactionFeeCharged — a Subsidy lookup on every pre-v8 fee event, which never short-circuits because the old runtime charges the weight fee with no event. subsidisedCall(args) is an extrinsic inspection rather than a store read and would skip most of them, though it reorders the logic, so worth confirming rather than assuming.

The last two are questions rather than change requests.

stash: string,
blockId: string
): Promise<boolean> => {
const controller = await resolveController(stash, blockId).catch(() => stash);

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.

refreshPositionFromLedger reassigns controllerId but doesn't ensure the Account row exists, unlike getOrCreatePosition a few lines above, which calls ledgerAccount(controller, …) before referencing it.

set_controller can point at an account the index has never seen — it emits no event, so nothing else would have created the row either. StakingPosition.controller then names an Account that doesn't exist, and since the relation is nullable it resolves to null rather than failing loudly.

The awkward part is that this function only takes (position, stash, blockId), so it has no datetime for ledgerAccount. Threading one through from the three call sites (they all have block.timestamp in hand) seems the smaller change, unless you'd rather resolve it where the controller is already known to differ.

* applies is decided by the chain itself: `balances.holds` only exists from v8, so an
* `undefined` there *is* the pre-v8 signal, and only the unexplained remainder stays neutral.
*/
const holds = reserved > BigInt(0) ? await readChainHolds(address) : 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.

holds === undefined is doing double duty here, and the two meanings aren't the same.

The docstring above says "balances.holds only exists from v8, so an undefined there is the pre-v8 signal" — but the read is skipped entirely when reserved === 0, so undefined also means "we didn't look". A v8 account with frozen > 0 and no reserved therefore takes the pre-v8 branch: stakingLock comes from readStakingLock rather than readChainStakingLock, and pipsLock gets a 'pips ' read that only makes sense pre-v8 — v8 tracks pips deposits through the generic Locked/Unlocked, so a lock written under that id has no v8 handler to lower it. That's the F7 shape the paragraph above is explicitly guarding against.

reconcilePolyx.ts:148 does the same attribution and decides the era explicitly:

const is8x = is8xChain(block);
holds: is8x ? await readChainHolds(address) : undefined,
stakingLock: is8x ? … : …,
pipsLock: !is8x && frozen > BigInt(0) ? … : undefined,

Suggest matching it here — is8xChain is already imported in that sibling and the seed has the block in scope. It keeps "which era" and "did the read return anything" as separate questions, which is what went wrong.

// The withdrawal covers the fee, so it is the exact match or the smallest larger burn — larger
// when part of it is about to be refunded. Anything smaller belongs to a different charge.
const burns = await findExtrinsicEntries(args, MovementKind.Burn, payer);
const withdrawal =

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 picks the fee withdrawal by amount — exact match, else the smallest burn larger than the fee — scoped to the extrinsic and payer. That's sound for the refund case the comment describes, but it will also match a burn that has nothing to do with the fee: a payer who deliberately burns POLYX in the same extrinsic gets that entry relabelled as Fee if it happens to be the smallest one above the fee, and the real withdrawal stays a Burn.

Narrow, and the exact-match-first ordering keeps it out of the common path. Worth noting mainly because this module resolves the equivalent ambiguity elsewhere by adjacency — the Endowed/Deposit pairing — rather than by amount. If the withdrawal is reliably adjacent to the fee event, the same technique would remove the guess; if it isn't, then the amount rule is probably the best available and is worth a line saying so.

return;
}

const payingKey = await activeSubsidiser(who);

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.

Worth a look in a throughput PR: this runs a Subsidy lookup on every fee event that didn't find a withdrawal, which pre-v8 is most of them — the older runtime charges the weight fee with no event at all, so refileFeeWithdrawal has nothing to match and never short-circuits. Combined with the findExtrinsicEntries query above it, that's two store reads per extrinsic across the whole pre-v8 replay.

subsidisedCall(args) on line 1765 is an extrinsic inspection rather than a store read, and the vast majority of calls aren't subsidised. Checking it before activeSubsidiser would skip the lookup for nearly all of them.

That does reorder the logic — line 1761 currently re-files against payingKey regardless of whether the call was subsidised — so it's only safe if a paying key can't have withdrawn for a non-subsidised call. Worth confirming rather than assuming, but if it holds it removes a per-extrinsic store read.

const { frozen, holds, stakingLock, pipsLock } = chain;

if (holds !== undefined) {
balance.holds = holds;

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.

v8 balances migrate lazily, so balances.holds returning [] doesn't always mean "no holds" — for an account the chain hasn't migrated yet it means "not materialised yet". (v7.4→v8.0 balances changelog)

An account is migrated only once something touches it. Until then its reserved is carried over from the old model but balances.holds(who) is empty, so this line records an empty breakdown against a non-zero reserved — total right, attribution missing — and it stays that way, because re-reading gives the same empty answer. Both callers hit it: reconcilePolyx.ts:155 and seed/accountBalance.ts:72.

Measured on testnet 8001020, scanning 6,000 accounts:

  • 626 migrated, 5,374 not
  • of the 337 accounts with reserved > 0, 335 are migrated and 2 are not — and those 2 return holds: []
5EWY9XycFm…  reserved=4152780000   holds=[]
5GBLs4uJoC…  reserved=28553790000  holds=[]

(The sibling risk is fine, for what it's worth: accountDataFrozen reads the frozen slot, which pre-migration holds the old misc_frozen while the old fee_frozen sits in flags — so an unmigrated account with fee_frozen > misc_frozen would be understated. Zero of the 5,374 unmigrated accounts have either field non-zero, which makes sense since an account with a lock is one that gets touched.)

The fix is small, and needs no extra chain read. Migration state is the top bit of flags (1 << 127), and both callers already have the AccountData in hand — readOnChain decodes system.account, and the seed iterates its entries. So a predicate like:

const isMigratedAccountData = (data: Record<string, Codec>): boolean =>
  (getBigIntValue(data.flags) & (BigInt(1) << BigInt(127))) !== BigInt(0);

and passing holds: undefined when it's false. applyChainFreezes already treats undefined as "no information, never no holds" — this just makes the unmigrated case produce that instead of an empty array.

That said, the exposure is 2 accounts on testnet and it self-corrects the moment either is touched, so leaving it is defensible if you'd rather not add the predicate. If you do leave it, worth a line here saying [] can mean "pre-migration" — otherwise the next reader takes an empty holds at face value.

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