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/PAY.md +364 -0
- package/README.md +40 -1
- package/bin/jss.js +2 -0
- package/package.json +2 -1
- package/src/config.js +2 -0
- package/src/handlers/pay.js +495 -9
- package/src/server.js +2 -1
- package/src/token.js +8 -8
- package/src/webledger.js +43 -15
- package/test/pay.test.js +486 -0
- package/test/webledger.test.js +62 -0
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
|
|
86
|
+
// Handle array amount format
|
|
86
87
|
if (Array.isArray(entry.amount)) {
|
|
87
|
-
const
|
|
88
|
-
|
|
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
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
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
|
-
|
|
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;
|
package/test/webledger.test.js
CHANGED
|
@@ -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();
|