@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.
- package/CHANGELOG.md +8 -0
- package/docs/README.md +1 -0
- package/docs/client.md +135 -0
- package/docs/wab-shamir.md +311 -0
- package/docs/wallet.md +135 -0
- package/out/src/ShamirWalletManager.d.ts +213 -0
- package/out/src/ShamirWalletManager.d.ts.map +1 -0
- package/out/src/ShamirWalletManager.js +363 -0
- package/out/src/ShamirWalletManager.js.map +1 -0
- package/out/src/WalletPermissionsManager.d.ts +27 -0
- package/out/src/WalletPermissionsManager.d.ts.map +1 -1
- package/out/src/WalletPermissionsManager.js +308 -147
- package/out/src/WalletPermissionsManager.js.map +1 -1
- package/out/src/__tests/ShamirWalletManager.test.d.ts +2 -0
- package/out/src/__tests/ShamirWalletManager.test.d.ts.map +1 -0
- package/out/src/__tests/ShamirWalletManager.test.js +298 -0
- package/out/src/__tests/ShamirWalletManager.test.js.map +1 -0
- package/out/src/__tests/WalletPermissionsManager.callbacks.test.js +116 -0
- package/out/src/__tests/WalletPermissionsManager.callbacks.test.js.map +1 -1
- package/out/src/__tests/WalletPermissionsManager.pmodules.test.js +111 -0
- package/out/src/__tests/WalletPermissionsManager.pmodules.test.js.map +1 -1
- package/out/src/entropy/EntropyCollector.d.ts +89 -0
- package/out/src/entropy/EntropyCollector.d.ts.map +1 -0
- package/out/src/entropy/EntropyCollector.js +176 -0
- package/out/src/entropy/EntropyCollector.js.map +1 -0
- package/out/src/entropy/__tests/EntropyCollector.test.d.ts +2 -0
- package/out/src/entropy/__tests/EntropyCollector.test.d.ts.map +1 -0
- package/out/src/entropy/__tests/EntropyCollector.test.js +137 -0
- package/out/src/entropy/__tests/EntropyCollector.test.js.map +1 -0
- package/out/src/index.all.d.ts +2 -0
- package/out/src/index.all.d.ts.map +1 -1
- package/out/src/index.all.js +2 -0
- package/out/src/index.all.js.map +1 -1
- package/out/src/sdk/WalletServices.interfaces.d.ts.map +1 -1
- package/out/src/services/__tests/getRawTx.test.js +3 -0
- package/out/src/services/__tests/getRawTx.test.js.map +1 -1
- package/out/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.d.ts.map +1 -1
- package/out/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.js +4 -1
- package/out/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.js.map +1 -1
- package/out/src/storage/methods/internalizeAction.d.ts.map +1 -1
- package/out/src/storage/methods/internalizeAction.js +2 -2
- package/out/src/storage/methods/internalizeAction.js.map +1 -1
- package/out/src/storage/schema/entities/__tests/ProvenTxTests.test.js +4 -0
- package/out/src/storage/schema/entities/__tests/ProvenTxTests.test.js.map +1 -1
- package/out/src/wab-client/WABClient.d.ts +65 -0
- package/out/src/wab-client/WABClient.d.ts.map +1 -1
- package/out/src/wab-client/WABClient.js +107 -0
- package/out/src/wab-client/WABClient.js.map +1 -1
- package/out/tsconfig.all.tsbuildinfo +1 -1
- package/package.json +5 -1
- package/src/ShamirWalletManager.ts +499 -0
- package/src/WalletPermissionsManager.ts +368 -181
- package/src/__tests/ShamirWalletManager.test.ts +369 -0
- package/src/__tests/WalletPermissionsManager.callbacks.test.ts +140 -1
- package/src/__tests/WalletPermissionsManager.pmodules.test.ts +152 -0
- package/src/entropy/EntropyCollector.ts +228 -0
- package/src/entropy/__tests/EntropyCollector.test.ts +182 -0
- package/src/index.all.ts +2 -0
- package/src/sdk/WalletServices.interfaces.ts +0 -1
- package/src/services/__tests/getRawTx.test.ts +2 -0
- package/src/services/chaintracker/chaintracks/Storage/ChaintracksStorageBase.ts +4 -1
- package/src/storage/methods/internalizeAction.ts +4 -3
- package/src/storage/schema/entities/__tests/ProvenTxTests.test.ts +2 -0
- 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 () => {
|