Repository Public
1// TON adapter. Two independent keyless sources with different software and operators:2// n.rpc[0] TON Center HTTP API v2 (tonlib over liteservers: every answer is anchored to a masterchain block)3// n.rpc[1] TonAPI v2 (Tonkeeper's indexer)4// Everything that feeds the signed message (wallet state, sequence number, destination state for the bounce flag) is read from5// both and must agree, or the transfer is refused. Both are rate-limited to about one request per second without a key, so6// requests to one source are queued and a 429 is retried after a pause.7//8// Fees: a TON message carries no fee field; the network charges the wallet from its own price table. prepare() estimates it with9// TON Center's estimateFee, simulates the whole transfer on TonAPI, and budgets maxFee with a margin that the balance must cover.10// The first outgoing transfer deploys the wallet contract: the API does not hold the public key, so that single case cannot be11// simulated and uses a fixed, conservative estimate.12import {13 formatUnits, TON_MAINNET_ZEROSTATE, TON_SEND_MODE, TON_SUBWALLET_ID, TON_V4R2_CODE_HASH, tonAddressOf, tonCommentBytes, tonExternalMessage, tonNormalizeAddress,14 tonNormalizedHash, tonParseAddress, tonParseBoc, tonRawAddress, tonReadExternal, tonSerializeBoc, tonSignedBody, tonSigningMessage, tonWalletData, toHex, type NetworkDef, type TonCell,15} from '@spw/core';16import { UserError } from '../config.ts';17import { hostOf, http, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';1819/** approval window; the message itself stays valid longer so it can still be included after a late approval */20const TTL_SEC = 120, VALIDITY_SEC = 300;21/** fee budget in nanotons: three times the estimate, never below 0.005 TON, never above 0.05 TON */22const FEE_FLOOR = 5_000_000n, FEE_CAP = 50_000_000n;23/** measured 2026-11-10: about 0.0004 TON for a transfer, about 0.001 TON when the wallet contract is deployed with it */24const DEPLOY_FEE_ESTIMATE = 1_500_000n;25/** masterchain blocks are about half a second apart: sources may differ by a minute; clocks by a minute */26const MAX_MC_GAP = 150, MAX_CLOCK_SKEW_SEC = 60, STALE_SEC = 120;27/** how long a verified chain identity is trusted before it is checked again */28const IDENTITY_TTL_MS = 120_000;29/** a "not included" verdict needs chain time this far past valid_until on every source */30const EXPIRY_MARGIN_SEC = 60;31const RETRY_429_MS = 1100, RETRIES_429 = 3;32const MC_SHARD = '-9223372036854775808';3334type State = 'active' | 'uninit' | 'frozen';35interface TcTxId { lt: string; hash: string }36interface TcMsg { hash?: string; source: string; destination: string; value: string; msg_data?: { '@type'?: string; body?: string } }37interface TcTx { utime: number; transaction_id: TcTxId; in_msg?: TcMsg; out_msgs: TcMsg[] }38interface TaAccount { balance: number; status: string; last_transaction_lt?: number; code?: string; data?: string }39interface TaMsg { msg_type: string; value: number; bounce?: boolean; source?: { address: string }; destination?: { address: string } }40interface TaTx {41 hash: string; lt: number; utime: number; success: boolean; aborted: boolean; account: { address: string }; in_msg?: TaMsg; out_msgs: TaMsg[];42 compute_phase?: { success?: boolean }; action_phase?: { success: boolean; total_actions: number; skipped_actions: number };43}44interface TaTrace { transaction: TaTx; children?: TaTrace[] }45/** where a transfer was found and what it did: the approved transfer, nothing (action skipped), or something else (a source is wrong) */46interface Found { lt: string; hash: string; outcome: 'sent' | 'nothing' | 'mismatch' }4748const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));49const nat = (v: unknown, what: string): bigint => {50 if (typeof v === 'number' ? !Number.isSafeInteger(v) || v < 0 : typeof v !== 'string' || !/^\d{1,30}$/.test(v)) throw new Error(`ton: bad ${what}`);51 return BigInt(v as string | number);52};53const int = (v: unknown, what: string): number => { const b = nat(v, what); if (b > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`ton: bad ${what}`); return Number(b); };54const hex64 = (v: unknown, what: string): string => { if (typeof v !== 'string' || !/^[0-9a-fA-F]{64}$/.test(v)) throw new Error(`ton: bad ${what}`); return v.toLowerCase(); };55const b64ToHex = (v: unknown, what: string): string => { const b = typeof v === 'string' ? Buffer.from(v, 'base64') : null; if (!b || b.length !== 32) throw new Error(`ton: bad ${what}`); return b.toString('hex'); };56const bocOf = (c: TonCell) => Buffer.from(tonSerializeBoc(c)).toString('base64');57const idHex = (addr: string) => toHex(tonParseAddress(addr));58const stateOf = (s: unknown): State => {59 if (s === 'active') return 'active';60 if (s === 'uninitialized' || s === 'uninit' || s === 'nonexist') return 'uninit'; // no contract at this address (it may still hold coins)61 if (s === 'frozen') return 'frozen';62 throw new Error('ton: bad account state');63};6465export function tonAdapter(n: NetworkDef): Adapter {66 const [TC, TA] = n.rpc as [string, string];67 if (n.rpc.length !== 2 || n.chainRef !== TON_MAINNET_ZEROSTATE) throw new Error('TON adapter needs exactly two sources (TON Center v2, TonAPI v2) on the main network');68 const sources = [TC, TA];6970 // one request at a time per source; a 429 pauses that source's queue and retries71 const queues = new Map<string, Promise<unknown>>();72 const request = (base: string, path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> => {73 const run = async () => {74 for (let attempt = 0; ; attempt++) {75 const r = await http(base + path, { ...init, headers: { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) } });76 if (r.status === 429 && attempt < RETRIES_429) { await r.arrayBuffer().catch(() => {}); await sleep(RETRY_429_MS); continue; }77 return { status: r.status, body: await r.json().catch(() => null) };78 }79 };80 const p = (queues.get(base) ?? Promise.resolve()).then(run, run);81 queues.set(base, p.catch(() => {}));82 return p;83 };84 /** TON Center: { ok: true, result } or an error */85 const tc = async <T>(path: string, post?: unknown): Promise<T> => {86 const { status, body } = await request(TC, path, post === undefined ? {} : { method: 'POST', body: JSON.stringify(post) });87 if (status !== 200 || body?.ok !== true || body.result === undefined) throw new Error(`toncenter ${status}${typeof body?.error === 'string' ? ': ' + body.error.slice(0, 160) : ''}`);88 return body.result as T;89 };90 /** TonAPI: the JSON document; `null` only for its own "not found" answer when `missing` is allowed */91 const ta = async <T>(path: string, opts: { post?: unknown; missing?: boolean } = {}): Promise<T | null> => {92 const { status, body } = await request(TA, path, opts.post === undefined ? {} : { method: 'POST', body: JSON.stringify(opts.post) });93 if (status === 200) return (body !== null && typeof body === 'object' ? body : {}) as T; // callers validate every field they use94 if (opts.missing && status === 404 && typeof body?.error === 'string' && /not found/i.test(body.error)) return null;95 throw Object.assign(new Error(`tonapi ${status}${typeof body?.error === 'string' ? ': ' + body.error.slice(0, 160) : ''}`), { status, detail: typeof body?.error === 'string' ? body.error : '' });96 };97 const taMust = async <T>(path: string): Promise<T> => (await ta<T>(path))!;98 /** TonAPI answers "entity not found" for an address that never appeared on-chain: that is an empty, non-existent account */99 const accountTa = async (raw: string): Promise<TaAccount> => (await ta<TaAccount>(`/blockchain/accounts/${raw}`, { missing: true })) ?? { balance: 0, status: 'nonexist', last_transaction_lt: 0 };100101 const any = async <T>(f: (u: string) => Promise<T>): Promise<T> => { let last: unknown; for (const u of sources) { try { return await f(u); } catch (e) { last = e; } } throw last; };102 /** every source must answer */103 const both = async <A, B>(fa: () => Promise<A>, fb: () => Promise<B>, what: string): Promise<[A, B]> => {104 const [a, b] = await Promise.allSettled([fa(), fb()]);105 for (const r of [a, b]) if (r.status === 'rejected' && r.reason instanceof UserError) throw r.reason;106 if (a.status === 'rejected' || b.status === 'rejected') throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);107 return [a.value, b.value];108 };109 const agree = <T>(a: T, b: T, what: string): T => {110 if (JSON.stringify(a) !== JSON.stringify(b)) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (${what}); transfer refused`, 503);111 return a;112 };113114 // ---- chain identity: masterchain zero-state root hash, and the current masterchain head ----115 const verifiedAt = new Map<string, number>();116 const headOf = async (u: string): Promise<{ seqno: number; time: number | null }> => {117 if (u === TC) {118 const r = await tc<{ last: { seqno: number }; init: { root_hash: string } }>('/getMasterchainInfo');119 if (b64ToHex(r.init?.root_hash, 'zero state') !== n.chainRef) throw new Error('zero-state hash mismatch');120 verifiedAt.set(u, Date.now());121 return { seqno: int(r.last?.seqno, 'seqno'), time: null };122 }123 const r = await taMust<{ init: { root_hash: string } }>('/liteserver/get_masterchain_info');124 if (hex64(r.init?.root_hash, 'zero state') !== n.chainRef) throw new Error('zero-state hash mismatch');125 const h = await taMust<{ seqno: number; gen_utime: number }>('/blockchain/masterchain-head');126 verifiedAt.set(u, Date.now());127 return { seqno: int(h.seqno, 'seqno'), time: int(h.gen_utime, 'time') };128 };129 const identityFresh = (u: string) => Date.now() - (verifiedAt.get(u) ?? -Infinity) < IDENTITY_TTL_MS;130 const ensureIdentity = async () => {131 const rs = await Promise.allSettled(sources.map(async (u) => { if (!identityFresh(u)) await headOf(u); }));132 if (rs.some((r) => r.status === 'rejected')) throw new UserError('rpc_degraded', `${n.name} data source unavailable or on another network (chain identity); transfer refused`, 503);133 };134135 // ---- the wallet account, as each source sees it ----136 interface Wallet { state: State; balance: string; lastLt: string; seqno: number }137 const walletTc = async (addr: string): Promise<Wallet & { mcSeqno?: number }> => {138 const w = await tc<{ wallet: boolean; balance: string; account_state: string; wallet_type?: string; seqno?: number; wallet_id?: number; last_transaction_id: TcTxId }>(`/getWalletInformation?address=${tonRawAddress(addr)}`);139 const state = stateOf(w.account_state), base = { state, balance: nat(w.balance, 'balance').toString(), lastLt: nat(w.last_transaction_id?.lt, 'lt').toString() };140 if (state !== 'active') return { ...base, seqno: 0 };141 if (w.wallet !== true || w.wallet_type !== 'wallet v4 r2' || w.wallet_id !== TON_SUBWALLET_ID) throw new Error('toncenter: the account is not this wallet contract');142 return { ...base, seqno: int(w.seqno, 'seqno') };143 };144 /** decode and verify the on-chain code and data of an active wallet: v4R2 code, default subwallet, the key behind this address */145 const walletSeqno = (addr: string, code: Uint8Array, data: Uint8Array): number => {146 if (toHex(tonParseBoc(code).hash) !== TON_V4R2_CODE_HASH) throw new Error('ton: the account does not run the wallet v4R2 code');147 const d = tonWalletData(tonParseBoc(data));148 if (d.subwalletId !== TON_SUBWALLET_ID || tonAddressOf(d.publicKey) !== addr) throw new Error('ton: the account is not this wallet contract');149 return d.seqno;150 };151 const walletTa = async (addr: string): Promise<Wallet> => {152 const a = await accountTa(tonRawAddress(addr));153 const state = stateOf(a.status), base = { state, balance: nat(a.balance, 'balance').toString(), lastLt: nat(a.last_transaction_lt ?? 0, 'lt').toString() };154 if (state !== 'active') return { ...base, seqno: 0 };155 if (typeof a.code !== 'string' || typeof a.data !== 'string' || !/^([0-9a-f]{2})+$/i.test(a.code) || !/^([0-9a-f]{2})+$/i.test(a.data)) throw new Error('tonapi: bad account code or data');156 return { ...base, seqno: walletSeqno(addr, Buffer.from(a.code, 'hex'), Buffer.from(a.data, 'hex')) };157 };158159 // ---- finding a transfer ----160 const sameTransfer = (dest: string | undefined, value: unknown, intent: any): boolean => {161 try { return dest !== undefined && idHex(dest) === idHex(intent.to) && nat(value, 'value') === BigInt(intent.amount); } catch { return false; }162 };163 /** TON Center transaction → Found when its inbound external message is the one with this normalized hash */164 const matchTc = (t: TcTx, txid: string, intent: any): Found | null => {165 const m = t.in_msg;166 if (!m || m.source !== '' || typeof m.msg_data?.body !== 'string') return null;167 let hash: string;168 try { hash = toHex(tonNormalizedHash(tonParseAddress(intent.from), tonParseBoc(Buffer.from(m.msg_data.body, 'base64')))); } catch { return null; }169 if (hash !== txid) return null;170 const outs = Array.isArray(t.out_msgs) ? t.out_msgs : [];171 const outcome = outs.length === 0 ? 'nothing' : outs.length === 1 && sameTransfer(outs[0]!.destination, outs[0]!.value, intent) ? 'sent' : 'mismatch';172 return { lt: nat(t.transaction_id.lt, 'lt').toString(), hash: b64ToHex(t.transaction_id.hash, 'transaction hash'), outcome };173 };174 const findTa = async (txid: string, intent: any): Promise<Found | null> => {175 const t = await ta<TaTx>(`/blockchain/messages/${txid}/transaction`, { missing: true });176 if (t === null) return null;177 if (t.in_msg?.msg_type !== 'ext_in_msg' || typeof t.account?.address !== 'string' || t.account.address.toLowerCase() !== tonRawAddress(intent.from)) throw new Error('tonapi: answered with another transaction');178 const outs = Array.isArray(t.out_msgs) ? t.out_msgs : [];179 const outcome = outs.length === 0 ? 'nothing' : t.success === true && t.aborted === false && outs.length === 1 && sameTransfer(outs[0]!.destination?.address, outs[0]!.value, intent) ? 'sent' : 'mismatch';180 return { lt: nat(t.lt, 'lt').toString(), hash: hex64(t.hash, 'transaction hash'), outcome };181 };182 /** TON Center: the exact transaction when its position is known, otherwise a scan of the wallet's latest transactions */183 const findTc = async (txid: string, intent: any, hint: Found | null): Promise<Found | null> => {184 const q = `/getTransactions?address=${tonRawAddress(intent.from)}`;185 const list = await tc<TcTx[]>(hint ? `${q}&limit=1<=${hint.lt}&hash=${hint.hash}` : `${q}&limit=30`);186 if (!Array.isArray(list)) throw new Error('toncenter: bad transaction list');187 for (const t of list) { const f = matchTc(t, txid, intent); if (f) return f; }188 return null;189 };190191 let identity = false;192 return {193 async health(): Promise<Health> {194 const endpoints = await Promise.all(sources.map(async (url) => {195 const t = Date.now();196 try {197 const h = await headOf(url); // verifies the zero-state hash on every check198 if (h.time !== null && Date.now() / 1000 - h.time > STALE_SEC) throw new Error('stale');199 return { url: hostOf(url), ok: true, ms: Date.now() - t, h: h.seqno as number | null };200 } catch { verifiedAt.delete(url); return { url: hostOf(url), ok: false, ms: null, h: null as number | null }; }201 }));202 const heads = endpoints.map((e) => e.h ?? 0);203 const ok = endpoints.every((e) => e.ok) && Math.max(...heads) - Math.min(...heads) <= MAX_MC_GAP;204 if (ok) identity = true;205 return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: Math.max(0, ...heads) || null,206 endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };207 },208209 async balances(addr) {210 const raw = tonRawAddress(addr);211 return { native: await any(async (u) => (u === TC ? nat(await tc<string>(`/getAddressBalance?address=${raw}`), 'balance') : nat((await accountTa(raw)).balance, 'balance'))) };212 },213214 async prepare(d): Promise<Prepared> {215 if (d.asset !== 'native') throw new UserError('unsupported_asset', 'only Toncoin can be sent on TON in this version');216 let to: Uint8Array;217 try { to = tonParseAddress(d.to); if (tonNormalizeAddress(d.from) !== d.from || tonNormalizeAddress(d.to) !== d.to) throw new Error('address is not in canonical form'); }218 catch (e) { throw new UserError('bad_address', (e as Error).message); }219 if (d.from === d.to) throw new UserError('bad_address', 'destination is this wallet');220 if (d.amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');221 let comment: Uint8Array | undefined;222 try { comment = d.memo === undefined ? undefined : tonCommentBytes(d.memo); } catch (e) { throw new UserError('bad_memo', (e as Error).message); }223 const warnings: string[] = [];224 await ensureIdentity();225226 // one snapshot per source; both must answer and agree on everything that ends up in the signed message227 const toRaw = tonRawAddress(d.to);228 const [a, b] = await both(229 async () => {230 const src = await walletTc(d.from);231 const dst = await tc<{ state: string; sync_utime: number; block_id: { seqno: number } }>(`/getAddressInformation?address=${toRaw}`);232 return { src, dst: stateOf(dst.state), time: int(dst.sync_utime, 'time'), mc: int(dst.block_id?.seqno, 'seqno') };233 },234 async () => {235 const head = await taMust<{ seqno: number; gen_utime: number }>('/blockchain/masterchain-head');236 const src = await walletTa(d.from);237 const dst = await accountTa(toRaw);238 return { src, dst: stateOf(dst.status), time: int(head.gen_utime, 'time'), mc: int(head.seqno, 'seqno') };239 }, 'account state');240241 const now = Math.floor(Date.now() / 1000);242 if (Math.abs(a.mc - b.mc) > MAX_MC_GAP) throw new UserError('rpc_disagreement', `${n.name} data sources are not at the same block; transfer refused`, 503);243 for (const s of [a, b]) if (Math.abs(now - s.time) > MAX_CLOCK_SKEW_SEC) throw new UserError('rpc_degraded', `${n.name} chain time and server clock differ; transfer refused`, 503);244 const src = agree(a.src, b.src, 'wallet state');245 const dst = agree(a.dst, b.dst, 'destination account');246247 if (src.state === 'frozen') throw new UserError('unsupported_account', 'this TON wallet is frozen (unpaid storage fees); it cannot send');248 if (dst === 'frozen') throw new UserError('bad_destination', 'the destination account is frozen; a transfer to it would be lost or returned');249 const balance = BigInt(src.balance);250 if (balance === 0n) throw new UserError('insufficient_funds', `this ${n.name} wallet holds no ${n.native.symbol}`);251 if (balance < d.amount + FEE_FLOOR) throw new UserError('insufficient_funds', `insufficient ${n.native.symbol}: ${formatUnits(balance, 9)} available for amount plus network fee`);252 const stateInit = src.state !== 'active'; // no contract yet: this message must deploy it253 const bounce = dst === 'active'; // nothing at the destination to refuse the coins: they must not be bounced back254 if (stateInit) warnings.push('deploys_wallet');255 if (!bounce) warnings.push('destination_not_initialized');256257 const validUntil = now + VALIDITY_SEC;258 let feeEstimate: bigint;259 if (stateInit) feeEstimate = DEPLOY_FEE_ESTIMATE + 10_000n * BigInt(comment?.length ?? 0);260 else {261 // same message as the one that will be signed, with an all-zero signature262 const body = tonSignedBody(tonSigningMessage({ to, amount: d.amount, comment, seqno: src.seqno, validUntil, bounce }), new Uint8Array(64));263 const from = tonParseAddress(d.from);264 const [fees, sim] = await both(265 async () => {266 const f = (await tc<{ source_fees: Record<string, unknown> }>('/estimateFee', { address: tonRawAddress(d.from), body: bocOf(body), ignore_chksig: true })).source_fees ?? {};267 return { inFwd: nat(f.in_fwd_fee, 'fee'), storage: nat(f.storage_fee, 'fee'), gas: nat(f.gas_fee, 'fee'), fwd: nat(f.fwd_fee, 'fee') };268 },269 async () => {270 let trace: TaTrace | undefined;271 try { trace = (await ta<{ trace: TaTrace }>('/wallet/emulate', { post: { boc: bocOf(tonExternalMessage(from, body)) } }))!.trace; }272 catch (e) { // 4xx with a reason = the emulator refused the message itself273 const st = (e as { status?: number }).status ?? 0;274 if (st >= 400 && st < 500 && st !== 429 && (e as { detail?: string }).detail) throw new UserError('simulation_failed', `simulation: ${(e as { detail: string }).detail.slice(0, 200)}`);275 throw e;276 }277 const t = trace?.transaction, sent = trace?.children ?? [], child = sent[0]?.transaction;278 if (!t || typeof t.success !== 'boolean' || !Array.isArray(sent)) throw new Error('tonapi: bad emulation result');279 let value: bigint | null = null;280 try { value = nat(child?.in_msg?.value, 'value'); } catch { /* no usable child message */ }281 const sends = t.success === true && t.aborted === false && t.action_phase?.success === true && t.action_phase.total_actions === 1 && t.action_phase.skipped_actions === 0282 && sent.length === 1 && child?.account?.address?.toLowerCase() === toRaw && child.in_msg?.bounce === bounce && value === d.amount;283 return { sends, returned: child?.aborted === true };284 }, 'fee estimate and simulation');285 // estimateFee has no verdict of its own: gas and a forwarding fee are only charged when the wallet accepted the message and emitted the transfer286 if (fees.gas === 0n || fees.fwd === 0n || !sim.sends) throw new UserError('simulation_failed', 'simulation: the wallet would not send this transfer');287 if (bounce && sim.returned) throw new UserError('simulation_failed', 'simulation: the destination contract rejects a plain transfer (it would be returned, minus fees)');288 feeEstimate = fees.inFwd + fees.storage + fees.gas + fees.fwd;289 }290 if (feeEstimate > FEE_CAP) throw new UserError('fee_too_high', `${n.name} network fee estimate is abnormally high (${formatUnits(feeEstimate, 9)} ${n.native.symbol}); transfer refused`, 503);291 const want = 3n * feeEstimate;292 const maxFee = want > FEE_CAP ? FEE_CAP : want < FEE_FLOOR ? FEE_FLOOR : want;293 // send mode 3 ignores a failed action: an underfunded wallet would pay the fee and send nothing. Refuse here instead.294 if (balance < d.amount + maxFee) throw new UserError('insufficient_funds',295 `insufficient ${n.native.symbol}: ${formatUnits(balance, 9)} available for amount plus network fee budget (${formatUnits(maxFee, 9)})`);296297 return { section: { x: { seqno: String(src.seqno), validUntil: String(validUntil), bounce: bounce ? '1' : '0', sendMode: String(TON_SEND_MODE), stateInit: stateInit ? '1' : '0' } }, maxFee, feeEstimate, warnings, ttlSec: TTL_SEC };298 },299300 async broadcast(raw) {301 const m = tonReadExternal(raw);302 const rs = await Promise.allSettled([tc<unknown>('/sendBoc', { boc: raw }), ta<unknown>('/blockchain/message', { post: { boc: raw } })]);303 if (rs.some((r) => r.status === 'fulfilled')) return;304 // "already known": once included, a re-submission is refused by the wallet (wrong seqno); the chain is the judge305 const intent = { from: tonNormalizeAddress('0:' + toHex(m.accountId)), to: '', amount: '0' };306 const known = await Promise.allSettled([findTa(m.txid, intent), findTc(m.txid, intent, null)]);307 if (known.some((k) => k.status === 'fulfilled' && k.value !== null)) return;308 throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message));309 },310311 async track(p): Promise<Tracked> {312 const i = p.intent, validUntil = Number(i?.x?.validUntil), seqno = Number(i?.x?.seqno);313 if (!Number.isSafeInteger(validUntil) || validUntil <= 0 || !Number.isSafeInteger(seqno) || !/^[0-9a-f]{64}$/.test(p.txid)) throw new Error('intent has no valid_until');314 // TonAPI finds by message hash; TON Center (anchored to the masterchain) is then asked for that exact transaction, or scanned315 const viaTa = await findTa(p.txid, i).then((v) => ({ up: true, v }), (e) => { if (/another transaction/.test((e as Error).message)) throw e; return { up: false, v: null }; });316 const viaTc = await findTc(p.txid, i, viaTa.v).then((v) => ({ up: true, v }), () => ({ up: false, v: null as Found | null }));317 const tcFound = viaTc.v, taFound = viaTa.v;318 if (tcFound && taFound && (tcFound.lt !== taFound.lt || tcFound.hash !== taFound.hash || tcFound.outcome !== taFound.outcome)) throw new Error('data sources disagree on the transaction result');319 const f = tcFound ?? taFound;320 if (f) {321 const blockRef = f.lt;322 // the message is ours (its hash commits to the signed body), so its only possible effects are this transfer or nothing323 if (f.outcome === 'mismatch') throw new Error('data source reports an effect that does not match the approved transfer');324 // final = part of the account's transaction chain as served by a liteserver (committed by a masterchain block),325 // and confirmed by the second source whenever it is reachable326 if (!tcFound || (viaTa.up && !taFound)) return { state: 'confirming', confirmations: 0, blockRef };327 if (f.outcome === 'sent') return { state: 'finalized', confirmations: 1, blockRef };328 if (!taFound) return { state: 'confirming', confirmations: 1, blockRef }; // a failure verdict needs both sources329 return { state: 'failed', confirmations: 0, blockRef, error: 'accepted by the wallet contract but the transfer was not carried out; only the network fee was charged, no funds moved' };330 }331 const now = Date.now() / 1000;332 if (now <= validUntil + EXPIRY_MARGIN_SEC || !viaTa.up || !viaTc.up) return { state: 'broadcast', confirmations: 0, rebroadcast: now <= validUntil };333 // Expired locally. "Not included, and never will be" needs, on both sources, chain time past valid_until and a wallet334 // whose sequence number is still the one this message was signed for (inclusion would have advanced it).335 const rs = await Promise.allSettled([336 (async () => {337 const acc = await tc<{ state: string; data: string; code: string; block_id: { seqno: number } }>(`/getAddressInformation?address=${tonRawAddress(i.from)}`);338 const hdr = await tc<{ gen_utime: number }>(`/getBlockHeader?workchain=-1&shard=${MC_SHARD}&seqno=${int(acc.block_id?.seqno, 'seqno')}`);339 const cur = stateOf(acc.state) === 'active' ? walletSeqno(i.from, Buffer.from(acc.code, 'base64'), Buffer.from(acc.data, 'base64')) : 0;340 return { time: int(hdr.gen_utime, 'time'), seqno: cur };341 })(),342 (async () => {343 const head = await taMust<{ gen_utime: number }>('/blockchain/masterchain-head');344 return { time: int(head.gen_utime, 'time'), seqno: (await walletTa(i.from)).seqno };345 })(),346 ]);347 if (rs.every((r) => r.status === 'fulfilled' && r.value.time > validUntil + EXPIRY_MARGIN_SEC && r.value.seqno === seqno))348 return { state: 'failed', confirmations: 0, error: 'not included before valid_until; no funds moved' };349 return { state: 'broadcast', confirmations: 0, rebroadcast: false };350 },351352 async deposits(addr, cursor) {353 const raw = tonRawAddress(addr);354 const fromOf = (s: string) => { try { return tonNormalizeAddress(s); } catch { return s; } };355 // an inbound message with value that the wallet kept: a bounced-back or forwarded one has an outgoing message356 const item = (lt: string, hash: string, time: number, value: bigint, from: string, outs: number): Deposit[] =>357 value > 0n && outs === 0 ? [{ txid: hash, idx: 0, asset: 'native', amount: value, from: fromOf(from), block: Number(lt), time }] : [];358 return any(async (u) => {359 const items: Deposit[] = [], seen = new Set<string>();360 let top = cursor ?? 0;361 if (u === TC) {362 // newest first, from the account state at the latest masterchain block down to the cursor363 let page = `/getTransactions?address=${raw}&limit=50${cursor === null ? '' : `&to_lt=${cursor}`}`;364 for (let k = 0; ; k++) {365 const list = await tc<TcTx[]>(page);366 if (!Array.isArray(list)) throw new Error('toncenter: bad transaction list');367 for (const t of list) {368 const lt = nat(t.transaction_id.lt, 'lt').toString(), m = t.in_msg;369 if (Number(lt) <= (cursor ?? -1) || seen.has(lt)) continue;370 seen.add(lt);371 top = Math.max(top, Number(lt));372 if (m && m.source !== '') items.push(...item(lt, b64ToHex(t.transaction_id.hash, 'transaction hash'), int(t.utime, 'time'), nat(m.value, 'value'), m.source, Array.isArray(t.out_msgs) ? t.out_msgs.length : 1));373 }374 if (cursor === null || list.length < 50) break;375 if (k === 4) throw new Error('toncenter: too many new transactions for one scan'); // let the other source page forward376 const last = list[list.length - 1]!.transaction_id;377 page = `/getTransactions?address=${raw}&limit=50&to_lt=${cursor}<=${nat(last.lt, 'lt')}&hash=${b64ToHex(last.hash, 'transaction hash')}`;378 }379 return { items, cursor: top, finalizedHeight: top };380 }381 // TonAPI (indexer): oldest first after the cursor; the last seconds are left for the next scan382 const head = await taMust<{ gen_utime: number }>('/blockchain/masterchain-head');383 const settled = int(head.gen_utime, 'time') - 15;384 const path = `/blockchain/accounts/${raw}/transactions`;385 const take = (list: TaTx[]): boolean => {386 for (const t of list) {387 const lt = nat(t.lt, 'lt').toString(), m = t.in_msg;388 if (int(t.utime, 'time') > settled) return false;389 top = Math.max(top, Number(lt));390 if (m?.msg_type === 'int_msg' && m.source) items.push(...item(lt, hex64(t.hash, 'transaction hash'), t.utime, nat(m.value, 'value'), m.source.address, Array.isArray(t.out_msgs) ? t.out_msgs.length : 1));391 }392 return true;393 };394 if (cursor === null) {395 const list = (await ta<{ transactions: TaTx[] }>(`${path}?limit=50&sort_order=desc`, { missing: true }))?.transactions ?? [];396 take([...list].reverse());397 } else {398 for (let k = 0; k < 5; k++) {399 const list = (await ta<{ transactions: TaTx[] }>(`${path}?limit=100&sort_order=asc&after_lt=${top}`, { missing: true }))?.transactions ?? [];400 if (!take(list) || list.length < 100) break;401 }402 }403 return { items, cursor: top, finalizedHeight: top };404 });405 },406 };407}408