diff options
| author | Adam Soltys <[email protected]> | 2026-09-11 08:51:54 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-11 08:51:54 -0700 |
| commit | 207cb4451a07417baf63b2586e6759cf8b471482 (patch) | |
| tree | 9190520f40a22246ec63c857554bc64c6b54376d /src | |
| parent | f99a608465145271ef11d77e2451f520fbdc1dd3 (diff) | |
| download | coinosv3-207cb4451a07417baf63b2586e6759cf8b471482.tar.gz coinosv3-207cb4451a07417baf63b2586e6759cf8b471482.zip | |
Report rejected Ark sends and exclude server-rejected expired inputs
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 26 | ||||
| -rw-r--r-- | src/features/ark.js | 25 |
2 files changed, 38 insertions, 13 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index c0e972b..15714b7 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -210,6 +210,10 @@ export class ArkManager { this.state.serverPubkey = this.info.serverPubkey; this._save(); } + // Older builds kept server-rejected dust in the spendable balance. + let expiryChanged = false; + for (const action of this.state.actions) expiryChanged = this._recordExpiryRejection(action) || expiryChanged; + if (expiryChanged) this._save(); return this; } @@ -337,7 +341,22 @@ export class ArkManager { // the exception — they have no unilateral exit to protect and the coinos ASP // keeps honoring them off-chain (mirrors validateVtxo's carve-out). _expired(v, tip) { - return !!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip; + return !!v.expiryRejected || (!!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip); + } + + // The dust exemption is not a promise that every old coin will be + // accepted. A cosign rejection names the exact expired input; remember + // that verdict without discarding the other inputs in its atomic package. + _recordExpiryRejection(action) { + if (action.step !== 'failed') return false; + const m = /vtxo ([0-9a-f]{64}:\d+) expired at height (\d+) \(tip = (\d+)\)/i.exec(action.error || ''); + if (!m) return false; + const inputs = [...(action.inputIds || []), ...this._sendParts(action).map(p => p.inputId)]; + const v = inputs.includes(m[1]) && this._vtxo(m[1]); + if (!v || v.expiryHeight !== Number(m[2]) || v.expiryRejected) return false; + v.expiryRejected = true; + this._tipH = Math.max(this._tipH || 0, Number(m[3])); + return true; } balance() { @@ -810,6 +829,7 @@ export class ArkManager { this.state.actions.push(action); this._save(); await this._driveSend(action); + if (action.step !== 'done') throw new Error(action.error || 'Ark payment did not complete'); return action.id; } @@ -853,7 +873,8 @@ export class ArkManager { if (spent) this.reconcile().catch(() => {}); action.step = 'failed'; action.error = e.message; - this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message }); + this._recordExpiryRejection(action); + this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message, to: action.destAddress }); this._save(); return; } @@ -1105,6 +1126,7 @@ export class ArkManager { if (spent) this.reconcile().catch(() => {}); action.step = 'failed'; action.error = e.message; + this._recordExpiryRejection(action); this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'failed', detail: e.message }); this._save(); return; diff --git a/src/features/ark.js b/src/features/ark.js index b70bafe..6398537 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -664,7 +664,7 @@ export function arkFeature(ctx) { function arkBalance() { const s = arkStateNow(); if (!s) return null; - const sum = (st) => (s.vtxos || []).filter((v) => v.state === st).reduce((n, v) => n + v.amountSat, 0); + const sum = (st) => (s.vtxos || []).filter((v) => v.state === st && !v.expiryRejected).reduce((n, v) => n + v.amountSat, 0); const boardingSat = (s.actions || []) .filter((a) => a.type === 'board' && a.fundingTxid && !['done', 'failed'].includes(a.step)) .reduce((n, a) => n + (a.amountSat - a.feeSat), 0); @@ -1188,8 +1188,9 @@ export function arkFeature(ctx) { : null; function arkHistoryItem(m) { + const failed = m.status === 'failed'; const incoming = !['send', 'offboard', 'exit', 'ln-send', 'refresh'].includes(m.type); - const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') + const label = failed ? t('arkLnPayFailed') : m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory') : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : m.type === 'refresh' ? t('arkRenewedHistory') : t('sent'); @@ -1248,10 +1249,10 @@ export function arkFeature(ctx) { g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag'))); })(), (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(), - m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), + m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status) : null), h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), h('div', { style: 'text-align:right' }, - h('div', { class: incoming ? 'amount-pos' : 'amount-neg' }, (incoming ? '+' : '-') + fmtAmount(m.amountSat))) + h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg' }, (failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat))) ); } @@ -1336,8 +1337,9 @@ export function arkFeature(ctx) { } function arkMoveDetailView(m) { + const failed = m.status === 'failed'; const incoming = m.type === 'refresh' ? false : !['send', 'offboard', 'exit', 'ln-send'].includes(m.type); - const label = m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') + const label = failed ? t('arkLnPayFailed') : m.type === 'receive' ? t('received') : m.type === 'board' ? t('arkBoarded') : m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory') : m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited') : m.type === 'refresh' ? t('arkRenewedHistory') : t('sent'); @@ -1355,12 +1357,12 @@ export function arkFeature(ctx) { : ONCHAIN_ARK.has(m.type) ? h('span', { html: BITCOIN_ICON(18) }) : h('span', { html: ARK_ICON(18) }), h('h3', { style: 'margin:0' }, label), - m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), + m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status) : null), m.type === 'refresh' ? h('div', { class: m.feeSat > 0 ? 'amount-neg' : 'small faint', style: 'font-size:20px' }, m.feeSat > 0 ? '-' + fmtAmount(m.feeSat) + ' ' + unitLabel() : t('arkDepthFree')) - : h('div', { class: incoming ? 'amount-pos' : 'amount-neg', style: 'font-size:20px' }, - (incoming ? '+' : '-') + fmtAmount(m.amountSat) + ' ' + unitLabel()), + : h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg', style: 'font-size:20px' }, + (failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat) + ' ' + unitLabel()), row(t('dateLabel'), new Date(m.ts).toLocaleString()), (() => { const pk = zapNoteFor(m); @@ -2645,7 +2647,7 @@ export function arkFeature(ctx) { async function maybeAutoRefresh(mgr) { if (wallet.watchOnly || !mgr || !mgr.state) return; if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return; - const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable'); + const spendables = (mgr.state.vtxos || []).filter((v) => v.state === 'spendable' && !v.expiryRejected); if (!spendables.length) { arkRenewWarn = null; return; } // never start a round while any other action is still in flight if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return; @@ -3280,7 +3282,8 @@ export function arkFeature(ctx) { // renewal is net zero, invisible money movement, noise. const seenPay = new Set(); const moves = (s.movements || []) - .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit', 'ln-send', 'ln-receive', 'refresh', 'reconcile'].includes(m.type) && m.status === 'complete') + .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit', 'ln-send', 'ln-receive', 'refresh', 'reconcile'].includes(m.type) + && (m.status === 'complete' || (m.type === 'send' && m.status === 'failed'))) .filter((m) => m.type !== 'refresh' || m.feeSat > 0) .sort((m, n) => (m.ts || 0) - (n.ts || 0)) .filter((m) => { @@ -3392,7 +3395,7 @@ export function arkFeature(ctx) { // balance is enough: the depth advisory outranks this forecast, and // Manage carries the full story either way. if (!depthNotice && ark && ark.state && ark._tipH && !wallet.watchOnly) { - const spend = (ark.state.vtxos || []).filter((v) => v.state === 'spendable' && v.expiryHeight); + const spend = (ark.state.vtxos || []).filter((v) => v.state === 'spendable' && v.expiryHeight && !v.expiryRejected); if (spend.length) { const tip = ark._tipH; const RENEW = renewWindow(); |
