@bsv/wallet-toolbox 1.7.20 → 1.8.1

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/docs/README.md +1 -0
  3. package/docs/client.md +135 -0
  4. package/docs/wab-shamir.md +311 -0
  5. package/docs/wallet.md +135 -0
  6. package/out/src/ShamirWalletManager.d.ts +213 -0
  7. package/out/src/ShamirWalletManager.d.ts.map +1 -0
  8. package/out/src/ShamirWalletManager.js +363 -0
  9. package/out/src/ShamirWalletManager.js.map +1 -0
  10. package/out/src/WalletPermissionsManager.d.ts +27 -0
  11. package/out/src/WalletPermissionsManager.d.ts.map +1 -1
  12. package/out/src/WalletPermissionsManager.js +308 -147
  13. package/out/src/WalletPermissionsManager.js.map +1 -1
  14. package/out/src/__tests/ShamirWalletManager.test.d.ts +2 -0
  15. package/out/src/__tests/ShamirWalletManager.test.d.ts.map +1 -0
  16. package/out/src/__tests/ShamirWalletManager.test.js +298 -0
  17. package/out/src/__tests/ShamirWalletManager.test.js.map +1 -0
  18. package/out/src/__tests/WalletPermissionsManager.callbacks.test.js +116 -0
  19. package/out/src/__tests/WalletPermissionsManager.callbacks.test.js.map +1 -1
  20. package/out/src/__tests/WalletPermissionsManager.pmodules.test.js +111 -0
  21. package/out/src/__tests/WalletPermissionsManager.pmodules.test.js.map +1 -1
  22. package/out/src/entropy/EntropyCollector.d.ts +89 -0
  23. package/out/src/entropy/EntropyCollector.d.ts.map +1 -0
  24. package/out/src/entropy/EntropyCollector.js +176 -0
  25. package/out/src/entropy/EntropyCollector.js.map +1 -0
  26. package/out/src/entropy/__tests/EntropyCollector.test.d.ts +2 -0
  27. package/out/src/entropy/__tests/EntropyCollector.test.d.ts.map +1 -0
  28. package/out/src/entropy/__tests/EntropyCollector.test.js +137 -0
  29. package/out/src/entropy/__tests/EntropyCollector.test.js.map +1 -0
  30. package/out/src/index.all.d.ts +2 -0
  31. package/out/src/index.all.d.ts.map +1 -1
  32. package/out/src/index.all.js +2 -0
  33. package/out/src/index.all.js.map +1 -1
  34. package/out/src/sdk/WalletServices.interfaces.d.ts.map +1 -1
  35. package/out/src/services/__tests/getRawTx.test.js +3 -0
  36. package/out/src/services/__tests/getRawTx.test.js.map +1 -1
  37. package/out/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.d.ts.map +1 -1
  38. package/out/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.js +4 -1
  39. package/out/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.js.map +1 -1
  40. package/out/src/storage/methods/internalizeAction.d.ts.map +1 -1
  41. package/out/src/storage/methods/internalizeAction.js +2 -2
  42. package/out/src/storage/methods/internalizeAction.js.map +1 -1
  43. package/out/src/storage/schema/entities/__tests/ProvenTxTests.test.js +4 -0
  44. package/out/src/storage/schema/entities/__tests/ProvenTxTests.test.js.map +1 -1
  45. package/out/src/wab-client/WABClient.d.ts +65 -0
  46. package/out/src/wab-client/WABClient.d.ts.map +1 -1
  47. package/out/src/wab-client/WABClient.js +107 -0
  48. package/out/src/wab-client/WABClient.js.map +1 -1
  49. package/out/tsconfig.all.tsbuildinfo +1 -1
  50. package/package.json +5 -1
  51. package/src/ShamirWalletManager.ts +499 -0
  52. package/src/WalletPermissionsManager.ts +368 -181
  53. package/src/__tests/ShamirWalletManager.test.ts +369 -0
  54. package/src/__tests/WalletPermissionsManager.callbacks.test.ts +140 -1
  55. package/src/__tests/WalletPermissionsManager.pmodules.test.ts +152 -0
  56. package/src/entropy/EntropyCollector.ts +228 -0
  57. package/src/entropy/__tests/EntropyCollector.test.ts +182 -0
  58. package/src/index.all.ts +2 -0
  59. package/src/sdk/WalletServices.interfaces.ts +0 -1
  60. package/src/services/__tests/getRawTx.test.ts +2 -0
  61. package/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.ts +4 -1
  62. package/src/storage/methods/internalizeAction.ts +4 -3
  63. package/src/storage/schema/entities/__tests/ProvenTxTests.test.ts +2 -0
  64. package/src/wab-client/WABClient.ts +135 -0
