Skip to content

chore: 🤖 address the review of the holdings, NFTs and metadata schema - #360

Open
prashantasdeveloper wants to merge 9 commits into
redesign/10-throughputfrom
redesign/11-holdings-review
Open

prashantasdeveloper wants to merge 9 commits into
redesign/10-throughputfrom
redesign/11-holdings-review

Conversation

@prashantasdeveloper

Copy link
Copy Markdown
Contributor

This PR fixes the nine findings from Francis's review of #352 (holdings, NFTs, movement ledger). It is stacked on #359 (redesign/10-throughput). #352 is further down the stack, so every finding was first checked against this tip (5b55ab0) before being fixed. All nine were still present; none had been fixed higher up.

Base: redesign/10-throughput. There is one commit per finding. Everything these commits fix was added earlier in this unreleased stack, so they are typed chore (and docs for S6), not fix/feat, to keep the release notes from listing a feature and then its fix.

# Finding Fix
S1 (High) CreatedAssetTransfer writes an AssetTransaction that duplicates the real movement, or records one that hasn't happened yet The event no longer writes a row. On v8.0.0, base_transfer_asset emits it only after settlement::transfer_funds returns. The movement is already recorded by FundsTransferred or AssetBalanceUpdated, or, when the receiver's affirmation is pending, it hasn't happened yet. The memo is already on the FundsTransferred row and on the instruction.
S2 (Medium) holderCount is always 0 for NFT collections The NFT path now counts an identity whenever its NftHolder rollup goes between empty and non-empty. The docstring now says the field counts identities.
S3 (Medium) Nft.metadata is never filled in but pays for a GIN index The field and its NftMetadataEntry type are removed. The field is new in this redesign, so it has no consumer yet.
S4 (Low) Redundant indexes Dropped the @index(unique: true) on Asset.assetId (the column stays) and Nft's ["asset","nftId"] composite index. Holding's composite indexes stay.
S5 (Low) LockedUntil loses its time Added AssetMetadata.lockedUntil. The other two points hold without a code change, because the chain never renames a global metadata name and never registers one twice. This is now documented.
S6 (Low) Holding rows outlive a zero balance, undocumented The docstring now says so and gives the filters for current holdings.
S7 (Low) A reused Holding row keeps its old identity identityId is now restamped on every touch.
S8 (Low) AssetAllowance.spender can point at a missing Account Owner and spender now resolve through ledgerAccount, which falls back to a bare Account row. That helper was added to the stack after the review, so the field stays non-null and there is no schema change.
S9 (Low) Asset.metadata derived field missing Added.

Consumer impact

  • Removed: Nft.metadata and the NftMetadataEntry type. Neither was ever released, so nothing breaks.
  • Added: AssetMetadata.lockedUntil and Asset.metadata.
  • Changed values:
    • Asset.holderCount is now non-zero for NFT collections.
    • assetTransactions no longer returns the duplicate or pending rows from CreatedAssetTransfer.
  • Indexes: the Asset.assetId index and the Nft (asset, nftId) composite index are gone. Look an asset up by id, which holds the same value.
  • Reindex: schema.graphql changed, so this needs a reindex from genesis.
  • Unchanged: the AssetAllowance.spender type.

One correction to #352's description: burnedEvent (formerly burnedBlock) is a relation, so SubQuery indexes it automatically. The description said it wasn't indexed.

Verification

  • yarn codegen && yarn typecheck && yarn lint && yarn test:unit (679 tests), plus yarn build and check-handlers.
  • New regression tests cover:
    • the NFT holder count through issue, transfer, same-identity move and redemption;
    • the LockedUntil moment;
    • a re-linked account receiving the asset again;
    • a spender with no identity.
  • The clean genesis resync of the stack will run from this branch.

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.
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.
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.
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.
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).
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.
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.
…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.
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.
@prashantasdeveloper
prashantasdeveloper marked this pull request as ready for review September 18, 2026 11:15
@prashantasdeveloper
prashantasdeveloper requested a review from a team as a code owner September 18, 2026 11:15
@sonarqubecloud

Copy link
Copy Markdown

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