Repository Public
1// Polkadot (Asset Hub) adapter against two scripted JSON-RPC sources (no network), with the REAL runtime metadata and real2// blocks recorded from mainnet: the hand-written metadata reader against @polkadot/types, the runtime-upgrade pins, agreement3// rules, existential-deposit rules, fee and dry run, tracking by nonce, deposits by state comparison + event decoding.4import { readFileSync } from 'node:fs';5import { gunzipSync } from 'node:zlib';6import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';7import { Metadata as SdkMetadata, TypeRegistry } from '@polkadot/types';8import { blake2AsHex, xxhashAsHex } from '@polkadot/util-crypto';9import { buildPolkadotTx, NETWORKS, polkadotAccountId, polkadotDecodeExtrinsic, polkadotExtrinsic, polkadotTxid, POLKADOT_EXTENSIONS, scaleCompact, ScaleReader, ss58Encode, type Intent } from '@spw/core';10import { decodeValue, palletOf, parseMetadata, renderType, storageKey, twox128, typeByPath, variantsOf, xxhash64, type Metadata } from '../src/chains/polkadot-metadata.ts';11import type { Adapter } from '../src/chains/rpc.ts';1213const n = NETWORKS.polkadot;14const [A, B] = n.rpc as [string, string];15const fx = JSON.parse(readFileSync(new URL('../../../packages/core/test/fixtures/polkadot-asset-hub.json', import.meta.url), 'utf8')) as { transfers: { block: number; blockHash: string; index: number; hex: string; events?: string }[] };16const META_BYTES = gunzipSync(readFileSync(new URL('../../../packages/core/test/fixtures/polkadot-asset-hub-metadata.hex.gz', import.meta.url)));17const META_HEX = '0x' + META_BYTES.toString('hex');18const meta = parseMetadata(META_BYTES);19const registry = new TypeRegistry();20const sdkMeta = new SdkMetadata(registry, META_BYTES);21registry.setMetadata(sdkMeta);22const hex = (b: Uint8Array) => Buffer.from(b).toString('hex');2324const FROM = ss58Encode(0, new Uint8Array(32).fill(0x11)), TO = ss58Encode(0, new Uint8Array(32).fill(0x22)), NEW = ss58Encode(0, new Uint8Array(32).fill(0x33));25const GENESIS = '0x' + n.chainRef, FIN = 23_004_000, ED = 100_000_000n, QUOTE = '9058355';26const hashOf = (b: number) => '0x' + b.toString(16).padStart(64, '0');27const numberOf = (h: string) => parseInt(h.slice(2), 16);28const DRY = blake2AsHex('DryRunApi', 64);29const le = (v: bigint, bytes: number) => { const b = new Uint8Array(bytes); for (let k = 0; k < bytes; k++) { b[k] = Number(v & 0xffn); v >>= 8n; } return b; };30const accountRaw = (nonce: number, free: bigint, opt: { reserved?: bigint; frozen?: bigint; providers?: number } = {}) =>31 '0x' + hex(Uint8Array.from([...le(BigInt(nonce), 4), ...le(0n, 4), ...le(BigInt(opt.providers ?? 1), 4), ...le(0n, 4), ...le(free, 16), ...le(opt.reserved ?? 0n, 16), ...le(opt.frozen ?? 0n, 16), ...le(1n << 127n, 16)]));32const keyOf = (addr: string) => storageKey('System', 'Account', polkadotAccountId(addr));33const EVENTS = storageKey('System', 'Events'), NOW = storageKey('Timestamp', 'Now');3435type Handler = (params: any[], url: string) => unknown;36class RpcErr extends Error { code: number; constructor(code: number, message: string) { super(message); this.code = code; } }37interface World {38 fin: Record<string, number>; best: number; version: { specName: string; specVersion: number; transactionVersion: number; apis: [string, number][] }; metadata: Record<string, string>;39 /** account state per address: a function of the block number (undefined = latest) */40 accounts: Record<string, (block: number) => string | null>; nextIndex: Record<string, number>; quote: Record<string, string>; dry: Record<string, string>;41 blocks: Record<number, { extrinsics: string[]; events?: string }>; submitted: string[]; calls: { url: string; method: string; params: any[] }[]; over: Record<string, Handler | 'down'>; perUrl: Record<string, Record<string, Handler | 'down'>>;42}43function world(patch: (w: World) => void = () => {}): World {44 const w: World = {45 fin: { [A]: FIN, [B]: FIN }, best: FIN + 25, version: { specName: 'statemint', specVersion: 2005002, transactionVersion: 15, apis: [[DRY, 2]] }, metadata: { [A]: META_HEX, [B]: META_HEX },46 accounts: { [FROM]: () => accountRaw(7, 100_000_000_000n), [TO]: () => accountRaw(0, 5_000_000_000n) }, nextIndex: { [FROM]: 7 }, quote: { [A]: QUOTE, [B]: QUOTE }, dry: { [A]: '0x0000', [B]: '0x0000' },47 blocks: {}, submitted: [], calls: [], over: {}, perUrl: {},48 };49 patch(w);50 const acct = (key: string, block: number): string | null => { for (const [addr, f] of Object.entries(w.accounts)) if (keyOf(addr) === key) return f(block); return null; };51 const methods: Record<string, Handler> = {52 chain_getBlockHash: ([b]) => (b === 0 ? GENESIS : hashOf(b)),53 chain_getFinalizedHead: (_p, u) => hashOf(w.fin[u]!),54 chain_getHeader: ([h]) => ({ number: '0x' + (h === undefined ? w.best : numberOf(h)).toString(16) }),55 state_getRuntimeVersion: () => w.version,56 state_getMetadata: (_p, u) => w.metadata[u],57 system_accountNextIndex: ([addr]) => w.nextIndex[addr] ?? 0,58 payment_queryInfo: (_p, u) => ({ weight: { refTime: 1, proofSize: 1 }, class: 'normal', partialFee: w.quote[u] }),59 state_call: ([method], u) => { if (method !== 'DryRunApi_dry_run_call') throw new RpcErr(-32000, 'unknown runtime api'); return w.dry[u]; },60 state_getStorage: ([key, at]) => {61 const block = at === undefined ? w.best : numberOf(at);62 if (key === NOW) return '0x' + hex(le(BigInt(Date.now() - 4000), 8));63 if (key === EVENTS) return w.blocks[block]?.events ?? '0x00';64 return acct(key, block);65 },66 chain_getBlock: ([h]) => ({ block: { extrinsics: w.blocks[numberOf(h)]?.extrinsics ?? ['0x280403000b'] } }),67 author_submitExtrinsic: ([raw]) => { w.submitted.push(raw); return polkadotTxid(Buffer.from(raw.slice(2), 'hex')); },68 };69 vi.stubGlobal('fetch', async (url: string, init: RequestInit = {}) => {70 const { id, method, params } = JSON.parse(String(init.body)) as { id: number; method: string; params: any[] };71 w.calls.push({ url, method, params });72 const h = w.perUrl[url]?.[method] ?? w.over[method] ?? methods[method];73 if (!h || h === 'down') throw new TypeError('fetch failed');74 try { return new Response(JSON.stringify({ jsonrpc: '2.0', id, result: h(params, url) })); }75 catch (e) { if (e instanceof RpcErr) return new Response(JSON.stringify({ jsonrpc: '2.0', id, error: { code: e.code, message: e.message } })); throw e; }76 });77 return w;78}7980let make: () => Adapter, checkPolkadotMetadata: (m: Metadata) => { existentialDeposit: bigint; originPrefix: Uint8Array; dispatchErrorType: number };81beforeAll(async () => {82 Object.assign(process.env, { SPW_ORIGIN: 'https://wallet.test', SPW_RP_ID: 'wallet.test', DATABASE_URL: 'postgres://unused', SPW_SIGNER_SECRET: 'x'.repeat(48), SPW_BROADCAST: 'off', SPW_WORKERS: 'off' });83 const mod = await import('../src/chains/polkadot.ts');84 make = () => mod.polkadotAdapter(n);85 checkPolkadotMetadata = mod.checkPolkadotMetadata;86});87afterEach(() => vi.unstubAllGlobals());88const req = { asset: 'native', from: FROM, to: TO, amount: 12_500_000_000n };89const code = async (p: Promise<unknown>) => p.then(() => 'ok', (e: { code?: string; message: string }) => e.code ?? `error:${e.message}`);90const msg = async (p: Promise<unknown>) => p.then(() => 'ok', (e: Error) => e.message);91const clone = (): Metadata => parseMetadata(META_BYTES);9293describe('metadata reader (hand-written) against @polkadot/types on the real Asset Hub metadata', () => {94 const latest = sdkMeta.asLatest as any;95 it('reads the same pallets, calls, extensions and constants', () => {96 expect(meta.pallets.map((p) => [p.name, p.index])).toEqual(latest.pallets.map((p: any) => [p.name.toString(), p.index.toNumber()]));97 expect(meta.extrinsic.extensions.map((e) => e.id)).toEqual(latest.extrinsic.transactionExtensions.map((e: any) => e.identifier.toString()));98 expect(meta.extrinsic.extensions.map((e) => [e.id, renderType(meta, e.type), renderType(meta, e.implicit)])).toEqual(POLKADOT_EXTENSIONS);99 expect(meta.types.length).toBe(registry.lookup.types.length);100 for (const p of latest.pallets) {101 if (p.calls.isNone) continue;102 const sdkCalls = registry.lookup.getSiType(p.calls.unwrap().type).def.asVariant.variants.map((v: any) => [v.name.toString(), v.index.toNumber(), v.fields.length]);103 expect(variantsOf(meta, palletOf(meta, p.name.toString()).calls).map((v) => [v.name, v.index, v.fields.length])).toEqual(sdkCalls);104 }105 const call = registry.findMetaCall(Uint8Array.of(10, 3));106 expect([call.section, call.method]).toEqual(['balances', 'transferKeepAlive']);107 expect(variantsOf(meta, palletOf(meta, 'Balances').calls).find((v) => v.name === 'transfer_keep_alive')!.index).toBe(3);108 const c = checkPolkadotMetadata(meta);109 expect(c.existentialDeposit).toBe(ED);110 expect([...c.originPrefix]).toEqual([0, 1]);111 });112113 it('computes storage keys as @polkadot/util-crypto does', () => {114 for (const s of ['System', 'Account', 'Events', 'Balances', 'TotalIssuance', 'Timestamp', 'Now', '', 'a', 'x'.repeat(31), 'y'.repeat(32), 'z'.repeat(77)]) expect('0x' + hex(twox128(s))).toBe(xxhashAsHex(s, 128));115 expect(xxhash64(new Uint8Array(0), 0n)).toBe(0xef46db3751d8e999n); // XXH64 reference value for empty input116 const acc = polkadotAccountId(FROM);117 expect(storageKey('System', 'Account', acc)).toBe(xxhashAsHex('System', 128) + xxhashAsHex('Account', 128).slice(2) + blake2AsHex(acc, 128).slice(2) + hex(acc));118 expect(storageKey('System', 'Events')).toBe('0x26aa394eea5630e07c48ae0c9558cef780d41e5e16056765bc8461851072c9d7');119 });120121 it('decodes the events of real blocks exactly as @polkadot/types does', () => {122 const system = palletOf(meta, 'System'), eventsType = system.storage.find((s) => s.name === 'Events')!.value;123 const withEvents = fx.transfers.filter((t) => t.events);124 expect(withEvents.length).toBeGreaterThan(0);125 const outcomes: string[] = [];126 for (const t of withEvents) {127 const r = new ScaleReader(Buffer.from(t.events!.slice(2), 'hex'));128 const mine = decodeValue(meta, eventsType, r) as any[];129 expect(r.done).toBe(true);130 const sdk = registry.createType('Vec<EventRecord>', t.events) as any;131 expect(mine.length).toBe(sdk.length);132 mine.forEach((rec, k) => {133 expect(rec.event.variant.toLowerCase()).toBe(sdk[k].event.section.toLowerCase());134 expect(rec.event.value.variant).toBe(sdk[k].event.method);135 if (sdk[k].phase.isApplyExtrinsic) expect(rec.phase.value).toBe(BigInt(sdk[k].phase.asApplyExtrinsic.toNumber()));136 });137 // the transfer of the recorded extrinsic, as an event138 const d = polkadotDecodeExtrinsic(Buffer.from(t.hex.slice(2), 'hex'));139 const outcome = mine.find((rec) => rec.event.variant === 'System' && /^Extrinsic(Success|Failed)$/.test(rec.event.value.variant) && rec.phase.value === BigInt(t.index));140 outcomes.push(outcome.event.value.variant);141 const ev = mine.find((rec) => rec.event.variant === 'Balances' && rec.event.value.variant === 'Transfer' && rec.phase.value === BigInt(t.index));142 if (outcome.event.value.variant === 'ExtrinsicFailed') { expect(ev).toBeUndefined(); continue; } // a real transfer that the chain rejected143 expect(hex(ev.event.value.value.to)).toBe(hex(d.dest));144 expect(ev.event.value.value.amount).toBe(d.value);145 }146 expect(outcomes).toContain('ExtrinsicSuccess');147 });148149 it('refuses a runtime whose metadata differs from the reviewed pins in any way the encoder relies on', () => {150 const changes: [string, (m: Metadata) => void, RegExp][] = [151 ['an extension added', (m) => m.extrinsic.extensions.push({ id: 'CheckSomethingNew', type: m.extrinsic.extensions[0]!.type, implicit: m.extrinsic.extensions[0]!.implicit }), /extensions differ at position 13/],152 ['an extension removed', (m) => m.extrinsic.extensions.splice(9, 1), /extensions differ at position 9/],153 ['two extensions swapped', (m) => { const e = m.extrinsic.extensions; [e[5], e[6]] = [e[6]!, e[5]!]; }, /extensions differ at position 5/],154 ['an extension gaining explicit data', (m) => { m.extrinsic.extensions[7]!.type = m.extrinsic.extensions[6]!.type; }, /extensions differ at position 7/],155 ['an implicit field changing type', (m) => { m.extrinsic.extensions[2]!.implicit = m.extrinsic.extensions[4]!.implicit; }, /extensions differ at position 2/],156 ['extrinsic version 5', (m) => { m.extrinsic.version = 5; }, /extrinsic version 5/],157 ['Balances moved', (m) => { palletOf(m, 'Balances').index = 11; }, /pallet Balances has index 11/],158 ['transfer_keep_alive renumbered', (m) => { variantsOf(m, palletOf(m, 'Balances').calls).find((v) => v.name === 'transfer_keep_alive')!.index = 7; }, /transfer_keep_alive is index 7/],159 ['transfer_keep_alive renamed', (m) => { variantsOf(m, palletOf(m, 'Balances').calls).find((v) => v.name === 'transfer_keep_alive')!.name = 'transfer_preserve'; }, /transfer_keep_alive is missing/],160 ['transfer_keep_alive arguments swapped', (m) => { variantsOf(m, palletOf(m, 'Balances').calls).find((v) => v.name === 'transfer_keep_alive')!.fields.reverse(); }, /transfer_keep_alive is index 3 \(value:Compact<u128>,dest:MultiAddress\)/],161 ['System.Account hashed differently', (m) => { palletOf(m, 'System').storage.find((s) => s.name === 'Account')!.hashers = [5]; }, /System\.Account layout changed/],162 ['existential deposit constant missing', (m) => { palletOf(m, 'Balances').constants = []; }, /ExistentialDeposit changed/],163 ['SS58 prefix changed', (m) => { palletOf(m, 'System').constants.find((c) => c.name === 'SS58Prefix')!.value = Uint8Array.of(2, 0); }, /SS58 prefix changed/],164 ];165 for (const [name, mutate, re] of changes) { const m = clone(); mutate(m); expect(() => checkPolkadotMetadata(m), name).toThrow(re); }166 expect(() => parseMetadata(Uint8Array.from([...META_BYTES.subarray(0, 4), 15, ...META_BYTES.subarray(5)]))).toThrow(/unsupported version/);167 expect(() => parseMetadata(META_BYTES.subarray(0, META_BYTES.length - 1))).toThrow();168 });169});170171describe('polkadot adapter: health, balances', () => {172 it('is ok only when both sources are up, on Asset Hub, and finalizing', async () => {173 world();174 expect(await make().health()).toMatchObject({ ok: true, readable: true, identityVerified: true, height: FIN });175 world((w) => { w.perUrl[B] = { chain_getFinalizedHead: 'down' }; });176 expect(await make().health()).toMatchObject({ ok: false, readable: true, identityVerified: false });177 world((w) => { w.perUrl[B] = { chain_getBlockHash: () => '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3' }; }); // the relay chain178 expect(await make().health()).toMatchObject({ ok: false, identityVerified: false });179 world((w) => { w.over.state_getStorage = () => '0x' + hex(le(BigInt(Date.now() - 3_600_000), 8)); }); // finality stalled for an hour180 expect(await make().health()).toMatchObject({ ok: false, readable: false });181 });182 it('reads the free balance; an account that does not exist reads as 0', async () => {183 world();184 expect(await make().balances(FROM)).toEqual({ native: 100_000_000_000n });185 expect(await make().balances(NEW)).toEqual({ native: 0n });186 await expect(make().balances('5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY')).rejects.toThrow(/address/);187 });188});189190describe('polkadot adapter: prepare', () => {191 const intentOf = (p: { section: Record<string, unknown>; maxFee: bigint }, d = req): Intent => ({ v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: 1, expiresAt: 91, policyVersion: 4,192 network: 'polkadot', chainRef: n.chainRef, asset: 'native', symbol: 'DOT', decimals: 10, amount: d.amount.toString(), from: d.from, to: d.to, maxFee: p.maxFee.toString(), x: p.section.x as Record<string, string> });193194 it('builds on a finalized block known to both sources, quotes the fee on the exact bytes and dry-runs the exact call', async () => {195 const w = world();196 const p = await make().prepare(req);197 expect(p.section.x).toEqual({ spec_version: '2005002', tx_version: '15', nonce: '7', era_block: String(FIN), era_hash: hashOf(FIN), era_period: '256' });198 expect(p).toMatchObject({ feeEstimate: BigInt(QUOTE), maxFee: 2n * BigInt(QUOTE), warnings: [], ttlSec: 90 });199 const b = buildPolkadotTx(intentOf(p), n);200 const quoted = w.calls.filter((c) => c.method === 'payment_queryInfo');201 expect(quoted.map((c) => c.url).sort()).toEqual([A, B].sort());202 for (const q of quoted) {203 expect(q.params[1]).toBe(hashOf(FIN));204 const xt = registry.createType('Extrinsic', Buffer.from(polkadotExtrinsic(b, polkadotAccountId(FROM), new Uint8Array(64).fill(9))));205 expect(q.params[0].length).toBe(xt.toHex().length); // same length as what will be signed206 expect(hex(polkadotDecodeExtrinsic(Buffer.from(q.params[0].slice(2), 'hex')).call)).toBe(hex(b.call));207 }208 const dry = w.calls.filter((c) => c.method === 'state_call');209 expect(dry.length).toBe(2);210 // OriginCaller::system(RawOrigin::Signed(account)) ‖ call ‖ XCM version211 expect(dry[0]!.params).toEqual(['DryRunApi_dry_run_call', '0x0001' + hex(polkadotAccountId(FROM)) + hex(b.call) + '05000000', hashOf(FIN)]);212 expect(registry.createType('Call', b.call).method).toBe('transferKeepAlive');213 // every state read names the reference block214 for (const c of w.calls.filter((x) => x.method === 'state_getStorage')) expect(c.params[1]).toBe(hashOf(FIN));215 });216217 it('uses the lower finalized head when the sources are a few blocks apart, and refuses when they are not on the same chain', async () => {218 world((w) => { w.fin[B] = FIN - 4; });219 expect((await make().prepare(req)).section.x).toMatchObject({ era_block: String(FIN - 4), era_hash: hashOf(FIN - 4) });220 world((w) => { w.fin[B] = FIN - 500; });221 expect(await code(make().prepare(req))).toBe('rpc_disagreement');222 world((w) => { w.perUrl[B] = { chain_getBlockHash: ([b]) => (b === 0 ? GENESIS : '0x' + 'ab'.repeat(32)) }; });223 expect(await code(make().prepare(req))).toBe('rpc_disagreement');224 });225226 it('pauses on a runtime it was not reviewed for', async () => {227 world((w) => { w.version.transactionVersion = 16; });228 expect(await msg(make().prepare(req))).toMatch(/runtime changed \(transaction version 16, reviewed 15\); sending is paused/);229 world((w) => { w.version.specName = 'asset-hub-polkadot'; });230 expect(await code(make().prepare(req))).toBe('runtime_changed');231 world((w) => { w.version.specVersion = 2005001; });232 expect(await code(make().prepare(req))).toBe('runtime_changed');233 // a new spec version with the same transaction version: the metadata is read again and checked by name234 const renamed = Buffer.from(META_BYTES);235 const at = renamed.lastIndexOf(Buffer.from('CheckNonZeroSender')); // the extension identifier (the type of the same name comes earlier)236 renamed.write('CheckNonZeroSendeR', at);237 world((w) => { w.version.specVersion = 2006000; w.metadata = { [A]: '0x' + renamed.toString('hex'), [B]: '0x' + renamed.toString('hex') }; });238 expect(await msg(make().prepare(req))).toMatch(/runtime changed \(transaction extensions differ at position 1/);239 world((w) => { w.version.specVersion = 2006000; w.metadata[B] = '0x' + renamed.toString('hex'); });240 expect(await code(make().prepare(req))).toBe('rpc_disagreement');241 world((w) => { w.version.specVersion = 2006000; });242 expect(await code(make().prepare(req))).toBe('ok'); // same layout under a newer spec version: fine243 world((w) => { w.perUrl[B] = { state_getRuntimeVersion: () => ({ ...w.version, specVersion: 2005003 }) }; });244 expect(await code(make().prepare(req))).toBe('rpc_disagreement');245 const a = make();246 const w = world();247 await a.prepare(req); await a.prepare(req);248 expect(w.calls.filter((c) => c.method === 'state_getMetadata').length).toBe(2); // once per source, then cached for that spec version249 });250251 it('applies the existential-deposit rules on both sides', async () => {252 world();253 // the sender keeps 0.01 DOT and the fee ceiling: 10 − 0.01 − 2 × quote254 const most = 100_000_000_000n - ED - 2n * BigInt(QUOTE);255 expect(await code(make().prepare({ ...req, amount: most }))).toBe('ok');256 expect(await msg(make().prepare({ ...req, amount: most + 1n }))).toMatch(/keeps 0\.01 DOT in the account \(the existential deposit: an account below it is deleted\).*the most that can be sent is 9\.9881883[0-9]* DOT/);257 expect(await code(make().prepare({ ...req, amount: 100_000_000_000n }))).toBe('insufficient_funds');258 world((w) => { w.accounts[FROM] = () => accountRaw(7, 100_000_000_000n, { frozen: 60_000_000_000n, reserved: 10_000_000_000n }); });259 expect(await msg(make().prepare({ ...req, amount: 60_000_000_000n }))).toMatch(/keeps 5 DOT in the account \(locked funds\)/);260 expect(await code(make().prepare({ ...req, amount: 40_000_000_000n }))).toBe('ok');261 world((w) => { w.accounts[FROM] = () => accountRaw(7, 105_000_000n); });262 expect(await msg(make().prepare({ ...req, amount: 1n }))).toMatch(/of which 0\.01 must stay/);263 world((w) => { w.accounts[FROM] = () => null; });264 expect(await msg(make().prepare(req))).toMatch(/holds no DOT/);265 // a destination that does not exist yet must receive at least the existential deposit266 world();267 expect(await msg(make().prepare({ ...req, to: NEW, amount: ED - 1n }))).toMatch(/does not exist yet: it must receive at least 0\.01 DOT/);268 expect(await make().prepare({ ...req, to: NEW, amount: ED })).toMatchObject({ warnings: ['dt_new_account'] });269 expect(await make().prepare({ ...req, amount: 1n })).toMatchObject({ warnings: [] }); // an existing account can receive any amount270 });271272 it('refuses while a previous transaction of the account is not finalized', async () => {273 world((w) => { w.nextIndex[FROM] = 8; });274 expect(await code(make().prepare(req))).toBe('pending_tx');275 world((w) => { w.perUrl[B] = { system_accountNextIndex: () => 8 }; });276 expect(await code(make().prepare(req))).toBe('pending_tx');277 });278279 it('refuses when the sources are down or disagree on anything that feeds the signature', async () => {280 for (const method of ['chain_getFinalizedHead', 'state_getRuntimeVersion', 'state_getStorage', 'system_accountNextIndex', 'payment_queryInfo', 'state_call']) {281 world((w) => { w.perUrl[B] = { [method]: 'down' }; });282 expect(await code(make().prepare(req)), method).toBe('rpc_degraded');283 }284 world((w) => { w.perUrl[B] = { state_getStorage: ([key]) => (key === keyOf(FROM) ? accountRaw(8, 100_000_000_000n) : accountRaw(0, 5_000_000_000n)) }; });285 expect(await code(make().prepare(req))).toBe('rpc_disagreement');286 world((w) => { w.quote[B] = '9058356'; });287 expect(await code(make().prepare(req))).toBe('rpc_disagreement');288 world((w) => { w.perUrl[A] = { chain_getBlockHash: ([b]) => (b === 0 ? '0x' + '00'.repeat(32) : hashOf(b)) }; });289 expect(await code(make().prepare(req))).toBe('rpc_degraded'); // not Asset Hub290 world((w) => { w.quote = { [A]: '300000000', [B]: '300000000' }; });291 expect(await code(make().prepare(req))).toBe('fee_too_high');292 });293294 it('refuses what the chain would reject in the dry run, with the chain\'s own reason', async () => {295 const err = variantsOf(meta, typeByPath(meta, 'sp_runtime', 'DispatchError')), token = err.find((v) => v.name === 'Token')!;296 const below = variantsOf(meta, token.fields[0]!.type).find((v) => v.name === 'BelowMinimum')!;297 // Ok(effects { execution_result: Err { post_info { actual_weight: Some(…), pays_fee }, error: Token(BelowMinimum) } })298 const failing = '0x' + hex(Uint8Array.from([0, 1, 1, ...scaleCompact(1000n), ...scaleCompact(2000n), 0, token.index, below.index, 0, 0]));299 world((w) => { w.dry = { [A]: failing, [B]: failing }; });300 expect(await msg(make().prepare(req))).toBe('simulation failed: Token.BelowMinimum');301 world((w) => { w.dry[B] = failing; });302 expect(await code(make().prepare(req))).toBe('rpc_disagreement');303 world((w) => { w.dry = { [A]: '0x0100', [B]: '0x0100' }; });304 expect(await msg(make().prepare(req))).toMatch(/could not run the simulation/);305 const w = world((x) => { x.version.apis = []; }); // a runtime without the dry-run API: skipped306 expect(await code(make().prepare(req))).toBe('ok');307 expect(w.calls.some((c) => c.method === 'state_call')).toBe(false);308 });309310 it('refuses bad requests', async () => {311 world();312 expect(await code(make().prepare({ ...req, asset: '1984' }))).toBe('unsupported_asset');313 expect(await code(make().prepare({ ...req, amount: 0n }))).toBe('bad_amount');314 expect(await code(make().prepare({ ...req, memo: 'hi' }))).toBe('bad_memo');315 expect(await code(make().prepare({ ...req, to: FROM }))).toBe('bad_address');316 expect(await code(make().prepare({ ...req, to: 'G9xV2EatmrjRC1FLPexc3ddqNRRzCsAdURU8RFiAAJX6ppY' }))).toBe('bad_address');317 });318});319320describe('polkadot adapter: broadcast, tracking, deposits (real recorded blocks)', () => {321 // a real transfer and the real events of its block322 const outcomeOf = (t: { events?: string; index: number }) => (t.events ? (registry.createType('Vec<EventRecord>', t.events) as any).find((r: any) => r.phase.isApplyExtrinsic && r.phase.asApplyExtrinsic.toNumber() === t.index && r.event.section === 'system')?.event.method : undefined);323 const real = fx.transfers.find((t) => outcomeOf(t) === 'ExtrinsicSuccess')!, realFailed = fx.transfers.find((t) => outcomeOf(t) === 'ExtrinsicFailed');324 const d = polkadotDecodeExtrinsic(Buffer.from(real.hex.slice(2), 'hex'));325 const SENDER = ss58Encode(0, d.signer), RECIPIENT = ss58Encode(0, d.dest), TXID = polkadotTxid(Buffer.from(real.hex.slice(2), 'hex')), NONCE = Number(d.nonce);326 // the block as recorded: every real transfer of that block at its own index, placeholders elsewhere327 const sameBlock = fx.transfers.filter((t) => t.block === real.block);328 const xts = (): string[] => { const list = Array.from({ length: Math.max(...sameBlock.map((t) => t.index)) + 2 }, () => '0x280403000b'); for (const t of sameBlock) list[t.index] = t.hex; return list; };329 const chain = (w: World, head = real.block + 60) => {330 w.fin = { [A]: head, [B]: head }; w.best = head + 20;331 w.blocks[real.block] = { extrinsics: xts(), events: real.events! };332 w.accounts[SENDER] = (b) => accountRaw(b >= real.block ? NONCE + 1 : NONCE, 50_000_000_000n);333 w.accounts[RECIPIENT] = (b) => accountRaw(3, b >= real.block ? 20_000_000_000n + d.value : 20_000_000_000n);334 };335 const tracked = (over: Record<string, string> = {}) => ({ txid: TXID, intent: { from: SENDER, x: { nonce: String(NONCE), era_block: String(real.block - 30), era_period: '256', ...over } }, blockRef: null, broadcastAt: 0 });336337 it('broadcasts to both sources; "already imported" is success, a refusal is fine only when the extrinsic is in a block', async () => {338 let w = world();339 await make().broadcast(real.hex);340 expect(w.submitted).toEqual([real.hex, real.hex]);341 w = world((x) => { x.over.author_submitExtrinsic = () => { throw new RpcErr(1013, 'Transaction Already Imported'); }; });342 await make().broadcast(real.hex);343 w = world((x) => { x.perUrl[A] = { author_submitExtrinsic: () => { throw new RpcErr(1010, 'Invalid Transaction'); } }; });344 await make().broadcast(real.hex); // one source is enough345 w = world((x) => { x.over.author_submitExtrinsic = () => { throw new RpcErr(1010, 'Invalid Transaction: Transaction is outdated'); }; });346 await expect(make().broadcast(real.hex)).rejects.toThrow(/outdated/);347 w = world((x) => { chain(x, real.block + 5); x.over.author_submitExtrinsic = () => { throw new RpcErr(1010, 'Invalid Transaction: Transaction is outdated'); }; });348 await make().broadcast(real.hex); // outdated because it is already on chain349 await expect(make().broadcast('0x00')).rejects.toThrow(/not a Polkadot signed extrinsic/);350 void w;351 });352353 it('finds a finalized transfer by the account nonce and reads its outcome from the events', async () => {354 const w = world(chain);355 expect(await make().track(tracked())).toEqual({ state: 'finalized', confirmations: 1, blockRef: String(real.block) });356 expect(w.calls.filter((c) => c.method === 'chain_getBlock').length).toBe(1); // bisection over state, one block read357 expect(w.calls.filter((c) => c.method === 'state_getStorage').length).toBeLessThan(16);358 // the nonce was used by another extrinsic (this id is not in that block), confirmed by both sources359 world(chain);360 expect(await make().track({ ...tracked(), txid: '0x' + 'ab'.repeat(32) })).toMatchObject({ state: 'replaced' });361 world((x) => { chain(x); x.perUrl[B] = { chain_getBlock: 'down' }; });362 expect(await make().track({ ...tracked(), txid: '0x' + 'ab'.repeat(32) })).toEqual({ state: 'broadcast', confirmations: 0 }); // one source cannot confirm: no verdict363 });364365 it('reports a transfer the chain rejected, with its reason', async () => {366 if (realFailed) { // a REAL transfer that failed on mainnet (the sender's funds were frozen), with the real events of its block367 const f = polkadotDecodeExtrinsic(Buffer.from(realFailed.hex.slice(2), 'hex')), who = ss58Encode(0, f.signer);368 world((x) => {369 x.fin = { [A]: realFailed.block + 60, [B]: realFailed.block + 60 };370 x.blocks[realFailed.block] = { extrinsics: Array.from({ length: realFailed.index + 1 }, (_, k) => (k === realFailed.index ? realFailed.hex : '0x280403000b')), events: realFailed.events! };371 x.accounts[who] = (b) => accountRaw(b >= realFailed.block ? Number(f.nonce) + 1 : Number(f.nonce), 50_000_000_000n);372 });373 const r = await make().track({ txid: polkadotTxid(Buffer.from(realFailed.hex.slice(2), 'hex')), intent: { from: who, x: { nonce: f.nonce.toString(), era_block: String(realFailed.block - 20), era_period: '64' } }, blockRef: null, broadcastAt: 0 });374 expect(r).toMatchObject({ state: 'failed', blockRef: String(realFailed.block), error: expect.stringMatching(/^rejected by the chain \(Token\.\w+\)/) });375 }376 const err = variantsOf(meta, typeByPath(meta, 'sp_runtime', 'DispatchError')), token = err.find((v) => v.name === 'Token')!;377 const funds = variantsOf(meta, token.fields[0]!.type).find((v) => v.name === 'FundsUnavailable')!;378 const sysEvents = variantsOf(meta, palletOf(meta, 'System').events), failedIx = sysEvents.find((v) => v.name === 'ExtrinsicFailed')!.index;379 // one EventRecord: phase ApplyExtrinsic(index), System.ExtrinsicFailed { dispatch_error: Token(FundsUnavailable), dispatch_info { weight, class, pays_fee } }, no topics380 const rec = Uint8Array.from([...scaleCompact(1n), 0, ...le(BigInt(real.index), 4), palletOf(meta, 'System').index, failedIx, token.index, funds.index, ...scaleCompact(1000n), ...scaleCompact(2000n), 0, 0, 0]);381 const sdk = registry.createType('Vec<EventRecord>', rec) as any;382 expect([sdk[0].event.section, sdk[0].event.method]).toEqual(['system', 'ExtrinsicFailed']); // the hand-made record is what @polkadot/types reads383 world((x) => { chain(x); x.blocks[real.block]!.events = '0x' + hex(rec); });384 expect(await make().track(tracked())).toEqual({ state: 'failed', confirmations: 0, blockRef: String(real.block), error: 'rejected by the chain (Token.FundsUnavailable): the transfer was not made (only the network fee was charged)' });385 });386387 it('declares expiry only when the mortal era has ended on the finalized chain of every source', async () => {388 const idle = (head: number) => (x: World) => { x.fin = { [A]: head, [B]: head }; x.best = head + 20; x.accounts[SENDER] = () => accountRaw(NONCE, 50_000_000_000n); };389 world(idle(real.block + 100));390 expect(await make().track(tracked())).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: true });391 world(idle(real.block - 30 + 257));392 expect(await make().track(tracked())).toEqual({ state: 'failed', confirmations: 0, error: 'expired before inclusion (mortal era ended); no funds moved' });393 world((x) => { idle(real.block - 30 + 257)(x); x.fin[B] = real.block - 30 + 250; });394 expect(await make().track(tracked())).toMatchObject({ state: 'broadcast' }); // the other source has not finalized past the era yet395 // included in a block that is not finalized yet396 world((x) => { chain(x, real.block - 5); x.best = real.block + 3; });397 expect(await make().track(tracked())).toEqual({ state: 'confirming', confirmations: 0, blockRef: 'best:' + real.block });398 world(idle(real.block + 100));399 expect(await make().track({ ...tracked(), blockRef: 'best:1' })).toMatchObject({ state: 'reorged', rebroadcast: true });400 });401402 it('falls back to reading every block of the era when the account no longer exists (its nonce says nothing)', async () => {403 const w = world((x) => { chain(x); x.accounts[SENDER] = (b) => (b >= real.block ? null : accountRaw(NONCE, 50_000_000_000n)); });404 expect(await make().track(tracked())).toEqual({ state: 'finalized', confirmations: 1, blockRef: String(real.block) });405 expect(w.calls.filter((c) => c.method === 'chain_getBlock').length).toBe(30); // era start + 1 … the block that holds it406 world((x) => { chain(x, real.block - 30 + 300); x.blocks = {}; x.accounts[SENDER] = () => null; });407 expect(await make().track(tracked())).toMatchObject({ state: 'failed', error: expect.stringMatching(/expired/) }); // all 256 blocks read, not there408 });409410 it('finds deposits by comparing the account between two finalized blocks and decoding only the blocks where it changed', async () => {411 let w = world(chain);412 const a = make();413 expect(await a.deposits(RECIPIENT, null)).toEqual({ items: [], cursor: real.block + 60, finalizedHeight: real.block + 60 }); // first pass: from the head on414 const r = await a.deposits(RECIPIENT, real.block - 3000);415 expect(r.items[0]).toEqual({ txid: TXID, idx: expect.any(Number), asset: 'native', amount: d.value, from: SENDER, block: real.block, time: expect.any(Number) });416 // the same real block may pay the same recipient again (an exchange deposit address): each such event maps to its own extrinsic417 for (const i of r.items) { const t = sameBlock.find((x) => polkadotTxid(Buffer.from(x.hex.slice(2), 'hex')) === i.txid)!, dd = polkadotDecodeExtrinsic(Buffer.from(t.hex.slice(2), 'hex')); expect([ss58Encode(0, dd.dest), dd.value, ss58Encode(0, dd.signer)]).toEqual([RECIPIENT, i.amount, i.from]); }418 expect(r.cursor).toBe(real.block + 60);419 expect(w.calls.filter((c) => c.method === 'state_getStorage' && c.params[0] === EVENTS).length).toBe(1); // one block decoded out of 3060420 w = world(chain);421 expect(await make().deposits(RECIPIENT, real.block + 1)).toMatchObject({ items: [], cursor: real.block + 60 }); // nothing changed since: no block read422 expect(w.calls.some((c) => c.method === 'chain_getBlock' || c.params[0] === EVENTS)).toBe(false);423 world(chain);424 expect((await make().deposits(SENDER, real.block - 10)).items).toEqual([]); // the sender's own transfer is not a deposit425 world((x) => { chain(x); x.version.transactionVersion = 16; });426 expect(await make().deposits(RECIPIENT, real.block - 10)).toMatchObject({ items: [], cursor: real.block - 10 }); // unreviewed runtime: events are not decoded, the cursor waits427 world((x) => { chain(x, real.block + 100_000); });428 expect(await make().deposits(RECIPIENT, real.block - 10)).toMatchObject({ cursor: real.block - 10 + 43_200 }); // long gaps are caught up in bounded passes429 });430});431