javascript-solid-server 0.0.106 → 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 CHANGED
@@ -9,13 +9,15 @@ JSS has a built-in payment system. Resources under `/pay/*` cost satoshis to acc
9
9
  ```
10
10
  User (Nostr keypair)
11
11
 
12
- ├── POST /pay/.deposit → credit sat balance
13
- ├── GET /pay/.balance → check balance
12
+ ├── POST /pay/.deposit → credit sat balance (multi-chain: txo:tbtc3:, txo:tbtc4:, etc.)
13
+ ├── GET /pay/.balance → check balance (includes per-chain balances)
14
14
  ├── GET /pay/* → spend 1 sat, get resource
15
15
  ├── POST /pay/.buy → spend sats, get tokens (Bitcoin TX)
16
16
  ├── POST /pay/.withdraw → spend balance, get tokens back
17
17
  ├── POST /pay/.sell → list tokens for sale
18
- └── POST /pay/.swap → buy someone's sell order
18
+ ├── POST /pay/.swap → buy someone's sell order
19
+ ├── GET /pay/.pool → AMM pool state (multi-chain)
20
+ └── POST /pay/.pool → AMM: swap, add-liquidity, remove-liquidity
19
21
  ```
20
22
 
21
23
  All state lives in two places:
@@ -59,6 +61,17 @@ Response:
59
61
 
60
62
  The `token` field is only present when `--pay-token` is configured. `rate` is sats per token.
61
63
 
64
+ When `--pay-chains` is configured, the response also includes:
65
+ ```json
66
+ {
67
+ "chains": [
68
+ { "id": "tbtc3", "unit": "tbtc3", "name": "Bitcoin Testnet3" },
69
+ { "id": "tbtc4", "unit": "tbtc4", "name": "Bitcoin Testnet4" }
70
+ ],
71
+ "pool": "/pay/.pool"
72
+ }
73
+ ```
74
+
62
75
  ### GET /pay/.balance
63
76
  **Requires NIP-98 auth.**
64
77
 
@@ -280,9 +293,10 @@ jss token info PODS
280
293
 
281
294
  | File | Contents |
282
295
  |------|----------|
283
- | `/.well-known/webledgers/webledgers.json` | Sat balances per DID (webledgers.org spec) |
296
+ | `/.well-known/webledgers/webledgers.json` | Balances per DID — multi-currency array format (webledgers.org spec) |
284
297
  | `/.well-known/webledgers/replay.json` | Seen MRC20 state hashes (replay protection) |
285
298
  | `/.well-known/webledgers/offers.json` | Open sell orders (secondary market) |
299
+ | `/.well-known/webledgers/pool.json` | AMM pool state (reserves, LP shares, k) |
286
300
  | `/.well-known/token/<ticker>.json` | MRC20 token trail (state chain, keys, UTXO) |
287
301
 
288
302
  ## Source Files
@@ -330,6 +344,18 @@ jss token info PODS
330
344
  6. POST /pay/.swap → buys the 50 PODS, seller gets 750 sats credited
331
345
  ```
332
346
 
347
+ ### Cross-chain AMM trading
348
+ ```
349
+ 1. Configure pod: jss start --pay --pay-chains "tbtc3,tbtc4"
350
+ 2. User A deposits: POST /pay/.deposit "txo:tbtc3:<txid>:<vout>" → gets tbtc3 balance
351
+ 3. User B deposits: POST /pay/.deposit "txo:tbtc4:<txid>:<vout>" → gets tbtc4 balance
352
+ 4. User A adds liquidity: POST /pay/.pool { "action": "add-liquidity", "tbtc3": 1000, "tbtc4": 5000 }
353
+ 5. User B swaps: POST /pay/.pool { "action": "swap", "sell": "tbtc4", "amount": 500 }
354
+ → receives ~90 tbtc3 (constant product formula, 0.3% fee)
355
+ 6. User A removes liquidity: POST /pay/.pool { "action": "remove-liquidity", "all": true }
356
+ → gets back proportional share of both currencies + earned fees
357
+ ```
358
+
333
359
  ### Full exit
334
360
  ```
335
361
  1. POST /pay/.withdraw { "all": true } → converts entire balance to portable tokens
package/README.md CHANGED
@@ -158,6 +158,7 @@ jss --help # Show help
158
158
  | `--pay-address <addr>` | Address for receiving deposits | - |
159
159
  | `--pay-token <ticker>` | Token to sell (enables primary market + withdrawal) | - |
