diff options
| author | Adam Soltys <[email protected]> | 2026-09-28 22:32:49 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-28 22:33:37 -0700 |
| commit | 615e8fa6ea643660ae6b6d35e669d6ad4816728a (patch) | |
| tree | 65392b6f49fb40cb035fffe2eb44f14a2742e634 | |
| parent | a31d97220c8acb858c71301972f38597c1eb2270 (diff) | |
| download | coinosv3-615e8fa6ea643660ae6b6d35e669d6ad4816728a.tar.gz coinosv3-615e8fa6ea643660ae6b6d35e669d6ad4816728a.zip | |
A zap refused for an old expired coin goes again on the other coins instead of showing the server error.
Sub-dust coins skip the local expiry check, so the ASP can refuse one at
cosign ("vtxo … expired at height …"). The coin was already marked
excluded, but the error still surfaced and the user had to tap again.
send() and payLnInvoice() now retry (up to 5 attempts) when that exact
refusal marks an input, without a failed history row for the retried try.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Du1qT1wbkYwYK6zkGH79Rp
| -rw-r--r-- | src/ark/manager.js | 31 | ||||
| -rw-r--r-- | tools/ark/send-failure-test.js | 23 |
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; |
