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.
- package/PAY.md +30 -4
- package/README.md +79 -1
- package/bin/jss.js +8 -0
- package/package.json +1 -1
- package/src/config.js +8 -0
- package/src/handlers/pay.js +341 -18
- package/src/server.js +18 -1
- package/src/webledger.js +43 -15
- package/src/webrtc/index.js +159 -0
- package/test/pay.test.js +145 -0
- package/test/webledger.test.js +62 -0
- package/test/webrtc.test.js +212 -0
- package/test-webrtc-smoke.mjs +90 -0
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
|
|
|
@@ -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
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
|
|
691
|
-
|
|
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
|
|
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
|
|