Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/decode/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,31 @@ import { FieldNotFound } from './errors';
/** An event's parameters keyed by field name */
export type DecodedEvent = Readonly<Record<string, Codec>>;

/**
* `asset_id` -> `assetId`. A no-op for a name with no underscore, so an already-camelCase name
* (the common case so far) passes through unchanged rather than being lowercased.
*/
const toCamelCase = (name: string): string =>
name.includes('_')
? name.replace(/_([a-zA-Z0-9])/g, (_match, c: string) => c.toUpperCase())
: name;

/**
* The field names the block's own metadata gives this event, in parameter order.
*
* Since metadata v14 the metadata is self-describing: a struct-style event carries a name per
* field and a tuple-style one carries none. `mapEvent` already straddles this boundary for
* `typeName`; this reads the sibling `name`.
*
* Substrate's own macros name fields idiomatic-Rust snake_case (`asset_id`), while every shape
* table and handler in this codebase reads camelCase (`assetId`) - the convention `#[derive]`d
* upstream-pallet events (and, on a later runtime, some of Polymesh's own) already happen to
* satisfy only when the name has no underscore to begin with. Normalising here, once, is what
* lets the shape table and every handler stay camelCase-only.
*/
export const metadataFieldNames = (event: SubstrateEvent): (string | undefined)[] =>
(event.event as unknown as GenericEvent).meta.fields.map(({ name }) =>
name.isSome ? name.unwrap().toString() : undefined
name.isSome ? toCamelCase(name.unwrap().toString()) : undefined
);

/**
Expand Down
8 changes: 4 additions & 4 deletions src/mappings/entities/assets/mapAsset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ export const handleAssetTransfer = async (event: SubstrateEvent): Promise<void>
let toDid: string;

if (!rawFromHolder.isEmpty) {
fromHolder = await rawAssetHolderToAssetHolder(rawFromHolder, block, blockId);
fromHolder = await rawAssetHolderToAssetHolder(rawFromHolder, block, blockId, blockEventId);
fromDid = fromHolder.identityId;
if (fromDid === emptyDid) {
return; // We ignore the transfer case when Asset tokens are issued
Expand All @@ -549,7 +549,7 @@ export const handleAssetTransfer = async (event: SubstrateEvent): Promise<void>
let toHolder: AssetHolderDetails | undefined;

if (!rawToHolder.isEmpty) {
toHolder = await rawAssetHolderToAssetHolder(rawToHolder, block, blockId);
toHolder = await rawAssetHolderToAssetHolder(rawToHolder, block, blockId, blockEventId);
toDid = toHolder.identityId;
if (toDid === emptyDid) {
toDid = null;
Expand Down Expand Up @@ -700,13 +700,13 @@ export const handleAssetBalanceUpdated = async (event: SubstrateEvent): Promise<
let fromHolder: AssetHolderDetails | undefined;

if (!rawFromHolder.isEmpty) {
fromHolder = await rawAssetHolderToAssetHolder(rawFromHolder, block, blockId);
fromHolder = await rawAssetHolderToAssetHolder(rawFromHolder, block, blockId, blockEventId);
await applyHoldingDelta(asset, fromHolder, blockEventId, -transferAmount, promises);
}
let toHolder: AssetHolderDetails | undefined;

if (!rawToHolder.isEmpty) {
toHolder = await rawAssetHolderToAssetHolder(rawToHolder, block, blockId);
toHolder = await rawAssetHolderToAssetHolder(rawToHolder, block, blockId, blockEventId);
await applyHoldingDelta(asset, toHolder, blockEventId, transferAmount, promises);
}

Expand Down
174 changes: 157 additions & 17 deletions src/mappings/entities/assets/mapAssetMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { decodeEvent } from '../../../decode';
import {
AnomalyKind,
AssetMetadata,
CallIdEnum,
CustomAssetType,
GlobalMetadataKey,
MetadataScope,
MultiSigProposal,
} from '../../../types';
import {
bytesToString,
Expand All @@ -22,25 +24,71 @@ 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<string, number | string>;
// `toHuman()` formats a `u64` with thousands separators ("1,234"); `toJSON()` and `getTextValue`
// do not. Strip them so the same key id round-trips to the same `AssetMetadata` row regardless
// of which path below resolved it.
const keyId = (id: number | string): string => `${id}`.replace(/,/g, '');
if (obj && ('local' in obj || 'Local' in obj)) {
return { scope: MetadataScope.Local, keyId: `${obj.local ?? obj.Local}` };
return { scope: MetadataScope.Local, keyId: keyId(obj.local ?? obj.Local) };
}
if (obj && ('global' in obj || 'Global' in obj)) {
return { scope: MetadataScope.Global, keyId: `${obj.global ?? obj.Global}` };
return { scope: MetadataScope.Global, keyId: keyId(obj.global ?? obj.Global) };
}
return undefined;
};

/** 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;

/**
* `event`'s 0-based position among `SetAssetMetadataValue` / `...ValueDetails` siblings dispatched
* by the same extrinsic. A batch or multisig proposal runs its calls in order and each
* `setAssetMetadata(Details)` call fires exactly one such event, so this ordinal lines up with
* that call's own position among the batch's/proposal's `setAssetMetadata(Details)` entries β€”
* needed because more than one can target the *same* asset (set now, tighten the lock later),
* where matching by `asset_id` alone cannot tell them apart.
*/
const metadataSiblingOrdinal = (event: SubstrateEvent): number => {
const events = event.block.events as unknown as BlockEventRecord[];
const extrinsicIdx = event.extrinsic?.idx;

let ordinal = 0;
for (let i = 0; i < event.idx; i += 1) {
const record = events[i];
if (
record?.event.section === 'asset' &&
(record.event.method === 'SetAssetMetadataValue' ||
record.event.method === 'SetAssetMetadataValueDetails') &&
isFromExtrinsic(record, extrinsicIdx)
) {
ordinal += 1;
}
}
return ordinal;
};

