aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md3
-rw-r--r--build.js34
-rw-r--r--feed/note-page.js12
-rw-r--r--feed/profile-page.js8
-rw-r--r--src/api.js11
-rw-r--r--src/app.js77
-rw-r--r--src/ark/manager.js355
-rw-r--r--src/ark/proto.js4
-rw-r--r--src/ark/validate.js8
-rw-r--r--src/assets/zap.mp3.base64.txt2
-rw-r--r--src/features/ark.js636
-rw-r--r--src/features/gifts-wallet.js4
-rw-r--r--src/features/gifts.js73
-rw-r--r--src/features/messages.js470
-rw-r--r--src/features/names.js106
-rw-r--r--src/features/nostrlogin.js10
-rw-r--r--src/features/nwc.js62
-rw-r--r--src/i18n.js43
-rw-r--r--src/image-thumbnails.js19
-rw-r--r--src/locales/ar.json7
-rw-r--r--src/locales/bn.json7
-rw-r--r--src/locales/de.json7
-rw-r--r--src/locales/es.json7
-rw-r--r--src/locales/fr.json7
-rw-r--r--src/locales/hi.json7
-rw-r--r--src/locales/id.json9
-rw-r--r--src/locales/it.json7
-rw-r--r--src/locales/ja.json7
-rw-r--r--src/locales/ko.json7
-rw-r--r--src/locales/mr.json9
-rw-r--r--src/locales/pt.json7
-rw-r--r--src/locales/ru.json7
-rw-r--r--src/locales/sw.json9
-rw-r--r--src/locales/ta.json9
-rw-r--r--src/locales/te.json9
-rw-r--r--src/locales/tr.json7
-rw-r--r--src/locales/ur.json9
-rw-r--r--src/locales/vi.json9
-rw-r--r--src/locales/zh.json7
-rw-r--r--src/marmot-client.js2
-rw-r--r--src/media-timeline.js6
-rw-r--r--src/nostr-login.js42
-rw-r--r--src/nwc-bg.js66
-rw-r--r--src/nwc-lock.js18
-rw-r--r--src/nwc-respond.js310
-rw-r--r--src/style.css70
-rw-r--r--src/sw-nwc.js194
-rw-r--r--static/og-image.pngbin0 -> 122763 bytes
-rw-r--r--tools/amount-field-unit-test.js16
-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-foreign-test.js102
-rw-r--r--tools/ark/custody-test.js130
-rw-r--r--tools/ark/custody-ui-test.js136
-rw-r--r--tools/ark/offboard-split-test.js128
-rw-r--r--tools/feed-media-cache-test.js4
-rw-r--r--tools/feed-media-views-test.js9
-rw-r--r--tools/feed-video-pager-test.js2
-rw-r--r--tools/fullscreen-notice-test.js2
-rw-r--r--tools/gift-profile-setup-test.js96
-rw-r--r--tools/grid-early-player-test.js14
-rw-r--r--tools/image-thumbnails-test.js19
-rw-r--r--tools/live-stream-test.js7
-rw-r--r--tools/ln-pay-double-wake.test.js74
-rw-r--r--tools/ln-receive-twin-test.js14
-rw-r--r--tools/media-grid-dups-test.js169
-rw-r--r--tools/media-grid-order-test.js128
-rw-r--r--tools/media-moments-feed-test.js12
-rw-r--r--tools/nwc-sw-test.js146
-rw-r--r--tools/pager-caption-test.js4
-rw-r--r--tools/pager-close-test.js2
-rw-r--r--tools/pager-eager-test.js2
-rw-r--r--tools/pager-first-frame-test.js2
-rw-r--r--tools/pager-no-poster-test.js2
-rw-r--r--tools/pager-prime-test.js2
-rw-r--r--tools/pager-rotate-test.js2
-rw-r--r--tools/pager-seek-hide-test.js12
-rw-r--r--tools/pager-tail-test.js2
-rw-r--r--tools/post-edit-test.js1
-rw-r--r--tools/profile-publish-race-test.js95
-rw-r--r--tools/push-resubscribe-test.js103
-rw-r--r--tools/quoted-clip-fullscreen-test.js2
-rw-r--r--tools/receive-batch-fold-test.js20
-rw-r--r--tools/safe-boot-test.js77
-rw-r--r--tools/sw-push-quota.test.js89
-rw-r--r--tools/video-events-test.js2
-rw-r--r--tools/video-tap-test.js40
-rw-r--r--tools/video-tile-poster-test.js40
-rw-r--r--tools/zap-feedback-test.js7
90 files changed, 4009 insertions, 584 deletions
diff --git a/README.md b/README.md
index 4227479..077b71a 100644
--- a/README.md
+++ b/README.md
@@ -225,7 +225,8 @@ bun run build # → dist/
The app is static. The small services that run beside it (Bun) live in
their own repository, **[coinosv3-server](https://github.com/coinos/coinosv3-server)**
-(also at https://git.coinos.io/coinosv3-server.git): the payment-name
+(also at https://git.coinos.io/coinosv3-server.git and on Nostr via
+[gittr](https://gittr.space/npub1w27mc4aa6m0ufe3xs5z3m6qyr52gc0rglept7vqlwx4xeaf72tasnqz70r/coinosv3-server)): the payment-name
registrar (`names.coinos.io`: BIP-353 records, LNURL-pay for `@coinos.io`
names, link previews, translation), the NWC push notifier (`nwcpush`), the
Blossom server for encrypted sync envelopes (`blossom.coinos.io`), the
diff --git a/build.js b/build.js
index 5a279c4..838e891 100644
--- a/build.js
+++ b/build.js
@@ -14,7 +14,7 @@ import { mkdir, readdir } from 'node:fs/promises';
const FAVICON = 'data:image/svg+xml,%3Csvg%20width%3D%2272%22%20height%3D%2272%22%20viewBox%3D%220%200%2072%2072%22%20fill%3D%22none%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%3E%20%3Cpath%20d%3D%22M69.8824%2036C69.8824%2054.7127%2054.7127%2069.8824%2036%2069.8824C17.2873%2069.8824%202.11765%2054.7127%202.11765%2036C2.11765%2017.2873%2017.2873%202.11765%2036%202.11765C54.7127%202.11765%2069.8824%2017.2873%2069.8824%2036Z%22%20fill%3D%22white%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%204.23529C18.4568%204.23529%204.23529%2018.4568%204.23529%2036C4.23529%2053.5432%2018.4568%2067.7647%2036%2067.7647C53.5432%2067.7647%2067.7647%2053.5432%2067.7647%2036C67.7647%2018.4568%2053.5432%204.23529%2036%204.23529ZM0%2036C0%2016.1177%2016.1177%200%2036%200C55.8823%200%2072%2016.1177%2072%2036C72%2055.8823%2055.8823%2072%2036%2072C16.1177%2072%200%2055.8823%200%2036Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36%2058.5882C48.4751%2058.5882%2058.5882%2048.4751%2058.5882%2036C58.5882%2023.5248%2048.4751%2013.4117%2036%2013.4117C23.5249%2013.4117%2013.4118%2023.5248%2013.4118%2036C13.4118%2048.4751%2023.5249%2058.5882%2036%2058.5882ZM36%2054C45.9411%2054%2054%2045.9411%2054%2036C54%2026.0589%2045.9411%2018%2036%2018C26.0589%2018%2018%2026.0589%2018%2036C18%2045.9411%2026.0589%2054%2036%2054Z%22%20fill%3D%22black%22/%3E%20%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M36.0001%2022.8988C36.0001%2022.8988%2036%2022.8988%2036%2022.8988C28.7644%2022.8988%2022.8988%2028.7644%2022.8988%2036C22.8988%2043.2356%2028.7644%2049.1012%2036%2049.1012C36%2049.1012%2036.0001%2049.1012%2036.0001%2049.1012V22.8988Z%22%20fill%3D%22black%22/%3E%20%3C/svg%3E';
// Static assets copied verbatim into dist/ (icons referenced by the manifest).
-const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'PublicSans-OFL.txt'];
+const STATIC = ['icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'icon.svg', 'badge-96.png', 'og-image.png', 'PublicSans.woff2', 'PublicSans-Italic.woff2', 'PublicSans-OFL.txt'];
const MANIFEST = {
id: '/',
@@ -159,6 +159,15 @@ const SW_REGISTER =
`navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` +
`addEventListener('load',function(){navigator.serviceWorker.register('/sw.js',{updateViaCache:'none'}).catch(function(){})})}</script>`;
+// Crash-loop guard. iOS kills a page that dies twice in a row while opening
+// ("A problem repeatedly occurred") and the wallet is locked out with nothing
+// to go on. Count page starts here, before the app runs; the app clears the
+// count once it has lived a while or is left cleanly (hidden / pagehide) and
+// notes the boot phase it reaches. Three starts with no clear = the app
+// opens in safe mode: no automatic connections, a notice naming the last
+// phase, and a way back to a normal boot.
+export const BOOT_GUARD = `<script>try{var _bk='coinos-boot',_bn=+(localStorage.getItem(_bk)||0)+1;localStorage.setItem(_bk,String(_bn));window.__bootPhase=localStorage.getItem('coinos-boot-phase')||'';if(_bn>=3)window.__safeBoot=_bn;}catch(e){}</script>`;
+
// Lazy bundles load as classic scripts into the page's global scope, where
// the main bundle's minified top-level names already live — two Bun outputs
// can mint the same identifier and die with "already been declared". An IIFE
@@ -394,6 +403,26 @@ const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za
// fetch the live site over the top of them.
const NO_SW = !!env('NO_SW');
+// What a link to the site unfurls as (chat apps, social posts, search). The
+// server-rendered note and profile pages swap this block for their own
+// (feed/note-page.js siteTagsOut), so the markers must stay.
+const SHARE_IMAGE = 'https://v3.coinos.io/og-image.png';
+const SHARE_DESC = 'Self-custody Bitcoin wallet in your browser: Lightning, Ark and Nostr, with your own keys.';
+const siteTags = (staging) => `<!--site-tags-->
+<meta name="description" content="${SHARE_DESC}">
+<meta property="og:type" content="website">
+<meta property="og:site_name" content="Coinos">
+<meta property="og:title" content="${staging ? 'Coinos Staging' : 'Coinos'}">
+<meta property="og:description" content="${SHARE_DESC}">
+<meta property="og:image" content="${SHARE_IMAGE}">
+<meta property="og:image:width" content="1200">
+<meta property="og:image:height" content="630">
+<meta property="og:image:alt" content="The coinos logo">
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:image" content="${SHARE_IMAGE}">
+<!--/site-tags-->
+`;
+
async function pageHtml({ css, staging, pwa, scriptHtml }) {
// On https the page asks for its http:// pictures and clips (and ws://
// relays) over https: a post's one http:// clip made Chrome call the whole
@@ -408,12 +437,13 @@ async function pageHtml({ css, staging, pwa, scriptHtml }) {
<meta name="color-scheme" content="light dark">
<title>${staging ? 'Coinos Staging' : 'Coinos'}</title>
<link rel="icon" href="${FAVICON}">
-${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',t==='dark'?'#15171a':'#eef0f3');}catch(e){}</script>
+${siteTags(staging)}${pwa ? PWA_HEAD : ''}<script>try{var t=localStorage.getItem('btc-wallet-theme');if(t!=='dark'&&t!=='light')t=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';document.documentElement.dataset.theme=t;var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',t==='dark'?'#15171a':'#eef0f3');}catch(e){}</script>
<style>${css}</style>
</head>
<body>
<div id="app">${bootShell(await brandSvg(), staging)}</div>
${BOOT_SHELL_SCRIPT}
+${BOOT_GUARD}
${scriptHtml}
${pwa && !NO_SW ? SW_REGISTER + '\n' : ''}</body>
</html>`;
diff --git a/feed/note-page.js b/feed/note-page.js
index 3145f14..89b2f43 100644
--- a/feed/note-page.js
+++ b/feed/note-page.js
@@ -126,6 +126,12 @@ function nameOf(data, pk) {
try { const m = JSON.parse(p.content); return { name: m.display_name || m.name || null, picture: m.picture || null }; } catch { return {}; }
}
+// index.html carries the site's own share tags between these markers; a
+// rendered page drops them for its own (crawlers take the first of a pair).
+// The site's picture stays as the stand-in for a page that has none.
+export const siteTagsOut = (html) => html.replace(/<!--site-tags-->[\s\S]*?<!--\/site-tags-->\n?/, '');
+export const siteImage = (origin) => (origin ? origin + '/og-image.png' : '');
+
// The app's index.html with the thread and its preview tags in the head.
export function renderNotePage(html, data, ref, origin) {
const focus = [data.root, ...data.replies].find((e) => e.id === ref.id) || data.root;
@@ -141,13 +147,13 @@ export function renderNotePage(html, data, ref, origin) {
desc ? `<meta property="og:description" content="${esc(desc)}">` : '',
desc ? `<meta name="description" content="${esc(desc)}">` : '',
origin ? `<meta property="og:url" content="${esc(origin + '/' + (ref.path || ''))}">` : '',
- image ? `<meta property="og:image" content="${esc(image)}">` : '',
- `<meta name="twitter:card" content="${image && image !== who.picture ? 'summary_large_image' : 'summary'}">`,
+ image || siteImage(origin) ? `<meta property="og:image" content="${esc(image || siteImage(origin))}">` : '',
+ `<meta name="twitter:card" content="${!image || image !== who.picture ? 'summary_large_image' : 'summary'}">`,
// the author's face starts downloading with the page, not after the app boots
who.picture && /^https:\/\//.test(who.picture) ? `<link rel="preload" as="image" href="${esc(who.picture)}">` : '',
`<script type="application/json" id="boot-thread">${scriptJson(data)}</script>`,
].filter(Boolean).join('\n');
- let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
+ let out = siteTagsOut(html).replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out;
return out;
}
diff --git a/feed/profile-page.js b/feed/profile-page.js
index a30c2ab..623dc09 100644
--- a/feed/profile-page.js
+++ b/feed/profile-page.js
@@ -11,7 +11,7 @@
// page goes out (and the build carries on into the cache).
import { decode } from 'nostr-tools/nip19';
import { verifyEvent } from 'nostr-tools/pure';
-import { NOTE_RELAYS, esc, scriptJson } from './note-page.js';
+import { NOTE_RELAYS, esc, scriptJson, siteTagsOut, siteImage } from './note-page.js';
export const PROFILE_PATH = /^\/((?:npub1|nprofile1)[a-z0-9]+|[A-Za-z0-9._-]{1,30})\/?$/;
// single-segment app routes and files that are never a person
@@ -186,14 +186,14 @@ export function renderProfilePage(html, data, ref, origin) {
desc ? `<meta property="og:description" content="${esc(desc)}">` : '',
desc ? `<meta name="description" content="${esc(desc)}">` : '',
origin ? `<meta property="og:url" content="${esc(origin + '/' + ref.path)}">` : '',
- picture ? `<meta property="og:image" content="${esc(picture)}">` : '',
- `<meta name="twitter:card" content="summary">`,
+ picture || siteImage(origin) ? `<meta property="og:image" content="${esc(picture || siteImage(origin))}">` : '',
+ `<meta name="twitter:card" content="${picture ? 'summary' : 'summary_large_image'}">`,
// the face and the cover start downloading with the page
picture ? `<link rel="preload" as="image" href="${esc(picture)}">` : '',
banner ? `<link rel="preload" as="image" href="${esc(banner)}">` : '',
`<script type="application/json" id="boot-profile">${scriptJson({ ...data, path: ref.path })}</script>`,
].filter(Boolean).join('\n');
- let out = html.replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
+ let out = siteTagsOut(html).replace(/<title>[^<]*<\/title>/i, `<title>${esc(title)}</title>`);
out = out.includes('</head>') ? out.replace('</head>', head + '\n</head>') : head + out;
return out;
}
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/app.js b/src/app.js
index eb51e2f..6d6933f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -585,6 +585,15 @@ let _publicChatMode = false;
// welcome screen is the one screen that must never be guessed at. The boot
// shell holds the frame for those few ms instead; see the dispatch below.
let _bootDeciding = true;
+// Boot phases for the crash-loop guard (build.js BOOT_GUARD counts page
+// starts): the app notes the phase it reaches, and clears the count once it
+// has lived eight seconds or leaves cleanly. A page iOS kills while opening
+// does neither, so the third start in a row opens in safe mode.
+function bootPhase(p) { try { localStorage.setItem('coinos-boot-phase', p); } catch {} }
+function bootSettled() { try { localStorage.removeItem('coinos-boot'); } catch {} }
+const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot);
+document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') bootSettled(); });
+addEventListener('pagehide', bootSettled);
// Belt and braces: a boot path that returns early (or throws) must not leave
// the welcome screen held back forever. Nothing legitimate is still deciding
// four seconds in — the store wait alone caps at 800ms.
@@ -1200,31 +1209,44 @@ let unit = (() => {
// been fetched — a third position showing blanks is worse than two that work.
function toggleUnit() {
const ring = haveRate() ? ['sats', 'btc', 'fiat'] : ['sats', 'btc'];
+ const was = unit;
unit = ring[(ring.indexOf(unit) + 1) % ring.length];
try {
localStorage.setItem(UNIT_KEY, unit);
} catch {}
+ // An amount already typed into the send form is the same money in the new
+ // unit, not the same digits: 1000 sats does not become 1000 BTC. Whatever
+ // doesn't parse is left as typed.
+ for (const r of (ui.send && ui.send.recipients) || []) {
+ if (!r.amount || !String(r.amount).trim()) continue;
+ const sat = parseField(r.amount, was);
+ if (sat) r.amount = amountField(sat);
+ }
render();
}
// A sats figure as the amount field types it in the current unit — what a
// Max button or a fixed-amount invoice fills in. Fiat is floored to the
// cent so a filled-in maximum never parses back to more than was offered.
-// The figure it filled is remembered with its exact sats: a fiat maximum
+// The figures it filled are remembered with their exact sats: a fiat maximum
// rounded to the cent would otherwise parse back a few sats short and leave
-// them behind. Typing anything else parses as typed.
-let lastFill = null;
+// them behind. Typing anything else parses as typed. Several are kept so a
+// form with more than one amount round-trips each of them.
+const fills = new Map();
function amountField(sat) {
let s;
const r = unit === 'fiat' && rateNow();
if (unit === 'sats') s = String(sat);
else if (r) s = (Math.floor((sat / 1e8) * r * 100) / 100).toFixed(2);
else s = (sat / 1e8).toFixed(8);
- lastFill = { s, unit, sat };
+ fills.delete(unit + '|' + s);
+ fills.set(unit + '|' + s, sat);
+ while (fills.size > 16) fills.delete(fills.keys().next().value);
return s;
}
function parseField(v, u = unit) {
- if (lastFill && lastFill.unit === u && String(v).trim() === lastFill.s) return lastFill.sat;
+ const k = u + '|' + String(v).trim();
+ if (fills.has(k)) return fills.get(k);
return parseAmount(v, u, rateNow());
}
@@ -4188,16 +4210,18 @@ function onboardScreen() {
}
if (o.step === 'spend') {
return page([
- title(t('onbSpendTitle')),
- h('p', { class: 'muted', style: 'margin:0' }, t('onbSpendBody')),
+ title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')),
+ h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')),
featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))),
// Optional means optional: one tap to the wallet, no questions — on
- // Savings, the account that exists without any setup.
+ // Savings, the account that exists without any setup (or, after a
+ // gift, the account the gift arrived in).
h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ const home = o.home || 'savings';
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
- ui.account = 'savings';
- try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {}
+ ui.account = home;
+ try { localStorage.setItem(ACCOUNT_KEY, home); } catch {}
ui.tab = 'receive';
render();
} }, t('onbNotNow')),
@@ -4417,6 +4441,15 @@ function claimMigratedName() {
attempt();
}
+// After a gift is claimed into a fresh wallet (and its seed written down):
+// the wizard's name → look → done steps, the rest of a first run. `home` is
+// the account a skip lands on — the one the gift arrived in.
+function offerProfileSetup({ home = 'savings' } = {}) {
+ if (!canOfferSpending()) return false;
+ ui.onb = { step: 'spend', enterAddr: undefined, fromGift: true, home };
+ return true;
+}
+
function leaveWizard() {
ui.onb = null;
try { localStorage.removeItem(ONB_STEP_KEY); } catch {}
@@ -4478,6 +4511,7 @@ function walletScreen() {
brandHeader(true),
h('div', { class: 'mt16' }, balanceCard()),
ui.offlineFallback && wallet.offline ? offlineBanner() : null,
+ safeBootNotice(),
backupNotice(),
...featureAll('walletNotices'),
tabsBar(),
@@ -4536,6 +4570,24 @@ function backupNotice() {
} }, t('backupReminderGo')));
}
+// Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a
+// row, so this boot made no automatic connections. Say so, name the phase
+// the last boot reached, and offer the way back.
+function safeBootNotice() {
+ if (!safeBoot()) return null;
+ const phase = (typeof window !== 'undefined' && window.__bootPhase) || '?';
+ const report = () => ['Coinos safe mode', 'build ' + (typeof window._v === 'string' ? window._v : '?'),
+ 'starts without a clean boot: ' + window.__safeBoot, 'last phase: ' + phase, navigator.userAgent].join('\n');
+ return h('div', { class: 'notice info col', style: 'margin:12px 0 0;gap:8px' },
+ h('b', {}, t('safeBootTitle')),
+ h('span', { class: 'small' }, t('safeBootBody', { n: window.__safeBoot - 1, phase })),
+ h('div', { class: 'row', style: 'gap:8px' },
+ h('button', { class: 'btn-sm', onClick: () => { bootSettled(); location.reload(); } }, t('safeBootOpen')),
+ h('button', { class: 'btn-sm', onClick: (e) => {
+ navigator.clipboard?.writeText(report()).then(() => { e.target.textContent = t('safeBootCopied'); }).catch(() => {});
+ } }, t('safeBootCopy'))));
+}
+
function offlineBanner() {
return h(
'div',
@@ -6439,6 +6491,7 @@ const ctx = {
|| (wallet.nostrPubkey && wallet.nostrPubkey()) || (activeAccount() || {}).nostrPk || null,
getAccount: () => accountSel(),
setAccount: (a, dir) => setAccountSel(a, dir),
+ offerProfileSetup, pickConfirm,
// Open (or create) a wallet from a mnemonic — used by nostr login.
openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts),
// Profile pictures retain the metadata-scrubbing image uploader. Public
@@ -6824,6 +6877,7 @@ window.addEventListener('load', () => {
}, 1000);
});
loadLocale(getLang()).finally(async () => {
+ bootPhase('boot');
applyBootAutoLogout(); // clear an overdue session before we read it for claim targets
// Public read-only community chat: no wallet, no login — the join material
// ships in the bundle, so the page decrypts client-side (src/public-chat.js).
@@ -6849,6 +6903,7 @@ loadLocale(getLang()).finally(async () => {
// and a wallet opening in fiat should open with a number in it.
refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {});
setInterval(() => { refreshRates().then(() => { if (unit === 'fiat') render(); }).catch(() => {}); }, 10 * 60_000);
+ bootPhase('store');
const storeReady = featureHook('arkStoreReady');
// the IndexedDB caches (profiles, reports, …) open alongside: a wallet's
// caches are read as soon as it opens
@@ -6859,6 +6914,8 @@ loadLocale(getLang()).finally(async () => {
_bootDeciding = false; // from here on the screen is a decision, not a guess
const opened = restoreAccountsState();
if (!opened) render();
+ bootPhase('ui');
+ setTimeout(bootSettled, 8000);
if (WAKE_PAYLOAD != null) {
// Nothing opened — a locked vault, or no wallet on this device. There is
// nothing to answer with, so say so now.
diff --git a/src/ark/manager.js b/src/ark/manager.js
index d5ae2b0..aea6020 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -92,6 +92,16 @@ const actionTs = (a) => Number(String(a.id || '').split('-').pop()) || 0;
// claim recorded before it stamped them — the same amount within minutes).
// Derived at read time, never deleted: a synced copy from another device
// can't bring a twin back.
+// Two receive rows that are one payment delivered as several coins (a gift
+// held in two swept at once, a dust-padded split): the same mailbox message
+// (`batch`), or for rows recorded before the stamp, the same delivery instant.
+// A receive the registrar's receipts tagged ⚡ is its own payment.
+export function sameDelivery(a, b) {
+ return !!a && !!b && a.type === 'receive' && b.type === 'receive'
+ && a.status === 'complete' && b.status === 'complete' && !a.viaLn && !b.viaLn
+ && (a.batch || b.batch ? a.batch === b.batch : !!a.ts && a.ts === b.ts);
+}
+
export function lnFoldedIds(movements) {
const out = new Set();
const recs = (movements || []).filter((m) => m.type === 'receive' && m.status === 'complete' && m.vtxoId);
@@ -119,6 +129,24 @@ export function lnFoldedIds(movements) {
}
if (best) out.add(best.id);
}
+ // A Lightning payment the closed app's worker made is told twice once the
+ // app opens: the worker's own ln-send row (merged in from the mirror) and
+ // the "while closed" note the app adds from the worker's spend log. Per
+ // preimage (or payment hash), keep the row that names its inputs — the
+ // manager's — else the earliest.
+ const sends = (movements || []).filter((m) => m.type === 'ln-send' && m.status === 'complete' && (m.preimage || m.paymentHash));
+ const keyOf = new Map(); // payment hash → the preimage key, where a row knows both
+ for (const s of sends) if (s.preimage && s.paymentHash) keyOf.set(s.paymentHash, 'p:' + s.preimage);
+ const byPay = new Map();
+ for (const s of sends) {
+ const k = s.preimage ? 'p:' + s.preimage : (keyOf.get(s.paymentHash) || 'h:' + s.paymentHash);
+ (byPay.get(k) || byPay.set(k, []).get(k)).push(s);
+ }
+ for (const rows of byPay.values()) {
+ if (rows.length < 2) continue;
+ const keep = rows.find((r) => r.inputIds && r.inputIds.length) || rows.reduce((a, b) => ((a.ts || 0) <= (b.ts || 0) ? a : b));
+ for (const r of rows) if (r !== keep) out.add(r.id);
+ }
return out;
}
@@ -177,9 +205,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 +307,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 +494,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
@@ -462,6 +541,32 @@ export class ArkManager {
// free to spend, renew or exit from THIS device
_free(v, busy = this._remoteBusy()) { return !!v && v.state === 'spendable' && !busy.has(v.id) && this._policyType(v) === 'pubkey'; }
+ // A coin's exit depth: one transaction per hop of its history (the round
+ // tree's branch, then every arkoor transfer). Each off-chain payment adds
+ // one to the change and one to what the recipient gets; only a round
+ // flattens it. The server refuses to cosign a spend of a coin at its
+ // `maxVtxoExitDepth` (100 on coinos: a wallet paying hourly from one change
+ // chain gets there in four days), so such a coin is money only a refresh
+ // can free — and it refreshes without the base fee at `deepWaiverDepth`.
+ exitDepth(v) { try { return this._decoded(v).genesis.length; } catch { return 0; } }
+ _capDepth() { return (this.info && this.info.maxVtxoExitDepth) || 0; }
+ _waiverDepth() { return (this.info && this.info.refreshFees && this.info.refreshFees.deepWaiverDepth) || 0; }
+ _capped(v) { const cap = this._capDepth(); return !!cap && this.exitDepth(v) >= cap; }
+ // The free coins a refresh should flatten now: at the server's waiver depth
+ // (no base fee, so always worth it), or within a few hops of the cap on a
+ // server without one (stuck otherwise). Expired coins too: a round is the
+ // one thing an expired coin can always do, and the server honors it.
+ deepCoins() {
+ const waiver = this._waiverDepth(), cap = this._capDepth();
+ if (!waiver && !cap) return [];
+ const busy = this._remoteBusy();
+ return this.state.vtxos.filter((v) => {
+ if (!this._free(v, busy)) return false;
+ const d = this.exitDepth(v);
+ return (waiver && d >= waiver) || (cap && d >= cap - 8);
+ });
+ }
+
balance() {
const tip = this._tipH || 0;
const busy = this._remoteBusy();
@@ -508,6 +613,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 +679,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 +802,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 });
@@ -653,9 +815,13 @@ export class ArkManager {
// same message concurrently (validation awaits in between), and only the
// copy that actually added the vtxo may record the receive movement.
if (!this._addVtxo(v, v._raw.bytes, 0)) continue;
+ // `batch` names the mailbox message: one payment can arrive as several
+ // coins (a sweep of a gift held in two, a dust-padded split), and the
+ // history folds a message's coins back into one row
this._movement({
type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id,
ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)),
+ ...(m.checkpoint ? { batch: String(m.checkpoint) } : {}),
});
changed = true;
}
@@ -733,8 +899,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 +930,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 +1133,46 @@ 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();
+ // a coin at the server's depth cap can't be an input: the cosign is refused
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];
+ .filter((v) => this._free(v, busy) && !this._expired(v, tip) && !this._capped(v));
+ 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;
}
@@ -986,6 +1181,8 @@ export class ArkManager {
.filter((v) => v.state === 'spendable' && this._expired(v, tip))
.reduce((n, v) => n + v.amountSat, 0);
if (expiredSat) return `insufficient ark balance (${expiredSat} sat expired and unusable)`;
+ const cappedSat = this.state.vtxos.filter((v) => this._free(v) && this._capped(v)).reduce((n, v) => n + v.amountSat, 0);
+ if (cappedSat) return `insufficient ark balance (${cappedSat} sat have a history too long to spend and are waiting to be renewed)`;
// coins a renewal took stay unspendable until its round confirms on-chain
const renewing = lnShortfall(this.state.vtxos, this.state.actions, Infinity).renewingSat;
return renewing
@@ -1313,9 +1510,13 @@ export class ArkManager {
if (expectNet && dec.network && dec.network !== expectNet) throw new Error(`invoice is for ${dec.network}, wallet is on ${expectNet}`);
const amountSat = dec.amountSat ?? userAmountSat;
if (!amountSat || amountSat <= 0) throw new Error('invoice has no amount');
- if (this.state.actions.some((a) =>
- a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed')) {
- throw new Error('this invoice was already paid or is being paid');
+ const prior = this.state.actions.find((a) =>
+ a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step !== 'failed');
+ if (prior) {
+ // two messages, so a caller can tell a settled payment from one still
+ // on its way (an NWC client once read "already paid or is being paid"
+ // as paid, hours before the money moved)
+ throw new Error(prior.step === 'done' ? 'this invoice was already paid' : 'a payment for this invoice is already in progress');
}
const tip = await this.chain.tipHeight();
const routing = routingFeeSat != null ? routingFeeSat : await this.lnRouteFee(invoice, amountSat);
@@ -1326,20 +1527,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;
@@ -1497,9 +1696,16 @@ export class ArkManager {
action.step = 'initiated';
this._save();
} catch (e) {
+ // the HTLC already went to this payment: another context (a second
+ // wake of the phone's worker, seconds apart, over its own copy of the
+ // state) initiated it first. That payment is this one — follow it.
+ // (PullCap 2026-10-24 23:21: a paid send was marked 'revoking' here.)
+ if (e instanceof GrpcError && /state: ln-spent/i.test(e.message)) {
+ action.step = 'initiated';
+ this._save();
// INVALID_ARGUMENT: the server itself deems the invoice unpayable —
// retrying can never succeed, revoke the HTLC instead
- if (e instanceof GrpcError && e.grpcStatus === 3) {
+ } else if (e instanceof GrpcError && e.grpcStatus === 3) {
action.error = e.message;
action.step = 'revoking';
this._save();
@@ -1526,7 +1732,22 @@ export class ArkManager {
this._save();
}
}
- if (action.step === 'revoking') await this._revokeLnPay(action);
+ if (action.step === 'revoking') {
+ // Ask first: a send can land in 'revoking' over a stale copy of the
+ // state while the payment went through (or is still going) — a
+ // settled payment is settled here too, and one in flight waits.
+ const res = await checkLightningPayment(this.arkUrl, hex.decode(action.paymentHash), false).catch(() => null);
+ if (res && res.status === 'success' && res.preimage
+ && hex.encode(sha256(hex.decode(res.preimage))) === action.paymentHash) {
+ this._settleLnPay(action, res.preimage);
+ return;
+ }
+ if (res && res.status === 'pending' && action.htlcExpiry) {
+ const tip = await this.chain.tipHeight().catch(() => 0);
+ if (tip && tip <= action.htlcExpiry) { action.step = 'initiated'; delete action.error; this._save(); return; }
+ }
+ await this._revokeLnPay(action);
+ }
}
_settleLnPay(action, preimageHex) {
@@ -1771,7 +1992,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 +2208,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) {
@@ -2110,7 +2331,10 @@ export class ArkManager {
const entry = table.filter((e) => e.thresholdBlocks <= blocks).pop();
ppmUnits += v.amountSat * (entry?.ppm ?? 0);
}
- return this.info.refreshFees.baseFeeSat + Math.ceil(ppmUnits / 1_000_000);
+ // the server waives the base fee when every input is at its waiver depth
+ const waiver = this._waiverDepth();
+ const base = waiver && inputs.length && inputs.every((v) => this.exitDepth(v) >= waiver) ? 0 : this.info.refreshFees.baseFeeSat;
+ return base + Math.ceil(ppmUnits / 1_000_000);
}
// manual: the user asked for this one (the coins page) — it earns a
@@ -2250,19 +2474,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 +2662,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);
@@ -2464,6 +2697,32 @@ export class ArkManager {
return n;
}
+ // The worker's version of flattening deep coins: it can't sit through a
+ // live round, so each deep coin gets a standing order for two blocks out,
+ // one per coin (an order dies whole when any input is spent — and a coin
+ // that is spent hourly will void it; the next wake registers again, and
+ // the one time the coin rests through the height, the round runs). Only
+ // coins still inside their timelock can be scheduled; an expired deep coin
+ // waits for the app, whose live refresh the server honors.
+ async ensureDeepRefreshes(maxCoins = 2) {
+ const deep = this.deepCoins();
+ if (!deep.length || !maxCoins) return 0;
+ const tip = await this._tipMemo();
+ if (!tip) return 0;
+ const covered = new Set((this.state.scheduled || []).flatMap((s) => s.inputIds));
+ const cap = this.info.maxVtxoAmountSat || Infinity;
+ const coins = deep
+ .filter((v) => !covered.has(v.id) && v.expiryHeight > tip + 2 && v.amountSat <= cap)
+ .sort((a, b) => this.exitDepth(b) - this.exitDepth(a))
+ .slice(0, maxCoins);
+ let n = 0;
+ for (const v of coins) {
+ try { await this.scheduleRefresh([v.id], tip + 2); n++; }
+ catch (e) { console.warn('ark: no deep refresh order for', v.id, '—', e.message); }
+ }
+ return n;
+ }
+
_dropScheduled(entry) {
this.state.scheduled = (this.state.scheduled || []).filter((s) => s !== entry);
this._save();
diff --git a/src/ark/proto.js b/src/ark/proto.js
index eb89d5e..849aaac 100644
--- a/src/ark/proto.js
+++ b/src/ark/proto.js
@@ -416,6 +416,7 @@ export async function getArkInfo(ark) {
if (field === 13) info.minBoardAmountSat = Number(value);
if (field === 15) info.lnReceiveAntiDosRequired = Number(value) !== 0;
if (field === 16) info.mailboxPubkey = hex.encode(value);
+ if (field === 19) info.maxVtxoExitDepth = Number(value); // past this depth a coin can't be spent until refreshed
if (field === 20) info.maxOffboardInputs = Number(value);
if (field === 18) { // FeeSchedule
for (const f of pbFields(value)) {
@@ -445,9 +446,10 @@ export async function getArkInfo(ark) {
info.offboardFees = ob;
}
if (f.field === 3) { // refresh
- const rf = { baseFeeSat: 0, ppmExpiryTable: [] };
+ const rf = { baseFeeSat: 0, ppmExpiryTable: [], deepWaiverDepth: 0 };
for (const g of pbFields(f.value)) {
if (g.field === 1) rf.baseFeeSat = Number(g.value);
+ if (g.field === 3) rf.deepWaiverDepth = Number(g.value); // base fee waived when every input is this deep
if (g.field === 2) {
const e = { thresholdBlocks: 0, ppm: 0 };
for (const h of pbFields(g.value)) {
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 @@
-SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAFhpbmcAAAAPAAAAOQAAIuAADxEREhITExYfHyUlLS0yOztGRk5OU1dXWlpeXmNsbHJyd3d7f3+Dg4iIkJWVmZmdnaGlpampra2wtLS5uby8wMTEyMjNzdHV1dnZ3d3h5eXp6ezs7/Ly9PT39/r8/P7+////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQDgAAAAAAAACLg/oQQbwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//O0xAAcwBJlvmBAAQBk3JLbt/wH8DgAAAAw8PDw8AAAAAAw8PDw8AAAAAAw8PDw8AAAAAAw8PDw8AAAAARh4eHjwAAAEAGHh4eHgAAAAAGHh4ePAP/9+Hh4eAAAAAAYeHh4eAAAAAAYeHh4eAADv2Hh4ePAAAAAEYeHh48AAAAD0pyXfgNmWNGgESJEiVESJEiRIhBQoKCgoJBQUFBQoKCgoJBQUFBQoKCgoJBQUFBQoKCgo4FBQV3hQUowAKqQimoPXYSgFJq9unDgTEcFy4h69VVtuUyf5hBBhgBAFcHofCq5ODzmW6vw3DevrVKOSqsjw7Ork+xZv/u//+/Rv/9XIRmP//+k87f///////////zzrjnjnYQH///+PA+hRYY3QbhYbJeY/9uy0fACCPCgMYCRGL4qbIBTNDCEo8jPoDTjMMCCACeEOnDGluTC//NExNAO2EJRuMmCDag2wYt+MhEJcJEIhAU1irk7DNg0GBjoYUSIHgC2H1eBhgCADRIFBEwJGJHwMUAxxMSSxRe80+oYXVUL4GbAiAEsWKO8uZLKMJ/0FFFWScgtRNb6//MUxOcBYBZdQJiAAucWIPEnQ4D9wt/2//MkxOwESBJeQVgAAJ0qsutE781csWpE8K1zEBlQtPZOuSWo95WI9TU9NulrRujt//M0xP0S8zZSNZsQANLPTPxaUvs2jiKYN5IZf///yJ44Ei9Lr///+hxe+lu4/////hUjD8Mvf+f925El4rizG7n/8827XHEd//O0xOxOvCKbH5rSIEf/+PvG35YY/H/923SXrtb/u3qDd28XIaBONwhN+Tu5K0Ab1PIl+nXPyD///swBDF6tPy2jh6Luu29HE7VX///+cwpefPV5dKuSimkVX7iGlTc2aY/3kYCeDACGFISw1UMDgOBD2dT7fR0sT4fjPPCiNjZM/YE0Sebk946hsLDcUx3h7J46xyeNjxGNTcPY1qk81GxhAG6Z4kjraHuSQTGJxcPeO9xoszSeoY084QBMSTXbZeoymXKd+xjH3HTKYx7+7Yz5fERHvk3Pv//Zve/hj7v//2U9NM3fBxjGzfTGMpjGfvfbqqmH7t73/y9N753vZ///Dz75erEm7WppjvJ7m2aUbu7EAEdqqpWEWHWIX/b5gJvUqgGNXkLdkmcsVSpxq81SwU11JJRclA5UBryvw1F5dTxZ3lD1TSkQCbnBwOGr//OExPQu0+avH89YAWNdhTQHOk0nmuwRDrS4StIeoqIkCjU+qL7jOSuRYrtA062nvR/eR1i3kRGWCDZUkNdQmY0QizIeoTAQTaPhxeEzQptL45uZAEJfBq1wogbjs5heFyRI6jIPsjjdE+Jqdw7zGXYrji1lAuXxO1xAJYxzEEfKgsZuLpGqpVnAkkq9VZDMgbyVrm2VFG0HUYvf/9/lJJpkkXVliiuUutFy7MzSzUC0ZTU9+lpf2rieTZlDUGTX//OkxOtDtB6zHsPTjK6BfUE5o0Cjj5GJUYOrGA8q8Nh8qIzChIdQticqiFKBXo1EsniKiJf9/9bAF20Ps1DcBkKq4O0vigE5P86wgx+H4ETijrWHO8tDcgTxwUiY8Ekd7R5N1zZBt1JcwgiaqSV0CMfxvNDogD5KNESoim5BiETCo2aVExVpDuJ5+TdlFFKFLNFzz60LdLNZMmNWr56KO3rNSX1as1jSZJqfcavXLmUZH9auXq7EoYk1dtf//819zsj49v/39ff3H///W29v3HUxX38NiKZLpttXTGOp6NptegERR1V1WVVjTff7+7SVyuJJIJlh5CJHVYZJ1vgFQOYmI1hIRyioFiUyw9mDcw3icMo014SQLk/0LPEn5hHU//OExO8tDALS/09YAXwapB2Mn5pGQwMKkIUmSdn8h7Kl2RJzrTIyIs7EMOgt50GUWE8lSYx4oY2pdSsyw5qBvSsdOPlU0H+oS/oYr2ZQSuCnhQ5meK5MkKI5vUMeKhHv1epFlankcmSKwMLM4qtghdneqZlbFSj1HBcorFBwubYfLFMxX7Jlsh7on3z6BuktlerIbp28tFjws/cBziNKgV8OM63ef7ppqmfrKrhO4kXEu///l7PAs9jx30XUO0d5//OkxO1DTCbC/5h4AX1//5GzcdFszIqHrnPGpjf//zfor0QxTp5C+oXLrzQAKmZoqXh5LbXZ23KezGGU1crMcWzZWsyglN9gTeJgFqw+On+05j+GZzMgCwNsaDCQc2qsMvvTE1MQSIBElxccLDChwUvMmEDHkUIhRGq0VjLKAEgOdUgDDzPRFolb1giZRs1QeHAhItWnKYIZeMrDQhDD4FAyqDgBHWmBSS0hQiXrTXVoLtpIKwF+ihUIgL7omMwTyQwbAqo+604ykGj4wxD5WxTBLRe5ZhKhGpNxZBftGdFNQ1KtS5H9H53E41DKVXDsM1Ug5kkUgu5bCTTOlTJNrPi7kwAxRuz2N+uSu+cwtNyF3xWFrUh9T65HPeaaUDh+//PExPJhvDKS85vIALrWY80NHN95hFqXS4uXL7zoSF+1EkyJEnSierx5otebC7GdlW+86LA3cYeyJrCzqTl66yWr1eMDyxaK71uNhXuprXoae0ze1f+SyJ0FMqLqykI1B4ba2mlA1l6HsvR/H/9qsQKgSzoSvBDgmooOxSPv+kjlGkvZWw6m5/WYrcJQIPpUnkHiQR7kPEOlMl9cnE/Kyas3ltuu5qqs8LM0cbTupGf0vG/Bb9piQY6FriPaPitTJCqFF8RET0FbEMCEPVCS47h9BHy6kBLogTJ0LeVxelACcFhJyqhQga4FApB3JZXOc5jC0AKI9BCAjJyHgwJovCgUB3FyJIPQiFGY5Xmg1IYq4EBTqVheqNM9PplfQxzUrD4DVOi1WqT9XKgQh6olJEfWcle+VbI5MTehr1geKRPrKzH7C/c47pXv6qxie6RqHv2NEPoemlpdKhUqvNmyMxUbJ3jAyK5siO96mjySdsjN7HGcYcd5Fu/vfPiRdSwW//OkxN5FLCq6fZh4AQeUhxtUxNPf6cJI+JH8Cj+2G3d4Pd4/+oFob3tr2DFq6nq9255r/lYVjBar1zdu3C8aWC1uqSqIe2ZWh0/RBylagZA6rAVFFoPwT+USW7cbNV0TmM3s19O4cVHGhECNALuc1rEHj7sfzUl1l1kXkckLJ9RUFVccpKreZ9/ez8/Pz8e1d/XFfVxdJ1XL+NLREt7cOJvRtVYq1f67Vsqhwkpx4i8aYAri+RsKFFj1LeaUZA92m2Z3iF/hK0BoQSFYuxkQwjaFThNSPyXWjFcmVEjKw+oDg8dA8lZJLcadBjjO7NrE8sPOFMwvDuwmQUoLBFR8E0IkUSTM6DnB1DkRtcu2dkbaCkQ9KkfdXLY6p9p/9Ur6//N0xNweugrPFc9AAC0udWNmz/6sqMzkItSKuzrIi6nLQyv+31NyKxlYR6ipWazqEZV3eVllx09RXzxhB9IDY51ljIKhLkKTJENVyLuP55FPDWGnS66ETu2w8oDgGTLZAZyzuWMk+mKW32BGde1E2P6fTPlWFyQ6WbPTbSp2VGqzNDKUxYW20SDTpKoEuXhUu4wKpjNPJ6RA0gUC4MEgqeEtsuR/ytQ3//NkxPwhVBbTFHpEsBY9DSrIiWhot0kiDbmGoYoI0aly6rchVM+Dqo+Qauayk9HWoKD6mmJBrLGWmDLFQR3VpRnBvDYJofEUGIpmRkBtclnIahsuTbdE01223pgtWttm1QqmUm6Bnb0UmRbtGpq383pRUaejzVV2Bsog1/I06EZG3+nqEI7Kd2CMcgMeQ8XV//NUxPkeSfbTFHmFDAhGOHDAdZVmWNfZEiRfKw6wgZqQDkS+yf2iGjvJyHITAuNFIyzw3ctplzIBwP5G6KsizUOSg1Dj7sxDxyJ5/NzI6mtxvv5T/ovtULXV77V9aXn+/pSXcfPx8fPcff1Xf3/o3Pz/HfE8z/3X//NkxOohHBLjHnpE4Vdt9/UdzHM1EWozm6buklJ/49/93aGvrgftfdRtadX3zGSj2QWyfXOIyRLIidsqlAouNKktwm6tBPVqicpgIW8SoQCPej2AALZf9eqw6w0OO2lWo8wRdrS5idXc1Zf9VSlkxEWTvVHWXxSFwC9DQHflq/n864EsaBOwukZXUiUOOXFZ//N0xOgiY/7S/U9AAXyaanInG5Y3kxi7dFflEqr5T0Vpn8dLJ28aKenKaUUNiIV4FvyCtM2qa1GLU7Is41IbERn4blT+VL0nlupurPSypS279z+W5PdjligryuV4x6puxuWfUzp6/3K0/Say1Vl16UTNatOU9e5K888f+7TSuRS2xN2rdFjfu//fwmLNHS2r1Nev0eXzMx93OY7Zu3YrNyt/Kfn/hSc+//OkxPlFZDK+d5jAAG+TUQnstdxu2tUvIp//cnf+h+5GrH//67rG5GcVVlRpVIRmmGRDkiTKCaSLjQZjQgKqdEFCWsRnaYCAcsiYQqUVGlMACLuoQTt4cxdNAa5/wjgLuegRGVgwww6xlIQRMoND2c1EgeCqPhNMKrQMyCcycPVRBLeWxCEeuFyW86FWXtdMandK96/UjZCZGdtTM92eKaavZW9zRLxrWIMOC5zOT5SJtugKZUMrg3u4l8Q1ty2wLETTt5Hhud6RlbDeR3N4q2ueH37Otu1XDqtxfDvBTl3m/Tbm1x3F2yUgXiPMfz6WYTyLAb3OJGh03/FxGhx40k7Pv4v/5WCPN////1Vj+77/COPFfm//+/8zV/a5v9nP//OUxPY+fAbHH5h4AKh61yCmVVVphE7hInIQtdCxWLU6stvrd7m301abWzWgWs9MSrPDOTmdsmeNxuPnXoN2MJIVEo4eWiHml3J0QyiHz2Luac5phpSTY920xzITRdU042qfqYyMYmk481TjVoh7I6qfc3tqfV6vRqOaa//U1EWh/pmWM3zlNM33q//vZ6t18///lQeHvQyYC8RAGFqnVVlZlUqWgVwR8UI0LrLJHhDn9gPV9v5qUJr51W83BrUKupBYgq1UiFrXcItSgwB0qTkOL4M7TMlzUjWkwJZK7Z8qMMurnEq5nb7N2Te7XK0b//N0xN8jNBbXF9g4AHpO3K36Eb6aPv/77vf7M++2Tbf1f/2r////9pHneDcLZYNIeFVPVhlZkCgM1BTEIaAVVunJ+ZEA/xDpjABevFKRzyDFYUqCs5KHEyyssbhV4ZknRMUwBUFVniasjjeeoPSopWdD8zkMwcpVaWU+9G9HcFqiMZyJYsvq0nz//yZMOWqGZ1OdnZGVXQru0quCOsb1m3/iX+MCwQKk//NkxO0chA7bFMDFEQYY2jl3scRB+o+ogO4sDGY6alMF4/UeHiLnbSkbBkKV82lpBXbk/f6671hyICdx9FMMKRBJgxOKJhZanXmrKXC9HUyRNelczItDgR6FNEKtEz9IcgMwwSN0u/slDJQouOd2HrUv2X8zzKNGCY4KGjzPsLj6OQgeIKB2RESj3iU3//Br//NUxP4fQybXFHmE7HB1QNGAYu5nVhlThmjIeOx3LFj19DsiQy4YunsLhCZD2Me6nOBmYM2HJ9VoKWgcvjIGH7CPesrdDW6u7+6qbnrrm7j3u2iKuYmU94FqYqmelMuFv9Nxx+1UNupmkaT6S7/mOEmf///6y8aP//NkxOwhswLSXHjLGKit02pSe09PeomRj3jtbouK0T//7FPqoIp+b5v+4SLJHno9W40fYcYsI5lHJBJ4hhQdhmdVhVaEhldVrtijVkYVSLSAcrukwVMS/A0MFDSvbukOw0wJa1JlK1lRFDlAMaZUtBAWtGLuQj0qVgwFJBL0RRXMCOXFmnIeogM6V20F1tvx//N0xOglvBbWd09AACdzHFizrRiD5LF4TSP04E5PRjCNwBSu5ucizkNleqOvDJnLicql0sxg2ITuUrpMqtqNO5BNeHIftQ7efafgS9F4pRRaBIrQ37Utjd2WXZu67TtQLHJbOvA/s//wqlrVdx6rczrfuMX9Qrkxf/eP15XDEMu9IYJhh37UVkTzUMQz7+qm8pq7clVDNXKv/+ec/KIDjcsh+VxihvVv//OkxOxJFC7LH5jAAP1Y+68MWk0fi11/mdMDaxFV7M1bi0pst7/5/4/r8d40MczoZqX1ZRW/////Dn//KGNdoYlFYZtWFc5DImVoAA4NRlAe1nBMJpjhVNSUT2Z4q5MqY7TG3mB16KPCQYcBHjXiuLlOBJxagq5HYSWr3Ldr5aNGOo1JknZkuW46w9xroV5sUcpKFx9HrputKL8///ij+KS0EhzCosZnhY1xJ1FhqMmUSlG+Zfh5/+r0KX/dFi9VKUzmHXYwcxso1VHlkXljZa4pJEsaeTEsHWHcpWqDvUezyhuUVo5YqtAkFQMdWASCAtXKjmd0mchPGuXEsrTN3c0zwrYj4MvJ7K5n1VyXstViqLM8HldQe+VY7LJHOtrS//N0xNohtArKV88oAZWdmTepv/p5kkypahXQdiO5TurSrf7urN//JvXX659Z0UyzzkJI6sA0JA05JcWqlXNYrteiTgSTUK4oV78Vt52099fDla9EYg+i6aeq+Wf3u5XrEf/s3hun1yQasCZNKT+OGEsFCPZv5GgkJ6Qj60+Znn2yQ0zK7R0mMUHw74eq81qWo5mSwsEUer//2o1KSzXBOo+Kc853qavX//NkxO4gS97a/MGEvf/96bbeqPY98yrOyMVGUjlQjAkUiFHpKRLEUzSOxONLchpL3oMPn2WhNYcjATpMGtwOxWHc7stx9ltvJNxqDc4O6OezWIrCM+LRFdSOdJyMRVELGW+6zra6EejTttO7s9nUshpDIR52uzO1tv6ZM8p5Xey2KQSXMqzqipRH9fp//X////NkxO8hM/re/sDFOW33s8yTIxkMFqJlcfBWefs1rbJEktiWHwHQxrB8Yiki3lYzqai+pMLqPazhhji9gxRixgTEAKFCEFNWGN5h2KEZ+ckqmtcihvN/y3LeS2nV1TZ/redv31lOkf9dvY0Ov929NGzWNI8mtC0Euoqz+v///v///XTfHq1Wg9Dhm/Mv1ZX///NkxO0eO9be/MJEPUmrscbbYgZCEcE5qg3kZHGjTCXiYZ/MUtoSb/m8usQol2/yq4c0TUlBkBs5TQdy5MSiiEV3Rod3Z4K7YRI9SXdwTsp0BTJkzTPV2Q6GaQ82dJqP/TtXNn+8M7mGYEORKv9MnsSp//7fav0y7So8TKNBnqRQbS9kGxXSLazRxpLYnCAG//NUxPcck87eXHjFHdJTZRxdnJrDi7ioiC8NO78xvhxuv7lY5qRZ9ws9uvYWRDHCoOwEensz3UeY3fnVdA0O8ORdc1GWuQLKXOGt3hJcdAZlHzI4XYSeNl8+vwnvp1ck4dz0VLEN6r/ttXv6b7/7Otkolk3S015D//NkxO8edAreXovEHZbrkzVM5GUolbNTRfpJ8sxIk2guKEZxGF6ca3G1bpXF4/IQakxuynKx8z+NTPfx72vTA7yF3u6AmgiwdQ0gJEWuCI5HKdkYtkvsrMx3o1NdNNapVqOq0eiXff71fazoahsttcz///19bgj0z7bbtbLV6Kq65LncwtlG3otdndGi2x4q//NkxPgfZBLWXHjFPIRUZ9vW0lIiCYjKD7QNjAVUUkOappvjsT1tOCM9LTVGYHDSM4atWVLXANbjGU3qWUR9LDlQXg5qTDnkTFgjoMtj28bj8fvmIo42hqDOhyjMQmUTgj1sCmlgYdGRskOMBoreVnP+7tUGA4h6BfXGJW4xtasfHTyHwiBxCEb5zbXSf3Gg//NUxP0eTArS9MMEXWzmVRvLSLBO0HKweSNDuQBG1hLdO05qAalUFYipwxInUGMMeuNZBE6njhKw9BErIKzDkCkdqiOUph9jUdFZFYytNVlZEtSqsowx7863dWbvYy3UrLZLCSd+3//6T5MilIvtT/vfXf10betV//NkxO4e8gLO/npGzMiJVkpUrUeN59bL5tJHE/icYofNbmGldofZlASPo2jIM8gmbKMYbu07GkdyCfLeeq3aa0/KkhTGCOzWEogyxkQpAQlIiCKdqPchZEuqsq2K7L2Z0RUu25KJTe9Vbu2VWbW7vKXEo//0/9ULvUq1a/o+/ps9LN6tt6OxZ1ZxrFoLKGlG//NkxPUevA7OVsJKOZ3VZFRImrdabjANYYgqbuYN3qGxcqOSsi7Xzq8kfDc6xCxZdxYcPWYddOHyN0TAQrVhxhRiw6DljtqSLyQF4dd1gtIJKBSRTdtSgfwiaeU3LO1iiWLsTuer0vY3apHXR0I9s2P////7b5q3TSqbZ8ezrc5B/2FunB3fEwFxMbE5pYZC//NUxP0e4+7KXHsEXWOXNxNSIgxeJFUacU83CXZQV+Mouy2ENIZuHYoNl4cJTy6n3Qvsb2Rfjv/hlYaexdkj0UfV1EHYLHRuXNh6j1ZbRqYqUyIdmSwrM8zERqsr3M53vdhyy7WWJQ5H/7TjgavVdct6b/UxzXQo//NkxOwfo1rC/njFOS7AkWLQMYMmAuG1tVKG30bcdkQZM6Il7VOcGyaxeP8LpyAHqxCqIxNFY9U2IiBu7jnvA9va2m7trnejRjxK4+g7Fo1IweYLKpHYYlbBlSSDkTGiyiJKrRLtFjE701ojNXR6yq8imOiFOSqXUp9f//9FeaqzGel/t/L96N9+iFe5Akcg//NkxPAd4i66/sGKmJKqqxFKZRIOOpiI1q0urbTjkQJD2wGYCP0iko5j81y2KRVm9BgNLUu4k8kR9EnMELG3OTbhLEGrieX8aPt6g/m5Uzk/k2bSBh2NeBJe/JSwG4Y2oddrZ1Cta7ylRabmfI7as0rIj90eYrojGXRBxsyv0//q+61dyqrXZC1/75v8ys63//NkxPsgw/q2/sJKXUm7apZamkq7GdjxiXWVRnBa2rjkcsRTZ9EghpU6zNlulJft94jStNdVqrLr0y8l44IcqG3watgLcJV3cnDCVcDTNN9T/hGj8YCZdV5soP4XO9u0HUQWdEH6GcrPNeWbaV8VlKh/5Xc+ZEdUdbF6M5st////9qsm5vpt+n/Vm1L6Pmsr//NkxPshVAamXnoE+Vms7HpMgMteSjaASddK5Nf7Kfdgdy4ToIdD0LFh/QyztBlG2pieRxyUhUSWJJs1e0ORL4ezRs9Tlr+a5XZnsCbo7DOyfZblb/P/5hsAedmdLs+zKfDtltI+kQVFIqGZiYqvCQ+tHM0UbW/qw4QBn/foHQOQPmB8dxyGL0UERcBEAgGg//NkxPgfXAKi/sGE2drDQncsyRNmxYlVtwXk0kkt/lyMVgVMkxTvaxakXd337sUcslmMZaB5oFhssYQC0Dr8ukd8QoxE6MYSlBjaDo1v4n3Wuv0Hhl4IjtlPUrMBVGMdGRyGaUqro5k2a1WlYx2V2dpqsu1qmZQbQbXUx6m////+qM3v5nKVqde82tNGRWLR//NkxP0hCm6i/nmG3E7EAtEZguZRKziTsCpDgFn/247triIVVYdLG0qhB78hQf+3kakS83OgRs7QFYKUxeuBRtPh1uWVS6MTNid1Jys5tc3GBmf/M73JlcUr3jHZLDFZtqO52ULRs7S6omivsVrMj7JpPsizbq7F2qX///fSRTslTI+7N/QoMl6G20+XZDOA//NkxPshK/qaXsGEvKq+fnFexrdtaro3At9SU5/ZWhWSqVAa04EiFgNujNlpVwgZAMVoVfu40SQIkSJEgh62UGkr8KxqS2eG/ao5CghFAauSR0aCoomXbCpHI5+xjKqSldNkm3s5mOz5FdDGQNHRTGmK5hQUWu12Qox4Z////qio+r0dE0N/dZktppovXXRv//NkxPkf466O3sGEvUXo92dFnJFZNS6BayiSpf/a810YKt+lGTo+tJRly6lariFoIFcgqkS1gaImi9xoZh2qpWQ9U9kdhpF4x4Lo4b2NmBb54O5HIMhBq6/1Iy5GRcPBzL2JboY+9S0sb1U7fDXLQz1FRDcfKmreqj2GXYx1FQwhP1VrfA1rGg2KHrWVVPjl//NkxPwgfAZ+PsJEfcfoY0CGUNVJPQoCrMJigVXEgp7/a4fB0EfkuoW6DJRluAvRXBB3U0kWK53EWma5orwfJNjkfIbnewqPGupiWwdMztTvQ1iP/yX7EeIGab2rPX5vZUyp/WTmGh6BmbPk/Uvk6/sXyVWFF9s+3z35R1IhJyk//XnUKtl6eQNaJtyWeqtJ//NkxP0hql52XsMQbAWhe9cKAOMgAp7gAbyIFI1YIICIUCLn5ZCw61CgSaiilTwqhogtkk7XEBd+MON7DS+uskD2GG3A+ivOaSv4YzgfYhBpZvcd/jJx9b6jvqFE867QlxRvZ2TTz8SzQP4wS6kCgAQyoFW/gATgJwXDMBuB8BXDgi0A7OSUM+M+SdqusHob//NUxPkeekpuXnmHGGuWBuP8MZGtcGIkxpuJzp+eJ4XlQs1K06zVma/yyNMh0iIFjhuezHhl81KHK/zXtMC5H+ZlbhNkw7/pllWHARqGJX9r0VKBIW7l5E122AYXkXzl0RhLVHRypOV7Q3vSxMENSxK3oLWPJm2x//NUxOoXMf5mXsMQcBtSMnWVI26nrXm05t/O/qaIgldS/F9rpexNiKnR6vgwXuhbxJiiAYAEJNLf6rkJVW7TbDipjqz/fEQI3VYrt+YCdnpcErThUQzdzWOTZlfR81IubyyiKW6NOUiSmZO/F79+0wCNmEaGyjJE//NExPgXobpm/oPWHRQroj30Zwiz8jrFajOA3IYnv+ykYSw+mEAxJbB1sd6oHFZ5+5fQv/qzJsNqirW7XIhucq6czvnU0s1ep6cutEU5o3CvqWyG7ua2rQgHIMGslksb//NExOwU6ZJmWMMWeAvKrPV+t3pGWrt2Jq+D3YbLuKsHllUY5xkA0qys/t1tIUdSFvfoyDFe4P8eT5PV5mT2zBprxWcXGUJjimGgNWEclHoS1QqqV6VBuJBFWxVz9NP2//NExOsUEYpe2MLGvb1LA5ohX24EsgdxtxCZHUzD9+Eu3Tq2MqWyLCNuc7x1JFCVKhwtypXMrdddawuelhHAXTFNNhS9Zpd6abBLgXKRWMDIEMvQewUt++AAzUfRXLlb//NExO0UsYpeWMMW6AYxGU6e6uMjv4tRBr5LAaGmJjWpE0m3Yw5oZiWpFkxSgjCTCugEKi1MQU1FMy4xMDBVVUxBTUUzLjEwMFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NExO0R6SpeWMPQcVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//M0xPgReSZaeHsHCFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//M0xO0KgMpWWHsQKFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//MUxP0CKDY5QAhEBVVVVVVVVVVVVVVV//MUxPQAAAP8AAAAAFVVVVVVVVVVVVVV \ No newline at end of file
+SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAEluZm8AAAAPAAAAEQAADYAAHBwcHBwqKioqKio4ODg4ODhHR0dHR0dVVVVVVVVjY2NjY2NxcXFxcXGAgICAgICOjo6OjpycnJycnKqqqqqqqri4uLi4uMfHx8fHx9XV1dXV1ePj4+Pj4/Hx8fHx8f//////AAAAAExhdmM1OC4xMwAAAAAAAAAAAAAAACQDMAAAAAAAAA2An17QwwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//OExAAk7CHMAVoYAQMTFBxqHhyqB0JBulRlgi9H0THXW77/y+3nnTxiMRiMRikpOZgAgvQAEEiIiF10RERC/3d3fqbu7+jnp7uLAAAQiIiBAMDA3P/RE///c///////9/4ibgAhEREL/d//67u7uiIhf/7v6I7u/p/EREREd3c9C0RERP3d3/ocAAABELrgAAiIiBAADAwMDAwMWcDi3Ho72l3+P1gYaA5/MCltyYAttOwALBcuvBHVIRctmHBM//OExB8sazYMAZyYAIgeNSIk0T4HMo9EEY4ms4bl08SA0yfFhWzqNDNNSkRZY2iSTHGaMmu5oSaTpoBkcW9MLgximCloPemyOyhOZfPixkAPGj2st6LJ6k1JrZMwLjFxl3ZN0EK9a6mpVoLqrdBRPmykTyi4YKr2st913ehs6lLSouhNEGqWm6aVWicgIWRKi4sCA51j9RWzLxpBo0YzRSwwKFaJDm8z27HWTGAEAT8lkk5lruK4Ql9kAYCA0fzM//OExCAwKwJiX5iQAGMxoOXOukDRAkYh50GkA3MFlCkxOZoRWXxOp4lxfCcx2kMICLMFGEoCE4WFjLDvQJobA7xjhcY7hlxhkoGXCkOaHvj2gNkvHySYsMVigYGImwlRjAxkVTxBEFlA3KpaMDM0IoT0xMymfQMDRPlwpERL5r5TMThuv/Lg7zA8mbpl/0CJl8rk4s6j6RfPIo1pJ//8jCobnEK2TWyb83QLyrDaGYgJQUGOhEIC7Q4w0agUhGAk//OExBIrgnZQAZh4AKqYVKLGKhkuBAcqAFyXBIhDQfq4CNg3DCXLYWgZpPXBTF0JeSkh6EEvGAcg90EjHQlD3ItEq06y9nExqZmPtjV6BSLO2l7gl+iM6rU65c1hkjTzuKjiKym4DxWvIbRPvcPcjf9//MC1qZ1J8Q64gxfNr0+sJ5rtj3b4lf1fHZX0LGKP6bb36H+aFEu8fB41UA8sRKNQA9IADR+cmQCQBIINOPVvwFyKQY/IREMIW/jbigXD//OExBctqwqBlZh4AVVi67Cn1e5apfOLkMmYHilLyqhhhpnI4XBWSK00Y5hj1osbRwM50WPNCVCu3CoAHHmpQLbk1KhVRYLkqFw4zP4JBz7gnOzxlJNlkl8NngvYazC0/OPSFs7Iq5bXy/nxqO4Wfxozj4M07Ir7RGeSJWn+YjPHzu95v/8R8WrSFK+m36trM8syd5nU18U/z/nf/g0mg1//1jX/8O3nibsIST0CARSgBEx3Co4v0j2CwixY2ieQ//OExBMtAs6ArZh4AIi1wjM3dOkEiacgjTEK9LC2ogfQmxYC5kMEDO8/y9tDDCTIhp8HMK6ErIDd+QVnJUhI/VwLIGm5BoroWBKk9UCNQkq0NUOWFQhfIeWxZVStORYWW+IqVe2Kpc6abxlb1fO4TvmuDKv3zmmXyubdQGGs2WtsgeJFhbzjO/psZID/cLEHV//jevrM0bH9sVzN7Y36/ftSn//+v80l0haq3SIBHZl8kBid4i0YXB3GDQGkebzC//OExBIrOmJ8AZh4AJkDgbd0nwAkTIEOQ/QFcnhZCsIUJMDeL65HOLODTbxmuRbRNWQ8SUCADCMQW8UhPGqVySLiW0mw9RAhYSTrRjpwyB9IlhcFhEEtHpMFRGkrmqI5uKvvDaVWztisYW+71hj7g00yQnl4qvmla7S2kup86ta0NlbmyA3yRK/1jyYi5rAbN/ULUS/1jPv9wZYJsgBJsQX4IwGTZxnSkAGae1ADXgsEqHQYRtLhqaK2s9VpVqQQ//OExBgtoxaML5h4ASHJtw46sDsuOS4yS2H8oSxsA+DELiyqY0VpWK5pTZoLDYlE8iEcfqsN9I2cFYhhlrKcblC3RVcxrK+4H9EWzHOuO9V6rdtsCsK9odnkWW0aA4PI2KM72SDJmNmkWA16YoO3LwoMskPcCup4DljcVrmfYhVs9jfzTP3mr/DVFzLeAxv6X+NZt95cZNQtxv661n/Ns3f0f6xr/f//n8RVSgrw1FhYZaRgQk+hxTOj7b0A0qDi//OExBQnOuKAB9h4AENfg0iNk0Ww4c42R+aVTfIoVdiK2J9sJ04J+I2PLFz5hHAnT8aiGsphxEUS3JyRjQczB2woCExXeNLe5Q3l5Iq4ZdqvC4nbaYWKqfWY18YZNyzy33G083LPnPj+2v/XMsXGcX+f/nXl9t/eqWl/j/Hx//8b1/////T/vNwcf/NLRrnjJpZZGVRRCeDNWQhPLA1NxWUkSpKPCqG4rOBCBOFDuiVEExoPSNkSU0moWGU4d0c9//OExConctJ4BsPK/FWuRDmgSFFFG1LqOxGg6LM2k2JWUZ8Utq24EQoEC/MpqTNz0WXNI6MKdEs11DKibHZAfvISHyLtYgP5lBpfZ96wxwHsKKtUZdTyw5+5dhz9XuQJOFlJ4lOlUEEI8c4sUrqb+urU/xV0iSHtjBjwm9qqk5KlBfCzUtBwNswBvouIXPtA0kB3o6I4sVUVkcNMclBIKo0Z5KFgtJNzsZytN5I24Ubp2IizfFlfWEz9Gx221x51//OExD8lojp4BsMNaJFi1EZI/cwBUqOJhwToycrVYXYjsN1hhg9TQfaFiEeVzxtAr1PVkm3gfTE4HYHYY0DjK1LcV/o2IYrxLNN7q3vleEdVyJYtKLeB0SiAIMCYXNEw+xH9wCUF4J42BPTjA2jcCq5oTxPBIAjrxiKK61FV+cbDTsHeRjTmSRpslvFYn6sjsZO4qsUR+iWuQBVEwR2RmmYEZiD/ZUyqVUQ5YTjExITYx3RSTnLU+U+3FwiqOiIa//OExFsm8mZwBsPK/CRX9D8IgpsoKzk5oZXKEuBwukjK/jyLez+kZrtU3cKNus3GgxB8SjHRGI6ucQyo0T/5xJjBVlyDzCIAHg2WkP+hBWCkG4g6DLSF8hJXQAypakRA2nidhKNrqLEOq6hkUDifkJDjzbjlRzZMih5MSGK8ZCEEHQ5VhIT0HFEnDobhfq4mpzCQtZ7mwzrOmM8F2gnRTOx8PFK4sZbVCeNzsULIgrpC5dp13GuVMRNyRUrIj1Bs//OExHInYhJsBsPS3CS8pDGxPHBTiE+fdX5hQxH+/pplLK6agHFQy48GeKEgXNFjCFgJ6qXf/O6VGBM1mRiLfLSQOIQLMftClnYCa/wyp+7iLFAhDCk7ItiJhRRh3LTnEqIFGELgF+GoP0TAkQLYnIYLOiQmLHMbwxk8YZ9qUeNKaSJeTZcoJBDyPMiUWli7k2OYnUM3VMrjq5x0KkTAKIZKPQlJIkkVKCy8yLIp0pqcQtX1SofKemViRp2ZnItc//OExIcm8ipkBMPY2L69BWXP6YI5sDshhEHw2tiHh06VrQKAzVL4JlFkLocYMog0tw4FONFGVNlaGVKa2Uzr67ph9Eu0SIt6T2AZoZOTIXwoYtKVDJVoEqsoxJoWCBiC0qYL1ShUyxUC8P257q1I5bG9FBJTxLqzFtRqOS8yRPFnJrpDGVTpTBYXpCUJWWQ3odENgp04HXlsfIkJAyVp68JBKH/0rPhVNSDVzbr9NbjwdQKOrBNrRG6ulSVnheVo//OExJ4mIhpYBsPM3KAAr1RtRxZiXeWHMhUFQQNA05nNICanSF6MWr3Dasp1QstSeiGrDmqRPQ5XUFFEKOYG0SYuo/T2IUcw9TOyqZxYifBqkErZESPSdUZPKKMVRF4OUEGqkqiKsJxWAkehKHqYyJQjGQCjUQlRWEZVlzn2l329lacmR8u9l2q2szLK3rfjLrLoiLfBoGgKs7hoRZZ55apMQU1FMy4xMDCqqqqqqqqqqqqqqqqqqqqqqqqqqqqq//OExLgmCgo8AsPYnqqqqqqqqqqqqqqqqqqqqqqqqqqqqqoA1JoGdGhKGCgwjkasFDAg6B6LCtIwX1iotgUUFtQqZMhIXFaxUWD34Of/qF+tuLC4qKioqSfs/rFEGhUVJPxRsWFiILCQCigs3WKirv9YoLVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//OExJ0UKFX0FhhGAlVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV \ No newline at end of file
diff --git a/src/features/ark.js b/src/features/ark.js
index 3ef6cea..0a529c5 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, sameDelivery, 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,17 @@ 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));
+}
+// The crash-loop guard's phase notes (build.js BOOT_GUARD, app.js bootPhase):
+// a boot iOS killed shows the last one it reached, and three in a row open
+// the app in safe mode, where nothing connects on its own.
+const bootPhase = (p) => { try { localStorage.setItem('coinos-boot-phase', p); } catch {} };
+const safeBoot = () => !!(typeof window !== 'undefined' && window.__safeBoot);
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 +43,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 +465,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 +518,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
@@ -506,9 +552,11 @@ export function arkFeature(ctx) {
// holds is on screen the moment it can be.
const store = wallet._arkStore;
if (store && !store.ready) {
+ bootPhase('ark-store');
store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {});
return;
}
+ if (safeBoot()) return; // safe mode: the coins show from the store; nothing dials out unasked
if (arkAvailable() && arkWanted()) { lastAutoInit = Date.now(); connectArk().then(() => rescueOtherServers()).catch(() => {}); }
}
@@ -541,7 +589,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');
@@ -555,7 +603,7 @@ export function arkFeature(ctx) {
}
}
function maybeInitArk() {
- if (ark || arkConnectPromise) return;
+ if (ark || arkConnectPromise || safeBoot()) return;
if (Date.now() - lastAutoInit < 20000) return;
// A fresh snapshot may be held under its server pubkey, so arkWanted()
// cannot see it until the handshake identifies the server and adopts it.
@@ -623,6 +671,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,9 +688,27 @@ 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(() => {
+ bootPhase('ark-init');
+ const inited = mgr.init().then(() => {
+ if (gen !== arkInitGen) return;
+ bootPhase('ark-inited');
+ 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;
+ bootPhase('ark-adopt');
// Sync may have arrived while init awaited the server handshake.
adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark));
// Boards that died before funding (a failed tx build, a closed tab)
@@ -679,12 +749,14 @@ export function arkFeature(ctx) {
// sync() already reconciles on its first pass. Start the live stream
// afterwards, at the caught-up cursor: running both together replayed
// the backlog one message/render at a time alongside the batch read.
+ bootPhase('ark-sync');
tick().finally(() => { if (ark === mgr) mgr.startMailboxStream(); });
mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed
// Receives arrive in real time over the mailbox stream; the poll is the
// fallback and what drives in-flight boards/refreshes forward.
scheduleLnTag(mgr); // receives that landed while this device was closed
arkTimer = setInterval(() => { if (ark === mgr) tick(); }, getNetwork() === 'regtest' ? 5000 : 30000);
+ bootPhase('ark-ready');
render();
return mgr;
}).catch((e) => {
@@ -931,14 +1003,15 @@ 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,
autowithdraw: await awMirror(prev),
});
- if (prev) rec.spends = prev.spends; // the worker's log survives rewrites
+ if (prev) { rec.spends = prev.spends; rec.pendingPays = prev.pendingPays; } // the worker's log survives rewrites
if (prefs) rec.prefs = prefs; // notification choices the worker honours
await saveBg(wallet._cacheKey(), rec);
await disarmSiblingRecords(wallet._cacheKey(), rec).catch(() => {});
@@ -957,6 +1030,7 @@ export function arkFeature(ctx) {
return;
}
if (!rec.mgr) return;
+ bootPhase('ark-merge');
const merged = mergeArkStates(mgr.state, rec.mgr);
// the worker's view of OUR vtxos is newer for anything it spent
const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state]));
@@ -966,8 +1040,11 @@ export function arkFeature(ctx) {
merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1);
mgr.state = merged;
mgr._save();
+ bootPhase('ark-merged');
await mgr.reconcile().catch(() => {});
+ bootPhase('ark-reconciled');
await mgr.resumePending().catch(() => {});
+ bootPhase('ark-resumed');
}
// A leftover chain-6 pouch from before whole-balance background spending:
@@ -987,7 +1064,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++) {
@@ -1422,9 +1499,12 @@ export function arkFeature(ctx) {
h('span', { class: g.claimed ? 'tag conf' : 'tag' },
g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag')));
})(),
- (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(),
- m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null),
- h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))),
+ (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })()),
+ // the status tag sits by the time, not the name: in the narrow wallet
+ // column the title row has room for the label and the chip, not a
+ // third thing — the chip was breaking mid-name to make space
+ h('div', { class: 'row gap6 small faint' }, timeAgo(m.ts / 1000),
+ m.status !== 'complete' && !failed ? h('span', { class: 'tag pending' }, m.status === 'pending' ? t('arkExitPendingTag') : m.status) : null)),
h('div', { style: 'text-align:right' },
// in fiat this is what the payment was worth when it was made
h('div', { class: failed ? 'muted' : incoming ? 'amount-pos' : 'amount-neg' }, fmtAmount(m.amountSat, m.ts / 1000)))
@@ -1593,6 +1673,7 @@ export function arkFeature(ctx) {
})(),
m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null,
m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null,
+ m.type === 'receive' ? coinListRows(t('arkVtxoId'), m.vtxoIds) : null,
// the coins this payment consumed — a send can gather up to 24 inputs
coinListRows(t('arkVtxoInputs'), m.inputIds),
// An offboard's fee is two different pockets — the miners' cut for the
@@ -1641,7 +1722,7 @@ export function arkFeature(ctx) {
const g = giftForMovement(m);
const open = g && !g.claimed && !g.revoked;
const chips = [
- open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}`, t('giftCopyLinkAgain')) : null,
+ open ? copyBtn(`${location.origin}/g/${encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex))}${g.note ? '#m=' + encodeURIComponent(g.note) : ''}`, t('giftCopyLinkAgain')) : null,
m.preimage ? copyBtn(m.preimage, t('copyPreimage')) : null,
m.txid ? copyBtn(m.txid, t('copyTxid')) : null,
m.txid ? h('a', { class: 'btn btn-sm', href: url, target: '_blank', rel: 'noopener', onClick: (e) => { e.preventDefault(); openExternal(url); } }, t('viewOnMempool')) : null,
@@ -2147,23 +2228,8 @@ export function arkFeature(ctx) {
const spk = btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(target.address));
// Offboarding moves whole vtxos, so carve out the exact amount first
// when we're not sending everything.
- const spendables = () => freeCoins(mgr.state);
- const total = spendables().reduce((n, v) => n + v.amountSat, 0);
- let ids;
- if (amountSat < total) {
- let exact = spendables().find((v) => v.amountSat === amountSat);
- if (!exact) {
- const before = new Set(spendables().map((v) => v.id));
- await mgr.send(mgr.address(), amountSat);
- for (let i = 0; i < 30 && !exact; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await mgr.sync().catch(() => {});
- exact = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id));
- }
- if (!exact) throw new Error(t('arkSplitTimeout'));
- }
- ids = [exact.id];
- }
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
+ const ids = amountSat < total ? await carveCoins(mgr, amountSat) : undefined;
await mgr.startOffboard(spk, target.address, ids);
return;
}
@@ -2301,7 +2367,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 +2379,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 +2405,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 +2423,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 +2465,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 +2481,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 +2507,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 +2515,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 +2548,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 +2615,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 +2639,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 = getCustody(); // never 'custodial' on a server that doesn't honor expired coins
+ 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 +2928,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 +2945,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 +3123,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;
@@ -3031,7 +3171,7 @@ export function arkFeature(ctx) {
// Fund a fresh bearer gift identity. Shared by the gifts feature's hook and
// the zap flow's locked-gift fallback; `lockedTo` records who a locked gift
// was for (the sender can still revoke either kind while unclaimed).
- async function createArkGift(amountSat, lockedTo = null) {
+ async function createArkGift(amountSat, lockedTo = null, note = null) {
const mgr = await connectArk();
const secret = crypto.getRandomValues(new Uint8Array(32));
const secretHex = hex.encode(secret);
@@ -3041,7 +3181,7 @@ export function arkFeature(ctx) {
const action = mgr.state.actions.find((a) => a.id === actionId);
if (!action || action.step === 'failed') throw new Error(action?.error || t('claimFailed'));
const vtxoId = decodeVtxo(hex.decode((action.destBytesList || [action.destBytes])[0])).id;
- arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}) });
+ arkGiftRecords().push({ id: vtxoId, amountSat, secretHex, created: Date.now(), revoked: false, claimed: false, ...(lockedTo ? { lockedTo } : {}), ...(note ? { note } : {}) });
mgr._save();
return { code: encodeArkGiftCode(getNetwork(), amountSat, secret), amount: amountSat, address };
}
@@ -3075,7 +3215,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 +3223,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 +3235,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 +3246,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 +3260,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 +3269,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,29 +3305,69 @@ export function arkFeature(ctx) {
})());
}
- async function doArkOffboard(amountSat, destAddress = null) {
+ // A set of coins adding up to exactly `amountSat`: one coin of that size,
+ // else the smallest subset of the free coins that sums to it (largest coins
+ // first, so an offboard takes as few inputs as it can). null when no subset
+ // fits. Reachable sums are built up coin by coin, capped so a balance in
+ // hundreds of dust coins can't stall the tap.
+ function exactCoins(coins, amountSat) {
+ const one = coins.find((v) => v.amountSat === amountSat);
+ if (one) return [one];
+ const sorted = [...coins].filter((v) => v.amountSat > 0 && v.amountSat < amountSat).sort((a, b) => b.amountSat - a.amountSat);
+ const reach = new Map([[0, null]]); // sum -> { coin, prev } that first reached it
+ for (const v of sorted) {
+ const add = [];
+ for (const sum of reach.keys()) {
+ const next = sum + v.amountSat;
+ if (next <= amountSat && !reach.has(next)) add.push([next, { coin: v, prev: sum }]);
+ }
+ for (const [sum, via] of add) reach.set(sum, via);
+ if (reach.has(amountSat)) break;
+ if (reach.size > 50_000) return null;
+ }
+ if (!reach.has(amountSat)) return null;
+ const picked = [];
+ for (let sum = amountSat; sum > 0;) { const via = reach.get(sum); picked.push(via.coin); sum = via.prev; }
+ return picked;
+ }
+ // The coins that leave Spending for an on-chain send of `amountSat`. An
+ // offboard moves whole vtxos, so the amount has to be covered exactly:
+ // by coins already in the wallet when they fit (a balance that happens to
+ // add up, or the halves an earlier split left behind), else an exact amount
+ // is split off by paying it to this wallet's own address. A payment spread
+ // over several inputs arrives as several coins, one per input with the last
+ // carrying the remainder, so what the split yields is every coin that lands
+ // on the receive key and adds up to the amount, not a single coin of that
+ // size: looking for one made every partial on-chain send from a balance
+ // in two or more coins time out (Jarryd, 2026-10-23, three splits and no
+ // send). Change from the split carries its own key index and stays.
+ async function carveCoins(mgr, amountSat) {
+ const free = () => freeCoins(mgr.state);
+ const have = exactCoins(free(), amountSat);
+ if (have) return have.map((v) => v.id);
+ const before = new Set(free().map((v) => v.id));
+ await mgr.send(mgr.address(), amountSat);
+ for (let i = 0; i < 30; i++) {
+ await mgr.sync().catch(() => {});
+ const landed = free().filter((v) => !before.has(v.id) && !v.keyIndex);
+ const hit = exactCoins(landed, amountSat);
+ if (hit) return hit.map((v) => v.id);
+ await new Promise((r) => setTimeout(r, 2000));
+ }
+ throw new Error(t('arkSplitTimeout'));
+ }
+
+ // 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;
const spk = addrScript(address);
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 target = spendables().find((v) => v.amountSat === amountSat);
- if (!target) {
- const before = new Set(spendables().map((v) => v.id));
- await mgr.send(mgr.address(), amountSat);
- for (let i = 0; i < 30 && !target; i++) {
- await new Promise((r) => setTimeout(r, 2000));
- await mgr.sync().catch(() => {});
- target = spendables().find((v) => v.amountSat === amountSat && !before.has(v.id));
- }
- if (!target) throw new Error(t('arkSplitTimeout'));
- }
- ids = [target.id];
- }
+ const total = freeCoins(mgr.state).reduce((n, v) => n + v.amountSat, 0);
+ let ids = vtxoIds; // undefined = all spendable vtxos
+ if (!ids && amountSat && amountSat < total) ids = await carveCoins(mgr, amountSat);
const action = await mgr.startOffboard(spk, address, ids);
ui.arkOffboardAmt = '';
ui.arkMoveOpen = false;
@@ -3199,25 +3383,63 @@ 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';
+ // The tier in force: the stored choice, except that "coinos holds my coins"
+ // exists only on a server that honors a coin past its timelock. Anywhere
+ // else (Second, a custom captaind) an expired coin is gone, so renewal is
+ // automatic there whatever is stored — the stored default stays on the
+ // record for a later switch back to a coinos server. From 2026-10-22 to
+ // 10-23 the stored default applied everywhere, and a wallet on such a
+ // server renewed nothing and warned of nothing, in the app and the worker.
+ 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)
+ const tier = CUSTODY_TIERS.includes(v) ? v : CUSTODY_DEFAULT;
+ if (tier !== 'custodial') return tier;
+ const cfg = getArkConfig();
+ return cfg && !cfg.honorsExpired ? 'self' : tier;
+ }
+ 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)) return;
+ const same = tier === getCustody();
+ setCustody(tier); // the record keeps the choice even when it is already in force here
+ if (same) return;
+ 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
@@ -3248,12 +3470,38 @@ export function arkFeature(ctx) {
return true;
}
+ // Whatever the tier, a coin whose history has run very deep is renewed:
+ // the server stops cosigning spends of a coin at its depth cap, so this is
+ // what keeps such a coin spendable, and it waives the base fee for a
+ // refresh of deep coins alone — so they go in a round of their own, never
+ // mixed with coins that would make it pay. A wallet paying hourly from one
+ // change chain (a subscription) is the usual way to get here.
+ async function flattenDeepCoins(mgr, tip) {
+ const deep = mgr.deepCoins ? mgr.deepCoins() : [];
+ if (!deep.length) return false;
+ if ((mgr.state.actions || []).some((a) => !['done', 'failed'].includes(a.step))) return false;
+ const feeSat = mgr.refreshFee(deep, tip);
+ const totalSat = deep.reduce((n, v) => n + v.amountSat, 0);
+ if (totalSat - feeSat < 330 || feeSat * 10 > totalSat) return false; // dust, or a server charging full price: left alone
+ await mgr.refresh(deep.map((v) => v.id));
+ render();
+ return true;
+ }
+
async function maybeAutoRefresh(mgr) {
if (wallet.watchOnly || !mgr || !mgr.state) return;
if (Date.now() - arkAutoRefreshAt < 30 * 60_000) return;
+ const policy = custodyPolicy();
+ if (mgr.deepCoins && mgr.deepCoins().length) {
+ arkAutoRefreshAt = Date.now();
+ try { if (await flattenDeepCoins(mgr, await mgr.chain.tipHeight())) return; } catch {}
+ }
+ // "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 +3513,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 +3560,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 +3860,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 +3880,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 +3915,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() {
@@ -3840,9 +4105,21 @@ export function arkFeature(ctx) {
async arkBgNoteSpends(spends) {
const mgr = await connectArk();
for (const sp of spends) {
+ // The worker's own manager wrote this payment's row into the mirror,
+ // and the merge on connect brings it over: a second row here showed
+ // every closed-app payment twice (PullCap, 2026-10-23). Stamp the
+ // row that exists instead; lnFoldedIds hides a twin that still
+ // forms when the merge lands after this note.
+ const twin = (mgr.state.movements || []).find((m) => m.type === 'ln-send'
+ && ((sp.preimage && m.preimage === sp.preimage) || (sp.paymentHash && m.paymentHash === sp.paymentHash)));
+ if (twin) {
+ if (!/while closed/.test(twin.detail || '')) twin.detail = `while closed · fee ${sp.feeSat || 0} sat`;
+ continue;
+ }
mgr._movement({
type: 'ln-send', amountSat: sp.amountSat, status: 'complete',
detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '',
+ paymentHash: sp.paymentHash || undefined,
});
}
mgr._save();
@@ -4016,7 +4293,22 @@ export function arkFeature(ctx) {
for (const m of s.movements || []) for (const id of m.inputIds || []) accounted.add(id);
const out = [];
const seenCoin = new Set();
+ // One payment delivered as several coins (a gift held in two, a
+ // dust-padded split) is one row: receives from the same mailbox
+ // message (`batch`; older rows: the same delivery instant) fold.
+ const folded = new Map();
for (const m of moves) {
+ if (m.type === 'receive' && sameDelivery(out[out.length - 1], m)) {
+ const last = out[out.length - 1];
+ if (!folded.has(last.id)) {
+ const f = { ...last, vtxoIds: [last.vtxoId], vtxoId: null };
+ out[out.length - 1] = f; folded.set(f.id, f);
+ }
+ const f = folded.get(last.id);
+ f.amountSat += m.amountSat;
+ f.vtxoIds.push(m.vtxoId);
+ continue;
+ }
if (m.type !== 'reconcile') { out.push(m); continue; }
if (m.vtxoId && (seenCoin.has(m.vtxoId) || accounted.has(m.vtxoId))) continue;
if (m.vtxoId) seenCoin.add(m.vtxoId);
@@ -4029,6 +4321,7 @@ export function arkFeature(ctx) {
out.push({ ...m, count: 1, vtxoIds: m.vtxoId ? [m.vtxoId] : [] });
}
}
+ this._histFolded = folded; // a folded row's detail shows the whole payment
return exits.concat(renewals, lnPending, out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m), csv: arkCsvRow(m) })));
},
historyDetail() {
@@ -4048,7 +4341,8 @@ export function arkFeature(ctx) {
if (done) return arkMoveDetailView(done);
if (a && !['done', 'failed'].includes(a.step)) return arkMoveDetailView(lnPendingMove(a));
}
- const m = s ? (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
+ if (s) this.historyEntries(); // (memoized) keeps the folded rows current
+ const m = s ? (this._histFolded && this._histFolded.get(ui.arkMoveDetail)) || (s.movements || []).find((x) => x.id === ui.arkMoveDetail) : null;
if (m) return arkMoveDetailView(m);
ui.arkMoveDetail = null;
return null;
@@ -4113,7 +4407,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
@@ -4195,7 +4493,7 @@ export function arkFeature(ctx) {
setNetwork(g.net);
return true;
},
- arkGiftCreate(amountSat) { return createArkGift(amountSat); },
+ arkGiftCreate(amountSat, note) { return createArkGift(amountSat, null, note); },
// Claim-side status: claimable (with the live amount), taken, wrongnet, or
// unknown (not visible here — wrong server or not yet delivered).
async arkGiftStatus(code) {
diff --git a/src/features/gifts-wallet.js b/src/features/gifts-wallet.js
index 824ddc0..7004b12 100644
--- a/src/features/gifts-wallet.js
+++ b/src/features/gifts-wallet.js
@@ -274,9 +274,9 @@ export function installGiftWallet(wallet) {
// gift's link/QR can be re-viewed later and a claimed one identified in history.
_giftRecordsKey() { return this._cacheKey() + ':giftrec'; },
- recordGift({ code, locked, amount, claimCode, outpoints }) {
+ recordGift({ code, locked, amount, claimCode, outpoints, note }) {
if (!outpoints || !outpoints.length) return;
- this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false };
+ this.giftRecords()[outpoints[0]] = { id: outpoints[0], code, locked: !!locked, amount, claimCode: claimCode || null, outpoints, created: Date.now(), revoked: false, ...(note ? { note } : {}) };
this._saveGiftRecords();
try { this.saveCache(); } catch {} // publish the new gift to cross-device sync
},
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 56fd99e..399a807 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -15,7 +15,7 @@ export function giftsFeature(ctx) {
h, ui, render, wallet, toast, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal,
fmtAmount, unitLabel, unitTag, parseAmount, getUnit, toggleUnit, download,
brandHeader, activeAccount, setAccounts, getAccounts, claimTargets,
- enterWallet, activateAccount, commitAccount,
+ enterWallet, activateAccount, commitAccount, pickConfirm,
} = ctx;
installGiftWallet(wallet); // gift primitives live outside the core wallet
// Ark-gift support comes from the ark feature (when this build ships it);
@@ -32,6 +32,7 @@ export function giftsFeature(ctx) {
try {
const am = location.pathname.match(/^\/ag\/([A-Za-z0-9]+)\/?$/i); // ark gift
if (am) {
+ ui.claimNote = readNoteFrag();
history.replaceState(null, '', '/');
return arkGiftOf(am[1]) ? am[1] : null;
}
@@ -43,6 +44,7 @@ export function giftsFeature(ctx) {
if (hm) code = hm[1];
}
if (!code) return null;
+ if (pm) ui.claimNote = readNoteFrag();
history.replaceState(null, '', '/');
return previewGift(code) ? code : null;
} catch {
@@ -57,6 +59,7 @@ export function giftsFeature(ctx) {
try {
const m = location.pathname.match(/^\/lg\/([A-Za-z0-9_-]+)\/?$/);
if (!m) return null;
+ ui.claimNote = null;
history.replaceState(null, '', '/');
return previewLockedGift(m[1]);
} catch { return null; }
@@ -120,6 +123,25 @@ export function giftsFeature(ctx) {
if (!acceptLockedPayload(payload)) { ui.claimError = t('claimCodeWrong'); render(); }
}
+ // A gift's note rides its link after the # (#m=<text>): the fragment never
+ // leaves the browser, so no server sees it, and it is only as private as the
+ // link itself (which already IS the money).
+ const GIFT_NOTE_MAX = 280;
+ const noteFrag = (note) => (note ? '#m=' + encodeURIComponent(note) : '');
+ function readNoteFrag() {
+ try {
+ const m = new URLSearchParams(location.hash.slice(1)).get('m');
+ return m ? m.trim().slice(0, GIFT_NOTE_MAX) || null : null;
+ } catch { return null; }
+ }
+ // the sender's words, quoted above the amount wherever the gift is opened
+ const giftNoteView = () => (ui.claimNote
+ ? h('blockquote', { class: 'gift-note' }, ui.claimNote)
+ : null);
+ // with a note the link has lower case in it: a byte-mode QR (the bare
+ // link is upper-cased into the denser alphanumeric mode)
+ const giftQr = (url, hasNote) => qrSvg(hasNote ? url : url.toUpperCase(), { ec: 'L', mode: hasNote ? undefined : 'Alphanumeric' });
+
// Entry point for claiming a gift code. If the recipient already has wallet(s),
// offer to claim into one or make a new one; otherwise go straight to a fresh
// wallet (the original first-timer flow).
@@ -171,6 +193,7 @@ export function giftsFeature(ctx) {
h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' },
h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'),
h('h2', { style: 'margin:0' }, t('giftWelcome')),
+ giftNoteView(),
pv ? h('div', { class: 'amt', style: 'font-size:30px' }, h('span', { class: 'amount-pos' }, fmtAmount(pv.room)), ' ', unitTag('unit')) : null,
h('div', { class: 'small faint' }, claimFeeNote(code)),
h('p', { class: 'muted', style: 'margin:0' }, t('claimIntoPrompt'))
@@ -214,7 +237,7 @@ export function giftsFeature(ctx) {
),
h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))),
h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')),
- h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification'))
+ h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
)
);
}
@@ -246,13 +269,11 @@ export function giftsFeature(ctx) {
onClick: () => {
const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]);
if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; }
- ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = '';
- landAccount();
- render();
+ finishClaim();
},
}, t('openWallet'))
),
- h('button', { class: 'btn-block', onClick: () => { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); render(); } }, t('skipVerification'))
+ h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
)
);
}
@@ -313,6 +334,7 @@ export function giftsFeature(ctx) {
h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:12px' },
h('div', { class: 'check-badge', style: 'background:var(--accent)' }, '🎁'),
h('h2', { style: 'margin:0' }, t('giftWelcome')),
+ giftNoteView(),
h('div', { class: 'amt', style: 'font-size:30px' },
h('span', { class: 'amount-pos' }, fmtAmount(total)), ' ', unitTag('unit')
),
@@ -324,10 +346,12 @@ export function giftsFeature(ctx) {
!ui.claimError && ui.claimNotVisible && h('div', { class: 'notice info' }, t('claimNotVisibleBody')),
ui.busy || ui.claimChecking
? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner' }))
+ // an ark gift costs nothing to claim: just the button (an on-chain
+ // one says what its network fee will take)
: h('button', { class: 'btn-primary btn-block', onClick: doClaim },
- t('claimBtn'), ' ',
- h('span', { style: 'font-size:0.85em;opacity:0.9' },
- '(' + (ag ? t('claimArkFree') : t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() })) + ')'))
+ ...(ag ? [t('claimNowBtn')] : [t('claimBtn'), ' ',
+ h('span', { style: 'font-size:0.85em;opacity:0.9' },
+ '(' + t('claimFeeNote', { n: fmtAmount(estFee) + ' ' + unitLabel() }) + ')')]))
);
}
@@ -388,6 +412,16 @@ export function giftsFeature(ctx) {
if (ctx.setAccount) ctx.setAccount('spending');
}
+ // The seed is written down: into the new wallet — by way of the
+ // wizard's username, avatar and cover, so a gift's recipient arrives as
+ // someone people can pay by name (skippable, like any first run).
+ function finishClaim() {
+ ui.screen = 'wallet'; ui.claimStep = null; ui.claimError = '';
+ landAccount();
+ if (ctx.offerProfileSetup) ctx.offerProfileSetup({ home: ui.claimVia === 'ark' ? 'spending' : 'savings' });
+ render();
+ }
+
function afterClaim(amount) {
ui.claimedAmount = amount;
const wasProvisional = !!(activeAccount() && activeAccount().provisional);
@@ -526,13 +560,14 @@ export function giftsFeature(ctx) {
// the sender can re-share an unclaimed gift.
function viewGiftView() {
const g = ui.viewGift; // { code, locked, amount, claimCode }
- const url = `${location.origin}/g/${g.code}`;
+ const url = `${location.origin}/g/${g.code}${noteFrag(g.note)}`;
let svg = null;
- try { svg = qrSvg(url.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {}
+ try { svg = giftQr(url, !!g.note); } catch {}
return h('div', { class: 'col', style: 'gap:16px;padding:16px;max-width:460px;margin:0 auto;width:100%' },
h('div', { class: 'card col', style: 'gap:12px;align-items:center' },
h('h3', { style: 'margin:0' }, t('giftView')),
g.amount != null ? h('div', { class: 'amt' }, fmtAmount(g.amount), ' ', unitTag()) : null,
+ g.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, g.note) : null,
svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong')),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, url),
copyBtn(url, t('copyLink')),
@@ -567,7 +602,7 @@ export function giftsFeature(ctx) {
}
ui.busy = true; ui.giftError = ''; render();
try {
- const g = await hook('arkGiftCreate', amt);
+ const g = await hook('arkGiftCreate', amt, giftNoteNow());
ui.giftIsArk = true;
finishGift(g, amt);
} catch (e) {
@@ -615,8 +650,10 @@ export function giftsFeature(ctx) {
function finishGift(g, fallbackAmt) {
const amount = g.amount || fallbackAmt || 0;
ui.giftCode = g.code;
- wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved });
+ ui.giftNoteMade = giftNoteNow(); // the link says what was typed when it was made
+ wallet.recordGift({ code: g.code, locked: false, amount, outpoints: g.reserved, note: ui.giftNoteMade });
}
+ const giftNoteNow = () => String(ui.giftNote || '').trim().slice(0, GIFT_NOTE_MAX) || null;
function giftCard() {
const active = wallet.outstandingGifts();
@@ -631,13 +668,14 @@ export function giftsFeature(ctx) {
// (many-input) gift can exceed QR capacity — then fall back to the link.
(() => {
let svg = null;
- try { svg = qrSvg(giftUrl().toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }); } catch {}
+ try { svg = giftQr(giftUrl(), !!ui.giftNoteMade); } catch {}
return svg ? h('div', { html: svg }) : h('div', { class: 'small faint', style: 'text-align:center;padding:8px' }, t('giftQrTooLong'));
})(),
+ ui.giftNoteMade ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, ui.giftNoteMade) : null,
h('div', { class: 'addr-box break', style: 'width:100%;font-size:12px' }, giftUrl()),
h('div', { class: 'row gap6 wrap' },
copyBtn(giftUrl(), t('copyLink')),
- h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother'))
+ h('button', { class: 'btn-sm grow', onClick: () => { ui.giftCode = null; ui.giftAmount = ''; ui.giftNote = ''; ui.giftNoteMade = null; ui.giftMax = false; ui.giftIsArk = false; render(); } }, t('giftAnother'))
)
)
: ui.giftSplitOffer
@@ -692,6 +730,9 @@ export function giftsFeature(ctx) {
ui.giftSource === 'chain'
? h('div', { class: 'notice warn small' }, t('giftChainWarn'))
: null,
+ // an optional note, shown to whoever opens the gift
+ h('textarea', { class: 'gift-note-input', rows: 2, maxlength: GIFT_NOTE_MAX, placeholder: t('giftNotePlaceholder'),
+ value: ui.giftNote || '', onInput: (e) => { ui.giftNote = e.target.value; } }),
ui.giftError && h('div', { class: 'notice err' }, ui.giftError),
ui.busy
? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
@@ -864,7 +905,7 @@ export function giftsFeature(ctx) {
// Gift link: presign a chosen amount as a #gift= PSBT that whoever opens claims
// into a fresh wallet only they control. The coin is reserved until claimed.
function giftUrl() {
- return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}`;
+ return `${location.origin}/${ui.giftIsArk ? 'ag' : 'g'}/${ui.giftCode}${noteFrag(ui.giftNoteMade)}`;
}
function giftRate() {
diff --git a/src/features/messages.js b/src/features/messages.js
index d09ad83..6f010b2 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -67,7 +67,12 @@ import { mergeFeeds } from '../feed-sync.js';
// coinos's own relay and primal: both take DM gift wraps and pass call
// messages (ephemeral 21059) on live. nos.lol was here until 2026-10-19 — it
// refused or ignored the household IP's publishes, call answers among them.
-const DM_RELAYS = ['wss://relay.coinos.io', 'wss://relay.primal.net'];
+// messages.coinos.io (Magnitude Relay, built for gift-wrapped messages) leads;
+// relay.coinos.io stays on while older inbox lists still name it
+const MESSAGES_RELAY = 'wss://messages.coinos.io';
+const DM_RELAYS = [MESSAGES_RELAY, 'wss://relay.coinos.io', 'wss://relay.primal.net'];
+// what this app put in a wallet key's kind 10050 before messages.coinos.io
+const OLD_DM_DEFAULTS = ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.damus.io'];
const NOTIFIER = 'https://nwcpush.coinos.io';
const APP_BASE = 'https://v3.coinos.io';
const CACHE_MAX = 50; // messages kept per channel / per DM thread in feature state
@@ -3314,12 +3319,19 @@ export function messagesFeature(ctx) {
// — the user's other clients own that. And when a login identity IS active,
// don't advertise the seed key at all: it's internal, not a second inbox
// (advertising it is what let DMs land on a phantom identity).
+ // A list this app wrote with its old defaults (nothing else in it) is
+ // brought up to today's, so senders find messages.coinos.io first.
async function ensureDmRelayList() {
if (!wallet.nostr || !wallet.nostr.sk) return;
if (hook('nostrLoginIdentity')) return;
const pk = wallet.nostr.pk;
const existing = await queryOn(DM_RELAYS, { kinds: [10050], authors: [pk] }, 2500);
- if (existing.length) return;
+ if (existing.length) {
+ const latest = existing.reduce((a, b) => (b.created_at > a.created_at ? b : a));
+ const urls = latest.tags.filter((x) => x[0] === 'relay').map((x) => String(x[1] || '').replace(/\/$/, ''));
+ const ours = urls.length && urls.every((u) => OLD_DM_DEFAULTS.includes(u));
+ if (!ours || urls.includes(DM_RELAYS[0])) return;
+ }
const evt = finalizeEvent({
kind: 10050,
content: '',
@@ -4123,7 +4135,8 @@ export function messagesFeature(ctx) {
// ---- profile notes: latest public posts & replies -----------------------
- const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS])];
+ // (not messages.coinos.io: it takes messages, not posts)
+ const NOTE_RELAYS = [...new Set([...PROFILE_RELAYS, ...DM_RELAYS.filter((r) => r !== MESSAGES_RELAY)])];
// ---- zap tallies ---------------------------------------------------------
// How much a post or chat message has been zapped: the public receipts
@@ -4562,7 +4575,8 @@ export function messagesFeature(ctx) {
};
const box = player.parentElement;
box._skipMorph = true;
- box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph }));
+ box.append(mediaTimeline(player, url, { h, event, label: t, moments: momentsOn, glyph: momentGlyph,
+ onMarks: player.tagName === 'VIDEO' ? (on) => ownBar(player, on) : null }));
player.dispatchEvent(new Event('timeupdate'));
}
function noteEvent(ev) {
@@ -6716,7 +6730,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]; }
@@ -6768,6 +6783,7 @@ export function messagesFeature(ctx) {
const videoProbes = new Map();
let videoProbing = 0;
const videoProbeQueue = [];
+ const clipGone = (url) => { const d = videoInfo.get(url); return !!d && typeof d.goneAt === 'number' && Date.now() - d.goneAt < 86_400_000; };
const probeFailedLately = (d) => !!d && typeof d.failedAt === 'number' && Date.now() - d.failedAt < 3_600_000;
function probeVideo(url, layout = false) {
const have = videoInfo.get(url);
@@ -6801,7 +6817,7 @@ export function messagesFeature(ctx) {
// player's plain load answers this request without them, and it fails.
// Any clip that had played in the feed was "unmeasurable" that way.
const r = await fetch(url, { headers: { Range: 'bytes=0-' + (VIDEO_PROBE_BYTES - 1) }, cache: 'no-store', signal: ctrl.signal });
- if (!r.ok) throw new Error('HTTP ' + r.status);
+ if (!r.ok) throw Object.assign(new Error('HTTP ' + r.status), { status: r.status });
const ranges = r.status === 206;
const total = ranges ? +(/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] : +r.headers.get('content-length');
// never more than the first bytes, even from a server that ignored the range
@@ -6818,22 +6834,27 @@ export function messagesFeature(ctx) {
let o = 0;
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, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) };
- if (total > 0) d.size = total;
+ 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; 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; }
if (head.w) rememberVideo(url, head.w, head.h);
noteVideoInfo(url, d);
- } catch {
- noteVideoInfo(url, { failedAt: Date.now() }); // a host that won't say (no CORS, down, slow): asked again in an hour
+ } catch (e) {
+ // a host that won't say (no CORS, down, slow): asked again in an hour.
+ // One that says the file is gone (deleted: a 404 or 410) is believed
+ // for a day, and the grid leaves the clip out (clipGone).
+ const gone = e && (e.status === 404 || e.status === 410);
+ noteVideoInfo(url, { failedAt: Date.now(), ...(gone ? { goneAt: Date.now() } : {}) });
+ if (gone && mediaMode === 'videos') scheduleRepaint();
} finally { clearTimeout(timer); }
return videoInfo.get(url);
}
@@ -7942,7 +7963,7 @@ export function messagesFeature(ctx) {
// known, never taller than the screen; only a clip taller than that sits
// in a black frame, rather than in a strip at the left.
const videoBoxStyle = (d) => d && d.width && d.height
- ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:100vh;object-fit:contain`
+ ? `width:100%;aspect-ratio:${d.width}/${d.height};max-height:var(--vid-cap);object-fit:contain`
: 'width:100%;aspect-ratio:16/9;object-fit:contain';
// Over a clip that waits for a tap: what it costs, and the tap that plays
// it (with sound — the reader asked for this one). The box is left alone
@@ -7960,7 +7981,7 @@ export function messagesFeature(ctx) {
if (v) {
if (v.getAttribute('src') !== playSrc(url)) v.setAttribute('src', playSrc(url));
v.preload = 'auto';
- v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); v.controls = true; });
+ v.play().catch(() => { wokeClips.add(url); box.classList.remove('idle'); nativeControls(v); });
}
} }, h('span', { class: 'vid-heavy-play', 'aria-hidden': 'true' }, '\u25b6'), h('span', {}, heavyLabel(url)));
box.append(cover);
@@ -7980,6 +8001,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
@@ -8087,10 +8109,38 @@ export function messagesFeature(ctx) {
});
}
// A tap on the picture itself (not the player's own play button or its
- // control bar): inline, it goes full screen — the corner button is a
+ // control bar): inline, a mouse click pauses a playing clip; a paused one
+ // (or any touch) goes full screen — the corner button is a
// reach on a phone. Full screen, it pauses a playing clip, and a tap on a
// paused one comes back out. The player's controls are inside the video
// element, so where they sit is judged from the tap's position.
+ // The big play/pause over a feed clip is for a mouse: <html> carries
+ // .has-mouse while the last pointer was one (a touch laptop switches with
+ // the hand), and CSS shows the button on :hover (a repaint can't drop it).
+ if (typeof document !== 'undefined') {
+ const mouseIs = (on) => document.documentElement.classList.toggle('has-mouse', on);
+ document.addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') mouseIs(true); }, { passive: true });
+ document.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'mouse') mouseIs(false); }, { passive: true });
+ }
+ // A clip whose post has timed reactions seeks on the moments bar under it
+ // (the reactions sit on it at their moments), so the player's own controls
+ // — a second seek bar — stay off; the middle button and the sound button
+ // are its controls then (Adam: "hide the main seek bar … and only show a
+ // moments seek bar"). Otherwise the player's controls, once it has woken.
+ const nativeControls = (v) => { v.controls = !v._ownBar; };
+ function ownBar(player, on) {
+ const box = player.closest('.note-video-box');
+ if (!box || player._ownBar === on) return;
+ player._ownBar = on;
+ box.classList.toggle('own-bar', on);
+ if (!box.classList.contains('idle')) nativeControls(player);
+ // with no player controls, the sound button stays to turn it back down
+ if (on && !box.querySelector('.vid-sound')) {
+ const b = soundBtn((button) => { player.muted = !player.muted; button.setSound(!player.muted); });
+ b.setSound(!player.muted);
+ box.append(b);
+ }
+ }
const fsElement = () => (typeof document !== 'undefined' && (document.fullscreenElement || document.webkitFullscreenElement)) || null;
let videoTapAt = 0; // a touch's tap on a clip, taken at pointerup: the click after it lands on whatever opened
function videoTap(e, url) {
@@ -8112,12 +8162,24 @@ export function messagesFeature(ctx) {
else (document.exitFullscreen || document.webkitExitFullscreen || (() => {})).call(document)?.catch?.(() => {});
return true;
}
+ // a mouse click on a playing clip just pauses it (Adam: "clicking on a
+ // video that's playing should pause the video, not full screen it"); it
+ // keeps its controls and the middle button resumes it, and a click on it
+ // paused opens the pager. A touch has no middle button: its tap still opens.
+ if (!v.paused && e.pointerType === 'mouse') {
+ v.pause();
+ wokeClips.add(url);
+ const box = v.closest('.note-video-box');
+ if (box) { box.classList.remove('idle'); box._skipMorph = true; }
+ nativeControls(v);
+ return true;
+ }
if (openClip(v, url)) return true;
// a clip still idle (autoplaying, no controls) becomes the reader's
wokeClips.add(url);
const box = v.closest('.note-video-box');
if (box) { box.classList.remove('idle'); box._skipMorph = true; }
- v.controls = true;
+ nativeControls(v);
const go = v.requestFullscreen || v.webkitRequestFullscreen || v.webkitEnterFullscreen;
if (!go) return true;
// (the screen isn't turned for it: a wide clip plays with bars, and the
@@ -8179,13 +8241,38 @@ export function messagesFeature(ctx) {
if (videoTap(e, url)) el._tapAt = videoTapAt = Date.now();
});
v.muted = !loud; // the property, not just the attribute: a script-made element autoplays only muted
- const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : '') }, v);
+ // a tall clip is held to the cap's height and the box narrows to its
+ // shape, instead of a full-width box of black bars (Adam, on a 1920 screen)
+ const boxCap = (w, ht) => w && ht ? `max-width:calc(var(--vid-cap) * ${w} / ${ht})` : undefined;
+ const known = feedMedia(url);
+ const box = h('div', { class: 'note-video-box' + (idle ? ' idle' : ''), style: boxCap(known && known.width, known && known.height) }, v);
+ v.addEventListener('loadedmetadata', () => { if (v.videoWidth && v.videoHeight) box.style.maxWidth = `calc(var(--vid-cap) * ${v.videoWidth} / ${v.videoHeight})`; });
if (loud) box._skipMorph = true;
+ // With a mouse: a big play/pause button in the middle while the pointer is
+ // over the clip. It plays or pauses in place, never full screen (a click
+ // elsewhere on a paused clip opens the pager). Touch keeps its taps.
+ const pp = h('button', { class: 'vid-pp', type: 'button', 'aria-label': t('videoPlay'), onClick: (e) => {
+ e.stopPropagation(); e.preventDefault();
+ const player = e.currentTarget.closest('.note-video-box')?.querySelector('video');
+ if (!player) return;
+ if (player.paused) { tappedClips.add(url); player.play().catch(() => {}); } else player.pause();
+ } }, h('span', { class: 'vid-pp-i', 'aria-hidden': 'true' }));
+ const ppState = () => {
+ const live = v.closest('.note-video-box');
+ const b = live && live.querySelector('.vid-pp');
+ if (!b) return;
+ b.classList.toggle('playing', !v.paused);
+ b.setAttribute('aria-label', t(v.paused ? 'videoPlay' : 'videoPause'));
+ };
+ v.addEventListener('play', ppState);
+ v.addEventListener('pause', ppState);
+ box.append(pp);
+
// remembered by clip, so a repaint draws the box as it now stands
const wake = () => {
wokeClips.add(url);
box.classList.remove('idle');
- v.controls = true;
+ nativeControls(v);
};
v.addEventListener('playing', wake);
const btn = loud ? null : soundBtn((button) => {
@@ -8194,14 +8281,19 @@ export function messagesFeature(ctx) {
const liveBox = button.closest('.note-video-box');
const player = liveBox?.querySelector('video');
if (!player) return;
+ if (player._ownBar && !player.muted) { player.muted = true; button.setSound(false); return; }
player.muted = false;
unmutedClips.add(url);
liveBox._skipMorph = true; // the box is the viewer's now: no repaint rebuilds it muted
if (player.paused) player.play().catch(() => {});
- button.remove(); // the player's own controls carry mute from here
+ // the player's own controls carry mute from here — unless they're off
+ // for the moments bar: then this button turns it down again
+ if (player._ownBar) button.setSound(true); else button.remove();
});
v.onvolumechange = () => {
- if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected) btn.remove(); } // unmuted from the native control: same
+ const b = v.closest('.note-video-box')?.querySelector('.vid-sound');
+ if (v._ownBar && b && b.setSound) b.setSound(!v.muted);
+ if (!v.muted) { unmutedClips.add(url); if (btn && btn.isConnected && !v._ownBar) btn.remove(); } // unmuted from the native control: same
else unmutedClips.delete(url); // muted again from the control: the next box starts quiet
};
if (btn) box.append(btn);
@@ -9301,7 +9393,7 @@ export function messagesFeature(ctx) {
};
return h('div', { class: 'col live-page', style: 'gap:12px' },
ctx.brandHeader(!ui.pubProf && wallet.loaded),
- h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'card col live-main', style: 'gap:10px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
mine ? h('button', { class: 'iconbtn chat-back', onClick: () => { if (confirm(t('goLiveEndAsk'))) endBroadcast(); } }, '‹')
: backBtn(() => { ui.liveStream = null; }),
@@ -9353,7 +9445,7 @@ export function messagesFeature(ctx) {
watchersRow(w),
liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null,
ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null),
- h('div', { class: 'card col', style: 'gap:8px' },
+ h('div', { class: 'card col live-chat-card', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('liveChat')),
h('div', {
class: 'live-chat', 'data-key': 'live-chat:' + s.a,
@@ -11069,6 +11161,10 @@ export function messagesFeature(ctx) {
return null;
}
const hidden = (ev) => !!hiddenWhy(ev);
+ // A list row with nothing left to show: a post its author deleted, or a
+ // repost of one (a deletion that lands after the row did, too). Threads
+ // keep their deleted posts' places; lists just drop them.
+ const goneHere = (ev) => isDeleted(ev) || (ev.kind === REPOST && !!repostInner(ev) && isDeleted(repostInner(ev)));
const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || (baselineMutes() || { set: new Set() }).set.has(pk) || isSpammer(pk) || reportsNow().mine.has(pk));
const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk));
@@ -12579,12 +12675,32 @@ export function messagesFeature(ctx) {
...noteOverlays());
}
- async function publishProfileFields(fields, opts = {}) {
+ // Every kind-0 publish merges over the newest copy on the relays, so two
+ // publishes in flight at once clobber each other: both read the same base,
+ // the later one lands without the earlier one's fields. That exact race
+ // put a migrated user's released placeholder name back into their lud16
+ // (the rename repointed it, the legacy-avatar import republished the old
+ // base nine seconds later). One at a time, and the base is never older
+ // than what this session last wrote.
+ let kind0Chain = Promise.resolve();
+ let kind0Last = null; // { at, content } — the newest kind 0 we published
+ const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; };
+ async function newestKind0(pk) {
+ const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000);
+ let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null;
+ if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last;
+ let base = {};
+ try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ return base;
+ }
+ // Strictly later than the last one we sent: relays keep the LOWEST id of
+ // two replaceable events with the same second, so a second publish in
+ // the same second as the first could lose to it.
+ const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; };
+ function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); }
+ async function publishProfileFieldsNow(fields, opts = {}) {
const id = await requireIdentity();
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be
const merged = { ...base };
// fillOnly fields are offers, not orders: the onboarding wizard suggests
@@ -12600,10 +12716,11 @@ export function messagesFeature(ctx) {
}
if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name;
if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() });
return true;
@@ -12617,11 +12734,20 @@ export function messagesFeature(ctx) {
ui.profSaving = true;
render();
try {
+ await withKind0Lock(() => saveProfileNow(id, e));
+ ui.profEdit = null; ui.profEditFilled = false;
+ toast(t('profSaved'));
+ } catch (err) {
+ if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
+ toast(err.message || String(err));
+ } finally {
+ ui.profSaving = false;
+ render();
+ }
+ }
+ async function saveProfileNow(id, e) {
// merge over the newest published kind 0 so unknown fields round-trip
- const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000);
- const newest = evs.sort((a, b) => b.created_at - a.created_at)[0];
- let base = {};
- try { base = newest ? JSON.parse(newest.content) : {}; } catch {}
+ const base = await newestKind0(id.pubkey);
// A changed username claims first — if the name is taken (or invalid)
// the save stops here and the editor stays open to fix it.
const oldAddr = hook('namesAddress');
@@ -12645,22 +12771,46 @@ export function messagesFeature(ctx) {
if (!merged[k] || merged[k] === oldAddr) merged[k] = addr;
}
if (merged.name) merged.display_name = merged.name;
- const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) };
+ const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt);
if (!ok) throw new Error(t('msgSendFailed'));
+ kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content };
fullProfiles.set(id.pubkey, merged);
profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null });
- ui.profEdit = null; ui.profEditFilled = false;
- toast(t('profSaved'));
- } catch (err) {
- if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName;
- toast(err.message || String(err));
- } finally {
- ui.profSaving = false;
- render();
+ }
+ // A payment-address rename released the old name: repoint the kind 0's
+ // lud16 and nip05 — but only where they pointed at the released address
+ // (or were empty). Deliberately different values are not ours to touch.
+ function repointAddress(oldAddr, newAddr) {
+ const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
+ return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
+ onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
+ fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
+ });
+ }
+ // Our kind 0 still names an address at our own domain that is not the one
+ // we hold — a placeholder name released by a migration whose repoint lost
+ // the race above. Repointed once the domain confirms the old name is dead;
+ // a same-domain name that still answers (a second account, say) is left
+ // alone. Once per address per session.
+ const addrHealed = new Set();
+ async function healStaleAddress(olds, myAddr) {
+ for (const old of olds) {
+ if (addrHealed.has(old)) continue;
+ addrHealed.add(old);
+ try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {}
}
}
+ async function addressDead(addr) {
+ const [name, domain] = addr.split('@');
+ try {
+ const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' });
+ if (!r.ok) return true;
+ const j = await r.json().catch(() => null);
+ return !j || j.status === 'ERROR' || !j.callback;
+ } catch { return false; } // unreachable is not dead
+ }
function profileScreen() {
const pk = ui.profilePk;
@@ -12800,7 +12950,12 @@ export function messagesFeature(ctx) {
// authoritative locally, a deep link carries the address in the URL
// (seeded as `addr`), and the light cache remembers it from last time.
const lp = profileOf(pk) || {};
- const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null;
+ // Our own page never shows a name at our domain that the registrar says
+ // is no longer ours (see healStaleAddress): the claimed address is the
+ // truth here, and the kind 0 is brought in line in the background.
+ const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null;
+ if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); }
+ const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null;
const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null;
const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null;
const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05;
@@ -13046,7 +13201,8 @@ export function messagesFeature(ctx) {
// Streams joins the tabs once they've streamed
const streamed = pastOf(pk).length > 0;
const tab = profTab(pk) === 'streams' && !streamed ? 'posts' : profTab(pk);
- const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e));
+ // a post its author deleted leaves the list (no "was deleted" row)
+ const shown = c.notes.filter((e) => (tab === 'replies') === replyish(e) && !goneHere(e));
// a tab that holds only a few of the pages fetched so far (someone
// who mostly replies, or mostly posts) asks for older ones by itself:
// too short to scroll means the scroll that loads more never comes
@@ -13079,7 +13235,7 @@ export function messagesFeature(ctx) {
return h('div', { class: 'col', style: 'gap:8px' },
tabs,
h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab },
- ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
+ ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e) && !goneHere(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' },
h('span', { class: 'spinner sm' }))
@@ -13490,7 +13646,9 @@ export function messagesFeature(ctx) {
const key = mediaIdxKey(feedId);
let r = mediaIdx.get(key);
if (r) return r;
- r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false };
+ const order = () => ({ seq: new Map(), top: 0, bottom: 0, newest: 0 });
+ r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false,
+ order: { images: order(), videos: order() } };
mediaIdx.set(key, r);
loadMediaIndex(key).then(async (rec) => {
if (rec) {
@@ -13717,12 +13875,17 @@ export function messagesFeature(ctx) {
// <img> of the original (or the 1100px column copy) to fetch and decode,
// and the browser's lazy margin is a screen or two. These are local and a
// few KB, so every tile loads at once and decodes in no time.
- const TILE_CACHE = 'coinos-tile-thumbs-v1', TILE_PX = 360, TILE_MAX = 1200;
+ const TILE_CACHE = 'coinos-tile-thumbs-v2', TILE_PX = 360, TILE_MAX = 1200;
const tileThumbs = new Map(); // url -> object URL
const tileKey = (url) => 'https://tile-thumb.invalid/?u=' + encodeURIComponent(url);
const tileQueue = [], tileTried = new Set(), tileLooked = new Set();
let tileBusy = 0, tileRepaint = null;
- const tileStore = () => caches.open(TILE_CACHE);
+ let tileOldGone = false;
+ const tileStore = () => {
+ // the square tiles of before (v1) go: the grid's rows want each shape
+ if (!tileOldGone) { tileOldGone = true; caches.delete('coinos-tile-thumbs-v1').catch(() => {}); }
+ return caches.open(TILE_CACHE);
+ };
async function warmTileThumbs(urls) {
if (typeof caches === 'undefined') return;
let found = 0;
@@ -13742,7 +13905,7 @@ export function messagesFeature(ctx) {
const kept = mediaReady.get(url)?.thumb;
if (!kept && (noCorsHost(url) || corsRefused.has(url))) {
// the host keeps its bytes: the server cuts the tile
- const tile = await serverPicBlob(url, 'tile');
+ const tile = await serverPicBlob(url, 'row');
if (tile) await saveTileThumb(url, tile);
return;
}
@@ -13752,7 +13915,7 @@ export function messagesFeature(ctx) {
if (!res.ok) return;
blob = await res.blob();
} catch (e) {
- if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'tile'); if (tile) await saveTileThumb(url, tile); }
+ if (e && e.corsRefused) { corsRefused.add(url); const tile = await serverPicBlob(url, 'row'); if (tile) await saveTileThumb(url, tile); }
return;
}
if (/gif/i.test(blob.type)) return; // animated: the tile shows the original
@@ -13907,16 +14070,51 @@ export function messagesFeature(ctx) {
// videos by default on a phone that's in vertical orientation and only
// horizontal videos if the phone is in landscape but have some kind of
// control to toggle the ability to see all videos".)
- const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', or unset: the screen's way round
- const clipsAll = () => { try { return localStorage.getItem(CLIPS_FIT_KEY) === 'all'; } catch { return false; } };
- const setClipsAll = (all) => { try { if (all) localStorage.setItem(CLIPS_FIT_KEY, 'all'); else localStorage.removeItem(CLIPS_FIT_KEY); } catch {} };
+ // On a desktop-sized window every shape is the default (Adam, 2026-10-22:
+ // the justified rows hold both); a phone keeps to its way round.
+ const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', 'fit' (the screen's way round), or unset: the device's default
+ const bigWindow = () => { try { return matchMedia('(min-width: 1000px) and (min-height: 600px)').matches; } catch { return false; } };
+ const clipsAll = () => { try { const v = localStorage.getItem(CLIPS_FIT_KEY); return v === 'all' || (v !== 'fit' && bigWindow()); } catch { return bigWindow(); } };
+ const setClipsAll = (all) => { try { if (all === bigWindow()) localStorage.removeItem(CLIPS_FIT_KEY); else localStorage.setItem(CLIPS_FIT_KEY, all ? 'all' : 'fit'); } catch {} };
const screenTall = () => window.innerHeight >= window.innerWidth;
- const clipShape = (url) => {
+ const clipSize = (url) => {
const d = videoInfo.get(url) || {};
const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url);
const w = k && (k.w || k.width), hh = k && (k.h || k.height);
- return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null;
+ return w && hh ? { w, h: hh } : null;
+ };
+ // (no size yet: the still's proportions, which a grid of every shape has
+ // loaded without measuring anything — so going back to one shape finds
+ // the clips' shapes known)
+ const clipShape = (url) => {
+ const z = clipSize(url), r = z ? z.w / z.h : posterAr.get(url);
+ return r ? (r < 0.99 ? 'tall' : r > 1.01 ? 'wide' : 'square') : null;
};
+ // width over height, kept between a phone's upright 9:16 and a cinema 2:1
+ // (a stray 32:9 or 1:4 would make a tile a sliver); unknown: a square
+ // (until the clip's size is known, the still's own proportions: the
+ // server's and a frame grab keep the clip's shape)
+ const posterAr = new Map();
+ const clampAr = (r) => Math.min(2, Math.max(0.5, r));
+ const clipRatio = (url) => { const z = clipSize(url); return z ? clampAr(z.w / z.h) : posterAr.get(url) || 1; };
+ // a grid item's: the file's measured size, the post's word for it (NIP-92
+ // dim), or its still once that has loaded
+ function itemRatio(it) {
+ if (clipSize(it.url)) return clipRatio(it.url);
+ for (const tag of it.ev.tags || []) {
+ if (tag[0] !== 'imeta' || !tag.includes('url ' + it.url)) continue;
+ const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(imetaFields(tag).dim || '');
+ if (m && +m[1] && +m[2]) return clampAr(+m[1] / +m[2]);
+ }
+ return posterAr.get(it.url) || 1;
+ }
+ function arFromStill(img, url) {
+ if (clipSize(url) || !img.naturalWidth || !img.naturalHeight) return;
+ const r = clampAr(img.naturalWidth / img.naturalHeight);
+ posterAr.set(url, r);
+ const tile = img.closest('.media-grid.justified .media-tile');
+ if (tile) tile.style.setProperty('--ar', r.toFixed(3));
+ }
// true/false, or null while the clip's shape isn't known
const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); };
const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide');
@@ -13946,8 +14144,17 @@ export function messagesFeature(ctx) {
if (d && d.probed && !d.w && d.ranges && !videoDims.has(url)) return playerShape(url);
}).finally(() => { if (mediaMode === 'videos') scheduleRepaint(); });
}
- function mediaItems(c, kind) {
- const out = [], seen = new Set();
+ // One picture or clip shows once in the grid, however many posts carry it
+ // (Adam, 2026-10-22: a conference promo filled the Videos view with
+ // copies): the same file (a Blossom hash names it wherever it's hosted),
+ // or the same words from the same person posted again with another upload.
+ const sameFile = (url) => { const m = /\/([0-9a-f]{64})(?:\.\w+)?(?:[?#]|$)/i.exec(url); return m ? m[1].toLowerCase() : url.replace(/[?#].*$/, ''); };
+ const saidAgain = (ev) => { const w = String(ev.content || '').replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim(); return w.length >= 8 ? ev.pubkey + '|' + w : null; };
+ // (anyShape: the pager's list — every clip, the ones the wrong way round
+ // for the screen held out of sight by pagerFit, so "All shapes" there has
+ // them to show)
+ function mediaItems(c, kind, anyShape = false) {
+ const out = [], seen = new Set(), said = new Map();
const r = mediaIndex(c.id);
indexMedia(r, c.notes);
const evs = [...r[kind].values()].sort((a, b) => b.created_at - a.created_at);
@@ -13956,17 +14163,51 @@ export function messagesFeature(ctx) {
if (hidden(ev)) continue;
// a grid shows the picture itself — no warning to fold it behind
if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue;
+ const again = saidAgain(ev);
+ if (again && said.has(again) && said.get(again) !== ev.id) continue;
if (kind === 'videos') learnVideoMeta(ev);
- const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip)
+ // (a clip whose host says it's deleted has nothing to show: a blank tile)
+ const urls = kind === 'videos' ? clipUrlsOf(ev).filter((u) => !clipGone(u) && (anyShape || fitClip(u)))
: noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
+ // (the words are taken by a post that shows: one whose clip doesn't fit
+ // the screen leaves them to the next)
+ if (again && urls.length) said.set(again, ev.id);
for (const url of urls) {
- const it = { ev, url };
- if (seen.has(mediaKey(it))) continue;
- seen.add(mediaKey(it));
- out.push(it);
+ 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 });
}
}
- return out;
+ return settledOrder(r, kind, out);
+ }
+ // The grid's order holds for the session (Adam, 2026-10-23: clips the
+ // crawl found landed in the middle of the grid, above an older tail the
+ // index had from before, and the tiles already there jumped): what it
+ // shows first is newest first, and whatever turns up after goes under what
+ // is already there, in its own date order. Only a post newer than the top
+ // tile goes above it. Nothing is settled until the kept index is in, so
+ // the first paint is one date order, not two batches.
+ function settledOrder(r, kind, out) {
+ if (!r.loaded) return out;
+ const o = r.order[kind];
+ const key = (it) => it.ev.id + '|' + it.url;
+ const fresh = out.filter((it) => !o.seq.has(key(it)));
+ if (fresh.length) {
+ const above = o.seq.size ? fresh.filter((it) => it.ev.created_at > o.newest) : [];
+ for (const it of above.reverse()) o.seq.set(key(it), --o.top);
+ for (const it of fresh) if (!o.seq.has(key(it))) o.seq.set(key(it), ++o.bottom);
+ for (const it of fresh) if (it.ev.created_at > o.newest) o.newest = it.ev.created_at;
+ }
+ return out.sort((a, b) => o.seq.get(key(a)) - o.seq.get(key(b)));
}
// The feed's pictures (or clips) in reading order — every one of a post
// before the next post's — for the pager opened from one in the feed: the
@@ -14184,7 +14425,15 @@ export function messagesFeature(ctx) {
if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u))));
}
if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url));
- return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode },
+ // The tiles take the clips' shape (Adam, 2026-10-22): wide clips only (a
+ // wide screen) go one to a row, their posters whole, not slivers cut to
+ // portrait tiles; every shape ("All shapes") is laid out in justified
+ // rows, each tile its clip's own proportions and each row as tall as it
+ // takes to fill the width; tall ones only (a phone held upright) keep
+ // the tall tiles three across.
+ // Pictures go in justified rows too, in their own shapes.
+ const shape = mediaMode === 'images' ? ' justified' : mediaMode !== 'videos' ? '' : clipsAll() ? ' justified' : screenTall() ? '' : ' wide';
+ return h('div', { class: 'media-grid' + shape, 'data-key': 'media-grid:' + mediaMode },
...items.map((it) => {
const k = mediaKey(it);
// a video tile shows a still at once: kept, given, or the server's
@@ -14197,19 +14446,28 @@ export function messagesFeature(ctx) {
const fromServer = mediaMode === 'videos' && poster && poster === serverThumb(it.url);
return h('button', {
class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k,
+ style: shape === ' justified' ? '--ar:' + itemRatio(it).toFixed(3) : undefined,
'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k),
onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined,
},
mediaMode === 'images' || poster
? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false',
loading: poster || tileThumbs.has(it.url) ? 'eager' : 'lazy', decoding: 'async',
+ 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);
}));
}
@@ -14223,8 +14481,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
@@ -14238,9 +14502,11 @@ export function messagesFeature(ctx) {
if (await grabFrame(url)) {
if (!v.isConnected) return;
const img = document.createElement('img');
- img.alt = ''; img.draggable = false; img.src = videoThumbs.get(url);
+ img.alt = ''; img.draggable = false;
+ 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;
@@ -14252,7 +14518,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 });
}
@@ -14260,11 +14526,23 @@ export function messagesFeature(ctx) {
// (a plain load: shown, not kept — a player's pixels can only be
// read from a CORS load, and those fail on a cached plain copy)
v.muted = true;
+ // a clip the player can't load either is gone as far as the grid
+ // goes: a deleted file whose 404 came without CORS headers
+ // (Primal's blossom) only looked like a quiet host to the probe,
+ // and its tile sat blank. Left out for a day, like a 404 the
+ // probe could read.
+ v.addEventListener('error', () => {
+ if (!v.isConnected || !v.getAttribute('src')) return; // let go of by the eight-second cap
+ noteVideoInfo(url, { goneAt: Date.now() });
+ const b = v.closest('.media-tile');
+ if (b) b.classList.add('broken');
+ 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' });
@@ -14485,6 +14763,7 @@ export function messagesFeature(ctx) {
m.closest('.mp-slide').classList.add('mp-gone');
m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null));
} }));
+ if (video) capUpscale(media);
const caption = mediaCaption(ev, true);
const stop = (fn) => (e) => { e.stopPropagation(); fn(e); };
const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) },
@@ -14537,11 +14816,30 @@ export function messagesFeature(ctx) {
// (the clip holds still meanwhile, and the time shows); the drag is the
// bar's alone, never the pager's swipe or its tap.
const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); };
+ // A big screen blew a small clip up into blocks (Adam, 2026-10-22: "videos
+ // that are low res … so pixellated on my large 1920 monitor"). In a big
+ // window (a phone on its side is too short) a clip is drawn at most 1.5×
+ // its own pixels,
+ // smaller ones sitting in the middle on the pager's black. A phone fills
+ // its screen as before.
+ const MAX_UPSCALE = 1.5;
+ function capUpscale(v) {
+ const fit = () => {
+ const big = v.videoWidth && matchMedia('(min-width: 1000px) and (min-height: 600px)').matches;
+ const k = MAX_UPSCALE / (window.devicePixelRatio || 1);
+ v.style.maxWidth = big ? Math.round(v.videoWidth * k) + 'px' : '';
+ v.style.maxHeight = big ? Math.round(v.videoHeight * k) + 'px' : '';
+ };
+ v.addEventListener('loadedmetadata', fit);
+ if (v.readyState >= 1) fit();
+ }
// up for a few seconds, and on while a finger is on it
- function revealSeek(bar) {
+ // (a mouse brings it up by moving, so it goes sooner once the mouse is
+ // still, and stays while the pointer rests on the bar itself)
+ function revealSeek(bar, ms = 3500) {
bar.classList.add('reveal');
clearTimeout(bar._hide);
- bar._hide = setTimeout(() => { if (bar._drag) revealSeek(bar); else bar.classList.remove('reveal'); }, 3500);
+ bar._hide = setTimeout(() => { if (bar._drag || bar._hover) revealSeek(bar, ms); else bar.classList.remove('reveal'); }, ms);
}
// What was said, reacted or zapped at a moment of the clip sits over the
// bar at that moment (a tap goes there and says who and what), and floats
@@ -14551,6 +14849,8 @@ export function messagesFeature(ctx) {
const time = h('span', { class: 'mp-seek-time' });
const marks = h('div', { class: 'mp-marks' });
const bar = h('div', { class: 'mp-seek' }, marks, h('div', { class: 'mp-seek-track' }, fill), time);
+ bar.addEventListener('pointerenter', (e) => { if (e.pointerType === 'mouse') bar._hover = true; });
+ bar.addEventListener('pointerleave', () => { bar._hover = false; });
let items = [], sig = '', checked = 0, last = 0;
const say = (x) => {
time.textContent = clock(x.moment.seconds) + ' ' + x.text;
@@ -15358,6 +15658,13 @@ export function messagesFeature(ctx) {
else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); }
}, true);
el.addEventListener('click', (e) => pagerTap(e.target, e.clientY));
+ // On a desktop the bar comes up while the mouse moves over a playing
+ // clip and fades once it's been still a moment (Adam, 2026-10-22).
+ el.addEventListener('pointermove', (e) => {
+ if (e.pointerType !== 'mouse' || !pager || !video) return;
+ const bar = pager.cur && pager.cur.querySelector('.mp-seek.ready');
+ if (bar) revealSeek(bar, 2000);
+ });
// a tap shows (or hides) who posted it and what they said
function pagerTap(target, y) {
if (target.closest('button, a, .mp-caption, .mp-seek')) return;
@@ -15547,7 +15854,7 @@ export function messagesFeature(ctx) {
const c = feedNow();
pager.groups ||= new Map();
const src = pagerSource();
- for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) {
+ for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind, true))) {
const k = mediaKey(it);
if (pager.keys.has(k)) continue;
pager.keys.add(k);
@@ -15643,8 +15950,13 @@ export function messagesFeature(ctx) {
function pagerFit() {
if (!pager) return;
const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group');
+ // the grid's pager goes by the grid's rule: a clip of a shape not yet
+ // known stays out until it's measured (fitClip measures it); a page's
+ // pager shows one until it's known not to fit
+ const strict = pager.kind === 'videos' && !pagerSource();
+ const ok = (u) => (strict ? fitClip(u) : clipFits(u) !== false);
for (const g of pagerGroups(true)) {
- const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || clipFits(m.getAttribute('data-full') || m.getAttribute('data-src')) !== false; });
+ const fits = g._opened || g === cg || [...g._row.children].some((s) => { const m = s.querySelector('.mp-media'); return !m || ok(m.getAttribute('data-full') || m.getAttribute('data-src')); });
pagerToggleGroup(g, 'mp-off', !fits);
}
}
@@ -15726,7 +16038,7 @@ export function messagesFeature(ctx) {
// (a reply whose post every lookup came back without isn't shown: a
// "Couldn't find that note" over it told nobody anything — Adam, 2026-10-20)
const repliesHere = feedReplies(c);
- const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
+ const shownHere = (ev) => !hidden(ev) && !goneHere(ev) && !(curatedFeed && notForPublic(ev)) && !(repliesHere && orphaned(ev));
const visible = c.notes.filter(shownHere);
// Every child keyed by its post, hairlines included, so the morph
// reconciles the list by post: a new one at the top is inserted as its
@@ -18091,13 +18403,7 @@ export function messagesFeature(ctx) {
// A payment-address rename released the old name: repoint the kind 0's
// lud16 and nip05 — but only where they pointed at the released address
// (or were empty). Deliberately different values are not ours to touch.
- addressRenamed(oldAddr, newAddr) {
- const follows = (k) => (base) => !base[k] || base[k] === oldAddr;
- return publishProfileFields({ lud16: newAddr, nip05: newAddr }, {
- onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr),
- fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') },
- }).catch(() => {});
- },
+ addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); },
// opens the profile, unless the caller has a step to take first (the
// Accounts page switches to that identity before showing its profile)
profileChip(pk, size, onOpen) {
diff --git a/src/features/names.js b/src/features/names.js
index 45b0b36..d57bb19 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -455,29 +455,35 @@ export function namesFeature(ctx) {
// said as it's typed, not after a round trip (the registrar is the judge:
// a name claimed before the rule can still be retaken by its owner)
nameRuleHint(ui.nameClaim) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null,
- h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => {
- const name = (ui.nameClaim || '').toLowerCase().trim();
- if (!name) return;
- ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render();
- try {
- // Sign with the login identity when one can be (re)attached: the
- // registrar lets a migrated coinos user's own identity take their
- // name back, which the wallet key alone could never prove.
- const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
- await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {});
- // the wizard's Spending step tells a name claimed here from one
- // the registrar handed back (an account set up before)
- if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name;
- ui.nameClaim = '';
- ui.nameEditOpen = false;
- toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` }));
- } catch (e) {
- ui.nameClaimError = e.message;
- if (ui.nameClaim === name) ui.nameClaimMigration = e.migrationName || null;
- }
- ui.busy = false; render();
- } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')),
- ui.nameClaimMigration && ui.nameClaimMigration === ui.nameClaim && DOMAIN() === 'coinos.io'
+ h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: () => claimTyped(ui.nameClaim) }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')),
+ migrateLink());
+ }
+ async function claimTyped(typed) {
+ const name = (typed || '').toLowerCase().trim();
+ if (!name) return;
+ ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render();
+ try {
+ // Sign with the login identity when one can be (re)attached: the
+ // registrar lets a migrated coinos user's own identity take their
+ // name back, which the wallet key alone could never prove.
+ const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
+ await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {});
+ // the wizard's Spending step tells a name claimed here from one
+ // the registrar handed back (an account set up before)
+ if (ui.onb && ui.onb.step === 'spend') ui.onb.claimedHere = name;
+ ui.nameClaim = '';
+ ui.nameEdit = null;
+ ui.nameEditOpen = false;
+ toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` }));
+ } catch (e) {
+ ui.nameClaimError = e.message;
+ if ((ui.nameEdit ?? ui.nameClaim) === name) ui.nameClaimMigration = e.migrationName || null;
+ }
+ ui.busy = false; render();
+ }
+ // "I have a coinos account": a name the registrar holds for a legacy user
+ function migrateLink() {
+ return ui.nameClaimMigration && ui.nameClaimMigration === (ui.nameEdit ?? ui.nameClaim) && DOMAIN() === 'coinos.io'
? h('button', {
type: 'button', class: 'linklike small',
onClick: () => {
@@ -490,7 +496,48 @@ export function namesFeature(ctx) {
location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`;
}
},
- }, t('onbHaveCoinos')) : null);
+ }, t('onbHaveCoinos')) : null;
+ }
+
+ // Settings: the address and changing it are one card — the name sits in
+ // its own field; typing a different one offers the change (and says the
+ // old address stops working), otherwise Copy and Release.
+ function addressCard(st) {
+ const domain = st.domain || DOMAIN();
+ const addr = `${st.name}@${domain}`;
+ const typed = ui.nameEdit ?? st.name;
+ const changed = !!typed && typed !== st.name;
+ return h('div', { class: 'card col', 'data-key': 'names' },
+ h('h3', {}, t('namesTitle')),
+ ui.nameClaimError ? h('div', { class: 'notice err' }, ui.nameClaimError) : null,
+ h('div', { class: 'row gap6 name-field' },
+ h('input', {
+ type: 'text', class: 'mono-input', placeholder: t('namesPlaceholder'), style: 'flex:1',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', 'aria-label': t('namesCustom'),
+ value: typed,
+ onInput: (e) => {
+ const was = changed;
+ ui.nameEdit = e.target.value.toLowerCase().trim();
+ ui.nameClaimError = null;
+ if (ui.nameClaimMigration) ui.nameClaimMigration = null;
+ if ((!!ui.nameEdit && ui.nameEdit !== st.name) !== was || nameRuleHint(ui.nameEdit)) render();
+ },
+ }),
+ h('span', { class: 'muted', style: 'align-self:center' }, '@' + domain)),
+ changed && nameRuleHint(typed) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null,
+ changed
+ ? h('div', { class: 'col', style: 'gap:8px' },
+ h('p', { class: 'small muted', style: 'margin:0' }, t('namesCustomHow')),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost btn-sm', disabled: ui.busy, onClick: () => { ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); } }, t('cancel')),
+ h('button', { class: 'btn-primary btn-sm grow', disabled: ui.busy, onClick: () => claimTyped(typed) },
+ ui.busy ? h('span', { class: 'spinner sm' }) : t('namesChangeTo', { addr: typed }))),
+ migrateLink())
+ : h('div', { class: 'row gap6' },
+ copyBtn(addr, t('namesCopy')),
+ h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
+ await release(); toast(t('namesReleased')); render();
+ } }, t('namesRelease'))));
}
// Settings: one card per concern, all laid out (no collapsed sections —
@@ -519,14 +566,7 @@ export function namesFeature(ctx) {
h('div', { class: 'addr-box break', style: 'font-size:11px' },
`${st.name}.user._bitcoin-payment.yourdomain.com. CNAME ${st.name}.user._bitcoin-payment.${domain}.`)),
card('ownnode', t('namesOwnNode'), t('namesOwnNodeMenuHow'), ownNodeSection()),
- card('custom', t('namesCustom'), t('namesCustomHow'), claimForm(false)),
- card('names', t('namesTitle'), null,
- h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr),
- h('div', { class: 'row gap6' },
- copyBtn(addr, t('namesCopy')),
- h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
- await release(); toast(t('namesReleased')); render();
- } }, t('namesRelease')))),
+ addressCard(st),
].filter(Boolean);
}
// The terminal's cards live on the Point of sale settings page now — no
@@ -884,7 +924,7 @@ export function namesFeature(ctx) {
checked = false; lastError = null;
// The claim field is a draft for THIS account — a name typed before a
// logout used to greet the next account that opened on this device.
- ui.nameClaim = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null;
+ ui.nameClaim = null; ui.nameEdit = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null;
refresh();
},
// The pencil by the address opens this: choosing a name gets a page of
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index f7f9e16..f39f9c3 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -16,7 +16,7 @@ import { qrSvg } from '../qr.js';
import {
extensionSigner, bunkerSigner, keySigner, parseNostrSecret,
walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker,
- amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, onAmberChange } from '../nostr-login.js';
+ amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, amberOpen, onAmberChange } from '../nostr-login.js';
// The nostr ostrich, in the brand's monochrome.
const OSTRICH = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>';
@@ -408,7 +408,13 @@ export function nostrLoginFeature(ctx) {
if (!p) return null;
const e = ui.amberPaste || (ui.amberPaste = { text: '' });
return h('div', { class: 'card col amber-wait', style: 'gap:8px' },
- h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))),
+ // a request waiting for a tap (see amberOpen): the Open button is the
+ // way forward, not the spinner
+ p.needsTap
+ ? h('div', { class: 'col', style: 'gap:6px' },
+ h('div', { class: 'small' }, t('nlAmberTap')),
+ h('button', { class: 'btn-primary btn-block amber-open', type: 'button', onClick: () => amberOpen() }, t('nlAmberOpen')))
+ : h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))),
h('div', { class: 'small muted' }, t('nlAmberPasteHint')),
h('div', { class: 'row gap6' },
h('input', { type: 'text', class: 'grow amber-paste', placeholder: t('nlAmberPastePh'), value: e.text, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
diff --git a/src/features/nwc.js b/src/features/nwc.js
index ec79081..a2730f5 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -30,7 +30,7 @@ import { t } from '../i18n.js';
import { dlog } from '../debug.js';
import { qrSvg } from '../qr.js';
import { getNetwork } from '../api.js';
-import { loadBg, saveBg, clearBg } from '../nwc-bg.js';
+import { loadBg, saveBg, clearBg, readBgLog } from '../nwc-bg.js';
import { encodeNoffer } from '../noffer.js';
import { maybeBolt11 } from '../ark/lightning.js';
import { withRequestLock } from '../nwc-lock.js';
@@ -56,7 +56,7 @@ const nowSec = () => Math.floor(Date.now() / 1000);
const errRes = (code, message) => ({ error: { code, message } });
export function nwcFeature(ctx) {
- const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx;
+ const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, copy, toast } = ctx;
// Relay transport, injectable so the protocol can be driven in tests
// without a live relay.
const {
@@ -478,11 +478,21 @@ export function nwcFeature(ctx) {
try {
res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat });
} catch (e) {
- // The ASP already holds a payment for this invoice: another device
- // of this wallet took the same request first. Its outcome is ours —
- // wait for it rather than report a failure over a zap that is
- // paying. The payer records the spend; this side only relays.
- if (!/already in progress/i.test(e.message || '')) throw e;
+ // A payment for this invoice already exists: ours, begun on an
+ // earlier request (the worker started it while closed, say), or
+ // another device's — the ASP refuses a second HTLC for one invoice.
+ // Its outcome is the answer, never an error whose text says "paid":
+ // a client once read "already paid or is being paid" as paid, hours
+ // before the money moved. The payer records the spend; this side
+ // only relays.
+ if (!/already in progress|already paid/i.test(e.message || '')) throw e;
+ const mine = await hook('arkLnLookup', { paymentHash: dec.paymentHash }).catch(() => null);
+ if (mine && mine.type === 'outgoing' && mine.state === 'settled' && mine.preimage) {
+ return { sibling: true, result: { preimage: mine.preimage, fees_paid: 0 } };
+ }
+ if (mine && mine.type === 'outgoing' && mine.state === 'pending') {
+ return errRes('OTHER', 'a payment for this invoice is still in progress; ask again in a moment');
+ }
const out = await hook('arkLnOutcome', dec.paymentHash, 60000);
if (!out || out.status !== 'success') throw e;
return { sibling: true, result: { preimage: out.preimage, fees_paid: 0 } };
@@ -822,6 +832,25 @@ export function nwcFeature(ctx) {
// wrong keys — background wake-ups silently die the first time a
// connection is recreated. Debounced; the server replaces by endpoint.
let regTimer = null;
+ // The browser's subscription, made again when it's gone: iOS drops it after
+ // three pushes that showed no notification (and a key rotation strands an
+ // old one), and nothing else re-arms a wallet whose `background` is still on.
+ async function currentSub() {
+ const reg = await navigator.serviceWorker.ready;
+ const { publicKey } = await (await fetch(`${NOTIFIER}/vapid`)).json();
+ const want = b64ToBytes(publicKey);
+ let sub = await reg.pushManager.getSubscription();
+ if (sub) {
+ let have = [];
+ try { have = new Uint8Array(sub.options.applicationServerKey || []); } catch {}
+ if (have.length === want.length && have.every((b, i) => b === want[i])) return sub.toJSON();
+ try { await sub.unsubscribe(); } catch {}
+ }
+ if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return null;
+ sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: want });
+ dlog('nwc: push subscription was gone, made a new one');
+ return sub.toJSON();
+ }
function refreshRegistration() {
if (!load().background) return;
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return;
@@ -831,8 +860,7 @@ export function nwcFeature(ctx) {
const pks = watchPks();
if (!pks.length) return;
const up2 = upEndpoint();
- const sub = up2 ? { endpoint: up2, unifiedpush: true }
- : await (await navigator.serviceWorker.ready).pushManager.getSubscription().then((x) => x && x.toJSON());
+ const sub = up2 ? { endpoint: up2, unifiedpush: true } : await currentSub();
if (!sub) return;
await fetch(`${NOTIFIER}/register`, {
method: 'POST', headers: { 'content-type': 'application/json' },
@@ -949,7 +977,7 @@ export function nwcFeature(ctx) {
if (typeof window !== 'undefined') {
window.addEventListener('online', () => listen());
document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible' && conns().length) listen();
+ if (document.visibilityState === 'visible' && conns().length) { listen(); refreshRegistration(); }
});
}
}
@@ -1075,7 +1103,19 @@ export function nwcFeature(ctx) {
h('button', { class: 'btn-sm', onClick: () => {
const s = load(); s.paySentNotify = !on; save(s); writeBg(); render();
} }, on ? t('nwcOn') : t('nwcOff'))),
- h('div', { class: 'small faint' }, t('notifPaySentHint')))];
+ h('div', { class: 'small faint' }, t('notifPaySentHint'))),
+ // the worker's diary, for the report that says where a closed wallet
+ // got to (a phone stops it without a trace)
+ h('div', { class: 'card col', style: 'gap:10px' },
+ h('div', { class: 'row between' },
+ h('span', { class: 'small' }, t('nwcWorkerLog')),
+ h('button', { class: 'btn-sm', onClick: async () => {
+ const lines = await readBgLog();
+ if (!lines.length) { toast(t('nwcWorkerLogEmpty')); return; }
+ await copy(lines.join('\n'));
+ toast(t('copied'));
+ } }, t('copy'))),
+ h('div', { class: 'small faint' }, t('nwcWorkerLogHint')))];
},
};
}
diff --git a/src/i18n.js b/src/i18n.js
index a07cba4..a01999a 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -282,6 +282,8 @@ const STR = {
clipsAll: 'All shapes',
clipsToggleTitle: 'Which clips to show: the ones that fit the screen, or all',
videoTapToPlay: "Tap to play",
+ videoPlay: "Play",
+ videoPause: "Pause",
pagerFetchingVideo: "Fetching the next video…",
pagerFetchingPictures: "Fetching more pictures…",
pagerCaughtUp: "That’s everything for now",
@@ -777,6 +779,31 @@ 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',
+ safeBootTitle: 'Coinos opened in safe mode',
+ safeBootBody: 'The app was closed {n} times in a row before it finished opening, so automatic connections are paused this time. Your coins and keys are untouched. Last step reached: {phase}.',
+ safeBootOpen: 'Open normally',
+ safeBootCopy: 'Copy report',
+ safeBootCopied: 'Copied',
+ 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. The one exception: a coin whose payment history has grown very long is renewed free of the base fee, so it stays spendable.',
+ 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 +827,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?',
@@ -1030,6 +1059,7 @@ const STR = {
giftWelcome: 'You’ve been sent Bitcoin',
giftBackedUp: 'I’ve written it down',
giftLink: 'Gift some Bitcoin',
+ giftNotePlaceholder: 'Add a note (optional)',
giftLinkDesc: 'Presign an amount as a link. Whoever opens it claims the Bitcoin into a brand-new wallet that only they control.',
giftLinkWarn: '⚠ Anyone who opens this link can claim the amount, so share it only with the person you are gifting. Keep amounts small.',
giftLinkReveal: 'Create gift link',
@@ -1093,6 +1123,7 @@ const STR = {
claimOr: 'or',
goToWallet: 'Go to wallet',
claimBtn: 'Claim it',
+ claimNowBtn: 'Claim it now',
claimFeeNote: '~{n} fee',
claimFailed: 'This gift has already been claimed or is no longer available.',
claimNotVisibleBody: 'The coins funding this gift aren\u2019t visible from this wallet\u2019s network/data source. Make sure this wallet uses the same network and data source as the sender, then try again.',
@@ -1359,6 +1390,8 @@ const STR = {
onbSpendBody: 'Claim a username and get your own payment address. People can send you bitcoin instantly, with just your name.',
onbNameWait: 'Getting things ready…',
onbNotNow: 'Not now',
+ onbGiftNameTitle: 'Now pick a username',
+ onbGiftNameBody: 'Claim a name and get your own payment address, so people can send you bitcoin with just your name. Then choose a picture and a cover.',
onbAvatarTitle: 'Choose your look',
onbAvatarBody: 'Pick a cover and an avatar, or upload your own. You can change them anytime.',
onbCover: 'Cover image',
@@ -1384,6 +1417,8 @@ const STR = {
nlExtension: 'Use browser extension',
nlAmber: 'Sign in with Amber (Android)',
nlAmberWait: 'Waiting for your signer app… approve there, then come back here.',
+ nlAmberTap: 'Your signer app has another request to answer. Open it to continue.',
+ nlAmberOpen: 'Open signer app',
nlAmberPasteHint: 'The answer is read from the clipboard when you return. If nothing happens, paste it here:',
nlAmberPastePh: 'Paste the signer’s answer',
nlAmberPasteBtn: 'Use it',
@@ -1445,6 +1480,7 @@ const STR = {
namesClaim: 'Claim',
namesClaimed: 'You are now {name}',
namesCopy: 'Copy address',
+ namesChangeTo: 'Change to {addr}',
namesMoreOpen: 'More options',
namesMoreTitle: 'More ways to get paid',
namesMoreBolt11: 'Lightning invoice (BOLT 11)',
@@ -1568,6 +1604,9 @@ const STR = {
notifChatHint: 'Community chat is opt-in per room. Turn it on from a room’s menu.',
notifPaySent: 'Payments sent by connected apps',
notifPaySentHint: 'A heads-up when a connected app (a nostr client zapping, for example) spends from this wallet while it’s closed.',
+ nwcWorkerLog: 'Background activity log',
+ nwcWorkerLogHint: 'What the wallet did while it was closed: wake-ups, payments, notifications. Copy it when reporting a problem.',
+ nwcWorkerLogEmpty: 'Nothing recorded yet',
nwcNotifHint: 'You’ll be asked to allow notifications. The connection uses them to answer when Coinos is closed.',
nwcNotifNeeded: 'Without notifications the connection couldn’t answer when Coinos is closed, so it wasn’t added. Allow notifications and try again.',
nwcNotifBlocked: 'Notifications are blocked for this site. Allow them in your browser settings, then add the connection again.',
diff --git a/src/image-thumbnails.js b/src/image-thumbnails.js
index ffac4b9..2686dac 100644
--- a/src/image-thumbnails.js
+++ b/src/image-thumbnails.js
@@ -14,16 +14,21 @@ function thumbnailWorker() {
const result = { width: bitmap.width, height: bitmap.height, media: null, tile: null };
// GIFs must keep animating; a canvas copy would freeze their first frame.
if (!/gif/i.test(blob.type)) {
+ // The grid's tile: the picture in its own shape (justified rows),
+ // tileSize tall, its middle cut to between 1:2 and 2:1 — the most a
+ // tile stretches either way.
if (tileSize) {
- const side = Math.min(bitmap.width, bitmap.height), px = Math.min(tileSize, side);
- let square;
+ const W = bitmap.width, H = bitmap.height;
+ const cw = Math.min(W, H * 2), ch = Math.min(H, W * 2);
+ const th = Math.min(tileSize, ch), tw = Math.max(1, Math.round(th * cw / ch));
+ let cut;
try {
- square = await createImageBitmap(bitmap, (bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side,
- { resizeWidth: px, resizeHeight: px, resizeQuality: 'high' });
- const canvas = new OffscreenCanvas(px, px);
- canvas.getContext('2d').drawImage(square, 0, 0);
+ cut = await createImageBitmap(bitmap, (W - cw) / 2, (H - ch) / 2, cw, ch,
+ { resizeWidth: tw, resizeHeight: th, resizeQuality: 'high' });
+ const canvas = new OffscreenCanvas(tw, th);
+ canvas.getContext('2d').drawImage(cut, 0, 0);
result.tile = await encode(canvas, 0.8);
- } catch {} finally { square?.close(); }
+ } catch {} finally { cut?.close(); }
}
if (width && bitmap.width > width * 1.25) {
try {
diff --git a/src/locales/ar.json b/src/locales/ar.json
index f112568..7d11408 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -364,6 +364,7 @@
"giftWelcome": "لقد أُرسلت إليك عملة Bitcoin",
"giftBackedUp": "لقد دوّنتها",
"giftLink": "أهدِ بعض عملات Bitcoin",
+ "giftNotePlaceholder": "أضف ملاحظة (اختياري)",
"giftLinkDesc": "وقّع مبلغاً مسبقاً كرابط. من يفتحه يطالب بعملات Bitcoin في محفظة جديدة تماماً يتحكم بها وحده.",
"giftLinkWarn": "⚠ أي شخص يفتح هذا الرابط يمكنه المطالبة بالمبلغ، لذا شاركه فقط مع الشخص الذي تهديه إياه. أبقِ المبالغ صغيرة.",
"giftLinkReveal": "إنشاء رابط الإهداء",
@@ -425,6 +426,7 @@
"claimOr": "أو",
"goToWallet": "اذهب إلى المحفظة",
"claimBtn": "طالب بها",
+ "claimNowBtn": "استلمها الآن",
"claimFeeNote": "♪{n} الرسوم",
"claimFailed": "تمت المطالبة بهذه الهدية بالفعل أو لم تعد متاحة.",
"claimNotVisibleBody": "والعملات التي تمول هذه الهدية غير مرئية من مصدر هذه المحفظة/البيانات. تأكد من استخدام هذه المحفظة نفس الشبكة ومصدر البيانات كمرسل، ثم محاولة مرة أخرى.",
@@ -618,6 +620,8 @@
"onbSpendBody": "اطالب بلقب مستخدم والحصول على عنوان الدفع الخاص بك - الناس يمكن أن ترسل لك قليلا على الفور، مع مجرد اسمك.",
"onbNameWait": "جهز الأشياء",
"onbNotNow": "ليس الآن",
+ "onbGiftNameTitle": "الآن اختر اسم مستخدم",
+ "onbGiftNameBody": "احجز اسمًا واحصل على عنوان دفع خاص بك، ليتمكن الناس من إرسال البيتكوين إليك باسمك فقط. ثم اختر صورة وغلافًا.",
"onbAvatarTitle": "اختر مظهرك",
"onbAvatarBody": "اختر صورة رمزية أو ارفع صورتك. يمكنك تغييرها في أي وقت",
"onbUpload": "تحميل الصورة",
@@ -678,6 +682,7 @@
"namesClaim": "المطالبة",
"namesClaimed": "أنت الآن {name}",
"namesCopy": "عنوان الرسالة",
+ "namesChangeTo": "غيّره إلى {addr}",
"namesMoreOpen": "المزيد من الخيارات",
"namesMoreTitle": "المزيد من الطرق للحصول على أجر",
"namesMoreBolt11": "فاتورة البرق (BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "لا صور في هذه الخلاصة بعد.",
"mediaNoVideos": "لا فيديوهات عمودية في هذه الخلاصة بعد.",
"videoTapToPlay": "انقر للتشغيل",
+ "videoPlay": "تشغيل",
+ "videoPause": "إيقاف مؤقت",
"videoLargeTitle": "الفيديوهات",
"videoLargeToggle": "تشغيل الفيديوهات الكبيرة تلقائيًا",
"videoLargeHelp": "الفيديوهات التي تزيد على {mb} ميغابايت أو تتجاوز 1080p تنتظر نقرة، حتى لا تستهلك الخلاصة بياناتك. الحد أقل على الاتصال البطيء أو عند توفير البيانات.",
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 85ce58a..27cac0b 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -364,6 +364,7 @@
"giftWelcome": "আপনাকে Bitcoin পাঠানো হয়েছে",
"giftBackedUp": "আমি এটি লিখে রেখেছি",
"giftLink": "কিছু Bitcoin উপহার দিন",
+ "giftNotePlaceholder": "একটি নোট যোগ করুন (ঐচ্ছিক)",
"giftLinkDesc": "একটি পরিমাণ লিঙ্ক হিসেবে আগে থেকে সই করুন। যে এটি খুলবে সে Bitcoin-টি একটি একদম নতুন ওয়ালেটে দাবি করে, যা কেবল সে-ই নিয়ন্ত্রণ করে।",
"giftLinkWarn": "⚠ যে কেউ এই লিঙ্ক খুললে পরিমাণটি দাবি করতে পারে, তাই কেবল যাকে উপহার দিচ্ছেন তার সাথেই এটি শেয়ার করুন। পরিমাণ কম রাখুন।",
"giftLinkReveal": "লিংক নির্মাণ করুন",
@@ -425,6 +426,7 @@
"claimOr": "অথবা",
"goToWallet": "ওয়ালেট যাও",
"claimBtn": "দাবি করুন",
+ "claimNowBtn": "এখনই নিন",
"claimFeeNote": "একটিও নয়{n} কাগজ",
"claimFailed": "এই উপহারটি আগেই দাবি করা হয়েছে অথবা আর পাওয়া যাচ্ছে না।",
"claimNotVisibleBody": "এই দানের অর্থ এই ওয়ালেটের নেটওয়ার্ক/ডাটা উৎস থেকে দেখা যাচ্ছে না। নিশ্চিত করুন যে এই ওয়ালেট একই নেটওয়ার্ক এবং ডাটা উৎস ব্যবহার করে, অতঃপর পুনরায় চেষ্টা করুন ।",
@@ -618,6 +620,8 @@
"onbSpendBody": "আপনার নাম শুধু আপনার নামের সঙ্গে সঙ্গে সঙ্গে সঙ্গে আপনি আপনার পেকেন পাঠাতে পারেন ।",
"onbNameWait": "জিনিস প্রস্তুত করা হচ্ছে...",
"onbNotNow": "এই মুহূর্তে নয়",
+ "onbGiftNameTitle": "এবার একটি ইউজারনেম বেছে নিন",
+ "onbGiftNameBody": "একটি নাম নিন এবং নিজের পেমেন্ট ঠিকানা পান, যাতে লোকে শুধু আপনার নাম দিয়েই আপনাকে বিটকয়েন পাঠাতে পারে। তারপর একটি ছবি আর একটি কভার বেছে নিন।",
"onbAvatarTitle": "আপনার চেহারা পছন্দ করুন",
"onbAvatarBody": "একটি অবতার বেছে নাও অথবা নিজের ছবি আপলোড করো। যেকোনো সময় এটি বদলাতে পারো।",
"onbUpload": "একটি ছবি আপলোড করুন",
@@ -678,6 +682,7 @@
"namesClaim": "মুছে ফেলুন (_D)",
"namesClaimed": "তুমি এখন {name}",
"namesCopy": "ঠিকানা কপি করো",
+ "namesChangeTo": "{addr}-এ বদলান",
"namesMoreOpen": "অতিরিক্ত বিকল্প",
"namesMoreTitle": "আরো টাকা পেতে",
"namesMoreBolt11": "(বিওএল ১১)",
@@ -910,6 +915,8 @@
"mediaNoImages": "এই ফিডে এখনও কোনো ছবি নেই।",
"mediaNoVideos": "এই ফিডে এখনও কোনো খাড়া ভিডিও নেই।",
"videoTapToPlay": "চালাতে ট্যাপ করুন",
+ "videoPlay": "চালান",
+ "videoPause": "বিরতি",
"videoLargeTitle": "ভিডিও",
"videoLargeToggle": "বড় ভিডিও নিজে থেকে চালান",
"videoLargeHelp": "{mb} MB-এর বেশি বা 1080p-এর ওপরের ভিডিও ট্যাপের অপেক্ষায় থাকে, যাতে ফিড আপনার ডেটা না খায়। ধীর বা ডেটা-সেভার সংযোগে সীমা কম।",
diff --git a/src/locales/de.json b/src/locales/de.json
index 0210332..3a18040 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -364,6 +364,7 @@
"giftWelcome": "Dir wurde Bitcoin gesendet",
"giftBackedUp": "Ich habe sie aufgeschrieben",
"giftLink": "Bitcoin verschenken",
+ "giftNotePlaceholder": "Notiz hinzufügen (optional)",
"giftLinkDesc": "Signiere einen Betrag im Voraus als Link. Wer ihn öffnet, beansprucht das Bitcoin in einer brandneuen Wallet, die nur er selbst kontrolliert.",
"giftLinkWarn": "⚠ Wer diesen Link öffnet, kann den Betrag beanspruchen. Teile ihn daher nur mit der Person, die du beschenkst. Halte die Beträge klein.",
"giftLinkReveal": "Geschenk-Link erstellen",
@@ -425,6 +426,7 @@
"claimOr": "oder",
"goToWallet": "Zur Brieftasche gehen",
"claimBtn": "Beanspruchen",
+ "claimNowBtn": "Jetzt einlösen",
"claimFeeNote": "~{n} Gebühr",
"claimFailed": "Dieses Geschenk wurde bereits beansprucht oder ist nicht mehr verfügbar.",
"claimNotVisibleBody": "Die Münzen finanzieren dieses Geschenk sind nicht sichtbar aus dieser Brieftasche Netzwerk / Datenquelle. Stellen Sie sicher, dass diese Brieftasche die gleiche Netzwerk- und Datenquelle wie der Absender verwendet, dann versuchen Sie es erneut.",
@@ -618,6 +620,8 @@
"onbSpendBody": "Fordern Sie einen Benutzernamen an und erhalten Sie Ihre eigene Zahlungsadresse Die Leute können Ihnen Bitcoin sofort mit Ihrem Namen senden.",
"onbNameWait": "Bereitmachen...",
"onbNotNow": "Nicht jetzt",
+ "onbGiftNameTitle": "Wähle jetzt einen Benutzernamen",
+ "onbGiftNameBody": "Sichere dir einen Namen und deine eigene Zahlungsadresse, damit man dir Bitcoin einfach an deinen Namen senden kann. Danach wählst du ein Bild und ein Titelbild.",
"onbAvatarTitle": "Wählen Sie Ihren Look",
"onbAvatarBody": "Wählen Sie einen Avatar oder laden Sie Ihr eigenes Bild hoch. Sie können ihn jederzeit ändern.",
"onbUpload": "Foto hochladen",
@@ -678,6 +682,7 @@
"namesClaim": "Anspruch",
"namesClaimed": "Du bist jetzt {name}",
"namesCopy": "Adresse kopieren",
+ "namesChangeTo": "Ändern zu {addr}",
"namesMoreOpen": "Weitere Optionen",
"namesMoreTitle": "Mehr Möglichkeiten, um bezahlt zu werden",
"namesMoreBolt11": "Blitzrechnung (BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "Noch keine Bilder in diesem Feed.",
"mediaNoVideos": "Noch keine Hochkant-Videos in diesem Feed.",
"videoTapToPlay": "Zum Abspielen tippen",
+ "videoPlay": "Abspielen",
+ "videoPause": "Pause",
"videoLargeTitle": "Videos",
"videoLargeToggle": "Große Videos automatisch abspielen",
"videoLargeHelp": "Videos über {mb} MB oder über 1080p warten auf einen Tipp, damit der Feed nicht dein Datenvolumen frisst. Bei langsamer Verbindung oder Datensparmodus ist die Grenze niedriger.",
diff --git a/src/locales/es.json b/src/locales/es.json
index 9637b0a..40176a7 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -364,6 +364,7 @@
"giftWelcome": "Te han enviado Bitcoin",
"giftBackedUp": "Ya la anoté",
"giftLink": "Regala algo de Bitcoin",
+ "giftNotePlaceholder": "Añade una nota (opcional)",
"giftLinkDesc": "Prefirma un importe como enlace. Quien lo abra reclama el Bitcoin en una billetera totalmente nueva que solo esa persona controla.",
"giftLinkWarn": "⚠ Cualquiera que abra este enlace puede reclamar el importe, así que compártelo solo con la persona a quien se lo regalas. Mantén los importes pequeños.",
"giftLinkReveal": "Crear enlace de regalo",
@@ -425,6 +426,7 @@
"claimOr": "o",
"goToWallet": "Ve a la cartera.",
"claimBtn": "Reclamarlo",
+ "claimNowBtn": "Reclámalo ahora",
"claimFeeNote": "~{n} tarifa",
"claimFailed": "Este regalo ya ha sido reclamado o ya no está disponible.",
"claimNotVisibleBody": "Las monedas que financian este regalo no son visibles desde la fuente de red/datos de esta cartera. Asegúrese de que esta cartera utiliza la misma red y fuente de datos que el remitente, a continuación, inténtelo de nuevo.",
@@ -618,6 +620,8 @@
"onbSpendBody": "Reclame un nombre de usuario y obtenga su propia dirección de pago, la gente puede enviarle bitcoin al instante, con sólo su nombre.",
"onbNameWait": "Preparando las cosas...",
"onbNotNow": "Ahora no.",
+ "onbGiftNameTitle": "Ahora elige un nombre de usuario",
+ "onbGiftNameBody": "Reclama un nombre y obtén tu propia dirección de pago, para que te puedan enviar bitcoin solo con tu nombre. Después elige una foto y una portada.",
"onbAvatarTitle": "Elige tu look",
"onbAvatarBody": "Escoge un avatar o sube tu propia imagen. Puedes cambiarlo en cualquier momento.",
"onbUpload": "Sube una foto",
@@ -678,6 +682,7 @@
"namesClaim": "Reclamación",
"namesClaimed": "Ahora sí. {name}",
"namesCopy": "Dirección de copia",
+ "namesChangeTo": "Cambiar a {addr}",
"namesMoreOpen": "Más opciones",
"namesMoreTitle": "Más formas de pagar",
"namesMoreBolt11": "Factura de relámpago (BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "Aún no hay imágenes en este feed.",
"mediaNoVideos": "Aún no hay vídeos verticales en este feed.",
"videoTapToPlay": "Toca para reproducir",
+ "videoPlay": "Reproducir",
+ "videoPause": "Pausa",
"videoLargeTitle": "Vídeos",
"videoLargeToggle": "Reproducir vídeos grandes automáticamente",
"videoLargeHelp": "Los vídeos de más de {mb} MB o por encima de 1080p esperan a que los toques, para que el feed no se coma tus datos. El límite es menor con una conexión lenta o en ahorro de datos.",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 280be04..407794b 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -364,6 +364,7 @@
"giftWelcome": "Vous avez reçu des Bitcoin",
"giftBackedUp": "Je l’ai notée",
"giftLink": "Offrir des Bitcoin",
+ "giftNotePlaceholder": "Ajouter un mot (facultatif)",
"giftLinkDesc": "Présignez un montant sous forme de lien. Celui qui l’ouvre récupère les Bitcoin dans un tout nouveau portefeuille qu’il est seul à contrôler.",
"giftLinkWarn": "⚠ Quiconque ouvre ce lien peut récupérer le montant, alors ne le partagez qu’avec la personne à qui vous l’offrez. Gardez les montants faibles.",
"giftLinkReveal": "Créer le lien cadeau",
@@ -425,6 +426,7 @@
"claimOr": "ou",
"goToWallet": "Allez dans le portefeuille",
"claimBtn": "Récupérer",
+ "claimNowBtn": "Récupérer maintenant",
"claimFeeNote": "~{n} Taxe",
"claimFailed": "Ce cadeau a déjà été réclamé ou n’est plus disponible.",
"claimNotVisibleBody": "Les pièces de monnaie qui financent ce cadeau ne sont pas visibles à partir de ce portefeuille. Assurez-vous que ce portefeuille utilise le même réseau et la même source de données que l'expéditeur, puis essayez à nouveau.",
@@ -618,6 +620,8 @@
"onbSpendBody": "Réclamez un nom d'utilisateur et obtenez votre propre adresse de paiement, les gens peuvent vous envoyer Bitcoin instantanément, avec juste votre nom.",
"onbNameWait": "Préparer les choses...",
"onbNotNow": "Pas maintenant.",
+ "onbGiftNameTitle": "Choisissez maintenant un nom d'utilisateur",
+ "onbGiftNameBody": "Réservez un nom et obtenez votre propre adresse de paiement, pour qu'on puisse vous envoyer des bitcoins avec votre seul nom. Choisissez ensuite une photo et une couverture.",
"onbAvatarTitle": "Choisissez votre look",
"onbAvatarBody": "Choisissez un avatar ou téléchargez votre propre photo. Vous pouvez le changer à tout moment.",
"onbUpload": "Télécharger une photo",
@@ -678,6 +682,7 @@
"namesClaim": "Réclamation",
"namesClaimed": "Vous êtes maintenant {name}",
"namesCopy": "Copier l'adresse",
+ "namesChangeTo": "Passer à {addr}",
"namesMoreOpen": "Autres options",
"namesMoreTitle": "Plus de moyens de se faire payer",
"namesMoreBolt11": "Facture de foudre (BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "Pas encore d’images dans ce fil.",
"mediaNoVideos": "Pas encore de vidéos verticales dans ce fil.",
"videoTapToPlay": "Touchez pour lire",
+ "videoPlay": "Lire",
+ "videoPause": "Pause",
"videoLargeTitle": "Vidéos",
"videoLargeToggle": "Lire automatiquement les grosses vidéos",
"videoLargeHelp": "Les vidéos de plus de {mb} Mo ou au-delà de 1080p attendent un toucher, pour que le fil ne dévore pas vos données. La limite est plus basse sur une connexion lente ou en économie de données.",
diff --git a/src/locales/hi.json b/src/locales/hi.json
index 8057344..41eb792 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -364,6 +364,7 @@
"giftWelcome": "आपको Bitcoin भेजा गया है",
"giftBackedUp": "मैंने इसे लिख लिया है",
"giftLink": "कुछ Bitcoin उपहार में दें",
+ "giftNotePlaceholder": "एक नोट जोड़ें (वैकल्पिक)",
"giftLinkDesc": "एक राशि को लिंक के रूप में पहले से साइन करें। जो भी इसे खोलेगा, वह Bitcoin को एक बिल्कुल नए वॉलेट में प्राप्त कर लेगा जिसे केवल वही नियंत्रित करता है।",
"giftLinkWarn": "⚠ जो भी इस लिंक को खोलेगा वह राशि प्राप्त कर सकता है, इसलिए इसे केवल उसी व्यक्ति के साथ साझा करें जिसे आप उपहार दे रहे हैं। राशि कम रखें।",
"giftLinkReveal": "उपहार लिंक बनाएँ",
@@ -425,6 +426,7 @@
"claimOr": "या",
"goToWallet": "वॉलेट पर जाएं",
"claimBtn": "इसे प्राप्त करें",
+ "claimNowBtn": "अभी लें",
"claimFeeNote": "~{n} शुल्क",
"claimFailed": "इस उपहार का दावा पहले ही किया जा चुका है या यह अब उपलब्ध नहीं है।",
"claimNotVisibleBody": "इस उपहार को वित्तपोषित करने वाले सिक्के इस वॉलेट के नेटवर्क / डेटा स्रोत से दिखाई नहीं देते हैं। सुनिश्चित करें कि यह वॉलेट प्रेषक के समान नेटवर्क और डेटा स्रोत का उपयोग करता है, फिर फिर से प्रयास करें।",
@@ -618,6 +620,8 @@
"onbSpendBody": "एक उपयोगकर्ता नाम का दावा करें और अपना खुद का भुगतान पता प्राप्त करें - लोग तुरंत अपने नाम के साथ बिटकॉइन भेज सकते हैं।",
"onbNameWait": "चीजों को तैयार करना",
"onbNotNow": "अब नहीं",
+ "onbGiftNameTitle": "अब एक यूज़रनेम चुनें",
+ "onbGiftNameBody": "एक नाम लें और अपना भुगतान पता पाएं, ताकि लोग सिर्फ़ आपके नाम से आपको बिटकॉइन भेज सकें। फिर एक तस्वीर और एक कवर चुनें।",
"onbAvatarTitle": "अपना लुक चुनें",
"onbAvatarBody": "एक अवतार चुनें या अपनी तस्वीर अपलोड करें। आप इसे कभी भी बदल सकते हैं।",
"onbUpload": "फ़ोटो अपलोड करें",
@@ -678,6 +682,7 @@
"namesClaim": "दावा",
"namesClaimed": "अब {name}",
"namesCopy": "पता कॉपी करें",
+ "namesChangeTo": "{addr} में बदलें",
"namesMoreOpen": "अधिक विकल्प",
"namesMoreTitle": "भुगतान करने के तरीके",
"namesMoreBolt11": "बिजली चालान (BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "इस फ़ीड में अभी कोई तस्वीर नहीं।",
"mediaNoVideos": "इस फ़ीड में अभी कोई वर्टिकल वीडियो नहीं।",
"videoTapToPlay": "चलाने के लिए टैप करें",
+ "videoPlay": "चलाएँ",
+ "videoPause": "रोकें",
"videoLargeTitle": "वीडियो",
"videoLargeToggle": "बड़े वीडियो अपने आप चलाएँ",
"videoLargeHelp": "{mb} MB से बड़े या 1080p से ऊपर के वीडियो टैप का इंतज़ार करते हैं, ताकि फ़ीड आपका डेटा न खाए। धीमे या डेटा-सेवर कनेक्शन पर सीमा कम होती है।",
diff --git a/src/locales/id.json b/src/locales/id.json
index 6773d96..f16c07b 100644
--- a/src/locales/id.json
+++ b/src/locales/id.json
@@ -137,6 +137,7 @@
"giftSkip": "Lewati dulu",
"giftBackedUp": "Saya sudah mencatatnya",
"giftLink": "Hadiahkan Bitcoin",
+ "giftNotePlaceholder": "Tambahkan catatan (opsional)",
"giftLinkDesc": "Tandatangani sejumlah dana sebagai tautan. Siapa pun yang membukanya mengklaim Bitcoin itu ke dompet baru yang hanya dia kendalikan.",
"giftLinkWarn": "⚠ Siapa pun yang membuka tautan ini bisa mengklaim jumlahnya, jadi bagikan hanya kepada orang yang Anda beri hadiah. Jaga jumlahnya tetap kecil.",
"giftAmountLabel": "Jumlah yang dihadiahkan",
@@ -145,6 +146,7 @@
"giftReserved": "Tautan hadiah belum diklaim ({n})",
"claimBody": "Klaim ke dompet baru yang hanya Anda kendalikan.",
"claimBtn": "Klaim",
+ "claimNowBtn": "Klaim sekarang",
"claimFailed": "Hadiah ini sudah diklaim atau tidak lagi tersedia.",
"claimedTitle": "Diklaim!",
"claimedBody": "Bitcoin Anda sedang dalam perjalanan. Cadangkan frasa pemulihan Anda agar tetap aman.",
@@ -247,6 +249,8 @@
"mediaNoImages": "Belum ada gambar di feed ini.",
"mediaNoVideos": "Belum ada video vertikal di feed ini.",
"videoTapToPlay": "Ketuk untuk memutar",
+ "videoPlay": "Putar",
+ "videoPause": "Jeda",
"videoLargeTitle": "Video",
"videoLargeToggle": "Putar otomatis video besar",
"videoLargeHelp": "Video di atas {mb} MB atau lebih dari 1080p menunggu ketukan, agar feed tidak menghabiskan kuota. Batasnya lebih rendah di koneksi lambat atau mode hemat data.",
@@ -261,5 +265,8 @@
"arkRenewViewTx": "Lihat transaksi ronde",
"pagerFetchingVideo": "Mengambil video berikutnya…",
"pagerFetchingPictures": "Mengambil gambar lainnya…",
- "pagerCaughtUp": "Sementara itu saja"
+ "pagerCaughtUp": "Sementara itu saja",
+ "onbGiftNameTitle": "Sekarang pilih nama pengguna",
+ "onbGiftNameBody": "Klaim sebuah nama dan dapatkan alamat pembayaran sendiri, agar orang bisa mengirimimu bitcoin hanya dengan namamu. Lalu pilih foto dan sampul.",
+ "namesChangeTo": "Ganti ke {addr}"
}
diff --git a/src/locales/it.json b/src/locales/it.json
index 7ac8b00..9988888 100644
--- a/src/locales/it.json
+++ b/src/locales/it.json
@@ -222,6 +222,8 @@
"playVideo": "Riproduci",
"videoUnmute": "Attiva audio",
"videoTapToPlay": "Tocca per riprodurre",
+ "videoPlay": "Riproduci",
+ "videoPause": "Pausa",
"videoNoPicture": "Questo browser non riesce a mostrare l'immagine di questo video",
"videoOpenFile": "Apri il file",
"videoLargeTitle": "Video",
@@ -825,6 +827,7 @@
"giftWelcome": "Ti hanno inviato dei Bitcoin",
"giftBackedUp": "L'ho scritta",
"giftLink": "Regala dei Bitcoin",
+ "giftNotePlaceholder": "Aggiungi un messaggio (facoltativo)",
"giftLinkDesc": "Prefirma un importo sotto forma di link. Chi lo apre riscuote i Bitcoin in un portafoglio nuovo di zecca che controlla solo lui.",
"giftLinkWarn": "⚠ Chiunque apra questo link può riscuotere l'importo, quindi condividilo solo con la persona a cui fai il regalo. Mantieni importi piccoli.",
"giftLinkReveal": "Crea link regalo",
@@ -888,6 +891,7 @@
"claimOr": "oppure",
"goToWallet": "Vai al portafoglio",
"claimBtn": "Riscuoti",
+ "claimNowBtn": "Riscatta ora",
"claimFeeNote": "~{n} di commissione",
"claimFailed": "Questo regalo è già stato riscosso o non è più disponibile.",
"claimNotVisibleBody": "Le monete che finanziano questo regalo non sono visibili dalla rete/fonte dati di questo portafoglio. Assicurati che questo portafoglio usi la stessa rete e fonte dati del mittente, poi riprova.",
@@ -1154,6 +1158,8 @@
"onbSpendBody": "Scegli un nome utente e ottieni il tuo indirizzo di pagamento. Chiunque potrà inviarti bitcoin all’istante, solo con il tuo nome.",
"onbNameWait": "Preparazione in corso…",
"onbNotNow": "Non ora",
+ "onbGiftNameTitle": "Ora scegli un nome utente",
+ "onbGiftNameBody": "Prendi un nome e ottieni il tuo indirizzo di pagamento, così ti possono inviare bitcoin solo con il tuo nome. Poi scegli una foto e una copertina.",
"onbAvatarTitle": "Scegli il tuo aspetto",
"onbAvatarBody": "Scegli una copertina e un avatar, oppure carica i tuoi. Puoi cambiarli quando vuoi.",
"onbCover": "Immagine di copertina",
@@ -1240,6 +1246,7 @@
"namesClaim": "Richiedi",
"namesClaimed": "Ora sei {name}",
"namesCopy": "Copia indirizzo",
+ "namesChangeTo": "Cambia in {addr}",
"namesMoreOpen": "Altre opzioni",
"namesMoreTitle": "Altri modi per farti pagare",
"namesMoreBolt11": "Fattura Lightning (BOLT 11)",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index b8968cc..010955a 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -364,6 +364,7 @@
"giftWelcome": "Bitcoin が届きました",
"giftBackedUp": "書き留めました",
"giftLink": "Bitcoin を贈る",
+ "giftNotePlaceholder": "メッセージを添える(任意)",
"giftLinkDesc": "金額をリンクとして事前署名します。リンクを開いた人が、その人だけが管理できる真新しいウォレットに Bitcoin を受け取ります。",
"giftLinkWarn": "⚠ このリンクを開いた人は誰でもその金額を受け取れるので、贈る相手とだけ共有してください。金額は少なめにしましょう。",
"giftLinkReveal": "ギフトリンクを作成",
@@ -425,6 +426,7 @@
"claimOr": "または",
"goToWallet": "ウォレットに行く",
"claimBtn": "受け取る",
+ "claimNowBtn": "今すぐ受け取る",
"claimFeeNote": "ふりがな{n} 料金について",
"claimFailed": "このギフトはすでに受け取られたか、現在利用できません。",
"claimNotVisibleBody": "このギフトに資金を調達するコインは、このウォレットのネットワーク/データソースから表示されません。 このウォレットは、送信者と同じネットワークとデータソースを使用していて、もう一度お試しください。",
@@ -618,6 +620,8 @@
"onbSpendBody": "ユーザー名を主張し、独自の支払いアドレスを取得する, 人々は、あなたの名前だけで、即座にビットコインを送ることができます。",
"onbNameWait": "物事を準備する...",
"onbNotNow": "今は今",
+ "onbGiftNameTitle": "次にユーザー名を選びましょう",
+ "onbGiftNameBody": "名前を取得して自分の支払いアドレスを手に入れましょう。名前だけでビットコインを受け取れます。そのあと写真とカバーを選びます。",
"onbAvatarTitle": "お問い合わせ",
"onbAvatarBody": "アバターを選ぶか、自分の写真をアップロードしてください。いつでも変更できます。",
"onbUpload": "写真をアップロードする",
@@ -678,6 +682,7 @@
"namesClaim": "ログイン",
"namesClaimed": "これから {name}",
"namesCopy": "コピーアドレス",
+ "namesChangeTo": "{addr} に変更",
"namesMoreOpen": "その他のオプション",
"namesMoreTitle": "支払われるより多くの方法",
"namesMoreBolt11": "ライトニングインボイス(BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "このフィードにはまだ画像がありません。",
"mediaNoVideos": "このフィードにはまだ縦型動画がありません。",
"videoTapToPlay": "タップして再生",
+ "videoPlay": "再生",
+ "videoPause": "一時停止",
"videoLargeTitle": "動画",
"videoLargeToggle": "大きな動画を自動再生",
"videoLargeHelp": "{mb} MBを超える動画や1080pを超える動画は、データを使いすぎないようタップするまで再生しません。低速回線やデータセーバー時は上限が下がります。",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index e4bf342..e25af6e 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -363,6 +363,7 @@
"giftWelcome": "Bitcoin을 받았습니다",
"giftBackedUp": "적어 두었습니다",
"giftLink": "Bitcoin 선물하기",
+ "giftNotePlaceholder": "메시지 추가 (선택)",
"giftLinkDesc": "금액을 링크로 미리 서명하세요. 링크를 여는 사람은 그 Bitcoin을 자신만 통제하는 완전히 새로운 지갑으로 받게 됩니다.",
"giftLinkWarn": "⚠ 이 링크를 여는 사람은 누구나 금액을 받을 수 있으니, 선물할 상대에게만 공유하세요. 금액은 적게 유지하세요.",
"giftLinkReveal": "선물 링크 만들기",
@@ -424,6 +425,7 @@
"claimOr": "또는",
"goToWallet": "지갑 바로가기",
"claimBtn": "받기",
+ "claimNowBtn": "지금 받기",
"claimFeeNote": "·{n} 요금안내",
"claimFailed": "이 선물은 이미 수령되었거나 더 이상 이용할 수 없습니다.",
"claimNotVisibleBody": "이 선물은 이 지갑의 네트워크/데이터 소스에서 볼 수 없습니다. 이 지갑이 같은 네트워크와 데이터 소스를 보낸 후 다시 시도하십시오.",
@@ -617,6 +619,8 @@
"onbSpendBody": "사용자 이름 및 자신의 지불 주소를 얻기, 사람들은 즉시 Bitcoin을 보낼 수 있습니다, 그냥 당신의 이름.",
"onbNameWait": "준비하는 것들 ...",
"onbNotNow": "현재 위치",
+ "onbGiftNameTitle": "이제 사용자 이름을 고르세요",
+ "onbGiftNameBody": "이름을 등록하고 나만의 결제 주소를 받으세요. 이름만으로 비트코인을 받을 수 있어요. 그다음 사진과 커버를 고르세요.",
"onbAvatarTitle": "자주 묻는 질문",
"onbAvatarBody": "아바타를 선택하거나 사진을 업로드하세요. 언제든지 변경할 수 있습니다.",
"onbUpload": "사진 업로드",
@@ -677,6 +681,7 @@
"namesClaim": "계정 만들기",
"namesClaimed": "현재 위치 {name}",
"namesCopy": "본문 바로가기",
+ "namesChangeTo": "{addr}(으)로 변경",
"namesMoreOpen": "더 많은 옵션",
"namesMoreTitle": "지불하는 방법",
"namesMoreBolt11": "번개 송장 (BOLT 11)",
@@ -909,6 +914,8 @@
"mediaNoImages": "이 피드에는 아직 이미지가 없습니다.",
"mediaNoVideos": "이 피드에는 아직 세로 동영상이 없습니다.",
"videoTapToPlay": "탭하여 재생",
+ "videoPlay": "재생",
+ "videoPause": "일시정지",
"videoLargeTitle": "동영상",
"videoLargeToggle": "큰 동영상 자동 재생",
"videoLargeHelp": "{mb}MB를 넘거나 1080p보다 높은 동영상은 데이터를 아끼기 위해 탭할 때까지 기다립니다. 느린 연결이나 데이터 절약 모드에서는 한도가 더 낮습니다.",
diff --git a/src/locales/mr.json b/src/locales/mr.json
index 17ee1d5..a603747 100644
--- a/src/locales/mr.json
+++ b/src/locales/mr.json
@@ -137,6 +137,7 @@
"giftSkip": "आत्ता वगळा",
"giftBackedUp": "मी ते लिहून ठेवले आहे",
"giftLink": "थोडे Bitcoin भेट द्या",
+ "giftNotePlaceholder": "एक नोंद जोडा (पर्यायी)",
"giftLinkDesc": "एखादी रक्कम लिंक म्हणून आधीच स्वाक्षरित करा. ती उघडणारा कोणीही ते Bitcoin फक्त त्याच्याच नियंत्रणातील एका नव्या कोऱ्या वॉलेटमध्ये घेतो.",
"giftLinkWarn": "⚠ ही लिंक उघडणारा कोणीही ती रक्कम घेऊ शकतो, म्हणून ज्याला भेट देत आहात फक्त त्याच्यासोबतच ती शेअर करा. रक्कम कमी ठेवा.",
"giftAmountLabel": "भेट देण्याची रक्कम",
@@ -145,6 +146,7 @@
"giftReserved": "दावा न केलेल्या भेट लिंक ({n})",
"claimBody": "ते फक्त तुमच्याच नियंत्रणातील एका नव्या कोऱ्या वॉलेटमध्ये घ्या.",
"claimBtn": "ते घ्या",
+ "claimNowBtn": "आता घ्या",
"claimFailed": "ही भेट आधीच घेतली गेली आहे किंवा आता उपलब्ध नाही.",
"claimedTitle": "घेतले!",
"claimedBody": "तुमचे Bitcoin येत आहे. ते सुरक्षित ठेवण्यासाठी तुमच्या रिकव्हरी वाक्यांशाचा बॅकअप घ्या.",
@@ -247,6 +249,8 @@
"mediaNoImages": "या फीडमध्ये अजून चित्रे नाहीत.",
"mediaNoVideos": "या फीडमध्ये अजून उभे व्हिडिओ नाहीत.",
"videoTapToPlay": "चालवण्यासाठी टॅप करा",
+ "videoPlay": "चालवा",
+ "videoPause": "थांबवा",
"videoLargeTitle": "व्हिडिओ",
"videoLargeToggle": "मोठे व्हिडिओ आपोआप चालवा",
"videoLargeHelp": "{mb} MB पेक्षा मोठे किंवा 1080p पेक्षा वरचे व्हिडिओ तुमचा डेटा वाचवण्यासाठी टॅपची वाट पाहतात. धीम्या किंवा डेटा-सेव्हर कनेक्शनवर मर्यादा कमी असते.",
@@ -261,5 +265,8 @@
"arkRenewViewTx": "राउंडचा व्यवहार पहा",
"pagerFetchingVideo": "पुढचा व्हिडिओ आणत आहे…",
"pagerFetchingPictures": "आणखी चित्रे आणत आहे…",
- "pagerCaughtUp": "सध्या एवढेच"
+ "pagerCaughtUp": "सध्या एवढेच",
+ "onbGiftNameTitle": "आता एक युजरनेम निवडा",
+ "onbGiftNameBody": "एक नाव घ्या आणि स्वतःचा पेमेंट पत्ता मिळवा, म्हणजे लोक फक्त तुमच्या नावाने तुम्हाला बिटकॉइन पाठवू शकतील. मग एक फोटो आणि कव्हर निवडा.",
+ "namesChangeTo": "{addr} मध्ये बदला"
}
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 11ff22c..48496ee 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -364,6 +364,7 @@
"giftWelcome": "Você recebeu Bitcoin",
"giftBackedUp": "Já anotei",
"giftLink": "Presentear Bitcoin",
+ "giftNotePlaceholder": "Adicione um recado (opcional)",
"giftLinkDesc": "Pré-assine um valor como um link. Quem o abrir resgata os Bitcoin para uma carteira novinha que só essa pessoa controla.",
"giftLinkWarn": "⚠ Qualquer pessoa que abrir este link pode resgatar o valor, então compartilhe-o apenas com a pessoa que você está presenteando. Mantenha os valores baixos.",
"giftLinkReveal": "Criar link de presente",
@@ -425,6 +426,7 @@
"claimOr": "ou",
"goToWallet": "Ir para a carteira",
"claimBtn": "Resgatar",
+ "claimNowBtn": "Resgatar agora",
"claimFeeNote": "~{n} taxa",
"claimFailed": "Este presente já foi resgatado ou não está mais disponível.",
"claimNotVisibleBody": "As moedas que financiam este presente não são visíveis da rede/fonte de dados desta carteira. Certifique-se de que esta carteira usa a mesma rede e fonte de dados que o remetente e tente novamente.",
@@ -618,6 +620,8 @@
"onbSpendBody": "Reclame um nome de usuário e obtenha seu próprio endereço de pagamento, as pessoas podem enviar bitcoin instantaneamente, com apenas seu nome.",
"onbNameWait": "Preparando as coisas...",
"onbNotNow": "Agora não.",
+ "onbGiftNameTitle": "Agora escolha um nome de usuário",
+ "onbGiftNameBody": "Reserve um nome e ganhe seu próprio endereço de pagamento, para que possam te enviar bitcoin só com o seu nome. Depois escolha uma foto e uma capa.",
"onbAvatarTitle": "Escolha o seu look",
"onbAvatarBody": "Escolha um avatar ou carregue a sua própria foto. Podes mudá-lo quando quiseres.",
"onbUpload": "Enviar uma foto",
@@ -678,6 +682,7 @@
"namesClaim": "Alegação",
"namesClaimed": "Agora és. {name}",
"namesCopy": "Copiar endereço",
+ "namesChangeTo": "Mudar para {addr}",
"namesMoreOpen": "Mais opções",
"namesMoreTitle": "Mais formas de ser pago",
"namesMoreBolt11": "Factura relâmpago (BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "Ainda não há imagens neste feed.",
"mediaNoVideos": "Ainda não há vídeos verticais neste feed.",
"videoTapToPlay": "Toque para reproduzir",
+ "videoPlay": "Reproduzir",
+ "videoPause": "Pausar",
"videoLargeTitle": "Vídeos",
"videoLargeToggle": "Reproduzir vídeos grandes automaticamente",
"videoLargeHelp": "Vídeos acima de {mb} MB ou de 1080p esperam um toque, para o feed não consumir seus dados. O limite é menor em conexões lentas ou com economia de dados.",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 0f2936d..6c0b003 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -364,6 +364,7 @@
"giftWelcome": "Вам отправили Bitcoin",
"giftBackedUp": "Я записал(а)",
"giftLink": "Подарить немного Bitcoin",
+ "giftNotePlaceholder": "Добавьте записку (необязательно)",
"giftLinkDesc": "Заранее подпишите сумму в виде ссылки. Тот, кто откроет её, получит Bitcoin в совершенно новый кошелёк, которым управляет только он.",
"giftLinkWarn": "⚠ Любой, кто откроет эту ссылку, может получить эту сумму, поэтому делитесь ею только с тем, кому дарите. Держите суммы небольшими.",
"giftLinkReveal": "Создать ссылку-подарок",
@@ -425,6 +426,7 @@
"claimOr": "или",
"goToWallet": "Иди к кошельку",
"claimBtn": "Получить",
+ "claimNowBtn": "Получить сейчас",
"claimFeeNote": "~{n} взнос",
"claimFailed": "Этот подарок уже получен или больше недоступен.",
"claimNotVisibleBody": "Монеты, финансирующие этот подарок, не видны из сети / источника данных этого кошелька. Убедитесь, что этот кошелек использует ту же сеть и источник данных, что и отправитель.",
@@ -618,6 +620,8 @@
"onbSpendBody": "Заявите имя пользователя и получите свой собственный платежный адрес, люди могут отправить вам биткойн мгновенно, только с вашим именем.",
"onbNameWait": "Готовить вещи...",
"onbNotNow": "Не сейчас",
+ "onbGiftNameTitle": "Теперь выберите имя пользователя",
+ "onbGiftNameBody": "Займите имя и получите свой платёжный адрес — тогда вам смогут отправлять биткоин просто по имени. Потом выберите фото и обложку.",
"onbAvatarTitle": "Выберите свой взгляд",
"onbAvatarBody": "Выберите аватар или загрузите свою фотографию. Его можно изменить в любое время.",
"onbUpload": "Загрузить фото",
@@ -678,6 +682,7 @@
"namesClaim": "Претензия",
"namesClaimed": "Ты теперь {name}",
"namesCopy": "Адрес копии",
+ "namesChangeTo": "Сменить на {addr}",
"namesMoreOpen": "Больше вариантов",
"namesMoreTitle": "Больше способов получить оплату",
"namesMoreBolt11": "Инвойс молнии (BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "В этой ленте пока нет фото.",
"mediaNoVideos": "В этой ленте пока нет вертикальных видео.",
"videoTapToPlay": "Нажмите, чтобы воспроизвести",
+ "videoPlay": "Воспроизвести",
+ "videoPause": "Пауза",
"videoLargeTitle": "Видео",
"videoLargeToggle": "Автовоспроизведение больших видео",
"videoLargeHelp": "Видео больше {mb} МБ или выше 1080p ждут нажатия, чтобы лента не съедала трафик. На медленном соединении или в режиме экономии лимит ниже.",
diff --git a/src/locales/sw.json b/src/locales/sw.json
index b130faf..4ffba61 100644
--- a/src/locales/sw.json
+++ b/src/locales/sw.json
@@ -137,6 +137,7 @@
"giftSkip": "Ruka kwa sasa",
"giftBackedUp": "Nimeshakiandika",
"giftLink": "Toa Bitcoin kama zawadi",
+ "giftNotePlaceholder": "Ongeza ujumbe (si lazima)",
"giftLinkDesc": "Tia saini kiasi mapema kama kiungo. Yeyote anayekifungua hudai Bitcoin kwenye pochi mpya kabisa anayoidhibiti yeye pekee.",
"giftLinkWarn": "⚠ Yeyote anayefungua kiungo hiki anaweza kudai kiasi hicho, kwa hivyo kishiriki tu na mtu unayempa zawadi. Weka kiasi kidogo.",
"giftAmountLabel": "Kiasi cha kutoa zawadi",
@@ -145,6 +146,7 @@
"giftReserved": "Viungo vya zawadi visivyodaiwa ({n})",
"claimBody": "Idai kwenye pochi mpya kabisa unayoidhibiti wewe pekee.",
"claimBtn": "Idai",
+ "claimNowBtn": "Dai sasa",
"claimFailed": "Zawadi hii tayari imedaiwa au haipatikani tena.",
"claimedTitle": "Imedaiwa!",
"claimedBody": "Bitcoin yako inakuja. Hifadhi kifungu chako cha kurejesha ili kuilinda.",
@@ -247,6 +249,8 @@
"mediaNoImages": "Bado hakuna picha kwenye mlisho huu.",
"mediaNoVideos": "Bado hakuna video wima kwenye mlisho huu.",
"videoTapToPlay": "Gusa ili kucheza",
+ "videoPlay": "Cheza",
+ "videoPause": "Sitisha",
"videoLargeTitle": "Video",
"videoLargeToggle": "Cheza video kubwa zenyewe",
"videoLargeHelp": "Video zaidi ya MB {mb} au juu ya 1080p husubiri uguse, ili mlisho usimalize data yako. Kikomo ni cha chini kwenye muunganisho wa polepole au wa kuokoa data.",
@@ -261,5 +265,8 @@
"arkRenewViewTx": "Tazama muamala wa raundi",
"pagerFetchingVideo": "Inaleta video inayofuata…",
"pagerFetchingPictures": "Inaleta picha zaidi…",
- "pagerCaughtUp": "Hayo ndiyo yote kwa sasa"
+ "pagerCaughtUp": "Hayo ndiyo yote kwa sasa",
+ "onbGiftNameTitle": "Sasa chagua jina la mtumiaji",
+ "onbGiftNameBody": "Chukua jina upate anwani yako ya malipo, ili watu wakutumie bitcoin kwa jina lako tu. Kisha chagua picha na jalada.",
+ "namesChangeTo": "Badilisha kuwa {addr}"
}
diff --git a/src/locales/ta.json b/src/locales/ta.json
index da4e5b0..c9e6aae 100644
--- a/src/locales/ta.json
+++ b/src/locales/ta.json
@@ -137,6 +137,7 @@
"giftSkip": "இப்போதைக்குத் தவிர்",
"giftBackedUp": "நான் எழுதி வைத்துவிட்டேன்",
"giftLink": "கொஞ்சம் Bitcoin பரிசளி",
+ "giftNotePlaceholder": "ஒரு குறிப்பைச் சேர்க்கவும் (விருப்பம்)",
"giftLinkDesc": "ஒரு தொகையை இணைப்பாக முன்கூட்டியே கையொப்பமிடுங்கள். இதைத் திறப்பவர், அவர்கள் மட்டுமே கட்டுப்படுத்தும் புத்தம்புதிய வாலட்டிற்குள் Bitcoin-ஐ உரிமை கொள்வார்கள்.",
"giftLinkWarn": "⚠ இந்த இணைப்பைத் திறக்கும் எவரும் தொகையை உரிமை கொள்ளலாம், எனவே நீங்கள் பரிசளிக்கும் நபருடன் மட்டுமே இதைப் பகிரவும். தொகையைக் குறைவாக வைத்திருங்கள்.",
"giftAmountLabel": "பரிசளிக்கும் தொகை",
@@ -145,6 +146,7 @@
"giftReserved": "கோரப்படாத பரிசு இணைப்புகள் ({n})",
"claimBody": "நீங்கள் மட்டுமே கட்டுப்படுத்தும் புத்தம்புதிய வாலட்டிற்குள் இதை உரிமை கொள்ளுங்கள்.",
"claimBtn": "உரிமை கொள்",
+ "claimNowBtn": "இப்போதே பெறுங்கள்",
"claimFailed": "இந்தப் பரிசு ஏற்கனவே உரிமை கொள்ளப்பட்டுவிட்டது அல்லது இனி கிடைக்கவில்லை.",
"claimedTitle": "உரிமை கொள்ளப்பட்டது!",
"claimedBody": "உங்கள் Bitcoin வந்துகொண்டிருக்கிறது. அதைப் பாதுகாப்பாக வைக்க உங்கள் மீட்பு சொற்றொடரைக் காப்புப்பிரதி எடுங்கள்.",
@@ -247,6 +249,8 @@
"mediaNoImages": "இந்த ஊட்டத்தில் இன்னும் படங்கள் இல்லை.",
"mediaNoVideos": "இந்த ஊட்டத்தில் இன்னும் செங்குத்து வீடியோக்கள் இல்லை.",
"videoTapToPlay": "இயக்கத் தட்டவும்",
+ "videoPlay": "இயக்கு",
+ "videoPause": "இடைநிறுத்து",
"videoLargeTitle": "வீடியோக்கள்",
"videoLargeToggle": "பெரிய வீடியோக்களைத் தானாக இயக்கு",
"videoLargeHelp": "{mb} MB-க்கு மேல் அல்லது 1080p-க்கு மேலான வீடியோக்கள் உங்கள் டேட்டாவைச் சேமிக்க, தட்டும் வரை காத்திருக்கும். மெதுவான அல்லது டேட்டா சேமிப்பு இணைப்பில் வரம்பு குறைவு.",
@@ -261,5 +265,8 @@
"arkRenewViewTx": "சுற்றின் பரிவர்த்தனையைக் காண்க",
"pagerFetchingVideo": "அடுத்த வீடியோவைப் பெறுகிறது…",
"pagerFetchingPictures": "மேலும் படங்களைப் பெறுகிறது…",
- "pagerCaughtUp": "இப்போதைக்கு இவ்வளவுதான்"
+ "pagerCaughtUp": "இப்போதைக்கு இவ்வளவுதான்",
+ "onbGiftNameTitle": "இப்போது ஒரு பயனர்பெயரைத் தேர்ந்தெடுங்கள்",
+ "onbGiftNameBody": "ஒரு பெயரைப் பெற்று உங்கள் சொந்த பணம் பெறும் முகவரியைப் பெறுங்கள்; உங்கள் பெயரை மட்டும் வைத்தே மற்றவர்கள் பிட்காயின் அனுப்பலாம். பிறகு ஒரு படத்தையும் அட்டைப்படத்தையும் தேர்ந்தெடுங்கள்.",
+ "namesChangeTo": "{addr} ஆக மாற்று"
}
diff --git a/src/locales/te.json b/src/locales/te.json
index 7e60eaf..7a30efb 100644
--- a/src/locales/te.json
+++ b/src/locales/te.json
@@ -137,6 +137,7 @@
"giftSkip": "ప్రస్తుతానికి దాటవేయి",
"giftBackedUp": "నేను దాన్ని రాసుకున్నాను",
"giftLink": "కొంత Bitcoin బహుమతిగా ఇవ్వండి",
+ "giftNotePlaceholder": "ఒక సందేశం జోడించండి (ఐచ్ఛికం)",
"giftLinkDesc": "ఒక మొత్తాన్ని ముందుగా సంతకం చేసి లింక్‌గా ఇవ్వండి. దాన్ని తెరిచినవారు ఆ Bitcoinను తాము మాత్రమే నియంత్రించే సరికొత్త వాలెట్‌లోకి క్లెయిమ్ చేసుకుంటారు.",
"giftLinkWarn": "⚠ ఈ లింక్ తెరిచిన ఎవరైనా ఆ మొత్తాన్ని క్లెయిమ్ చేయగలరు, కాబట్టి మీరు బహుమతి ఇస్తున్న వ్యక్తికి మాత్రమే దీన్ని పంచుకోండి. మొత్తాలను తక్కువగా ఉంచండి.",
"giftAmountLabel": "బహుమతిగా ఇచ్చే మొత్తం",
@@ -145,6 +146,7 @@
"giftReserved": "దావా చేయని బహుమతి లింకులు ({n})",
"claimBody": "మీరు మాత్రమే నియంత్రించే సరికొత్త వాలెట్‌లోకి దాన్ని క్లెయిమ్ చేసుకోండి.",
"claimBtn": "క్లెయిమ్ చేసుకోండి",
+ "claimNowBtn": "ఇప్పుడే పొందండి",
"claimFailed": "ఈ బహుమతి ఇప్పటికే క్లెయిమ్ చేయబడింది లేదా ఇక అందుబాటులో లేదు.",
"claimedTitle": "క్లెయిమ్ అయింది!",
"claimedBody": "మీ Bitcoin మీ వద్దకు వస్తోంది. దాన్ని సురక్షితంగా ఉంచడానికి మీ రికవరీ ఫ్రేజ్‌ను బ్యాకప్ చేయండి.",
@@ -245,6 +247,8 @@
"mediaNoImages": "ఈ ఫీడ్‌లో ఇంకా చిత్రాలు లేవు.",
"mediaNoVideos": "ఈ ఫీడ్‌లో ఇంకా నిలువు వీడియోలు లేవు.",
"videoTapToPlay": "ప్లే చేయడానికి నొక్కండి",
+ "videoPlay": "ప్లే",
+ "videoPause": "పాజ్",
"videoLargeTitle": "వీడియోలు",
"videoLargeToggle": "పెద్ద వీడియోలను ఆటో‌ప్లే చేయి",
"videoLargeHelp": "{mb} MB కంటే పెద్దవి లేదా 1080p పైబడిన వీడియోలు మీ డేటా ఆదా కోసం నొక్కే వరకు ఆగుతాయి. నెమ్మదైన లేదా డేటా సేవర్ కనెక్షన్‌లో పరిమితి తక్కువ.",
@@ -259,5 +263,8 @@
"arkRenewViewTx": "రౌండ్ లావాదేవీని చూడండి",
"pagerFetchingVideo": "తదుపరి వీడియోను తెస్తోంది…",
"pagerFetchingPictures": "మరిన్ని చిత్రాలను తెస్తోంది…",
- "pagerCaughtUp": "ప్రస్తుతానికి ఇంతే"
+ "pagerCaughtUp": "ప్రస్తుతానికి ఇంతే",
+ "onbGiftNameTitle": "ఇప్పుడు ఒక యూజర్‌నేమ్ ఎంచుకోండి",
+ "onbGiftNameBody": "ఒక పేరు తీసుకుని మీ సొంత చెల్లింపు చిరునామా పొందండి, అప్పుడు మీ పేరుతోనే ఎవరైనా మీకు బిట్‌కాయిన్ పంపవచ్చు. తర్వాత ఒక ఫోటో, ఒక కవర్ ఎంచుకోండి.",
+ "namesChangeTo": "{addr}కి మార్చు"
}
diff --git a/src/locales/tr.json b/src/locales/tr.json
index 1e60eeb..a406b7b 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -363,6 +363,7 @@
"giftWelcome": "Size Bitcoin gönderildi",
"giftBackedUp": "Yazdım",
"giftLink": "Bitcoin hediye et",
+ "giftNotePlaceholder": "Not ekle (isteğe bağlı)",
"giftLinkDesc": "Bir tutarı önceden imzalayıp bağlantı olarak verin. Bağlantıyı açan, Bitcoin’i yalnızca kendi kontrolündeki yepyeni bir cüzdana alır.",
"giftLinkWarn": "⚠ Bu bağlantıyı açan herkes tutarı talep edebilir, bu yüzden yalnızca hediye ettiğiniz kişiyle paylaşın. Tutarları küçük tutun.",
"giftLinkReveal": "Hediye bağlantısı oluştur",
@@ -424,6 +425,7 @@
"claimOr": "veya",
"goToWallet": "Cüzdana git",
"claimBtn": "Talep et",
+ "claimNowBtn": "Şimdi al",
"claimFeeNote": "~{n} ücret ücreti",
"claimFailed": "Bu hediye zaten talep edilmiş veya artık kullanılamıyor.",
"claimNotVisibleBody": "Paralar bu hediyeyi finanse ediyor bu cüzdanın ağ/data kaynağından görünür değil. Bu cüzdanın aynı ağ ve veri kaynağını gönderileyici olarak kullandığından emin olun, sonra tekrar deneyin.",
@@ -617,6 +619,8 @@
"onbSpendBody": "Bir kullanıcı adı talep edin ve kendi ödeme adresinizi alın - insanlar size anında Bitcoin gönderebilir, sadece adınızla.",
"onbNameWait": "İşler hazır olsun...",
"onbNotNow": "Şimdi Değil",
+ "onbGiftNameTitle": "Şimdi bir kullanıcı adı seç",
+ "onbGiftNameBody": "Bir isim al ve kendi ödeme adresine sahip ol; insanlar sana sadece adınla bitcoin gönderebilsin. Sonra bir fotoğraf ve kapak seç.",
"onbAvatarTitle": "görünümünü seçin",
"onbAvatarBody": "Bir avatar seçin veya kendi resminizi yükleyin. İstediğiniz zaman değiştirebilirsiniz.",
"onbUpload": "Bir fotoğraf",
@@ -677,6 +681,7 @@
"namesClaim": "Talep Davası",
"namesClaimed": "You are now {name}",
"namesCopy": "Kopya adresi",
+ "namesChangeTo": "{addr} olarak değiştir",
"namesMoreOpen": "Daha fazla seçenek",
"namesMoreTitle": "Ödeme yapmak için daha fazla yol",
"namesMoreBolt11": "Yıldırım Fatura (BOLT 11)",
@@ -909,6 +914,8 @@
"mediaNoImages": "Bu akışta henüz görsel yok.",
"mediaNoVideos": "Bu akışta henüz dikey video yok.",
"videoTapToPlay": "Oynatmak için dokun",
+ "videoPlay": "Oynat",
+ "videoPause": "Duraklat",
"videoLargeTitle": "Videolar",
"videoLargeToggle": "Büyük videoları otomatik oynat",
"videoLargeHelp": "{mb} MB’den büyük veya 1080p’nin üzerindeki videolar, akış verini tüketmesin diye dokunmanı bekler. Yavaş ya da veri tasarruflu bağlantıda sınır daha düşüktür.",
diff --git a/src/locales/ur.json b/src/locales/ur.json
index 6efb6ec..1f35df9 100644
--- a/src/locales/ur.json
+++ b/src/locales/ur.json
@@ -137,6 +137,7 @@
"giftSkip": "ابھی چھوڑیں",
"giftBackedUp": "میں نے اسے لکھ لیا ہے",
"giftLink": "کچھ Bitcoin تحفے میں دیں",
+ "giftNotePlaceholder": "ایک نوٹ شامل کریں (اختیاری)",
"giftLinkDesc": "ایک رقم کو بطور لنک پہلے سے دستخط کریں۔ جو بھی اسے کھولے وہ Bitcoin کو ایک بالکل نئے والیٹ میں وصول کر لیتا ہے جو صرف اسی کے کنٹرول میں ہوتا ہے۔",
"giftLinkWarn": "⚠ جو بھی یہ لنک کھولے وہ اس رقم کا دعویٰ کر سکتا ہے، اس لیے اسے صرف اسی شخص کے ساتھ شیئر کریں جسے آپ تحفہ دے رہے ہیں۔ رقم کم رکھیں۔",
"giftLinkReveal": "تحفہ لنک بنائیں",
@@ -147,6 +148,7 @@
"giftReserved": "غیر دعویٰ شدہ تحفہ لنکس ({n})",
"claimBody": "اسے ایک بالکل نئے والیٹ میں وصول کریں جو صرف آپ کے کنٹرول میں ہو۔",
"claimBtn": "وصول کریں",
+ "claimNowBtn": "ابھی حاصل کریں",
"claimFailed": "اس تحفے کا پہلے ہی دعویٰ کیا جا چکا ہے یا یہ اب دستیاب نہیں۔",
"claimedTitle": "وصول ہو گیا!",
"claimedBody": "آپ کا Bitcoin راستے میں ہے۔ اسے محفوظ رکھنے کے لیے اپنے بحالی فقرے کا بیک اپ بنا لیں۔",
@@ -247,6 +249,8 @@
"mediaNoImages": "اس فیڈ میں ابھی کوئی تصویر نہیں۔",
"mediaNoVideos": "اس فیڈ میں ابھی کوئی عمودی ویڈیو نہیں۔",
"videoTapToPlay": "چلانے کے لیے ٹیپ کریں",
+ "videoPlay": "چلائیں",
+ "videoPause": "روکیں",
"videoLargeTitle": "ویڈیوز",
"videoLargeToggle": "بڑی ویڈیوز خود چلائیں",
"videoLargeHelp": "{mb} MB سے بڑی یا 1080p سے اوپر کی ویڈیوز ٹیپ کا انتظار کرتی ہیں، تاکہ فیڈ آپ کا ڈیٹا نہ کھائے۔ سست یا ڈیٹا بچت والے کنکشن پر حد کم ہوتی ہے۔",
@@ -261,5 +265,8 @@
"arkRenewViewTx": "راؤنڈ کی ٹرانزیکشن دیکھیں",
"pagerFetchingVideo": "اگلی ویڈیو لائی جا رہی ہے…",
"pagerFetchingPictures": "مزید تصاویر لائی جا رہی ہیں…",
- "pagerCaughtUp": "فی الحال بس اتنا ہی"
+ "pagerCaughtUp": "فی الحال بس اتنا ہی",
+ "onbGiftNameTitle": "اب ایک یوزرنیم چنیں",
+ "onbGiftNameBody": "ایک نام لیں اور اپنا ادائیگی کا پتہ پائیں، تاکہ لوگ صرف آپ کے نام سے آپ کو بٹ کوائن بھیج سکیں۔ پھر ایک تصویر اور ایک کور چنیں۔",
+ "namesChangeTo": "{addr} میں بدلیں"
}
diff --git a/src/locales/vi.json b/src/locales/vi.json
index 415f4e7..31f61ad 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -137,6 +137,7 @@
"giftSkip": "Bỏ qua lúc này",
"giftBackedUp": "Tôi đã ghi lại rồi",
"giftLink": "Tặng một ít Bitcoin",
+ "giftNotePlaceholder": "Thêm lời nhắn (không bắt buộc)",
"giftLinkDesc": "Ký trước một số tiền thành một liên kết. Ai mở liên kết sẽ nhận số Bitcoin đó vào một ví hoàn toàn mới mà chỉ riêng họ kiểm soát.",
"giftLinkWarn": "⚠ Bất kỳ ai mở liên kết này đều có thể nhận số tiền, vì vậy chỉ chia sẻ với người bạn muốn tặng. Hãy giữ số tiền nhỏ.",
"giftAmountLabel": "Số tiền muốn tặng",
@@ -145,6 +146,7 @@
"giftReserved": "Liên kết quà chưa nhận ({n})",
"claimBody": "Nhận nó vào một ví hoàn toàn mới mà chỉ riêng bạn kiểm soát.",
"claimBtn": "Nhận ngay",
+ "claimNowBtn": "Nhận ngay",
"claimFailed": "Món quà này đã được nhận hoặc không còn khả dụng.",
"claimedTitle": "Đã nhận!",
"claimedBody": "Bitcoin của bạn đang trên đường tới. Hãy sao lưu cụm từ khôi phục để giữ an toàn.",
@@ -247,6 +249,8 @@
"mediaNoImages": "Bảng tin này chưa có ảnh.",
"mediaNoVideos": "Bảng tin này chưa có video dọc.",
"videoTapToPlay": "Chạm để phát",
+ "videoPlay": "Phát",
+ "videoPause": "Tạm dừng",
"videoLargeTitle": "Video",
"videoLargeToggle": "Tự động phát video lớn",
"videoLargeHelp": "Video trên {mb} MB hoặc cao hơn 1080p sẽ chờ bạn chạm, để bảng tin không ngốn dữ liệu. Giới hạn thấp hơn khi kết nối chậm hoặc tiết kiệm dữ liệu.",
@@ -261,5 +265,8 @@
"arkRenewViewTx": "Xem giao dịch của vòng",
"pagerFetchingVideo": "Đang tải video tiếp theo…",
"pagerFetchingPictures": "Đang tải thêm ảnh…",
- "pagerCaughtUp": "Tạm thời chỉ có vậy"
+ "pagerCaughtUp": "Tạm thời chỉ có vậy",
+ "onbGiftNameTitle": "Giờ hãy chọn tên người dùng",
+ "onbGiftNameBody": "Nhận một cái tên và địa chỉ thanh toán của riêng bạn, để mọi người gửi bitcoin cho bạn chỉ bằng tên. Sau đó chọn ảnh đại diện và ảnh bìa.",
+ "namesChangeTo": "Đổi thành {addr}"
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 6b3cc49..1ed54fa 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -364,6 +364,7 @@
"giftWelcome": "有人给你转了 Bitcoin",
"giftBackedUp": "我已经抄写下来了",
"giftLink": "赠送一些 Bitcoin",
+ "giftNotePlaceholder": "添加留言(可选)",
"giftLinkDesc": "将一笔金额预签为一个链接。打开链接的人可将这笔 Bitcoin 领取到一个仅由他们自己掌控的全新钱包中。",
"giftLinkWarn": "⚠ 任何打开此链接的人都能领取这笔金额,所以请仅分享给你要赠送的对象。请保持金额较小。",
"giftLinkReveal": "创建赠送链接",
@@ -425,6 +426,7 @@
"claimOr": "或者说",
"goToWallet": "转到钱包",
"claimBtn": "领取",
+ "claimNowBtn": "立即领取",
"claimFeeNote": "~ (法语){n} 收费",
"claimFailed": "此礼物已被领取或不再可用。",
"claimNotVisibleBody": "从这个钱包的网络/数据来源看, 确保这个钱包使用与发送者相同的网络和数据源,然后再次尝试.",
@@ -618,6 +620,8 @@
"onbSpendBody": "要求一个用户名并获得你自己的付款地址,人们可以立刻发送你比特币,只要你的名字.",
"onbNameWait": "准备...",
"onbNotNow": "现在不行",
+ "onbGiftNameTitle": "现在选一个用户名",
+ "onbGiftNameBody": "领取一个名字,获得你自己的收款地址,别人只用你的名字就能给你发比特币。然后选一张头像和封面。",
"onbAvatarTitle": "选择你的外观",
"onbAvatarBody": "选择一个头像或上传自己的照片。你可以随时更改",
"onbUpload": "上传相片",
@@ -678,6 +682,7 @@
"namesClaim": "索赔",
"namesClaimed": "你现在是 {name}",
"namesCopy": "复制地址",
+ "namesChangeTo": "改为 {addr}",
"namesMoreOpen": "更多选项",
"namesMoreTitle": "更多赚钱的方法",
"namesMoreBolt11": "闪电发票(BOLT 11)",
@@ -910,6 +915,8 @@
"mediaNoImages": "此动态中还没有图片。",
"mediaNoVideos": "此动态中还没有竖屏视频。",
"videoTapToPlay": "点按播放",
+ "videoPlay": "播放",
+ "videoPause": "暂停",
"videoLargeTitle": "视频",
"videoLargeToggle": "自动播放大视频",
"videoLargeHelp": "超过 {mb} MB 或高于 1080p 的视频会等你点按再播放,以免动态耗尽流量。在慢速或省流量连接下,上限更低。",
diff --git a/src/marmot-client.js b/src/marmot-client.js
index b514216..707003f 100644
--- a/src/marmot-client.js
+++ b/src/marmot-client.js
@@ -16,7 +16,7 @@ import { dlog } from './debug.js';
// Where the groups we found live. Signed into group state, so every member
// reads and writes the same list. Not relay.damus.io or relay.nostr.band:
// White Noise (MDK's RETIRED_RELAY_HOSTS) never dials those.
-export const GROUP_RELAYS = ['wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net'];
+export const GROUP_RELAYS = ['wss://messages.coinos.io', 'wss://relay.coinos.io', 'wss://nos.lol', 'wss://relay.primal.net'];
// Where White Noise looks people up. It will not invite an account whose
// relay lists (kinds 10002 and 10050) it cannot find, and one whose profile
diff --git a/src/media-timeline.js b/src/media-timeline.js
index 8b6045e..d8ae008 100644
--- a/src/media-timeline.js
+++ b/src/media-timeline.js
@@ -85,7 +85,7 @@ export function markAt(seconds, duration) {
// with closures pointing at a detached player. What was said, reacted or
// zapped at a moment sits on the bar at that moment; replies, reactions and
// zaps made while a clip plays carry its time on their own (see momentOf).
-export function mediaTimeline(player, url, { h, event, moments, glyph, label }) {
+export function mediaTimeline(player, url, { h, event, moments, glyph, label, onMarks = null }) {
const audio = player.tagName === 'AUDIO';
const box = h('div', { class: 'media-timeline', onClick: e => e.stopPropagation(), onPointerdown: e => e.stopPropagation() });
box._skipMorph = true;
@@ -123,6 +123,10 @@ export function mediaTimeline(player, url, { h, event, moments, glyph, label })
}
const items = ev && finite ? moments(ev.id, url) : [];
const next = items.map(x => x.id).join(',') + '@' + (finite ? player.duration : 0);
+ // a video has its own seek bar: this one shows under it only to carry
+ // what was said at a moment (two bars read as a glitch on a phone)
+ box.classList.toggle('has-marks', items.length > 0);
+ if (onMarks) onMarks(items.length > 0);
if (signature !== next) {
signature = next;
marks.replaceChildren(...items.map(x => h('button', { type: 'button', class: 'media-mark', title: mediaClock(x.moment.seconds) + ' ' + x.text,
diff --git a/src/nostr-login.js b/src/nostr-login.js
index fe60261..3357f61 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -324,7 +324,7 @@ let amberWatch = null;
const amberListeners = new Set();
export const onAmberChange = (fn) => { amberListeners.add(fn); return () => amberListeners.delete(fn); };
const amberChanged = () => { for (const fn of amberListeners) { try { fn(); } catch {} } };
-export const amberPending = () => (amberReq ? { type: amberReq.type, since: amberReq.ts } : null);
+export const amberPending = () => (amberReq ? { type: amberReq.type, since: amberReq.ts, needsTap: !!amberReq.needsTap } : null);
const HEX64 = /^[0-9a-f]{64}$/i;
// What an answer of this type looks like — anything else on the clipboard
// is somebody's grocery list, not a signature.
@@ -384,7 +384,10 @@ function amberWatchClipboard() {
if (amberReq && amberDeliver(txt)) return;
} catch {} // no permission: the paste box is the way in
};
- const onVis = () => { if (document.visibilityState === 'visible') setTimeout(read, 250); };
+ const onVis = () => {
+ if (document.visibilityState === 'hidden') { if (amberReq) amberReq.hidden = true; return; } // the signer came up
+ setTimeout(read, 250);
+ };
document.addEventListener('visibilitychange', onVis);
window.addEventListener('focus', onVis);
const iv = setInterval(read, 800);
@@ -395,6 +398,35 @@ function amberLaunch(url) {
if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') { window.__coinosAmberLaunch(url); return; } // tests
window.location.href = url;
}
+// Chrome lets a page open another app only on the heels of a tap: without
+// one the `nostrsigner:` navigation is dropped with a console warning the
+// page never sees ("Navigation is blocked"). The first request rides the
+// Sign-in tap; the ones the login fires once an answer is back (decrypt the
+// backup, sign the relay list…) have no tap behind them, so they wait for
+// one — the card shows an Open button — instead of spinning forever. A
+// launch that leaves the page in front is taken as blocked too.
+export function amberOpen() {
+ const r = amberReq;
+ if (!r) return false;
+ r.needsTap = false;
+ r.hidden = false;
+ clearTimeout(r.timer);
+ r.timer = setTimeout(() => amberCancel('signer did not answer'), r.timeoutMs);
+ amberChanged();
+ amberLaunch(r.url);
+ if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') return true; // tests: no app to come up
+ setTimeout(() => {
+ if (amberReq !== r || r.hidden || typeof document === 'undefined' || document.visibilityState !== 'visible') return;
+ r.needsTap = true;
+ amberChanged();
+ }, 2500);
+ return true;
+}
+const amberTapAvailable = () => {
+ if (typeof window !== 'undefined' && typeof window.__coinosAmberLaunch === 'function') return true; // tests
+ const ua = typeof navigator !== 'undefined' ? navigator.userActivation : null;
+ return ua ? ua.isActive : true;
+};
// One trip at a time: the app fires several signatures at once right after a
// sign-in (the backup, the relay list, the follows), and Amber can only be
// asked for one; the rest wait their turn in this chain.
@@ -404,11 +436,11 @@ function amberRequest(type, payload, params = {}, { timeoutMs = 180_000 } = {})
if (params.pubkey) q.set('pubkey', params.pubkey);
const url = 'nostrsigner:' + encodeURIComponent(payload || '') + '?' + q.toString();
const run = () => new Promise((resolve, reject) => {
- amberReq = { type, payload: payload || '', ts: Date.now(), resolve, reject,
+ amberReq = { type, payload: payload || '', url, ts: Date.now(), resolve, reject, timeoutMs,
timer: setTimeout(() => amberCancel('signer did not answer'), timeoutMs) };
amberWatch = amberWatchClipboard();
- amberChanged();
- amberLaunch(url);
+ if (amberTapAvailable()) amberOpen();
+ else { amberReq.needsTap = true; amberChanged(); }
});
const p = amberQueue.then(run, run);
amberQueue = p.catch(() => {});
diff --git a/src/nwc-bg.js b/src/nwc-bg.js
index 3bdef08..ab083d9 100644
--- a/src/nwc-bg.js
+++ b/src/nwc-bg.js
@@ -57,6 +57,62 @@ export async function saveBg(walletKey, rec) {
export async function clearBg(walletKey) {
return tx('readwrite', (s) => { s.delete(walletKey); });
}
+
+// The worker's diary: what it did while the app was closed — each push, the
+// steps of a payment, every notification shown or refused. A phone stops a
+// worker without a trace, and the diary's last line says where it got to.
+// Settings → Notifications → Copy hands it to whoever is looking into it.
+// Appended line by line (a worker that dies mid-push keeps what it wrote),
+// one writer at a time, a few hundred lines kept.
+const LOG_KEY = '__log';
+const LOG_MAX = 400;
+let logChain = Promise.resolve();
+export function appendBgLog(line) {
+ logChain = logChain.then(() => tx('readwrite', async (s) => {
+ const cur = (await get(s, LOG_KEY)) || [];
+ cur.push(line);
+ s.put(cur.length > LOG_MAX ? cur.slice(-LOG_MAX) : cur, LOG_KEY);
+ })).catch(() => {});
+ return logChain;
+}
+export async function readBgLog() {
+ try { return (await tx('readonly', (s) => get(s, LOG_KEY))) || []; } catch { return []; }
+}
+export async function clearBgLog() {
+ return tx('readwrite', (s) => { s.delete(LOG_KEY); });
+}
+// A lease on the worker's wallet work, kept in this store. Two pushes seconds
+// apart ran side by side on iOS, each in its own worker (two "worker started"
+// lines), and nothing kept them apart: both finished one send, and the
+// second, over a stale copy, marked a paid send 'revoking'. The store is
+// the one thing both see, and a readwrite transaction is atomic across them.
+// A lease outlives a stopped worker only for its ttl.
+export async function takeLease(name, { ttl = 12_000, wait = 5_000 } = {}) {
+ const key = '__lease:' + name;
+ const me = Math.random().toString(36).slice(2);
+ const start = Date.now();
+ for (;;) {
+ let got = false;
+ try {
+ got = await tx('readwrite', async (s) => {
+ const cur = await get(s, key);
+ if (cur && cur.until > Date.now() && cur.owner !== me) return false;
+ s.put({ owner: me, until: Date.now() + ttl }, key);
+ return true;
+ });
+ } catch { got = true; } // no store, no lease: run
+ if (got) break;
+ if (Date.now() - start > wait) break; // a holder that overstays: go anyway
+ await new Promise((r) => setTimeout(r, 250));
+ }
+ const waited = Date.now() - start;
+ const release = () => tx('readwrite', async (s) => {
+ const cur = await get(s, key);
+ if (cur && cur.owner === me) s.delete(key);
+ }).catch(() => {});
+ return { waited, release };
+}
+
// The service worker doesn't know wallet cache keys — it finds the right
// record by the connection's service pubkey across all of them.
export async function allBgs() {
@@ -67,7 +123,8 @@ export async function allBgs() {
cur.onsuccess = () => {
const c = cur.result;
if (!c) return res(out);
- out.push({ walletKey: c.key, rec: c.value });
+ // the worker's diary shares the store under a reserved key
+ if (!(typeof c.key === 'string' && c.key.startsWith('__'))) out.push({ walletKey: c.key, rec: c.value });
c.continue();
};
cur.onerror = () => rej(cur.error);
@@ -84,7 +141,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,11 +169,14 @@ 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.
connections: (connections || []).map((c) => ({
- id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
+ id: c.id, name: c.name || '', servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
maxSat: c.maxSat, dailySat: c.dailySat, period: c.period || 'day',
spentDate: c.spentDate || null, spentToday: c.spentToday || 0,
revoked: !!c.revoked,
diff --git a/src/nwc-lock.js b/src/nwc-lock.js
index c4fec08..bd8586a 100644
--- a/src/nwc-lock.js
+++ b/src/nwc-lock.js
@@ -7,6 +7,24 @@
// the sats had gone. Web Locks are shared by every same-origin context, so
// the first to claim a request's lock answers it and the rest stay quiet.
// Where locks are unavailable (tests, ancient engines) the caller just runs.
+// The worker's wallet work, one push at a time. Every push handler builds
+// its own manager over the one shared mirror, and two of them driving the
+// same send at once would sign it twice (a client's retry lands seconds
+// after its lookup). A waiting push keeps its waitUntil and reads the mirror
+// fresh once it gets the lock.
+// The Web Lock alone didn't hold on iOS (two workers at once, neither
+// waited), so the lease in the worker's store (nwc-bg.js) is taken inside it.
+export async function withWorkLock(fn, { lease = null } = {}) {
+ const locks = typeof navigator !== 'undefined' && navigator.locks;
+ const run = async () => {
+ if (!lease) return fn(0);
+ const l = await lease();
+ try { return await fn(l.waited); } finally { await l.release(); }
+ };
+ if (!locks) return run();
+ return locks.request('nwc-bg-work', run);
+}
+
export async function withRequestLock(id, fn, held = () => undefined) {
const locks = typeof navigator !== 'undefined' && navigator.locks;
if (!locks || !id) return fn();
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
index ec19923..0778813 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
@@ -61,17 +61,92 @@ export function shimAccount(rec) {
return { deriveChild: (chain) => ({ deriveChild: (i) => ({ privateKey: byChain(chain, i) }) }) };
}
-export async function bgManager(rec, walletKey, saveFn) {
+export async function bgManager(rec, walletKey, saveFn, log = null) {
+ // the diary follows each send's steps through the mirror's saves: a worker
+ // the phone stops mid-payment leaves its last step on record
+ const steps = new Map(((rec.mgr && rec.mgr.actions) || []).filter((a) => a.type === 'ln-pay').map((a) => [a.id, a.step]));
const storage = {
load: () => rec.mgr,
- save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); },
+ save: (s) => {
+ rec.mgr = s;
+ if (log) {
+ for (const a of s.actions || []) {
+ if (a.type !== 'ln-pay' || steps.get(a.id) === a.step) continue;
+ steps.set(a.id, a.step);
+ log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} → ${a.step}${a.error ? ': ' + a.error : ''}`);
+ }
+ }
+ Promise.resolve(saveFn(walletKey, rec)).catch(() => {});
+ },
};
return new ArkManager({
account: shimAccount(rec), storage,
arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network,
+ honorsExpired: !!rec.ark.honorsExpired,
}).init();
}
+// ---- sends in flight across wakes ------------------------------------------
+// The phone stops a worker without warning: between reserving a coin and the
+// ASP's cosign, or between the ASP paying and our reply. The action stays in
+// the mirror at its last step, its coin reserved, and only the app used to
+// finish it. These let the worker finish it on a later wake instead.
+const pendingLnPays = (rec) => ((rec.mgr && rec.mgr.actions) || [])
+ .filter((a) => a.type === 'ln-pay' && !['done', 'failed'].includes(a.step));
+// which connection a send belongs to, kept beside the mirror: the action is
+// the manager's and knows nothing of NWC
+function notePending(rec, hash, conn) {
+ rec.pendingPays = (rec.pendingPays || []).filter((p) => p.hash !== hash).slice(-50);
+ rec.pendingPays.push({ hash, connId: conn.id, ts: Date.now() });
+}
+function forgetPending(rec, hash) {
+ if (rec.pendingPays) rec.pendingPays = rec.pendingPays.filter((p) => p.hash !== hash);
+}
+function connOf(rec, a) {
+ const p = (rec.pendingPays || []).find((x) => x.hash === a.paymentHash);
+ return (p && (rec.connections || []).find((c) => c.id === p.connId)) || null;
+}
+// A settled send goes on the worker's spend log once; the app absorbs it
+// into budgets and history on next open.
+function recordBgSpend(rec, a, conn, fallback = {}) {
+ const hash = a.paymentHash || fallback.paymentHash;
+ forgetPending(rec, hash);
+ rec.spends = rec.spends || [];
+ if (hash && rec.spends.some((s) => s.paymentHash === hash)) return;
+ rec.spends.push({
+ connId: conn ? conn.id : null, amountSat: a.amountSat || fallback.amountSat, feeSat: a.feeSat || 0,
+ paymentHash: hash, preimage: a.preimage, invoice: a.invoice || fallback.invoice, ts: Date.now(),
+ });
+ if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200);
+}
+// Money left while the app was closed — that deserves a heads-up, unless
+// the user turned "payments sent" notifications off in Settings.
+function sentNotice(rec, a, conn, fallbackSat = 0) {
+ if (rec.prefs && rec.prefs.paySent === false) return true;
+ const paidSat = a.amountSat || fallbackSat;
+ const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : '';
+ return {
+ notify: {
+ title: 'Payment sent', tag: 'nwc-sent',
+ body: `-${paidSat.toLocaleString()} sats${feeNote} via ${(conn && conn.name) || 'a connected app'}`,
+ },
+ };
+}
+// Drive a Lightning send on until it settles, fails, or the budget is gone.
+// Every step is held to the deadline: a call to the ASP that hangs must not
+// carry the worker past its budget (iOS stops it, mid-line, with no trace).
+async function finishLnPay(mgr, id, deadline) {
+ const by = (p) => Promise.race([p, new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]);
+ let a = await by(mgr.awaitLnPay(id, Math.max(500, deadline - Date.now())).catch(() => null));
+ a = mgr.lnAction(id);
+ while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline - 1500) {
+ await new Promise((r) => setTimeout(r, 1000));
+ await by(mgr.driveLn(id).catch(() => {}));
+ a = mgr.lnAction(id);
+ }
+ return a;
+}
+
// A CLINK offer request (kind 21001): mint an invoice against the mirrored
// wallet and reply. The payment itself arrives later over the ASP's hold
// invoice — the app claims it on next open, so we also ask the caller to
@@ -127,7 +202,7 @@ async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log
// left to another device); false means "wake the user instead".
export async function respondFromBg(data, {
notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {},
- errGraceMs = 3000,
+ errGraceMs = 3000, deadline: until = null,
} = {}) {
const ev = data && data.event;
if (!ev || !ev.id || !ev.pubkey) return false;
@@ -175,6 +250,9 @@ export async function respondFromBg(data, {
try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; }
const method = req?.method;
const params = req?.params || {};
+ // the push's budget (on iOS about ten seconds from the push, not thirty)
+ const deadline = until || Date.now() + PAY_WAIT_MS;
+ let lookupNotice = null;
// Revoked, lately: refuse out loud — a silent worker looks like a phone
// that is off, which is the one thing the other app cannot tell apart.
if (conn.revoked) {
@@ -251,23 +329,47 @@ export async function respondFromBg(data, {
await publish(errRes('lookup_invoice', 'OTHER', 'payment_hash or invoice required'));
return true;
}
- if (!rec.mgr) return false;
const q = { paymentHash: params.payment_hash, invoice: params.invoice };
- // read straight off the mirror; the ASP is only needed to claim
- const view = Object.assign(Object.create(ArkManager.prototype), { state: rec.mgr, account: shimAccount(rec) });
- let tx = view.lnLookup(q);
- const a = tx && tx.state === 'pending' && tx.type === 'incoming'
- && (rec.mgr.actions || []).find((x) => x.type === 'ln-recv' && x.paymentHash === tx.payment_hash);
+ // read straight off the mirror; the ASP is only needed to claim. Every
+ // copy of the connection is asked (the freshest may be another network's
+ // mirror: PullCap's lookups of a send in flight came back "not found").
+ let tx = null;
+ for (const m of matches) {
+ if (!m.rec.mgr) continue;
+ const view = Object.assign(Object.create(ArkManager.prototype), { state: m.rec.mgr, account: shimAccount(m.rec) });
+ tx = view.lnLookup(q);
+ if (tx) { ({ walletKey, rec, conn } = m); break; }
+ }
+ if (!tx && !rec.mgr) return false;
+ // a payment of ours still on its way (incoming: claim it; outgoing: a
+ // send an earlier wake began — drive it on, so the lookup itself can
+ // answer 'settled' with the preimage instead of a second pay request)
+ const a = tx && tx.state === 'pending'
+ && (rec.mgr.actions || []).find((x) => x.type === (tx.type === 'incoming' ? 'ln-recv' : 'ln-pay') && x.paymentHash === tx.payment_hash
+ && !['done', 'failed'].includes(x.step));
if (a) {
try {
- const mgr = await bgManager(rec, walletKey, saveFn);
- await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, PAY_WAIT_MS))]);
+ const mgr = await bgManager(rec, walletKey, saveFn, log);
+ if (a.type === 'ln-pay') {
+ const r = await finishLnPay(mgr, a.id, Math.min(deadline, Date.now() + 6000));
+ if (r && r.step === 'done') {
+ const c = connOf(rec, r) || conn;
+ recordBgSpend(rec, r, c);
+ log(`finished a ${r.amountSat} sat send from an earlier wake`);
+ const n = sentNotice(rec, r, c);
+ if (n !== true) lookupNotice = n.notify;
+ } else if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
+ await saveFn(walletKey, rec);
+ } else {
+ await Promise.race([mgr.driveLn(a.id).catch((e) => log('claim: ' + e.message)), new Promise((r) => setTimeout(r, Math.max(0, deadline - Date.now())))]);
+ }
tx = mgr.lnLookup(q);
- if (tx.state === 'settled') log(`received ${tx.amount / 1000} sat while closed`);
+ if (tx.state === 'settled' && tx.type === 'incoming') log(`received ${tx.amount / 1000} sat while closed`);
} catch (e) { log('ark init failed: ' + e.message); }
}
await publish(tx ? { result_type: 'lookup_invoice', result: tx } : errRes('lookup_invoice', 'NOT_FOUND', 'invoice not found'));
- return true;
+ log(`lookup: ${tx ? tx.type + ' ' + tx.state : 'not found'}`);
+ return lookupNotice ? { notify: lookupNotice } : true;
}
if (method === 'get_balance') {
await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } });
@@ -332,17 +434,22 @@ export async function respondFromBg(data, {
await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining ${renewalPeriod(conn.period)} budget (${left} sat)`));
return true;
}
- if (spendableSat(rec) < dec.amountSat) return false; // stale/empty mirror: wake the user
-
- // The ASP's own Lightning send, over the mirrored state. Change and HTLC
- // outputs allocate from nextKeyIndex, which must stay inside the key
- // window the app pre-derived; if it ran out, wake the user instead.
- if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; }
- let mgr;
- try {
- mgr = await bgManager(rec, walletKey, saveFn);
- } catch (e) { log('ark init failed: ' + e.message); return false; }
-
+ // A send this worker began on an earlier wake and never finished (PullCap's
+ // night of 2026-10-23: three hours of fifteen). Its coin sat reserved in
+ // the mirror, so a fresh attempt at the SAME invoice was refused as
+ // "already paid or is being paid" — which the client read as paid, hours
+ // before the money moved — and one at any OTHER invoice found no spendable
+ // coin and woke the user. Finish what was begun first: the same invoice
+ // gets that payment's outcome, another invoice gets the coin back.
+ const stuck = pendingLnPays(rec);
+ const same = stuck.find((a) => a.paymentHash === dec.paymentHash)
+ || (rec.mgr.actions || []).find((a) => a.type === 'ln-pay' && a.paymentHash === dec.paymentHash && a.step === 'done');
+ let mgr = null;
+ let mgrP = null;
+ const ready = async () => mgr || (mgr = await (mgrP || (mgrP = bgManager(rec, walletKey, saveFn, log))));
+ // the manager's start (the ASP's info, the chain tip) runs while the
+ // notifier is asked whether anyone else answered
+ ready().catch(() => {});
// A local failure carries no urgency — wait out the grace period and
// re-check before publishing, so a sibling device's slower SUCCESS reply
// wins the client's eyes instead of being shouted over.
@@ -353,22 +460,81 @@ export async function respondFromBg(data, {
}
return true;
};
+ const settled = async (a, c, { record = true } = {}) => {
+ if (record) recordBgSpend(rec, a, c, { paymentHash: dec.paymentHash, amountSat: dec.amountSat, invoice });
+ // the mirror and the reply at once: every second counts on iOS, and the
+ // "Payment sent" line comes after both
+ await Promise.all([
+ saveFn(walletKey, rec),
+ publish({ result_type: 'pay_invoice', result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 } }).then(() => log('reply sent')),
+ ]);
+ return sentNotice(rec, a, c, dec.amountSat);
+ };
+ if (same) {
+ if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
+ log(`a send for this invoice is already ${same.step} — finishing it`);
+ const wasDone = same.step === 'done';
+ let a = same;
+ if (!wasDone) {
+ try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; }
+ a = await finishLnPay(mgr, same.id, deadline);
+ }
+ // settled earlier (by the app, perhaps): its preimage again, nothing new
+ // on the spend log
+ if (a && a.step === 'done') return settled(a, conn, { record: !wasDone });
+ if (a && a.step === 'failed') { forgetPending(rec, dec.paymentHash); return failQuietly(a.error || 'payment failed'); }
+ log('that send is still in flight — waking the user to finish it');
+ return false;
+ }
+ const notices = [];
+ // Only when they hold the coins this payment needs: otherwise the answer
+ // comes first and the wake's tending finishes them after.
+ if (stuck.length && spendableSat(rec) < dec.amountSat) {
+ try {
+ await ready();
+ for (const a of stuck) {
+ if (Date.now() > deadline - 4000) break;
+ const r = await finishLnPay(mgr, a.id, Math.min(deadline - 3000, Date.now() + 5000));
+ if (!r || r.step !== 'done') {
+ log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`);
+ if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
+ continue;
+ }
+ const c = connOf(rec, r);
+ recordBgSpend(rec, r, c);
+ log(`finished a ${r.amountSat} sat send from an earlier wake`);
+ const n = sentNotice(rec, r, c);
+ if (n !== true) notices.push(n.notify);
+ }
+ await saveFn(walletKey, rec);
+ } catch (e) { log('ark init failed: ' + e.message); }
+ }
+ const withNotices = (out) => (notices.length && out ? { ...(out === true ? {} : out), notices } : out);
+
+ const spendable = spendableSat(rec);
+ if (spendable < dec.amountSat) { log(`${spendable} sat spendable in the mirror, ${dec.amountSat} asked — waking the user`); return false; }
+
+ // The ASP's own Lightning send, over the mirrored state. Change and HTLC
+ // outputs allocate from nextKeyIndex, which must stay inside the key
+ // window the app pre-derived; if it ran out, wake the user instead.
+ if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; }
+ try { await ready(); } catch (e) { log('ark init failed: ' + e.message); return false; }
+
if (!(await claim())) { log('another device claimed this request — standing down'); return true; }
- const deadline = Date.now() + PAY_WAIT_MS;
+ // whose send this is, for the wake that finishes it should this one not
+ notePending(rec, dec.paymentHash, conn);
+ await saveFn(walletKey, rec);
+ log(`paying ${dec.amountSat} sat`);
let a = null;
for (let attempt = 0; ; attempt++) {
let id;
try {
id = await mgr.payLnInvoice(invoice);
} catch (e) {
+ forgetPending(rec, dec.paymentHash);
return failQuietly(e.message);
}
- a = await mgr.awaitLnPay(id, Math.max(1000, deadline - Date.now())).catch(() => mgr.lnAction(id));
- while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline) {
- await new Promise((r) => setTimeout(r, 1500));
- await mgr.driveLn(id).catch(() => {});
- a = mgr.lnAction(id);
- }
+ a = await finishLnPay(mgr, id, deadline);
// A ghost coin (spent elsewhere, this mirror never learned) dies on the
// launchpad in milliseconds. Reconcile against the server — flipping the
// ghost — and retry once from live coins; the budget has barely started.
@@ -383,6 +549,7 @@ export async function respondFromBg(data, {
// Another device of this wallet started this same payment first. Its
// outcome is ours: wait on it, and only speak if the payer stays quiet.
log('a sibling device is paying this invoice — waiting on its outcome');
+ forgetPending(rec, dec.paymentHash);
const out = await mgr.lnOutcome(dec.paymentHash, Math.max(3000, deadline - Date.now()));
if (out.status === 'failed') return failQuietly(a.error);
if (out.status !== 'success') { log('sibling payment still pending — leaving it to them'); return true; }
@@ -393,38 +560,50 @@ export async function respondFromBg(data, {
return true;
}
if (!a || a.step === 'failed') {
+ forgetPending(rec, dec.paymentHash);
return failQuietly((a && a.error) || 'payment failed');
}
if (a.step !== 'done') {
- // still in flight as our budget runs out: the app resumes it from the
- // mirrored state on next open — wake the user rather than guess.
+ // still in flight as our budget runs out: the next wake (or the app, on
+ // open) finishes it — wake the user rather than guess.
log('payment still pending at SW deadline');
return false;
}
-
- rec.spends = rec.spends || [];
- rec.spends.push({
- connId: conn.id, amountSat: a.amountSat || dec.amountSat, feeSat: a.feeSat || 0,
- paymentHash: dec.paymentHash, preimage: a.preimage, invoice, ts: Date.now(),
- });
- if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200);
- await saveFn(walletKey, rec);
- await publish({
- result_type: 'pay_invoice',
- result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 },
- });
log(`paid ${a.amountSat || dec.amountSat} sat via the ASP from the background mirror`);
- // Money left while the app was closed — that deserves a heads-up, unless
- // the user turned "payments sent" notifications off in Settings.
- if (rec.prefs && rec.prefs.paySent === false) return true;
- const paidSat = a.amountSat || dec.amountSat;
- const feeNote = a.feeSat ? ` (+${a.feeSat} fee)` : '';
- return {
- notify: {
- title: 'Payment sent', tag: 'nwc-sent',
- body: `-${paidSat.toLocaleString()} sats${feeNote} via ${conn.name || 'a connected app'}`,
- },
- };
+ return withNotices(await settled(a, conn));
+}
+
+// Sends begun on an earlier wake and never finished (see pay_invoice): any
+// wake drives each on, within its budget, and hands back the "Payment sent"
+// lines for the ones that settle. Without this a stuck send waited for the
+// app to open, its coin reserved all the while.
+export async function tendLnPays({ recordsFn = allBgs, saveFn = saveBg, log = () => {}, deadline = Date.now() + 8000 } = {}) {
+ const notices = [];
+ for (const { walletKey, rec } of await recordsFn()) {
+ if (!rec || rec.v < 3 || !rec.mgr || !rec.ark) continue;
+ const stuck = pendingLnPays(rec);
+ if (!stuck.length || Date.now() > deadline - 3000) continue;
+ try {
+ const mgr = await Promise.race([bgManager(rec, walletKey, saveFn, log),
+ new Promise((_, rej) => setTimeout(() => rej(new Error('out of time')), Math.max(0, deadline - Date.now())))]);
+ for (const a of stuck) {
+ if (Date.now() > deadline - 3000) break;
+ const r = await finishLnPay(mgr, a.id, deadline);
+ if (!r || r.step !== 'done') {
+ log(`ln-pay ${String(a.paymentHash || '').slice(0, 8)} from an earlier wake is still ${r ? r.step : 'gone'}`);
+ if (r && r.step === 'failed') forgetPending(rec, a.paymentHash);
+ continue;
+ }
+ const c = connOf(rec, r);
+ recordBgSpend(rec, r, c);
+ log(`finished a ${r.amountSat} sat send from an earlier wake`);
+ const n = sentNotice(rec, r, c);
+ if (n !== true) notices.push(n.notify);
+ }
+ await saveFn(walletKey, rec);
+ } catch (e) { log('earlier sends: ' + e.message); }
+ }
+ return notices;
}
// Renewals while the app is closed. The app registers a standing order with
@@ -460,12 +639,21 @@ 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 (got) { n += got; log(`registered ${got} renewal order(s) while closed`); }
+ 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; room -= got; log(`registered ${got} renewal order(s) while closed`); }
+ }
+ // Under every tier: a coin near the server's depth cap must be
+ // flattened to stay spendable (the base fee is waived for it).
+ if (room > 0) {
+ const got = await bounded(mgr.ensureDeepRefreshes(room)).catch((e) => { log('deep refresh order: ' + e.message); return 0; });
+ if (got) { n += got; log(`registered ${got} deep-coin renewal order(s) while closed`); }
}
await saveFn(walletKey, rec);
} catch (e) { log('renewals: ' + e.message); }
diff --git a/src/style.css b/src/style.css
index 4fbe96b..460b584 100644
--- a/src/style.css
+++ b/src/style.css
@@ -359,6 +359,10 @@ textarea.send-dest::placeholder { font-family: var(--sans); }
/* ---- lists (tx / utxo) ---- */
.list { display: flex; flex-direction: column; }
.item { display: flex; align-items: center; gap: 12px; padding: 13px 2px; border-top: 1px solid var(--line); }
+/* the middle column may shrink: a long name chip beside the label then
+ ellipsizes on one line instead of breaking mid-name and pushing the
+ amount about */
+.item > .grow { min-width: 0; }
.item:first-child { border-top: 0; }
.ico { width: 34px; height: 34px; border-radius: 9px; flex: none; display: grid; place-items: center; font-size: 16px; }
.ico.in { background: var(--green-soft); color: var(--green); }
@@ -366,7 +370,7 @@ textarea.send-dest::placeholder { font-family: var(--sans); }
.amount-pos { color: var(--green); font-family: var(--mono); font-weight: 600; }
.amount-neg { color: var(--red); font-family: var(--mono); font-weight: 600; }
.amount { font-family: var(--mono); font-weight: 600; }
-.tag { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 6px; }
+.tag { font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 6px; white-space: nowrap; flex: none; }
.tag.pending { background: var(--accent-soft); color: var(--accent-ink); }
.tag.conf { background: var(--green-soft); color: var(--green); }
@@ -847,11 +851,29 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
}
.lightbox-x:hover { background: rgba(255, 255, 255, .24); }
@keyframes lightbox-in { from { opacity: 0; } to { opacity: 1; } }
-.note-video { display: block; width: 100%; height: auto; max-height: 100vh; object-fit: contain; border-radius: 10px; margin-top: 6px; background: #000; }
+.note-video { display: block; width: 100%; height: auto; max-height: var(--vid-cap); object-fit: contain; border-radius: 10px; margin-top: 6px; background: #000; }
/* a video plays by itself, muted, while it is on screen; the corner button
turns the sound on (see autoplay in messages.js) */
.note-video-box { position: relative; display: block; max-width: 100%; margin-top: 6px; }
.note-video-box .note-video { margin-top: 0; }
+/* the height a feed clip may take: the whole screen on a phone, three
+ quarters of it on a computer (a portrait clip on a 1920 screen ran off it) */
+:root { --vid-cap: 100vh; }
+@media (min-width: 900px) { :root { --vid-cap: 75vh; } }
+/* with a mouse: the big play/pause in the middle, while the pointer is over the clip */
+.vid-pp {
+ position: absolute; left: 50%; top: 50%; z-index: 2; width: 64px; height: 64px; margin: -32px 0 0 -32px; padding: 0;
+ border: 0; border-radius: 50%; background: rgba(18, 18, 18, .6); color: #fff; cursor: pointer;
+ display: none; align-items: center; justify-content: center;
+}
+.vid-pp:hover { background: rgba(18, 18, 18, .85); }
+.vid-pp-i { width: 0; height: 0; margin-left: 5px; border-style: solid; border-width: 13px 0 13px 22px; border-color: transparent transparent transparent #fff; }
+.vid-pp.playing .vid-pp-i { width: 20px; height: 24px; margin: 0; border-width: 0 7px; border-color: #fff; }
+.has-mouse .note-video-box:hover .vid-pp, .vid-pp:focus-visible { display: flex; }
+/* the moments bar is the clip's only seek bar: the middle button is its play
+ control on a touch screen too, up whenever the clip is paused */
+.note-video-box.own-bar .vid-pp:not(.playing) { display: flex; }
+.note-video-box:has(.vid-heavy) .vid-pp { display: none; }
.vid-sound {
position: absolute; top: 8px; left: 8px; z-index: 2; margin: 0; padding: 4px 10px; border: 0;
border-radius: 999px; background: rgba(18, 18, 18, .7); color: #fff; font-size: 13px; line-height: 1.3;
@@ -929,6 +951,23 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.live-line-body { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; line-height: 1.35; }
.live-zap .live-line-body b { color: var(--accent); }
.live-chat .chat-row { padding-right: 2px; }
+/* on phones the stream is the page, the way Nostrich lays one out: the
+ picture runs edge to edge and the chat under it is a plain full-width
+ list of lines, no bubbles — the card walls step outside #app's padding */
+@media (max-width: 520px) {
+ .live-page > .card {
+ margin-left: -16px; margin-right: -16px; padding: 10px 0 12px;
+ border-left: none; border-right: none; border-radius: 0; box-shadow: none;
+ }
+ .live-page > .live-chat-card { margin-top: 0; padding-top: 6px; border-top: none; }
+ .live-page > .card > :not(.live-player, .live-chat) { margin-left: 14px; margin-right: 14px; }
+ .live-page .live-player { border-radius: 0; }
+ .live-page .live-chat { padding: 10px 14px 0; max-height: 60vh; }
+ .live-page .live-title { font-size: 17px; }
+ .live-chat .chat-row { gap: 10px; }
+ .live-chat .chat-bubble { display: block; background: transparent; border: 0; border-radius: 0; padding: 0; font-size: 15px; }
+ .live-chat .chat-quote { margin-bottom: 2px; }
+}
.bc-preview.mirror { transform: scaleX(-1); }
.bc-bar .bc-clock { font-variant-numeric: tabular-nums; font-weight: 650; }
.go-live-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #e0245e; box-shadow: 0 0 0 3px rgba(224, 36, 94, .25); }
@@ -1180,7 +1219,8 @@ html.no-scroll { overflow: hidden; scrollbar-gutter: auto; }
.profile-banner + .row .profile-avatar {
border: 3px solid var(--paper); box-sizing: content-box;
}
-.zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; }
+.zap-chip { display: inline-flex; gap: 5px; align-items: center; cursor: pointer; background: var(--surface2); border: 1px solid var(--line); border-radius: 999px; padding: 1px 8px 1px 2px; min-width: 0; max-width: 100%; }
+.zap-chip > span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-avatar.mini { width: 18px; height: 18px; font-size: 8px; flex-shrink: 0; }
/* A feed row is the one place a face is the point, not a label on a line —
Amethyst gives it 55dp; at our 30px it read as a bullet beside the post.
@@ -1538,6 +1578,20 @@ body::after {
background: var(--surface2); overflow: hidden; cursor: pointer;
}
.media-tile.video { aspect-ratio: 9 / 16; }
+/* only wide clips in it: one to a row, the poster whole */
+.media-grid.wide { grid-template-columns: 1fr; gap: 6px; }
+.media-grid.wide .media-tile.video { aspect-ratio: 16 / 9; }
+/* every shape in it: justified rows. A tile starts at its proportions at
+ the row height and grows in proportion to them, so a row's tiles share one
+ height and fill the width; the filler takes the last row's slack instead
+ of stretching its few tiles. */
+.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; }
.media-tile-play {
@@ -1965,6 +2019,7 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
/* A post's audio/video timeline: the waveform (audio), and what was said,
reacted or zapped at a moment, sitting over the bar at that moment. */
.media-timeline { padding: 24px 0 8px; white-space: normal; color: var(--text); }
+.note-video-box .media-timeline:not(.has-marks) { display: none; }
.media-time { font-variant-numeric: tabular-nums; }
.media-seek { position: relative; height: 42px; --played: 0%; }
.media-wave { position: absolute; inset: 3px 0; display: flex; align-items: center; gap: 2px;
@@ -2007,3 +2062,12 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut
.live-watching.has-who { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 3px; }
.live-who { display: flex; flex-wrap: wrap; gap: 6px; }
.live-who-p { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 3px; border-radius: 999px; background: var(--bg2, rgba(127,127,127,.12)); border: 0; color: inherit; cursor: pointer; }
+
+/* a gift's note: the sender's words above the amount, and the field for them */
+.gift-note {
+ margin: 0; padding: 8px 12px; max-width: 100%;
+ border-left: 3px solid var(--accent); border-radius: 4px;
+ background: color-mix(in srgb, var(--accent) 8%, transparent);
+ font-style: italic; text-align: left; white-space: pre-wrap; overflow-wrap: anywhere;
+}
+.gift-note-input { width: 100%; min-height: 0; font-family: var(--sans); }
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
index f9d383e..3a48cff 100644
--- a/src/sw-nwc.js
+++ b/src/sw-nwc.js
@@ -4,20 +4,77 @@
// nwc-respond.js), and only falls back to the wake-the-user notification
// when it can't.
-import { respondFromBg, tendRenewals } from './nwc-respond.js';
-import { withRequestLock } from './nwc-lock.js';
+import { respondFromBg, tendRenewals, tendLnPays } from './nwc-respond.js';
+import { withRequestLock, withWorkLock } from './nwc-lock.js';
+import { appendBgLog, takeLease } from './nwc-bg.js';
import { bgAutoWithdraw } from './bg-autowithdraw.js';
import { allInboxes, classifyDm, shouldNotifyDm, classifyCall } from './dm-inbox.js';
const NOTIFIER = 'https://nwcpush.coinos.io';
+// The worker's diary (nwc-bg.js): every line also lands in IndexedDB as it is
+// written, so a worker the phone stops mid-push leaves its last step behind.
+const stamp = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
+const diary = (m) => { console.log('[sw-nwc]', m); appendBgLog(`${stamp()} ${m}`); };
+diary(`worker started${typeof navigator !== 'undefined' && navigator.locks ? '' : ' (no Web Locks)'}`);
const CALL_CACHE = 'coinos-call'; // a call offer a push brought, for the app the tap opens
// where a call's offer wrap is fetched from: the notifier, or (the app served
// on this machine, as the tests do) this origin
const WRAPS = /^(localhost|127\.0\.0\.1)$/.test(self.location.hostname) ? self.location.origin + '/nwcpush' : NOTIFIER;
+// WebKit (Safari, and every browser on iOS) allows no silent pushes: a push
+// event that ends without showing a notification is a strike, even with the
+// app open, and after three it drops the subscription. The wallet then stops
+// waking until it is opened again. Elsewhere a quiet push is fine, so only an
+// Apple endpoint gets a stand-in where we'd otherwise stay silent: closed at
+// once when the app is on screen (it shows everything live there), left as
+// one self-replacing silent line in the background.
+async function applePush() {
+ try {
+ const s = await self.registration.pushManager.getSubscription();
+ return !!s && /(^|\.)push\.apple\.com$/.test(new URL(s.endpoint).hostname);
+ } catch (_) { return false; }
+}
+async function standIn(visible) {
+ if (!(await applePush())) return false;
+ await self.registration.showNotification('Coinos', {
+ body: 'Your wallet checked in.', icon: 'icon-192.png', badge: 'badge-96.png',
+ tag: 'coinos-bg', renotify: false, silent: true, data: { url: './' },
+ });
+ if (visible) {
+ const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []);
+ for (const n of open) n.close();
+ }
+ return true;
+}
+
self.addEventListener('push', (e) => {
let data = {};
try { data = e.data ? e.data.json() : {}; } catch (_) {}
+ // every path below ends in a notification or, failing that, the stand-in
+ let showed = false;
+ // A notification the platform refuses must not count as shown: the old
+ // code flagged it before the call, so a refusal left the push with no line
+ // at all — a silent push, on iOS a strike towards losing the subscription.
+ // (PullCap's night of 2026-10-22: paid four times, no "Payment sent" seen.)
+ // Try the full options; refused, try again with the plainest ones.
+ const show = async (title, opts) => {
+ try { await self.registration.showNotification(title, opts); showed = true; diary(`notification shown: ${title}`); return; }
+ catch (e) { diary(`notification refused (${title}): ${e && e.message}`); }
+ try { await self.registration.showNotification(title, { body: opts && opts.body, tag: opts && opts.tag, data: opts && opts.data }); showed = true; diary(`plain notification shown: ${title}`); }
+ catch (e) { diary(`plain notification refused too (${title}): ${e && e.message}`); }
+ };
+ diary(`push ${data.type || '?'}${data.reason ? '/' + data.reason : ''}${data.event && data.event.id ? ' ' + data.event.id.slice(0, 8) : ''}`);
+ // the stand-in, as soon as a path knows it has nothing to say (a long tail
+ // of work after it mustn't keep the push waiting for its notification)
+ const quiet = async () => {
+ if (showed) return;
+ showed = true;
+ const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }).catch(() => []);
+ await standIn(clients.some((c) => c.visibilityState === 'visible')).catch(() => {});
+ };
+ const settle = (work) => e.waitUntil((async () => {
+ try { await work(); } finally { await quiet(); }
+ })());
// User-facing notifications (payments, DMs, community chat). An open
// window already shows everything live — stay quiet then.
// A call ringing in: the push names the offer's wrap, kept at the notifier
@@ -26,7 +83,7 @@ self.addEventListener('push', (e) => {
// until it's answered or missed. The offer is kept for the app to pick up
// when the tap opens it (CALL_CACHE). An open window rings by itself.
if (data.type === 'notify' && data.reason === 'call') {
- e.waitUntil((async () => {
+ settle(async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clients.some((c) => c.visibilityState === 'visible')) return;
let ring = null;
@@ -51,17 +108,17 @@ self.addEventListener('push', (e) => {
await cache.put('/__pending-call', new Response(JSON.stringify({ offer: ring.offer, at: Date.now() }), { headers: { 'content-type': 'application/json' } }));
} catch (_) {}
for (const c of clients) { try { c.postMessage({ type: 'call-offer', offer: ring.offer }); } catch (_) {} }
- await self.registration.showNotification((ring.name || 'Someone') + ' is calling', {
+ await show((ring.name || 'Someone') + ' is calling', {
body: ring.type === 'video' ? 'Video call. Tap to answer.' : 'Voice call. Tap to answer.',
icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'call', renotify: true, requireInteraction: true, vibrate: [400, 200, 400, 200, 400],
data: { url: './?open=call', view: 'call' },
});
- })());
+ });
return;
}
if (data.type === 'notify') {
- e.waitUntil((async () => {
+ settle(async () => {
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
if (clients.some((c) => c.visibilityState === 'visible')) return;
// Money landing is exactly when a forwarding rule wants to run, and this
@@ -104,65 +161,112 @@ self.addEventListener('push', (e) => {
const [title, body] = T[data.reason] || T.chat;
// a reply lands you in the Notifications list, where it is
const target = data.reason === 'mention' || data.reason === 'reaction' ? { url: './?open=notifs', view: 'notifs' } : { url: './' };
- await self.registration.showNotification(title, {
+ await show(title, {
body,
icon: 'icon-192.png', badge: 'badge-96.png',
tag: 'notify-' + (data.reason || 'chat'), renotify: ['payment', 'zap', 'mention'].includes(data.reason),
data: target,
});
- })());
+ });
return;
}
- if (data.type !== 'nwc') return;
+ if (data.type !== 'nwc') { settle(async () => {}); return; }
const startedAt = Date.now();
- e.waitUntil((async () => {
+ settle(async () => {
+ // WebKit's silent-push quota counts a push whose notification comes
+ // late: a payment can take most of the push budget, and a worker stopped
+ // before its last line never shows one (PullCap's night of 2026-10-22:
+ // four payments, no line for the fourth, then no more wakes). On an
+ // Apple endpoint the stand-in goes up FIRST; the outcome replaces it.
+ const apple = await applePush();
+ // What a push may take. iOS stops the worker about ten seconds in (PullCap's
+ // diary of 2026-10-24: every wake's last line 8–12 s after its push, in the
+ // middle of a payment), whatever the waitUntil — so there everything is
+ // planned inside nine; elsewhere the usual thirty.
+ const budgetEnd = startedAt + (apple ? 9_000 : 27_000);
+ const dropStandIn = async () => {
+ const open = await self.registration.getNotifications({ tag: 'coinos-bg' }).catch(() => []);
+ for (const n of open) n.close();
+ };
+ if (apple && await standIn(false).catch((e) => { diary('stand-in refused: ' + (e && e.message)); return false; })) { showed = true; diary('stand-in shown'); }
// An open window handles requests itself with full wallet state — nudge
- // it and stay out of the way.
- const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ // it and stay out of the way. On iOS only one on screen counts: a
+ // backgrounded app is suspended, so a nudge to it went unanswered.
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ const clients = all.filter((c) => !apple || c.visibilityState === 'visible');
if (clients.length) {
for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey });
+ if (apple) await dropStandIn(); // the window on screen shows it live
+ diary('window on screen — handed over');
return;
}
+ const notice = (n) => show(n.title, {
+ body: n.body, icon: 'icon-192.png', badge: 'badge-96.png',
+ tag: n.tag || 'nwc-incoming', renotify: !apple, data: { url: './' },
+ });
// A wake is the only moment a closed wallet gets to look after its coins:
- // claim renewals whose round has run, cover coins without one. After the
- // answer, inside what is left of the push budget.
- const tend = () => tendRenewals({ deadline: startedAt + 27_000, log: (m) => console.log('[sw-renew]', m) })
- .catch((err) => console.warn('[sw-renew] failed:', err && err.message));
+ // finish sends an earlier wake left in flight, claim renewals whose round
+ // has run, cover coins without one. After the answer, inside what is
+ // left of the push budget.
+ const tend = async () => {
+ const late = await tendLnPays({ deadline: budgetEnd, log: diary }).catch((err) => { diary('earlier sends failed: ' + (err && err.message)); return []; });
+ for (const n of late) await notice(n);
+ if (late.length && apple) await dropStandIn();
+ await tendRenewals({ deadline: budgetEnd, log: (m) => diary('renew: ' + m) })
+ .catch((err) => diary('renewals failed: ' + (err && err.message)));
+ };
// an NWC wake-up may also have moved money in; check the rule after
setTimeout(() => { bgAutoWithdraw({ log: (m) => console.log('[sw-aw]', m) }).catch(() => {}); }, 0);
- let handled = false;
- try {
- handled = await withRequestLock(data.event && data.event.id,
- () => respondFromBg(data, { notifier: NOTIFIER, log: (m) => console.log('[sw-nwc]', m) }),
- () => { console.log('[sw-nwc] another context holds this request'); return true; });
- } catch (err) {
- console.warn('[sw-nwc] auto-answer failed:', err && err.message);
- }
- // handled-with-a-heads-up: e.g. an invoice was minted while closed and
- // the user should open coinos to complete the receive
- if (handled && handled.notify) {
- await self.registration.showNotification(handled.notify.title, {
- body: handled.notify.body,
+ // one push at a time over the shared mirror (a client's retry lands
+ // seconds after its lookup, and each push builds its own manager)
+ await withWorkLock(async () => {
+ const waited = Date.now() - startedAt;
+ if (waited > 500) diary(`waited ${waited} ms for an earlier push to finish`);
+ if (Date.now() > budgetEnd - 2_000) { diary('no time left after waiting — leaving it to the next try'); return; }
+ let handled = false;
+ try {
+ handled = await withRequestLock(data.event && data.event.id,
+ () => respondFromBg(data, { notifier: NOTIFIER, log: diary, deadline: budgetEnd }),
+ () => { diary('another context holds this request'); return true; });
+ } catch (err) {
+ diary('auto-answer failed: ' + (err && err.message));
+ }
+ // sends from an earlier wake that settled on the way to this answer
+ const late = (handled && handled.notices) || [];
+ for (const n of late) await notice(n);
+ // handled-with-a-heads-up: e.g. an invoice was minted while closed and
+ // the user should open coinos to complete the receive
+ if (handled && handled.notify) {
+ await notice(handled.notify);
+ if (apple) await dropStandIn();
+ await tend();
+ return;
+ }
+ if (handled) {
+ if (late.length && apple) await dropStandIn();
+ diary('answered quietly');
+ await quiet();
+ await tend();
+ return;
+ }
+ if (Date.now() > budgetEnd) { diary('out of time — leaving it to the next try'); return; }
+ // A kind-21001 offer request is money coming IN (someone asking us to
+ // mint an invoice) — "an app wants to pay" would read as the opposite.
+ const incoming = data.event && data.event.kind === 21001;
+ diary(incoming ? 'waking the user: incoming payment' : 'waking the user: payment request');
+ await show(incoming ? 'Incoming payment' : 'Payment request', {
+ body: incoming
+ ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.'
+ : 'An app is asking your wallet to pay. Open Coinos to approve.',
icon: 'icon-192.png', badge: 'badge-96.png',
- tag: handled.notify.tag || 'nwc-incoming', renotify: true,
+ tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
data: { url: './' },
});
+ if (apple) await dropStandIn();
await tend();
- return;
- }
- if (handled) { await tend(); return; }
- // A kind-21001 offer request is money coming IN (someone asking us to
- // mint an invoice) — "an app wants to pay" would read as the opposite.
- const incoming = data.event && data.event.kind === 21001;
- await self.registration.showNotification(incoming ? 'Incoming payment' : 'Payment request', {
- body: incoming
- ? 'Someone is trying to send you money. Open coinos so your wallet can receive it.'
- : 'An app is asking your wallet to pay. Open Coinos to approve.',
- icon: 'icon-192.png', badge: 'badge-96.png',
- tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
- data: { url: './' },
- });
- })());
+ }, { lease: () => takeLease('work', { wait: Math.max(0, budgetEnd - Date.now() - 3_000) }) });
+ diary(`push done in ${Date.now() - startedAt} ms`);
+ });
});
self.addEventListener('notificationclick', (e) => {
diff --git a/static/og-image.png b/static/og-image.png
new file mode 100644
index 0000000..c048083
--- /dev/null
+++ b/static/og-image.png
Binary files differ
diff --git a/tools/amount-field-unit-test.js b/tools/amount-field-unit-test.js
index b984064..596969d 100644
--- a/tools/amount-field-unit-test.js
+++ b/tools/amount-field-unit-test.js
@@ -2,6 +2,8 @@
// showing: in fiat that is money to the cent, floored so a filled-in maximum
// never parses back to more sats than were offered — not a BTC figure under a
// CAD label (2026-10-08: Max on an ark recipient filled 0.00004978 'CAD').
+// Switching the unit with an amount already typed into the send form converts
+// it (2026-10-23): the same money in the new unit, round-tripping exactly.
// Run: bun tools/amount-field-unit-test.js
import assert from 'node:assert/strict';
import puppeteer from './puppeteer.js';
@@ -9,7 +11,7 @@ import puppeteer from './puppeteer.js';
const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
plugins: [{ name: 'amount-field-test', setup(build) {
build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
-window.amtApp = { ctx, toggleUnit, get unit() { return unit; } };
+window.amtApp = { ctx, ui, toggleUnit, get unit() { return unit; } };
` }));
build.onLoad({ filter: /src\/video-compress\.js$/ }, () => ({ loader: 'js', contents: 'export async function compressVideo(f) { return f; }' }));
} }] });
@@ -37,6 +39,13 @@ try {
while (amtApp.unit !== 'sats') amtApp.toggleUnit();
out.sats = ctx.amountField(62212);
amtApp.toggleUnit(); out.btc = ctx.amountField(62212);
+ // typed into the send form, then the unit toggled around the ring
+ while (amtApp.unit !== 'sats') amtApp.toggleUnit();
+ const rs = amtApp.ui.send.recipients;
+ rs[0].amount = '62212'; rs.push({ address: '', amount: '93.318' }, { address: '', amount: '' }, { address: '', amount: 'abc' });
+ out.ring = [];
+ for (let i = 0; i < 3; i++) { amtApp.toggleUnit(); out.ring.push([amtApp.unit, ...rs.map((r) => r.amount)]); }
+ rs.splice(1);
return out;
});
console.log(r);
@@ -46,6 +55,11 @@ try {
assert.equal(r.typed, 62207, 'the same money typed as something else parses as typed');
assert.equal(r.sats, '62212');
assert.equal(r.btc, '0.00062212');
+ assert.deepEqual(r.ring, [
+ ['btc', '0.00062212', '0.00000093', '', 'abc'],
+ ['fiat', '93.31', '0.13', '', 'abc'],
+ ['sats', '62212', '93', '', 'abc'],
+ ], 'a typed amount follows the unit around the ring and comes back exact; blanks and junk are left alone');
assert.deepEqual(errors, []);
console.log('✅ amount fills follow the display unit');
} finally { await browser.close(); server.stop(true); }
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-foreign-test.js b/tools/ark/custody-foreign-test.js
new file mode 100644
index 0000000..148622d
--- /dev/null
+++ b/tools/ark/custody-foreign-test.js
@@ -0,0 +1,102 @@
+// On a server that does not honor a coin past its timelock (Second, a custom
+// captaind) "coinos holds my coins" is not a tier: whatever is stored, the
+// tier in force is "self" (or "exit" when chosen), the card offers only those
+// two, and the renewal pass registers backstops and renews what is due.
+// Real app with a local Ark manager stub; nothing is sent anywhere.
+// Run: bun tools/ark/custody-foreign-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-foreign-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, applyCustody, maybeAutoRefresh, 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');
+ // a custom server: no honorsExpired on its record, like Second's preset
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'custom');
+ localStorage.setItem('btc-wallet-ark-custom:regtest', JSON.stringify({ ark: 'http://localhost:3535', esplora: 'http://localhost:30002' }));
+ });
+ 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: 288, ppm: 2000 }, { thresholdBlocks: 1008, ppm: 4000 }, { thresholdBlocks: 2016, ppm: 5000 }];
+ window.coopManager = {
+ honorsExpired: false,
+ state: { vtxos: [
+ { id: 'coin-0', amountSat: 20000, state: 'spendable', expiryHeight: 973921, depth: 2 }, // weeks left
+ { id: 'coin-1', amountSat: 5000, state: 'spendable', expiryHeight: 969760, depth: 1 }, // 9 blocks from its timelock
+ ], actions: [], movements: [], scheduled: [] },
+ info: { network: 'regtest', vtxoExpiryDelta: 4032, roundIntervalSecs: 3600, refreshFees: { baseFeeSat: 0, 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: 25000, 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 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');
+
+ // nothing stored: the tier in force is "self", and the card offers no custodial option
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), null, 'nothing was written to the record');
+ const opts = await page.$$eval('#ark-custody-select option', (os) => os.map((o) => [o.value, o.selected]));
+ assert.deepEqual(opts, [['self', true], ['exit', false]]);
+ console.log('✓ a server that does not honor expired coins has no "coinos holds my coins" tier; "self" is in force');
+
+ // the stored default from a coinos wallet does not switch renewals off here
+ await page.evaluate(() => coopApp.wallet.saveFeatureState('arkCustody', 'custodial'));
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'self');
+ console.log('✓ a stored "custodial" choice reads as "self" on such a server');
+
+ // the renewal pass runs: backstop orders are registered and the coin at its timelock renews now
+ const calls = await page.evaluate(async () => { coopFeature.autoRefreshCalls.length = 0; await coopFeature.maybeAutoRefresh(coopManager); return coopFeature.autoRefreshCalls; });
+ assert.deepEqual(calls[0], ['ensure', 6, 8, 1], 'backstop orders registered (regtest margin 6)');
+ assert.deepEqual(calls[1], ['refresh', ['coin-1']], 'the coin close to its timelock is renewed now');
+ console.log('✓ automatic renewal runs on such a server with nothing opted into');
+
+ // the exit tier can still be chosen, and is kept
+ await page.evaluate(() => coopFeature.applyCustody('exit'));
+ assert.equal(await page.evaluate(() => coopFeature.getCustody()), 'exit');
+ assert.equal(await page.evaluate(() => coopApp.wallet.loadFeatureState('arkCustody', null)), 'exit');
+ console.log('✓ "exit" can be chosen there and is saved');
+ assert.deepEqual(errors, [], 'no page errors');
+} finally { await browser.close(); server.stop(true); }
diff --git a/tools/ark/custody-test.js b/tools/ark/custody-test.js
new file mode 100644
index 0000000..214ab45
--- /dev/null
+++ b/tools/ark/custody-test.js
@@ -0,0 +1,130 @@
+// 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');
+}
+
+// deep coins: the server's depth cap and its base-fee waiver, mirrored
+{
+ const m = mk(true);
+ m.info.refreshFees.deepWaiverDepth = 60;
+ m.info.maxVtxoExitDepth = 100;
+ m.exitDepth = (v) => v.depth || 0;
+ m._tipMemo = async () => 1000;
+ const shallow = coin('s', 5000, 2000, { depth: 7 }), deep = coin('d', 4000, 2000, { depth: 60 });
+ const capped = coin('x', 3000, 2000, { depth: 100 }), near = coin('n', 2500, 2000, { depth: 92 });
+ const expiredDeep = coin('e', 6000, 900, { depth: 70 });
+ m.state.vtxos = [shallow, deep, capped, near, expiredDeep];
+ assert.equal(m.refreshFee([deep], 1000), 0, 'a refresh of deep coins alone pays no base fee');
+ assert.equal(m.refreshFee([deep, expiredDeep], 1000), 0);
+ assert.equal(m.refreshFee([deep, shallow], 1000), 300, '…one shallow coin in the set and it pays');
+ assert.equal(m.refreshFee([shallow], 1000), 300);
+ assert.deepEqual(m.deepCoins().map((v) => v.id).sort(), ['d', 'e', 'n', 'x'], 'deep = at the waiver depth or within 8 hops of the cap, expired or not');
+ assert.deepEqual(m._selectInputs(2900).map((v) => v.id), ['d'], 'a coin at the cap is never an input (the cosign would be refused)');
+ m.state.vtxos = [capped];
+ assert.throws(() => m._selectInputs(1000), /waiting to be renewed/, 'and the shortfall says why');
+ m.state.vtxos = [shallow, deep, capped, near, expiredDeep];
+ // the worker's orders: one per deep coin still inside its timelock, two blocks out
+ const orders = [];
+ m.scheduleRefresh = async (ids, h) => { orders.push([ids.join(), h]); (m.state.scheduled ||= []).push({ inputIds: ids, unlockHash: 'u' + ids }); };
+ assert.equal(await m.ensureDeepRefreshes(3), 3);
+ assert.deepEqual(orders, [['x', 1002], ['n', 1002], ['d', 1002]], 'deepest first, at tip + 2; the expired one waits for the app');
+ assert.equal(await m.ensureDeepRefreshes(3), 0, 'covered coins are not ordered twice');
+ // no waiver and no cap advertised: nothing is deep
+ const plain = mk(true); plain.exitDepth = () => 999; plain.state.vtxos = [coin('p', 5000, 2000)];
+ assert.deepEqual(plain.deepCoins(), []);
+ assert.equal(plain.refreshFee(plain.state.vtxos, 1000), 300, 'no waiver advertised: the base fee stands however deep');
+ console.log('✓ deep coins: no base fee for a refresh of deep coins alone, a capped coin is never spent, the worker orders them flattened');
+}
+
+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/ark/offboard-split-test.js b/tools/ark/offboard-split-test.js
new file mode 100644
index 0000000..561ee2b
--- /dev/null
+++ b/tools/ark/offboard-split-test.js
@@ -0,0 +1,128 @@
+// A partial on-chain send from Spending moves whole coins: the amount is
+// covered by coins that already add up to it, else split off via a self-send
+// that lands as one coin per input — all of them go, not one of the exact
+// size (Jarryd, 2026-10-23: three splits, no send). Local manager stub.
+// Run: bun tools/ark/offboard-split-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: 'offboard-split-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, get ready() { return !_bootDeciding; } };
+` }));
+ 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; }, offboard: doArkOffboard, exactCoins };
+ 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', 'off');
+ });
+ 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 () => {};
+ window.coopCalls = [];
+ window.coopManager = {
+ state: { vtxos: [], actions: [], movements: [], nextKeyIndex: 5 },
+ async offboardQuote() { return { chainSat: 736, serviceSat: 204, totalSat: 940, minSat: 1270 }; },
+ info: { network: 'regtest' }, _tipH: 100,
+ vtxos() { return this.state.vtxos; },
+ refreshFee() { return 0; }, unseenReceives() { return []; }, ackReceives() {},
+ address() { return 'test-ark-address'; },
+ // the real thing: largest inputs first until covered, one destination
+ // coin per input (the last carries the remainder), change on a new key;
+ // the destination coins land on the receive key at the next sync
+ async send(address, amount) {
+ coopCalls.push({ type: 'split', address, amount });
+ const free = this.state.vtxos.filter((v) => v.state === 'spendable').sort((a, b) => b.amountSat - a.amountSat);
+ const inputs = []; let sum = 0;
+ for (const v of free) { inputs.push(v); sum += v.amountSat; if (sum >= amount) break; }
+ let remaining = amount; const landing = [];
+ for (const v of inputs) {
+ const dest = Math.min(remaining, v.amountSat); remaining -= dest;
+ landing.push({ id: `dest-${v.id}`, amountSat: dest, state: 'spendable', keyIndex: 0 });
+ if (v.amountSat > dest) this.state.vtxos.push({ id: `change-${v.id}`, amountSat: v.amountSat - dest, state: 'spendable', keyIndex: this.state.nextKeyIndex++ });
+ v.state = 'spent';
+ }
+ this.pendingLanding = landing;
+ return 'send-1';
+ },
+ async sync() { if (this.pendingLanding) { this.state.vtxos.push(...this.pendingLanding); this.pendingLanding = null; } },
+ async startOffboard(spk, address, ids) {
+ coopCalls.push({ type: 'offboard', address, ids: ids || null });
+ return { txid: 'ab'.repeat(32), netSat: 1, feeSat: 1 };
+ },
+ };
+ coopFeature.setManager(coopManager);
+ coopApp.ui.screen = 'wallet'; coopApp.ui.tab = 'send'; coopApp.render();
+ }, generateMnemonic(wordlist));
+ const run = (coins, amount) => page.evaluate(async (coins, amount) => {
+ coopManager.state.vtxos = coins.map((c) => ({ state: 'spendable', keyIndex: 0, ...c }));
+ coopCalls.length = 0;
+ coopApp.ui.arkError = ''; coopApp.ui.arkOffboarded = null;
+ await coopFeature.offboard(amount, coopApp.wallet.derive(0, 1).address);
+ return { calls: coopCalls, err: coopApp.ui.arkError, done: !!coopApp.ui.arkOffboarded };
+ }, coins, amount);
+
+ // Jarryd's balance after one split: the halves add up, so nothing is split again
+ let r = await run([{ id: 'a', amountSat: 109958 }, { id: 'b', amountSat: 75042 }, { id: 'c', amountSat: 24958, keyIndex: 2 }], 185000);
+ assert.equal(r.err, '');
+ assert(r.done);
+ assert.deepEqual(r.calls.map((c) => c.type), ['offboard']);
+ assert.deepEqual([...r.calls[0].ids].sort(), ['a', 'b']);
+ console.log('✓ coins that already add up to the amount go as they are, no split');
+
+ // one coin of the exact size
+ r = await run([{ id: 'a', amountSat: 109958 }, { id: 'c', amountSat: 24958 }], 24958);
+ assert.deepEqual(r.calls, [{ type: 'offboard', address: r.calls[0].address, ids: ['c'] }]);
+ console.log('✓ a single coin of the exact size is the one that goes');
+
+ // Jarryd's first attempt: 185,000 out of 100,000 + 109,958 — nothing adds
+ // up, so a self-send splits it off and lands as two coins; both go
+ r = await run([{ id: 'a', amountSat: 100000 }, { id: 'b', amountSat: 109958 }], 185000);
+ assert.equal(r.err, '');
+ assert(r.done);
+ assert.deepEqual(r.calls.map((c) => c.type), ['split', 'offboard']);
+ assert.equal(r.calls[0].amount, 185000);
+ assert.deepEqual([...r.calls[1].ids].sort(), ['dest-a', 'dest-b']);
+ const left = await page.evaluate(() => coopManager.state.vtxos.filter((v) => v.state === 'spendable').map((v) => [v.id, v.amountSat, v.keyIndex]));
+ assert.deepEqual(left.filter(([id]) => id.startsWith('change')), [['change-a', 24958, 5]]);
+ console.log('✓ a split that lands as two coins sends both; the change stays behind');
+
+ // everything: no carving at all
+ r = await run([{ id: 'a', amountSat: 100000 }, { id: 'b', amountSat: 109958 }], 0);
+ assert.deepEqual(r.calls, [{ type: 'offboard', address: r.calls[0].address, ids: null }]);
+ console.log('✓ the whole balance goes without a split');
+
+ // the subset search: prefers few large coins, gives up on no fit
+ const sub = await page.evaluate(() => {
+ const coins = (a) => a.map((amountSat, i) => ({ id: 'c' + i, amountSat }));
+ const pick = (a, n) => { const r = coopFeature.exactCoins(coins(a), n); return r && r.map((v) => v.amountSat).sort((x, y) => y - x); };
+ return [pick([5000, 3000, 2000, 1000, 1000], 6000), pick([5000, 3000], 7000), pick([5000, 3000, 2000], 5000), pick([7000, 3000], 3000)];
+ });
+ assert.deepEqual(sub, [[5000, 1000], null, [5000], [3000]]);
+ console.log('✓ exactCoins finds a subset, the single coin, or nothing');
+ assert.deepEqual(errors, []);
+} 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 87bf3cf..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`]);
@@ -95,7 +97,8 @@ const pagerState = () => page.evaluate(() => {
const p = document.querySelector('.media-pager');
if (!p) return null;
const sc = p.querySelector('.mp-scroll');
- const slides = [...sc.querySelectorAll('.mp-slide')]; // (not the loading screen at the end)
+ // (not the loading screen at the end, nor a clip held out for its shape)
+ const slides = [...sc.querySelectorAll('.mp-slide')].filter((x) => !x.closest('.mp-off'));
const i = slides.findIndex((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); // the slide on screen
const vids = slides.map((s) => s.querySelector('video'));
return { n: slides.length, i, src: (slides[i]?.querySelector('.mp-media')?.getAttribute('data-src') || '').split('/').pop(),
@@ -201,7 +204,7 @@ try {
const vi = await info();
check('a probe reads the size, ranges, layout and picture size from 64 KB', vi['a.mp4']?.probed && vi['a.mp4'].ranges && vi['a.mp4'].faststart
&& vi['a.mp4'].size > 1000 && vi['a.mp4'].w === 360 && vi['a.mp4'].h === 640, JSON.stringify(vi['a.mp4']));
- check('the landscape clip is left out of Videos', !(await page.evaluate(() => [...document.querySelectorAll('.media-pager video')].some((v) => /slow/.test(v.getAttribute('data-src'))))));
+ check('the landscape clip is left out of Videos', !(await page.evaluate(() => [...document.querySelectorAll('.media-pager video')].some((v) => /slow/.test(v.getAttribute('data-src')) && !v.closest('.mp-off')))));
check('an index-last file from a server without ranges is seen for what it is', vi['slow.mp4']?.probed && vi['slow.mp4'].faststart === false && vi['slow.mp4'].ranges === false, JSON.stringify(vi['slow.mp4']));
check('the 50 MB clip is known as such before any of it plays', vi['c.mp4']?.size === 50000000, JSON.stringify(vi['c.mp4']));
await page.keyboard.press('ArrowDown'); await sleep(1200);
diff --git a/tools/feed-video-pager-test.js b/tools/feed-video-pager-test.js
index cccde41..61119a0 100644
--- a/tools/feed-video-pager-test.js
+++ b/tools/feed-video-pager-test.js
@@ -67,6 +67,8 @@ const openClip = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
diff --git a/tools/fullscreen-notice-test.js b/tools/fullscreen-notice-test.js
index cf15f8c..d3b2785 100644
--- a/tools/fullscreen-notice-test.js
+++ b/tools/fullscreen-notice-test.js
@@ -68,6 +68,8 @@ async function run(lockOk) {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
diff --git a/tools/gift-profile-setup-test.js b/tools/gift-profile-setup-test.js
new file mode 100644
index 0000000..9304d1a
--- /dev/null
+++ b/tools/gift-profile-setup-test.js
@@ -0,0 +1,96 @@
+// After a gift is opened into a fresh wallet and its seed written down, the
+// first-run wizard asks for a username, then a picture and a cover — the gift's
+// recipient should arrive as someone people can pay by name. "Not now" lands in
+// the account the gift went to.
+// Drives an ark gift on mutinynet whose bearer key holds nothing (a random
+// secret: the staging ASP sees no coin, so the sweep finds it taken), then
+// "Create wallet anyway" → the seed → on to the wizard.
+// Run: bun run build && bun tools/gift-profile-setup-test.js [screenshot.png]
+import puppeteer from './puppeteer.js';
+import { base32nopad } from '@scure/base';
+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 server = Bun.serve({ port: 0, async fetch(req) {
+ const p = new URL(req.url).pathname;
+ const f = Bun.file('dist' + (p === '/' ? '/index.html' : p));
+ return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } });
+} });
+const giftCode = () => {
+ const b = new Uint8Array(42);
+ b[0] = 0x11; b[1] = 3; // ark gift, mutinynet
+ new DataView(b.buffer).setBigUint64(2, 1000n, true);
+ b.set(crypto.getRandomValues(new Uint8Array(32)), 10);
+ return base32nopad.encode(b);
+};
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const text = (page) => page.evaluate(() => document.body.innerText);
+const click = (page, x) => page.evaluate((x) => {
+ const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x));
+ e?.click(); return !!e;
+}, x);
+const waitText = async (page, re, ms = 30000) => {
+ for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); }
+ return false;
+};
+async function toSeed(page, note) {
+ await page.goto(server.url.href);
+ await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); });
+ await page.goto(server.url.href + 'ag/' + giftCode() + (note ? '#m=' + encodeURIComponent(note) : ''));
+ check('the gift opens: the server sees no coin behind it', await waitText(page, /aren.t visible/i));
+ if (note) {
+ const q = await page.evaluate(() => document.querySelector('.gift-note')?.textContent || null);
+ check('the sender\'s note shows on the gift', q === note, String(q));
+ check('...and leaves the address bar with the code', await page.evaluate(() => location.pathname === '/' && !location.hash));
+ }
+ await click(page, 'claim');
+ check('...so the claim finds it taken', await waitText(page, /create a wallet anyway/i));
+ check('...create a wallet anyway shows the seed', await click(page, 'create a wallet anyway') && await waitText(page, /recovery phrase/i, 10000));
+}
+try {
+ const page = await browser.newPage();
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
+ await toSeed(page, 'Happy birthday! 🎂 <b>not bold</b>');
+ await click(page, 'skip verification');
+ check('the seed done: the wizard asks for a username', await waitText(page, /now pick a username/i, 10000), (await text(page)).slice(0, 200));
+ check('...with the claim form (or its wait) and Not now', await page.evaluate(() => !!document.querySelector('.onb')) && /not now/i.test(await text(page)));
+ if (process.argv[2]) await page.screenshot({ path: process.argv[2] });
+ // the look step follows the name, as in any first run
+ await page.evaluate(() => localStorage.setItem('btc-wallet-onb-step', 'avatar'));
+ await page.reload();
+ check('the next step is the picture and cover', !!await page.waitForSelector('.onb-cover', { timeout: 15000 }).catch(() => null));
+ // a second gift, verified this time, then Not now
+ const p2 = await browser.newPage();
+ p2.on('pageerror', (e) => errors.push(String(e)));
+ await p2.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
+ await toSeed(p2);
+ check('Verify backup opens the word check', await click(p2, 'verify') && await waitText(p2, /word #?\d/i, 5000));
+ await sleep(500);
+ // the verify page doesn't show the words: read them from the account
+ const filled = await p2.evaluate(() => {
+ const accs = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}');
+ const list = Array.isArray(accs) ? accs : accs.accounts || accs.list || [];
+ const m = list.map((a) => a.mnemonic).filter(Boolean).pop(); if (!m) return false;
+ const w = m.split(' ');
+ for (const lab of document.querySelectorAll('label.field')) {
+ const n = Number(lab.querySelector('.lab').textContent.match(/\d+/)[0]);
+ const inp = lab.querySelector('input');
+ const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
+ set.call(inp, w[n - 1]); inp.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ return true;
+ });
+ if (filled) {
+ await click(p2, 'open wallet');
+ check('a verified seed leads to the username step too', await waitText(p2, /now pick a username/i, 10000));
+ await click(p2, 'not now');
+ let gone = false;
+ for (let i = 0; i < 20 && !gone; i++) { await sleep(250); gone = !(await p2.evaluate(() => !!document.querySelector('.onb'))); }
+ check('Not now: into the wallet, the wizard gone', gone, (await text(p2)).slice(0, 200));
+ } else console.log(' (accounts not in localStorage — verify path not driven)');
+ check('no page errors', errors.length === 0, errors.join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a gift\'s new wallet is offered a name, a picture and a cover' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
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/image-thumbnails-test.js b/tools/image-thumbnails-test.js
index ede7e11..6ece275 100644
--- a/tools/image-thumbnails-test.js
+++ b/tools/image-thumbnails-test.js
@@ -1,4 +1,4 @@
-// Real minified blob worker: both thumbnail sizes, animated originals,
+// Real minified blob worker: both thumbnail sizes (the tile in the picture's own shape), animated originals,
// bounded concurrency and graceful fallback, with no page-thread encoding.
import assert from 'node:assert/strict';
import puppeteer from './puppeteer.js';
@@ -30,25 +30,30 @@ try {
thumbnails(new Blob(['broken'],{type:'image/jpeg'}),{width:1100}), thumbnails(blob,{width:1100}),
]);
const media = await decode(big.media), tile = await decode(big.tile);
- const square = new OffscreenCanvas(360,360);square.getContext('2d').drawImage(tile,0,0);
- const pixel=[...square.getContext('2d').getImageData(0,180,1,1).data];
+ const whole = new OffscreenCanvas(tile.width,tile.height);whole.getContext('2d').drawImage(tile,0,0);
+ const pixel=[...whole.getContext('2d').getImageData(tile.width>>1,180,1,1).data], edge=[...whole.getContext('2d').getImageData(2,180,1,1).data];
+ // a panorama past 2:1 keeps its middle, 2:1
+ const pano=new OffscreenCanvas(4000,1000);pano.getContext('2d').fillStyle='green';pano.getContext('2d').fillRect(0,0,4000,1000);
+ const panoTile=await decode((await thumbnails(await encode.call(pano,{type:'image/png'}),{tileSize:360})).tile);
const smallCanvas=new OffscreenCanvas(32,20);smallCanvas.getContext('2d');
const smallBlob=await encode.call(smallCanvas,{type:'image/png'});
const small=await thumbnails(smallBlob,{width:1100,tileSize:360});
const smallTile=await decode(small.tile);
const gif=new Blob([Uint8Array.from(atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),c=>c.charCodeAt(0))],{type:'image/gif'});
const animated=await thumbnails(gif,{width:1100,tileSize:360});
- const out={mainCalls,maxPending,big:[big.width,big.height],media:[media.width,media.height],tile:[tile.width,tile.height],pixel,
+ const out={mainCalls,maxPending,big:[big.width,big.height],media:[media.width,media.height],tile:[tile.width,tile.height],pixel,edge,pano:[panoTile.width,panoTile.height],
tileOnly:!tiles.media&&!!tiles.tile,bad,recovered:!!recovered.media,smallOriginal:small.media===null,smallTile:[smallTile.width,smallTile.height],
animated: animated && [animated.width,animated.height,animated.media,animated.tile]};
media.close();tile.close();smallTile.close();return out;
});
console.log(result);
assert.equal(result.mainCalls,0);assert.equal(result.maxPending,1);
- assert.deepEqual(result.big,[3000,2000]);assert.deepEqual(result.media,[1100,733]);assert.deepEqual(result.tile,[360,360]);
- assert(result.pixel[2]>200&&result.pixel[0]<30,'tile crops the center');
+ assert.deepEqual(result.big,[3000,2000]);assert.deepEqual(result.media,[1100,733]);assert.deepEqual(result.tile,[540,360]);
+ // the grid's justified rows: a tile in the picture's own shape, 360 tall, uncut up to 2:1
+ assert(result.pixel[2]>200&&result.pixel[0]<30&&result.edge[0]>200,'tile keeps the whole picture');
+ assert.deepEqual(result.pano,[720,360]);
assert(result.tileOnly&&result.recovered&&result.smallOriginal);assert.equal(result.bad,null);
- assert.deepEqual(result.smallTile,[20,20]);assert.deepEqual(result.animated,[1,1,null,null]);
+ assert.deepEqual(result.smallTile,[32,20]);assert.deepEqual(result.animated,[1,1,null,null]);
const fallback=await browser.newPage();await fallback.goto(server.url.href);await fallback.waitForFunction(()=>window.thumbnails);
assert.equal(await fallback.evaluate(async()=>{window.Worker=class {constructor(){throw Error('blocked');}};return thumbnails(new Blob(['x']),{width:1100});}),null);
console.log('✓ thumbnails stay off the page thread; sizes, crop, originals, queue, bad-image recovery and worker fallback pass');
diff --git a/tools/live-stream-test.js b/tools/live-stream-test.js
index ef76043..2be6051 100644
--- a/tools/live-stream-test.js
+++ b/tools/live-stream-test.js
@@ -197,6 +197,12 @@ try {
check('the host\'s face on their post wears the live ring', await waitFor((pk) => !!document.querySelector('.notes-feed .note-post .note-avatar.live-ring'), 5000));
await page.evaluate(() => document.querySelector('.notes-feed .note-post .note-avatar.live-ring').click());
check('...and a tap on it opens the stream', await waitFor(() => !!document.querySelector('.live-page'), 5000));
+ const lay = await page.evaluate(() => {
+ const r = (q) => document.querySelector(q)?.getBoundingClientRect();
+ const p = r('.live-page .live-player'), c = r('.live-page .live-chat');
+ return { w: document.body.clientWidth, player: p && [p.left, p.width], chat: c && [c.left, c.width] };
+ });
+ check('on a phone the picture runs edge to edge and the chat is as wide', lay.player && lay.player[0] === 0 && lay.player[1] === lay.w && lay.chat && lay.chat[0] === 0 && lay.chat[1] === lay.w, JSON.stringify(lay));
await page.goBack();
await waitFor(() => !document.querySelector('.live-page') && !!document.querySelector('.media-modes'), 5000);
await page.evaluate(() => document.querySelector('.media-mode[data-mode="live"]').click());
@@ -217,6 +223,7 @@ try {
check('...and the zap, its amount and words', await waitText('zapped 21 sats', 5000) && await waitText('great stream', 1000));
const rowSel = '.live-message[data-key="' + chat.id + '"]';
check('comments use the group-chat bubble and shared composer', await page.$(rowSel + ' .chat-bubble') && await page.$('.live-page coinos-text#msg-draft'));
+ check('...drawn plain on a phone, no bubble walls', await page.evaluate((q) => { const b = document.querySelector(q); return b && getComputedStyle(b).borderStyle === 'none' && getComputedStyle(b).display === 'block'; }, rowSel + ' .chat-bubble'));
check('another client’s e-only reaction arrives on the comment', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .chat-reacts')?.textContent.includes('👍'), 6000, chat.id));
check('per-comment zap receipts show under the bubble', await waitFor(id => document.querySelector('.live-message[data-key="'+id+'"] .zap-tally')?.textContent.includes('5'), 6000, chat.id));
await page.click(rowSel + ' .chat-bubble');
diff --git a/tools/ln-pay-double-wake.test.js b/tools/ln-pay-double-wake.test.js
new file mode 100644
index 0000000..18a1943
--- /dev/null
+++ b/tools/ln-pay-double-wake.test.js
@@ -0,0 +1,74 @@
+// Two wakes of the phone's worker, seconds apart, each over its own copy of
+// the wallet, both drive one Lightning send (PullCap, 2026-10-24 23:21). The
+// second's InitiateLightningPayment is refused — the HTLC is already 'ln-spent'
+// to this very payment — and that once marked a PAID send 'revoking'. Now the
+// send follows the payment instead, and a send in 'revoking' asks the ASP
+// before revoking: paid → settled; still in flight → back to waiting.
+// Run: bun test tools/ln-pay-double-wake.test.js
+import { test, expect, mock } from 'bun:test';
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import * as realLn from '../src/ark/lightning.js';
+import * as realProto from '../src/ark/proto.js';
+import * as realSend from '../src/ark/send.js';
+
+const PRE = 'ab'.repeat(32);
+const HASH = hex.encode(sha256(hex.decode(PRE)));
+const asp = { initiate: null, check: null, revoked: 0, checks: 0 };
+mock.module('../src/ark/lightning.js', () => ({
+ ...realLn,
+ initiateLightningPayment: async () => { if (asp.initiate) throw asp.initiate; },
+ checkLightningPayment: async () => { asp.checks++; if (asp.check instanceof Error) throw asp.check; return asp.check; },
+ requestLightningPayHtlcRevocation: async () => { asp.revoked++; throw new Error('revocation should not be asked for'); },
+}));
+mock.module('../src/ark/proto.js', () => ({ ...realProto, decodeVtxo: () => ({ id: 'htlc1', point: { raw: new Uint8Array(36) } }) }));
+mock.module('../src/ark/send.js', () => ({ ...realSend, registerVtxoTransactions: async () => {},
+ buildArkoorSend: () => ({}), genUserNonces: () => ({}), cosignPartBytes: () => new Uint8Array(1) }));
+const { ArkManager } = await import('../src/ark/manager.js');
+const { GrpcError } = realProto;
+
+function mgrWith(step) {
+ const action = { id: 'lnpay-1700000000000', type: 'ln-pay', step, paymentHash: HASH, amountSat: 1, htlcExpiry: 1000, htlcBytesList: ['00'], htlcVtxoIds: ['htlc1'], changeBytesList: [], invoice: 'lnbc1' };
+ const m = Object.create(ArkManager.prototype);
+ const own = {
+ arkUrl: 'http://ark.test', chain: { tipHeight: async () => 900 },
+ state: { actions: [action], vtxos: [{ id: 'htlc1', state: 'pending', amountSat: 1 }], movements: [] },
+ _save: () => {}, _lnDriving: new Set(), _spentRetried: new Set(),
+ _key: () => ({ pubkey: new Uint8Array(33) }), _mailboxKey: () => ({ pubkey: new Uint8Array(33) }),
+ };
+ for (const [k, v] of Object.entries(own)) Object.defineProperty(m, k, { value: v, writable: true, configurable: true });
+ return { m, action };
+}
+
+test("a second wake's initiate refused as 'ln-spent' follows the payment to done", async () => {
+ Object.assign(asp, { initiate: new GrpcError(3, 'bad user input: vtxo b47f…:0 is not spendable (state: ln-spent)', 'bark_server.ArkService/InitiateLightningPayment'), check: { status: 'success', preimage: PRE }, revoked: 0 });
+ const { m, action } = mgrWith('cosigned');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('done');
+ expect(action.preimage).toBe(PRE);
+ expect(asp.revoked).toBe(0);
+ expect(m.state.movements.filter((x) => x.type === 'ln-send' && x.status === 'complete').length).toBe(1);
+});
+
+test("a send in 'revoking' that the ASP paid settles, nothing revoked", async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'success', preimage: PRE }, revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('done');
+ expect(asp.revoked).toBe(0);
+});
+
+test("a send in 'revoking' still in flight before its expiry goes back to waiting", async () => {
+ Object.assign(asp, { initiate: null, check: { status: 'pending' }, revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action);
+ expect(action.step).toBe('initiated');
+ expect(asp.revoked).toBe(0);
+});
+
+test("a send the ASP never saw (not found) is still revoked", async () => {
+ Object.assign(asp, { initiate: null, check: new GrpcError(5, 'not found: payment attempt not found', 'x'), revoked: 0 });
+ const { m, action } = mgrWith('revoking');
+ await m._driveLnPay(action).catch(() => {});
+ expect(asp.revoked).toBe(1);
+});
diff --git a/tools/ln-receive-twin-test.js b/tools/ln-receive-twin-test.js
index 20d4f48..167a26b 100644
--- a/tools/ln-receive-twin-test.js
+++ b/tools/ln-receive-twin-test.js
@@ -35,5 +35,19 @@ f = lnFoldedIds([rcv('A', 1000, T), { ...rcv('B', 1000, T + 2000), vtxoId: 'v-A'
check('...or the earliest when none is tagged', f.size === 1 && f.has('B'));
f = lnFoldedIds([rcv('A', 1000, T), rcv('B', 1000, T + 2000)]);
check('two coins of the same amount stay two rows', f.size === 0);
+// a Lightning payment the closed app's worker made: its own ln-send row
+// (merged from the mirror) plus the app's "while closed" note of the same
+// preimage — one row, the one that names its inputs
+const snd = (id, ts, extra = {}) => ({ id, type: 'ln-send', status: 'complete', amountSat: 1, ts, ...extra });
+f = lnFoldedIds([snd('W', T, { preimage: 'pi1', paymentHash: 'h1', inputIds: ['v1'] }), snd('N', T + 60_000, { preimage: 'pi1', detail: 'while closed · fee 0 sat' })]);
+check('a closed-app payment told twice shows once, the row with inputs', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('N', T, { preimage: 'pi1' }), snd('W', T + 5000, { preimage: 'pi1', paymentHash: 'h1', inputIds: ['v1'] })]);
+check('...whichever order they were written', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('W', T, { paymentHash: 'h1', preimage: 'pi1', inputIds: ['v1'] }), snd('N', T + 1000, { paymentHash: 'h1' })]);
+check('...matched by payment hash when the note has no preimage', f.size === 1 && f.has('N'));
+f = lnFoldedIds([snd('A', T, { preimage: 'pi1', inputIds: ['v1'] }), snd('B', T + 1000, { preimage: 'pi2', inputIds: ['v2'] })]);
+check('two payments stay two rows', f.size === 0);
+f = lnFoldedIds([snd('A', T, { preimage: 'pi1' }), snd('B', T + 1000, { preimage: 'pi1' })]);
+check('two notes with no inputs keep the earliest', f.size === 1 && f.has('B'));
console.log(ok ? '\n✅ a claimed LN receive is told once' : '\n❌ failed');
process.exit(ok ? 0 : 1);
diff --git a/tools/media-grid-dups-test.js b/tools/media-grid-dups-test.js
new file mode 100644
index 0000000..e7b67da
--- /dev/null
+++ b/tools/media-grid-dups-test.js
@@ -0,0 +1,169 @@
+// 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, 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';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { existsSync } from 'node:fs';
+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, 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;
+};
+// (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);
+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|twina|twinb)\.webm$/.exec(path);
+ if (m) {
+ 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' || 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' } });
+} });
+const U = (n) => server.url.origin + '/' + n + '.webm';
+const post = (content, url, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [['imeta', 'url ' + url, 'image ' + server.url.origin + '/still.png', 'm video/webm']], content }, SK);
+const seeded = [
+ // one clip, three posts (a promo posted again and again)
+ post('see you at the conference ' + U('wide'), U('wide'), 100),
+ post('tickets going fast ' + U('wide'), U('wide'), 150),
+ post('last call ' + U('wide') + '?x=1', U('wide') + '?x=1', 160),
+ // the same words from the same person, uploaded afresh each time
+ 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),
+ // a tall one: out of a wide screen's grid until "All shapes"
+ finalizeEvent({ kind: 1, created_at: now - 450, tags: [['imeta', 'url ' + U('tall'), 'm video/webm', 'dim 180x320']], content: 'standing up ' + U('tall') }, SK),
+ // a clip its host has since deleted (it says so: 404) — no blank tile for it
+ finalizeEvent({ kind: 1, created_at: now - 500, tags: [['imeta', 'url ' + U('dead'), 'm video/webm', 'dim 320x180']], content: 'deleted since ' + U('dead') }, SK),
+];
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(() => {
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+});
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
+try {
+ await page.setViewport({ width: 1600, height: 900 });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, AUTHOR, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ // (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));
+ // the rest starts from the screen's way round: wide only
+ 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 >= 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 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),
+ JSON.stringify(await page.evaluate(() => { const v = document.querySelector('.mp-slide.mp-cur video.mp-media'); return v && { vw: v.videoWidth, w: v.getBoundingClientRect().width, max: v.style.maxWidth }; })));
+ // "All shapes", from the pager's top bar: the tall clip is there to flick to
+ const tallShown = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')); return !!s && !s.closest('.mp-group').classList.contains('mp-off'); });
+ check('in the pager, the tall clip is held out on a wide screen', !(await tallShown()));
+ await page.evaluate(() => document.querySelector('.mp-fit').click());
+ check('…and "All shapes" brings it in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/tall.webm')); return !!s && !s.closest('.mp-group').classList.contains('mp-off'); }, null, 3000));
+ await page.keyboard.press('Escape');
+ check('…which, being the desktop\'s default, is kept as no setting at all', await page.evaluate(() => localStorage.getItem('coinos-clips-fit') === null));
+ check('the grid has it too', await waitFor(() => [...document.querySelectorAll('.media-grid .media-tile')].some((t) => t.getAttribute('data-key').endsWith('/tall.webm')), null, 5000));
+ const rows = () => page.evaluate(() => {
+ const g = document.querySelector('.media-grid'), gw = g.getBoundingClientRect().width;
+ const ts = [...g.querySelectorAll('.media-tile')].map((t) => ({ k: t.getAttribute('data-key').split('/').pop(), r: t.getBoundingClientRect() }));
+ const by = new Map(); for (const t of ts) { const y = Math.round(t.r.top); (by.get(y) || by.set(y, []).get(y)).push(t); }
+ return { justified: g.classList.contains('justified'), gw, rows: [...by.values()].map((row) => ({ ks: row.map((t) => t.k), hs: row.map((t) => Math.round(t.r.height)), ars: row.map((t) => +(t.r.width / t.r.height).toFixed(2)), w: Math.round(row.reduce((a, t) => a + t.r.width, 0) + 3 * (row.length - 1)) })) };
+ });
+ await waitFor(() => { const t = [...document.querySelectorAll('.media-grid .media-tile')].find((x) => x.getAttribute('data-key').endsWith('/tall.webm')); return t && t.getBoundingClientRect().width < t.getBoundingClientRect().height; }, null, 5000);
+ const R = await rows();
+ const tallT = R.rows.flatMap((r) => r.ks.map((k, i) => ({ k, ar: r.ars[i] }))).find((x) => x.k === 'tall.webm');
+ check('…in justified rows: every tile its clip\'s shape (the tall one tall, the wide ones wide)', R.justified && tallT && Math.abs(tallT.ar - 0.5625) < 0.03 && R.rows.flatMap((r) => r.ks.map((k, i) => ({ k, ar: r.ars[i] }))).filter((x) => x.k !== 'tall.webm' && x.k !== 'last.webm').every((x) => Math.abs(x.ar - 16 / 9) < 0.03), JSON.stringify(R));
+ check('…one height across each row', R.rows.every((r) => Math.max(...r.hs) - Math.min(...r.hs) <= 1), JSON.stringify(R.rows.map((r) => r.hs)));
+ // the Images grid: justified rows too, each picture in its own shape
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="images"]').click());
+ 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();
+ server.stop(true);
+}
+console.log(ok ? '\n✅ the grid shows each clip once; small clips stay sharp on a big screen' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-grid-order-test.js b/tools/media-grid-order-test.js
new file mode 100644
index 0000000..9274d92
--- /dev/null
+++ b/tools/media-grid-order-test.js
@@ -0,0 +1,128 @@
+// The Videos grid's order holds for the session (Adam, 2026-10-23: clips the
+// crawl found landed in the middle of the grid and the tiles already there
+// jumped around): tiles found later go under the ones already showing, in
+// their own date order, even when they are dated between them; only a post
+// newer than the top tile goes above it. Run: bun tools/media-grid-order-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { buildHtml } from '../build.js';
+import { mkdtempSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+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 dir = mkdtempSync(tmpdir() + '/morder-');
+const NAMES = ['a', 'b', 'c', 'gap1', 'gap2', 'old1', 'old2', 'fresh'];
+for (const [i, n] of NAMES.entries()) {
+ const p = Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=360x640:rate=10', '-t', String(2 + i / 4), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', `${dir}/${n}.mp4`]);
+ if (p.exitCode !== 0) throw new Error('ffmpeg failed');
+}
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 0, idleTimeout: 30, fetch: async (req) => {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/v/')) {
+ const f = Bun.file(dir + path.slice(2));
+ const cors = { 'access-control-allow-origin': '*', 'access-control-expose-headers': 'content-range, content-length', 'content-type': 'video/mp4', 'accept-ranges': 'bytes' };
+ if (req.method === 'HEAD') return new Response(null, { headers: { ...cors, 'content-length': String(f.size) } });
+ const m = /bytes=(\d+)-(\d*)/.exec(req.headers.get('range') || '');
+ if (m) { const a = +m[1], b = Math.min(m[2] ? +m[2] : f.size - 1, f.size - 1); return new Response(f.slice(a, b + 1), { status: 206, headers: { ...cors, 'content-range': `bytes ${a}-${b}/${f.size}` } }); }
+ return new Response(f, { headers: cors });
+ }
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+} });
+const page0 = server.url.origin, base = page0.replace('localhost', '127.0.0.1');
+const SK = generateSecretKey(), PK = getPublicKey(SK);
+const now = Math.floor(Date.now() / 1000);
+const clip = (name, at) => finalizeEvent({ kind: 1, created_at: at, content: name + ' ' + base + '/v/' + name + '.mp4',
+ tags: [['imeta', 'url ' + base + '/v/' + name + '.mp4', 'm video/mp4', 'dim 360x640']] }, SK);
+const words = (i, at) => finalizeEvent({ kind: 1, created_at: at, tags: [], content: 'just words ' + i }, SK);
+// the page: words and three clips minutes apart; the crawl (a hundred at a
+// time) from under the page finds only two clips from long ago; the feed's
+// own next page of posts (asked as the grid is scrolled to its end) brings
+// two clips dated between c and old1
+const abc = [clip('a', now - 3000), clip('b', now - 3400), clip('c', now - 3800)];
+const gap = [clip('gap1', now - 12000), clip('gap2', now - 14000)];
+const tail = [clip('old1', now - 20000), clip('old2', now - 30000)];
+const wordsAll = []; for (let i = 0; i < 10; i++) wordsAll.push(words(i, i < 5 ? now - 100 - i * 400 : now - 4000 - (i - 5) * 400));
+const fresh = clip('fresh', now + 5);
+const all = [...wordsAll, ...abc, ...gap, ...tail].sort((x, y) => y.created_at - x.created_at);
+
+const browser = await puppeteer.launch({ relays: 'read', executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument(([all, tail, fresh, pk]) => {
+ window.__crawls = 0; window.__pages = 0; window.__live = []; window.__reqs = [];
+ const Real = window.WebSocket;
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ const emit = (sub, ev) => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', sub, ev]) }));
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ') {
+ const f = m[2] || {};
+ let out = [];
+ if ((f.kinds || []).includes(1) && (f.authors || []).includes(pk) && !f['#e'] && !f.ids) {
+ window.__reqs.push({ until: f.until, since: f.since, limit: f.limit });
+ if (f.since && !f.until) window.__live.push(() => emit(m[1], fresh));
+ else if (f.limit === 100 && f.until) { window.__crawls++; out = tail.filter((e) => e.created_at <= f.until); }
+ else { window.__pages += f.until ? 1 : 0; out = all.filter((e) => !f.until || e.created_at <= f.until).slice(0, f.limit || 30); }
+ }
+ setTimeout(() => {
+ for (const ev of out) emit(m[1], ev);
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) }));
+ }, 0);
+ return;
+ }
+ if (m[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+}, [all, tail, fresh, PK]);
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn)) return true; await sleep(100); } return false; };
+const order = () => page.evaluate(() => [...document.querySelectorAll('.media-grid .media-tile.video')].map((t) => t.getAttribute('data-key').split('/').pop().replace('.mp4', '')));
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(page0, { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const key = cacheKeyFor(mn + '\n');
+ await page.evaluate(([k, pk]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [key, PK]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => [...document.querySelectorAll('.app-nav-button, button')].find((b) => b.textContent.trim() === 'Feed')?.click());
+ await waitFor(() => !!document.querySelector('.media-modes'), 20000);
+ await page.evaluate(() => document.querySelector('.media-mode[data-mode="videos"]').click());
+ check('the grid: the page\'s three clips, newest first', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 20000) && (await order()).join(' ') === 'a b c', (await order()).join(' '));
+ check('the crawl finds the two from long ago, under them', await waitFor(() => window.__crawls >= 1 && document.querySelectorAll('.media-grid .media-tile.video').length === 5, 25000) && (await order()).join(' ') === 'a b c old1 old2', (await order()).join(' ') + ' crawls ' + await page.evaluate(() => window.__crawls));
+ for (let i = 0; i < 40 && !(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length === 7)); i++) {
+ await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
+ await sleep(500);
+ }
+ check('the feed\'s next page, asked at the grid\'s end, brings the two dated between c and old1', await page.evaluate(() => window.__pages >= 1 && document.querySelectorAll('.media-grid .media-tile.video').length === 7), (await order()).join(' ') + ' pages ' + await page.evaluate(() => window.__pages));
+ check('...shown under the tiles already there, in their own date order', (await order()).join(' ') === 'a b c old1 old2 gap1 gap2', (await order()).join(' '));
+ await page.evaluate(() => { for (const f of window.__live) f(); });
+ check('a post newer than the top tile goes above it', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 8, 20000) && (await order()).join(' ') === 'fresh a b c old1 old2 gap1 gap2', (await order()).join(' '));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ media grid: found later, shown under' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/media-moments-feed-test.js b/tools/media-moments-feed-test.js
index a188c5d..7b429c9 100644
--- a/tools/media-moments-feed-test.js
+++ b/tools/media-moments-feed-test.js
@@ -175,6 +175,18 @@ try {
check('...and shows over the pager\'s seek bar', await page.waitForFunction(() => !!document.querySelector('.media-pager .mp-cur .mp-seek .mp-mark'), { timeout: 3000 }).then(() => true, () => false));
check('no timeline panel over the pager', await page.evaluate(() => !document.querySelector('.media-pager .media-timeline')));
await page.screenshot({ path: '/tmp/media-timeline-pager.png' });
+ // back in the feed: the clip has a moment now, so the moments bar is its
+ // only seek bar (no player controls), with the middle button to play it
+ await page.keyboard.press('Escape'); await sleep(500);
+ await page.evaluate(() => { const v = document.querySelector('video.note-video'); v.pause(); v.dispatchEvent(new Event('timeupdate')); });
+ await sleep(200);
+ const one = await page.evaluate(() => { const v = document.querySelector('video.note-video'); const b = v.closest('.note-video-box'); const tl = b.querySelector('.media-timeline');
+ return { controls: v.controls, own: b.classList.contains('own-bar'), bar: !!tl && getComputedStyle(tl).display !== 'none', mark: !!b.querySelector('.media-mark'), pp: getComputedStyle(b.querySelector('.vid-pp')).display }; });
+ check('in the feed, a clip with a moment has one seek bar: the moments bar, no player controls', !one.controls && one.own && one.bar && one.mark, JSON.stringify(one));
+ check('...and the middle button plays it, on a touch screen too', one.pp === 'flex', JSON.stringify(one));
+ const vid2 = await page.evaluate(() => { const b = document.querySelector('.note-video-box .vid-pp').getBoundingClientRect(); return { x: b.x + b.width / 2, y: b.y + b.height / 2 }; });
+ await page.mouse.click(vid2.x, vid2.y); await sleep(600);
+ check('...which plays it in place', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager')));
check('no browser exceptions', errs.length === 0, errs.join(' | '));
await page.screenshot({ path: '/tmp/media-timeline-feed.png' });
} finally { await browser.close(); server.stop(); }
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js
index e3bdb7b..5a15d48 100644
--- a/tools/nwc-sw-test.js
+++ b/tools/nwc-sw-test.js
@@ -5,7 +5,8 @@ import { hex } from '@scure/base';
import * as nip04 from 'nostr-tools/nip04';
import * as nip44 from 'nostr-tools/nip44';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
-import { respondFromBg } from '../src/nwc-respond.js';
+import { respondFromBg, tendLnPays } from '../src/nwc-respond.js';
+import { maybeBolt11 } from '../src/ark/lightning.js';
let fails = 0;
const check = (name, ok, detail = '') => {
@@ -118,6 +119,24 @@ console.log('\n[merchant methods (BTCPay)]');
check('list_transactions includes receives', txs.some((t) => t.type === 'incoming' && t.amount === 21000), JSON.stringify(txs));
}
{
+ // PullCap 2026-10-24: lookups of a send in flight came back "not found".
+ // Every copy of the connection is asked; an outgoing send answers as one.
+ const INV = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g';
+ const H = '7e9d690de6b475ef4f9e964b8a5180afc5fb11af12f0056fece6fa1788398f7e';
+ const fresher = baseRec(); fresher.updated = 2; fresher.ark = { ...fresher.ark, network: 'signet' };
+ const live = baseRec(); live.updated = 1;
+ live.mgr.actions.push({ id: 'lnpay-1', type: 'ln-pay', step: 'done', paymentHash: H, preimage: 'cd'.repeat(32), amountSat: 21, invoice: INV });
+ const { deps, published, decryptReply } = harness({ recs: [{ walletKey: 'old', rec: fresher }, { walletKey: 'w1', rec: live }] });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, deps);
+ const r = decryptReply(published[0]).result || {};
+ check('lookup_invoice finds a send in the older copy of the connection → outgoing, settled, its preimage', r.type === 'outgoing' && r.state === 'settled' && r.preimage === 'cd'.repeat(32), JSON.stringify(r));
+ live.mgr.actions[0].step = 'initiated'; delete live.mgr.actions[0].preimage;
+ const t0 = Date.now();
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('lookup_invoice', { payment_hash: H })) }, { ...deps, deadline: Date.now() + 2500 });
+ const r2 = decryptReply(published[1]).result || {};
+ check('…one still in flight (ASP unreachable here) → outgoing, pending, inside the push budget', r2.type === 'outgoing' && r2.state === 'pending' && Date.now() - t0 < 4000, JSON.stringify(r2) + ` ${Date.now() - t0} ms`);
+}
+{
const { deps } = harness(); // a mirror without receive keys
check('make_invoice on an old mirror → wake the user', (await respondFromBg({ type: 'nwc', event: ev(makeReq('make_invoice', { amount: 21000 })) }, deps)) === false);
}
@@ -178,6 +197,112 @@ const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0z
check('exhausted key window → wake the user', handled === false);
}
+console.log('\n[pay outcome]');
+{
+ // a payment that goes through: the worker hands the push handler a
+ // "Payment sent" heads-up (unless the user turned those off)
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const orig = { init: ArkManager.prototype.init, pay: ArkManager.prototype.payLnInvoice, wait: ArkManager.prototype.awaitLnPay, act: ArkManager.prototype.lnAction };
+ const done = { id: 'ln-1', step: 'done', amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ ArkManager.prototype.payLnInvoice = async () => 'ln-1';
+ ArkManager.prototype.awaitLnPay = async () => done;
+ ArkManager.prototype.lnAction = () => done;
+ try {
+ const { deps, published, decryptReply } = harness();
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('a paid invoice answers with the preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === done.preimage, JSON.stringify(handled));
+ check('…and asks for a "Payment sent" notification', handled && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ const rec = baseRec(); rec.prefs = { paySent: false };
+ const h2 = harness({ rec });
+ const quiet = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, h2.deps);
+ check('payments-sent notifications off → paid quietly', quiet === true && h2.published.length === 1);
+ } finally {
+ Object.assign(ArkManager.prototype, { init: orig.init, payLnInvoice: orig.pay, awaitLnPay: orig.wait, lnAction: orig.act });
+ }
+}
+
+console.log('\n[sends begun on an earlier wake]');
+// The worker died mid-payment on an earlier wake: the action sits in the
+// mirror at 'created' with its coin reserved (PullCap, 2026-10-23).
+{
+ const { ArkManager } = await import('../src/ark/manager.js');
+ const HASH = maybeBolt11(INV21).paymentHash;
+ const PRE = 'cd'.repeat(32);
+ const names = ['init', 'payLnInvoice', 'awaitLnPay', 'lnAction', 'driveLn'];
+ const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]]));
+ ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; };
+ // a fresh attempt at an invoice with an action on the books is refused…
+ ArkManager.prototype.payLnInvoice = async function (invoice) {
+ const hash = maybeBolt11(invoice).paymentHash;
+ if (this.state.actions.some((a) => a.type === 'ln-pay' && a.paymentHash === hash && a.step !== 'failed')) throw new Error('a payment for this invoice is already in progress');
+ const a = { id: 'lnpay-new', type: 'ln-pay', step: 'done', invoice, paymentHash: hash, amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) };
+ this.state.actions.push(a); this.storage.save(this.state);
+ return a.id;
+ };
+ // …and a drive takes the stuck one through to done, freeing its change
+ ArkManager.prototype.awaitLnPay = async function (id) { return this.driveLn(id); };
+ ArkManager.prototype.driveLn = async function (id) {
+ const a = this.state.actions.find((x) => x.id === id);
+ if (a && a.step !== 'done') {
+ a.step = 'done'; a.preimage = PRE;
+ for (const v of this.state.vtxos) if (v.state === 'pending') v.state = 'spendable';
+ this.storage.save(this.state);
+ }
+ return a;
+ };
+ ArkManager.prototype.lnAction = function (id) { return this.state.actions.find((x) => x.id === id); };
+ const stuckRec = (hash) => {
+ const rec = baseRec();
+ rec.mgr.vtxos[0].state = 'pending';
+ rec.mgr.actions.push({ id: 'lnpay-stuck', type: 'ln-pay', step: 'created', invoice: INV21, paymentHash: hash, amountSat: 21, feeSat: 0 });
+ rec.pendingPays = [{ hash, connId: 'c1', ts: 1 }];
+ return rec;
+ };
+ try {
+ {
+ // the SAME invoice asked again: finish that send, answer with ITS preimage
+ const rec = stuckRec(HASH);
+ const log = [];
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, log: (m) => log.push(m) });
+ check('same invoice → the stuck send is finished and answered with its preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE, JSON.stringify(handled));
+ check('…recorded once on the spend log, under its connection', rec.spends.length === 1 && rec.spends[0].connId === 'c1' && rec.spends[0].preimage === PRE && rec.pendingPays.length === 0, JSON.stringify(rec.spends));
+ check('…with a "Payment sent" heads-up', handled && handled.notify && handled.notify.title === 'Payment sent');
+ check('…and the diary names the step it reached', log.some((m) => /already created/.test(m)) && log.some((m) => /→ done/.test(m)), log.join(' | '));
+ }
+ {
+ // a send that settled earlier (in the app, say): its preimage again,
+ // nothing new on the spend log
+ const rec = baseRec();
+ rec.mgr.actions.push({ id: 'lnpay-old', type: 'ln-pay', step: 'done', invoice: INV21, paymentHash: HASH, amountSat: 21, feeSat: 0, preimage: PRE });
+ const { deps, published, decryptReply } = harness({ rec });
+ await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('an invoice paid earlier answers with the same preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE);
+ check('…without a second spend', (rec.spends || []).length === 0);
+ }
+ {
+ // a stuck send for ANOTHER invoice holds the only coin: finish it first,
+ // then pay the new request from the freed coin
+ const rec = stuckRec('ee'.repeat(32));
+ const { deps, published, decryptReply } = harness({ rec });
+ const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps);
+ check('another invoice → the earlier send is finished, then this one paid', published.length === 1 && decryptReply(published[0]).result?.preimage === 'ab'.repeat(32), JSON.stringify(handled));
+ check('…both on the spend log', rec.spends.length === 2 && rec.spends[0].preimage === PRE && rec.spends[1].preimage === 'ab'.repeat(32));
+ check('…the earlier one gets its own "Payment sent" line', handled && handled.notices && handled.notices.length === 1 && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled));
+ }
+ {
+ // any wake at all finishes it (a lookup, a push for something else)
+ const rec = stuckRec(HASH);
+ const notices = await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} });
+ check('tendLnPays finishes a stuck send and reports it', notices.length === 1 && notices[0].title === 'Payment sent' && rec.spends.length === 1 && rec.spends[0].connId === 'c1', JSON.stringify(notices));
+ check('…and a second wake has nothing left to do', (await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} })).length === 0);
+ }
+ } finally {
+ Object.assign(ArkManager.prototype, orig);
+ }
+}
+
console.log('\n[network fit]');
{
// a mutinynet-era mirror must not answer a mainnet invoice with a nonsense
@@ -332,16 +457,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 +477,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 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.ark.network = 'regtest'; rec2.mgr.nextKeyIndex = 9; rec2.keys = { 1: 'aa' }; // no upcoming key
+ 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/pager-caption-test.js b/tools/pager-caption-test.js
index 5952d81..50ffe4f 100644
--- a/tools/pager-caption-test.js
+++ b/tools/pager-caption-test.js
@@ -55,6 +55,8 @@ const openClip = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
const opened = await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000);
// the caption and the counts show on a tap on the clip (details), as a reader sees them
@@ -72,6 +74,8 @@ const clickIn = async (sel, where) => {
// the caption's first word sits at its left edge; a link in the middle
return where === 'start' ? { x: r.left + 12, y: r.top + 9 } : { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, ['(' + CAP + ')', sel, where]);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
};
const toFeed = async () => { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 8000); };
diff --git a/tools/pager-close-test.js b/tools/pager-close-test.js
index 73aa15b..a7c6c12 100644
--- a/tools/pager-close-test.js
+++ b/tools/pager-close-test.js
@@ -84,6 +84,8 @@ const st = () => page.evaluate(() => ({ pager: !!document.querySelector('.media-
const openFirst = async () => {
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
await sleep(400);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
return waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
diff --git a/tools/pager-eager-test.js b/tools/pager-eager-test.js
index 3b5c5c3..3d980fb 100644
--- a/tools/pager-eager-test.js
+++ b/tools/pager-eager-test.js
@@ -117,6 +117,8 @@ async function run(saveData, slow = false) {
await waitFor(() => { const v = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')).querySelector('video.note-video'); return v && !v.paused && v.currentTime > 0; }, null, 10000);
const r = await feedRow('clip c0 here');
log = log.filter((e) => e.clip === 'c0');
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
await sleep(slow ? 8000 : 3000);
diff --git a/tools/pager-first-frame-test.js b/tools/pager-first-frame-test.js
index 6507607..0d477b7 100644
--- a/tools/pager-first-frame-test.js
+++ b/tools/pager-first-frame-test.js
@@ -99,6 +99,8 @@ try {
await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
await sleep(600);
const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
check('the clip on screen plays', await waitFor(() => [...document.querySelectorAll('.mp-slide.mp-cur video.mp-media')].some((v) => !v.paused), null, 8000));
diff --git a/tools/pager-no-poster-test.js b/tools/pager-no-poster-test.js
index 0930834..332eb53 100644
--- a/tools/pager-no-poster-test.js
+++ b/tools/pager-no-poster-test.js
@@ -64,6 +64,8 @@ const openClip = async (word) => {
await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); }, word);
await sleep(400);
const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }, word);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000);
};
diff --git a/tools/pager-prime-test.js b/tools/pager-prime-test.js
index 7263ee1..4989790 100644
--- a/tools/pager-prime-test.js
+++ b/tools/pager-prime-test.js
@@ -114,6 +114,8 @@ async function run() {
await page.waitForSelector('.notes-feed video.note-video', { timeout: 20000 });
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip c0 here')); row.querySelector('.note-video-box').scrollIntoView({ block: 'center' }); const v = row.querySelector('video.note-video').getBoundingClientRect(); return { x: v.x + v.width * 0.2, y: v.y + v.height * 0.3 }; });
await sleep(800);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
check('the next clip\'s warm player has played a moment, unheard, and waits paused a frame or two in', await waitFor(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/c1.webm')); const v = s && s.querySelector('video.mp-media'); return v && v.paused && v.currentTime >= 0.03 && v.currentTime < 0.6; }, null, 10000), JSON.stringify(await vid('c1')));
diff --git a/tools/pager-rotate-test.js b/tools/pager-rotate-test.js
index 9912b7a..9dcc7e3 100644
--- a/tools/pager-rotate-test.js
+++ b/tools/pager-rotate-test.js
@@ -91,6 +91,8 @@ async function run(unmuteFirst) {
await sleep(800);
}
const at = await rowOf(); await sleep(300);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
await waitFor(() => { const v = document.querySelector('.media-pager video[src]'); return v && !v.paused && v.currentTime > 1.5; }, null, 8000);
diff --git a/tools/pager-seek-hide-test.js b/tools/pager-seek-hide-test.js
index d5d527b..50863ba 100644
--- a/tools/pager-seek-hide-test.js
+++ b/tools/pager-seek-hide-test.js
@@ -88,6 +88,8 @@ try {
await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
await sleep(400);
const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('a wide one')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('the pager opens', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide'), null, 4000));
@@ -104,7 +106,15 @@ try {
await page.mouse.click(195, 380);
check('a tap mid-screen pauses the clip, and the bar is up', await waitFor(() => { const b = document.querySelector('.mp-slide.mp-cur .mp-seek'); return getComputedStyle(b).opacity === '1'; }, null, 2000) && !(await curBar()).playing, JSON.stringify(await curBar()));
await page.mouse.click(195, 380);
- check('…another plays it on, the bar gone', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 2000) && (await curBar()).playing, JSON.stringify(await curBar()));
+ // (the test's mouse moved to click, which a desktop answers with the bar for a moment)
+ check('…another plays it on, the bar gone', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 3500) && (await curBar()).playing, JSON.stringify(await curBar()));
+ // a mouse (a desktop) brings it up by moving, and it fades once the mouse is still
+ await page.waitForFunction(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', { timeout: 6000 }).catch(() => {});
+ for (let i = 0; i < 5; i++) { await page.mouse.move(150 + i * 20, 300 + i * 10); await sleep(60); }
+ check('a mouse moving over the clip brings the bar up, the clip playing on', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '1', null, 1500) && (await curBar()).playing, JSON.stringify(await curBar()));
+ const stillAt = Date.now();
+ check('…and it fades once the mouse is still', await waitFor(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur .mp-seek')).opacity === '0', null, 4000), JSON.stringify(await curBar()));
+ check('…a couple of seconds later, not the tap\'s longer wait', Date.now() - stillAt < 3200, (Date.now() - stillAt) + ' ms');
await page.mouse.click(195, 844 - 70); // up again, for the glide
check('the pager is not marked moving', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('moving')));
check('the position counter has faded out', await waitFor(() => +getComputedStyle(document.querySelector('.mp-position')).opacity < 0.01, null, 4000), String(await counterOpacity()));
diff --git a/tools/pager-tail-test.js b/tools/pager-tail-test.js
index b294b8f..9d537e1 100644
--- a/tools/pager-tail-test.js
+++ b/tools/pager-tail-test.js
@@ -103,6 +103,8 @@ try {
const r = await page.evaluate(() => { const row = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('clip zero')); row.querySelector('video.note-video').scrollIntoView({ block: 'center' }); const b = row.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
await sleep(400);
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [r.x, r.y]);
await page.mouse.click(r.x, r.y);
check('the pager opens on the first clip', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000) && await resting('c0'), await at());
check('a loading screen waits at the end, after the clips', (await tail())?.last, JSON.stringify(await tail()));
diff --git a/tools/post-edit-test.js b/tools/post-edit-test.js
index 0b8bf6f..2ed1260 100644
--- a/tools/post-edit-test.js
+++ b/tools/post-edit-test.js
@@ -160,6 +160,7 @@ try {
await openFeed();
check('the feed is back', await waitText('a post a stranger tried to delete', 15000));
check('my deleted post stays gone', !(await has('my own post')));
+ check('...with no "was deleted" row in its place', !(await has('deleted by its author')));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ post edits and deletions' : '\n❌ failed');
diff --git a/tools/profile-publish-race-test.js b/tools/profile-publish-race-test.js
new file mode 100644
index 0000000..390c01b
--- /dev/null
+++ b/tools/profile-publish-race-test.js
@@ -0,0 +1,95 @@
+// Two kind-0 publishes at once used to clobber each other: both read the
+// same base off the relays, the later one landed without the earlier one's
+// fields. The migration did exactly that — the rename repointed lud16/nip05
+// to the new name while the legacy-avatar import republished the old base
+// nine seconds later, so a user's published lightning address was the
+// placeholder name their rename had just released (Jarryd, 2026-10-24).
+// Now kind-0 publishes go one at a time and merge over what was last sent.
+// Run: bun tools/profile-publish-race-test.js
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 160) : ''}`);
+ if (!ok) fails++;
+};
+
+globalThis.localStorage = {
+ _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
+ get length() { return Object.keys(this._d).length; },
+};
+
+// One in-memory relay behind every socket: EVENT stores, REQ answers with
+// what matches (newest first) and EOSE. Enough for a kind-0 round trip.
+const store = [];
+const published = [];
+class FakeSocket {
+ constructor(url) {
+ this.url = url; this.readyState = 0;
+ setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0);
+ }
+ reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); }
+ send(str) {
+ let m; try { m = JSON.parse(str); } catch { return; }
+ if (m[0] === 'EVENT') { store.push(m[1]); published.push(m[1]); this.reply(['OK', m[1].id, true, '']); }
+ if (m[0] === 'REQ') {
+ const [, sub, ...filters] = m;
+ const hit = store.filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))));
+ hit.sort((a, b) => b.created_at - a.created_at);
+ for (const e of hit) this.reply(['EVENT', sub, e]);
+ this.reply(['EOSE', sub]);
+ }
+ }
+ close() { this.readyState = 3; this.onclose && this.onclose({}); }
+}
+globalThis.WebSocket = FakeSocket;
+globalThis.Image = class { set src(v) { this._src = v; } };
+// the legacy account the migration brings along
+globalThis.fetch = async (url) => {
+ if (/coinos\.io\/api\/users\//.test(String(url))) return { ok: true, json: async () => ({ profile: 'face', banner: 'cover' }) };
+ throw new Error('offline');
+};
+
+const { messagesFeature } = await import('../src/features/messages.js');
+
+const sk = generateSecretKey();
+const pk = getPublicKey(sk);
+const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) };
+let now = Math.floor(Date.now() / 1000) - 100;
+const seed = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: '[email protected]', nip05: '[email protected]' }) }, sk);
+store.push(seed);
+
+const walletSk = generateSecretKey();
+const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [],
+ nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) },
+ loadFeatureState: () => null, saveFeatureState: () => {},
+ _cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {},
+};
+const feature = messagesFeature({
+ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null,
+ hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null),
+});
+
+console.log('[the migration: rename and avatar import fired together]');
+await Promise.all([
+ feature.addressRenamed('[email protected]', '[email protected]'),
+ feature.adoptLegacyProfile('jarryd'),
+]);
+const newest = store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
+const final = JSON.parse(newest.content);
+const sent = [...new Map(published.filter((e) => e.kind === 0).map((e) => [e.id, e])).values()];
+check('two kind 0s went out, one after the other', sent.length === 2 && sent[1].created_at > sent[0].created_at, sent.map((e) => e.created_at + ' ' + JSON.parse(e.content).lud16 + ' ' + (JSON.parse(e.content).picture ? 'face' : '-')).join(' → '));
+check('the newest kind 0 carries the new address', final.lud16 === '[email protected]' && final.nip05 === '[email protected]', JSON.stringify(final));
+check('...and the legacy face and cover', /face\.webp$/.test(final.picture || '') && /cover\.webp$/.test(final.banner || ''), final.picture + ' ' + final.banner);
+check('...and the name it had', final.name === 'Jarryd');
+
+console.log('[a second rename right behind, before the relays answer]');
+published.length = 0;
+await Promise.all([feature.addressRenamed('[email protected]', '[email protected]'), feature.publishProfile({ about: 'hi' })]);
+const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content);
+check('both changes survive', last.lud16 === '[email protected]' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last));
+
+console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns');
+process.exit(fails ? 1 : 0);
diff --git a/tools/push-resubscribe-test.js b/tools/push-resubscribe-test.js
new file mode 100644
index 0000000..19998b2
--- /dev/null
+++ b/tools/push-resubscribe-test.js
@@ -0,0 +1,103 @@
+// iOS drops a web push subscription after three pushes that showed no
+// notification, and nothing used to make a new one: the wallet's background
+// switch stayed on, getSubscription() came back empty, and the notifier kept
+// a dead endpoint, so NWC requests stopped waking the closed app until the
+// user toggled it off and on (reported by PullCap). Opening the app, or
+// coming back to it, must subscribe again and tell the notifier.
+//
+// Run: bun tools/push-resubscribe-test.js
+import puppeteer from './puppeteer.js';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { cacheKeyFor } from '../src/wallet.js';
+import { buildHtml } from '../build.js';
+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 html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5263, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const KEY = 'BP' + 'A'.repeat(85); // any 65-byte key, base64url
+await page.evaluateOnNewDocument((KEY) => {
+ const bytes = () => Uint8Array.from(atob(KEY.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
+ window.__subs = 0; window.__regs = [];
+ let sub = null; // iOS has already dropped it
+ const make = () => ({
+ endpoint: 'https://web.push.apple.com/sub' + window.__subs,
+ options: { applicationServerKey: bytes().buffer },
+ toJSON() { return { endpoint: this.endpoint, keys: { p256dh: 'x', auth: 'y' } }; },
+ unsubscribe: () => { sub = null; return Promise.resolve(true); },
+ });
+ window.__drop = () => { sub = null; };
+ const reg = {
+ pushManager: {
+ getSubscription: () => Promise.resolve(sub),
+ subscribe: () => { window.__subs++; sub = make(); return Promise.resolve(sub); },
+ },
+ showNotification: () => Promise.resolve(),
+ };
+ Object.defineProperty(navigator.serviceWorker, 'ready', { get: () => Promise.resolve(reg) });
+ Notification.requestPermission = () => Promise.resolve('granted');
+ Object.defineProperty(Notification, 'permission', { get: () => 'granted' });
+ const f = window.fetch;
+ window.fetch = async (u, o) => {
+ const url = String(u);
+ if (url.startsWith('https://nwcpush.coinos.io/vapid')) return new Response(JSON.stringify({ publicKey: KEY }));
+ if (url.startsWith('https://nwcpush.coinos.io/register')) {
+ const b = JSON.parse(o.body);
+ if (b.servicePubkeys) window.__regs.push(b.subscription.endpoint);
+ return new Response('{"ok":true}');
+ }
+ return f(u, o);
+ };
+}, KEY);
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms/250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5263/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(500);
+ await click('to import'); await sleep(400);
+ await page.waitForSelector('textarea');
+ const mn = generateMnemonic(wordlist);
+ await page.type('textarea', mn);
+ await click('open wallet');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+
+ // a wallet answering in the background, whose subscription iOS took away
+ await page.evaluate((k) => localStorage.setItem(k + ':nwc', JSON.stringify({
+ conns: [{ id: 'x', name: 'test', servicePk: 'a'.repeat(64), sk: 'b'.repeat(64), maxSat: 1000, budget: {} }],
+ background: true,
+ })), base);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await sleep(3000);
+ let s = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('opening the app subscribes again', s.subs >= 1, `${s.subs} subscribe()`);
+ check('...and registers the new endpoint for the connection', s.regs.includes('https://web.push.apple.com/sub' + s.subs), s.regs.join(' '));
+
+ // dropped again while the app sat in the background; it resumes, no reload
+ await page.evaluate(() => { window.__drop(); window.__regs.length = 0; });
+ await page.evaluate(() => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
+ document.dispatchEvent(new Event('visibilitychange'));
+ });
+ await sleep(3000);
+ const before = s.subs;
+ s = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('coming back to the app subscribes again', s.subs > before, `${s.subs} subscribe()`);
+ check('...and registers it', s.regs.includes('https://web.push.apple.com/sub' + s.subs), s.regs.join(' '));
+
+ // a live subscription is left alone
+ await page.evaluate(() => { window.__regs.length = 0; document.dispatchEvent(new Event('visibilitychange')); });
+ await sleep(3000);
+ const again = await page.evaluate(() => ({ subs: window.__subs, regs: window.__regs }));
+ check('a subscription still there is reused', again.subs === s.subs && again.regs.length >= 1);
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ a dropped push subscription comes back' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/quoted-clip-fullscreen-test.js b/tools/quoted-clip-fullscreen-test.js
index bd8dd45..41984a2 100644
--- a/tools/quoted-clip-fullscreen-test.js
+++ b/tools/quoted-clip-fullscreen-test.js
@@ -70,6 +70,8 @@ try {
console.log('\n[a tap on the picture]');
const box = await (await page.$(SEL)).boundingBox();
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [box.x + box.width / 2, box.y + box.height * 0.25]);
await page.mouse.click(box.x + box.width / 2, box.y + box.height * 0.25); // above the middle: not the play button
check('the clip goes full screen', await waitFor(() => document.fullscreenElement && document.fullscreenElement.matches('video'), 4000), JSON.stringify(await state()));
await sleep(600);
diff --git a/tools/receive-batch-fold-test.js b/tools/receive-batch-fold-test.js
new file mode 100644
index 0000000..8e38265
--- /dev/null
+++ b/tools/receive-batch-fold-test.js
@@ -0,0 +1,20 @@
+// One payment delivered as several coins — a gift whose 886 sats sat in two
+// (595 + 291, dust padding) swept in one claim — is one Received row.
+// sameDelivery decides which receive rows fold; nothing else does.
+// Run: bun tools/receive-batch-fold-test.js
+import { sameDelivery } from '../src/ark/manager.js';
+let ok = true;
+const check = (n, c) => { console.log(` ${c ? '✓' : '✗'} ${n}`); if (!c) ok = false; };
+const T = 1_790_000_000_000;
+const rcv = (id, sat, ts, extra = {}) => ({ id, type: 'receive', status: 'complete', amountSat: sat, ts, vtxoId: 'v-' + id, ...extra });
+check('two coins of one mailbox message fold', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7' })));
+check('...even when their instants differ (live, no checkpoint time)', sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T + 40, { batch: '7' })));
+check('two messages stay two rows, same instant or not', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '8' })));
+check('a stamped row never folds an unstamped one', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T)));
+check('older rows (no stamp) fold on the same delivery instant', sameDelivery(rcv('a', 595, T), rcv('b', 291, T)));
+check('...not a millisecond apart', !sameDelivery(rcv('a', 595, T), rcv('b', 291, T + 1)));
+check('a ⚡ receive is its own payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', viaLn: true })));
+check('a rejected coin is not part of the payment', !sameDelivery(rcv('a', 595, T, { batch: '7' }), rcv('b', 291, T, { batch: '7', status: 'rejected' })));
+check('only receives fold', !sameDelivery({ ...rcv('a', 595, T), type: 'send' }, rcv('b', 291, T)));
+console.log(ok ? '\n✅ one delivery, one row' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/safe-boot-test.js b/tools/safe-boot-test.js
new file mode 100644
index 0000000..a23c067
--- /dev/null
+++ b/tools/safe-boot-test.js
@@ -0,0 +1,77 @@
+// The crash-loop guard: the inline BOOT_GUARD counts page starts, the app
+// clears the count once it has lived or leaves cleanly, and a third start
+// in a row opens in safe mode — a notice naming the last boot phase, no
+// automatic Ark connection, and "Open normally" back to a plain boot.
+// Run: bun tools/safe-boot-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';
+import { BOOT_GUARD } from '../build.js';
+
+const bundle = await Bun.build({ entrypoints: ['src/app.js'], target: 'browser', minify: true,
+ plugins: [{ name: 'safe-boot-test', setup(build) {
+ build.onLoad({ filter: /src\/app\.js$/ }, async ({ path }) => ({ loader: 'js', contents: await Bun.file(path).text() + `
+window.sbApp = { ui, wallet, render, enterWallet, get ready() { return !_bootDeciding; } };
+` }));
+ build.onLoad({ filter: /src\/features\/ark\.js$/ }, async ({ path }) => ({ loader: 'js', contents:
+ (await Bun.file(path).text()).replace(" return {\n id: 'ark',", `
+ window.sbArk = { get ark() { return ark; }, get connecting() { return !!arkConnectPromise; }, get lastAutoInit() { return lastAutoInit; } };
+ 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>${BOOT_GUARD}<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'] });
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+try {
+ const page = await browser.newPage(), errors = [];
+ page.on('pageerror', (e) => errors.push(e.message));
+ await page.setViewport({ width: 390, height: 844 });
+ 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(() => {
+ if (!localStorage.getItem('sb-seeded')) {
+ localStorage.setItem('sb-seeded', '1');
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local');
+ // two starts already, neither cleared: the last one died merging the worker's state
+ localStorage.setItem('coinos-boot', '2');
+ localStorage.setItem('coinos-boot-phase', 'ark-merge');
+ }
+ });
+ await page.goto(server.url.href, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.sbApp?.ready);
+ const guard = await page.evaluate(() => ({ safe: window.__safeBoot, phase: window.__bootPhase, count: localStorage.getItem('coinos-boot') }));
+ check('the third start in a row opens in safe mode, knowing the phase the last one reached', guard.safe === 3 && guard.phase === 'ark-merge' && guard.count === '3', JSON.stringify(guard));
+ await page.evaluate(async (mn) => {
+ await sbApp.enterWallet(mn, '', { fresh: true, generated: true });
+ sbApp.ui.onb = null; localStorage.removeItem('btc-wallet-onb-step');
+ sbApp.ui.tab = 'wallet'; sbApp.render();
+ }, generateMnemonic(wordlist));
+ await new Promise((r) => setTimeout(r, 1500));
+ const notice = await page.evaluate(() => { const n = [...document.querySelectorAll('.notice')].find((x) => /safe mode/i.test(x.textContent)); return n ? n.textContent : ''; });
+ check('the wallet home says so and names the phase', /safe mode/i.test(notice) && /ark-merge/.test(notice) && /2 times/.test(notice), notice.slice(0, 120));
+ const arkState = await page.evaluate(() => ({ ark: !!sbArk.ark, connecting: sbArk.connecting, auto: sbArk.lastAutoInit }));
+ check('nothing dialled the Ark server on its own', !arkState.ark && !arkState.connecting && !arkState.auto, JSON.stringify(arkState));
+ // a clean leave clears the count
+ await page.evaluate(() => dispatchEvent(new Event('pagehide')));
+ check('leaving the page cleanly clears the count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null);
+ // "Open normally": the count is cleared and the page reloads into a plain boot
+ await page.evaluate(() => localStorage.setItem('coinos-boot', '3'));
+ await Promise.all([
+ page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
+ page.evaluate(() => { const b = [...document.querySelectorAll('.notice button')].find((x) => /open normally/i.test(x.textContent)); b.click(); }),
+ ]);
+ await page.waitForFunction(() => window.sbApp?.ready);
+ const after = await page.evaluate(() => ({ safe: window.__safeBoot, count: localStorage.getItem('coinos-boot'), notice: [...document.querySelectorAll('.notice')].some((x) => /safe mode/i.test(x.textContent)) }));
+ check('"Open normally" reloads into a plain boot (one start counted, no notice)', after.safe === undefined && after.count === '1' && !after.notice, JSON.stringify(after));
+ await new Promise((r) => setTimeout(r, 8600));
+ check('a boot that lives eight seconds clears its count', (await page.evaluate(() => localStorage.getItem('coinos-boot'))) === null);
+ check('no page errors', errors.length === 0, errors.join(' | ').slice(0, 200));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ safe mode after a crash loop' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js
new file mode 100644
index 0000000..cf4a7bf
--- /dev/null
+++ b/tools/sw-push-quota.test.js
@@ -0,0 +1,89 @@
+// WebKit drops a push subscription after three pushes that show no
+// notification, so on an Apple endpoint every path through the worker's push
+// handler must end in one. Run: bun test tools/sw-push-quota.test.js
+import { test, expect, mock } from 'bun:test';
+
+let respond = async () => true;
+mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] }));
+mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() }));
+mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {}, takeLease: async () => ({ waited: 0, release: async () => {} }) }));
+mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} }));
+mock.module('../src/dm-inbox.js', () => ({
+ allInboxes: async () => [{ muted: ['m'.repeat(64)] }],
+ classifyDm: async () => ({ own: true }), shouldNotifyDm: () => false, classifyCall: async () => null,
+}));
+
+const listeners = {};
+let endpoint = 'https://web.push.apple.com/abc';
+let windows = [];
+const shown = [], closed = [];
+globalThis.self = {
+ location: { hostname: 'v3.coinos.io', origin: 'https://v3.coinos.io' },
+ addEventListener: (t, f) => { listeners[t] = f; },
+ clients: { matchAll: async () => windows },
+ registration: {
+ pushManager: { getSubscription: async () => ({ endpoint }) },
+ showNotification: async (title, o) => { shown.push({ title, ...o }); },
+ getNotifications: async ({ tag }) => shown.filter((n) => n.tag === tag).map((n) => ({ close: () => closed.push(n) })),
+ },
+};
+await import('../src/sw-nwc.js');
+
+async function push(data) {
+ shown.length = 0; closed.length = 0;
+ let done;
+ listeners.push({ data: { json: () => data }, waitUntil: (p) => { done = p; } });
+ await done;
+ return shown.map((n) => n.title);
+}
+const nwc = { type: 'nwc', servicePubkey: 'a'.repeat(64), event: { id: 'e'.repeat(64), kind: 23194 } };
+const hidden = { visibilityState: 'hidden', postMessage: () => {} };
+const visible = { visibilityState: 'visible', postMessage: () => {} };
+
+test('apple: every quiet path shows the stand-in', async () => {
+ endpoint = 'https://web.push.apple.com/abc'; windows = [];
+ respond = async () => true; // a lookup answered, or another device took it
+ expect(await push(nwc)).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'dm', wrap: {} })).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'reaction', from: 'm'.repeat(64) })).toEqual(['Coinos']);
+ expect(await push({ type: 'notify', reason: 'call', wrapId: 'x' })).toEqual(['Coinos']);
+ expect(await push({ type: 'weird' })).toEqual(['Coinos']);
+ expect(closed.length).toBe(0);
+});
+
+test('apple: a send finished from an earlier wake shows its own line and drops the stand-in', async () => {
+ endpoint = 'https://web.push.apple.com/abc'; windows = [];
+ respond = async () => ({ notices: [{ title: 'Payment sent', body: '-21 sats via a connected app', tag: 'nwc-sent' }] });
+ expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']);
+ expect(closed.length).toBe(1);
+});
+
+test('apple: an app on screen gets the stand-in closed at once', async () => {
+ windows = [visible];
+ expect(await push({ type: 'notify', reason: 'payment', amountSat: 5 })).toEqual(['Coinos']);
+ expect(closed.length).toBe(1);
+});
+
+test('apple: a suspended background app does not swallow the request', async () => {
+ windows = [hidden];
+ let asked = 0;
+ respond = async () => { asked++; return { notify: { title: 'Payment sent', body: '' } }; };
+ // the stand-in goes up first and the outcome replaces it (91dbee4)
+ expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']);
+ expect(closed.length).toBe(1);
+ expect(asked).toBe(1);
+});
+
+test('elsewhere: quiet stays quiet', async () => {
+ endpoint = 'https://fcm.googleapis.com/fcm/send/abc'; windows = [];
+ respond = async () => true;
+ expect(await push(nwc)).toEqual([]);
+ windows = [hidden];
+ let asked = 0;
+ respond = async () => { asked++; return true; };
+ expect(await push(nwc)).toEqual([]);
+ expect(asked).toBe(0); // the open (if hidden) window answers on Android
+ windows = [];
+ respond = async () => false;
+ expect(await push(nwc)).toEqual(['Payment request']);
+});
diff --git a/tools/video-events-test.js b/tools/video-events-test.js
index bef0956..c024949 100644
--- a/tools/video-events-test.js
+++ b/tools/video-events-test.js
@@ -128,6 +128,8 @@ try {
await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); r.querySelector('video.note-video').scrollIntoView({ block: 'center' }); });
await sleep(500);
const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('A normal clip')); const b = r.querySelector('video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; });
+ // a mouse click on a playing clip pauses it; a paused one opens
+ await page.evaluate(([x, y]) => { const v = document.elementFromPoint(x, y); if (v && v.tagName === 'VIDEO') v.pause(); }, [at.x, at.y]);
await page.mouse.click(at.x, at.y);
check('a tap opens the pager', await waitFor(() => !!document.querySelector('.media-pager.videos'), null, 4000));
const slides = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('|')[1].replace(location.origin, '')));
diff --git a/tools/video-tap-test.js b/tools/video-tap-test.js
index f82238b..50e5a69 100644
--- a/tools/video-tap-test.js
+++ b/tools/video-tap-test.js
@@ -1,8 +1,7 @@
-// Tapping a video: in the feed it opens the media pager (feed-video-pager-
-// test.js has the rest). In a thread, anywhere on the picture (not the
-// player's own play button or control bar) goes full screen — the corner
-// button is a reach on a phone. Full screen, a tap pauses a playing clip; a
-// tap on a paused one comes back out. Taps on the controls stay the player's.
+// Tapping a video: inline, a playing clip pauses. A paused one, in the feed,
+// and in a thread opens the media pager (feed-video-pager-test.js has the
+// rest). With a mouse the middle of the clip is a big play/pause button that
+// never goes full screen.
// Run: bun tools/video-tap-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
@@ -69,8 +68,18 @@ try {
for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250);
check('the clip autoplays in the feed', !(await st()).paused);
+ // the hover button: pauses and plays in place
+ const pp = async () => { const b = await page.evaluate(() => { const r = document.querySelector('.notes-feed .note-video-box').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.move(b.x - 40, b.y); await page.mouse.move(b.x, b.y, { steps: 3 }); await sleep(250); if (!(await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'flex'))) check('a mouse over the clip brings up the middle button', false); await page.mouse.click(b.x, b.y); await sleep(700); };
+ check('no middle button until a mouse comes over the clip', await page.evaluate(() => getComputedStyle(document.querySelector('.notes-feed .vid-pp')).display === 'none'));
+ await pp();
+ check('the middle button pauses the clip in place', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement));
+ check('...and shows play while paused', await page.evaluate(() => !document.querySelector('.vid-pp').classList.contains('playing')));
+ await pp();
+ check('the middle button plays it again, still inline', await page.evaluate(() => !document.querySelector('video.note-video').paused && !document.querySelector('.media-pager') && !document.fullscreenElement));
await tap(0.2, 0.3);
- check('in the feed, a tap opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ check('in the feed, a tap on a playing clip pauses it, no pager', await page.evaluate(() => document.querySelector('video.note-video').paused && !document.querySelector('.media-pager')));
+ await tap(0.2, 0.3);
+ check('a tap on the paused clip opens the media pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
await page.keyboard.press('Escape'); await sleep(700);
check('...which Escape closes', await page.evaluate(() => !document.querySelector('.media-pager') && !document.fullscreenElement));
// the thread: the post's words open it
@@ -87,22 +96,11 @@ try {
for (let i = 0; i < 40 && (await st()).paused; i++) await sleep(250);
await tap(0.2, 0.3);
let s = await st();
- check('in a thread, a tap on the picture goes full screen', s.fs, JSON.stringify(s));
- check('...and not the pager', await page.evaluate(() => !document.querySelector('.media-pager')));
- await tap(0.25, 0.3);
- s = await st();
- check('full screen, a tap pauses the playing clip', s.fs && s.paused, JSON.stringify(s));
- await tap(0.5, 0.5);
- s = await st();
- check('a tap on the paused clip\'s play button is left to the player', s.fs, JSON.stringify(s));
- await page.evaluate(() => document.querySelector('.thread-card video.note-video').pause()); await sleep(300);
+ check('in a thread, a tap on the playing clip pauses it in place', !s.fs && s.paused, JSON.stringify(s));
await tap(0.2, 0.3);
- s = await st();
- check('a tap elsewhere on the paused clip comes out of full screen', !s.fs, JSON.stringify(s));
- await tap(0.3, 0.97);
- s = await st();
- check('a tap on the control bar stays the player\'s', !s.fs, JSON.stringify(s));
+ check('a tap on the paused clip opens the media pager there too', await page.evaluate(() => !!document.querySelector('.media-pager.videos')));
+ await page.keyboard.press('Escape'); await sleep(700);
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} finally { await browser.close(); server.stop(true); }
-console.log(ok ? '\n✅ video taps: full screen, pause, back out' : '\n❌ failed');
+console.log(ok ? '\n✅ video taps: pause in place, pager when paused, middle button' : '\n❌ failed');
process.exit(ok ? 0 : 1);
diff --git a/tools/video-tile-poster-test.js b/tools/video-tile-poster-test.js
index 3b5fe93..48385fe 100644
--- a/tools/video-tile-poster-test.js
+++ b/tools/video-tile-poster-test.js
@@ -1,7 +1,8 @@
// Videos grid: a tile shows the server's still (vmedia thumb.jpg, cached at
// the CDN) at once, instead of waiting on the browser's own probe and frame
// grab — and never touches the clip itself for it. A clip the server has no
-// picture for falls back to the frame grab.
+// picture for falls back to the frame grab, and one the browser can't load
+// either (deleted, its 404 without CORS headers) leaves the grid.
// Run: bun tools/video-tile-poster-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
@@ -13,12 +14,17 @@ const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? '
const JPG = '/tmp/coinos-video-tile-poster.jpg';
Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=orange:s=270x480', '-frames:v', '1', JPG]);
const JPEG = Buffer.from(await Bun.file(JPG).arrayBuffer());
+const MP4 = '/tmp/coinos-video-tile-poster.mp4';
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=blue:s=360x640:d=1', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', MP4]);
+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);
-const POSTS = [post('a', 100), post('b', 200), post('nopic', 300)];
+ 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 });
const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
@@ -31,10 +37,16 @@ page.on('request', (r) => {
if (u.pathname.startsWith('/vmedia/thumb.jpg')) {
const src = u.searchParams.get('u');
asked.push(['thumb', src]);
- if (src === CLIP('nopic')) return r.respond({ status: 404, body: 'No picture' });
+ if (src === CLIP('nopic') || src === CLIP('dead')) return r.respond({ status: 404, body: 'No picture' });
return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
}
- if (u.host === 'media.test') { asked.push(['clip', r.url()]); return r.abort(); }
+ if (u.host === 'media.test') {
+ asked.push(['clip', r.url()]);
+ // deleted, and the host's 404 says nothing to scripts (Primal's blossom)
+ if (u.pathname === '/dead.mp4') return r.respond({ status: 404, body: 'not found' });
+ if (u.pathname === '/nopic.mp4') return r.respond({ status: 200, contentType: 'video/mp4', body: CLIPBYTES, headers: { 'access-control-allow-origin': '*' } });
+ return r.abort();
+ }
r.continue();
});
await page.evaluateOnNewDocument(() => {
@@ -79,17 +91,31 @@ try {
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
- check('the Videos grid has the three clips', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 15000),
+ check('the Videos grid has the clips', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length >= 3, 15000),
String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
check('two tiles show the server\'s still, loaded', await waitFor(() => [...document.querySelectorAll('.media-tile.video img')]
.filter((i) => i.src.includes('/vmedia/thumb.jpg') && i.naturalWidth > 0).length === 2, 8000),
JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-tile.video')].map((t) => (t.querySelector('img')?.src || t.querySelector('video') ? 'video' : t.innerHTML).slice(-40)))));
check('the clip without one falls back to the frame grab', await waitFor(() => [...document.querySelectorAll('.media-tile.video')]
.some((t) => !t.querySelector('img') && (t.querySelector('video') || t.querySelector('.media-tile-cost'))), 8000));
+ check('the deleted clip leaves the grid instead of sitting there blank', await waitFor(() => {
+ const tiles = [...document.querySelectorAll('.media-grid .media-tile.video')].filter((t) => getComputedStyle(t).display !== 'none');
+ return tiles.length === 3 && !document.querySelector('.media-grid video[src*="dead"]');
+ }, 10000), String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
await sleep(1500);
- const clipHits = asked.filter(([k, u]) => k === 'clip' && !u.includes('nopic'));
+ const clipHits = asked.filter(([k, u]) => k === 'clip' && !u.includes('nopic') && !u.includes('dead'));
check('the pictured clips themselves were never fetched for a still', clipHits.length === 0, clipHits.map((x) => x[1]).join(', '));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ // remembered: the next visit leaves it out from the start
+ await sleep(2000);
+ asked.length = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length >= 3, 15000);
+ await sleep(3000);
+ check('...and stays out after a reload', await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length) === 3
+ && !asked.some(([k, u]) => k === 'clip' && u.includes('dead')), String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ video tiles show the server\'s still at once' : '\n❌ failed');
process.exit(ok ? 0 : 1);
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));