aboutsummaryrefslogtreecommitdiffstats
path: root/src/ark
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-22 21:06:32 -0700
committerAdam Soltys <[email protected]>2026-10-22 21:06:32 -0700
commit648a5503089d33232855925a24ff60f78cab6df4 (patch)
tree1280073d797653d3ed86f0bb7b6c26b5d2271804 /src/ark
parent8257a98e8d1891d0795f6b8b0623678245cc8e33 (diff)
downloadcoinosv3-648a5503089d33232855925a24ff60f78cab6df4.tar.gz
coinosv3-648a5503089d33232855925a24ff60f78cab6df4.zip
Custody is a choice, and coinos holding the coins is the default: nothing renews on its own and no renewal fee is paid unless the user opts in
Why: renewal fees were "money passively draining" to people who thought they had a custodial wallet (Stacker News, 2026-10-22). Measured over Sep–Oct: 84% of what users paid came from the client's own consolidation round at 20 coins (0.2% + 300 on every fresh coin), the rest from 300-sat rounds that each cohort of Lightning-received coins (30-day pool lifetime) triggered on its own schedule. The ASP itself honors a coin past its timelock — rounds and offboards never checked expiry, and captaind's allow_expired_arkoor (now on at ark.coinos.io) lets arkoor spends through too — so expiry only ever meant "the exit is gone", which most people coming from v2 never had. Settings → Spending gets a Custody card with three tiers: - coinos holds my coins (default): no renewals, no fees; a coin past its timelock shows as "held by coinos", stays spendable and receivable, and is left out of a unilateral exit (there is nothing on-chain for it to take). - Keep my own exit: renew in the last hours (free bracket + base), the server backstop registered 36 blocks out and snapped to a 48-block grid so every wallet's orders share one round. - Renew a week early: 1008 blocks out on a 144-block grid (0.1% + base). renewPolicy() in ark/manager.js is the one table; the app, the service worker (the mirror carries the tier) and the Manage page all read it. Only shown on servers that honor expired coins (presets carry honorsExpired); elsewhere renewal stays automatic. Manager: honorsExpired makes a past-timelock coin ordinary money (balance, selection, receive validation via acceptExpired) while a server refusal (expiryRejected) still excludes it; such coins spend LAST so an older build on the receiving side almost never meets one. A once-per-wallet rescan re-reads the mailbox and adopts coins older builds refused as expired, un-forgets expired dust, and clears stale refusals. The refresh gate that retired a renewal of expired inputs is gone (the server accepts them; the Manage page's Renew of exactly those coins was failing silently). Automatic consolidation is gone; the send cap goes 24 → 48 coins; small change (<1,000 sats) folds into a renewal that happens anyway. Server (cs:~/ark/captaind-main.toml, restarted): allow_expired_arkoor = true; pool vtxo_lifetime 4320 → 8640 so Lightning-received coins live as long as the rest. Tests: tools/ark/custody-test.js (policies, honoring, ordering, grid snap, validator), tools/ark/custody-ui-test.js (the card, held coins, exit count, switching), nwc-sw-test updated for the tier the worker reads. Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/ark')
-rw-r--r--src/ark/manager.js189
-rw-r--r--src/ark/validate.js8
2 files changed, 158 insertions, 39 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index d5ae2b0..2b7a5a7 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -177,9 +177,35 @@ 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.
+// early renew a week ahead (the 0.1% bracket + base) for whoever wants
+// a margin against the server being unreachable near the end.
+// 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 same fee bracket: self runs
+// 36–83 blocks out (free under 96), early 1008–1151 (0.1% under 2160).
+export const CUSTODY_TIERS = ['custodial', 'self', 'early'];
+export const MAX_SEND_INPUTS = 48;
+export function renewPolicy(tier, network) {
+ const rt = network === 'regtest';
+ if (tier === 'early') return { window: rt ? 60 : 1008, margin: rt ? 60 : 1008, grid: rt ? 1 : 144 };
+ if (tier === 'self') return { window: rt ? 24 : 96, margin: backstopMargin(network), grid: rt ? 1 : 48 };
+ 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 }) {
this.account = account; // HDKey node; ark keys derived beneath it
+ // 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;
@@ -417,7 +443,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 +536,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 +602,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 +725,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 });
@@ -962,22 +1047,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 +1434,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 +1877,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 +2093,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 +2356,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 +2544,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);
diff --git a/src/ark/validate.js b/src/ark/validate.js
index fae3465..9cb1751 100644
--- a/src/ark/validate.js
+++ b/src/ark/validate.js
@@ -24,7 +24,11 @@ const fail = (msg) => { throw new VtxoValidationError(msg); };
// plus getTxStatus(txid) -> {confirmed, block_height} (esplora semantics)
// allowPolicies: acceptable policy types (default plain pubkey only; lightning
// flows pass ['serverHtlcSend'] / ['serverHtlcRecv'] for their HTLC vtxos)
-export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'] }) {
+// acceptExpired: the server keeps honoring coins past their timelock (coinos:
+// captaind allow_expired_arkoor) — the coin is then an IOU the server has
+// already swept the tree of, which is exactly what the holder chose when they
+// left custody to coinos; refuse only on servers that would refuse to spend it
+export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, allowPolicies = ['pubkey'], acceptExpired = false }) {
const serverHex = hex.encode(serverPubkey);
// -- structural --
@@ -41,7 +45,7 @@ export async function validateVtxo(vtxo, { serverPubkey, chain, expectPubkeys, a
// and the coinos ASP keeps honoring them off-chain after sweeping their
// round — an explicit IOU where the trustless path never existed anyway.
const tip = await chain.tipHeight();
- if (vtxo.expiryHeight <= tip && vtxo.amountSat >= 330) {
+ if (!acceptExpired && vtxo.expiryHeight <= tip && vtxo.amountSat >= 330) {
fail(`vtxo expired at ${vtxo.expiryHeight}, tip ${tip}`);
}