@bsv/sdk 1.4.13 → 1.4.17

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 (45) hide show
  1. package/dist/cjs/mod.js +1 -0
  2. package/dist/cjs/mod.js.map +1 -1
  3. package/dist/cjs/package.json +9 -9
  4. package/dist/cjs/src/auth/clients/AuthFetch.js +2 -0
  5. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  6. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js +2 -15
  7. package/dist/cjs/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  8. package/dist/cjs/src/kvstore/LocalKVStore.js +268 -0
  9. package/dist/cjs/src/kvstore/LocalKVStore.js.map +1 -0
  10. package/dist/cjs/src/kvstore/index.js +9 -0
  11. package/dist/cjs/src/kvstore/index.js.map +1 -0
  12. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  13. package/dist/esm/mod.js +1 -0
  14. package/dist/esm/mod.js.map +1 -1
  15. package/dist/esm/src/auth/clients/AuthFetch.js +2 -0
  16. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  17. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js +2 -15
  18. package/dist/esm/src/auth/transports/SimplifiedFetchTransport.js.map +1 -1
  19. package/dist/esm/src/kvstore/LocalKVStore.js +263 -0
  20. package/dist/esm/src/kvstore/LocalKVStore.js.map +1 -0
  21. package/dist/esm/src/kvstore/index.js +2 -0
  22. package/dist/esm/src/kvstore/index.js.map +1 -0
  23. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  24. package/dist/types/mod.d.ts +1 -0
  25. package/dist/types/mod.d.ts.map +1 -1
  26. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  27. package/dist/types/src/auth/transports/SimplifiedFetchTransport.d.ts.map +1 -1
  28. package/dist/types/src/kvstore/LocalKVStore.d.ts +79 -0
  29. package/dist/types/src/kvstore/LocalKVStore.d.ts.map +1 -0
  30. package/dist/types/src/kvstore/index.d.ts +2 -0
  31. package/dist/types/src/kvstore/index.d.ts.map +1 -0
  32. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  33. package/dist/umd/bundle.js +1 -1
  34. package/docs/identity.md +225 -0
  35. package/docs/kvstore.md +132 -0
  36. package/docs/registry.md +383 -0
  37. package/docs/transaction.md +3 -3
  38. package/mod.ts +2 -1
  39. package/package.json +19 -9
  40. package/src/auth/clients/AuthFetch.ts +2 -0
  41. package/src/auth/transports/SimplifiedFetchTransport.ts +2 -16
  42. package/src/kvstore/LocalKVStore.ts +282 -0
  43. package/src/kvstore/__tests/LocalKVStore.test.ts +703 -0
  44. package/src/kvstore/index.ts +1 -0
  45. package/docs/wallet-substrates.md +0 -1194
