diff options
| author | Adam Soltys <[email protected]> | 2026-09-09 22:44:24 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-09 22:44:24 -0700 |
| commit | 9f2ce90f494a5b415bec0898e3185437126fc7d9 (patch) | |
| tree | e5154b4ce9867496488e12e0a6056bd3eba28f62 /src/ark | |
| parent | 6ea73cf4f8bad7384f8ead16a1aa6988ae46e545 (diff) | |
| download | coinosv3-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
Diffstat (limited to 'src/ark')
| -rw-r--r-- | src/ark/exit.js | 28 | ||||
| -rw-r--r-- | src/ark/refresh.js | 24 |
2 files changed, 41 insertions, 11 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 = [ |
