summaryrefslogtreecommitdiffstats
path: root/tools/nwc-test.js
blob: 9189d522ef7e8c3d05dcfc4eb63d1e032babc848 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// Drive the NWC feature as a real NIP-47 client would:
//   bun tools/nwc-test.js
// encrypted 23194
// requests in, encrypted 23195 responses out, both nip04 and nip44.
import { hex } from '@scure/base';
import { nwcFeature } from '../src/features/nwc.js';
import { nip04, nip44, getPublicKey, finalizeEvent, generateSecretKey } from '../src/nostr.js';

let ok = true;
const check = (n, c, d='') => { console.log(` ${c?'✓':'✗'} ${n}${d?' — '+d:''}`); if(!c) ok=false; };

const store = {};
globalThis.localStorage = { getItem:k=>k in store?store[k]:null, setItem:(k,v)=>{store[k]=v;}, removeItem:k=>{delete store[k];} };

let subHandler = null;
let priorReplies = [];
let claimOk = true;
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; },
  query: async () => priorReplies, // what other devices have answered (none, unless a test says so)
  errGraceMs: 20,        // the sibling-first hold, shortened for the harness
  claim: async () => claimOk, // the notifier's first-come-first-pays verdict
};
let paid = [];
const wallet = {
  watchOnly: false,
  loadFeatureState: (n, d) => (store['fs:'+n] ? JSON.parse(store['fs:'+n]) : d),
  saveFeatureState: (n, v) => { store['fs:'+n] = JSON.stringify(v); },
  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,
  arkSpendableSat: () => 42000,
  arkPayInvoice: async (inv, opts) => { paid.push({ inv, opts }); return { preimage: 'ab'.repeat(32), feeSat: 1, amountSat: 25 }; },
  arkMakeInvoice: async (sat) => ({ invoice: 'lnbc'+sat, paymentHash: 'cd'.repeat(32), amountSat: sat }),
  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:()=>{}, nwcTransport };

const f = nwcFeature(ctx);
f.init();

// create a connection the way the UI does
const st = { conns: [] };
// invoke via the settings card is awkward headless; call through the module's
// exported behaviour by simulating what createConn does: use the feature's card
// action indirectly — instead drive persistence directly then re-init.
const serviceSk = generateSecretKey(), clientSk = generateSecretKey();
const conn = {
  id: 'test1', name: 'TestApp',
  serviceSk: hex.encode(serviceSk), servicePk: getPublicKey(serviceSk),
  secret: hex.encode(clientSk), clientPk: getPublicKey(clientSk),
  maxSat: 1000, dailySat: 2000, spentToday: 0, spentDate: null, created: Date.now(), revoked: false,
};
store['fs:nwc'] = JSON.stringify({ conns: [conn] });
f.stop(); f.init();
check('subscribed for requests', typeof subHandler === 'function');

async function request(method, params, scheme='nip44_v2') {
  const payload = JSON.stringify({ method, params });
  const content = scheme === 'nip44_v2'
    ? nip44.encrypt(payload, nip44.getConversationKey(clientSk, conn.servicePk))
    : await nip04.encrypt(clientSk, conn.servicePk, payload);
  const ev = finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now()/1000),
    tags: [['p', conn.servicePk], ...(scheme==='nip44_v2'?[['encryption','nip44_v2']]:[])], content }, clientSk);
  const before = published.length;
  await subHandler(ev);
  for (let i=0;i<40 && published.length===before;i++) await new Promise(r=>setTimeout(r,50));
  const res = published[published.length-1];
  if (!res) return null;
  const txt = scheme === 'nip44_v2'
    ? nip44.decrypt(res.content, nip44.getConversationKey(clientSk, conn.servicePk))
    : await nip04.decrypt(clientSk, conn.servicePk, res.content);
  return JSON.parse(txt);
}

console.log('\n[nip44 requests]');
let r = await request('get_info', {});
check('get_info lists methods', r?.result?.methods?.includes('pay_invoice'), JSON.stringify(r?.result?.methods));
r = await request('get_balance', {});
check('get_balance in msat', r?.result?.balance === 42000*1000, String(r?.result?.balance));
r = await request('pay_invoice', { invoice: 'lnbc250n1p4xu9wtsp5t5c9w2qlv4mkngwn6txm4' });
check('pay_invoice rejects an unparseable invoice', !!r?.error, JSON.stringify(r?.error));

