aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-09 22:44:24 -0700
committerAdam Soltys <[email protected]>2026-09-09 22:44:24 -0700
commit9f2ce90f494a5b415bec0898e3185437126fc7d9 (patch)
treee5154b4ce9867496488e12e0a6056bd3eba28f62
parent6ea73cf4f8bad7384f8ead16a1aa6988ae46e545 (diff)
downloadcoinosv3-9f2ce90f494a5b415bec0898e3185437126fc7d9.tar.gz
coinosv3-9f2ce90f494a5b415bec0898e3185437126fc7d9.zip
Boot: price the Ark exit advisory by size only — no more 5s of secp256k1 on load.
The exit-depth advisory rebuilt every spendable coin's exit chain to learn its fee, and each hop's input taproot key ran a musig key aggregation (several secp256k1 multiplies on uncached points). With 27 coins of depth up to 86 (1685 hops) that was ~5.3s of solid main-thread work on every boot, laptop and phone alike — requestIdleCallback only delayed it, and the Android wrapper hits a full boot whenever the OS evicts the app, so "reopening the app" froze for seconds. A fee only needs vsizes, and a taproot output script is 34 bytes whatever key it holds: vtxoTransactions/transitionInputTxout gain a sizeOnly mode with stand-in keys, exit.js exposes exitTxVsizes (same chain checks, no EC, nothing broadcastable), and the advisory + coins page use it. Verified against the real signed chain on the live coin set: identical vsizes on all 27 coins, 9s → 26ms. Also: the sync-slot replay at boot merged the ark snapshot and rewrote ~1MB of state (plus re-adopting it into the manager) twice per boot even when the union brought nothing. Compare the serialized union to what's stored and skip the write/adopt when unchanged. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
-rw-r--r--src/ark/exit.js28
-rw-r--r--src/ark/refresh.js24
-rw-r--r--src/features/ark.js33
3 files changed, 61 insertions, 24 deletions
diff --git a/src/ark/exit.js b/src/ark/exit.js
index e2b253f..616c6a1 100644
--- a/src/ark/exit.js
+++ b/src/ark/exit.js
@@ -18,7 +18,7 @@ import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
import { concatBytes } from './proto.js';
import { pubkeyPolicyTaproot, txid } from './send.js';
-import { vtxoTransactions, harkLeafTaproot, tapLeafHash, taprootScriptSighash } from './refresh.js';
+import { vtxoTransactions, harkLeafTaproot, harkUnlockWitnessShape, tapLeafHash, taprootScriptSighash } from './refresh.js';
const u16le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff);
const u32le = (n) => Uint8Array.of(n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff);
@@ -57,8 +57,16 @@ export function serializeTxW(tx, witnesses) {
// The vtxo's chain as broadcastable transactions, oldest first.
// Throws if any transition lacks its signature (an unsigned chain can't exit).
-export function signedExitTxs(vtxo, serverPub) {
- const items = vtxoTransactions(vtxo, serverPub);
+export const signedExitTxs = (vtxo, serverPub) => exitChain(vtxo, serverPub, false);
+
+// Just the vsize of each hop's transaction, with the same "can this chain
+// exit at all" checks — but no elliptic-curve work: the output keys are
+// stand-ins of the right length. For fee estimates only; nothing here is
+// broadcastable.
+export const exitTxVsizes = (vtxo, serverPub) => exitChain(vtxo, serverPub, true).map((t) => t.vsize);
+
+function exitChain(vtxo, serverPub, sizeOnly) {
+ const items = vtxoTransactions(vtxo, serverPub, sizeOnly);
return items.map((it, i) => {
const t = vtxo.genesis[i].transition;
let witness;
@@ -70,14 +78,20 @@ export function signedExitTxs(vtxo, serverPub) {
throw new Error('vtxo chain is missing its unlock preimage — cannot exit');
}
const preimage = hex.decode(t.unlock.preimage);
- const tp = harkLeafTaproot(hex.decode(t.userPubkey), serverPub, vtxo.expiryHeight, sha256(preimage));
- const cb = concatBytes(Uint8Array.of(0xc0 | tp.outputParity), tp.internalXOnly, tp.expiryLeaf);
- witness = [hex.decode(t.signature), preimage, tp.unlockScript, cb];
+ if (sizeOnly) {
+ const shape = harkUnlockWitnessShape(sha256(preimage));
+ witness = [hex.decode(t.signature), preimage, shape.unlockScript, shape.controlBlock];
+ } else {
+ const tp = harkLeafTaproot(hex.decode(t.userPubkey), serverPub, vtxo.expiryHeight, sha256(preimage));
+ const cb = concatBytes(Uint8Array.of(0xc0 | tp.outputParity), tp.internalXOnly, tp.expiryLeaf);
+ witness = [hex.decode(t.signature), preimage, tp.unlockScript, cb];
+ }
} else {
throw new Error('unsupported genesis transition: ' + t.type);
}
- const idInternal = txid(it.tx);
const { raw, vsize } = serializeTxW(it.tx, [witness]);
+ if (sizeOnly) return { vsize };
+ const idInternal = txid(it.tx);
return {
txid: hex.encode(idInternal.slice().reverse()),
txidInternal: idInternal,
diff --git a/src/ark/refresh.js b/src/ark/refresh.js
index 2894977..4bd74f2 100644
--- a/src/ark/refresh.js
+++ b/src/ark/refresh.js
@@ -114,7 +114,23 @@ function forfeitClaimTaproot(userPub, serverPub, exitDelta, unlockHash) {
const otherSum = (item) =>
item.feeSat + item.otherOutputs.reduce((n, o) => n + o.valueSat, 0);
-export function transitionInputTxout(transition, amountSat, serverPub, expiryHeight) {
+// A taproot output script is 34 bytes whatever key sits inside it. Callers
+// that only need transaction SIZES (fee estimates) pass sizeOnly and skip
+// the musig key aggregation — several secp256k1 multiplies per hop, which
+// across a wallet's coins was seconds of main-thread work at boot.
+const DUMMY_P2TR = concatBytes(hex.decode('5120'), new Uint8Array(32));
+// The hark unlock witness's script + control block, shaped for size only.
+export function harkUnlockWitnessShape(unlockHash, gen = 1) {
+ const unlockScript = (gen === 1 ? hashSignScriptV1 : hashSignScript)(unlockHash, new Uint8Array(32));
+ return { unlockScript, controlBlock: new Uint8Array(65) };
+}
+
+export function transitionInputTxout(transition, amountSat, serverPub, expiryHeight, sizeOnly = false) {
+ if (sizeOnly) {
+ const known = ['cosigned', 'hashLockedCosigned', 'hashLockedCosigned_v0', 'arkoor'];
+ if (!known.includes(transition.type)) throw new Error('input_txout unsupported for transition ' + transition.type);
+ return { valueSat: amountSat, scriptPubKey: DUMMY_P2TR };
+ }
if (transition.type === 'cosigned') {
const pubkeys = transition.pubkeys.map((p) => hex.decode(p));
return { valueSat: amountSat, scriptPubKey: cosignedInputTaproot(pubkeys, serverPub, expiryHeight).scriptPubKey };
@@ -140,7 +156,7 @@ export const vtxoAnchorAmount = (vtxo) =>
vtxo.amountSat + vtxo.genesis.reduce((n, it) => n + otherSum(it), 0);
// Walk the genesis chain, returning [{tx, outputIdx}] like Vtxo::transactions()
-export function vtxoTransactions(vtxo, serverPub) {
+export function vtxoTransactions(vtxo, serverPub, sizeOnly = false) {
const anchorAmount = vtxoAnchorAmount(vtxo);
let prev = vtxo.anchorPoint.raw;
let currentAmount = anchorAmount;
@@ -149,9 +165,9 @@ export function vtxoTransactions(vtxo, serverPub) {
const item = vtxo.genesis[i];
const nextAmount = currentAmount - otherSum(item);
const nextOutput = i + 1 < vtxo.genesis.length
- ? transitionInputTxout(vtxo.genesis[i + 1].transition, nextAmount, serverPub, vtxo.expiryHeight)
+ ? transitionInputTxout(vtxo.genesis[i + 1].transition, nextAmount, serverPub, vtxo.expiryHeight, sizeOnly)
: { valueSat: vtxo.amountSat,
- scriptPubKey: policyTaproot(vtxo.policy, serverPub, vtxo.exitDelta).scriptPubKey };
+ scriptPubKey: sizeOnly ? DUMMY_P2TR : policyTaproot(vtxo.policy, serverPub, vtxo.exitDelta).scriptPubKey };
// decoded txouts carry hex-string scripts — normalize to bytes
const others = item.otherOutputs.map((o) => ({ valueSat: o.valueSat, scriptPubKey: hex.decode(o.scriptPubKey) }));
const outs = [
diff --git a/src/features/ark.js b/src/features/ark.js
index bc04dd6..a5264ad 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -11,7 +11,7 @@ import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js';
import { boardFee, p2trAddress } from '../ark/board.js';
import { maybeBolt11, maybeLnInvoice, lnSendFee } from '../ark/lightning.js';
import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js';
-import { signedExitTxs, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js';
+import { signedExitTxs, exitTxVsizes, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js';
import { utxoId } from '../wallet.js';
import {
getNetwork, setNetwork, getArkProviderId, getArkConfig,
@@ -126,8 +126,8 @@ export function installArkWallet(wallet) {
return st;
} catch { return null; }
},
- saveArkState(state, url) {
- try { localStorage.setItem(this._arkKey(url), JSON.stringify(state)); } catch {}
+ saveArkState(state, url, json = null) {
+ try { localStorage.setItem(this._arkKey(url), json ?? JSON.stringify(state)); } catch {}
},
});
}
@@ -348,7 +348,14 @@ export function arkFeature(ctx) {
}
const local = wallet.loadArkState(cfg.ark);
const merged = mergeArkStates(local, d.arkState);
- wallet.saveArkState(merged, cfg.ark);
+ // Every boot replays the sync slot, and on a converged slot the union
+ // brings nothing — not worth rewriting ~1 MB of state or re-adopting
+ // it into the manager (profiled at ~200ms per replay, twice per boot).
+ // Bytes decide: unchanged means it serializes to what's already stored.
+ const json = JSON.stringify(merged);
+ let changed = true;
+ try { changed = json !== localStorage.getItem(wallet._arkKey(cfg.ark)); } catch {}
+ if (changed) wallet.saveArkState(merged, cfg.ark, json);
const mergedN = (merged.vtxos || []).length;
const remoteN = (d.arkState.vtxos || []).length;
// We know vtxos this snapshot lacked — push our superset back up so the
@@ -358,7 +365,7 @@ export function arkFeature(ctx) {
// maybeInitArk deliberately does nothing when connected. Update that
// manager directly or the newly synced change/history stays invisible
// until reload, even though storage already contains the right balance.
- if (ark) adoptArkSnapshot(ark, merged);
+ if (ark) { if (changed) adoptArkSnapshot(ark, merged); }
else if (mergedN) setTimeout(() => maybeInitArk(), 0);
},
});
@@ -1976,7 +1983,7 @@ export function arkFeature(ctx) {
const exitFeeOf = (v) => {
try {
let f = Math.ceil(150 * feeRate);
- for (const txi of signedExitTxs(mgr._decoded(v), mgr.serverPub)) f += Math.ceil((txi.vsize + 130) * feeRate);
+ for (const vsize of exitTxVsizes(mgr._decoded(v), mgr.serverPub)) f += Math.ceil((vsize + 130) * feeRate);
return f;
} catch { return null; }
};
@@ -2201,11 +2208,11 @@ export function arkFeature(ctx) {
// What a unilateral exit will need from Savings, BEFORE it starts: every
// chain hop is a zero-fee tx bumped by a ~130 vB CPFP child, plus the
// final claim. Priced at today's feerate — the driver reprices per hop.
- // Memoized on the coin set + feerate: signedExitTxs rebuilds every hop of
- // every coin's exit chain with an elliptic-curve tweak per hop — profiled
- // at ~200ms of secp256k1 per call on a phone with depth-13 coins — and
- // the depth advisory asked for it on EVERY wallet render. Same coins at
- // the same feerate price the same, so pay once per change instead.
+ // Sizes only (exitTxVsizes): building the real signed chain costs several
+ // secp256k1 multiplies per hop, and across 27 coins of depth ~13 that was
+ // 5+ seconds of main-thread work on every boot (idle callback or not).
+ // Fee = size × rate needs none of it. Still memoized on the coin set +
+ // feerate, since the advisory asks on every wallet render.
let _exitFeeMemo = { key: null, val: 0 };
function estimateExitFeeSat(mgr) {
const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2);
@@ -2215,8 +2222,8 @@ export function arkFeature(ctx) {
let total = 0;
for (const v of coins) {
try {
- for (const txi of signedExitTxs(mgr._decoded(v), mgr.serverPub))
- total += Math.ceil((txi.vsize + 130) * feeRate);
+ for (const vsize of exitTxVsizes(mgr._decoded(v), mgr.serverPub))
+ total += Math.ceil((vsize + 130) * feeRate);
total += Math.ceil(150 * feeRate); // the claim tx
} catch {}
}