javascript-solid-server 0.0.105 → 0.0.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/webledger.js CHANGED
@@ -77,16 +77,21 @@ export async function writeLedger(ledger, ledgerPath = DEFAULT_PATH) {
77
77
  * Get balance for a URI
78
78
  * @param {object} ledger - WebLedger object
79
79
  * @param {string} uri - Agent URI (e.g. did:nostr:...)
80
+ * @param {string} [currency] - Currency code (e.g. 'tbtc3', 'tbtc4'). If omitted, reads default/simple amount.
80
81
  * @returns {number} Balance as integer
81
82
  */
82
- export function getBalance(ledger, uri) {
83
+ export function getBalance(ledger, uri, currency) {
83
84
  const entry = ledger.entries.find(e => e.url === uri);
84
85
  if (!entry) return 0;
85
- // Handle both simple string and array amount formats
86
+ // Handle array amount format
86
87
  if (Array.isArray(entry.amount)) {
87
- const sat = entry.amount.find(a => a.currency === 'satoshi' || a.currency === 'sat');
88
- return sat ? parseInt(sat.value, 10) || 0 : 0;
88
+ const target = currency
89
+ ? entry.amount.find(a => a.currency === currency)
90
+ : entry.amount.find(a => a.currency === 'satoshi' || a.currency === 'sat');
91
+ return target ? parseInt(target.value, 10) || 0 : 0;
89
92
  }
93
+ // Simple string format — only if no specific currency requested, or currency matches default
94
+ if (currency) return 0;
90
95
  return parseInt(entry.amount, 10) || 0;
91
96
  }
92
97
 
@@ -95,13 +100,34 @@ export function getBalance(ledger, uri) {
95
100
  * @param {object} ledger - WebLedger object
96
101
  * @param {string} uri - Agent URI
97
102
  * @param {number} amount - New balance
103
+ * @param {string} [currency] - Currency code. If provided, uses array amount format.
98
104
  */
99
- export function setBalance(ledger, uri, amount) {
100
- const entry = ledger.entries.find(e => e.url === uri);
101
- if (entry) {
102
- entry.amount = String(amount);
105
+ export function setBalance(ledger, uri, amount, currency) {
106
+ let entry = ledger.entries.find(e => e.url === uri);
107
+ if (!currency) {
108
+ // Simple string format (backward compatible)
109
+ if (entry) {
110
+ entry.amount = String(amount);
111
+ } else {
112
+ ledger.entries.push({ type: 'Entry', url: uri, amount: String(amount) });
113
+ }
114
+ return;
115
+ }
116
+ // Multi-currency: use array format
117
+ if (!entry) {
118
+ entry = { type: 'Entry', url: uri, amount: [] };
119
+ ledger.entries.push(entry);
120
+ }
121
+ // Migrate simple string to array if needed
122
+ if (!Array.isArray(entry.amount)) {
123
+ const oldVal = parseInt(entry.amount, 10) || 0;
124
+ entry.amount = oldVal > 0 ? [{ currency: 'satoshi', value: String(oldVal) }] : [];
125
+ }
126
+ const existing = entry.amount.find(a => a.currency === currency);
127
+ if (existing) {
128
+ existing.value = String(amount);
103
129
  } else {
104
- ledger.entries.push({ type: 'Entry', url: uri, amount: String(amount) });
130
+ entry.amount.push({ currency, value: String(amount) });
105
131
  }
106
132
  }
107
133
 
@@ -110,12 +136,13 @@ export function setBalance(ledger, uri, amount) {
110
136
  * @param {object} ledger - WebLedger object
111
137
  * @param {string} uri - Agent URI
112
138
  * @param {number} amount - Amount to add
139
+ * @param {string} [currency] - Currency code
113
140
  * @returns {number} New balance
114
141
  */
115
- export function credit(ledger, uri, amount) {
116
- const current = getBalance(ledger, uri);
142
+ export function credit(ledger, uri, amount, currency) {
143
+ const current = getBalance(ledger, uri, currency);
117
144
  const newBalance = current + amount;
118
- setBalance(ledger, uri, newBalance);
145
+ setBalance(ledger, uri, newBalance, currency);
119
146
  return newBalance;
120
147
  }
121
148
 
@@ -124,15 +151,16 @@ export function credit(ledger, uri, amount) {
124
151
  * @param {object} ledger - WebLedger object
125
152
  * @param {string} uri - Agent URI
126
153
  * @param {number} amount - Amount to subtract
154
+ * @param {string} [currency] - Currency code
127
155
  * @returns {{success: boolean, balance: number}} Result
128
156
  */
129
- export function debit(ledger, uri, amount) {
130
- const current = getBalance(ledger, uri);
157
+ export function debit(ledger, uri, amount, currency) {
158
+ const current = getBalance(ledger, uri, currency);
131
159
  if (current < amount) {
132
160
  return { success: false, balance: current };
133
161
  }
134
162
  const newBalance = current - amount;
135
- setBalance(ledger, uri, newBalance);
163
+ setBalance(ledger, uri, newBalance, currency);
136
164
  return { success: true, balance: newBalance };
137
165
  }
138
166
 
package/test/pay.test.js CHANGED
@@ -205,6 +205,492 @@ describe('HTTP 402 Pay Middleware', () => {
205
205
  });
206
206
  });
207
207
 
208
+ describe('GET /pay/.info', () => {
209
+ it('should return info without auth', async () => {
210
+ const res = await fetch(`${getBaseUrl()}/pay/.info`);
211
+ assertStatus(res, 200);
212
+ const body = await res.json();
213
+ assert.strictEqual(body.cost, 10);
214
+ assert.strictEqual(body.unit, 'sat');
215
+ assert.strictEqual(body.deposit, '/pay/.deposit');
216
+ assert.strictEqual(body.balance, '/pay/.balance');
217
+ });
218
+
219
+ it('should not include token info when payToken not configured', async () => {
220
+ const res = await fetch(`${getBaseUrl()}/pay/.info`);
221
+ const body = await res.json();
222
+ assert.strictEqual(body.token, undefined);
223
+ });
224
+ });
225
+
226
+ describe('POST /pay/.buy', () => {
227
+ it('should return 401 without auth', async () => {
228
+ const url = `${getBaseUrl()}/pay/.buy`;
229
+ const res = await fetch(url, { method: 'POST', body: '{"amount":10}' });
230
+ assertStatus(res, 401);
231
+ });
232
+
233
+ it('should return 400 when payToken not configured', async () => {
234
+ const url = `${getBaseUrl()}/pay/.buy`;
235
+ const res = await fetch(url, {
236
+ method: 'POST',
237
+ headers: {
238
+ 'Authorization': createNip98Header(url, 'POST'),
239
+ 'Content-Type': 'application/json'
240
+ },
241
+ body: JSON.stringify({ amount: 10 })
242
+ });
243
+ assertStatus(res, 400);
244
+ const body = await res.json();
245
+ assert.ok(body.error.includes('not configured'));
246
+ });
247
+ });
248
+
249
+ describe('POST /pay/.withdraw', () => {
250
+ it('should return 401 without auth', async () => {
251
+ const url = `${getBaseUrl()}/pay/.withdraw`;
252
+ const res = await fetch(url, { method: 'POST', body: '{"all":true}' });
253
+ assertStatus(res, 401);
254
+ });
255
+
256
+ it('should return 400 when payToken not configured', async () => {
257
+ const url = `${getBaseUrl()}/pay/.withdraw`;
258
+ const res = await fetch(url, {
259
+ method: 'POST',
260
+ headers: {
261
+ 'Authorization': createNip98Header(url, 'POST'),
262
+ 'Content-Type': 'application/json'
263
+ },
264
+ body: JSON.stringify({ all: true })
265
+ });
266
+ assertStatus(res, 400);
267
+ const body = await res.json();
268
+ assert.ok(body.error.includes('not configured'));
269
+ });
270
+ });
271
+
272
+ describe('Pay with token configured', () => {
273
+ let tokenServer;
274
+ let tokenUrl;
275
+ const tokenPrivkey = crypto.randomBytes(32);
276
+ const tokenPubkey = Buffer.from(schnorr.getPublicKey(tokenPrivkey)).toString('hex');
277
+
278
+ function tokenNip98(url, method = 'GET') {
279
+ const event = {
280
+ pubkey: tokenPubkey,
281
+ created_at: Math.floor(Date.now() / 1000),
282
+ kind: 27235,
283
+ tags: [['u', url], ['method', method]],
284
+ content: ''
285
+ };
286
+ const serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
287
+ event.id = crypto.createHash('sha256').update(serialized).digest('hex');
288
+ event.sig = Buffer.from(schnorr.sign(event.id, tokenPrivkey)).toString('hex');
289
+ return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
290
+ }
291
+
292
+ before(async () => {
293
+ const { createServer } = await import('../src/server.js');
294
+ tokenServer = createServer({
295
+ logger: false,
296
+ forceCloseConnections: true,
297
+ pay: true,
298
+ payCost: 5,
299
+ payAddress: 'test-addr',
300
+ payToken: 'TEST',
301
+ payRate: 10
302
+ });
303
+ await tokenServer.listen({ port: 0, host: '127.0.0.1' });
304
+ const addr = tokenServer.server.address();
305
+ tokenUrl = `http://127.0.0.1:${addr.port}`;
306
+ });
307
+
308
+ after(async () => {
309
+ if (tokenServer) await tokenServer.close();
310
+ });
311
+
312
+ it('GET /pay/.info should include token info', async () => {
313
+ const res = await fetch(`${tokenUrl}/pay/.info`);
314
+ assertStatus(res, 200);
315
+ const body = await res.json();
316
+ assert.strictEqual(body.cost, 5);
317
+ assert.strictEqual(body.token.ticker, 'TEST');
318
+ assert.strictEqual(body.token.rate, 10);
319
+ assert.strictEqual(body.token.buy, '/pay/.buy');
320
+ assert.strictEqual(body.token.withdraw, '/pay/.withdraw');
321
+ });
322
+
323
+ it('POST /pay/.buy should return 402 with zero balance', async () => {
324
+ const url = `${tokenUrl}/pay/.buy`;
325
+ const res = await fetch(url, {
326
+ method: 'POST',
327
+ headers: {
328
+ 'Authorization': tokenNip98(url, 'POST'),
329
+ 'Content-Type': 'application/json'
330
+ },
331
+ body: JSON.stringify({ amount: 10 })
332
+ });
333
+ assertStatus(res, 402);
334
+ const body = await res.json();
335
+ assert.strictEqual(body.error, 'Insufficient sat balance');
336
+ assert.strictEqual(body.balance, 0);
337
+ assert.strictEqual(body.cost, 100); // 10 tokens * rate 10
338
+ assert.strictEqual(body.rate, 10);
339
+ });
340
+
341
+ it('POST /pay/.buy should reject wrong ticker', async () => {
342
+ const url = `${tokenUrl}/pay/.buy`;
343
+ const res = await fetch(url, {
344
+ method: 'POST',
345
+ headers: {
346
+ 'Authorization': tokenNip98(url, 'POST'),
347
+ 'Content-Type': 'application/json'
348
+ },
349
+ body: JSON.stringify({ ticker: 'WRONG', amount: 10 })
350
+ });
351
+ assertStatus(res, 400);
352
+ const body = await res.json();
353
+ assert.ok(body.error.includes('only sells TEST'));
354
+ });
355
+
356
+ it('POST /pay/.buy should reject malformed JSON', async () => {
357
+ const url = `${tokenUrl}/pay/.buy`;
358
+ const res = await fetch(url, {
359
+ method: 'POST',
360
+ headers: {
361
+ 'Authorization': tokenNip98(url, 'POST'),
362
+ 'Content-Type': 'application/json'
363
+ },
364
+ body: '{not valid json'
365
+ });
366
+ assertStatus(res, 400);
367
+ });
368
+
369
+ it('POST /pay/.buy should reject missing amount', async () => {
370
+ const url = `${tokenUrl}/pay/.buy`;
371
+ const res = await fetch(url, {
372
+ method: 'POST',
373
+ headers: {
374
+ 'Authorization': tokenNip98(url, 'POST'),
375
+ 'Content-Type': 'application/json'
376
+ },
377
+ body: JSON.stringify({})
378
+ });
379
+ assertStatus(res, 400);
380
+ const body = await res.json();
381
+ assert.ok(body.error.includes('Specify'));
382
+ });
383
+
384
+ it('POST /pay/.withdraw should return 400 with zero balance and all:true', async () => {
385
+ const url = `${tokenUrl}/pay/.withdraw`;
386
+ const res = await fetch(url, {
387
+ method: 'POST',
388
+ headers: {
389
+ 'Authorization': tokenNip98(url, 'POST'),
390
+ 'Content-Type': 'application/json'
391
+ },
392
+ body: JSON.stringify({ all: true })
393
+ });
394
+ assertStatus(res, 400);
395
+ const body = await res.json();
396
+ assert.ok(body.error.includes('Nothing to withdraw'));
397
+ });
398
+
399
+ it('POST /pay/.withdraw should return 402 when balance insufficient', async () => {
400
+ const url = `${tokenUrl}/pay/.withdraw`;
401
+ const res = await fetch(url, {
402
+ method: 'POST',
403
+ headers: {
404
+ 'Authorization': tokenNip98(url, 'POST'),
405
+ 'Content-Type': 'application/json'
406
+ },
407
+ body: JSON.stringify({ tokens: 1000 })
408
+ });
409
+ assertStatus(res, 402);
410
+ const body = await res.json();
411
+ assert.strictEqual(body.error, 'Insufficient balance');
412
+ });
413
+
414
+ it('POST /pay/.withdraw should reject malformed JSON', async () => {
415
+ const url = `${tokenUrl}/pay/.withdraw`;
416
+ const res = await fetch(url, {
417
+ method: 'POST',
418
+ headers: {
419
+ 'Authorization': tokenNip98(url, 'POST'),
420
+ 'Content-Type': 'application/json'
421
+ },
422
+ body: '{bad json'
423
+ });
424
+ assertStatus(res, 400);
425
+ });
426
+
427
+ it('POST /pay/.withdraw should reject missing params', async () => {
428
+ const url = `${tokenUrl}/pay/.withdraw`;
429
+ const res = await fetch(url, {
430
+ method: 'POST',
431
+ headers: {
432
+ 'Authorization': tokenNip98(url, 'POST'),
433
+ 'Content-Type': 'application/json'
434
+ },
435
+ body: JSON.stringify({})
436
+ });
437
+ assertStatus(res, 400);
438
+ const body = await res.json();
439
+ assert.ok(body.error.includes('Specify'));
440
+ });
441
+
442
+ it('POST /pay/.sell should reject missing amount/price', async () => {
443
+ const url = `${tokenUrl}/pay/.sell`;
444
+ const res = await fetch(url, {
445
+ method: 'POST',
446
+ headers: {
447
+ 'Authorization': tokenNip98(url, 'POST'),
448
+ 'Content-Type': 'application/json'
449
+ },
450
+ body: JSON.stringify({})
451
+ });
452
+ assertStatus(res, 400);
453
+ const body = await res.json();
454
+ assert.ok(body.error.includes('Specify'));
455
+ });
456
+
457
+ it('POST /pay/.swap should reject missing offer id', async () => {
458
+ const url = `${tokenUrl}/pay/.swap`;
459
+ const res = await fetch(url, {
460
+ method: 'POST',
461
+ headers: {
462
+ 'Authorization': tokenNip98(url, 'POST'),
463
+ 'Content-Type': 'application/json'
464
+ },
465
+ body: JSON.stringify({})
466
+ });
467
+ assertStatus(res, 400);
468
+ const body = await res.json();
469
+ assert.ok(body.error.includes('Specify offer id'));
470
+ });
471
+
472
+ it('POST /pay/.swap should return 404 for unknown offer', async () => {
473
+ const url = `${tokenUrl}/pay/.swap`;
474
+ const res = await fetch(url, {
475
+ method: 'POST',
476
+ headers: {
477
+ 'Authorization': tokenNip98(url, 'POST'),
478
+ 'Content-Type': 'application/json'
479
+ },
480
+ body: JSON.stringify({ id: 'nonexistent' })
481
+ });
482
+ assertStatus(res, 404);
483
+ });
484
+
485
+ it('GET /pay/.offers should return empty list', async () => {
486
+ const res = await fetch(`${tokenUrl}/pay/.offers`);
487
+ assertStatus(res, 200);
488
+ const body = await res.json();
489
+ assert.ok(Array.isArray(body));
490
+ });
491
+ });
492
+
493
+ describe('GET /pay/.offers', () => {
494
+ it('should return empty list without auth', async () => {
495
+ const res = await fetch(`${getBaseUrl()}/pay/.offers`);
496
+ assertStatus(res, 200);
497
+ const body = await res.json();
498
+ assert.ok(Array.isArray(body));
499
+ assert.strictEqual(body.length, 0);
500
+ });
501
+ });
502
+
503
+ describe('POST /pay/.sell', () => {
504
+ it('should return 401 without auth', async () => {
505
+ const url = `${getBaseUrl()}/pay/.sell`;
506
+ const res = await fetch(url, { method: 'POST', body: '{}' });
507
+ assertStatus(res, 401);
508
+ });
509
+
510
+ it('should return 400 when payToken not configured', async () => {
511
+ const url = `${getBaseUrl()}/pay/.sell`;
512
+ const res = await fetch(url, {
513
+ method: 'POST',
514
+ headers: {
515
+ 'Authorization': createNip98Header(url, 'POST'),
516
+ 'Content-Type': 'application/json'
517
+ },
518
+ body: JSON.stringify({ amount: 10, price: 100 })
519
+ });
520
+ assertStatus(res, 400);
521
+ const body = await res.json();
522
+ assert.ok(body.error.includes('not configured'));
523
+ });
524
+ });
525
+
526
+ describe('POST /pay/.swap', () => {
527
+ it('should return 401 without auth', async () => {
528
+ const url = `${getBaseUrl()}/pay/.swap`;
529
+ const res = await fetch(url, { method: 'POST', body: '{}' });
530
+ assertStatus(res, 401);
531
+ });
532
+
533
+ it('should return 400 when payToken not configured', async () => {
534
+ const url = `${getBaseUrl()}/pay/.swap`;
535
+ const res = await fetch(url, {
536
+ method: 'POST',
537
+ headers: {
538
+ 'Authorization': createNip98Header(url, 'POST'),
539
+ 'Content-Type': 'application/json'
540
+ },
541
+ body: JSON.stringify({ id: 'test-id' })
542
+ });
543
+ assertStatus(res, 400);
544
+ const body = await res.json();
545
+ assert.ok(body.error.includes('not configured'));
546
+ });
547
+ });
548
+
549
+ describe('AMM with multi-chain', () => {
550
+ let ammServer;
551
+ let ammUrl;
552
+ const ammPrivkey = crypto.randomBytes(32);
553
+ const ammPubkey = Buffer.from(schnorr.getPublicKey(ammPrivkey)).toString('hex');
554
+ const ammPrivkey2 = crypto.randomBytes(32);
555
+ const ammPubkey2 = Buffer.from(schnorr.getPublicKey(ammPrivkey2)).toString('hex');
556
+
557
+ function ammNip98(pk, url, method = 'GET') {
558
+ const event = {
559
+ pubkey: Buffer.from(schnorr.getPublicKey(pk)).toString('hex'),
560
+ created_at: Math.floor(Date.now() / 1000),
561
+ kind: 27235,
562
+ tags: [['u', url], ['method', method]],
563
+ content: ''
564
+ };
565
+ const serialized = JSON.stringify([0, event.pubkey, event.created_at, event.kind, event.tags, event.content]);
566
+ event.id = crypto.createHash('sha256').update(serialized).digest('hex');
567
+ event.sig = Buffer.from(schnorr.sign(event.id, pk)).toString('hex');
568
+ return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
569
+ }
570
+
571
+ before(async () => {
572
+ const { createServer } = await import('../src/server.js');
573
+ ammServer = createServer({
574
+ logger: false,
575
+ forceCloseConnections: true,
576
+ pay: true,
577
+ payCost: 1,
578
+ payChains: 'tbtc3,tbtc4'
579
+ });
580
+ await ammServer.listen({ port: 0, host: '127.0.0.1' });
581
+ const addr = ammServer.server.address();
582
+ ammUrl = `http://127.0.0.1:${addr.port}`;
583
+ });
584
+
585
+ after(async () => {
586
+ if (ammServer) await ammServer.close();
587
+ });
588
+
589
+ it('GET /pay/.info should include chains and pool', async () => {
590
+ const res = await fetch(`${ammUrl}/pay/.info`);
591
+ assertStatus(res, 200);
592
+ const body = await res.json();
593
+ assert.ok(body.chains);
594
+ assert.strictEqual(body.chains.length, 2);
595
+ assert.strictEqual(body.chains[0].id, 'tbtc3');
596
+ assert.strictEqual(body.chains[1].id, 'tbtc4');
597
+ assert.strictEqual(body.pool, '/pay/.pool');
598
+ });
599
+
600
+ it('GET /pay/.pool should return empty pool', async () => {
601
+ const res = await fetch(`${ammUrl}/pay/.pool`);
602
+ assertStatus(res, 200);
603
+ const body = await res.json();
604
+ assert.strictEqual(body.reserves.tbtc3, 0);
605
+ assert.strictEqual(body.reserves.tbtc4, 0);
606
+ assert.strictEqual(body.k, 0);
607
+ assert.strictEqual(body.totalShares, 0);
608
+ });
609
+
610
+ it('GET /pay/.balance should include per-chain balances', async () => {
611
+ const url = `${ammUrl}/pay/.balance`;
612
+ const res = await fetch(url, {
613
+ headers: { 'Authorization': ammNip98(ammPrivkey, url) }
614
+ });
615
+ assertStatus(res, 200);
616
+ const body = await res.json();
617
+ assert.ok(body.balances);
618
+ assert.strictEqual(body.balances.tbtc3, 0);
619
+ assert.strictEqual(body.balances.tbtc4, 0);
620
+ });
621
+
622
+ it('POST /pay/.pool swap should fail with no liquidity', async () => {
623
+ const url = `${ammUrl}/pay/.pool`;
624
+ const res = await fetch(url, {
625
+ method: 'POST',
626
+ headers: {
627
+ 'Authorization': ammNip98(ammPrivkey, url, 'POST'),
628
+ 'Content-Type': 'application/json'
629
+ },
630
+ body: JSON.stringify({ action: 'swap', sell: 'tbtc3', amount: 100 })
631
+ });
632
+ assertStatus(res, 400);
633
+ const body = await res.json();
634
+ assert.ok(body.error.includes('no liquidity'));
635
+ });
636
+
637
+ it('POST /pay/.pool add-liquidity should fail with zero balance', async () => {
638
+ const url = `${ammUrl}/pay/.pool`;
639
+ const res = await fetch(url, {
640
+ method: 'POST',
641
+ headers: {
642
+ 'Authorization': ammNip98(ammPrivkey, url, 'POST'),
643
+ 'Content-Type': 'application/json'
644
+ },
645
+ body: JSON.stringify({ action: 'add-liquidity', tbtc3: 1000, tbtc4: 5000 })
646
+ });
647
+ assertStatus(res, 402);
648
+ });
649
+
650
+ it('POST /pay/.pool should reject unknown action', async () => {
651
+ const url = `${ammUrl}/pay/.pool`;
652
+ const res = await fetch(url, {
653
+ method: 'POST',
654
+ headers: {
655
+ 'Authorization': ammNip98(ammPrivkey, url, 'POST'),
656
+ 'Content-Type': 'application/json'
657
+ },
658
+ body: JSON.stringify({ action: 'invalid' })
659
+ });
660
+ assertStatus(res, 400);
661
+ const body = await res.json();
662
+ assert.ok(body.error.includes('Unknown action'));
663
+ });
664
+
665
+ it('POST /pay/.pool swap should reject invalid sell unit', async () => {
666
+ const url = `${ammUrl}/pay/.pool`;
667
+ const res = await fetch(url, {
668
+ method: 'POST',
669
+ headers: {
670
+ 'Authorization': ammNip98(ammPrivkey, url, 'POST'),
671
+ 'Content-Type': 'application/json'
672
+ },
673
+ body: JSON.stringify({ action: 'swap', sell: 'invalid', amount: 100 })
674
+ });
675
+ assertStatus(res, 400);
676
+ });
677
+
678
+ it('POST /pay/.pool remove-liquidity should fail with no pool', async () => {
679
+ const url = `${ammUrl}/pay/.pool`;
680
+ const res = await fetch(url, {
681
+ method: 'POST',
682
+ headers: {
683
+ 'Authorization': ammNip98(ammPrivkey, url, 'POST'),
684
+ 'Content-Type': 'application/json'
685
+ },
686
+ body: JSON.stringify({ action: 'remove-liquidity', shares: 10 })
687
+ });
688
+ assertStatus(res, 400);
689
+ const body = await res.json();
690
+ assert.ok(body.error.includes('no liquidity'));
691
+ });
692
+ });
693
+
208
694
  describe('Pay disabled', () => {
209
695
  let noPayServer;
210
696
  let noPayUrl;
@@ -152,6 +152,68 @@ describe('Web Ledger', () => {
152
152
  });
153
153
  });
154
154
 
155
+ describe('multi-currency', () => {
156
+ it('should credit and debit with specific currency', () => {
157
+ const ledger = createLedger();
158
+ const bal = credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
159
+ assert.strictEqual(bal, 1000);
160
+ assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc3'), 1000);
161
+ // Default balance should be 0 (no satoshi credits)
162
+ assert.strictEqual(getBalance(ledger, 'did:nostr:user1'), 0);
163
+ });
164
+
165
+ it('should track multiple currencies independently', () => {
166
+ const ledger = createLedger();
167
+ credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
168
+ credit(ledger, 'did:nostr:user1', 5000, 'tbtc4');
169
+ assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc3'), 1000);
170
+ assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc4'), 5000);
171
+ });
172
+
173
+ it('should debit specific currency', () => {
174
+ const ledger = createLedger();
175
+ credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
176
+ credit(ledger, 'did:nostr:user1', 5000, 'tbtc4');
177
+ const result = debit(ledger, 'did:nostr:user1', 300, 'tbtc3');
178
+ assert.strictEqual(result.success, true);
179
+ assert.strictEqual(result.balance, 700);
180
+ // tbtc4 unchanged
181
+ assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc4'), 5000);
182
+ });
183
+
184
+ it('should fail debit when currency balance insufficient', () => {
185
+ const ledger = createLedger();
186
+ credit(ledger, 'did:nostr:user1', 100, 'tbtc3');
187
+ const result = debit(ledger, 'did:nostr:user1', 200, 'tbtc3');
188
+ assert.strictEqual(result.success, false);
189
+ assert.strictEqual(result.balance, 100);
190
+ });
191
+
192
+ it('should migrate simple string to array on currency credit', () => {
193
+ const ledger = createLedger();
194
+ // First set a simple balance
195
+ setBalance(ledger, 'did:nostr:user1', 500);
196
+ assert.strictEqual(getBalance(ledger, 'did:nostr:user1'), 500);
197
+ // Now add a currency-specific balance — should migrate to array
198
+ credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
199
+ assert.strictEqual(getBalance(ledger, 'did:nostr:user1', 'tbtc3'), 1000);
200
+ // Old satoshi balance should be preserved in array
201
+ const entry = ledger.entries.find(e => e.url === 'did:nostr:user1');
202
+ assert.ok(Array.isArray(entry.amount));
203
+ const satEntry = entry.amount.find(a => a.currency === 'satoshi');
204
+ assert.strictEqual(parseInt(satEntry.value), 500);
205
+ });
206
+
207
+ it('should use array format in entries', () => {
208
+ const ledger = createLedger();
209
+ credit(ledger, 'did:nostr:user1', 1000, 'tbtc3');
210
+ const entry = ledger.entries.find(e => e.url === 'did:nostr:user1');
211
+ assert.ok(Array.isArray(entry.amount));
212
+ assert.strictEqual(entry.amount[0].currency, 'tbtc3');
213
+ assert.strictEqual(entry.amount[0].value, '1000');
214
+ });
215
+ });
216
+
155
217
  describe('URI format support', () => {
156
218
  it('should work with did:nostr URIs', () => {
157
219
  const ledger = createLedger();