@bsv/btms-permission-module 1.0.0

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.
@@ -0,0 +1,1018 @@
1
+ import { Hash, LockingScript, PushDrop, Transaction, Utils } from '@bsv/sdk';
2
+ import { ISSUE_MARKER } from '@bsv/btms';
3
+ import { P_BASKET_PREFIX, BTMS_FIELD } from './types';
4
+ /**
5
+ * BasicTokenModule - BTMS Permission Module
6
+ *
7
+ * SECURITY MODEL:
8
+ * This module enforces permissions when spending BTMS tokens stored in
9
+ * permissioned baskets (format: "p btms <assetId>"). It prevents unauthorized
10
+ * token transfers by requiring explicit user approval for each transaction.
11
+ *
12
+ * THREAT MODEL:
13
+ * - Malicious dApp attempts to spend tokens without user knowledge
14
+ * - Malicious dApp gets approval for one transaction, attempts to sign different transaction
15
+ * - Malicious dApp attempts to bypass authorization checks
16
+ * - Malicious dApp attempts to steal tokens via preimage manipulation
17
+ *
18
+ * SECURITY BOUNDARIES:
19
+ * 1. createAction: Extracts token details and prompts user for approval
20
+ * 2. createSignature: Verifies session authorization + preimage integrity
21
+ * 3. Session authorization: Time-limited (60s) to prevent replay attacks
22
+ * 4. Preimage verification: Ensures signed transaction matches approved transaction
23
+ *
24
+ * AUTHORIZATION FLOW:
25
+ * 1. createAction → extract token info → prompt user → grant session auth
26
+ * 2. createSignature → verify session auth → verify preimage → allow signature
27
+ *
28
+ * ISSUANCE HANDLING:
29
+ * Token issuance is auto-approved (no user prompt) because:
30
+ * - Issuance creates new tokens (doesn't spend existing ones)
31
+ * - Detected by ISSUE_MARKER in locking script or btms_issue tag
32
+ * - Short signatures (<157 bytes) are assumed to be issuance
33
+ */
34
+ export class BasicTokenModule {
35
+ requestTokenAccess;
36
+ btms;
37
+ /**
38
+ * Session-based authorization tracking.
39
+ *
40
+ * SECURITY: Time-limited to prevent replay attacks. Each approval expires after 60s.
41
+ * Key: originator (dApp identifier)
42
+ * Value: timestamp of approval (milliseconds since epoch)
43
+ */
44
+ sessionAuthorizations = new Map();
45
+ SESSION_TIMEOUT_MS = 60000; // 60 seconds
46
+ /**
47
+ * Authorized transaction data from createAction responses.
48
+ *
49
+ * SECURITY: Stores cryptographic commitments (hashOutputs, outpoints) to verify
50
+ * that createSignature is signing the exact transaction the user approved.
51
+ * This prevents a malicious dApp from getting approval for one transaction
52
+ * and then signing a different transaction.
53
+ *
54
+ * Key: originator (dApp identifier)
55
+ * Value: authorized transaction details (reference, hashOutputs, outpoints, timestamp)
56
+ */
57
+ authorizedTransactions = new Map();
58
+ /**
59
+ * Creates a new BasicTokenModule instance.
60
+ *
61
+ * @param requestTokenAccess - Callback to prompt user for token spending approval.
62
+ * Should return true if user approves, false if denied.
63
+ * SECURITY: This callback MUST be implemented securely to prevent UI spoofing.
64
+ * @param btms - BTMS instance for fetching token metadata via getAssetInfo
65
+ */
66
+ constructor(requestTokenAccess, btms) {
67
+ if (!requestTokenAccess || typeof requestTokenAccess !== 'function') {
68
+ throw new Error('requestTokenAccess callback is required');
69
+ }
70
+ this.requestTokenAccess = requestTokenAccess;
71
+ this.btms = btms;
72
+ // Start periodic cleanup of expired sessions
73
+ this.startSessionCleanup();
74
+ }
75
+ /**
76
+ * Periodic cleanup of expired session authorizations.
77
+ * Runs every 30 seconds to prevent memory leaks.
78
+ */
79
+ startSessionCleanup() {
80
+ setInterval(() => {
81
+ const now = Date.now();
82
+ for (const [originator, timestamp] of this.sessionAuthorizations.entries()) {
83
+ if (now - timestamp > this.SESSION_TIMEOUT_MS) {
84
+ this.sessionAuthorizations.delete(originator);
85
+ }
86
+ }
87
+ for (const [originator, tx] of this.authorizedTransactions.entries()) {
88
+ if (now - tx.timestamp > this.SESSION_TIMEOUT_MS) {
89
+ this.authorizedTransactions.delete(originator);
90
+ }
91
+ }
92
+ }, 30000); // Every 30 seconds
93
+ }
94
+ /**
95
+ * Intercepts wallet method requests for P-basket/protocol operations.
96
+ *
97
+ * SECURITY: This is the main entry point for all permission checks.
98
+ * All token spending operations MUST go through this method.
99
+ *
100
+ * @param req - Request object containing method, args, and originator
101
+ * @returns Modified args (unchanged in this implementation)
102
+ * @throws Error if authorization is denied
103
+ */
104
+ async onRequest(req) {
105
+ const { method, args, originator } = req;
106
+ // Input validation
107
+ if (!method || typeof method !== 'string') {
108
+ throw new Error('Invalid method');
109
+ }
110
+ if (!originator || typeof originator !== 'string') {
111
+ throw new Error('Invalid originator');
112
+ }
113
+ if (!args || typeof args !== 'object') {
114
+ throw new Error('Invalid args');
115
+ }
116
+ // Handle security-critical methods
117
+ if (method === 'createAction') {
118
+ await this.handleCreateAction(args, originator);
119
+ }
120
+ else if (method === 'createSignature') {
121
+ await this.handleCreateSignature(args, originator);
122
+ }
123
+ else if (method === 'listActions') {
124
+ await this.handleListActions(args, originator);
125
+ }
126
+ else if (method === 'listOutputs') {
127
+ await this.handleListOutputs(args, originator);
128
+ }
129
+ return { args };
130
+ }
131
+ /**
132
+ * Transforms responses from the underlying wallet.
133
+ * For createAction: Captures signable transaction data for security verification.
134
+ */
135
+ async onResponse(res, context) {
136
+ const { method, originator } = context;
137
+ if (method === 'createAction') {
138
+ await this.captureAuthorizedTransaction(res, originator);
139
+ }
140
+ return res;
141
+ }
142
+ /**
143
+ * Captures authorized transaction data from createAction response.
144
+ *
145
+ * SECURITY: This data is used to verify that createSignature calls are signing
146
+ * the exact transaction the user approved. Prevents transaction substitution attacks.
147
+ *
148
+ * Captured data:
149
+ * 1. reference - Transaction reference for matching
150
+ * 2. hashOutputs - BIP-143 hash of all outputs (prevents output modification)
151
+ * 3. authorizedOutpoints - Whitelist of inputs that can be signed (prevents input substitution)
152
+ * 4. timestamp - For expiry checking
153
+ *
154
+ * @param result - createAction response
155
+ * @param originator - dApp identifier
156
+ */
157
+ async captureAuthorizedTransaction(result, originator) {
158
+ if (!result || typeof result !== 'object' || !result.signableTransaction) {
159
+ return;
160
+ }
161
+ try {
162
+ const { tx, reference } = result.signableTransaction;
163
+ if (!tx || !reference) {
164
+ return;
165
+ }
166
+ const transaction = Transaction.fromAtomicBEEF(tx);
167
+ // Compute hashOutputs (BIP-143 style) from the transaction outputs
168
+ const hashOutputs = this.computeHashOutputs(transaction);
169
+ // Collect all input outpoints as authorized
170
+ const authorizedOutpoints = new Set();
171
+ if (Array.isArray(transaction.inputs)) {
172
+ for (const input of transaction.inputs) {
173
+ if (!input || typeof input !== 'object')
174
+ continue;
175
+ const txid = input.sourceTXID || input.sourceTransaction?.id('hex');
176
+ if (txid && typeof txid === 'string') {
177
+ const vout = input.sourceOutputIndex;
178
+ if (typeof vout === 'number' && vout >= 0) {
179
+ const outpoint = `${txid}.${vout}`;
180
+ authorizedOutpoints.add(outpoint);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ // Store the authorized transaction data
186
+ this.authorizedTransactions.set(originator, {
187
+ reference,
188
+ hashOutputs,
189
+ authorizedOutpoints,
190
+ timestamp: Date.now()
191
+ });
192
+ }
193
+ catch (error) {
194
+ // Don't throw - we'll fall back to session-based auth
195
+ }
196
+ }
197
+ /**
198
+ * Computes BIP-143 hashOutputs from a transaction.
199
+ *
200
+ * SECURITY: This hash commits to all transaction outputs. Any modification
201
+ * to outputs (amounts, recipients, scripts) will change this hash.
202
+ *
203
+ * @param tx - Transaction to compute hashOutputs for
204
+ * @returns Hex-encoded double-SHA256 hash of all outputs
205
+ */
206
+ computeHashOutputs(tx) {
207
+ if (!tx || typeof tx !== 'object' || !Array.isArray(tx.outputs)) {
208
+ throw new Error('Invalid transaction for hashOutputs computation');
209
+ }
210
+ // Serialize all outputs: satoshis (8 bytes LE) + scriptLen (varint) + script
211
+ const outputBytes = [];
212
+ for (const output of tx.outputs) {
213
+ // Satoshis as 8-byte little-endian
214
+ const satoshis = output.satoshis ?? 0;
215
+ for (let i = 0; i < 8; i++) {
216
+ outputBytes.push(Number((BigInt(satoshis) >> BigInt(i * 8)) & BigInt(0xff)));
217
+ }
218
+ // Script length as varint + script bytes
219
+ const scriptBytes = output.lockingScript?.toBinary() ?? [];
220
+ const scriptLen = scriptBytes.length;
221
+ if (scriptLen < 0xfd) {
222
+ outputBytes.push(scriptLen);
223
+ }
224
+ else if (scriptLen <= 0xffff) {
225
+ outputBytes.push(0xfd);
226
+ outputBytes.push(scriptLen & 0xff);
227
+ outputBytes.push((scriptLen >> 8) & 0xff);
228
+ }
229
+ else {
230
+ outputBytes.push(0xfe);
231
+ outputBytes.push(scriptLen & 0xff);
232
+ outputBytes.push((scriptLen >> 8) & 0xff);
233
+ outputBytes.push((scriptLen >> 16) & 0xff);
234
+ outputBytes.push((scriptLen >> 24) & 0xff);
235
+ }
236
+ outputBytes.push(...scriptBytes);
237
+ }
238
+ // Double SHA-256
239
+ const hash = Hash.hash256(outputBytes);
240
+ return Utils.toHex(hash);
241
+ }
242
+ /**
243
+ * Handles createAction requests that involve BTMS P-baskets.
244
+ *
245
+ * SECURITY: This is the primary authorization checkpoint. User approval here
246
+ * grants session authorization for subsequent createSignature calls.
247
+ *
248
+ * ISSUANCE DETECTION: Token issuance is auto-approved because it creates new
249
+ * tokens rather than spending existing ones. Detected by:
250
+ * - ISSUE_MARKER in locking script
251
+ * - btms_issue tag in outputs
252
+ * - No inputs (issuance doesn't spend existing UTXOs)
253
+ *
254
+ * @param args - createAction arguments
255
+ * @param originator - dApp identifier
256
+ * @throws Error if user denies authorization
257
+ */
258
+ async handleCreateAction(args, originator) {
259
+ // Input validation
260
+ if (!args || typeof args !== 'object') {
261
+ throw new Error('Invalid createAction args');
262
+ }
263
+ // Check if this is token issuance - auto-approve
264
+ const isIssuance = this.isTokenIssuance(args);
265
+ if (isIssuance) {
266
+ this.grantSessionAuthorization(originator);
267
+ return;
268
+ }
269
+ // No inputs = likely issuance (creating new tokens)
270
+ if (!args.inputs || args.inputs.length === 0) {
271
+ this.grantSessionAuthorization(originator);
272
+ return;
273
+ }
274
+ // Extract token spend information for user prompt
275
+ const spendInfo = this.extractTokenSpendInfo(args);
276
+ const enrichedSpendInfo = await this.enrichSpendInfoWithMetadata(spendInfo, spendInfo.assetId);
277
+ const actionClassification = this.classifyTokenAction(enrichedSpendInfo);
278
+ if (actionClassification.isInvalidBurn) {
279
+ throw new Error('Burn transactions must not send tokens to a recipient');
280
+ }
281
+ if (actionClassification.isBurn) {
282
+ await this.promptForTokenBurn(originator, {
283
+ ...enrichedSpendInfo,
284
+ sendAmount: 0,
285
+ totalInputAmount: actionClassification.burnAmount
286
+ });
287
+ return;
288
+ }
289
+ if (enrichedSpendInfo.sendAmount > 0 || enrichedSpendInfo.totalInputAmount > 0) {
290
+ await this.promptForTokenSpend(originator, enrichedSpendInfo);
291
+ return;
292
+ }
293
+ // Fallback to generic prompt if we can't parse token details
294
+ await this.promptForGenericAuthorization(originator);
295
+ }
296
+ async enrichSpendInfoWithMetadata(spendInfo, assetId) {
297
+ if (!assetId)
298
+ return spendInfo;
299
+ const meta = await this.getAssetMetadata(assetId);
300
+ return {
301
+ ...spendInfo,
302
+ assetId,
303
+ tokenName: meta?.name || spendInfo.tokenName,
304
+ iconURL: meta?.iconURL || spendInfo.iconURL
305
+ };
306
+ }
307
+ classifyTokenAction(spendInfo) {
308
+ const inputAmountReliable = spendInfo.inputAmountSource === 'beef';
309
+ const burnAmount = inputAmountReliable
310
+ ? Math.max(0, spendInfo.totalInputAmount - spendInfo.outputChangeAmount - spendInfo.outputSendAmount)
311
+ : 0;
312
+ const isInvalidBurn = burnAmount > 0 && spendInfo.outputSendAmount > 0;
313
+ const isBurn = inputAmountReliable && burnAmount > 0 && spendInfo.outputSendAmount === 0;
314
+ return {
315
+ burnAmount,
316
+ isBurn,
317
+ isInvalidBurn
318
+ };
319
+ }
320
+ /**
321
+ * Extracts comprehensive token spend information from createAction args.
322
+ *
323
+ * Parses ALL output locking scripts to get token data, and extracts
324
+ * recipient info from the action description.
325
+ */
326
+ extractTokenSpendInfo(args) {
327
+ let sendAmount = 0;
328
+ let changeAmount = 0;
329
+ let totalInputAmount = 0;
330
+ let outputSendAmount = 0;
331
+ let outputChangeAmount = 0;
332
+ let hasTokenOutputs = false;
333
+ let inputAmountSource = 'none';
334
+ let inputAssetId;
335
+ let outputAssetId;
336
+ let assetIdMismatch = false;
337
+ let tokenName = 'BTMS Token';
338
+ let assetId = '';
339
+ let iconURL;
340
+ let recipient;
341
+ // Input validation
342
+ if (!args || typeof args !== 'object') {
343
+ throw new Error('Invalid args for extractTokenSpendInfo');
344
+ }
345
+ // Parse inputs using inputBEEF to get total input amount (if available)
346
+ let beefInputAmount = 0;
347
+ if (args.inputBEEF && Array.isArray(args.inputs)) {
348
+ for (const input of args.inputs) {
349
+ if (!input?.outpoint || typeof input.outpoint !== 'string')
350
+ continue;
351
+ const [txid, voutStr] = input.outpoint.split('.');
352
+ const outputIndex = Number(voutStr);
353
+ if (!txid || !Number.isFinite(outputIndex) || outputIndex < 0)
354
+ continue;
355
+ try {
356
+ const tx = Transaction.fromBEEF(args.inputBEEF, txid);
357
+ const lockingScript = tx.outputs?.[outputIndex]?.lockingScript;
358
+ const scriptHex = lockingScript?.toHex?.();
359
+ if (!scriptHex)
360
+ continue;
361
+ const parsed = this.parseTokenLockingScript(scriptHex);
362
+ if (!parsed || parsed.assetId === ISSUE_MARKER)
363
+ continue;
364
+ if (!inputAssetId) {
365
+ inputAssetId = parsed.assetId;
366
+ }
367
+ else if (parsed.assetId !== inputAssetId) {
368
+ assetIdMismatch = true;
369
+ continue;
370
+ }
371
+ if (!assetId) {
372
+ assetId = parsed.assetId;
373
+ }
374
+ else if (parsed.assetId !== assetId) {
375
+ assetIdMismatch = true;
376
+ continue;
377
+ }
378
+ beefInputAmount += parsed.amount;
379
+ if (parsed.metadata?.name && typeof parsed.metadata.name === 'string') {
380
+ tokenName = parsed.metadata.name;
381
+ }
382
+ if (parsed.metadata?.iconURL && typeof parsed.metadata.iconURL === 'string') {
383
+ iconURL = parsed.metadata.iconURL;
384
+ }
385
+ }
386
+ catch {
387
+ // Ignore malformed input BEEF
388
+ }
389
+ }
390
+ }
391
+ if (beefInputAmount > 0) {
392
+ totalInputAmount = beefInputAmount;
393
+ inputAmountSource = 'beef';
394
+ }
395
+ // Parse ALL output locking scripts to extract token metadata
396
+ if (Array.isArray(args.outputs)) {
397
+ for (const output of args.outputs) {
398
+ if (output?.lockingScript && typeof output.lockingScript === 'string') {
399
+ const parsed = this.parseTokenLockingScript(output.lockingScript);
400
+ if (parsed) {
401
+ if (parsed.assetId === ISSUE_MARKER) {
402
+ continue;
403
+ }
404
+ // Get asset ID from first valid token
405
+ if (!outputAssetId) {
406
+ outputAssetId = parsed.assetId;
407
+ }
408
+ else if (parsed.assetId !== outputAssetId) {
409
+ assetIdMismatch = true;
410
+ continue;
411
+ }
412
+ if (!assetId) {
413
+ assetId = parsed.assetId;
414
+ }
415
+ else if (parsed.assetId !== assetId) {
416
+ assetIdMismatch = true;
417
+ continue;
418
+ }
419
+ hasTokenOutputs = true;
420
+ if (parsed.metadata?.name && typeof parsed.metadata.name === 'string') {
421
+ tokenName = parsed.metadata.name;
422
+ }
423
+ if (parsed.metadata?.iconURL && typeof parsed.metadata.iconURL === 'string') {
424
+ iconURL = parsed.metadata.iconURL;
425
+ }
426
+ // Determine if this is a change output or send output
427
+ // Basket presence indicates change (returning to self)
428
+ if (output.basket && typeof output.basket === 'string' && output.basket.startsWith(P_BASKET_PREFIX)) {
429
+ outputChangeAmount += parsed.amount;
430
+ }
431
+ else {
432
+ outputSendAmount += parsed.amount;
433
+ }
434
+ }
435
+ }
436
+ }
437
+ }
438
+ if (hasTokenOutputs) {
439
+ sendAmount = outputSendAmount;
440
+ changeAmount = outputChangeAmount;
441
+ }
442
+ if (assetIdMismatch || (inputAssetId && outputAssetId && inputAssetId !== outputAssetId)) {
443
+ throw new Error('Asset swap support coming soon');
444
+ }
445
+ // If we have token outputs, derive total input amount from them
446
+ if (sendAmount + changeAmount > 0 && totalInputAmount === 0) {
447
+ totalInputAmount = sendAmount + changeAmount;
448
+ inputAmountSource = 'derived';
449
+ }
450
+ // If we still couldn't determine send amount, try calculating from inputs
451
+ if (sendAmount === 0 && totalInputAmount > 0) {
452
+ sendAmount = totalInputAmount - changeAmount;
453
+ }
454
+ return {
455
+ sendAmount,
456
+ totalInputAmount,
457
+ changeAmount,
458
+ outputSendAmount,
459
+ outputChangeAmount,
460
+ hasTokenOutputs,
461
+ inputAmountSource,
462
+ tokenName,
463
+ assetId,
464
+ recipient,
465
+ iconURL,
466
+ actionDescription: args.description || 'Token transaction'
467
+ };
468
+ }
469
+ /**
470
+ * Prompts user for token spend authorization with detailed information.
471
+ *
472
+ * SECURITY: The prompt data is JSON-encoded to prevent injection attacks.
473
+ * The UI component (TokenAccessPrompt) is responsible for safely rendering this data.
474
+ *
475
+ * @param originator - dApp identifier
476
+ * @param spendInfo - Parsed token spend information
477
+ * @throws Error if user denies authorization
478
+ */
479
+ async promptForTokenSpend(originator, spendInfo) {
480
+ // Input validation
481
+ if (!originator || typeof originator !== 'string') {
482
+ throw new Error('Invalid originator');
483
+ }
484
+ if (!spendInfo || typeof spendInfo !== 'object') {
485
+ throw new Error('Invalid spendInfo');
486
+ }
487
+ // Build structured prompt data (JSON-encoded for safety)
488
+ const promptData = {
489
+ type: 'btms_spend',
490
+ sendAmount: spendInfo.sendAmount,
491
+ tokenName: spendInfo.tokenName,
492
+ assetId: spendInfo.assetId,
493
+ recipient: spendInfo.recipient,
494
+ iconURL: spendInfo.iconURL,
495
+ changeAmount: spendInfo.changeAmount,
496
+ totalInputAmount: spendInfo.totalInputAmount
497
+ };
498
+ const message = JSON.stringify(promptData);
499
+ const approved = await this.requestTokenAccess(originator, message);
500
+ if (!approved) {
501
+ throw new Error('User denied permission to spend tokens');
502
+ }
503
+ this.grantSessionAuthorization(originator);
504
+ }
505
+ /**
506
+ * Prompts user for token burn authorization (burns all inputs with no token outputs).
507
+ */
508
+ async promptForTokenBurn(originator, spendInfo) {
509
+ // Input validation
510
+ if (!originator || typeof originator !== 'string') {
511
+ throw new Error('Invalid originator');
512
+ }
513
+ if (!spendInfo || typeof spendInfo !== 'object') {
514
+ throw new Error('Invalid spendInfo');
515
+ }
516
+ const promptData = {
517
+ type: 'btms_burn',
518
+ burnAmount: spendInfo.totalInputAmount,
519
+ tokenName: spendInfo.tokenName,
520
+ assetId: spendInfo.assetId,
521
+ iconURL: spendInfo.iconURL,
522
+ burnAll: spendInfo.changeAmount === 0
523
+ };
524
+ const message = JSON.stringify(promptData);
525
+ const approved = await this.requestTokenAccess(originator, message);
526
+ if (!approved) {
527
+ throw new Error('User denied permission to burn tokens');
528
+ }
529
+ this.grantSessionAuthorization(originator);
530
+ }
531
+ /**
532
+ * Prompts user for generic authorization when token details cannot be parsed.
533
+ *
534
+ * SECURITY: Fallback prompt when we can't extract detailed token information.
535
+ * Still requires explicit user approval.
536
+ *
537
+ * @param originator - dApp identifier
538
+ * @throws Error if user denies authorization
539
+ */
540
+ async promptForGenericAuthorization(originator) {
541
+ if (!originator || typeof originator !== 'string') {
542
+ throw new Error('Invalid originator');
543
+ }
544
+ const message = `Spend BTMS tokens\n\nApp: ${originator}`;
545
+ const approved = await this.requestTokenAccess(originator, message);
546
+ if (!approved) {
547
+ throw new Error('User denied permission to spend BTMS tokens');
548
+ }
549
+ this.grantSessionAuthorization(originator);
550
+ }
551
+ /**
552
+ * Handles createSignature requests for BTMS token spending.
553
+ *
554
+ * SECURITY: This is the second checkpoint. It verifies that:
555
+ * 1. Session authorization exists (granted by createAction approval)
556
+ * 2. The preimage matches the authorized transaction (prevents transaction substitution)
557
+ *
558
+ * ISSUANCE HANDLING:
559
+ * Token issuance is auto-approved via multiple detection methods:
560
+ * - Session auth from createAction (if ISSUE_MARKER or btms_issue tag detected)
561
+ * - Preimage parsing (checks for ISSUE_MARKER in scriptCode)
562
+ * - Short signatures (<157 bytes, not full BIP-143 preimages)
563
+ *
564
+ * @param args - createSignature arguments
565
+ * @param originator - dApp identifier
566
+ * @throws Error if authorization is denied or verification fails
567
+ */
568
+ async handleCreateSignature(args, originator) {
569
+ // Input validation
570
+ if (!args || typeof args !== 'object') {
571
+ throw new Error('Invalid createSignature args');
572
+ }
573
+ if (!originator || typeof originator !== 'string') {
574
+ throw new Error('Invalid originator');
575
+ }
576
+ // Check if we have session authorization from createAction
577
+ if (this.hasSessionAuthorization(originator)) {
578
+ // Session auth exists - proceed to verification
579
+ }
580
+ else {
581
+ // No session auth - check if this is token issuance
582
+ // Method 1: Parse BIP-143 preimage for ISSUE_MARKER
583
+ if (args.data && args.data.length >= 157) {
584
+ if (this.isIssuanceFromPreimage(args.data)) {
585
+ this.grantSessionAuthorization(originator);
586
+ return;
587
+ }
588
+ }
589
+ // Method 2: Short signatures (not full BIP-143 preimages) are assumed to be issuance
590
+ // This handles PushDrop signatures that don't include full transaction context
591
+ if (args.data && args.data.length > 0 && args.data.length < 157) {
592
+ this.grantSessionAuthorization(originator);
593
+ return;
594
+ }
595
+ // No authorization and not issuance - require user approval
596
+ await this.promptForGenericAuthorization(originator);
597
+ }
598
+ // Verify the signature request matches the authorized transaction
599
+ const authorizedTx = this.authorizedTransactions.get(originator);
600
+ if (!authorizedTx) {
601
+ // No transaction data captured - allow based on session auth alone
602
+ return;
603
+ }
604
+ // Check if authorization has expired
605
+ const elapsed = Date.now() - authorizedTx.timestamp;
606
+ if (elapsed > this.SESSION_TIMEOUT_MS) {
607
+ this.sessionAuthorizations.delete(originator);
608
+ this.authorizedTransactions.delete(originator);
609
+ throw new Error('Transaction authorization has expired. Please try again.');
610
+ }
611
+ // Verify the preimage matches the authorized transaction
612
+ if (args.data && args.data.length >= 157) {
613
+ this.verifyPreimage(args.data, authorizedTx, originator);
614
+ }
615
+ }
616
+ /**
617
+ * Verifies that a BIP-143 preimage matches the authorized transaction.
618
+ *
619
+ * SECURITY: This prevents a malicious dApp from:
620
+ * 1. Getting approval for one transaction
621
+ * 2. Signing a different transaction with different outputs or inputs
622
+ *
623
+ * BIP-143 preimage structure:
624
+ * - Version: 4 bytes
625
+ * - hashPrevouts: 32 bytes
626
+ * - hashSequence: 32 bytes
627
+ * - Outpoint (txid + vout): 36 bytes
628
+ * - scriptCode: variable (varint length + script)
629
+ * - Value: 8 bytes
630
+ * - Sequence: 4 bytes
631
+ * - hashOutputs: 32 bytes
632
+ * - Locktime: 4 bytes
633
+ * - Sighash type: 4 bytes
634
+ *
635
+ * Verification checks:
636
+ * 1. Outpoint being signed is in our authorized list
637
+ * 2. hashOutputs matches what we computed from createAction
638
+ *
639
+ * @param data - BIP-143 preimage bytes
640
+ * @param authorizedTx - Authorized transaction data from createAction
641
+ * @param _originator - dApp identifier (unused, for future logging)
642
+ * @throws Error if verification fails
643
+ */
644
+ verifyPreimage(data, authorizedTx, _originator) {
645
+ // Input validation
646
+ if (!Array.isArray(data) || data.length < 157) {
647
+ // Too short to be a valid BIP-143 preimage - skip verification
648
+ return;
649
+ }
650
+ if (!authorizedTx || typeof authorizedTx !== 'object') {
651
+ throw new Error('Invalid authorized transaction data');
652
+ }
653
+ try {
654
+ // Extract outpoint (bytes 68-103: 32-byte txid reversed + 4-byte vout)
655
+ const outpointStart = 4 + 32 + 32; // After version, hashPrevouts, hashSequence
656
+ if (data.length < outpointStart + 36) {
657
+ throw new Error('Preimage too short to extract outpoint');
658
+ }
659
+ const txidBytes = data.slice(outpointStart, outpointStart + 32);
660
+ // Reverse the txid bytes (Bitcoin uses little-endian)
661
+ const txid = Utils.toHex(txidBytes.reverse());
662
+ const voutBytes = data.slice(outpointStart + 32, outpointStart + 36);
663
+ const vout = voutBytes[0] | (voutBytes[1] << 8) | (voutBytes[2] << 16) | (voutBytes[3] << 24);
664
+ const outpoint = `${txid}.${vout}`;
665
+ // SECURITY: Verify the outpoint is in our authorized list
666
+ if (!authorizedTx.authorizedOutpoints.has(outpoint)) {
667
+ throw new Error(`Unauthorized outpoint: ${outpoint}. This transaction was not approved.`);
668
+ }
669
+ // Parse scriptCode length to find hashOutputs position
670
+ const scriptCodeLenStart = outpointStart + 36;
671
+ if (data.length < scriptCodeLenStart + 1) {
672
+ throw new Error('Preimage too short to parse scriptCode length');
673
+ }
674
+ let scriptCodeLen;
675
+ let scriptCodeDataStart;
676
+ const firstByte = data[scriptCodeLenStart];
677
+ if (firstByte < 0xfd) {
678
+ scriptCodeLen = firstByte;
679
+ scriptCodeDataStart = scriptCodeLenStart + 1;
680
+ }
681
+ else if (firstByte === 0xfd) {
682
+ if (data.length < scriptCodeLenStart + 3) {
683
+ throw new Error('Preimage too short for varint');
684
+ }
685
+ scriptCodeLen = data[scriptCodeLenStart + 1] | (data[scriptCodeLenStart + 2] << 8);
686
+ scriptCodeDataStart = scriptCodeLenStart + 3;
687
+ }
688
+ else if (firstByte === 0xfe) {
689
+ if (data.length < scriptCodeLenStart + 5) {
690
+ throw new Error('Preimage too short for varint');
691
+ }
692
+ scriptCodeLen = data[scriptCodeLenStart + 1] | (data[scriptCodeLenStart + 2] << 8) |
693
+ (data[scriptCodeLenStart + 3] << 16) | (data[scriptCodeLenStart + 4] << 24);
694
+ scriptCodeDataStart = scriptCodeLenStart + 5;
695
+ }
696
+ else {
697
+ // 0xff varint not expected for script lengths
698
+ return;
699
+ }
700
+ // Validate scriptCode length is reasonable (prevent DoS)
701
+ if (scriptCodeLen < 0 || scriptCodeLen > 10000) {
702
+ throw new Error('Invalid scriptCode length in preimage');
703
+ }
704
+ // hashOutputs starts after scriptCode + value(8) + sequence(4)
705
+ const hashOutputsStart = scriptCodeDataStart + scriptCodeLen + 8 + 4;
706
+ if (hashOutputsStart + 32 > data.length) {
707
+ throw new Error('Preimage too short to extract hashOutputs');
708
+ }
709
+ const hashOutputsBytes = data.slice(hashOutputsStart, hashOutputsStart + 32);
710
+ const preimageHashOutputs = Utils.toHex(hashOutputsBytes);
711
+ // SECURITY: Verify hashOutputs matches what user approved
712
+ if (preimageHashOutputs !== authorizedTx.hashOutputs) {
713
+ throw new Error('Transaction outputs do not match approved transaction. Possible attack detected.');
714
+ }
715
+ }
716
+ catch (error) {
717
+ // Re-throw security-critical errors
718
+ if (error instanceof Error &&
719
+ (error.message.includes('Unauthorized') ||
720
+ error.message.includes('do not match') ||
721
+ error.message.includes('attack'))) {
722
+ throw error;
723
+ }
724
+ // For parsing errors, fall back to session auth (don't block legitimate transactions)
725
+ }
726
+ }
727
+ /**
728
+ * Grants session authorization for an originator.
729
+ *
730
+ * SECURITY: Session authorization is time-limited (60s) to prevent replay attacks.
731
+ * After expiry, user must re-approve the transaction.
732
+ *
733
+ * @param originator - dApp identifier
734
+ */
735
+ grantSessionAuthorization(originator) {
736
+ if (!originator || typeof originator !== 'string') {
737
+ throw new Error('Invalid originator for session authorization');
738
+ }
739
+ this.sessionAuthorizations.set(originator, Date.now());
740
+ }
741
+ /**
742
+ * Checks if an originator has valid session authorization.
743
+ *
744
+ * SECURITY: Automatically expires and removes stale authorizations.
745
+ *
746
+ * @param originator - dApp identifier
747
+ * @returns true if valid session authorization exists
748
+ */
749
+ hasSessionAuthorization(originator) {
750
+ if (!originator || typeof originator !== 'string') {
751
+ return false;
752
+ }
753
+ const timestamp = this.sessionAuthorizations.get(originator);
754
+ if (!timestamp || typeof timestamp !== 'number') {
755
+ return false;
756
+ }
757
+ const elapsed = Date.now() - timestamp;
758
+ if (elapsed > this.SESSION_TIMEOUT_MS) {
759
+ // Auto-cleanup expired authorization
760
+ this.sessionAuthorizations.delete(originator);
761
+ this.authorizedTransactions.delete(originator);
762
+ return false;
763
+ }
764
+ return true;
765
+ }
766
+ /**
767
+ * Checks if a signature request is for token issuance by examining the BIP-143 preimage.
768
+ *
769
+ * ISSUANCE DETECTION: Parses the scriptCode from the preimage and checks for ISSUE_MARKER.
770
+ * This is needed because during issuance, createAction doesn't have P-basket outputs
771
+ * (basket is added later via internalizeAction), so handleCreateAction isn't triggered.
772
+ *
773
+ * @param preimage - BIP-143 preimage data
774
+ * @returns true if this is a token issuance signature
775
+ */
776
+ isIssuanceFromPreimage(preimage) {
777
+ if (!Array.isArray(preimage) || preimage.length < 157) {
778
+ return false;
779
+ }
780
+ try {
781
+ // Skip to scriptCode position
782
+ let offset = 4 + 32 + 32 + 36; // version + hashPrevouts + hashSequence + outpoint
783
+ if (offset >= preimage.length) {
784
+ return false;
785
+ }
786
+ // Parse varint scriptCode length
787
+ const firstByte = preimage[offset];
788
+ let scriptLength;
789
+ if (firstByte < 0xfd) {
790
+ scriptLength = firstByte;
791
+ offset += 1;
792
+ }
793
+ else if (firstByte === 0xfd) {
794
+ if (offset + 3 > preimage.length)
795
+ return false;
796
+ scriptLength = preimage[offset + 1] | (preimage[offset + 2] << 8);
797
+ offset += 3;
798
+ }
799
+ else if (firstByte === 0xfe) {
800
+ if (offset + 5 > preimage.length)
801
+ return false;
802
+ scriptLength = preimage[offset + 1] | (preimage[offset + 2] << 8) |
803
+ (preimage[offset + 3] << 16) | (preimage[offset + 4] << 24);
804
+ offset += 5;
805
+ }
806
+ else {
807
+ return false; // 0xff not expected
808
+ }
809
+ // Validate scriptLength
810
+ if (scriptLength < 0 || scriptLength > 10000 || offset + scriptLength > preimage.length) {
811
+ return false;
812
+ }
813
+ // Extract and decode scriptCode
814
+ const scriptBytes = preimage.slice(offset, offset + scriptLength);
815
+ const lockingScript = LockingScript.fromBinary(scriptBytes);
816
+ const decoded = PushDrop.decode(lockingScript);
817
+ // Check for ISSUE_MARKER in first field
818
+ if (decoded.fields.length >= 1) {
819
+ const assetId = Utils.toUTF8(decoded.fields[BTMS_FIELD.ASSET_ID]);
820
+ return assetId === ISSUE_MARKER;
821
+ }
822
+ }
823
+ catch (e) {
824
+ // Not a valid PushDrop script or parsing failed
825
+ return false;
826
+ }
827
+ return false;
828
+ }
829
+ /**
830
+ * Handles listActions requests that query BTMS token labels.
831
+ *
832
+ * Prompts the user when an app tries to list token transactions.
833
+ * This provides transparency about which apps are accessing token history.
834
+ *
835
+ * @param args - listActions arguments
836
+ * @param originator - dApp identifier
837
+ * @throws Error if user denies authorization
838
+ */
839
+ async handleListActions(args, originator) {
840
+ // Extract asset ID from labels if present
841
+ let assetId;
842
+ if (args.labels && Array.isArray(args.labels)) {
843
+ for (const label of args.labels) {
844
+ if (typeof label === 'string') {
845
+ // Parse p-label format: "p btms assetId <assetId>"
846
+ const match = label.match(/^p btms assetId (.+)$/);
847
+ if (match && match[1]) {
848
+ assetId = match[1];
849
+ break;
850
+ }
851
+ }
852
+ }
853
+ }
854
+ await this.promptForBTMSAccess(originator, assetId);
855
+ }
856
+ /**
857
+ * Handles listOutputs requests that query BTMS token baskets.
858
+ *
859
+ * Prompts the user when an app tries to list token balances/UTXOs.
860
+ * This provides transparency about which apps are accessing token data.
861
+ *
862
+ * @param args - listOutputs arguments
863
+ * @param originator - dApp identifier
864
+ * @throws Error if user denies authorization
865
+ */
866
+ async handleListOutputs(args, originator) {
867
+ // Extract asset ID from basket if present
868
+ let assetId;
869
+ if (args.basket && typeof args.basket === 'string') {
870
+ // Parse p-basket format: "p btms" or with asset ID
871
+ const match = args.basket.match(/^p btms(?:\s+(.+))?$/);
872
+ if (match && match[1]) {
873
+ assetId = match[1];
874
+ }
875
+ }
876
+ await this.promptForBTMSAccess(originator, assetId);
877
+ }
878
+ /**
879
+ * Prompts user once per session for BTMS token access (listActions/listOutputs).
880
+ */
881
+ async promptForBTMSAccess(originator, assetId) {
882
+ if (this.hasSessionAuthorization(originator))
883
+ return;
884
+ const promptData = {
885
+ type: 'btms_access',
886
+ action: 'access BTMS tokens',
887
+ assetId
888
+ };
889
+ const message = JSON.stringify(promptData);
890
+ const approved = await this.requestTokenAccess(originator, message);
891
+ if (!approved) {
892
+ throw new Error('User denied permission to access BTMS tokens');
893
+ }
894
+ this.grantSessionAuthorization(originator);
895
+ }
896
+ /**
897
+ * Fetches metadata for a specific asset using btms.getAssetInfo.
898
+ *
899
+ * @param assetId - The asset ID to look up
900
+ * @returns Token metadata or null if not found
901
+ */
902
+ async getAssetMetadata(assetId) {
903
+ try {
904
+ const info = await this.btms.getAssetInfo(assetId);
905
+ if (info) {
906
+ return {
907
+ name: info.name,
908
+ iconURL: info.metadata?.iconURL
909
+ };
910
+ }
911
+ }
912
+ catch {
913
+ // Ignore errors
914
+ }
915
+ return null;
916
+ }
917
+ /**
918
+ * Checks if the createAction is for token issuance.
919
+ *
920
+ * ISSUANCE DETECTION: Token issuance is detected by:
921
+ * 1. Output tags containing 'btms_type_issue'
922
+ * 2. Locking script contains ISSUE_MARKER in assetId field
923
+ *
924
+ * @param args - createAction arguments
925
+ * @returns true if this is a token issuance operation
926
+ */
927
+ isTokenIssuance(args) {
928
+ if (!args || !Array.isArray(args.outputs)) {
929
+ return false;
930
+ }
931
+ for (const output of args.outputs) {
932
+ if (!output || typeof output !== 'object') {
933
+ continue;
934
+ }
935
+ // Check for btms_type_issue tag
936
+ if (Array.isArray(output.tags) && output.tags.includes('btms_type_issue')) {
937
+ return true;
938
+ }
939
+ // Check locking script for ISSUE_MARKER
940
+ if (output.lockingScript && typeof output.lockingScript === 'string') {
941
+ try {
942
+ const lockingScript = LockingScript.fromHex(output.lockingScript);
943
+ const decoded = PushDrop.decode(lockingScript);
944
+ if (decoded.fields.length >= 1) {
945
+ const assetId = Utils.toUTF8(decoded.fields[BTMS_FIELD.ASSET_ID]);
946
+ if (assetId === ISSUE_MARKER) {
947
+ return true;
948
+ }
949
+ }
950
+ }
951
+ catch (e) {
952
+ // Not a valid PushDrop script, continue checking
953
+ }
954
+ }
955
+ }
956
+ return false;
957
+ }
958
+ /**
959
+ * Parses a BTMS token locking script to extract token information.
960
+ *
961
+ * BTMS TOKEN STRUCTURE:
962
+ * - Field 0: assetId (or "ISSUE" for issuance)
963
+ * - Field 1: amount (as string)
964
+ * - Field 2: metadata (optional JSON string)
965
+ * - Field 3: signature (present in signed PushDrop scripts)
966
+ *
967
+ * @param lockingScriptHex - Hex-encoded locking script
968
+ * @returns Parsed token info or null if parsing fails
969
+ */
970
+ parseTokenLockingScript(lockingScriptHex) {
971
+ if (!lockingScriptHex || typeof lockingScriptHex !== 'string') {
972
+ return null;
973
+ }
974
+ try {
975
+ const lockingScript = LockingScript.fromHex(lockingScriptHex);
976
+ const decoded = PushDrop.decode(lockingScript);
977
+ // BTMS tokens have 2-4 fields depending on metadata and signature presence
978
+ if (decoded.fields.length < 2 || decoded.fields.length > 4) {
979
+ return null;
980
+ }
981
+ // Extract assetId and amount
982
+ const assetId = Utils.toUTF8(decoded.fields[BTMS_FIELD.ASSET_ID]);
983
+ const amountStr = Utils.toUTF8(decoded.fields[BTMS_FIELD.AMOUNT]);
984
+ const amount = Number(amountStr);
985
+ // Validate amount
986
+ if (isNaN(amount) || amount <= 0 || !Number.isFinite(amount)) {
987
+ return null;
988
+ }
989
+ // Validate assetId
990
+ if (!assetId || typeof assetId !== 'string') {
991
+ return null;
992
+ }
993
+ // Try to parse metadata from field 2 if it exists
994
+ let metadata;
995
+ if (decoded.fields.length >= 3) {
996
+ try {
997
+ const potentialMetadata = Utils.toUTF8(decoded.fields[BTMS_FIELD.METADATA]);
998
+ // Only parse if it looks like JSON (starts with {)
999
+ if (potentialMetadata && typeof potentialMetadata === 'string' && potentialMetadata.startsWith('{')) {
1000
+ const parsed = JSON.parse(potentialMetadata);
1001
+ // Validate metadata is an object
1002
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1003
+ metadata = parsed;
1004
+ }
1005
+ }
1006
+ }
1007
+ catch (e) {
1008
+ // Field 2 might be a signature, not metadata - that's fine
1009
+ }
1010
+ }
1011
+ return { assetId, amount, metadata };
1012
+ }
1013
+ catch (e) {
1014
+ // Parsing failed - not a valid BTMS token
1015
+ return null;
1016
+ }
1017
+ }
1018
+ }