javascript-solid-server 0.0.132 → 0.0.134

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.134",
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, btDeriveChainedPrivkey } 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,28 @@ export function createPayHandler(options = {}) {
244
282
  return reply.send(info);
245
283
  }
246
284
 
285
+ // --- GET /pay/.address — deposit address (optional per-user tweak) ---
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 user = request.query?.user?.trim().toLowerCase() || null;
297
+ if (user && !/^did:nostr:[0-9a-f]{64}$/.test(user)) {
298
+ return reply.code(400).send({ error: 'Invalid user DID. Expected: did:nostr:<64-hex>' });
299
+ }
300
+ const states = user ? [user] : [];
301
+ const address = btAddress(kp.pubkey, states, network);
302
+ const response = { address, chain, pubkey: kp.pubkey };
303
+ if (user) response.user = user;
304
+ return reply.send(response);
305
+ }
306
+
247
307
  // --- GET /pay/.balance ---
248
308
  if (url === '/pay/.balance' && request.method === 'GET') {
249
309
  const pubkey = await getNostrPubkey(request);
@@ -370,11 +430,79 @@ export function createPayHandler(options = {}) {
370
430
  });
371
431
  }
372
432
 
433
+ // --- Claim deposit: user sent sats to pod's address ---
434
+ if (deposit.type === 'claim') {
435
+ const kp = await loadOrCreateKeypair();
436
+ const chainId = deposit.chain || (payChains ? payChains[0] : 'tbtc4');
437
+ if (payChains && !payChains.includes(chainId)) {
438
+ return reply.code(400).send({ error: `Chain '${chainId}' not enabled`, enabledChains: payChains });
439
+ }
440
+ const chain = CHAIN_REGISTRY[chainId];
441
+ if (!chain) {
442
+ return reply.code(400).send({ error: `Unknown chain: ${chainId}` });
443
+ }
444
+
445
+ // Derive address — try per-user tweaked address first, fall back to generic
446
+ const network = chainId === 'btc' ? 'mainnet' : (chainId === 'tbtc3' ? 'testnet' : 'testnet4');
447
+ const didUri = pubkeyToDidNostr(pubkey);
448
+ const userAddress = btAddress(kp.pubkey, [didUri], network);
449
+ const podAddress = btAddress(kp.pubkey, [], network);
450
+
451
+ // Fetch transaction from mempool
452
+ let txData;
453
+ try {
454
+ const txResp = await fetch(`${chain.explorer}/tx/${deposit.txid}`);
455
+ if (!txResp.ok) {
456
+ return reply.code(400).send({ error: 'Transaction not found' });
457
+ }
458
+ txData = await txResp.json();
459
+ } catch (err) {
460
+ return reply.code(502).send({ error: `Failed to verify transaction: ${err.message}` });
461
+ }
462
+ const output = txData.vout?.[deposit.vout];
463
+ if (!output) {
464
+ return reply.code(400).send({ error: `Output ${deposit.vout} not found` });
465
+ }
466
+
467
+ // Verify output pays our address (per-user tweaked or generic pod address)
468
+ const outputAddr = output.scriptpubkey_address;
469
+ const tweak = outputAddr === userAddress ? didUri : null;
470
+ if (outputAddr !== userAddress && outputAddr !== podAddress) {
471
+ return reply.code(400).send({ error: 'Output does not pay this pod\'s address', expected: { user: userAddress, pod: podAddress } });
472
+ }
473
+
474
+ const amount = output.value;
475
+ const currency = chain.unit;
476
+
477
+ // Replay protection + UTXO tracking
478
+ const utxos = await loadUtxos();
479
+ if (utxos.find(u => u.txid === deposit.txid && u.vout === deposit.vout)) {
480
+ return reply.code(400).send({ error: 'This output has already been claimed' });
481
+ }
482
+ utxos.push({ txid: deposit.txid, vout: deposit.vout, amount, scriptpubkey: output.scriptpubkey, chain: chainId, tweak, spent: false });
483
+ await saveUtxos(utxos);
484
+
485
+ const ledger = await readLedger();
486
+ const newBalance = credit(ledger, didUri, amount, currency);
487
+ await writeLedger(ledger);
488
+
489
+ return reply.send({
490
+ did: didUri,
491
+ deposited: amount,
492
+ balance: newBalance,
493
+ unit: currency,
494
+ chain: chainId,
495
+ txid: deposit.txid,
496
+ address: podAddress
497
+ });
498
+ }
499
+
373
500
  return reply.code(400).send({
374
- error: 'Invalid deposit format. Send a TXO URI string or MRC20 state proof.',
501
+ error: 'Invalid deposit format. Send a TXO URI string, MRC20 state proof, or claim {txid, vout}.',
375
502
  formats: {
376
503
  sats: 'POST body: "<txid>:<vout>" or {"txo": "<txid>:<vout>"}',
377
- mrc20: 'POST body: {"type": "mrc20", "state": {...}, "prevState": {...}}'
504
+ mrc20: 'POST body: {"type": "mrc20", "state": {...}, "prevState": {...}}',
505
+ claim: 'POST body: {"txid": "...", "vout": 0, "chain": "tbtc4"}'
378
506
  }
379
507
  });
