feat!: holdings, NFTs and the movement ledger (redesign 5/10) - #352
Open
prashantasdeveloper wants to merge 9 commits into
Open
prashantasdeveloper wants to merge 9 commits into
prashantasdeveloper wants to merge 9 commits into
Conversation
prashantasdeveloper
force-pushed
the
redesign/05-holdings-nfts
branch
from
September 10, 2026 13:21
5598c4a to
1324c23
Compare
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).
prashantasdeveloper
force-pushed
the
redesign/05-holdings-nfts
branch
from
September 10, 2026 13:49
418d011 to
3a26046
Compare
|
prashantasdeveloper
marked this pull request as ready for review
September 11, 2026 12:30
prashantasdeveloper
added this pull request to stack #358
September 15, 2026 08:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



PR 6 of 10 in the indexer redesign series. Based on
redesign/04-polyx-ledger(#349), notmaster. Implementsdocs/implementation/03-holdings-nfts.mdanddocs/implementation/05-movement-ledger.md.What changed
feat!: add the Holding entity at portfolio and account grainHoldingat the finest grain the chain uses (a portfolio, or a v8 account-level holder), with a genesis seeder following theaccountBalance.tspattern.AssetgainsassetId,holderCount,holdings.rawAssetHolderToAssetHoldernow carries aHolderKinddiscriminator.AssetHolder/NftHolderstay as maintained rollups (recommendation (b) — the SDK queries them directly).feat: write Holding rows from asset balance updateshandleAssetBalanceUpdatedwrites aHoldingrow per side alongside the rollup;holderCountfollows the rollup crossing zero. An account holder with no known Identity gets itsHoldingrow without being folded into a DID rollup.feat: add the Nft entity and stop rewriting NftHolder.nftIds as a whole arrayNftrow — mint = N inserts, transfer = one location update, burn = one column update — replacing push/filter on a lengtheningnftIdsarray that historical mode re-serialises in full every mutation.burnedBlockis a nullable relation (Boolean can't be indexed); filterburnedBlockId: { isNull: true }.feat: index v8 asset allowancesAssetAllowance(assetId/owner/spender).handleAllowanceSpenttakes the chain's ownremainingAllowancerather than subtracting, so a missed/reordered event can't drift.Approval/AllowanceSpentare newproject.tskeys with v8 decoder shapes.feat: index asset metadata and CreatedAssetTransferAssetMetadata(MetadataScopeLocal/Global),GlobalMetadataKey,CustomAssetType. Ten previously-unregistered metadata/type events handled.SetAssetMetadataValue/Detailscarry no key — it's recovered from thesetAssetMetadatacall args, else from aRegisterAssetMetadata*Typeevent in the same extrinsic (theregister-and-setpath — this was a review finding:registerAndSetLocalAssetMetadatawas silently dropping values), else recorded as an anomaly.handleCreatedAssetTransferwrites an account-sideAssetTransactionand linksinstructionwhenpendingTransferIdis present (it's anInstructionId, no new state).feat!: normalise Asset ids and drop isUniquenessRequiredAsset.idis the assetId;isUniquenessRequired(dead pre-6.0 concept) removed;NftHolder.nftIds→[BigInt](the one[Int]left, G10).feat!: fold PortfolioMovement into AssetTransactionAssetTransactiongainsisInternalTransfer(not indexed — filter by equality),memo,address. The threePortfolioMovementwriters funnel through one shared writer; classification is a helper inutils/portfolios.ts, keyed on holder presence first, DID equality second — an unresolved-DID holder classifiesfalse, never falling through to the issuance/redemption (null) case.PortfolioMovementandPortfolioMovementTypeEnumremoved.feat: index the v5-era portfolio movement eventsFungibleTokensMovedBetweenPortfolios(6 args) /NFTsMovedBetweenPortfolios(5 args), emitted only at v5.4.3 viaunchecked_move_fundsand never registered. Each gets its own legacy decoder entry; both writeAssetTransactionwithisInternalTransfer: true. Measured: 0 on mainnet, 1 on testnet.chore: address SonarCloud findings on the phase 5 difftypescript:S6551inparseMetadataKey— narrow the metadata-key value fromunknowntonumber | string.new_duplicated_lines— thestoremock,codecstand-in and tuple-event builder copy-pasted across the five new handler tests are extracted totests/unit/helpers.ts. No behaviour change.D5: full resync from genesis, no
db/migrationsentries.NFT throughput — the measured cost this removes
NftHolder.nftIds.push(...)/.filter(...)rewrites the whole array on every mutation, andunder historical state each
save()inserts a new row carrying the entire array. Testnet block15,391,572: 399
RedeemedNFT, one id per event, all against a holder whose array held 2,724ids ≈ 1.16M integer serialisations across 399 row versions — to delete 399 ids. The
Nftentity 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
portfolioMovementsdirectly.assetHolders/nftHolders— compatible, kept as rollups.nftHolders.nftIdsnarrows[Int]→[BigInt]; check SDK typings.assets— gainsassetId, losesisUniquenessRequired;holdersderived-field shape maychange.
portfolioMovements→ rewrite toassetTransactionswithisInternalTransfer: { equalTo: true }.portfolioMovements→ rewrite toassetTransactions. Itstype: { equalTo: Fungible|NonFungible }filter maps toamount/nftIdsnull-checks — whichits existing
assetTransactionsquery already does.fromPortfolioId/toPortfolioIdfiltersare unaffected and become better-served once
Holdingexists.holdings(filter: { portfolioId: { equalTo: "did/1" } })— not expressible against anything today.Notes
handleAssetBalanceUpdated(v6+). Pre-v6Issued/Redeemedcarry only aDID, not a portfolio, so
Holdingstays identity-grain via the rollup for the v5 era and becomesportfolio-precise from v6.
Nftsits at 9 indexes (subql auto-indexes themetadatajsonField), soburnedBlockis not
@index-ed —isNullfiltering is fine at the measured NFT cardinality (thousands). Thegenesis
Holdingseeder is fungible-only; the NFT read for an arbitrary start block belongs toplan 10.
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 wholeredesign and regenerate on the first resync. This branch is unit-gate-only, like phases 1–3.