diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.js | 11 | ||||
| -rw-r--r-- | src/ark/manager.js | 225 | ||||
| -rw-r--r-- | src/ark/validate.js | 8 | ||||
| -rw-r--r-- | src/assets/zap.mp3.base64.txt | 2 | ||||
| -rw-r--r-- | src/features/ark.js | 446 | ||||
| -rw-r--r-- | src/features/messages.js | 43 | ||||
| -rw-r--r-- | src/i18n.js | 26 | ||||
| -rw-r--r-- | src/nwc-bg.js | 5 | ||||
| -rw-r--r-- | src/nwc-respond.js | 12 | ||||
| -rw-r--r-- | src/style.css | 3 |
10 files changed, 589 insertions, 192 deletions
@@ -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; } |
