SPB Git

Repository Public

polkadot.ts 35.4 KB 454 lines typescript

1// Polkadot adapter: native DOT on Polkadot Asset Hub (where balances live since the migration of 4 November 2025).2// JSON-RPC over HTTPS, two independent operators (both archive nodes). Everything that feeds the signature — genesis,3// runtime version, metadata, the finalized reference block, the account's nonce and balances, the fee quote, the dry4// run — is read from both sources and must be identical; otherwise the transfer is refused.5//6// Runtime-upgrade safety. A Substrate runtime can change pallet and call indices and the list of transaction extensions.7// Before every transfer this adapter reads the runtime version from both nodes; on a spec version it has not checked yet8// it downloads the metadata from both (they must be byte-identical) and verifies, BY NAME, everything the hand-written9// encoder in @spw/core relies on: spec name, transaction version, extrinsic version, the extension list with the type of10// every explicit and implicit field, the index of pallet Balances and of call transfer_keep_alive with its arguments, the11// MultiAddress::Id and MultiSignature::Ed25519 variants, the layout of System.Account. Any difference → `runtime_changed`,12// sends stop until packages/core/src/plugins/polkadot.ts is reviewed (docs/chains/polkadot.md). The signer independently13// refuses a transaction version other than the reviewed one.14//15// Rules (stated, not worked around):16// - The call is Balances.transfer_keep_alive: the wallet's account always keeps the existential deposit (0.01 DOT) and is17//   never reaped. There is no "send all": the last 0.01 DOT stays (reaping would burn it anyway).18// - A transfer below the existential deposit to an account that does not exist would fail on chain: refused beforehand.19// - The fee is not a field of the transaction: the chain computes it. The quote comes from `payment_queryInfo` on both20//   nodes for the exact bytes (placeholder signature); the tip is always 0. maxFee = twice the quote, capped.21// - Dry run: `system_dryRun` is not served by public nodes; the runtime API `DryRunApi.dry_run_call` is, and executes the22//   exact call as the wallet's account on the reference block of both nodes.23// - There is no transaction index in a Substrate node: a transfer is located by its account nonce (bisection over state24//   at past blocks), then read from the block and its events. Deposits are found the same way: the account's state is25//   compared between the last scanned block and the finalized head, and only the blocks where it changed are decoded.26import { blake2b } from '@noble/hashes/blake2.js';27import {28  buildPolkadotTx, polkadotAccountId, polkadotExtrinsic, polkadotParseRaw, polkadotTxid, POLKADOT_EXTENSIONS, POLKADOT_MAX_ERA_PERIOD, POLKADOT_REVIEWED, POLKADOT_SS58_PREFIX, ScaleReader, ss58Encode,29  type Intent, type NetworkDef,30} from '@spw/core';31import { UserError } from '../config.ts';32import { decodeValue, palletOf, parseMetadata, renderFields, renderType, storageKey, typeByPath, variantsOf, type Metadata, type Value } from './polkadot-metadata.ts';33import { hostOf, rpcOne, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';3435const TTL_SEC = 90, ERA_PERIOD = POLKADOT_MAX_ERA_PERIOD;36/** the fee ceiling offered to the policy: twice the chain's quote, never above this (planck); equals the default policy ceiling */37const FEE_CAP = 500_000_000n;38/** finalized heads of the two sources further apart than this (blocks of 2 s) are not the same view of the chain */39const MAX_FINALITY_SPREAD = 60;40const MAX_FINALITY_AGE_MS = 10 * 60_000;41const DEPOSIT_MAX_SPAN = 43_200, DEPOSIT_MAX_BLOCKS = 12;42/** layouts this adapter decodes by hand or relies on, as rendered by renderType */43const ACCOUNT_INFO = 'AccountInfo{nonce:u32,consumers:u32,providers:u32,sufficients:u32,data:AccountData{free:u128,reserved:u128,frozen:u128,flags:ExtraFlags}}';44const ADDRESS_ID = 'AccountId32{[u8;32]}', SIGNATURE_ED25519 = '[u8;64]';45const BLAKE2_128_CONCAT = 2;4647const hexOf = (b: Uint8Array) => Array.from(b, (v) => v.toString(16).padStart(2, '0')).join('');48const unhex = (s: unknown, what: string): Uint8Array => {49  if (typeof s !== 'string' || !/^0x([0-9a-f]{2})*$/.test(s)) throw new Error(`polkadot: bad ${what}`);50  return Uint8Array.from(Buffer.from(s.slice(2), 'hex'));51};52const hash = (v: unknown, what: string): string => { if (typeof v !== 'string' || !/^0x[0-9a-f]{64}$/.test(v)) throw new Error(`polkadot: bad ${what}`); return v; };53const fmt = (v: bigint) => { const f = (v % 10n ** 10n).toString().padStart(10, '0').replace(/0+$/, ''); return `${v / 10n ** 10n}${f ? '.' + f : ''}`; };54const u32le = (v: number) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, v, true); return b; };55const obj = (v: Value | undefined): Record<string, Value> => { if (v === null || typeof v !== 'object' || Array.isArray(v) || v instanceof Uint8Array) throw new Error('polkadot: unexpected value'); return v; };5657interface Account { nonce: number; free: bigint; reserved: bigint; frozen: bigint; providers: number }58interface Runtime {59  specVersion: number; transactionVersion: number; meta: Metadata; existentialDeposit: bigint; accountType: number; eventsType: number;60  /** DryRunApi version (0 = not offered), and the encoded origin prefix `OriginCaller::system(RawOrigin::Signed(` */61  dryRun: number; originPrefix: Uint8Array; dispatchErrorType: number;62}63interface Ref { hash: string; number: number }6465/**66 * Check a parsed metadata document against everything the plugin was reviewed for. Throws with the first difference.67 * Exported for the tests and the smoke script.68 */69export function checkPolkadotMetadata(m: Metadata): { existentialDeposit: bigint; accountType: number; eventsType: number; originPrefix: Uint8Array; dispatchErrorType: number } {70  const R = POLKADOT_REVIEWED;71  if (m.extrinsic.version !== R.extrinsicVersion) throw new Error(`extrinsic version ${m.extrinsic.version} (reviewed: ${R.extrinsicVersion})`);72  const ext = m.extrinsic.extensions.map((e) => [e.id, renderType(m, e.type), renderType(m, e.implicit)]);73  if (JSON.stringify(ext) !== JSON.stringify(POLKADOT_EXTENSIONS)) {74    const k = ext.findIndex((e, n) => JSON.stringify(e) !== JSON.stringify(POLKADOT_EXTENSIONS[n]));75    throw new Error(`transaction extensions differ at position ${k < 0 ? ext.length : k}: ${JSON.stringify(ext[k] ?? null)} (reviewed: ${JSON.stringify(POLKADOT_EXTENSIONS[k < 0 ? ext.length : k] ?? null)})`);76  }77  const balances = palletOf(m, R.pallet.name);78  if (balances.index !== R.pallet.index) throw new Error(`pallet ${R.pallet.name} has index ${balances.index} (reviewed: ${R.pallet.index})`);79  const call = variantsOf(m, balances.calls).find((v) => v.name === R.call.name);80  if (!call || call.index !== R.call.index || renderFields(m, call.fields) !== R.call.args) throw new Error(`call ${R.pallet.name}.${R.call.name} is ${call ? `index ${call.index} (${renderFields(m, call.fields)})` : 'missing'} (reviewed: index ${R.call.index} (${R.call.args}))`);81  // the address and signature types of the extrinsic82  const xt = m.types[m.extrinsic.type];83  const param = (name: string): number => { const p = xt?.params.find((x) => x.name === name)?.type; if (p === null || p === undefined) throw new Error(`extrinsic type has no ${name} parameter`); return p; };84  const id = variantsOf(m, param('Address')).find((v) => v.name === 'Id');85  if (!id || id.index !== R.multiAddressId || id.fields.length !== 1 || renderType(m, id.fields[0]!.type) !== ADDRESS_ID) throw new Error('MultiAddress::Id changed');86  const ed = variantsOf(m, param('Signature')).find((v) => v.name === 'Ed25519');87  if (!ed || ed.index !== R.signatureEd25519 || ed.fields.length !== 1 || renderType(m, ed.fields[0]!.type) !== SIGNATURE_ED25519) throw new Error('MultiSignature::Ed25519 changed');88  if (variantsOf(m, call.fields[0]!.type).find((v) => v.name === 'Id')?.index !== R.multiAddressId) throw new Error('destination address type changed');89  // storage read by hand90  const system = palletOf(m, 'System');91  const account = system.storage.find((s) => s.name === 'Account'), events = system.storage.find((s) => s.name === 'Events');92  if (!account || account.hashers.length !== 1 || account.hashers[0] !== BLAKE2_128_CONCAT || renderType(m, account.value) !== ACCOUNT_INFO) throw new Error('System.Account layout changed');93  if (!events || events.key !== null) throw new Error('System.Events layout changed');94  const edConst = balances.constants.find((c) => c.name === 'ExistentialDeposit');95  if (!edConst || edConst.value.length !== 16 || renderType(m, edConst.type) !== 'u128') throw new Error('Balances.ExistentialDeposit changed');96  const prefix = system.constants.find((c) => c.name === 'SS58Prefix');97  if (!prefix || prefix.value.length !== 2 || prefix.value[0] !== POLKADOT_SS58_PREFIX || prefix.value[1] !== 0) throw new Error('SS58 prefix changed');98  const transfer = variantsOf(m, balances.events).find((v) => v.name === 'Transfer');99  if (!transfer || renderFields(m, transfer.fields) !== 'from:AccountId32{[u8;32]},to:AccountId32{[u8;32]},amount:u128') throw new Error('Balances.Transfer event changed');100  // OriginCaller::system(RawOrigin::Signed(account)) for the dry-run runtime API101  const sys = variantsOf(m, typeByPath(m, 'OriginCaller')).find((v) => v.name === 'system');102  const signed = sys && sys.fields.length === 1 ? variantsOf(m, sys.fields[0]!.type).find((v) => v.name === 'Signed') : undefined;103  if (!sys || !signed || signed.fields.length !== 1 || renderType(m, signed.fields[0]!.type) !== ADDRESS_ID) throw new Error('OriginCaller changed');104  const dispatchErrorType = typeByPath(m, 'sp_runtime', 'DispatchError');105  variantsOf(m, dispatchErrorType);106  return { existentialDeposit: new ScaleReader(edConst.value).uint(16), accountType: account.value, eventsType: events.value, originPrefix: Uint8Array.of(sys.index, signed.index), dispatchErrorType };107}108109export function polkadotAdapter(n: NetworkDef): Adapter {110  const sources = n.rpc;111  if (sources.length < 2) throw new Error(`${n.name} needs two independent sources`);112  const genesis = '0x' + n.chainRef;113  let identity = false, runtime: Runtime | null = null;114115  const any = async <T>(f: (u: string) => Promise<T>): Promise<T> => {116    let last: unknown;117    for (const u of sources) { try { return await f(u); } catch (e) { last = e; } }118    throw last;119  };120  const all = async <T>(what: string, f: (u: string) => Promise<T>): Promise<T[]> => {121    const rs = await Promise.allSettled(sources.map(f));122    return rs.map((r) => {123      if (r.status === 'fulfilled') return r.value;124      if (r.reason instanceof UserError) throw r.reason;125      throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);126    });127  };128  const agree = <T>(what: string, vs: T[]): T => {129    for (const v of vs) if (JSON.stringify(v) !== JSON.stringify(vs[0])) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (${what}); transfer refused`, 503);130    return vs[0]!;131  };132  /** A call that may legitimately answer null (missing storage). */133  const rpcNullable = async <T>(u: string, method: string, params: unknown[]): Promise<T | null> => {134    try { return await rpcOne<T>(u, method, params); } catch (e) { if ((e as Error).message === 'rpc: empty result') return null; throw e; }135  };136137  const checkIdentity = async (u: string): Promise<void> => { if (await rpcOne<string>(u, 'chain_getBlockHash', [0]) !== genesis) throw new Error('identity: genesis hash mismatch'); };138  const finalized = async (u: string): Promise<Ref> => {139    const h = hash(await rpcOne(u, 'chain_getFinalizedHead', []), 'finalized head');140    const hd = await rpcOne<{ number?: string }>(u, 'chain_getHeader', [h]);141    const number = typeof hd.number === 'string' && /^0x[0-9a-f]{1,8}$/.test(hd.number) ? parseInt(hd.number, 16) : NaN;142    if (!Number.isSafeInteger(number) || number < 1) throw new Error('polkadot: bad header');143    return { hash: h, number };144  };145  const blockHash = async (u: string, number: number): Promise<string> => hash(await rpcOne(u, 'chain_getBlockHash', [number]), 'block hash');146  /** One finalized reference block known to every source: the lowest of their finalized heads, with the same hash everywhere. */147  const reference = async (): Promise<Ref> => {148    const heads = await all('finalized head', finalized);149    const ref = heads.reduce((a, b) => (b.number < a.number ? b : a));150    if (Math.max(...heads.map((h) => h.number)) - ref.number > MAX_FINALITY_SPREAD) throw new UserError('rpc_disagreement', `${n.name} data sources are not at the same block; transfer refused`, 503);151    for (const h of await all('reference block', (u) => blockHash(u, ref.number))) if (h !== ref.hash) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (reference block); transfer refused`, 503);152    return ref;153  };154  const timestampAt = async (u: string, at: string): Promise<number> => Number(new ScaleReader(unhex(await rpcOne(u, 'state_getStorage', [storageKey('Timestamp', 'Now'), at]), 'timestamp')).uint(8));155156  /** Runtime of both sources, verified against the reviewed pins (metadata is downloaded only when the spec version is new). */157  const checkRuntime = async (): Promise<Runtime> => {158    const paused = (why: string) => new UserError('runtime_changed', `${n.name} runtime changed (${why}); sending is paused until this wallet has been reviewed for it`, 503);159    const v = agree('runtime version', await all('runtime version', async (u) => {160      const r = await rpcOne<{ specName?: string; specVersion?: number; transactionVersion?: number; apis?: [string, number][] }>(u, 'state_getRuntimeVersion', []);161      if (typeof r.specVersion !== 'number' || typeof r.transactionVersion !== 'number' || !Array.isArray(r.apis)) throw new Error('polkadot: bad runtime version');162      return { specName: r.specName, specVersion: r.specVersion, transactionVersion: r.transactionVersion, dryRun: r.apis.find((a) => a[0] === DRY_RUN_API)?.[1] ?? 0 };163    }));164    if (v.specName !== POLKADOT_REVIEWED.specName) throw paused(`spec name ${String(v.specName)}`);165    if (v.specVersion < POLKADOT_REVIEWED.minSpecVersion) throw paused(`spec version ${v.specVersion} is older than the reviewed one`);166    if (v.transactionVersion !== POLKADOT_REVIEWED.transactionVersion) throw paused(`transaction version ${v.transactionVersion}, reviewed ${POLKADOT_REVIEWED.transactionVersion}`);167    if (runtime && runtime.specVersion === v.specVersion) return runtime;168    const docs = await all('metadata', (u) => rpcOne<string>(u, 'state_getMetadata', []));169    if (docs.some((d) => d !== docs[0])) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (runtime metadata); transfer refused`, 503);170    let meta: Metadata, checked: ReturnType<typeof checkPolkadotMetadata>;171    try { meta = parseMetadata(unhex(docs[0], 'metadata')); checked = checkPolkadotMetadata(meta); } catch (e) { throw paused((e as Error).message.slice(0, 160)); }172    runtime = { specVersion: v.specVersion, transactionVersion: v.transactionVersion, meta, ...checked, dryRun: v.dryRun };173    return runtime;174  };175176  const accountKey = (addr: string) => storageKey('System', 'Account', polkadotAccountId(addr));177  const rawAccount = (u: string, addr: string, at?: string) => rpcNullable<string>(u, 'state_getStorage', at ? [accountKey(addr), at] : [accountKey(addr)]);178  /** AccountInfo, decoded by hand (layout verified against the metadata): nonce, consumers, providers, sufficients, free, reserved, frozen, flags. */179  const decodeAccount = (raw: string | null): Account | null => {180    if (raw === null) return null;181    const r = new ScaleReader(unhex(raw, 'account'));182    const nonce = Number(r.uint(4)); r.uint(4); const providers = Number(r.uint(4)); r.uint(4);183    const free = r.uint(16), reserved = r.uint(16), frozen = r.uint(16); r.uint(16);184    if (!r.done) throw new Error('polkadot: bad account');185    return { nonce, free, reserved, frozen, providers };186  };187188  /** Events of a block, decoded with the current metadata. */189  const eventsAt = async (u: string, rt: Runtime, at: string): Promise<{ phase: Value; pallet: string; name: string; data: Value }[]> => {190    const raw = await rpcNullable<string>(u, 'state_getStorage', [storageKey('System', 'Events'), at]);191    if (raw === null) return [];192    const r = new ScaleReader(unhex(raw, 'events'));193    const list = decodeValue(rt.meta, rt.eventsType, r);194    if (!r.done || !Array.isArray(list)) throw new Error('polkadot: bad events');195    return list.map((rec) => { const e = obj(obj(rec).event), inner = obj(e.value); return { phase: obj(rec).phase!, pallet: String(e.variant), name: String(inner.variant), data: inner.value ?? null }; });196  };197  const extrinsicsAt = async (u: string, at: string): Promise<string[]> => {198    const b = await rpcOne<{ block?: { extrinsics?: string[] } }>(u, 'chain_getBlock', [at]);199    if (!Array.isArray(b.block?.extrinsics)) throw new Error('polkadot: bad block');200    return b.block.extrinsics;201  };202  const dispatchError = (v: Value): string => {203    try { const e = obj(v); const inner = e.value; return `${String(e.variant)}${inner && typeof inner === 'object' && !Array.isArray(inner) && !(inner instanceof Uint8Array) && 'variant' in inner ? '.' + String(inner.variant) : inner && typeof inner === 'object' && !Array.isArray(inner) && !(inner instanceof Uint8Array) && 'index' in inner ? ` (pallet ${String(inner.index)})` : ''}`; }204    catch { return 'dispatch error'; }205  };206207  /** outcome of the extrinsic with this hash in one block, or null when the block does not hold it */208  const outcomeIn = async (u: string, rt: Runtime, block: Ref, txid: string): Promise<{ block: Ref; ok: boolean; error?: string } | null> => {209    const index = (await extrinsicsAt(u, block.hash)).findIndex((x) => { try { return polkadotTxid(unhex(x, 'extrinsic')) === txid; } catch { return false; } });210    if (index < 0) return null;211    const mine = (await eventsAt(u, rt, block.hash)).filter((e) => { try { const p = obj(e.phase); return p.variant === 'ApplyExtrinsic' && p.value === BigInt(index); } catch { return false; } });212    const failed = mine.find((e) => e.pallet === 'System' && e.name === 'ExtrinsicFailed');213    if (failed) return { block, ok: false, error: dispatchError(obj(failed.data).dispatch_error ?? null) };214    if (!mine.some((e) => e.pallet === 'System' && e.name === 'ExtrinsicSuccess')) throw new Error('polkadot: no outcome event for the extrinsic');215    return { block, ok: true };216  };217  /** blocks of an era window already read without finding the extrinsic (per transaction id), for the slow path below */218  const scanned = new Map<string, number>();219  /**220   * Where a signed extrinsic of ours ended up, looking at the chain up to `head`. Fast path: the account's nonce tells whether221   * SOME extrinsic with that nonce was included; bisection over past state finds the block, and the block tells whether it was222   * this one. Slow path, when the account does not exist at `head` (emptied and deleted, so its nonce says nothing): every223   * block of the mortal era is read once.224   */225  const locate = async (u: string, rt: Runtime, intent: { from: string; nonce: number; eraBlock: number; period: number }, txid: string, head: Ref): Promise<{ block: Ref; ok: boolean; error?: string } | 'replaced' | null> => {226    if (head.number <= intent.eraBlock) return null;227    const accountAt = async (hsh: string) => decodeAccount(await rawAccount(u, intent.from, hsh));228    const atHead = await accountAt(head.hash);229    if (atHead === null) {230      const last = Math.min(head.number, intent.eraBlock + intent.period);231      for (let b = Math.max(intent.eraBlock + 1, (scanned.get(txid) ?? 0) + 1); b <= last; b++) {232        const found = await outcomeIn(u, rt, { number: b, hash: await blockHash(u, b) }, txid);233        if (found) return found;234        scanned.set(txid, b);235        if (scanned.size > 200) scanned.delete(scanned.keys().next().value!);236      }237      return null;238    }239    if (atHead.nonce <= intent.nonce) return null;240    let lo = intent.eraBlock, hi = head.number, hiHash = head.hash; // invariant: nonce(lo) <= intent.nonce < nonce(hi)241    while (hi - lo > 1) {242      const mid = lo + Math.floor((hi - lo) / 2), h = await blockHash(u, mid);243      if (((await accountAt(h))?.nonce ?? 0) > intent.nonce) { hi = mid; hiHash = h; } else lo = mid;244    }245    return (await outcomeIn(u, rt, { hash: hiHash, number: hi }, txid)) ?? 'replaced';246  };247  const intentOf = (i: any): { from: string; nonce: number; eraBlock: number; period: number } | null => {248    const nonce = Number(i?.x?.nonce), eraBlock = Number(i?.x?.era_block), period = Number(i?.x?.era_period);249    return typeof i?.from === 'string' && Number.isSafeInteger(nonce) && nonce >= 0 && Number.isSafeInteger(eraBlock) && eraBlock > 0 && Number.isSafeInteger(period) && period > 0 ? { from: i.from, nonce, eraBlock, period } : null;250  };251252  return {253    async health(): Promise<Health> {254      let height = 0;255      const endpoints = await Promise.all(sources.map(async (url) => {256        const t = Date.now();257        try {258          const f = await finalized(url);259          if (!identity) await checkIdentity(url);260          if (Date.now() - (await timestampAt(url, f.hash)) > MAX_FINALITY_AGE_MS) throw new Error('polkadot: finality is stalled');261          height = Math.max(height, f.number);262          return { url: hostOf(url), ok: true, ms: Date.now() - t };263        } catch { return { url: hostOf(url), ok: false, ms: null }; }264      }));265      const ok = endpoints.every((e) => e.ok);266      if (ok) identity = true; // every source reported the pinned genesis hash267      return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: height || null, endpoints, checkedAt: new Date().toISOString() };268    },269270    async balances(addr) {271      polkadotAccountId(addr);272      return { native: decodeAccount(await any((u) => rawAccount(u, addr)))?.free ?? 0n }; // an account that does not exist reads as 0273    },274275    async prepare(d): Promise<Prepared> {276      if (d.asset !== 'native') throw new UserError('unsupported_asset', `only ${n.native.symbol} can be sent on ${n.name}`);277      if (d.amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');278      if (d.memo !== undefined) throw new UserError('bad_memo', `${n.name} transfers do not carry a memo`);279      let sender: Uint8Array;280      try { sender = polkadotAccountId(d.from); polkadotAccountId(d.to); } catch (e) { throw new UserError('bad_address', (e as Error).message); }281      if (d.from === d.to) throw new UserError('bad_address', 'destination is this wallet');282      if (!identity) { await all('identity', checkIdentity); identity = true; }283      const rt = await checkRuntime();284      const ref = await reference();285      const ED = rt.existentialDeposit;286287      const [srcRaw, dstRaw, next] = await Promise.all([288        all('account', (u) => rawAccount(u, d.from, ref.hash)), all('account', (u) => rawAccount(u, d.to, ref.hash)),289        all('account nonce', (u) => rpcOne<number>(u, 'system_accountNextIndex', [d.from])),290      ]);291      const src = decodeAccount(agree('source account', srcRaw)), dst = decodeAccount(agree('destination account', dstRaw));292      if (!src || src.free === 0n) throw new UserError('insufficient_funds', `this wallet's ${n.name} account holds no ${n.native.symbol}`);293      // the transaction pool of either node already holds (or the chain has just included) a transaction of this account294      if (next.some((x) => x !== src.nonce)) throw new UserError('pending_tx', 'a previous transaction from this wallet is not finalized yet; retry in a minute', 409);295296      const draft = (maxFee: bigint): Intent => ({ v: 1, proposalId: '00000000-0000-4000-8000-000000000000', keyId: 'simulation', createdAt: 1, expiresAt: 2, policyVersion: 0, network: n.id, chainRef: n.chainRef,297        asset: 'native', symbol: n.native.symbol, decimals: n.native.decimals, amount: d.amount.toString(), from: d.from, to: d.to, maxFee: maxFee.toString(),298        x: { spec_version: String(rt.specVersion), tx_version: String(rt.transactionVersion), nonce: String(src.nonce), era_block: String(ref.number), era_hash: ref.hash, era_period: String(ERA_PERIOD) } });299      // the same bytes the signer will produce, with a placeholder signature: the fee depends on their length and on the call only300      const built = buildPolkadotTx(draft(FEE_CAP), n);301      const xt = '0x' + hexOf(polkadotExtrinsic(built, sender, new Uint8Array(64)));302      const quote = BigInt(agree('fee', await all('fee', async (u) => {303        const q = await rpcOne<{ partialFee?: string }>(u, 'payment_queryInfo', [xt, ref.hash]);304        if (typeof q.partialFee !== 'string' || !/^[1-9]\d{0,30}$/.test(q.partialFee)) throw new Error('polkadot: bad fee quote');305        return q.partialFee;306      })));307      const maxFee = quote * 2n;308      if (maxFee > FEE_CAP) throw new UserError('fee_too_high', `the network fee (${fmt(quote)} ${n.native.symbol}) is above this wallet's ceiling; retry later`);309310      // keep-alive rule: after the amount and the fee, the account must still hold the existential deposit and whatever is frozen311      const locked = src.frozen > src.reserved ? src.frozen - src.reserved : 0n, keep = locked > ED ? locked : ED;312      const most = src.free - keep - maxFee;313      if (d.amount > most) {314        if (most <= 0n) throw new UserError('insufficient_funds', `insufficient ${n.native.symbol}: the account holds ${fmt(src.free)} ${n.native.symbol}, of which ${fmt(keep)} must stay (existential deposit${locked > ED ? ' and locked funds' : ''}) and the network fee is about ${fmt(quote)}`);315        throw new UserError('insufficient_funds', `${n.name} keeps ${fmt(keep)} ${n.native.symbol} in the account (${locked > ED ? 'locked funds' : 'the existential deposit: an account below it is deleted'}) and the fee is about ${fmt(quote)}: the most that can be sent is ${fmt(most)} ${n.native.symbol}`);316      }317      const warnings: string[] = [];318      if (!dst || dst.providers === 0) {319        // a new account must receive at least the existential deposit, or the transfer fails on chain (and the fee is lost)320        if (d.amount < ED) throw new UserError('bad_amount', `the destination account does not exist yet: it must receive at least ${fmt(ED)} ${n.native.symbol} (the existential deposit) to be created`);321        warnings.push('dt_new_account'); // translated by the web app: the account is created by this transfer and must keep the existential deposit322      }323324      // dry run of the exact call, as this account, on the reference block of both sources325      if (rt.dryRun > 0) {326        const param = '0x' + hexOf(Uint8Array.from([...rt.originPrefix, ...sender, ...built.call, ...(rt.dryRun >= 2 ? u32le(DRY_RUN_XCM_VERSION) : [])]));327        const verdicts = await all('simulation', async (u) => {328          // Result<CallDryRunEffects { execution_result: Result<PostDispatchInfo, DispatchErrorWithPostInfo { post_info, error }>, … }, Error>329          const r = new ScaleReader(unhex(await rpcOne(u, 'state_call', ['DryRunApi_dry_run_call', param, ref.hash]), 'dry run'));330          if (r.u8() !== 0) return 'the node could not run the simulation';331          const outcome = r.u8();332          if (outcome === 0) return '';333          if (outcome !== 1) throw new Error('polkadot: bad dry-run result');334          const weight = r.u8(); // post_info.actual_weight: Option<Weight { ref_time, proof_size }>335          if (weight === 1) { r.compact(); r.compact(); } else if (weight !== 0) throw new Error('polkadot: bad dry-run result');336          r.u8(); // post_info.pays_fee337          return dispatchError(decodeValue(rt.meta, rt.dispatchErrorType, r));338        });339        const verdict = agree('simulation', verdicts);340        if (verdict !== '') throw new UserError('simulation_failed', `simulation failed: ${verdict}`);341      }342      return { section: { x: draft(maxFee).x }, maxFee, feeEstimate: quote, warnings, ttlSec: TTL_SEC };343    },344345    async broadcast(raw) {346      const { txid, decoded } = polkadotParseRaw(raw);347      const rs = await Promise.allSettled(sources.map(async (u) => {348        try { if (await rpcOne<string>(u, 'author_submitExtrinsic', [raw]) !== txid) throw new Error('polkadot: node answered with another hash'); }349        catch (e) {350          // 1013 "Transaction Already Imported": it is in this node's pool351          if ((e as { rpcCode?: number }).rpcCode === 1013) return;352          throw e;353        }354      }));355      if (rs.some((r) => r.status === 'fulfilled')) return;356      // refused everywhere ("outdated": the nonce is used): fine only if this very extrinsic is in a block357      try {358        const rt = await checkRuntime(), hd = await any(async (u) => { const h = await rpcOne<{ number: string }>(u, 'chain_getHeader', []); const number = parseInt(h.number, 16); return { number, hash: await blockHash(u, number) }; });359        const era = decoded.era;360        if (era && hd.number >= era.phase) {361          // the era began at the last block ≡ phase (mod period) at or before the head, or one period earlier362          const birth = hd.number - ((hd.number - era.phase) % era.period);363          const found = await any((u) => locate(u, rt, { from: ss58Encode(POLKADOT_SS58_PREFIX, decoded.signer), nonce: Number(decoded.nonce), eraBlock: Math.max(1, birth - era.period), period: 2 * era.period }, txid, hd));364          if (found && found !== 'replaced') return;365        }366      } catch { /* fall through to the node's own error */ }367      throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message).slice(0, 200));368    },369370    async track(p): Promise<Tracked> {371      const i = intentOf(p.intent);372      const pending: Tracked = { state: p.blockRef ? 'reorged' : 'broadcast', confirmations: 0, rebroadcast: true };373      if (!i) return { state: 'broadcast', confirmations: 0 };374      const rt = await checkRuntime().catch(() => runtime);375      if (!rt) return pending;376      const head = await any(finalized);377      const found = await any((u) => locate(u, rt, i, p.txid, head));378      if (found === 'replaced') {379        // another transaction of this account used the nonce; make sure every source says so before declaring it380        const rs = await Promise.allSettled(sources.map((u) => locate(u, rt, i, p.txid, head)));381        return rs.every((r) => r.status === 'fulfilled' && r.value === 'replaced') ? { state: 'replaced', confirmations: 0, error: 'the account nonce was used by another transaction' } : { state: 'broadcast', confirmations: 0 };382      }383      if (found) {384        // in a block that GRANDPA has finalized: irreversible385        if (found.ok) return { state: 'finalized', confirmations: Math.max(1, n.minConf), blockRef: String(found.block.number) };386        return { state: 'failed', confirmations: 0, blockRef: String(found.block.number), error: `rejected by the chain (${found.error}): the transfer was not made (only the network fee was charged)` };387      }388      // not in a finalized block. Dead once the mortal era has ended on the finalized chain of every source.389      const end = i.eraBlock + i.period;390      if (head.number > end) {391        const heads = await Promise.allSettled(sources.map(finalized));392        if (heads.every((h) => h.status === 'fulfilled' && h.value.number > end)) {393          const again = await Promise.allSettled(sources.map(async (u) => locate(u, rt, i, p.txid, (heads.find((h) => h.status === 'fulfilled') as PromiseFulfilledResult<Ref>).value)));394          if (again.every((r) => r.status === 'fulfilled' && r.value === null)) return { state: 'failed', confirmations: 0, error: 'expired before inclusion (mortal era ended); no funds moved' };395        }396        return { state: p.blockRef ? 'reorged' : 'broadcast', confirmations: 0 };397      }398      // included in a block that is not finalized yet?399      try {400        const best = await any(async (u) => { const h = await rpcOne<{ number: string }>(u, 'chain_getHeader', []); const number = parseInt(h.number, 16); return { number, hash: await blockHash(u, number) }; });401        const soft = await any((u) => locate(u, rt, i, p.txid, best));402        if (soft && soft !== 'replaced' && soft.ok) return { state: 'confirming', confirmations: 0, blockRef: 'best:' + soft.block.number };403      } catch { /* stays pending */ }404      return pending;405    },406407    async deposits(addr, cursor) {408      const account = polkadotAccountId(addr);409      const items: Deposit[] = [];410      const head = await any(finalized);411      // first pass: start from the finalized head (nothing older is scanned)412      if (cursor === null || cursor >= head.number) return { items, cursor: cursor === null ? head.number : cursor, finalizedHeight: head.number };413      const rt = await checkRuntime().catch(() => null); // events are decoded with the metadata of the reviewed runtime only414      const to = Math.min(head.number, cursor + DEPOSIT_MAX_SPAN);415      if (!rt) return { items, cursor, finalizedHeight: head.number };416      const u = sources[0]!; // one source for the whole pass (state of the same blocks); the next pass retries on failure417      const stateAt = async (number: number): Promise<{ hash: string; raw: string | null }> => { const h = number === head.number ? head.hash : await blockHash(u, number); return { hash: h, raw: await rawAccount(u, addr, h) }; };418      const a = await stateAt(cursor), b = await stateAt(to);419      // blocks in (cursor, to] where the account's state changed (nonce or balances), oldest first420      const changed: { number: number; hash: string }[] = [];421      const walk = async (lo: number, loRaw: string | null, hi: number, hiState: { hash: string; raw: string | null }): Promise<void> => {422        if (loRaw === hiState.raw || changed.length > DEPOSIT_MAX_BLOCKS) return;423        if (hi - lo === 1) { changed.push({ number: hi, hash: hiState.hash }); return; }424        const mid = lo + Math.floor((hi - lo) / 2), m = await stateAt(mid);425        await walk(lo, loRaw, mid, m);426        await walk(mid, m.raw, hi, hiState);427      };428      await walk(cursor, a.raw, to, b);429      const truncated = changed.length > DEPOSIT_MAX_BLOCKS;430      for (const blk of changed.slice(0, DEPOSIT_MAX_BLOCKS)) {431        let events: Awaited<ReturnType<typeof eventsAt>>;432        try { events = await eventsAt(u, rt, blk.hash); } catch { continue; } // a block of an older runtime that this metadata cannot decode433        let xts: string[] | null = null, time: number | null = null;434        for (const [idx, e] of events.entries()) {435          if (e.pallet !== 'Balances' || (e.name !== 'Transfer' && e.name !== 'Minted')) continue;436          const data = obj(e.data), who = e.name === 'Transfer' ? data.to : data.who, from = e.name === 'Transfer' ? data.from : null, amount = data.amount;437          if (!(who instanceof Uint8Array) || hexOf(who) !== hexOf(account) || typeof amount !== 'bigint' || amount <= 0n) continue;438          if (from instanceof Uint8Array && hexOf(from) === hexOf(account)) continue;439          xts ??= await extrinsicsAt(u, blk.hash);440          time ??= Math.floor((await timestampAt(u, blk.hash)) / 1000);441          const phase = obj(e.phase), k = phase.variant === 'ApplyExtrinsic' && typeof phase.value === 'bigint' ? Number(phase.value) : -1;442          items.push({ txid: k >= 0 && xts[k] ? polkadotTxid(unhex(xts[k], 'extrinsic')) : blk.hash, idx, asset: 'native', amount, from: from instanceof Uint8Array ? ss58Encode(POLKADOT_SS58_PREFIX, from) : '', block: blk.number, time });443        }444      }445      return { items, cursor: truncated ? changed[DEPOSIT_MAX_BLOCKS - 1]!.number : to, finalizedHeight: head.number };446    },447  };448}449450/** runtime API identifier: blake2b-64 of the trait name */451const DRY_RUN_API = '0x' + hexOf(blake2b(new TextEncoder().encode('DryRunApi'), { dkLen: 8 }));452/** XCM version asked for the (unused) XCM part of the dry-run result */453const DRY_RUN_XCM_VERSION = 5;454
Files main
Menu

Theme