diff --git a/project.ts b/project.ts index a6b4cfc6..3b4f2144 100644 --- a/project.ts +++ b/project.ts @@ -12,6 +12,8 @@ const dictionary = process.env.NETWORK_DICTIONARY || ''; const filters: Record> = { asset: { + AllowanceSpent: ['handleAllowanceSpent'], + Approval: ['handleApproval'], AssetAffirmationExemption: [], AssetBalanceUpdated: ['handleAssetBalanceUpdated'], AssetCreated: ['handleAssetCreated'], @@ -20,32 +22,32 @@ const filters: Record> = { AssetMediatorsRemoved: ['handleAssetMediatorsRemoved'], AssetOwnershipTransferred: ['handleAssetOwnershipTransferred'], AssetRenamed: ['handleAssetRenamed'], - AssetTypeChanged: [], + AssetTypeChanged: ['handleAssetTypeChanged'], AssetUnfrozen: ['handleUnfrozen'], ClassicTickerClaimed: [], - CreatedAssetTransfer: [], + CreatedAssetTransfer: ['handleCreatedAssetTransfer'], ControllerTransfer: [], - CustomAssetTypeExists: [], - CustomAssetTypeRegistered: [], + CustomAssetTypeExists: ['handleCustomAssetTypeExists'], + CustomAssetTypeRegistered: ['handleCustomAssetTypeRegistered'], DivisibilityChanged: ['handleDivisibilityChanged'], DocumentAdded: ['handleDocumentAdded'], DocumentRemoved: ['handleDocumentRemoved'], ExtensionRemoved: [], FundingRoundSet: ['handleFundingRoundSet'], - GlobalMetadataSpecUpdated: [], + GlobalMetadataSpecUpdated: ['handleGlobalMetadataSpecUpdated'], IdentifiersUpdated: ['handleIdentifiersUpdated'], IsIssuable: [], Issued: ['handleIssued', 'handleAssetIssuedStatistics'], - LocalMetadataKeyDeleted: [], - MetadataValueDeleted: [], + LocalMetadataKeyDeleted: ['handleLocalMetadataKeyDeleted'], + MetadataValueDeleted: ['handleMetadataValueDeleted'], PreApprovedAsset: ['handlePreApprovedAsset'], - RegisterAssetMetadataGlobalType: [], - RegisterAssetMetadataLocalType: [], + RegisterAssetMetadataGlobalType: ['handleRegisterAssetMetadataGlobalType'], + RegisterAssetMetadataLocalType: ['handleRegisterAssetMetadataLocalType'], RemoveAssetAffirmationExemption: [], RemovePreApprovedAsset: ['handleRemovePreApprovedAsset'], Redeemed: ['handleRedeemed', 'handleAssetRedeemedStatistics'], - SetAssetMetadataValue: [], - SetAssetMetadataValueDetails: [], + SetAssetMetadataValue: ['handleSetAssetMetadataValue'], + SetAssetMetadataValueDetails: ['handleSetAssetMetadataValueDetails'], TickerLinkedToAsset: ['handleTickerLinkedToAsset'], TickerRegistered: ['handleTickerRegistered'], TickerTransferred: ['handleTickerTransferred'], @@ -235,7 +237,9 @@ const filters: Record> = { portfolio: { AllowIdentityToCreatePortfolios: [], FundsMovedBetweenPortfolios: ['handleFundsMovedBetweenPortfolios'], + FungibleTokensMovedBetweenPortfolios: ['handleFungibleTokensMovedBetweenPortfolios'], MovedBetweenPortfolios: ['handlePortfolioMovement'], + NFTsMovedBetweenPortfolios: ['handleNftsMovedBetweenPortfolios'], PortfolioCreated: ['handlePortfolioCreated'], PortfolioCustodianChanged: ['handlePortfolioCustodianChanged'], PortfolioDeleted: ['handlePortfolioDeleted'], diff --git a/schema.graphql b/schema.graphql index 85db9c2a..b85f9752 100644 --- a/schema.graphql +++ b/schema.graphql @@ -2357,35 +2357,6 @@ type Portfolio @entity { createdEvent: Event! } -enum PortfolioMovementTypeEnum { - Fungible - NonFungible -} - -""" -Represents a transfer of fungible assets between a single identities portfolios. - -Note: transfers between identities are known as `Settlements` -""" -type PortfolioMovement @entity { - id: ID! - from: Portfolio @index(unique: false) - fromAccount: String @index(unique: false) - to: Portfolio @index(unique: false) - toAccount: String @index(unique: false) - identity: Identity @index(unique: false) - type: PortfolioMovementTypeEnum! - asset: Asset! - "the number of fungible tokens transferred. Defined if type == Fungible" - amount: BigInt - "the IDs of non-fungible tokens transferred. Defined if type == NonFungible" - nftIds: [BigInt] - memo: String - address: String! - createdBlock: Block! - updatedBlock: Block! -} - """ Represents a place to trade assets. This allows for additional mediation and control over the exchange of Assets @@ -2913,7 +2884,71 @@ type NftHolder @entity { id: ID! #assetId/did identity: Identity! @index(unique: false) asset: Asset! @index(unique: false) - nftIds: [Int] + nftIds: [BigInt] + createdBlock: Block! + updatedBlock: Block! +} + +""" +Discriminates the grain a `Holding` is keyed at: a portfolio (identity model) or a v8 +account-level holder. +""" +enum HolderKind { + Portfolio + Account +} + +""" +One key/value pair of an NFT's on-chain metadata. +""" +type NftMetadataEntry @jsonField { + key: String! + value: String! +} + +""" +One row per individual non-fungible token — the authoritative per-token record (defects G9, G10). +The `NftHolder` rollup is kept alongside for the SDK, but provenance ("who owns NFT #42?", "where +has it been?") is answered here. +""" +type Nft @entity @compositeIndexes(fields: [["asset", "nftId"]]) { + id: ID! # assetId/padId(nftId) + "the collection this token belongs to" + asset: Asset! + "BigInt everywhere — resolves G10" + nftId: BigInt! + "current location; exactly one of portfolio / account is set while the token is in circulation" + portfolio: Portfolio + account: Account + identity: Identity + metadata: [NftMetadataEntry] + mintedBlock: Block! + "null = still in circulation. Filter `burnedBlockId: { isNull: true }` — Boolean cannot be indexed" + burnedBlock: Block + updatedBlock: Block! +} + +""" +One row per (asset, holder) at the finest grain the chain uses — a portfolio, or a v8 +account-level holder. Identity-level holdings are derived from these (see `AssetHolder` / +`NftHolder`, kept as maintained rollups), never stored as primary truth. +""" +type Holding + @entity + @compositeIndexes(fields: [["asset", "identity"], ["portfolio", "asset"]]) { + id: ID! # assetId/portfolioId or assetId/address + asset: Asset! + holderKind: HolderKind! + "set when `holderKind` is `Portfolio`" + portfolio: Portfolio + "set when `holderKind` is `Account` (v8 account-level holder)" + account: Account + "denormalised — makes the identity rollup a groupBy, not a join. Null when the holder account has no known identity" + identity: Identity + "fungible balance held" + amount: BigInt! + "count of non-fungible tokens held; the ids live on `Nft`" + nftCount: Int! createdBlock: Block! updatedBlock: Block! } @@ -2948,7 +2983,9 @@ type AssetDocument @entity { Represents a tokenized Asset on the Polymesh chain. The central data structure for the ecosystem """ type Asset @entity { - id: ID! # ticker + id: ID! # assetId + "the chain's own asset id — equal to `id`; on 7.x+ the on-chain AssetId, the blake2-hashed legacy ticker before that" + assetId: String! @index(unique: true) ticker: String @index(unique: false) name: String @index(unique: false) isNftCollection: Boolean! @@ -2956,11 +2993,14 @@ type Asset @entity { fundingRound: String @index(unique: false) isDivisible: Boolean! isFrozen: Boolean! - isUniquenessRequired: Boolean! + "number of holders with a non-zero balance — maintained where `Holding` rows cross zero" + holderCount: Int! identifiers: [SecurityIdentifier]! owner: Identity! documents: [AssetDocument]! @derivedFrom(field: "asset") holders: [AssetHolder]! @derivedFrom(field: "asset") + holdings: [Holding!]! @derivedFrom(field: "asset") + nfts: [Nft!]! @derivedFrom(field: "asset") totalSupply: BigInt! totalTransfers: BigInt! isCompliancePaused: Boolean! @@ -3006,12 +3046,88 @@ type AssetTransaction @entity { instruction: Instruction "`instructionMemo` will only be present for the cases where memo is provided for the instruction which got executed" instructionMemo: String + "true when both sides belong to one Identity (a portfolio movement, or a settlement same-DID fast path); null for issuance and redemption. Not indexed — filter by plain equality" + isInternalTransfer: Boolean + "note attached to a portfolio movement or a settlement.transferFunds call" + memo: String + "the account that signed the extrinsic, for movements initiated directly by an account" + address: String datetime: Date! createdBlock: Block! updatedBlock: Block! createdEvent: Event! } +""" +The scope a metadata key is registered in: `Local` to one asset, or `Global` across the chain. +""" +enum MetadataScope { + Local + Global +} + +""" +A metadata key registered against an asset, with its current value (defect G13). Global keys +applied to an asset also land here; the chain-wide global key registry is `GlobalMetadataKey`. +""" +type AssetMetadata @entity { + id: ID! # assetId/scope/keyId + asset: Asset! @index(unique: false) + scope: MetadataScope! + "the numeric key id within its scope, as a string" + keyId: String! + "registered name of the key" + name: String + "current value, once one has been set" + value: String + "true while the value is Locked or LockedUntil" + isLocked: Boolean! + "value expiry, when the detail carried one" + expiry: Date + createdBlock: Block! + updatedBlock: Block! +} + +""" +A chain-wide global metadata key registration (name and id). Asset-scoped applications of a +global key are `AssetMetadata` rows. +""" +type GlobalMetadataKey @entity { + id: ID! # global key id + name: String! @index(unique: false) + "JSON of the metadata spec (url / description / typeDef), as last registered or updated" + spec: String + createdBlock: Block! + updatedBlock: Block! +} + +""" +A registered custom asset type — the `u32` id the chain assigns and the human name. +""" +type CustomAssetType @entity { + id: ID! # custom type id + name: String! + createdBlock: Block! + updatedBlock: Block! +} + +""" +An ERC20-style spending allowance one account has granted another over an asset (defect G11). +v8 account-level layer — keyed by `AccountId`, outside the identity/portfolio model. +""" +type AssetAllowance @entity { + id: ID! # assetId/owner/spender + asset: Asset! @index(unique: false) + owner: Account! @index(unique: false) + spender: Account! @index(unique: false) + "remaining allowance — taken from the chain's own computed value, never derived by subtraction" + amount: BigInt! + "lifetime total spent against this allowance" + totalSpent: BigInt! + createdBlock: Block! + updatedBlock: Block! +} + """ Represents an investment into an Asset """ diff --git a/src/decode/shapes/asset.ts b/src/decode/shapes/asset.ts index 112382b5..10cc4019 100644 --- a/src/decode/shapes/asset.ts +++ b/src/decode/shapes/asset.ts @@ -1,4 +1,4 @@ -import { LAST_V5, V6 } from './consts'; +import { LAST_V5, V6, V8 } from './consts'; import { discontinuedAt, registerShape, stable } from './registry'; /** @@ -87,6 +87,37 @@ registerShape( discontinuedAt(LAST_V5, ['did', 'assetId', 'beneficiaryDid', 'amount']) ); +// v8 account-level asset layer (defect G11). AccountId-keyed, so no `did` parameter. +registerShape('asset', 'Approval', [ + { from: V8, fields: ['owner', 'spender', 'assetId', 'amount'] }, +]); +registerShape('asset', 'AllowanceSpent', [ + { from: V8, fields: ['owner', 'spender', 'assetId', 'amountSpent', 'remainingAllowance'] }, +]); + +// the v8 account-side transfer path — `pendingTransferId` links to an existing Instruction +registerShape('asset', 'CreatedAssetTransfer', [ + { from: V8, fields: ['assetId', 'from', 'to', 'amount', 'memo', 'pendingTransferId'] }, +]); + +// asset metadata (defect G13) and asset-type events. Arity has been stable across the tags +// walked in docs/reference/event-shape-verification.md; only `Ticker → AssetId` at 7.x, which +// `getAssetId` already absorbs. +registerShape('asset', 'SetAssetMetadataValue', stable(['did', 'assetId', 'value', 'detail'])); +registerShape('asset', 'SetAssetMetadataValueDetails', stable(['did', 'assetId', 'detail'])); +registerShape( + 'asset', + 'RegisterAssetMetadataLocalType', + stable(['did', 'assetId', 'name', 'localKeyId', 'spec']) +); +registerShape('asset', 'RegisterAssetMetadataGlobalType', stable(['name', 'globalKeyId', 'spec'])); +registerShape('asset', 'LocalMetadataKeyDeleted', stable(['did', 'assetId', 'localKeyId'])); +registerShape('asset', 'MetadataValueDeleted', stable(['did', 'assetId', 'key'])); +registerShape('asset', 'GlobalMetadataSpecUpdated', stable(['name', 'spec'])); +registerShape('asset', 'AssetTypeChanged', stable(['did', 'assetId', 'assetType'])); +registerShape('asset', 'CustomAssetTypeExists', stable(['did', 'typeId', 'name'])); +registerShape('asset', 'CustomAssetTypeRegistered', stable(['did', 'typeId', 'name'])); + registerShape('asset', 'TickerRegistered', stable(['did', 'ticker', 'expiry'])); // Deprecated at 6.0.0 registerShape('asset', 'ClassicTickerClaimed', discontinuedAt(LAST_V5, ['did', 'ticker'])); diff --git a/src/decode/shapes/index.ts b/src/decode/shapes/index.ts index 348337aa..14b89ff7 100644 --- a/src/decode/shapes/index.ts +++ b/src/decode/shapes/index.ts @@ -16,6 +16,7 @@ import './asset'; import './balances'; import './externalAgents'; import './identity'; +import './portfolio'; import './settlement'; import './staking'; diff --git a/src/decode/shapes/portfolio.ts b/src/decode/shapes/portfolio.ts new file mode 100644 index 00000000..be77104f --- /dev/null +++ b/src/decode/shapes/portfolio.ts @@ -0,0 +1,23 @@ +import { LAST_V5 } from './consts'; +import { discontinuedAt, registerShape } from './registry'; + +/** + * `portfolio` pallet parameter shapes. + * + * `FungibleTokensMovedBetweenPortfolios` and `NFTsMovedBetweenPortfolios` were declared and + * emitted only at v5.4.3, in `unchecked_move_funds`, and removed at v6.0.0 (defect A8). They are + * exclusive branches of a `match` and `MovedBetweenPortfolios` is not emitted alongside them, so + * a v5-era movement routed through `unchecked_move_funds` is otherwise absent from the index. + * Different arities (6 vs 5), so each needs its own entry. + */ +registerShape( + 'portfolio', + 'FungibleTokensMovedBetweenPortfolios', + discontinuedAt(LAST_V5, ['did', 'fromPortfolio', 'toPortfolio', 'ticker', 'amount', 'memo']) +); + +registerShape( + 'portfolio', + 'NFTsMovedBetweenPortfolios', + discontinuedAt(LAST_V5, ['did', 'fromPortfolio', 'toPortfolio', 'nfts', 'memo']) +); diff --git a/src/mappings/entities/assets/mapAsset.ts b/src/mappings/entities/assets/mapAsset.ts index 3de0c71e..803a7115 100644 --- a/src/mappings/entities/assets/mapAsset.ts +++ b/src/mappings/entities/assets/mapAsset.ts @@ -6,18 +6,23 @@ import { Asset, AssetDocument, AssetHolder, + AssetAllowance, AssetMandatoryMediator, AssetPreApproval, AssetTransaction, CallIdEnum, EventIdEnum, Funding, + HolderKind, + Holding, SecurityIdentifier, } from '../../../types'; import { + accountHolder, AssetHolderDetails, bytesToString, camelToSnakeCase, + classifyInternalTransfer, coerceHexToString, emptyDid, getAssetId, @@ -28,6 +33,7 @@ import { getFirstKeyFromJson, getFirstValueFromJson, getNumberValue, + getOrCreateAccount, getPortfolioId, getSecurityIdentifiers, getStringArrayValue, @@ -36,7 +42,6 @@ import { isMigratedAssetId, rawAssetHolderToAssetHolder, serializeTicker, - specVersionOf, } from '../../../utils'; import { processInstructionId } from '../settlements/mapSettlement'; import { extractArgs, getAsset, getAssetOrAnomaly } from './../common'; @@ -69,7 +74,14 @@ export const createAssetTransaction = ( datetime: Date, details: Pick< AssetTransaction, - 'assetId' | 'amount' | 'fundingRound' | 'nftIds' | 'instructionId' | 'instructionMemo' + | 'assetId' + | 'amount' + | 'fundingRound' + | 'nftIds' + | 'instructionId' + | 'instructionMemo' + | 'memo' + | 'address' > & { fromHolder?: AssetHolderDetails; toHolder?: AssetHolderDetails }, blockEventId: string, eventId?: EventIdEnum, @@ -120,6 +132,9 @@ export const createAssetTransaction = ( ...details, // adding in fall back for `eventId` helps in identifying cases where utility.batchAtomic is used as extrinsic eventId: callToEventMappings[callId] || eventId || callToEventMappings['default'], + // classified on holder presence first, DID equality second — an unresolved holder is + // present, not absent, and must never read as an issuance/redemption + isInternalTransfer: classifyInternalTransfer(details.fromHolder, details.toHolder), fromPortfolioId, fromAccount, fromIdentityId, @@ -159,6 +174,77 @@ export const getAssetHolder = async ( return assetHolder; }; +const holdingId = (assetId: string, holder: AssetHolderDetails): string => + holder.holderKind === HolderKind.Account + ? `${assetId}/${holder.account}` + : `${assetId}/${getPortfolioId(holder)}`; + +export const getHolding = async ( + assetId: string, + holder: AssetHolderDetails, + blockId: string +): Promise => { + const id = holdingId(assetId, holder); + + let holding = await Holding.get(id); + + if (!holding) { + holding = Holding.create({ + id, + assetId, + holderKind: holder.holderKind, + portfolioId: holder.holderKind === HolderKind.Portfolio ? getPortfolioId(holder) : undefined, + accountId: holder.holderKind === HolderKind.Account ? holder.account : undefined, + identityId: holder.identityId || undefined, + amount: BigInt(0), + nftCount: 0, + createdBlockId: blockId, + updatedBlockId: blockId, + }); + } + + return holding; +}; + +/** + * Applies a fungible delta to one holder: the portfolio/account-grain `Holding` row it is the + * primary truth for, and the identity-grain `AssetHolder` rollup kept alongside (plan 03 + * recommendation (b) — the SDK queries the rollup directly, and one row version per touched + * block is cheap). `Asset.holderCount` follows the rollup crossing zero. + * + * The rollup and `holderCount` are only touched when the holder resolves to a DID — an + * account-grain holder with no known Identity still gets its `Holding` row, and must not be + * folded into a DID rollup it does not belong to. + */ +export const applyHoldingDelta = async ( + asset: Asset, + holder: AssetHolderDetails, + blockId: string, + delta: bigint, + promises: Promise[] +): Promise => { + const holding = await getHolding(asset.id, holder, blockId); + holding.amount += delta; + holding.updatedBlockId = blockId; + promises.push(holding.save()); + + if (!holder.identityId) { + return; + } + + const rollup = await getAssetHolder(asset.id, holder.identityId, blockId); + const before = rollup.amount; + rollup.amount += delta; + rollup.updatedBlockId = blockId; + promises.push(rollup.save()); + + if (before <= BigInt(0) && rollup.amount > BigInt(0)) { + asset.holderCount += 1; + } else if (before > BigInt(0) && rollup.amount <= BigInt(0)) { + asset.holderCount = Math.max(0, asset.holderCount - 1); + } +}; + export const handleAssetCreated = async (event: SubstrateEvent): Promise => { const { block, eventIdx, blockId, blockEventId } = extractArgs(event); const decoded = decodeEvent(event); @@ -172,14 +258,6 @@ export const handleAssetCreated = async (event: SubstrateEvent): Promise = fundingRound: rawFundingRoundName, } = decoded; - /** - * Investor uniqueness was removed at 6.0.0 and the parameter went with it, so only the pre-6 - * shape declares `disableIu`. Reading it on a later event would throw, which is why the - * version check is here rather than a `?.` - */ - const isUniquenessRequired = - specVersionOf(block) < 6_000_000 && !getBooleanValue(decoded.disableIu); - const ownerId = getTextValue(rawOwnerDid); const ticker = @@ -226,8 +304,11 @@ export const handleAssetCreated = async (event: SubstrateEvent): Promise = identifiers = getSecurityIdentifiers(rawIdentifiers); } + const assetId = await getAssetId(rawAssetId, block); + await Asset.create({ - id: await getAssetId(rawAssetId, block), + id: assetId, + assetId, ticker, name, type: assetType, @@ -235,7 +316,7 @@ export const handleAssetCreated = async (event: SubstrateEvent): Promise = fundingRound, isDivisible: getBooleanValue(divisible), isFrozen: false, - isUniquenessRequired, + holderCount: 0, identifiers, ownerId, totalSupply: BigInt(0), @@ -542,19 +623,6 @@ export const handleAssetTransfer = async (event: SubstrateEvent): Promise await Promise.all(promises); }; -const updateAssetHolderAmount = async ( - assetId: string, - identityId: string, - blockId: string, - delta: bigint, - promises: Promise[] -): Promise => { - const holder = await getAssetHolder(assetId, identityId, blockId); - holder.amount += delta; - holder.updatedBlockId = blockId; - promises.push(holder.save()); -}; - type UpdateReasonResult = { eventId: EventIdEnum; fundingRoundName?: string; @@ -641,19 +709,13 @@ export const handleAssetBalanceUpdated = async (event: SubstrateEvent): Promise< if (!rawFromHolder.isEmpty) { fromHolder = await rawAssetHolderToAssetHolder(rawFromHolder, block, blockId); - await updateAssetHolderAmount( - assetId, - fromHolder.identityId, - blockId, - -transferAmount, - promises - ); + await applyHoldingDelta(asset, fromHolder, blockId, -transferAmount, promises); } let toHolder: AssetHolderDetails | undefined; if (!rawToHolder.isEmpty) { toHolder = await rawAssetHolderToAssetHolder(rawToHolder, block, blockId); - await updateAssetHolderAmount(assetId, toHolder.identityId, blockId, transferAmount, promises); + await applyHoldingDelta(asset, toHolder, blockId, transferAmount, promises); } const updateReason = getFirstKeyFromJson(rawUpdateReason); @@ -674,7 +736,14 @@ export const handleAssetBalanceUpdated = async (event: SubstrateEvent): Promise< if (assetDelta.totalTransfers !== undefined) { asset.totalTransfers += assetDelta.totalTransfers; } - if (assetDelta.totalSupply !== undefined || assetDelta.totalTransfers !== undefined) { + if ( + assetDelta.totalSupply !== undefined || + assetDelta.totalTransfers !== undefined || + fromHolder || + toHolder + ) { + // one save persists the supply/transfer deltas and any holderCount change applyHoldingDelta + // made to this same in-memory Asset asset.updatedBlockId = blockId; promises.push(asset.save()); } @@ -775,3 +844,120 @@ export const handleRemovePreApprovedAsset = async (event: SubstrateEvent): Promi await AssetPreApproval.remove(`${assetId}/${identityId}`); }; + +const getAssetAllowance = async ( + assetId: string, + ownerId: string, + spenderId: string, + blockId: string, + block: SubstrateEvent['block'] +): Promise => { + await Promise.all([ + getOrCreateAccount(ownerId, blockId, block.timestamp), + getOrCreateAccount(spenderId, blockId, block.timestamp), + ]); + + const id = `${assetId}/${ownerId}/${spenderId}`; + + return ( + (await AssetAllowance.get(id)) ?? + AssetAllowance.create({ + id, + assetId, + ownerId, + spenderId, + amount: BigInt(0), + totalSpent: BigInt(0), + createdBlockId: blockId, + updatedBlockId: blockId, + }) + ); +}; + +export const handleApproval = async (event: SubstrateEvent): Promise => { + const { blockId, block } = extractArgs(event); + const { + owner: rawOwner, + spender: rawSpender, + assetId: rawAssetId, + amount: rawAmount, + } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const allowance = await getAssetAllowance( + assetId, + getTextValue(rawOwner), + getTextValue(rawSpender), + blockId, + block + ); + + allowance.amount = getBigIntValue(rawAmount); + allowance.updatedBlockId = blockId; + + await allowance.save(); +}; + +export const handleCreatedAssetTransfer = async (event: SubstrateEvent): Promise => { + const { blockId, eventIdx, block, extrinsic, blockEventId } = extractArgs(event); + const { + assetId: rawAssetId, + from: rawFrom, + to: rawTo, + amount: rawAmount, + memo: rawMemo, + pendingTransferId: rawPending, + } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + await getAsset(assetId); + + // `pendingTransferId` is an InstructionId — the pending transfer is an already-modelled + // Instruction, so this is a plain relation, no new state machine + const instructionId = rawPending?.isEmpty ? undefined : getTextValue(rawPending); + + await createAssetTransaction( + blockId, + eventIdx, + block.timestamp, + { + assetId, + fromHolder: accountHolder(undefined, getTextValue(rawFrom)), + toHolder: accountHolder(undefined, getTextValue(rawTo)), + amount: getBigIntValue(rawAmount), + instructionId, + instructionMemo: rawMemo?.isEmpty ? undefined : bytesToString(rawMemo), + }, + blockEventId, + EventIdEnum.CreatedAssetTransfer, + extrinsic + ); +}; + +export const handleAllowanceSpent = async (event: SubstrateEvent): Promise => { + const { blockId, block } = extractArgs(event); + const { + owner: rawOwner, + spender: rawSpender, + assetId: rawAssetId, + amountSpent: rawAmountSpent, + remainingAllowance: rawRemaining, + } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const allowance = await getAssetAllowance( + assetId, + getTextValue(rawOwner), + getTextValue(rawSpender), + blockId, + block + ); + + // take the chain's own remaining value rather than subtracting — subtraction drifts if any + // AllowanceSpent is ever missed or reordered + allowance.amount = getBigIntValue(rawRemaining); + allowance.totalSpent += getBigIntValue(rawAmountSpent); + allowance.updatedBlockId = blockId; + + await allowance.save(); +}; diff --git a/src/mappings/entities/assets/mapAssetMetadata.ts b/src/mappings/entities/assets/mapAssetMetadata.ts new file mode 100644 index 00000000..fc8c6eb5 --- /dev/null +++ b/src/mappings/entities/assets/mapAssetMetadata.ts @@ -0,0 +1,305 @@ +import { SubstrateEvent, SubstrateExtrinsic } from '@subql/types'; +import { decodeEvent } from '../../../decode'; +import { + AnomalyKind, + AssetMetadata, + CustomAssetType, + GlobalMetadataKey, + MetadataScope, +} from '../../../types'; +import { + bytesToString, + getAssetId, + getAssetType, + getNumberValue, + getTextValue, +} from '../../../utils'; +import { recordAnomaly } from '../../../utils/anomaly'; +import { extractArgs, getAsset } from '../common'; + +type MetadataKey = { scope: MetadataScope; keyId: string }; + +/** `{ Local: n } | { Global: n }`, from an event param or an extrinsic arg. The `n` is a `u64`. */ +const parseMetadataKey = (raw: unknown): MetadataKey | undefined => { + const obj = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record; + if (obj && ('local' in obj || 'Local' in obj)) { + return { scope: MetadataScope.Local, keyId: `${obj.local ?? obj.Local}` }; + } + if (obj && ('global' in obj || 'Global' in obj)) { + return { scope: MetadataScope.Global, keyId: `${obj.global ?? obj.Global}` }; + } + return undefined; +}; + +/** + * `SetAssetMetadataValue` / `...ValueDetails` do not carry the metadata key. It is recovered from + * either the call args or a sibling event, in that order: + * + * 1. a direct `asset.setAssetMetadata` / `setAssetMetadataDetails` call — the key is `args[1]`; + * 2. `asset.registerAndSetLocalAssetMetadata`, which registers the key and sets its value in one + * call and so emits `RegisterAssetMetadataLocalType` in the same extrinsic, just before this + * event — that carries the new key id. + * + * Only when neither resolves (an unrecognised wrapper) is the row dropped with an anomaly. + */ +const metadataKeyFromExtrinsic = ( + extrinsic: SubstrateExtrinsic | undefined +): MetadataKey | undefined => { + const method = extrinsic?.extrinsic.method; + if ( + method?.section !== 'asset' || + (method.method !== 'setAssetMetadata' && method.method !== 'setAssetMetadataDetails') + ) { + return undefined; + } + return parseMetadataKey(extrinsic.extrinsic.args[1]?.toJSON()); +}; + +/** The fields of a block `EventRecord` this module reads — avoids the CJS/ESM `EventRecord` clash. */ +type BlockEventRecord = { + event: { section: string; method: string }; + phase: { isApplyExtrinsic: boolean; asApplyExtrinsic: { toNumber: () => number } }; +}; + +const isFromExtrinsic = (record: BlockEventRecord, extrinsicIdx: number | undefined): boolean => + extrinsicIdx !== undefined && + record.phase.isApplyExtrinsic && + record.phase.asApplyExtrinsic.toNumber() === extrinsicIdx; + +/** + * The key id from a `RegisterAssetMetadata{Local,Global}Type` emitted earlier in the same + * extrinsic — the `register-and-set` path, where the setter event has no key of its own. + */ +const metadataKeyFromSiblingRegistration = (event: SubstrateEvent): MetadataKey | undefined => { + const events = event.block.events as unknown as BlockEventRecord[]; + const siblingIdx = events.findIndex( + record => + record.event.section === 'asset' && + (record.event.method === 'RegisterAssetMetadataLocalType' || + record.event.method === 'RegisterAssetMetadataGlobalType') && + isFromExtrinsic(record, event.extrinsic?.idx) + ); + + if (siblingIdx === -1) { + return undefined; + } + + const sibling = events[siblingIdx]; + const decoded = decodeEvent({ + ...event, + idx: siblingIdx, + event: sibling.event, + } as unknown as SubstrateEvent); + + return sibling.event.method === 'RegisterAssetMetadataLocalType' + ? { scope: MetadataScope.Local, keyId: getTextValue(decoded.localKeyId) } + : { scope: MetadataScope.Global, keyId: getTextValue(decoded.globalKeyId) }; +}; + +const resolveMetadataKey = (event: SubstrateEvent): MetadataKey | undefined => + metadataKeyFromExtrinsic(event.extrinsic) ?? metadataKeyFromSiblingRegistration(event); + +const metadataId = (assetId: string, key: MetadataKey): string => + `${assetId}/${key.scope}/${key.keyId}`; + +const detailFrom = (rawDetail: unknown): { isLocked: boolean; expiry: Date | undefined } => { + const detail = rawDetail as { expire?: number | null; lockStatus?: unknown } | null; + if (!detail) { + return { isLocked: false, expiry: undefined }; + } + const lock = detail.lockStatus; + const isLocked = typeof lock === 'string' ? lock !== 'Unlocked' : lock != null; + const expiry = detail.expire ? new Date(Number(detail.expire)) : undefined; + return { isLocked, expiry }; +}; + +const upsertMetadata = async ( + assetId: string, + key: MetadataKey, + blockId: string, + apply: (row: AssetMetadata) => void +): Promise => { + const id = metadataId(assetId, key); + const row = + (await AssetMetadata.get(id)) ?? + AssetMetadata.create({ + id, + assetId, + scope: key.scope, + keyId: key.keyId, + isLocked: false, + createdBlockId: blockId, + updatedBlockId: blockId, + }); + + if (key.scope === MetadataScope.Global && !row.name) { + row.name = (await GlobalMetadataKey.get(key.keyId))?.name; + } + + apply(row); + row.updatedBlockId = blockId; + await row.save(); +}; + +export const handleRegisterAssetMetadataLocalType = async ( + event: SubstrateEvent +): Promise => { + const { blockId, block } = extractArgs(event); + const { assetId: rawAssetId, name: rawName, localKeyId: rawKeyId } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + await getAsset(assetId); + + await upsertMetadata( + assetId, + { scope: MetadataScope.Local, keyId: getTextValue(rawKeyId) }, + blockId, + row => { + row.name = bytesToString(rawName); + } + ); +}; + +export const handleRegisterAssetMetadataGlobalType = async ( + event: SubstrateEvent +): Promise => { + const { blockId } = extractArgs(event); + const { name: rawName, globalKeyId: rawKeyId, spec: rawSpec } = decodeEvent(event); + + const id = getTextValue(rawKeyId); + const row = + (await GlobalMetadataKey.get(id)) ?? + GlobalMetadataKey.create({ + id, + name: bytesToString(rawName), + createdBlockId: blockId, + updatedBlockId: blockId, + }); + + row.name = bytesToString(rawName); + row.spec = rawSpec.isEmpty ? undefined : JSON.stringify(rawSpec.toJSON()); + row.updatedBlockId = blockId; + await row.save(); +}; + +export const handleGlobalMetadataSpecUpdated = async (event: SubstrateEvent): Promise => { + const { blockId } = extractArgs(event); + const { name: rawName, spec: rawSpec } = decodeEvent(event); + + // the event carries the name, not the id; match on the indexed name + const name = bytesToString(rawName); + const [match] = await GlobalMetadataKey.getByName(name, { limit: 1, offset: 0 }); + if (match) { + match.spec = rawSpec.isEmpty ? undefined : JSON.stringify(rawSpec.toJSON()); + match.updatedBlockId = blockId; + await match.save(); + } +}; + +export const handleSetAssetMetadataValue = async (event: SubstrateEvent): Promise => { + const { blockId, block } = extractArgs(event); + const { assetId: rawAssetId, value: rawValue, detail: rawDetail } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const key = resolveMetadataKey(event); + + if (!key) { + await recordAnomaly({ + kind: AnomalyKind.MissingReferencedEntity, + detail: `SetAssetMetadataValue for asset ${assetId} could not resolve its metadata key from the extrinsic or a sibling registration event`, + block, + eventIdx: event.idx, + }); + return; + } + + const { isLocked, expiry } = detailFrom(rawDetail?.isEmpty ? null : rawDetail?.toJSON()); + + await upsertMetadata(assetId, key, blockId, row => { + row.value = bytesToString(rawValue); + row.isLocked = isLocked; + row.expiry = expiry; + }); +}; + +export const handleSetAssetMetadataValueDetails = async (event: SubstrateEvent): Promise => { + const { blockId, block } = extractArgs(event); + const { assetId: rawAssetId, detail: rawDetail } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const key = resolveMetadataKey(event); + if (!key) { + return; + } + + const { isLocked, expiry } = detailFrom(rawDetail?.toJSON()); + + await upsertMetadata(assetId, key, blockId, row => { + row.isLocked = isLocked; + row.expiry = expiry; + }); +}; + +export const handleLocalMetadataKeyDeleted = async (event: SubstrateEvent): Promise => { + const { block } = extractArgs(event); + const { assetId: rawAssetId, localKeyId: rawKeyId } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + await AssetMetadata.remove( + metadataId(assetId, { scope: MetadataScope.Local, keyId: getTextValue(rawKeyId) }) + ); +}; + +export const handleMetadataValueDeleted = async (event: SubstrateEvent): Promise => { + const { blockId, block } = extractArgs(event); + const { assetId: rawAssetId, key: rawKey } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const key = parseMetadataKey(rawKey.toJSON()); + if (!key) { + return; + } + + const row = await AssetMetadata.get(metadataId(assetId, key)); + if (row) { + row.value = undefined; + row.isLocked = false; + row.expiry = undefined; + row.updatedBlockId = blockId; + await row.save(); + } +}; + +export const handleAssetTypeChanged = async (event: SubstrateEvent): Promise => { + const { blockId, block } = extractArgs(event); + const { assetId: rawAssetId, assetType: rawType } = decodeEvent(event); + + const assetId = await getAssetId(rawAssetId, block); + const asset = await getAsset(assetId); + + asset.type = await getAssetType(rawType); + asset.updatedBlockId = blockId; + await asset.save(); +}; + +const upsertCustomAssetType = async (event: SubstrateEvent): Promise => { + const { blockId } = extractArgs(event); + const { typeId: rawTypeId, name: rawName } = decodeEvent(event); + + const id = getNumberValue(rawTypeId).toString(); + const existing = await CustomAssetType.get(id); + + if (existing) { + return; + } + + await CustomAssetType.create({ + id, + name: bytesToString(rawName), + createdBlockId: blockId, + updatedBlockId: blockId, + }).save(); +}; + +export const handleCustomAssetTypeRegistered = upsertCustomAssetType; +export const handleCustomAssetTypeExists = upsertCustomAssetType; diff --git a/src/mappings/entities/assets/mapNfts.ts b/src/mappings/entities/assets/mapNfts.ts index b80b9e4e..cf8ec0ae 100644 --- a/src/mappings/entities/assets/mapNfts.ts +++ b/src/mappings/entities/assets/mapNfts.ts @@ -1,6 +1,6 @@ import { Codec } from '@polkadot/types/types'; import { SubstrateEvent } from '@subql/types'; -import { EventIdEnum, NftHolder } from '../../../types'; +import { EventIdEnum, HolderKind, Nft, NftHolder } from '../../../types'; import { AssetHolderDetails, bytesToString, @@ -8,11 +8,83 @@ import { getFirstKeyFromJson, getFirstValueFromJson, getNftId, + getPortfolioId, getTextValue, + padId, + portfolioHolder, rawAssetHolderToAssetHolder, } from '../../../utils'; import { extractArgs, getAsset } from './../common'; -import { createAssetTransaction } from './mapAsset'; +import { createAssetTransaction, getHolding } from './mapAsset'; + +const nftRowId = (assetId: string, nftId: number): string => `${assetId}/${padId(String(nftId))}`; + +const locationOf = ( + holder: AssetHolderDetails | undefined +): Pick => ({ + portfolioId: holder?.holderKind === HolderKind.Portfolio ? getPortfolioId(holder) : undefined, + accountId: holder?.holderKind === HolderKind.Account ? holder.account : undefined, + identityId: holder?.identityId || undefined, +}); + +const mintNft = ( + assetId: string, + nftId: number, + holder: AssetHolderDetails, + blockId: string +): Promise => + Nft.create({ + id: nftRowId(assetId, nftId), + assetId, + nftId: BigInt(nftId), + ...locationOf(holder), + metadata: [], + mintedBlockId: blockId, + updatedBlockId: blockId, + }).save(); + +/** Moves a token to a new location. A miss means the row predates the seed — reconciliation covers it. */ +const moveNft = async ( + assetId: string, + nftId: number, + holder: AssetHolderDetails, + blockId: string +): Promise => { + const nft = await Nft.get(nftRowId(assetId, nftId)); + if (!nft) { + return; + } + Object.assign(nft, locationOf(holder)); + nft.updatedBlockId = blockId; + return nft.save(); +}; + +/** Marks a token burned. The row stays queryable; `burnedBlockId: { isNull: true }` filters it out. */ +const burnNft = async (assetId: string, nftId: number, blockId: string): Promise => { + const nft = await Nft.get(nftRowId(assetId, nftId)); + if (!nft) { + return; + } + nft.burnedBlockId = blockId; + nft.updatedBlockId = blockId; + return nft.save(); +}; + +const adjustNftCount = async ( + assetId: string, + holder: AssetHolderDetails | undefined, + blockId: string, + delta: number, + promises: Promise[] +): Promise => { + if (!holder) { + return; + } + const holding = await getHolding(assetId, holder, blockId); + holding.nftCount += delta; + holding.updatedBlockId = blockId; + promises.push(holding.save()); +}; /** * `NftHolder.nftIds` is a JSON array, and under historical mode every `.save()` writes a new @@ -118,6 +190,8 @@ export const handleNftHoldingsUpdates = async (event: SubstrateEvent): Promise promises.push(mintNft(assetId, nftId, holder, blockId))); + await adjustNftCount(assetId, holder, blockId, ids.length, promises); } else if (reason === 'redeemed') { eventId = EventIdEnum.RedeemedNFT; asset.totalSupply -= BigInt(ids.length); const nftHolder = await getNftHolder(assetId, did, blockId); - nftHolder.nftIds = nftHolder.nftIds.filter(heldId => !ids.includes(heldId)); + nftHolder.nftIds = nftHolder.nftIds.filter(heldId => !bigIds.includes(heldId)); nftHolder.updatedBlockId = blockId; await bufferHolder(blockId, nftHolder); + + // one single-column update per token instead of an n-element array filter + const holder = fromHolder ?? portfolioHolder(did, 0); + ids.forEach(nftId => promises.push(burnNft(assetId, nftId, blockId))); + await adjustNftCount(assetId, holder, blockId, -ids.length, promises); } else if (reason === 'transferred' || reason === 'controllerTransfer') { - const [fromHolder, toHolder] = await Promise.all([ + const [fromRollup, toRollup] = await Promise.all([ getNftHolder(assetId, fromDid, blockId), getNftHolder(assetId, toDid, blockId), ]); - fromHolder.nftIds = fromHolder.nftIds.filter(id => !ids.includes(id)); - toHolder.nftIds.push(...ids); - fromHolder.updatedBlockId = blockId; - toHolder.updatedBlockId = blockId; + fromRollup.nftIds = fromRollup.nftIds.filter(id => !bigIds.includes(id)); + toRollup.nftIds.push(...bigIds); + fromRollup.updatedBlockId = blockId; + toRollup.updatedBlockId = blockId; + + await bufferHolder(blockId, fromRollup); + await bufferHolder(blockId, toRollup); - await bufferHolder(blockId, fromHolder); - await bufferHolder(blockId, toHolder); + ids.forEach(nftId => promises.push(moveNft(assetId, nftId, toHolder, blockId))); + await adjustNftCount(assetId, fromHolder, blockId, -ids.length, promises); + await adjustNftCount(assetId, toHolder, blockId, ids.length, promises); asset.totalTransfers += BigInt(1); diff --git a/src/mappings/entities/identities/mapPortfolio.ts b/src/mappings/entities/identities/mapPortfolio.ts index 6a9bdfe4..9359f2ee 100644 --- a/src/mappings/entities/identities/mapPortfolio.ts +++ b/src/mappings/entities/identities/mapPortfolio.ts @@ -1,10 +1,6 @@ -import { SubstrateBlock, SubstrateEvent } from '@subql/types'; -import { - EventIdEnum, - Portfolio, - PortfolioMovement, - PortfolioMovementTypeEnum, -} from '../../../types'; +import { SubstrateBlock, SubstrateEvent, SubstrateExtrinsic } from '@subql/types'; +import { decodeEvent } from '../../../decode'; +import { EventIdEnum, Portfolio } from '../../../types'; import { AssetHolderDetails, bytesToString, @@ -17,6 +13,7 @@ import { getTextValue, rawPortfolioToAssetHolder, } from '../../../utils'; +import { createAssetTransaction } from '../assets/mapAsset'; import { Attributes, extractArgs } from '../common'; import { createIdentityIfNotExists } from './mapIdentities'; @@ -159,44 +156,47 @@ export const handlePortfolioCustodianChanged = async (event: SubstrateEvent): Pr }; /** - * Handles old event for portfolio movement + * Handles the pre-6.0 `portfolio.MovedBetweenPortfolios` event. + * + * Folded into `AssetTransaction` (plan 05): every writer of the old `PortfolioMovement` entity — + * this one, `handleFundsMovedBetweenPortfolios` and `settlement.FundsTransferred` — now goes + * through the one shared writer, and the two tables provably did not overlap so nothing is + * double counted. */ export const handlePortfolioMovement = async (event: SubstrateEvent): Promise => { - const { params, extrinsic, blockId, block, blockEventId } = extractArgs(event); + const { params, extrinsic, blockId, eventIdx, block, blockEventId } = extractArgs(event); const [, rawFromPortfolio, rawToPortfolio, rawAssetId, rawAmount, rawMemo] = params; - const address = getSignerAddress(extrinsic); - const from = rawPortfolioToAssetHolder(rawFromPortfolio); - // since this event was on old event, it is safe to assume that only portfolio data can be received for below params - if ('account' in from) { - return; - } - const to = rawPortfolioToAssetHolder(rawToPortfolio); - if ('account' in to) { + const fromHolder = rawPortfolioToAssetHolder(rawFromPortfolio); + const toHolder = rawPortfolioToAssetHolder(rawToPortfolio); + // this pre-6.0 event only ever carried portfolio holders + if ('account' in fromHolder || 'account' in toHolder) { return; } - const assetId = await getAssetId(rawAssetId, block); - const amount = getBigIntValue(rawAmount); - const memo = bytesToString(rawMemo); - - await PortfolioMovement.create({ - id: blockEventId, - fromId: `${from.identityId}/${from.number}`, - toId: `${to.identityId}/${to.number}`, - identityId: from.identityId, - type: PortfolioMovementTypeEnum.Fungible, - assetId, - amount, - address, - memo, - createdBlockId: blockId, - updatedBlockId: blockId, - }).save(); + + await createAssetTransaction( + blockId, + eventIdx, + block.timestamp, + { + assetId: await getAssetId(rawAssetId, block), + fromHolder, + toHolder, + amount: getBigIntValue(rawAmount), + memo: bytesToString(rawMemo), + address: getSignerAddress(extrinsic), + }, + blockEventId, + EventIdEnum.MovedBetweenPortfolios, + extrinsic + ); }; type AssetMovementArgs = { blockEventId: string; blockId: string; + eventIdx: number; + eventId: EventIdEnum; address: string; fromHolder: AssetHolderDetails; toHolder: AssetHolderDetails; @@ -204,11 +204,19 @@ type AssetMovementArgs = { fundDescription: unknown; memo: string | undefined; block: SubstrateBlock; + extrinsic?: SubstrateExtrinsic; }; +/** + * Shared writer for the fund-description movement events (`FundsMovedBetweenPortfolios`, + * `settlement.FundsTransferred`). Writes one `AssetTransaction` row; `createAssetTransaction` + * classifies `isInternalTransfer` from the two holders. + */ export const mapAssetMovement = async ({ blockEventId, blockId, + eventIdx, + eventId, address, fromHolder, toHolder, @@ -216,82 +224,133 @@ export const mapAssetMovement = async ({ fundDescription, memo, block, + extrinsic, }: AssetMovementArgs): Promise => { - let fromPortfolioId: string, toPortfolioId: string, fromAccount: string, toAccount: string; - - if ('account' in fromHolder) { - ({ account: fromAccount } = fromHolder); - } else { - fromPortfolioId = `${fromHolder.identityId}/${fromHolder.number}`; - } - - if ('account' in toHolder) { - ({ account: toAccount } = toHolder); - } else { - toPortfolioId = `${toHolder.identityId}/${toHolder.number}`; - } - - let assetId: string, amount: bigint, nftIds: bigint[]; - let type: PortfolioMovementTypeEnum; + let assetId: string; + let amount: bigint | undefined; + let nftIds: bigint[] | undefined; if (assetType === 'fungible') { - const description = fundDescription as { - ticker?: string; - assetId?: string; - amount: number; - }; + const description = fundDescription as { ticker?: string; assetId?: string; amount: number }; assetId = await getAssetId(description.ticker ?? description.assetId, block); amount = BigInt(description.amount); - type = PortfolioMovementTypeEnum.Fungible; } else if (assetType === 'nonFungible') { - const description = fundDescription as { - ticker?: string; - assetId?: string; - ids: number[]; - }; - nftIds = description.ids.map(BigInt); + const description = fundDescription as { ticker?: string; assetId?: string; ids: number[] }; assetId = await getAssetId(description.ticker ?? description.assetId, block); - type = PortfolioMovementTypeEnum.NonFungible; + nftIds = description.ids.map(BigInt); + } else { + return; } - await PortfolioMovement.create({ - id: blockEventId, - fromId: fromPortfolioId, - fromAccount, - identityId: fromHolder.identityId, - toId: toPortfolioId, - toAccount, - type, - assetId, - amount, - nftIds, - address, - memo, - createdBlockId: blockId, - updatedBlockId: blockId, - }).save(); + await createAssetTransaction( + blockId, + eventIdx, + block.timestamp, + { assetId, fromHolder, toHolder, amount, nftIds, memo, address }, + blockEventId, + eventId, + extrinsic + ); }; export const handleFundsMovedBetweenPortfolios = async (event: SubstrateEvent): Promise => { - const { params, extrinsic, blockId, block, blockEventId } = extractArgs(event); + const { params, extrinsic, blockId, eventIdx, block, blockEventId } = extractArgs(event); const [, rawFromPortfolio, rawToPortfolio, rawFundDescription, rawMemo] = params; - const address = getSignerAddress(extrinsic); - const fromHolder = rawPortfolioToAssetHolder(rawFromPortfolio); - const toHolder = rawPortfolioToAssetHolder(rawToPortfolio); - - const assetType = getFirstKeyFromJson(rawFundDescription); - const fundDescription = getFirstValueFromJson(rawFundDescription); - const memo = bytesToString(rawMemo); await mapAssetMovement({ blockEventId, blockId, - address, - fromHolder, - toHolder, - assetType, - fundDescription, - memo, + eventIdx, + eventId: EventIdEnum.FundsMovedBetweenPortfolios, + address: getSignerAddress(extrinsic), + fromHolder: rawPortfolioToAssetHolder(rawFromPortfolio), + toHolder: rawPortfolioToAssetHolder(rawToPortfolio), + assetType: getFirstKeyFromJson(rawFundDescription), + fundDescription: getFirstValueFromJson(rawFundDescription), + memo: bytesToString(rawMemo), block, + extrinsic, }); }; + +/** + * `portfolio.FungibleTokensMovedBetweenPortfolios` — a v5.4.3-only event (defect A8). Emitted + * from `unchecked_move_funds` and removed at v6.0.0; `MovedBetweenPortfolios` is not emitted + * alongside it, so without this the movement is not indexed at all. Measured: 0 on mainnet, + * 0 on testnet — registered for completeness and testnet parity. + */ +export const handleFungibleTokensMovedBetweenPortfolios = async ( + event: SubstrateEvent +): Promise => { + const { extrinsic, blockId, eventIdx, block, blockEventId } = extractArgs(event); + const { + fromPortfolio: rawFrom, + toPortfolio: rawTo, + ticker: rawTicker, + amount: rawAmount, + memo: rawMemo, + } = decodeEvent(event); + + const fromHolder = rawPortfolioToAssetHolder(rawFrom); + const toHolder = rawPortfolioToAssetHolder(rawTo); + if ('account' in fromHolder || 'account' in toHolder) { + return; + } + + await createAssetTransaction( + blockId, + eventIdx, + block.timestamp, + { + assetId: await getAssetId(rawTicker, block), + fromHolder, + toHolder, + amount: getBigIntValue(rawAmount), + memo: bytesToString(rawMemo), + address: getSignerAddress(extrinsic), + }, + blockEventId, + EventIdEnum.FungibleTokensMovedBetweenPortfolios, + extrinsic + ); +}; + +/** + * `portfolio.NFTsMovedBetweenPortfolios` — the non-fungible sibling of the above, 5 args + * (an `NFTs` collection rather than a ticker/amount pair). Measured: 0 on mainnet, 1 on testnet + * (block 7,786,536). + */ +export const handleNftsMovedBetweenPortfolios = async (event: SubstrateEvent): Promise => { + const { extrinsic, blockId, eventIdx, block, blockEventId } = extractArgs(event); + const { + fromPortfolio: rawFrom, + toPortfolio: rawTo, + nfts: rawNfts, + memo: rawMemo, + } = decodeEvent(event); + + const fromHolder = rawPortfolioToAssetHolder(rawFrom); + const toHolder = rawPortfolioToAssetHolder(rawTo); + if ('account' in fromHolder || 'account' in toHolder) { + return; + } + + const nfts = rawNfts.toJSON() as { ticker?: string; assetId?: string; ids: number[] }; + + await createAssetTransaction( + blockId, + eventIdx, + block.timestamp, + { + assetId: await getAssetId(nfts.ticker ?? nfts.assetId, block), + fromHolder, + toHolder, + nftIds: nfts.ids.map(BigInt), + memo: bytesToString(rawMemo), + address: getSignerAddress(extrinsic), + }, + blockEventId, + EventIdEnum.NFTsMovedBetweenPortfolios, + extrinsic + ); +}; diff --git a/src/mappings/entities/index.ts b/src/mappings/entities/index.ts index 21bbe99b..1f1de677 100644 --- a/src/mappings/entities/index.ts +++ b/src/mappings/entities/index.ts @@ -1,4 +1,5 @@ export * from './assets/mapAsset'; +export * from './assets/mapAssetMetadata'; export * from './identities/mapAuthorization'; export * from './block/mapBlock'; export * from './events/mapBridgeEvent'; diff --git a/src/mappings/entities/settlements/mapSettlement.ts b/src/mappings/entities/settlements/mapSettlement.ts index 46d80a5e..e00c71cd 100644 --- a/src/mappings/entities/settlements/mapSettlement.ts +++ b/src/mappings/entities/settlements/mapSettlement.ts @@ -809,11 +809,9 @@ export const handleReceiptClaimed = async (event: SubstrateEvent): Promise }; export const handleFundsTransferred = async (event: SubstrateEvent): Promise => { - const { extrinsic, blockId, block, blockEventId } = extractArgs(event); + const { extrinsic, blockId, eventIdx, block, blockEventId } = extractArgs(event); const { fromHolder: rawFromHolder, toHolder: rawToHolder, fund: rawFund } = decodeEvent(event); - const address = getSignerAddress(extrinsic); - const [fromHolder, toHolder] = await Promise.all([ rawAssetHolderToAssetHolder(rawFromHolder, block, blockId), rawAssetHolderToAssetHolder(rawToHolder, block, blockId), @@ -826,12 +824,15 @@ export const handleFundsTransferred = async (event: SubstrateEvent): Promise => { // the genesis allocation (docs/implementation/02-polyx-ledger.md) await seedAccountBalances({ blockId: genesisBlock, datetime }); + // opening asset-holding snapshot — Holding is rebuilt from the movement stream, so it needs + // the same genesis baseline (docs/implementation/03-holdings-nfts.md) + await seedHoldings({ blockId: genesisBlock, datetime }); + logger.info('Applied genesis migrations'); }; diff --git a/src/seed/holding.ts b/src/seed/holding.ts new file mode 100644 index 00000000..0680078a --- /dev/null +++ b/src/seed/holding.ts @@ -0,0 +1,132 @@ +import { Codec } from '@polkadot/types/types'; +import { Asset, AssetHolder, HolderKind, Holding } from '../types'; +import { + getAssetIdForLegacyTicker, + getBigIntValue, + getPortfolioId, + is7xSpecVersion, +} from '../utils'; +import { meshPortfolioToAssetHolder } from '../utils/portfolios'; + +/** + * A full storage scan of a double map. `@polkadot`'s `.entries()` type requires the first key + * for a double map, but passing none is a valid full-prefix scan at runtime. + */ +const scanDoubleMap = (entry: { entries: unknown }): Promise<[{ args: Codec[] }, Codec][]> => + (entry.entries as () => Promise<[{ args: Codec[] }, Codec][]>)(); + +/** + * Snapshots `portfolio.portfolioAssetBalances` into `Holding` rows at the portfolio grain, plus + * the `AssetHolder` identity rollup and `Asset.holderCount`. + * + * `Holding` is rebuilt from the movement stream, so without an opening snapshot every derived + * holding is short by whatever a portfolio held at the start block — the same reasoning as + * `seedAccountBalances` for the POLYX ledger. `genesisHandler` seeds Portfolios but no balances; + * this fills that gap, and plan [10](../../docs/implementation/10-partial-index.md) reuses the + * identical read at an arbitrary `START_BLOCK` (`api.query` always targets the block being + * indexed). + * + * Assets whose `Asset` row is not yet indexed are skipped: at genesis there are none, and the + * partial-index path seeds `Asset` rows before calling this. NFT holdings are seeded separately + * once the `Nft` entity exists. + */ + +export interface SeedContext { + blockId: string; + /** unused here, kept for signature parity with `seedAccountBalances` and the plan-10 seeder */ + datetime: Date; +} + +export const seedHoldings = async ({ blockId }: SeedContext): Promise<{ seeded: number }> => { + if (!api.query.portfolio?.portfolioAssetBalances) { + return { seeded: 0 }; + } + + // Pre-7.x the storage key is a `Ticker`; 7.x+ it is the `AssetId` already. + const is7x = is7xSpecVersion(api.runtimeVersion.specVersion.toNumber()); + const resolveAssetId = (rawKey: Codec): Promise => + is7x ? Promise.resolve(rawKey.toString()) : getAssetIdForLegacyTicker(rawKey.toString()); + + const holdings = new Map(); + const rollups = new Map(); + + const entries = await scanDoubleMap(api.query.portfolio.portfolioAssetBalances); + + for (const [key, rawBalance] of entries) { + const amount = getBigIntValue(rawBalance); + if (amount === BigInt(0)) { + continue; + } + + const [rawPortfolioId, rawAssetId] = key.args; + const assetId = await resolveAssetId(rawAssetId); + + if (!(await Asset.get(assetId))) { + continue; + } + + const holder = meshPortfolioToAssetHolder(JSON.parse(rawPortfolioId.toString())); + if ('account' in holder) { + continue; // account-kind portfolios never carried a balance in this storage + } + const { identityId, number } = holder; + const portfolioId = getPortfolioId({ identityId, number }); + + holdings.set( + `${assetId}/${portfolioId}`, + Holding.create({ + id: `${assetId}/${portfolioId}`, + assetId, + holderKind: HolderKind.Portfolio, + portfolioId, + identityId, + amount, + nftCount: 0, + createdBlockId: blockId, + updatedBlockId: blockId, + }) + ); + + const rollupId = `${assetId}/${identityId}`; + const rollup = + rollups.get(rollupId) ?? + AssetHolder.create({ + id: rollupId, + identityId, + assetId, + amount: BigInt(0), + createdBlockId: blockId, + updatedBlockId: blockId, + }); + rollup.amount += amount; + rollups.set(rollupId, rollup); + } + + const holderCounts = new Map(); + for (const rollup of rollups.values()) { + if (rollup.amount > BigInt(0)) { + holderCounts.set(rollup.assetId, (holderCounts.get(rollup.assetId) ?? 0) + 1); + } + } + + const assetUpdates = await Promise.all( + [...holderCounts].map(async ([assetId, count]) => { + const asset = await Asset.get(assetId); + asset.holderCount = count; + asset.updatedBlockId = blockId; + return asset; + }) + ); + + await Promise.all([ + ...[...holdings.values()].map(row => row.save()), + ...[...rollups.values()].map(row => row.save()), + ...assetUpdates.map(asset => asset.save()), + ]); + + logger.info( + `Seeded ${holdings.size} Holding rows (${rollups.size} AssetHolder rollups) from portfolioAssetBalances at ${blockId}` + ); + + return { seeded: holdings.size }; +}; diff --git a/src/utils/portfolios.ts b/src/utils/portfolios.ts index 114436f6..c0f64a58 100644 --- a/src/utils/portfolios.ts +++ b/src/utils/portfolios.ts @@ -1,15 +1,72 @@ import { Codec } from '@polkadot/types/types'; import { SubstrateBlock } from '@subql/types'; -import { Distribution, Portfolio } from '../types'; +import { Distribution, HolderKind, Portfolio } from '../types'; import { getOrCreateAccount } from './accounts'; import { getAssetId } from './assets'; import { extractNumber, is8xChain } from './common'; -export type AccountDetails = { identityId: string; account: string }; -export type PortfolioDetails = Pick; +/** + * A resolved asset holder, carrying the grain it was resolved at. + * + * `holderKind` is the discriminator every downstream writer keys off — a `Holding` row, an + * `AssetTransaction`'s from/to columns, the internal-transfer classification. It is set here, + * at the one place a raw holder is parsed, so no consumer has to re-derive "portfolio or + * account?" from the shape. `identityId` may still be empty/undefined for an account-grain + * holder whose Identity is unknown — that is a *present* holder, not an absent one. + */ +export type AccountDetails = { + identityId: string; + account: string; + holderKind: HolderKind.Account; +}; +export type PortfolioDetails = Pick & { + holderKind: HolderKind.Portfolio; +}; export type AssetHolderDetails = AccountDetails | PortfolioDetails; +export const accountHolder = (identityId: string | undefined, account: string): AccountDetails => ({ + // typed `string` to match the rest of the codebase; genuinely undefined when the account's + // Identity is unknown, which holder classification accounts for explicitly. + identityId: identityId as string, + account, + holderKind: HolderKind.Account, +}); + +export const portfolioHolder = (identityId: string, number: number): PortfolioDetails => ({ + identityId, + number, + holderKind: HolderKind.Portfolio, +}); + +/** + * Whether a movement is internal to one Identity — the shared classifier for + * `AssetTransaction.isInternalTransfer`. + * + * Presence is checked before DID equality, and the order matters: + * + * - a missing `from` holder is an issuance, a missing `to` holder a redemption — neither is a + * transfer between two holders, so both return `undefined` rather than a boolean; + * - a holder that is *present* but whose DID never resolved (an account with no known Identity) + * is not an absent holder. It classifies `false` — we cannot prove it is the same Identity, + * but it must never fall through to the issuance/redemption case and be recorded as one. + * + * `ControllerTransfer` is the case `eventId` alone cannot decide: nothing on chain stops its + * source and destination resolving to the same DID, so it is classified here like any other. + */ +export const classifyInternalTransfer = ( + fromHolder: AssetHolderDetails | undefined, + toHolder: AssetHolderDetails | undefined +): boolean | undefined => { + if (!fromHolder || !toHolder) { + return undefined; + } + if (!fromHolder.identityId || !toHolder.identityId) { + return false; + } + return fromHolder.identityId === toHolder.identityId; +}; + export interface MeshPortfolio { did: string; kind: @@ -30,19 +87,13 @@ export const meshPortfolioToAssetHolder = (meshPortfolio: MeshPortfolio): AssetH // extract account address if ('accountId' in meshPortfolio.kind) { - return { - identityId: meshPortfolio.did, - account: meshPortfolio.kind.accountId, - }; + return accountHolder(meshPortfolio.did, meshPortfolio.kind.accountId); } // extract portfolio number if ('user' in meshPortfolio.kind) { number = meshPortfolio.kind.user; } - return { - identityId: meshPortfolio.did, - number: number || 0, // 0 maps to default portfolio - }; + return portfolioHolder(meshPortfolio.did, number || 0); // 0 maps to default portfolio }; /** @@ -65,8 +116,9 @@ export const meshAssetHolderToAssetHolder = async ( ): Promise => { if ('account' in meshAssetHolder) { const account = await getOrCreateAccount(meshAssetHolder.account, blockId, datetime); - // @prashantasdeveloper need to confirm if we can safely assume that account will be present - return { account: meshAssetHolder.account, identityId: account?.identityId }; + // `identityId` may be undefined here — an account with no known Identity is still a present + // holder, and callers must classify on holder presence before DID equality. + return accountHolder(account?.identityId, meshAssetHolder.account); } const { did, kind } = meshAssetHolder.portfolio; @@ -75,7 +127,7 @@ export const meshAssetHolderToAssetHolder = async ( if ('user' in kind) { number = kind.user; } - return { identityId: did, number }; + return portfolioHolder(did, number); }; /** diff --git a/tests/entities/__snapshots__/assets.test.ts.snap b/tests/entities/__snapshots__/assets.test.ts.snap index 839f5559..3d1df5d7 100644 --- a/tests/entities/__snapshots__/assets.test.ts.snap +++ b/tests/entities/__snapshots__/assets.test.ts.snap @@ -57,7 +57,6 @@ Object { "isCompliancePaused": false, "isDivisible": true, "isFrozen": false, - "isUniquenessRequired": false, "name": "11BTICKER1", "ownerDid": "0x0500000000000000000000000000000000000000000000000000000000000000", "ticker": "11BTICKER1", @@ -101,7 +100,6 @@ Object { "isCompliancePaused": false, "isDivisible": true, "isFrozen": false, - "isUniquenessRequired": false, "name": "15TICKER", "ownerDid": "0x43b62e2ea8713eb6f766beed4651f41ad52b607e7c6214bd6cb444952e9c3a6f", "ticker": "15TICKER", @@ -152,7 +150,6 @@ Object { "isCompliancePaused": false, "isDivisible": true, "isFrozen": false, - "isUniquenessRequired": false, "name": "4TICKER", "ownerDid": "0xa9755a2d863adf7981d60f816884783bb2b71788efdfae845fc2830c36500551", "ticker": "4TICKER", @@ -196,7 +193,6 @@ Object { "isCompliancePaused": false, "isDivisible": true, "isFrozen": false, - "isUniquenessRequired": false, "name": "7TICKER", "ownerDid": "0x5c7834d42ff31e9df992016d5983301d8376494a61d464c66fd03bb4b02471e0", "ticker": "7TICKER", @@ -240,7 +236,6 @@ Object { "isCompliancePaused": false, "isDivisible": true, "isFrozen": false, - "isUniquenessRequired": false, "name": "8TICKER", "ownerDid": "0xb92252cbcfe17dc4dfaec11ec63930a028d5d7cd106bacf54e1603d27c59df70", "ticker": "8TICKER", diff --git a/tests/entities/__snapshots__/portfolioMovements.test.ts.snap b/tests/entities/__snapshots__/portfolioMovements.test.ts.snap deleted file mode 100644 index 314f92cc..00000000 --- a/tests/entities/__snapshots__/portfolioMovements.test.ts.snap +++ /dev/null @@ -1,39 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`portfolioMovements should return all Portfolio movements associated with a particular Identity 1`] = ` -Object { - "portfolioMovements": Object { - "__typename": "PortfolioMovementsConnection", - "nodes": Array [ - Object { - "__typename": "PortfolioMovement", - "address": "5GKLEq7o3zZCGAeokuSoAMbmxamJ2uXJ984Fg8xYpaaWMUPr", - "amount": "100", - "assetId": "4TICKER", - "fromId": "0xa9755a2d863adf7981d60f816884783bb2b71788efdfae845fc2830c36500551/0", - "id": "111/1", - "toId": "0xa9755a2d863adf7981d60f816884783bb2b71788efdfae845fc2830c36500551/1", - }, - ], - }, -} -`; - -exports[`portfolioMovements should return all movements associated with a particular Portfolio 1`] = ` -Object { - "portfolioMovements": Object { - "__typename": "PortfolioMovementsConnection", - "nodes": Array [ - Object { - "__typename": "PortfolioMovement", - "address": "5GKLEq7o3zZCGAeokuSoAMbmxamJ2uXJ984Fg8xYpaaWMUPr", - "amount": "100", - "assetId": "4TICKER", - "fromId": "0xa9755a2d863adf7981d60f816884783bb2b71788efdfae845fc2830c36500551/0", - "id": "111/1", - "toId": "0xa9755a2d863adf7981d60f816884783bb2b71788efdfae845fc2830c36500551/1", - }, - ], - }, -} -`; diff --git a/tests/entities/assets.test.ts b/tests/entities/assets.test.ts index d7035e75..da405ae5 100644 --- a/tests/entities/assets.test.ts +++ b/tests/entities/assets.test.ts @@ -19,7 +19,6 @@ describe('assets', () => { fundingRound isDivisible isFrozen - isUniquenessRequired identifiers ownerDid: ownerId totalSupply diff --git a/tests/entities/portfolioMovements.test.ts b/tests/entities/portfolioMovements.test.ts deleted file mode 100644 index beee531d..00000000 --- a/tests/entities/portfolioMovements.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { gql } from '@apollo/client/core'; -import { getApolloClient } from '../util'; -const { query } = getApolloClient(); - -describe('portfolioMovements', () => { - let fromId: string; - - beforeEach(async () => { - const q = { - query: gql` - query { - portfolioMovements(first: 1, orderBy: ID_ASC) { - nodes { - fromId - } - } - } - `, - }; - const result = await query(q); - - fromId = result.data.portfolioMovements.nodes[0].fromId; - }); - it('should return all movements associated with a particular Portfolio', async () => { - const q = { - query: gql` - query { - portfolioMovements( - filter: { - fromId: { - equalTo: "${fromId}" - } - }, orderBy: ID_ASC - ) { - nodes { - id - fromId - toId - assetId - amount - address - } - } - } - `, - }; - - const subquery = await query(q); - - expect(subquery?.errors).toBeFalsy(); - expect(subquery?.data).toMatchSnapshot(); - }); - - it('should return all Portfolio movements associated with a particular Identity', async () => { - const fromDid = fromId.split('/')[0]; - const q = { - query: gql` - query { - portfolioMovements( - filter: { - fromId: { - startsWith: "${fromDid}" - } - }, orderBy: ID_ASC - ) { - nodes { - id - fromId - toId - assetId - amount - address - } - } - } - `, - }; - - const subquery = await query(q); - - expect(subquery?.errors).toBeFalsy(); - expect(subquery?.data).toMatchSnapshot(); - }); -}); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts new file mode 100644 index 00000000..9e501b6d --- /dev/null +++ b/tests/unit/helpers.ts @@ -0,0 +1,88 @@ +/** + * Shared unit-test scaffolding for handler tests — a Codec stand-in, an in-memory `store`, and a + * tuple-style `SubstrateEvent` builder. Not a test file (no `.test.ts`), so jest does not run it. + */ + +import { SubstrateEvent } from '@subql/types'; + +export const storeGet = (): jest.Mock => (globalThis as any).store.get as jest.Mock; +export const storeSet = (): jest.Mock => (globalThis as any).store.set as jest.Mock; +export const storeRemove = (): jest.Mock => (globalThis as any).store.remove as jest.Mock; + +/** Minimal Codec stand-in — handlers only read `toString` / `toJSON` / `isEmpty`. */ +export const codec = (value: unknown, opts: { isEmpty?: boolean } = {}) => ({ + isEmpty: opts.isEmpty ?? (value === undefined || value === null), + toString: () => (typeof value === 'string' ? value : JSON.stringify(value)), + toJSON: () => value, +}); + +export type MockDb = Record>; + +/** + * Points `store.get` / `set` / `remove` at an in-memory object and returns it, and clears + * `api.query`. Call from `beforeEach` — jest resets mocks between tests. + */ +export const mockStore = (db: MockDb = {}): MockDb => { + storeGet().mockImplementation((entity: string, id: string) => Promise.resolve(db[entity]?.[id])); + storeSet().mockImplementation((entity: string, id: string, data: unknown) => { + db[entity] ??= {}; + db[entity][id] = { ...(data as object) }; + return Promise.resolve(); + }); + storeRemove().mockImplementation((entity: string, id: string) => { + delete db[entity]?.[id]; + return Promise.resolve(); + }); + (globalThis as any).api.query = {}; + return db; +}; + +/** Field metadata for a Polymesh tuple event — unnamed, so decode falls to the shape table. */ +const unnamedMeta = (length: number) => ({ + fields: Array.from({ length }, () => ({ + name: { isSome: false }, + typeName: { isSome: true, unwrap: () => codec('Dummy') }, + })), +}); + +export interface TupleEventOptions { + section: string; + method: string; + data: unknown[]; + specVersion?: number; + blockNumber?: string; + idx?: number; + extrinsic?: unknown; + events?: unknown[]; +} + +/** A tuple-style `SubstrateEvent` for handler tests. */ +export const tupleEvent = ({ + section, + method, + data, + specVersion = 8_000_000, + blockNumber = '1000', + idx = 0, + extrinsic, + events = [], +}: TupleEventOptions): SubstrateEvent => + ({ + idx, + extrinsic, + block: { + block: { header: { number: { toString: () => blockNumber } } }, + specVersion, + timestamp: new Date('2026-01-01T00:00:00Z'), + events, + }, + event: { section, method, data, meta: unnamedMeta(data.length) }, + } as unknown as SubstrateEvent); + +/** `PortfolioId` codec: `{ did, kind: { user: n } | { default: null } }`. */ +export const portfolioCodec = (did: string, number = 0) => + codec({ did, kind: number ? { user: number } : { default: null } }); + +/** `AssetHolder` codec wrapping a portfolio: `{ portfolio: { did, kind } }`. */ +export const meshPortfolioHolderCodec = (did: string, number = 0) => + codec({ portfolio: { did, kind: number ? { user: number } : { default: null } } }); diff --git a/tests/unit/mapAssetAllowance.test.ts b/tests/unit/mapAssetAllowance.test.ts new file mode 100644 index 00000000..d8113c3e --- /dev/null +++ b/tests/unit/mapAssetAllowance.test.ts @@ -0,0 +1,72 @@ +/** + * v8 asset allowances (defect G11). `handleApproval` upserts the remaining allowance; + * `handleAllowanceSpent` takes the chain's own `remainingAllowance` rather than subtracting + * `amountSpent`, so a missed or reordered event cannot accumulate drift. + */ + +import { handleAllowanceSpent, handleApproval } from '../../src/mappings/entities/assets/mapAsset'; +import { codec, MockDb, mockStore, tupleEvent } from './helpers'; + +const ASSET = '0xasset0000000000000000000000000a'; +const OWNER = '5Owner00000000000000000000000000000000000000000000'; +const SPENDER = '5Spender000000000000000000000000000000000000000000'; + +const allowanceEvent = (method: 'Approval' | 'AllowanceSpent', values: string[]) => + tupleEvent({ section: 'asset', method, data: values.map(v => codec(v)), blockNumber: '800' }); + +describe('asset allowances', () => { + let db: MockDb; + + beforeEach(() => { + db = mockStore({ + Account: { + [OWNER]: { id: OWNER, address: OWNER, identityId: '0x01' }, + [SPENDER]: { id: SPENDER, address: SPENDER, identityId: '0x02' }, + }, + }); + }); + + const id = `${ASSET}/${OWNER}/${SPENDER}`; + + it('handleApproval upserts the remaining allowance', async () => { + await handleApproval(allowanceEvent('Approval', [OWNER, SPENDER, ASSET, '1000'])); + + expect(db['AssetAllowance'][id]).toMatchObject({ + assetId: ASSET, + ownerId: OWNER, + spenderId: SPENDER, + amount: BigInt(1000), + totalSpent: BigInt(0), + }); + + await handleApproval(allowanceEvent('Approval', [OWNER, SPENDER, ASSET, '250'])); + expect(db['AssetAllowance'][id].amount).toBe(BigInt(250)); + }); + + it('handleAllowanceSpent sets amount from remainingAllowance, not by subtraction', async () => { + db['AssetAllowance'] = { + [id]: { + id, + assetId: ASSET, + ownerId: OWNER, + spenderId: SPENDER, + amount: BigInt(1000), + totalSpent: BigInt(0), + }, + }; + + // amountSpent 300, but the chain reports remaining 690 (a 10-unit fee, say) — take 690, not 700 + await handleAllowanceSpent( + allowanceEvent('AllowanceSpent', [OWNER, SPENDER, ASSET, '300', '690']) + ); + + expect(db['AssetAllowance'][id].amount).toBe(BigInt(690)); + expect(db['AssetAllowance'][id].totalSpent).toBe(BigInt(300)); + + await handleAllowanceSpent( + allowanceEvent('AllowanceSpent', [OWNER, SPENDER, ASSET, '90', '600']) + ); + expect(db['AssetAllowance'][id].amount).toBe(BigInt(600)); + expect(db['AssetAllowance'][id].totalSpent).toBe(BigInt(390)); + }); +}); diff --git a/tests/unit/mapAssetMetadata.test.ts b/tests/unit/mapAssetMetadata.test.ts new file mode 100644 index 00000000..0ad0bf56 --- /dev/null +++ b/tests/unit/mapAssetMetadata.test.ts @@ -0,0 +1,228 @@ +/** + * Asset metadata (defect G13), asset-type changes, and the v8 account-side CreatedAssetTransfer. + * SetAssetMetadataValue / ...Details do not carry the metadata key — it is recovered from the + * direct setAssetMetadata call args, else from a RegisterAssetMetadata*Type event in the same + * extrinsic (the register-and-set path), and only an unrecognised wrapper is recorded and dropped. + */ + +import { + handleAssetTypeChanged, + handleLocalMetadataKeyDeleted, + handleMetadataValueDeleted, + handleRegisterAssetMetadataLocalType, + handleSetAssetMetadataValue, +} from '../../src/mappings/entities/assets/mapAssetMetadata'; +import { handleCreatedAssetTransfer } from '../../src/mappings/entities/assets/mapAsset'; +import { codec, MockDb, mockStore, storeSet, tupleEvent } from './helpers'; + +const ASSET = '0xasset000000000000000000000000000'; + +const metaEvent = (method: string, data: unknown[], extrinsic?: unknown, events: unknown[] = []) => + tupleEvent({ + section: 'asset', + method, + data, + blockNumber: '700', + idx: events.length, + extrinsic, + events, + }); + +/** An `EventRecord`-shaped sibling event in the same extrinsic. */ +const siblingRecord = (method: string, values: unknown[], extrinsicIdx: number) => ({ + phase: { isApplyExtrinsic: true, asApplyExtrinsic: { toNumber: () => extrinsicIdx } }, + event: metaEvent(method, values).event, +}); + +const setMetadataExtrinsic = (key: unknown) => ({ + idx: 4, + extrinsic: { + method: { section: 'asset', method: 'setAssetMetadata' }, + args: [codec(ASSET), codec(key)], + }, +}); + +describe('asset metadata', () => { + let db: MockDb; + + beforeEach(() => { + db = mockStore({ Asset: { [ASSET]: { id: ASSET, type: 'EquityCommon' } } }); + }); + + it('registers a local metadata key with its name', async () => { + await handleRegisterAssetMetadataLocalType( + metaEvent('RegisterAssetMetadataLocalType', [ + codec('0xdid'), + codec(ASSET), + codec('Prospectus'), + codec('1'), + codec({ url: null, description: null, typeDef: null }), + ]) + ); + + expect(db['AssetMetadata'][`${ASSET}/Local/1`]).toMatchObject({ + scope: 'Local', + keyId: '1', + name: 'Prospectus', + isLocked: false, + }); + }); + + it('sets the value + lock, reading the key from the extrinsic', async () => { + await handleSetAssetMetadataValue( + metaEvent( + 'SetAssetMetadataValue', + [ + codec('0xdid'), + codec(ASSET), + codec('ipfs://cid'), + codec({ expire: null, lockStatus: 'Locked' }), + ], + setMetadataExtrinsic({ local: 1 }) + ) + ); + + expect(db['AssetMetadata'][`${ASSET}/Local/1`]).toMatchObject({ + value: 'ipfs://cid', + isLocked: true, + }); + }); + + it('recovers the key from a sibling RegisterAssetMetadataLocalType (register-and-set path)', async () => { + // the setter is dispatched by registerAndSetLocalAssetMetadata, not setAssetMetadata, so the + // key is not in args[1]; the RegisterAssetMetadataLocalType event fired earlier in the same + // extrinsic carries the new local key id (3) + const event = metaEvent( + 'SetAssetMetadataValue', + [codec('0xdid'), codec(ASSET), codec('ipfs://new'), codec(null, { isEmpty: true })], + { + idx: 6, + extrinsic: { method: { section: 'asset', method: 'registerAndSetLocalAssetMetadata' } }, + }, + [ + siblingRecord( + 'RegisterAssetMetadataLocalType', + [codec('0xdid'), codec(ASSET), codec('Whitepaper'), codec('3'), codec({})], + 6 + ), + ] + ); + + await handleSetAssetMetadataValue(event); + + expect(db['AssetMetadata'][`${ASSET}/Local/3`]).toMatchObject({ + scope: 'Local', + keyId: '3', + value: 'ipfs://new', + }); + expect(storeSet().mock.calls.some(([entity]) => entity === 'IndexerAnomaly')).toBe(false); + }); + + it('records an anomaly and writes nothing only when neither the extrinsic nor a sibling resolves the key', async () => { + await handleSetAssetMetadataValue( + metaEvent('SetAssetMetadataValue', [ + codec('0xdid'), + codec(ASSET), + codec('x'), + codec(null, { isEmpty: true }), + ]) + ); + + expect(db['AssetMetadata']).toBeUndefined(); + expect(storeSet().mock.calls.some(([entity]) => entity === 'IndexerAnomaly')).toBe(true); + }); + + it('clears the value on MetadataValueDeleted but keeps the key row', async () => { + db['AssetMetadata'] = { + [`${ASSET}/Local/1`]: { + id: `${ASSET}/Local/1`, + assetId: ASSET, + scope: 'Local', + keyId: '1', + name: 'Prospectus', + value: 'ipfs://cid', + isLocked: true, + }, + }; + + await handleMetadataValueDeleted( + metaEvent('MetadataValueDeleted', [codec('0xdid'), codec(ASSET), codec({ local: 1 })]) + ); + + expect(db['AssetMetadata'][`${ASSET}/Local/1`]).toMatchObject({ + name: 'Prospectus', + isLocked: false, + }); + expect(db['AssetMetadata'][`${ASSET}/Local/1`].value).toBeUndefined(); + }); + + it('removes the row entirely on LocalMetadataKeyDeleted', async () => { + db['AssetMetadata'] = { [`${ASSET}/Local/1`]: { id: `${ASSET}/Local/1` } }; + + await handleLocalMetadataKeyDeleted( + metaEvent('LocalMetadataKeyDeleted', [codec('0xdid'), codec(ASSET), codec('1')]) + ); + + expect(db['AssetMetadata'][`${ASSET}/Local/1`]).toBeUndefined(); + }); + + it('AssetTypeChanged updates Asset.type', async () => { + await handleAssetTypeChanged( + metaEvent('AssetTypeChanged', [codec('0xdid'), codec(ASSET), codec('Derivative')]) + ); + + expect(db['Asset'][ASSET].type).toBe('Derivative'); + }); +}); + +describe('handleCreatedAssetTransfer', () => { + let db: MockDb; + + beforeEach(() => { + db = mockStore({ Asset: { [ASSET]: { id: ASSET } } }); + }); + + it('writes an account-side AssetTransaction and links the pending instruction', async () => { + await handleCreatedAssetTransfer( + metaEvent( + 'CreatedAssetTransfer', + [ + codec(ASSET), + codec('5From000000000000000000000000000000000000000000000'), + codec('5To00000000000000000000000000000000000000000000000'), + codec('4200'), + codec(null, { isEmpty: true }), + codec('91'), + ], + { idx: 2, extrinsic: { method: { section: 'asset', method: 'transferAssetFrom' } } } + ) + ); + + const [row] = Object.values(db['AssetTransaction']); + expect(row).toMatchObject({ + assetId: ASSET, + fromAccount: '5From000000000000000000000000000000000000000000000', + toAccount: '5To00000000000000000000000000000000000000000000000', + amount: BigInt(4200), + instructionId: '91', + eventId: 'CreatedAssetTransfer', + }); + expect(row.fromPortfolioId).toBeUndefined(); + }); + + it('leaves instruction null when there is no pending transfer id', async () => { + await handleCreatedAssetTransfer( + metaEvent('CreatedAssetTransfer', [ + codec(ASSET), + codec('5From000000000000000000000000000000000000000000000'), + codec('5To00000000000000000000000000000000000000000000000'), + codec('10'), + codec(null, { isEmpty: true }), + codec(null, { isEmpty: true }), + ]) + ); + + const [row] = Object.values(db['AssetTransaction']); + expect(row.instructionId).toBeUndefined(); + }); +}); diff --git a/tests/unit/mapHolding.test.ts b/tests/unit/mapHolding.test.ts new file mode 100644 index 00000000..542a036b --- /dev/null +++ b/tests/unit/mapHolding.test.ts @@ -0,0 +1,146 @@ +/** + * `applyHoldingDelta` (src/mappings/entities/assets/mapAsset.ts) — the shared writer that keeps + * the portfolio/account-grain `Holding` row and the identity-grain `AssetHolder` rollup in step, + * and moves `Asset.holderCount` as the rollup crosses zero. + */ + +import { applyHoldingDelta } from '../../src/mappings/entities/assets/mapAsset'; +import { accountHolder, portfolioHolder } from '../../src/utils'; + +const ASSET = '0xaaaa'; +const DID = '0x01'.padEnd(66, '0'); +const BLOCK = '0000000100'; + +const storeGet = (): jest.Mock => (globalThis as any).store.get as jest.Mock; +const storeSet = (): jest.Mock => (globalThis as any).store.set as jest.Mock; + +type Row = Record; + +const makeAsset = (holderCount = 0): Row => ({ id: ASSET, holderCount }); + +const flush = async (fn: (promises: Promise[]) => Promise): Promise => { + const promises: Promise[] = []; + await fn(promises); + await Promise.all(promises); +}; + +describe('applyHoldingDelta', () => { + let db: Record>; + + beforeEach(() => { + db = {}; + storeGet().mockImplementation((entity: string, id: string) => + Promise.resolve(db[entity]?.[id]) + ); + storeSet().mockImplementation((entity: string, id: string, data: Row) => { + (db[entity] ??= {})[id] = { ...data }; + return Promise.resolve(); + }); + }); + + it('an issuance creates the Holding, the rollup, and increments holderCount', async () => { + const asset = makeAsset(0); + + await flush(p => + applyHoldingDelta(asset as any, portfolioHolder(DID, 0), BLOCK, BigInt(1000), p) + ); + + expect(db['Holding'][`${ASSET}/${DID}/0`]).toMatchObject({ + amount: BigInt(1000), + holderKind: 'Portfolio', + portfolioId: `${DID}/0`, + identityId: DID, + nftCount: 0, + }); + expect(db['AssetHolder'][`${ASSET}/${DID}`].amount).toBe(BigInt(1000)); + expect(asset.holderCount).toBe(1); + }); + + it('a portfolio-to-portfolio transfer within one identity moves two Holding rows and leaves the rollup net-zero', async () => { + const asset = makeAsset(1); + db['AssetHolder'] = { + [`${ASSET}/${DID}`]: { + id: `${ASSET}/${DID}`, + assetId: ASSET, + identityId: DID, + amount: BigInt(1000), + }, + }; + db['Holding'] = { + [`${ASSET}/${DID}/0`]: { + id: `${ASSET}/${DID}/0`, + assetId: ASSET, + holderKind: 'Portfolio', + portfolioId: `${DID}/0`, + identityId: DID, + amount: BigInt(1000), + nftCount: 0, + }, + }; + + await flush(async p => { + await applyHoldingDelta(asset as any, portfolioHolder(DID, 0), BLOCK, BigInt(-400), p); + await applyHoldingDelta(asset as any, portfolioHolder(DID, 1), BLOCK, BigInt(400), p); + }); + + expect(db['Holding'][`${ASSET}/${DID}/0`].amount).toBe(BigInt(600)); + expect(db['Holding'][`${ASSET}/${DID}/1`].amount).toBe(BigInt(400)); + // identity net zero — the case the old identity-grain model could not express + expect(db['AssetHolder'][`${ASSET}/${DID}`].amount).toBe(BigInt(1000)); + expect(asset.holderCount).toBe(1); + }); + + it('an account holder with no known Identity gets a Holding row but no rollup', async () => { + const asset = makeAsset(0); + + await flush(p => + applyHoldingDelta( + asset as any, + accountHolder(undefined, '5FHneW46xGiveU'), + BLOCK, + BigInt(50), + p + ) + ); + + expect(db['Holding'][`${ASSET}/5FHneW46xGiveU`]).toMatchObject({ + amount: BigInt(50), + holderKind: 'Account', + accountId: '5FHneW46xGiveU', + }); + expect(db['Holding'][`${ASSET}/5FHneW46xGiveU`].identityId).toBeUndefined(); + expect(db['AssetHolder']).toBeUndefined(); + expect(asset.holderCount).toBe(0); + }); + + it('a redemption that empties the rollup decrements holderCount', async () => { + const asset = makeAsset(1); + db['AssetHolder'] = { + [`${ASSET}/${DID}`]: { + id: `${ASSET}/${DID}`, + assetId: ASSET, + identityId: DID, + amount: BigInt(500), + }, + }; + db['Holding'] = { + [`${ASSET}/${DID}/0`]: { + id: `${ASSET}/${DID}/0`, + assetId: ASSET, + holderKind: 'Portfolio', + portfolioId: `${DID}/0`, + identityId: DID, + amount: BigInt(500), + nftCount: 0, + }, + }; + + await flush(p => + applyHoldingDelta(asset as any, portfolioHolder(DID, 0), BLOCK, BigInt(-500), p) + ); + + expect(db['Holding'][`${ASSET}/${DID}/0`].amount).toBe(BigInt(0)); + expect(db['AssetHolder'][`${ASSET}/${DID}`].amount).toBe(BigInt(0)); + expect(asset.holderCount).toBe(0); + }); +}); diff --git a/tests/unit/mapMovementLedger.test.ts b/tests/unit/mapMovementLedger.test.ts new file mode 100644 index 00000000..2bb0f04f --- /dev/null +++ b/tests/unit/mapMovementLedger.test.ts @@ -0,0 +1,128 @@ +/** + * Plan 05 — PortfolioMovement folded into AssetTransaction. + * + * The classification subtlety the plan calls out for review: a holder that is *present* but + * whose DID never resolved must not be treated as an *absent* holder — classify on presence + * first, DID equality second, or an unresolved sender is silently recorded as an issuance. + */ + +import { accountHolder, classifyInternalTransfer, portfolioHolder } from '../../src/utils'; +import { createAssetTransaction } from '../../src/mappings/entities/assets/mapAsset'; +import { handlePortfolioMovement } from '../../src/mappings/entities/identities/mapPortfolio'; +import { + codec, + MockDb, + mockStore, + portfolioCodec, + storeGet, + storeSet, + tupleEvent, +} from './helpers'; + +const DID_A = '0x0a'.padEnd(66, '0'); +const DID_B = '0x0b'.padEnd(66, '0'); +const ASSET = '0xasset000000000000000000000000000'; +const ADDR = '5Signer0000000000000000000000000000000000000000000'; + +describe('classifyInternalTransfer', () => { + it('is true only when both holders resolve to the same DID', () => { + expect(classifyInternalTransfer(portfolioHolder(DID_A, 0), portfolioHolder(DID_A, 1))).toBe( + true + ); + expect(classifyInternalTransfer(portfolioHolder(DID_A, 0), portfolioHolder(DID_B, 0))).toBe( + false + ); + }); + + it('a present but unresolved-DID holder is false, never undefined (not an issuance)', () => { + const unresolvedSender = accountHolder(undefined, ADDR); + + expect(classifyInternalTransfer(unresolvedSender, portfolioHolder(DID_A, 0))).toBe(false); + // and the key distinction: it is NOT the "no from holder" (issuance) case + expect( + classifyInternalTransfer(unresolvedSender, portfolioHolder(DID_A, 0)) + ).not.toBeUndefined(); + }); + + it('is undefined for issuance (no from) and redemption (no to)', () => { + expect(classifyInternalTransfer(undefined, portfolioHolder(DID_A, 0))).toBeUndefined(); + expect(classifyInternalTransfer(portfolioHolder(DID_A, 0), undefined)).toBeUndefined(); + }); +}); + +describe('createAssetTransaction — isInternalTransfer', () => { + beforeEach(() => { + storeSet().mockResolvedValue(undefined); + storeGet().mockResolvedValue(undefined); + }); + + it('classifies a ControllerTransfer internal when both sides resolve to one DID', async () => { + await createAssetTransaction( + '0000000100', + 0, + new Date(0), + { + assetId: ASSET, + fromHolder: portfolioHolder(DID_A, 0), + toHolder: portfolioHolder(DID_A, 1), + amount: BigInt(10), + }, + '0000000100/0000000000', + undefined, + { idx: 1, extrinsic: { method: { method: 'controllerTransfer', section: 'asset' } } } as any + ); + + const [, , row] = storeSet().mock.calls.find(([e]) => e === 'AssetTransaction'); + expect(row.isInternalTransfer).toBe(true); + expect(row.eventId).toBe('ControllerTransfer'); + }); +}); + +describe('handlePortfolioMovement → AssetTransaction', () => { + let db: MockDb; + + const movementEvent = () => + tupleEvent({ + section: 'portfolio', + method: 'MovedBetweenPortfolios', + idx: 2, + extrinsic: { + idx: 0, + extrinsic: { + signer: codec(ADDR), + method: { method: 'movePortfolioFunds', section: 'portfolio' }, + }, + }, + data: [ + codec(DID_A), + portfolioCodec(DID_A, 0), + portfolioCodec(DID_A, 1), + codec(ASSET), + codec('750'), + codec('rebalance'), + ], + }); + + beforeEach(() => { + db = mockStore(); + }); + + it('writes exactly one internal-transfer AssetTransaction with matching identities', async () => { + await handlePortfolioMovement(movementEvent()); + + const rows = Object.values(db['AssetTransaction'] ?? {}); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + assetId: ASSET, + fromPortfolioId: `${DID_A}/0`, + toPortfolioId: `${DID_A}/1`, + fromIdentityId: DID_A, + toIdentityId: DID_A, + amount: BigInt(750), + isInternalTransfer: true, + memo: 'rebalance', + address: ADDR, + eventId: 'MovedBetweenPortfolios', + }); + }); +}); diff --git a/tests/unit/mapNfts.test.ts b/tests/unit/mapNfts.test.ts new file mode 100644 index 00000000..0aa47746 --- /dev/null +++ b/tests/unit/mapNfts.test.ts @@ -0,0 +1,124 @@ +/** + * `handleNftHoldingsUpdates` (src/mappings/entities/assets/mapNfts.ts) after the G9/G10 rewrite: + * each token id is now its own `Nft` row (mint = insert, transfer = one location update, burn = + * one column update) instead of being pushed onto / filtered out of `NftHolder.nftIds`. The + * `NftHolder` rollup is still written, per the SDK. `Holding.nftCount` tracks each side. + */ + +import { + __resetNftBuffer, + flushNftBuffer, + handleNftHoldingsUpdates, +} from '../../src/mappings/entities/assets/mapNfts'; +import { codec, MockDb, meshPortfolioHolderCodec, mockStore, tupleEvent } from './helpers'; + +const ASSET = '0xcollection0000000000000000000000'; +const DID_A = '0x0a'.padEnd(66, '0'); +const DID_B = '0x0b'.padEnd(66, '0'); + +const nftEvent = ( + reason: 'issued' | 'redeemed' | 'transferred', + { holderDid, from, to, ids }: { holderDid: string; from?: unknown; to?: unknown; ids: number[] } +) => + tupleEvent({ + section: 'nft', + method: 'NFTHoldingsUpdated', + idx: 1, + blockNumber: '500', + data: [ + codec(holderDid), + codec({ assetId: ASSET, ids }), + from ?? codec(undefined), + to ?? codec(undefined), + codec( + reason === 'transferred' + ? { transferred: { instructionId: '7', instructionMemo: null } } + : { [reason]: null } + ), + ], + }); + +describe('handleNftHoldingsUpdates — per-token Nft rows', () => { + let db: MockDb; + + beforeEach(() => { + __resetNftBuffer(); + db = mockStore({ + Asset: { + [ASSET]: { + id: ASSET, + totalSupply: BigInt(0), + totalTransfers: BigInt(0), + isNftCollection: true, + }, + }, + }); + }); + + it('mints one Nft row per issued token and raises Holding.nftCount', async () => { + await handleNftHoldingsUpdates( + nftEvent('issued', { + holderDid: DID_A, + to: meshPortfolioHolderCodec(DID_A, 0), + ids: [1, 2, 3], + }) + ); + await flushNftBuffer(); + + expect(Object.keys(db['Nft']).sort()).toEqual([ + `${ASSET}/0000000001`, + `${ASSET}/0000000002`, + `${ASSET}/0000000003`, + ]); + expect(db['Nft'][`${ASSET}/0000000002`]).toMatchObject({ + nftId: BigInt(2), + portfolioId: `${DID_A}/0`, + identityId: DID_A, + mintedBlockId: '0000000500', + }); + expect(db['Nft'][`${ASSET}/0000000002`].burnedBlockId).toBeUndefined(); + expect(db['Holding'][`${ASSET}/${DID_A}/0`].nftCount).toBe(3); + // rollup still maintained + expect(db['NftHolder'][`${ASSET}/${DID_A}`].nftIds).toEqual([BigInt(1), BigInt(2), BigInt(3)]); + }); + + it('moves the Nft row on transfer and adjusts both Holding.nftCount values', async () => { + await handleNftHoldingsUpdates( + nftEvent('issued', { holderDid: DID_A, to: meshPortfolioHolderCodec(DID_A, 0), ids: [7] }) + ); + await flushNftBuffer(); + + await handleNftHoldingsUpdates( + nftEvent('transferred', { + holderDid: DID_A, + from: meshPortfolioHolderCodec(DID_A, 0), + to: meshPortfolioHolderCodec(DID_B, 0), + ids: [7], + }) + ); + await flushNftBuffer(); + + expect(db['Nft'][`${ASSET}/0000000007`]).toMatchObject({ + portfolioId: `${DID_B}/0`, + identityId: DID_B, + }); + expect(db['Holding'][`${ASSET}/${DID_A}/0`].nftCount).toBe(0); + expect(db['Holding'][`${ASSET}/${DID_B}/0`].nftCount).toBe(1); + }); + + it('marks the Nft burned on redemption but leaves the row queryable', async () => { + await handleNftHoldingsUpdates( + nftEvent('issued', { holderDid: DID_A, to: meshPortfolioHolderCodec(DID_A, 0), ids: [9] }) + ); + await flushNftBuffer(); + + await handleNftHoldingsUpdates( + nftEvent('redeemed', { holderDid: DID_A, from: meshPortfolioHolderCodec(DID_A, 0), ids: [9] }) + ); + await flushNftBuffer(); + + expect(db['Nft'][`${ASSET}/0000000009`].burnedBlockId).toBe('0000000500'); + expect(db['Holding'][`${ASSET}/${DID_A}/0`].nftCount).toBe(0); + expect(db['Asset'][ASSET].totalSupply).toBe(BigInt(0)); + }); +}); diff --git a/tests/unit/mapV5PortfolioMovements.test.ts b/tests/unit/mapV5PortfolioMovements.test.ts new file mode 100644 index 00000000..9d3c1559 --- /dev/null +++ b/tests/unit/mapV5PortfolioMovements.test.ts @@ -0,0 +1,85 @@ +/** + * Defect A8 — `portfolio.FungibleTokensMovedBetweenPortfolios` (6 args) and + * `NFTsMovedBetweenPortfolios` (5 args) were emitted only at v5.4.3, through `unchecked_move_funds`, + * and never registered. They are intra-Identity, so they write AssetTransaction rows with + * isInternalTransfer: true, matching their v6+ successor's shape. + */ + +import { + handleFungibleTokensMovedBetweenPortfolios, + handleNftsMovedBetweenPortfolios, +} from '../../src/mappings/entities/identities/mapPortfolio'; +import { codec, MockDb, mockStore, portfolioCodec, tupleEvent } from './helpers'; + +const DID = '0x0a'.padEnd(66, '0'); +const ADDR = '5Signer0000000000000000000000000000000000000000000'; + +const v5Event = (method: string, data: unknown[]) => + tupleEvent({ + section: 'portfolio', + method, + data, + specVersion: 5_003_001, + blockNumber: '7786536', + extrinsic: { + idx: 0, + extrinsic: { signer: codec(ADDR), method: { section: 'portfolio', method: 'moveFunds' } }, + }, + }); + +describe('v5-era portfolio movement events', () => { + let db: MockDb; + + beforeEach(() => { + db = mockStore(); + }); + + it('FungibleTokensMovedBetweenPortfolios writes an internal-transfer AssetTransaction', async () => { + await handleFungibleTokensMovedBetweenPortfolios( + v5Event('FungibleTokensMovedBetweenPortfolios', [ + codec(DID), + portfolioCodec(DID, 0), + portfolioCodec(DID, 2), + codec('0x5449434b4552000000000000'), + codec('999'), + codec('note'), + ]) + ); + + const [row] = Object.values(db['AssetTransaction']); + expect(row).toMatchObject({ + fromPortfolioId: `${DID}/0`, + toPortfolioId: `${DID}/2`, + fromIdentityId: DID, + toIdentityId: DID, + amount: BigInt(999), + isInternalTransfer: true, + memo: 'note', + address: ADDR, + eventId: 'FungibleTokensMovedBetweenPortfolios', + }); + expect(row.nftIds).toBeUndefined(); + }); + + it('NFTsMovedBetweenPortfolios writes an internal-transfer AssetTransaction with nftIds', async () => { + await handleNftsMovedBetweenPortfolios( + v5Event('NFTsMovedBetweenPortfolios', [ + codec(DID), + portfolioCodec(DID, 0), + portfolioCodec(DID, 1), + codec({ ticker: '0x5449434b4552000000000000', ids: [4, 5] }), + codec('gift'), + ]) + ); + + const [row] = Object.values(db['AssetTransaction']); + expect(row).toMatchObject({ + fromIdentityId: DID, + toIdentityId: DID, + isInternalTransfer: true, + nftIds: [BigInt(4), BigInt(5)], + eventId: 'NFTsMovedBetweenPortfolios', + }); + expect(row.amount).toBeUndefined(); + }); +}); diff --git a/tests/unit/seedHoldings.test.ts b/tests/unit/seedHoldings.test.ts new file mode 100644 index 00000000..b71c7259 --- /dev/null +++ b/tests/unit/seedHoldings.test.ts @@ -0,0 +1,100 @@ +/** + * The genesis holding seeder (`src/seed/holding.ts`). `Holding` is rebuilt from the movement + * stream, so — exactly as with `seedAccountBalances` for the POLYX ledger — it needs an opening + * snapshot or every derived holding is short by the start-block allocation. + */ + +import { seedHoldings } from '../../src/seed/holding'; + +const ASSET_A = '0x11111111111111111111111111111111'; +const ASSET_B = '0x22222222222222222222222222222222'; +const DID_1 = '0x01'.padEnd(66, '0'); +const DID_2 = '0x02'.padEnd(66, '0'); + +const storeGet = (): jest.Mock => (globalThis as any).store.get as jest.Mock; +const storeSet = (): jest.Mock => (globalThis as any).store.set as jest.Mock; + +const codec = (value: unknown) => ({ + toString: () => (typeof value === 'string' ? value : JSON.stringify(value)), + toJSON: () => value, +}); + +const portfolio = (did: string, number?: number) => + codec({ did, kind: number ? { user: number } : { default: null } }); + +/** `[storageKey, balance]` pairs as `portfolioAssetBalances.entries()` yields them. */ +const balanceEntry = (did: string, number: number | undefined, assetId: string, amount: string) => [ + { args: [portfolio(did, number), codec(assetId)] }, + codec(amount), +]; + +describe('seedHoldings', () => { + let db: Record>; + + beforeEach(() => { + db = {}; + + storeGet().mockImplementation((entity: string, id: string) => { + if (entity === 'Asset' && (id === ASSET_A || id === ASSET_B)) { + return Promise.resolve(db['Asset']?.[id] ?? { id, assetId: id, holderCount: 0 }); + } + return Promise.resolve(db[entity]?.[id]); + }); + storeSet().mockImplementation((entity: string, id: string, data: any) => { + (db[entity] ??= {})[id] = { ...data }; + return Promise.resolve(); + }); + + (globalThis as any).api.runtimeVersion.specVersion.toNumber = () => 8000000; + (globalThis as any).api.query = { + portfolio: { + portfolioAssetBalances: { + entries: jest.fn().mockResolvedValue([ + balanceEntry(DID_1, 0, ASSET_A, '1000'), + balanceEntry(DID_1, 1, ASSET_A, '500'), + balanceEntry(DID_2, 0, ASSET_A, '250'), + balanceEntry(DID_2, 0, ASSET_B, '0'), // zero balances are skipped + balanceEntry(DID_1, 0, '0xdeadbeefdeadbeefdeadbeefdeadbeef', '9'), // unknown asset skipped + ]), + }, + }, + }; + }); + + it('creates one Holding per funded (portfolio, asset), skipping zero and unknown-asset rows', async () => { + const { seeded } = await seedHoldings({ blockId: '0000000000', datetime: new Date(0) }); + + expect(seeded).toBe(3); + expect(Object.keys(db['Holding']).sort()).toEqual( + [`${ASSET_A}/${DID_1}/0`, `${ASSET_A}/${DID_1}/1`, `${ASSET_A}/${DID_2}/0`].sort() + ); + expect(db['Holding'][`${ASSET_A}/${DID_1}/1`]).toMatchObject({ + amount: BigInt(500), + holderKind: 'Portfolio', + portfolioId: `${DID_1}/1`, + identityId: DID_1, + nftCount: 0, + }); + }); + + it('rolls Holding rows up into one AssetHolder per (asset, identity)', async () => { + await seedHoldings({ blockId: '0000000000', datetime: new Date(0) }); + + expect(db['AssetHolder'][`${ASSET_A}/${DID_1}`].amount).toBe(BigInt(1500)); + expect(db['AssetHolder'][`${ASSET_A}/${DID_2}`].amount).toBe(BigInt(250)); + }); + + it('sets Asset.holderCount to the number of identities holding a positive balance', async () => { + await seedHoldings({ blockId: '0000000000', datetime: new Date(0) }); + + expect(db['Asset'][ASSET_A].holderCount).toBe(2); + }); + + it('no-ops when the portfolio pallet has no balance storage', async () => { + (globalThis as any).api.query = { portfolio: {} }; + + const { seeded } = await seedHoldings({ blockId: '0000000000', datetime: new Date(0) }); + + expect(seeded).toBe(0); + }); +}); diff --git a/tests/unit/setupJest.ts b/tests/unit/setupJest.ts index fba175e7..fc230db8 100644 --- a/tests/unit/setupJest.ts +++ b/tests/unit/setupJest.ts @@ -45,6 +45,11 @@ import { Codec } from '@polkadot/types/types'; // SubQuery Runtime Mocks // ============================================================================ +// `chainId` is injected as a global from the manifest at `subql build`; utils that hash a +// legacy ticker into an asset id read it. Default to a non-staging value so the standard +// UUID-variant logic runs. +(globalThis as any).chainId = '0x6fbd74e5e1d0a61d52ccfe9d4adaed16dd3a7caa37c6bc4d0c2d1396a1a56db7'; + // Mock the global `api` object that SubQuery injects at runtime (globalThis as any).api = { runtimeVersion: { diff --git a/tests/unit/syncMetadata.test.ts b/tests/unit/syncMetadata.test.ts index e529383d..3f040ecb 100644 --- a/tests/unit/syncMetadata.test.ts +++ b/tests/unit/syncMetadata.test.ts @@ -205,10 +205,10 @@ describe('eventDrift', () => { describe('unhandledEvents', () => { it('lists an event of a subscribed pallet that no handler reads', () => { const unhandled = unhandledEvents( - snapshot({ events: { asset: { AssetCreated: 8, AssetTypeChanged: 3 } } }) + snapshot({ events: { asset: { AssetCreated: 8, ClassicTickerClaimed: 2 } } }) ); - expect(unhandled).toContain('asset.AssetTypeChanged'); + expect(unhandled).toContain('asset.ClassicTickerClaimed'); expect(unhandled).not.toContain('asset.AssetCreated'); });