console.log('\n[budget enforcement]');
// a real-looking 25-sat invoice from earlier in this session
const INV25 = 'lnbc250n1p4xu9wtsp5t5c9w2qlv4mkngwn6txm4';
r = await request('pay_invoice', { invoice: 'lnbc50u1p4xu8whsp5jjnp3jzhjrgp3992dhurhryued5dhk0fvp7jexkjtd86czhwu77spp5dg2d76m6cjl5s6s6m2sffeux746ha345cmmmu2ttgjs0mu2gnkfsdqcdemkxgrzw4jxwet5yp6x2um5xqyjw5qcqpjrzjqgfffll4jmjf0tffqtx47xt886gzp9fajp3966xz96gm2xj9cqedxr4zxyqq3vgqqqqqqqqqqqqqrssqyg9qxpqysgq0g76kefudlp6z9qp3xypghqvqtkgtz3cwc89f76x4wzkvnx2lmu9azt8ewnet7avrhxek62sdm67zx72mnntldarhlsdkcna9k9r4uqqe5lr3p' });  // 5000 sat, over the 1000 limit
check('over-limit payment refused', r?.error?.code === 'QUOTA_EXCEEDED', JSON.stringify(r?.error));
check('nothing was paid', paid.length === 0);

console.log('\n[cumulative spend]');
// a real 21-sat invoice (long expired — the mock seam never pays anything);
// two payments must ACCUMULATE: recordSpend once restarted from a stale
// snapshot of the connection, so only the last payment ever counted
const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g';
r = await request('pay_invoice', { invoice: INV21 });
check('first payment succeeds', !!r?.result?.preimage, JSON.stringify(r?.error || r));
r = await request('pay_invoice', { invoice: INV21 });
check('second payment succeeds', !!r?.result?.preimage, JSON.stringify(r?.error || r));
{
  const spent = JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentToday;
  check('spend accumulates across payments', spent === 52, String(spent)); // 2 × (25 + 1 fee) from the mock seam
}

console.log('\n[sibling payment in flight]');
{
  // The ASP refuses a second HTLC for an invoice another device is paying.
  // This device must relay THAT payment's outcome, not report a failure.
  const payer = hooks.arkPayInvoice;
  hooks.arkPayInvoice = async () => { throw new Error('grpc bark_server.ArkService/RequestLightningPayHtlcCosign: status 3: error making payment: bad user input: payment already in progress for this invoice'); };
  hooks.arkLnOutcome = async () => ({ status: 'success', preimage: '12'.repeat(32) });
  r = await request('pay_invoice', { invoice: INV21 });
  check('sibling success relayed with its preimage', r?.result?.preimage === '12'.repeat(32), JSON.stringify(r));
  const spent = JSON.parse(store['fs:nwc']).conns.find((x) => x.id === 'test1').spentToday;
  check('relayed payment not counted as our spend', spent === 52, String(spent));
  // ...and stays quiet when the payer's own reply is already on the relay
  priorReplies = [{ id: 'x' }];
  const n = published.length;
  r = await request('pay_invoice', { invoice: INV21 });
  check('silent when the payer already replied', published.length === n);
  priorReplies = [];
  hooks.arkLnOutcome = async () => ({ status: 'failed' });
  r = await request('pay_invoice', { invoice: INV21 });
  check('sibling failure surfaces as an error', /already in progress/.test(r?.error?.message || ''), JSON.stringify(r));
  hooks.arkPayInvoice = payer;
  delete hooks.arkLnOutcome;
}

console.log('\n[cross-device claim]');
{
  claimOk = false;
  const n = published.length, wasPaid = paid.length;
  await request('pay_invoice', { invoice: INV21 });
  check('unclaimed pay request stays silent and unpaid', published.length === n && paid.length === wasPaid);
  claimOk = true;
  r = await request('get_balance', {});
  check('non-pay methods need no claim', r?.result?.balance === 42000*1000);
  // no verdict from the notifier → the relay look-back decides
  claimOk = null; priorReplies = [{ id: 'x' }];
  const n2 = published.length;
  await request('pay_invoice', { invoice: INV21 });
  check('no verdict + a reply on the relays → silent', published.length === n2);
  priorReplies = [];
  r = await request('pay_invoice', { invoice: INV21 });
  check('no verdict + nothing on the relays → pays', !!r?.result?.preimage);
  claimOk = true;
}

console.log('\n[nip04 fallback]');
r = await request('get_balance', {}, 'nip04');
check('nip04 request answered', r?.result?.balance === 42000*1000);

console.log('\n[other methods]');
r = await request('make_invoice', { amount: 21000 });
check('make_invoice returns a bolt11', !!r?.result?.invoice, r?.result?.invoice);
r = await request('list_transactions', {});
check('list_transactions returns history', Array.isArray(r?.result?.transactions) && r.result.transactions.length === 1);
r = await request('pay_keysend', {});
check('unsupported method errors cleanly', r?.error?.code === 'NOT_IMPLEMENTED');

console.log('\n[isolation]');
const strangerSk = generateSecretKey();
const payload = JSON.stringify({ method: 'get_balance', params: {} });
const ev = finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now()/1000),
  tags: [['p', conn.servicePk], ['encryption','nip44_v2']],
  content: nip44.encrypt(payload, nip44.getConversationKey(strangerSk, conn.servicePk)) }, strangerSk);
const before = published.length;
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);