Skip to content

feat!: rebuild the POLYX record as a double-entry ledger (redesign phase 04) - #349

Open
prashantasdeveloper wants to merge 22 commits into
docs/architecturefrom
redesign/04-polyx-ledger
Open

prashantasdeveloper wants to merge 22 commits into
docs/architecturefrom
redesign/04-polyx-ledger

Conversation

@prashantasdeveloper

Copy link
Copy Markdown
Contributor

Summary

Phase 4 of the indexer redesign — plan docs/implementation/02-polyx-ledger.md.
Replaces the single-column PolyxTransaction list with an entry-centric PolyxEntry
ledger plus a materialised AccountBalance, so "what was account X's balance at
block N" becomes an index scan instead of an un-answerable question.

Chained on redesign/03-infrastructure (decode layer + IndexerAnomaly).

Why

PolyxTransaction had four structural faults, each fatal to point-in-time balances:

  1. One type column for a two-sided movementReserved is Free → Reserved;
    a single row records half of it.
  2. frozen is a MAX of active locks, not a SUM — no summing of movement rows
    reproduces it.
  3. BalanceSet is an absolute value recorded as a delta — corrupts every running
    total after it.
  4. BalanceTypeEnum conflated three systems — real pools (Free/Reserved), a
    lock floor, and staking-ledger states whose mechanism inverted at v8.

Plus: several v8 balances events were unregistered, leaving reserved unindexed on v8.

What changed

Model

  • PolyxEntry — append-only, one row per (account, movement side), carrying
    freeAfter / reservedAfter / frozenAfter snapshots; sibling entries of one
    on-chain movement share movementId. New MovementKind / EntryDirection /
    PolyxPool / HoldReason enums replace BalanceTypeEnum.
  • AccountBalance — running balance, height-versioned. frozen = MAX(locks),
    bonded = staking lock (≤ v7.4) or Staking hold (v8), plus transferable,
    otherReserved, and lifetime aggregates.
  • PolyxTransaction and BalanceTypeEnum removed.

HandlersmapPolyxLedger.ts replaces mapPolyxTransaction.ts

  • Every balances.* movement (transfer / endow / reserve / repatriate / hold /
    release / burn / mint / dust / BalanceSet) writes entries and advances the
    balance, at both the pre-v8 tuple shape and the v8 struct shape.
  • Staking: pre-v8 set_lock bonding, RewardDestination::Staked auto-restake
    (credited and locked, no Bonded event), v8 Bonded ↔ balances.Held{Staking}.
  • Treasury reimbursement / disbursement and transaction-fee attribution.

Reconciliation

  • In-flight (reconcilePolyx.ts) — periodically checks the derived AccountBalance
    against system.account on chain, records a BalanceReconciliationDrift anomaly
    and corrects, so one missed event can't compound.
  • Offline (scripts/reconcile-polyx.ts) — full-history audit.

Decode shapes — pre-v8 tuple shapes for balances.* and staking.*.

Postgres 18 — the height-versioned indexes need btree_gist's boolean operator
class (PG 13+); image postgres:12-alpine → 18-alpine, data mount adjusted for the
PG 18 subdirectory layout.

