aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-11-03 16:18:01 -0700
committerAdam Soltys <[email protected]>2026-11-03 16:18:01 -0700
commit7e8f2b375aebd3809b6fe9c474c6a36b392163f2 (patch)
treee23a70eb0cee8f5b24f94d4925ee3a3da5911583
parentc566edbe50a8921311127efac65836bd6ff9c6d3 (diff)
downloadcoinosv3-7e8f2b375aebd3809b6fe9c474c6a36b392163f2.tar.gz
coinosv3-7e8f2b375aebd3809b6fe9c474c6a36b392163f2.zip
A wallet on another ark server tells the registrar it is listening, so Lightning sent to its name arrives the moment it opens. A name whose wallet lives on Second (or a custom server) can only be paid over Lightning — the registrar's float can't arkoor to a server it isn't on — and only while the wallet is open to answer the registrar's offer request; its hourly retry with a six-second window rarely met a short phone session, and manna's 52 sats failed 449 times from 2026-10-18 to 11-03. Once the offer listener is up (nwc listen(), a beat after subscribing), the names feature POSTs /listening (NIP-98, coinosv3-server 14a6dd5) and the registrar sends whatever is queued for the name at once. Once per ten minutes, and never for a wallet on the coinos server: it is paid while asleep and spends no signature on this. tools/names-listening-test.js: nothing on coinos's server, one authed call on Second, no second within ten minutes, a registrar that is down is asked again next time
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VMan6TFgisGKUDhdStLUvs
-rw-r--r--src/features/names.js17
-rw-r--r--src/features/nwc.js4
-rw-r--r--tools/names-listening-test.js70
3 files changed, 90 insertions, 1 deletions
diff --git a/src/features/names.js b/src/features/names.js
index d57bb19..e07e025 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -17,7 +17,7 @@ import { nip98Header } from '../nip98.js';
import { qrSvg } from '../qr.js';
import { bech32 } from '@scure/base';
import { isArkAddress } from './ark.js';
-import { getNetwork } from '../api.js';
+import { getNetwork, getArkProviderId } from '../api.js';
import { t } from '../i18n.js';
import { getCurrency } from '../rates.js';
@@ -411,6 +411,7 @@ export function namesFeature(ctx) {
// should have to retype who they already are — but only when that name is
// actually free on the registrar (or already theirs to retake).
let lnReceipts = { at: 0, p: null };
+ let listeningAt = 0;
let suggestedFor = null;
function suggestName() {
@@ -959,6 +960,20 @@ export function namesFeature(ctx) {
};
return lnReceipts.p;
},
+ // A wallet on another ark server (Second, a custom one) gets its name's
+ // Lightning receives only while it is open to answer the registrar's
+ // offer request — the registrar can't arkoor to a server it has no
+ // float on — and its hourly retry rarely met a short session (52 sats
+ // waited 449 tries, 2026-10-18 → 11-03). So once the offer listener is
+ // up, say so: whatever is queued for this name goes now. Once per ten
+ // minutes; a wallet on the coinos server is paid while asleep and
+ // never needs to (and never spends a signature on it).
+ namesListening() {
+ if (!available() || !load().name || getArkProviderId() === 'coinos') return null;
+ if (Date.now() - listeningAt < 600_000) return null;
+ listeningAt = Date.now();
+ return post('/listening', 'POST', {}).catch(() => { listeningAt = 0; return null; });
+ },
// the claimed payment address, for anyone prefilling a lightning address
namesAddress() { const st = load(); return st.name ? `${st.name}@${st.domain || DOMAIN()}` : null; },
// Sign-in races this lookup against the seed work: by the time the
diff --git a/src/features/nwc.js b/src/features/nwc.js
index a2730f5..dc87388 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -671,6 +671,10 @@ export function nwcFeature(ctx) {
{ kinds: [OFFER_KIND], '#p': [o.pk], since: nowSec() - MAX_AGE_SEC },
(ev) => { onOfferRequest(ev).catch((e) => console.warn('nwc: offer handler', e.message)); },
));
+ // a beat for the subscription to reach the relays, then tell the
+ // registrar this wallet is here to answer: Lightning deliveries
+ // queued for a wallet on another ark server go out now (names)
+ setTimeout(() => { if (gen === listenGen) Promise.resolve(hook('namesListening')).catch(() => {}); }, 1500);
}
const list = [...conns(), ...revokedConns()];
dlog(`nwc: listen() gen ${gen} — ${list.length} connection(s)`);
diff --git a/tools/names-listening-test.js b/tools/names-listening-test.js
new file mode 100644
index 0000000..acb84e2
--- /dev/null
+++ b/tools/names-listening-test.js
@@ -0,0 +1,70 @@
+// A wallet on another ark server tells the registrar it is listening once
+// its offer listener is up, so Lightning receives queued for its name go
+// out now instead of on the hourly retry. A wallet on the coinos server
+// never does (it is paid while asleep). Run: bun tools/names-listening-test.js
+import assert from 'node:assert/strict';
+import puppeteer from './puppeteer.js';
+
+const app = await Bun.file('src/app.js').text();
+const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing'));
+const entry = `
+import { namesFeature } from './src/features/names.js';
+import { setArkProviderId, getArkProviderId, getNetwork } from './src/api.js';
+${domHelper}
+const state = { names: { byDomain: { 'coinos.io': { name: 'manna', domain: 'coinos.io' } } } };
+const ui = { screen: 'wallet' };
+const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = v; }, nostrSign: async (e) => ({ ...e, id: 'ab'.repeat(32), sig: 'cd'.repeat(64), pubkey: 'aa'.repeat(32) }), nostrPubkey: () => 'aa'.repeat(32), _cacheKey: () => 'w1' };
+const ctx = { h, ui, wallet, render: () => {}, toast: () => {}, copy: () => {}, copyBtn: () => null, brandHeader: () => null, goBack: () => {}, setAccount: () => {}, hook: (name) => (name === 'arkReady' ? true : null) };
+const feature = namesFeature(ctx);
+window.test = { listening: () => feature.namesListening(), server: (id) => setArkProviderId(id), dbg: () => ({ net: getNetwork(), prov: getArkProviderId(), avail: feature.namesAvailable() }) };
+`;
+const bundle = await Bun.build({
+ entrypoints: ['names-listen-entry'], target: 'browser',
+ plugins: [{ name: 'names-listen', setup(build) {
+ build.onResolve({ filter: /^names-listen-entry$/ }, () => ({ path: 'entry', namespace: 'names-listen' }));
+ build.onLoad({ filter: /.*/, namespace: 'names-listen' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() }));
+ } }],
+});
+assert(bundle.success, bundle.logs.join('\n'));
+const html = `<!doctype html><meta charset="utf-8"><div id="app"></div><script type="module">${await bundle.outputs[0].text()}</script>`;
+const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push(e.message));
+// the registrar, faked: every call recorded, answered as told
+const calls = [];
+let answer = { status: 200, body: '{"waiting":1,"sat":52}' };
+await page.setRequestInterception(true);
+page.on('request', (r) => {
+ if (!r.url().startsWith('https://names.coinos.io/')) return r.continue();
+ const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*', 'access-control-allow-methods': '*' };
+ if (r.method() === 'OPTIONS') return r.respond({ status: 204, headers: cors });
+ calls.push({ method: r.method(), path: new URL(r.url()).pathname, auth: r.headers().authorization || '' });
+ r.respond({ ...answer, contentType: 'application/json', headers: cors });
+});
+const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); };
+const listening = () => page.evaluate(() => test.listening());
+try {
+ await page.goto(server.url.href);
+ await page.waitForFunction(() => !!window.test);
+ check('a wallet on the coinos server says nothing', (await listening()) === null && !calls.length);
+ await page.evaluate(() => test.server('second'));
+ const r = await listening();
+ const posts = () => calls.filter((c) => c.method === 'POST');
+ check('on Second it tells the registrar it is listening', posts().length === 1 && posts()[0].path === '/listening', JSON.stringify(calls));
+ check('...as itself (NIP-98)', /^Nostr /.test(posts()[0].auth));
+ check('...and hears what is waiting', r && r.waiting === 1 && r.sat === 52, JSON.stringify(r));
+ check('a second listen within ten minutes is not another call', (await listening()) === null && posts().length === 1);
+ await page.reload(); await page.waitForFunction(() => !!window.test);
+ answer = { status: 500, body: '{"error":"down"}' };
+ const down = await listening();
+ check('a registrar that is down is quietly left', down === null && posts().length === 2, JSON.stringify({ down, calls }));
+ answer = { status: 200, body: '{"waiting":0,"sat":0}' };
+ check('...and asked again next time, not in ten minutes', (await listening())?.waiting === 0 && posts().length === 3);
+ check('no page errors', !errors.length, errors.join(' | '));
+ console.log('\n✅ names: a wallet on another ark server says it is listening');
+} finally {
+ await browser.close();
+ server.stop(true);
+}