aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 11:02:37 -0700
committerAdam Soltys <[email protected]>2026-08-01 11:02:37 -0700
commit0519a79ac05ec983ae7ad8d4292f5cfa9fa321a9 (patch)
tree6939dd691379a5642d47c51c866dc98f846c24af
parentd8d2746f16d1bdcb71e1409a90871b65117d026c (diff)
downloadcoinosv3-0519a79ac05ec983ae7ad8d4292f5cfa9fa321a9.tar.gz
coinosv3-0519a79ac05ec983ae7ad8d4292f5cfa9fa321a9.zip
NWC: own the relay set instead of borrowing the sync feature's
The connection URI advertised up to four relays (sync relays + profile relays) while the subscription only ever listened on the sync relay, so a client could publish its request somewhere hal wasn't and spin forever waiting for a reply. Amethyst did exactly that. NWC now has its own relay list used for all three things that must agree: the URI, the subscription, and the replies. It no longer depends on device sync being enabled, which would otherwise have made the whole wallet service a silent no-op. Also republish the kind 13194 capability event on every start — a client that can't find it just spins, and a single publish at creation time is easy for a relay to miss. The relay transport is now injectable so the protocol can be exercised without a live relay (tools/nwc-test.js). Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--src/features/nwc.js29
-rw-r--r--src/nostr.js14
-rw-r--r--tools/nwc-test.js7
3 files changed, 39 insertions, 11 deletions
diff --git a/src/features/nwc.js b/src/features/nwc.js
index b582ed9..61bfc60 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -25,6 +25,7 @@ import { hex } from '@scure/base';
import { sha256 } from '@noble/hashes/sha256';
import {
nip04, nip44, getPublicKey, finalizeEvent, generateSecretKey,
+ subscribeOn, publishOn,
} from '../nostr.js';
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
@@ -47,6 +48,9 @@ const errRes = (code, message) => ({ error: { code, message } });
export function nwcFeature(ctx) {
const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx;
+ // Relay transport, injectable so the protocol can be driven in tests
+ // without a live relay.
+ const { subscribe = subscribeOn, publish = publishOn } = ctx.nwcTransport || {};
// ---- persisted connections -------------------------------------------
// { id, name, secret (client sk hex), clientPk, servicePk, serviceSk,
@@ -75,10 +79,11 @@ export function nwcFeature(ctx) {
updateConn(c.id, { spentDate: today, spentToday: spent + sat, lastUsed: Date.now() });
}
- function relays() {
- const r = wallet.nostrRelays ? wallet.nostrRelays() : [];
- return r.length ? r.slice(0, 4) : ['wss://relay.coinos.io'];
- }
+ // The relays advertised in the URI MUST be the ones we listen on, or a
+ // client will publish its request somewhere we aren't and wait forever.
+ // Deliberately independent of the device-sync relay setting.
+ const NWC_RELAYS = ['wss://relay.coinos.io', 'wss://relay.damus.io', 'wss://nos.lol'];
+ const relays = () => NWC_RELAYS;
// The string the user pastes into another app.
function uriFor(c) {
@@ -124,7 +129,7 @@ export function nwcFeature(ctx) {
tags: [['p', c.clientPk], ['encryption', 'nip44_v2 nip04']],
content: METHODS.join(' '),
}, sk);
- return wallet.nostrPublishSigned ? wallet.nostrPublishSigned(evt) : null;
+ return publish(NWC_RELAYS, evt);
}
const decrypt = (scheme, sk, pk, ct) => (scheme === 'nip44_v2'
@@ -146,7 +151,7 @@ export function nwcFeature(ctx) {
tags: [['p', ev.pubkey], ['e', ev.id], ['encryption', scheme]],
content,
}, sk);
- await wallet.nostrPublishSigned(evt);
+ await publish(NWC_RELAYS, evt);
} catch (e) {
console.warn('nwc: could not reply', e.message);
}
@@ -262,15 +267,19 @@ export function nwcFeature(ctx) {
}
function listen() {
stop();
- if (!wallet.nostrSubscribe || wallet.watchOnly) return;
+ if (wallet.watchOnly) return;
const list = conns();
if (!list.length) return;
for (const c of list) {
- const un = wallet.nostrSubscribe(
+ unsubs.push(subscribe(
+ NWC_RELAYS,
{ kinds: [REQ_KIND], '#p': [c.servicePk], since: nowSec() - MAX_AGE_SEC },
(ev) => { onRequest(c, ev).catch(() => {}); },
- );
- unsubs.push(un);
+ ));
+ // Republish the capability event on every start: a client that can't
+ // find kind 13194 just spins, and a single publish at creation time can
+ // easily have been missed by a relay.
+ publishInfo(c).catch(() => {});
}
}
diff --git a/src/nostr.js b/src/nostr.js
index 652c579..1a86499 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -23,6 +23,20 @@ import { base64urlnopad } from '@scure/base';
// CLOSE-after-EOSE handshake) cleanly, so we never send on a half-closed socket.
const pool = new SimplePool();
+// Subscribe / publish on an explicit relay set, independent of the sync
+// feature's configuration. NWC needs this: the relays it advertises in a
+// connection URI must be exactly the ones it listens on, or a client's
+// request lands somewhere the wallet isn't and it waits forever.
+export function subscribeOn(relays, filter, onEvent) {
+ try {
+ const sub = pool.subscribeMany(relays, [filter], { onevent: onEvent });
+ return () => { try { sub.close(); } catch {} };
+ } catch { return () => {}; }
+}
+export async function publishOn(relays, evt) {
+ try { await Promise.allSettled(pool.publish(relays, evt)); return true; } catch { return false; }
+}
+
// --- locking a gift to a recipient's Nostr key ---------------------------
// An npub or 64-hex nostr pubkey → hex, or null if it isn't one.
// NIP-47 wallet services sign with their own per-connection key and must
diff --git a/tools/nwc-test.js b/tools/nwc-test.js
index e22c5d2..3df0f28 100644
--- a/tools/nwc-test.js
+++ b/tools/nwc-test.js
@@ -14,6 +14,11 @@ globalThis.localStorage = { getItem:k=>k in store?store[k]:null, setItem:(k,v)=>
let subHandler = null;
const published = [];
+// fake relay transport: capture the subscription handler and any replies
+const nwcTransport = {
+ subscribe: (relays, filter, on) => { subHandler = on; return () => { subHandler = null; }; },
+ publish: async (relays, evt) => { published.push(evt); return true; },
+};
let paid = [];
const wallet = {
watchOnly: false,
@@ -31,7 +36,7 @@ const hooks = {
arkMovements: () => ([{ type:'ln-send', status:'complete', amountSat: 25, ts: Date.now(), invoice:'lnbc250n1', preimage:'ef'.repeat(32) }]),
};
const ctx = { h:()=>null, ui:{}, render:()=>{}, wallet, hook:(n,...a)=>hooks[n]?hooks[n](...a):null,
- fmtAmount:(n)=>String(n), unitLabel:()=>'sats', copyBtn:()=>null, toast:()=>{} };
+ fmtAmount:(n)=>String(n), unitLabel:()=>'sats', copyBtn:()=>null, toast:()=>{}, nwcTransport };
const f = nwcFeature(ctx);
f.init();