aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js26
-rw-r--r--src/features/ark.js25
-rw-r--r--tools/ark/send-failure-test.js70
3 files changed, 108 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();
diff --git a/tools/ark/send-failure-test.js b/tools/ark/send-failure-test.js
new file mode 100644
index 0000000..e3120f8
--- /dev/null
+++ b/tools/ark/send-failure-test.js
@@ -0,0 +1,70 @@
+// A rejected package must reject send(), retain the unspent inputs, and
+// appear as a failed attempt rather than a successful payment.
+// Run: bun tools/ark/send-failure-test.js
+import assert from 'node:assert/strict';
+import { mock } from 'bun:test';
+import { secp256k1 } from '@noble/curves/secp256k1';
+import { encodeAddress, GrpcError } from '../../src/ark/proto.js';
+
+const send = await import('../../src/ark/send.js');
+let cosigns = 0;
+const badId = 'a'.repeat(64) + ':0';
+const error = new GrpcError(3, `bad user input: vtxo ${badId} expired at height 100 (tip = 200)`, 'RequestArkoorCosign');
+mock.module('../../src/ark/send.js', () => ({
+ ...send,
+ registerVtxoTransactions: async () => {},
+ buildArkoorSend: () => ({}),
+ cosignPackageWithServer: async () => { cosigns++; throw error; },
+}));
+const { ArkManager } = await import('../../src/ark/manager.js');
+const key = n => secp256k1.getPublicKey(new Uint8Array(32).fill(n), true);
+const dest = encodeAddress({ testnet: true, serverPubkey: key(1), userPubkey: key(2), blindedMailboxId: new Uint8Array(32).fill(3) });
+const mgr = new ArkManager({ storage: { save() {} }, arkUrl: 'http://test.invalid' });
+mgr.serverPub = key(1);
+mgr.state = {
+ nextKeyIndex: 1, movements: [], actions: [],
+ vtxos: [
+ { id: badId, amountSat: 1, expiryHeight: 100, state: 'spendable' },
+ { id: 'b'.repeat(64) + ':0', amountSat: 180, expiryHeight: 500, state: 'spendable' },
+ ],
+};
+Object.defineProperty(mgr, 'chain', { value: { tipHeight: async () => 200 } });
+mgr._decoded = () => ({ _raw: { bytes: new Uint8Array() } });
+mgr._keyForVtxo = () => ({});
+
+await assert.rejects(mgr.send(dest, 181), /expired at height 100/);
+assert.equal(mgr.state.actions[0].step, 'failed');
+assert.ok(mgr.state.vtxos.every(v => v.state === 'spendable'), 'atomic rejection does not consume either input');
+assert.deepEqual(mgr.balance(), { spendableSat: 180, pendingSat: 0, boardingSat: 0, expiredSat: 1 });
+assert.equal(mgr.state.movements[0].status, 'failed');
+assert.equal(mgr.state.movements[0].to, dest);
+assert.equal(mgr.pendingActions().length, 0, 'a definitive rejection is not automatically retried');
+console.log('✓ a rejected send throws, preserves unspent inputs, and records failure');
+
+await assert.rejects(mgr.send(dest, 181), /1 sat expired and unusable/);
+assert.equal(cosigns, 1, 'send-all does not reuse the rejected coin');
+assert.equal(mgr._selectInputs(180, 200)[0].amountSat, 180);
+assert.equal(mgr._expired({ amountSat: 1, expiryHeight: 100 }, 200), false, 'other dust retains its existing policy');
+delete mgr.state.vtxos[0].expiryRejected;
+assert.equal(mgr._recordExpiryRejection(mgr.state.actions[0]), true);
+assert.equal(mgr.balance().spendableSat, 180, 'old failed actions repair the displayed balance');
+assert.equal(mgr._recordExpiryRejection(mgr.state.actions[0]), false, 'repair is idempotent');
+assert.equal(mgr._recordExpiryRejection({ ...mgr.state.actions[0], parts: [] }), false, 'only inputs of that failed action can be marked');
+console.log('✓ known expired inputs stay excluded after reload; the usable 180 sats remain selectable');
+
+mgr._driveSend = async action => { action.step = 'done'; };
+assert.match(await mgr.send(dest, 180), /^send-/);
+console.log('✓ a completed send still returns its action ID');
+
+const { arkFeature } = await import('../../src/features/ark.js');
+const h = (tag, props, ...children) => ({ tag, props, children: children.flat().filter(x => x != null && x !== false) });
+const text = n => typeof n === 'object' ? n.children.map(text).join('') : String(n);
+const feature = arkFeature({ h, ui: {}, render() {}, fmtAmount: String, unitLabel: () => 'sats', hook: () => null,
+ wallet: { registerCacheExtension() {}, loadFeatureState: () => ({}) } });
+const rows = feature._histBuild({ movements: [{ id: 'failed-send', type: 'send', status: 'failed', amountSat: 181, ts: Date.now() }], actions: [], vtxos: [] });
+assert.equal(rows.length, 1);
+const label = text(rows[0].render());
+assert.match(label, /Payment failed/);
+assert.ok(!label.includes('-181'), 'a rejected amount is not displayed as money spent');
+console.log('✓ history includes the failed attempt without claiming a debit');
+process.exit(0);