diff options
| author | Adam Soltys <[email protected]> | 2026-07-05 20:39:02 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-05 20:39:02 -0700 |
| commit | 15e0b236e7ff2cafcb439c891c790deea8ec16a7 (patch) | |
| tree | 960057559e60b4b2e624a7de9e4ac35b1ecb81ad | |
| parent | 0408609fc68fa71ff705ef7a241880efefddcc3f (diff) | |
| download | coinosv3-15e0b236e7ff2cafcb439c891c790deea8ec16a7.tar.gz coinosv3-15e0b236e7ff2cafcb439c891c790deea8ec16a7.zip | |
Unilateral exit: put vtxos on-chain without the server
The trustless backstop behind the Ark card's promise. The vtxo's
genesis chain reconstructs to fully-signed txs (keyspend sigs for
cosigned/arkoor transitions, preimage script-path for hark leaves);
they are zero-fee v3 with P2A anchors, so each hop broadcasts as a
[parent, CPFP child] package via esplora POST /txs/package, one
confirmed hop at a time (TRUC allows a single unconfirmed parent).
After the vtxo tx confirms and the CSV exit delta passes, the claim
spends the delayed_sign leaf with only the user key into the wallet's
own receive address.
Settings gains 'Exit on-chain without the server' with live per-exit
status lines; the claim tx is labeled 'Ark exit' in History. Probing
uses /tx/:txid (definitive 404), never /tx/:txid/status, which answers
confirmed:false for unknown txids.
Also: a fresh visitor opening an ark gift link now adopts the gift's
network automatically instead of being sent to Settings.
Co-Authored-By: Claude Fable 5 <[email protected]>
| -rw-r--r-- | src/ark/exit.js | 160 | ||||
| -rw-r--r-- | src/ark/manager.js | 21 | ||||
| -rw-r--r-- | src/ark/refresh.js | 9 | ||||
| -rw-r--r-- | src/ark/send.js | 15 | ||||
| -rw-r--r-- | src/features/ark.js | 150 | ||||
| -rw-r--r-- | src/features/gifts.js | 3 | ||||
| -rw-r--r-- | src/i18n.js | 8 |
7 files changed, 349 insertions, 17 deletions
diff --git a/src/ark/exit.js b/src/ark/exit.js new file mode 100644 index 0000000..2770284 --- /dev/null +++ b/src/ark/exit.js @@ -0,0 +1,160 @@ +// Unilateral exit — put a vtxo on-chain WITHOUT the server's cooperation, +// then claim it through the CSV exit leaf. This is the trustless backstop +// behind "you can always exit on-chain". +// +// The vtxo's genesis chain reconstructs to fully-signed transactions +// (signatures ride in the encoded vtxo). They are zero-fee v3 txs with P2A +// anchors, so each hop needs a CPFP child spending its anchor plus one of the +// wallet's on-chain coins — and TRUC (v3) topology allows only one +// unconfirmed parent, so a deep chain exits one confirmed hop at a time. +// [parent, child] pairs are submitted via esplora's POST /txs/package +// (bitcoind submitpackage). After the vtxo tx confirms and exitDelta blocks +// pass, the claim tx spends it via the delayed_sign leaf with only our key. + +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +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'; + +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); +const u64le = (n) => { + const b = new Uint8Array(8); + new DataView(b.buffer).setBigUint64(0, BigInt(n), true); + return b; +}; +const varint = (n) => { + if (n < 0xfd) return Uint8Array.of(n); + if (n <= 0xffff) return concatBytes(Uint8Array.of(0xfd), u16le(n)); + return concatBytes(Uint8Array.of(0xfe), u32le(n)); +}; +const sha256d = (b) => sha256(sha256(b)); + +// Segwit serialization: tx is the {version, locktime, inputs, outputs} shape +// the ark modules use; witnesses is one item-array per input ([] = empty). +export function serializeTxW(tx, witnesses) { + const base = [ + u32le(tx.version), + varint(tx.inputs.length), + ...tx.inputs.flatMap((i) => [i.prevout, Uint8Array.of(0x00), u32le(i.sequence)]), + varint(tx.outputs.length), + ...tx.outputs.flatMap((o) => [u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey]), + ]; + const wit = witnesses.flatMap((items) => [ + varint(items.length), + ...items.flatMap((w) => [varint(w.length), w]), + ]); + const full = concatBytes(u32le(tx.version), Uint8Array.of(0x00, 0x01), + ...base.slice(1), ...wit, u32le(tx.locktime)); + const stripped = concatBytes(...base, u32le(tx.locktime)); + const weight = stripped.length * 3 + full.length; + return { raw: full, vsize: Math.ceil(weight / 4) }; +} + +// 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); + return items.map((it, i) => { + const t = vtxo.genesis[i].transition; + let witness; + if (t.type === 'cosigned' || t.type === 'arkoor') { + if (!t.signature) throw new Error('vtxo chain is missing a signature — cannot exit'); + witness = [hex.decode(t.signature)]; + } else if (t.type === 'hashLockedCosigned') { + if (!t.signature || !t.unlock?.preimage) { + 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]; + } else { + throw new Error('unsupported genesis transition: ' + t.type); + } + const idInternal = txid(it.tx); + const { raw, vsize } = serializeTxW(it.tx, [witness]); + return { + txid: hex.encode(idInternal.slice().reverse()), + txidInternal: idInternal, + hex: hex.encode(raw), + vsize, + anchorVout: it.tx.outputs.length - 1, + anchorValue: it.tx.outputs[it.tx.outputs.length - 1].valueSat, + }; + }); +} + +// CPFP child: spends [parent P2A anchor (keyless, empty witness), one wallet +// p2wpkh coin] into a single change output. v3 like its parent (TRUC). +// coin: { txid (display hex), vout, value, pubkey (33B), privkey }. +export function buildBumpChild({ parentTxidInternal, anchorVout, anchorValue, coin, changeScript, feeSat }) { + const changeSat = coin.value + anchorValue - feeSat; + if (changeSat < 294) throw new Error('fee coin too small for the exit bump'); + const coinPrevout = concatBytes(hex.decode(coin.txid).reverse(), u32le(coin.vout)); + const tx = { + version: 3, locktime: 0, + inputs: [ + { prevout: concatBytes(parentTxidInternal, u32le(anchorVout)), sequence: 0xfffffffd }, + { prevout: coinPrevout, sequence: 0xfffffffd }, + ], + outputs: [{ valueSat: changeSat, scriptPubKey: changeScript }], + }; + // BIP143 sighash (ALL) for the p2wpkh input + const hashPrevouts = sha256d(concatBytes(...tx.inputs.map((i) => i.prevout))); + const hashSequence = sha256d(concatBytes(...tx.inputs.map((i) => u32le(i.sequence)))); + const hashOutputs = sha256d(concatBytes(...tx.outputs.map((o) => + concatBytes(u64le(o.valueSat), varint(o.scriptPubKey.length), o.scriptPubKey)))); + const pkh = ripemd160(sha256(coin.pubkey)); + const scriptCode = concatBytes(hex.decode('1976a914'), pkh, hex.decode('88ac')); + const preimage = concatBytes( + u32le(tx.version), hashPrevouts, hashSequence, + coinPrevout, scriptCode, u64le(coin.value), u32le(tx.inputs[1].sequence), + hashOutputs, u32le(tx.locktime), u32le(1), + ); + const sig = secp256k1.sign(sha256d(preimage), coin.privkey, { lowS: true }); + const der = concatBytes(sig.toDERRawBytes ? sig.toDERRawBytes() : sig.toBytes('der'), Uint8Array.of(0x01)); + const { raw, vsize } = serializeTxW(tx, [[], [der, coin.pubkey]]); + return { hex: hex.encode(raw), txid: hex.encode(txid(tx).slice().reverse()), vsize, changeSat }; +} + +// The claim: spend the confirmed vtxo output through the delayed_sign leaf +// (CSV = exitDelta) with only the user key, paying destScript. +export function buildExitClaim({ vtxo, keys, serverPub, destScript, feeRate }) { + const tap = pubkeyPolicyTaproot(keys.pubkey, serverPub, vtxo.exitDelta); + const cb = concatBytes(Uint8Array.of(0xc0 | tap.outputParity), tap.internalXOnly); + const prevouts = [{ valueSat: vtxo.amountSat, scriptPubKey: tap.scriptPubKey }]; + const build = (feeSat) => { + const tx = { + version: 2, locktime: 0, + inputs: [{ prevout: vtxo.point.raw, sequence: vtxo.exitDelta }], + outputs: [{ valueSat: vtxo.amountSat - feeSat, scriptPubKey: destScript }], + }; + const sighash = taprootScriptSighash(tx, prevouts, tapLeafHash(tap.leafScript)); + const sig = schnorr.sign(sighash, keys.privkey); + return { tx, witness: [[sig, tap.leafScript, cb]] }; + }; + // two-pass: measure with a real-size witness, then set the fee + const probe = build(0); + const vsize = serializeTxW(probe.tx, probe.witness).vsize; + const feeSat = Math.max(200, Math.ceil(vsize * Math.max(1, feeRate))); + if (vtxo.amountSat - feeSat < 294) throw new Error('vtxo too small to claim at this fee rate'); + const fin = build(feeSat); + const { raw } = serializeTxW(fin.tx, fin.witness); + return { hex: hex.encode(raw), txid: hex.encode(txid(fin.tx).slice().reverse()), feeSat, amountSat: vtxo.amountSat - feeSat, vsize }; +} + +// Submit a [parent, child] (or single-tx) package via esplora -> bitcoind +// submitpackage. Treats already-known txs as success so retries are safe. +export async function submitPackage(esploraBase, hexes) { + const r = await fetch(`${esploraBase}/txs/package`, { method: 'POST', body: JSON.stringify(hexes) }); + const body = await r.text(); + if (!r.ok && !/already|duplicate/i.test(body)) { + throw new Error(`package submit failed: ${body.slice(0, 160)}`); + } + return body; +} diff --git a/src/ark/manager.js b/src/ark/manager.js index efcc18a..cfc37a9 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -41,6 +41,7 @@ import { prepareOffboard, validateOffboardTx, signOffboardForfeits, finishOffboard, P2TR_DUST, } from './offboard.js'; +import { signedExitTxs } from './exit.js'; import { validateVtxo, VtxoValidationError } from './validate.js'; const EMPTY_STATE = () => ({ @@ -268,6 +269,26 @@ export class ArkManager { } } + // ---- unilateral exit ---- + // Marks the vtxo pending and records the action. The ark FEATURE drives the + // steps (it owns the on-chain wallet needed for fee-bump coins and the + // claim destination); crash-safety comes from the persisted action like + // every other flow. signedExitTxs() up front validates the chain is fully + // signed before anything is committed. + startExit(vtxoId) { + const v = this._vtxo(vtxoId); + if (!v || v.state !== 'spendable') throw new Error('vtxo is not spendable'); + const txs = signedExitTxs(this._decoded(v), this.serverPub); + const action = { + id: `exit-${Date.now()}-${vtxoId.slice(0, 8)}`, type: 'exit', step: 'chain', + vtxoId, amountSat: v.amountSat, txids: txs.map((t) => t.txid), + }; + v.state = 'pending'; + this.state.actions.push(action); + this._save(); + return action; + } + // ---- offboard (collaborative exit: all spendable vtxos -> one on-chain output) ---- async startOffboard(spk, address) { if (this.pendingActions().some((a) => a.type === 'offboard')) { diff --git a/src/ark/refresh.js b/src/ark/refresh.js index e3be402..54a3fc8 100644 --- a/src/ark/refresh.js +++ b/src/ark/refresh.js @@ -68,8 +68,10 @@ function taprootFromMerkle(internalXOnly, merkleRoot) { const P = secp256k1.ProjectivePoint; const internalPoint = P.fromHex(concatBytes(Uint8Array.of(0x02), internalXOnly)); const outputPoint = internalPoint.add(P.BASE.multiply(BigInt('0x' + hex.encode(tapTweak)))); - const outputXOnly = outputPoint.toRawBytes(true).slice(1); - return { tapTweak, outputXOnly, scriptPubKey: concatBytes(hex.decode('5120'), outputXOnly) }; + const outputCompressed = outputPoint.toRawBytes(true); + const outputXOnly = outputCompressed.slice(1); + const outputParity = outputCompressed[0] === 0x03 ? 1 : 0; + return { tapTweak, outputXOnly, outputParity, scriptPubKey: concatBytes(hex.decode('5120'), outputXOnly) }; } // Cosigned transition input: internal = musig(pubkeys), one timelock leaf @@ -86,7 +88,8 @@ export function harkLeafTaproot(userPub, serverPub, expiryHeight, unlockHash) { const expiryLeaf = tapLeafHash(timelockSignScript(expiryHeight, serverPub.slice(1))); const unlockScript = hashSignScript(unlockHash, internalXOnly); const unlockLeaf = tapLeafHash(unlockScript); - return { ...taprootFromMerkle(internalXOnly, tapBranch(expiryLeaf, unlockLeaf)), unlockScript, internalXOnly }; + // expiryLeaf doubles as the merkle path when spending through the unlock leaf + return { ...taprootFromMerkle(internalXOnly, tapBranch(expiryLeaf, unlockLeaf)), unlockScript, internalXOnly, expiryLeaf }; } // Forfeit claim output: internal = musig(user, server); diff --git a/src/ark/send.js b/src/ark/send.js index 39cd1fb..a959b22 100644 --- a/src/ark/send.js +++ b/src/ark/send.js @@ -84,14 +84,19 @@ export function taprootOneLeaf(userPub, serverPub, leafScript) { // lift internal x-only, add tweak*G const internalPoint = P.fromHex(concatBytes(Uint8Array.of(0x02), internalXOnly)); const outputPoint = internalPoint.add(P.BASE.multiply(BigInt('0x' + hex.encode(tapTweak)))); - const outputXOnly = outputPoint.toRawBytes(true).slice(1); + const outputCompressed = outputPoint.toRawBytes(true); + const outputXOnly = outputCompressed.slice(1); const scriptPubKey = concatBytes(hex.decode('5120'), outputXOnly); - return { sortedKeys, internalXOnly, merkleRoot, tapTweak, outputXOnly, scriptPubKey }; + const outputParity = outputCompressed[0] === 0x03 ? 1 : 0; // for script-path control blocks + return { sortedKeys, internalXOnly, merkleRoot, tapTweak, outputXOnly, outputParity, scriptPubKey }; } -// PubkeyVtxoPolicy: keyspend musig(user,server); leaf = delayed_sign(exit_delta, user) -export const pubkeyPolicyTaproot = (userPub, serverPub, exitDelta) => - taprootOneLeaf(userPub, serverPub, delayedSignScript(exitDelta, xonly(userPub))); +// PubkeyVtxoPolicy: keyspend musig(user,server); leaf = delayed_sign(exit_delta, user). +// leafScript is returned so a unilateral exit can claim through it after the CSV. +export const pubkeyPolicyTaproot = (userPub, serverPub, exitDelta) => { + const leafScript = delayedSignScript(exitDelta, xonly(userPub)); + return { ...taprootOneLeaf(userPub, serverPub, leafScript), leafScript }; +}; // CheckpointVtxoPolicy: keyspend musig(user,server); leaf = timelock_sign(expiry, server) export const checkpointPolicyTaproot = (userPub, serverPub, expiryHeight) => diff --git a/src/features/ark.js b/src/features/ark.js index d61925b..75e3da0 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -8,7 +8,9 @@ import { hex, base32nopad } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js'; -import { getNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from '../api.js'; +import { signedExitTxs, buildBumpChild, buildExitClaim, submitPackage } from '../ark/exit.js'; +import { utxoId } from '../wallet.js'; +import { getNetwork, setNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from '../api.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { shortAddr, shortTxid, timeAgo } from '../format.js'; @@ -88,7 +90,7 @@ export function arkFeature(ctx) { arkConnectPromise = mgr.init().then(() => { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; - const tick = () => mgr.sync().catch(() => {}); + const tick = () => mgr.sync().catch(() => {}).then(() => driveExits(mgr)).catch(() => {}); tick(); // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. @@ -169,9 +171,9 @@ export function arkFeature(ctx) { } function arkHistoryItem(m) { - const incoming = !['send', 'offboard'].includes(m.type); + const incoming = !['send', 'offboard', 'exit'].includes(m.type); const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') - : m.type === 'offboard' ? t('arkOffboarded') : t('sent'); + : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : t('sent'); return h( 'div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, @@ -188,9 +190,9 @@ export function arkFeature(ctx) { } function arkMoveDetailView(m) { - const incoming = !['send', 'offboard'].includes(m.type); + const incoming = !['send', 'offboard', 'exit'].includes(m.type); const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') - : m.type === 'offboard' ? t('arkOffboarded') : t('sent'); + : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : t('sent'); const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' }, h('span', { class: 'small muted', style: 'flex-shrink:0' }, k), h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, v)); const url = m.txid ? wallet.api.explorerTx(m.txid) : null; @@ -282,7 +284,18 @@ export function arkFeature(ctx) { spendables.length >= 1 ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkRefresh }, ui.arkBusy === 'refresh' ? h('span', { class: 'spinner sm' }) : t('arkRefreshBtn', { n: spendables.length })) - : null + : null, + // the trustless escape hatch: broadcast the exit chains, no server needed + spendables.length >= 1 + ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkExit }, + ui.arkBusy === 'exit' ? h('span', { class: 'spinner sm' }) : t('arkExitBtn', { n: spendables.length })) + : null, + ...ark.state.actions.filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)).map((a) => + h('div', { class: 'small muted' }, + a.step === 'chain' ? t('arkExitChainStatus', { n: fmtAmount(a.amountSat) }) + : a.step === 'timelock' ? t('arkExitTimelockStatus', { n: fmtAmount(a.amountSat), blocks: String(a.claimableAt) }) + : t('arkExitClaimingStatus', { n: fmtAmount(a.amountSat) }), + a.lastError ? h('div', { class: 'small err' }, a.lastError) : null)) ); } @@ -309,6 +322,115 @@ export function arkFeature(ctx) { ui.arkBusy = null; render(); } + // ---- unilateral exit (trustless: no server cooperation) ------------------ + const addrScript = (address) => btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address)); + + // Smallest confirmed, unreserved coin that can fund a CPFP bump. + function pickFeeCoin(minSat) { + return wallet.utxos + .filter((u) => u.confirmed && !wallet.isReserved(utxoId(u)) && u.value >= minSat) + .sort((a, b) => a.value - b.value)[0] || null; + } + + async function doArkExit() { + ui.arkBusy = 'exit'; ui.arkError = ''; render(); + try { + const mgr = await connectArk(); + const spendables = mgr.vtxos().filter((v) => v.state === 'spendable'); + if (!spendables.length) throw new Error(t('arkNotConnected')); + for (const v of spendables) mgr.startExit(v.id); + toast(t('arkExitStarted', { n: spendables.length })); + driveExits(mgr).catch(() => {}); + } catch (e) { + ui.arkError = e.message; + } + ui.arkBusy = null; render(); + } + + async function driveExits(mgr) { + const open = mgr.state.actions.filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)); + for (const a of open) { + try { + await driveExit(mgr, a); + if (a.lastError) { delete a.lastError; mgr._save(); } // transient error resolved + } catch (e) { + a.lastError = e.message; + mgr._save(); + } + } + } + + // One tick of an exit's state machine: broadcast the next unconfirmed hop + // (with its fee child) as a package, then wait out the CSV, then claim. + async function driveExit(mgr, action) { + const rec = mgr._vtxo(action.vtxoId); + const decoded = mgr._decoded(rec); + if (action.step === 'chain') { + const txs = signedExitTxs(decoded, mgr.serverPub); + let lastConfirmedHeight = 0; + for (const txi of txs) { + const st = await mgr.chain.getTxStatus(txi.txid); + if (st?.confirmed) { lastConfirmedHeight = st.block_height; continue; } + // /tx/:txid/status answers {confirmed:false} even for UNKNOWN txids + // (electrs + mempool.space) — only /tx/:txid 404s definitively + if (await mgr.chain.getTxHex(txi.txid)) return; // in mempool — wait + // unknown to the chain: submit this hop + CPFP child as a package + const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); + // the child pays for the whole package (the parent is zero-fee) + const feeSat = Math.ceil((txi.vsize + 130) * feeRate); // child ≈ 130 vB + const coin = pickFeeCoin(Math.max(294, feeSat - txi.anchorValue + 294)); + if (!coin) throw new Error(t('arkExitNoFeeCoin')); + const changeAddr = wallet.freshChange().address; + const child = buildBumpChild({ + parentTxidInternal: txi.txidInternal, anchorVout: txi.anchorVout, anchorValue: txi.anchorValue, + coin: { + txid: coin.txid, vout: coin.vout, value: coin.value, + pubkey: wallet.derive(coin.chain, coin.index).pubkey, + privkey: wallet.node(coin.chain, coin.index).privateKey, + }, + changeScript: addrScript(changeAddr), feeSat, + }); + await submitPackage(mgr.esploraUrl, [txi.hex, child.hex]); + // reflect the spent fee coin locally so nothing double-spends it + wallet.utxos = wallet.utxos.filter((u) => !(u.txid === coin.txid && u.vout === coin.vout)); + const ce = wallet.addrMap.get(changeAddr); + if (ce) wallet.utxos.push({ txid: child.txid, vout: 0, value: child.changeSat, address: changeAddr, chain: ce.chain, index: ce.index, confirmed: false }); + wallet._recomputeBalanceFromUtxos(); + wallet.saveCache(); + action.bumpTxid = child.txid; + mgr._save(); + return; // one hop per tick (TRUC: single unconfirmed parent) + } + // whole chain confirmed — start the CSV clock from the vtxo tx's height + action.claimableAt = lastConfirmedHeight + decoded.exitDelta; + action.step = 'timelock'; + mgr._save(); + } + if (action.step === 'timelock') { + const tip = await mgr.chain.tipHeight(); + if (tip < action.claimableAt) return; + const keys = mgr._keyForVtxo(rec); + const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2); + const claim = buildExitClaim({ + vtxo: decoded, keys, serverPub: mgr.serverPub, + destScript: addrScript(wallet.freshReceive().address), feeRate, + }); + await mgr.chain.broadcastTx(claim.hex); + rec.state = 'spent'; + action.claimTxid = claim.txid; + action.step = 'claiming'; + mgr._movement({ type: 'exit', amountSat: claim.amountSat, status: 'complete', txid: claim.txid, detail: `fee ${claim.feeSat} sat` }); + mgr._save(); + wallet.scan().catch(() => {}); + } + if (action.step === 'claiming') { + const st = await mgr.chain.getTxStatus(action.claimTxid); + if (!st?.confirmed) return; + action.step = 'done'; + mgr._save(); + } + } + // ---- ark gifts: bearer-key vtxos ---------------------------------------- // Ark can't presign a bearer spend (every arkoor needs a live server cosign), // so an ark gift is a vtxo sent to an ephemeral ark identity whose seed IS @@ -597,7 +719,7 @@ export function arkFeature(ctx) { historyEntries() { if (!ark || !ark.state) return []; return ark.movements() - .filter((m) => ['receive', 'send', 'board', 'offboard'].includes(m.type) && m.status === 'complete') + .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit'].includes(m.type) && m.status === 'complete') .map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })); }, historyDetail() { @@ -624,7 +746,9 @@ export function arkFeature(ctx) { return a ? { icon: '⚔', label: h('span', {}, t('arkBoardHistory')) } : null; } const o = ark.state.actions.find((x) => x.type === 'offboard' && x.txid === tx.txid); - return o ? { icon: '⚔', label: h('span', {}, t('arkOffboardHistory')) } : null; + if (o) return { icon: '⚔', label: h('span', {}, t('arkOffboardHistory')) }; + const e = ark.state.actions.find((x) => x.type === 'exit' && x.claimTxid === tx.txid); + return e ? { icon: '⚔', label: h('span', {}, t('arkExitHistory')) } : null; }, txDetailSection(tx) { if (!ark || !ark.state) return null; @@ -646,6 +770,14 @@ export function arkFeature(ctx) { return { spendableSat: ark.balance().spendableSat }; }, arkGiftDecode(code) { return decodeArkGiftCode(code); }, + // A fresh visitor (no wallets) opening a gift link lands on the gift's + // network automatically instead of being told to change Settings. + arkGiftAdoptNetwork(code) { + const g = decodeArkGiftCode(code); + if (!g || g.net === getNetwork()) return false; + setNetwork(g.net); + return true; + }, async arkGiftCreate(amountSat) { const mgr = await connectArk(); const secret = crypto.getRandomValues(new Uint8Array(32)); diff --git a/src/features/gifts.js b/src/features/gifts.js index e4a445f..7cc9e9c 100644 --- a/src/features/gifts.js +++ b/src/features/gifts.js @@ -70,6 +70,9 @@ export function giftsFeature(ctx) { ui.claimLocked = null; ui.claimArkAmount = null; const targets = claimTargets(); + // No wallets on this device: adopt the gift's network so a first-timer + // never has to visit Settings before claiming. + if (!targets.length) hook('arkGiftAdoptNetwork', code); if (targets.length) { setAccounts(targets); ui.claimChoose = { code }; render(); } else enterWallet(newMnemonic(), '', { gift: code }); } diff --git a/src/i18n.js b/src/i18n.js index 9e04f71..47e93f4 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -388,6 +388,14 @@ const STR = { arkOffboardedNote: 'Arrives in this wallet’s on-chain balance after 1 confirmation.', arkOffboarded: 'Withdrawn on-chain', arkOffboardHistory: 'Ark withdrawal', + arkExitBtn: 'Exit on-chain without the server ({n} coin(s))', + arkExitStarted: 'Unilateral exit started for {n} coin(s) — broadcasting the exit chain.', + arkExitNoFeeCoin: 'Exiting needs an on-chain coin to pay the exit-chain fees.', + arkExitChainStatus: 'Exit {n}: broadcasting the exit chain…', + arkExitTimelockStatus: 'Exit {n}: waiting for the timelock (claimable at block {blocks})', + arkExitClaimingStatus: 'Exit {n}: claim broadcast, awaiting confirmation', + arkExited: 'Exited on-chain', + arkExitHistory: 'Ark exit', arkRefreshStarted: 'Refresh submitted — completes with the next round.', arkSendTitle: 'Send over Ark', arkSendHint: 'Ark address — sent instantly off-chain, no mining fee.', |