@@ -0,0 +1,369 @@
1
+ import { PrivateKey, WalletInterface } from '@bsv/sdk'
2
+ import { ShamirWalletManager } from '../ShamirWalletManager'
3
+ import { PrivilegedKeyManager } from '../sdk/PrivilegedKeyManager'
4
+ import { jest } from '@jest/globals'
5
+
6
+ // Mock WABClient
7
+ const mockWABClient = {
8
+ startShareAuth: jest.fn<() => Promise<{ success: boolean }>>().mockResolvedValue({ success: true }),
9
+ storeShare: jest
10
+ .fn<() => Promise<{ success: boolean; userId: number }>>()
11
+ .mockResolvedValue({ success: true, userId: 1 }),
12
+ retrieveShare: jest.fn<() => Promise<{ success: boolean; shareB: string }>>().mockResolvedValue({
13
+ success: true,
14
+ shareB: '1.mockedserversharefromwab.2.test'
15
+ }),
16
+ updateShare: jest
17
+ .fn<() => Promise<{ success: boolean; shareVersion: number }>>()
18
+ .mockResolvedValue({ success: true, shareVersion: 2 }),
19
+ deleteShamirUser: jest.fn<() => Promise<{ success: boolean }>>().mockResolvedValue({ success: true })
20
+ }
21
+
22
+ jest.mock('../wab-client/WABClient', () => {
23
+ return {
24
+ WABClient: jest.fn().mockImplementation(() => mockWABClient)
25
+ }
26
+ })
27
+
28
+ // Mock wallet builder
29
+ const mockWallet = {} as WalletInterface
30
+ const mockWalletBuilder = jest.fn(
31
+ async (privateKey: PrivateKey, privilegedKeyManager: PrivilegedKeyManager) => mockWallet
32
+ )
33
+
34
+ describe('ShamirWalletManager', () => {
35
+ describe('Configuration', () => {
36
+ it('should use default 2-of-3 configuration', () => {
37
+ const manager = new ShamirWalletManager({
38
+ wabServerUrl: 'https://test.example.com',
39
+ authMethodType: 'DevConsole',
40
+ walletBuilder: mockWalletBuilder
41
+ })
42
+
43
+ expect(manager.getThreshold()).toBe(2)
44
+ expect(manager.getTotalShares()).toBe(3)
45
+ })
46
+
47
+ it('should accept custom threshold configuration', () => {
48
+ const manager = new ShamirWalletManager({
49
+ wabServerUrl: 'https://test.example.com',
50
+ authMethodType: 'DevConsole',
51
+ walletBuilder: mockWalletBuilder,
52
+ threshold: 3,
53
+ totalShares: 5
54
+ })
55
+
56
+ expect(manager.getThreshold()).toBe(3)
57
+ expect(manager.getTotalShares()).toBe(5)
58
+ })
59
+
60
+ it('should reject threshold less than 2', () => {
61
+ expect(() => {
62
+ new ShamirWalletManager({
63
+ wabServerUrl: 'https://test.example.com',
64
+ authMethodType: 'DevConsole',
65
+ walletBuilder: mockWalletBuilder,
66
+ threshold: 1,
67
+ totalShares: 3
68
+ })
69
+ }).toThrow('Threshold must be at least 2')
70
+ })
71
+
72
+ it('should reject totalShares less than 3', () => {
73
+ expect(() => {
74
+ new ShamirWalletManager({
75
+ wabServerUrl: 'https://test.example.com',
76
+ authMethodType: 'DevConsole',
77
+ walletBuilder: mockWalletBuilder,
78
+ threshold: 2,
79
+ totalShares: 2
80
+ })
81
+ }).toThrow('Total shares must be at least 3')
82
+ })
83
+
84
+ it('should reject configurations where user cannot recover independently (custodial)', () => {
85
+ // 3-of-4 means user gets 3 shares (4-1), which equals threshold - OK
86
+ expect(() => {
87
+ new ShamirWalletManager({
88
+ wabServerUrl: 'https://test.example.com',
89
+ authMethodType: 'DevConsole',
90
+ walletBuilder: mockWalletBuilder,
91
+ threshold: 3,
92
+ totalShares: 4
93
+ })
94
+ }).not.toThrow()
95
+
96
+ // 3-of-3 means user gets 2 shares (3-1), which is less than threshold 3 - NOT OK
97
+ expect(() => {
98
+ new ShamirWalletManager({
99
+ wabServerUrl: 'https://test.example.com',
100
+ authMethodType: 'DevConsole',
101
+ walletBuilder: mockWalletBuilder,
102
+ threshold: 3,
103
+ totalShares: 3
104
+ })
105
+ }).toThrow('User must have at least 3 shares to recover independently')
106
+ })
107
+ })
108
+
109
+ describe('Entropy Collection', () => {
110
+ it('should track entropy collection', () => {
111
+ const manager = new ShamirWalletManager({
112
+ wabServerUrl: 'https://test.example.com',
113
+ authMethodType: 'DevConsole',
114
+ walletBuilder: mockWalletBuilder
115
+ })
116
+
117
+ expect(manager.hasEnoughEntropy()).toBe(false)
118
+
119
+ // Add a sample
120
+ const progress = manager.addMouseEntropy(100, 200)
121
+
122
+ // First sample should be accepted
123
+ expect(progress).not.toBeNull()
124
+ expect(manager.getEntropyProgress().collected).toBeGreaterThan(0)
125
+ })
126
+
127
+ it('should reset entropy', () => {
128
+ const manager = new ShamirWalletManager({
129
+ wabServerUrl: 'https://test.example.com',
130
+ authMethodType: 'DevConsole',
131
+ walletBuilder: mockWalletBuilder
132
+ })
133
+
134
+ // Add at least one sample
135
+ manager.addMouseEntropy(100, 200)
136
+
137
+ expect(manager.getEntropyProgress().collected).toBeGreaterThan(0)
138
+
139
+ manager.resetEntropy()
140
+
141
+ expect(manager.getEntropyProgress().collected).toBe(0)
142
+ })
143
+ })
144
+
145
+ describe('User ID Hash', () => {
146
+ it('should allow setting user ID hash', () => {
147
+ const manager = new ShamirWalletManager({
148
+ wabServerUrl: 'https://test.example.com',
149
+ authMethodType: 'DevConsole',
150
+ walletBuilder: mockWalletBuilder
151
+ })
152
+
153
+ expect(manager.getUserIdHash()).toBeUndefined()
154
+
155
+ manager.setUserIdHash('abc123')
156
+
157
+ expect(manager.getUserIdHash()).toBe('abc123')
158
+ })
159
+ })
160
+
161
+ describe('Share Validation', () => {
162
+ it('should validate share format with 4 parts', async () => {
163
+ const manager = new ShamirWalletManager({
164
+ wabServerUrl: 'https://test.example.com',
165
+ authMethodType: 'DevConsole',
166
+ walletBuilder: mockWalletBuilder
167
+ })
168
+
169
+ // This should fail because the share format is invalid
170
+ await expect(manager.recoverWithUserShares(['invalid-share', 'also-invalid'])).rejects.toThrow(
171
+ 'Invalid share format'
172
+ )
173
+ })
174
+
175
+ it('should reject shares with threshold less than 2', async () => {
176
+ const manager = new ShamirWalletManager({
177
+ wabServerUrl: 'https://test.example.com',
178
+ authMethodType: 'DevConsole',
179
+ walletBuilder: mockWalletBuilder
180
+ })
181
+
182
+ await expect(manager.recoverWithUserShares(['1.data.1.check', '2.data.1.check'])).rejects.toThrow(
183
+ 'Invalid share: threshold must be at least 2'
184
+ )
185
+ })
186
+ })
187
+
188
+ describe('Recovery', () => {
189
+ it('should require enough shares for recovery', async () => {
190
+ const manager = new ShamirWalletManager({
191
+ wabServerUrl: 'https://test.example.com',
192
+ authMethodType: 'DevConsole',
193
+ walletBuilder: mockWalletBuilder
194
+ })
195
+
196
+ // Only 1 share but threshold encoded in share is 2
197
+ await expect(manager.recoverWithUserShares(['1.somedata.2.check'])).rejects.toThrow(
198
+ 'Need at least 2 shares to recover'
199
+ )
200
+ })
201
+
202
+ it('should require userIdHash for server share recovery', async () => {
203
+ const manager = new ShamirWalletManager({
204
+ wabServerUrl: 'https://test.example.com',
205
+ authMethodType: 'DevConsole',
206
+ walletBuilder: mockWalletBuilder
207
+ })
208
+
209
+ await expect(manager.recoverWithServerShare(['1.somedata.2.check'], { otp: '123456' })).rejects.toThrow(
210
+ 'User ID hash not set'
211
+ )
212
+ })
213
+ })
214
+
215
+ describe('Wallet Building', () => {
216
+ it('should fail to build wallet without private key', async () => {
217
+ const manager = new ShamirWalletManager({
218
+ wabServerUrl: 'https://test.example.com',
219
+ authMethodType: 'DevConsole',
220
+ walletBuilder: mockWalletBuilder
221
+ })
222
+
223
+ await expect(manager.buildWallet()).rejects.toThrow('No private key available')
224
+ })
225
+
226
+ it('should fail to get wallet without building', () => {
227
+ const manager = new ShamirWalletManager({
228
+ wabServerUrl: 'https://test.example.com',
229
+ authMethodType: 'DevConsole',
230
+ walletBuilder: mockWalletBuilder
231
+ })
232
+
233
+ expect(() => manager.getWallet()).toThrow('Wallet not built')
234
+ })
235
+
236
+ it('should report no private key initially', () => {
237
+ const manager = new ShamirWalletManager({
238
+ wabServerUrl: 'https://test.example.com',
239
+ authMethodType: 'DevConsole',
240
+ walletBuilder: mockWalletBuilder
241
+ })
242
+
243
+ expect(manager.hasPrivateKey()).toBe(false)
244
+ })
245
+ })
246
+
247
+ describe('Wallet Creation', () => {
248
+ it('should create wallet and return correct number of user shares', async () => {
249
+ const manager = new ShamirWalletManager({
250
+ wabServerUrl: 'https://test.example.com',
251
+ authMethodType: 'DevConsole',
252
+ walletBuilder: mockWalletBuilder,
253
+ threshold: 2,
254
+ totalShares: 3
255
+ })
256
+
257
+ // Collect enough entropy
258
+ for (let i = 0; i < 256; i++) {
259
+ manager.addMouseEntropy(Math.random() * 1000, Math.random() * 1000)
260
+ }
261
+
262
+ let capturedShares: string[] = []
263
+
264
+ const result = await manager.createNewWallet({ otp: '123456' }, async (userShares, threshold, totalShares) => {
265
+ capturedShares = userShares
266
+ expect(threshold).toBe(2)
267
+ expect(totalShares).toBe(3)
268
+ return true
269
+ })
270
+
271
+ // Should get 2 user shares (totalShares - 1 for server)
272
+ expect(result.userShares.length).toBe(2)
273
+ expect(capturedShares.length).toBe(2)
274
+ expect(result.threshold).toBe(2)
275
+ expect(result.totalShares).toBe(3)
276
+ expect(result.userIdHash).toBeDefined()
277
+ expect(result.privateKey).toBeInstanceOf(PrivateKey)
278
+ expect(manager.hasPrivateKey()).toBe(true)
279
+ })
280
+
281
+ it('should return more user shares for higher totalShares', async () => {
282
+ const manager = new ShamirWalletManager({
283
+ wabServerUrl: 'https://test.example.com',
284
+ authMethodType: 'DevConsole',
285
+ walletBuilder: mockWalletBuilder,
286
+ threshold: 3,
287
+ totalShares: 5
288
+ })
289
+
290
+ for (let i = 0; i < 256; i++) {
291
+ manager.addMouseEntropy(Math.random() * 1000, Math.random() * 1000)
292
+ }
293
+
294
+ const result = await manager.createNewWallet({ otp: '123456' }, async userShares => {
295
+ expect(userShares.length).toBe(4) // 5 - 1 = 4 user shares
296
+ return true
297
+ })
298
+
299
+ expect(result.userShares.length).toBe(4)
300
+ })
301
+ })
302
+
303
+ describe('Account Deletion', () => {
304
+ it('should require userIdHash for deletion', async () => {
305
+ const manager = new ShamirWalletManager({
306
+ wabServerUrl: 'https://test.example.com',
307
+ authMethodType: 'DevConsole',
308
+ walletBuilder: mockWalletBuilder
309
+ })
310
+
311
+ await expect(manager.deleteAccount({ otp: '123456' })).rejects.toThrow('User ID hash not set')
312
+ })
313
+
314
+ it('should clear state after deletion', async () => {
315
+ const manager = new ShamirWalletManager({
316
+ wabServerUrl: 'https://test.example.com',
317
+ authMethodType: 'DevConsole',
318
+ walletBuilder: mockWalletBuilder
319
+ })
320
+
321
+ // Collect entropy and create wallet
322
+ for (let i = 0; i < 256; i++) {
323
+ manager.addMouseEntropy(Math.random() * 1000, Math.random() * 1000)
324
+ }
325
+
326
+ await manager.createNewWallet({ otp: '123456' }, async () => true)
327
+
328
+ expect(manager.hasPrivateKey()).toBe(true)
329
+ expect(manager.getUserIdHash()).toBeDefined()
330
+
331
+ // Delete
332
+ await manager.deleteAccount({ otp: '654321' })
333
+
334
+ expect(manager.hasPrivateKey()).toBe(false)
335
+ expect(manager.getUserIdHash()).toBeUndefined()
336
+ })
337
+ })
338
+
339
+ describe('Key Rotation', () => {
340
+ it('should require userIdHash for rotation', async () => {
341
+ const manager = new ShamirWalletManager({
342
+ wabServerUrl: 'https://test.example.com',
343
+ authMethodType: 'DevConsole',
344
+ walletBuilder: mockWalletBuilder
345
+ })
346
+
347
+ // Collect entropy
348
+ for (let i = 0; i < 256; i++) {
349
+ manager.addMouseEntropy(Math.random() * 1000, Math.random() * 1000)
350
+ }
351
+
352
+ await expect(manager.rotateKeys({ otp: '123456' }, async () => true)).rejects.toThrow('User ID hash not set')
353
+ })
354
+
355
+ it('should require entropy for rotation', async () => {
356
+ const manager = new ShamirWalletManager({
357
+ wabServerUrl: 'https://test.example.com',
358
+ authMethodType: 'DevConsole',
359
+ walletBuilder: mockWalletBuilder
360
+ })
361
+
362
+ manager.setUserIdHash('abc123')
363
+
364
+ await expect(manager.rotateKeys({ otp: '123456' }, async () => true)).rejects.toThrow(
365
+ 'Collect entropy before key rotation'
366
+ )
367
+ })
368
+ })
369
+ })
@@ -1,5 +1,6 @@
1
1
  import { mockUnderlyingWallet, MockedBSV_SDK } from './WalletPermissionsManager.fixtures'
