aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark/manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/ark/manager.js')
-rw-r--r--src/ark/manager.js197
1 files changed, 171 insertions, 26 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 33bac55..91b5f0a 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -92,6 +92,16 @@ const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0;
// claim recorded before it stamped them — the same amount within minutes).
// Derived at read time, never deleted: a synced copy from another device
// can't bring a twin back.
+// Two receive rows that are one payment delivered as several coins (a gift
+// held in two swept at once, a dust-padded split): the same mailbox message
+// (`batch`), or for rows recorded before the stamp, the same delivery instant.
+// A receive the registrar's receipts tagged ⚡ is its own payment.
+export function sameDelivery(a, b) {
+ return !!a && !!b && a.type === 'receive' && b.type === 'receive'
+ && a.status === 'complete' && b.status === 'complete' && !a.viaLn && !b.viaLn
+ && (a.batch || b.batch ? a.batch === b.batch : !!a.ts && a.ts === b.ts);
+}
+
export function lnFoldedIds(movements) {
const out = new Set();
const recs = (movements || []).filter((m) => m.type === 'receive' && m.status === 'complete' && m.vtxoId);
@@ -119,6 +129,24 @@ export function lnFoldedIds(movements) {
}
if (best) out.add(best.id);
}
+ // A Lightning payment the closed app's worker made is told twice once the
+ // app opens: the worker's own ln-send row (merged in from the mirror) and
+ // the "while closed" note the app adds from the worker's spend log. Per
+ // preimage (or payment hash), keep the row that names its inputs — the
+ // manager's — else the earliest.
+ const sends = (movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete' && (m.preimage || m.paymentHash));
+ const keyOf = new Map(); // payment hash → the preimage key, where a row knows both
+ for (const s of sends) if (s.preimage && s.paymentHash) keyOf.set(s.paymentHash, 'p:' + s.preimage);
+ const byPay = new Map();
+ for (const s of sends) {
+ const k = s.preimage ? 'p:' + s.preimage : (keyOf.get(s.paymentHash) || 'h:' + s.paymentHash);
+ (byPay.get(k) || byPay.set(k, []).get(k)).push(s);
+ }
+ for (const rows of byPay.values()) {
+ if (rows.length < 2) continue;
+ const keep = rows.find((r) => r.inputIds && r.inputIds.length) || rows.reduce((a, b) => ((a.ts || 0) <= (b.ts || 0) ? a : b));
+ for (const r of rows) if (r !== keep) out.add(r.id);
+ }
return out;
}
@@ -208,7 +236,7 @@ export function renewPolicy(tier, network) {
}
export class ArkManager {
- constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null }) {
+ constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null, lnOldestFirst }) {
this.account = account; // HDKey node; ark keys derived beneath it
// The server's last published info, kept by the app: lets the wallet open
// when the server can't be reached (see init)
@@ -230,6 +258,9 @@ export class ArkManager {
: network === 'mutinynet' ? 'https://names.halwallet.app/lnquote?network=mutinynet'
: null;
this.onUpdate = onUpdate || (() => {});
+ // () => true when this wallet renews its own coins (custody tier self or
+ // exit): a Lightning send then spends the coins nearest expiry first
+ this.lnOldestFirst = lnOldestFirst || (() => false);
this.state = null;
this.info = null;
this._lnDriving = new Set(); // re-entrancy guard: sync poll vs UI fast-poll vs mailbox push
@@ -513,6 +544,32 @@ export class ArkManager {
// free to spend, renew or exit from THIS device
_free(v, busy = this._remoteBusy()) { return !!v && v.state === 'spendable' && !busy.has(v.id) && this._policyType(v) === 'pubkey'; }
+ // A coin's exit depth: one transaction per hop of its history (the round
+ // tree's branch, then every arkoor transfer). Each off-chain payment adds
+ // one to the change and one to what the recipient gets; only a round
+ // flattens it. The server refuses to cosign a spend of a coin at its
+ // `maxVtxoExitDepth` (100 on coinos: a wallet paying hourly from one change
+ // chain gets there in four days), so such a coin is money only a refresh
+ // can free — and it refreshes without the base fee at `deepWaiverDepth`.
+ exitDepth(v) { try { return this._decoded(v).genesis.length; } catch { return 0; } }
+ _capDepth() { return (this.info && this.info.maxVtxoExitDepth) || 0; }
+ _waiverDepth() { return (this.info && this.info.refreshFees && this.info.refreshFees.deepWaiverDepth) || 0; }
+ _capped(v) { const cap = this._capDepth(); return !!cap && this.exitDepth(v) >= cap; }
+ // The free coins a refresh should flatten now: at the server's waiver depth
+ // (no base fee, so always worth it), or within a few hops of the cap on a
+ // server without one (stuck otherwise). Expired coins too: a round is the
+ // one thing an expired coin can always do, and the server honors it.
+ deepCoins() {
+ const waiver = this._waiverDepth(), cap = this._capDepth();
+ if (!waiver && !cap) return [];
+ const busy = this._remoteBusy();
+ return this.state.vtxos.filter((v) => {
+ if (!this._free(v, busy)) return false;
+ const d = this.exitDepth(v);
+ return (waiver && d >= waiver) || (cap && d >= cap - 8);
+ });
+ }
+
balance() {
const tip = this._tipH || 0;
const busy = this._remoteBusy();
@@ -761,9 +818,13 @@ export class ArkManager {
// same message concurrently (validation awaits in between), and only the
// copy that actually added the vtxo may record the receive movement.
if (!this._addVtxo(v, v._raw.bytes, 0)) continue;
+ // `batch` names the mailbox message: one payment can arrive as several
+ // coins (a sweep of a gift held in two, a dust-padded split), and the
+ // history folds a message's coins back into one row
this._movement({
type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id,
ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)),
+ ...(m.checkpoint ? { batch: String(m.checkpoint) } : {}),
});
changed = true;
}
@@ -1085,10 +1146,10 @@ export class ArkManager {
const past = list.filter((v) => this._pastExpiry(v, tip)).sort(cmp);
return [...live, ...past];
}
- // largest-first until `need` is covered; the coins still inside their
+ // largest-first (or in `order`) until `need` is covered; the coins inside their
// timelock alone if they can carry it, else everything
- _accumulate(spendable, tip, need) {
- const desc = (a, b) => b.amountSat - a.amountSat;
+ _accumulate(spendable, tip, need, order) {
+ const desc = order || ((a, b) => b.amountSat - a.amountSat);
const live = spendable.filter((v) => !this._pastExpiry(v, tip)).sort(desc);
for (const pool of [live, [...spendable].sort(desc)]) {
const picked = [];
@@ -1103,8 +1164,9 @@ export class ArkManager {
_selectInputs(amountSat, tip = this._tipH || 0) {
const busy = this._remoteBusy();
+ // a coin at the server's depth cap can't be an input: the cosign is refused
const spendable = this.state.vtxos
- .filter((v) => this._free(v, busy) && !this._expired(v, tip));
+ .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v));
const single = this._spendOrder(spendable.filter((v) => v.amountSat >= amountSat), tip, (a, b) => a.amountSat - b.amountSat)[0];
if (single) return [single];
const picked = this._accumulate(spendable, tip, (set, sum) => sum >= amountSat);
@@ -1117,11 +1179,48 @@ export class ArkManager {
return picked;
}
+ // Inputs for a Lightning send of `amountSat` plus `routing`, and the fee
+ // they come to: { inputs, feeSat }. Throws when the coins can't cover it.
+ _selectLnInputs(spendable, tip, amountSat, routing) {
+ let inputs = null, feeSat = 0;
+ // A wallet that renews its own coins pays for every renewal, so it
+ // spends the coins nearest expiry first (Stacker News, 2026-10-29): the
+ // ASP takes them, so nobody downstream inherits the short timelock, as
+ // an Ark-to-Ark send's recipient would. Otherwise (and on a tie) the
+ // smallest coin that covers it, keeping the balance in few coins.
+ const oldest = !!this.lnOldestFirst();
+ const exp = (v) => v.expiryHeight || Infinity;
+ const bySize = (a, b) => a.amountSat - b.amountSat;
+ const byAge = (a, b) => (exp(a) - exp(b)) || bySize(a, b);
+ for (const v of this._spendOrder(spendable, tip, oldest ? byAge : bySize)) {
+ const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing;
+ if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; }
+ }
+ if (!inputs) {
+ const need = (set, sum) => {
+ feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing;
+ return sum >= amountSat + feeSat;
+ };
+ let picked = oldest ? this._accumulate(spendable, tip, need, byAge) : null;
+ // too many old crumbs for one payment: fall back to largest-first
+ if (!picked || picked.length > MAX_SEND_INPUTS) picked = this._accumulate(spendable, tip, need);
+ if (picked) inputs = picked;
+ if (!inputs) throw new Error(this._insufficientMsg(tip));
+ if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment');
+ }
+ return { inputs, feeSat };
+ }
+
_insufficientMsg(tip) {
const expiredSat = this.state.vtxos
.filter((v) => v.state === 'spendable' && this._expired(v, tip))
.reduce((n, v) => n + v.amountSat, 0);
if (expiredSat) return `insufficient ark balance (${expiredSat} sat expired and unusable)`;
+ const cappedSat = this.state.vtxos.filter((v) => this._free(v) && this._capped(v)).reduce((n, v) => n + v.amountSat, 0);
+ // under dust a round won't take it either: only new money pays from here
+ if (cappedSat) return cappedSat < 330
+ ? `insufficient ark balance (${cappedSat} sat have a history too long to spend and are too few to renew; receive more to keep paying)`
+ : `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`;
// coins a renewal took stay unspendable until its round confirms on-chain
const renewing = lnShortfall(this.state.vtxos, this.state.actions, Infinity).renewingSat;
return renewing
@@ -1449,9 +1548,13 @@ export class ArkManager {
if (expectNet && dec.network && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`);
const amountSat = dec.amountSat ?? userAmountSat;
if (!amountSat || amountSat <= 0) throw new Error('invoice has no amount');
- if (this.state.actions.some((a) =>
- a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed')) {
- throw new Error('this invoice was already paid or is being paid');
+ const prior = this.state.actions.find((a) =>
+ a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed');
+ if (prior) {
+ // two messages, so a caller can tell a settled payment from one still
+ // on its way (an NWC client once read "already paid or is being paid"
+ // as paid, hours before the money moved)
+ throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress');
}
const tip = await this.chain.tipHeight();
const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat);
@@ -1459,22 +1562,10 @@ export class ArkManager {
// fee — else gather largest-first: each input funds its own HTLC vtxo with
// the same payment hash, and the server sums them into one payment.
const busy = this._remoteBusy();
+ // a coin at the depth cap can't be an input: the ASP refuses the cosign
const spendable = this.state.vtxos
- .filter((v) => this._free(v, busy) && !this._expired(v, tip));
- let inputs = null, feeSat = 0;
- for (const v of this._spendOrder(spendable, tip, (a, b) => a.amountSat - b.amountSat)) {
- const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing;
- if (v.amountSat >= amountSat + fee) { inputs = [v]; feeSat = fee; break; }
- }
- if (!inputs) {
- const picked = this._accumulate(spendable, tip, (set, sum) => {
- feeSat = lnSendFee(amountSat, this.info.lnSendFees, set, tip) + routing;
- return sum >= amountSat + feeSat;
- });
- if (picked) inputs = picked;
- if (!inputs) throw new Error(this._insufficientMsg(tip));
- if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment');
- }
+ .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v));
+ const { inputs, feeSat } = this._selectLnInputs(spendable, tip, amountSat, routing);
// per-input HTLC share: first n-1 consumed whole, the last carries change
let remainingLock = amountSat + feeSat;
const parts = inputs.map((v) => {
@@ -1631,9 +1722,16 @@ export class ArkManager {
action.step = 'initiated';
this._save();
} catch (e) {
+ // the HTLC already went to this payment: another context (a second
+ // wake of the phone's worker, seconds apart, over its own copy of the
+ // state) initiated it first. That payment is this one — follow it.
+ // (PullCap 2026-10-24 23:21: a paid send was marked 'revoking' here.)
+ if (e instanceof GrpcError && /state: ln-spent/i.test(e.message)) {
+ action.step = 'initiated';
+ this._save();
// INVALID_ARGUMENT: the server itself deems the invoice unpayable —
// retrying can never succeed, revoke the HTLC instead
- if (e instanceof GrpcError && e.grpcStatus === 3) {
+ } else if (e instanceof GrpcError && e.grpcStatus === 3) {
action.error = e.message;
action.step = 'revoking';
this._save();
@@ -1660,7 +1758,22 @@ export class ArkManager {
this._save();
}
}
- if (action.step === 'revoking') await this._revokeLnPay(action);
+ if (action.step === 'revoking') {
+ // Ask first: a send can land in 'revoking' over a stale copy of the
+ // state while the payment went through (or is still going) — a
+ // settled payment is settled here too, and one in flight waits.
+ const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false).catch(() => null);
+ if (res && res.status === 'success' && res.preimage
+ && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) {
+ this._settleLnPay(action, res.preimage);
+ return;
+ }
+ if (res && res.status === 'pending' && action.htlcExpiry) {
+ const tip = await this.chain.tipHeight().catch(() => 0);
+ if (tip && tip <= action.htlcExpiry) { action.step = 'initiated'; delete action.error; this._save(); return; }
+ }
+ await this._revokeLnPay(action);
+ }
}
_settleLnPay(action, preimageHex) {
@@ -2244,7 +2357,10 @@ export class ArkManager {
const entry = table.filter((e) => e.thresholdBlocks <= blocks).pop();
ppmUnits += v.amountSat * (entry?.ppm ?? 0);
}
- return this.info.refreshFees.baseFeeSat + Math.ceil(ppmUnits / 1_000_000);
+ // the server waives the base fee when every input is at its waiver depth
+ const waiver = this._waiverDepth();
+ const base = waiver && inputs.length && inputs.every((v) => this.exitDepth(v) >= waiver) ? 0 : this.info.refreshFees.baseFeeSat;
+ return base + Math.ceil(ppmUnits / 1_000_000);
}
// manual: the user asked for this one (the coins page) — it earns a
@@ -2607,6 +2723,35 @@ export class ArkManager {
return n;
}
+ // The worker's version of flattening deep coins: it can't sit through a
+ // live round, so each deep coin gets a standing order for two blocks out,
+ // one per coin (an order dies whole when any input is spent — and a coin
+ // that is spent hourly will void it; the next wake registers again, and
+ // the one time the coin rests through the height, the round runs). Only
+ // coins still inside their timelock can be scheduled; an expired deep coin
+ // waits for the app, whose live refresh the server honors.
+ async ensureDeepRefreshes(maxCoins = 2) {
+ const deep = this.deepCoins();
+ if (!deep.length || !maxCoins) return 0;
+ const tip = await this._tipMemo();
+ if (!tip) return 0;
+ const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds));
+ const cap = this.info.maxVtxoAmountSat || Infinity;
+ // a round output under dust (330 sat) is refused, so a coin that small
+ // can't be renewed on its own — asking only spends the wake's budget
+ const coins = deep
+ .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap
+ && v.amountSat - this.refreshFee([v], tip + 2) >= 330)
+ .sort((a, b) => this.exitDepth(b) - this.exitDepth(a))
+ .slice(0, maxCoins);
+ let n = 0;
+ for (const v of coins) {
+ try { await this.scheduleRefresh([v.id], tip + 2); n++; }
+ catch (e) { console.warn('ark: no deep refresh order for', v.id, '—', e.message); }
+ }
+ return n;
+ }
+
_dropScheduled(entry) {
this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry);
this._save();