diff options
| -rw-r--r-- | src/features/nwc.js | 53 | ||||
| -rw-r--r-- | tools/nwc-test.js | 35 |
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); |
