aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-06 10:05:12 -0700
committerAdam Soltys <[email protected]>2026-07-06 10:05:12 -0700
commitd838a91ada7a5d7d91e4ee42fb34e001f9a5a1b6 (patch)
tree827d5072e15a6b11673c6d2b185a78d51166c08d /src/features
parentda883adfab30f0fb6d992b73514d8ea850d860df (diff)
downloadcoinosv3-d838a91ada7a5d7d91e4ee42fb34e001f9a5a1b6.tar.gz
coinosv3-d838a91ada7a5d7d91e4ee42fb34e001f9a5a1b6.zip
Consolidate ark exit into one page: cooperative + unilateral
The cooperative offboard was buried under the receive-tab board form and the unilateral exit lived in Settings — both unintuitive. Now a small 'Exit' link next to the Ark balance opens a dedicated page that explains and offers both: cooperative (server co-signs, fast, no on-chain funds needed, small server fee) and unilateral (trustless, timelocked, pays mining fees). In-flight exit progress (hop counter, timelock countdown, cancel) moved here from Settings; the receive pane loses the offboard button and Settings loses the exit button. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js121
1 files changed, 80 insertions, 41 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 29a55eb..36aecf2 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -399,39 +399,11 @@ export function arkFeature(ctx) {
? 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,
- // the trustless escape hatch: broadcast the exit chains, no server needed
+ // Moving funds out (cooperative offboard / unilateral exit) lives on its
+ // own page, reached from the "Exit" link on the Ark balance line.
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) => {
- // 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,
- // 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);
- })
+ ? h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkExitPage = true; ui.arkError = ''; render(); } }, t('arkExitPageTitle'))
+ : null
);
}
@@ -909,13 +881,6 @@ export function arkFeature(ctx) {
canBoard
? h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardAvailable', { n: fmtAmount(wallet.spendable) + ' ' + unitLabel() }))
: h('div', { class: 'small faint', style: 'text-align:center' }, t('arkBoardNoFunds', { n: minBoard.toLocaleString() })),
- // ...and the way back out: offboard the whole ark balance on-chain.
- ark.balance().spendableSat > 0
- ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkOffboard },
- ui.arkBusy === 'offboard'
- ? h('span', { class: 'spinner sm' })
- : t('arkOffboardBtn', { n: fmtAmount(ark.balance().spendableSat) + ' ' + unitLabel() }))
- : null,
ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null)
);
}
@@ -962,7 +927,7 @@ export function arkFeature(ctx) {
h('div', { class: 'row gap6' },
copyBtn(o.txid, t('copyTxid')),
h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool'))),
- h('button', { class: 'btn-primary btn-block', onClick: () => { ui.arkOffboarded = null; render(); } }, t('done'))
+ h('button', { class: 'btn-primary btn-block', onClick: () => { ui.arkOffboarded = null; ui.arkExitPage = null; render(); } }, t('done'))
);
}
@@ -989,10 +954,78 @@ export function arkFeature(ctx) {
return null;
}
+ // Per-exit progress lines (chain hops / timelock countdown / claiming), with
+ // a cancel for an exit that never published. Shown on the exit page.
+ function arkExitStatusLines() {
+ const s = arkStateNow();
+ return ((s && s.actions) || []).filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)).map((a) => {
+ 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', style: 'margin-top:4px' },
+ 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,
+ a.actionable && !(a.hopsDone > 0)
+ ? h('button', { class: 'linklike small', onClick: () => {
+ a.step = 'failed';
+ const v = ark && ark._vtxo(a.vtxoId);
+ if (v && v.state === 'pending') v.state = 'spendable';
+ if (ark) ark._save();
+ render();
+ } }, t('arkExitCancel'))
+ : null);
+ });
+ }
+
+ // The exit page: cooperative offboard vs unilateral exit, with explanation.
+ // Reached from the "Exit" link on the Ark balance line.
+ function arkExitPage() {
+ if (ui.arkOffboarded) return arkOffboardedScreen(); // cooperative success takeover
+ const b = arkBalance() || { spendableSat: 0, pendingSat: 0 };
+ const spendable = b.spendableSat;
+ const total = b.spendableSat + b.pendingSat;
+ const nSpend = ((arkStateNow() && arkStateNow().vtxos) || []).filter((v) => v.state === 'spendable').length;
+ const exits = arkExitStatusLines();
+ const back = () => { ui.arkExitPage = null; ui.arkError = ''; render(); };
+ return h('div', { class: 'col', style: 'gap:16px' },
+ h('div', { class: 'card col', style: 'gap:8px' },
+ h('h3', { class: 'row gap6', style: 'align-items:center;margin:0' }, h('span', { html: ARK_ICON(18) }), t('arkExitPageTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitPageIntro')),
+ total > 0 ? h('div', { class: 'row between', style: 'margin-top:4px' },
+ h('span', { class: 'small muted' }, t('arkBalance')),
+ h('span', { class: 'small' }, fmtAmount(total) + ' ' + unitLabel())) : null),
+ // cooperative
+ h('div', { class: 'card col', style: 'gap:8px' },
+ h('h4', { style: 'margin:0' }, t('arkCoopTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
+ spendable > 0
+ ? h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: doArkOffboard },
+ ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtAmount(spendable) + ' ' + unitLabel() }))
+ : h('div', { class: 'small faint' }, t('arkExitNoBalance'))),
+ // unilateral
+ h('div', { class: 'card col', style: 'gap:8px' },
+ h('h4', { style: 'margin:0' }, t('arkUniTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkUniDesc')),
+ nSpend > 0
+ ? h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy, onClick: doArkExit },
+ ui.arkBusy === 'exit' ? h('span', { class: 'spinner sm' }) : t('arkExitBtn', { n: nSpend }))
+ : null,
+ ...exits),
+ ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null,
+ h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))
+ );
+ }
+
return {
id: 'ark',
init() { initArk(); },
stop() { stopArk(); },
+ screenView() { return ui.arkExitPage ? arkExitPage() : null; },
receiveModes() {
if (!arkAvailable()) return [];
return [{ id: 'ark', label: t('receiveArkTab'), icon: ARK_MARK(18), render: (seg) => arkReceivePane(seg) }];
@@ -1059,7 +1092,13 @@ export function arkFeature(ctx) {
const b = arkBalance();
if (!b) return [];
const lines = [];
- if (b.spendableSat + b.pendingSat > 0) lines.push({ label: t('arkBalance'), sat: b.spendableSat + b.pendingSat });
+ if (b.spendableSat + b.pendingSat > 0) {
+ // "Ark balance … Exit" — the exit link opens the offboard/exit page.
+ const label = h('span', { class: 'row gap6', style: 'align-items:center' },
+ t('arkBalance'),
+ h('span', { class: 'linklike', style: 'font-size:12px', onClick: () => { ui.arkExitPage = true; ui.arkError = ''; render(); } }, t('arkExitLink')));
+ lines.push({ label, sat: b.spendableSat + b.pendingSat });
+ }
// A board in flight: the sats already left the on-chain balance, so show
// where they went instead of having them silently disappear.
if (b.boardingSat > 0) lines.push({ label: t('arkBoarding'), sat: b.boardingSat });