Repository Public
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