aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/api.js11
-rw-r--r--src/ark/manager.js225
-rw-r--r--src/ark/validate.js8
-rw-r--r--src/assets/zap.mp3.base64.txt2
-rw-r--r--src/features/ark.js446
-rw-r--r--src/features/messages.js43
-rw-r--r--src/i18n.js26
-rw-r--r--src/nwc-bg.js5
-rw-r--r--src/nwc-respond.js12
-rw-r--r--src/style.css3
-rw-r--r--tools/ark-htlc-orphan.test.js33
-rw-r--r--tools/ark-offline-init.test.js22
-rw-r--r--tools/ark/cooperative-exit-ui-test.js25
-rw-r--r--tools/ark/custody-test.js96
-rw-r--r--tools/ark/custody-ui-test.js136
-rw-r--r--tools/feed-media-cache-test.js4
-rw-r--r--tools/feed-media-views-test.js4
-rw-r--r--tools/grid-early-player-test.js14
-rw-r--r--tools/media-grid-dups-test.js50
-rw-r--r--tools/nwc-sw-test.js19
-rw-r--r--tools/video-tile-poster-test.js4
-rw-r--r--tools/zap-feedback-test.js7
22 files changed, 972 insertions, 223 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..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);
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/assets/zap.mp3.base64.txt b/src/assets/zap.mp3.base64.txt
index 6f056c5..b9b7d5b 100644
--- a/src/assets/zap.mp3.base64.txt
+++ b/src/assets/zap.mp3.base64.txt
@@ -1 +1 @@
-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 \ No newline at end of file
+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 \ No newline at end of file
diff --git a/src/features/ark.js b/src/features/ark.js
index 3ef6cea..94dfc09 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';
@@ -32,7 +38,7 @@ import { t } from '../i18n.js';
import { rateNow } from '../rates.js';
import { resolveBip353, parsePaymentName, parseBip21 as parseBip21Uri } from '../bip353.js';
import { parseZapTarget, fetchPayParams, requestInvoice } from '../lnurl.js';
-import { shortAddr, shortTxid, timeAgo, fmtSats, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js';
+import { shortAddr, shortTxid, timeAgo, fmtSats, fmtBtc, ARK_ICON, ARK_MARK, BITCOIN_ICON } from '../format.js';
// t?ark1… bech32m — an Ark address for this or another ASP.
export function isArkAddress(a) { return /^t?ark1[a-z0-9]{20,}$/i.test((a || '').trim()); }
@@ -454,8 +460,41 @@ export function arkFeature(ctx) {
// One manager per open wallet; null when Ark is off in Settings, watch-only,
// or not yet dialed (lazy: fresh wallets connect on first use).
let ark = null;
+ // The same manager the moment its own init is done (server info, tip,
+ // persisted coins) — before the Nostr snapshot restore that `ark` also
+ // waits for, which can take ten seconds on a slow relay/Blossom day. For
+ // READING only: the pages price and label coins from it; anything that
+ // acts (a renewal, an exit) still waits for `ark`.
+ let arkInited = null;
+ const arkView = () => ark || arkInited;
+ // An amount in bitcoin terms whatever the display unit: sats, or BTC when
+ // that is the unit chosen — never fiat. A co-operative exit moves coins,
+ // and the number on its button is the coins' own.
+ const fmtNative = (sat) => (unitLabel() === 'BTC' ? fmtBtc(sat) + ' BTC' : fmtSats(sat) + ' sats');
+ // The last server info and chain tip seen, per server, so the first paint
+ // after a refresh shows the real renewal price and expiries instead of
+ // "free" and "—" for the half second the info call takes.
+ const ARK_INFO_KEY = 'btc-wallet-arkinfo:';
+ function cachedArkInfo(url) {
+ try { return url ? JSON.parse(localStorage.getItem(ARK_INFO_KEY + url) || 'null') : null; } catch { return null; }
+ }
+ function rememberArkInfo(url, info, tip) {
+ try {
+ if (!url || !info) return;
+ // no tip yet (right after init): keep the last one seen, dated as it was
+ const prev = tip ? null : cachedArkInfo(url);
+ localStorage.setItem(ARK_INFO_KEY + url, JSON.stringify(tip ? { info, tip, at: Date.now() } : { info, tip: (prev && prev.tip) || 0, at: (prev && prev.at) || Date.now() }));
+ } catch {}
+ }
+ // the tip, live or estimated from the last one seen (a block per ten minutes)
+ function arkTipNow(mgr) {
+ if (mgr && mgr._tipH) return mgr._tipH;
+ const c = cachedArkInfo((getArkConfig() || {}).ark);
+ return c && c.tip ? c.tip + Math.floor((Date.now() - (c.at || Date.now())) / 600_000) : 0;
+ }
let arkTimer = null;
let arkConnectPromise = null;
+ let arkInitPromise = null; // the manager's own init, apart from the snapshot gate
let arkInitGen = 0; // guards against a stale init() resolving after a wallet switch
function stopArk() {
@@ -474,7 +513,9 @@ export function arkFeature(ctx) {
clearTimeout(lnTagT);
lnTagSeen = -1;
ark = null;
+ arkInited = null;
arkConnectPromise = null;
+ arkInitPromise = null;
// The renewal warning describes the coins of the wallet we're leaving.
// Switching accounts used to carry it onto the next one — with the old
// wallet's amount and deadline, over the new wallet's balance — and the
@@ -541,7 +582,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 +664,10 @@ export function arkFeature(ctx) {
arkUrl: cfg.ark,
esploraUrl: cfg.esplora,
network: getNetwork(),
+ honorsExpired: !!cfg.honorsExpired,
+ // the server's last published info: the wallet opens on it when the
+ // server is gone, so coins can still be read and exited
+ infoHint: (cachedArkInfo(cfg.ark) || {}).info || null,
// 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
@@ -636,7 +681,22 @@ export function arkFeature(ctx) {
// Open the server connection while Nostr restores, but do not replay the
// mailbox before the snapshot's history and cursor have arrived.
const restored = Promise.resolve(wallet.nostrRestoreReady || wallet.syncFromNostr?.()).catch(() => false);
- arkConnectPromise = Promise.all([mgr.init(), restored]).then(() => {
+ const inited = mgr.init().then(() => {
+ if (gen !== arkInitGen) return;
+ arkInited = mgr; // readable now; `ark` (and the mailbox) wait for the snapshot
+ if (!mgr.offline) rememberArkInfo(cfg.ark, mgr.info, mgr._tipH);
+ render();
+ });
+ arkInitPromise = inited;
+ // The tip: expiries and exit costs are judged against it, and init alone
+ // never asks for it (the first sync does, after the snapshot gate). Ask
+ // now, off the connect path, and remember it for the next refresh.
+ inited.then(() => mgr._tipMemo()).then(() => {
+ if (gen !== arkInitGen || !mgr._tipH) return;
+ rememberArkInfo(cfg.ark, mgr.info, mgr._tipH);
+ render();
+ }).catch(() => {});
+ arkConnectPromise = Promise.all([inited, restored]).then(() => {
if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect
ark = mgr;
// Sync may have arrived while init awaited the server handshake.
@@ -931,8 +991,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 +1048,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++) {
@@ -2301,7 +2362,7 @@ export function arkFeature(ctx) {
// page, or a tap before the manager has connected (the first seconds
// after boot) found no coins and closed itself — Manage needed two taps.
// The manager is dialled meanwhile; what needs it live waits for it.
- const mgr = ark;
+ const mgr = arkView();
const st = (mgr && mgr.state) || arkStateNow();
// another device's renewal holds its coins just the same: greyed here too
const elsewhere = st ? remoteRenewalsInFlight(st) : [];
@@ -2313,7 +2374,11 @@ export function arkFeature(ctx) {
const renewing = ((st && st.actions) || []).filter((a) => a.type === 'refresh' && !['done', 'failed'].includes(a.step));
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;
+ const tip = arkTipNow(mgr);
+ // 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 +2400,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
@@ -2353,10 +2418,11 @@ export function arkFeature(ctx) {
// bracket the share is zero, where the wallet renews on its own anyway.
// A flat base fee (the round's on-chain cost, which coinos stopped
// absorbing in October 2026) rides on top of every bracket.
- const table = (((mgr && mgr.info) || {}).refreshFees || {}).ppmExpiryTable || [];
- const baseSat = (((mgr && mgr.info) || {}).refreshFees || {}).baseFeeSat || 0;
+ const infoNow = (mgr && mgr.info) || (cachedArkInfo((getArkConfig() || {}).ark) || {}).info || {};
+ const table = (infoNow.refreshFees || {}).ppmExpiryTable || [];
+ const baseSat = (infoNow.refreshFees || {}).baseFeeSat || 0;
const baseCost = fmtSats(baseSat) + ' sats';
- const roundMins = Math.max(1, Math.round((((mgr && mgr.info) || {}).roundIntervalSecs || 120) / 60));
+ const roundMins = Math.max(1, Math.round((infoNow.roundIntervalSecs || 120) / 60));
const roundEvery = roundMins === 1 ? t('arkCoinsRoundEveryOne') : t('arkCoinsRoundEvery', { n: roundMins });
const ppmFor = (blocks) => (table.filter((e) => e.thresholdBlocks <= blocks).pop()?.ppm) ?? 0;
// A coin's SHARE of the renewal fee, fractional: the server totals the
@@ -2394,6 +2460,12 @@ export function arkFeature(ctx) {
for (const id of ui.arkCoinsSel) if (!ids.has(id)) ui.arkCoinsSel.delete(id);
const sel = ui.arkCoinsSel;
const selCoins = spend.filter((v) => sel.has(v.id));
+ // the three actions below the table act on the ticked coins
+ const selExitable = selCoins.filter((v) => exitable.includes(v));
+ const selExitSat = selExitable.reduce((n, v) => n + v.amountSat, 0);
+ let selExitFee = 0; try { if (mgr) selExitFee = estimateExitFeeSat(mgr, selExitable); } catch {}
+ const subset = selCoins.length > 0 && selCoins.length < spend.length;
+ const coopSat = subset ? selCoins.reduce((n, v) => n + v.amountSat, 0) : spendableSat;
let selFee = 0; try { selFee = mgr.refreshFee(selCoins, tip); } catch {}
const renew = () => {
if (!selCoins.length || !mgr) return;
@@ -2404,6 +2476,20 @@ export function arkFeature(ctx) {
ui.arkRenewConfirm = null;
render(); // the page stays: the coins show as renewing
};
+ // What a renewal is and what it costs, told where it is decided: on the
+ // confirmation, not as a card on the page (the page is the coins and
+ // their three buttons; the explainers were a wall under them).
+ const renewExplainer = () => [
+ 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),
+ h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null,
+ ];
// "Renew" asks once, with the numbers on the table: the coins it takes
// can't be spent until the round's transaction confirms, it can't be
// called back, and by default it takes every coin. (A user renewed their
@@ -2416,6 +2502,7 @@ export function arkFeature(ctx) {
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('arkRenewConfirmTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmBody')),
+ ...renewExplainer(),
h('div', { class: 'col', style: 'gap:4px' },
row(t('arkExitConfirmCoins'), String(selCoins.length)),
row(t('arkExitConfirmAmount'), fmtSats(selSat) + ' sats'),
@@ -2423,7 +2510,7 @@ export function arkFeature(ctx) {
row(t('arkRenewConfirmLeft'), fmtSats(leftSat) + ' sats', leftSat ? '' : 'err')),
leftSat ? null : h('div', { class: 'notice err small' }, t('arkRenewConfirmAll')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkRenewConfirmWait')),
- h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !mgr, onClick: renew },
+ h('button', { class: 'btn-primary btn-block ark-renew-go', disabled: !!ui.arkBusy || !ark, onClick: renew },
selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.arkRenewConfirm = null; render(); } }, t('cancel')));
}
@@ -2456,22 +2543,23 @@ export function arkFeature(ctx) {
// is published: an exit cannot be called back.
if (ui.arkExitConfirm) {
const savings = wallet.spendable || 0;
- const short = savings < exitFee;
+ const short = savings < selExitFee;
const row = (k, v, cls = '') => h('div', { class: 'row between' }, h('span', { class: 'small muted' }, k), h('span', { class: 'small' + (cls ? ' ' + cls : '') }, v));
return h('div', { class: 'col', style: 'gap:16px' },
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, t('arkExitConfirmTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('arkExitConfirmBody')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoinsExitDesc', { fee: fmtSats(selExitFee), after: fmtSats(afterFee) })),
h('div', { class: 'col', style: 'gap:4px' },
- row(t('arkExitConfirmCoins'), String(spend.length)),
- row(t('arkExitConfirmAmount'), fmtSats(totalSat) + ' sats'),
- row(t('arkExitConfirmFee'), fmtSats(exitFee) + ' sats'),
+ row(t('arkExitConfirmCoins'), String(selExitable.length)),
+ row(t('arkExitConfirmAmount'), fmtSats(selExitSat) + ' sats'),
+ row(t('arkExitConfirmFee'), fmtSats(selExitFee) + ' 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,
+ short ? h('div', { class: 'notice err small' }, t('arkExitFeeShort', { need: fmtSats(selExitFee), 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 || !selExitable.length, onClick: () => {
ui.arkExitConfirm = null;
- doArkExit();
+ doArkExit(selExitable.map((v) => v.id));
ui.arkCoinsSel = null;
back();
ui.tab = 'history'; // the exit lives as a pending record there
@@ -2522,7 +2610,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;
@@ -2545,43 +2634,78 @@ export function arkFeature(ctx) {
h('td', { class: 'num' }, fmtSats(v.amountSat), h('span', { class: 'small faint' }, ' sats')),
h('td', { class: 'num' }, expiresOf(v)),
h('td', { class: 'num' }, '—'),
- h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing'))))))))),
+ h('td', { class: 'num' }, h('span', { class: 'tag pending' }, t('arkCoinsRenewing')))))))),
+ // What the ticked coins can do, right under them: renew, withdraw
+ // (the co-operative exit, into Savings) or exit on their own. Each
+ // names the ticked total it acts on.
+ // present even while every coin is in a round: disabled, so the page
+ // keeps its shape and says why nothing can be done
+ h('div', { class: 'col ark-coin-actions', style: 'gap:6px;margin-top:4px' },
+ h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !ark, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } },
+ !selCoins.length ? t('arkCoinsRenewNone')
+ : selCoins.length < spend.length
+ ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') })
+ : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn')),
+ h('button', { class: 'btn-ghost btn-block ark-coop-go', disabled: !!ui.arkBusy || !coopSat || !selCoins.length || wallet.watchOnly, onClick: () => {
+ const address = wallet.freshReceive().address;
+ const vtxoIds = subset ? selCoins.map((v) => v.id) : undefined;
+ ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null;
+ ui.arkOffboarded = null; ui.arkError = '';
+ ctx.showSend({ fresh: true });
+ ui.arkOffboardSend = { address, amount: String(coopSat), fromCoins: true, vtxoIds };
+ render();
+ } }, t('arkCoinsWithdrawBtn', { amount: fmtNative(coopSat) })),
+ h('button', { class: 'btn-ghost btn-block ark-exit-ask', disabled: !!ui.arkBusy || !selExitable.length || !mgr, onClick: () => { ui.arkExitConfirm = true; render(); } },
+ t('arkCoinsExitBtnAmt', { amount: fmtNative(selExitSat) })))),
renewingCard,
- h('div', { class: 'card col', style: 'gap:8px' },
- 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 })),
- 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),
- h('span', { class: 'small' + (r.free ? ' faint' : '') }, r.cost)))) : null,
- !spend.length ? null : h('button', { class: 'btn-primary btn-block ark-renew-ask', disabled: !!ui.arkBusy || !selCoins.length || !mgr, onClick: () => { ui.arkRenewConfirm = true; render(); try { window.scrollTo({ top: 0 }); } catch {} } },
- !selCoins.length ? t('arkCoinsRenewNone')
- : selCoins.length < spend.length
- ? t('arkCoinsRenewSome', { n: selCoins.length, fee: selFee > 0 ? fmtSats(selFee) + ' sats' : t('arkDepthFree') })
- : selFee > 0 ? t('arkCoinsRenewNowFee', { fee: fmtSats(selFee) + ' sats' }) : t('arkDepthRenewBtn'))),
- h('div', { class: 'card col', style: 'gap:8px' },
- h('h4', { style: 'margin:0' }, t('arkCoinsCoopTitle')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
- h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy || !spendableSat || wallet.watchOnly, onClick: () => {
- const address = wallet.freshReceive().address;
- ui.arkCoinsPage = null; ui.arkCoinsSel = null; ui.arkExitPage = null; ui.arkExitConfirm = null; ui.arkRenewConfirm = null;
- ui.arkOffboarded = null; ui.arkError = '';
- ctx.showSend({ fresh: true });
- ui.arkOffboardSend = { address, amount: String(spendableSat), fromCoins: true };
- render();
- } }, t('arkOffboardBtn', { n: fmtAmount(spendableSat) + ' ' + unitLabel() }))),
- 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'))),
+ embedded ? custodyCard() : null,
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 cheap by flattening deep histories early (exit). 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 view = arkView();
+ const info = (view && view.info) || (cachedArkInfo(cfg && cfg.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 = !info ? '…' : base ? fmtSats(base) + ' sats' : t('arkDepthFree');
+ // an early renewal pays the bracket a fresh coin sits in (the top one)
+ const earlyPct = pct(ppmAt(((info && info.vtxoExpiryDelta) || 8640) - 1));
+ const hops = (renewPolicy('exit', getNetwork()) || {}).maxDepth || 4;
+ 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: 'exit', label: t('arkCustodyExit'), desc: t('arkCustodyExitDesc', { hops }), cost: t('arkCustodyCostExit', { pct: earlyPct, base: baseCost }) },
+ ].filter((x) => honors || x.id !== 'custodial');
+ const chosen = tiers.find((x) => x.id === cur) || tiers[0];
+ // A drop-down, and only the chosen strategy's description under it: three
+ // paragraphs at once read as a wall, and two of them are about choices
+ // the reader has not made.
+ return h('div', { class: 'card col ark-custody', style: 'gap:10px' },
+ h('h4', { style: 'margin:0' }, t('arkCustodyTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('arkCustodyDesc', { days: lifeDays })),
+ h('select', { id: 'ark-custody-select', disabled: !honors, onChange: (e) => applyCustody(e.target.value) },
+ ...tiers.map((x) => h('option', { value: x.id, selected: x.id === cur }, x.label))),
+ // the price under the choice, not in the option (a long option text
+ // overflows the select on a phone)
+ h('div', { class: 'small ark-custody-cost' }, chosen.cost),
+ h('p', { class: 'small muted ark-custody-desc', style: 'margin:0' }, chosen.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)
@@ -2799,9 +2923,9 @@ export function arkFeature(ctx) {
// Fee = size × rate needs none of it. Still memoized on the coin set +
// feerate, since the advisory asks on every wallet render.
let _exitFeeMemo = { key: null, val: 0 };
- function estimateExitFeeSat(mgr) {
+ function estimateExitFeeSat(mgr, only = null) {
const feeRate = Math.max(1, (wallet.feeRates && wallet.feeRates.halfHourFee) || 2);
- const coins = freeCoins(mgr.state);
+ const coins = only || 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;
@@ -2816,16 +2940,27 @@ export function arkFeature(ctx) {
return total;
}
- async function doArkExit() {
+ // ids: exactly these coins (the Manage page's ticked ones); else every coin
+ async function doArkExit(ids = null) {
ui.arkBusy = 'exit'; ui.arkError = ''; render();
try {
- const mgr = await connectArk();
- const spendables = freeCoins(mgr.state);
- if (!spendables.length) throw new Error(t('arkNotConnected'));
+ // The manager's own init is enough for an exit — the server may be
+ // gone (that is what a unilateral exit is for), and the snapshot gate
+ // the full connect also waits on may never clear without the relays.
+ let mgr = arkView();
+ if (!mgr) {
+ connectArk().catch(() => {});
+ for (let i = 0; i < 50 && !arkInitPromise; i++) await new Promise((r) => setTimeout(r, 100));
+ try { await arkInitPromise; } catch {}
+ mgr = arkView();
+ }
+ if (!mgr) throw new Error(t('arkNotConnected'));
+ const spendables = exitableCoins(mgr.state, mgr._tipH || 0).filter((v) => !ids || ids.includes(v.id));
+ 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.
- const feeEst = estimateExitFeeSat(mgr);
+ const feeEst = estimateExitFeeSat(mgr, spendables);
if ((wallet.spendable || 0) < feeEst) {
throw new Error(t('arkExitFeeShort', { need: fmtSats(feeEst), have: fmtSats(wallet.spendable || 0) }));
}
@@ -2983,7 +3118,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;
@@ -3075,7 +3210,7 @@ export function arkFeature(ctx) {
async function quoteOffboard(o, address) {
o.quoteFor = address; o.quote = null;
let quote = null;
- try { quote = await (await connectArk()).offboardQuote(addrScript(address)); }
+ try { quote = await (await connectArk()).offboardQuote(addrScript(address), o.vtxoIds); }
catch {} // no quote: the send itself still says what went wrong
if (o.quoteFor !== address) return; // the address changed meanwhile
o.quote = quote; o.quoteDone = true;
@@ -3083,7 +3218,9 @@ export function arkFeature(ctx) {
}
function arkOffboardSendView() {
const o = ui.arkOffboardSend;
- const spendable = arkBalance()?.spendableSat || 0;
+ // chosen coins: the amount is theirs and is not a field
+ const chosen = o.vtxoIds ? freeCoins(arkStateNow()).filter((v) => o.vtxoIds.includes(v.id)) : null;
+ const spendable = chosen ? chosen.reduce((n, v) => n + v.amountSat, 0) : (arkBalance()?.spendableSat || 0);
if (o.quoteFor !== o.address) {
o.quote = null; o.quoteDone = false; o.quoteFor = o.address;
let ok = false; try { addrScript(o.address); ok = true; } catch {}
@@ -3093,6 +3230,7 @@ export function arkFeature(ctx) {
const tooSmall = !!q && spendable < q.minSat;
return h('div', { class: 'card col', style: 'gap:12px' },
h('h3', {}, t(o.fromCoins ? 'arkCoinsCoopTitle' : 'arkExitSendTitle')),
+ o.fromCoins ? h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')) : null,
h('label', { class: 'field' },
h('span', { class: 'lab' }, t('recipient')),
h('input', { type: 'text', class: 'mono-input', value: o.address, spellcheck: 'false', autocomplete: 'off', autocapitalize: 'none',
@@ -3103,10 +3241,11 @@ export function arkFeature(ctx) {
fee: fmtSats(q.totalSat), chain: fmtSats(q.chainSat), service: fmtSats(q.serviceSat), min: fmtSats(q.minSat),
})) : !o.quoteDone ? h('div', { class: 'small faint' }, t('arkOffboardQuoting')) : null,
tooSmall ? h('div', { class: 'notice' }, t('arkOffboardBelowMin', { n: fmtSats(spendable), min: fmtSats(q.minSat) })) : null,
- h('div', { class: 'input-group' },
- h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount,
- onInput: (e) => { o.amount = e.target.value; } }),
- h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))),
+ chosen ? h('div', { class: 'small ark-coop-chosen' }, chosen.length === 1 ? t('arkCoopSelectedAmtOne', { amount: fmtSats(spendable) + ' sats' }) : t('arkCoopSelectedAmt', { n: chosen.length, amount: fmtSats(spendable) + ' sats' }))
+ : h('div', { class: 'input-group' },
+ h('input', { type: 'number', min: '1', placeholder: String(spendable), value: o.amount,
+ onInput: (e) => { o.amount = e.target.value; } }),
+ h('button', { type: 'button', onClick: () => { o.amount = String(spendable); render(); } }, t('max'))),
ui.arkError ? h('div', { class: 'notice err' }, ui.arkError) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-ghost grow', onClick: () => {
@@ -3116,7 +3255,7 @@ export function arkFeature(ctx) {
ui.arkBusy === 'offboard'
? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner sm' }))
: h('button', { class: 'btn-primary grow', disabled: tooSmall, onClick: () => {
- const amount = String(o.amount ?? '').trim();
+ const amount = chosen ? String(spendable) : String(o.amount ?? '').trim();
const sats = amount ? Number(amount) : spendable;
if (!Number.isSafeInteger(sats) || sats <= 0 || sats > spendable) { ui.arkError = t('enterValidAmtForN', { n: 1 }); render(); return; }
try { addrScript(o.address); } catch { ui.arkError = t('arkOffboardBadAddress'); render(); return; }
@@ -3125,7 +3264,7 @@ export function arkFeature(ctx) {
ui.arkCoopFailed = false;
ui.arkError = t('arkOffboardAmtBelowMin', { min: fmtSats(q.minSat), fee: fmtSats(q.totalSat) }); render(); return;
}
- doArkOffboard(sats >= spendable ? 0 : sats, o.address);
+ doArkOffboard(sats >= spendable ? 0 : sats, o.address, chosen ? o.vtxoIds : undefined);
} }, t('arkSendBtn'))),
// This send IS the cooperative exit. The trustless fallback only
// appears when cooperation actually failed (not when the amount was
@@ -3161,7 +3300,9 @@ export function arkFeature(ctx) {
})());
}
- async function doArkOffboard(amountSat, destAddress = null) {
+ // vtxoIds: exactly these coins go (the coins page's ticked subset);
+ // otherwise an amount is split off the balance, or everything goes.
+ async function doArkOffboard(amountSat, destAddress = null, vtxoIds = undefined) {
ui.arkBusy = 'offboard'; ui.arkError = ''; ui.arkCoopFailed = false; render();
try {
const address = destAddress || wallet.freshReceive().address;
@@ -3169,8 +3310,8 @@ export function arkFeature(ctx) {
const mgr = await connectArk();
const spendables = () => freeCoins(mgr.state);
const total = spendables().reduce((n, v) => n + v.amountSat, 0);
- let ids; // undefined = all spendable vtxos
- if (amountSat && amountSat < total) {
+ let ids = vtxoIds; // undefined = all spendable vtxos
+ if (!ids && amountSat && amountSat < total) {
let target = spendables().find((v) => v.amountSat === amountSat);
if (!target) {
const before = new Set(spendables().map((v) => v.id));
@@ -3199,25 +3340,51 @@ 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.
+ // exit — the same, plus a coin is renewed as soon as its history is four
+ // hops deep: an exit pays one on-chain transaction per hop, so this
+ // keeps the exit price of every coin near a fresh coin's. Costs the
+ // top bracket + base each time, so only for coins worth it.
+ // 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() {
+ let v = wallet.loadFeatureState ? wallet.loadFeatureState('arkCustody', null) : null;
+ if (v === 'early') v = 'exit'; // the third tier's first name (one day in October 2026)
+ 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 +3418,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 +3436,43 @@ 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);
+ // The exit tier also renews a coin whose history has grown deep — each
+ // hop is one more on-chain transaction in a unilateral exit — but only
+ // a coin worth it: never more than a tenth of the coin on its renewal
+ // (300 sats to flatten a 21-sat coin's history would be absurd, and
+ // such a coin's exit is uneconomic whatever its depth).
+ const depthOf = (v) => { try { return mgr._decoded(v).genesis.length; } catch { return 0; } };
+ const deepCoins = !policy.maxDepth ? [] : spendables.filter((v) =>
+ !expiringCoins.includes(v) && depthOf(v) >= policy.maxDepth && mgr.refreshFee([v], tip) * 10 <= v.amountSat);
+ if (!expiringCoins.length && !deepCoins.length) { arkRenewWarn = null; return; }
+ // Worth it, or not at all: an automatic renewal never spends more than
+ // a tenth of what it renews. 301 sats to renew a 1,000-sat coin and
+ // four bits of change restored an exit nobody would ever use (the
+ // exit itself would cost more than the coins). Coins left alone stay
+ // spendable — the server keeps honoring them.
+ const worthIt = (set) => mgr.refreshFee(set, tip) * 10 <= set.reduce((n, v) => n + v.amountSat, 0);
+ if (!expiringCoins.length) {
+ // nothing is expiring: this round is for the deep coins alone, and
+ // they pay for it — the floor logic below still applies
+ arkRenewWarn = null;
+ let renewSet = [...deepCoins, ...spendables.filter((v) => !deepCoins.includes(v) && v.amountSat < 1000)];
+ const netSat = (set) => set.reduce((n, v) => n + v.amountSat, 0) - mgr.refreshFee(set, tip);
+ if (netSat(renewSet) < 330 || !worthIt(renewSet)) return;
+ await mgr.refresh(renewSet.map((v) => v.id));
+ render();
+ 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 +3483,25 @@ 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, ...deepCoins, ...spendables.filter((v) => !expiringCoins.includes(v) && !deepCoins.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
+ if (!worthIt(renewSet)) return; // not worth a tenth of itself: left alone, still spendable
+ await mgr.refresh(renewSet.map((v) => v.id));
render();
} catch {} // transient — the next throttled attempt retries
}
@@ -3595,7 +3783,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);
@@ -3615,7 +3803,7 @@ export function arkFeature(ctx) {
h('p', { class: 'small muted', style: 'margin:0' }, t('arkCoopDesc')),
spendable > 0
? h('button', { class: 'btn-primary btn-block', disabled: !!ui.arkBusy, onClick: () => doArkOffboard() },
- ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtAmount(spendable) + ' ' + unitLabel() }))
+ ui.arkBusy === 'offboard' ? h('span', { class: 'spinner sm' }) : t('arkOffboardBtn', { n: fmtNative(spendable) }))
: h('div', { class: 'small faint' }, exitingSat > 0 ? t('arkExitCoopHeld') : t('arkExitNoBalance'))),
// unilateral
h('div', { class: 'card col', style: 'gap:8px' },
@@ -3650,10 +3838,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 arkCoinsPage({ embedded: true }); // the custody strategy sits inside, after the coins
},
// the wallet's own pages keep the top nav: these are pages, not takeovers
screenView() {
@@ -4113,7 +4301,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/features/messages.js b/src/features/messages.js
index 71d3370..030d9fa 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -6716,7 +6716,8 @@ export function messagesFeature(ctx) {
const d = {};
if (!have.size && +f.size > 0) d.size = +f.size;
// no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough
- else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8);
+ // (a guess: never taken for the file's identity, see mediaItems)
+ else if (!have.size && +f.bitrate > 0 && +f.duration > 0) { d.size = Math.round(+f.bitrate * +f.duration / 8); d.sizeGuess = true; }
if (!have.dur && +f.duration > 0) d.dur = +f.duration;
const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(f.dim || '');
if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; }
@@ -6820,14 +6821,14 @@ export function messagesFeature(ctx) {
for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; }
const head = readMp4Head(buf);
const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, goneAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
- if (total > 0) d.size = total;
+ if (total > 0) { d.size = total; d.sizeGuess = undefined; }
else {
// a server that hides Content-Range from scripts (Primal's) still
// says Content-Length to a HEAD, which any page may read
try {
const hr = await fetch(url, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(5000) });
const n = +hr.headers.get('content-length');
- if (hr.ok && n > 0) d.size = n;
+ if (hr.ok && n > 0) { d.size = n; d.sizeGuess = undefined; }
} catch {}
}
if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; }
@@ -7986,6 +7987,7 @@ export function messagesFeature(ctx) {
const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url);
const noPicture = new Set(), h264Failed = new Set();
const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session)
+ const deadTiles = new Set(); // grid pictures that wouldn't load (this session): left out
// A picture from a host that won't hand over its bytes (no CORS: primal's
// blossom, nostrcheck, most personal sites) can't have its column copy made
// here, so the server makes it (pic.webp: 1100px wide, or a 360px square
@@ -14021,9 +14023,17 @@ export function messagesFeature(ctx) {
// the screen leaves them to the next)
if (again && urls.length) said.set(again, ev.id);
for (const url of urls) {
+ if (deadTiles.has(url)) continue;
const f = sameFile(url);
if (seen.has(f)) continue;
seen.add(f);
+ // the same clip uploaded twice by one person (a note and a video
+ // event a few minutes apart) gets two hashes but keeps its size to
+ // the byte
+ const d = kind === 'videos' ? videoInfo.get(url) || {} : {};
+ const twin = d.size && !d.sizeGuess ? ev.pubkey + '|' + d.size : null;
+ if (twin && seen.has(twin)) continue;
+ if (twin) seen.add(twin);
out.push({ ev, url });
}
}
@@ -14276,11 +14286,18 @@ export function messagesFeature(ctx) {
onLoad: (e) => arFromStill(e.currentTarget, it.url),
onError: (e) => {
if (fromServer) { serverThumbFailed.add(it.url); render(); return; } // the frame grab's turn
+ // remembered, not only a class on the tile: the next
+ // repaint rewrites the class list, and the broken-picture
+ // glyph came back (a deleted nostr.media file in #artstr)
+ deadTiles.add(it.url);
const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken');
+ scheduleRepaint();
} })
// no still to show: the clip's first frame, fetched once the tile is near
: h('video', { 'data-src': it.url + '#t=0.1', muted: true, playsinline: true, preload: 'metadata' }),
- mediaMode === 'videos' && poster && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null,
+ // (drawn here whatever the tile shows, so a repaint keeps the one
+ // the frame-grab added)
+ mediaMode === 'videos' && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null,
mediaMode === 'videos' ? h('span', { class: 'media-tile-play', 'aria-hidden': 'true' }, '▶') : null);
}));
}
@@ -14294,8 +14311,14 @@ export function messagesFeature(ctx) {
if (!e.isIntersecting) continue;
const v = e.target;
tileObs.unobserve(v);
- const tile = v.closest('.media-tile');
- if (tile) tile._skipMorph = true;
+ // the player (and whatever stands in for it) is left alone by
+ // repaints, which would strip its src; the tile around it keeps up
+ // with them (its shape when the grid's layout changes, its class).
+ // Freezing the whole tile kept a tile built for one-to-a-row out of
+ // the justified rows: no --ar, no width.
+ v._skipMorph = true;
+ const standIn = (el) => { el._skipMorph = true; return el; };
+ const badge = (el) => { if (!el.parentNode.querySelector('.media-tile-badge')) el.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); };
// A first frame costs the start of the file when the index leads or
// the server serves ranges — whatever the clip's size. Only an
// index-last file without ranges would have to come down whole for
@@ -14313,7 +14336,7 @@ export function messagesFeature(ctx) {
img.addEventListener('load', () => arFromStill(img, url), { once: true });
img.src = videoThumbs.get(url);
v.replaceWith(img);
- if (heavy && d.size) img.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
+ if (heavy && d.size) badge(img);
return;
}
if (!v.isConnected) return;
@@ -14325,7 +14348,7 @@ export function messagesFeature(ctx) {
const cap = setTimeout(() => {
if (v.readyState >= 2 || !v.isConnected) return;
try { v.removeAttribute('src'); v.load(); } catch {}
- v.replaceWith(h('span', { class: 'media-tile-cost' }, ''));
+ v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, '')));
}, 8000);
v.addEventListener('loadeddata', () => clearTimeout(cap), { once: true });
}
@@ -14346,10 +14369,10 @@ export function messagesFeature(ctx) {
scheduleRepaint();
}, { once: true });
v.src = v.getAttribute('data-src');
- if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
+ if (heavy && d.size) badge(v);
return;
}
- v.replaceWith(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : ''));
+ v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : '')));
});
}
}, { rootMargin: '400px 0px' });
diff --git a/src/i18n.js b/src/i18n.js
index a07cba4..edf9102 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -777,6 +777,26 @@ const STR = {
arkFeeChain: 'On-chain fee',
arkFeeService: 'Service fee',
arkCoinsExpired: 'expired',
+ arkCoinsHeld: 'held by Coinos',
+ arkCoopSelected: 'Only the {n} coins ticked above will move.',
+ arkCoopSelectedOne: 'Only the coin ticked above will move.',
+ arkCoopSelectedAmt: '{n} selected coins · {amount}',
+ arkCoopSelectedAmtOne: '1 selected coin · {amount}',
+ arkCustodyTitle: 'Custody strategy',
+ arkCustodyDesc: 'Each coin expires about {days} days after it was made. Until then you can bring it on-chain yourself, without Coinos; after that, Coinos keeps honoring it. Renewing before expiry keeps your own exit, for a fee, so it is a choice:',
+ arkCustodyCustodial: 'Allow coins to expire',
+ arkCustodyCustodialDesc: 'Nothing renews and no renewal fees are paid. A coin keeps its own exit until it expires. After that, paying still works, but bringing it on-chain needs Coinos’s cooperation.',
+ arkCustodySelf: 'Minimize renewal cost',
+ arkCustodySelfDesc: 'Coins renew in the last hours before they expire, when renewal is cheapest, so you can always exit on your own without Coinos. Roughly once every {days} days per coin. Coins too small to be worth the fee are left alone.',
+ arkCustodyExit: 'Minimize exit cost',
+ arkCustodyExitDesc: 'Every transfer adds a hop to a coin’s history, and a unilateral exit pays one on-chain transaction per hop. This renews a coin before it expires, and also as soon as its history is {hops} hops deep, so bringing any coin on-chain stays close to a fresh coin’s price. Only for coins worth it: never more than a tenth of a coin on its renewal.',
+ arkCustodyCostNone: 'no fees',
+ arkCustodyCostSelf: '{base} per renewal',
+ arkCustodyCostExit: '{base} near expiry, {pct} + {base} when early',
+ arkCustodySaved: 'Custody choice saved',
+ arkCustodyNotHere: 'This server does not honor expired coins, so they renew automatically here.',
+ arkExitNoneExitable: 'Every coin has expired, 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 “Allow coins to expire”, nothing renews on its own. A renewal replaces the selected coins with a single fresh one and restores your own exit for them. Every renewal costs a flat {base} toward its on-chain fee, plus a share that depends on how close each coin is to expiry. The closer, the cheaper:',
noteForeignKind: 'A kind {kind} event this app doesn’t show.',
zapRowText: 'zapped {n} sats',
pollVoter: '{n} vote',
@@ -800,8 +820,10 @@ const STR = {
arkRenewStageRetry: 'The last check with the server didn’t go through; the wallet keeps trying.',
arkRenewingHistory: 'Renewing',
arkRenewingOpen: 'See the coins',
- arkCoinsExitTitle: 'Unilateral exit',
- arkCoinsCoopTitle: 'Co-operative exit',
+ arkCoinsExitTitle: 'Exit',
+ arkCoinsCoopTitle: 'Withdraw',
+ arkCoinsWithdrawBtn: 'Withdraw {amount}',
+ arkCoinsExitBtnAmt: 'Exit {amount}',
arkOffboardBadAddress: 'Enter a valid on-chain address for this network.',
arkCoinsExitBtn: 'Exit now',
arkExitConfirmTitle: 'Start a unilateral exit?',
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);
diff --git a/src/style.css b/src/style.css
index 61d6f01..275bb59 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1548,6 +1548,9 @@ body::after {
.media-grid.justified { display: flex; flex-wrap: wrap; --row-h: 140px; }
.media-grid.justified .media-tile { aspect-ratio: var(--ar); flex: var(--ar) 1 calc(var(--ar) * var(--row-h)); min-width: 0; }
.media-grid.justified::after { content: ''; flex: 1000 1 0; }
+/* the picture fills the tile without sizing it: a frame-grab <video>'s own
+ 320px would widen its tile, and the row's height with it */
+.media-grid.justified .media-tile > img, .media-grid.justified .media-tile > video { position: absolute; inset: 0; }
@media (max-width: 600px) { .media-grid.justified { --row-h: 100px; } }
.media-tile img, .media-tile video { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.media-tile.broken { display: none; }
diff --git a/tools/ark-htlc-orphan.test.js b/tools/ark-htlc-orphan.test.js
new file mode 100644
index 0000000..cc127be
--- /dev/null
+++ b/tools/ark-htlc-orphan.test.js
@@ -0,0 +1,33 @@
+// bun test tools/ark-htlc-orphan.test.js — an HTLC coin whose payment's
+// action has been pruned is an orphan the server settles: spent means spent
+// (it stopped counting as pending money), still-open stays pending, and a
+// coin a live payment holds is never asked about.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+const asked = [];
+mock.module('../src/ark/proto.js', () => ({ ...proto, getVtxoStatus: async (url, raw) => { asked.push(raw[0]); return raw[0] === 1 ? proto.VTXO_STATE_SPENT : proto.VTXO_STATE_SPENDABLE; } }));
+const { ArkManager } = await import('../src/ark/manager.js');
+test('orphan HTLC coins are settled by the server\'s answer', async () => {
+ const m = Object.create(ArkManager.prototype);
+ m.state = {
+ vtxos: [
+ { id: 'gone', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 1 }, // payment done long ago, action pruned: server says spent
+ { id: 'open', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 2 }, // orphan the server still holds open
+ { id: 'held', state: 'pending', amountSat: 22, bytes: 'x', policy: 'serverHtlcSend', tag: 3 }, // a live payment's: not asked about
+ { id: 'coin', state: 'pending', amountSat: 500, bytes: 'x', policy: 'pubkey', tag: 2 }, // a plain orphan the server counts: spendable again
+ ],
+ actions: [{ type: 'ln-pay', step: 'initiated', parts: [{ inputId: 'in1' }], htlcVtxoIds: ['held'] }],
+ scheduled: [],
+ };
+ m.arkUrl = 'x';
+ m._decoded = (v) => ({ point: { raw: Uint8Array.of(v.tag) }, policy: { type: v.policy } });
+ m._keyForVtxo = () => ({ privkey: new Uint8Array(32) });
+ m._save = () => {}; m._movement = () => {}; m._remoteBusy = () => new Set();
+ await m.reconcile();
+ const st = Object.fromEntries(m.state.vtxos.map((v) => [v.id, v.state]));
+ expect(st.gone).toBe('spent');
+ expect(st.open).toBe('pending');
+ expect(st.held).toBe('pending');
+ expect(st.coin).toBe('spendable');
+ expect(asked.sort()).toEqual([1, 2, 2]);
+});
diff --git a/tools/ark-offline-init.test.js b/tools/ark-offline-init.test.js
new file mode 100644
index 0000000..922682d
--- /dev/null
+++ b/tools/ark-offline-init.test.js
@@ -0,0 +1,22 @@
+// bun test tools/ark-offline-init.test.js — the Ark server gone: with the
+// server's last published info in hand the manager still opens (coins read,
+// exits can start); without it, init fails as before.
+import { test, expect, mock } from 'bun:test';
+import * as proto from '../src/ark/proto.js';
+mock.module('../src/ark/proto.js', () => ({ ...proto,
+ getArkInfo: async () => { throw new Error('fetch failed'); },
+ handshake: async () => { throw new Error('fetch failed'); },
+}));
+const { ArkManager } = await import('../src/ark/manager.js');
+const serverPubkey = '02' + 'ab'.repeat(32);
+const hint = { serverPubkey, vtxoExpiryDelta: 8640, vtxoExitDelta: 144, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] }, offboardFees: { baseFeeSat: 0, fixedAdditionalVb: 0, ppmExpiryTable: [] }, lnSendFees: {}, lnReceiveFees: {} };
+const mk = (infoHint) => new ArkManager({ account: null, storage: { load: () => ({ v: 1, serverPubkey, vtxos: [], actions: [], movements: [], scheduled: [] }), save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', infoHint });
+test('with cached info the manager opens offline', async () => {
+ const m = await mk(hint).init();
+ expect(m.offline).toBe(true);
+ expect(m.info.refreshFees.baseFeeSat).toBe(300);
+ expect(Buffer.from(m.serverPub).toString('hex')).toBe(serverPubkey);
+});
+test('without cached info the old failure stands', async () => {
+ await expect(mk(null).init()).rejects.toThrow('fetch failed');
+});
diff --git a/tools/ark/cooperative-exit-ui-test.js b/tools/ark/cooperative-exit-ui-test.js
index d8fa0d8..03e65d5 100644
--- a/tools/ark/cooperative-exit-ui-test.js
+++ b/tools/ark/cooperative-exit-ui-test.js
@@ -66,10 +66,9 @@ try {
});
await page.waitForSelector('.coin-table');
};
- const coopButton = () => page.evaluate(() => [...document.querySelectorAll('.card')]
- .find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').click());
+ const coopButton = () => page.evaluate(() => document.querySelector('.ark-coop-go').click());
const send = () => page.evaluate(() => [...document.querySelectorAll('.card')]
- .find((c) => c.querySelector('h3')?.textContent === 'Co-operative exit').querySelector('.btn-primary').click());
+ .find((c) => c.querySelector('h3')?.textContent === 'Withdraw').querySelector('.btn-primary').click());
const edit = (selector, value) => page.$eval(selector, (e, value) => { e.value = value; e.dispatchEvent(new Event('input', { bubbles: true })); }, value);
// The balance card's gear is the way in: Spending's settings lead with
// its coins and carry what a unilateral exit costs; the home screen no
@@ -114,13 +113,16 @@ try {
await page.evaluate(() => { window.forceArkReady = false; coopApp.wallet.saveFeatureState('arkDepth', null); coopApp.ui.screen = 'wallet'; coopApp.render(); });
await manage();
- assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), ['Renewal', 'Co-operative exit', 'Unilateral exit']);
+ // the three actions sit right under the table, named for the ticked total; no explainer cards
+ assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew now', 'Withdraw 20,000 sats', 'Exit 20,000 sats']);
+ assert.deepEqual(await page.$$eval('.card h4', (es) => es.map((e) => e.textContent)), []);
await coopButton();
await page.waitForSelector('input.mono-input');
const savings = await page.evaluate(() => coopApp.wallet.freshReceive().address);
assert.equal(await page.$eval('input.mono-input', (e) => e.value), savings);
assert.equal(await page.$eval('input[type="number"]', (e) => e.value), '20000');
assert.equal(await page.$eval('.card .btn-primary', (e) => e.textContent), 'Send');
+ assert.match(await page.evaluate(() => document.body.innerText), /one transaction into Savings, ready after a single confirmation/, 'the withdraw explainer is on the form');
assert.deepEqual(await page.evaluate(() => coopCalls), [], 'opening the form never starts an exit');
await page.evaluate(() => [...document.querySelectorAll('.card button')].find((b) => b.textContent.includes('Back')).click());
await page.waitForSelector('.coin-table');
@@ -162,12 +164,25 @@ try {
assert.deepEqual(await page.evaluate(() => coopCalls[1].script), await page.evaluate(() => [...coopApp.wallet.derive(0, 1).script]));
console.log('✓ Editing the destination and amount offboards only the requested amount to the chosen on-chain address');
+ // ticking a subset on Manage: exactly those coins go, no split, the amount is theirs and not a field
+ await manage();
+ await page.evaluate(() => { coopApp.ui.arkCoinsSel = new Set(['coin-b']); coopApp.render(); });
+ assert.deepEqual(await page.$$eval('.ark-coin-actions button', (es) => es.map((e) => e.textContent)), ['Renew 1 selected for free', 'Withdraw 8,000 sats', 'Exit 8,000 sats'], 'the buttons name the ticked total');
+ await coopButton();
+ await page.waitForSelector('.ark-coop-chosen');
+ assert.match(await page.$eval('.ark-coop-chosen', (e) => e.textContent), /1 selected coin · 8,000 sats/);
+ assert.equal(await page.$('input[type="number"]'), null, 'no amount field for chosen coins');
+ await send();
+ await page.waitForFunction(() => coopCalls.length === 3 && !coopApp.ui.arkBusy);
+ assert.deepEqual(await page.evaluate(() => ({ type: coopCalls[2].type, ids: coopCalls[2].ids, address: coopCalls[2].address })), { type: 'offboard', ids: ['coin-b'], address: savings });
+ console.log('✓ Ticking a subset on Manage offboards exactly those coins, without splitting');
+
await manage(); await coopButton();
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Wallet"]').click());
assert.equal(await page.evaluate(() => coopApp.ui.arkOffboardSend), null);
await manage();
await page.evaluate(() => { coopManager.state.vtxos.forEach((v) => v.state = 'pending'); coopManager.state.actions = [{ type: 'refresh', step: 'submitted', inputIds: ['coin-a', 'coin-b'], inAmountSat: 20000 }]; coopApp.render(); });
- assert(await page.evaluate(() => [...document.querySelectorAll('.card')].find((c) => c.querySelector('h4')?.textContent === 'Co-operative exit').querySelector('button').disabled));
+ assert(await page.evaluate(() => document.querySelector('.ark-coop-go').disabled));
await page.evaluate(() => {
const { ui, render } = coopApp;
ui.arkCoinsPage = null; ui.tab = 'send';
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js
new file mode 100644
index 0000000..f8106b7
--- /dev/null
+++ b/tools/ark/custody-test.js
@@ -0,0 +1,96 @@
+// The custody tiers, without a network: what each policy aims for, how the
+// manager treats a coin past its timelock on a server that honors it, how
+// backstop orders snap to a grid so wallets share rounds, and that the
+// receive-side validator can be told to accept such a coin.
+// Run: bun tools/ark/custody-test.js
+import assert from 'node:assert/strict';
+import { hex } from '@scure/base';
+import { ArkManager, renewPolicy, CUSTODY_TIERS, MAX_SEND_INPUTS } from '../../src/ark/manager.js';
+import { validateVtxo, VtxoValidationError } from '../../src/ark/validate.js';
+
+// policies
+assert.deepEqual(CUSTODY_TIERS, ['custodial', 'self', 'exit']);
+assert.equal(renewPolicy('custodial', 'mainnet'), null, 'the default tier renews nothing');
+assert.equal(renewPolicy('nonsense', 'mainnet'), null);
+assert.deepEqual(renewPolicy('self', 'mainnet'), { window: 96, margin: 36, grid: 48 });
+assert.deepEqual(renewPolicy('exit', 'mainnet'), { window: 96, margin: 36, grid: 48, maxDepth: 4 }, 'exit = self + a depth trigger');
+assert.deepEqual(renewPolicy('early', 'mainnet'), renewPolicy('exit', 'mainnet'), 'the old name still means the exit tier');
+assert.deepEqual(renewPolicy('self', 'regtest'), { window: 24, margin: 6, grid: 1 });
+// the snap never leaves the free bracket: under 96 blocks out
+for (let h = 100000; h < 100200; h++) { const s = h - 36; assert.ok(h - (s - (s % 48)) < 96); }
+console.log('✓ renewal policies: custodial renews nothing; self and exit aim inside the free bracket');
+
+const mk = (honorsExpired) => {
+ const mgr = new ArkManager({ account: null, storage: { load: () => null, save() {} }, arkUrl: 'http://x', esploraUrl: 'http://y', network: 'mainnet', honorsExpired });
+ mgr.state = { v: 1, nextKeyIndex: 1, vtxos: [], actions: [], movements: [], scheduled: [] };
+ mgr.info = { maxVtxoAmountSat: 5_000_000, refreshFees: { baseFeeSat: 300, ppmExpiryTable: [] } };
+ mgr._tipH = 1000;
+ return mgr;
+};
+const coin = (id, amountSat, expiryHeight, extra = {}) => ({ id, amountSat, expiryHeight, state: 'spendable', policy: 'pubkey', keyIndex: 1, ...extra });
+
+// a coin past its timelock: unusable on a server that refuses it, money on one that honors it
+{
+ const strict = mk(false), lenient = mk(true);
+ for (const m of [strict, lenient]) m.state.vtxos = [coin('a', 5000, 900), coin('b', 7000, 1200), coin('c', 100, 900)];
+ assert.equal(strict._expired(strict.state.vtxos[0], 1000), true);
+ assert.equal(strict._expired(strict.state.vtxos[2], 1000), false, 'sub-dust was always honored');
+ assert.deepEqual(strict.balance(), { spendableSat: 7100, pendingSat: 0, boardingSat: 0, expiredSat: 5000 });
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), false);
+ assert.deepEqual(lenient.balance(), { spendableSat: 12100, pendingSat: 0, boardingSat: 0, expiredSat: 0 });
+ assert.deepEqual(lenient._selectInputs(5000).map((v) => v.id), ['b'], 'a coin still inside its timelock is preferred…');
+ assert.deepEqual(lenient._selectInputs(7001).map((v) => v.id), ['b', 'a'], '…and the expired one joins only when needed');
+ lenient.state.vtxos.push(coin('d', 9000, 900));
+ assert.deepEqual(lenient._selectInputs(8000).map((v) => v.id), ['d'], 'a single expired coin beats a many-coin package');
+ lenient.state.vtxos.pop();
+ assert.deepEqual(strict._selectInputs(5000).map((v) => v.id), ['b']);
+ // the server's word still wins: a refusal naming the coin excludes it
+ lenient.state.vtxos[0].expiryRejected = true;
+ assert.equal(lenient._expired(lenient.state.vtxos[0], 1000), true);
+ assert.equal(lenient.balance().spendableSat, 7100);
+ console.log('✓ honorsExpired: a coin past its timelock stays spendable; a server refusal still excludes it');
+}
+
+// the send cap is wider now that nothing consolidates on its own
+{
+ const m = mk(true);
+ m.state.vtxos = Array.from({ length: 40 }, (_, i) => coin('c' + i, 100, 2000));
+ assert.equal(m._selectInputs(3500).length, 35, 'a 35-coin send is fine');
+ m.state.vtxos = Array.from({ length: MAX_SEND_INPUTS + 5 }, (_, i) => coin('d' + i, 100, 2000));
+ assert.throws(() => m._selectInputs(100 * (MAX_SEND_INPUTS + 1)), /too many coins/);
+ console.log(`✓ a send takes up to ${MAX_SEND_INPUTS} coins`);
+}
+
+// backstop orders snap down to the grid, and skip what the snap would put in the past
+{
+ const m = mk(true);
+ const orders = [];
+ m._tipMemo = async () => 1000;
+ m.scheduleRefresh = async (ids, height) => { orders.push([ids[0], height]); };
+ m.state.vtxos = [
+ coin('x', 10000, 1100), // 1100-36 = 1064 → 1056
+ coin('y', 20000, 1041), // 1005 → 960 ≤ tip: skipped (the app itself renews it)
+ coin('z', 30000, 1200), // 1164 → 1152
+ coin('w', 40000, 1200, { state: 'pending' }),
+ ];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 48), 2);
+ assert.deepEqual(orders, [['z', 1152], ['x', 1056]], 'largest first, snapped to multiples of 48');
+ assert.ok(orders.every(([, h]) => h % 48 === 0));
+ orders.length = 0;
+ m.state.scheduled = [];
+ assert.equal(await m.ensureScheduledRefreshes(36, 8, 1), 3, 'grid 1 = the old exact heights');
+ assert.deepEqual(orders, [['z', 1164], ['y', 1005], ['x', 1064]]);
+ console.log('✓ backstop orders snap to the grid (and never into the past)');
+}
+
+// the validator: an expired coin is refused by default, accepted when told the server honors it
+{
+ const serverPub = new Uint8Array(33).fill(2);
+ const v = { policy: { type: 'pubkey', userPubkey: 'aa' }, serverPubkey: hex.encode(serverPub), genesis: [{ transition: {} }],
+ expiryHeight: 500, amountSat: 1000, anchorPoint: { txid: 'ab'.repeat(32), vout: 0 }, point: { txid: 'cd'.repeat(32), vout: 0 }, id: 'x' };
+ const chain = { tipHeight: async () => 600, getTxStatus: async () => ({ confirmed: false }), getTxHex: async () => null };
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain }), (e) => e instanceof VtxoValidationError && /expired at 500/.test(e.message));
+ await assert.rejects(validateVtxo(v, { serverPubkey: serverPub, chain, acceptExpired: true }), (e) => e instanceof VtxoValidationError && /not confirmed/.test(e.message), 'past the expiry check, on to the anchor');
+ console.log('✓ validateVtxo: acceptExpired lets a coin past its timelock through the expiry check');
+}
+console.log('\n✅ custody tiers behave');
diff --git a/tools/ark/custody-ui-test.js b/tools/ark/custody-ui-test.js
new file mode 100644
index 0000000..9c238f5
--- /dev/null
+++ b/tools/ark/custody-ui-test.js
@@ -0,0 +1,136 @@
+// The custody card on the Spending settings page: three tiers, coinos holding
+// the coins by default, a coin past its timelock shown as held (not expired),
+// and switching tiers persists and reprices the page. Real app with a local
+// Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-ui-test.js
+import assert from 'node:assert/strict';
+import puppeteer from '../puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'custody-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.coopApp = { ui, wallet, ctx, render, enterWallet, openWalletSettings, get ready() { return !_bootDeciding; },
+ setSpending: (on = true) => { const a = activeAccount(); if (a) { if (on) a.kind = 'spending'; else delete a.kind; } } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.coopFeature = { setManager: (mgr) => { ark = mgr; }, getCustody, autoRefreshCalls: [] };
+ return {\n id: 'ark',`) }));
+ build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
+ } }] });
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>${await Bun.file('src/style.css').text()}</style><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: (req) => new URL(req.url).pathname === '/'
+ ? new Response(html, { headers: { 'Content-Type': 'text/html' } }) : new Response(null, { status: 404 }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ const cdp = await page.createCDPSession();
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: ['wss://*', 'ws://*'] });
+ await page.setRequestInterception(true);
+ page.on('request', (r) => r.url().startsWith(server.url.origin) || r.url().startsWith('data:') ? r.continue() : r.abort());
+ await page.evaluateOnNewDocument(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); // the local preset honors expired coins
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.coopApp?.ready);
+ await page.evaluate(async (mn) => {
+ await coopApp.enterWallet(mn, '', { fresh: true, generated: true });
+ coopApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ coopApp.wallet.scan = async () => {};
+ const table = [{ thresholdBlocks: 0, ppm: 0 }, { thresholdBlocks: 97, ppm: 250 }, { thresholdBlocks: 199, ppm: 1000 }, { thresholdBlocks: 2161, ppm: 2000 }];
+ window.coopManager = {
+ honorsExpired: true,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 5 }, // 29 days left, five hops deep
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 960000, depth: 1 }, // past its timelock
+ { id: 'coin-2', amountSat: 800, state: 'spendable', expiryHeight: 973921, depth: 6 }, // deep but not worth 300 sats
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 8640, roundIntervalSecs: 120, refreshFees: { baseFeeSat: 300, ppmExpiryTable: table } }, _tipH: 969751,
+ chain: { tipHeight: async () => 969751 },
+ vtxos() { return this.state.vtxos; },
+ _decoded(v) { return { genesis: new Array(v.depth || 1).fill({}), policy: { type: 'pubkey' } }; },
+ balance() { return { spendableSat: 25800, pendingSat: 0, boardingSat: 0, expiredSat: 0 }; },
+ refreshFee(inputs, tip) {
+ let u = 0;
+ for (const v of inputs) u += v.amountSat * (table.filter((e) => e.thresholdBlocks <= v.expiryHeight - tip).pop()?.ppm ?? 0);
+ return 300 + Math.ceil(u / 1e6);
+ },
+ async ensureScheduledRefreshes(margin, max, grid) { coopFeature.autoRefreshCalls.push(['ensure', margin, max, grid]); return 0; },
+ async refresh(ids) { coopFeature.autoRefreshCalls.push(['refresh', ids]); return 'refresh-1'; },
+ unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.setSpending(true);
+ coopApp.openWalletSettings('spending');
+ }, generateMnemonic(wordlist));
+ await page.waitForSelector('.ark-custody');
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+ const text = () => page.evaluate(() => document.body.innerText);
+
+ let tx = await text();
+ assert.match(tx, /Custody/);
+ const opts = await page.$$eval('.ark-custody option', (es) => es.map((e) => e.value + '=' + e.textContent));
+ assert.deepEqual(opts, ['custodial=Allow coins to expire', 'self=Minimize renewal cost', 'exit=Minimize exit cost']);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), 'no fees');
+ assert.equal(await page.$eval('#ark-custody-select', (e) => e.value), 'custodial', 'coins expire by default');
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /keeps its own exit until it expires/);
+ assert.doesNotMatch(tx, /Coins renew in the last hours|Every transfer adds a hop/, 'only the chosen strategy is described');
+ assert.doesNotMatch(tx, /timelock|Spending coin carries|old Coinos/, 'no jargon on the card');
+ const order = await page.$$eval('.card h4, .card h3', (es) => es.map((e) => e.textContent).filter((x) => /Spending coins|Custody strategy|Renewal/.test(x)));
+ assert.deepEqual(order, ['Spending coins', 'Custody strategy'], 'coins first, then the strategy; no explainer cards');
+ assert.match(tx, /held by Coinos/, 'the coin past its timelock is held, not "expired"');
+ assert.doesNotMatch(tx, /5,000 sats\s+expired/, 'the row never says "expired"');
+ assert.doesNotMatch(tx, /nothing renews on its own/, 'the renewal explainer is not on the page…');
+ const renewSheet = async () => { await page.click('.ark-renew-ask'); await sleep(200); const t = await text(); await page.evaluate(() => [...document.querySelectorAll('.ark-renew-confirm button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200); return t; };
+ assert.match(await renewSheet(), /With “Allow coins to expire”, nothing renews on its own[\s\S]*more than 15 days left/, '…it is on the Renew confirmation, with the fee schedule');
+ if (process.env.SHOT) await page.screenshot({ path: process.env.SHOT, fullPage: true });
+ console.log('✓ the custody card: a drop-down of three strategies with their prices, coins expire by default, one description, a past-timelock coin shown as held');
+
+ // the unilateral exit only counts the coin still inside its timelock
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.textContent), 'Exit 20,800 sats', 'the exit button names the ticked coins still inside their timelock');
+ assert.equal(await page.$eval('.ark-exit-ask', (b) => b.disabled), false);
+ await page.click('.ark-exit-ask'); await sleep(200);
+ tx = await text();
+ assert.match(tx, /Coins\s*2/); assert.match(tx, /Amount\s*20,800 sats/);
+ assert.match(tx, /If the server vanished today/, 'the exit explainer is on the confirmation');
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent === 'Cancel').click()); await sleep(200);
+ console.log('✓ a unilateral exit counts only the coin still inside its timelock');
+
+ // opt into keeping the exit: persisted, the page reprices, renewal starts covering
+ await page.select('#ark-custody-select', 'self'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody')), 'self', 'saved with the wallet');
+ tx = await text();
+ assert.match(tx, /Custody choice saved/);
+ assert.match(await renewSheet(), /renews coins automatically once they are close to expiring/);
+ assert.match(await page.$eval('.ark-custody-desc', (e) => e.textContent), /Coins renew in the last hours/);
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats per renewal');
+ const calls = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered at once (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1', 'coin-2']], 'the coin past its timelock is renewed now with the small change; the deep healthy one is left alone');
+ console.log('✓ switching to "Keep my own exit" persists, reprices the page and starts renewing');
+
+ // the exit tier also flattens the deep coin — but not the deep 800-sat one on its own merits
+ await page.evaluate(() => { coopFeature.autoRefreshCalls.length = 0; });
+ await page.select('#ark-custody-select', 'exit'); await sleep(400);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.$eval('.ark-custody-cost', (e) => e.textContent), '300 sats near expiry, 0.2% + 300 sats when early');
+ const calls2 = await page.evaluate(() => coopFeature.autoRefreshCalls);
+ assert.deepEqual(calls2[1], ['refresh', ['coin-1', 'coin-0', 'coin-2']], 'expired + deep (worth it) + small change ride one round');
+ console.log('✓ "Keep my exit cheap" renews the deep coin too, when the fee is under a tenth of it');
+ await page.select('#ark-custody-select', 'custodial'); await sleep(300);
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'custodial');
+ assert.match(await renewSheet(), /nothing renews on its own/);
+ console.log('✓ the other tiers switch the same way, and back');
+ assert.deepEqual(errors, [], 'no page errors');
+ console.log('\n✅ custody is a choice, made on the Spending page');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/feed-media-cache-test.js b/tools/feed-media-cache-test.js
index eee88c5..5a26ca2 100644
--- a/tools/feed-media-cache-test.js
+++ b/tools/feed-media-cache-test.js
@@ -14,7 +14,9 @@ let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const dir = mkdtempSync(tmpdir() + '/mcache-');
const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
-for (const n of ['v1', 'v2', 'v3', 'h4', 'n5', 'r6']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// (six different files: one person's clips of one size to the byte are taken
+// for one clip uploaded twice)
+for (const [i, n] of ['v1', 'v2', 'v3', 'h4', 'n5', 'r6'].entries()) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', String(2 + i / 4), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/slow.jpg`]);
const html = await buildHtml({ minify: true, pwa: false });
diff --git a/tools/feed-media-views-test.js b/tools/feed-media-views-test.js
index 1eac95c..dac9804 100644
--- a/tools/feed-media-views-test.js
+++ b/tools/feed-media-views-test.js
@@ -16,7 +16,9 @@ let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const dir = mkdtempSync(tmpdir() + '/media-');
const ff = (args) => { const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', ...args]); if (p.exitCode !== 0) throw new Error('ffmpeg failed'); };
-for (const n of ['a', 'b', 'c']) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+// (three different files: one person's clips of one size to the byte are
+// taken for one clip uploaded twice)
+for (const [n, t] of [['a', '2'], ['b', '2.5'], ['c', '3']]) ff(['-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', t, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
// index last (no faststart), from a server that ignores ranges
ff(['-f', 'lavfi', '-i', 'testsrc=size=480x270:rate=10', '-t', '2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', `${dir}/slow.mp4`]);
for (const n of ['p1', 'p2', 'p3']) ff(['-f', 'lavfi', '-i', 'testsrc=size=800x600', '-frames:v', '1', `${dir}/${n}.jpg`]);
diff --git a/tools/grid-early-player-test.js b/tools/grid-early-player-test.js
index e6dd540..0ab1c1e 100644
--- a/tools/grid-early-player-test.js
+++ b/tools/grid-early-player-test.js
@@ -29,6 +29,9 @@ if (!existsSync(BIG)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f',
const SIZE = statSync(BIG).size;
const FILE = new Uint8Array(await Bun.file(BIG).arrayBuffer());
const NAMES = ['c0', 'c1', 'c2', 'c3', 'c4'];
+// one file each, a byte or two apart (trailing padding): one person's clips of
+// one size to the byte are taken for one clip uploaded twice
+const FILES = Object.fromEntries(NAMES.map((n, i) => { const f = new Uint8Array(SIZE + i); f.set(FILE); return [n, f]; }));
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = Math.floor(Date.now() / 1000);
@@ -43,9 +46,10 @@ const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
const m = /^\/(c\d)\.webm$/.exec(path);
if (m) {
const headers = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/webm', 'accept-ranges': 'bytes', etag: '"' + m[1] + '-v1"', 'last-modified': 'Mon, 05 Oct 2026 00:00:00 GMT', 'cache-control': 'public, max-age=3600' };
- if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(SIZE) } });
+ const F = FILES[m[1]], S = F.length;
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...headers, 'content-length': String(S) } });
const rg = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
- const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], SIZE - 1) : SIZE - 1;
+ const start = rg ? +rg[1] : 0, end = rg && rg[2] ? Math.min(+rg[2], S - 1) : S - 1;
const entry = { clip: m[1], dest: req.headers.get('sec-fetch-dest') || '', start, end, sent: 0 };
log.push(entry);
let at = start;
@@ -54,9 +58,9 @@ const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
if (at > end) { ctl.close(); return; }
if (pace) { const due = t0 + ((at - start) / pace) * 1000; if (due > Date.now()) await new Promise((r) => setTimeout(r, due - Date.now())); }
const n = Math.min(65536, end - at + 1);
- ctl.enqueue(FILE.subarray(at, at + n)); at += n; entry.sent += n;
+ ctl.enqueue(F.subarray(at, at + n)); at += n; entry.sent += n;
} }, { highWaterMark: 0 });
- return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${SIZE}` } : {}) } });
+ return new Response(body, { status: rg ? 206 : 200, headers: { ...headers, 'content-length': String(end - start + 1), ...(rg ? { 'content-range': `bytes ${start}-${end}/${S}` } : {}) } });
}
if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
return new Response(html, { headers: { 'content-type': 'text/html' } });
@@ -64,7 +68,7 @@ const server = Bun.serve({ port: 0, idleTimeout: 60, fetch: (req) => {
const U = (n) => server.url.origin.replace('localhost', '127.0.0.1') + '/' + n + '.webm'; // another origin than the page's: one to preconnect to
const seeded = [
// each says its size and length (as Primal's and others' imeta do): a bitrate of SIZE / 50 bytes a second
- ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + SIZE, 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
+ ...NAMES.map((n, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [['imeta', 'url ' + U(n), 'm video/webm', 'size ' + (SIZE + i), 'duration 50', 'dim 180x320']], content: 'clip ' + n + ' here ' + U(n) }, SK)),
...Array.from({ length: 6 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 900 + i * 60)),
];
const sent = (clip, dest) => log.filter((e) => e.clip === clip && (!dest || e.dest === dest)).reduce((a, e) => a + e.sent, 0);
diff --git a/tools/media-grid-dups-test.js b/tools/media-grid-dups-test.js
index 205a4a5..e7b67da 100644
--- a/tools/media-grid-dups-test.js
+++ b/tools/media-grid-dups-test.js
@@ -1,6 +1,8 @@
// The Videos grid shows a clip once, however many posts carry it: the same
// file (or a Blossom hash's mirror), or the same words from the same person
-// with a fresh upload (Adam, 2026-10-22). And on a big screen with a mouse, a
+// with a fresh upload, or the same file uploaded twice under two hashes (a
+// note and a video event: one size to the byte) (Adam, 2026-10-22). A picture
+// that won't load leaves the Images grid instead of a broken-picture glyph. And on a big screen with a mouse, a
// small clip opened in the pager is drawn at most 1.5× its own pixels, not
// blown up across the monitor. Run: bun tools/media-grid-dups-test.js
import puppeteer from './puppeteer.js';
@@ -12,12 +14,18 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
const png = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180', '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
-const clip = (name, size) => {
- const f = '/tmp/coinos-vpager-' + name + '.webm';
- if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]);
+const clip = (name, size, t = 30) => {
+ const f = '/tmp/coinos-vpager-' + name + (t === 30 ? '' : '-' + t) + '.webm';
+ if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', String(t), '-c:v', 'libvpx', '-b:v', '100k', f]);
return f;
};
-const CLIPS = { wide: clip('wide', '320x180'), again: clip('wide', '320x180'), other: clip('wide', '320x180'), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+// (the re-uploaded post's files differ from the reposted one's: one size from
+// one person is taken for one file)
+const CLIPS = { wide: clip('wide', '320x180'), again: clip('wide', '320x180', 25), other: clip('wide', '320x180', 26), tall: clip('tall', '180x320'), last: clip('last', '240x240') };
+// the twice-uploaded clip: its own file (the ones above are all one file, so a
+// size match would take them for one clip too)
+const TWIN = '/tmp/coinos-vpager-twin.webm';
+if (!existsSync(TWIN)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=10', '-t', '20', '-c:v', 'libvpx', '-b:v', '100k', TWIN]);
const pic = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout;
const picWide = pic('640x360'), picTall = pic('360x640');
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
@@ -25,16 +33,16 @@ const now = Math.floor(Date.now() / 1000);
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: (req) => {
const path = new URL(req.url).pathname;
- const m = /^\/(wide|again|other|tall|last)\.webm$/.exec(path);
+ const m = /^\/(wide|again|other|tall|last|twina|twinb)\.webm$/.exec(path);
if (m) {
- const f = Bun.file(CLIPS[m[1]]), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || '');
+ const f = Bun.file(CLIPS[m[1]] || TWIN), range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.get('range') || '');
if (!range) return new Response(f, { headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes' } });
const a = +range[1], b = range[2] ? Math.min(+range[2], f.size - 1) : f.size - 1;
return new Response(f.slice(a, b + 1), { status: 206, headers: { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-range': 'bytes ' + a + '-' + b + '/' + f.size, 'content-length': String(b - a + 1) } });
}
const pm = /^\/pic-(wide|tall)\.png$/.exec(path);
if (pm) return new Response(pm[1] === 'wide' ? picWide : picTall, { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } });
- if (path === '/dead.webm') return new Response('gone', { status: 404 });
+ if (path === '/dead.webm' || path === '/deadpic.png') return new Response('gone', { status: 404 });
if (path === '/still.png') return new Response(png, { headers: { 'content-type': 'image/png' } });
if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
return new Response(html, { headers: { 'content-type': 'text/html' } });
@@ -50,6 +58,12 @@ const seeded = [
post('Transylvania lightning conference 2026 ' + U('again'), U('again'), 200),
post('Transylvania lightning conference 2026 ' + U('other'), U('other'), 300),
post('the last clip ' + U('last'), U('last'), 400),
+ // one person's clip twice, as a note and as a video event, a fresh hash
+ // each (the server stores it again): different words, same bytes
+ finalizeEvent({ kind: 1, created_at: now - 410, tags: [['imeta', 'url ' + U('twina'), 'm video/webm', 'dim 320x180', 'size ' + Bun.file(TWIN).size]], content: '#goodmorning another beat ' + U('twina') }, SK),
+ finalizeEvent({ kind: 22, created_at: now - 420, tags: [['imeta', 'url ' + U('twinb'), 'm video/webm', 'dim 320x180', 'size ' + Bun.file(TWIN).size], ['title', 'Random Beat']], content: 'Random Beat' }, SK),
+ // a picture its host has deleted
+ finalizeEvent({ kind: 1, created_at: now - 125, tags: [], content: 'gone now ' + server.url.origin + '/deadpic.png' }, SK),
// pictures, one wide and one tall, the post saying nothing of their size
finalizeEvent({ kind: 1, created_at: now - 120, tags: [], content: 'a wide view ' + server.url.origin + '/pic-wide.png' }, SK),
finalizeEvent({ kind: 1, created_at: now - 130, tags: [], content: 'a tall view ' + server.url.origin + '/pic-tall.png' }, SK),
@@ -92,7 +106,8 @@ try {
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
- check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 8, null, 20000));
+ // (the feed paints its first rows: seven of the clips' posts are among them)
+ check('the clips are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed video.note-video').length >= 7, null, 20000), String(await page.evaluate(() => document.querySelectorAll('.notes-feed video.note-video').length)));
await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
// a desktop-sized window starts on every shape (justified rows, the tall clip in)
check('a desktop starts on every shape', await waitFor(() => !!document.querySelector('.media-grid.justified') && [...document.querySelectorAll('.media-grid .media-tile')].some((t) => t.getAttribute('data-key').endsWith('/tall.webm')), null, 15000));
@@ -100,12 +115,12 @@ try {
await page.evaluate(() => { localStorage.setItem('coinos-clips-fit', 'fit'); document.querySelector('.media-mode[data-mode="images"]').click(); });
await sleep(300);
await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
- await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length >= 3, null, 15000);
- await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 3, null, 8000);
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length >= 4, null, 15000);
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile').length === 4, null, 8000);
await sleep(1500);
const tiles = await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile').length);
check('a wide screen lays the clips out one to a row, wide', await page.evaluate(() => { const g = document.querySelector('.media-grid'), t = g.querySelector('.media-tile').getBoundingClientRect(); return g.classList.contains('wide') && Math.abs(t.width / t.height - 16 / 9) < 0.05 && t.width > g.getBoundingClientRect().width * 0.95; }));
- check('one tile per clip: the reposted file once, the re-uploaded post once, the other one, none for the deleted one', tiles === 3, tiles + ' tiles');
+ check('one tile per clip: the reposted file once, the re-uploaded post once, the twice-uploaded file once, the other one, none for the deleted one', tiles === 4, tiles + ' tiles: ' + (await page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile')].map((t) => t.getAttribute('data-key').split('/').pop()).join(' '))));
await page.evaluate(() => document.querySelector('.media-grid .media-tile').click());
check('the pager opens', await waitFor(() => !!document.querySelector('.mp-slide.mp-cur video.mp-media'), null, 6000));
check('the clip is drawn no bigger than 1.5× its pixels', await waitFor(() => { const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return v && v.videoWidth && v.getBoundingClientRect().width <= v.videoWidth * 1.5 + 1 && v.style.maxWidth; }, null, 8000),
@@ -134,6 +149,17 @@ try {
const picAr = (name) => page.evaluate((n) => { const t = [...document.querySelectorAll('.media-grid .media-tile')].find((x) => x.getAttribute('data-key').endsWith(n)); if (!t) return null; const r = t.getBoundingClientRect(); return +(r.width / r.height).toFixed(2); }, name);
check('the Images grid lays pictures out in justified rows, each its own shape', await waitFor(() => { const g = document.querySelector('.media-grid.justified'); if (!g) return false; const ar = (n) => { const t = [...g.querySelectorAll('.media-tile')].find((x) => x.getAttribute('data-key').endsWith(n)); if (!t) return 0; const r = t.getBoundingClientRect(); return r.width / r.height; }; return Math.abs(ar('pic-wide.png') - 16 / 9) < 0.05 && Math.abs(ar('pic-tall.png') - 9 / 16) < 0.05; }, null, 10000),
JSON.stringify({ wide: await picAr('pic-wide.png'), tall: await picAr('pic-tall.png') }));
+ // the deleted picture: gone from the grid, and still gone after repaints
+ // (they rewrite each tile's class list)
+ await sleep(1500);
+ await page.evaluate(() => { document.querySelector('.media-mode[data-mode="videos"]').click(); });
+ await sleep(300);
+ await page.evaluate(() => { document.querySelector('.media-mode[data-mode="images"]').click(); });
+ await sleep(1500);
+ check('a picture that won\'t load leaves the grid for good', await page.evaluate(() => {
+ const ts = [...document.querySelectorAll('.media-grid .media-tile')];
+ return ts.length >= 2 && !ts.some((t) => t.getAttribute('data-key').endsWith('deadpic.png') && getComputedStyle(t).display !== 'none');
+ }), await page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile')].map((t) => t.getAttribute('data-key').split('/').pop() + (getComputedStyle(t).display === 'none' ? '(hidden)' : '')).join(' ')));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally {
await browser.close();
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index e3bdb7b..f037275 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -332,16 +332,17 @@ console.log('\n[tending renewals while closed]');
if (ran) (this.state.actions ||= []).push({ id: 'r1', type: 'refresh', step: 'submitted', unlockHash: ran.unlockHash });
};
ArkManager.prototype._driveRefresh = async function (a) { calls.push(['claim', a.id, a.step]); a.step = 'done'; };
- ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max) { calls.push(['ensure', margin, max]); return 1; };
+ ArkManager.prototype.ensureScheduledRefreshes = async function (margin, max, grid) { calls.push(['ensure', margin, max, grid]); return 1; };
try {
const rec = baseRec();
rec.key4 = 'ff'.repeat(32);
+ rec.custody = 'self'; // a renewing tier: the worker covers bare coins
rec.mgr.nextKeyIndex = 5;
rec.keys = { 1: 'aa', 5: 'bb', 6: 'cc' }; // two upcoming indices in the window
rec.mgr.scheduled = [{ unlockHash: 'ran', inputIds: ['v1'], outKeyIndex: 2 }, { unlockHash: 'waiting', inputIds: ['v1'], outKeyIndex: 3 }];
const saved = [];
const n = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async (k, r) => saved.push(k), log: () => {} });
- check('claims what ran, then covers what is bare: orders limited to the key window', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2]]), JSON.stringify(calls));
+ check('claims what ran, then covers what is bare: orders limited to the key window, snapped to the 48-block grid', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 2], ['claim', 'r1', 'submitted'], ['ensure', 36, 2, 48]]), JSON.stringify(calls));
check('one claim and one order counted, the record saved', n === 2 && saved.includes('w1'));
check('the claimed order is gone from the mirror, its claim done', rec.mgr.scheduled.length === 1 && rec.mgr.scheduled[0].unlockHash === 'waiting' && rec.mgr.actions[0].step === 'done');
// a claim left in flight by an earlier wake (out of time, unconfirmed) is driven on by the next
@@ -351,8 +352,20 @@ console.log('\n[tending renewals while closed]');
const n5 = await tendRenewals({ recordsFn: async () => [{ walletKey: 'w5', rec: rec5 }], saveFn: async () => {} });
check('a renewal claim in flight is driven on; finished ones and other actions are not', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['claim', 'r2', 'issued']]) && n5 === 1, JSON.stringify(calls));
+ // the default tier — coinos holds the coins — registers no order at all,
+ // however many keys the window holds (the claim of an order that already
+ // ran still happens: it was priced and promised when registered)
calls.length = 0;
- const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
+ const rec6 = baseRec(); rec6.key4 = 'ff'.repeat(32); rec6.mgr.nextKeyIndex = 5; rec6.keys = { 1: 'aa', 5: 'bb', 6: 'cc' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w6', rec: rec6 }], saveFn: async () => {} });
+ check('custodial (default, or a mirror without the field) → claims only, no orders', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
+ calls.length = 0;
+ const rec7 = baseRec(); rec7.key4 = 'ff'.repeat(32); rec7.custody = 'exit'; rec7.mgr.nextKeyIndex = 5; rec7.keys = { 1: 'aa', 5: 'bb' };
+ await tendRenewals({ recordsFn: async () => [{ walletKey: 'w7', rec: rec7 }], saveFn: async () => {} });
+ check('exit tier → the same backstop as self (depth is the app\'s business)', JSON.stringify(calls) === JSON.stringify([['init', 'mainnet'], ['tip'], ['drive', 0], ['ensure', 36, 1, 48]]), JSON.stringify(calls));
+
+ calls.length = 0;
+ const rec2 = baseRec(); rec2.key4 = 'ff'.repeat(32); rec2.custody = 'self'; rec2.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
await tendRenewals({ recordsFn: async () => [{ walletKey: 'w2', rec: rec2 }], saveFn: async () => {} });
check('no key for an order\'s output → claims still, registers nothing (regtest margin would be 6)', JSON.stringify(calls) === JSON.stringify([['init', 'regtest'], ['tip'], ['drive', 0]]), JSON.stringify(calls));
diff --git a/tools/video-tile-poster-test.js b/tools/video-tile-poster-test.js
index 1f2cb47..48385fe 100644
--- a/tools/video-tile-poster-test.js
+++ b/tools/video-tile-poster-test.js
@@ -20,8 +20,10 @@ const CLIPBYTES = Buffer.from(await Bun.file(MP4).arrayBuffer());
const AUTHOR = (() => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; })();
const now = Math.floor(Date.now() / 1000);
const CLIP = (n) => 'https://media.test/' + n + '.mp4';
+// (each its own size: one person's clips of one size to the byte are taken
+// for one clip uploaded twice)
const post = (n, ago) => finalizeEvent({ kind: 1, created_at: now - ago, content: 'clip ' + n + ' ' + CLIP(n),
- tags: [['imeta', 'url ' + CLIP(n), 'm video/mp4', 'size 900000', 'dim 360x640']] }, AUTHOR.sk);
+ tags: [['imeta', 'url ' + CLIP(n), 'm video/mp4', 'size ' + (900000 + ago), 'dim 360x640']] }, AUTHOR.sk);
const POSTS = [post('a', 100), post('b', 200), post('nopic', 300), post('dead', 400)];
const html = await buildHtml({ minify: true, pwa: false });
diff --git a/tools/zap-feedback-test.js b/tools/zap-feedback-test.js
index adc7663..80ec434 100644
--- a/tools/zap-feedback-test.js
+++ b/tools/zap-feedback-test.js
@@ -124,7 +124,7 @@ try {
check('the same tap plays the supplied MP3 clip', await page.evaluate(() => {
const samples = zapAudio.buffer?.getChannelData(0);
return zapAudio.starts === 1 && zapAudio.context.state === 'running'
- && zapAudio.buffer.duration > 1.2 && zapAudio.buffer.duration < 1.6
+ && zapAudio.buffer.duration > 0.3 && zapAudio.buffer.duration < 0.5
&& samples.some(v => Math.abs(v) > .05)
&& samples.every(v => Number.isFinite(v));
}));
@@ -202,8 +202,9 @@ try {
// The same screen switches the sound off; the strike and tally stay.
await page.mouse.down(); await pause(650); await page.mouse.up(); await pause(100);
check('the held screen offers the sound switch, on by default', await page.$eval('#setup input[type=checkbox]', (e) => e.checked));
- await page.click('#setup input[type=checkbox]');
- await page.click('#setup .btn-primary'); await pause(100);
+ // the toggle re-renders the screen mid-click: dispatch the clicks in the page, where a morph can't detach the node under the pointer
+ await page.$eval('#setup input[type=checkbox]', (e) => e.click()); await pause(150);
+ await page.$eval('#setup .btn-primary', (e) => e.click()); await pause(100);
const starts = await page.evaluate(() => zapAudio.starts);
await page.click('.note-zap'); await pause(100);
check('with the sound off a tap still strikes and counts, silently', await page.evaluate((n, c) => zapAudio.starts === n && test.calls === c + 2 && !!document.querySelector('.zap-fx'), starts, calls));