SPB Git

Repository Public

cosmos.ts 24.0 KB 335 lines typescript

1// Cosmos SDK adapter (Cosmos Hub): native coin only. LCD/REST (gRPC-gateway) over HTTPS, two independent sources.2// Everything that feeds the signature (account number, sequence, spendable balance, gas price, destination account type)3// is read from both sources at one and the same block height (`x-cosmos-block-height`), whose hash both sources must4// agree on, and must be identical; gas comes from a dry run on both sources. Otherwise the transfer is refused.5//6// Limits (stated, not worked around):7// - Public REST nodes prune early history: no early block hash can be pinned. Identity = the chain id reported by both8//   sources (it is also signed into every transaction) + both sources agreeing on the hash of a recent block.9// - The REST API does not expose the mempool: a transfer of ours that is broadcast but not yet in a block is invisible10//   here; a second transfer prepared meanwhile carries the same sequence and is rejected by the chain (nothing is lost).11// - Deposits come from the nodes' transaction index (`transfer.recipient`), which public nodes prune after a few weeks12//   and which can only be read newest-first: at most 250 new transactions are examined per pass. Balances do not depend on it.13// - Fees follow x/feemarket (Cosmos Hub). A chain without that module needs another fee source before it is registered.14import { cosmosAddressBytes, cosmosMemo, cosmosSimulationTx, cosmosTxid, type Intent, type NetworkDef } from '@spw/core';15import { UserError } from '../config.ts';16import { hostOf, http, type Adapter, type Deposit, type Health, type Prepared, type Tracked } from './rpc.ts';1718/** timeout_height = reference block + 30 ≈ 170 s at 5.7 s per block; the approval window (90 s) leaves ≥ 10 blocks for inclusion */19const BLOCK_WINDOW = 30, TTL_SEC = 90;20/** sources further apart than this are not looking at the same chain tip */21const MAX_TIP_SPREAD = 5;22/** a tip older than this means the node is stalled or still syncing */23const MAX_TIP_AGE_SEC = 180;24/**25 * Gas. `simulate` does not run the fee market's post-handler, so it under-reports: a plain send simulates at ≈ 86 000 gas and26 * really uses 100 000–119 000 (40 recent mainnet sends, 2026-11-10). Limit = max(200 000, 2 × simulated); the fee is paid on27 * the limit, not on the gas used (200 000 gas ≈ 0.0015 ATOM), so the generous limit costs a fraction of a cent.28 */29const GAS_FLOOR = 200_000n, GAS_CEIL = 1_000_000n;30/** gas price offered = 1.5 × the price required at the reference block (the fee market moves by at most 12.5 % per block) */31const PRICE_NUM = 3n, PRICE_DEN = 2n;32/** the fee offered never exceeds this (base units); equals the default policy ceiling */33const FEE_CAP: Record<string, bigint> = { 'cosmoshub-4': 50_000n };34/** not found by either source this many blocks after the timeout, with the sequence consumed: another transaction took its place */35const REPLACED_AFTER = 20;36const DEC = 10n ** 18n;37const NOT_FOUND = 5; // gRPC status38const BASE_ACCOUNT = '/cosmos.auth.v1beta1.BaseAccount', MODULE_ACCOUNT = '/cosmos.auth.v1beta1.ModuleAccount';39const PAGE = 50, MAX_PAGES = 5;4041interface BlockRes { block_id?: { hash?: string }; block?: { header?: { height?: string; chain_id?: string; time?: string } } }42interface AccountRes { account?: { '@type'?: string; account_number?: string; sequence?: string; base_account?: { account_number?: string; sequence?: string } } }43interface TxResponse { height?: string; txhash?: string; code?: number; codespace?: string; raw_log?: string; timestamp?: string; events?: { type?: string; attributes?: { key?: string; value?: string }[] }[] }44interface Account { type: string; accountNumber: string; sequence: string }45type Tip = { height: number; hash: string };4647const fail = (message: string, extra: object = {}) => Object.assign(new Error(message), extra);48const grpcOf = (e: unknown): number | undefined => (e as { grpc?: number }).grpc;49const uintStr = (v: unknown, what: string): string => { if (typeof v !== 'string' || !/^(0|[1-9]\d{0,18})$/.test(v)) throw new Error(`rest: bad ${what}`); return v; };50/** "0.005000000000000000" → 18-decimal fixed point */51function dec(v: unknown, what: string): bigint {52  const m = typeof v === 'string' ? /^(\d{1,20})(?:\.(\d{1,18}))?$/.exec(v) : null;53  if (!m) throw new Error(`rest: bad ${what}`);54  return BigInt(m[1]!) * DEC + BigInt((m[2] ?? '').padEnd(18, '0'));55}56const fmt = (v: bigint, decimals: number) => { const d = 10n ** BigInt(decimals); const f = (v % d).toString().padStart(decimals, '0').replace(/0+$/, ''); return `${v / d}${f ? '.' + f : ''}`; };5758/** One REST call. `height` pins the state the node answers from; the answer must say it used exactly that height. */59async function rest<T>(base: string, path: string, opt: { height?: number; body?: unknown } = {}): Promise<T> {60  const r = await http(base + path, { method: opt.body === undefined ? 'GET' : 'POST',61    headers: { accept: 'application/json', ...(opt.body === undefined ? {} : { 'content-type': 'application/json' }), ...(opt.height === undefined ? {} : { 'x-cosmos-block-height': String(opt.height) }) },62    ...(opt.body === undefined ? {} : { body: JSON.stringify(opt.body) }) });63  let j: unknown;64  try { j = JSON.parse(await r.text()); } catch { throw fail(`rest http ${r.status}`, { status: r.status }); }65  if (!j || typeof j !== 'object' || Array.isArray(j)) throw fail('rest: empty result', { status: r.status });66  const e = j as { code?: unknown; message?: unknown };67  // an error body of the gateway is { code, message, details }; anything not 2xx is an error too68  if (!r.ok || (typeof e.code === 'number' && typeof e.message === 'string' && 'details' in e))69    throw fail(typeof e.message === 'string' ? e.message : `rest http ${r.status}`, { status: r.status, ...(typeof e.code === 'number' && typeof e.message === 'string' ? { grpc: e.code } : {}) });70  if (opt.height !== undefined && r.headers.get('x-cosmos-block-height') !== String(opt.height)) throw fail('rest: answered for another height');71  return j as T;72}7374export function cosmosAdapter(n: NetworkDef): Adapter {75  const c = n.cosmos;76  if (!c) throw new Error(`no Cosmos SDK parameters for network ${n.id}`);77  const feeCap = FEE_CAP[n.chainRef];78  if (feeCap === undefined) throw new Error(`no fee ceiling for Cosmos SDK network ${n.id}`);79  const sources = n.rpc;80  if (sources.length < 2) throw new Error(`${n.name} needs two independent sources`);81  const { denom, bech32: prefix } = c;82  let identity = false;8384  /** First source that gives an answer about the chain wins (display-only reads). */85  const any = async <T>(f: (u: string) => Promise<T>): Promise<T> => {86    let last: unknown;87    for (const u of sources) { try { return await f(u); } catch (e) { last = e; if (grpcOf(e) === NOT_FOUND) throw e; } }88    throw last;89  };90  /** Every source must answer; used for anything that feeds a signature or declares a transfer dead. */91  const all = async <T>(what: string, f: (u: string) => Promise<T>): Promise<T[]> => {92    const rs = await Promise.allSettled(sources.map(f));93    return rs.map((r) => {94      if (r.status === 'fulfilled') return r.value;95      if (r.reason instanceof UserError) throw r.reason;96      throw new UserError('rpc_degraded', `${n.name} data source unavailable (${what}); transfer refused`, 503);97    });98  };99  const agree = <T>(what: string, vs: T[]): T => {100    for (const v of vs) if (JSON.stringify(v) !== JSON.stringify(vs[0])) throw new UserError('rpc_disagreement', `${n.name} data sources disagree (${what}); transfer refused`, 503);101    return vs[0]!;102  };103104  const blockOf = (r: BlockRes, want?: number): Tip & { time: number } => {105    const h = r.block?.header, height = Number(h?.height), hash = r.block_id?.hash, time = Date.parse(String(h?.time));106    if (h?.chain_id !== n.chainRef) throw new Error('identity: chain id mismatch');107    if (!Number.isSafeInteger(height) || height < 1 || (want !== undefined && height !== want) || typeof hash !== 'string' || !/^[A-Za-z0-9+/]{43}=$/.test(hash) || !Number.isFinite(time)) throw new Error('rest: bad block');108    return { height, hash, time };109  };110  /** Latest block of a source: right chain id, and recent (a stalled or syncing node is not a source). */111  const tip = async (u: string): Promise<Tip> => {112    const b = blockOf(await rest<BlockRes>(u, '/cosmos/base/tendermint/v1beta1/blocks/latest'));113    if (Date.now() - b.time > MAX_TIP_AGE_SEC * 1000) throw new Error('rest: node is behind');114    return { height: b.height, hash: b.hash };115  };116  const blockAt = async (u: string, height: number): Promise<string> => blockOf(await rest<BlockRes>(u, `/cosmos/base/tendermint/v1beta1/blocks/${height}`), height).hash;117  const checkIdentity = async (u: string): Promise<void> => {118    const r = await rest<{ default_node_info?: { network?: string } }>(u, '/cosmos/base/tendermint/v1beta1/node_info');119    if (r.default_node_info?.network !== n.chainRef) throw new Error('identity: chain id mismatch');120  };121  /** One reference block: the lower of the two tips, with the same hash on both sources. */122  const reference = async (tips: Tip[]): Promise<number> => {123    const height = Math.min(...tips.map((t) => t.height));124    if (Math.max(...tips.map((t) => t.height)) - height > MAX_TIP_SPREAD) throw new UserError('rpc_disagreement', `${n.name} data sources are not at the same block; transfer refused`, 503);125    agree('reference block', await all('block', (u) => blockAt(u, height)));126    return height;127  };128129  /** Account at one height (latest when omitted); null when it does not exist on chain. */130  const account = async (u: string, addr: string, height?: number): Promise<Account | null> => {131    try {132      const a = (await rest<AccountRes>(u, `/cosmos/auth/v1beta1/accounts/${addr}`, height === undefined ? {} : { height })).account;133      const type = a?.['@type'];134      if (!a || typeof type !== 'string') throw new Error('rest: bad account');135      if (type !== BASE_ACCOUNT) return { type, accountNumber: '', sequence: '' };136      return { type, accountNumber: uintStr(a.account_number, 'account_number'), sequence: uintStr(a.sequence, 'sequence') };137    } catch (e) { if (grpcOf(e) === NOT_FOUND) return null; throw e; }138  };139  const spendable = async (u: string, addr: string, height?: number): Promise<bigint> => {140    const r = await rest<{ balance?: { denom?: string; amount?: string } }>(u, `/cosmos/bank/v1beta1/spendable_balances/${addr}/by_denom?denom=${encodeURIComponent(denom)}`, height === undefined ? {} : { height });141    if (r.balance?.denom !== denom || typeof r.balance.amount !== 'string' || !/^(0|[1-9]\d{0,38})$/.test(r.balance.amount)) throw new Error('rest: bad balance');142    return BigInt(r.balance.amount);143  };144  /** Gas price required by consensus at a height (x/feemarket), 18-decimal fixed point. */145  const marketPrice = async (u: string, height: number): Promise<bigint> => {146    const r = await rest<{ price?: { denom?: string; amount?: string } }>(u, `/feemarket/v1/gas_price/${encodeURIComponent(denom)}`, { height });147    if (r.price?.denom !== denom) throw new Error('rest: bad gas price');148    return dec(r.price.amount, 'gas price');149  };150  /** The node's own mempool floor ("0.005uatom,…"); 0 when it has none for this denom. Per node, so not subject to agreement. */151  const nodeFloor = async (u: string): Promise<bigint> => {152    const r = await rest<{ minimum_gas_price?: string }>(u, '/cosmos/base/node/v1beta1/config');153    if (typeof r.minimum_gas_price !== 'string') throw new Error('rest: bad node config');154    for (const part of r.minimum_gas_price.split(',')) { const m = /^(\d+(?:\.\d+)?)(.+)$/.exec(part.trim()); if (m && m[2] === denom) return dec(m[1], 'minimum gas price'); }155    return 0n;156  };157  const lookup = async (u: string, txid: string): Promise<TxResponse | null> => {158    try {159      const t = (await rest<{ tx_response?: TxResponse }>(u, `/cosmos/tx/v1beta1/txs/${txid}`)).tx_response;160      if (!t || String(t.txhash ?? '').toUpperCase() !== txid.toUpperCase() || !(Number(t.height) > 0) || typeof t.code !== 'number') throw new Error('rest: tx answered for another hash');161      return t;162    } catch (e) { if (grpcOf(e) === NOT_FOUND) return null; throw e; }163  };164165  return {166    async health(): Promise<Health> {167      const endpoints = await Promise.all(sources.map(async (url) => {168        const t = Date.now();169        try {170          const tp = await tip(url);171          if (!identity) await checkIdentity(url);172          return { url: hostOf(url), ok: true, ms: Date.now() - t, tip: tp as Tip | null };173        } catch { return { url: hostOf(url), ok: false, ms: null, tip: null as Tip | null }; }174      }));175      const ok = endpoints.every((e) => e.ok);176      if (ok && !identity) { try { await reference(endpoints.map((e) => e.tip!)); identity = true; } catch { /* stays unverified */ } }177      return { network: n.id, ok: ok && identity, readable: endpoints.some((e) => e.ok), identityVerified: identity, height: Math.max(0, ...endpoints.map((e) => e.tip?.height ?? 0)) || null,178        endpoints: endpoints.map(({ tip: _t, ...e }) => e), checkedAt: new Date().toISOString() };179    },180181    async balances(addr) {182      cosmosAddressBytes(prefix, addr);183      return { native: await any((u) => spendable(u, addr)) }; // an account that does not exist yet reads as 0184    },185186    async prepare(d): Promise<Prepared> {187      if (d.asset !== 'native') throw new UserError('unsupported_asset', `only ${n.native.symbol} can be sent on ${n.name}`);188      if (d.amount <= 0n) throw new UserError('bad_amount', 'amount must be greater than zero');189      try { cosmosMemo(d.memo); } catch (e) { throw new UserError('bad_memo', (e as Error).message); }190      try { cosmosAddressBytes(prefix, d.from); cosmosAddressBytes(prefix, d.to); } catch (e) { throw new UserError('bad_address', (e as Error).message); }191      if (d.from === d.to) throw new UserError('bad_address', 'destination is this wallet');192      if (!identity) await all('identity', checkIdentity);193194      // one reference block, present on both sources with the same hash195      const ref = await reference(await all('block', tip));196      identity = true;197198      const [srcs, dsts, bals, prices, floors] = await Promise.all([199        all('account', (u) => account(u, d.from, ref)),200        all('account', (u) => account(u, d.to, ref)),201        all('balance', (u) => spendable(u, d.from, ref)),202        all('gas price', (u) => marketPrice(u, ref)),203        all('node config', nodeFloor),204      ]);205      const src = agree('source account', srcs), dst = agree('destination account', dsts);206      const balance = BigInt(agree('balance', bals.map(String)));207      let price = BigInt(agree('gas price', prices.map(String)));208      for (const f of floors) if (f > price) price = f;209      if (price <= 0n) throw new UserError('rpc_degraded', `${n.name} fee data unusable; transfer refused`, 503);210211      if (!src) throw new UserError('insufficient_funds', `this wallet's ${n.name} account does not exist on chain yet: it has never received funds`);212      if (src.type !== BASE_ACCOUNT) throw new UserError('unsupported_account', `this ${n.name} account is not a plain account (${src.type.split('.').pop()}); this wallet cannot send from it`);213      if (dst && dst.type === MODULE_ACCOUNT) throw new UserError('bad_destination', 'the destination is a module account of the chain; transfers to it are refused');214      // a destination that does not exist yet is created by the transfer; nothing stays locked in it215216      const feeFor = (gas: bigint) => (gas * price * PRICE_NUM + PRICE_DEN * DEC - 1n) / (PRICE_DEN * DEC);217      const check = (fee: bigint) => {218        if (fee > feeCap) throw new UserError('fee_too_high', `the network fee (${fmt(fee, n.native.decimals)} ${n.native.symbol}) is above this wallet's ceiling; retry later`);219        if (balance < d.amount + fee) throw new UserError('insufficient_funds', `insufficient ${n.native.symbol}: the amount plus the network fee (${fmt(fee, n.native.decimals)} ${n.native.symbol}) exceeds the spendable balance (${fmt(balance, n.native.decimals)} ${n.native.symbol})`);220      };221      const timeoutHeight = ref + BLOCK_WINDOW;222      const section = (gas: bigint, fee: bigint) => ({ account_number: src.accountNumber, sequence: src.sequence, gas_limit: gas.toString(), fee_amount: fee.toString(), timeout_height: String(timeoutHeight) });223      let gas = GAS_FLOOR, fee = feeFor(gas);224      check(fee);225226      // dry run of this exact transfer (no public key, no signature: it cannot be broadcast) on the latest state of both sources227      const draft: Intent = { v: 1, proposalId: '00000000-0000-4000-8000-000000000000', keyId: 'simulation', createdAt: 1, expiresAt: 2, policyVersion: 0, network: n.id, chainRef: n.chainRef,228        asset: 'native', symbol: n.native.symbol, decimals: n.native.decimals, amount: d.amount.toString(), from: d.from, to: d.to, maxFee: fee.toString(),229        ...(d.memo !== undefined ? { memo: d.memo } : {}), x: section(gas, fee) };230      const tx_bytes = cosmosSimulationTx(draft, n);231      const used = await all('simulation', async (u) => {232        try {233          const r = await rest<{ gas_info?: { gas_used?: string } }>(u, '/cosmos/tx/v1beta1/simulate', { body: { tx_bytes } });234          const g = BigInt(uintStr(r.gas_info?.gas_used, 'gas_used'));235          if (g <= 0n) throw new Error('rest: bad gas_used');236          return g;237        } catch (e) {238          const status = (e as { status?: number }).status, msg = (e as Error).message;239          // the chain's own verdict on the transaction (gRPC Unknown / InvalidArgument carried by HTTP 400 or 500); anything else is the node failing240          if ((grpcOf(e) === 2 || grpcOf(e) === 3) && (status === 400 || status === 500)) {241            if (/account sequence mismatch/.test(msg)) throw new UserError('pending_tx', 'a previous transaction from this wallet has just been confirmed; retry in a few seconds', 409);242            throw new UserError('simulation_failed', `simulation failed: ${msg.replace(/\s*\[[^\]]*\]/g, '').slice(0, 200)}`);243          }244          throw e;245        }246      });247      const simulated = used.reduce((a, b) => (b > a ? b : a));248      if (simulated * 2n > gas) gas = simulated * 2n;249      if (gas > GAS_CEIL) throw new UserError('simulation_failed', `simulation failed: a plain transfer should not need ${simulated} gas`);250      fee = feeFor(gas);251      check(fee);252253      return { section: { x: section(gas, fee) }, maxFee: fee, feeEstimate: fee, warnings: [], ttlSec: TTL_SEC };254    },255256    async broadcast(raw) {257      const txid = cosmosTxid(raw);258      const rs = await Promise.allSettled(sources.map(async (u) => {259        let why: string;260        try {261          const t = (await rest<{ tx_response?: TxResponse }>(u, '/cosmos/tx/v1beta1/txs', { body: { tx_bytes: raw, mode: 'BROADCAST_MODE_SYNC' } })).tx_response;262          if (!t || typeof t.code !== 'number') throw new Error('rest: bad broadcast answer');263          // accepted into the mempool, or already there (sdk code 19)264          if ((t.code === 0 && String(t.txhash ?? '').toUpperCase() === txid) || (t.code === 19 && (t.codespace ?? 'sdk') === 'sdk')) return;265          why = `code ${t.code}${t.codespace ? ` (${t.codespace})` : ''}: ${String(t.raw_log ?? '').slice(0, 160)}`;266        } catch (e) { if (grpcOf(e) === undefined) throw e; why = (e as Error).message.slice(0, 160); }267        // "sequence already used", "already in cache", "timed out": fine only if this very transaction is in a block268        if (await lookup(u, txid)) return;269        throw new Error(why);270      }));271      if (rs.some((r) => r.status === 'fulfilled')) return;272      throw new Error(String((rs[0] as PromiseRejectedResult).reason?.message));273    },274275    async track(p): Promise<Tracked> {276      const rs = await Promise.allSettled(sources.map((u) => lookup(u, p.txid)));277      const seen = rs.flatMap((r) => (r.status === 'fulfilled' && r.value ? [r.value] : []))[0];278      if (seen) {279        // Tendermint finality: a transaction in a committed block is final280        if (seen.code === 0) return { state: 'finalized', confirmations: Math.max(1, n.minConf), blockRef: String(seen.height) };281        return { state: 'failed', confirmations: 0, blockRef: String(seen.height),282          error: `rejected by the chain (code ${seen.code}${seen.codespace ? ` ${seen.codespace}` : ''}: ${String(seen.raw_log ?? '').slice(0, 160)}): the transfer was not made (only the network fee was charged)` };283      }284      // Not found. It is dead only when every source says so and every source is past timeout_height.285      const timeout = Number(p.intent?.x?.timeout_height), from = p.intent?.from as unknown;286      if (!Number.isSafeInteger(timeout) || timeout < 1 || typeof from !== 'string' || !rs.every((r) => r.status === 'fulfilled')) return { state: 'broadcast', confirmations: 0, rebroadcast: true };287      const tips = await Promise.allSettled(sources.map(tip));288      const heights = tips.flatMap((t) => (t.status === 'fulfilled' ? [t.value.height] : []));289      if (heights.length < sources.length) return { state: 'broadcast', confirmations: 0, rebroadcast: true };290      const past = Math.min(...heights) - timeout;291      if (past <= 0) return { state: 'broadcast', confirmations: 0, rebroadcast: true };292      // Past the timeout the chain can no longer accept it. Proof that it never got in: the sequence it would have consumed is still unused.293      const accts = await Promise.allSettled(sources.map((u) => account(u, from)));294      const seqs = accts.flatMap((a) => (a.status === 'fulfilled' ? [a.value?.sequence ?? p.intent.x.sequence] : []));295      if (seqs.length === sources.length) {296        if (seqs.every((s) => s === p.intent.x.sequence)) return { state: 'failed', confirmations: 0, error: 'expired before inclusion (timeout height passed); no funds moved' };297        // sequence consumed and, well after the timeout, still unknown to both indexes: another transaction used that sequence298        if (past > REPLACED_AFTER) return { state: 'replaced', confirmations: 0, error: 'the sequence was consumed by another transaction' };299      }300      return { state: 'broadcast', confirmations: 0 };301    },302303    async deposits(addr, cursor) {304      cosmosAddressBytes(prefix, addr);305      const tipHeight = (await any(tip)).height;306      const items: Deposit[] = [];307      if (cursor !== null && cursor >= tipHeight) return { items, cursor, finalizedHeight: tipHeight };308      const query = encodeURIComponent(`transfer.recipient='${addr}'`);309      // newest first (public nodes refuse height ranges in the query); first scan: the most recent page only310      for (let page = 1, more = true; more && page <= (cursor === null ? 1 : MAX_PAGES); page++) {311        let list: TxResponse[];312        try { list = (await any((u) => rest<{ tx_responses?: TxResponse[] }>(u, `/cosmos/tx/v1beta1/txs?query=${query}&order_by=ORDER_BY_DESC&limit=${PAGE}&page=${page}`))).tx_responses ?? []; }313        catch (e) { if (page > 1 && grpcOf(e) !== undefined) break; throw e; } // past the last page314        if (!Array.isArray(list)) throw new Error('rest: bad search answer');315        more = list.length === PAGE;316        for (const t of list) {317          const height = Number(t.height), txid = String(t.txhash ?? '').toUpperCase(), time = Date.parse(String(t.timestamp));318          if (!Number.isSafeInteger(height) || height < 1 || height > tipHeight || !/^[0-9A-F]{64}$/.test(txid)) continue;319          if (cursor !== null && height <= cursor) { more = false; continue; }320          if (t.code !== 0) continue;321          // every `transfer` event of the transaction that credits this address in the native denom (sends, multi-sends, IBC receipts, rewards)322          (t.events ?? []).filter((e) => e.type === 'transfer').forEach((e, idx) => {323            const a = Object.fromEntries((e.attributes ?? []).map((x) => [String(x.key), String(x.value)]));324            if (a.recipient !== addr || a.sender === addr) return;325            let amount = 0n;326            for (const part of String(a.amount ?? '').split(',')) { const m = /^([1-9]\d{0,30})(.+)$/.exec(part); if (m && m[2] === denom) amount += BigInt(m[1]!); }327            if (amount > 0n) items.push({ txid, idx, asset: 'native', amount, from: a.sender ?? '', block: height, time: Number.isFinite(time) ? Math.floor(time / 1000) : null });328          });329        }330      }331      return { items, cursor: tipHeight, finalizedHeight: tipHeight };332    },333  };334}335
Files main
Menu

Theme