@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.
- package/INTEGRATION.md +399 -0
- package/README.md +174 -0
- package/dist/BasicTokenModule.d.ts +307 -0
- package/dist/BasicTokenModule.d.ts.map +1 -0
- package/dist/BasicTokenModule.js +1018 -0
- package/dist/TokenUsagePrompt.d.ts +32 -0
- package/dist/TokenUsagePrompt.d.ts.map +1 -0
- package/dist/TokenUsagePrompt.js +181 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/types.d.ts +67 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +19 -0
- package/package.json +37 -0
- package/src/BasicTokenModule.ts +1145 -0
- package/src/index.ts +28 -0
- package/src/types.ts +81 -0
- package/tsconfig.json +27 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -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
|
+
}
|