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 +1 -1
- package/src/handlers/pay.js +269 -4
- package/src/token.js +4 -4
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, 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
|
|
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' },
|