aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api.js11
-rw-r--r--src/ark/manager.js189
-rw-r--r--src/ark/validate.js8
-rw-r--r--src/features/ark.js218
-rw-r--r--src/i18n.js16
-rw-r--r--src/nwc-bg.js5
-rw-r--r--src/nwc-respond.js12
7 files changed, 340 insertions, 119 deletions
diff --git a/src/api.js b/src/api.js
index c0ecb4b..7f22e78 100644
--- a/src/api.js
+++ b/src/api.js
@@ -232,7 +232,7 @@ export function getBoltzWs() { const p = getBoltzProvider(); return p.ws || deri
const ARK_PRESETS_BY_NET = {
mainnet: [
{ id: 'off', label: 'Off', ark: '', esplora: '' },
- { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api' },
+ { id: 'coinos', label: 'coinos (ark.coinos.io)', ark: 'https://ark.coinos.io', esplora: 'https://mempool.space/api', honorsExpired: true },
{ id: 'second', label: 'Second (ark.second.tech)', ark: 'https://ark.second.tech', esplora: 'https://mempool.second.tech/api' },
{ id: 'custom', label: 'Custom…', ark: '', esplora: '' },
],
@@ -249,12 +249,12 @@ const ARK_PRESETS_BY_NET = {
],
mutinynet: [
{ id: 'off', label: 'Off', ark: '', esplora: '' },
- { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora' },
+ { id: 'coinos', label: 'coinos (ark-staging.coinos.io)', ark: 'https://ark-staging.coinos.io', esplora: 'https://staging.coinos.io/esplora', honorsExpired: true },
{ id: 'custom', label: 'Custom…', ark: '', esplora: '' },
],
regtest: [
{ id: 'off', label: 'Off', ark: '', esplora: '' },
- { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002' },
+ { id: 'local', label: 'Local (regtest)', ark: 'http://localhost:3535', esplora: 'http://localhost:30002', honorsExpired: true },
{ id: 'custom', label: 'Custom…', ark: '', esplora: '' },
],
};
@@ -294,7 +294,10 @@ export function getArkConfig(net = getNetwork()) {
const id = getArkProviderId(net);
if (id === 'off') return null;
const p = arkPresets(net).find((x) => x.id === id);
- const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora };
+ // honorsExpired: the coinos servers keep cosigning spends of a coin past
+ // its timelock (captaind allow_expired_arkoor) — the basis of the default
+ // "coinos holds my coins" custody tier. Other servers refuse such a coin.
+ const cfg = p.id === 'custom' ? getArkCustom(net) : { ark: p.ark, esplora: p.esplora, honorsExpired: !!p.honorsExpired };
return cfg.ark && cfg.esplora ? cfg : null;
}
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}`);
}
diff --git a/src/features/ark.js b/src/features/ark.js
index 3ef6cea..7a77d2f 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -8,7 +8,7 @@ import * as btc from '@scure/btc-signer';
import { HDKey } from '@scure/bip32';
import { hex, base32nopad, bech32 } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
-import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, backstopMargin } from '../ark/manager.js';
+import { ArkManager, remoteRenewalsInFlight, lnFoldedIds, renewPolicy, CUSTODY_TIERS } from '../ark/manager.js';
export { remoteRenewalsInFlight };
// Coins this device may offer: spendable, and not held by a renewal another
@@ -18,6 +18,12 @@ function freeCoins(st) {
const busy = new Set(remoteRenewalsInFlight(st).flatMap((r) => r.inputIds || []));
return (st.vtxos || []).filter((v) => v.state === 'spendable' && !busy.has(v.id));
}
+// The coins a unilateral exit can still take on-chain. Past its timelock a
+// coin's tree is the server's to sweep (and it has): only the server can
+// honor it now, and it does — but no exit transaction will ever confirm.
+function exitableCoins(st, tip) {
+ return freeCoins(st).filter((v) => !(tip && v.expiryHeight && v.expiryHeight <= tip));
+}
import { loadBg, saveBg, buildBg, disarmSiblingRecords } from '../nwc-bg.js';
import { boardFee, p2trAddress } from '../ark/board.js';
import { maybeBolt11, maybeLnInvoice, lnSendFee, lnShortfall } from '../ark/lightning.js';
@@ -541,7 +547,7 @@ export function arkFeature(ctx) {
adopt: (serverPubkey) => wallet.adoptArkState(serverPubkey, p.ark),
save: (s) => { wallet.saveArkState(mergeArkStates(s, wallet.loadArkState(p.ark)), p.ark); },
},
- arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(),
+ arkUrl: p.ark, esploraUrl: p.esplora, network: getNetwork(), honorsExpired: !!p.honorsExpired,
});
await mgr.init();
const done = () => (mgr.state.movements || []).filter((m) => m.type === 'ln-receive' && m.status === 'complete');
@@ -623,6 +629,7 @@ export function arkFeature(ctx) {
arkUrl: cfg.ark,
esploraUrl: cfg.esplora,
network: getNetwork(),
+ honorsExpired: !!cfg.honorsExpired,
// the wallet's own tx table as a second witness for confirmations —
// its data source is user-configurable and often healthier than the
// ark preset's esplora from this device
@@ -931,8 +938,9 @@ export function arkFeature(ctx) {
if (offer === undefined) offer = (prev && prev.offer) || null;
if (prefs === undefined) prefs = (prev && prev.prefs) || undefined;
const rec = buildBg({
- ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey },
+ ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey, honorsExpired: !!cfg.honorsExpired },
mgrState: ark.state,
+ custody: getCustody(),
keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey),
connections,
offer,
@@ -987,7 +995,7 @@ export function arkFeature(ctx) {
const pm = await new ArkManager({
account: shim,
storage: { load: () => state, save: (s2) => { state = s2; } },
- arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(),
+ arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired,
}).init();
const dest = mgr.address();
for (let guard = 0; guard < 30; guard++) {
@@ -2314,6 +2322,10 @@ export function arkFeature(ctx) {
const inRound = new Set([...renewing, ...elsewhere].flatMap((a) => a.inputIds || []));
const pendingCoins = ((st && st.vtxos) || []).filter((v) => (v.state === 'pending' || v.state === 'spendable') && inRound.has(v.id));
const tip = (mgr && mgr._tipH) || 0;
+ // the coinos servers keep honoring a coin past its timelock: it is held
+ // by coinos, not expired — still money, just not exitable on its own
+ const honors = !!(mgr && mgr.honorsExpired);
+ const exitable = exitableCoins(st, tip);
const back = embedded
? () => { ui.arkCoinsSel = null; ui.arkRenewConfirm = null; ctx.goHome(); }
: () => ctx.goBack(() => { ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkRenewConfirm = null; }); // the page is a history entry now
@@ -2335,7 +2347,7 @@ export function arkFeature(ctx) {
const expiresOf = (v) => {
if (!v.expiryHeight || !tip) return '—';
const blocks = v.expiryHeight - tip;
- if (blocks <= 0) return t('arkCoinsExpired');
+ if (blocks <= 0) return honors ? t('arkCoinsHeld') : t('arkCoinsExpired');
return blocks >= 144 ? Math.round(blocks / 144) + 'd' : Math.max(1, Math.round(blocks / 6)) + 'h';
};
// what pulling THIS coin on-chain alone would cost: every hop's fee
@@ -2463,13 +2475,13 @@ export function arkFeature(ctx) {
h('h3', { style: 'margin:0' }, t('arkExitConfirmTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmBody')),
h('div', { class: 'col', style: 'gap:4px' },
- row(t('arkExitConfirmCoins'), String(spend.length)),
- row(t('arkExitConfirmAmount'), fmtSats(totalSat) + ' sats'),
+ row(t('arkExitConfirmCoins'), String(exitable.length)),
+ row(t('arkExitConfirmAmount'), fmtSats(exitable.reduce((n, v) => n + v.amountSat, 0)) + ' sats'),
row(t('arkExitConfirmFee'), fmtSats(exitFee) + ' sats'),
row(t('arkExitConfirmSavings'), fmtSats(savings) + ' sats', short ? 'err' : '')),
short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(exitFee), have: fmtSats(savings) })) : null,
h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmWait')),
- h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !spend.length, onClick: () => {
+ h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || short || !exitable.length, onClick: () => {
ui.arkExitConfirm = null;
doArkExit();
ui.arkCoinsSel = null;
@@ -2522,7 +2534,8 @@ export function arkFeature(ctx) {
h('tbody', {}, ...spend.map((v) => {
const f = feeNowOf(v);
const xf = exitFeeOf(v);
- const due = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0) || !!v.expiryRejected;
+ const past = !!(v.expiryHeight && tip && v.expiryHeight - tip <= 0);
+ const due = (past && !honors) || !!v.expiryRejected;
// expired and under the floor: nothing can renew it, so the
// last column offers to give it up
const lost = due && v.amountSat < 330;
@@ -2551,9 +2564,11 @@ export function arkFeature(ctx) {
h('h4', { style: 'margin:0' }, t('arkCoinsRenewTitle')),
// the round cadence comes from the server (it has been an hour and
// is two minutes as of September 2026) — never a number baked in here
- h('p', { class: 'small muted', style: 'margin:0' }, baseSat
- ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost })
- : t('arkCoinsRenewDesc', { every: roundEvery })),
+ h('p', { class: 'small muted', style: 'margin:0' }, getCustody() === 'custodial' && honors
+ ? t('arkCoinsRenewDescCustodial', { every: roundEvery, base: baseSat ? baseCost : t('arkDepthFree') })
+ : baseSat
+ ? t('arkCoinsRenewDescBase', { every: roundEvery, base: baseCost })
+ : t('arkCoinsRenewDesc', { every: roundEvery })),
schedule.length ? h('div', { class: 'col', style: 'gap:2px;margin:2px 0' },
...schedule.map((r) => h('div', { class: 'row between' },
h('span', { class: 'small muted' }, r.label),
@@ -2577,11 +2592,50 @@ export function arkFeature(ctx) {
h('div', { class: 'card col', style: 'gap:8px' },
h('h4', { style: 'margin:0' }, t('arkCoinsExitTitle')),
h('p', { class: 'small muted', style: 'margin:0' },
- t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) })),
- h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !spend.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))),
+ exitable.length ? t('arkCoinsExitDesc', { fee: fmtSats(exitFee), after: fmtSats(afterFee) }) : t('arkExitNoneExitable')),
+ h('button', { class: 'btn-ghost btn-block', disabled: !!ui.arkBusy || !exitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } }, t('arkCoinsExitBtn'))),
embedded ? null : h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back')));
}
+ // Who holds the coins once their timelock runs out — the user's call, made
+ // here, with what each option costs in the server's own numbers. The
+ // default is coinos: most people come from v2, where coinos held
+ // everything, and the server honors a coin past its timelock anyway. The
+ // two renewing tiers are the opt-ins, for those who want the exit kept
+ // (self) or kept with a week's margin (early). Only shown on a server that
+ // honors expired coins; elsewhere renewal is the only way to keep money.
+ function custodyCard() {
+ if (!arkAvailable() || wallet.watchOnly) return null;
+ const cfg = getArkConfig();
+ const info = (ark && ark.info) || null;
+ const honors = !!(cfg && cfg.honorsExpired);
+ const cur = honors ? getCustody() : 'self';
+ const base = (info && info.refreshFees && info.refreshFees.baseFeeSat) || 0;
+ const table = (info && info.refreshFees && info.refreshFees.ppmExpiryTable) || [];
+ const ppmAt = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0;
+ const lifeDays = Math.max(1, Math.round(((info && info.vtxoExpiryDelta) || 8640) / 144));
+ const baseCost = base ? fmtSats(base) + ' sats' : t('arkDepthFree');
+ const earlyPct = pct(ppmAt(1008));
+ const tiers = [
+ { id: 'custodial', label: t('arkCustodyCustodial'), desc: t('arkCustodyCustodialDesc'), cost: t('arkCustodyCostNone') },
+ { id: 'self', label: t('arkCustodySelf'), desc: t('arkCustodySelfDesc', { days: lifeDays }), cost: t('arkCustodyCostSelf', { base: baseCost }) },
+ { id: 'early', label: t('arkCustodyEarly'), desc: t('arkCustodyEarlyDesc'), cost: t('arkCustodyCostEarly', { pct: earlyPct, base: baseCost }) },
+ ].filter((x) => honors || x.id !== 'custodial');
+ return h('div', { class: 'card col ark-custody', style: 'gap:10px' },
+ h('h3', { style: 'margin:0' }, t('arkCustodyTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCustodyDesc', { days: lifeDays })),
+ h('div', { class: 'col', style: 'gap:8px' }, ...tiers.map((x) => h('label', {
+ class: 'row gap6 ark-custody-opt' + (x.id === cur ? ' active' : ''), style: 'align-items:flex-start;cursor:pointer',
+ 'data-tier': x.id,
+ },
+ h('input', { type: 'radio', name: 'arkCustody', value: x.id, checked: x.id === cur, disabled: !honors, style: 'flex:0 0 auto;margin:3px 0 0',
+ onChange: () => applyCustody(x.id) }),
+ h('div', { class: 'col', style: 'gap:2px;flex:1' },
+ h('div', { class: 'row between gap6', style: 'align-items:baseline' }, h('span', { style: 'white-space:nowrap' }, x.label), h('span', { class: 'small' + (x.id === 'custodial' ? ' faint' : ''), style: 'text-align:right' }, x.cost)),
+ h('div', { class: 'small muted' }, x.desc))))),
+ honors ? null : h('p', { class: 'small faint', style: 'margin:0' }, t('arkCustodyNotHere')));
+ }
+
// What each server charges and promises, from its own published info —
// laid side by side under the switcher so the choice is an informed one.
const serverInfos = new Map(); // url -> info | null (failed) | undefined (loading)
@@ -2801,7 +2855,7 @@ export function arkFeature(ctx) {
let _exitFeeMemo = { key: null, val: 0 };
function estimateExitFeeSat(mgr) {
const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2);
- const coins = freeCoins(mgr.state);
+ const coins = exitableCoins(mgr.state, mgr._tipH || 0);
const key = feeRate + '|' + coins.map((v) => v.id).join(',');
if (_exitFeeMemo.key === key) return _exitFeeMemo.val;
let total = 0;
@@ -2820,8 +2874,8 @@ export function arkFeature(ctx) {
ui.arkBusy = 'exit'; ui.arkError = ''; render();
try {
const mgr = await connectArk();
- const spendables = freeCoins(mgr.state);
- if (!spendables.length) throw new Error(t('arkNotConnected'));
+ const spendables = exitableCoins(mgr.state, mgr._tipH || 0);
+ if (!spendables.length) throw new Error(freeCoins(mgr.state).length ? t('arkExitNoneExitable') : t('arkNotConnected'));
// Fees are paid hop by hop from Savings as the exit unrolls — an exit
// that can't fund its hops just stalls at step zero. Refuse to start
// one Savings can't carry, with the number on the table.
@@ -2983,7 +3037,7 @@ export function arkFeature(ctx) {
load: () => { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } },
save: (s) => { try { localStorage.setItem(key, JSON.stringify(s)); } catch {} },
},
- arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(),
+ arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), honorsExpired: !!cfg.honorsExpired,
});
await mgr.init();
return mgr;
@@ -3199,25 +3253,48 @@ export function arkFeature(ctx) {
ui.arkBusy = null; render();
}
- // VTXO upkeep, invisibly: a send needs a single vtxo covering the amount
- // (fragmentation breaks coin selection) and every vtxo expires at
- // expiryHeight (unrenewed, the ASP can eventually sweep it). Rather than
- // exposing a refresh button, consolidate-and-renew automatically when a
- // vtxo is inside the renewal window or fragmentation builds up. The round
- // fee this spends is the cost of keeping the money spendable at all.
- // The ASP's refresh fee brackets reward renewing LATE — under 96 blocks of
- // remaining life a renewal is free, and this ASP keeps honoring (and freely
- // rescuing) a coin even past its expiry, so aiming inside the free window
- // risks nothing. ~16 hours of margin on mainnet.
- const renewWindow = () => (getNetwork() === 'regtest' ? 24 : 96);
- // How close to expiry the SERVER's own backstop renewal is aimed. The
- // window above only helps a wallet someone opens inside it; this one is a
- // participation registered far in advance that the ASP runs on its own,
- // whether or not anybody is looking. It sits well inside renewWindow so an
- // app that IS open still renews first (and the untouched backstop is
- // dropped), and far enough from expiry — 36 blocks, six hourly rounds — for
- // the round to actually run in time. Both are in the free fee bracket.
- const backstopWindow = () => backstopMargin(getNetwork());
+ // VTXO upkeep, by the user's choice of custody (Settings → Spending):
+ // custodial (the default) — nothing renews. A coin past its timelock is
+ // coinos's IOU: the server sweeps the tree and keeps cosigning spends of
+ // the coin (captaind allow_expired_arkoor), so the balance works as a v2
+ // one did, and no renewal fee is ever paid. The holder trusts coinos.
+ // self — renew in the last hours before expiry, the free ppm bracket, one
+ // base fee per round (~300 sats): a unilateral exit is always possible.
+ // early — renew a week ahead at 0.1% + base, for whoever wants a margin
+ // against coinos being unreachable right at the end.
+ // The policy's window is when THIS app renews a coin (blocks of life left);
+ // margin/grid aim the SERVER's own backstop order — a participation the ASP
+ // runs on its own, registered far in advance, snapped to a grid so every
+ // wallet's orders share one round. renewPolicy() in ark/manager.js.
+ //
+ // Consolidation is gone from here: it used to trigger at 20 coins, bill the
+ // top ppm bracket on every fresh coin, and was 84% of all renewal fees paid
+ // in Sep–Oct 2026 — the "money draining" people saw. Multi-input sends take
+ // up to MAX_SEND_INPUTS coins; small change folds into a renewal that is
+ // happening anyway, for next to nothing; the Manage page consolidates on
+ // request.
+ const CUSTODY_DEFAULT = 'custodial';
+ function getCustody() {
+ const v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null;
+ return CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT;
+ }
+ function setCustody(tier) { if (wallet.saveFeatureState) wallet.saveFeatureState('arkCustody', tier); }
+ const custodyPolicy = () => renewPolicy(getCustody(), getNetwork());
+ // Switching: a renewing tier starts covering coins right away (the throttles
+ // are reset so the next sync registers backstops and renews what is due),
+ // and the worker's mirror learns the tier so closed-app renewals follow it.
+ // Orders already registered with the server keep running whatever the tier
+ // (there is no cancel; a spent input voids one) — they were priced when
+ // registered and renew one coin each, once.
+ function applyCustody(tier) {
+ if (!CUSTODY_TIERS.includes(tier) || tier === getCustody()) return;
+ setCustody(tier);
+ arkAutoRefreshAt = 0; arkBackstopAt = 0; arkRenewWarn = null;
+ if (ark && ark.state) maybeAutoRefresh(ark).catch(() => {});
+ writeBgMirror().catch(() => {});
+ toast(t('arkCustodySaved'));
+ render();
+ }
let arkAutoRefreshAt = 0;
let arkBackstopAt = 0;
// Set when the balance is too small to renew itself (total minus round fee
@@ -3251,9 +3328,13 @@ export function arkFeature(ctx) {
async function maybeAutoRefresh(mgr) {
if (wallet.watchOnly || !mgr || !mgr.state) return;
if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return;
+ const policy = custodyPolicy();
+ // "coinos holds my coins": nothing renews on its own and nothing warns.
+ // The server honors a coin past its timelock, and the holder chose this.
+ if (!policy) { arkRenewWarn = null; return; }
// NOT filtered by expiryRejected. A coin the server refused as expired
// can still be REFRESHED — that is the one thing an expired vtxo can
- // still do — and excluding it here meant a wallet holding nothing else
+ // always do — and excluding it here meant a wallet holding nothing else
// did nothing at all: no renewal attempt, and no warning either.
const spendables = freeCoins(mgr.state);
if (!spendables.length) { arkRenewWarn = null; return; }
@@ -3265,25 +3346,18 @@ export function arkFeature(ctx) {
// stamped, and this must not then run on every five-second sync.
if (Date.now() - arkBackstopAt > 30 * 60_000) {
arkBackstopAt = Date.now();
- await mgr.ensureScheduledRefreshes(backstopWindow()).catch(() => {});
+ await mgr.ensureScheduledRefreshes(policy.margin, 8, policy.grid).catch(() => {});
}
// never start a round while any other action is still in flight
if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return;
arkAutoRefreshAt = Date.now();
try {
const tip = await mgr.chain.tipHeight();
- const RENEW_BLOCKS = renewWindow();
- const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < RENEW_BLOCKS);
- const expiring = expiringCoins.length > 0;
- // Fragmentation alone is no longer worth paying for: multi-input sends
- // handle up to 24 coins, and consolidating FRESH coins bills the round
- // fee's top ppm bracket (a burst of refund coins after failed payments
- // once cost a user ~1% overnight). Consolidate only when the coin count
- // itself approaches the 24-input send ceiling.
- const fragmented = spendables.length >= 20;
+ const expiringCoins = spendables.filter((v) => v.expiryHeight && v.expiryHeight - tip < policy.window);
+ if (!expiringCoins.length) { arkRenewWarn = null; return; }
const totalSat = spendables.reduce((n, v) => n + v.amountSat, 0);
const renewable = totalSat - mgr.refreshFee(spendables, tip) >= 330;
- if (expiring && !renewable) {
+ if (!renewable) {
// Can't save these coins ourselves — tell the user while receiving
// any amount (or spending) can still beat the clock.
const minExpiry = Math.min(...spendables.map((v) => v.expiryHeight || Infinity));
@@ -3294,26 +3368,24 @@ export function arkFeature(ctx) {
return;
}
arkRenewWarn = null;
- if (!expiring && !fragmented) return;
// Renew ONLY the coins that need it: dragging far-from-expiry coins
// into the round would bill their (higher) fee bracket for nothing.
- // The rare fragmentation consolidation still sweeps everything.
- //
- // But the subset must clear the server's 330-sat output floor BY
- // ITSELF — the renewable check above prices the whole balance. Two
- // dust coins at expiry (133 sats between them) failed their renewal
- // round silently every 30 minutes forever. Pull in the smallest
- // healthy coins until the renewed output is mintable.
- let renewSet = expiringCoins;
- if (!fragmented) {
- const extras = spendables
- .filter((v) => !expiringCoins.includes(v))
- .sort((a, b) => a.amountSat - b.amountSat);
- const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
- while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()];
- if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it
- }
- await mgr.refresh(fragmented ? undefined : renewSet.map((v) => v.id));
+ // Small change rides along, though: a coin under 1,000 sats costs at
+ // most 2 sats of ppm in this round, and folding it in now spares it a
+ // whole base fee's worth of round of its own when its turn comes.
+ let renewSet = [...expiringCoins, ...spendables.filter((v) => !expiringCoins.includes(v) && v.amountSat < 1000)];
+ // The set must clear the server's 330-sat output floor BY ITSELF — the
+ // renewable check above prices the whole balance. Two dust coins at
+ // expiry (133 sats between them) failed their renewal round silently
+ // every 30 minutes forever. Pull in the smallest healthy coins until the
+ // renewed output is mintable.
+ const extras = spendables
+ .filter((v) => !renewSet.includes(v))
+ .sort((a, b) => a.amountSat - b.amountSat);
+ const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
+ while (netSat(renewSet) < 330 && extras.length) renewSet = [...renewSet, extras.shift()];
+ if (netSat(renewSet) < 330) return; // whole balance can't clear the floor — the warn above covers it
+ await mgr.refresh(renewSet.map((v) => v.id));
render();
} catch {} // transient — the next throttled attempt retries
}
@@ -3595,7 +3667,7 @@ export function arkFeature(ctx) {
const b = arkBalance() || { spendableSat: 0, pendingSat: 0 };
const spendable = b.spendableSat;
const total = b.spendableSat + b.pendingSat;
- const nSpend = freeCoins(arkStateNow()).length;
+ const nSpend = exitableCoins(arkStateNow(), (ark && ark._tipH) || 0).length;
const exitingSat = ((arkStateNow() && arkStateNow().actions) || [])
.filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step))
.reduce((n, a) => n + a.amountSat, 0);
@@ -3650,10 +3722,10 @@ export function arkFeature(ctx) {
new Promise((r) => setTimeout(r, 800)),
]);
},
- // the Spending face's settings page leads with its coins
+ // the Spending face's settings page: the custody choice, then its coins
spendingSettings() {
if (wallet.watchOnly) return null; // a locked wallet can't renew or exit
- return arkCoinsPage({ embedded: true });
+ return h('div', { class: 'col', style: 'gap:16px' }, custodyCard(), arkCoinsPage({ embedded: true }));
},
// the wallet's own pages keep the top nav: these are pages, not takeovers
screenView() {
@@ -4113,7 +4185,11 @@ export function arkFeature(ctx) {
// and it is one tap away on the Manage page. What it cannot do is be
// spent over Ark or exited unilaterally, so saying nothing is the one
// option that isn't honest.
- if (!wallet.watchOnly && ark && ark.balance) {
+ // On the coinos servers a coin past its timelock is still money (the
+ // server keeps cosigning its spends) — the balance counts it and
+ // there is nothing to warn about; the custody tier the user chose
+ // says what expiry means to them. Elsewhere, the old warning.
+ if (!wallet.watchOnly && ark && ark.balance && !ark.honorsExpired) {
let expiredSat = 0;
try { expiredSat = ark.balance().expiredSat || 0; } catch {}
// Expired dust (under the 330-sat floor) cannot be renewed or exited
diff --git a/src/i18n.js b/src/i18n.js
index a07cba4..6d36f49 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -777,6 +777,22 @@ const STR = {
arkFeeChain: 'On-chain fee',
arkFeeService: 'Service fee',
arkCoinsExpired: 'expired',
+ arkCoinsHeld: 'held by coinos',
+ arkCustodyTitle: 'Custody',
+ arkCustodyDesc: 'Each Spending coin carries a timelock of about {days} days. Renewing a coin before it runs out keeps your right to bring it on-chain without coinos, and costs a share of a round’s mining fee. coinos keeps honoring coins past their timelock, so renewing is a choice:',
+ arkCustodyCustodial: 'coinos holds my coins',
+ arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. Past its timelock a coin is held by coinos, like a balance on the old coinos: paying, receiving and withdrawing keep working, but bringing it on-chain needs coinos’s cooperation.',
+ arkCustodySelf: 'Keep my own exit',
+ arkCustodySelfDesc: 'Coins renew in the last hours before their timelock ends — the cheapest moment — so you can always exit on your own. Roughly once every {days} days per coin.',
+ arkCustodyEarly: 'Renew a week early',
+ arkCustodyEarlyDesc: 'Coins renew about a week before their timelock ends, a margin in case coinos is unreachable right at the end.',
+ arkCustodyCostNone: 'no fees',
+ arkCustodyCostSelf: '{base} per renewal',
+ arkCustodyCostEarly: '{pct} + {base} per renewal',
+ arkCustodySaved: 'Custody choice saved',
+ arkCustodyNotHere: 'This server does not honor coins past their timelock, so they renew automatically here.',
+ arkExitNoneExitable: 'Every coin has passed its timelock, so there is nothing a unilateral exit can take on-chain: coinos holds them now. Renewing restores your own exit; withdrawing with coinos’s cooperation works as before.',
+ arkCoinsRenewDescCustodial: 'With “coinos holds my coins”, nothing renews on its own. A renewal replaces the selected coins with a single fresh one in the next round ({every}) and restores your own exit for them. Every renewal costs a flat {base} toward the round’s on-chain fee, plus a share that depends on how close each coin is to its timelock. The closer, the cheaper:',
noteForeignKind: 'A kind {kind} event this app doesn’t show.',
zapRowText: 'zapped {n} sats',
pollVoter: '{n} vote',
diff --git a/src/nwc-bg.js b/src/nwc-bg.js
index 3bdef08..3349126 100644
--- a/src/nwc-bg.js
+++ b/src/nwc-bg.js
@@ -84,7 +84,7 @@ export const BG_KEY_LOOKAHEAD = 25;
// module). Chain 3 covers coins + change, chain 4/0 the mailbox, chain 5 a
// window of receive preimages — everything the worker needs to pay AND to
// mint invoices while the app is closed.
-export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdraw }) {
+export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdraw, custody }) {
const keys = {};
const idx = new Set((mgrState.vtxos || []).filter((v) => v.state !== 'spent').map((v) => v.keyIndex));
for (let i = 0; i < BG_KEY_LOOKAHEAD; i++) idx.add((mgrState.nextKeyIndex || 1) + i);
@@ -112,6 +112,9 @@ export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdra
// { on, threshold, keep, target: { kind, address, url?, spkHex? } } — the
// destination is resolved by the app, so the worker never needs a relay
autowithdraw: autowithdraw || null,
+ // the custody tier ('custodial' | 'self' | 'early'): whether the worker
+ // registers renewal orders for bare coins, and how far ahead
+ custody: custody || null,
// The budget's spent counter rides along (stamped with its window) so the
// worker counts what the app spent; a revoked one rides along flagged, to
// be refused rather than ignored.
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index ec19923..e6ee563 100644
--- a/src/nwc-respond.js
+++ b/src/nwc-respond.js
@@ -21,7 +21,7 @@ import { ArkManager } from './ark/manager.js';
import { maybeBolt11 } from './ark/lightning.js';
import { allBgs, saveBg } from './nwc-bg.js';
import { budgetRenewsAt, renewalPeriod, spentInWindow } from './nwc-budget.js';
-import { backstopMargin } from './ark/manager.js';
+import { renewPolicy } from './ark/manager.js';
const MAX_AGE_SEC = 120;
const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin
@@ -69,6 +69,7 @@ export async function bgManager(rec, walletKey, saveFn) {
return new ArkManager({
account: shimAccount(rec), storage,
arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network,
+ honorsExpired: !!rec.ark.honorsExpired,
}).init();
}
@@ -460,11 +461,14 @@ export async function tendRenewals({ recordsFn = allBgs, saveFn = saveBg, log =
if (a.step === 'done') { n++; log('claimed a renewal while closed'); }
}
// coins without an order: register one, as long as the output key the
- // order burns is inside the mirrored window (the worker can't derive)
+ // order burns is inside the mirrored window (the worker can't derive).
+ // Only under a custody tier that renews at all — the default lets coins
+ // run past their timelock in coinos's hands, by choice, and pays nothing.
+ const policy = renewPolicy(rec.custody || 'custodial', rec.ark.network);
let room = 0;
while (rec.keys[String((mgr.state.nextKeyIndex || 1) + room)] && room < 4) room++;
- if (room) {
- const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? backstopMargin(rec.ark.network), room)).catch((e) => { log('renewal order: ' + e.message); return 0; });
+ if (room && policy) {
+ const got = await bounded(mgr.ensureScheduledRefreshes(margin ?? policy.margin, room, policy.grid)).catch((e) => { log('renewal order: ' + e.message); return 0; });
if (got) { n += got; log(`registered ${got} renewal order(s) while closed`); }
}
await saveFn(walletKey, rec);