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.
@@ -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
- return reply.send({
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
- const result = await verifySatsDeposit(deposit.txo, mempoolUrl);
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
- if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
309
- if (typeof body === 'string') body = JSON.parse(body);
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
- if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
407
- if (typeof body === 'string') body = JSON.parse(body);
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 issuer balance
321
- const issuerAddr = trail.pubkeyBase;
322
- const issuerBalance = currentBalances[issuerAddr] || 0;
323
- if (issuerBalance < amount) {
324
- throw new Error(`Insufficient balance: ${issuerBalance} < ${amount}`);
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[issuerAddr] = issuerBalance - amount;
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: issuerAddr, to, amt: amount }]
345
+ ops: [{ op: 'urn:mono:op:transfer', from: senderAddr, to, amt: amount }]
346
346
  };
347
347
  const newJcs = jcs(newState);
348
348