javascript-solid-server 0.0.106 → 0.0.108

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.
@@ -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
 
@@ -356,13 +424,17 @@ export function createPayHandler(options = {}) {
356
424
  return reply.code(400).send({ error: 'Amount must be positive' });
357
425
  }
358
426
 
359
- // Check sat balance
427
+ // Determine payment currency — chain-specific (e.g. "tbtc4") or generic "sat"
428
+ const currency = (body?.currency && payChains && payChains.includes(body.currency))
429
+ ? body.currency : null;
430
+
431
+ // Check balance
360
432
  const didUri = pubkeyToDidNostr(pubkey);
361
433
  const ledger = await readLedger();
362
- const balance = getBalance(ledger, didUri);
434
+ const balance = getBalance(ledger, didUri, currency);
363
435
  if (balance < satCost) {
364
436
  return reply.code(402).send({
365
- error: 'Insufficient sat balance',
437
+ error: `Insufficient ${currency || 'sat'} balance`,
366
438
  balance,
367
439
  cost: satCost,
368
440
  rate: payRate,
@@ -389,8 +461,8 @@ export function createPayHandler(options = {}) {
389
461
  return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
390
462
  }
391
463
 
392
- // Debit sats from buyer
393
- debit(ledger, didUri, satCost);
464
+ // Debit from buyer
465
+ debit(ledger, didUri, satCost, currency);
394
466
  await writeLedger(ledger);
395
467
 
396
468
  return reply.send({
@@ -398,8 +470,8 @@ export function createPayHandler(options = {}) {
398
470
  ticker,
399
471
  cost: satCost,
400
472
  rate: payRate,
401
- balance: getBalance(ledger, didUri),
402
- unit: 'sat',
473
+ balance: getBalance(ledger, didUri, currency),
474
+ unit: currency || 'sat',
403
475
  txid: result.txid,
404
476
  proof: {
405
477
  state: result.state,
@@ -673,6 +745,237 @@ export function createPayHandler(options = {}) {
673
745
  });
674
746
  }
675
747
 
748
+ // --- GET /pay/.pool — AMM pool state (public) ---
749
+ if (url === '/pay/.pool' && request.method === 'GET') {
750
+ if (!payChains || payChains.length < 2) {
751
+ return reply.code(400).send({ error: 'AMM not configured (requires --pay-chains with 2 chains)' });
752
+ }
753
+ const pool = await loadPool();
754
+ if (!pool) {
755
+ return reply.send({
756
+ pair: [CHAIN_REGISTRY[payChains[0]].unit, CHAIN_REGISTRY[payChains[1]].unit],
757
+ reserves: { [CHAIN_REGISTRY[payChains[0]].unit]: 0, [CHAIN_REGISTRY[payChains[1]].unit]: 0 },
758
+ k: 0,
759
+ fee: 0.003,
760
+ totalShares: 0,
761
+ lpShares: {}
762
+ });
763
+ }
764
+ return reply.send(pool);
765
+ }
766
+
767
+ // --- POST /pay/.pool — AMM operations (swap, add-liquidity, remove-liquidity) ---
768
+ if (url === '/pay/.pool' && request.method === 'POST') {
769
+ if (!payChains || payChains.length < 2) {
770
+ return reply.code(400).send({ error: 'AMM not configured (requires --pay-chains with 2 chains)' });
771
+ }
772
+
773
+ const pubkey = await getNostrPubkey(request);
774
+ if (!pubkey) {
775
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
776
+ }
777
+
778
+ let body = request.body;
779
+ try {
780
+ if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
781
+ if (typeof body === 'string') body = JSON.parse(body);
782
+ } catch {
783
+ return reply.code(400).send({ error: 'Invalid JSON body' });
784
+ }
785
+
786
+ const didUri = pubkeyToDidNostr(pubkey);
787
+ const unitA = CHAIN_REGISTRY[payChains[0]].unit;
788
+ const unitB = CHAIN_REGISTRY[payChains[1]].unit;
789
+ const action = body?.action;
790
+
791
+ // --- ADD LIQUIDITY ---
792
+ if (action === 'add-liquidity') {
793
+ const amountA = Math.floor(body?.[unitA] || 0);
794
+ const amountB = Math.floor(body?.[unitB] || 0);
795
+ if (amountA <= 0 || amountB <= 0) {
796
+ return reply.code(400).send({ error: `Specify ${unitA} and ${unitB} amounts` });
797
+ }
798
+
799
+ const ledger = await readLedger();
800
+ const balA = getBalance(ledger, didUri, unitA);
801
+ const balB = getBalance(ledger, didUri, unitB);
802
+ if (balA < amountA) {
803
+ return reply.code(402).send({ error: `Insufficient ${unitA} balance`, balance: balA, required: amountA });
804
+ }
805
+ if (balB < amountB) {
806
+ return reply.code(402).send({ error: `Insufficient ${unitB} balance`, balance: balB, required: amountB });
807
+ }
808
+
809
+ let pool = await loadPool();
810
+ if (!pool) {
811
+ pool = {
812
+ pair: [unitA, unitB],
813
+ reserves: { [unitA]: 0, [unitB]: 0 },
814
+ k: 0,
815
+ fee: 0.003,
816
+ totalShares: 0,
817
+ lpShares: {}
818
+ };
819
+ }
820
+
821
+ // Calculate LP shares (initial: shares = sqrt(amountA * amountB))
822
+ let newShares;
823
+ if (pool.totalShares === 0) {
824
+ newShares = Math.floor(Math.sqrt(amountA * amountB));
825
+ } else {
826
+ // Proportional: min(amountA/reserveA, amountB/reserveB) * totalShares
827
+ const ratioA = amountA / pool.reserves[unitA];
828
+ const ratioB = amountB / pool.reserves[unitB];
829
+ newShares = Math.floor(Math.min(ratioA, ratioB) * pool.totalShares);
830
+ }
831
+ if (newShares <= 0) {
832
+ return reply.code(400).send({ error: 'Amounts too small to mint LP shares' });
833
+ }
834
+
835
+ // Debit user balances
836
+ debit(ledger, didUri, amountA, unitA);
837
+ debit(ledger, didUri, amountB, unitB);
838
+ await writeLedger(ledger);
839
+
840
+ // Update pool
841
+ pool.reserves[unitA] += amountA;
842
+ pool.reserves[unitB] += amountB;
843
+ pool.k = pool.reserves[unitA] * pool.reserves[unitB];
844
+ pool.totalShares += newShares;
845
+ pool.lpShares[didUri] = (pool.lpShares[didUri] || 0) + newShares;
846
+ await savePool(pool);
847
+
848
+ return reply.send({
849
+ action: 'add-liquidity',
850
+ deposited: { [unitA]: amountA, [unitB]: amountB },
851
+ shares: newShares,
852
+ totalShares: pool.totalShares,
853
+ reserves: pool.reserves,
854
+ k: pool.k
855
+ });
856
+ }
857
+
858
+ // --- REMOVE LIQUIDITY ---
859
+ if (action === 'remove-liquidity') {
860
+ const shares = Math.floor(body?.shares || 0);
861
+ const pool = await loadPool();
862
+ if (!pool || pool.totalShares === 0) {
863
+ return reply.code(400).send({ error: 'Pool has no liquidity' });
864
+ }
865
+
866
+ const userShares = pool.lpShares[didUri] || 0;
867
+ const toRemove = body?.all ? userShares : shares;
868
+ if (toRemove <= 0 || toRemove > userShares) {
869
+ return reply.code(400).send({ error: 'Invalid shares', yours: userShares });
870
+ }
871
+
872
+ // Calculate proportional withdrawal
873
+ const fraction = toRemove / pool.totalShares;
874
+ const outA = Math.floor(pool.reserves[unitA] * fraction);
875
+ const outB = Math.floor(pool.reserves[unitB] * fraction);
876
+
877
+ // Credit user
878
+ const ledger = await readLedger();
879
+ credit(ledger, didUri, outA, unitA);
880
+ credit(ledger, didUri, outB, unitB);
881
+ await writeLedger(ledger);
882
+
883
+ // Update pool
884
+ pool.reserves[unitA] -= outA;
885
+ pool.reserves[unitB] -= outB;
886
+ pool.k = pool.reserves[unitA] * pool.reserves[unitB];
887
+ pool.totalShares -= toRemove;
888
+ pool.lpShares[didUri] -= toRemove;
889
+ if (pool.lpShares[didUri] <= 0) delete pool.lpShares[didUri];
890
+ await savePool(pool);
891
+
892
+ return reply.send({
893
+ action: 'remove-liquidity',
894
+ withdrawn: { [unitA]: outA, [unitB]: outB },
895
+ sharesRemoved: toRemove,
896
+ totalShares: pool.totalShares,
897
+ reserves: pool.reserves
898
+ });
899
+ }
900
+
901
+ // --- SWAP ---
902
+ if (action === 'swap') {
903
+ const sellUnit = body?.sell;
904
+ const amount = Math.floor(body?.amount || 0);
905
+ if (!sellUnit || ![unitA, unitB].includes(sellUnit)) {
906
+ return reply.code(400).send({ error: `Specify sell: "${unitA}" or "${unitB}"` });
907
+ }
908
+ if (amount <= 0) {
909
+ return reply.code(400).send({ error: 'Amount must be positive' });
910
+ }
911
+
912
+ const pool = await loadPool();
913
+ if (!pool || pool.k === 0) {
914
+ return reply.code(400).send({ error: 'Pool has no liquidity' });
915
+ }
916
+
917
+ const buyUnit = sellUnit === unitA ? unitB : unitA;
918
+
919
+ // Check user balance
920
+ const ledger = await readLedger();
921
+ const userBal = getBalance(ledger, didUri, sellUnit);
922
+ if (userBal < amount) {
923
+ return reply.code(402).send({
924
+ error: `Insufficient ${sellUnit} balance`,
925
+ balance: userBal,
926
+ required: amount,
927
+ deposit: '/pay/.deposit'
928
+ });
929
+ }
930
+
931
+ // Constant product: (reserveIn + amountIn * (1-fee)) * (reserveOut - amountOut) = k
932
+ const reserveIn = pool.reserves[sellUnit];
933
+ const reserveOut = pool.reserves[buyUnit];
934
+ const amountInAfterFee = amount * (1 - pool.fee);
935
+ const amountOut = Math.floor((reserveOut * amountInAfterFee) / (reserveIn + amountInAfterFee));
936
+
937
+ if (amountOut <= 0) {
938
+ return reply.code(400).send({ error: 'Trade too small' });
939
+ }
940
+
941
+ // Slippage protection
942
+ if (body?.minReceived && amountOut < body.minReceived) {
943
+ return reply.code(400).send({
944
+ error: 'Slippage exceeded',
945
+ wouldReceive: amountOut,
946
+ minReceived: body.minReceived
947
+ });
948
+ }
949
+
950
+ // Execute: debit sellUnit, credit buyUnit
951
+ debit(ledger, didUri, amount, sellUnit);
952
+ credit(ledger, didUri, amountOut, buyUnit);
953
+ await writeLedger(ledger);
954
+
955
+ // Update pool reserves
956
+ pool.reserves[sellUnit] += amount;
957
+ pool.reserves[buyUnit] -= amountOut;
958
+ pool.k = pool.reserves[unitA] * pool.reserves[unitB];
959
+ await savePool(pool);
960
+
961
+ const price = amount / amountOut;
962
+ return reply.send({
963
+ action: 'swap',
964
+ sold: { unit: sellUnit, amount },
965
+ bought: { unit: buyUnit, amount: amountOut },
966
+ price: Math.round(price * 10000) / 10000,
967
+ fee: Math.floor(amount * pool.fee),
968
+ reserves: pool.reserves,
969
+ balances: {
970
+ [sellUnit]: getBalance(ledger, didUri, sellUnit),
971
+ [buyUnit]: getBalance(ledger, didUri, buyUnit)
972
+ }
973
+ });
974
+ }
975
+
976
+ return reply.code(400).send({ error: 'Unknown action. Use: swap, add-liquidity, remove-liquidity' });
977
+ }
978
+
676
979
  // --- GET/HEAD /pay/* — paid resource access ---
677
980
  if (request.method === 'GET' || request.method === 'HEAD') {
678
981
  const pubkey = await getNostrPubkey(request);
@@ -685,21 +988,41 @@ export function createPayHandler(options = {}) {
685
988
 
686
989
  const didUri = pubkeyToDidNostr(pubkey);
687
990
  const ledger = await readLedger();
688
- const { success, balance } = debit(ledger, didUri, cost);
689
991
 
690
- if (!success) {
691
- return reply.code(402).send({
992
+ // Try generic sat balance first, then fall back to chain balances
993
+ const currency = request.headers['x-pay-currency'] || null;
994
+ let payUnit = currency && payChains && payChains.includes(currency) ? currency : null;
995
+ let result = debit(ledger, didUri, cost, payUnit);
996
+
997
+ // If generic sat failed and no explicit currency, try each chain balance
998
+ if (!result.success && !payUnit && payChains) {
999
+ for (const chainId of payChains) {
1000
+ result = debit(ledger, didUri, cost, chainId);
1001
+ if (result.success) { payUnit = chainId; break; }
1002
+ }
1003
+ }
1004
+
1005
+ if (!result.success) {
1006
+ const response = {
692
1007
  error: 'Payment Required',
693
- balance,
1008
+ balance: result.balance,
694
1009
  cost,
695
1010
  unit: 'sat',
696
1011
  deposit: '/pay/.deposit'
697
- });
1012
+ };
1013
+ if (payChains) {
1014
+ response.balances = {};
1015
+ for (const chainId of payChains) {
1016
+ response.balances[chainId] = getBalance(ledger, didUri, chainId);
1017
+ }
1018
+ }
1019
+ return reply.code(402).send(response);
698
1020
  }
699
1021
 
700
1022
  await writeLedger(ledger);
701
- reply.header('X-Balance', String(balance));
1023
+ reply.header('X-Balance', String(result.balance));
702
1024
  reply.header('X-Cost', String(cost));
1025
+ if (payUnit) reply.header('X-Pay-Currency', payUnit);
703
1026
  return; // continue to normal resource handler
704
1027
  }
705
1028
 
package/src/server.js CHANGED
@@ -18,6 +18,7 @@ import { createPayHandler, isPayRequest } from './handlers/pay.js';
18
18
  import { activityPubPlugin, getActorHandler } from './ap/index.js';
19
19
  import { remoteStoragePlugin } from './remotestorage.js';
20
20
  import { dbPlugin } from './db/index.js';
21
+ import { webrtcPlugin } from './webrtc/index.js';
21
22
 
22
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
24
 
@@ -74,6 +75,9 @@ export function createServer(options = {}) {
74
75
  const nostrEnabled = options.nostr ?? false;
75
76
  const nostrPath = options.nostrPath ?? '/relay';
76
77
  const nostrMaxEvents = options.nostrMaxEvents ?? 1000;
78
+ // WebRTC signaling is OFF by default
79
+ const webrtcEnabled = options.webrtc ?? false;
80
+ const webrtcPath = options.webrtcPath ?? '/.webrtc';
77
81
  // ActivityPub federation is OFF by default
78
82
  const activitypubEnabled = options.activitypub ?? false;
79
83
  const apUsername = options.apUsername ?? 'me';
@@ -102,6 +106,7 @@ export function createServer(options = {}) {
102
106
  const payAddress = options.payAddress ?? null; // Pod's MRC20 address for token deposits
103
107
  const payToken = options.payToken ?? null; // Token ticker for primary market
104
108
  const payRate = options.payRate ?? 1; // Sats per token
109
+ const payChains = options.payChains ?? null; // Multi-chain IDs (e.g. "tbtc3,tbtc4")
105
110
 
106
111
  // Set data root via environment variable if provided
107
112
  if (options.root) {
@@ -239,6 +244,11 @@ export function createServer(options = {}) {
239
244
  });
240
245
  }
241
246
 
247
+ // Register WebRTC signaling if enabled
248
+ if (webrtcEnabled) {
249
+ fastify.register(webrtcPlugin, { path: webrtcPath });
250
+ }
251
+
242
252
  // Register ActivityPub plugin if enabled
243
253
  if (activitypubEnabled) {
244
254
  fastify.register(activityPubPlugin, {
@@ -330,6 +340,12 @@ export function createServer(options = {}) {
330
340
  return;
331
341
  }
332
342
 
343
+ // Allow WebRTC signaling endpoint through when enabled
344
+ const urlNoQuery = request.url.split('?')[0];
345
+ if (webrtcEnabled && urlNoQuery === webrtcPath) {
346
+ return;
347
+ }
348
+
333
349
  const segments = request.url.split('/').map(s => s.split('?')[0]); // Remove query strings
334
350
  const hasForbiddenDotfile = segments.some(seg =>
335
351
  seg.startsWith('.') &&
@@ -376,7 +392,7 @@ export function createServer(options = {}) {
376
392
 
377
393
  // HTTP 402 Payment Required handler for /pay/* routes
378
394
  if (payEnabled) {
379
- fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate }));
395
+ fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate, payChains }));
380
396
  }
381
397
 
382
398
  // Authorization hook - check WAC permissions
@@ -404,6 +420,7 @@ export function createServer(options = {}) {
404
420
  request.url.startsWith('/storage/') ||
405
421
  (payEnabled && isPayRequest(request.url)) ||
406
422
  (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
423
+ (webrtcEnabled && (request.url === webrtcPath || request.url.startsWith(webrtcPath + '?'))) ||
407
424
  mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
408
425
  return;
409
426
  }
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