aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js31
-rw-r--r--tools/ark/send-failure-test.js23
2 files changed, 41 insertions, 13 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 311c793..b0985de 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -853,7 +853,23 @@ export class ArkManager {
: 'insufficient ark balance';
}
+ // A server refusal naming an expired input marks that coin; the payment
+ // then goes again on the remaining coins instead of surfacing an error the
+ // user would only answer by tapping again. Each refusal names one coin.
+ async _retryExpired(attempt) {
+ for (let i = 0; ; i++) {
+ const action = await attempt();
+ if (!(action.step === 'failed' && action.expiryRetry) || i >= 4) return action;
+ }
+ }
+
async send(addrString, amountSat) {
+ const action = await this._retryExpired(() => this._sendOnce(addrString, amountSat));
+ if (action.step !== 'done') throw new Error(action.error || 'Ark payment did not complete');
+ return action.id;
+ }
+
+ async _sendOnce(addrString, amountSat) {
const dest = decodeAddress(addrString);
if (dest.arkId !== hex.encode(arkIdFromServerPubkey(this.serverPub))) {
throw new Error('address belongs to a different ark server');
@@ -887,8 +903,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;
+ return action;
}
// pre-multi-input actions carried the single part in flat fields
@@ -931,7 +946,7 @@ export class ArkManager {
if (spent) this.reconcile().catch(() => {});
action.step = 'failed';
action.error = e.message;
- this._recordExpiryRejection(action);
+ if (this._recordExpiryRejection(action)) { action.expiryRetry = true; this._save(); return; }
this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message, to: action.destAddress });
this._save();
return;
@@ -1089,7 +1104,11 @@ export class ArkManager {
// Pay a bolt11 or bolt12 invoice with ark funds. Returns the action id;
// drive to a terminal step ('done' | 'failed') via driveLn()/sync.
- async payLnInvoice(invoice, { amountSat: userAmountSat, routingFeeSat } = {}) {
+ async payLnInvoice(invoice, opts = {}) {
+ return (await this._retryExpired(() => this._payLnInvoiceOnce(invoice, opts))).id;
+ }
+
+ async _payLnInvoiceOnce(invoice, { amountSat: userAmountSat, routingFeeSat } = {}) {
const dec = decodeLnInvoice(invoice);
const expectNet = { bitcoin: 'mainnet', regtest: 'regtest', signet: 'signet', testnet: 'testnet' }[this.info.network];
// a null network is a bolt12 on a chain we don't recognize (custom
@@ -1143,7 +1162,7 @@ export class ArkManager {
this.state.actions.push(action);
this._save();
await this._driveLnPay(action);
- return action.id;
+ return action;
}
// pre-multi-input ln-pay actions carried the single part in flat fields
@@ -1215,7 +1234,7 @@ export class ArkManager {
if (spent) this.reconcile().catch(() => {});
action.step = 'failed';
action.error = e.message;
- this._recordExpiryRejection(action);
+ if (this._recordExpiryRejection(action)) { action.expiryRetry = true; this._save(); return; }
// "already in progress" means another device of this wallet is
// paying this very invoice — that payment's row is the history,
// a failed row beside it would read as a lost zap.
diff --git a/tools/ark/send-failure-test.js b/tools/ark/send-failure-test.js
index e3120f8..e3d64bf 100644
--- a/tools/ark/send-failure-test.js
+++ b/tools/ark/send-failure-test.js
@@ -10,11 +10,12 @@ 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');
+let nextError = error;
mock.module('../../src/ark/send.js', () => ({
...send,
registerVtxoTransactions: async () => {},
buildArkoorSend: () => ({}),
- cosignPackageWithServer: async () => { cosigns++; throw error; },
+ cosignPackageWithServer: async () => { cosigns++; throw nextError; },
}));
const { ArkManager } = await import('../../src/ark/manager.js');
const key = n => secp256k1.getPublicKey(new Uint8Array(32).fill(n), true);
@@ -32,17 +33,25 @@ 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/);
+// the refusal marks the coin and the send goes again on what is left —
+// here nothing covers 181, so the user hears about the shortfall
+await assert.rejects(mgr.send(dest, 181), /1 sat expired and unusable/);
+assert.equal(cosigns, 1, 'the retry does not reuse the rejected coin');
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.length, 0, 'the retried attempt leaves no failed row');
+assert.equal(mgr.pendingActions().length, 0, 'a definitive rejection is not driven again');
+console.log('✓ an expired-input rejection retries without the coin and preserves unspent inputs');
+
+// any other refusal still surfaces at once and is recorded
+nextError = new GrpcError(3, 'bad user input: something else', 'RequestArkoorCosign');
+await assert.rejects(mgr.send(dest, 180), /something else/);
+assert.equal(cosigns, 2, 'no retry for other refusals');
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');
+console.log('✓ other rejections throw once and record failure');
+nextError = error;
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;