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