aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-18 21:21:30 -0700
committerAdam Soltys <[email protected]>2026-08-18 21:21:30 -0700
commitf538229a2bfca83309499e34533ad22acde551db (patch)
tree1772ce3d1ea322ec5fb2c430080382dc7c82a0e6
parent6b09b097535c0c32aa40c1a09320cfd95a2207a1 (diff)
downloadcoinosv3-f538229a2bfca83309499e34533ad22acde551db.tar.gz
coinosv3-f538229a2bfca83309499e34533ad22acde551db.zip
The float keeps itself fed: below low water it loops sats off the CLN through a neighbourly circular route all by itself, the sync heartbeat drives whatever's mid-flight every minute, and an operator can board on-chain reserves in through two admin calls
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012u4oe1KPx9faCsDBLfZYqi
-rw-r--r--names/server.js110
1 files changed, 106 insertions, 4 deletions
diff --git a/names/server.js b/names/server.js
index 7e4adec..298240b 100644
--- a/names/server.js
+++ b/names/server.js
@@ -418,16 +418,81 @@ async function settleLoop() {
}
}
}
+// -- float self-top-up -------------------------------------------------------
+// The float drains toward recipients while LN settlements pile up on the CLN
+// — money that can't reach the float by a normal payment, because the top-up
+// invoice lives on the very node holding it (a self-payment the hold plugin
+// never sees). The refill that works is a circular route: out one channel to
+// a peer, straight back in on another, arriving as a real HTLC the ASP
+// intercepts. Costs the peer's forwarding fee (well under 1%).
+const TOPUP = { lowWaterSat: 100_000, targetSat: 500_000, ...(CFG.floatTopup || {}) };
+let topupCoolUntil = 0;
+async function autoTopupFloat() {
+ if (!fwd || !ln || Date.now() < topupCoolUntil) return;
+ const floatSat = fwd.balance().spendableSat;
+ const queuedSat = (state.pending || []).reduce((n, p) => n + p.sat, 0);
+ if (floatSat >= TOPUP.lowWaterSat && floatSat >= queuedSat) return;
+ topupCoolUntil = Date.now() + 10 * 60_000; // one attempt, then cool off
+ const info = await ln.call('getinfo');
+ const chans = ((await ln.call('listpeerchannels')).channels || [])
+ .filter((c) => c.state === 'CHANNELD_NORMAL' && c.peer_connected);
+ // the widest same-peer pair: out on one channel, back on the other
+ let pick = null;
+ for (const out of chans) {
+ for (const back of chans) {
+ if (out === back || out.peer_id !== back.peer_id) continue;
+ const cap = Math.min(
+ Math.floor((out.spendable_msat ?? 0) / 1000) - 10_000,
+ Math.floor((back.receivable_msat ?? 0) / 1000) - 10_000);
+ if (!pick || cap > pick.cap) pick = { out, back, cap };
+ }
+ }
+ if (!pick || pick.cap < 20_000) { log('float top-up: no circular route capacity'); return; }
+ const amountSat = Math.min(Math.max(TOPUP.targetSat, queuedSat + TOPUP.lowWaterSat) - floatSat, pick.cap);
+ const a = await fwd.createLnInvoice(amountSat, 'float auto top-up');
+ const dec = await ln.call('decode', { string: a.invoice });
+ const gossip = await ln.call('listchannels', { short_channel_id: pick.back.short_channel_id });
+ const ret = (gossip.channels || []).find((c) => c.source === pick.back.peer_id);
+ if (!ret) throw new Error('no gossip for the return channel');
+ const amtMsat = amountSat * 1000;
+ const feeMsat = Number(ret.base_fee_millisatoshi) + Math.ceil((amtMsat * ret.fee_per_millionth) / 1e6);
+ const finalDelay = dec.min_final_cltv_expiry || 18;
+ await ln.call('sendpay', {
+ route: [
+ { id: pick.out.peer_id, channel: pick.out.short_channel_id, amount_msat: amtMsat + feeMsat, delay: finalDelay + ret.delay, style: 'tlv' },
+ { id: info.id, channel: pick.back.short_channel_id, amount_msat: amtMsat, delay: finalDelay, style: 'tlv' },
+ ],
+ payment_hash: a.paymentHash, bolt11: a.invoice, payment_secret: dec.payment_secret,
+ });
+ // the HTLC sits held until the float wallet claims — drive it through
+ for (let i = 0; i < 30; i++) {
+ await new Promise((r) => setTimeout(r, 2000));
+ await fwd.sync().catch(() => {});
+ if (fwd.balance().spendableSat > floatSat) break;
+ }
+ const after = fwd.balance().spendableSat;
+ if (after > floatSat) {
+ log(`float self-top-up: +${after - floatSat} sat via circular route (fee ${Math.ceil(feeMsat / 1000)} sat) — float now ${after}`);
+ for (const p of state.pending || []) p.next = 0; // the queue can go again now
+ persist();
+ topupCoolUntil = 0;
+ } else {
+ log('float self-top-up did not settle — cooling off');
+ }
+}
+
// 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
// forever.
setInterval(async () => {
- const q = state.pending || [];
- if (!q.length || !fwd) return;
- // a float top-up arrives as ark mail; without this the queue would keep
- // failing on a balance that's already been refilled
+ if (!fwd) return;
+ // always sync: it reads ark mail AND drives pending actions (a board
+ // 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));
+ const q = state.pending || [];
+ if (!q.length) return;
const still = [];
for (const p of q) {
if (p.next && Date.now() < p.next) { still.push(p); continue; }
@@ -875,6 +940,43 @@ Bun.serve({
}
}
+ // Board on-chain sats into the float: returns a P2TR funding address.
+ // The funding tx must pay EXACTLY the requested amount to it AT VOUT 0
+ // (the board outpoint is fixed to index 0), then /admin/board/complete
+ // with the txid; confirmations are waited out by the sync loop.
+ if (url.pathname === '/admin/board' && req.method === 'POST') {
+ if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
+ return json({ error: 'unauthorized' }, 401);
+ }
+ if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
+ const body = await req.json().catch(() => ({}));
+ const sat = Math.floor(body.sat || 0);
+ if (!sat || sat < 10_000) return json({ error: 'amount too small' }, 400);
+ try {
+ const b = await fwd.startBoard(sat);
+ log(`float board started: ${sat} sat -> ${b.fundingAddress} (fee ${b.feeSat} sat)`);
+ return json(b);
+ } catch (e) {
+ return json({ error: e.message }, 500);
+ }
+ }
+ if (url.pathname === '/admin/board/complete' && req.method === 'POST') {
+ if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {
+ return json({ error: 'unauthorized' }, 401);
+ }
+ if (!fwd) return json({ error: 'no forwarder wallet' }, 503);
+ const body = await req.json().catch(() => ({}));
+ if (!body.actionId || !/^[0-9a-f]{64}$/.test(body.txid || '')) {
+ return json({ error: 'actionId and txid required' }, 400);
+ }
+ try {
+ const a = await fwd.completeBoard(body.actionId, body.txid);
+ return json({ ok: true, step: a.step, confs: a.confs ?? 0, needConfs: a.needConfs ?? null });
+ } catch (e) {
+ return json({ error: e.message }, 500);
+ }
+ }
+
// Drive an in-flight lightning receive forward (top-ups settle async).
if (url.pathname === '/admin/drive' && req.method === 'POST') {
if (!CFG.adminToken || req.headers.get('x-admin-token') !== CFG.adminToken) {