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
F-OBrien
reviewed
Sep 17, 2026
|
|
||
| const holdingId = (assetId: string, holder: AssetHolderDetails): string => | ||
| holder.holderKind === HolderKind.Account | ||
| ? `${assetId}/${holder.account}` |
Contributor
There was a problem hiding this comment.
Should account holding ID also included the DID to cover cases where an account gets unlinked from one DID and linked to another?
| holderKind: holder.holderKind, | ||
| portfolioId: holder.holderKind === HolderKind.Portfolio ? getPortfolioId(holder) : undefined, | ||
| accountId: holder.holderKind === HolderKind.Account ? holder.account : undefined, | ||
| identityId: holder.identityId || undefined, |
Contributor
There was a problem hiding this comment.
This only gets set on initial creation which could leave a stale DID is an account changes identity and the ID is keyed by asset and account only.
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
S1 from the review of #352. asset::transfer_asset emits CreatedAssetTransfer after settlement::transfer_funds has returned (base_transfer_asset, v8.0.0), so the movement is either already recorded or has not happened: - same identity: FundsTransferred came first and mapAssetMovement wrote the row; - different identities, executed at once: AssetBalanceUpdated wrote it; - receiver affirmation pending: nothing has moved, and AssetBalanceUpdated writes it when the receiver affirms, in a later block or never. The extra row, keyed by its own event, duplicated the first two and recorded the third as a completed movement that stayed even if the receiver rejected it. Its identities were null and isInternalTransfer false, so identity filters missed it while counts and sums included it. Traced on testnet at blocks 25,888,006 / 056 / 071 / 086 / 147. The event is left unhandled. Its memo is already on the FundsTransferred row and on the instruction's InstructionCreated.
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
S2 from the review of #352. holderCount was only maintained by applyHoldingDelta, on the fungible path, so every NFT collection reported 0 holders, and its description ("where Holding rows cross zero") did not match what it counts. It counts identities, which every holder has, so the NFT path now does the same where an identity's NftHolder rollup goes between empty and non-empty: on issue, redemption, and a transfer between two identities. A move between one identity's portfolios changes nothing. The description now says it counts identities and that a key with no identity is not counted.
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
S3 from the review of #352. mintNft wrote `metadata: []` and nothing else ever wrote the field, but SubQuery gives every @jsonField a GIN index unless it is declared indexed: false, so every NFT mint, move and burn maintained an index over an empty array. The field is new in this redesign and has no consumer yet, so it is removed with its NftMetadataEntry type rather than kept empty. Filling it from the chain means a metadata read per token, which a bulk mint of thousands cannot afford; it can come back once that is designed.
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
S4 from the review of #352. Two index facts from the installed SubQuery (@subql/utils, @subql/node-core 19.0.0): every relation gets an index of its own, and in historical mode addBlockRangeColumnToIndexes turns every index into a (…, _block_range) GiST and drops `unique`, since GiST cannot enforce it. Plan 13 measured GiST maintenance as the main cost of historical mode. - Asset.assetId @index(unique: true) held the same value as `id`, enforced nothing, and added a GiST index to one of the most updated tables. The column stays for the SDK; the index goes. Nothing in the indexer looks an asset up by it. - Nft @compositeIndexes(["asset", "nftId"]) duplicated `id` (assetId/padId(nftId)) for point lookups and the automatic `asset` index for scans. Nothing looks a token up by it. Holding's two composite indexes stay: they serve the consumer's asset-by-identity and portfolio-by-asset queries, not lookups another index already covers. burnedEvent is a relation and so is indexed automatically, which the #352 description said it was not.
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
S5 from the review of #352. A metadata value's lock status is Unlocked | Locked | LockedUntil(Moment), and detailFrom collapsed LockedUntil to isLocked: true and threw the moment away. The chain emits nothing when that lock ends, so the row stayed locked for good with nothing to say when the value became editable. AssetMetadata gains lockedUntil (UTC, like expiry), set from LockedUntil and cleared by any other status or by MetadataValueDeleted; isLocked's description says to compare it with the current time. The review's other two points on this entity hold without a code change, and are now documented: a global key's name is copied once, but the chain has no rename for global metadata names; and GlobalMetadataSpecUpdated's lookup by the non-unique name index is exact, because register_asset_metadata_global_type refuses a name AssetMetadataGlobalNameToKey already holds (v8.0.0).
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
S6 from the review of #352. Holding rows are never removed when amount and nftCount reach zero, which is right under historical state, but a query such as holdings(filter: { portfolioId: { equalTo: "did/1" } }) then returns past holdings too. The entity description now says so and gives the filters for current holdings.
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
S7 from the review of #352. getHolding set identityId only when it created the row, and rows are never removed. An account cannot change identity while it holds an asset (a non-zero balance raises AccountKeyRefCount, and unlinking the key fails with AccountKeyIsBeingUsed), but it can once the balance is back to zero. If it then received the same asset again, the old row came back with the old DID while applyHoldingDelta sent the delta to the new DID's AssetHolder rollup, and SUM(Holding.amount) per (asset, identity) = AssetHolder.amount failed for both identities. identityId is now set from the resolved holder on every touch. Both callers save the row next, so it costs no extra write.
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
…tity S8 from the review of #352. getAssetAllowance resolved owner and spender with getOrCreateAccount, which creates a row only for a key with an identity, and skips multisig signer keys. asset::approve never checks the spender, so an allowance approved to a key with no identity, and never spent, pointed AssetAllowance.spender at an Account that did not exist, and any query selecting spender { … } failed with "Cannot return null for non-nullable field". Both sides now go through ledgerAccount, which falls back to a bare Account row (address and key type) exactly as the POLYX ledger already does for pallet and multisig addresses. The field stays non-null, so no schema change.
prashantasdeveloper
added a commit
that referenced
this pull request
Sep 18, 2026
S9 from the review of #352. Plan 03's Asset diff has metadata: [AssetMetadata!]! @derivedFrom(field: "asset"); the PR added holdings and nfts beside it but left this one out. A derived field adds no column and no index, so an asset's metadata can now be read from the asset instead of a separate assetMetadata query.
This was referenced Sep 18, 2026
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.