/**
* `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
* 2. the same call, batched alongside others in one `utility.batch*` extrinsic (e.g. `createAsset`
* + `setAssetMetadata` for a new asset's initial metadata) β€” see `metadataKeyFromBatch`;
* 3. the same call, executed via `multiSig.approve` of a previously-created proposal β€” see
* `metadataKeyFromMultiSigProposal`;
* 4. `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.
* Only when none resolves (an unrecognised wrapper) is the row dropped with an anomaly.
*/
const metadataKeyFromExtrinsic = (
extrinsic: SubstrateExtrinsic | undefined
Expand All @@ -55,16 +103,102 @@ const metadataKeyFromExtrinsic = (
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 } };
/** One call as `Extrinsic.toHuman().method` shapes it β€” named, snake_case args. */
type HumanCall = { section: string; method: string; args: Record<string, unknown> };

const isSetMetadataCall = (call: HumanCall): boolean =>
call.section === 'asset' &&
(call.method === 'setAssetMetadata' || call.method === 'setAssetMetadataDetails');

/** Every `utility` call that dispatches a `Vec<Call>` in order, including the legacy/forced forms. */
const BATCH_METHODS = new Set([
'batch',
'batchAll',
'batchAtomic',
'batchOptimistic',
'forceBatch',
'batchOld',
]);

/**
* `asset.setAssetMetadata(Details)` batched alongside other calls in one `utility.batch*`
* extrinsic. The outer, signed extrinsic is `utility.*`, not `asset.*`, so `metadataKeyFromExtrinsic`
* never matches it β€” and there is commonly no sibling registration event either, since this usually
* sets a pre-existing global key rather than registering a new local one. The matching call is
* picked by ordinal among the batch's own `setAssetMetadata(Details)` entries (`metadataSiblingOrdinal`)
* and then checked against `assetId` as a consistency guard, so a mismatch (an unexpected call
* order) falls through to the anomaly rather than writing the wrong asset's key.
*
* Pre-7.0 history is out of reach here: `asset_id` never matches a batched call's legacy `ticker`
* arg, so those fall straight through to the anomaly, same as today.
*/
const metadataKeyFromBatch = (event: SubstrateEvent, assetId: string): MetadataKey | undefined => {
const extrinsic = event.extrinsic;
if (!extrinsic) {
return undefined;
}

const method = extrinsic.extrinsic.method;
if (method.section !== 'utility' || !BATCH_METHODS.has(method.method)) {
return undefined;
}

const human = extrinsic.extrinsic.toHuman() as { method?: { args?: { calls?: HumanCall[] } } };
const call = (human.method?.args?.calls ?? []).filter(isSetMetadataCall)[
metadataSiblingOrdinal(event)
];

return call?.args.asset_id === assetId ? parseMetadataKey(call.args.key) : undefined;
};

