SPB Git

Repository Public

polkadot-adapter.test.ts 35.3 KB 431 lines typescript

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
Files main
Menu

Theme