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 +30 -4
- package/README.md +40 -1
- package/bin/jss.js +2 -0
- package/package.json +1 -1
- package/src/config.js +2 -0
- package/src/handlers/pay.js +304 -5
- package/src/server.js +2 -1
- package/src/webledger.js +43 -15
- package/test/pay.test.js +145 -0
- package/test/webledger.test.js +62 -0
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
|
-
|
|
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` |
|
|
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,
|
|
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
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',
|
package/src/handlers/pay.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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;
|
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();
|