const isFromExtrinsic = (record: BlockEventRecord, extrinsicIdx: number | undefined): boolean =>
extrinsicIdx !== undefined &&
record.phase.isApplyExtrinsic &&
record.phase.asApplyExtrinsic.toNumber() === extrinsicIdx;
/**
* The same call, executed via `multiSig.approve` of a proposal created earlier. The call never
* appears on the `approve` extrinsic itself, and `multiSig.proposals` storage is cleared once a
* proposal executes β€” but the indexer already captured it at `ProposalAdded` time:
* `MultiSigProposal.params.proposals` holds the module/call/args of the proposed call (flattened
* one level if the proposal was itself a batch β€” a batch-of-batches proposal is not unwrapped
* further and falls through to the anomaly), keyed `${multisig}/${proposalId}`, exactly what
* `approve`'s own args carry. Matched the same way as `metadataKeyFromBatch`: by ordinal, then
* checked against `assetId`.
*/
const metadataKeyFromMultiSigProposal = async (
event: SubstrateEvent,
assetId: string
): Promise<MetadataKey | undefined> => {
const extrinsic = event.extrinsic;
if (!extrinsic) {
return undefined;
}

const method = extrinsic.extrinsic.method;
if (method.section !== 'multiSig' || method.method !== 'approve') {
return undefined;
}

const [rawMultiSig, rawProposalId] = extrinsic.extrinsic.args;
if (!rawMultiSig || !rawProposalId) {
return undefined;
}

// `.toString()` directly, not `getTextValue`/`getNumberValue` β€” `extrinsic.extrinsic.args`
// resolves through the `@polkadot/types-codec` cjs build, a distinct (if structurally
// identical) `Codec` from the esm one those helpers are typed against.
const proposal = await MultiSigProposal.get(
`${rawMultiSig.toString()}/${Number(rawProposalId.toString())}`
);

const call = (proposal?.params.proposals ?? []).filter(
p =>
p.call === CallIdEnum.set_asset_metadata || p.call === CallIdEnum.set_asset_metadata_details
)[metadataSiblingOrdinal(event)];

if (!call) {
return undefined;
}

const args = JSON.parse(call.args) as Record<string, unknown>;
return args.asset_id === assetId ? parseMetadataKey(args.key) : undefined;
};

