SPB Git

Repository Public

sui.ts 25.9 KB 314 lines typescript

1// Sui adapter: two independent full nodes (n.rpc) reached over gRPC (sui.rpc.v2; see sui-grpc.ts — public nodes no longer serve2// JSON-RPC). Everything that feeds the signature — the sender's coin objects (id, version, digest, balance), the epoch, the3// reference gas price — is read from both nodes and must agree, and the exact bytes to be signed are simulated on both nodes with4// identical results, or the transfer is refused.5//6// Finality: a transaction included in a checkpoint is final on Sui (checkpoints are certified by a validator quorum and never7// reorganised). A transaction that was not executed by the end of its expiration epoch can never execute.8import { buildSuiTx, formatUnits, SUI_MAX_GAS_COINS, suiAddressBytes, suiFormatPayment, suiParseRaw, type Intent, type NetworkDef, type SuiObjectRef } from '@spw/core';9import { UserError } from '../config.ts';10import { hostOf, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';11import {12  decode, encode, GRPC_NOT_FOUND, GRPC_OUT_OF_RANGE, grpcCall, GrpcError, M,13  type BalanceMsg, type EpochMsg, type ExecutedTx, type GasMsg, type GrpcCall, type ListedTx, type ObjectMsg, type Schema, type ServiceInfo,14} from './sui-grpc.ts';1516const SUI_TYPE = `0x${'0'.repeat(63)}2::sui::SUI`;17const SUI_COIN_TYPE = `0x${'0'.repeat(63)}2::coin::Coin<${SUI_TYPE}>`;18const LEDGER = '/sui.rpc.v2.LedgerService/', STATE = '/sui.rpc.v2.StateService/', EXEC = '/sui.rpc.v2.TransactionExecutionService/';19const OWNER_ADDRESS = 1; // Owner.OwnerKind.ADDRESS20const END_ITEM_LIMIT = 1, END_SCAN_LIMIT = 2; // QueryEndReason2122/** approval window in seconds (the transaction itself stays valid until the end of the current epoch, at most about a day) */23const TTL_SEC = 120;24/** budget used for the first simulation, and the most this adapter ever asks the owner to approve as a fee ceiling: 0.02 SUI */25const PROBE_BUDGET = 20_000_000n;26/** the protocol refuses a budget below this many gas units at the transaction's gas price (base_tx_cost_fixed is 1000) */27const MIN_BUDGET_UNITS = 2_000n;28/** coins of the sender read per source, at most (5 pages of 200) */29const PAGE = 200, MAX_PAGES = 5;30/** sources may be this many checkpoints apart (about 4 per second); node time and server clock may differ by this many seconds */31const MAX_CHECKPOINT_GAP = 240n, MAX_CLOCK_SKEW_SEC = 60, STALE_SEC = 300;32/** a "not executed" verdict needs every source this many checkpoints past the end of the expiration epoch */33const EXPIRY_MARGIN = 40n;34const EXECUTE_TIMEOUT_MS = 20_000;3536const big = (v: bigint | undefined, what: string): bigint => { if (typeof v !== 'bigint') throw new Error(`sui: missing ${what}`); return v; };37const safe = (v: bigint | undefined, what: string): number => { const b = big(v, what); if (b > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error(`sui: ${what} out of range`); return Number(b); };38const notFound = (e: unknown) => e instanceof GrpcError && e.grpcCode === GRPC_NOT_FOUND;3940interface Coin extends SuiObjectRef { balance: bigint }41interface Sim { success: boolean; error: string; gas: { computation: string; storage: string; rebate: string }; changes: string[] }4243export function suiAdapter(n: NetworkDef, call: GrpcCall = grpcCall): Adapter {44  const sources = n.rpc;45  const rpc = async <T>(u: string, method: string, reqS: Schema, resS: Schema, req: Record<string, unknown>, timeoutMs?: number): Promise<T> => {46    const frames = await call(u, method, encode(reqS, req), timeoutMs);47    if (frames.length !== 1) throw new Error('sui: expected exactly one response message');48    return decode<T>(resS, frames[0]!);49  };50  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; };51  /** every source must answer */52  const all = async <T>(f: (u: string) => Promise<T>, what: string): Promise<T[]> => {53    const rs = await Promise.allSettled(sources.map(f));54    if (sources.length < 2 || rs.some((r) => r.status === 'rejected')) throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);55    return rs.map((r) => (r as PromiseFulfilledResult<T>).value);56  };57  const agree = <T>(vals: T[], what: string): T => {58    for (const v of vals) if (JSON.stringify(v) !== JSON.stringify(vals[0])) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (${what}); transfer refused`, 503);59    return vals[0]!;60  };6162  /** node position; verifies the chain identity (genesis checkpoint digest) on every call */63  const info = async (u: string) => {64    const r = await rpc<ServiceInfo>(u, LEDGER + 'GetServiceInfo', M.GetServiceInfoRequest, M.GetServiceInfoResponse, {});65    if (r.chainId !== n.chainRef || r.chain !== 'mainnet') throw new Error('sui: chain identity mismatch');66    return { epoch: big(r.epoch, 'epoch'), height: big(r.checkpointHeight, 'checkpoint height'), time: safe(r.timestamp?.seconds, 'timestamp'), lowest: r.lowestAvailableCheckpoint ?? null };67  };68  const epochInfo = async (u: string, epoch?: bigint): Promise<EpochMsg> => {69    const r = await rpc<{ epoch?: EpochMsg }>(u, LEDGER + 'GetEpoch', M.GetEpochRequest, M.GetEpochResponse, { ...(epoch === undefined ? {} : { epoch }), readMask: { paths: ['epoch', 'first_checkpoint', 'last_checkpoint', 'start', 'end', 'reference_gas_price'] } });70    if (!r.epoch || typeof r.epoch.epoch !== 'bigint' || (epoch !== undefined && r.epoch.epoch !== epoch)) throw new Error('sui: bad epoch answer');71    return r.epoch;72  };73  const balance = async (u: string, addr: string): Promise<BalanceMsg> => {74    const r = await rpc<{ balance?: BalanceMsg }>(u, STATE + 'GetBalance', M.GetBalanceRequest, M.GetBalanceResponse, { owner: addr, coinType: SUI_TYPE });75    if (r.balance?.coinType !== SUI_TYPE) throw new Error('sui: bad balance answer');76    return r.balance;77  };78  /** every SUI coin object owned by the address, sorted by id; throws when there are more than this adapter reads */79  const coins = async (u: string, addr: string): Promise<Coin[]> => {80    const out: Coin[] = [];81    let pageToken: Uint8Array | undefined;82    for (let page = 0; ; page++) {83      if (page >= MAX_PAGES) throw new UserError('too_many_coins', `this address holds more than ${PAGE * MAX_PAGES} separate SUI coin objects; merge them with another wallet first`);84      const r = await rpc<{ objects: ObjectMsg[]; nextPageToken?: Uint8Array }>(u, STATE + 'ListOwnedObjects', M.ListOwnedObjectsRequest, M.ListOwnedObjectsResponse,85        { owner: addr, pageSize: PAGE, objectType: SUI_COIN_TYPE, readMask: { paths: ['object_id', 'version', 'digest', 'owner', 'object_type', 'balance'] }, ...(pageToken ? { pageToken } : {}) });86      for (const o of r.objects) {87        if (o.objectType !== SUI_COIN_TYPE || o.owner?.kind !== OWNER_ADDRESS || o.owner.address !== addr) throw new Error('sui: unexpected object in coin list');88        if (typeof o.objectId !== 'string' || typeof o.digest !== 'string') throw new Error('sui: bad coin object');89        suiAddressBytes(o.objectId);90        out.push({ objectId: o.objectId, version: big(o.version, 'object version'), digest: o.digest, balance: big(o.balance, 'coin balance') });91      }92      if (!r.nextPageToken || r.nextPageToken.length === 0) break;93      pageToken = r.nextPageToken;94    }95    if (new Set(out.map((c) => c.objectId)).size !== out.length) throw new Error('sui: duplicate coin object');96    return out.sort((a, b) => (a.objectId < b.objectId ? -1 : 1));97  };98  /** does any object live at this id? (a destination that is an object id is not a wallet) */99  const objectExists = async (u: string, id: string): Promise<boolean> => {100    try { await rpc(u, LEDGER + 'GetObject', M.GetObjectRequest, M.GetObjectResponse, { objectId: id, readMask: { paths: ['object_id'] } }); return true; }101    catch (e) { if (notFound(e)) return false; throw e; }102  };103  const getTx = async (u: string, digest: string): Promise<ExecutedTx | null> => {104    try {105      const r = await rpc<{ transaction?: ExecutedTx }>(u, LEDGER + 'GetTransaction', M.GetTransactionRequest, M.GetTransactionResponse, { digest, readMask: { paths: ['digest', 'checkpoint', 'effects.status'] } });106      if (r.transaction?.digest !== digest || typeof r.transaction.effects?.status?.success !== 'boolean') throw new Error('sui: bad transaction record');107      return r.transaction;108    } catch (e) { if (notFound(e)) return null; throw e; }109  };110  const gasOf = (g: GasMsg | undefined) => ({ computation: big(g?.computationCost, 'gas').toString(), storage: big(g?.storageCost, 'gas').toString(), rebate: big(g?.storageRebate, 'gas').toString() });111  /** dry run of exact TransactionData bytes with every validity check on (signature excepted) */112  const simulate = async (u: string, tx: Uint8Array): Promise<Sim> => {113    const r = await rpc<{ transaction?: ExecutedTx }>(u, EXEC + 'SimulateTransaction', M.SimulateTransactionRequest, M.SimulateTransactionResponse,114      { transaction: { bcs: { value: tx } }, checks: 0, doGasSelection: false, readMask: { paths: ['transaction.effects.status', 'transaction.effects.gas_used', 'transaction.balance_changes'] } });115    const t = r.transaction, st = t?.effects?.status;116    if (!t || typeof st?.success !== 'boolean') throw new Error('sui: bad simulation answer');117    const changes = t.balanceChanges.map((c) => { if (typeof c.address !== 'string' || typeof c.coinType !== 'string' || !/^-?\d{1,30}$/.test(c.amount ?? '')) throw new Error('sui: bad balance change'); return `${c.address}|${c.coinType}|${BigInt(c.amount!)}`; }).sort();118    return { success: st.success, error: st.success ? '' : (st.error?.description ?? 'execution failed').slice(0, 200), gas: gasOf(t.effects!.gasUsed), changes };119  };120  const simulateBoth = async (tx: Uint8Array, what: string): Promise<Sim> => {121    const rs = await Promise.allSettled(sources.map((u) => simulate(u, tx)));122    const refused = rs.find((r) => r.status === 'rejected' && r.reason instanceof GrpcError) as PromiseRejectedResult | undefined;123    // a node refusing the transaction outright (invalid input, stale object, …) is an answer, not an outage124    if (refused && rs.every((r) => r.status === 'rejected' && r.reason instanceof GrpcError)) throw new UserError('simulation_failed', `the network refuses this transfer: ${(refused.reason as Error).message}`);125    if (rs.some((r) => r.status === 'rejected')) throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);126    return agree(rs.map((r) => (r as PromiseFulfilledResult<Sim>).value), what);127  };128  let identity = false;129130  return {131    async health(): Promise<Health> {132      const endpoints = await Promise.all(sources.map(async (url) => {133        const t = Date.now();134        try {135          const i = await info(url); // verifies the genesis checkpoint digest on every check136          if (Date.now() / 1000 - i.time > STALE_SEC) throw new Error('stale');137          return { url: hostOf(url), ok: true, ms: Date.now() - t, h: Number(i.height) };138        } catch { return { url: hostOf(url), ok: false, ms: null, h: null as number | null }; }139      }));140      const ok = endpoints.length >= 2 && endpoints.every((e) => e.ok);141      if (ok) identity = true;142      return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: Math.max(0, ...endpoints.map((e) => e.h ?? 0)) || null,143        endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };144    },145146    async balances(addr) {147      // everything the address owns: coin objects plus its "address balance" (funds received without a coin object)148      return { native: big((await any((u) => balance(u, addr))).balance ?? 0n, 'balance') };149    },150151    async prepare(d): Promise<Prepared> {152      if (d.asset !== 'native') throw new UserError('unsupported_asset', 'only SUI can be sent on Sui in this version');153      if (d.memo !== undefined) throw new UserError('bad_memo', 'Sui transfers do not carry a memo');154      try { suiAddressBytes(d.from); suiAddressBytes(d.to); } catch (e) { throw new UserError('bad_address', (e as Error).message); }155      if (d.from === d.to) throw new UserError('bad_address', 'destination is this wallet');156      if (d.amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');157158      // one snapshot per source; both must answer and agree on everything that ends up in the signed bytes159      const snaps = await all(async (u) => {160        const i = await info(u);161        const [ep, cs, bal, toIsObject] = await Promise.all([epochInfo(u), coins(u, d.from), balance(u, d.from), objectExists(u, d.to)]);162        return {163          height: i.height, time: i.time,164          epoch: i.epoch.toString(), epochSeen: big(ep.epoch, 'epoch').toString(), gasPrice: big(ep.referenceGasPrice, 'reference gas price').toString(),165          coins: cs.map((c) => `${c.objectId}:${c.version}:${c.digest}:${c.balance}`), total: (bal.balance ?? 0n).toString(), toIsObject,166        };167      }, 'account state');168169      const now = Math.floor(Date.now() / 1000);170      const heights = snaps.map((s) => s.height).sort((a, b) => (a < b ? -1 : 1));171      if (heights[heights.length - 1]! - heights[0]! > MAX_CHECKPOINT_GAP) throw new UserError('rpc_disagreement', `${n.name} data sources are not at the same checkpoint; transfer refused`, 503);172      for (const s of snaps) if (Math.abs(now - s.time) > MAX_CLOCK_SKEW_SEC) throw new UserError('rpc_degraded', `${n.name} checkpoint time and server clock differ; transfer refused`, 503);173      const epoch = BigInt(agree(snaps.flatMap((s) => [s.epoch, s.epochSeen]), 'epoch'));174      const gasPrice = BigInt(agree(snaps.map((s) => s.gasPrice), 'reference gas price'));175      if (gasPrice <= 0n) throw new UserError('rpc_degraded', `${n.name} network parameters look wrong; transfer refused`, 503);176      const coinList = agree(snaps.map((s) => s.coins), 'coin objects of this wallet').map((s) => { const [objectId, version, digest, bal] = s.split(':') as [string, string, string, string]; return { objectId, version: BigInt(version), digest, balance: BigInt(bal) }; });177      const total = BigInt(agree(snaps.map((s) => s.total), 'balance'));178      if (agree(snaps.map((s) => s.toIsObject), 'destination')) throw new UserError('bad_address', 'the destination is an object or a package on Sui, not a wallet address; funds sent there could be lost');179180      // deterministic coin selection: largest first, until amount + probe budget is covered181      const sorted = [...coinList].sort((a, b) => (a.balance === b.balance ? (a.objectId < b.objectId ? -1 : 1) : a.balance > b.balance ? -1 : 1));182      const want = d.amount + PROBE_BUDGET;183      const picked: Coin[] = [];184      let sum = 0n;185      for (const c of sorted) { if (sum >= want || picked.length >= SUI_MAX_GAS_COINS) break; if (c.balance === 0n) continue; picked.push(c); sum += c.balance; }186      const minBudget = MIN_BUDGET_UNITS * gasPrice;187      const sui = (v: bigint) => formatUnits(v > 0n ? v : 0n, n.native.decimals);188      if (sum < d.amount + minBudget) {189        const inCoins = coinList.reduce((a, c) => a + c.balance, 0n);190        if (inCoins >= d.amount + minBudget) throw new UserError('too_many_coins', `the balance is spread over too many small coin objects: at most ${sui(sum)} ${n.native.symbol} can be sent in one transfer`);191        if (total >= d.amount + minBudget) throw new UserError('unsupported_funds', `insufficient ${n.native.symbol} held as coins: ${sui(inCoins)} spendable; ${sui(total - inCoins)} is held as an address balance, which this version cannot send`);192        throw new UserError('insufficient_funds', `insufficient ${n.native.symbol}: ${sui(inCoins)} available for amount plus network fee`);193      }194      const payment = suiFormatPayment(picked.sort((a, b) => (a.objectId < b.objectId ? -1 : 1)));195      const probeBudget = sum - d.amount < PROBE_BUDGET ? sum - d.amount : PROBE_BUDGET;196      const txFor = (budget: bigint): Uint8Array => {197        const intent: Intent = { v: 1, proposalId: '00000000-0000-4000-8000-000000000000', keyId: 'spw-simulation', network: n.id, chainRef: n.chainRef, asset: 'native', symbol: n.native.symbol, decimals: n.native.decimals,198          amount: d.amount.toString(), from: d.from, to: d.to, maxFee: budget.toString(), createdAt: now, expiresAt: now + TTL_SEC, policyVersion: 0,199          x: { payment, gasPrice: gasPrice.toString(), gasBudget: budget.toString(), expiration: epoch.toString() } };200        try { return buildSuiTx(intent, n).tx; } catch (e) { throw new UserError('bad_address', (e as Error).message); }201      };202203      // first simulation: learn the cost; second: the exact bytes that will be signed204      const probe = await simulateBoth(txFor(probeBudget), 'simulation');205      if (!probe.success) throw new UserError(/InsufficientGas|InsufficientCoinBalance/.test(probe.error) ? 'insufficient_funds' : 'simulation_failed',206        /InsufficientGas|InsufficientCoinBalance/.test(probe.error) ? `insufficient ${n.native.symbol} for amount plus network fee` : `the network would reject this transfer: ${probe.error}`);207      const gross = BigInt(probe.gas.computation) + BigInt(probe.gas.storage);208      let budget = 2n * gross; // the budget must cover computation + storage before the storage rebate; doubled for headroom209      if (budget < minBudget) budget = minBudget;210      if (budget > probeBudget) budget = probeBudget;211      const final = budget === probeBudget ? probe : await simulateBoth(txFor(budget), 'simulation');212      if (!final.success) throw new UserError('simulation_failed', `the network would reject this transfer: ${final.error}`);213      const net = BigInt(final.gas.computation) + BigInt(final.gas.storage) - BigInt(final.gas.rebate);214      // the simulated effects must be exactly this payment: recipient + amount, sender - (amount + net gas), nothing else215      const expected = [`${d.to}|${SUI_TYPE}|${d.amount}`, `${d.from}|${SUI_TYPE}|${-(d.amount + net)}`].sort();216      if (JSON.stringify(final.changes) !== JSON.stringify(expected)) throw new UserError('simulation_failed', 'the simulated transfer does not match the request; transfer refused');217      return { section: { x: { payment, gasPrice: gasPrice.toString(), gasBudget: budget.toString(), expiration: epoch.toString() } }, maxFee: budget, feeEstimate: net > 0n ? net : 0n, warnings: [], ttlSec: TTL_SEC };218    },219220    async broadcast(raw) {221      const { tx, signature, txid } = suiParseRaw(raw);222      const rs = await Promise.allSettled(sources.map(async (u) => {223        const r = await rpc<{ transaction?: ExecutedTx }>(u, EXEC + 'ExecuteTransaction', M.ExecuteTransactionRequest, M.ExecuteTransactionResponse,224          { transaction: { bcs: { value: tx } }, signatures: [{ bcs: { value: signature } }], readMask: { paths: ['digest'] } }, EXECUTE_TIMEOUT_MS);225        if (r.transaction?.digest !== txid) throw new Error('node answered for another transaction');226      }));227      if (rs.some((r) => r.status === 'fulfilled')) return;228      // "already known": a node may refuse a transaction it has already executed; the ledger is the judge229      const known = await Promise.allSettled(sources.map((u) => getTx(u, txid)));230      if (known.some((k) => k.status === 'fulfilled' && k.value !== null)) return;231      throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message));232    },233234    async track(p): Promise<Tracked> {235      const xs = p.intent?.x?.expiration;236      if (typeof xs !== 'string' || !/^\d{1,20}$/.test(xs)) throw new Error('intent has no expiration epoch');237      const expiration = BigInt(xs);238      // per source: position FIRST, then the lookup — a miss is then known to cover everything up to that position239      const rs = await Promise.allSettled(sources.map(async (u) => { const i = await info(u); return { u, i, tx: await getTx(u, p.txid) }; }));240      const up = rs.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));241      const allUp = up.length === sources.length && up.length >= 2;242      const found = up.filter((s) => s.tx !== null);243      if (found.length > 0) {244        const good = found[0]!.tx!.effects!.status!.success!;245        const cps = found.flatMap((s) => (typeof s.tx!.checkpoint === 'bigint' ? [s.tx!.checkpoint] : []));246        if (found.some((s) => s.tx!.effects!.status!.success !== good) || cps.some((c) => c !== cps[0])) throw new Error('data sources disagree on the transaction result');247        // executed but not yet in a checkpoint on some source: not final yet248        if (cps.length < found.length) return { state: 'confirming', confirmations: 0 };249        const cp = cps[0]!, blockRef = cp.toString();250        // a source that does not show it must be unable to: it prunes that checkpoint already (some public nodes keep seconds of history)251        for (const s of up) if (s.tx === null && !(s.i.lowest !== null && s.i.lowest > cp)) return { state: 'confirming', confirmations: 1, blockRef };252        const tip = up.map((s) => s.i.height).reduce((a, b) => (a > b ? a : b));253        if (good) return { state: 'finalized', confirmations: Math.max(1, Number(tip - cp) + 1), blockRef };254        // executed but aborted (gas charged, nothing transferred): only final once every source is reachable255        if (!allUp) return { state: 'confirming', confirmations: 1, blockRef };256        return { state: 'failed', confirmations: 0, blockRef, error: `executed but failed (${(found[0]!.tx!.effects!.status!.error?.description ?? 'execution error').slice(0, 120)}); only the network fee was charged, no funds moved` };257      }258      const maxEpoch = up.length ? up.map((s) => s.i.epoch).reduce((a, b) => (a > b ? a : b)) : 0n;259      if (allUp && up.every((s) => s.i.epoch > expiration)) {260        // expired everywhere. "Not executed" additionally needs a source whose history covers the whole life of the transaction:261        // created inside the expiration epoch (so it could only execute there), that epoch closed, and nothing of it pruned.262        const cover = await Promise.allSettled(up.map(async (s) => {263          const e = await epochInfo(s.u, expiration);264          const first = big(e.firstCheckpoint, 'first checkpoint'), last = big(e.lastCheckpoint, 'last checkpoint'), start = safe(e.start?.seconds, 'epoch start');265          return s.i.lowest !== null && s.i.lowest <= first && s.i.height >= last + EXPIRY_MARGIN && start <= Number(p.intent.createdAt);266        }));267        if (cover.some((c) => c.status === 'fulfilled' && c.value)) return { state: 'failed', confirmations: 0, error: 'not executed before its expiration epoch ended; no funds moved' };268      }269      return { state: 'broadcast', confirmations: 0, rebroadcast: up.length > 0 && maxEpoch <= expiration };270    },271272    async deposits(addr, cursor) {273      const scan = async (u: string, from: bigint | null): Promise<{ items: Deposit[]; cursor: number; finalizedHeight: number }> => {274        const latest = (await info(u)).height; // read before the listing: nothing checkpointed up to here can be missed275        const frames = await call(u, LEDGER + 'ListTransactions', encode(M.ListTransactionsRequest, {276          filter: { terms: [{ literals: [{ affectedAddress: { address: addr } }] }] }, ...(from === null ? { options: { limit: 50, ordering: 1 } } : { startCheckpoint: from, options: { limit: 200, ordering: 0 } }),277          readMask: { paths: ['digest', 'checkpoint', 'timestamp', 'effects.status', 'balance_changes', 'transaction.sender'] } }));278        const items: Deposit[] = [];279        let covered: bigint | null = null, lastItem: bigint | null = null, partial = false;280        for (const f of frames) {281          const m = decode<ListedTx>(M.ListTransactionsResponse, f), t = m.transaction;282          if (m.end && (m.end.reason === END_ITEM_LIMIT || m.end.reason === END_SCAN_LIMIT)) partial = true;283          if (typeof m.watermark?.checkpoint === 'bigint') covered = m.watermark.checkpoint; // everything up to here has been delivered284          if (!t || typeof t.checkpoint !== 'bigint' || typeof t.digest !== 'string') continue;285          lastItem = t.checkpoint;286          const sender = t.transaction?.sender ?? '';287          if (t.effects?.status?.success !== true || sender === addr) continue;288          let amount = 0n;289          for (const c of t.balanceChanges) if (c.address === addr && c.coinType === SUI_TYPE && /^\d{1,30}$/.test(c.amount ?? '')) amount += BigInt(c.amount!);290          if (amount > 0n) items.push({ txid: t.digest, idx: 0, asset: 'native', amount, from: sender, block: safe(t.checkpoint, 'checkpoint'), time: t.timestamp?.seconds !== undefined ? Number(t.timestamp.seconds) : null });291        }292        // first scan (newest 50) or a listing that reached the tip: everything up to `latest` is covered; two checkpoints of overlap293        // (inserts are idempotent). A listing cut short resumes from the last checkpoint known to be fully delivered.294        let next = latest - 2n;295        if (from !== null && partial) next = (covered !== null ? covered : lastItem !== null ? lastItem - 1n : from - 1n) - 1n;296        if (next > latest - 2n) next = latest - 2n;297        return { items, cursor: Math.max(cursor ?? 0, Number(next)), finalizedHeight: Number(latest) };298      };299      const fromOldestKept = () => any(async (u) => { const i = await info(u); if (i.lowest === null) throw new Error('source does not report its history range'); return scan(u, i.lowest + 1n); });300      if (cursor === null) {301        // newest-first listing walks back until it finds 50 items; for an address with little or no recent activity it runs302        // into pruned history and the node answers OUT_OF_RANGE. Then scan forward from the oldest checkpoint still kept.303        try { return await any((u) => scan(u, null)); }304        catch (e) { if (e instanceof GrpcError && e.grpcCode === GRPC_OUT_OF_RANGE) return fromOldestKept(); throw e; }305      }306      const errs: unknown[] = [];307      for (const u of sources) { try { return await scan(u, BigInt(cursor) + 1n); } catch (e) { errs.push(e); } }308      // every source answered that it no longer holds history that far back (long downtime): resume from the oldest checkpoint kept309      if (!errs.every((e) => e instanceof GrpcError && e.grpcCode === GRPC_OUT_OF_RANGE)) throw errs[errs.length - 1];310      return fromOldestKept();311    },312  };313}314
Files main
Menu

Theme