2
- import { WalletPermissionsManager } from '../WalletPermissionsManager'
2
+ import { WalletPermissionsManager, GroupedPermissions } from '../WalletPermissionsManager'
3
+ import { WalletProtocol } from '@bsv/sdk'
3
4
 
4
5
  import { jest } from '@jest/globals'
5
6
 
@@ -320,4 +321,142 @@ describe('WalletPermissionsManager - Callbacks & Event Handling', () => {
320
321
  await expect(call1).resolves.toBeDefined()
321
322
  await expect(call2).rejects.toThrow(/Permission denied/)
322
323
  })
324
+
325
+ // -------------------------------------------------------------------------
326
+ // 3) Grouped Permission Error Handling Tests
327
+ // -------------------------------------------------------------------------
328
+
329
+ describe('grantGroupedPermission error handling', () => {
330
+ it('should reject pending promises when grantGroupedPermission throws a validation error', async () => {
331
+ // This test verifies the fix for the bug where pending promises would hang forever
332
+ // if grantGroupedPermission() threw an error during validation.
333
+
334
+ const groupedCb = jest.fn(() => {})
335
+ manager.bindCallback('onGroupedPermissionRequested', groupedCb)
336
+
337
+ // Manually set up a grouped permission request in the activeRequests map
338
+ // This simulates what happens when waitForAuthentication() is called
339
+ const requestID = 'group:test-originator.com'
340
+ const originalRequest = {
341
+ originator: 'test-originator.com',
342
+ permissions: {
343
+ protocolPermissions: [
344
+ { protocolID: [1, 'requested-protocol'] as WalletProtocol, counterparty: 'self', description: 'test' }
345
+ ]
346
+ }
347
+ }
348
+
349
+ // Create a promise that would normally hang forever if the fix isn't in place
350
+ let resolvedValue: any = null
351
+ let rejectedError: any = null
352
+ const pendingPromise = new Promise<boolean>((resolve, reject) => {
353
+ ;(manager as any).activeRequests.set(requestID, {
354
+ request: originalRequest,
355
+ pending: [{ resolve, reject }]
356
+ })
357
+ }).then(
358
+ val => {
359
+ resolvedValue = val
360
+ return val
361
+ },
362
+ err => {
363
+ rejectedError = err
364
+ throw err
365
+ }
366
+ )
367
+
368
+ // Try to grant with MISMATCHED permissions (different protocol than requested)
369
+ // This should cause grantGroupedPermission to throw a validation error
370
+ const grantPromise = manager.grantGroupedPermission({
371
+ requestID,
372
+ granted: {
373
+ protocolPermissions: [
374
+ { protocolID: [1, 'DIFFERENT-protocol'] as WalletProtocol, counterparty: 'self', description: 'test' }
375
+ ]
376
+ }
377
+ })
378
+
379
+ // grantGroupedPermission should throw
380
+ await expect(grantPromise).rejects.toThrow(/not a subset of the original request/)
381
+
382
+ // The pending promise should also be rejected (not left hanging!)
383
+ await expect(pendingPromise).rejects.toThrow(/not a subset of the original request/)
384
+
385
+ // Verify the activeRequests map was cleaned up
386
+ expect((manager as any).activeRequests.has(requestID)).toBe(false)
387
+ })
388
+
389
+ it('should clean up activeRequests even when grantGroupedPermission throws', async () => {
390
+ // Set up a request
391
+ const requestID = 'group:cleanup-test.com'
392
+ ;(manager as any).activeRequests.set(requestID, {
393
+ request: {
394
+ originator: 'cleanup-test.com',
395
+ permissions: { protocolPermissions: [] }
396
+ },
397
+ pending: [
398
+ {
399
+ resolve: () => {},
400
+ reject: () => {}
401
+ }
402
+ ]
403
+ })
404
+
405
+ // Verify it's in the map
406
+ expect((manager as any).activeRequests.has(requestID)).toBe(true)
407
+
408
+ // Grant with mismatched spending authorization to trigger an error
409
+ await expect(
410
+ manager.grantGroupedPermission({
411
+ requestID,
412
+ granted: {
413
+ spendingAuthorization: { amount: 1000, description: 'test' }
414
+ }
415
+ })
416
+ ).rejects.toThrow()
417
+
418
+ // The request should be cleaned up even though an error was thrown
419
+ expect((manager as any).activeRequests.has(requestID)).toBe(false)
420
+ })
421
+
422
+ it('should resolve pending promises when grantGroupedPermission succeeds', async () => {
423
+ // Mock createPermissionOnChain to prevent actual on-chain operations
424
+ jest.spyOn(manager as any, 'createPermissionOnChain').mockResolvedValue(undefined)
425
+
426
+ const requestID = 'group:success-test.com'
427
+ const requestedPermissions: Partial<GroupedPermissions> = {
428
+ protocolPermissions: [
429
+ { protocolID: [1, 'test-proto'] as WalletProtocol, counterparty: 'self', description: 'Test' }
430
+ ]
431
+ }
432
+
433
+ // Set up a pending request
434
+ let wasResolved = false
435
+ const pendingPromise = new Promise<boolean>((resolve, reject) => {
436
+ ;(manager as any).activeRequests.set(requestID, {
437
+ request: {
438
+ originator: 'success-test.com',
439
+ permissions: requestedPermissions
440
+ },
441
+ pending: [{ resolve, reject }]
442
+ })
443
+ }).then(val => {
444
+ wasResolved = true
445
+ return val
446
+ })
447
+
448
+ // Grant with matching permissions
449
+ await manager.grantGroupedPermission({
450
+ requestID,
451
+ granted: requestedPermissions
452
+ })
453
+
454
+ // The pending promise should resolve
455
+ await expect(pendingPromise).resolves.toBe(true)
456
+ expect(wasResolved).toBe(true)
457
+
458
+ // The request should be cleaned up
459
+ expect((manager as any).activeRequests.has(requestID)).toBe(false)
460
+ })
461
+ })
323
462
  })
