aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-05 23:11:32 -0700
committerAdam Soltys <[email protected]>2026-07-05 23:11:32 -0700
commiteb89b9e3681d9eea331b6471ecc576f39a0fd5ec (patch)
tree63135d74a974502bf1d64fce9bdeb2696b43cdb1 /src
parentd24ba23df05bbc8072cbd97bec84a2a198951d03 (diff)
downloadcoinosv3-eb89b9e3681d9eea331b6471ecc576f39a0fd5ec.tar.gz
coinosv3-eb89b9e3681d9eea331b6471ecc576f39a0fd5ec.zip
Exit status: real progress, blocks-left countdown, honest failures
submitpackage answers HTTP 200 even when it rejects the package — the verdict hides in package_msg/tx-results — so a mainnet no-fee-coin exit sat at 'publishing on-chain…' forever believing its bare hop had relayed. Rejections now surface (and become the actionable needs-a-fee- coin message on the bumpless path). The status line shows hop progress (step 2 of 9), the timelock wait counts down in blocks with a rough wall-clock estimate, and a stuck exit that never published anything gets a 'Cancel this exit' link that returns the vtxo to spendable. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/ark/exit.js16
-rw-r--r--src/features/ark.js33
-rw-r--r--src/i18n.js5
3 files changed, 45 insertions, 9 deletions
diff --git a/src/ark/exit.js b/src/ark/exit.js
index 9310b65..e2b253f 100644
--- a/src/ark/exit.js
+++ b/src/ark/exit.js
@@ -156,7 +156,21 @@ export function buildExitClaim({ vtxo, keys, serverPub, destScript, feeRate }) {
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)) return body;
+ if (r.ok) {
+ // submitpackage answers 200 even when it REJECTS the txs — the verdict is
+ // inside package_msg / per-tx errors. Treat anything but success (or
+ // already-known) as a real failure or the exit waits forever in silence.
+ let parsed = null;
+ try { parsed = JSON.parse(body); } catch {}
+ if (parsed && parsed.package_msg && parsed.package_msg !== 'success') {
+ const errs = Object.values(parsed['tx-results'] || {}).map((t) => t.error).filter(Boolean).join('; ');
+ if (!/already/i.test(errs)) {
+ throw new Error(`package rejected: ${(errs || parsed.package_msg).slice(0, 160)}`);
+ }
+ }
+ return body;
+ }
+ if (/already|duplicate/i.test(body)) return body;
if (r.status === 404 || /endpoint does not exist/i.test(body)) {
for (const hx of hexes) {
const br = await fetch(`${esploraBase}/tx`, { method: 'POST', body: hx });
diff --git a/src/features/ark.js b/src/features/ark.js
index 972dc53..1f87541 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -297,15 +297,34 @@ export function arkFeature(ctx) {
? 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) })
+ ...ark.state.actions.filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)).map((a) => {
+ // blocks left in the timelock, with a rough wall-clock estimate
+ // (~10 min/block everywhere but regtest, where blocks are manual)
+ const left = a.step === 'timelock' ? Math.max(0, a.claimableAt - (a.tipSeen || 0)) : 0;
+ const mins = left * 10;
+ const eta = getNetwork() === 'regtest' ? ''
+ : mins >= 2880 ? ` (≈ ${Math.round(mins / 1440)} d)`
+ : mins >= 120 ? ` (≈ ${Math.round(mins / 60)} h)`
+ : ` (≈ ${mins} min)`;
+ return h('div', { class: 'small muted' },
+ a.step === 'chain' ? t('arkExitChainStatus', { n: fmtAmount(a.amountSat), done: String(a.hopsDone || 0), total: String((a.txids || []).length) })
+ : a.step === 'timelock' ? t('arkExitTimelockStatus', { n: fmtAmount(a.amountSat), blocks: String(left), eta })
: t('arkExitClaimingStatus', { n: fmtAmount(a.amountSat) }),
a.lastError
? h('div', { class: a.actionable ? 'small err' : 'small faint' },
a.actionable ? a.lastError : t('arkExitRetrying'))
- : null))
+ : null,
+ // a stuck exit that never published anything can be safely undone
+ a.actionable && !(a.hopsDone > 0)
+ ? h('button', { class: 'linklike small', onClick: () => {
+ a.step = 'failed';
+ const v = ark._vtxo(a.vtxoId);
+ if (v && v.state === 'pending') v.state = 'spendable';
+ ark._save();
+ render();
+ } }, t('arkExitCancel'))
+ : null);
+ })
);
}
@@ -379,9 +398,10 @@ export function arkFeature(ctx) {
if (action.step === 'chain') {
const txs = signedExitTxs(decoded, mgr.serverPub);
let lastConfirmedHeight = 0;
+ let hopsDone = 0;
for (const txi of txs) {
const st = await mgr.chain.getTxStatus(txi.txid);
- if (st?.confirmed) { lastConfirmedHeight = st.block_height; continue; }
+ if (st?.confirmed) { lastConfirmedHeight = st.block_height; hopsDone++; if (action.hopsDone !== hopsDone) { action.hopsDone = hopsDone; mgr._save(); } 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
@@ -433,6 +453,7 @@ export function arkFeature(ctx) {
}
if (action.step === 'timelock') {
const tip = await mgr.chain.tipHeight();
+ if (action.tipSeen !== tip) { action.tipSeen = tip; mgr._save(); } // for the blocks-left display
if (tip < action.claimableAt) return;
const keys = mgr._keyForVtxo(rec);
const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2);
diff --git a/src/i18n.js b/src/i18n.js
index 8c3058c..586bacb 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -393,10 +393,11 @@ const STR = {
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: 'Exiting {n} sats — publishing on-chain…',
- arkExitTimelockStatus: 'Exiting {n} sats — waiting out the timelock (block {blocks})',
+ arkExitChainStatus: 'Exiting {n} sats — publishing on-chain, step {done} of {total}…',
+ arkExitTimelockStatus: 'Exiting {n} sats — {blocks} block(s) of timelock left{eta}',
arkExitClaimingStatus: 'Exiting {n} sats — almost done, awaiting confirmation',
arkExitRetrying: 'hit a snag — retrying automatically',
+ arkExitCancel: 'Cancel this exit',
arkOpsBoard: '{n} deposit(s) confirming',
arkOpsSend: '{n} send(s) finishing up',
arkOpsRefresh: '{n} refresh(es) waiting for the next round',