Skip to content

feat!: holdings, NFTs and the movement ledger (redesign 5/10) - #352

Open
prashantasdeveloper wants to merge 9 commits into
redesign/04-polyx-ledgerfrom
redesign/05-holdings-nfts
Open

prashantasdeveloper wants to merge 9 commits into
redesign/04-polyx-ledgerfrom
redesign/05-holdings-nfts

Conversation

@prashantasdeveloper

@prashantasdeveloper prashantasdeveloper commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

PR 6 of 10 in the indexer redesign series. Based on redesign/04-polyx-ledger (#349), not
master. Implements docs/implementation/03-holdings-nfts.md and
docs/implementation/05-movement-ledger.md.

What changed

# Commit
1 feat!: add the Holding entity at portfolio and account grain G8. Holding at the finest grain the chain uses (a portfolio, or a v8 account-level holder), with a genesis seeder following the accountBalance.ts pattern. Asset gains assetId, holderCount, holdings. rawAssetHolderToAssetHolder now carries a HolderKind discriminator. AssetHolder/NftHolder stay as maintained rollups (recommendation (b) — the SDK queries them directly).
2 feat: write Holding rows from asset balance updates handleAssetBalanceUpdated writes a Holding row per side alongside the rollup; holderCount follows the rollup crossing zero. An account holder with no known Identity gets its Holding row without being folded into a DID rollup.
3 feat: add the Nft entity and stop rewriting NftHolder.nftIds as a whole array G9, G10, and the largest single throughput cost in the indexer. Each token id is its own small immutable Nft row — mint = N inserts, transfer = one location update, burn = one column update — replacing push/filter on a lengthening nftIds array that historical mode re-serialises in full every mutation. burnedBlock is a nullable relation (Boolean can't be indexed); filter burnedBlockId: { isNull: true }.
4 feat: index v8 asset allowances G11. AssetAllowance (assetId/owner/spender). handleAllowanceSpent takes the chain's own remainingAllowance rather than subtracting, so a missed/reordered event can't drift. Approval/AllowanceSpent are new project.ts keys with v8 decoder shapes.
5 feat: index asset metadata and CreatedAssetTransfer G13 + rest of G11. AssetMetadata (MetadataScope Local/Global), GlobalMetadataKey, CustomAssetType. Ten previously-unregistered metadata/type events handled. SetAssetMetadataValue/Details carry no key — it's recovered from the setAssetMetadata call args, else from a RegisterAssetMetadata*Type event in the same extrinsic (the register-and-set path — this was a review finding: registerAndSetLocalAssetMetadata was silently dropping values), else recorded as an anomaly. handleCreatedAssetTransfer writes an account-side AssetTransaction and links instruction when pendingTransferId is present (it's an InstructionId, no new state).
6 feat!: normalise Asset ids and drop isUniquenessRequired Asset.id is the assetId; isUniquenessRequired (dead pre-6.0 concept) removed; NftHolder.nftIds[BigInt] (the one [Int] left, G10).
7 feat!: fold PortfolioMovement into AssetTransaction Plan 05. AssetTransaction gains isInternalTransfer (not indexed — filter by equality), memo, address. The three PortfolioMovement writers funnel through one shared writer; classification is a helper in utils/portfolios.ts, keyed on holder presence first, DID equality second — an unresolved-DID holder classifies false, never falling through to the issuance/redemption (null) case. PortfolioMovement and PortfolioMovementTypeEnum removed.
8 feat: index the v5-era portfolio movement events A8. FungibleTokensMovedBetweenPortfolios (6 args) / NFTsMovedBetweenPortfolios (5 args), emitted only at v5.4.3 via unchecked_move_funds and never registered. Each gets its own legacy decoder entry; both write AssetTransaction with isInternalTransfer: true. Measured: 0 on mainnet, 1 on testnet.
9 chore: address SonarCloud findings on the phase 5 diff typescript:S6551 in parseMetadataKey — narrow the metadata-key value from unknown to number | string. new_duplicated_lines — the store mock, codec stand-in and tuple-event builder copy-pasted across the five new handler tests are extracted to tests/unit/helpers.ts. No behaviour change.

D5: full resync from genesis, no db/migrations entries.

NFT throughput — the measured cost this removes

NftHolder.nftIds.push(...) / .filter(...) rewrites the whole array on every mutation, and
under historical state each save() inserts a new row carrying the entire array. Testnet block
15,391,572: 399 RedeemedNFT, one id per event, all against a holder whose array held 2,724
ids ≈ 1.16M integer serialisations across 399 row versions — to delete 399 ids. The Nft
entity removes this class of cost structurally: that block becomes 399 single-column updates.

Consumer impact — BREAKING, coordinate releases with both teams

This is the only plan in the series where a portal change is mandatory — the portal queries
portfolioMovements directly.

  • SDK
    • assetHolders / nftHolders — compatible, kept as rollups. nftHolders.nftIds narrows
      [Int][BigInt]; check SDK typings.
    • assets — gains assetId, loses isUniquenessRequired; holders derived-field shape may
      change.
    • portfolioMovementsrewrite to assetTransactions with
      isInternalTransfer: { equalTo: true }.
  • Portal
    • portfolioMovementsrewrite to assetTransactions. Its
      type: { equalTo: Fungible|NonFungible } filter maps to amount / nftIds null-checks — which
      its existing assetTransactions query already does. fromPortfolioId/toPortfolioId filters
      are unaffected and become better-served once Holding exists.
  • New capability for both teams:
    holdings(filter: { portfolioId: { equalTo: "did/1" } }) — not expressible against anything today.

Notes

  • Commit 2 is scoped to handleAssetBalanceUpdated (v6+). Pre-v6 Issued/Redeemed carry only a
    DID, not a portfolio, so Holding stays identity-grain via the rollup for the v5 era and becomes
    portfolio-precise from v6.
  • Commit 3: Nft sits at 9 indexes (subql auto-indexes the metadata jsonField), so burnedBlock
    is not @index-ed — isNull filtering is fine at the measured NFT cardinality (thousands). The
    genesis Holding seeder is fungible-only; the NFT read for an arbitrary start block belongs to
    plan 10.
  • Commit 5: metadata-event decoder shapes are registered stable() (arity assumed constant pre-v8;
    only the v8 arity fixture exists to check against).
  • tests/entities/* snapshot suites (Docker, not in the unit gate) are stale across the whole
    redesign and regenerate on the first resync. This branch is unit-gate-only, like phases 1–3.

@prashantasdeveloper prashantasdeveloper changed the title Redesign/05 holdings nfts feat!: holdings, NFTs and the movement ledger (redesign 5/10) Sep 10, 2026
Defect G8. Adds Holding at the finest grain the chain uses (a portfolio, or a
v8 account-level holder) with its genesis seeder, alongside the existing
identity-grain AssetHolder/NftHolder rollups. Asset gains assetId, holderCount
and the holdings derived field. rawAssetHolderToAssetHolder now carries a
HolderKind discriminator instead of collapsing straight to a DID.

BREAKING CHANGE: Holding and Asset.assetId/holdings are added alongside the
current holder model. The identity-grain model is removed later in this phase.
handleAssetBalanceUpdated now writes a Holding row at the portfolio or account
grain (keyed off the HolderKind discriminator) for each side of the movement,
in addition to the AssetHolder rollup it already maintained — the rollup and
Asset.holderCount follow the Holding rows crossing zero. An account-grain holder
with no known Identity gets its Holding row without being folded into a DID
rollup it does not belong to.
…hole array

Defects G9, G10, and the largest single throughput cost in the indexer. Each
token id is now its own small immutable Nft row: a mint is N inserts, a transfer
is one location update, a redemption is one column update — instead of pushing
onto and filtering a lengthening nftIds array that historical mode re-serialises
in full on every mutation (measured: ~1.16M integer serialisations to delete 399
ids in one testnet block). burnedBlock is a nullable relation, not a Boolean
flag, since @index is invalid on Boolean — filter burnedBlockId: { isNull: true }.

The buffered NftHolder.nftIds rollup is still written for the SDK; Holding.nftCount
tracks each side per token. Handles NFTPortfolioUpdated (<=7.4) and
NFTHoldingsUpdated (v8) through the one already-routed handler.
Defect G11. New AssetAllowance entity (assetId/owner/spender) for the v8
account-level, ERC20-style spending allowances that bypass the identity model.
handleApproval upserts the remaining allowance; handleAllowanceSpent takes the
chain's own remainingAllowance value rather than subtracting amountSpent, so a
missed or reordered event cannot accumulate drift, and accrues totalSpent.
Approval and AllowanceSpent are new project.ts keys, registered with v8 decoder
shapes.
Defect G13 and the rest of G11. New AssetMetadata (assetId/scope/keyId),
GlobalMetadataKey and CustomAssetType entities with a MetadataScope enum.
Handlers for all ten previously-unregistered metadata / asset-type events:
local and global key registration, value and detail sets (the key is read from
the direct extrinsic, since the event omits it; a batched call is recorded
rather than guessed), deletions, spec updates, AssetTypeChanged, and the custom
type registry.

handleCreatedAssetTransfer writes an account-side AssetTransaction and, when the
event carries a pendingTransferId, links AssetTransaction.instruction to the
existing Instruction — the id is an InstructionId, so this needs no new state.
Asset.id is the assetId, not a ticker — the id comment and the AssetCreated
handler are corrected to match. isUniquenessRequired was a dead pre-6.0 concept
(investor uniqueness); it and the disableIu read that fed it are removed.
NftHolder.nftIds becomes [BigInt], matching Leg.nftIds and
AssetTransaction.nftIds — it was the one [Int] occurrence left (G10).

BREAKING CHANGE: Asset.isUniquenessRequired removed; NftHolder.nftIds is now
[BigInt] not [Int].
Plan 05. Every asset movement is now one row in one table. AssetTransaction
gains isInternalTransfer (not indexed — Boolean cannot be; filter by equality),
memo and address. The three PortfolioMovement writers —
handleFundsMovedBetweenPortfolios, handlePortfolioMovement and
settlement.FundsTransferred — funnel through one shared writer, and
createAssetTransaction classifies isInternalTransfer via a helper extracted to
utils/portfolios.ts.

The classifier keys on holder presence first, DID equality second: a holder that
is present but whose DID never resolved classifies false, never falling through
to the issuance/redemption (null) case. ControllerTransfer, which has no on-chain
same-DID guard, is classified the same way — the case eventId alone cannot decide.

The two tables provably did not overlap (every PortfolioMovement writer is
intra-Identity, and intra-Identity movement emits no AssetBalanceUpdated), so
this fills a gap rather than double counting.

BREAKING CHANGE: PortfolioMovement is removed. Query assetTransactions with
isInternalTransfer: { equalTo: true } instead. PortfolioMovementTypeEnum is
removed — the Fungible/NonFungible distinction survives as amount vs nftIds
being null.
Defect A8. portfolio.FungibleTokensMovedBetweenPortfolios (6 args) and
NFTsMovedBetweenPortfolios (5 args) were declared and emitted only at v5.4.3, in
unchecked_move_funds, and removed at v6.0.0. They are exclusive branches of a
match and MovedBetweenPortfolios is not emitted alongside them, so a v5-era
movement routed through unchecked_move_funds was absent from the index entirely.

Each gets its own legacy decoder entry (differing arities) and writes an
AssetTransaction with isInternalTransfer: true, matching the shape of its v6+
successor. Measured volume: 0 on mainnet, 1 on testnet — registered for
completeness and testnet parity.
- typescript:S6551 in parseMetadataKey: narrow the metadata-key value from
  `unknown` to `number | string` (it is always a `u64`) so the string coercion
  is a known primitive, not a potential `[object Object]`.
- new_duplicated_lines: the db-backed `store` mock, the `codec` stand-in and the
  tuple-event builder were copy-pasted across the five new handler tests. Extract
  them to tests/unit/helpers.ts and import.

No behaviour change; gate green (457 unit tests).
@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.

1 participant