aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-31 08:04:33 -0700
committerAdam Soltys <[email protected]>2026-07-31 08:04:33 -0700
commitad281e832ed9e36b777f03ed54e799df7d70ffc9 (patch)
tree2e7ba6df785e6b8f74266c56a7ca2006cbb9579b /src/ark
parent72e407bf6d7d09923d15461be82685017a0887be (diff)
downloadcoinosv3-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.js40
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) {