160
160
  | `--pay-rate <n>` | Sats per token for buy/withdraw | 1 |
161
+ | `--pay-chains <ids>` | Multi-chain deposits + AMM (e.g. "tbtc3,tbtc4") | - |
161
162
  | `--mongo` | Enable MongoDB-backed /db/ route | false |
162
163
  | `--mongo-url <url>` | MongoDB connection URL | mongodb://localhost:27017 |
163
164
  | `--mongo-database <name>` | MongoDB database name | solid |
@@ -834,11 +835,16 @@ jss start --pay --pay-cost 10 --pay-address your-address --pay-token PODS --pay-
834
835
 
835
836
  | Method | Path | Description |
836
837
  |--------|------|-------------|
837
- | GET | `/pay/.info` | Public: cost, token info, available routes |
838
+ | GET | `/pay/.info` | Public: cost, token info, chains, pool |
838
839
  | GET | `/pay/.balance` | Check your balance (NIP-98 auth) |
839
840
  | POST | `/pay/.deposit` | Deposit sats via TXO URI or MRC20 state proof |
840
841
  | POST | `/pay/.buy` | Buy tokens with sat balance (requires `--pay-token`) |
841
842
  | POST | `/pay/.withdraw` | Withdraw balance as portable tokens (requires `--pay-token`) |
843
+ | GET | `/pay/.offers` | List open sell orders (secondary market) |
844
+ | POST | `/pay/.sell` | Create a sell order (requires `--pay-token`) |
845
+ | POST | `/pay/.swap` | Execute a swap against a sell order |
846
+ | GET | `/pay/.pool` | AMM pool state (requires `--pay-chains`) |
847
+ | POST | `/pay/.pool` | AMM swap, add/remove liquidity |
842
848
  | GET | `/pay/*` | Paid resource access (deducts balance) |
843
849
 
844
850
  ### How It Works
@@ -879,6 +885,39 @@ curl -X POST -H "Authorization: Nostr <base64-event>" \
879
885
 
880
886
  Deposit verification uses the mempool API (default: testnet4). The `X-Balance` and `X-Cost` headers are returned on successful paid requests. Buy and withdraw return portable MRC20 proofs with Bitcoin anchor data for independent verification.
881
887
 
888
+ ### Secondary Market
889
+
890
+ Users can trade tokens peer-to-peer through the pod. Sell orders are created via `/pay/.sell` and filled via `/pay/.swap`. The pod acts as escrow — transferring tokens on the Bitcoin-anchored MRC20 trail and settling sats in the webledger.
891
+
892
+ ### Multi-Chain AMM
893
+
894
+ Enable multi-chain deposits and an automated market maker:
895
+
896
+ ```bash
897
+ jss start --pay --pay-chains "tbtc3,tbtc4"
898
+ ```
899
+
900
+ Deposits detect the chain from the TXO URI prefix (`txo:tbtc3:txid:vout`). Each chain's balance is tracked separately. The AMM uses a constant-product formula (x × y = k) with a 0.3% fee.
901
+
902
+ ```bash
903
+ # Add liquidity
904
+ curl -X POST -H "Authorization: Nostr <token>" \
905
+ -H "Content-Type: application/json" \
906
+ http://localhost:3000/pay/.pool \
907
+ -d '{"action": "add-liquidity", "tbtc3": 1000, "tbtc4": 5000}'
908
+
909
+ # Swap
910
+ curl -X POST -H "Authorization: Nostr <token>" \
911
+ -H "Content-Type: application/json" \
912
+ http://localhost:3000/pay/.pool \
913
+ -d '{"action": "swap", "sell": "tbtc3", "amount": 100}'
914
+
915
+ # Check pool state
916
+ curl http://localhost:3000/pay/.pool
917
+ ```
918
+
919
+ Supported chains: `btc`, `tbtc3`, `tbtc4`, `ltc`, `signet`.
920
+
882
921
  ## Authentication
883
922
 
884
923
  ### Simple Tokens (Development)
package/bin/jss.js CHANGED
@@ -87,6 +87,7 @@ program
87
87
  .option('--pay-address <addr>', 'Address for receiving deposits')
88
88
  .option('--pay-token <ticker>', 'Token to sell (enables primary market)')
89
89
  .option('--pay-rate <n>', 'Sats per token for primary market (default: 1)', parseInt)
90
+ .option('--pay-chains <chains>', 'Comma-separated chain IDs for multi-chain deposits/AMM (e.g. "tbtc3,tbtc4")')
90
91
  .option('--mongo', 'Enable MongoDB-backed /db/ route')
