javascript-solid-server 0.0.131 → 0.0.133
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/package.json +1 -1
- package/src/handlers/pay.js +246 -8
- package/src/token.js +3 -3
package/package.json
CHANGED
package/src/handlers/pay.js
CHANGED
|
@@ -28,11 +28,45 @@
|
|
|
28
28
|
import crypto from 'crypto';
|
|
29
29
|
import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
|
|
30
30
|
import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
|
|
31
|
-
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs,
|
|
32
|
-
import { loadTrail, transferToken } from '../token.js';
|
|
31
|
+
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, btAddress } from '../mrc20.js';
|
|
32
|
+
import { loadTrail, transferToken, buildTransaction, broadcastTx, p2trScript } from '../token.js';
|
|
33
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
34
|
+
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
|
33
35
|
import fs from 'fs-extra';
|
|
34
36
|
import path from 'path';
|
|
35
37
|
|
|
38
|
+
// --- Pod keypair for deposit addresses (stored outside web-accessible tree) ---
|
|
39
|
+
const keypairFile = () => path.join(process.env.DATA_ROOT || './data', '.private/keypair.json');
|
|
40
|
+
|
|
41
|
+
async function loadOrCreateKeypair() {
|
|
42
|
+
try {
|
|
43
|
+
const data = await fs.readFile(keypairFile(), 'utf8');
|
|
44
|
+
return JSON.parse(data);
|
|
45
|
+
} catch {
|
|
46
|
+
const privkey = secp256k1.utils.randomPrivateKey();
|
|
47
|
+
const pubkey = secp256k1.getPublicKey(privkey, true);
|
|
48
|
+
const kp = { privkey: bytesToHex(privkey), pubkey: bytesToHex(pubkey) };
|
|
49
|
+
await fs.ensureDir(path.dirname(keypairFile()));
|
|
50
|
+
await fs.writeFile(keypairFile(), JSON.stringify(kp, null, 2));
|
|
51
|
+
return kp;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Pod UTXO tracking (stored outside web-accessible tree) ---
|
|
56
|
+
const utxoFile = () => path.join(process.env.DATA_ROOT || './data', '.private/utxos.json');
|
|
57
|
+
|
|
58
|
+
async function loadUtxos() {
|
|
59
|
+
try {
|
|
60
|
+
const data = await fs.readFile(utxoFile(), 'utf8');
|
|
61
|
+
return JSON.parse(data);
|
|
62
|
+
} catch { return []; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function saveUtxos(utxos) {
|
|
66
|
+
await fs.ensureDir(path.dirname(utxoFile()));
|
|
67
|
+
await fs.writeFile(utxoFile(), JSON.stringify(utxos, null, 2));
|
|
68
|
+
}
|
|
69
|
+
|
|
36
70
|
const DEFAULT_COST = 1; // satoshis per request
|
|
37
71
|
|
|
38
72
|
// --- Chain registry for multi-chain deposits ---
|
|
@@ -176,6 +210,10 @@ function classifyDepositObject(obj) {
|
|
|
176
210
|
if (obj.state?.profile === 'mono.mrc20.v0.1' && obj.prevState) {
|
|
177
211
|
return { type: 'mrc20', state: obj.state, prevState: obj.prevState, anchor: obj.anchor };
|
|
178
212
|
}
|
|
213
|
+
// Claim deposit: user sent sats to pod's address, claiming with txid
|
|
214
|
+
if (obj.txid && obj.vout !== undefined) {
|
|
215
|
+
return { type: 'claim', txid: obj.txid, vout: parseInt(obj.vout, 10), chain: obj.chain };
|
|
216
|
+
}
|
|
179
217
|
// Fall back to TXO URI in .txo field
|
|
180
218
|
if (obj.txo) {
|
|
181
219
|
return { type: 'sats', txo: obj.txo };
|
|
@@ -244,6 +282,21 @@ export function createPayHandler(options = {}) {
|
|
|
244
282
|
return reply.send(info);
|
|
245
283
|
}
|
|
246
284
|
|
|
285
|
+
// --- GET /pay/.address — public deposit address ---
|
|
286
|
+
if (url === '/pay/.address' && request.method === 'GET') {
|
|
287
|
+
const chain = request.query?.chain || (payChains ? payChains[0] : 'tbtc4');
|
|
288
|
+
if (!CHAIN_REGISTRY[chain]) {
|
|
289
|
+
return reply.code(400).send({ error: `Unsupported chain: ${chain}` });
|
|
290
|
+
}
|
|
291
|
+
if (payChains && !payChains.includes(chain)) {
|
|
292
|
+
return reply.code(400).send({ error: `Chain not enabled: ${chain}`, enabledChains: payChains });
|
|
293
|
+
}
|
|
294
|
+
const kp = await loadOrCreateKeypair();
|
|
295
|
+
const network = chain === 'btc' ? 'mainnet' : (chain === 'tbtc3' ? 'testnet' : 'testnet4');
|
|
296
|
+
const address = btAddress(kp.pubkey, [], network);
|
|
297
|
+
return reply.send({ address, chain, pubkey: kp.pubkey });
|
|
298
|
+
}
|
|
299
|
+
|
|
247
300
|
// --- GET /pay/.balance ---
|
|
248
301
|
if (url === '/pay/.balance' && request.method === 'GET') {
|
|
249
302
|
const pubkey = await getNostrPubkey(request);
|
|
@@ -370,11 +423,77 @@ export function createPayHandler(options = {}) {
|
|
|
370
423
|
});
|
|
371
424
|
}
|
|
372
425
|
|
|
426
|
+
// --- Claim deposit: user sent sats to pod's address ---
|
|
427
|
+
if (deposit.type === 'claim') {
|
|
428
|
+
const kp = await loadOrCreateKeypair();
|
|
429
|
+
const chainId = deposit.chain || (payChains ? payChains[0] : 'tbtc4');
|
|
430
|
+
if (payChains && !payChains.includes(chainId)) {
|
|
431
|
+
return reply.code(400).send({ error: `Chain '${chainId}' not enabled`, enabledChains: payChains });
|
|
432
|
+
}
|
|
433
|
+
const chain = CHAIN_REGISTRY[chainId];
|
|
434
|
+
if (!chain) {
|
|
435
|
+
return reply.code(400).send({ error: `Unknown chain: ${chainId}` });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Derive pod's address for this chain
|
|
439
|
+
const network = chainId === 'btc' ? 'mainnet' : (chainId === 'tbtc3' ? 'testnet' : 'testnet4');
|
|
440
|
+
const podAddress = btAddress(kp.pubkey, [], network);
|
|
441
|
+
|
|
442
|
+
// Fetch transaction from mempool
|
|
443
|
+
let txData;
|
|
444
|
+
try {
|
|
445
|
+
const txResp = await fetch(`${chain.explorer}/tx/${deposit.txid}`);
|
|
446
|
+
if (!txResp.ok) {
|
|
447
|
+
return reply.code(400).send({ error: 'Transaction not found' });
|
|
448
|
+
}
|
|
449
|
+
txData = await txResp.json();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
return reply.code(502).send({ error: `Failed to verify transaction: ${err.message}` });
|
|
452
|
+
}
|
|
453
|
+
const output = txData.vout?.[deposit.vout];
|
|
454
|
+
if (!output) {
|
|
455
|
+
return reply.code(400).send({ error: `Output ${deposit.vout} not found` });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Verify output pays our address
|
|
459
|
+
if (output.scriptpubkey_address !== podAddress) {
|
|
460
|
+
return reply.code(400).send({ error: 'Output does not pay this pod\'s address', expected: podAddress });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const amount = output.value;
|
|
464
|
+
const currency = chain.unit;
|
|
465
|
+
|
|
466
|
+
// Replay protection + UTXO tracking
|
|
467
|
+
const utxos = await loadUtxos();
|
|
468
|
+
const utxoKey = `${deposit.txid}:${deposit.vout}`;
|
|
469
|
+
if (utxos.find(u => u.txid === deposit.txid && u.vout === deposit.vout)) {
|
|
470
|
+
return reply.code(400).send({ error: 'This output has already been claimed' });
|
|
471
|
+
}
|
|
472
|
+
utxos.push({ txid: deposit.txid, vout: deposit.vout, amount, scriptpubkey: output.scriptpubkey, chain: chainId, spent: false });
|
|
473
|
+
await saveUtxos(utxos);
|
|
474
|
+
|
|
475
|
+
const didUri = pubkeyToDidNostr(pubkey);
|
|
476
|
+
const ledger = await readLedger();
|
|
477
|
+
const newBalance = credit(ledger, didUri, amount, currency);
|
|
478
|
+
await writeLedger(ledger);
|
|
479
|
+
|
|
480
|
+
return reply.send({
|
|
481
|
+
did: didUri,
|
|
482
|
+
deposited: amount,
|
|
483
|
+
balance: newBalance,
|
|
484
|
+
unit: currency,
|
|
485
|
+
chain: chainId,
|
|
486
|
+
txid: deposit.txid,
|
|
487
|
+
address: podAddress
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
373
491
|
return reply.code(400).send({
|
|
374
|
-
error: 'Invalid deposit format. Send a TXO URI string
|
|
492
|
+
error: 'Invalid deposit format. Send a TXO URI string, MRC20 state proof, or claim {txid, vout}.',
|
|
375
493
|
formats: {
|
|
376
494
|
sats: 'POST body: "<txid>:<vout>" or {"txo": "<txid>:<vout>"}',
|
|
377
|
-
mrc20: 'POST body: {"type": "mrc20", "state": {...}, "prevState": {...}}'
|
|
495
|
+
mrc20: 'POST body: {"type": "mrc20", "state": {...}, "prevState": {...}}',
|
|
496
|
+
claim: 'POST body: {"txid": "...", "vout": 0, "chain": "tbtc4"}'
|
|
378
497
|
}
|
|
379
498
|
});
|
|
380
499
|
}
|
|
@@ -507,7 +626,11 @@ export function createPayHandler(options = {}) {
|
|
|
507
626
|
|
|
508
627
|
const didUri = pubkeyToDidNostr(pubkey);
|
|
509
628
|
const ledger = await readLedger();
|
|
510
|
-
|
|
629
|
+
if (body?.currency && (!payChains || !payChains.includes(body.currency))) {
|
|
630
|
+
return reply.code(400).send({ error: `Unsupported currency: ${body.currency}`, enabledChains: payChains || [] });
|
|
631
|
+
}
|
|
632
|
+
const currency = body?.currency || null;
|
|
633
|
+
const balance = getBalance(ledger, didUri, currency);
|
|
511
634
|
|
|
512
635
|
// Calculate withdrawal amount
|
|
513
636
|
let satCost, tokenAmount;
|
|
@@ -562,7 +685,7 @@ export function createPayHandler(options = {}) {
|
|
|
562
685
|
}
|
|
563
686
|
|
|
564
687
|
// Debit balance
|
|
565
|
-
debit(ledger, didUri, satCost);
|
|
688
|
+
debit(ledger, didUri, satCost, currency);
|
|
566
689
|
await writeLedger(ledger);
|
|
567
690
|
|
|
568
691
|
return reply.send({
|
|
@@ -570,8 +693,8 @@ export function createPayHandler(options = {}) {
|
|
|
570
693
|
ticker: payToken,
|
|
571
694
|
cost: satCost,
|
|
572
695
|
rate: payRate,
|
|
573
|
-
balance: getBalance(ledger, didUri),
|
|
574
|
-
unit: 'sat',
|
|
696
|
+
balance: getBalance(ledger, didUri, currency),
|
|
697
|
+
unit: currency || 'sat',
|
|
575
698
|
txid: result.txid,
|
|
576
699
|
proof: {
|
|
577
700
|
state: result.state,
|
|
@@ -585,6 +708,121 @@ export function createPayHandler(options = {}) {
|
|
|
585
708
|
});
|
|
586
709
|
}
|
|
587
710
|
|
|
711
|
+
// --- POST /pay/.withdraw-sats — withdraw sats as a TXO voucher ---
|
|
712
|
+
if (url === '/pay/.withdraw-sats' && request.method === 'POST') {
|
|
713
|
+
const pubkey = await getNostrPubkey(request);
|
|
714
|
+
if (!pubkey) {
|
|
715
|
+
return reply.code(401).send({ error: 'NIP-98 authentication required' });
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let body = request.body;
|
|
719
|
+
try {
|
|
720
|
+
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
|
|
721
|
+
if (typeof body === 'string') body = JSON.parse(body);
|
|
722
|
+
} catch {
|
|
723
|
+
return reply.code(400).send({ error: 'Invalid JSON body' });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const withdrawAmount = parseInt(body?.amount, 10);
|
|
727
|
+
const chainId = body?.chain || (payChains ? payChains[0] : 'tbtc4');
|
|
728
|
+
if (!withdrawAmount || withdrawAmount <= 0) {
|
|
729
|
+
return reply.code(400).send({ error: 'Specify amount to withdraw' });
|
|
730
|
+
}
|
|
731
|
+
if (payChains && !payChains.includes(chainId)) {
|
|
732
|
+
return reply.code(400).send({ error: `Chain '${chainId}' not enabled` });
|
|
733
|
+
}
|
|
734
|
+
const chain = CHAIN_REGISTRY[chainId];
|
|
735
|
+
if (!chain) {
|
|
736
|
+
return reply.code(400).send({ error: `Unknown chain: ${chainId}` });
|
|
737
|
+
}
|
|
738
|
+
const currency = chain.unit;
|
|
739
|
+
|
|
740
|
+
// Check user balance
|
|
741
|
+
const didUri = pubkeyToDidNostr(pubkey);
|
|
742
|
+
const ledger = await readLedger();
|
|
743
|
+
const balance = getBalance(ledger, didUri, currency);
|
|
744
|
+
if (balance < withdrawAmount) {
|
|
745
|
+
return reply.code(402).send({ error: 'Insufficient balance', balance, requested: withdrawAmount, unit: currency });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Find unspent UTXOs for this chain
|
|
749
|
+
const utxos = await loadUtxos();
|
|
750
|
+
const available = utxos.filter(u => u.chain === chainId && !u.spent);
|
|
751
|
+
if (available.length === 0) {
|
|
752
|
+
return reply.code(400).send({ error: 'No UTXOs available for withdrawal' });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Select UTXOs (simple: pick first one that's big enough, or accumulate)
|
|
756
|
+
let selected = [];
|
|
757
|
+
let total = 0;
|
|
758
|
+
const fee = 300;
|
|
759
|
+
const needed = withdrawAmount + fee;
|
|
760
|
+
for (const utxo of available) {
|
|
761
|
+
selected.push(utxo);
|
|
762
|
+
total += utxo.amount;
|
|
763
|
+
if (total >= needed) break;
|
|
764
|
+
}
|
|
765
|
+
if (total < needed) {
|
|
766
|
+
return reply.code(400).send({ error: 'Not enough UTXO value for withdrawal + fee', available: total, needed });
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Load pod keypair
|
|
770
|
+
const kp = await loadOrCreateKeypair();
|
|
771
|
+
const privkeyBytes = hexToBytes(kp.privkey);
|
|
772
|
+
|
|
773
|
+
// Generate a new keypair for the voucher recipient
|
|
774
|
+
const voucherPrivkey = secp256k1.utils.randomPrivateKey();
|
|
775
|
+
const voucherPubkey = secp256k1.getPublicKey(voucherPrivkey, true);
|
|
776
|
+
const voucherXonly = voucherPubkey.slice(1);
|
|
777
|
+
const voucherScript = p2trScript(voucherXonly);
|
|
778
|
+
|
|
779
|
+
// Build outputs: voucher + change back to pod
|
|
780
|
+
const outputs = [{ amount: withdrawAmount, scriptPubKey: voucherScript }];
|
|
781
|
+
const change = total - withdrawAmount - fee;
|
|
782
|
+
const podXonly = hexToBytes(kp.pubkey).slice(1);
|
|
783
|
+
if (change > 546) {
|
|
784
|
+
outputs.push({ amount: change, scriptPubKey: p2trScript(podXonly) });
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Build inputs
|
|
788
|
+
const inputs = selected.map(u => ({
|
|
789
|
+
txid: u.txid, vout: u.vout, amount: u.amount, scriptPubKey: hexToBytes(u.scriptpubkey)
|
|
790
|
+
}));
|
|
791
|
+
|
|
792
|
+
// Build and broadcast
|
|
793
|
+
let newTxid;
|
|
794
|
+
try {
|
|
795
|
+
const rawTx = buildTransaction(inputs, outputs, privkeyBytes);
|
|
796
|
+
newTxid = await broadcastTx(rawTx, chain.explorer.replace(/\/api$/, ''));
|
|
797
|
+
} catch (err) {
|
|
798
|
+
return reply.code(500).send({ error: `Broadcast failed: ${err.message}` });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Debit user balance
|
|
802
|
+
const debitResult = debit(ledger, didUri, withdrawAmount, currency);
|
|
803
|
+
if (!debitResult.success) {
|
|
804
|
+
return reply.code(402).send({ error: 'Balance changed during withdrawal', balance: debitResult.balance });
|
|
805
|
+
}
|
|
806
|
+
await writeLedger(ledger);
|
|
807
|
+
|
|
808
|
+
// Mark UTXOs as spent, add change UTXO
|
|
809
|
+
for (const u of selected) { u.spent = true; }
|
|
810
|
+
if (change > 546) {
|
|
811
|
+
utxos.push({ txid: newTxid, vout: 1, amount: change, scriptpubkey: '5120' + bytesToHex(podXonly), chain: chainId, spent: false });
|
|
812
|
+
}
|
|
813
|
+
await saveUtxos(utxos);
|
|
814
|
+
|
|
815
|
+
// Return voucher URI
|
|
816
|
+
const voucherUri = `txo:${chainId}:${newTxid}:0?amount=${withdrawAmount}&key=${bytesToHex(voucherPrivkey)}`;
|
|
817
|
+
return reply.send({
|
|
818
|
+
voucher: voucherUri,
|
|
819
|
+
txid: newTxid,
|
|
820
|
+
amount: withdrawAmount,
|
|
821
|
+
unit: currency,
|
|
822
|
+
balance: getBalance(ledger, didUri, currency)
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
588
826
|
// --- GET /pay/.offers — list open sell orders ---
|
|
589
827
|
if (url === '/pay/.offers' && request.method === 'GET') {
|
|
590
828
|
const offers = await loadOffers();
|
package/src/token.js
CHANGED
|
@@ -109,12 +109,12 @@ function btDeriveChainedPrivkey(privkeyBytes, states) {
|
|
|
109
109
|
return bigIntToBytes(d);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
function p2trScript(xonly) {
|
|
112
|
+
export function p2trScript(xonly) {
|
|
113
113
|
return concatBytes(new Uint8Array([0x51, 0x20]), xonly);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
// --- Bitcoin transaction building ---
|
|
117
|
-
function buildTransaction(inputs, outputs, privkeyBytes) {
|
|
117
|
+
export function buildTransaction(inputs, outputs, privkeyBytes) {
|
|
118
118
|
const internalXOnly = new Uint8Array(secp256k1.getPublicKey(privkeyBytes, true)).slice(1);
|
|
119
119
|
const untweakedHex = '5120' + bytesToHex(internalXOnly);
|
|
120
120
|
const needsTweak = bytesToHex(inputs[0].scriptPubKey) !== untweakedHex;
|
|
@@ -173,7 +173,7 @@ function buildTransaction(inputs, outputs, privkeyBytes) {
|
|
|
173
173
|
return bytesToHex(concatBytes(...parts));
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
async function broadcastTx(rawTxHex, mempoolUrl) {
|
|
176
|
+
export async function broadcastTx(rawTxHex, mempoolUrl) {
|
|
177
177
|
const res = await fetch(`${mempoolUrl}/api/tx`, {
|
|
178
178
|
method: 'POST',
|
|
179
179
|
headers: { 'Content-Type': 'text/plain' },
|