aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ark/exit.js160
-rw-r--r--src/ark/manager.js21
-rw-r--r--src/ark/refresh.js9
-rw-r--r--src/ark/send.js15
-rw-r--r--src/features/ark.js150
-rw-r--r--src/features/gifts.js3
-rw-r--r--src/i18n.js8
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.',