diff options
| author | Adam Soltys <[email protected]> | 2026-07-31 08:04:33 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-07-31 08:04:33 -0700 |
| commit | ad281e832ed9e36b777f03ed54e799df7d70ffc9 (patch) | |
| tree | 2e7ba6df785e6b8f74266c56a7ca2006cbb9579b /src/ark | |
| parent | 72e407bf6d7d09923d15461be82685017a0887be (diff) | |
| download | coinosv3-ad281e832ed9e36b777f03ed54e799df7d70ffc9.tar.gz coinosv3-ad281e832ed9e36b777f03ed54e799df7d70ffc9.zip | |
Fix send-form UX around lightning pays; harden ln state machines
- a rejected cosign (gRPC INVALID_ARGUMENT) returns the input vtxo to
spendable and fails the action instead of leaving it pending forever —
the stuck-pending state that pushed small zaps onto Boltz's 25k minimum
- ark ln-pay now takes over when funds are settling (retrying quote) rather
than falling through to Boltz
- typing a destination clears stale send errors (also cleared on tab
switch); paste path likewise
- typed lightning addresses no longer auto-advance mid-keystroke (the
matcher fired at `[email protected]` before `.com`, yanking the form away): the send
form offers an explicit "Zap <addr>" button; pasted/scanned addresses
still advance immediately
- ln-recv: HTLC vtxos anchored to a not-yet-confirmed pool round park and
retry instead of throwing; the requested HTLC expiry is anchored on first
prepare so retries can't inflate it past the server's grant
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/ark')
| -rw-r--r-- | src/ark/manager.js | 40 |
1 files changed, 33 insertions, 7 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 700050a..d575d64 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -491,9 +491,12 @@ export class ArkManager { try { sigs = await cosignWithServer(this.arkUrl, build, { input, outputs, vtxoKeys: keys, serverPubkey: this.serverPub }); } catch (e) { - if (e instanceof GrpcError && /already spent|not spendable/i.test(e.message)) { - // the input is gone (possibly a prior crashed attempt) — don't retry - inputRec.state = 'spent'; + const spent = e instanceof GrpcError && /already spent|not spendable/i.test(e.message); + if (spent || (e instanceof GrpcError && e.grpcStatus === 3)) { + // "already spent": the input is gone (possibly a prior crashed + // attempt). Status 3: the server rejected the cosign outright, so + // the input is untouched and returns to spendable. Neither retries. + inputRec.state = spent ? 'spent' : 'spendable'; action.step = 'failed'; action.error = e.message; this._movement({ type: 'send', amountSat: action.amountSat, status: 'failed', detail: e.message }); @@ -646,8 +649,12 @@ export class ArkManager { [resp] = await requestLightningPayHtlcCosign(this.arkUrl, [cosignPartBytes({ build, input, vtxoKeys: keys, nonces })]); } catch (e) { - if (e instanceof GrpcError && /already spent|not spendable/i.test(e.message)) { - inputRec.state = 'spent'; + // INVALID_ARGUMENT (status 3): the server rejected the cosign outright + // — nothing was spent, so the input goes back to spendable instead of + // rotting in pending. "already spent" means the input really is gone. + const spent = e instanceof GrpcError && /already spent|not spendable/i.test(e.message); + if (spent || (e instanceof GrpcError && e.grpcStatus === 3)) { + inputRec.state = spent ? 'spent' : 'spendable'; action.step = 'failed'; action.error = e.message; this._movement({ type: 'ln-send', amountSat: action.amountSat, status: 'failed', detail: e.message }); @@ -861,7 +868,14 @@ export class ArkManager { // accepted | htlcsReady: ask for our HTLC vtxos and validate them const tip = await this.chain.tipHeight(); const keys = this._key(action.keyIndex); - const htlcRecvExpiry = tip + action.minCltvDelta; + // anchor the requested expiry on the FIRST attempt: the server's grant + // is fixed by the inbound HTLC, so recomputing from a later tip would + // inflate our own requirement until it exceeds the grant + if (!action.htlcRecvExpiry) { + action.htlcRecvExpiry = tip + action.minCltvDelta; + this._save(); + } + const htlcRecvExpiry = action.htlcRecvExpiry; let antiDos = null; if (this.info.lnReceiveAntiDosRequired) { const proof = this.state.vtxos.filter((v) => v.state === 'spendable') @@ -887,7 +901,19 @@ export class ArkManager { if (v.policy.paymentHash !== action.paymentHash) throw new Error('HTLC vtxo payment hash mismatch'); if (v.policy.userPubkey !== hex.encode(keys.pubkey)) throw new Error('HTLC vtxo pubkey mismatch'); if (v.policy.htlcExpiry < htlcRecvExpiry) throw new Error('HTLC vtxo expiry lower than requested'); - await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] }); + try { + await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] }); + } catch (e) { + // a granted vtxo can anchor to a round the chain hasn't confirmed + // yet — park (no preimage revealed, nothing at risk) and retry on + // sync; the invoice-expiry reap bounds how long we'll wait + if (e instanceof VtxoValidationError) { + action.lastError = e.message; + this._save(); + return; + } + throw e; + } total += v.amountSat; } if (total + lnReceiveFee(action.amountSat, this.info.lnReceiveFees) < action.amountSat) { |