@@ -14,6 +14,134 @@ describe('WalletPermissionsManager - Permission Module Support', () => {
14
14
  underlying = mockUnderlyingWallet()
15
15
  })
16
16
 
17
+ describe('P-Label Delegation', () => {
18
+ it('should delegate listActions through P-label modules in order and return responses in reverse order', async () => {
19
+ const callOrder: string[] = []
20
+
21
+ const module1: PermissionsModule = {
22
+ onRequest: jest.fn(async req => {
23
+ callOrder.push('req1')
24
+ expect((req.args as any).req1Processed).toBeUndefined()
25
+ return { ...req, args: { ...(req.args as any), req1Processed: true } }
26
+ }),
27
+ onResponse: jest.fn(async res => {
28
+ callOrder.push('res1')
29
+ expect((res as any).processedBy).toBe('module2')
30
+ return { ...res, finalProcessedBy: 'module1' }
31
+ })
32
+ }
33
+
34
+ const module2: PermissionsModule = {
35
+ onRequest: jest.fn(async req => {
36
+ callOrder.push('req2')
37
+ expect((req.args as any).req1Processed).toBe(true)
38
+ return { ...req, args: { ...(req.args as any), req2Processed: true } }
39
+ }),
40
+ onResponse: jest.fn(async res => {
41
+ callOrder.push('res2')
42
+ expect((res as any).processedBy).toBeUndefined()
43
+ return { ...res, processedBy: 'module2' }
44
+ })
45
+ }
46
+
47
+ const config: PermissionsManagerConfig = {
48
+ permissionModules: {
49
+ scheme1: module1,
50
+ scheme2: module2
51
+ },
52
+ seekPermissionWhenListingActionsByLabel: false
53
+ }
54
+
55
+ const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', config)
56
+
57
+ underlying.listActions.mockResolvedValue({ totalActions: 0, actions: [] })
58
+
59
+ const result = await manager.listActions(
60
+ {
61
+ labels: ['p scheme1 alpha', 'p scheme2 beta', 'regular-label']
62
+ },
63
+ 'app.com'
64
+ )
65
+
66
+ expect(module1.onRequest).toHaveBeenCalledTimes(1)
67
+ expect(module2.onRequest).toHaveBeenCalledTimes(1)
68
+ expect(module1.onResponse).toHaveBeenCalledTimes(1)
69
+ expect(module2.onResponse).toHaveBeenCalledTimes(1)
70
+ expect(callOrder).toEqual(['req1', 'req2', 'res2', 'res1'])
71
+ expect((result as any).finalProcessedBy).toBe('module1')
72
+ })
73
+
74
+ it('should delegate createAction when a P-label is present', async () => {
75
+ const testModule: PermissionsModule = {
76
+ onRequest: jest.fn(async req => req),
77
+ onResponse: jest.fn(async res => res)
78
+ }
79
+
80
+ const config: PermissionsManagerConfig = {
81
+ permissionModules: {
82
+ labels: testModule
83
+ },
84
+ seekSpendingPermissions: false,
85
+ seekBasketInsertionPermissions: false,
86
+ seekPermissionWhenApplyingActionLabels: false
87
+ }
88
+
89
+ const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', config)
90
+
91
+ underlying.createAction.mockResolvedValue({ txid: 'abc123', tx: [] })
92
+
93
+ await manager.createAction(
94
+ {
95
+ description: 'Label-based createAction',
96
+ labels: ['p labels token', 'regular-label'],
97
+ outputs: [
98
+ {
99
+ lockingScript: 'abcd',
100
+ satoshis: 1000,
101
+ basket: 'regular-basket',
102
+ outputDescription: 'Test output'
103
+ }
104
+ ]
105
+ },
106
+ 'app.com'
107
+ )
108
+
109
+ expect(testModule.onRequest).toHaveBeenCalledTimes(1)
110
+ expect(testModule.onResponse).toHaveBeenCalledTimes(1)
111
+ })
112
+
113
+ it('should delegate internalizeAction when a P-label is present', async () => {
114
+ const testModule: PermissionsModule = {
115
+ onRequest: jest.fn(async req => req),
116
+ onResponse: jest.fn(async res => res)
117
+ }
118
+
119
+ const config: PermissionsManagerConfig = {
120
+ permissionModules: {
121
+ labels: testModule
122
+ },
123
+ seekPermissionWhenApplyingActionLabels: false
124
+ }
125
+
126
+ const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', config)
127
+
128
+ underlying.internalizeAction.mockResolvedValue({ accepted: true })
129
+
130
+ await manager.internalizeAction(
131
+ {
132
+ tx: [],
133
+ description: 'Internalize with label',
134
+ labels: ['p labels internal', 'regular-label'],
135
+ outputs: []
136
+ } as any,
137
+ 'app.com'
138
+ )
139
+
140
+ expect(testModule.onRequest).toHaveBeenCalledTimes(1)
141
+ expect(testModule.onResponse).toHaveBeenCalledTimes(1)
142
+ })
143
+ })
144
+
17
145
  afterEach(() => {
18
146
  jest.clearAllMocks()
19
147
  })
