aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-24 09:27:54 -0700
committerAdam Soltys <[email protected]>2026-08-24 09:27:54 -0700
commitc131fe1f6d64d141f8c71397f3776ac43795a17c (patch)
tree10e1ada2f8214e22df7ec278c78cdc822667636e
parent2b7cd02ad584963f7f7b8f94653b2fc0db6a751e (diff)
downloadcoinosv3-c131fe1f6d64d141f8c71397f3776ac43795a17c.tar.gz
coinosv3-c131fe1f6d64d141f8c71397f3776ac43795a17c.zip
Dead coins stop travelling: an expired vtxo leaves the balance and the coin picker (sub-dust IOUs excepted), a shortfall names the dead weight it can't spend, and the names float renews any coin a day ahead of the reaper — a 100k forward left home on an expired coin and the recipient rightly refused it at the door
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019eA8JRq9Wr7z2fRQk99HLi
-rw-r--r--names/server.js26
-rw-r--r--src/ark/manager.js43
2 files changed, 61 insertions, 8 deletions
diff --git a/names/server.js b/names/server.js
index 12e1023..f54864d 100644
--- a/names/server.js
+++ b/names/server.js
@@ -484,6 +484,31 @@ async function autoTopupFloat() {
}
}
+// -- float coin upkeep -------------------------------------------------------
+// The float's coins arrive mostly from the ASP's vtxo pool, whose short
+// lifetimes (~3 days) expire long before the app's monthly renewal cadence —
+// and an expired coin forwarded onward strands money the recipient's wallet
+// refuses (a 100k forward died this way on 2026-08-24). Nothing here runs the
+// app's upkeep layer, so renew explicitly: refresh any coin inside a day of
+// its expiry into a fresh round output.
+let floatRefreshAt = 0;
+const REFRESH_MARGIN_BLOCKS = 144;
+async function floatUpkeep() {
+ if (!fwd || Date.now() - floatRefreshAt < 30 * 60_000) return;
+ const spendables = (fwd.state?.vtxos || []).filter((v) => v.state === 'spendable');
+ // never start a round while another action is still in flight
+ if (!spendables.length || (fwd.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return;
+ const tip = await fwd.chain.tipHeight();
+ const expiring = spendables.filter((v) =>
+ v.expiryHeight && v.expiryHeight - tip < REFRESH_MARGIN_BLOCKS && v.amountSat >= 330);
+ if (!expiring.length) return;
+ floatRefreshAt = Date.now();
+ const sat = expiring.reduce((n, v) => n + v.amountSat, 0);
+ log(`float upkeep: refreshing ${expiring.length} coins (${sat} sat) nearing expiry`);
+ await fwd.refresh(expiring.map((v) => v.id));
+ log(`float upkeep: refresh round complete — float now ${fwd.balance().spendableSat} sat`);
+}
+
// retry queued forwards (e.g. after the float is topped up). Each failure
// backs off exponentially (1m → 1h cap): a stuck forward must not fire an
// offer request — and push-wake the recipient's devices — every minute
@@ -494,6 +519,7 @@ setInterval(async () => {
// waiting out its confirmations, an ln-recv mid-claim, a queued top-up)
await fwd.sync().catch(() => {});
await autoTopupFloat().catch((e) => log('float self-top-up failed: ' + e.message));
+ await floatUpkeep().catch((e) => log('float upkeep failed: ' + e.message));
const q = state.pending || [];
if (!q.length) return;
const still = [];
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 5eeffd3..eef2ec9 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -222,15 +222,28 @@ export class ArkManager {
});
}
+ // A coin past its expiry is money only the server still honors: the
+ // recipient's validator refuses anything expired at pickup time, so sending
+ // one strands the amount in a vtxo no wallet will accept. Sub-dust coins are
+ // 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 !!tip && !!v.expiryHeight && v.amountSat >= 330 && v.expiryHeight <= tip;
+ }
+
balance() {
- const sum = (st) => this.state.vtxos.filter((v) => v.state === st).reduce((n, v) => n + v.amountSat, 0);
+ const tip = this._tipH || 0;
+ const sum = (st) => this.state.vtxos
+ .filter((v) => v.state === st && !this._expired(v, tip)).reduce((n, v) => n + v.amountSat, 0);
+ const expiredSat = this.state.vtxos
+ .filter((v) => v.state === 'spendable' && this._expired(v, tip)).reduce((n, v) => n + v.amountSat, 0);
// Boards whose funding tx is broadcast but whose vtxo isn't registered yet:
// the sats have left the on-chain balance, so surface them here instead of
// letting them vanish until the board completes.
const boardingSat = this.state.actions
.filter((a) => a.type === 'board' && a.fundingTxid && !['done', 'failed'].includes(a.step))
.reduce((n, a) => n + (a.amountSat - a.feeSat), 0);
- return { spendableSat: sum('spendable'), pendingSat: sum('pending'), boardingSat };
+ return { spendableSat: sum('spendable'), pendingSat: sum('pending'), boardingSat, expiredSat };
}
// Receives newer than the last acknowledgement — the UI's "Payment
@@ -254,6 +267,9 @@ export class ArkManager {
// in-flight actions forward.
async sync() {
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
+ await this._tipMemo();
const mailbox = this._mailboxKey();
const { messages } = await readMailbox(this.arkUrl, mailbox, this.state.mailboxCheckpoint);
let changed = false;
@@ -559,8 +575,9 @@ 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.
- _selectInputs(amountSat) {
- const spendable = this.state.vtxos.filter((v) => v.state === 'spendable');
+ _selectInputs(amountSat, tip = this._tipH || 0) {
+ const spendable = this.state.vtxos
+ .filter((v) => v.state === 'spendable' && !this._expired(v, tip));
const single = spendable
.filter((v) => v.amountSat >= amountSat)
.sort((a, b) => a.amountSat - b.amountSat)[0];
@@ -571,11 +588,20 @@ export class ArkManager {
picked.push(v); sum += v.amountSat;
if (sum >= amountSat) break;
}
- if (sum < amountSat) throw new Error('insufficient ark balance');
+ 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');
return picked;
}
+ _insufficientMsg(tip) {
+ const expiredSat = this.state.vtxos
+ .filter((v) => v.state === 'spendable' && this._expired(v, tip))
+ .reduce((n, v) => n + v.amountSat, 0);
+ return expiredSat
+ ? `insufficient ark balance (${expiredSat} sat expired and unusable)`
+ : 'insufficient ark balance';
+ }
+
async send(addrString, amountSat) {
const dest = decodeAddress(addrString);
if (dest.arkId !== hex.encode(arkIdFromServerPubkey(this.serverPub))) {
@@ -584,7 +610,7 @@ export class ArkManager {
const mailboxDelivery = dest.delivery.find((d) => d.type === 1);
if (!mailboxDelivery) throw new Error('address has no mailbox delivery mechanism');
- const inputs = this._selectInputs(amountSat);
+ const inputs = this._selectInputs(amountSat, await this._tipMemo());
// Each input funds its own arkoor: the first n-1 are consumed whole, the
// last carries the remainder plus any change.
let remaining = amountSat;
@@ -768,7 +794,8 @@ export class ArkManager {
// smallest single vtxo that covers amount + routing + its expiry-dependent
// 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 spendable = this.state.vtxos.filter((v) => v.state === 'spendable');
+ const spendable = this.state.vtxos
+ .filter((v) => v.state === 'spendable' && !this._expired(v, tip));
let inputs = null, feeSat = 0;
for (const v of [...spendable].sort((a, b) => a.amountSat - b.amountSat)) {
const fee = lnSendFee(amountSat, this.info.lnSendFees, [v], tip) + routing;
@@ -782,7 +809,7 @@ export class ArkManager {
feeSat = lnSendFee(amountSat, this.info.lnSendFees, picked, tip) + routing;
if (sum >= amountSat + feeSat) { inputs = picked; break; }
}
- if (!inputs) throw new Error('insufficient ark balance');
+ 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');
}
// per-input HTLC share: first n-1 consumed whole, the last carries change