SPB Git

Repository Public

near.ts 30.1 KB 391 lines typescript

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
Files main
Menu

Theme