@@ -0,0 +1,703 @@
1
+ /** eslint-env jest */
2
+ import LocalKVStore from '../LocalKVStore.js'
3
+ import LockingScript from '../../script/LockingScript.js'
4
+ import PushDrop from '../../script/templates/PushDrop.js'
5
+ import * as Utils from '../../primitives/utils.js'
6
+ import {
7
+ WalletInterface,
8
+ ListOutputsResult,
9
+ WalletDecryptResult,
10
+ WalletEncryptResult,
11
+ CreateActionResult,
12
+ SignActionResult
13
+ } from '../../wallet/Wallet.interfaces.js'
14
+ import Transaction from '../../transaction/Transaction.js'
15
+
16
+ // --- Constants for Mock Values ---
17
+ const testLockingScriptHex = 'mockLockingScriptHex'
18
+ const testUnlockingScriptHex = 'mockUnlockingScriptHex'
19
+ const testEncryptedValue = Buffer.from('encryptedData') // Use Buffer for ciphertext
20
+ const testRawValue = 'myTestDataValue'
21
+ const testRawValueBuffer = Buffer.from(testRawValue) // Buffer for raw value
22
+
23
+ jest.mock('../../script/LockingScript.js', () => {
24
+ const mockLockingScriptInstance = {
25
+ toHex: jest.fn(() => testLockingScriptHex) // Default value
26
+ }
27
+ return {
28
+ fromHex: jest.fn(() => mockLockingScriptInstance) // Static method returns mock instance
29
+ }
30
+ })
31
+
32
+ jest.mock('../../script/templates/PushDrop.js', () => {
33
+ const mockLockingScriptInstance = {
34
+ toHex: jest.fn(() => testLockingScriptHex) // Default value
35
+ }
36
+ const mockUnlockerInstance = {
37
+ // Default sign behavior returns an object with a toHex mock
38
+ sign: jest.fn().mockResolvedValue({ toHex: jest.fn(() => testUnlockingScriptHex) })
39
+ }
40
+ // --- Define the mock instance returned by the PushDrop constructor ---
41
+ const mockPushDropInstance = {
42
+ // Default lock behavior returns the mock script
43
+ lock: jest.fn().mockResolvedValue(mockLockingScriptInstance),
44
+ // Default unlock behavior returns the mock unlocker
45
+ unlock: jest.fn().mockReturnValue(mockUnlockerInstance)
46
+ // Add a mock for the static decode method directly here if needed,
47
+ // or manage it separately as done below.
48
+ }
49
+
50
+ // --- Define the mock for the static decode method ---
51
+ // It needs to be separate because it's static, not on the instance.
52
+ const mockPushDropDecode = jest.fn()
53
+ return Object.assign(
54
+ jest.fn(() => mockPushDropInstance), // Constructor mock
55
+ { decode: mockPushDropDecode } // Static method mock
56
+ )
57
+ })
58
+ jest.mock('../../transaction/Transaction.js', () => ({
59
+ // Static method returns a minimal mock object
60
+ fromAtomicBEEF: jest.fn(() => ({ /* mock tx object if needed */ }))
61
+ }))
62
+
63
+ jest.mock('../../primitives/utils.js', () => ({
64
+ // Ensure toArray returns Array<number> or Uint8Array
65
+ toArray: jest.fn((str: string, encoding = 'utf8') => Array.from(Buffer.from(str, encoding as BufferEncoding))),
66
+ toUTF8: jest.fn((arr: number[] | Uint8Array) => Buffer.from(arr).toString('utf8'))
67
+ }))
68
+
69
+ jest.mock('../../wallet/WalletClient.js', () => jest.fn())
70
+
71
+ // --- Typed Mocks for SDK Components ---
72
+ const MockedLockingScript = LockingScript as jest.Mocked<typeof LockingScript>
73
+ // Use MockedClass for the constructor and add static methods separately
74
+ const MockedPushDrop = PushDrop as jest.MockedClass<typeof PushDrop> & {
75
+ decode: jest.Mock<any, any>
76
+ }
77
+ // Access the static mock assigned during jest.mock
78
+ const MockedPushDropDecode = MockedPushDrop.decode
79
+ const MockedUtils = Utils as jest.Mocked<typeof Utils>
80
+ const MockedTransaction = Transaction as jest.Mocked<typeof Transaction>
81
+
82
+ // --- Mock Wallet Setup ---
83
+ const createMockWallet = (): jest.Mocked<WalletInterface> => ({
84
+ listOutputs: jest.fn(),
85
+ encrypt: jest.fn(),
86
+ decrypt: jest.fn(),
87
+ createAction: jest.fn(),
88
+ signAction: jest.fn(),
89
+ relinquishOutput: jest.fn()
90
+ } as unknown as jest.Mocked<WalletInterface>)
91
+
92
+ describe('localKVStore', () => {
93
+ let mockWallet: jest.Mocked<WalletInterface>
94
+ let kvStore: LocalKVStore
95
+ const testContext = 'test-kv-context'
96
+ const testKey = 'myTestKey'
97
+ const testValue = 'myTestDataValue' // Raw value string used in tests
98
+ // Use the constants defined above for mock results
99
+ // const testEncryptedValue = Buffer.from('encryptedData'); // Defined above
100
+ const testOutpoint = 'txid123.0'
101
+ // const testLockingScriptHex = 'mockLockingScriptHex'; // Defined above
102
+ // const testUnlockingScriptHex = 'mockUnlockingScriptHex'; // Defined above
103
+
104
+ beforeEach(() => {
105
+ // Reset mocks before each test (clears calls and resets implementations)
106
+ jest.clearAllMocks()
107
+
108
+ // Create a fresh mock wallet for each test
109
+ mockWallet = createMockWallet()
110
+
111
+ // Create a kvStore instance with the mock wallet
112
+ // Default encrypt=true unless specified otherwise in a test block
113
+ kvStore = new LocalKVStore(mockWallet, testContext, true)
114
+
115
+ // Reset specific mock implementations if needed after clearAllMocks
116
+ // (e.g., if a test overrides a default implementation)
117
+ MockedPushDropDecode.mockClear() // Clear calls/results for static decode
118
+ })
119
+
120
+ // --- Constructor Tests ---
121
+ describe('constructor', () => {
122
+ it('should create an instance with default wallet and encrypt=true', () => {
123
+ // We need to mock the default WalletClient if the SUT uses it
124
+ const MockedWalletClient = require('../../../mod.js').WalletClient
125
+ const store = new LocalKVStore(undefined, 'default-context')
126
+ expect(store).toBeInstanceOf(LocalKVStore)
127
+ expect(MockedWalletClient).toHaveBeenCalledTimes(1) // Verify default was created
128
+ expect((store as any).context).toEqual('default-context')
129
+ expect((store as any).encrypt).toBe(true)
130
+ })
131
+
132
+ it('should create an instance with provided wallet, context, and encrypt=false', () => {
133
+ const customWallet = createMockWallet()
134
+ const store = new LocalKVStore(customWallet, 'custom-context', false)
135
+ expect(store).toBeInstanceOf(LocalKVStore)
136
+ expect((store as any).wallet).toBe(customWallet)
137
+ expect((store as any).context).toEqual('custom-context')
138
+ expect((store as any).encrypt).toBe(false)
139
+ })
140
+
141
+ it('should throw an error if context is missing or empty', () => {
142
+ expect(() => new LocalKVStore(mockWallet, '')).toThrow('A context in which to operate is required.')
143
+ expect(() => new LocalKVStore(mockWallet, null as any)).toThrow('A context in which to operate is required.')
144
+ })
145
+ })
146
+
147
+ // --- Get Method Tests ---
148
+ describe('get', () => {
149
+ it('should return defaultValue if no output is found', async () => {
150
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [], totalOutputs: 0, BEEF: undefined })
151
+ const defaultValue = 'default'
152
+ const result = await kvStore.get(testKey, defaultValue)
153
+
154
+ expect(result).toBe(defaultValue)
155
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({
156
+ basket: testContext,
157
+ tags: [testKey],
158
+ include: 'locking scripts' // Check include value
159
+ })
160
+ })
161
+
162
+ it('should return undefined if no output is found and no defaultValue provided', async () => {
163
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [], totalOutputs: 0, BEEF: undefined })
164
+ const result = await kvStore.get(testKey)
165
+
166
+ expect(result).toBeUndefined()
167
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({
168
+ basket: testContext,
169
+ tags: [testKey],
170
+ include: 'locking scripts' // Check include value
171
+ })
172
+ })
173
+
174
+ it('should throw an error if multiple outputs are found', async () => {
175
+ mockWallet.listOutputs.mockResolvedValue({
176
+ outputs: [
177
+ { outpoint: 'txid1.0', lockingScript: 'script1' },
178
+ { outpoint: 'txid2.0', lockingScript: 'script2' }
179
+ ],
180
+ totalOutputs: 2,
181
+ BEEF: undefined
182
+ } as unknown as ListOutputsResult)
183
+
184
+ await expect(kvStore.get(testKey)).rejects.toThrow(
185
+ 'Multiple tokens found for this key. You need to call set to collapse this ambiguous state before you can get this value again.'
186
+ )
187
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({
188
+ basket: testContext,
189
+ tags: [testKey],
190
+ include: 'locking scripts' // Check include value
191
+ })
192
+ })
193
+
194
+ it('should throw an error if PushDrop.decode fails', async () => {
195
+ const mockOutput = { outpoint: testOutpoint, lockingScript: testLockingScriptHex }
196
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [mockOutput], totalOutputs: 1, BEEF: undefined } as any)
197
+ // LockingScript.fromHex is called internally by PushDrop.decode in the real implementation,
198
+ // but we mock decode directly here. We still need fromHex mocked if the SUT calls it elsewhere.
199
+ // MockedLockingScript.fromHex is already mocked globally to return mockLockingScriptInstance
200
+
201
+ // Make the *static* decode method throw
202
+ MockedPushDropDecode.mockImplementation(() => { throw new Error('Decode failed') })
203
+
204
+ await expect(kvStore.get(testKey)).rejects.toThrow(
205
+ // Match the error message precisely
206
+ `Invalid value found. You need to call set to collapse the corrupted state (or relinquish the corrupted ${testOutpoint} output from the ${testContext} basket) before you can get this value again.`
207
+ )
208
+ expect(MockedLockingScript.fromHex).toHaveBeenCalledWith(testLockingScriptHex)
209
+ expect(MockedPushDropDecode).toHaveBeenCalledWith(expect.objectContaining({ // Check arg for decode
210
+ toHex: expect.any(Function) // Check it got the script obj
211
+ }))
212
+ })
213
+
214
+ it('should throw an error if decoded fields length is not 1', async () => {
215
+ const mockOutput = { outpoint: testOutpoint, lockingScript: testLockingScriptHex }
216
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [mockOutput], totalOutputs: 1, BEEF: undefined } as any)
217
+ // MockedLockingScript.fromHex is implicitly called by PushDrop.decode
218
+
219
+ // Mock the *static* decode to return multiple fields
220
+ MockedPushDropDecode.mockReturnValue({ fields: [Buffer.from([1, 2]), Buffer.from([3, 4])] })
221
+
222
+ await expect(kvStore.get(testKey)).rejects.toThrow('Invalid value found. You need to call set to collapse the corrupted state (or relinquish the corrupted txid123.0 output from the test-kv-context basket) before you can get this value again.')
223
+ expect(MockedLockingScript.fromHex).toHaveBeenCalledWith(testLockingScriptHex)
224
+ expect(MockedPushDropDecode).toHaveBeenCalled()
225
+ })
226
+
227
+ it('should get, decrypt and return the value when encrypt=true', async () => {
228
+ const mockOutput = { outpoint: testOutpoint, lockingScript: testLockingScriptHex }
229
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [mockOutput], totalOutputs: 1, BEEF: undefined } as any)
230
+ // MockedLockingScript.fromHex is implicitly called by PushDrop.decode
231
+
232
+ // Mock the *static* decode to return the encrypted value buffer
233
+ MockedPushDropDecode.mockReturnValue({ fields: [testEncryptedValue] })
234
+
235
+ // Mock decrypt to return the plain text Array<number>
236
+ mockWallet.decrypt.mockResolvedValue({ plaintext: Array.from(testRawValueBuffer) } as WalletDecryptResult)
237
+
238
+ // Mock Utils.toUTF8 to perform the final conversion
239
+ MockedUtils.toUTF8.mockReturnValue(testValue) // Mock based on testValue string
240
+
241
+ const result = await kvStore.get(testKey)
242
+
243
+ expect(result).toBe(testValue)
244
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({ basket: testContext, tags: [testKey], include: 'locking scripts' })
245
+ expect(MockedLockingScript.fromHex).toHaveBeenCalledWith(testLockingScriptHex)
246
+ expect(MockedPushDropDecode).toHaveBeenCalled()
247
+ expect(mockWallet.decrypt).toHaveBeenCalledWith({
248
+ protocolID: [2, testContext],
249
+ keyID: testKey,
250
+ // Ensure ciphertext is passed as Uint8Array or Buffer (Buffer should work)
251
+ ciphertext: testEncryptedValue
252
+ })
253
+ // Ensure toUTF8 is called with the *decrypted* buffer data (as Array<number> or Uint8Array)
254
+ expect(MockedUtils.toUTF8).toHaveBeenCalledWith(Array.from(testRawValueBuffer))
255
+ })
256
+
257
+ it('should get and return the value without decryption when encrypt=false', async () => {
258
+ kvStore = new LocalKVStore(mockWallet, testContext, false) // Recreate store with encrypt=false
259
+
260
+ const mockOutput = { outpoint: testOutpoint, lockingScript: testLockingScriptHex }
261
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [mockOutput], totalOutputs: 1, BEEF: undefined } as any)
262
+ // MockedLockingScript.fromHex implicitly called by PushDrop.decode
263
+
264
+ // Mock the *static* decode to return the raw value buffer
265
+ MockedPushDropDecode.mockReturnValue({ fields: [testRawValueBuffer] })
266
+
267
+ // Mock Utils.toUTF8 for final conversion
268
+ MockedUtils.toUTF8.mockReturnValue(testValue)
269
+
270
+ const result = await kvStore.get(testKey)
271
+
272
+ expect(result).toBe(testValue)
273
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({ basket: testContext, tags: [testKey], include: 'locking scripts' })
274
+ expect(MockedLockingScript.fromHex).toHaveBeenCalledWith(testLockingScriptHex)
275
+ expect(MockedPushDropDecode).toHaveBeenCalled()
276
+ expect(mockWallet.decrypt).not.toHaveBeenCalled() // Ensure decrypt was NOT called
277
+ expect(MockedUtils.toUTF8).toHaveBeenCalledWith(testRawValueBuffer) // Called with the raw buffer
278
+ })
279
+ })
280
+
281
+ // --- Set Method Tests ---
282
+ describe('set', () => {
283
+ let pushDropInstance: PushDrop // To access the instance methods
284
+
285
+ beforeEach(() => {
286
+ // Get the mock instance that will be created by `new PushDrop()`
287
+ pushDropInstance = new (PushDrop as any)()
288
+ })
289
+
290
+ it('should create a new encrypted output if none exists', async () => {
291
+ const valueArray = Array.from(testRawValueBuffer)
292
+ const encryptedArray = Array.from(testEncryptedValue)
293
+ MockedUtils.toArray.mockReturnValue(valueArray) // Mock toArray -> Array<number>
294
+ mockWallet.encrypt.mockResolvedValue({ ciphertext: encryptedArray } as WalletEncryptResult) // Encrypt returns Array<number>
295
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [], totalOutputs: 0, BEEF: undefined })
296
+ mockWallet.createAction.mockResolvedValue({ txid: 'newTxId' } as CreateActionResult)
297
+
298
+ // Get the mock instance returned by the constructor
299
+ const mockPDInstance = new MockedPushDrop(mockWallet)
300
+
301
+ const result = await kvStore.set(testKey, testValue)
302
+
303
+ expect(result).toBe('newTxId.0')
304
+ expect(MockedUtils.toArray).toHaveBeenCalledWith(testValue, 'utf8')
305
+ expect(mockWallet.encrypt).toHaveBeenCalledWith({
306
+ plaintext: valueArray, // Should be Array<number>
307
+ protocolID: [2, testContext],
308
+ keyID: testKey
309
+ })
310
+ // Check the mock instance's lock method
311
+ expect(mockPDInstance.lock).toHaveBeenCalledWith(
312
+ // The lock function expects Array<number[] | Uint8Array>
313
+ // Ensure the encrypted value is passed correctly (as Uint8Array or Array<number>)
314
+ [(encryptedArray)], // Pass buffer derived from encrypted array
315
+ [2, testContext],
316
+ testKey,
317
+ 'self'
318
+ )
319
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({
320
+ basket: testContext,
321
+ tags: [testKey],
322
+ include: 'entire transactions'
323
+ })
324
+ // Verify createAction for NEW output
325
+ expect(mockWallet.createAction).toHaveBeenCalledWith({
326
+ description: `Set ${testKey} in ${testContext}`,
327
+ outputs: [{
328
+ lockingScript: testLockingScriptHex, // From the mock lock result
329
+ satoshis: 1,
330
+ outputDescription: 'Key-value token'
331
+ }],
332
+ options: {
333
+ acceptDelayedBroadcast: false,
334
+ randomizeOutputs: false
335
+ }
336
+ })
337
+ expect(mockWallet.signAction).not.toHaveBeenCalled()
338
+ expect(mockWallet.relinquishOutput).not.toHaveBeenCalled()
339
+ })
340
+
341
+ it('should create a new non-encrypted output if none exists and encrypt=false', async () => {
342
+ kvStore = new LocalKVStore(mockWallet, testContext, false) // encrypt=false
343
+ const valueArray = Array.from(testRawValueBuffer)
344
+ MockedUtils.toArray.mockReturnValue(valueArray)
345
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [], totalOutputs: 0, BEEF: undefined })
346
+ mockWallet.createAction.mockResolvedValue({ txid: 'newTxIdNonEnc' } as CreateActionResult)
347
+
348
+ // Get the mock instance returned by the constructor
349
+ const mockPDInstance = new MockedPushDrop(mockWallet)
350
+
351
+ const result = await kvStore.set(testKey, testValue)
352
+
353
+ expect(result).toBe('newTxIdNonEnc.0')
354
+ expect(MockedUtils.toArray).toHaveBeenCalledWith(testValue, 'utf8')
355
+ expect(mockWallet.encrypt).not.toHaveBeenCalled()
356
+ // Check the mock instance's lock method
357
+ expect(mockPDInstance.lock).toHaveBeenCalledWith(
358
+ [(valueArray)], // Pass raw value buffer
359
+ [2, testContext],
360
+ testKey,
361
+ 'self'
362
+ )
363
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({
364
+ basket: testContext,
365
+ tags: [testKey],
366
+ include: 'entire transactions'
367
+ })
368
+ expect(mockWallet.createAction).toHaveBeenCalledWith({
369
+ description: `Set ${testKey} in ${testContext}`,
370
+ outputs: [{
371
+ lockingScript: testLockingScriptHex, // From mock lock
372
+ satoshis: 1,
373
+ outputDescription: 'Key-value token'
374
+ }],
375
+ options: {
376
+ acceptDelayedBroadcast: false,
377
+ randomizeOutputs: false
378
+ }
379
+ })
380
+ expect(mockWallet.signAction).not.toHaveBeenCalled()
381
+ expect(mockWallet.relinquishOutput).not.toHaveBeenCalled()
382
+ })
383
+
384
+ it('should update an existing output (spend and create)', async () => {
385
+ const existingOutpoint = 'oldTxId.0'
386
+ const existingOutput = { outpoint: existingOutpoint, txid: 'oldTxId', vout: 0, lockingScript: 'oldScriptHex' } // Added script
387
+ const mockBEEF = Array.from(Buffer.from('mockBEEFData'))
388
+ const signableRef = 'signableTxRef123'
389
+ const signableTx = []
390
+ const updatedTxId = 'updatedTxId'
391
+
392
+ const valueArray = Array.from(testRawValueBuffer)
393
+ const encryptedArray = Array.from(testEncryptedValue)
394
+
395
+ MockedUtils.toArray.mockReturnValue(valueArray)
396
+ mockWallet.encrypt.mockResolvedValue({ ciphertext: encryptedArray } as WalletEncryptResult)
397
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [existingOutput], totalOutputs: 1, BEEF: mockBEEF } as any)
398
+
399
+ // Mock createAction to return a signable transaction structure
400
+ mockWallet.createAction.mockResolvedValue({
401
+ signableTransaction: { reference: signableRef, tx: signableTx }
402
+ } as CreateActionResult)
403
+
404
+ // Mock Transaction.fromAtomicBEEF to return a mock TX object
405
+ const mockTxObject = { /* Can add mock properties/methods if SUT uses them */ }
406
+ MockedTransaction.fromAtomicBEEF.mockReturnValue(mockTxObject as any)
407
+
408
+ mockWallet.signAction.mockResolvedValue({ txid: updatedTxId } as SignActionResult)
409
+
410
+ // Get the mock instance returned by the constructor
411
+ const mockPDInstance = new MockedPushDrop(mockWallet)
412
+
413
+ const result = await kvStore.set(testKey, testValue)
414
+
415
+ expect(result).toBe(`${updatedTxId}.0`) // Assuming output 0 is the new KV token
416
+ expect(mockWallet.encrypt).toHaveBeenCalled()
417
+ expect(mockPDInstance.lock).toHaveBeenCalledWith([(encryptedArray)], [2, testContext], testKey, 'self')
418
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({ basket: testContext, tags: [testKey], include: 'entire transactions' })
419
+
420
+ // Verify createAction for UPDATE
421
+ expect(mockWallet.createAction).toHaveBeenCalledWith(expect.objectContaining({ // Use objectContaining for flexibility
422
+ description: `Update ${testKey} in ${testContext}`,
423
+ inputBEEF: mockBEEF,
424
+ inputs: expect.arrayContaining([ // Check inputs array
425
+ expect.objectContaining({ outpoint: existingOutpoint }) // Check specific input
426
+ ]),
427
+ outputs: expect.arrayContaining([ // Check outputs array
428
+ expect.objectContaining({ lockingScript: testLockingScriptHex }) // Check the new output script
429
+ ])
430
+ }))
431
+
432
+ // Verify signing steps
433
+ expect(MockedTransaction.fromAtomicBEEF).toHaveBeenCalledWith(signableTx)
434
+ // Check unlock was called on the instance
435
+ expect(mockPDInstance.unlock).toHaveBeenCalledWith([2, testContext], testKey, 'self')
436
+
437
+ // Get the unlocker returned by the mock unlock method
438
+ const mockUnlocker = (mockPDInstance.unlock as jest.Mock).mock.results[0].value
439
+ expect(mockUnlocker.sign).toHaveBeenCalledWith(mockTxObject, 0) // Check sign args
440
+
441
+ // Verify signAction call
442
+ expect(mockWallet.signAction).toHaveBeenCalledWith({
443
+ reference: signableRef,
444
+ spends: {
445
+ 0: { unlockingScript: testUnlockingScriptHex } // Check unlocking script from mock sign result
446
+ }
447
+ })
448
+ expect(mockWallet.relinquishOutput).not.toHaveBeenCalled()
449
+ })
450
+
451
+ it('should collapse multiple existing outputs into one', async () => {
452
+ const existingOutpoint1 = 'oldTxId1.0'
453
+ const existingOutpoint2 = 'oldTxId2.1'
454
+ const existingOutput1 = { outpoint: existingOutpoint1, txid: 'oldTxId1', vout: 0, lockingScript: 's1' }
455
+ const existingOutput2 = { outpoint: existingOutpoint2, txid: 'oldTxId2', vout: 1, lockingScript: 's2' }
456
+ const mockBEEF = Buffer.from('mockBEEFDataMulti')
457
+ const signableRef = 'signableTxRefMulti'
458
+ const signableTx = []
459
+ const updatedTxId = 'updatedTxIdMulti'
460
+ const mockTxObject = {} // Dummy TX object
461
+
462
+ const valueArray = Array.from(testRawValueBuffer)
463
+ const encryptedArray = Array.from(testEncryptedValue)
464
+
465
+ MockedUtils.toArray.mockReturnValue(valueArray)
466
+ mockWallet.encrypt.mockResolvedValue({ ciphertext: encryptedArray } as WalletEncryptResult)
467
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [existingOutput1, existingOutput2], totalOutputs: 2, BEEF: mockBEEF } as any)
468
+ mockWallet.createAction.mockResolvedValue({
469
+ signableTransaction: { reference: signableRef, tx: signableTx }
470
+ } as CreateActionResult)
471
+ MockedTransaction.fromAtomicBEEF.mockReturnValue(mockTxObject as any)
472
+ mockWallet.signAction.mockResolvedValue({ txid: updatedTxId } as SignActionResult)
473
+
474
+ // Get the mock instance
475
+ const mockPDInstance = new MockedPushDrop(mockWallet)
476
+
477
+ const result = await kvStore.set(testKey, testValue)
478
+
479
+ expect(result).toBe(`${updatedTxId}.0`)
480
+ expect(mockWallet.encrypt).toHaveBeenCalled()
481
+ expect(mockPDInstance.lock).toHaveBeenCalled()
482
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({ basket: testContext, tags: [testKey], include: 'entire transactions' })
483
+
484
+ // Verify createAction with multiple inputs
485
+ expect(mockWallet.createAction).toHaveBeenCalledWith(expect.objectContaining({
486
+ inputBEEF: mockBEEF,
487
+ inputs: expect.arrayContaining([
488
+ expect.objectContaining({ outpoint: existingOutpoint1 }),
489
+ expect.objectContaining({ outpoint: existingOutpoint2 })
490
+ ]),
491
+ outputs: expect.arrayContaining([
492
+ expect.objectContaining({ lockingScript: testLockingScriptHex })
493
+ ])
494
+ }))
495
+
496
+ // Verify signing loop
497
+ expect(MockedTransaction.fromAtomicBEEF).toHaveBeenCalledWith(signableTx)
498
+ expect(mockPDInstance.unlock).toHaveBeenCalledTimes(2) // Called for each input
499
+ expect(mockPDInstance.unlock).toHaveBeenNthCalledWith(1, [2, testContext], testKey, 'self')
500
+ expect(mockPDInstance.unlock).toHaveBeenNthCalledWith(2, [2, testContext], testKey, 'self')
501
+
502
+ // Get the *same* mock unlocker instance (since unlock is mocked to always return it)
503
+ const mockUnlocker = (mockPDInstance.unlock as jest.Mock).mock.results[0].value
504
+ expect(mockUnlocker.sign).toHaveBeenCalledTimes(2)
505
+ expect(mockUnlocker.sign).toHaveBeenNthCalledWith(1, mockTxObject, 0) // Input index 0
506
+ expect(mockUnlocker.sign).toHaveBeenNthCalledWith(2, mockTxObject, 1) // Input index 1
507
+
508
+ // Verify signAction call with multiple spends
509
+ expect(mockWallet.signAction).toHaveBeenCalledWith({
510
+ reference: signableRef,
511
+ spends: {
512
+ 0: { unlockingScript: testUnlockingScriptHex }, // Same mock script for both
513
+ 1: { unlockingScript: testUnlockingScriptHex }
514
+ }
515
+ })
516
+ expect(mockWallet.relinquishOutput).not.toHaveBeenCalled()
517
+ })
518
+
519
+ it('should relinquish outputs if signing fails during update', async () => {
520
+ const existingOutpoint1 = 'failTxId1.0'
521
+ const existingOutpoint2 = 'failTxId2.1'
522
+ const existingOutput1 = { outpoint: existingOutpoint1, txid: 'failTxId1', vout: 0, lockingScript: 's1' }
523
+ const existingOutput2 = { outpoint: existingOutpoint2, txid: 'failTxId2', vout: 1, lockingScript: 's2' }
524
+ const mockBEEF = Buffer.from('mockBEEFFail')
525
+ const signableRef = 'signableTxRefFail'
526
+ const signableTx = []
527
+ const mockTxObject = {}
528
+
529
+ const valueArray = Array.from(testRawValueBuffer)
530
+ const encryptedArray = Array.from(testEncryptedValue)
531
+
532
+ MockedUtils.toArray.mockReturnValue(valueArray)
533
+ mockWallet.encrypt.mockResolvedValue({ ciphertext: encryptedArray } as WalletEncryptResult)
534
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [existingOutput1, existingOutput2], totalOutputs: 2, BEEF: mockBEEF } as any)
535
+ mockWallet.createAction.mockResolvedValue({
536
+ signableTransaction: { reference: signableRef, tx: signableTx },
537
+ txid: 'fallback'
538
+ } as CreateActionResult)
539
+ MockedTransaction.fromAtomicBEEF.mockReturnValue(mockTxObject as any)
540
+ mockWallet.signAction.mockRejectedValue(new Error('Signature failed')) // Make signAction fail
541
+ mockWallet.relinquishOutput.mockResolvedValue({ relinquished: true }) // Mock relinquish success
542
+
543
+ // Get the mock instance
544
+ const mockPDInstance = new MockedPushDrop(mockWallet)
545
+
546
+ // Expect the error to be caught, and the method to complete and returns the fallback outpoint.
547
+ await expect(kvStore.set(testKey, testValue)).resolves.toEqual('fallback.0')
548
+
549
+ // Verify setup calls happened
550
+ expect(mockWallet.encrypt).toHaveBeenCalled()
551
+ expect(mockPDInstance.lock).toHaveBeenCalled()
552
+ expect(mockWallet.listOutputs).toHaveBeenCalled()
553
+ expect(mockWallet.createAction).toHaveBeenCalled()
554
+ expect(MockedTransaction.fromAtomicBEEF).toHaveBeenCalled()
555
+ expect(mockPDInstance.unlock).toHaveBeenCalledTimes(2) // Unlock was still called
556
+ const mockUnlocker = (mockPDInstance.unlock as jest.Mock).mock.results[0].value
557
+ expect(mockUnlocker.sign).toHaveBeenCalledTimes(2) // Sign was still called
558
+ expect(mockWallet.signAction).toHaveBeenCalled() // It was called, but failed
559
+
560
+ // Crucially, verify relinquish was called for each input
561
+ expect(mockWallet.relinquishOutput).toHaveBeenCalledTimes(2)
562
+ expect(mockWallet.relinquishOutput).toHaveBeenNthCalledWith(1, {
563
+ output: existingOutpoint1,
564
+ basket: testContext
565
+ })
566
+ expect(mockWallet.relinquishOutput).toHaveBeenNthCalledWith(2, {
567
+ output: existingOutpoint2,
568
+ basket: testContext
569
+ })
570
+ })
571
+ })
572
+
573
+ // --- Remove Method Tests ---
574
+ describe('remove', () => {
575
+ let pushDropInstance: PushDrop // To access the instance methods
576
+
577
+ beforeEach(() => {
578
+ // Get the mock instance that will be created by `new PushDrop()`
579
+ pushDropInstance = new (PushDrop as any)()
580
+ })
581
+
582
+ it('should do nothing and return void if key does not exist', async () => {
583
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [], totalOutputs: 0, BEEF: undefined })
584
+
585
+ const result = await kvStore.remove(testKey)
586
+
587
+ expect(result).toBeUndefined()
588
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({
589
+ basket: testContext,
590
+ tags: [testKey],
591
+ include: 'entire transactions' // remove checks for entire transactions
592
+ })
593
+ expect(mockWallet.createAction).not.toHaveBeenCalled()
594
+ expect(mockWallet.signAction).not.toHaveBeenCalled()
595
+ expect(mockWallet.relinquishOutput).not.toHaveBeenCalled()
596
+ })
597
+
598
+ it('should remove an existing key by spending its output(s)', async () => {
599
+ const existingOutpoint1 = 'removeTxId1.0'
600
+ const existingOutpoint2 = 'removeTxId2.1'
601
+ const existingOutput1 = { outpoint: existingOutpoint1, txid: 'removeTxId1', vout: 0, lockingScript: 's1' }
602
+ const existingOutput2 = { outpoint: existingOutpoint2, txid: 'removeTxId2', vout: 1, lockingScript: 's2' }
603
+ const mockBEEF = Buffer.from('mockBEEFRemove')
604
+ const signableRef = 'signableTxRefRemove'
605
+ const signableTx = []
606
+ const removalTxId = 'removalTxId'
607
+ const mockTxObject = {}
608
+
609
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [existingOutput1, existingOutput2], totalOutputs: 2, BEEF: mockBEEF } as any)
610
+ mockWallet.createAction.mockResolvedValue({
611
+ signableTransaction: { reference: signableRef, tx: signableTx }
612
+ } as CreateActionResult) // Note: removal tx has NO outputs field in result
613
+ MockedTransaction.fromAtomicBEEF.mockReturnValue(mockTxObject as any)
614
+ mockWallet.signAction.mockResolvedValue({ txid: removalTxId } as SignActionResult)
615
+
616
+ // Get the mock instance
617
+ const mockPDInstance = new MockedPushDrop(mockWallet)
618
+
619
+ const result = await kvStore.remove(testKey)
620
+
621
+ expect(result).toBe(removalTxId)
622
+ expect(mockWallet.listOutputs).toHaveBeenCalledWith({ basket: testContext, tags: [testKey], include: 'entire transactions' })
623
+
624
+ // Verify createAction for REMOVE (no outputs in the action)
625
+ expect(mockWallet.createAction).toHaveBeenCalledWith({
626
+ // The description might still say "Update" depending on implementation reuse
627
+ // description: `Remove ${testKey} from ${testContext}`, // Ideal description
628
+ description: expect.stringContaining(testKey), // More general check
629
+ inputBEEF: mockBEEF,
630
+ inputs: expect.arrayContaining([
631
+ expect.objectContaining({ outpoint: existingOutpoint1 }),
632
+ expect.objectContaining({ outpoint: existingOutpoint2 })
633
+ ]),
634
+ // IMPORTANT: No 'outputs' key should be present for removal action
635
+ outputs: undefined, // Or check that the key is not present
636
+ options: {
637
+ acceptDelayedBroadcast: false
638
+ }
639
+ })
640
+ // Check that outputs key is absent
641
+ expect(mockWallet.createAction.mock.calls[0][0]).not.toHaveProperty('outputs')
642
+
643
+ // Verify signing
644
+ expect(MockedTransaction.fromAtomicBEEF).toHaveBeenCalledWith(signableTx)
645
+ expect(mockPDInstance.unlock).toHaveBeenCalledTimes(2)
646
+ expect(mockPDInstance.unlock).toHaveBeenNthCalledWith(1, [2, testContext], testKey, 'self')
647
+ expect(mockPDInstance.unlock).toHaveBeenNthCalledWith(2, [2, testContext], testKey, 'self')
648
+ const mockUnlocker = (mockPDInstance.unlock as jest.Mock).mock.results[0].value
649
+ expect(mockUnlocker.sign).toHaveBeenCalledTimes(2)
650
+ expect(mockUnlocker.sign).toHaveBeenNthCalledWith(1, mockTxObject, 0)
651
+ expect(mockUnlocker.sign).toHaveBeenNthCalledWith(2, mockTxObject, 1)
652
+
653
+ // Verify signAction call
654
+ expect(mockWallet.signAction).toHaveBeenCalledWith({
655
+ reference: signableRef,
656
+ spends: {
657
+ 0: { unlockingScript: testUnlockingScriptHex },
658
+ 1: { unlockingScript: testUnlockingScriptHex }
659
+ }
660
+ })
661
+ expect(mockWallet.relinquishOutput).not.toHaveBeenCalled()
662
+ })
663
+
664
+ it('should relinquish outputs if signing fails during removal', async () => {
665
+ const existingOutpoint1 = 'failRemoveTxId1.0'
666
+ const existingOutput1 = { outpoint: existingOutpoint1, txid: 'failRemoveTxId1', vout: 0, lockingScript: 's1' }
667
+ const mockBEEF = Buffer.from('mockBEEFFailRemove')
668
+ const signableRef = 'signableTxRefFailRemove'
669
+ const signableTx = []
670
+ const mockTxObject = {}
671
+
672
+ mockWallet.listOutputs.mockResolvedValue({ outputs: [existingOutput1], totalOutputs: 1, BEEF: mockBEEF } as any)
673
+ mockWallet.createAction.mockResolvedValue({
674
+ signableTransaction: { reference: signableRef, tx: signableTx }
675
+ } as CreateActionResult)
676
+ MockedTransaction.fromAtomicBEEF.mockReturnValue(mockTxObject as any)
677
+ mockWallet.signAction.mockRejectedValue(new Error('Signature failed remove')) // Make signAction fail
678
+ mockWallet.relinquishOutput.mockResolvedValue({ relinquished: true })
679
+
680
+ // Get the mock instance
681
+ const mockPDInstance = new MockedPushDrop(mockWallet)
682
+
683
+ // Expect the error to be caught, method completes returning undefined/void
684
+ await expect(kvStore.remove(testKey)).resolves.toBeUndefined()
685
+
686
+ // Verify setup calls
687
+ expect(mockWallet.listOutputs).toHaveBeenCalled()
688
+ expect(mockWallet.createAction).toHaveBeenCalled() // createAction called for removal attempt
689
+ expect(MockedTransaction.fromAtomicBEEF).toHaveBeenCalled()
690
+ expect(mockPDInstance.unlock).toHaveBeenCalledTimes(1) // unlock was called
691
+ const mockUnlocker = (mockPDInstance.unlock as jest.Mock).mock.results[0].value
692
+ expect(mockUnlocker.sign).toHaveBeenCalledTimes(1) // sign was called
693
+ expect(mockWallet.signAction).toHaveBeenCalled() // Called but failed
694
+
695
+ // Verify relinquish was called
696
+ expect(mockWallet.relinquishOutput).toHaveBeenCalledTimes(1)
697
+ expect(mockWallet.relinquishOutput).toHaveBeenCalledWith({
698
+ output: existingOutpoint1,
699
+ basket: testContext
700
+ })
701
+ })
702
+ })
703
+ })