Skip to content
Closed
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
14 changes: 10 additions & 4 deletions scripts/reconcile-polyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
* --rpc wss://mainnet-rpc.polymesh.network \
* [--sample 80] [--high 30] [--typical 30] [--reset]
*/
// Chain-type augmentation, in the order `src/index.ts` loads it: `types-lookup` supplies the
// types `augment-api` imports, and `@polkadot/api-augment` is deliberately not loaded alongside
// (docs/implementation/12-types-and-ci.md §12.2). `tsconfig.test.json` already pulls this in via
// `src/**/*`, but a script is also run and edited on its own, so it declares what it depends on.
import '@polkadot/types-augment';
import '@polymeshassociation/polymesh-types/polkadot/types-lookup';
import '@polymeshassociation/polymesh-types/polkadot/augment-api';
import { ApiPromise, WsProvider } from '@polkadot/api';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
Expand Down Expand Up @@ -152,10 +159,9 @@ const summedAt = async (
const chainAt = async (api: ApiPromise, address: string, block: number): Promise<Triple> => {
const hash = await api.rpc.chain.getBlockHash(block);
const at = await api.at(hash);
const info = (await at.query.system.account(address)) as unknown as {
data: Record<string, { toString(): string }>;
};
const d = info.data;
const info = await at.query.system.account(address);
// The balance fields only: `frozen` is `miscFrozen`/`feeFrozen` on older runtimes.
const d = info.data as unknown as Record<string, { toString(): string }>;
const big = (v?: { toString(): string }) => BigInt(v?.toString() ?? '0');
const legacyFrozen = big(d.miscFrozen) > big(d.feeFrozen) ? big(d.miscFrozen) : big(d.feeFrozen);

Expand Down
20 changes: 12 additions & 8 deletions src/mappings/blockContext.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SubstrateBlock } from '@subql/types';
import { Account } from '../types';
import type { KeyRecordResolution } from '../utils/accounts';
import { padId } from '../utils/common';

/**
Expand All @@ -23,11 +23,14 @@ export interface BlockContext {
/** Extrinsic indices already handled in this block */
handledExtrinsics: Set<number>;
/**
* Addresses already resolved in this block, including the ones that resolved to nothing.
* What `identity.keyRecords` said about an address, including the addresses it said nothing
* about.
*
* Entries are shared between callers, so a caller must not mutate what it reads back.
* Safe to hold for a whole block because `api` reads the block's end-of-block state, so the
* answer is the same for every event in it. Entity rows are not cached here - a handler can
* link or unlink a key mid-block, so `Account` is read from the store on every lookup.
*/
accounts: Map<string, Account | undefined>;
keyRecords: Map<string, KeyRecordResolution | undefined>;
}

let current: BlockContext | undefined;
Expand All @@ -43,7 +46,7 @@ const contextFor = (blockId: string, blockHash?: string): BlockContext => {
blockHash,
blockWritten: false,
handledExtrinsics: new Set(),
accounts: new Map(),
keyRecords: new Map(),
};
} else if (blockHash !== undefined) {
current.blockHash = blockHash;
Expand All @@ -63,7 +66,8 @@ export const getBlockContext = (block: SubstrateBlock): BlockContext =>
contextFor(padId(block.block.header.number.toString()), block.hash.toHex());

/**
* The account resolution cache for a block, reachable from layers that carry only the block id.
* The key record cache for a block, reachable from layers that carry only the block id.
*/
export const getAccountCache = (blockId: string): Map<string, Account | undefined> =>
contextFor(blockId).accounts;
export const getKeyRecordCache = (
blockId: string
): Map<string, KeyRecordResolution | undefined> => contextFor(blockId).keyRecords;
13 changes: 7 additions & 6 deletions src/mappings/entities/identities/reconcilePolyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,15 @@ const readOnChain = async (address: string, blockHeight: number): Promise<OnChai
return hit;
}

const info = (await api.query.system.account(address)) as unknown as {
data: Record<string, Codec>;
};
const info = await api.query.system.account(address);
// The balance fields, not the account info around them: `frozen` is `miscFrozen`/`feeFrozen` on
// older runtimes, so only this inner shape is read spec-agnostically.
const data = info.data as unknown as Record<string, Codec>;

