aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js150
-rw-r--r--src/features/gifts.js3
2 files changed, 144 insertions, 9 deletions
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 });
}