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/handlers/pay.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* POST /pay/.deposit — deposit sats (TXO URI) or tokens (MRC20 state proof)
|
|
11
11
|
* POST /pay/.buy — buy tokens with sat balance (primary market)
|
|
12
12
|
* POST /pay/.withdraw — withdraw balance as tokens (portable MRC20 proof)
|
|
13
|
+
* GET /pay/.offers — list open sell orders (secondary market)
|
|
14
|
+
* POST /pay/.sell — create a sell order (NIP-69 kind 38383)
|
|
15
|
+
* POST /pay/.swap — execute a swap against a sell order
|
|
13
16
|
* GET /pay/* — paid resource access (requires balance >= cost)
|
|
14
17
|
* PUT /pay/* — upload resources (standard auth)
|
|
15
18
|
*
|
|
@@ -22,6 +25,7 @@
|
|
|
22
25
|
* - MRC20 profile: https://blocktrails.org/
|
|
23
26
|
*/
|
|
24
27
|
|
|
28
|
+
import crypto from 'crypto';
|
|
25
29
|
import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
|
|
26
30
|
import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
|
|
27
31
|
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, sha256Hex } from '../mrc20.js';
|
|
@@ -31,6 +35,40 @@ import path from 'path';
|
|
|
31
35
|
|
|
32
36
|
const DEFAULT_COST = 1; // satoshis per request
|
|
33
37
|
|
|
38
|
+
// --- Chain registry for multi-chain deposits ---
|
|
39
|
+
const CHAIN_REGISTRY = {
|
|
40
|
+
tbtc3: { explorer: 'https://mempool.space/testnet/api', unit: 'tbtc3', name: 'Bitcoin Testnet3' },
|
|
41
|
+
tbtc4: { explorer: 'https://mempool.space/testnet4/api', unit: 'tbtc4', name: 'Bitcoin Testnet4' },
|
|
42
|
+
btc: { explorer: 'https://mempool.space/api', unit: 'sat', name: 'Bitcoin' },
|
|
43
|
+
ltc: { explorer: 'https://litecoinspace.org/api', unit: 'ltc', name: 'Litecoin' },
|
|
44
|
+
signet:{ explorer: 'https://mempool.space/signet/api', unit: 'signet', name: 'Bitcoin Signet' },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse chain ID from a TXO URI body string.
|
|
49
|
+
* Supports: "txo:tbtc3:txid:vout", "txo:btc:txid:vout", or bare "txid:vout" (returns null).
|
|
50
|
+
*/
|
|
51
|
+
function parseTxoChain(body) {
|
|
52
|
+
const str = typeof body === 'string' ? body.trim() : '';
|
|
53
|
+
const match = str.match(/^txo:([a-z0-9]+):/i);
|
|
54
|
+
return match ? match[1].toLowerCase() : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- AMM pool storage ---
|
|
58
|
+
const poolFile = () => path.join(process.env.DATA_ROOT || './data', '.well-known/webledgers/pool.json');
|
|
59
|
+
|
|
60
|
+
async function loadPool() {
|
|
61
|
+
try {
|
|
62
|
+
const data = await fs.readFile(poolFile(), 'utf8');
|
|
63
|
+
return JSON.parse(data);
|
|
64
|
+
} catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function savePool(pool) {
|
|
68
|
+
await fs.ensureDir(path.dirname(poolFile()));
|
|
69
|
+
await fs.writeFile(poolFile(), JSON.stringify(pool, null, 2));
|
|
70
|
+
}
|
|
71
|
+
|
|
34
72
|
// --- Replay protection ---
|
|
35
73
|
const replayFile = () => path.join(process.env.DATA_ROOT || './data', '.well-known/webledgers/replay.json');
|
|
36
74
|
|
|
@@ -54,6 +92,21 @@ async function checkAndRecordState(stateHash) {
|
|
|
54
92
|
return true;
|
|
55
93
|
}
|
|
56
94
|
|
|
95
|
+
// --- Offers storage (secondary market) ---
|
|
96
|
+
const offersFile = () => path.join(process.env.DATA_ROOT || './data', '.well-known/webledgers/offers.json');
|
|
97
|
+
|
|
98
|
+
async function loadOffers() {
|
|
99
|
+
try {
|
|
100
|
+
const data = await fs.readFile(offersFile(), 'utf8');
|
|
101
|
+
return JSON.parse(data);
|
|
102
|
+
} catch { return []; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function saveOffers(offers) {
|
|
106
|
+
await fs.ensureDir(path.dirname(offersFile()));
|
|
107
|
+
await fs.writeFile(offersFile(), JSON.stringify(offers, null, 2));
|
|
108
|
+
}
|
|
109
|
+
|
|
57
110
|
// --- Deposit verification via mempool API ---
|
|
58
111
|
|
|
59
112
|
async function verifySatsDeposit(txoUri, mempoolUrl) {
|
|
@@ -154,6 +207,11 @@ export function createPayHandler(options = {}) {
|
|
|
154
207
|
const payToken = options.payToken ?? null;
|
|
155
208
|
const payRate = options.payRate ?? 1;
|
|
156
209
|
|
|
210
|
+
// Parse multi-chain config: "tbtc3,tbtc4" → ['tbtc3', 'tbtc4']
|
|
211
|
+
const payChains = options.payChains
|
|
212
|
+
? options.payChains.split(',').map(c => c.trim()).filter(c => CHAIN_REGISTRY[c])
|
|
213
|
+
: null;
|
|
214
|
+
|
|
157
215
|
return async function payHandler(request, reply) {
|
|
158
216
|
const url = request.url.split('?')[0];
|
|
159
217
|
if (!isPayRequest(request.url)) return;
|
|
@@ -179,6 +237,10 @@ export function createPayHandler(options = {}) {
|
|
|
179
237
|
info.token.issuer = trail.pubkeyBase ?? null;
|
|
180
238
|
}
|
|
181
239
|
}
|
|
240
|
+
if (payChains) {
|
|
241
|
+
info.chains = payChains.map(id => ({ id, unit: CHAIN_REGISTRY[id].unit, name: CHAIN_REGISTRY[id].name }));
|
|
242
|
+
info.pool = '/pay/.pool';
|
|
243
|
+
}
|
|
182
244
|
return reply.send(info);
|
|
183
245
|
}
|
|
184
246
|
|
|
@@ -190,12 +252,21 @@ export function createPayHandler(options = {}) {
|
|
|
190
252
|
}
|
|
191
253
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
192
254
|
const ledger = await readLedger();
|
|
193
|
-
|
|
255
|
+
const response = {
|
|
194
256
|
did: didUri,
|
|
195
257
|
balance: getBalance(ledger, didUri),
|
|
196
258
|
cost,
|
|
197
259
|
unit: 'sat'
|
|
198
|
-
}
|
|
260
|
+
};
|
|
261
|
+
// Include per-chain balances when multi-chain is enabled
|
|
262
|
+
if (payChains) {
|
|
263
|
+
response.balances = {};
|
|
264
|
+
for (const chainId of payChains) {
|
|
265
|
+
const unit = CHAIN_REGISTRY[chainId].unit;
|
|
266
|
+
response.balances[unit] = getBalance(ledger, didUri, unit);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return reply.send(response);
|
|
199
270
|
}
|
|
200
271
|
|
|
201
272
|
// --- POST /pay/.deposit ---
|
|
@@ -265,21 +336,37 @@ export function createPayHandler(options = {}) {
|
|
|
265
336
|
|
|
266
337
|
// --- Sats deposit (TXO URI) ---
|
|
267
338
|
if (deposit.type === 'sats') {
|
|
268
|
-
|
|
339
|
+
// Detect chain from TXO URI prefix (e.g. "txo:tbtc3:txid:vout")
|
|
340
|
+
const chainId = parseTxoChain(deposit.txo);
|
|
341
|
+
let depositMempoolUrl = mempoolUrl;
|
|
342
|
+
let currency = null; // null = default (simple string format)
|
|
343
|
+
|
|
344
|
+
if (chainId && payChains && payChains.includes(chainId)) {
|
|
345
|
+
const chain = CHAIN_REGISTRY[chainId];
|
|
346
|
+
depositMempoolUrl = chain.explorer.replace(/\/api$/, '');
|
|
347
|
+
currency = chain.unit;
|
|
348
|
+
} else if (chainId && payChains) {
|
|
349
|
+
return reply.code(400).send({
|
|
350
|
+
error: `Chain '${chainId}' not enabled. Enabled chains: ${payChains.join(', ')}`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const result = await verifySatsDeposit(deposit.txo, depositMempoolUrl);
|
|
269
355
|
if (!result.valid) {
|
|
270
356
|
return reply.code(400).send({ error: result.error });
|
|
271
357
|
}
|
|
272
358
|
|
|
273
359
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
274
360
|
const ledger = await readLedger();
|
|
275
|
-
const newBalance = credit(ledger, didUri, result.amount);
|
|
361
|
+
const newBalance = credit(ledger, didUri, result.amount, currency);
|
|
276
362
|
await writeLedger(ledger);
|
|
277
363
|
|
|
278
364
|
return reply.send({
|
|
279
365
|
did: didUri,
|
|
280
366
|
deposited: result.amount,
|
|
281
367
|
balance: newBalance,
|
|
282
|
-
unit: 'sat'
|
|
368
|
+
unit: currency || 'sat',
|
|
369
|
+
...(chainId ? { chain: chainId } : {})
|
|
283
370
|
});
|
|
284
371
|
}
|
|
285
372
|
|
|
@@ -305,8 +392,12 @@ export function createPayHandler(options = {}) {
|
|
|
305
392
|
|
|
306
393
|
// Parse buy request
|
|
307
394
|
let body = request.body;
|
|
308
|
-
|
|
309
|
-
|
|
395
|
+
try {
|
|
396
|
+
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
|
|
397
|
+
if (typeof body === 'string') body = JSON.parse(body);
|
|
398
|
+
} catch {
|
|
399
|
+
return reply.code(400).send({ error: 'Invalid JSON body' });
|
|
400
|
+
}
|
|
310
401
|
|
|
311
402
|
const ticker = body?.ticker || payToken;
|
|
312
403
|
if (ticker !== payToken) {
|
|
@@ -403,8 +494,12 @@ export function createPayHandler(options = {}) {
|
|
|
403
494
|
|
|
404
495
|
// Parse withdraw request
|
|
405
496
|
let body = request.body;
|
|
406
|
-
|
|
407
|
-
|
|
497
|
+
try {
|
|
498
|
+
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
|
|
499
|
+
if (typeof body === 'string') body = JSON.parse(body);
|
|
500
|
+
} catch {
|
|
501
|
+
return reply.code(400).send({ error: 'Invalid JSON body' });
|
|
502
|
+
}
|
|
408
503
|
|
|
409
504
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
410
505
|
const ledger = await readLedger();
|
|
@@ -486,6 +581,397 @@ export function createPayHandler(options = {}) {
|
|
|
486
581
|
});
|
|
487
582
|
}
|
|
488
583
|
|
|
584
|
+
// --- GET /pay/.offers — list open sell orders ---
|
|
585
|
+
if (url === '/pay/.offers' && request.method === 'GET') {
|
|
586
|
+
const offers = await loadOffers();
|
|
587
|
+
return reply.send(offers.filter(o => o.status === 'pending'));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// --- POST /pay/.sell — create a sell order ---
|
|
591
|
+
if (url === '/pay/.sell' && request.method === 'POST') {
|
|
592
|
+
const pubkey = await getNostrPubkey(request);
|
|
593
|
+
if (!pubkey) {
|
|
594
|
+
return reply.code(401).send({ error: 'NIP-98 authentication required' });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (!payToken) {
|
|
598
|
+
return reply.code(400).send({ error: 'Secondary market not configured (no --pay-token set)' });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let body = request.body;
|
|
602
|
+
try {
|
|
603
|
+
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
|
|
604
|
+
if (typeof body === 'string') body = JSON.parse(body);
|
|
605
|
+
} catch {
|
|
606
|
+
return reply.code(400).send({ error: 'Invalid JSON body' });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const amount = Math.floor(body?.amount || 0);
|
|
610
|
+
const price = Math.floor(body?.price || 0); // total sats for the lot
|
|
611
|
+
if (amount <= 0 || price <= 0) {
|
|
612
|
+
return reply.code(400).send({ error: 'Specify amount (tokens) and price (total sats)' });
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Verify seller has tokens on the trail
|
|
616
|
+
const trail = await loadTrail(payToken);
|
|
617
|
+
if (!trail) {
|
|
618
|
+
return reply.code(500).send({ error: `Token ${payToken} not minted on this pod` });
|
|
619
|
+
}
|
|
620
|
+
const currentState = trail.states[trail.states.length - 1];
|
|
621
|
+
const sellerBalance = currentState.balances[pubkey] || 0;
|
|
622
|
+
if (sellerBalance < amount) {
|
|
623
|
+
return reply.code(400).send({
|
|
624
|
+
error: 'Insufficient token balance on trail',
|
|
625
|
+
balance: sellerBalance,
|
|
626
|
+
amount
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const offer = {
|
|
631
|
+
id: crypto.randomUUID(),
|
|
632
|
+
seller: pubkey,
|
|
633
|
+
ticker: payToken,
|
|
634
|
+
amount,
|
|
635
|
+
price,
|
|
636
|
+
rate: Math.round(price / amount * 100) / 100,
|
|
637
|
+
status: 'pending',
|
|
638
|
+
created: Date.now()
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
const offers = await loadOffers();
|
|
642
|
+
offers.push(offer);
|
|
643
|
+
await saveOffers(offers);
|
|
644
|
+
|
|
645
|
+
return reply.send(offer);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// --- POST /pay/.swap — execute a swap against a sell order ---
|
|
649
|
+
if (url === '/pay/.swap' && request.method === 'POST') {
|
|
650
|
+
const pubkey = await getNostrPubkey(request);
|
|
651
|
+
if (!pubkey) {
|
|
652
|
+
return reply.code(401).send({ error: 'NIP-98 authentication required' });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (!payToken) {
|
|
656
|
+
return reply.code(400).send({ error: 'Secondary market not configured (no --pay-token set)' });
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
let body = request.body;
|
|
660
|
+
try {
|
|
661
|
+
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
|
|
662
|
+
if (typeof body === 'string') body = JSON.parse(body);
|
|
663
|
+
} catch {
|
|
664
|
+
return reply.code(400).send({ error: 'Invalid JSON body' });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const offerId = body?.id;
|
|
668
|
+
if (!offerId) {
|
|
669
|
+
return reply.code(400).send({ error: 'Specify offer id' });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Find the offer
|
|
673
|
+
const offers = await loadOffers();
|
|
674
|
+
const offer = offers.find(o => o.id === offerId && o.status === 'pending');
|
|
675
|
+
if (!offer) {
|
|
676
|
+
return reply.code(404).send({ error: 'Offer not found or already filled' });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Can't buy your own offer
|
|
680
|
+
if (offer.seller === pubkey) {
|
|
681
|
+
return reply.code(400).send({ error: 'Cannot swap with your own offer' });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Check buyer's sat balance
|
|
685
|
+
const didUri = pubkeyToDidNostr(pubkey);
|
|
686
|
+
const sellerDid = pubkeyToDidNostr(offer.seller);
|
|
687
|
+
const ledger = await readLedger();
|
|
688
|
+
const balance = getBalance(ledger, didUri);
|
|
689
|
+
if (balance < offer.price) {
|
|
690
|
+
return reply.code(402).send({
|
|
691
|
+
error: 'Insufficient sat balance',
|
|
692
|
+
balance,
|
|
693
|
+
cost: offer.price,
|
|
694
|
+
deposit: '/pay/.deposit'
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Transfer tokens from seller to buyer on the trail
|
|
699
|
+
let result;
|
|
700
|
+
try {
|
|
701
|
+
result = await transferToken({
|
|
702
|
+
ticker: payToken,
|
|
703
|
+
from: offer.seller,
|
|
704
|
+
to: pubkey,
|
|
705
|
+
amount: offer.amount,
|
|
706
|
+
mempoolUrl
|
|
707
|
+
});
|
|
708
|
+
} catch (err) {
|
|
709
|
+
return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Debit buyer, credit seller
|
|
713
|
+
debit(ledger, didUri, offer.price);
|
|
714
|
+
credit(ledger, sellerDid, offer.price);
|
|
715
|
+
await writeLedger(ledger);
|
|
716
|
+
|
|
717
|
+
// Mark offer as filled
|
|
718
|
+
offer.status = 'filled';
|
|
719
|
+
offer.buyer = pubkey;
|
|
720
|
+
offer.filledAt = Date.now();
|
|
721
|
+
offer.txid = result.txid;
|
|
722
|
+
await saveOffers(offers);
|
|
723
|
+
|
|
724
|
+
return reply.send({
|
|
725
|
+
swapped: offer.amount,
|
|
726
|
+
ticker: payToken,
|
|
727
|
+
cost: offer.price,
|
|
728
|
+
rate: offer.rate,
|
|
729
|
+
balance: getBalance(ledger, didUri),
|
|
730
|
+
sellerCredited: offer.price,
|
|
731
|
+
txid: result.txid,
|
|
732
|
+
proof: {
|
|
733
|
+
state: result.state,
|
|
734
|
+
prevState: result.prevState,
|
|
735
|
+
anchor: {
|
|
736
|
+
pubkey: result.trail.pubkeyBase,
|
|
737
|
+
stateStrings: result.trail.stateStrings,
|
|
738
|
+
network: result.trail.network
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// --- GET /pay/.pool — AMM pool state (public) ---
|
|
745
|
+
if (url === '/pay/.pool' && request.method === 'GET') {
|
|
746
|
+
if (!payChains || payChains.length < 2) {
|
|
747
|
+
return reply.code(400).send({ error: 'AMM not configured (requires --pay-chains with 2 chains)' });
|
|
748
|
+
}
|
|
749
|
+
const pool = await loadPool();
|
|
750
|
+
if (!pool) {
|
|
751
|
+
return reply.send({
|
|
752
|
+
pair: [CHAIN_REGISTRY[payChains[0]].unit, CHAIN_REGISTRY[payChains[1]].unit],
|
|
753
|
+
reserves: { [CHAIN_REGISTRY[payChains[0]].unit]: 0, [CHAIN_REGISTRY[payChains[1]].unit]: 0 },
|
|
754
|
+
k: 0,
|
|
755
|
+
fee: 0.003,
|
|
756
|
+
totalShares: 0,
|
|
757
|
+
lpShares: {}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
return reply.send(pool);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// --- POST /pay/.pool — AMM operations (swap, add-liquidity, remove-liquidity) ---
|
|
764
|
+
if (url === '/pay/.pool' && request.method === 'POST') {
|
|
765
|
+
if (!payChains || payChains.length < 2) {
|
|
766
|
+
return reply.code(400).send({ error: 'AMM not configured (requires --pay-chains with 2 chains)' });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const pubkey = await getNostrPubkey(request);
|
|
770
|
+
if (!pubkey) {
|
|
771
|
+
return reply.code(401).send({ error: 'NIP-98 authentication required' });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let body = request.body;
|
|
775
|
+
try {
|
|
776
|
+
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
|
|
777
|
+
if (typeof body === 'string') body = JSON.parse(body);
|
|
778
|
+
} catch {
|
|
779
|
+
return reply.code(400).send({ error: 'Invalid JSON body' });
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const didUri = pubkeyToDidNostr(pubkey);
|
|
783
|
+
const unitA = CHAIN_REGISTRY[payChains[0]].unit;
|
|
784
|
+
const unitB = CHAIN_REGISTRY[payChains[1]].unit;
|
|
785
|
+
const action = body?.action;
|
|
786
|
+
|
|
787
|
+
// --- ADD LIQUIDITY ---
|
|
788
|
+
if (action === 'add-liquidity') {
|
|
789
|
+
const amountA = Math.floor(body?.[unitA] || 0);
|
|
790
|
+
const amountB = Math.floor(body?.[unitB] || 0);
|
|
791
|
+
if (amountA <= 0 || amountB <= 0) {
|
|
792
|
+
return reply.code(400).send({ error: `Specify ${unitA} and ${unitB} amounts` });
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const ledger = await readLedger();
|
|
796
|
+
const balA = getBalance(ledger, didUri, unitA);
|
|
797
|
+
const balB = getBalance(ledger, didUri, unitB);
|
|
798
|
+
if (balA < amountA) {
|
|
799
|
+
return reply.code(402).send({ error: `Insufficient ${unitA} balance`, balance: balA, required: amountA });
|
|
800
|
+
}
|
|
801
|
+
if (balB < amountB) {
|
|
802
|
+
return reply.code(402).send({ error: `Insufficient ${unitB} balance`, balance: balB, required: amountB });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
let pool = await loadPool();
|
|
806
|
+
if (!pool) {
|
|
807
|
+
pool = {
|
|
808
|
+
pair: [unitA, unitB],
|
|
809
|
+
reserves: { [unitA]: 0, [unitB]: 0 },
|
|
810
|
+
k: 0,
|
|
811
|
+
fee: 0.003,
|
|
812
|
+
totalShares: 0,
|
|
813
|
+
lpShares: {}
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Calculate LP shares (initial: shares = sqrt(amountA * amountB))
|
|
818
|
+
let newShares;
|
|
819
|
+
if (pool.totalShares === 0) {
|
|
820
|
+
newShares = Math.floor(Math.sqrt(amountA * amountB));
|
|
821
|
+
} else {
|
|
822
|
+
// Proportional: min(amountA/reserveA, amountB/reserveB) * totalShares
|
|
823
|
+
const ratioA = amountA / pool.reserves[unitA];
|
|
824
|
+
const ratioB = amountB / pool.reserves[unitB];
|
|
825
|
+
newShares = Math.floor(Math.min(ratioA, ratioB) * pool.totalShares);
|
|
826
|
+
}
|
|
827
|
+
if (newShares <= 0) {
|
|
828
|
+
return reply.code(400).send({ error: 'Amounts too small to mint LP shares' });
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Debit user balances
|
|
832
|
+
debit(ledger, didUri, amountA, unitA);
|
|
833
|
+
debit(ledger, didUri, amountB, unitB);
|
|
834
|
+
await writeLedger(ledger);
|
|
835
|
+
|
|
836
|
+
// Update pool
|
|
837
|
+
pool.reserves[unitA] += amountA;
|
|
838
|
+
pool.reserves[unitB] += amountB;
|
|
839
|
+
pool.k = pool.reserves[unitA] * pool.reserves[unitB];
|
|
840
|
+
pool.totalShares += newShares;
|
|
841
|
+
pool.lpShares[didUri] = (pool.lpShares[didUri] || 0) + newShares;
|
|
842
|
+
await savePool(pool);
|
|
843
|
+
|
|
844
|
+
return reply.send({
|
|
845
|
+
action: 'add-liquidity',
|
|
846
|
+
deposited: { [unitA]: amountA, [unitB]: amountB },
|
|
847
|
+
shares: newShares,
|
|
848
|
+
totalShares: pool.totalShares,
|
|
849
|
+
reserves: pool.reserves,
|
|
850
|
+
k: pool.k
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// --- REMOVE LIQUIDITY ---
|
|
855
|
+
if (action === 'remove-liquidity') {
|
|
856
|
+
const shares = Math.floor(body?.shares || 0);
|
|
857
|
+
const pool = await loadPool();
|
|
858
|
+
if (!pool || pool.totalShares === 0) {
|
|
859
|
+
return reply.code(400).send({ error: 'Pool has no liquidity' });
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const userShares = pool.lpShares[didUri] || 0;
|
|
863
|
+
const toRemove = body?.all ? userShares : shares;
|
|
864
|
+
if (toRemove <= 0 || toRemove > userShares) {
|
|
865
|
+
return reply.code(400).send({ error: 'Invalid shares', yours: userShares });
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Calculate proportional withdrawal
|
|
869
|
+
const fraction = toRemove / pool.totalShares;
|
|
870
|
+
const outA = Math.floor(pool.reserves[unitA] * fraction);
|
|
871
|
+
const outB = Math.floor(pool.reserves[unitB] * fraction);
|
|
872
|
+
|
|
873
|
+
// Credit user
|
|
874
|
+
const ledger = await readLedger();
|
|
875
|
+
credit(ledger, didUri, outA, unitA);
|
|
876
|
+
credit(ledger, didUri, outB, unitB);
|
|
877
|
+
await writeLedger(ledger);
|
|
878
|
+
|
|
879
|
+
// Update pool
|
|
880
|
+
pool.reserves[unitA] -= outA;
|
|
881
|
+
pool.reserves[unitB] -= outB;
|
|
882
|
+
pool.k = pool.reserves[unitA] * pool.reserves[unitB];
|
|
883
|
+
pool.totalShares -= toRemove;
|
|
884
|
+
pool.lpShares[didUri] -= toRemove;
|
|
885
|
+
if (pool.lpShares[didUri] <= 0) delete pool.lpShares[didUri];
|
|
886
|
+
await savePool(pool);
|
|
887
|
+
|
|
888
|
+
return reply.send({
|
|
889
|
+
action: 'remove-liquidity',
|
|
890
|
+
withdrawn: { [unitA]: outA, [unitB]: outB },
|
|
891
|
+
sharesRemoved: toRemove,
|
|
892
|
+
totalShares: pool.totalShares,
|
|
893
|
+
reserves: pool.reserves
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// --- SWAP ---
|
|
898
|
+
if (action === 'swap') {
|
|
899
|
+
const sellUnit = body?.sell;
|
|
900
|
+
const amount = Math.floor(body?.amount || 0);
|
|
901
|
+
if (!sellUnit || ![unitA, unitB].includes(sellUnit)) {
|
|
902
|
+
return reply.code(400).send({ error: `Specify sell: "${unitA}" or "${unitB}"` });
|
|
903
|
+
}
|
|
904
|
+
if (amount <= 0) {
|
|
905
|
+
return reply.code(400).send({ error: 'Amount must be positive' });
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const pool = await loadPool();
|
|
909
|
+
if (!pool || pool.k === 0) {
|
|
910
|
+
return reply.code(400).send({ error: 'Pool has no liquidity' });
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const buyUnit = sellUnit === unitA ? unitB : unitA;
|
|
914
|
+
|
|
915
|
+
// Check user balance
|
|
916
|
+
const ledger = await readLedger();
|
|
917
|
+
const userBal = getBalance(ledger, didUri, sellUnit);
|
|
918
|
+
if (userBal < amount) {
|
|
919
|
+
return reply.code(402).send({
|
|
920
|
+
error: `Insufficient ${sellUnit} balance`,
|
|
921
|
+
balance: userBal,
|
|
922
|
+
required: amount,
|
|
923
|
+
deposit: '/pay/.deposit'
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Constant product: (reserveIn + amountIn * (1-fee)) * (reserveOut - amountOut) = k
|
|
928
|
+
const reserveIn = pool.reserves[sellUnit];
|
|
929
|
+
const reserveOut = pool.reserves[buyUnit];
|
|
930
|
+
const amountInAfterFee = amount * (1 - pool.fee);
|
|
931
|
+
const amountOut = Math.floor((reserveOut * amountInAfterFee) / (reserveIn + amountInAfterFee));
|
|
932
|
+
|
|
933
|
+
if (amountOut <= 0) {
|
|
934
|
+
return reply.code(400).send({ error: 'Trade too small' });
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Slippage protection
|
|
938
|
+
if (body?.minReceived && amountOut < body.minReceived) {
|
|
939
|
+
return reply.code(400).send({
|
|
940
|
+
error: 'Slippage exceeded',
|
|
941
|
+
wouldReceive: amountOut,
|
|
942
|
+
minReceived: body.minReceived
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Execute: debit sellUnit, credit buyUnit
|
|
947
|
+
debit(ledger, didUri, amount, sellUnit);
|
|
948
|
+
credit(ledger, didUri, amountOut, buyUnit);
|
|
949
|
+
await writeLedger(ledger);
|
|
950
|
+
|
|
951
|
+
// Update pool reserves
|
|
952
|
+
pool.reserves[sellUnit] += amount;
|
|
953
|
+
pool.reserves[buyUnit] -= amountOut;
|
|
954
|
+
pool.k = pool.reserves[unitA] * pool.reserves[unitB];
|
|
955
|
+
await savePool(pool);
|
|
956
|
+
|
|
957
|
+
const price = amount / amountOut;
|
|
958
|
+
return reply.send({
|
|
959
|
+
action: 'swap',
|
|
960
|
+
sold: { unit: sellUnit, amount },
|
|
961
|
+
bought: { unit: buyUnit, amount: amountOut },
|
|
962
|
+
price: Math.round(price * 10000) / 10000,
|
|
963
|
+
fee: Math.floor(amount * pool.fee),
|
|
964
|
+
reserves: pool.reserves,
|
|
965
|
+
balances: {
|
|
966
|
+
[sellUnit]: getBalance(ledger, didUri, sellUnit),
|
|
967
|
+
[buyUnit]: getBalance(ledger, didUri, buyUnit)
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return reply.code(400).send({ error: 'Unknown action. Use: swap, add-liquidity, remove-liquidity' });
|
|
973
|
+
}
|
|
974
|
+
|
|
489
975
|
// --- GET/HEAD /pay/* — paid resource access ---
|
|
490
976
|
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
491
977
|
const pubkey = await getNostrPubkey(request);
|
package/src/server.js
CHANGED
|
@@ -102,6 +102,7 @@ export function createServer(options = {}) {
|
|
|
102
102
|
const payAddress = options.payAddress ?? null; // Pod's MRC20 address for token deposits
|
|
103
103
|
const payToken = options.payToken ?? null; // Token ticker for primary market
|
|
104
104
|
const payRate = options.payRate ?? 1; // Sats per token
|
|
105
|
+
const payChains = options.payChains ?? null; // Multi-chain IDs (e.g. "tbtc3,tbtc4")
|
|
105
106
|
|
|
106
107
|
// Set data root via environment variable if provided
|
|
107
108
|
if (options.root) {
|
|
@@ -376,7 +377,7 @@ export function createServer(options = {}) {
|
|
|
376
377
|
|
|
377
378
|
// HTTP 402 Payment Required handler for /pay/* routes
|
|
378
379
|
if (payEnabled) {
|
|
379
|
-
fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate }));
|
|
380
|
+
fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate, payChains }));
|
|
380
381
|
}
|
|
381
382
|
|
|
382
383
|
// Authorization hook - check WAC permissions
|
package/src/token.js
CHANGED
|
@@ -306,7 +306,7 @@ export async function mintToken({ ticker, name, supply, voucher, mempoolUrl = 'h
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
// --- Transfer: send tokens to an address ---
|
|
309
|
-
export async function transferToken({ ticker, to, amount, mempoolUrl = 'https://mempool.space/testnet4' }) {
|
|
309
|
+
export async function transferToken({ ticker, from, to, amount, mempoolUrl = 'https://mempool.space/testnet4' }) {
|
|
310
310
|
const trail = await loadTrail(ticker);
|
|
311
311
|
if (!trail) throw new Error(`Token ${ticker} not found`);
|
|
312
312
|
|
|
@@ -317,15 +317,15 @@ export async function transferToken({ ticker, to, amount, mempoolUrl = 'https://
|
|
|
317
317
|
const currentState = trail.states[trail.states.length - 1];
|
|
318
318
|
const currentBalances = { ...currentState.balances };
|
|
319
319
|
|
|
320
|
-
// Check
|
|
321
|
-
const
|
|
322
|
-
const
|
|
323
|
-
if (
|
|
324
|
-
throw new Error(`Insufficient balance: ${
|
|
320
|
+
// Check sender balance (default: issuer)
|
|
321
|
+
const senderAddr = from || trail.pubkeyBase;
|
|
322
|
+
const senderBalance = currentBalances[senderAddr] || 0;
|
|
323
|
+
if (senderBalance < amount) {
|
|
324
|
+
throw new Error(`Insufficient balance: ${senderBalance} < ${amount}`);
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
// Create transfer state
|
|
328
|
-
currentBalances[
|
|
328
|
+
currentBalances[senderAddr] = senderBalance - amount;
|
|
329
329
|
currentBalances[to] = (currentBalances[to] || 0) + amount;
|
|
330
330
|
// Remove zero balances
|
|
331
331
|
for (const [k, v] of Object.entries(currentBalances)) {
|
|
@@ -342,7 +342,7 @@ export async function transferToken({ ticker, to, amount, mempoolUrl = 'https://
|
|
|
342
342
|
decimals: 0,
|
|
343
343
|
supply: trail.supply,
|
|
344
344
|
balances: currentBalances,
|
|
345
|
-
ops: [{ op: 'urn:mono:op:transfer', from:
|
|
345
|
+
ops: [{ op: 'urn:mono:op:transfer', from: senderAddr, to, amt: amount }]
|
|
346
346
|
};
|
|
347
347
|
const newJcs = jcs(newState);
|
|
348
348
|
|