@@ -658,6 +786,30 @@ describe('WalletPermissionsManager - Permission Module Support', () => {
658
786
  })
659
787
 
660
788
  describe('P-Module Error Handling', () => {
789
+ it('should reject invalid P-label formats', async () => {
790
+ const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', {})
791
+
792
+ await expect(manager.listActions({ labels: ['p schemeOnly'] }, 'app.com')).rejects.toThrow(
793
+ 'Invalid P-label format'
794
+ )
795
+ await expect(manager.listActions({ labels: ['p missingScheme'] }, 'app.com')).rejects.toThrow(
796
+ 'Invalid P-label format'
797
+ )
798
+ await expect(manager.listActions({ labels: ['p scheme '] }, 'app.com')).rejects.toThrow('Invalid P-label format')
799
+
800
+ expect(underlying.listActions).not.toHaveBeenCalled()
801
+ })
802
+
803
+ it('should throw if P-label scheme is unsupported', async () => {
804
+ const manager = new WalletPermissionsManager(underlying, 'customToken.domain.com', {})
805
+
806
+ await expect(manager.listActions({ labels: ['p unknown scheme', 'regular-label'] }, 'app.com')).rejects.toThrow(
807
+ 'Unsupported P-label scheme: p unknown'
808
+ )
809
+
810
+ expect(underlying.listActions).not.toHaveBeenCalled()
811
+ })
812
+
661
813
  it('should throw if P-module onRequest throws', async () => {
662
814
  const testModule: PermissionsModule = {
663
815
  onRequest: jest.fn(async () => {