diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/exit.js | 28 | ||||
| -rw-r--r-- | src/ark/refresh.js | 24 | ||||
| -rw-r--r-- | src/features/ark.js | 33 |
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 {} } |
