javascript-solid-server 0.0.132 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.132",
3
+ "version": "0.0.133",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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, sha256Hex } from '../mrc20.js';
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 or MRC20 state proof.',
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
  }
@@ -589,6 +708,121 @@ export function createPayHandler(options = {}) {
589
708
  });
590
709
  }
591
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
+
592
826
  // --- GET /pay/.offers — list open sell orders ---
593
827
  if (url === '/pay/.offers' && request.method === 'GET') {
594
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' },