const onChain: OnChain = {
free: getBigIntValue(info.data.free),
reserved: getBigIntValue(info.data.reserved),
frozen: accountDataFrozen(info.data),
free: getBigIntValue(data.free),
reserved: getBigIntValue(data.reserved),
frozen: accountDataFrozen(data),
};

onChainCache.set(address, onChain);
Expand Down
4 changes: 3 additions & 1 deletion src/seed/accountBalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export const seedAccountBalances = async ({

for (const [key, accountInfo] of entries) {
const address = key.args[0].toString();
const data = (accountInfo as unknown as { data: Record<string, Codec> }).data;
// The balance fields, not the account info around them: `frozen` is `miscFrozen`/`feeFrozen`
// on older runtimes, so only this inner shape is read spec-agnostically.
const data = accountInfo.data as unknown as Record<string, Codec>;

const free = getBigIntValue(data.free);
const reserved = getBigIntValue(data.reserved);
Expand Down
128 changes: 86 additions & 42 deletions src/utils/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { decodeAddress, encodeAddress } from '@polkadot/keyring';
import { Codec } from '@polkadot/types/types';
import { u8aToHex } from '@polkadot/util';
import { getAccountCache } from '../mappings/blockContext';
import { getKeyRecordCache } from '../mappings/blockContext';
import { createIdentity, createPermissions } from '../mappings/entities/identities/mapIdentities';
import { createPortfolio } from '../mappings/entities/identities/mapPortfolio';
import { Attributes } from '../mappings/entities/common';
import { Account, EventIdEnum, Identity } from '../types';
import {
extractString,
getFirstKeyFromJson,
getFirstValueFromJson,
getTextValue,
padId,
} from './common';
import { extractString, getTextValue, padId } from './common';
import { evmAddressFromSs58, isEthDerivedAddress } from './eth';
import { legacyQuery } from './legacyQuery';

Expand Down Expand Up @@ -45,27 +39,47 @@ export const getAccountKeyType = (
};

/**
* The DID an address is a key of, and whether it is the primary key.
* What the chain's key record says an address is.
*
* A multisig signer resolves to no DID, deliberately. The signer is linked to the multisig, and
* the multisig is separately linked to an identity that can be unlinked and joined to a different
* one; `multiSig.adminDid` names the admin identity, which is not necessarily the one it is
* joined to. Reading either through to a DID here would record a two-hop, point-in-time answer on
* the `Account` row as though it were a durable fact about the key.
*/
export type KeyRecordResolution =
| { kind: 'primaryKey'; did: string }
| { kind: 'secondaryKey'; did: string }
| { kind: 'multiSigSigner'; multiSig: string };

/**
* What an address is a key of, per the chain's own key record.
*
* `identity.keyRecords` is a 5.x rename of `identity.keyToIdentityIds`; a genesis resync sees the
* older name on early blocks. The legacy storage is `Option<IdentityId>` on both public chains
* (Polymesh launched at v3, so there is no `LinkedKeyInfo` enum to unwrap), and primary vs
* secondary is read from `identity.didRecords`.
* secondary is read from `identity.didRecords`. It predates multisig signer keys, so that variant
* only arises on the current path.
*/
const resolveKeyIdentity = async (
address: string
): Promise<{ did: string; type: 'primaryKey' | 'secondaryKey' } | undefined> => {
const resolveKeyIdentity = async (address: string): Promise<KeyRecordResolution | undefined> => {
if (typeof api.query.identity.keyRecords === 'function') {
const raw = (await api.query.identity.keyRecords(address)) as unknown as Codec;
const raw = await api.query.identity.keyRecords(address);

if (raw.isEmpty) {
return undefined;
}

return {
did: getFirstValueFromJson(raw),
type: getFirstKeyFromJson(raw) === 'primaryKey' ? 'primaryKey' : 'secondaryKey',
};
const keyRecord = raw.unwrap();

if (keyRecord.isPrimaryKey) {
return { kind: 'primaryKey', did: keyRecord.asPrimaryKey.toString() };
}

if (keyRecord.isSecondaryKey) {
return { kind: 'secondaryKey', did: keyRecord.asSecondaryKey.toString() };
}

return { kind: 'multiSigSigner', multiSig: keyRecord.asMultiSigSignerKey.toString() };
}

const raw = (await legacyQuery(
Expand All @@ -82,47 +96,79 @@ const resolveKeyIdentity = async (
const record = (await api.query.identity.didRecords(did)).toJSON() as Record<string, unknown>;
const primaryKey = extractString(record, 'primary_key');

return { did, type: primaryKey === address ? 'primaryKey' : 'secondaryKey' };
return { kind: primaryKey === address ? 'primaryKey' : 'secondaryKey', did };
};

/**
* The `Account` an address belongs to, creating it and its identity when the chain knows of one.
* The chain's key record for `address`, read at most once per block.
*
* Resolution is cached for the block, negatives included. An address the chain has no key record
* for produces no row and so no marker, and this is the hottest chain read in the indexer - it is
* reached twice per asset movement on v8, from both sides of the transfer - so without a negative
* cache a batch touching one unknown address N times issues N identical chain reads.
* `api` is bound to the block being indexed and serves its end-of-block state, so this answer is
* the same for every event in the block - which is what makes caching it safe, and it is the read
* worth caching: it is the hottest chain read in the indexer, reached twice per asset movement on
* v8 from both sides of the transfer, so without it a batch touching one address N times issues N
* identical reads. Addresses that resolve to nothing are cached too, or an unknown address would
* cost a read every time it is seen.
*
* Callers share the cached entity and must not mutate what they read back.
* The `Account` row is deliberately NOT cached alongside it. A handler can link or unlink a key
* partway through a block, so the row can change between events even though the key record cannot.
*/
export const getOrCreateAccount = async (
const resolveKeyRecord = async (
address: string,
blockId: string,
datetime: Date
): Promise<Account | undefined> => {
const cache = getAccountCache(blockId);
blockId: string
): Promise<KeyRecordResolution | undefined> => {
const cache = getKeyRecordCache(blockId);

if (cache.has(address)) {
return cache.get(address);
}

let account = await Account.get(address);
const resolution = await resolveKeyIdentity(address);

if (account) {
cache.set(address, account);
cache.set(address, resolution);

return account;
return resolution;
};

/**
* The `Account` an address belongs to, creating it from the chain's key record when it is absent.
*
* A primary or secondary key brings its identity with it, and that identity and its default
* portfolio are created alongside. Every other address resolves to nothing, deliberately - an
* `Account` is indexed once the chain attaches it to an identity, which is what `mapExtrinsic`
* relies on when it indexes an extrinsic's sender. A multisig signer key is one of those: it has
* no identity of its own (see `KeyRecordResolution`) and no permissions.
*
* The chain read is cached per block by `resolveKeyRecord`. The `Account` row is not: it is read
* from the store on every call, so a row another handler wrote or unlinked earlier in the same
* block is seen rather than shadowed by a stale cache entry.
*/
export const getOrCreateAccount = async (
address: string,
blockId: string,
datetime: Date
): Promise<Account | undefined> => {
const existing = await Account.get(address);

if (existing) {
return existing;
}

const keyIdentity = await resolveKeyIdentity(address);
const resolution = await resolveKeyRecord(address, blockId);

if (!keyIdentity) {
cache.set(address, undefined);
if (!resolution) {
return;
}

// No `Account` row for a signer key: it has no identity and no permissions, so it would carry
// only its key type, and `ledgerAccount` creates a bare one anyway if the address ever holds
// POLYX. The signer itself belongs to `MultiSigSigner`, which the multisig event handlers and
// the genesis/seed scan own - neither `status` nor `createdBlock` is derivable from a key
// record, so writing one from here would be guessing at both.
if (resolution.kind === 'multiSigSigner') {
return;
}

const { did, type } = keyIdentity;
const { did, kind } = resolution;

const eventId = EventIdEnum.AccountCreated;

Expand All @@ -140,7 +186,7 @@ export const getOrCreateAccount = async (
{ identityId: did, number: 0, eventIdx: 0, createdEventId: `${blockId}/${padId('0')}` },
blockId
);
} else if (type === 'primaryKey' && identity.primaryAccount !== address) {
} else if (kind === 'primaryKey' && identity.primaryAccount !== address) {
await createIdentity(
{ did, eventId, datetime, primaryAccount: address, secondaryKeysFrozen: false },
blockId
Expand All @@ -156,7 +202,7 @@ export const getOrCreateAccount = async (
blockId
);

account = Account.create({
const account = Account.create({
id: address,
eventId: EventIdEnum.AccountCreated,
datetime,
Expand All @@ -170,7 +216,5 @@ export const getOrCreateAccount = async (

await account.save();

cache.set(address, account);

return account;
};
Loading
Loading