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.js225
1 files changed, 184 insertions, 41 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index d5ae2b0..33bac55 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -177,9 +177,46 @@ export function pruneArkState(state, now = Date.now()) {
// Shared by the app (features/ark.js) and the worker tending a closed wallet.
export const backstopMargin = (network) => (network === 'regtest' ? 6 : 36);
+// Custody tiers — what a coin's timelock means to this wallet:
+// custodial nothing renews. The coinos server keeps honoring a coin past
+// its timelock (it has swept the tree; the coin is its IOU), so
+// the balance works like a v2 one, and no renewal is ever paid.
+// self renew in the last hours before expiry — the free ppm bracket,
+// one base fee per round — so a unilateral exit is always there.
+// exit the same, plus: renew a coin as soon as its history runs
+// `maxDepth` hops deep. A unilateral exit publishes one on-chain
+// transaction per hop, so depth — not age — is what makes an
+// exit expensive; a renewal flattens it to one hop. Early, so
+// the top ppm bracket + base; only for a coin worth it (the fee
+// under a tenth of its value — see maybeAutoRefresh).
+// A policy is { window, margin, grid } in blocks: the app renews a coin with
+// under `window` blocks left; the server-side backstop is registered `margin`
+// blocks before expiry, snapped DOWN to a multiple of `grid` so everyone's
+// orders land in the same round (one round's mining fee shared, not one per
+// coin). The snapped height stays inside the free bracket: 36–83 blocks out.
+export const CUSTODY_TIERS = ['custodial', 'self', 'exit'];
+export const MAX_SEND_INPUTS = 48;
+export function renewPolicy(tier, network) {
+ const rt = network === 'regtest';
+ const self = { window: rt ? 24 : 96, margin: backstopMargin(network), grid: rt ? 1 : 48 };
+ // 'early' was the third tier for a day (2026-10-22): a week-ahead renewal,
+ // which flattens the same history a last-hours one does — mirrors may
+ // still carry the name
+ if (tier === 'exit' || tier === 'early') return { ...self, maxDepth: 4 };
+ if (tier === 'self') return self;
+ return null;
+}
+
export class ArkManager {
- constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint }) {
+ constructor({ account, storage, arkUrl, esploraUrl, network = 'regtest', onUpdate, lnQuoteUrl, statusHint, honorsExpired = false, infoHint = null }) {
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)
+ this.infoHint = infoHint || null;
+ this.offline = false;
+ // the server cosigns spends of a coin past its timelock (coinos): such a
+ // coin stays money here — spendable, receivable — just not exitable
+ this.honorsExpired = !!honorsExpired;
this.storage = storage; // { load(): obj|null, save(obj): void }
this.arkUrl = arkUrl;
this.esploraUrl = esploraUrl;
@@ -242,7 +279,19 @@ export class ArkManager {
seen.add(m.vtxoId);
return true;
});
- this.info = await getArkInfo(this.arkUrl);
+ try {
+ this.info = await getArkInfo(this.arkUrl);
+ this.offline = false;
+ } catch (e) {
+ // The server is unreachable. With its last published info in hand the
+ // wallet still opens on its own records: coins read, a unilateral exit
+ // can start (that needs the chain, not the server), and anything that
+ // does need the server fails when it asks. Without that info there is
+ // no server key to check anything against — then it really is closed.
+ if (!this.infoHint) throw e;
+ this.info = this.infoHint;
+ this.offline = true;
+ }
const hs = await handshake(this.arkUrl).catch(() => null);
this.psa = hs?.psa;
this.serverPub = hex.decode(this.info.serverPubkey);
@@ -417,7 +466,9 @@ export class ArkManager {
// the exception — they have no unilateral exit to protect and the coinos ASP
// keeps honoring them off-chain (mirrors validateVtxo's carve-out).
_expired(v, tip) {
- return !!v.expiryRejected || (!!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip);
+ if (v.expiryRejected) return true; // the server said no to this one
+ if (this.honorsExpired) return false;
+ return !!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip;
}
// The dust exemption is not a promise that every old coin will be
@@ -508,6 +559,10 @@ export class ArkManager {
this._lnRescanned = true;
try { if (await this.rescanLnReceives()) await this._save(); } catch (e) { this._lnRescanned = false; }
}
+ if (!this._expiredRescanned) {
+ this._expiredRescanned = true;
+ try { if (await this.rescanRejectedExpired()) this._save(); } catch (e) { this._expiredRescanned = false; }
+ }
const baselining = !!this.state.baselinePending;
// keep the cached tip fresh: balance() and input selection judge coin
// expiry against it synchronously, and a stale tip hides dead coins
@@ -570,6 +625,59 @@ export class ArkManager {
}
}
+ // On a server that honors coins past their timelock, the ones this wallet
+ // wrote off before it knew that are still its money:
+ // - receives an older build refused at pickup ("vtxo expired at …"),
+ // recorded as rejected and left behind the mailbox cursor — the
+ // forwards stranded that way before 2026-08-24 among them;
+ // - coins the server once refused to spend (expiryRejected, from before
+ // allow_expired_arkoor), and expired dust the user gave up (Forget).
+ // Once per wallet: re-read the mailbox from the start and adopt what the
+ // server still reports spendable; clear the old verdicts the same way. The
+ // cursor is left where it is. Returns whether anything changed.
+ async rescanRejectedExpired() {
+ if (!this.honorsExpired || this.state.expiredRescanDone) return false;
+ let changed = false;
+ const alive = async (v) => {
+ try {
+ return (await getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey)) === VTXO_STATE_SPENDABLE;
+ } catch { return false; }
+ };
+ for (const v of this.state.vtxos) {
+ if (!(v.expiryRejected || (v.forgotten && v.state === 'spent'))) continue;
+ if (!(await alive(v))) continue;
+ delete v.expiryRejected; delete v.forgotten; v.state = 'spendable';
+ changed = true;
+ }
+ const rejected = (this.state.movements || []).filter((m) => m.type === 'receive' && m.status === 'rejected' && /expired at/i.test(m.detail || ''));
+ if (rejected.length) {
+ const mailbox = this._mailboxKey();
+ let checkpoint = 0;
+ for (let page = 0; page < 50; page++) {
+ const { messages, haveMore } = await readMailbox(this.arkUrl, mailbox, checkpoint);
+ for (const m of messages) {
+ checkpoint = Math.max(checkpoint, m.checkpoint || 0);
+ if (m.kind !== 'arkoor') continue;
+ const fresh = [];
+ for (const v of m.vtxos) {
+ if (this._vtxo(v.id)) continue;
+ // a coin another device of this wallet adopted and spent since
+ // is not money here; only what the server still holds for us is
+ let status = 0;
+ try { status = await getVtxoStatus(this.arkUrl, v.point.raw, this._key(0).privkey); } catch { status = 0; }
+ if (status === VTXO_STATE_SPENDABLE) fresh.push(v);
+ }
+ if (fresh.length && await this._handleMailboxMessage({ ...m, vtxos: fresh })) changed = true;
+ }
+ if (!haveMore || !messages.length) break;
+ }
+ this.state.movements = this.state.movements.filter((m) => !rejected.includes(m));
+ changed = true;
+ }
+ this.state.expiredRescanDone = true;
+ return true;
+ }
+
async _handleMailboxMessage(m) {
let changed = false;
if (m.kind === 'lnSendFinished') {
@@ -640,7 +748,7 @@ export class ArkManager {
continue;
}
try {
- await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys });
+ await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: ourKeys, acceptExpired: this.honorsExpired });
} catch (e) {
if (e instanceof VtxoValidationError) {
this._movement({ type: 'receive', amountSat: v.amountSat, status: 'rejected', detail: e.message });
@@ -733,8 +841,13 @@ export class ArkManager {
v.state = owner.step === 'done' || owner.step === 'failed' ? 'spent' : 'pending';
changed = true;
}
+ // An HTLC coin whose payment no action names any more (done ln-pays are
+ // pruned after a while) is an orphan too: five 22-sat ones sat 'pending'
+ // in a wallet for days, counted as money on its way. The server's answer
+ // settles it — spent is spent; one still open stays pending, since an
+ // HTLC coin is never this wallet's to spend (see below).
const candidates = this.state.vtxos.filter((v) => v.state === 'spendable'
- || (v.state === 'pending' && !held.has(v.id) && v.bytes && this._policyType(v) === 'pubkey'));
+ || (v.state === 'pending' && !held.has(v.id) && v.bytes));
const was = candidates.map((v) => v.state); // what each was when this pass began
// Each status query signs a challenge (schnorr) and may decode the vtxo —
// real secp256k1 work. Building them all in one synchronous map was a
@@ -759,8 +872,8 @@ export class ArkManager {
// an orphan the server still counts is simply spendable again; one it
// holds as spent is gone, and the pay that took it was recorded (or
// will merge in) elsewhere — no row of its own
- if (states[i] === VTXO_STATE_SPENDABLE) { v.state = 'spendable'; changed = true; }
- else if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; }
+ if (states[i] === VTXO_STATE_SPENT) { v.state = 'spent'; changed = true; }
+ else if (states[i] === VTXO_STATE_SPENDABLE && this._policyType(v) === 'pubkey') { v.state = 'spendable'; changed = true; }
return;
}
if (states[i] !== VTXO_STATE_SPENT) return;
@@ -962,22 +1075,45 @@ export class ArkManager {
// that covers it, else largest-first until the sum does. A balance scattered
// by many small receives stays spendable — the package cosign spends all
// the inputs atomically and the recipient just receives several vtxos.
+ // A coin past its timelock spends LAST. The server honors it, and so does
+ // this build at pickup — but a receiver on an older build still refuses
+ // one as expired and strands it. Reaching for such a coin only when
+ // nothing else covers the amount narrows that to almost no payment.
+ _pastExpiry(v, tip) { return !!tip && !!v.expiryHeight && v.expiryHeight <= tip; }
+ _spendOrder(list, tip, cmp) {
+ const live = list.filter((v) => !this._pastExpiry(v, tip)).sort(cmp);
+ 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
+ // timelock alone if they can carry it, else everything
+ _accumulate(spendable, tip, need) {
+ const desc = (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 = [];
+ let sum = 0;
+ for (const v of pool) {
+ picked.push(v); sum += v.amountSat;
+ if (need(picked, sum)) return picked;
+ }
+ }
+ return null;
+ }
+
_selectInputs(amountSat, tip = this._tipH || 0) {
const busy = this._remoteBusy();
const spendable = this.state.vtxos
.filter((v) => this._free(v, busy) && !this._expired(v, tip));
- const single = spendable
- .filter((v) => v.amountSat >= amountSat)
- .sort((a, b) => a.amountSat - b.amountSat)[0];
+ const single = this._spendOrder(spendable.filter((v) => v.amountSat >= amountSat), tip, (a, b) => a.amountSat - b.amountSat)[0];
if (single) return [single];
- const picked = [];
- let sum = 0;
- for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) {
- picked.push(v); sum += v.amountSat;
- if (sum >= amountSat) break;
- }
- if (sum < amountSat) throw new Error(this._insufficientMsg(tip));
- if (picked.length > 24) throw new Error('balance is spread over too many coins for one send');
+ const picked = this._accumulate(spendable, tip, (set, sum) => sum >= amountSat);
+ if (!picked) throw new Error(this._insufficientMsg(tip));
+ // The cap is this wallet's, not the server's (which caps offboard and
+ // board inputs only). It used to be 24, with an automatic consolidation
+ // round at 20 coins to stay under it — a round that cost 0.2% + base on
+ // every coin and was 84% of what users paid in fees. Wider, no round.
+ if (picked.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one send');
return picked;
}
@@ -1326,20 +1462,18 @@ export class ArkManager {
const spendable = this.state.vtxos
.filter((v) => this._free(v, busy) && !this._expired(v, tip));
let inputs = null, feeSat = 0;
- for (const v of [...spendable].sort((a, b) => a.amountSat - b.amountSat)) {
+ 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 = [];
- let sum = 0;
- for (const v of [...spendable].sort((a, b) => b.amountSat - a.amountSat)) {
- picked.push(v); sum += v.amountSat;
- feeSat = lnSendFee(amountSat, this.info.lnSendFees, picked, tip) + routing;
- if (sum >= amountSat + feeSat) { inputs = picked; break; }
- }
+ 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 > 24) throw new Error('balance is spread over too many coins for one payment');
+ if (inputs.length > MAX_SEND_INPUTS) throw new Error('balance is spread over too many coins for one payment');
}
// per-input HTLC share: first n-1 consumed whole, the last carries change
let remainingLock = amountSat + feeSat;
@@ -1771,7 +1905,7 @@ export class ArkManager {
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');
try {
- await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'] });
+ await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, allowPolicies: ['serverHtlcRecv'], acceptExpired: this.honorsExpired });
} 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
@@ -1987,7 +2121,7 @@ export class ArkManager {
if (v.policy.type !== 'pubkey' || v.policy.userPubkey !== expect) {
throw new Error('refund vtxo is not paid to our refund key');
}
- await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect] });
+ await validateVtxo(v, { serverPubkey: this.serverPub, chain: this.chain, expectPubkeys: [expect], acceptExpired: this.honorsExpired });
if (this._addVtxo(v, bytes, refundIndex)) total += v.amountSat;
}
if (total) {
@@ -2250,19 +2384,20 @@ export class ArkManager {
if (action.step === 'created') {
// A set the round would refuse anyway is retired here, not resubmitted
- // every sync: an input past its expiry (only the server's backstop can
- // renew it now), or an output the fee leaves under the 330-sat dust
- // floor (a 21-sat coin with a 150-sat fee sat "in progress" for days).
- // Nothing was spent, so the inputs go back to spendable.
- const tip = this._tipH || (await this.chain.tipHeight().catch(() => 0));
+ // every sync: an output the fee leaves under the 330-sat dust floor (a
+ // 21-sat coin with a 150-sat fee sat "in progress" for days), or an
+ // input no longer held. Nothing was spent, so the inputs go back to
+ // spendable. An input PAST ITS EXPIRY is not a reason: the server's
+ // round-input check has no expiry clause (a coin 3,127 blocks past its
+ // height was renewed on mainnet, 2026-10-14) — renewal is the one
+ // thing an expired coin can always do, and this gate used to retire
+ // the Manage page's own "Renew" of exactly those coins, silently.
const inSat = inputRecs.reduce((n, v) => n + (v ? v.amountSat : 0), 0);
- const expired = inputRecs.some((v) => v && v.expiryHeight && tip && v.expiryHeight <= tip);
- if (expired || inSat - (action.feeSat || 0) < 330 || inputRecs.some((v) => !v)) {
+ if (inSat - (action.feeSat || 0) < 330 || inputRecs.some((v) => !v)) {
for (const v of inputRecs) if (v && v.state === 'pending') v.state = 'spendable';
action.step = 'failed';
action.superseded = true; // no row: the renewal never began
- action.lastError = expired ? 'an input has expired: only the server can renew it now'
- : inputRecs.some((v) => !v) ? 'an input is no longer held' : 'the fee leaves less than the 330-sat minimum';
+ action.lastError = inputRecs.some((v) => !v) ? 'an input is no longer held' : 'the fee leaves less than the 330-sat minimum';
this._save();
return;
}
@@ -2437,24 +2572,32 @@ export class ArkManager {
// expiry. Coins arrive, get spent, get renewed — each new one needs its own
// backstop while it still has the lifetime to register one. Caller owns the
// policy (how much margin, how often to ask); this owns the bookkeeping.
- async ensureScheduledRefreshes(marginBlocks, maxCoins = 8) {
+ // grid: the order's height is snapped DOWN to a multiple of it, so orders
+ // from every wallet fall into the same round instead of one round (one
+ // on-chain fee) per coin. The snap only ever moves EARLIER, by under one
+ // grid step — the caller picks a margin+grid that stays in one fee bracket.
+ async ensureScheduledRefreshes(marginBlocks, maxCoins = 8, grid = 1) {
const tip = await this._tipMemo();
if (!tip) return 0;
const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds));
+ const runAt = (v) => {
+ const h = v.expiryHeight - marginBlocks;
+ return grid > 1 ? h - (h % grid) : h;
+ };
// A round output over the server's cap is refused when the round runs —
// long after registration, with nobody there to hear it. Don't register
// an order that is going to die that quietly.
const cap = this.info.maxVtxoAmountSat || Infinity;
const coins = this.state.vtxos
.filter((v) => this._free(v) && v.expiryHeight && !covered.has(v.id)
- && !this._expired(v, tip) && v.expiryHeight - marginBlocks > tip && v.amountSat <= cap)
+ && !this._expired(v, tip) && v.expiryHeight > tip && runAt(v) > tip && v.amountSat <= cap)
.sort((a, b) => b.amountSat - a.amountSat)
.slice(0, maxCoins);
let n = 0;
for (const v of coins) {
// One participation per coin. A shared one dies whole the moment any
// single input is spent, taking the untouched coins' cover with it.
- try { await this.scheduleRefresh([v.id], v.expiryHeight - marginBlocks); n++; }
+ try { await this.scheduleRefresh([v.id], runAt(v)); n++; }
catch (e) {
// priced from a stale schedule: re-read it so the next pass registers
if (/insufficient refresh fee/i.test(e.message || '')) this.info = await getArkInfo(this.arkUrl).catch(() => this.info);