perf: throughput fixes, v8 POLYX ledger corrections and a working reconciler - #359
prashantasdeveloper wants to merge 10 commits into
Conversation
…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.
- 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.
|
F-OBrien
left a comment
There was a problem hiding this comment.
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 === undefinedis doing double duty as "pre-v8 runtime" and "we skipped the read", and the read is skipped wheneverreserved === 0.reconcilePolyx.tsdoes the same attribution with an explicitis8xChain(block); the seed should match it.applyChainFreezes— v8 balances migrate lazily, sobalances.holdsreturning[]can mean "not materialised yet" rather than "no holds". Measured on testnet8001020: of 337 accounts withreserved > 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— reassignscontrollerIdwithoutledgerAccount, unlikegetOrCreatePosition, so aset_controllerto 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— aSubsidylookup 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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 = |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 returnholds: []
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.



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:
Nftrows are bulk-written on mint, burn and transfer, instead of being saved one at a time.NftHolder: a newly created holder is written once per block instead of twice.getCustomTypereads the indexedCustomAssetType. It falls back to a chain read, recording aMissingReferencedEntityanomaly, only when the type isn't indexed.Fixes
CorporateAction: these structs have snake_casetoJSONkeys. Genesis sync crash-looped at testnet block 90,693 (handleCaInitiated), andRecordDateChangedsilently clearedrecordDate.NftHolderon a same-identity move: an NFT moved between two portfolios of the same identity, within one block, could lose or duplicate ids.getAllByFieldsrows: it returned raw store records with no.save(), sohandleNominatedcrash-looped at block 555,566. The test helper had been adding a fake.save(), which hid the bug.Subsidy: a paying key authorised throughidentity.add_authorizationnever got aSubsidyrow. 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.RuntimeHoldReasonis a composite enum, so every v8 hold decoded asUnknownandbondedwas always 0.FungibleAdapteralready emits aWithdrawand a refundDeposit.protocolFee.FeeChargedhad the same problem.Deposit+Endowed). The two events come in either order depending on the call path (Currency::deposit_creatingvsfungible::deposit), so they are now matched by adjacency.Controllerpayee: v8 rewards for this payee went to the stash instead ofbonded(stash).TransferAndHold: it always posted 0.InitialPOLYXis paid with no balance event. It is now credited onDidCreated, using the value from the metadata constant, so this is a no-op on mainnet.Staking state
set_payeeandset_controlleremit 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 emittingBonded/Unbonded.genesislock that nothing ever cleared.In-flight reconciliation (D11)
This check had never actually run before this PR.
reconcileBlockhad 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.--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.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.compared/drifted/skippedStalesummary is logged on a timer, socompared=0is visible.reconcileStats()exposes the same counts.balances.locks, so astakinglock that is still present between the two lock→hold migration passes isn't renamed to residual.Misc
strict:tsconfignow setsstrict: falseexplicitly, so the in-editor native preview matches CI.handleBalanceSetand the hold handlers now guard against an undefined address instead of writing rows keyedundefined.Upstream event-order issues found (for the chain team)
Deposit/Endowedin a different order depending on the path:Currency::deposit_creatingemitsDeposit → NewAccount → Endowed, andfungible::Balanced::depositemitsNewAccount → Endowed → Deposit.Transferevent repeats the memo thatTransferWithMemoalready carries.Withdraw → Deposit(refund) → Deposit(dest) → TransactionFeePaidvsWithdraw → FeeCharged → Deposit(dest).DealWithFees = Author) has no event.Consumer impact
schema.graphql: the only change is theValidator.isPermissioneddocstring. There is no field or type change.Verification
yarn codegen && yarn typecheck && yarn lint && yarn test:unit(660 tests), plusyarn buildandcheck-handlers.MissingReferencedEntityand 43BalanceReconciliationDriftrows, all looked into.Known open items
StakingEvent.rewardDestinationAccountis empty for theControllerpayee.strictNullChecks(249 errors) and the opt-in D11 canary.