javascript-solid-server 0.0.102 → 0.0.104

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/bin/jss.js CHANGED
@@ -85,6 +85,8 @@ program
85
85
  .option('--pay-cost <n>', 'Cost per request in satoshis (default: 1)', parseInt)
86
86
  .option('--pay-mempool-url <url>', 'Mempool API URL for deposit verification')
87
87
  .option('--pay-address <addr>', 'Address for receiving deposits')
88
+ .option('--pay-token <ticker>', 'Token to sell (enables primary market)')
89
+ .option('--pay-rate <n>', 'Sats per token for primary market (default: 1)', parseInt)
88
90
  .option('--mongo', 'Enable MongoDB-backed /db/ route')
89
91
  .option('--no-mongo', 'Disable MongoDB-backed /db/ route')
90
92
  .option('--mongo-url <url>', 'MongoDB connection URL (default: mongodb://localhost:27017)')
@@ -155,6 +157,8 @@ program
155
157
  payCost: config.payCost,
156
158
  payMempoolUrl: config.payMempoolUrl,
157
159
  payAddress: config.payAddress,
160
+ payToken: config.payToken,
161
+ payRate: config.payRate,
158
162
  mongo: config.mongo,
159
163
  mongoUrl: config.mongoUrl,
160
164
  mongoDatabase: config.mongoDatabase,
@@ -193,7 +197,10 @@ program
193
197
  }
194
198
  console.log(' Do not expose to the internet!');
195
199
  }
196
- if (config.pay) console.log(` Pay: ${config.payCost} sat/req (402 enabled)`);
200
+ if (config.pay) {
201
+ console.log(` Pay: ${config.payCost} sat/req (402 enabled)`);
202
+ if (config.payToken) console.log(` Token: ${config.payToken} @ ${config.payRate} sat/token`);
203
+ }
197
204
  if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
198
205
  if (config.readOnly) console.log(' Read-only: enabled (PUT/DELETE/PATCH disabled)');
199
206
  console.log('\n Press Ctrl+C to stop\n');
@@ -477,6 +484,128 @@ quotaCmd
477
484
  }
478
485
  });
479
486
 
