SPB Git

Repository Public

ton-adapter.test.ts 32.0 KB 421 lines typescript

1// TON adapter against a scripted TON Center v2 and a scripted TonAPI v2 (no network): agreement rules, bounce flag from on-chain2// state, wallet deployment, simulation, fail-closed paths, tracking, broadcast and deposits.3import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';4import { beginCell, Cell, loadMessage, loadMessageRelaxed } from '@ton/core';5import { NETWORKS, pluginFor, TON_SUBWALLET_ID, TON_V4R2_CODE_BOC, tonFormatAddress, tonParseAddress, tonRawAddress, tonReadExternal, tonSerializeBoc, type Intent } from '@spw/core';6import { deriveKeys } from '../../isolated-signer/src/keys.ts';7import type { Adapter } from '../src/chains/rpc.ts';89const n = NETWORKS.ton, [A, B] = n.rpc as [string, string]; // A = TON Center, B = TonAPI10const keys = deriveKeys('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');11const PUB = keys.handle(n).publicKey;12const ME = keys.addresses.ton!;13const DEST = tonFormatAddress(new Uint8Array(32).fill(0x21)), NEW = tonFormatAddress(new Uint8Array(32).fill(0x42)), THIRD = tonFormatAddress(new Uint8Array(32).fill(0x63));14const raw = tonRawAddress, eq = (addr: string) => tonFormatAddress(tonParseAddress(addr), { bounceable: true });15const NOW = 1_800_000_000, TON1 = 1_000_000_000n;16const CODE = Cell.fromBase64(TON_V4R2_CODE_BOC);1718interface Acct { state: 'active' | 'uninit' | 'frozen'; balance: bigint; lastLt: number; seqno?: number; pub?: Uint8Array; walletType?: string; walletId?: number; code?: Cell }19interface Tx { lt: number; hash: string; utime: number; account: string; ext?: { body: string; norm: string }; from?: string; value?: bigint; outs: { to: string; value: bigint }[]; success?: boolean; aborted?: boolean }20interface Fake {21  down: boolean; zero: string; mc: number; time: number; accounts: Record<string, Acct>; txs: Tx[]; busy: number; seen: string[]; posts: { path: string; body: any }[];22  fees: Record<string, number>; emu: { status?: number; error?: string; skipped?: boolean; childAborted?: boolean; noChild?: boolean; value?: number };23  send: () => { status: number; body: any };24}25const wallet = (over: Partial<Acct> = {}): Acct => ({ state: 'active', balance: 10n * TON1, lastLt: 1000, seqno: 5, pub: PUB, walletType: 'wallet v4 r2', walletId: TON_SUBWALLET_ID, ...over });26const fresh = (): Fake => ({27  down: false, zero: n.chainRef, mc: 5000, time: NOW - 2, busy: 0, seen: [], posts: [], txs: [],28  accounts: { [raw(ME)]: wallet(), [raw(DEST)]: { state: 'active', balance: 3n * TON1, lastLt: 800 } },29  fees: { in_fwd_fee: 105334, storage_fee: 23, gas_fee: 220534, fwd_fee: 66667 }, emu: {}, send: () => ({ status: 200, body: { ok: true, result: { '@type': 'ok' } } }),30});31let h: Record<string, Fake>;32const dataOf = (a: Acct) => beginCell().storeUint(a.seqno ?? 0, 32).storeUint(a.walletId ?? TON_SUBWALLET_ID, 32).storeBuffer(Buffer.from(a.pub ?? new Uint8Array(32))).storeBit(0).endCell();33/** what the adapter posted for simulation, decoded by the official SDK */34function decodeTransfer(boc: string, external: boolean) {35  let body = Cell.fromBase64(boc);36  if (external) body = loadMessage(body.beginParse()).body;37  const s = body.beginParse();38  const signature = s.loadBuffer(64), signingHash = beginCell().storeSlice(s).endCell().hash().toString('hex'), head = [s.loadUint(32), s.loadUint(32), s.loadUint(32), s.loadUint(8), s.loadUint(8)];39  const out = loadMessageRelaxed(s.loadRef().beginParse());40  if (out.info.type !== 'internal') throw new Error('not internal');41  return { signature, signingHash, head, dest: out.info.dest.toRawString(), value: out.info.value.coins, bounce: out.info.bounce };42}4344async function fakeFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {45  const url = new URL(String(input)), base = [A, B].find((u) => String(input).startsWith(u))!, f = h[base]!;46  const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });47  if (!f || f.down) throw new TypeError('fetch failed');48  const path = url.pathname.slice(new URL(base).pathname.length), q = url.searchParams;49  f.seen.push(path + url.search);50  if (f.busy > 0) { f.busy--; return json(429, { ok: false, error: 'Ratelimit exceed' }); }51  const posted = init?.method === 'POST' ? JSON.parse(String(init.body)) : undefined;52  if (posted) f.posts.push({ path, body: posted });53  const acct = (r: string | null) => (r ? f.accounts[r] : undefined);54  if (base === A) {55    const ok = (result: unknown) => json(200, { ok: true, result });56    const a = acct(q.get('address')), state = !a ? 'uninitialized' : a.state === 'uninit' ? 'uninitialized' : a.state;57    const lastTx = { lt: String(a?.lastLt ?? 0), hash: Buffer.alloc(32, a ? 1 : 0).toString('base64') };58    if (path === '/getMasterchainInfo') return ok({ last: { seqno: f.mc }, init: { root_hash: Buffer.from(f.zero, 'hex').toString('base64') } });59    if (path === '/getAddressBalance') return ok(String(a?.balance ?? 0n));60    if (path === '/getWalletInformation') return ok({ wallet: a?.state === 'active', balance: String(a?.balance ?? 0n), account_state: state, last_transaction_id: lastTx,61      ...(a?.state === 'active' ? { wallet_type: a.walletType, seqno: a.seqno, wallet_id: a.walletId } : {}) });62    if (path === '/getAddressInformation') return ok({ state, balance: String(a?.balance ?? 0n), sync_utime: f.time, block_id: { seqno: f.mc }, last_transaction_id: lastTx,63      code: a?.state === 'active' ? (a.code ?? CODE).toBoc().toString('base64') : '', data: a?.state === 'active' ? dataOf(a).toBoc().toString('base64') : '' });64    if (path === '/getBlockHeader') return q.get('workchain') === '-1' && Number(q.get('seqno')) === f.mc ? ok({ gen_utime: f.time }) : json(500, { ok: false, error: 'block not found' });65    if (path === '/estimateFee') return ok({ '@type': 'query.fees', source_fees: f.fees, destination_fees: [] });66    if (path === '/sendBoc') { const r = f.send(); return json(r.status, r.body); }67    if (path === '/getTransactions') {68      let list = f.txs.filter((t) => t.account === q.get('address')).sort((x, y) => y.lt - x.lt);69      if (q.get('lt')) {70        const k = list.findIndex((t) => String(t.lt) === q.get('lt') && t.hash === q.get('hash'));71        if (k < 0) return json(500, { ok: false, error: 'LITE_SERVER_UNKNOWN: cannot locate transaction' });72        list = list.slice(k);73      }74      if (q.get('to_lt')) list = list.filter((t) => t.lt > Number(q.get('to_lt')));75      return ok(list.slice(0, Number(q.get('limit') ?? 10)).map((t) => ({76        utime: t.utime, transaction_id: { lt: String(t.lt), hash: Buffer.from(t.hash, 'hex').toString('base64') },77        in_msg: t.ext ? { source: '', destination: eq(t.account), value: '0', msg_data: { '@type': 'msg.dataRaw', body: t.ext.body } }78          : { source: eq(t.from!), destination: eq(t.account), value: String(t.value), msg_data: { '@type': 'msg.dataText', text: '' } },79        out_msgs: t.outs.map((o) => ({ source: eq(t.account), destination: eq(o.to), value: String(o.value) })),80      })));81    }82    return json(404, { ok: false, error: 'no such method' });83  }84  const taTx = (t: Tx) => ({85    hash: t.hash, lt: t.lt, utime: t.utime, success: t.success ?? true, aborted: t.aborted ?? false, account: { address: t.account },86    in_msg: t.ext ? { msg_type: 'ext_in_msg', value: 0, hash: t.ext.norm, destination: { address: t.account } } : { msg_type: 'int_msg', value: Number(t.value), bounce: true, source: { address: t.from }, destination: { address: t.account } },87    out_msgs: t.outs.map((o) => ({ msg_type: 'int_msg', value: Number(o.value), destination: { address: o.to }, source: { address: t.account } })),88  });89  if (path === '/liteserver/get_masterchain_info') return json(200, { last: { seqno: f.mc }, init: { workchain: -1, root_hash: f.zero } });90  if (path === '/blockchain/masterchain-head') return json(200, { seqno: f.mc, gen_utime: f.time });91  let m = /^\/blockchain\/accounts\/(0:[0-9a-f]{64})$/.exec(path);92  if (m) {93    const a = acct(m[1]!);94    if (!a) return json(404, { error: 'entity not found' });95    return json(200, { address: m[1], balance: Number(a.balance), status: a.state, last_transaction_lt: a.lastLt,96      ...(a.state === 'active' ? { code: (a.code ?? CODE).toBoc().toString('hex'), data: dataOf(a).toBoc().toString('hex') } : {}) });97  }98  m = /^\/blockchain\/messages\/([0-9a-f]{64})\/transaction$/.exec(path);99  if (m) { const t = f.txs.find((x) => x.ext?.norm === m![1]); return t ? json(200, taTx(t)) : json(404, { error: 'transaction not found' }); }100  m = /^\/blockchain\/accounts\/(0:[0-9a-f]{64})\/transactions$/.exec(path);101  if (m) {102    if (!acct(m[1]!)) return json(404, { error: 'entity not found' });103    let list = f.txs.filter((t) => t.account === m![1]).sort((x, y) => x.lt - y.lt);104    if (q.get('after_lt')) list = list.filter((t) => t.lt > Number(q.get('after_lt')));105    if (q.get('sort_order') !== 'asc') list.reverse();106    return json(200, { transactions: list.slice(0, Number(q.get('limit') ?? 100)).map(taTx) });107  }108  if (path === '/blockchain/message') { const r = f.send(); return r.status === 200 ? new Response(null, { status: 200 }) : json(r.status, r.body); }109  if (path === '/wallet/emulate') {110    if (f.emu.status) return json(f.emu.status, { error: f.emu.error });111    const t = decodeTransfer(posted.boc, true);112    const child = { transaction: { account: { address: t.dest }, aborted: f.emu.childAborted ?? false, in_msg: { msg_type: 'int_msg', value: f.emu.value ?? Number(t.value), bounce: t.bounce } } };113    return json(200, { trace: { transaction: { success: true, aborted: false, out_msgs: [], compute_phase: { success: true }, action_phase: { success: true, total_actions: 1, skipped_actions: f.emu.skipped ? 1 : 0 } },114      children: f.emu.skipped || f.emu.noChild ? [] : [child] } });115  }116  return json(404, { error: 'unknown route' });117}118119let a: Adapter;120let make: () => Adapter;121beforeAll(async () => {122  Object.assign(process.env, { SPW_ORIGIN: 'https://wallet.test', SPW_RP_ID: 'wallet.test', DATABASE_URL: 'postgres:///unused', SPW_SIGNER_SECRET: 'k'.repeat(48) });123  vi.useFakeTimers({ toFake: ['Date'] });124  vi.setSystemTime(NOW * 1000);125  vi.stubGlobal('fetch', fakeFetch);126  const { tonAdapter } = await import('../src/chains/ton.ts');127  make = () => tonAdapter(n);128});129afterAll(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });130beforeEach(() => { h = { [A]: fresh(), [B]: fresh() }; a = make(); vi.setSystemTime(NOW * 1000); }); // a new adapter: no cached chain identity131132const each = (f: (x: Fake) => void) => { for (const u of [A, B]) f(h[u]!); };133const prep = (over: Partial<{ to: string; amount: bigint; memo: string; asset: string; from: string }> = {}) => a.prepare({ asset: 'native', from: ME, to: DEST, amount: TON1, ...over });134const code = async (p: Promise<unknown>) => { try { await p; } catch (e) { return (e as { code?: string }).code ?? `threw: ${(e as Error).message}`; } return 'ok'; };135const intentOf = (x: Record<string, string>, over: Partial<Intent> = {}): Intent => ({ v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: keys.keyId, network: 'ton', chainRef: n.chainRef, asset: 'native', symbol: 'TON', decimals: 9,136  amount: TON1.toString(), from: ME, to: DEST, maxFee: '5000000', createdAt: NOW, expiresAt: NOW + 120, policyVersion: 4, x, ...over });137138describe('ton adapter: prepare', () => {139  it('agrees on both sources and returns the exact intent section', async () => {140    h[B]!.mc = 5100; h[B]!.time = NOW - 30; // sources a few blocks apart: still fine141    const r = await prep({ memo: 'hello' });142    expect(r.section).toEqual({ x: { seqno: '5', validUntil: String(NOW + 300), bounce: '1', sendMode: '3', stateInit: '0' } });143    expect([r.maxFee, r.feeEstimate, r.ttlSec, r.warnings]).toEqual([5_000_000n, 392_558n, 120, []]);144    // the prepared section builds with the signer's plugin145    const intent = intentOf((r.section as { x: Record<string, string> }).x, { memo: 'hello', maxFee: r.maxFee.toString(), expiresAt: NOW + r.ttlSec });146    expect(() => pluginFor(n).build(intent, n)).not.toThrow();147    // both simulations received exactly the message that will be signed (zero signature in place of the real one)148    const est = h[A]!.posts.find((p) => p.path === '/estimateFee')!.body, emu = h[B]!.posts.find((p) => p.path === '/wallet/emulate')!.body;149    expect([est.address, est.ignore_chksig, est.init_code]).toEqual([raw(ME), true, undefined]);150    for (const t of [decodeTransfer(est.body, false), decodeTransfer(emu.boc, true)]) {151      expect(t.signature.equals(Buffer.alloc(64))).toBe(true);152      expect([t.head, t.dest, t.value, t.bounce]).toEqual([[TON_SUBWALLET_ID, NOW + 300, 5, 0, 3], raw(DEST), TON1, true]);153      expect(t.signingHash).toBe(Buffer.from(pluginFor(n).build(intent, n).signDocs[0]!).toString('hex'));154    }155    expect(tonReadExternal(emu.boc).stateInit).toBe(false);156  });157  it('chooses the bounce flag from the destination state on both sources', async () => {158    const r = await prep({ to: NEW }); // toncenter: "uninitialized", tonapi: 404 entity not found — the same thing159    expect((r.section as any).x).toMatchObject({ bounce: '0', stateInit: '0' });160    expect(r.warnings).toEqual(['destination_not_initialized']);161    each((f) => { f.accounts[raw(NEW)] = { state: 'uninit', balance: 5n, lastLt: 7 }; }); // holds coins, no contract162    expect((await prep({ to: NEW })).section).toMatchObject({ x: { bounce: '0' } });163    h[B]!.accounts[raw(NEW)] = { state: 'active', balance: 5n, lastLt: 7 };164    expect(await code(prep({ to: NEW }))).toBe('rpc_disagreement');165    h[A]!.accounts[raw(NEW)] = { state: 'active', balance: 5n, lastLt: 7 };166    expect((await prep({ to: NEW })).section).toMatchObject({ x: { bounce: '1' } });167    each((f) => { f.accounts[raw(NEW)]!.state = 'frozen'; });168    expect(await code(prep({ to: NEW }))).toBe('bad_destination');169  });170  it('first outgoing transfer: attaches the StateInit, seqno 0, no simulation possible', async () => {171    each((f) => { f.accounts[raw(ME)] = { state: 'uninit', balance: 2n * TON1, lastLt: 900 }; });172    const r = await prep({ to: NEW, memo: 'x'.repeat(50) });173    expect(r.section).toEqual({ x: { seqno: '0', validUntil: String(NOW + 300), bounce: '0', sendMode: '3', stateInit: '1' } });174    expect([r.feeEstimate, r.maxFee, r.warnings]).toEqual([2_000_000n, 6_000_000n, ['deploys_wallet', 'destination_not_initialized']]);175    expect([h[A]!.posts, h[B]!.posts]).toEqual([[], []]);176    const intent = intentOf((r.section as any).x, { to: NEW, memo: 'x'.repeat(50), maxFee: r.maxFee.toString() });177    expect(tonReadExternal(pluginFor(n).sign(intent, n, keys.handle(n)).raw).stateInit).toBe(true);178    // one source already sees the deployed contract: refuse179    h[A]!.accounts[raw(ME)] = wallet({ seqno: 1, balance: 2n * TON1, lastLt: 900 });180    expect(await code(prep())).toBe('rpc_disagreement');181  });182  it('checks the balance against amount plus fee budget, and refuses frozen or empty wallets', async () => {183    each((f) => { f.accounts[raw(ME)]!.balance = TON1 + 5_000_000n; });184    expect(await code(prep())).toBe('ok');185    each((f) => { f.accounts[raw(ME)]!.balance = TON1 + 4_999_999n; });186    expect(await code(prep())).toBe('insufficient_funds');187    expect([h[A]!.posts.length, h[B]!.posts.length]).toEqual([1, 1]); // not simulated when it cannot be paid188    each((f) => { f.accounts[raw(ME)]!.balance = 60n * TON1; f.fees.gas_fee = 3_000_000; }); // 3 × estimate above the floor189    expect((await prep()).maxFee).toBe(3n * (105334n + 23n + 3_000_000n + 66667n));190    each((f) => { f.fees.gas_fee = 20_000_000; });191    expect((await prep()).maxFee).toBe(50_000_000n); // capped192    each((f) => { f.fees.gas_fee = 50_000_000; });193    expect(await code(prep())).toBe('fee_too_high');194    each((f) => { delete f.accounts[raw(ME)]; });195    expect(await code(prep())).toBe('insufficient_funds'); // nothing at this address yet196    each((f) => { f.accounts[raw(ME)] = wallet({ state: 'frozen' }); });197    expect(await code(prep())).toBe('unsupported_account');198  });199  it('fails closed when a source is down, on another network, answers oddly or disagrees', async () => {200    h[B]!.down = true; expect(await code(prep())).toBe('rpc_degraded');201    h[B] = { ...fresh(), zero: '823f81f306ff02694f935cf5021548e3ce2b86b529812af6a12148879e95a128' }; a = make(); expect(await code(prep())).toBe('rpc_degraded'); // testnet202    h[B] = fresh(); h[A] = { ...fresh(), zero: '823f81f306ff02694f935cf5021548e3ce2b86b529812af6a12148879e95a128' }; a = make(); expect(await code(prep())).toBe('rpc_degraded');203    h[A] = fresh(); a = make();204    const bad: [(f: Fake) => void, string][] = [205      [(f) => { f.accounts[raw(ME)]!.seqno = 6; }, 'rpc_disagreement'],206      [(f) => { f.accounts[raw(ME)]!.balance -= 1n; }, 'rpc_disagreement'],207      [(f) => { f.accounts[raw(ME)]!.lastLt = 1001; }, 'rpc_disagreement'],208      [(f) => { f.mc = 5151; }, 'rpc_disagreement'],209      [(f) => { f.time = NOW - 61; }, 'rpc_degraded'], // stale source or wrong local clock210      [(f) => { f.accounts[raw(ME)]!.walletId = 1; }, 'rpc_degraded'], // another subwallet: not this wallet contract211      [(f) => { f.accounts[raw(ME)]!.pub = new Uint8Array(32).fill(3); }, 'rpc_degraded'],212      [(f) => { f.accounts[raw(ME)]!.seqno = -1 as never; }, 'rpc_degraded'],213    ];214    for (const u of [A, B]) for (const [mutate, want] of bad) {215      h[A] = fresh(); h[B] = fresh(); mutate(h[u]!);216      // a wrong public key in the data only shows on the source that returns the data (TonAPI); TON Center reports the wallet type217      const expected = u === A && (mutate === bad[6]![0]) ? 'ok' : u === A && mutate === bad[7]![0] ? 'rpc_degraded' : want;218      expect(await code(prep()), `${u} ${bad.findIndex((b) => b[0] === mutate)}`).toBe(expected);219    }220    h[A] = fresh(); h[B] = fresh(); h[A]!.accounts[raw(ME)]!.walletType = 'wallet v3 r2'; expect(await code(prep())).toBe('rpc_degraded');221    h[A] = fresh(); h[B]!.accounts[raw(ME)]!.code = beginCell().storeUint(1, 8).endCell(); expect(await code(prep())).toBe('rpc_degraded'); // not the v4R2 code222    h[B] = fresh(); each((f) => { f.time = NOW + 61; }); expect(await code(prep())).toBe('rpc_degraded');223    h[A] = fresh(); h[B] = fresh(); expect(await code(prep())).toBe('ok');224  });225  it('re-verifies the chain identity when it is older than two minutes', async () => {226    expect(await code(prep())).toBe('ok');227    const calls = () => h[A]!.seen.filter((p) => p === '/getMasterchainInfo').length;228    expect(calls()).toBe(1);229    expect(await code(prep())).toBe('ok');230    expect(calls()).toBe(1);231    vi.setSystemTime((NOW + 121) * 1000); each((f) => { f.time = NOW + 120; });232    h[A]!.zero = 'ab'.repeat(32);233    expect(await code(prep())).toBe('rpc_degraded');234    expect(calls()).toBe(2);235  });236  it('simulation must show exactly this transfer leaving the wallet', async () => {237    h[A]!.fees.fwd_fee = 0; expect(await code(prep())).toBe('simulation_failed'); // estimateFee: no message was emitted238    h[A] = fresh(); h[A]!.fees.gas_fee = 0; expect(await code(prep())).toBe('simulation_failed'); // not even accepted239    h[A] = fresh(); h[B]!.emu = { status: 406, error: 'emulator: external message not accepted: exit code 33' };240    await expect(prep()).rejects.toThrow(/exit code 33/);241    expect(await code(prep())).toBe('simulation_failed');242    h[B]!.emu = { skipped: true }; expect(await code(prep())).toBe('simulation_failed'); // action skipped (send mode 3 hides it on-chain)243    h[B]!.emu = { noChild: true }; expect(await code(prep())).toBe('simulation_failed');244    h[B]!.emu = { value: 999 }; expect(await code(prep())).toBe('simulation_failed');245    h[B]!.emu = { childAborted: true }; expect(await code(prep())).toBe('simulation_failed'); // bounceable transfer the contract would return246    h[B]!.emu = { childAborted: true }; expect(await code(prep({ to: NEW }))).toBe('ok'); // nothing to bounce from: coins stay247    h[B]!.emu = { status: 500, error: 'internal' }; expect(await code(prep())).toBe('rpc_degraded');248    h[B]!.emu = {}; h[A]!.fees.gas_fee = 'x' as never; expect(await code(prep())).toBe('rpc_degraded');249  });250  it('waits and retries when a keyless source answers 429', async () => {251    h[A]!.busy = 1;252    expect(await code(prep())).toBe('ok');253    h[A]!.busy = 99;254    expect(await code(prep({ to: THIRD }))).toBe('rpc_degraded');255  }, 20_000);256  it('refuses jettons, bad or non-canonical addresses, bad amounts and bad comments', async () => {257    expect(await code(prep({ asset: 'EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs' }))).toBe('unsupported_asset');258    expect(await code(prep({ to: ME }))).toBe('bad_address');259    expect(await code(prep({ to: eq(DEST) }))).toBe('bad_address'); // must already be normalized260    expect(await code(prep({ to: raw(DEST) }))).toBe('bad_address');261    expect(await code(prep({ to: 'nope' }))).toBe('bad_address');262    expect(await code(prep({ amount: 0n }))).toBe('bad_amount');263    expect(await code(prep({ memo: '' }))).toBe('bad_memo');264    expect(await code(prep({ memo: 'a\ud800' }))).toBe('bad_memo');265    expect([h[A]!.seen, h[B]!.seen]).toEqual([[], []]); // refused before any request266  });267});268269describe('ton adapter: track and broadcast', () => {270  const intent = intentOf({ seqno: '5', validUntil: String(NOW + 300), bounce: '1', sendMode: '3', stateInit: '0' });271  const signed = pluginFor(n).sign(intent, n, keys.handle(n));272  const body = Buffer.from(tonSerializeBoc(tonReadExternal(signed.raw).body)).toString('base64');273  const included = (over: Partial<Tx> = {}): Tx => ({ lt: 1100, hash: 'cd'.repeat(32), utime: NOW + 5, account: raw(ME), ext: { body, norm: signed.txid }, outs: [{ to: raw(DEST), value: TON1 }], ...over });274  const track = () => a.track({ txid: signed.txid, intent, blockRef: null, broadcastAt: NOW });275276  it('finalized only when the masterchain-anchored source shows it and the indexer agrees', async () => {277    expect(await track()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: true }); // a returned hash is never success278    h[B]!.txs = [included()];279    expect(await track()).toEqual({ state: 'confirming', confirmations: 0, blockRef: '1100' }); // the indexer alone is not enough280    h[A]!.txs = [included()];281    expect(await track()).toEqual({ state: 'finalized', confirmations: 1, blockRef: '1100' });282    expect(h[A]!.seen.at(-1)).toContain(`limit=1&lt=1100&hash=${'cd'.repeat(32)}`); // asked for that exact transaction283    h[B]!.txs = [];284    expect((await track()).state).toBe('confirming'); // the reachable second source has not seen it285    h[B]!.down = true;286    expect((await track()).state).toBe('finalized'); // found by scanning the wallet's latest transactions287    expect(h[A]!.seen.at(-1)).toContain('limit=30');288    h[B] = fresh(); h[B]!.txs = [included({ lt: 1101 })];289    expect((await track()).state).toBe('confirming'); // TON Center cannot locate the transaction the indexer describes290    h[B]!.txs = [included({ outs: [] })];291    await expect(track()).rejects.toThrow(/disagree/);292    h[B]!.txs = [included({ account: raw(DEST) })];293    await expect(track()).rejects.toThrow(/another transaction/);294  });295  it('the wallet accepted the message but did not send the transfer: failed, on both sources only', async () => {296    // a signed message can only do what it says or nothing: any other reported effect means a source is wrong — no verdict297    for (const outs of [[{ to: raw(THIRD), value: TON1 }], [{ to: raw(DEST), value: TON1 - 1n }], [{ to: raw(DEST), value: TON1 }, { to: raw(THIRD), value: 1n }]]) {298      each((f) => { f.txs = [included({ outs })]; });299      await expect(track()).rejects.toThrow(/does not match/);300    }301    each((f) => { f.txs = [included({ outs: [] })]; });302    expect(await track()).toMatchObject({ state: 'failed', blockRef: '1100', error: expect.stringMatching(/not carried out/) });303    h[B]!.down = true;304    expect((await track()).state).toBe('confirming');305    h[B] = fresh(); h[B]!.txs = [included({ success: false, aborted: true })]; h[A]!.txs = [included()];306    await expect(track()).rejects.toThrow(/disagree/);307    h[B]!.txs = [included()]; h[A]!.txs = [included({ outs: [] })];308    await expect(track()).rejects.toThrow(/disagree/);309  });310  it('ignores other messages of the wallet and unreadable ones', async () => {311    const other = pluginFor(n).sign({ ...intent, amount: '5' }, n, keys.handle(n));312    const otherBody = Buffer.from(tonSerializeBoc(tonReadExternal(other.raw).body)).toString('base64');313    h[A]!.txs = [included({ lt: 1200, hash: 'ee'.repeat(32), ext: { body: otherBody, norm: other.txid } }), included({ lt: 1150, hash: 'ef'.repeat(32), ext: { body: 'AAAA', norm: 'x' } }),314      { lt: 1120, hash: 'aa'.repeat(32), utime: NOW, account: raw(ME), from: raw(DEST), value: 5n, outs: [] }];315    h[B]!.down = true;316    expect((await track()).state).toBe('broadcast');317    h[A]!.txs.push(included());318    expect(await track()).toMatchObject({ state: 'finalized', blockRef: '1100' });319  });320  it('expiry: failed only when both sources are past valid_until and the wallet seqno was never used', async () => {321    const validUntil = NOW + 300;322    vi.setSystemTime((validUntil + 60) * 1000); each((f) => { f.time = validUntil + 61; });323    expect(await track()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: false }); // local clock exactly at the margin: not yet324    vi.setSystemTime((validUntil + 61) * 1000);325    expect(await track()).toEqual({ state: 'failed', confirmations: 0, error: 'not included before valid_until; no funds moved' });326    expect(h[A]!.seen.slice(-2)).toEqual([`/getAddressInformation?address=${raw(ME)}`, '/getBlockHeader?workchain=-1&shard=-9223372036854775808&seqno=5000']);327    h[A]!.time = validUntil + 60;328    expect((await track()).state).toBe('broadcast'); // the block the state was read at is not late enough329    h[A]!.time = validUntil + 61; h[B]!.time = validUntil + 60;330    expect((await track()).state).toBe('broadcast');331    h[B]!.time = validUntil + 61; h[B]!.accounts[raw(ME)]!.seqno = 6;332    expect((await track()).state).toBe('broadcast'); // the seqno was consumed: the message may have been included — never "failed"333    each((f) => { f.accounts[raw(ME)]!.seqno = 6; });334    expect(await track()).toEqual({ state: 'broadcast', confirmations: 0, rebroadcast: false });335    each((f) => { f.accounts[raw(ME)]!.seqno = 5; }); h[B]!.down = true;336    expect((await track()).state).toBe('broadcast'); // never "failed" on a single source337    h[B] = fresh(); h[B]!.time = validUntil + 61; h[B]!.txs = [included()];338    expect((await track()).state).toBe('confirming'); // included after all339    // a wallet that was never deployed still has seqno 0340    each((f) => { f.txs = []; f.accounts[raw(ME)] = { state: 'uninit', balance: TON1, lastLt: 1 }; });341    const first = intentOf({ ...intent.x!, seqno: '0', stateInit: '1' });342    expect((await a.track({ txid: 'ab'.repeat(32), intent: first, blockRef: null, broadcastAt: NOW })).state).toBe('failed');343    await expect(a.track({ txid: signed.txid, intent: { evm: {} }, blockRef: null, broadcastAt: NOW })).rejects.toThrow(/valid_until/);344    await expect(a.track({ txid: 'zz', intent, blockRef: null, broadcastAt: NOW })).rejects.toThrow();345  });346347  it('broadcast: sends the exact bag of cells to every source', async () => {348    await a.broadcast(signed.raw);349    expect([h[A]!.posts, h[B]!.posts]).toEqual([[{ path: '/sendBoc', body: { boc: signed.raw } }], [{ path: '/blockchain/message', body: { boc: signed.raw } }]]);350    h[A]!.down = true;351    await a.broadcast(signed.raw);352    h[A] = fresh(); h[B]!.send = () => ({ status: 406, body: { error: 'rejected' } });353    await a.broadcast(signed.raw); // one acceptance is enough354  });355  it('broadcast: a refusal is an error unless the chain already contains the message', async () => {356    each((f) => { f.send = () => ({ status: 500, body: { ok: false, error: 'LITE_SERVER_UNKNOWN: cannot apply external message to current state : External message was not accepted', code: 500 } }); });357    h[B]!.send = () => ({ status: 406, body: { error: 'external message not accepted: exit code 33' } });358    await expect(a.broadcast(signed.raw)).rejects.toThrow(/not accepted/);359    h[B]!.txs = [included()];360    await a.broadcast(signed.raw); // already known (indexer)361    h[B]!.txs = []; h[A]!.txs = [included()];362    await a.broadcast(signed.raw); // already known (wallet history)363    await expect(a.broadcast('AAAA')).rejects.toThrow();364    await expect(a.broadcast(Buffer.from(beginCell().storeUint(0, 8).endCell().toBoc()).toString('base64'))).rejects.toThrow(/external message/);365  });366});367368describe('ton adapter: balances, deposits, health', () => {369  const tx = (lt: number, o: Partial<Tx>): Tx => ({ lt, hash: lt.toString(16).padStart(64, '0'), utime: NOW - 100, account: raw(ME), outs: [], ...o });370  const history = (): Tx[] => [371    tx(2000, { from: raw(DEST), value: 1_500_000_000n }),372    tx(2100, { from: raw(DEST), value: 0n }), // no value373    tx(2200, { from: raw(DEST), value: 9n * TON1, outs: [{ to: raw(DEST), value: 9n * TON1 - 1000n }], aborted: true }), // bounced back: the wallet kept nothing374    tx(2300, { ext: { body: 'AAAA', norm: 'x' }, outs: [{ to: raw(DEST), value: 5n }] }), // our own outgoing transfer375    tx(2400, { from: raw(THIRD), value: 7n * TON1 }),376    tx(2450, { account: raw(DEST), from: raw(ME), value: 5n }), // someone else's account377  ];378  it('lists incoming Toncoin kept by the wallet, from the anchored source', async () => {379    each((f) => { f.txs = history(); });380    const first = await a.deposits(ME, null);381    expect(first.items.map((d) => [d.block, d.idx, d.amount, d.from, d.asset, d.txid, d.time])).toEqual([382      [2400, 0, 7n * TON1, THIRD, 'native', (2400).toString(16).padStart(64, '0'), NOW - 100], [2000, 0, 1_500_000_000n, DEST, 'native', (2000).toString(16).padStart(64, '0'), NOW - 100]]);383    expect([first.cursor, first.finalizedHeight]).toEqual([2400, 2400]);384    const next = await a.deposits(ME, 2000);385    expect([next.items.map((d) => d.block), next.cursor]).toEqual([[2400], 2400]);386    expect(h[A]!.seen.at(-1)).toContain('to_lt=2000');387    expect(await a.deposits(ME, 2400)).toEqual({ items: [], cursor: 2400, finalizedHeight: 2400 });388    expect(await a.deposits(NEW, null)).toEqual({ items: [], cursor: 0, finalizedHeight: 0 });389  });390  it('pages through a long history and falls back to the indexer (which leaves the last seconds for later)', async () => {391    const many = Array.from({ length: 120 }, (_, k) => tx(3000 + k, { from: raw(DEST), value: BigInt(k + 1) }));392    each((f) => { f.txs = many; });393    const r = await a.deposits(ME, 2999);394    expect([r.items.length, new Set(r.items.map((d) => d.block)).size, r.cursor]).toEqual([120, 120, 3119]);395    expect(h[A]!.seen.filter((p) => p.startsWith('/getTransactions')).length).toBe(3);396    h[A]!.down = true; h[B]!.txs = [...history(), tx(2500, { from: raw(DEST), value: 3n, utime: NOW - 5 })];397    const viaIndexer = await a.deposits(ME, 2000);398    expect([viaIndexer.items.map((d) => [d.block, d.amount, d.from]), viaIndexer.cursor, viaIndexer.finalizedHeight]).toEqual([[[2400, 7n * TON1, THIRD]], 2400, 2400]);399    expect((await a.deposits(ME, null)).items.map((d) => d.block)).toEqual([2000, 2400]);400    expect(await a.deposits(NEW, 77)).toEqual({ items: [], cursor: 77, finalizedHeight: 77 }); // "entity not found": no history401  });402  it('balances and health', async () => {403    expect(await a.balances(ME)).toEqual({ native: 10n * TON1 });404    expect(await a.balances(NEW)).toEqual({ native: 0n });405    h[A]!.down = true;406    expect(await a.balances(ME)).toEqual({ native: 10n * TON1 });407    expect(await a.balances(NEW)).toEqual({ native: 0n });408    h[A] = fresh();409    expect(await a.health()).toMatchObject({ network: 'ton', ok: true, readable: true, identityVerified: true, height: 5000, endpoints: [{ url: 'toncenter.com', ok: true }, { url: 'tonapi.io', ok: true }] });410    h[B]!.zero = '823f81f306ff02694f935cf5021548e3ce2b86b529812af6a12148879e95a128';411    expect((await a.health()).endpoints.map((e) => e.ok)).toEqual([true, false]);412    expect(await code(prep())).toBe('rpc_degraded'); // a failed identity check is not cached as good413    h[B] = fresh(); h[B]!.time = NOW - 121;414    expect((await a.health()).ok).toBe(false);415    h[B] = fresh(); h[B]!.mc = 5151;416    expect(await a.health()).toMatchObject({ ok: false, readable: true });417    h[A]!.down = true; h[B]!.down = true;418    expect(await a.health()).toMatchObject({ ok: false, readable: false });419  });420});421
Files main
Menu

Theme