91
92
  .option('--no-mongo', 'Disable MongoDB-backed /db/ route')
92
93
  .option('--mongo-url <url>', 'MongoDB connection URL (default: mongodb://localhost:27017)')
@@ -159,6 +160,7 @@ program
159
160
  payAddress: config.payAddress,
160
161
  payToken: config.payToken,
161
162
  payRate: config.payRate,
163
+ payChains: config.payChains,
162
164
  mongo: config.mongo,
163
165
  mongoUrl: config.mongoUrl,
164
166
  mongoDatabase: config.mongoDatabase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.106",
3
+ "version": "0.0.107",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/config.js CHANGED
@@ -90,6 +90,7 @@ export const defaults = {
90
90
  payAddress: null,
91
91
  payToken: null,
92
92
  payRate: 1,
93
+ payChains: null, // comma-separated chain IDs, e.g. "tbtc3,tbtc4"
93
94
 
94
95
  // MongoDB-backed /db/ route
95
96
  mongo: false,
@@ -152,6 +153,7 @@ const envMap = {
152
153
  JSS_PAY_ADDRESS: 'payAddress',
153
154
  JSS_PAY_TOKEN: 'payToken',
154
155
  JSS_PAY_RATE: 'payRate',
156
+ JSS_PAY_CHAINS: 'payChains',
155
157
  JSS_MONGO: 'mongo',
156
158
  JSS_MONGO_URL: 'mongoUrl',
157
159
  JSS_MONGO_DATABASE: 'mongoDatabase',
@@ -35,6 +35,40 @@ import path from 'path';
35
35
 
36
36
  const DEFAULT_COST = 1; // satoshis per request
37
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
+
38
72
  // --- Replay protection ---
39
73
  const replayFile = () => path.join(process.env.DATA_ROOT || './data', '.well-known/webledgers/replay.json');
40
74
 
@@ -173,6 +207,11 @@ export function createPayHandler(options = {}) {
173
207
  const payToken = options.payToken ?? null;
174
208
  const payRate = options.payRate ?? 1;
175
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
+
176
215
  return async function payHandler(request, reply) {
177
216
  const url = request.url.split('?')[0];
178
217
  if (!isPayRequest(request.url)) return;
@@ -198,6 +237,10 @@ export function createPayHandler(options = {}) {
198
237
  info.token.issuer = trail.pubkeyBase ?? null;
199
238
  }
200
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
+ }
201
244
  return reply.send(info);
202
245
  }
203
246
 
@@ -209,12 +252,21 @@ export function createPayHandler(options = {}) {
209
252
  }
210
253
  const didUri = pubkeyToDidNostr(pubkey);
211
254
  const ledger = await readLedger();
212
- return reply.send({
255
+ const response = {
213
256
  did: didUri,
214
257
  balance: getBalance(ledger, didUri),
215
258
  cost,
216
259
  unit: 'sat'
217
- });
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);
218
270
  }
219
271
 
220
272
  // --- POST /pay/.deposit ---
@@ -284,21 +336,37 @@ export function createPayHandler(options = {}) {
284
336
 
285
337
  // --- Sats deposit (TXO URI) ---
286
338
  if (deposit.type === 'sats') {
287
- 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);
288
355
  if (!result.valid) {
289
356
  return reply.code(400).send({ error: result.error });
290
357
  }
291
358
 
292
359
  const didUri = pubkeyToDidNostr(pubkey);
293
360
  const ledger = await readLedger();
294
- const newBalance = credit(ledger, didUri, result.amount);
361
+ const newBalance = credit(ledger, didUri, result.amount, currency);
295
362
  await writeLedger(ledger);
296
363
 
297
364
  return reply.send({
298
365
  did: didUri,
299
366
  deposited: result.amount,
300
367
  balance: newBalance,
301
- unit: 'sat'
368
+ unit: currency || 'sat',
369
+ ...(chainId ? { chain: chainId } : {})
302
370
  });
303
371
  }
304
372
 
@@ -673,6 +741,237 @@ export function createPayHandler(options = {}) {
673
741
  });
674
742
  }
675
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
+
676
975
  // --- GET/HEAD /pay/* — paid resource access ---
677
976
  if (request.method === 'GET' || request.method === 'HEAD') {
678
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/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
@@ -546,6 +546,151 @@ describe('HTTP 402 Pay Middleware', () => {
546
546
  });
547
547
  });
548
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
+
549
694
  describe('Pay disabled', () => {
550
695
  let noPayServer;
551
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();