Full resync from genesis (decision D5) — db/migrations/* deleted; account
resolution fixed for reindex-from-zero.

Follow-up commits

Three chore commits refine the new code (all pre-merge, none touch released code):

  • settlement.InstructionCreated accepts its pre-memo 7-param arity.
  • skip identity.ClaimRevoked with a zero issuer (stripped early-chain event).
  • treasury-reimbursement crediting, pre-v8 Staked restake lock, staking.payee()
    .toJSON() variant normalisation, a 100-POLYX reconciler noise floor; plus a
    per-stash payee cache, a coarser reconcile interval and per-block system.account
    memoisation.

Breaking changes

  • Schema: PolyxTransaction, BalanceTypeEnum removed; PolyxEntry,
    AccountBalance, PolyxPool, MovementKind, EntryDirection, HoldReason added.
  • Postgres 12 → 18 (image + volume path).
  • Requires a full resync from genesis.

Testing

  • Gate green: yarn codegen && yarn typecheck && yarn lint && yarn test:unit
    (396 unit tests; new suites mapPolyxLedger, reconcilePolyx, seedAccountBalances,
    rewardDestinationA15, extract8xStakingAmount).
  • Validated by a full resync from genesis; the issues it surfaced are the three
    follow-up commits above.

Known follow-up

The pre-v8 staking lock is derived by accumulating restaked rewards; on chain it is
staking.ledger.active (max-bond capped, and MAX'd against other locks). The in-flight
reconciler bounds the resulting drift to ≤ ~1 era per validator and corrects it at each
checkpoint; a precise fix (read ledger.active at the reward block) is tracked for a
follow-up.

Merge

Rebase-merge onto redesign/03-infrastructure. Do not squash.

prashantasdeveloper and others added 13 commits September 8, 2026 23:23
Moves the database from postgres 12 to 18. Postgres 13 and later let a
boolean column be part of the range index the indexer builds when historical
tracking is on, which postgres 12 rejected. The data volume is now mounted at
/var/lib/postgresql because postgres 18 stores its files in a version folder
underneath.

BREAKING CHANGE: the database is a new major version and its volume path
changed, so any existing data volume has to be recreated and the chain
reindexed from the start.
The redesign reindexes the whole chain from block zero, so the incremental
schema-migration files no longer apply and would only run stale changes on
restart.
When resolving which identity an address belongs to, the code always called a
storage item that only exists on newer runtimes, so it threw on early blocks.
It also created an identity without its default portfolio, which made a later
identity-created event fail because the portfolio was missing. Both are now
handled: the older storage item is used when the new one is absent, and the
default portfolio is created alongside the identity.
Replaces the single PolyxTransaction table with two: PolyxEntry, one
append-only row per account per side of a movement, each carrying the
account's balance right after it; and AccountBalance, the running balance
that can be read at any past block.

Every balances event is decoded, turned into a move between the free and
reserved pools, and written as entries plus an updated balance. This fixes a
set of long-standing gaps:

- the events that create and destroy reserved balance on the current runtime
  were never indexed, so reserved balance and staking bonds were invisible;
- account reaping (dust) was not recorded;
- a transfer-with-memo produced a spurious second row instead of just adding
  the memo to the real transfer;
- an absolute "set balance" event was recorded as if it were a movement,
  corrupting every later total; it is now a checkpoint that records only the
  difference;
- frozen balance is now the largest active lock rather than a sum, which the
  old model could not represent;
- pre-upgrade staking bonds moved no balance (they were a lock) and no longer
  produce a movement row; on the current runtime the bond's balance effect
  comes from the paired hold/release events, so the staking events themselves
  are recorded as state only and not double-counted;
- for rewards paid before the upgrade the event only named the stash, so the
  actual payee is now read from chain storage at the reward block;
- opening balances are seeded from chain state at genesis, without which
  every derived balance would be off by the genesis allocation.

BREAKING CHANGE: polyxTransactions is replaced by polyxEntries. Queries that
matched an account across the from/to columns now filter on a single
accountId. BalanceTypeEnum is gone; use PolyxEntry.pool, .kind and
.direction. The same staking event name now means different things before and
after the v8 upgrade (a lock versus a hold).
An offline script samples accounts, oversampling the ones involved in the
riskier cases, and compares the ledger's free, reserved and frozen balances
against the chain at blocks on either side of each runtime upgrade, then
prints a breakdown of any differences by cause. A second script measures how
often pre-upgrade rewards were paid somewhere other than the stash. The
running indexer also checks touched accounts against chain state periodically
and after a set-balance or dust event, recording and correcting any drift.
`memo` is the trailing parameter of `InstructionCreated` and was added
after the event first shipped. Register the shape with `optionalFrom: 7`
so a 7-parameter event decodes with `memo` undefined rather than failing
an arity check. `handleInstructionCreated` already tolerates an absent memo.
A stripped `ClaimRevoked` carrying a zero issuer DID and a `NoData` claim
matches no indexed claim and is not an attributable revocation. Return
early instead of recording a MissingReferencedEntity anomaly for it.
Balance attribution:

- TreasuryReimbursement(payerDid, amount) is the treasury's share of a
  fee, not a refund to the payer. Credit the treasury pallet account; the
  payer's fee is already debited by protocolFee.FeeCharged /
  transactionPayment.TransactionFeePaid.
- A pre-v8 RewardDestination::Staked payee auto-restakes: the reward is
  credited to free and immediately locked, with no staking.Bonded event.
  Raise the staking lock in handleReward, and have the reconciler pin the
  staking lock to the on-chain frozen amount so later staking events
  adjust a real base.
- staking.payee(stash).toJSON() renders a unit variant as a lower-cased
  single-key object ({ staked: null }), not the bare string 'Staked'.
  Normalise both forms so a Staked payee is classified correctly.
- The in-flight reconciler ignores drift below 100 POLYX: the pre-v5.4
  weight fee is applied with no event, and a sample taken mid-block on a
  multi-touch account compares partial derived state against the block's
  final on-chain state. Neither is a handler defect.

Derivation cost:

- Cache staking.payee per stash; it rarely changes and the reconciler
  corrects any stale-entry drift.
- Reconcile every 2000 blocks rather than 500.
- Memoise system.account per block, so an account touched N times in a
  sampled block costs one read rather than N.
snapshotFromMetadata keyed events and calls by the metadata pallet spelling
(Asset, ExternalAgents) while eventDrift, arityFixtureFor, the arity fixtures,
CAPTURED_MODULES and project.ts all use the event.section spelling (asset,
externalAgents). Exact-key lookups therefore missed and fell back to {} -
eventDrift reported every fixture event as removed (81 against mainnet 8000020)
and arityFixtureFor wrote "modules": {} under --write.

Normalise the pallet name once, on the way in, via sectionId(); the metadata
spelling never leaves snapshotFromMetadata. snapshot.modules is unaffected -
section.toLowerCase() is the same value either way.

The unit tests keyed both fixture and snapshot "Balances", so the suite stayed
green over the broken script; they now use the real convention, plus a
snapshotFromMetadata block against real v15 metadata from
@polkadot/types-support.

Co-authored-by: Francis <francis@polymesh.network>
stateTrieMigration.Migrated / AutoMigrationFinished / Halted were not in
EventIdEnum, so the generic event recorder logged an UnknownEnumValue anomaly
and stored them as "Unknown". They carry no balance effect - just recorded
like any other event now.
… spec

@subql/node can hand a mapping the previous runtime's spec version for the one
block a runtime upgrade takes effect on. At the v5->v6 boundary that made
asset.AssetBalanceUpdated resolve no tuple shape and the throwing decode proxy
killed the worker.

resolveShapeTolerant retries once with api.runtimeVersion.specVersion (read from
the block's own runtime) when the reported version resolves nothing - but only
when it is newer and within one release line, so neither a correct reported
version nor a stale/HEAD api.runtimeVersion pulls in a wildly wrong shape.
Cognitive complexity: postTransition and handleBalanceSet split into named
helpers; the reconcile-polyx and measure-a15-payees scripts' main loops each
extract their inner body. Plus a bigint maxBig helper (Math.max cannot take
bigint), toHaveLength over .length assertions, one extracted ternary, an
optional chain, and .at(-1).
… flags

validators.AutomaticPayoutFinished / ValidatorPayoutFailed reach the generic
event recorder and would log an UnknownEnumValue anomaly; mmr / mmrleaf are
runtime pallets absent from ModuleIdEnum. sync-metadata.ts now reports zero
missing members across all three enums.
prashantasdeveloper and others added 2 commits September 10, 2026 00:39
The rewardedStashes helper took data: unknown[], so data[1].toString() looked
to Sonar (S6551) like it could stringify a plain object. The values are codecs;
type the param as { toString(): string }[].
`Enum#toJSON()` camel-cases the variant name, so an 8.x `Rewarded` with an
`Account` payee arrives as `{ account: "5..." }` and never matched the
`'Account'` comparison: the row stored `rewardDestination: 'account'` with no
destination account. Every object form was affected, `{ staked: null }`
included, so no destination account was resolved for any of them.

The parse now lives once in `utils/staking.ts`, shared with the pre-v8
`staking.payee` read. It takes `AnyJson` and narrows rather than casting
`toJSON()` to a hand-written shape, and `rewardDestination` is a union of the
variant names instead of `string`.

`.toJSON()` stays in place of the generated `Option<PalletStakingRewardDestination>`
accessors: those describe the current metadata only, while both callers read
older runtimes, and the reason is now recorded at the helper.
The '\''staking '\'' lock was accumulated from Bonded / Withdrawn / restaked-Reward
deltas, which never see the max-bond cap, the rounding of a compounded
RewardDestination::Staked reward, unbonding chunks, or a slash - so it drifted
from the real lock across the whole validator set.

readStakingLock(stash) reads staking.ledger(controller).total from chain - the
exact value pallet-staking passes to Currency::set_lock, hence what miscFrozen
reports. syncStakingLock is wired into handleBonded / handleWithdrawn /
handleReward (restaked) / handleStakingSlash, and falls back to the delta
accumulator when the ledger cannot be read. Controller is resolved via
staking.bonded(stash) and cached.
NftHolder.nftIds is a JSON array and historical mode versions the whole array on
every save. A bulk mint - hundreds of NFTPortfolioUpdated for one holder in one
block - made that Sigma(1..n) array serialisations and poisoned the store cache
with 30k-element arrays, degrading the indexer to a crawl on later empty blocks.

Holder mutations are now buffered per block and each holder is saved once, on
block change or from a new handleBlock (Block handler, filter modulo 100).
getNftHolder reads the buffer first. Nothing inside the indexer reads NftHolder
- it is written for external queries only - so a holder being at most one
block-handler interval stale is acceptable.
getAsset threw a plain Error on a missing asset, which crashed the worker for a
block whose AssetCreated was skipped upstream. getAssetOrAnomaly records a
MissingReferencedEntity and returns undefined instead; handleAssetBalanceUpdated
uses it and returns early.
…was right

CHANGES.md, entity-review.md and 02-polyx-ledger.md each asserted [V] that
get8xStakingEventDetails resolved the RewardDestination correctly. PR #350 showed
it did not - the object form never matched. Corrected in the repo's usual style.
The v5-v7 `Currency::set_lock("staking ", ...)` bond becomes a `RuntimeHoldReason::Staking`
hold on v8. `pallet_balances` runs the conversion as a no-extrinsic two-pass storage
migration: pass 1 emits `Upgraded` + `Held{Staking}`, pass 2 emits `Unlocked` for the
lingering currency lock. `handleBalanceHeld` already turns pass 1 into the `free -> reserved`
movement; the lock side was unhandled, so `frozen` over-reported for the ~420k blocks
between the two passes.

`handleBalanceUnlocked` now clears the account's `"staking "` lock on a v8 `Unlocked` when
one is present - post-v8 nothing re-creates a currency staking lock, so it can only be the
migration removing it. Pre-v8 and non-staking unlocks keep the generic `"balances"` path.

Confirmed against real testnet v8 blocks that `staking.Bonded` / `Unbonded` / `Withdrawn`
pair with `balances.Held` / `Released` in the same extrinsic (and `rebond` correctly emits
neither), so the docs' [I] on that pairing becomes [V].
Base automatically changed from redesign/03-infrastructure to docs/architecture September 10, 2026 10:52
… committee

`TreasuryDisbursement`'s first parameter is the identity that authorised the spend, not
the source of funds — `treasury.disbursement` always moves POLYX out of the treasury
pallet account, the mirror of the reimbursement handler's credit. Debiting the
authorising committee's primary key instead left the treasury drifting high forever and
the committee account low, on every spec version that emits no paired `balances.Transfer`
(pre-5.0.0). The paired-transfer relabel now covers both sides of the movement too.
`getAssetId` chose between a 12-byte ticker and a 16-byte asset ID on `is7xChain(block)`.
That is wrong whenever the block misreports its runtime: `@subql/node` has been seen
serving the pre-upgrade spec for a long run of blocks after v7.0.0 actually activated
(~169k blocks reported as spec 6003050 instead of 7000003), and an already-migrated
16-byte asset ID in one of those blocks was then run through `getAssetIdForLegacyTicker`
and blake2-hashed into a bogus id, so its `Asset` lookup missed.

A `Ticker` is `[u8; 12]`, so a 16-byte value is a migrated asset ID whatever spec the
block claims. `isMigratedAssetId` tests that, and `getAssetId` / `getAssetIdWithTicker` /
the `handleAssetCreated` ticker derivation / `getAssetIdForStatisticsEvent` all check it
before falling back to the spec gate.
@sonarqubecloud

Copy link
Copy Markdown

@prashantasdeveloper
prashantasdeveloper marked this pull request as ready for review September 11, 2026 12:30
@prashantasdeveloper
prashantasdeveloper requested a review from a team as a code owner September 11, 2026 12:30
@prashantasdeveloper
prashantasdeveloper added this pull request to stack #358 September 15, 2026 08:23
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