380
508
  }
@@ -589,6 +717,143 @@ export function createPayHandler(options = {}) {
589
717
  });
590
718
  }
591
719
 
720
+ // --- POST /pay/.withdraw-sats — withdraw sats as a TXO voucher ---
721
+ if (url === '/pay/.withdraw-sats' && request.method === 'POST') {
722
+ const pubkey = await getNostrPubkey(request);
723
+ if (!pubkey) {
724
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
725
+ }
726
+
727
+ let body = request.body;
728
+ try {
729
+ if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
730
+ if (typeof body === 'string') body = JSON.parse(body);
731
+ } catch {
732
+ return reply.code(400).send({ error: 'Invalid JSON body' });
733
+ }
734
+
735
+ const withdrawAmount = parseInt(body?.amount, 10);
736
+ const chainId = body?.chain || (payChains ? payChains[0] : 'tbtc4');
737
+ if (!withdrawAmount || withdrawAmount <= 0) {
738
+ return reply.code(400).send({ error: 'Specify amount to withdraw' });
739
+ }
740
+ if (payChains && !payChains.includes(chainId)) {
741
+ return reply.code(400).send({ error: `Chain '${chainId}' not enabled` });
742
+ }
743
+ const chain = CHAIN_REGISTRY[chainId];
744
+ if (!chain) {
745
+ return reply.code(400).send({ error: `Unknown chain: ${chainId}` });
746
+ }
747
+ const currency = chain.unit;
748
+
749
+ // Check user balance
750
+ const didUri = pubkeyToDidNostr(pubkey);
751
+ const ledger = await readLedger();
752
+ const balance = getBalance(ledger, didUri, currency);
753
+ if (balance < withdrawAmount) {
754
+ return reply.code(402).send({ error: 'Insufficient balance', balance, requested: withdrawAmount, unit: currency });
755
+ }
756
+
757
+ // Find unspent UTXOs for this chain
758
+ const utxos = await loadUtxos();
759
+ const available = utxos.filter(u => u.chain === chainId && !u.spent);
760
+ if (available.length === 0) {
761
+ return reply.code(400).send({ error: 'No UTXOs available for withdrawal' });
762
+ }
763
+
764
+ // Load pod keypair
765
+ const kp = await loadOrCreateKeypair();
766
+
767
+ // Select UTXOs — group by tweak so we can sign with one key
768
+ // Prefer untweaked UTXOs first, then tweaked ones
769
+ const fee = 300;
770
+ const needed = withdrawAmount + fee;
771
+ let selected = [];
772
+ let total = 0;
773
+ let selectedTweak = null;
774
+
775
+ // Try untweaked first
776
+ for (const utxo of available.filter(u => !u.tweak)) {
777
+ selected.push(utxo);
778
+ total += utxo.amount;
779
+ if (total >= needed) break;
780
+ }
781
+ // If not enough, try tweaked (same tweak group only)
782
+ if (total < needed) {
783
+ const tweaked = available.filter(u => u.tweak);
784
+ selected = [];
785
+ total = 0;
786
+ selectedTweak = null;
787
+ for (const utxo of tweaked) {
788
+ if (selectedTweak && utxo.tweak !== selectedTweak) continue;
789
+ selected.push(utxo);
790
+ selectedTweak = utxo.tweak;
791
+ total += utxo.amount;
792
+ if (total >= needed) break;
793
+ }
794
+ }
795
+ if (total < needed) {
796
+ return reply.code(400).send({ error: 'Not enough UTXO value for withdrawal + fee', available: total, needed });
797
+ }
798
+
799
+ // Derive signing key (tweaked if UTXOs are tweaked)
800
+ const privkeyBytes = selectedTweak
801
+ ? btDeriveChainedPrivkey(hexToBytes(kp.privkey), [selectedTweak])
802
+ : hexToBytes(kp.privkey);
803
+
804
+ // Generate a new keypair for the voucher recipient
805
+ const voucherPrivkey = secp256k1.utils.randomPrivateKey();
806
+ const voucherPubkey = secp256k1.getPublicKey(voucherPrivkey, true);
807
+ const voucherXonly = voucherPubkey.slice(1);
808
+ const voucherScript = p2trScript(voucherXonly);
809
+
810
+ // Build outputs: voucher + change back to pod
811
+ const outputs = [{ amount: withdrawAmount, scriptPubKey: voucherScript }];
812
+ const change = total - withdrawAmount - fee;
813
+ const podXonly = hexToBytes(kp.pubkey).slice(1);
814
+ if (change > 546) {
815
+ outputs.push({ amount: change, scriptPubKey: p2trScript(podXonly) });
816
+ }
817
+
818
+ // Build inputs
819
+ const inputs = selected.map(u => ({
820
+ txid: u.txid, vout: u.vout, amount: u.amount, scriptPubKey: hexToBytes(u.scriptpubkey)
821
+ }));
822
+
823
+ // Build and broadcast
824
+ let newTxid;
825
+ try {
826
+ const rawTx = buildTransaction(inputs, outputs, privkeyBytes);
827
+ newTxid = await broadcastTx(rawTx, chain.explorer.replace(/\/api$/, ''));
828
+ } catch (err) {
829
+ return reply.code(500).send({ error: `Broadcast failed: ${err.message}` });
830
+ }
831
+
832
+ // Debit user balance
833
+ const debitResult = debit(ledger, didUri, withdrawAmount, currency);
834
+ if (!debitResult.success) {
835
+ return reply.code(402).send({ error: 'Balance changed during withdrawal', balance: debitResult.balance });
836
+ }
837
+ await writeLedger(ledger);
838
+
839
+ // Mark UTXOs as spent, add change UTXO
840
+ for (const u of selected) { u.spent = true; }
841
+ if (change > 546) {
842
+ utxos.push({ txid: newTxid, vout: 1, amount: change, scriptpubkey: '5120' + bytesToHex(podXonly), chain: chainId, spent: false });
843
+ }
844
+ await saveUtxos(utxos);
845
+
846
+ // Return voucher URI
847
+ const voucherUri = `txo:${chainId}:${newTxid}:0?amount=${withdrawAmount}&key=${bytesToHex(voucherPrivkey)}`;
848
+ return reply.send({
849
+ voucher: voucherUri,
850
+ txid: newTxid,
851
+ amount: withdrawAmount,
852
+ unit: currency,
853
+ balance: getBalance(ledger, didUri, currency)
854
+ });
855
+ }
856
+
592
857
  // --- GET /pay/.offers — list open sell orders ---
593
858
  if (url === '/pay/.offers' && request.method === 'GET') {
594
859
  const offers = await loadOffers();
package/src/token.js CHANGED
@@ -98,7 +98,7 @@ function btDeriveChainedPubkey(pubkeyBase, states) {
98
98
  return cur;
99
99
  }
100
100
 
101
- function btDeriveChainedPrivkey(privkeyBytes, states) {
101
+ export function btDeriveChainedPrivkey(privkeyBytes, states) {
102
102
  let d = bytesToBigInt(privkeyBytes);
103
103
  let cur = new Uint8Array(secp256k1.getPublicKey(privkeyBytes, true));
104
104
  for (const s of states) {
@@ -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' },