Repository Public
1// Aptos adapter: two independent REST full nodes (n.rpc). Everything that feeds the signature (sequence number, authentication2// key, balance) is read from both at the same ledger version and must agree; the exact transaction is then simulated on both3// and must succeed and show the expected credit, or the transfer is refused.4// Identity: chain id 1 (node and on-chain 0x1::chain_id resource), a pinned ledger checkpoint, and the same block hash on both5// sources for the same recent height. Public full nodes prune history (about two to three weeks are kept), so they cannot show6// the genesis transaction; the keyless Aptos Labs archive node can, and when it answers it must show the pinned genesis hash7// and the same recent block as both sources (an unreachable archive does not block transfers, a contradicting one does).8import {9 APTOS_MAX_GAS_AMOUNT, APTOS_MIN_GAS_UNIT_PRICE, aptosAptStore, aptosDecodeAddress, aptosEncodeTransfer, aptosRawBytes, aptosSimulationTx, aptosTxid,10 formatUnits, type NetworkDef,11} from '@spw/core';12import { UserError } from '../config.ts';13import { hostOf, http, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';1415interface Head { chain: number; version: bigint; height: bigint; epoch: bigint; timeSec: number }16interface Event { type: string; data: Record<string, unknown> }17interface Tx {18 type: string; hash: string; version?: string; success?: boolean; vm_status?: string; gas_used?: string; timestamp?: string;19 sender?: string; sequence_number?: string; max_gas_amount?: string; gas_unit_price?: string; expiration_timestamp_secs?: string;20 payload?: { type?: string; function?: string; type_arguments?: unknown[]; arguments?: unknown[] }; events?: Event[];21}2223/** approval window; the transaction itself stays valid a little longer so it can still be included after a late approval */24const TTL_SEC = 120, VALIDITY_SEC = 180;25/** ledger state the sources must have reached: a chain that claims chain id 1 below this point is not the main network (read 2026-11-10) */26const CHECKPOINT = { height: 1_175_000_000n, version: 7_990_000_000n, epoch: 17_954n };27/** gas price bid bounds in octas per unit: the network minimum, and ten times it */28const PRICE_CAP = 1_000n;29/** highest fee this adapter proposes (0.1 APT, the default policy ceiling) */30const FEE_CEILING = 10_000_000n;31/** gas limit of the first, estimating simulation; margin applied to the gas it used; smallest limit proposed */32const SIM_GAS = 20_000n, GAS_FLOOR = 200n;33const MAX_CLOCK_SKEW_SEC = 60, STALE_SEC = 120, IDENTITY_TTL_MS = 600_000;34/** a "not included" verdict needs every source's ledger time to be this far past the expiration */35const EXPIRY_MARGIN_SEC = 20;36/** full-history node (Aptos Labs, keyless): genesis anchor, and reading deposits older than the sources keep */37const ARCHIVE = 'https://archive.mainnet.aptoslabs.com/v1';38/** hash of the genesis transaction (ledger version 0) of the main network, read from the archive node on 2026-11-10 */39const GENESIS_TX = '0xf650d76ea0a3176f0412b7e6bea5eb6fbf1d7adb0c39ab18e378adefb9247309';40const APT_COIN = '0x1::aptos_coin::AptosCoin', APT_FA = '0x' + '0'.repeat(63) + 'a';41const TRANSFER_FN = '0x1::aptos_account::transfer';42const BCS_TX = 'application/x.aptos.signed_transaction+bcs';43const PAGE = 100, FIRST_PAGE = 50, MAX_PAGES = 3;4445class ApiError extends Error { constructor(public status: number, public apiCode: string, message: string) { super(message); } }46async function read<T>(r: Response): Promise<T> {47 const j = (await r.json().catch(() => null)) as (T & { error_code?: string; message?: string; vm_error_code?: number | null }) | null;48 if (!r.ok || j === null) throw new ApiError(r.status, String(j?.error_code ?? ''), `aptos http ${r.status} ${j?.error_code ?? ''} ${String(j?.message ?? '').slice(0, 160)}`.trim());49 return j;50}51const get = async <T>(base: string, path: string): Promise<T> => read<T>(await http(base + path, { headers: { accept: 'application/json' } }));52const postJson = async <T>(url: string, body: unknown): Promise<T> => read<T>(await http(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body) }));53const postBcs = async <T>(url: string, body: Uint8Array): Promise<T> => read<T>(await http(url, { method: 'POST', headers: { 'content-type': BCS_TX, accept: 'application/json' }, body: body as BodyInit }));54const uint = (v: unknown, what: string): bigint => {55 if (typeof v === 'number' ? !Number.isSafeInteger(v) || v < 0 : typeof v !== 'string' || !/^\d{1,20}$/.test(v)) throw new Error(`aptos: bad ${what}`);56 return BigInt(v as string | number);57};58const hash32 = (v: unknown, what: string): string => { if (typeof v !== 'string' || !/^0x[0-9a-f]{64}$/.test(v)) throw new Error(`aptos: bad ${what}`); return v; };59/** the REST API prints account addresses without leading zeros: restore the canonical 64-digit form before comparing */60const canon = (v: unknown): string => (typeof v === 'string' && /^0x[0-9a-f]{1,64}$/.test(v) ? '0x' + v.slice(2).padStart(64, '0') : '');61const big = (a: bigint, b: bigint) => (a > b ? a : b), small = (a: bigint, b: bigint) => (a < b ? a : b);6263export function aptosAdapter(n: NetworkDef): Adapter {64 const sources = n.rpc;65 const apt = (v: bigint) => `${formatUnits(v, n.native.decimals)} ${n.native.symbol}`;66 const any = async <T>(f: (u: string) => Promise<T>, urls: string[] = sources): Promise<T> => { let last: unknown; for (const u of urls) { try { return await f(u); } catch (e) { last = e; } } throw last; };67 /** every source must answer */68 const all = async <T>(f: (u: string) => Promise<T>, what: string): Promise<T[]> => {69 const rs = await Promise.allSettled(sources.map(f));70 if (sources.length < 2 || rs.some((r) => r.status === 'rejected')) throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);71 return rs.map((r) => (r as PromiseFulfilledResult<T>).value);72 };73 const agree = <T>(vals: T[], what: string): T => {74 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);75 return vals[0]!;76 };77 /** ledger position of one source; verifies the chain id and the pinned checkpoint on every call */78 const head = async (u: string): Promise<Head> => {79 const r = await get<Record<string, unknown>>(u, '');80 const h: Head = { chain: r.chain_id as number, version: uint(r.ledger_version, 'ledger version'), height: uint(r.block_height, 'block height'), epoch: uint(r.epoch, 'epoch'),81 timeSec: Number(uint(r.ledger_timestamp, 'ledger timestamp') / 1_000_000n) };82 if (h.chain !== Number(n.chainRef) || h.chain !== 1) throw new Error('aptos: wrong chain id');83 if (h.height < CHECKPOINT.height || h.version < CHECKPOINT.version || h.epoch < CHECKPOINT.epoch) throw new Error('aptos: ledger is behind the pinned checkpoint');84 return h;85 };86 /** APT balance in octas: the framework view covers both the legacy CoinStore and the fungible-asset store */87 const balance = async (u: string, addr: string, version?: bigint): Promise<bigint> => {88 const r = await postJson<unknown[]>(`${u}/view${version === undefined ? '' : `?ledger_version=${version}`}`, { function: '0x1::coin::balance', type_arguments: [APT_COIN], arguments: [addr] });89 if (!Array.isArray(r) || r.length !== 1) throw new Error('aptos: bad balance answer');90 return uint(r[0], 'balance');91 };92 /** an account that was never created on-chain has sequence number 0 and its address as authentication key */93 const account = async (u: string, addr: string, version: bigint): Promise<{ sequence: string; authKey: string }> => {94 try {95 const a = await get<{ sequence_number: unknown; authentication_key: unknown }>(u, `/accounts/${addr}?ledger_version=${version}`);96 return { sequence: uint(a.sequence_number, 'sequence number').toString(), authKey: hash32(canon(a.authentication_key), 'authentication key') };97 } catch (e) { if (e instanceof ApiError && e.status === 404 && e.apiCode === 'account_not_found') return { sequence: '0', authKey: addr }; throw e; }98 };99 /** transaction by hash (the caller checks the record); null only for the node's own "not found" answer */100 const byHash = async (u: string, txid: string): Promise<Tx | null> => {101 try {102 return await get<Tx>(u, `/transactions/by_hash/${txid}`);103 } catch (e) { if (e instanceof ApiError && e.status === 404 && e.apiCode === 'transaction_not_found') return null; throw e; }104 };105 const simulate = async (u: string, rawTx: Uint8Array): Promise<Tx> => {106 const body = aptosSimulationTx(rawTx);107 const r = await postBcs<Tx[]>(`${u}/transactions/simulate`, body);108 if (!Array.isArray(r) || r.length !== 1 || typeof r[0]?.success !== 'boolean' || typeof r[0].vm_status !== 'string') throw new Error('aptos: bad simulation answer');109 if (r[0].hash !== aptosTxid(body)) throw new Error('aptos: simulation answered for another transaction');110 return r[0];111 };112 /** octas credited to `addr` by the events of one transaction: fungible-asset deposits into its APT store, or legacy coin deposits */113 const credited = (events: Event[] | undefined, addr: string, store: string): { idx: number; amount: bigint }[] => (events ?? []).flatMap((e, idx) => {114 const d = e?.data ?? {};115 const hit = (e?.type === '0x1::fungible_asset::Deposit' && canon(d.store) === store) || (e?.type === '0x1::coin::CoinDeposit' && canon(d.account) === addr && d.coin_type === APT_COIN);116 return hit ? [{ idx, amount: uint(d.amount, 'event amount') }] : [];117 });118119 let identity = false, identityAt = 0;120 /** on-chain chain id on every source, and the same block hash for the same recent height */121 const blockAt = async (u: string, height: bigint): Promise<string> => {122 const b = await get<Record<string, unknown>>(u, `/blocks/by_height/${height}`);123 return [hash32(b.block_hash, 'block hash'), uint(b.first_version, 'version'), uint(b.last_version, 'version'), uint(b.block_timestamp, 'timestamp')].join('|');124 };125 const verifyIdentity = async (heads: Head[]): Promise<void> => {126 const height = heads.map((h) => h.height).reduce(small) - 5n;127 const [seen, anchor] = await Promise.all([128 Promise.all(sources.map(async (u) => {129 const c = await get<{ data?: { id?: unknown } }>(u, '/accounts/0x1/resource/0x1::chain_id::ChainId');130 if (c.data?.id !== 1) throw new Error('aptos: on-chain chain id mismatch');131 return blockAt(u, height);132 })),133 (async () => {134 try { return { genesis: (await get<Tx>(ARCHIVE, '/transactions/by_version/0')).hash, block: await blockAt(ARCHIVE, height) }; }135 catch { return null; } // archive unreachable: the two-source checks stand alone136 })(),137 ]);138 if (seen.length < 2 || seen.some((s) => s !== seen[0])) throw new Error('aptos: sources are not on the same ledger');139 if (anchor && (anchor.genesis !== GENESIS_TX || anchor.block !== seen[0])) throw new Error('aptos: sources are not on the ledger that starts at the pinned genesis');140 };141142 return {143 async health(): Promise<Health> {144 const probes = await Promise.all(sources.map(async (url) => {145 const t = Date.now();146 try {147 const h = await head(url);148 if (Date.now() / 1000 - h.timeSec > STALE_SEC) throw new Error('stale');149 return { url: hostOf(url), ok: true, ms: Date.now() - t, h: h as Head | null };150 } catch { return { url: hostOf(url), ok: false, ms: null as number | null, h: null as Head | null }; }151 }));152 const up = probes.length >= 2 && probes.every((e) => e.ok);153 if (up && (!identity || Date.now() - identityAt > IDENTITY_TTL_MS)) {154 try { await verifyIdentity(probes.map((p) => p.h!)); identity = true; identityAt = Date.now(); } catch { identity = false; }155 }156 const heights = probes.flatMap((p) => (p.h ? [Number(p.h.height)] : []));157 return { network: n.id, ok: up && identity, readable: probes.some((e) => e.ok), identityVerified: identity, height: heights.length ? Math.max(...heights) : null,158 endpoints: probes.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };159 },160161 async balances(addr) {162 aptosDecodeAddress(addr);163 return { native: await any((u) => balance(u, addr)) };164 },165166 async prepare(d): Promise<Prepared> {167 if (d.asset !== 'native') throw new UserError('unsupported_asset', 'only APT can be sent on Aptos in this version');168 let sender: Uint8Array, recipient: Uint8Array;169 try { sender = aptosDecodeAddress(d.from); recipient = aptosDecodeAddress(d.to); } catch (e) { throw new UserError('bad_address', (e as Error).message); }170 if (d.from === d.to) throw new UserError('bad_address', 'destination is this wallet');171 if (d.amount <= 0n || d.amount >= 1n << 64n) throw new UserError('bad_amount', 'amount must be greater than zero');172 if (d.memo !== undefined) throw new UserError('bad_memo', 'Aptos transfers carry no memo');173 const warnings: string[] = [];174175 // ledger position of both sources, then the account state of both at the same ledger version176 const heads = await all(head, 'ledger position');177 const now = Math.floor(Date.now() / 1000);178 for (const h of heads) if (Math.abs(now - h.timeSec) > MAX_CLOCK_SKEW_SEC) throw new UserError('rpc_degraded', `${n.name} ledger time and server clock differ; transfer refused`, 503);179 const version = heads.map((h) => h.version).reduce(small);180 const snaps = await all(async (u) => {181 const [acct, bal, gas] = await Promise.all([account(u, d.from, version), balance(u, d.from, version), get<{ gas_estimate: unknown }>(u, '/estimate_gas_price')]);182 return { state: { ...acct, balance: bal.toString() }, gasPrice: uint(gas.gas_estimate, 'gas estimate') };183 }, 'account state');184 const state = agree(snaps.map((s) => s.state), 'source account');185 if (state.authKey !== d.from) throw new UserError('unsupported_account', 'the authentication key of this Aptos account was rotated; it cannot send from this wallet');186 const bal = BigInt(state.balance), sequence = BigInt(state.sequence);187188 // gas price: the larger of the two estimates, never below the network minimum, capped189 const want = big(snaps.map((s) => s.gasPrice).reduce(big), APTOS_MIN_GAS_UNIT_PRICE);190 if (want > PRICE_CAP) warnings.push('network_congested');191 const gasPrice = small(want, PRICE_CAP);192193 const short = () => new UserError('insufficient_funds', `insufficient ${n.native.symbol}: ${apt(bal)} available for amount plus network fee`);194 if (bal <= d.amount) throw short();195 const expiration = BigInt(now + VALIDITY_SEC);196 const encode = (maxGas: bigint) => aptosEncodeTransfer({ sender, recipient, amount: d.amount, sequence, maxGas, gasPrice, expiration });197 const store = aptosAptStore(d.to);198 /** simulate on both sources; both must execute the transfer and show the exact credit to the recipient */199 const run = async (maxGas: bigint, what: string): Promise<bigint> => {200 const rawTx = encode(maxGas);201 const sims = await all((u) => simulate(u, rawTx), what);202 for (const s of sims) {203 if (!s.success) {204 const vm = s.vm_status ?? '';205 if (/INSUFFICIENT_BALANCE|EINSUFFICIENT_BALANCE|ECOIN_STORE_NOT_PUBLISHED/.test(vm)) throw short();206 if (/SEQUENCE_NUMBER/.test(vm)) throw new UserError('pending_tx', 'this account has a transaction in progress; try again in a moment', 409);207 throw new UserError('simulation_failed', `the network would reject this transfer: ${vm.slice(0, 160)}`);208 }209 const args = s.payload?.arguments;210 const echo = [canon(s.sender), s.sequence_number, s.max_gas_amount, s.gas_unit_price, s.expiration_timestamp_secs, s.payload?.function, JSON.stringify(s.payload?.type_arguments), JSON.stringify(Array.isArray(args) && args.length === 2 ? [canon(args[0]), args[1]] : null)];211 const sent = [d.from, sequence.toString(), maxGas.toString(), gasPrice.toString(), expiration.toString(), TRANSFER_FN, '[]', JSON.stringify([d.to, d.amount.toString()])];212 if (JSON.stringify(echo) !== JSON.stringify(sent)) throw new UserError('rpc_disagreement', `${n.name} simulated a different transaction; transfer refused`, 503);213 const credit = credited(s.events, d.to, store);214 if (credit.length !== 1 || credit[0]!.amount !== d.amount) throw new UserError('simulation_failed', 'the simulation does not credit the recipient with the exact amount; transfer refused');215 }216 const used = sims.map((s) => uint(s.gas_used, 'gas used')).reduce(big);217 if (used <= 0n || used > maxGas) throw new UserError('rpc_degraded', `${n.name} simulation returned an implausible gas figure; transfer refused`, 503);218 return used;219 };220221 // first pass: how much gas the transfer needs (a recipient without an APT store costs a one-time storage fee)222 const affordable = (bal - d.amount) / gasPrice;223 if (affordable < 1n) throw short();224 const estimate = await run(small(SIM_GAS, affordable), 'simulation');225 const maxGas = big(estimate + estimate / 2n + 1n, GAS_FLOOR);226 const maxFee = maxGas * gasPrice;227 if (maxGas > APTOS_MAX_GAS_AMOUNT || maxFee > FEE_CEILING) throw new UserError('fee_too_high', `the network fee for this transfer (up to ${apt(maxFee)}) is above the wallet limit`);228 if (bal < d.amount + maxFee) throw short();229 // second pass: the exact transaction that will be signed230 const used = await run(maxGas, 'simulation of the exact transaction');231232 return {233 section: { x: { sequence_number: sequence.toString(), max_gas_amount: maxGas.toString(), gas_unit_price: gasPrice.toString(), expiration_timestamp_secs: expiration.toString() } },234 maxFee, feeEstimate: used * gasPrice, warnings, ttlSec: TTL_SEC,235 };236 },237238 async broadcast(raw) {239 const bytes = aptosRawBytes(raw), txid = aptosTxid(bytes);240 const rs = await Promise.allSettled(sources.map(async (u) => {241 const r = await postBcs<{ hash?: string }>(`${u}/transactions`, bytes);242 if (r.hash !== txid) throw new Error('aptos node answered for another transaction');243 }));244 if (rs.some((r) => r.status === 'fulfilled')) return;245 // "already known": a re-submission after inclusion is refused (sequence number too old); the ledger is the judge246 const known = await Promise.allSettled(sources.map((u) => byHash(u, txid)));247 if (known.some((k) => k.status === 'fulfilled' && k.value?.hash === txid)) return;248 throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message));249 },250251 async track(p): Promise<Tracked> {252 const exp = Number(p.intent?.x?.expiration_timestamp_secs);253 if (!Number.isSafeInteger(exp) || exp <= 0) throw new Error('intent has no expiration');254 hash32(p.txid, 'transaction id');255 // per source: ledger position FIRST, then the lookup — a miss is then known to cover the ledger up to that position256 const rs = await Promise.allSettled(sources.map(async (u) => { const h = await head(u); return { h, tx: await byHash(u, p.txid) }; }));257 const up = rs.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));258 const allUp = up.length === sources.length && up.length >= 2;259 for (const s of up) if (s.tx !== null && (s.tx.hash !== p.txid || typeof s.tx.type !== 'string')) throw new Error('aptos: bad transaction record');260 const committed = up.filter((s) => s.tx !== null && s.tx.type !== 'pending_transaction');261 for (const s of committed) if (s.tx!.type !== 'user_transaction' || typeof s.tx!.success !== 'boolean') throw new Error('aptos: bad transaction record');262 if (committed.length > 0) {263 const version = uint(committed[0]!.tx!.version, 'version'), good = committed[0]!.tx!.success!;264 if (committed.some((s) => uint(s.tx!.version, 'version') !== version || s.tx!.success !== good)) throw new Error('data sources disagree on the transaction result');265 const blockRef = version.toString();266 // a committed transaction is final on Aptos (BFT); every reachable source must show it before we say so267 if (committed.length < up.length) return { state: 'confirming', confirmations: 1, blockRef };268 if (good) return { state: 'finalized', confirmations: Math.max(1, Number(up.map((s) => s.h.version).reduce(big) - version) + 1), blockRef };269 // committed but aborted (gas charged, nothing transferred): only final once every source is reachable and agrees270 if (!allUp) return { state: 'confirming', confirmations: 1, blockRef };271 return { state: 'failed', confirmations: 0, blockRef, error: `committed but the transfer aborted (${String(committed[0]!.tx!.vm_status).slice(0, 120)}); only the network fee was charged, no funds moved` };272 }273 // a transaction can only be committed in a block whose time is before its expiration274 if (allUp && up.every((s) => s.tx === null && s.h.timeSec > exp + EXPIRY_MARGIN_SEC))275 return { state: 'failed', confirmations: 0, error: 'not included before its expiration; no funds moved' };276 return { state: 'broadcast', confirmations: 0, rebroadcast: Date.now() / 1000 < exp };277 },278279 // Full nodes have no per-account index of incoming transfers. Discovery uses the keyless Aptos Labs indexer (GraphQL, same280 // host as rpc[0]); the indexer is never trusted: each transaction it names is read from a full node (the other source281 // first) and only the credit shown by that node's events is recorded.282 async deposits(addr, cursor) {283 aptosDecodeAddress(addr);284 const index = sources.find((u) => /(^|\.)aptoslabs\.com$/.test(new URL(u).hostname));285 const store = aptosAptStore(addr);286 return any(async (u) => {287 const latest = Number((await head(u)).version); // read before the listing: every version up to here is final and readable on this node288 if (!index) return { items: [], cursor: cursor ?? 0, finalizedHeight: latest };289 const versions = new Set<number>();290 let bound = latest, from = cursor, cut = false;291 for (let page = 0; page < (cursor === null ? 1 : MAX_PAGES); page++) {292 const where = `owner_address:{_eq:"${addr}"}, asset_type:{_in:["${APT_FA}","${APT_COIN}"]}, type:{_in:["0x1::fungible_asset::Deposit","0x1::coin::CoinDeposit"]}, is_transaction_success:{_eq:true}`293 + (from === null ? '' : `, transaction_version:{_gt:${from}}`);294 const q = `{ processor_status(where:{processor:{_eq:"fungible_asset_processor"}}) { last_success_version } `295 + `fungible_asset_activities(limit:${from === null ? FIRST_PAGE : PAGE}, where:{${where}}, order_by:{transaction_version:${from === null ? 'desc' : 'asc'}}) { transaction_version } }`;296 const r = await postJson<{ data?: { processor_status?: { last_success_version: unknown }[]; fungible_asset_activities?: { transaction_version: unknown }[] }; errors?: unknown }>(`${index}/graphql`, { query: q });297 const rows = r.data?.fungible_asset_activities, status = r.data?.processor_status;298 if (r.errors || !Array.isArray(rows) || !Array.isArray(status) || status.length !== 1) throw new Error('aptos: history index unavailable');299 // nothing beyond what the index has fully processed, nor beyond what the verifying node holds, is consumed in this pass300 bound = Math.min(bound, Number(uint(status[0]!.last_success_version, 'index position')));301 const vs = rows.map((x) => Number(uint(x.transaction_version, 'version')));302 for (const v of vs) if (v <= bound) versions.add(v);303 cut = from !== null && vs.length >= PAGE;304 if (!cut) break;305 from = Math.max(...vs);306 if (from >= bound) { cut = false; break; }307 }308 const items: Deposit[] = [];309 /** null when this node has pruned the version */310 const byVersion = async (node: string, v: number): Promise<Tx | null> => {311 try {312 const t = await get<Tx>(node, `/transactions/by_version/${v}`);313 if (uint(t.version, 'version') !== BigInt(v)) throw new Error('aptos: bad transaction record');314 return t;315 } catch (e) { if (e instanceof ApiError && e.status === 410 && e.apiCode === 'version_pruned') return null; throw e; }316 };317 for (const v of [...versions].sort((a, b) => a - b)) {318 const t = await byVersion(u, v) ?? await byVersion(ARCHIVE, v).catch((e) => { if (cursor === null) return null; throw e; });319 if (t === null) { if (cursor === null) continue; throw new Error('aptos: deposit transaction no longer readable'); } // history only on a first scan320 if (t.type !== 'user_transaction' || t.success !== true || canon(t.sender) === addr) continue;321 const time = Number(uint(t.timestamp, 'timestamp') / 1_000_000n);322 for (const c of credited(t.events, addr, store)) if (c.amount > 0n) items.push({ txid: hash32(t.hash, 'hash'), idx: c.idx, asset: 'native', amount: c.amount, from: hash32(canon(t.sender), 'sender'), block: v, time });323 }324 // more to read: resume next time after the last version fully covered325 return { items, cursor: cut ? Math.max(cursor ?? 0, from ?? 0) : Math.max(cursor ?? 0, bound), finalizedHeight: latest };326 }, index ? [...sources.filter((s) => s !== index), index] : sources);327 },328 };329}330