SPB Git

Repository Public

ton.ts 30.4 KB 408 lines typescript

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&lt=${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}&lt=${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
Files main
Menu

Theme