diff options
| author | Adam Soltys <[email protected]> | 2026-07-05 09:05:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-05 09:05:20 -0700 |
| commit | f9fc0bb3e38c3387f8af5d151aa03d83376ba9cc (patch) | |
| tree | 4f7754e3b6b0be893e4c21acc8a881e9347d32b0 /src/features | |
| parent | db7e70fac51e16c7f389e031a80355b2d9d0f57e (diff) | |
| download | coinosv3-f9fc0bb3e38c3387f8af5d151aa03d83376ba9cc.tar.gz coinosv3-f9fc0bb3e38c3387f8af5d151aa03d83376ba9cc.zip | |
Collaborative offboard: move the ark balance back on-chain
PrepareOffboard/FinishOffboard against captaind — per-input ownership
attestations, connector-based musig forfeits (with the deterministic
fanout tx when there is more than one input), and fee math that mirrors
the server bit-for-bit (weight fee rounds up, ppm-by-expiry rounds
down) since it rejects any other net amount. The receive pane gains a
'Move back on-chain' button that withdraws everything to the wallet's
own fresh address; the incoming tx is labeled 'Ark withdrawal' in
History and the ark side records the movement. Crash-safe: the txid
persists before FinishOffboard and resume asks GetVtxoStatus whether
the forfeits landed (the server broadcasts the tx itself).
Also fixes boarding to applySentTx after broadcasting the funding tx —
without it the spent coin lingered in the utxo set (a second board
double-spent it), and the post-board scan() could resurrect it by
racing the explorer's indexing.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 76 |
1 files changed, 66 insertions, 10 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 6584dc7..dcfd042 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -2,6 +2,7 @@ // protocol, spoken natively by ../ark). Receive address + boarding, instant // sends, history entries, refresh/consolidation, server settings. +import * as btc from '@scure/btc-signer'; import { ArkManager } from '../ark/manager.js'; import { getNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig } from '../api.js'; import { t } from '../i18n.js'; @@ -164,8 +165,9 @@ export function arkFeature(ctx) { } function arkHistoryItem(m) { - const incoming = m.type !== 'send'; - const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent'); + const incoming = !['send', 'offboard'].includes(m.type); + const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') + : m.type === 'offboard' ? t('arkOffboarded') : t('sent'); return h( 'div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } }, @@ -182,8 +184,9 @@ export function arkFeature(ctx) { } function arkMoveDetailView(m) { - const incoming = m.type !== 'send'; - const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : t('sent'); + const incoming = !['send', 'offboard'].includes(m.type); + const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') + : m.type === 'offboard' ? t('arkOffboarded') : 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; @@ -289,10 +292,30 @@ export function arkFeature(ctx) { const draft = wallet.buildTx({ recipients: [{ address: fundingAddress, amount: sats }], feeRate, noSort: true }); const hexTx = wallet.sign(draft.tx); const txid = await wallet.broadcast(hexTx); + // Like the main send flow: reflect the spend locally and let the + // poll/watcher reconcile. A scan here would race the explorer's indexing + // and could resurrect the just-spent coin. + wallet.applySentTx(draft.tx); await ark.completeBoard(actionId, txid); ui.arkBoardAmt = ''; ui.arkBoarded = { txid, netSat: sats - feeSat }; - wallet.scan().catch(() => {}); + } catch (e) { + ui.arkError = e.message; + } + ui.arkBusy = null; render(); + } + + // Offboard the whole ark balance back into this wallet's own on-chain + // receive address — the mirror image of boarding. + async function doArkOffboard() { + ui.arkBusy = 'offboard'; ui.arkError = ''; render(); + try { + const mgr = await connectArk(); + const address = wallet.freshReceive().address; + const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address)); + const action = await mgr.startOffboard(spk, address); + ui.arkOffboarded = { txid: action.txid, netSat: action.netSat, feeSat: action.feeSat }; + wallet.scan().catch(() => {}); // surface the incoming pending tx promptly } catch (e) { ui.arkError = e.message; } @@ -351,6 +374,13 @@ 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) ); } @@ -380,6 +410,27 @@ export function arkFeature(ctx) { return null; } + // Offboard success screen — mirror of the boarded screen. + function arkOffboardedScreen() { + if (!ui.arkOffboarded) return null; + const o = ui.arkOffboarded; + const url = wallet.api.explorerTx(o.txid); + return h( + 'div', + { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:40px 20px' }, + h('div', { class: 'check-badge' }, '✓'), + h('h2', { style: 'margin:0' }, t('arkOffboardedTitle')), + h('div', { class: 'amount-pos', style: 'font-size:18px' }, '+' + fmtAmount(o.netSat) + ' ' + unitLabel()), + h('p', { class: 'small muted', style: 'margin:0' }, t('arkOffboardedNote')), + o.feeSat ? h('div', { class: 'small faint' }, t('feeShort', { x: fmtAmount(o.feeSat) })) : null, + h('div', { class: 'addr-box', style: 'width:100%' }, o.txid), + 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')) + ); + } + // Boarding success screen (txid + explorer link) until dismissed. function arkBoardedScreen() { // A board just broadcast — show its success screen until dismissed. @@ -412,6 +463,8 @@ export function arkFeature(ctx) { return [{ id: 'ark', label: t('receiveArkTab'), render: (seg) => arkReceivePane(seg) }]; }, receiveTakeover() { + const offboarded = arkOffboardedScreen(); + if (offboarded) return offboarded; const boarded = arkBoardedScreen(); if (boarded) return boarded; return arkCelebration(); @@ -441,7 +494,7 @@ export function arkFeature(ctx) { historyEntries() { if (!ark || !ark.state) return []; return ark.movements() - .filter((m) => ['receive', 'send', 'board'].includes(m.type) && m.status === 'complete') + .filter((m) => ['receive', 'send', 'board', 'offboard'].includes(m.type) && m.status === 'complete') .map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })); }, historyDetail() { @@ -462,10 +515,13 @@ export function arkFeature(ctx) { return lines; }, decorateTxRow(tx) { - if (tx.net >= 0 || !ark || !ark.state) return null; - const a = ark.state.actions.find((x) => x.type === 'board' && x.fundingTxid === tx.txid); - if (!a) return null; - return { icon: '⚔', label: h('span', {}, t('arkBoardHistory')) }; + if (!ark || !ark.state) return null; + if (tx.net < 0) { + const a = ark.state.actions.find((x) => x.type === 'board' && x.fundingTxid === tx.txid); + 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; }, txDetailSection(tx) { if (!ark || !ark.state) return null; |
