javascript-solid-server 0.0.101 → 0.0.103
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/README.md +1 -1
- package/bin/jss.js +130 -1
- package/package.json +1 -1
- package/src/config.js +9 -2
- package/src/handlers/pay.js +159 -9
- package/src/mrc20.js +189 -0
- package/src/server.js +3 -1
- package/src/token.js +412 -0
- package/test/mrc20.test.js +107 -1
package/README.md
CHANGED
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)
|
|
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
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)
|
|
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
|
}
|
package/src/handlers/pay.js
CHANGED
|
@@ -21,10 +21,36 @@
|
|
|
21
21
|
|
|
22
22
|
import { getNostrPubkey, pubkeyToDidNostr } from '../auth/nostr.js';
|
|
23
23
|
import { readLedger, writeLedger, getBalance, credit, debit } from '../webledger.js';
|
|
24
|
-
import { verifyMrc20Deposit } from '../mrc20.js';
|
|
24
|
+
import { verifyMrc20Deposit, verifyMrc20Anchor, jcs, sha256Hex } from '../mrc20.js';
|
|
25
|
+
import { loadTrail, transferToken } from '../token.js';
|
|
26
|
+
import fs from 'fs-extra';
|
|
27
|
+
import path from 'path';
|
|
25
28
|
|
|
26
29
|
const DEFAULT_COST = 1; // satoshis per request
|
|
27
30
|
|
|
31
|
+
// --- Replay protection ---
|
|
32
|
+
const replayFile = () => path.join(process.env.DATA_ROOT || './data', '.well-known/webledgers/replay.json');
|
|
33
|
+
|
|
34
|
+
async function loadReplaySet() {
|
|
35
|
+
try {
|
|
36
|
+
const data = await fs.readFile(replayFile(), 'utf8');
|
|
37
|
+
return new Set(JSON.parse(data));
|
|
38
|
+
} catch { return new Set(); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function saveReplaySet(set) {
|
|
42
|
+
await fs.ensureDir(path.dirname(replayFile()));
|
|
43
|
+
await fs.writeFile(replayFile(), JSON.stringify([...set]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function checkAndRecordState(stateHash) {
|
|
47
|
+
const seen = await loadReplaySet();
|
|
48
|
+
if (seen.has(stateHash)) return false; // replay!
|
|
49
|
+
seen.add(stateHash);
|
|
50
|
+
await saveReplaySet(seen);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
// --- Deposit verification via mempool API ---
|
|
29
55
|
|
|
30
56
|
async function verifySatsDeposit(txoUri, mempoolUrl) {
|
|
@@ -88,11 +114,11 @@ function parseDepositBody(body) {
|
|
|
88
114
|
function classifyDepositObject(obj) {
|
|
89
115
|
// Explicit type field
|
|
90
116
|
if (obj.type === 'mrc20' && obj.state && obj.prevState) {
|
|
91
|
-
return { type: 'mrc20', state: obj.state, prevState: obj.prevState };
|
|
117
|
+
return { type: 'mrc20', state: obj.state, prevState: obj.prevState, anchor: obj.anchor };
|
|
92
118
|
}
|
|
93
119
|
// Auto-detect: if it has state + prevState with MRC20 profile
|
|
94
120
|
if (obj.state?.profile === 'mono.mrc20.v0.1' && obj.prevState) {
|
|
95
|
-
return { type: 'mrc20', state: obj.state, prevState: obj.prevState };
|
|
121
|
+
return { type: 'mrc20', state: obj.state, prevState: obj.prevState, anchor: obj.anchor };
|
|
96
122
|
}
|
|
97
123
|
// Fall back to TXO URI in .txo field
|
|
98
124
|
if (obj.txo) {
|
|
@@ -122,6 +148,8 @@ export function createPayHandler(options = {}) {
|
|
|
122
148
|
const cost = options.cost ?? DEFAULT_COST;
|
|
123
149
|
const mempoolUrl = options.mempoolUrl ?? 'https://mempool.space/testnet4';
|
|
124
150
|
const payAddress = options.payAddress ?? null;
|
|
151
|
+
const payToken = options.payToken ?? null;
|
|
152
|
+
const payRate = options.payRate ?? 1;
|
|
125
153
|
|
|
126
154
|
return async function payHandler(request, reply) {
|
|
127
155
|
const url = request.url.split('?')[0];
|
|
@@ -160,11 +188,34 @@ export function createPayHandler(options = {}) {
|
|
|
160
188
|
});
|
|
161
189
|
}
|
|
162
190
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
191
|
+
// Replay protection: reject duplicate state hashes
|
|
192
|
+
const stateHash = jcs(deposit.state);
|
|
193
|
+
const isNew = await checkAndRecordState(stateHash);
|
|
194
|
+
if (!isNew) {
|
|
195
|
+
return reply.code(400).send({ error: 'Replay: this state has already been used for a deposit' });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let result;
|
|
199
|
+
|
|
200
|
+
// Anchor verification (if anchor data provided)
|
|
201
|
+
if (deposit.anchor && deposit.anchor.pubkey && deposit.anchor.stateStrings) {
|
|
202
|
+
result = await verifyMrc20Anchor({
|
|
203
|
+
state: deposit.state,
|
|
204
|
+
prevState: deposit.prevState,
|
|
205
|
+
toAddress: payAddress,
|
|
206
|
+
pubkey: deposit.anchor.pubkey,
|
|
207
|
+
stateStrings: deposit.anchor.stateStrings,
|
|
208
|
+
mempoolUrl,
|
|
209
|
+
network: deposit.anchor.network || 'testnet4'
|
|
210
|
+
});
|
|
211
|
+
} else {
|
|
212
|
+
// Fallback: verify chain integrity only (no anchor check)
|
|
213
|
+
result = verifyMrc20Deposit({
|
|
214
|
+
state: deposit.state,
|
|
215
|
+
prevState: deposit.prevState,
|
|
216
|
+
toAddress: payAddress
|
|
217
|
+
});
|
|
218
|
+
}
|
|
168
219
|
|
|
169
220
|
if (!result.valid) {
|
|
170
221
|
return reply.code(400).send({ error: result.error });
|
|
@@ -180,7 +231,8 @@ export function createPayHandler(options = {}) {
|
|
|
180
231
|
deposited: result.amount,
|
|
181
232
|
ticker: result.ticker,
|
|
182
233
|
balance: newBalance,
|
|
183
|
-
unit: 'token'
|
|
234
|
+
unit: 'token',
|
|
235
|
+
...(result.address ? { anchor: result.address } : {})
|
|
184
236
|
});
|
|
185
237
|
}
|
|
186
238
|
|
|
@@ -213,6 +265,104 @@ export function createPayHandler(options = {}) {
|
|
|
213
265
|
});
|
|
214
266
|
}
|
|
215
267
|
|
|
268
|
+
// --- POST /pay/.buy — primary market: buy tokens with sats ---
|
|
269
|
+
if (url === '/pay/.buy' && request.method === 'POST') {
|
|
270
|
+
const pubkey = await getNostrPubkey(request);
|
|
271
|
+
if (!pubkey) {
|
|
272
|
+
return reply.code(401).send({ error: 'NIP-98 authentication required' });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!payToken) {
|
|
276
|
+
return reply.code(400).send({ error: 'Primary market not configured (no --pay-token set)' });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Parse buy request
|
|
280
|
+
let body = request.body;
|
|
281
|
+
if (Buffer.isBuffer(body)) body = JSON.parse(body.toString('utf8'));
|
|
282
|
+
if (typeof body === 'string') body = JSON.parse(body);
|
|
283
|
+
|
|
284
|
+
const ticker = body?.ticker || payToken;
|
|
285
|
+
if (ticker !== payToken) {
|
|
286
|
+
return reply.code(400).send({ error: `This pod only sells ${payToken}` });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Calculate amount and cost
|
|
290
|
+
let tokenAmount, satCost;
|
|
291
|
+
if (body?.amount) {
|
|
292
|
+
tokenAmount = Math.floor(body.amount);
|
|
293
|
+
satCost = tokenAmount * payRate;
|
|
294
|
+
} else if (body?.sats) {
|
|
295
|
+
satCost = Math.floor(body.sats);
|
|
296
|
+
tokenAmount = Math.floor(satCost / payRate);
|
|
297
|
+
} else {
|
|
298
|
+
return reply.code(400).send({
|
|
299
|
+
error: 'Specify amount (tokens to buy) or sats (sats to spend)',
|
|
300
|
+
rate: payRate,
|
|
301
|
+
unit: 'sat/token'
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (tokenAmount <= 0) {
|
|
306
|
+
return reply.code(400).send({ error: 'Amount must be positive' });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Check sat balance
|
|
310
|
+
const didUri = pubkeyToDidNostr(pubkey);
|
|
311
|
+
const ledger = await readLedger();
|
|
312
|
+
const balance = getBalance(ledger, didUri);
|
|
313
|
+
if (balance < satCost) {
|
|
314
|
+
return reply.code(402).send({
|
|
315
|
+
error: 'Insufficient sat balance',
|
|
316
|
+
balance,
|
|
317
|
+
cost: satCost,
|
|
318
|
+
rate: payRate,
|
|
319
|
+
deposit: '/pay/.deposit'
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Load token trail
|
|
324
|
+
const trail = await loadTrail(ticker);
|
|
325
|
+
if (!trail) {
|
|
326
|
+
return reply.code(500).send({ error: `Token ${ticker} not minted on this pod` });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Transfer tokens to buyer
|
|
330
|
+
let result;
|
|
331
|
+
try {
|
|
332
|
+
result = await transferToken({
|
|
333
|
+
ticker,
|
|
334
|
+
to: pubkey,
|
|
335
|
+
amount: tokenAmount,
|
|
336
|
+
mempoolUrl
|
|
337
|
+
});
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return reply.code(500).send({ error: `Transfer failed: ${err.message}` });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Debit sats from buyer
|
|
343
|
+
debit(ledger, didUri, satCost);
|
|
344
|
+
await writeLedger(ledger);
|
|
345
|
+
|
|
346
|
+
return reply.send({
|
|
347
|
+
bought: tokenAmount,
|
|
348
|
+
ticker,
|
|
349
|
+
cost: satCost,
|
|
350
|
+
rate: payRate,
|
|
351
|
+
balance: getBalance(ledger, didUri),
|
|
352
|
+
unit: 'sat',
|
|
353
|
+
txid: result.txid,
|
|
354
|
+
proof: {
|
|
355
|
+
state: result.state,
|
|
356
|
+
prevState: result.prevState,
|
|
357
|
+
anchor: {
|
|
358
|
+
pubkey: result.trail.pubkeyBase,
|
|
359
|
+
stateStrings: result.trail.stateStrings,
|
|
360
|
+
network: result.trail.network
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
216
366
|
// --- GET/HEAD /pay/* — paid resource access ---
|
|
217
367
|
if (request.method === 'GET' || request.method === 'HEAD') {
|
|
218
368
|
const pubkey = await getNostrPubkey(request);
|
package/src/mrc20.js
CHANGED
|
@@ -13,10 +13,16 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import crypto from 'crypto';
|
|
16
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
16
17
|
|
|
17
18
|
const MRC20_PROFILE = 'mono.mrc20.v0.1';
|
|
18
19
|
const TRANSFER_OP = 'urn:mono:op:transfer';
|
|
19
20
|
|
|
21
|
+
// --- BIP-341 key chaining constants ---
|
|
22
|
+
const SECP_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
|
|
23
|
+
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
|
24
|
+
const BECH32M = 0x2bc830a3;
|
|
25
|
+
|
|
20
26
|
/**
|
|
21
27
|
* JSON Canonicalization Scheme (RFC 8785)
|
|
22
28
|
* Produces deterministic JSON — sorted keys, no whitespace.
|
|
@@ -144,3 +150,186 @@ export function verifyMrc20Deposit(params) {
|
|
|
144
150
|
ticker: state.ticker || 'UNKNOWN'
|
|
145
151
|
};
|
|
146
152
|
}
|
|
153
|
+
|
|
154
|
+
// --- BIP-341 key chaining (blocktrails anchor verification) ---
|
|
155
|
+
|
|
156
|
+
function sha256Bytes(data) {
|
|
157
|
+
const buf = data instanceof Uint8Array ? Buffer.from(data) : Buffer.from(data, 'utf8');
|
|
158
|
+
return new Uint8Array(crypto.createHash('sha256').update(buf).digest());
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function concatBytes(...arrays) {
|
|
162
|
+
const total = arrays.reduce((s, a) => s + a.length, 0);
|
|
163
|
+
const result = new Uint8Array(total);
|
|
164
|
+
let off = 0;
|
|
165
|
+
for (const a of arrays) { result.set(a, off); off += a.length; }
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function bytesToBigInt(bytes) {
|
|
170
|
+
let r = 0n;
|
|
171
|
+
for (const b of bytes) r = (r << 8n) | BigInt(b);
|
|
172
|
+
return r;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hexToU8(hex) {
|
|
176
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
177
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
178
|
+
return bytes;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function bytesToHex(bytes) {
|
|
182
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function taggedHash(tag, ...msgs) {
|
|
186
|
+
const tagHash = sha256Bytes(Buffer.from(tag, 'utf8'));
|
|
187
|
+
return sha256Bytes(concatBytes(tagHash, tagHash, ...msgs));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function btScalar(pubkeyCompressed, state) {
|
|
191
|
+
const xOnly = pubkeyCompressed.slice(1);
|
|
192
|
+
const stateBytes = typeof state === 'string' ? Buffer.from(state, 'utf8') : state;
|
|
193
|
+
const sh = sha256Bytes(stateBytes);
|
|
194
|
+
return bytesToBigInt(taggedHash('TapTweak', xOnly, sh)) % SECP_N;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function btDeriveChainedPubkey(pubkeyBase, states) {
|
|
198
|
+
let P = secp256k1.ProjectivePoint.fromHex(bytesToHex(pubkeyBase));
|
|
199
|
+
let cur = pubkeyBase;
|
|
200
|
+
for (const s of states) {
|
|
201
|
+
const t = btScalar(cur, s);
|
|
202
|
+
P = P.add(secp256k1.ProjectivePoint.BASE.multiply(t));
|
|
203
|
+
cur = new Uint8Array(P.toRawBytes(true));
|
|
204
|
+
}
|
|
205
|
+
return cur;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --- Bech32m encoding ---
|
|
209
|
+
|
|
210
|
+
function convertBits(data, from, to, pad) {
|
|
211
|
+
let acc = 0, bits = 0;
|
|
212
|
+
const ret = [], maxv = (1 << to) - 1;
|
|
213
|
+
for (const v of data) {
|
|
214
|
+
acc = (acc << from) | v;
|
|
215
|
+
bits += from;
|
|
216
|
+
while (bits >= to) { bits -= to; ret.push((acc >> bits) & maxv); }
|
|
217
|
+
}
|
|
218
|
+
if (pad && bits > 0) ret.push((acc << (to - bits)) & maxv);
|
|
219
|
+
return ret;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function hrpExpand(hrp) {
|
|
223
|
+
const r = [];
|
|
224
|
+
for (const c of hrp) r.push(c.charCodeAt(0) >> 5);
|
|
225
|
+
r.push(0);
|
|
226
|
+
for (const c of hrp) r.push(c.charCodeAt(0) & 31);
|
|
227
|
+
return r;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function polymod(values) {
|
|
231
|
+
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
|
232
|
+
let chk = 1;
|
|
233
|
+
for (const v of values) {
|
|
234
|
+
const b = chk >> 25;
|
|
235
|
+
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
|
236
|
+
for (let i = 0; i < 5; i++) if ((b >> i) & 1) chk ^= GEN[i];
|
|
237
|
+
}
|
|
238
|
+
return chk;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function bech32mEncode(hrp, version, program) {
|
|
242
|
+
const conv = convertBits(program, 8, 5, true);
|
|
243
|
+
const values = [version, ...conv];
|
|
244
|
+
const enc = [...hrpExpand(hrp), ...values, 0, 0, 0, 0, 0, 0];
|
|
245
|
+
const mod = polymod(enc) ^ BECH32M;
|
|
246
|
+
const checksum = [0, 1, 2, 3, 4, 5].map(i => (mod >> (5 * (5 - i))) & 31);
|
|
247
|
+
let result = hrp + '1';
|
|
248
|
+
for (const v of [...values, ...checksum]) result += BECH32_CHARSET[v];
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Derive the taproot address for a state chain
|
|
254
|
+
* @param {string} pubkeyHex - Issuer's compressed pubkey (66-char hex)
|
|
255
|
+
* @param {string[]} states - JCS-encoded state strings
|
|
256
|
+
* @param {string} [network='testnet4'] - 'testnet4' or 'mainnet'
|
|
257
|
+
* @returns {string} Bech32m taproot address
|
|
258
|
+
*/
|
|
259
|
+
export function btAddress(pubkeyHex, states, network = 'testnet4') {
|
|
260
|
+
const pubkeyBase = hexToU8(pubkeyHex);
|
|
261
|
+
const P = btDeriveChainedPubkey(pubkeyBase, states);
|
|
262
|
+
const xOnly = P.slice(1);
|
|
263
|
+
const hrp = network === 'mainnet' ? 'bc' : 'tb';
|
|
264
|
+
return bech32mEncode(hrp, 1, xOnly);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Verify that an MRC20 state is anchored to a Bitcoin UTXO
|
|
269
|
+
* @param {object} params
|
|
270
|
+
* @param {object} params.state - Current state with transfer ops
|
|
271
|
+
* @param {object} params.prevState - Previous state (chain verification)
|
|
272
|
+
* @param {string} params.toAddress - Pod's address for transfer verification
|
|
273
|
+
* @param {string} params.pubkey - Issuer's compressed pubkey (66-char hex)
|
|
274
|
+
* @param {string[]} params.stateStrings - All JCS state strings (genesis to current)
|
|
275
|
+
* @param {string} [params.mempoolUrl] - Mempool API base URL
|
|
276
|
+
* @param {string} [params.network] - 'testnet4' or 'mainnet'
|
|
277
|
+
* @returns {Promise<{valid: boolean, amount?: number, ticker?: string, address?: string, error?: string}>}
|
|
278
|
+
*/
|
|
279
|
+
export async function verifyMrc20Anchor(params) {
|
|
280
|
+
const {
|
|
281
|
+
state, prevState, toAddress, pubkey, stateStrings,
|
|
282
|
+
mempoolUrl = 'https://mempool.space/testnet4',
|
|
283
|
+
network = 'testnet4'
|
|
284
|
+
} = params;
|
|
285
|
+
|
|
286
|
+
// 1. Standard deposit verification (chain integrity + transfers)
|
|
287
|
+
const depositCheck = verifyMrc20Deposit({ state, prevState, toAddress });
|
|
288
|
+
if (!depositCheck.valid) return depositCheck;
|
|
289
|
+
|
|
290
|
+
// 2. Verify stateStrings is provided and non-empty
|
|
291
|
+
if (!Array.isArray(stateStrings) || stateStrings.length === 0) {
|
|
292
|
+
return { valid: false, amount: 0, error: 'stateStrings required for anchor verification' };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 3. Verify pubkey is provided
|
|
296
|
+
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 66) {
|
|
297
|
+
return { valid: false, amount: 0, error: 'pubkey must be a 66-char compressed pubkey hex' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 4. Verify the last stateString matches the current state
|
|
301
|
+
const currentJcs = jcs(state);
|
|
302
|
+
const lastStateString = stateStrings[stateStrings.length - 1];
|
|
303
|
+
if (currentJcs !== lastStateString) {
|
|
304
|
+
return { valid: false, amount: 0, error: 'Last stateString does not match JCS(state)' };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 5. Derive expected taproot address
|
|
308
|
+
let address;
|
|
309
|
+
try {
|
|
310
|
+
address = btAddress(pubkey, stateStrings, network);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
return { valid: false, amount: 0, error: `Key derivation failed: ${err.message}` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 6. Query mempool for UTXO at derived address
|
|
316
|
+
try {
|
|
317
|
+
const resp = await fetch(`${mempoolUrl}/api/address/${address}/utxo`);
|
|
318
|
+
if (!resp.ok) {
|
|
319
|
+
return { valid: false, amount: 0, error: `Mempool API error: ${resp.status}` };
|
|
320
|
+
}
|
|
321
|
+
const utxos = await resp.json();
|
|
322
|
+
if (!Array.isArray(utxos) || utxos.length === 0) {
|
|
323
|
+
return { valid: false, amount: 0, error: `No UTXO at derived address ${address}` };
|
|
324
|
+
}
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return { valid: false, amount: 0, error: `Mempool lookup failed: ${err.message}` };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
valid: true,
|
|
331
|
+
amount: depositCheck.amount,
|
|
332
|
+
ticker: depositCheck.ticker,
|
|
333
|
+
address
|
|
334
|
+
};
|
|
335
|
+
}
|
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
|
+
}
|
package/test/mrc20.test.js
CHANGED
|
@@ -11,8 +11,11 @@ import {
|
|
|
11
11
|
validateMrc20State,
|
|
12
12
|
extractTransfersTo,
|
|
13
13
|
totalTransferredTo,
|
|
14
|
-
verifyMrc20Deposit
|
|
14
|
+
verifyMrc20Deposit,
|
|
15
|
+
btAddress,
|
|
16
|
+
verifyMrc20Anchor
|
|
15
17
|
} from '../src/mrc20.js';
|
|
18
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
16
19
|
|
|
17
20
|
const PROFILE = 'mono.mrc20.v0.1';
|
|
18
21
|
|
|
@@ -234,4 +237,107 @@ describe('MRC20 Verification', () => {
|
|
|
234
237
|
assert.strictEqual(result.amount, 150);
|
|
235
238
|
});
|
|
236
239
|
});
|
|
240
|
+
|
|
241
|
+
describe('btAddress', () => {
|
|
242
|
+
// Use a known keypair for deterministic tests
|
|
243
|
+
const testPriv = Buffer.alloc(32, 1); // 0x0101...01
|
|
244
|
+
const testPub = Buffer.from(secp256k1.getPublicKey(testPriv, true)).toString('hex');
|
|
245
|
+
|
|
246
|
+
it('should derive a valid testnet bech32m address', () => {
|
|
247
|
+
const addr = btAddress(testPub, ['state0'], 'testnet4');
|
|
248
|
+
assert.ok(addr.startsWith('tb1p'), `Expected tb1p prefix, got ${addr}`);
|
|
249
|
+
assert.ok(addr.length >= 62, `Address too short: ${addr.length}`);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should derive a valid mainnet bech32m address', () => {
|
|
253
|
+
const addr = btAddress(testPub, ['state0'], 'mainnet');
|
|
254
|
+
assert.ok(addr.startsWith('bc1p'), `Expected bc1p prefix, got ${addr}`);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('should be deterministic', () => {
|
|
258
|
+
const a1 = btAddress(testPub, ['s1', 's2']);
|
|
259
|
+
const a2 = btAddress(testPub, ['s1', 's2']);
|
|
260
|
+
assert.strictEqual(a1, a2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should produce different addresses for different states', () => {
|
|
264
|
+
const a1 = btAddress(testPub, ['state-a']);
|
|
265
|
+
const a2 = btAddress(testPub, ['state-b']);
|
|
266
|
+
assert.notStrictEqual(a1, a2);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should produce different addresses for different pubkeys', () => {
|
|
270
|
+
const priv2 = Buffer.alloc(32, 2);
|
|
271
|
+
const pub2 = Buffer.from(secp256k1.getPublicKey(priv2, true)).toString('hex');
|
|
272
|
+
const a1 = btAddress(testPub, ['state']);
|
|
273
|
+
const a2 = btAddress(pub2, ['state']);
|
|
274
|
+
assert.notStrictEqual(a1, a2);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should chain multiple states', () => {
|
|
278
|
+
const a1 = btAddress(testPub, ['s1']);
|
|
279
|
+
const a2 = btAddress(testPub, ['s1', 's2']);
|
|
280
|
+
assert.notStrictEqual(a1, a2);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('verifyMrc20Anchor', () => {
|
|
285
|
+
const testPriv = Buffer.alloc(32, 1);
|
|
286
|
+
const testPub = Buffer.from(secp256k1.getPublicKey(testPriv, true)).toString('hex');
|
|
287
|
+
|
|
288
|
+
it('should reject missing stateStrings', async () => {
|
|
289
|
+
const { prevState, state } = createStatePair(
|
|
290
|
+
[{ op: 'urn:mono:op:transfer', from: 'user', to: 'pod', amt: 100 }],
|
|
291
|
+
'pod'
|
|
292
|
+
);
|
|
293
|
+
const result = await verifyMrc20Anchor({
|
|
294
|
+
state, prevState, toAddress: 'pod',
|
|
295
|
+
pubkey: testPub, stateStrings: []
|
|
296
|
+
});
|
|
297
|
+
assert.strictEqual(result.valid, false);
|
|
298
|
+
assert.ok(result.error.includes('stateStrings'));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should reject bad pubkey', async () => {
|
|
302
|
+
const { prevState, state } = createStatePair(
|
|
303
|
+
[{ op: 'urn:mono:op:transfer', from: 'user', to: 'pod', amt: 100 }],
|
|
304
|
+
'pod'
|
|
305
|
+
);
|
|
306
|
+
const result = await verifyMrc20Anchor({
|
|
307
|
+
state, prevState, toAddress: 'pod',
|
|
308
|
+
pubkey: 'short', stateStrings: [jcs(state)]
|
|
309
|
+
});
|
|
310
|
+
assert.strictEqual(result.valid, false);
|
|
311
|
+
assert.ok(result.error.includes('pubkey'));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should reject mismatched last stateString', async () => {
|
|
315
|
+
const { prevState, state } = createStatePair(
|
|
316
|
+
[{ op: 'urn:mono:op:transfer', from: 'user', to: 'pod', amt: 100 }],
|
|
317
|
+
'pod'
|
|
318
|
+
);
|
|
319
|
+
const result = await verifyMrc20Anchor({
|
|
320
|
+
state, prevState, toAddress: 'pod',
|
|
321
|
+
pubkey: testPub, stateStrings: ['wrong-jcs']
|
|
322
|
+
});
|
|
323
|
+
assert.strictEqual(result.valid, false);
|
|
324
|
+
assert.ok(result.error.includes('Last stateString'));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should reject when no UTXO exists (mempool returns empty)', async () => {
|
|
328
|
+
const { prevState, state } = createStatePair(
|
|
329
|
+
[{ op: 'urn:mono:op:transfer', from: 'user', to: 'pod', amt: 100 }],
|
|
330
|
+
'pod'
|
|
331
|
+
);
|
|
332
|
+
// Use a fake mempool URL that will fail
|
|
333
|
+
const result = await verifyMrc20Anchor({
|
|
334
|
+
state, prevState, toAddress: 'pod',
|
|
335
|
+
pubkey: testPub,
|
|
336
|
+
stateStrings: [jcs(prevState), jcs(state)],
|
|
337
|
+
mempoolUrl: 'http://127.0.0.1:1' // will fail to connect
|
|
338
|
+
});
|
|
339
|
+
assert.strictEqual(result.valid, false);
|
|
340
|
+
assert.ok(result.error.includes('Mempool') || result.error.includes('failed'));
|
|
341
|
+
});
|
|
342
|
+
});
|
|
237
343
|
});
|