Repository Public
1// NEAR adapter: two independent JSON-RPC sources (n.rpc). Everything that feeds the signature (access key nonce, reference2// block hash) or decides whether a transfer is proposed (balances, destination existence, protocol fee parameters) is read from3// both at one agreed FINAL block and must be identical, or the transfer is refused.4//5// NEAR has no pre-signature simulation endpoint: prepare() checks the documented failure causes instead (source account and6// full-access key present, balance after the upfront gas purchase and the storage requirement, destination existence).7// A NEAR transaction does not state its fee; see packages/core/src/plugins/near.ts for the worst-case bound (maxFee).8//9// Public RPC nodes keep about a day of history and there is no history index in the protocol. Incoming transfers are therefore10// discovered through FastNEAR's keyless Transfers API and every row is re-verified against RPC before it is reported; lookups11// that the two regular sources can no longer answer fall back to FastNEAR's keyless archival RPC (display and tracking only).12import {13 formatUnits, NEAR_GAS_BOUND_IMPLICIT, NEAR_GAS_BOUND_NAMED, NEAR_MAX_GAS_PRICE, nearAccountKind, nearDecodeHash, nearFeeCeiling, nearSignedTxInfo, type NetworkDef,14} from '@spw/core';15import { base58 } from '@scure/base';16import { UserError } from '../config.ts';17import { hostOf, http, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';1819export const NEAR_ARCHIVAL_RPC = 'https://archival-rpc.mainnet.fastnear.com';20export const NEAR_TRANSFERS_API = 'https://transfers.main.fastnear.com/v0/transfers';2122/** approval window, seconds */23const TTL_SEC = 120;24/** the two sources may be this many blocks apart (≈ 0.6 s each); the agreed final block may be this old */25const MAX_FINAL_GAP = 100, MAX_BLOCK_AGE_SEC = 90, MAX_CLOCK_SKEW_SEC = 60, STALE_SEC = 120;26/** a transaction is valid while the chain is at most this many blocks past its reference block (genesis transaction_validity_period) */27const VALIDITY_BLOCKS = 86_400, EXPIRY_MARGIN_BLOCKS = 100;28/** accounts using at most this many bytes need no balance for storage (NEP-448; nearcore runtime verifier) */29const ZERO_BALANCE_STORAGE = 770n;30/** deposits: rows re-read behind the final block on every scan (the index may lag), pages of 100 per scan */31const SCAN_OVERLAP_BLOCKS = 3000, SCAN_PAGES = 10, FIRST_SCAN_ROWS = 20;32/** regular nodes keep roughly a day of blocks; older rows are looked up on the archival node first */33const RECENT_BLOCKS = 80_000;34/** a row that no source can verify is retried on every scan until it is this far behind the final block (≈ 3 days) */35const GIVE_UP_BLOCKS = 430_000;3637interface Header { height: number; hash: string; time: number; gasPrice: bigint }38interface AccountView { amount: string; locked: string; storage_usage: number }39interface KeyView { nonce: number; permission: unknown }40interface Fee { send_not_sir: number; execution: number }41interface TxView {42 final_execution_status: string; status: Record<string, unknown>;43 transaction: { hash: string; signer_id: string; receiver_id: string; nonce: number; actions: unknown[] };44 transaction_outcome: { block_hash: string };45 receipts?: { predecessor_id: string; receiver_id: string; receipt_id: string; receipt: { Action?: { actions: unknown[] } } }[];46 receipts_outcome: { id: string; block_hash: string; outcome: { executor_id: string; status: Record<string, unknown> } }[];47}48interface TransferRow { block_height: string; transaction_id: string | null; receipt_id: string; signer_id: string; account_id: string; asset_id: string }4950/** an answer from the node itself (not a transport failure); `kind` is nearcore's error cause, e.g. UNKNOWN_ACCOUNT */51class NearRpcError extends Error { constructor(public kind: string, message: string, public data: unknown) { super(message); } }52const isKind = (e: unknown, kind: string) => e instanceof NearRpcError && e.kind === kind;5354let idc = 0;55async function call<T>(url: string, method: string, params: unknown): Promise<T> {56 const r = await http(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++idc, method, params }) });57 const j = (await r.json().catch(() => null)) as { result?: T; error?: { message?: string; data?: unknown; cause?: { name?: unknown } } } | null;58 if (!j || typeof j !== 'object') throw new Error(`near rpc http ${r.status}`);59 if (j.error) {60 const kind = j.error.cause?.name;61 if (typeof kind === 'string' && r.status !== 429) throw new NearRpcError(kind, `${kind}: ${typeof j.error.data === 'string' ? j.error.data : JSON.stringify(j.error.data ?? '')}`.slice(0, 300), j.error.data);62 throw new Error(`near rpc: ${String(j.error.message ?? r.status).slice(0, 120)}`);63 }64 if (!r.ok || j.result === undefined || j.result === null) throw new Error(`near rpc http ${r.status}`);65 return j.result;66}6768const uint = (v: unknown, what: string): bigint => {69 if (typeof v === 'number' ? !Number.isSafeInteger(v) || v < 0 : typeof v !== 'string' || !/^(0|[1-9]\d{0,38})$/.test(v)) throw new Error(`near rpc: bad ${what}`);70 return BigInt(v as string | number);71};72const hashOf = (v: unknown, what: string): string => { try { nearDecodeHash(v as string); } catch { throw new Error(`near rpc: bad ${what}`); } return v as string; };73const max = (a: bigint, b: bigint) => (a > b ? a : b);74const near = (v: bigint) => formatUnits(v > 0n ? v : 0n, 24);75/** sum of the deposits when every action is a plain Transfer; null otherwise */76function transferSum(actions: unknown): bigint | null {77 if (!Array.isArray(actions) || actions.length === 0) return null;78 let sum = 0n;79 for (const a of actions) {80 const t = (a as { Transfer?: { deposit?: unknown } } | null)?.Transfer;81 if (!a || typeof a !== 'object' || Object.keys(a).length !== 1 || !t) return null;82 sum += uint(t.deposit, 'deposit');83 }84 return sum;85}86const succeeded = (status: Record<string, unknown> | undefined) => !!status && typeof status === 'object' && ('SuccessValue' in status || 'SuccessReceiptId' in status);8788export function nearAdapter(n: NetworkDef): Adapter {89 const sources = n.rpc;90 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; };91 /** every source must answer */92 const all = async <T>(f: (u: string) => Promise<T>, what: string): Promise<T[]> => {93 const rs = await Promise.allSettled(sources.map(f));94 const bad = rs.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;95 if (bad?.reason instanceof UserError) throw bad.reason;96 if (sources.length < 2 || bad) throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);97 return rs.map((r) => (r as PromiseFulfilledResult<T>).value);98 };99 const agree = <T>(vals: T[], what: string): T => {100 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);101 return vals[0]!;102 };103104 /** node status; throws unless the node is on this network (chain id and genesis hash) and synced */105 const status = async (u: string): Promise<{ height: number; time: number }> => {106 const s = await call<{ chain_id?: string; genesis_hash?: string; sync_info?: { latest_block_height?: number; latest_block_time?: string; syncing?: boolean } }>(u, 'status', []);107 if (s.chain_id !== 'mainnet' || s.genesis_hash !== n.chainRef) throw new Error('chain identity mismatch');108 const height = Number(uint(s.sync_info?.latest_block_height, 'height')), time = Date.parse(String(s.sync_info?.latest_block_time));109 if (s.sync_info?.syncing !== false || !Number.isFinite(time) || height <= 0) throw new Error('node is syncing');110 return { height, time: Math.floor(time / 1000) };111 };112 const header = async (u: string, ref: { finality: 'final' } | { block_id: string | number }): Promise<Header> => {113 const b = await call<{ header?: { height?: number; hash?: string; timestamp_nanosec?: string; gas_price?: string } }>(u, 'block', ref);114 const h = b.header;115 const height = Number(uint(h?.height, 'height')), gasPrice = uint(h?.gas_price, 'gas price');116 if (height <= 0) throw new Error('near rpc: bad height');117 return { height, hash: hashOf(h?.hash, 'block hash'), time: Number(uint(h?.timestamp_nanosec, 'timestamp') / 1_000_000_000n), gasPrice };118 };119 const account = async (u: string, id: string, ref: object): Promise<{ amount: string; locked: string; storage: string } | null> => {120 try {121 const a = await call<AccountView>(u, 'query', { request_type: 'view_account', account_id: id, ...ref });122 return { amount: uint(a.amount, 'balance').toString(), locked: uint(a.locked, 'locked balance').toString(), storage: uint(a.storage_usage, 'storage usage').toString() };123 } catch (e) { if (isKind(e, 'UNKNOWN_ACCOUNT')) return null; throw e; }124 };125 /** the wallet's own access key (an implicit account's key is its account id); null when the account or the key does not exist */126 const accessKey = async (u: string, id: string, ref: object): Promise<{ nonce: string; fullAccess: boolean } | null> => {127 const publicKey = 'ed25519:' + base58.encode(Uint8Array.from(id.match(/../g)!, (h) => parseInt(h, 16)));128 try {129 const k = await call<KeyView & { error?: unknown }>(u, 'query', { request_type: 'view_access_key', account_id: id, public_key: publicKey, ...ref });130 if (k.error !== undefined) { if (/does not exist/.test(String(k.error))) return null; throw new Error('near rpc: access key query failed'); }131 return { nonce: uint(k.nonce, 'nonce').toString(), fullAccess: k.permission === 'FullAccess' };132 } catch (e) { if (isKind(e, 'UNKNOWN_ACCESS_KEY') || isKind(e, 'UNKNOWN_ACCOUNT')) return null; throw e; }133 };134 /** the protocol parameters that decide the cost of a transfer, as strings (compared across sources) */135 const paramCache = new Map<string, { at: number; v: Record<string, string> }>();136 const params = async (u: string, ref: object): Promise<Record<string, string>> => {137 const c = paramCache.get(u);138 if (c && Date.now() - c.at < 600_000) return c.v;139 const p = await call<{ transaction_validity_period?: number; runtime_config?: { storage_amount_per_byte?: string; min_gas_purchase_price?: string; account_creation_charge?: string;140 transaction_costs?: { action_receipt_creation_config?: Fee; action_creation_config?: { transfer_cost?: Fee; create_account_cost?: Fee; add_key_cost?: { full_access_cost?: Fee } } } } }>(u, 'EXPERIMENTAL_protocol_config', ref);141 const rc = p.runtime_config, tc = rc?.transaction_costs, ac = tc?.action_creation_config;142 const fee = (f: Fee | undefined, what: string) => ({ [`${what}.send`]: uint(f?.send_not_sir, what).toString(), [`${what}.exec`]: uint(f?.execution, what).toString() });143 const v = {144 validity: uint(p.transaction_validity_period, 'validity period').toString(), storagePerByte: uint(rc?.storage_amount_per_byte, 'storage price').toString(),145 minGasPurchasePrice: uint(rc?.min_gas_purchase_price, 'minimum gas purchase price').toString(), accountCreationCharge: uint(rc?.account_creation_charge, 'account creation charge').toString(),146 ...fee(tc?.action_receipt_creation_config, 'receipt'), ...fee(ac?.transfer_cost, 'transfer'), ...fee(ac?.create_account_cost, 'createAccount'), ...fee(ac?.add_key_cost?.full_access_cost, 'addKey'),147 };148 paramCache.set(u, { at: Date.now(), v });149 return v;150 };151 /** transaction lookup; null only for the node's own "unknown transaction" answer */152 const txStatus = async (u: string, txid: string, sender: string, withReceipts = false): Promise<TxView | null> => {153 try {154 const t = await call<TxView>(u, withReceipts ? 'EXPERIMENTAL_tx_status' : 'tx', { tx_hash: txid, sender_account_id: sender, wait_until: 'NONE' });155 if (t.transaction?.hash !== txid || t.transaction.signer_id !== sender || typeof t.final_execution_status !== 'string' || !Array.isArray(t.receipts_outcome)) throw new Error('near rpc: bad transaction record');156 return t;157 } catch (e) { if (isKind(e, 'UNKNOWN_TRANSACTION')) return null; throw e; }158 };159 const baseHeights = new Map<string, number>(), trackMemo = new Map<string, { at: number; v: Tracked }>(), verified = new Map<string, Deposit | null>();160161 return {162 async health(): Promise<Health> {163 const endpoints = await Promise.all(sources.map(async (url) => {164 const t = Date.now();165 try {166 const s = await status(url); // verifies chain id and genesis hash on every check167 if (Date.now() / 1000 - s.time > STALE_SEC) throw new Error('stale');168 return { url: hostOf(url), ok: true, ms: Date.now() - t, h: s.height as number | null };169 } catch { return { url: hostOf(url), ok: false, ms: null, h: null as number | null }; }170 }));171 const ok = endpoints.length >= 2 && endpoints.every((e) => e.ok);172 return { network: n.id, ok, readable: endpoints.some((e) => e.ok), identityVerified: ok, height: Math.max(0, ...endpoints.map((e) => e.h ?? 0)) || null,173 endpoints: endpoints.map(({ h: _h, ...e }) => e), checkedAt: new Date().toISOString() };174 },175176 async balances(addr) {177 const a = await any((u) => account(u, addr, { finality: 'final' }));178 return { native: a ? BigInt(a.amount) : 0n }; // an implicit account that never received NEAR does not exist and holds nothing179 },180181 async prepare(d): Promise<Prepared> {182 if (d.asset !== 'native') throw new UserError('unsupported_asset', 'only NEAR can be sent on NEAR Protocol in this version');183 let kind: 'implicit' | 'named';184 try { if (nearAccountKind(d.from) !== 'implicit') throw new Error('the source must be an implicit account'); kind = nearAccountKind(d.to); }185 catch (e) { throw new UserError('bad_address', (e as Error).message); }186 if (d.from === d.to) throw new UserError('bad_address', 'destination is this wallet');187 if (d.amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');188 if (d.memo !== undefined) throw new UserError('bad_memo', 'NEAR transfers do not carry a memo');189 const warnings: string[] = [];190191 // 1. both sources are on mainnet (chain id + genesis hash) and report a final block; the older of the two is the reference192 const heads = await all(async (u) => { await status(u); return header(u, { finality: 'final' }); }, 'final block');193 const ref = heads.reduce((a, b) => (b.height < a.height ? b : a));194 if (Math.max(...heads.map((h) => h.height)) - ref.height > MAX_FINAL_GAP) throw new UserError('rpc_disagreement', `${n.name} data sources are not at the same block; transfer refused`, 503);195 const now = Math.floor(Date.now() / 1000);196 if (now - ref.time > MAX_BLOCK_AGE_SEC || ref.time - now > MAX_CLOCK_SKEW_SEC) throw new UserError('rpc_degraded', `${n.name} data sources are behind (or the server clock is off); transfer refused`, 503);197 // 2. every source has that exact block at that height on its canonical chain (so it is final everywhere)198 const canon = await all(async (u) => (await header(u, { block_id: ref.height })).hash, 'reference block');199 if (canon.some((h) => h !== ref.hash)) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (reference block); transfer refused`, 503);200 // 3. one snapshot per source, all at the reference block; they must be identical201 const at = { block_id: ref.hash };202 const snap = agree(await all(async (u) => {203 const [src, key, dst, p] = await Promise.all([account(u, d.from, at), accessKey(u, d.from, at), account(u, d.to, at), params(u, at)]);204 return { src, key, dstExists: dst !== null, p };205 }, 'account state'), 'account state');206207 const { src, key, p } = snap;208 if (!src) throw new UserError('insufficient_funds', `this ${n.name} account is not activated yet: it exists on-chain only after it first receives ${n.native.symbol}`);209 if (!key || !key.fullAccess) throw new UserError('unsupported_account', 'the key of this wallet is not a full-access key of its NEAR account; it cannot send');210 if (kind === 'named' && !snap.dstExists) throw new UserError('bad_destination', `the account ${d.to} does not exist on ${n.name}: a transfer to it would fail. Check the name.`);211 const creates = kind === 'implicit' && !snap.dstExists;212 if (creates) warnings.push('near_creates_account');213214 // protocol parameters: the live costs must stay within the bounds the signer enforces (see the plugin)215 const g = (k: string) => BigInt(p[k]!);216 const send = g('receipt.send') + g('transfer.send') + (kind === 'implicit' ? g('createAccount.send') + g('addKey.send') : 0n);217 const exec = g('receipt.exec') + g('transfer.exec') + (kind === 'implicit' ? g('createAccount.exec') + g('addKey.exec') : 0n);218 const gasPrice = ref.gasPrice, minBuy = g('minGasPurchasePrice');219 if (send + exec > (kind === 'implicit' ? NEAR_GAS_BOUND_IMPLICIT : NEAR_GAS_BOUND_NAMED) || gasPrice <= 0n || gasPrice > NEAR_MAX_GAS_PRICE || minBuy > NEAR_MAX_GAS_PRICE || g('validity') !== BigInt(VALIDITY_BLOCKS))220 throw new UserError('rpc_degraded', `${n.name} fee parameters changed on-chain; the wallet must be updated before sending`, 503);221 const maxFee = nearFeeCeiling(d.to);222223 // what the network takes when it accepts the transaction: gas burnt now at the block price, the rest bought at224 // max(block price, minimum purchase price) and partly refunded later. Checked with the price doubled as headroom.225 const price = gasPrice * 2n > NEAR_MAX_GAS_PRICE ? NEAR_MAX_GAS_PRICE : gasPrice * 2n;226 const upfront = send * price + exec * max(price, minBuy);227 const storage = BigInt(src.storage);228 const reserve = storage <= ZERO_BALANCE_STORAGE ? 0n : max(0n, storage * g('storagePerByte') - BigInt(src.locked));229 const spendable = BigInt(src.amount) - reserve - upfront;230 if (spendable < d.amount) throw new UserError('insufficient_funds',231 `insufficient ${n.native.symbol}: ${near(spendable)} can be sent (${near(upfront)} is set aside for the network fee${reserve > 0n ? `, ${near(reserve)} stays locked for account storage` : ''})`);232233 // expected final cost after refunds: all gas at the block price, plus the account creation charge when the transfer creates the account234 const createExecCost = g('createAccount.exec') * gasPrice;235 const estimate = (send + exec) * gasPrice + (creates ? max(0n, g('accountCreationCharge') - createExecCost) : 0n);236 const nonce = BigInt(key.nonce) + 1n;237 return { section: { x: { nonce: nonce.toString(), blockHash: ref.hash } }, maxFee, feeEstimate: estimate > maxFee ? maxFee : estimate, warnings, ttlSec: TTL_SEC };238 },239240 async broadcast(raw) {241 const { txid, signerId } = nearSignedTxInfo(raw);242 // INCLUDED: the node validates the transaction first (and reports why it refuses it), then answers once it is in a block.243 // Re-sending an included transaction is answered as success by the node itself.244 const rs = await Promise.allSettled(sources.map(async (u) => {245 try { await call<unknown>(u, 'send_tx', { signed_tx_base64: raw, wait_until: 'INCLUDED' }); }246 catch (e) {247 if (isKind(e, 'TIMEOUT_ERROR')) return; // accepted, not in a block yet248 if (e instanceof NearRpcError) throw new Error(`rejected by the network: ${e.message}`);249 throw e;250 }251 }));252 if (rs.some((r) => r.status === 'fulfilled')) return;253 // "already known": the chain is the judge254 const known = await Promise.allSettled(sources.map((u) => txStatus(u, txid, signerId)));255 if (known.some((k) => k.status === 'fulfilled' && k.value !== null)) return;256 const reasons = rs.map((r) => String((r as PromiseRejectedResult).reason?.message ?? 'broadcast failed'));257 throw new Error(reasons.find((m) => m.startsWith('rejected')) ?? reasons[0]);258 },259260 async track(p): Promise<Tracked> {261 const from = String(p.intent?.from ?? ''), to = String(p.intent?.to ?? ''), x = p.intent?.x ?? {};262 if (!/^[1-9]\d{0,19}$/.test(String(x.nonce)) || nearAccountKind(from) !== 'implicit') throw new Error('intent has no NEAR parameters');263 const txNonce = BigInt(x.nonce), refHash = hashOf(x.blockHash, 'reference block');264 const memo = trackMemo.get(p.txid);265 if (memo && Date.now() - memo.at < 60_000) return memo.v;266 trackMemo.delete(p.txid);267268 const verdict = (t: TxView): { final: boolean; ok: boolean | null; blockRef: string; error?: string } => {269 if (t.transaction.receiver_id !== to || transferSum(t.transaction.actions)?.toString() !== String(p.intent.amount)) throw new Error('near rpc: transaction does not match the approved intent');270 const failure = (t.status as { Failure?: unknown } | undefined)?.Failure;271 return { final: t.final_execution_status === 'FINAL', ok: failure !== undefined ? false : t.status && 'SuccessValue' in t.status ? true : null,272 blockRef: hashOf(t.transaction_outcome?.block_hash, 'block hash'), ...(failure !== undefined ? { error: JSON.stringify(failure).slice(0, 160) } : {}) };273 };274 const FAILED_TEXT = 'included in a block but the transfer failed; the amount was returned to this wallet, only the network fee was charged';275276 // per source: final block FIRST, then the lookup — a miss then covers every block up to that position277 const rs = await Promise.allSettled(sources.map(async (u) => { const head = await header(u, { finality: 'final' }); const t = await txStatus(u, p.txid, from); return { u, head, v: t ? verdict(t) : null }; }));278 const up = rs.flatMap((r) => (r.status === 'fulfilled' ? [r.value] : []));279 const allUp = up.length === sources.length && up.length >= 2;280 const found = up.filter((s) => s.v !== null);281 if (found.length > 0) {282 const v = found[0]!.v!;283 // success or failure is only announced once every reachable source shows the same final result284 if (found.length < up.length || found.some((s) => !s.v!.final || s.v!.ok === null || s.v!.ok !== v.ok)) return { state: 'confirming', confirmations: 1, blockRef: v.blockRef };285 if (v.ok) return { state: 'finalized', confirmations: n.minConf, blockRef: v.blockRef };286 if (!allUp) return { state: 'confirming', confirmations: 1, blockRef: v.blockRef };287 return { state: 'failed', confirmations: 0, blockRef: v.blockRef, error: `${FAILED_TEXT} (${v.error})` };288 }289 // once seen in a block and now unknown again: the block was not kept (the state machine has a name for that)290 const pending: Tracked = { state: p.blockRef ? 'reorged' : 'broadcast', confirmations: 0, rebroadcast: true };291 if (!allUp) return pending;292293 // Unknown to both sources. The access key nonce tells whether it can still be, or ever was, included:294 // an included transaction sets the key's nonce to its own, and a nonce is never accepted twice.295 const keys = await Promise.allSettled(sources.map((u) => accessKey(u, from, { finality: 'final' })));296 if (keys.some((k) => k.status === 'rejected')) return pending;297 const nonces = keys.map((k) => { const v = (k as PromiseFulfilledResult<{ nonce: string } | null>).value; return v ? BigInt(v.nonce) : null; });298 const age = Date.now() / 1000 - p.broadcastAt;299 const remember = (v: Tracked): Tracked => { if (age > 300) { trackMemo.set(p.txid, { at: Date.now(), v }); if (trackMemo.size > 200) trackMemo.delete(trackMemo.keys().next().value!); } return v; };300 if (nonces.some((k) => k !== null && k >= txNonce)) {301 // The nonce is used: by this transaction (included, but the regular nodes no longer keep it) or by another one.302 if (age < 120) return pending;303 let old: TxView | null;304 try { old = await txStatus(NEAR_ARCHIVAL_RPC, p.txid, from); } catch { return remember({ ...pending, rebroadcast: false }); }305 if (old) {306 const v = verdict(old);307 if (!v.final || v.ok === null) return { state: 'confirming', confirmations: 1, blockRef: v.blockRef };308 return v.ok ? { state: 'finalized', confirmations: n.minConf, blockRef: v.blockRef } : { state: 'failed', confirmations: 0, blockRef: v.blockRef, error: `${FAILED_TEXT} (${v.error})` };309 }310 return { state: p.blockRef ? 'failed' : 'replaced', confirmations: 0, error: 'its nonce was used by another transaction of this account; it can no longer be included and moved no funds' };311 }312 // The nonce is unused on both sources at a final block: the transaction has not been included. It can no longer be once313 // the chain is past its reference block by the validity period.314 let base = baseHeights.get(refHash);315 if (base === undefined) {316 const hs = await Promise.allSettled(sources.map((u) => header(u, { block_id: refHash })));317 const ok = hs.flatMap((h) => (h.status === 'fulfilled' ? [h.value.height] : []));318 if (ok.length === sources.length && ok.every((h) => h === ok[0])) { base = ok[0]!; baseHeights.set(refHash, base); if (baseHeights.size > 200) baseHeights.delete(baseHeights.keys().next().value!); }319 // public nodes drop blocks after about a day; three days after the approval the reference block is expired for certain320 else if (hs.every((h) => h.status === 'rejected' && isKind(h.reason, 'UNKNOWN_BLOCK')) && Date.now() / 1000 - Number(p.intent.createdAt) > 3 * 86_400) base = 0;321 else return remember(pending);322 }323 if (up.every((s) => s.head.height > base! + VALIDITY_BLOCKS + EXPIRY_MARGIN_BLOCKS)) return { state: 'failed', confirmations: 0, error: 'not included before its reference block expired; no funds moved' };324 return remember(pending);325 },326327 async deposits(addr, cursor) {328 const finalHeight = (await any((u) => header(u, { finality: 'final' }))).height; // read first: the listing below cannot be ahead of it329 /** Re-verify one indexed row against RPC. null = definitively not an incoming transfer; throws when no source can tell. */330 const verify = async (row: TransferRow): Promise<Deposit | null> => {331 const txid = row.transaction_id!, receiptId = row.receipt_id;332 let last: unknown = null;333 const recent = finalHeight - Number(row.block_height) < RECENT_BLOCKS;334 for (const u of recent ? [...sources, NEAR_ARCHIVAL_RPC] : [NEAR_ARCHIVAL_RPC, ...sources]) {335 try {336 const t = await txStatus(u, txid, row.signer_id, true);337 if (!t) continue; // this node no longer keeps (or never saw) the transaction338 if (t.final_execution_status !== 'FINAL') throw new Error('not final yet');339 const idx = (t.receipts ?? []).findIndex((r) => r.receipt_id === receiptId);340 const rc = t.receipts?.[idx], out = t.receipts_outcome.find((o) => o.id === receiptId);341 if (!rc || !out) return null;342 const amount = transferSum(rc.receipt?.Action?.actions);343 if (rc.receiver_id !== addr || out.outcome?.executor_id !== addr || rc.predecessor_id === 'system' || rc.predecessor_id === addr || amount === null || amount <= 0n || !succeeded(out.outcome.status)) return null;344 const block = await header(u, { block_id: hashOf(out.block_hash, 'block hash') });345 return { txid, idx, asset: 'native', amount, from: rc.predecessor_id, block: block.height, time: block.time };346 } catch (e) { last = e; }347 }348 if (last) throw last;349 return null; // unknown to every source, archival included350 };351352 const rows: TransferRow[] = [];353 let token: string | null = null, complete = cursor === null;354 for (let page = 0; page < (cursor === null ? 1 : SCAN_PAGES); page++) {355 const r = await http(NEAR_TRANSFERS_API, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },356 body: JSON.stringify({ account_id: addr, direction: 'receiver', asset_id: 'native:near', ignore_system: true, desc: true, limit: cursor === null ? FIRST_SCAN_ROWS : 100, ...(token ? { resume_token: token } : {}) }) });357 if (!r.ok) throw new Error(`near transfers index http ${r.status}`);358 const j = (await r.json()) as { transfers?: TransferRow[]; resume_token?: string | null };359 if (!Array.isArray(j.transfers)) throw new Error('near transfers index: bad answer');360 const fresh = j.transfers.filter((t) => cursor === null || Number(uint(t.block_height, 'height')) > cursor);361 rows.push(...fresh);362 token = j.resume_token ?? null;363 if (fresh.length < j.transfers.length || !token || j.transfers.length === 0) { complete = true; break; }364 }365 const items: Deposit[] = [], seen = new Set<string>();366 const wellFormed = (row: TransferRow) => { try { nearDecodeHash(row.transaction_id!); nearDecodeHash(row.receipt_id); return /^[a-z0-9._-]{2,64}$/.test(row.signer_id) && /^\d{1,15}$/.test(row.block_height); } catch { return false; } };367 let held = Infinity; // lowest block of a row that could not be checked this time: the cursor must stay below it368 for (const row of rows) {369 if (row.account_id !== addr || row.asset_id !== 'native:near' || !wellFormed(row) || seen.has(row.receipt_id)) continue;370 seen.add(row.receipt_id);371 const key = `${addr}:${row.receipt_id}`;372 let d = verified.get(key);373 if (d === undefined) {374 try { d = await verify(row); }375 catch { // no source could answer: report nothing for this row and come back to it, unless it is hopelessly old376 if (cursor !== null && finalHeight - Number(row.block_height) < GIVE_UP_BLOCKS) held = Math.min(held, Number(row.block_height));377 continue;378 }379 verified.set(key, d);380 if (verified.size > 2000) verified.delete(verified.keys().next().value!);381 }382 if (d && d.block <= finalHeight) items.push(d);383 }384 // The index may lag behind the chain: keep re-reading a window behind the final block (inserts are idempotent).385 // When more rows were pending than one scan reads, the cursor stays where it was so that nothing is skipped.386 const next = complete ? Math.max(cursor ?? 0, Math.min(finalHeight - SCAN_OVERLAP_BLOCKS, held - 1)) : cursor!;387 return { items, cursor: next, finalizedHeight: finalHeight };388 },389 };390}391