/**
* The key id from a `RegisterAssetMetadata{Local,Global}Type` emitted earlier in the same
Expand Down Expand Up @@ -96,8 +230,14 @@ const metadataKeyFromSiblingRegistration = (event: SubstrateEvent): MetadataKey
: { scope: MetadataScope.Global, keyId: getTextValue(decoded.globalKeyId) };
};

const resolveMetadataKey = (event: SubstrateEvent): MetadataKey | undefined =>
metadataKeyFromExtrinsic(event.extrinsic) ?? metadataKeyFromSiblingRegistration(event);
const resolveMetadataKey = async (
event: SubstrateEvent,
assetId: string
): Promise<MetadataKey | undefined> =>
metadataKeyFromExtrinsic(event.extrinsic) ??
metadataKeyFromBatch(event, assetId) ??
(await metadataKeyFromMultiSigProposal(event, assetId)) ??
metadataKeyFromSiblingRegistration(event);

const metadataId = (assetId: string, key: MetadataKey): string =>
`${assetId}/${key.scope}/${key.keyId}`;
Expand Down Expand Up @@ -201,12 +341,12 @@ export const handleSetAssetMetadataValue = async (event: SubstrateEvent): Promis
const { assetId: rawAssetId, value: rawValue, detail: rawDetail } = decodeEvent(event);

const assetId = await getAssetId(rawAssetId, block);
const key = resolveMetadataKey(event);
const key = await resolveMetadataKey(event, assetId);

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`,
detail: `SetAssetMetadataValue for asset ${assetId} could not resolve its metadata key from the extrinsic, a batch, a multisig proposal, or a sibling registration event`,
block,
eventIdx: event.idx,
});
Expand All @@ -227,7 +367,7 @@ export const handleSetAssetMetadataValueDetails = async (event: SubstrateEvent):
const { assetId: rawAssetId, detail: rawDetail } = decodeEvent(event);

const assetId = await getAssetId(rawAssetId, block);
const key = resolveMetadataKey(event);
const key = await resolveMetadataKey(event, assetId);
if (!key) {
return;
}
Expand Down
4 changes: 2 additions & 2 deletions src/mappings/entities/assets/mapNfts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,13 @@ export const handleNftHoldingsUpdates = async (event: SubstrateEvent): Promise<v
let toDid: string;

if (!rawFromHolder.isEmpty) {
fromHolder = await rawAssetHolderToAssetHolder(rawFromHolder, block, blockId);
fromHolder = await rawAssetHolderToAssetHolder(rawFromHolder, block, blockId, blockEventId);
fromDid = fromHolder.identityId;
}
let toHolder: AssetHolderDetails | undefined;

if (!rawToHolder.isEmpty) {
toHolder = await rawAssetHolderToAssetHolder(rawToHolder, block, blockId);
toHolder = await rawAssetHolderToAssetHolder(rawToHolder, block, blockId, blockEventId);
toDid = toHolder.identityId;
}

Expand Down
19 changes: 13 additions & 6 deletions src/mappings/entities/settlements/mapSettlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,12 @@ const mapAutomaticAffirmation = async (
): Promise<[InstructionEvent, InstructionAffirmation]> => {
const [, rawHolder, rawInstructionId] = params;
const instructionId = processInstructionId(rawInstructionId);
const { identity, account, portfolio } = await getPortfolioOrAccount(rawHolder, block, blockId);
const { identity, account, portfolio } = await getPortfolioOrAccount(
rawHolder,
block,
blockId,
blockEventId
);

const automaticAffirmationEvent = InstructionEvent.create({
id: blockEventId,
Expand Down Expand Up @@ -283,7 +288,7 @@ export const handleInstructionCreated = async (event: SubstrateEvent): Promise<v
* count did not change, so this is a payload branch rather than a positional one
*/
if (specVersionOf(block) >= 6_000_000) {
legs = await getSettlementLeg(rawLegs, block, blockId);
legs = await getSettlementLeg(rawLegs, block, blockId, blockEventId);
} else {
legs = await getLegsValue(rawLegs, block);
}
Expand Down Expand Up @@ -389,7 +394,8 @@ export const handleInstructionUpdate = async (event: SubstrateEvent): Promise<vo
const { identity, account, portfolio } = await getPortfolioOrAccount(
rawPortfolio,
block,
blockId
blockId,
blockEventId
);

const partyId = getPartyId(instructionId, identity, account, false);
Expand Down Expand Up @@ -445,7 +451,8 @@ export const handleAffirmationWithdrawn = async (event: SubstrateEvent): Promise
const { identity, account, portfolio } = await getPortfolioOrAccount(
rawPortfolio,
block,
blockId
blockId,
blockEventId
);

const partyId = getPartyId(instructionId, identity, account, false);
Expand Down Expand Up @@ -803,8 +810,8 @@ export const handleFundsTransferred = async (event: SubstrateEvent): Promise<voi
const { fromHolder: rawFromHolder, toHolder: rawToHolder, fund: rawFund } = decodeEvent(event);

const [fromHolder, toHolder] = await Promise.all([
rawAssetHolderToAssetHolder(rawFromHolder, block, blockId),
rawAssetHolderToAssetHolder(rawToHolder, block, blockId),
rawAssetHolderToAssetHolder(rawFromHolder, block, blockId, blockEventId),
rawAssetHolderToAssetHolder(rawToHolder, block, blockId, blockEventId),
]);

const { description, memo } = JSON.parse(rawFund.toString());
Expand Down
10 changes: 5 additions & 5 deletions src/utils/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,11 @@ export const getOrCreateAccount = async (
blockId: string,
datetime: Date,
/**
* The event this account is being created in response to. An account discovered lazily
* (through a chain read, or as a side effect of an unrelated handler) has no single causing
* event β€” callers that have the real one pass it; the rest fall back to the block's first
* event. D13's block-granularity caveat on `updatedEvent` applies. Threading the real id
* through the asset-holder resolution chain is a follow-up.
* The event this account is being created in response to. Every asset-holder-resolution call
* site (`meshAssetHolderToAssetHolder` and up) now threads its real `blockEventId` through; the
* fallback below covers the few callers that still don't have one to give β€” an account
* discovered by a genuinely event-less path (the genesis/seed scan) has no single causing event
* at all. D13's block-granularity caveat on `updatedEvent` applies either way.
*/
createdEventId = `${blockId}/${padId('0')}`
): Promise<Account | undefined> => {
Expand Down
Loading
Loading