aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/nwc.js53
-rw-r--r--tools/nwc-test.js35
2 files changed, 87 insertions, 1 deletions
diff --git a/src/features/nwc.js b/src/features/nwc.js
index 61bfc60..8ead836 100644
--- a/src/features/nwc.js
+++ b/src/features/nwc.js
@@ -114,11 +114,60 @@ export function nwcFeature(ctx) {
};
st.conns.push(c);
save(st);
+ try { wallet.saveCache(); } catch {} // share it with the user's other devices
publishInfo(c).catch(() => {});
listen();
return c;
}
+ // Connections ride the encrypted device-sync snapshot, so a connection made
+ // on the phone can be answered by whichever device happens to be open. The
+ // snapshot is nip44-encrypted to a key derived from the seed — the same
+ // channel that already carries ark vtxo bytes and gift secrets — so the
+ // service keys travel no less safely than the coins do.
+ //
+ // Merge is a union by id and REVOCATION WINS: a connection revoked on one
+ // device must never be resurrected by an older snapshot from another.
+ function mergeConns(a = [], b = []) {
+ const by = new Map();
+ for (const c of [...a, ...b]) {
+ const prev = by.get(c.id);
+ if (!prev) { by.set(c.id, { ...c }); continue; }
+ by.set(c.id, {
+ ...prev, ...c,
+ revoked: !!(prev.revoked || c.revoked),
+ // keep the highest spend seen for the day so a stale device can't
+ // hand a connection back its budget
+ spentDate: (c.spentDate === prev.spentDate) ? c.spentDate : (c.spentDate || prev.spentDate),
+ spentToday: (c.spentDate === prev.spentDate)
+ ? Math.max(prev.spentToday || 0, c.spentToday || 0)
+ : (c.spentToday || prev.spentToday || 0),
+ lastUsed: Math.max(prev.lastUsed || 0, c.lastUsed || 0),
+ });
+ }
+ return [...by.values()];
+ }
+
+ // sync is an optional feature: a minimal build has no cache extensions
+ if (wallet.registerCacheExtension) wallet.registerCacheExtension({
+ mergeAlways: true, // the merge is commutative, so older snapshots are fine
+ save: () => {
+ const list = (load().conns || []);
+ return list.length ? { nwcConns: list } : {};
+ },
+ load: (d) => {
+ if (!Array.isArray(d.nwcConns)) return;
+ const st = load();
+ const merged = mergeConns(st.conns || [], d.nwcConns);
+ // only write (and re-listen) when something actually changed
+ if (JSON.stringify(merged) === JSON.stringify(st.conns || [])) return;
+ st.conns = merged;
+ save(st);
+ listen();
+ render();
+ },
+ });
+
// ---- protocol ---------------------------------------------------------
async function publishInfo(c) {
@@ -299,7 +348,9 @@ export function nwcFeature(ctx) {
h('div', { class: 'row between' },
h('span', {}, c.name),
h('span', { class: 'linklike small', onClick: () => {
- updateConn(c.id, { revoked: true }); listen(); render();
+ updateConn(c.id, { revoked: true }); listen();
+ try { wallet.saveCache(); } catch {} // propagate the revocation
+ render();
toast(t('nwcRevoked', { name: c.name }));
} }, t('nwcRevoke'))),
h('div', { class: 'small faint' },
diff --git a/tools/nwc-test.js b/tools/nwc-test.js
index 3df0f28..64d1cd6 100644
--- a/tools/nwc-test.js
+++ b/tools/nwc-test.js
@@ -27,6 +27,8 @@ const wallet = {
nostrRelays: () => ['wss://relay.example'],
nostrSubscribe: (filter, on) => { subHandler = on; return () => { subHandler = null; }; },
nostrPublishSigned: async (evt) => { published.push(evt); return true; },
+ saveCache: () => {},
+ registerCacheExtension: (ext) => { wallet.__ext = ext; },
};
const hooks = {
arkReady: () => true,
@@ -113,5 +115,38 @@ await subHandler(ev);
await new Promise(r=>setTimeout(r,300));
check('request from an unauthorized key is ignored', published.length === before);
+
+// ---------------------------------------------------------------------------
+console.log('\n[device sync of connections]');
+{
+ const ext = wallet.__ext;
+ check('feature registered a cache extension', !!ext);
+ // a connection made on another device arrives in a snapshot
+ const remote = { id:'phone1', name:'Phone app', servicePk:'aa'.repeat(32), serviceSk:'bb'.repeat(32),
+ clientPk:'cc'.repeat(32), secret:'dd'.repeat(32), maxSat:500, dailySat:1000, revoked:false, created:1 };
+ ext.load({ nwcConns: [remote] });
+ const after = JSON.parse(store['fs:nwc']).conns;
+ check('remote connection adopted', after.some(c => c.id === 'phone1'));
+ check('local connection kept', after.some(c => c.id === 'test1'));
+
+ // revoke locally, then receive a STALE snapshot that still says active
+ const st = JSON.parse(store['fs:nwc']);
+ st.conns.find(c => c.id === 'phone1').revoked = true;
+ store['fs:nwc'] = JSON.stringify(st);
+ ext.load({ nwcConns: [remote] }); // stale: revoked:false
+ const post = JSON.parse(store['fs:nwc']).conns.find(c => c.id === 'phone1');
+ check('revocation survives a stale snapshot', post.revoked === true);
+
+ // spend accounting must not be handed back by a stale device
+ const today = new Date().toISOString().slice(0,10);
+ const st2 = JSON.parse(store['fs:nwc']);
+ Object.assign(st2.conns.find(c => c.id === 'test1'), { spentDate: today, spentToday: 900 });
+ store['fs:nwc'] = JSON.stringify(st2);
+ ext.load({ nwcConns: [{ ...st2.conns.find(c=>c.id==='test1'), spentToday: 100 }] });
+ const t1 = JSON.parse(store['fs:nwc']).conns.find(c => c.id === 'test1');
+ check('higher same-day spend wins', t1.spentToday === 900, String(t1.spentToday));
+
+ check('what we publish carries the connections', Array.isArray(ext.save().nwcConns));
+}
console.log(ok ? '\n✅ NWC service behaves' : '\n❌ failed');
process.exit(ok?0:1);