Repository Public
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<=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