487
+ /**
488
+ * Token command - manage MRC20 tokens
489
+ */
490
+ const tokenCmd = program
491
+ .command('token')
492
+ .description('Manage MRC20 tokens anchored to Bitcoin');
493
+
494
+ tokenCmd
495
+ .command('mint')
496
+ .description('Create a new MRC20 token')
497
+ .requiredOption('-t, --ticker <ticker>', 'Token ticker symbol')
498
+ .requiredOption('-s, --supply <n>', 'Total supply', parseInt)
499
+ .requiredOption('-v, --voucher <txo>', 'Funded TXO URI (txo:btc:txid:vout?amount=N&key=hex)')
500
+ .option('-n, --name <name>', 'Token name (defaults to ticker)')
501
+ .option('-r, --root <path>', 'Data directory')
502
+ .option('--mempool-url <url>', 'Mempool API URL', 'https://mempool.space/testnet4')
503
+ .option('--network <net>', 'Bitcoin network (testnet4 or mainnet)', 'testnet4')
504
+ .action(async (options) => {
505
+ try {
506
+ if (options.root) process.env.DATA_ROOT = path.resolve(options.root);
507
+ const { mintToken } = await import('../src/token.js');
508
+ console.log(`\nMinting ${options.supply} ${options.ticker}...`);
509
+ const result = await mintToken({
510
+ ticker: options.ticker,
511
+ name: options.name,
512
+ supply: options.supply,
513
+ voucher: options.voucher,
514
+ mempoolUrl: options.mempoolUrl,
515
+ network: options.network
516
+ });
517
+ console.log(`\nToken minted!`);
518
+ console.log(` Ticker: ${options.ticker}`);
519
+ console.log(` Supply: ${options.supply}`);
520
+ console.log(` Issuer: ${result.trail.pubkeyBase}`);
521
+ console.log(` TX: ${result.txid}`);
522
+ console.log(` Address: ${result.address}`);
523
+ console.log('');
524
+ } catch (err) {
525
+ console.error(`Error: ${err.message}`);
526
+ process.exit(1);
527
+ }
528
+ });
529
+
530
+ tokenCmd
531
+ .command('transfer')
532
+ .description('Transfer tokens to an address')
533
+ .requiredOption('-t, --ticker <ticker>', 'Token ticker symbol')
534
+ .requiredOption('--to <address>', 'Recipient address (pubkey hex)')
535
+ .requiredOption('-a, --amount <n>', 'Amount to transfer', parseInt)
536
+ .option('-r, --root <path>', 'Data directory')
537
+ .option('--mempool-url <url>', 'Mempool API URL', 'https://mempool.space/testnet4')
538
+ .action(async (options) => {
539
+ try {
540
+ if (options.root) process.env.DATA_ROOT = path.resolve(options.root);
541
+ const { transferToken } = await import('../src/token.js');
542
+ console.log(`\nTransferring ${options.amount} ${options.ticker} to ${options.to.slice(0, 16)}...`);
543
+ const result = await transferToken({
544
+ ticker: options.ticker,
545
+ to: options.to,
546
+ amount: options.amount,
547
+ mempoolUrl: options.mempoolUrl
548
+ });
549
+ console.log(`\nTransfer complete!`);
550
+ console.log(` TX: ${result.txid}`);
551
+ console.log(` Address: ${result.address}`);
552
+ console.log(` Balance: ${JSON.stringify(result.trail.states[result.trail.states.length - 1].balances)}`);
553
+ console.log('');
554
+ } catch (err) {
555
+ console.error(`Error: ${err.message}`);
556
+ process.exit(1);
557
+ }
558
+ });
559
+
560
+ tokenCmd
561
+ .command('info [ticker]')
562
+ .description('Show token info (or list all tokens)')
563
+ .option('-r, --root <path>', 'Data directory')
564
+ .action(async (ticker, options) => {
565
+ try {
566
+ if (options.root) process.env.DATA_ROOT = path.resolve(options.root);
567
+ const { tokenInfo, listTrails } = await import('../src/token.js');
568
+
569
+ if (!ticker) {
570
+ // List all tokens
571
+ const trails = await listTrails();
572
+ if (trails.length === 0) {
573
+ console.log('\nNo tokens found.\n');
574
+ return;
575
+ }
576
+ console.log('\n TICKER SUPPLY SEQ SATS CREATED');
577
+ console.log(' ' + '-'.repeat(55));
578
+ for (const t of trails) {
579
+ const state = t.states[t.states.length - 1];
580
+ console.log(` ${t.ticker.padEnd(8)} ${String(t.supply).padEnd(8)} ${String(state.seq).padEnd(5)} ${String(t.currentAmount).padEnd(10)} ${t.dateCreated.split('T')[0]}`);
581
+ }
582
+ console.log('');
583
+ return;
584
+ }
585
+
586
+ const info = await tokenInfo(ticker);
587
+ console.log(`\n ${info.ticker} — ${info.name}`);
588
+ console.log(' ' + '-'.repeat(40));
589
+ console.log(` Supply: ${info.supply}`);
590
+ console.log(` Seq: ${info.seq}`);
591
+ console.log(` Issuer: ${info.pubkeyBase}`);
592
+ console.log(` Network: ${info.network}`);
593
+ console.log(` TX: ${info.currentTxid}`);
594
+ console.log(` Address: ${info.currentAddress}`);
595
+ console.log(` UTXO sats: ${info.currentAmount}`);
596
+ console.log(` Created: ${info.dateCreated}`);
597
+ console.log(' Balances:');
598
+ for (const [addr, bal] of Object.entries(info.balances)) {
599
+ const label = addr === info.pubkeyBase ? `${addr.slice(0, 16)}... (issuer)` : `${addr.slice(0, 16)}...`;
600
+ console.log(` ${label}: ${bal}`);
601
+ }
602
+ console.log('');
603
+ } catch (err) {
604
+ console.error(`Error: ${err.message}`);
605
+ process.exit(1);
606
+ }
607
+ });
608
+
480
609
  /**
481
610
  * Helper: Prompt for input
482
611
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.102",
3
+ "version": "0.0.104",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/config.js CHANGED
@@ -88,6 +88,8 @@ export const defaults = {
88
88
  payCost: 1,
89
89
  payMempoolUrl: 'https://mempool.space/testnet4',
90
90
  payAddress: null,
91
+ payToken: null,
92
+ payRate: 1,
91
93
 
92
94
  // MongoDB-backed /db/ route
93
95
  mongo: false,
@@ -148,6 +150,8 @@ const envMap = {
148
150
  JSS_PAY_COST: 'payCost',
149
151
  JSS_PAY_MEMPOOL_URL: 'payMempoolUrl',
150
152
  JSS_PAY_ADDRESS: 'payAddress',
153
+ JSS_PAY_TOKEN: 'payToken',
154
+ JSS_PAY_RATE: 'payRate',
151
155
  JSS_MONGO: 'mongo',
152
156
  JSS_MONGO_URL: 'mongoUrl',
153
157
  JSS_MONGO_DATABASE: 'mongoDatabase',
@@ -177,7 +181,7 @@ function parseEnvValue(value, key) {
177
181
  if (value.toLowerCase() === 'false') return false;
178
182
 
179
183
  // Numeric values for known numeric keys
180
- if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost') && !isNaN(value)) {
184
+ if ((key === 'port' || key === 'nostrMaxEvents' || key === 'payCost' || key === 'payRate') && !isNaN(value)) {
181
185
  return parseInt(value, 10);
182
186
  }
183
187
 
@@ -315,7 +319,10 @@ export function printConfig(config) {
315
319
  console.log(` Subdomains: ${config.subdomains ? (config.baseDomain || 'enabled') : 'disabled'}`);
316
320
  console.log(` Mashlib: ${config.mashlibModule ? `module (${config.mashlibModule})` : config.mashlibCdn ? `CDN v${config.mashlibVersion}` : config.mashlib ? 'local' : 'disabled'}`);
317
321
  console.log(` SolidOS UI: ${config.solidosUi ? 'enabled' : 'disabled'}`);
318
- if (config.pay) console.log(` Pay: ${config.payCost} sat/req`);
322
+ if (config.pay) {
323
+ console.log(` Pay: ${config.payCost} sat/req`);
324
+ if (config.payToken) console.log(` Token: ${config.payToken} @ ${config.payRate} sat/token`);
325
+ }
319
326
  if (config.mongo) console.log(` MongoDB: ${config.mongoUrl} (${config.mongoDatabase})`);
320
327
  console.log('─'.repeat(40));
321
328
  }
@@ -5,10 +5,12 @@
5
5
  * Authentication via NIP-98. Balance tracking via Web Ledgers spec.
6
6
  *
7
7
  * Routes:
8
- * GET /pay/.balance — check your balance
9
- * POST /pay/.deposit — deposit sats (TXO URI) or tokens (MRC20 state proof)
10
- * GET /pay/* paid resource access (requires balance >= cost)
11
- * PUT /pay/* upload resources (standard auth)
8
+ * GET /pay/.balance — check your balance
9
+ * POST /pay/.deposit — deposit sats (TXO URI) or tokens (MRC20 state proof)
10
+ * POST /pay/.buy buy tokens with sat balance (primary market)
11
+ * POST /pay/.withdraw withdraw balance as tokens (portable MRC20 proof)
12
+ * GET /pay/* — paid resource access (requires balance >= cost)
13
+ * PUT /pay/* — upload resources (standard auth)
12
14
  *
13
15
  * Ledger: /.well-known/webledgers/webledgers.json (webledgers.org spec)
14
16
  *
@@ -21,7 +23,8 @@
21
23
 
22
24
  import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
23
25
  import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
24
- import { verifyMrc20Deposit, verifyMrc20Anchor, jcs } from '../mrc20.js';
26
+ import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, sha256Hex } from '../mrc20.js';
27
+ import { loadTrail, transferToken } from '../token.js';
25
28
  import fs from 'fs-extra';
26
29
  import path from 'path';
27
30
 
@@ -147,6 +150,8 @@ export function createPayHandler(options = {}) {
147
150
  const cost = options.cost ?? DEFAULT_COST;
148
151
  const mempoolUrl = options.mempoolUrl ?? 'https://mempool.space/testnet4';
149
152
  const payAddress = options.payAddress ?? null;
153
+ const payToken = options.payToken ?? null;
154
+ const payRate = options.payRate ?? 1;
150
155
 
151
156
  return async function payHandler(request, reply) {
152
157
  const url = request.url.split('?')[0];
@@ -262,6 +267,200 @@ export function createPayHandler(options = {}) {
262
267
  });
263
268
  }
264
269
 
270
+ // --- POST /pay/.buy — primary market: buy tokens with sats ---
271
+ if (url === '/pay/.buy' && request.method === 'POST') {
272
+ const pubkey = await getNostrPubkey(request);
273
+ if (!pubkey) {
274
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
275
+ }
276
+
277
+ if (!payToken) {
278
+ return reply.code(400).send({ error: 'Primary market not configured (no --pay-token set)' });
279
+ }
280
+
281
+ // Parse buy request
282
+ let body = request.body;
283
+ if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
284
+ if (typeof body === 'string') body = JSON.parse(body);
285
+
286
+ const ticker = body?.ticker || payToken;
287
+ if (ticker !== payToken) {
288
+ return reply.code(400).send({ error: `This pod only sells ${payToken}` });
289
+ }
290
+
291
+ // Calculate amount and cost
292
+ let tokenAmount, satCost;
293
+ if (body?.amount) {
294
+ tokenAmount = Math.floor(body.amount);
295
+ satCost = tokenAmount * payRate;
296
+ } else if (body?.sats) {
297
+ satCost = Math.floor(body.sats);
298
+ tokenAmount = Math.floor(satCost / payRate);
299
+ } else {
300
+ return reply.code(400).send({
301
+ error: 'Specify amount (tokens to buy) or sats (sats to spend)',
302
+ rate: payRate,
303
+ unit: 'sat/token'
304
+ });
305
+ }
306
+
307
+ if (tokenAmount <= 0) {
308
+ return reply.code(400).send({ error: 'Amount must be positive' });
309
+ }
310
+
311
+ // Check sat balance
312
+ const didUri = pubkeyToDidNostr(pubkey);
313
+ const ledger = await readLedger();
314
+ const balance = getBalance(ledger, didUri);
315
+ if (balance < satCost) {
316
+ return reply.code(402).send({
317
+ error: 'Insufficient sat balance',
318
+ balance,
319
+ cost: satCost,
320
+ rate: payRate,
321
+ deposit: '/pay/.deposit'
322
+ });
323
+ }
324
+
325
+ // Load token trail
326
+ const trail = await loadTrail(ticker);
327
+ if (!trail) {
328
+ return reply.code(500).send({ error: `Token ${ticker} not minted on this pod` });
329
+ }
330
+
331
+ // Transfer tokens to buyer
332
+ let result;
333
+ try {
334
+ result = await transferToken({
335
+ ticker,
336
+ to: pubkey,
337
+ amount: tokenAmount,
338
+ mempoolUrl
339
+ });
340
+ } catch (err) {
341
+ return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
342
+ }
343
+
344
+ // Debit sats from buyer
345
+ debit(ledger, didUri, satCost);
346
+ await writeLedger(ledger);
347
+
348
+ return reply.send({
349
+ bought: tokenAmount,
350
+ ticker,
351
+ cost: satCost,
352
+ rate: payRate,
353
+ balance: getBalance(ledger, didUri),
354
+ unit: 'sat',
355
+ txid: result.txid,
356
+ proof: {
357
+ state: result.state,
358
+ prevState: result.prevState,
359
+ anchor: {
360
+ pubkey: result.trail.pubkeyBase,
361
+ stateStrings: result.trail.stateStrings,
362
+ network: result.trail.network
363
+ }
364
+ }
365
+ });
366
+ }
367
+
368
+ // --- POST /pay/.withdraw — withdraw balance as tokens ---
369
+ if (url === '/pay/.withdraw' && request.method === 'POST') {
370
+ const pubkey = await getNostrPubkey(request);
371
+ if (!pubkey) {
372
+ return reply.code(401).send({ error: 'NIP-98 authentication required' });
373
+ }
374
+
375
+ if (!payToken) {
376
+ return reply.code(400).send({ error: 'Withdrawal not configured (no --pay-token set)' });
377
+ }
378
+
379
+ // Parse withdraw request
380
+ let body = request.body;
381
+ if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
382
+ if (typeof body === 'string') body = JSON.parse(body);
383
+
384
+ const didUri = pubkeyToDidNostr(pubkey);
385
+ const ledger = await readLedger();
386
+ const balance = getBalance(ledger, didUri);
387
+
388
+ // Calculate withdrawal amount
389
+ let satCost, tokenAmount;
390
+ if (body?.all) {
391
+ satCost = balance;
392
+ tokenAmount = Math.floor(balance / payRate);
393
+ } else if (body?.sats) {
394
+ satCost = Math.floor(body.sats);
395
+ tokenAmount = Math.floor(satCost / payRate);
396
+ } else if (body?.tokens) {
397
+ tokenAmount = Math.floor(body.tokens);
398
+ satCost = tokenAmount * payRate;
399
+ } else {
400
+ return reply.code(400).send({
401
+ error: 'Specify tokens, sats, or all: true',
402
+ balance,
403
+ rate: payRate,
404
+ unit: 'sat/token'
405
+ });
406
+ }
407
+
408
+ if (tokenAmount <= 0) {
409
+ return reply.code(400).send({ error: 'Nothing to withdraw', balance, rate: payRate });
410
+ }
411
+
412
+ if (balance < satCost) {
413
+ return reply.code(402).send({
414
+ error: 'Insufficient balance',
415
+ balance,
416
+ cost: satCost,
417
+ rate: payRate
418
+ });
419
+ }
420
+
421
+ // Load token trail
422
+ const trail = await loadTrail(payToken);
423
+ if (!trail) {
424
+ return reply.code(500).send({ error: `Token ${payToken} not minted on this pod` });
425
+ }
426
+
427
+ // Transfer tokens to user
428
+ let result;
429
+ try {
430
+ result = await transferToken({
431
+ ticker: payToken,
432
+ to: pubkey,
433
+ amount: tokenAmount,
434
+ mempoolUrl
435
+ });
436
+ } catch (err) {
437
+ return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
438
+ }
439
+
440
+ // Debit balance
441
+ debit(ledger, didUri, satCost);
442
+ await writeLedger(ledger);
443
+
444
+ return reply.send({
445
+ withdrawn: tokenAmount,
446
+ ticker: payToken,
447
+ cost: satCost,
448
+ rate: payRate,
449
+ balance: getBalance(ledger, didUri),
450
+ unit: 'sat',
451
+ txid: result.txid,
452
+ proof: {
453
+ state: result.state,
454
+ prevState: result.prevState,
455
+ anchor: {
456
+ pubkey: result.trail.pubkeyBase,
457
+ stateStrings: result.trail.stateStrings,
458
+ network: result.trail.network
459
+ }
460
+ }
461
+ });
462
+ }
463
+
265
464
  // --- GET/HEAD /pay/* — paid resource access ---
266
465
  if (request.method === 'GET' || request.method === 'HEAD') {
267
466
  const pubkey = await getNostrPubkey(request);
package/src/server.js CHANGED
@@ -100,6 +100,8 @@ export function createServer(options = {}) {
100
100
  const payCost = options.payCost ?? 1;
101
101
  const payMempoolUrl = options.payMempoolUrl ?? 'https://mempool.space/testnet4';
102
102
  const payAddress = options.payAddress ?? null; // Pod's MRC20 address for token deposits
103
+ const payToken = options.payToken ?? null; // Token ticker for primary market
104
+ const payRate = options.payRate ?? 1; // Sats per token
103
105
 
104
106
  // Set data root via environment variable if provided
105
107
  if (options.root) {
@@ -374,7 +376,7 @@ export function createServer(options = {}) {
374
376
 
375
377
  // HTTP 402 Payment Required handler for /pay/* routes
376
378
  if (payEnabled) {
377
- fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress }));
379
+ fastify.addHook('preHandler', createPayHandler({ cost: payCost, mempoolUrl: payMempoolUrl, payAddress, payToken, payRate }));
378
380
  }
379
381
 
380
382
  // Authorization hook - check WAC permissions
package/src/token.js ADDED
@@ -0,0 +1,412 @@
1
+ /**
2
+ * MRC20 Token Management
3
+ *
4
+ * Create, manage, and transfer MRC20 tokens anchored to Bitcoin via blocktrails.
5
+ * Uses BIP-341 key chaining to derive unique taproot addresses for each state.
6
+ *
7
+ * Trail state stored at: {DATA_ROOT}/.well-known/token/{ticker}.json
8
+ *
9
+ * References:
10
+ * - Blocktrails: https://blocktrails.org/
11
+ * - BIP-341: https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki
12
+ */
13
+
14
+ import crypto from 'crypto';
15
+ import fs from 'fs-extra';
16
+ import path from 'path';
17
+ import { secp256k1, schnorr } from '@noble/curves/secp256k1';
18
+ import { jcs, sha256Hex, btAddress } from './mrc20.js';
19
+
20
+ // --- Constants ---
21
+ const SECP_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
22
+ const MRC20_PROFILE = 'mono.mrc20.v0.1';
23
+
24
+ // --- Byte helpers ---
25
+ function hexToU8(hex) {
26
+ const bytes = new Uint8Array(hex.length / 2);
27
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
28
+ return bytes;
29
+ }
30
+ function bytesToHex(bytes) {
31
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
32
+ }
33
+ function bytesToBigInt(bytes) {
34
+ let r = 0n;
35
+ for (const b of bytes) r = (r << 8n) | BigInt(b);
36
+ return r;
37
+ }
38
+ function bigIntToBytes(n) {
39
+ return hexToU8(n.toString(16).padStart(64, '0'));
40
+ }
41
+ function concatBytes(...arrays) {
42
+ const total = arrays.reduce((s, a) => s + a.length, 0);
43
+ const result = new Uint8Array(total);
44
+ let off = 0;
45
+ for (const a of arrays) { result.set(a, off); off += a.length; }
46
+ return result;
47
+ }
48
+
49
+ // --- Serialization helpers ---
50
+ function writeU32LE(val) {
51
+ const b = new Uint8Array(4);
52
+ b[0] = val & 0xff; b[1] = (val >> 8) & 0xff; b[2] = (val >> 16) & 0xff; b[3] = (val >> 24) & 0xff;
53
+ return b;
54
+ }
55
+ function writeU64LE(val) {
56
+ const b = new Uint8Array(8);
57
+ const n = BigInt(val);
58
+ for (let i = 0; i < 8; i++) b[i] = Number((n >> BigInt(i * 8)) & 0xffn);
59
+ return b;
60
+ }
61
+ function writeVarInt(val) {
62
+ if (val < 0xfd) return new Uint8Array([val]);
63
+ if (val <= 0xffff) return new Uint8Array([0xfd, val & 0xff, (val >> 8) & 0xff]);
64
+ throw new Error('VarInt too large');
65
+ }
66
+ function reverseTxid(txidHex) {
67
+ const bytes = hexToU8(txidHex);
68
+ bytes.reverse();
69
+ return bytes;
70
+ }
71
+
72
+ // --- Crypto ---
73
+ function sha256Bytes(data) {
74
+ const buf = data instanceof Uint8Array ? Buffer.from(data) : Buffer.from(data, 'utf8');
75
+ return new Uint8Array(crypto.createHash('sha256').update(buf).digest());
76
+ }
77
+ function taggedHash(tag, ...msgs) {
78
+ const tagHash = sha256Bytes(Buffer.from(tag, 'utf8'));
79
+ return sha256Bytes(concatBytes(tagHash, tagHash, ...msgs));
80
+ }
81
+
82
+ // --- BIP-341 key chaining ---
83
+ function btScalar(pubkeyCompressed, state) {
84
+ const xOnly = pubkeyCompressed.slice(1);
85
+ const stateBytes = typeof state === 'string' ? Buffer.from(state, 'utf8') : state;
86
+ const sh = sha256Bytes(stateBytes);
87
+ return bytesToBigInt(taggedHash('TapTweak', xOnly, sh)) % SECP_N;
88
+ }
89
+
90
+ function btDeriveChainedPubkey(pubkeyBase, states) {
91
+ let P = secp256k1.ProjectivePoint.fromHex(bytesToHex(pubkeyBase));
92
+ let cur = pubkeyBase;
93
+ for (const s of states) {
94
+ const t = btScalar(cur, s);
95
+ P = P.add(secp256k1.ProjectivePoint.BASE.multiply(t));
96
+ cur = new Uint8Array(P.toRawBytes(true));
97
+ }
98
+ return cur;
99
+ }
100
+
101
+ function btDeriveChainedPrivkey(privkeyBytes, states) {
102
+ let d = bytesToBigInt(privkeyBytes);
103
+ let cur = new Uint8Array(secp256k1.getPublicKey(privkeyBytes, true));
104
+ for (const s of states) {
105
+ const t = btScalar(cur, s);
106
+ d = (d + t) % SECP_N;
107
+ cur = new Uint8Array(secp256k1.ProjectivePoint.BASE.multiply(d).toRawBytes(true));
108
+ }
109
+ return bigIntToBytes(d);
110
+ }
111
+
112
+ function p2trScript(xonly) {
113
+ return concatBytes(new Uint8Array([0x51, 0x20]), xonly);
114
+ }
115
+
116
+ // --- Bitcoin transaction building ---
117
+ function buildTransaction(inputs, outputs, privkeyBytes) {
118
+ const internalXOnly = new Uint8Array(secp256k1.getPublicKey(privkeyBytes, true)).slice(1);
119
+ const untweakedHex = '5120' + bytesToHex(internalXOnly);
120
+ const needsTweak = bytesToHex(inputs[0].scriptPubKey) !== untweakedHex;
121
+ let signingKey = privkeyBytes;
122
+ if (needsTweak) {
123
+ const tweak = taggedHash('TapTweak', internalXOnly);
124
+ const t = bytesToBigInt(tweak);
125
+ let d = bytesToBigInt(privkeyBytes);
126
+ const fullPub = secp256k1.getPublicKey(privkeyBytes, false);
127
+ if (fullPub[64] & 1) d = SECP_N - d;
128
+ signingKey = bigIntToBytes((d + t) % SECP_N);
129
+ }
130
+
131
+ const version = 2, locktime = 0, sequence = 0xfffffffd;
132
+ const serOutputs = outputs.map(o =>
133
+ concatBytes(writeU64LE(o.amount), writeVarInt(o.scriptPubKey.length), o.scriptPubKey)
134
+ );
135
+
136
+ const shaPrevouts = sha256Bytes(concatBytes(...inputs.map(i =>
137
+ concatBytes(reverseTxid(i.txid), writeU32LE(i.vout))
138
+ )));
139
+ const shaAmounts = sha256Bytes(concatBytes(...inputs.map(i => writeU64LE(i.amount))));
140
+ const shaScriptPubKeys = sha256Bytes(concatBytes(...inputs.map(i =>
141
+ concatBytes(writeVarInt(i.scriptPubKey.length), i.scriptPubKey)
142
+ )));
143
+ const shaSequences = sha256Bytes(concatBytes(...inputs.map(() => writeU32LE(sequence))));
144
+ const shaOutputs = sha256Bytes(concatBytes(...serOutputs));
145
+
146
+ const sigs = [];
147
+ for (let i = 0; i < inputs.length; i++) {
148
+ const sigMsg = concatBytes(
149
+ new Uint8Array([0x00, 0x00]),
150
+ writeU32LE(version), writeU32LE(locktime),
151
+ shaPrevouts, shaAmounts, shaScriptPubKeys, shaSequences, shaOutputs,
152
+ new Uint8Array([0x00]),
153
+ writeU32LE(i)
154
+ );
155
+ const sighash = taggedHash('TapSighash', sigMsg);
156
+ sigs.push(schnorr.sign(sighash, signingKey));
157
+ }
158
+
159
+ const parts = [
160
+ writeU32LE(version),
161
+ new Uint8Array([0x00, 0x01]),
162
+ writeVarInt(inputs.length)
163
+ ];
164
+ for (const inp of inputs) {
165
+ parts.push(reverseTxid(inp.txid), writeU32LE(inp.vout), new Uint8Array([0x00]), writeU32LE(sequence));
166
+ }
167
+ parts.push(writeVarInt(outputs.length));
168
+ for (const so of serOutputs) parts.push(so);
169
+ for (const sig of sigs) {
170
+ parts.push(new Uint8Array([0x01]), writeVarInt(sig.length), sig);
171
+ }
172
+ parts.push(writeU32LE(locktime));
173
+ return bytesToHex(concatBytes(...parts));
174
+ }
175
+
176
+ async function broadcastTx(rawTxHex, mempoolUrl) {
177
+ const res = await fetch(`${mempoolUrl}/api/tx`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'text/plain' },
180
+ body: rawTxHex
181
+ });
182
+ if (!res.ok) {
183
+ const err = await res.text();
184
+ throw new Error(`Broadcast failed: ${err}`);
185
+ }
186
+ return res.text();
187
+ }
188
+
189
+ // --- Trail persistence ---
190
+ function trailDir() {
191
+ return path.join(process.env.DATA_ROOT || './data', '.well-known', 'token');
192
+ }
193
+
194
+ function trailPath(ticker) {
195
+ return path.join(trailDir(), `${ticker.toLowerCase()}.json`);
196
+ }
197
+
198
+ export async function loadTrail(ticker) {
199
+ try {
200
+ const data = await fs.readFile(trailPath(ticker), 'utf8');
201
+ return JSON.parse(data);
202
+ } catch { return null; }
203
+ }
204
+
205
+ async function saveTrail(trail) {
206
+ await fs.ensureDir(trailDir());
207
+ await fs.writeFile(trailPath(trail.ticker), JSON.stringify(trail, null, 2));
208
+ }
209
+
210
+ export async function listTrails() {
211
+ try {
212
+ const files = await fs.readdir(trailDir());
213
+ const trails = [];
214
+ for (const f of files) {
215
+ if (f.endsWith('.json')) {
216
+ const data = await fs.readFile(path.join(trailDir(), f), 'utf8');
217
+ trails.push(JSON.parse(data));
218
+ }
219
+ }
220
+ return trails;
221
+ } catch { return []; }
222
+ }
223
+
224
+ // --- TXO URI parsing ---
225
+ export function parseTxoUri(uri) {
226
+ // txo:btc:txid:vout?amount=N&key=hex
227
+ const match = uri.match(/(?:txo:btc:)?([0-9a-f]{64}):(\d+)\?amount=(\d+)&key=([0-9a-f]{64})/i);
228
+ if (!match) throw new Error('Invalid TXO URI format');
229
+ return {
230
+ txid: match[1],
231
+ vout: parseInt(match[2], 10),
232
+ amount: parseInt(match[3], 10),
233
+ privkey: match[4]
234
+ };
235
+ }
236
+
237
+ // --- Mint: create genesis MRC20 token ---
238
+ export async function mintToken({ ticker, name, supply, voucher, mempoolUrl = 'https://mempool.space/testnet4', network = 'testnet4' }) {
239
+ const txo = parseTxoUri(voucher);
240
+ const privkeyBytes = hexToU8(txo.privkey);
241
+ const pubkeyBase = new Uint8Array(secp256k1.getPublicKey(privkeyBytes, true));
242
+ const pubkeyBaseHex = bytesToHex(pubkeyBase);
243
+
244
+ // Check if token already exists
245
+ const existing = await loadTrail(ticker);
246
+ if (existing) throw new Error(`Token ${ticker} already exists`);
247
+
248
+ // Create genesis MRC20 state
249
+ const genesisState = {
250
+ profile: MRC20_PROFILE,
251
+ prev: '0'.repeat(64),
252
+ seq: 0,
253
+ ticker,
254
+ name: name || ticker,
255
+ decimals: 0,
256
+ supply,
257
+ balances: { [pubkeyBaseHex]: supply },
258
+ ops: []
259
+ };
260
+ const genesisJcs = jcs(genesisState);
261
+
262
+ // Derive genesis taproot address
263
+ const genesisP = btDeriveChainedPubkey(pubkeyBase, [genesisJcs]);
264
+ const genesisXonly = genesisP.slice(1);
265
+ const genesisScript = p2trScript(genesisXonly);
266
+ const genesisAddr = btAddress(pubkeyBaseHex, [genesisJcs], network);
267
+
268
+ // Fetch voucher scriptPubKey
269
+ const txResp = await fetch(`${mempoolUrl}/api/tx/${txo.txid}`);
270
+ if (!txResp.ok) throw new Error('Could not fetch voucher transaction');
271
+ const txData = await txResp.json();
272
+ const prevOut = txData.vout?.[txo.vout];
273
+ if (!prevOut) throw new Error(`Voucher output ${txo.vout} not found`);
274
+ const scriptPubKey = hexToU8(prevOut.scriptpubkey);
275
+
276
+ // Build and broadcast genesis tx
277
+ const fee = 300;
278
+ const outputAmount = txo.amount - fee;
279
+ if (outputAmount <= 546) throw new Error('Voucher too small for fee');
280
+
281
+ const rawTx = buildTransaction(
282
+ [{ txid: txo.txid, vout: txo.vout, amount: txo.amount, scriptPubKey }],
283
+ [{ amount: outputAmount, scriptPubKey: genesisScript }],
284
+ privkeyBytes
285
+ );
286
+ const newTxid = await broadcastTx(rawTx, mempoolUrl);
287
+
288
+ // Save trail
289
+ const trail = {
290
+ ticker,
291
+ name: name || ticker,
292
+ supply,
293
+ privkey: txo.privkey,
294
+ pubkeyBase: pubkeyBaseHex,
295
+ states: [genesisState],
296
+ stateStrings: [genesisJcs],
297
+ currentTxid: newTxid,
298
+ currentVout: 0,
299
+ currentAmount: outputAmount,
300
+ network,
301
+ dateCreated: new Date().toISOString()
302
+ };
303
+ await saveTrail(trail);
304
+
305
+ return { trail, txid: newTxid, address: genesisAddr };
306
+ }
307
+
308
+ // --- Transfer: send tokens to an address ---
309
+ export async function transferToken({ ticker, to, amount, mempoolUrl = 'https://mempool.space/testnet4' }) {
310
+ const trail = await loadTrail(ticker);
311
+ if (!trail) throw new Error(`Token ${ticker} not found`);
312
+
313
+ const privkeyBytes = hexToU8(trail.privkey);
314
+ const pubkeyBase = hexToU8(trail.pubkeyBase);
315
+
316
+ // Get current state
317
+ const currentState = trail.states[trail.states.length - 1];
318
+ const currentBalances = { ...currentState.balances };
319
+
320
+ // Check issuer balance
321
+ const issuerAddr = trail.pubkeyBase;
322
+ const issuerBalance = currentBalances[issuerAddr] || 0;
323
+ if (issuerBalance < amount) {
324
+ throw new Error(`Insufficient balance: ${issuerBalance} < ${amount}`);
325
+ }
326
+
327
+ // Create transfer state
328
+ currentBalances[issuerAddr] = issuerBalance - amount;
329
+ currentBalances[to] = (currentBalances[to] || 0) + amount;
330
+ // Remove zero balances
331
+ for (const [k, v] of Object.entries(currentBalances)) {
332
+ if (v === 0) delete currentBalances[k];
333
+ }
334
+
335
+ const prevJcs = trail.stateStrings[trail.stateStrings.length - 1];
336
+ const newState = {
337
+ profile: MRC20_PROFILE,
338
+ prev: sha256Hex(prevJcs),
339
+ seq: currentState.seq + 1,
340
+ ticker: trail.ticker,
341
+ name: trail.name,
342
+ decimals: 0,
343
+ supply: trail.supply,
344
+ balances: currentBalances,
345
+ ops: [{ op: 'urn:mono:op:transfer', from: issuerAddr, to, amt: amount }]
346
+ };
347
+ const newJcs = jcs(newState);
348
+
349
+ // Derive new address
350
+ const allStateStrings = [...trail.stateStrings, newJcs];
351
+ const newP = btDeriveChainedPubkey(pubkeyBase, allStateStrings);
352
+ const newXonly = newP.slice(1);
353
+ const newScript = p2trScript(newXonly);
354
+ const newAddr = btAddress(trail.pubkeyBase, allStateStrings, trail.network);
355
+
356
+ // Fetch current UTXO scriptPubKey
357
+ const txResp = await fetch(`${mempoolUrl}/api/tx/${trail.currentTxid}`);
358
+ if (!txResp.ok) throw new Error('Could not fetch current transaction');
359
+ const txData = await txResp.json();
360
+ const prevOut = txData.vout?.[trail.currentVout];
361
+ if (!prevOut) throw new Error('Current UTXO not found');
362
+ const scriptPubKey = hexToU8(prevOut.scriptpubkey);
363
+
364
+ // Derive chained privkey for signing
365
+ const chainedPriv = btDeriveChainedPrivkey(privkeyBytes, trail.stateStrings);
366
+
367
+ // Build and broadcast
368
+ const fee = 300;
369
+ const outputAmount = trail.currentAmount - fee;
370
+ if (outputAmount <= 546) throw new Error('Trail UTXO too small for fee');
371
+
372
+ const rawTx = buildTransaction(
373
+ [{ txid: trail.currentTxid, vout: trail.currentVout, amount: trail.currentAmount, scriptPubKey }],
374
+ [{ amount: outputAmount, scriptPubKey: newScript }],
375
+ chainedPriv
376
+ );
377
+ const newTxid = await broadcastTx(rawTx, mempoolUrl);
378
+
379
+ // Update trail
380
+ trail.states.push(newState);
381
+ trail.stateStrings.push(newJcs);
382
+ trail.currentTxid = newTxid;
383
+ trail.currentVout = 0;
384
+ trail.currentAmount = outputAmount;
385
+ await saveTrail(trail);
386
+
387
+ return { trail, txid: newTxid, address: newAddr, state: newState, prevState: currentState };
388
+ }
389
+
390
+ // --- Info: show token state ---
391
+ export async function tokenInfo(ticker) {
392
+ const trail = await loadTrail(ticker);
393
+ if (!trail) throw new Error(`Token ${ticker} not found`);
394
+
395
+ const currentState = trail.states[trail.states.length - 1];
396
+ const currentAddr = btAddress(trail.pubkeyBase, trail.stateStrings, trail.network);
397
+
398
+ return {
399
+ ticker: trail.ticker,
400
+ name: trail.name,
401
+ supply: trail.supply,
402
+ seq: currentState.seq,
403
+ balances: currentState.balances,
404
+ pubkeyBase: trail.pubkeyBase,
405
+ currentTxid: trail.currentTxid,
406
+ currentAddress: currentAddr,
407
+ currentAmount: trail.currentAmount,
408
+ network: trail.network,
409
+ stateCount: trail.states.length,
410
+ dateCreated: trail.dateCreated
411
+ };
412
+ }