@bsv/sdk 1.8.0 → 1.8.2

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 (69) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/kvstore/GlobalKVStore.js +420 -0
  3. package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -0
  4. package/dist/cjs/src/kvstore/LocalKVStore.js +6 -6
  5. package/dist/cjs/src/kvstore/LocalKVStore.js.map +1 -1
  6. package/dist/cjs/src/kvstore/index.js +3 -1
  7. package/dist/cjs/src/kvstore/index.js.map +1 -1
  8. package/dist/cjs/src/kvstore/kvStoreInterpreter.js +74 -0
  9. package/dist/cjs/src/kvstore/kvStoreInterpreter.js.map +1 -0
  10. package/dist/cjs/src/kvstore/types.js +11 -0
  11. package/dist/cjs/src/kvstore/types.js.map +1 -0
  12. package/dist/cjs/src/overlay-tools/Historian.js +153 -0
  13. package/dist/cjs/src/overlay-tools/Historian.js.map +1 -0
  14. package/dist/cjs/src/script/templates/PushDrop.js +2 -2
  15. package/dist/cjs/src/script/templates/PushDrop.js.map +1 -1
  16. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  17. package/dist/esm/src/kvstore/GlobalKVStore.js +416 -0
  18. package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -0
  19. package/dist/esm/src/kvstore/LocalKVStore.js +6 -6
  20. package/dist/esm/src/kvstore/LocalKVStore.js.map +1 -1
  21. package/dist/esm/src/kvstore/index.js +1 -0
  22. package/dist/esm/src/kvstore/index.js.map +1 -1
  23. package/dist/esm/src/kvstore/kvStoreInterpreter.js +47 -0
  24. package/dist/esm/src/kvstore/kvStoreInterpreter.js.map +1 -0
  25. package/dist/esm/src/kvstore/types.js +8 -0
  26. package/dist/esm/src/kvstore/types.js.map +1 -0
  27. package/dist/esm/src/overlay-tools/Historian.js +155 -0
  28. package/dist/esm/src/overlay-tools/Historian.js.map +1 -0
  29. package/dist/esm/src/script/templates/PushDrop.js +2 -2
  30. package/dist/esm/src/script/templates/PushDrop.js.map +1 -1
  31. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  32. package/dist/types/src/kvstore/GlobalKVStore.d.ts +129 -0
  33. package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -0
  34. package/dist/types/src/kvstore/index.d.ts +1 -0
  35. package/dist/types/src/kvstore/index.d.ts.map +1 -1
  36. package/dist/types/src/kvstore/kvStoreInterpreter.d.ts +22 -0
  37. package/dist/types/src/kvstore/kvStoreInterpreter.d.ts.map +1 -0
  38. package/dist/types/src/kvstore/types.d.ts +106 -0
  39. package/dist/types/src/kvstore/types.d.ts.map +1 -0
  40. package/dist/types/src/overlay-tools/Historian.d.ts +92 -0
  41. package/dist/types/src/overlay-tools/Historian.d.ts.map +1 -0
  42. package/dist/types/src/script/templates/PushDrop.d.ts +6 -5
  43. package/dist/types/src/script/templates/PushDrop.d.ts.map +1 -1
  44. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  45. package/dist/umd/bundle.js +3 -3
  46. package/dist/umd/bundle.js.map +1 -1
  47. package/docs/reference/compat.md +15 -27
  48. package/docs/reference/identity.md +12 -16
  49. package/docs/reference/kvstore.md +471 -4
  50. package/docs/reference/messages.md +0 -8
  51. package/docs/reference/overlay-tools.md +15 -22
  52. package/docs/reference/primitives.md +168 -168
  53. package/docs/reference/registry.md +9 -19
  54. package/docs/reference/script.md +35 -48
  55. package/docs/reference/storage.md +10 -14
  56. package/docs/reference/totp.md +5 -5
  57. package/docs/reference/transaction.md +117 -69
  58. package/docs/reference/wallet.md +131 -135
  59. package/package.json +1 -1
  60. package/src/kvstore/GlobalKVStore.ts +478 -0
  61. package/src/kvstore/LocalKVStore.ts +7 -7
  62. package/src/kvstore/__tests/GlobalKVStore.test.ts +965 -0
  63. package/src/kvstore/__tests/LocalKVStore.test.ts +72 -0
  64. package/src/kvstore/index.ts +1 -0
  65. package/src/kvstore/kvStoreInterpreter.ts +49 -0
  66. package/src/kvstore/types.ts +114 -0
  67. package/src/overlay-tools/Historian.ts +195 -0
  68. package/src/overlay-tools/__tests/Historian.test.ts +690 -0
  69. package/src/script/templates/PushDrop.ts +6 -5
@@ -0,0 +1,965 @@
1
+ /** eslint-env jest */
2
+ import GlobalKVStore from '../GlobalKVStore.js'
3
+ import { WalletInterface, CreateActionResult, SignActionResult } from '../../wallet/Wallet.interfaces.js'
4
+ import Transaction from '../../transaction/Transaction.js'
5
+ import { Historian } from '../../overlay-tools/Historian.js'
6
+ import { kvStoreInterpreter } from '../kvStoreInterpreter.js'
7
+ import { PushDrop } from '../../script/index.js'
8
+ import * as Utils from '../../primitives/utils.js'
9
+ import { TopicBroadcaster, LookupResolver } from '../../overlay-tools/index.js'
10
+ import { KVStoreConfig } from '../types.js'
11
+ import { Beef } from '../../transaction/Beef.js'
12
+ import { ProtoWallet } from '../../wallet/ProtoWallet.js'
13
+
14
+ // --- Module mocks ------------------------------------------------------------
15
+ jest.mock('../../transaction/Transaction.js')
16
+ jest.mock('../../transaction/Beef.js')
17
+ jest.mock('../../overlay-tools/Historian.js')
18
+ jest.mock('../kvStoreInterpreter.js')
19
+ jest.mock('../../script/index.js')
20
+ jest.mock('../../primitives/utils.js')
21
+ jest.mock('../../overlay-tools/index.js')
22
+ jest.mock('../../wallet/ProtoWallet.js')
23
+ jest.mock('../../wallet/WalletClient.js')
24
+
25
+ // --- Typed shortcuts to mocked classes --------------------------------------
26
+ const MockTransaction = Transaction as jest.MockedClass<typeof Transaction>
27
+ const MockBeef = Beef as jest.MockedClass<typeof Beef>
28
+ const MockHistorian = Historian as jest.MockedClass<typeof Historian>
29
+ const MockPushDrop = PushDrop as jest.MockedClass<typeof PushDrop>
30
+ const MockUtils = Utils as jest.Mocked<typeof Utils>
31
+ const MockTopicBroadcaster = TopicBroadcaster as jest.MockedClass<typeof TopicBroadcaster>
32
+ const MockLookupResolver = LookupResolver as jest.MockedClass<typeof LookupResolver>
33
+ const MockProtoWallet = ProtoWallet as jest.MockedClass<typeof ProtoWallet>
34
+
35
+ // --- Test constants ----------------------------------------------------------
36
+ const TEST_TXID =
37
+ '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
38
+ const TEST_CONTROLLER =
39
+ '02e3f2c4a5b6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3'
40
+ const TEST_KEY = 'testKey'
41
+ const TEST_VALUE = 'testValue'
42
+
43
+ // --- Helpers ----------------------------------------------------------------
44
+ type MTx = jest.Mocked<InstanceType<typeof Transaction>>
45
+ type MBeef = jest.Mocked<InstanceType<typeof Beef>> & { findOutput: jest.Mock }
46
+ type MHistorian = jest.Mocked<InstanceType<typeof Historian>>
47
+ type MResolver = jest.Mocked<InstanceType<typeof LookupResolver>>
48
+ type MBroadcaster = jest.Mocked<InstanceType<typeof TopicBroadcaster>>
49
+ type MProtoWallet = jest.Mocked<InstanceType<typeof ProtoWallet>>
50
+
51
+ function makeMockTx(): MTx {
52
+ return {
53
+ id: jest.fn().mockReturnValue(TEST_TXID),
54
+ // Only the properties used by GlobalKVStore are needed
55
+ outputs: [
56
+ {
57
+ lockingScript: {
58
+ toHex: jest.fn().mockReturnValue('mock_script'),
59
+ toArray: jest.fn().mockReturnValue([1, 2, 3]),
60
+ },
61
+ satoshis: 1,
62
+ },
63
+ ],
64
+ inputs: [],
65
+ } as any
66
+ }
67
+
68
+ function primeTransactionMocks(tx: MTx) {
69
+ ; (MockTransaction.fromAtomicBEEF as jest.Mock).mockReturnValue(tx)
70
+ ; (MockTransaction.fromBEEF as jest.Mock).mockReturnValue(tx)
71
+ }
72
+
73
+ function primeBeefMocks(beef: MBeef, tx: MTx) {
74
+ beef.toBinary.mockReturnValue(Array.from(new Uint8Array([1, 2, 3])))
75
+ beef.findTxid.mockReturnValue({ tx } as any)
76
+ beef.findOutput = jest.fn().mockReturnValue(tx.outputs[0] as any)
77
+ MockBeef.mockImplementation(() => beef)
78
+ ; (MockBeef as any).fromBinary = jest.fn().mockReturnValue(beef)
79
+ }
80
+
81
+ function primePushDropDecodeToValidValue() {
82
+ ; (MockPushDrop as any).decode = jest.fn().mockReturnValue({
83
+ fields: [
84
+ Array.from(Buffer.from(JSON.stringify([1, 'kvstore']))), // protocolID
85
+ Array.from(Buffer.from(TEST_KEY)), // key
86
+ Array.from(Buffer.from(TEST_VALUE)), // value
87
+ Array.from(Buffer.from(TEST_CONTROLLER, 'hex')), // controller
88
+ Array.from(Buffer.from('signature')), // signature
89
+ ],
90
+ })
91
+ }
92
+
93
+ function primeUtilsDefaults() {
94
+ MockUtils.toUTF8.mockImplementation((arr: any) => {
95
+ if (typeof arr === 'string') return arr
96
+ if (!Array.isArray(arr)) return TEST_VALUE
97
+
98
+ // Check for protocolID field (JSON for [1,"kvstore"])
99
+ if (arr.join(',') === '91,49,44,34,107,118,115,116,111,114,101,34,93') {
100
+ return '[1,"kvstore"]'
101
+ }
102
+
103
+ // Check for key field (TEST_KEY as bytes)
104
+ const testKeyBytes = Array.from(Buffer.from(TEST_KEY))
105
+ if (arr.join(',') === testKeyBytes.join(',')) {
106
+ return TEST_KEY
107
+ }
108
+
109
+ // Default to TEST_VALUE for value field
110
+ return TEST_VALUE
111
+ })
112
+ MockUtils.toHex.mockImplementation((arr: any) => {
113
+ if (Array.isArray(arr) && arr.length > 0) {
114
+ return TEST_CONTROLLER
115
+ }
116
+ return 'mock_hex'
117
+ })
118
+ MockUtils.toBase64.mockReturnValue('dGVzdEtleQ==') // base64 of 'testKey'
119
+ MockUtils.toArray.mockReturnValue([1, 2, 3, 4])
120
+ }
121
+
122
+ function primeWalletMocks() {
123
+ return {
124
+ getPublicKey: jest.fn().mockResolvedValue({ publicKey: TEST_CONTROLLER }),
125
+ createAction: jest.fn().mockResolvedValue({
126
+ tx: Array.from(new Uint8Array([1, 2, 3])),
127
+ txid: TEST_TXID,
128
+ signableTransaction: {
129
+ tx: Array.from(new Uint8Array([1, 2, 3])),
130
+ reference: 'ref123',
131
+ },
132
+ } as CreateActionResult),
133
+ signAction: jest.fn().mockResolvedValue({
134
+ tx: Array.from(new Uint8Array([1, 2, 3])),
135
+ txid: TEST_TXID,
136
+ } as SignActionResult),
137
+ } as unknown as jest.Mocked<WalletInterface>
138
+ }
139
+
140
+ function primeResolverEmpty(resolver: MResolver) {
141
+ resolver.query.mockResolvedValue({ type: 'output-list', outputs: [] } as any)
142
+ }
143
+
144
+ function primeResolverWithOneOutput(resolver: MResolver) {
145
+ const mockOutput = {
146
+ beef: Array.from(new Uint8Array([1, 2, 3])),
147
+ outputIndex: 0,
148
+ context: Array.from(new Uint8Array([4, 5, 6])),
149
+ }
150
+ resolver.query.mockResolvedValue({
151
+ type: 'output-list',
152
+ outputs: [mockOutput],
153
+ } as any)
154
+ }
155
+
156
+ function primeResolverWithMultipleOutputs(resolver: MResolver, count: number = 3) {
157
+ const mockOutputs = Array.from({ length: count }, (_, i) => ({
158
+ beef: Array.from(new Uint8Array([1, 2, 3, i])),
159
+ outputIndex: i,
160
+ context: Array.from(new Uint8Array([4, 5, 6, i])),
161
+ }))
162
+ resolver.query.mockResolvedValue({
163
+ type: 'output-list',
164
+ outputs: mockOutputs,
165
+ } as any)
166
+ }
167
+
168
+ // --- Test suite --------------------------------------------------------------
169
+ describe('GlobalKVStore', () => {
170
+ let kvStore: GlobalKVStore
171
+ let mockWallet: jest.Mocked<WalletInterface>
172
+ let mockHistorian: MHistorian
173
+ let mockResolver: MResolver
174
+ let mockBroadcaster: MBroadcaster
175
+ let mockBeef: MBeef
176
+ let mockProtoWallet: MProtoWallet
177
+ let tx: MTx
178
+
179
+ beforeEach(() => {
180
+ jest.clearAllMocks()
181
+
182
+ // Wallet
183
+ mockWallet = primeWalletMocks()
184
+
185
+ // Tx/BEEF
186
+ tx = makeMockTx()
187
+ primeTransactionMocks(tx)
188
+ mockBeef = {
189
+ toBinary: jest.fn(),
190
+ findTxid: jest.fn(),
191
+ findOutput: jest.fn(),
192
+ } as any
193
+ primeBeefMocks(mockBeef, tx)
194
+
195
+ // Historian
196
+ mockHistorian = {
197
+ buildHistory: jest.fn().mockResolvedValue([TEST_VALUE]),
198
+ } as any
199
+ MockHistorian.mockImplementation(() => mockHistorian)
200
+
201
+ // PushDrop lock/unlock plumbing
202
+ const mockLockingScript = { toHex: () => 'mockLockingScriptHex' }
203
+ const mockPushDrop = {
204
+ lock: jest.fn().mockResolvedValue(mockLockingScript),
205
+ unlock: jest.fn().mockReturnValue({
206
+ sign: jest.fn().mockResolvedValue({ toHex: () => 'mockUnlockingScript' }),
207
+ }),
208
+ }
209
+ MockPushDrop.mockImplementation(() => mockPushDrop as any)
210
+ primePushDropDecodeToValidValue()
211
+
212
+ // Utils
213
+ primeUtilsDefaults()
214
+
215
+ // Resolver / Broadcaster
216
+ mockResolver = {
217
+ query: jest.fn(),
218
+ } as any
219
+ MockLookupResolver.mockImplementation(() => mockResolver)
220
+ mockBroadcaster = {
221
+ broadcast: jest.fn().mockResolvedValue({ success: true }),
222
+ } as any
223
+ MockTopicBroadcaster.mockImplementation(() => mockBroadcaster)
224
+
225
+ // Proto wallet
226
+ mockProtoWallet = {
227
+ createHmac: jest.fn().mockResolvedValue({ hmac: new Uint8Array(32) }),
228
+ verifySignature: jest.fn().mockResolvedValue({ valid: true }),
229
+ } as any
230
+ MockProtoWallet.mockImplementation(() => mockProtoWallet)
231
+
232
+ // SUT
233
+ kvStore = new GlobalKVStore({ wallet: mockWallet })
234
+ })
235
+
236
+ // --------------------------------------------------------------------------
237
+ describe('Constructor', () => {
238
+ it('creates with default config', () => {
239
+ const store = new GlobalKVStore()
240
+ expect(store).toBeInstanceOf(GlobalKVStore)
241
+ })
242
+
243
+ it('creates with custom config', () => {
244
+ const config: KVStoreConfig = {
245
+ wallet: mockWallet,
246
+ protocolID: [2, 'custom'],
247
+ tokenAmount: 500,
248
+ networkPreset: 'testnet',
249
+ }
250
+ const store = new GlobalKVStore(config)
251
+ expect(store).toBeInstanceOf(GlobalKVStore)
252
+ })
253
+
254
+ it('initializes Historian with kvStoreInterpreter', () => {
255
+ expect(MockHistorian).toHaveBeenCalledWith(kvStoreInterpreter)
256
+ })
257
+ })
258
+
259
+ // --------------------------------------------------------------------------
260
+ describe('get', () => {
261
+ describe('happy paths', () => {
262
+ it('returns empty array when key not found', async () => {
263
+ primeResolverEmpty(mockResolver)
264
+ const result = await kvStore.get({ key: TEST_KEY })
265
+ expect(Array.isArray(result)).toBe(true)
266
+ expect(result).toHaveLength(0)
267
+ })
268
+
269
+
270
+ it('returns KVStoreEntry when a valid token exists', async () => {
271
+ primeResolverWithOneOutput(mockResolver)
272
+
273
+ const result = await kvStore.get({ key: TEST_KEY, controller: TEST_CONTROLLER })
274
+
275
+ expect(result).toEqual({
276
+ key: TEST_KEY,
277
+ value: TEST_VALUE,
278
+ controller: expect.any(String),
279
+ protocolID: [1, 'kvstore']
280
+ })
281
+ expect(mockResolver.query).toHaveBeenCalledWith({
282
+ service: 'ls_kvstore',
283
+ query: expect.objectContaining({
284
+ key: TEST_KEY,
285
+ controller: TEST_CONTROLLER
286
+ })
287
+ })
288
+ })
289
+
290
+ it('returns entry with history when history=true', async () => {
291
+ primeResolverWithOneOutput(mockResolver)
292
+ mockHistorian.buildHistory.mockResolvedValue(['oldValue', TEST_VALUE])
293
+
294
+ const result = await kvStore.get({ key: TEST_KEY, controller: TEST_CONTROLLER }, { history: true })
295
+
296
+ expect(result).toEqual({
297
+ key: TEST_KEY,
298
+ value: TEST_VALUE,
299
+ controller: expect.any(String),
300
+ protocolID: [1, 'kvstore'],
301
+ history: ['oldValue', TEST_VALUE]
302
+ })
303
+ expect(mockHistorian.buildHistory).toHaveBeenCalledWith(
304
+ expect.any(Object),
305
+ expect.objectContaining({ key: TEST_KEY })
306
+ )
307
+ })
308
+
309
+ it('supports querying by protocolID', async () => {
310
+ primeResolverWithOneOutput(mockResolver)
311
+
312
+ const result = await kvStore.get({ protocolID: [1, 'kvstore'] })
313
+
314
+ expect(Array.isArray(result)).toBe(true)
315
+ expect(mockResolver.query).toHaveBeenCalledWith({
316
+ service: 'ls_kvstore',
317
+ query: expect.objectContaining({
318
+ protocolID: [1, 'kvstore']
319
+ })
320
+ })
321
+ })
322
+
323
+ it('includes token data when includeToken=true for key queries', async () => {
324
+ primeResolverWithOneOutput(mockResolver)
325
+
326
+ const result = await kvStore.get({ key: TEST_KEY }, { includeToken: true })
327
+
328
+ expect(Array.isArray(result)).toBe(true)
329
+ expect(result).toHaveLength(1)
330
+ if (Array.isArray(result) && result.length > 0) {
331
+ expect(result[0].token).toBeDefined()
332
+ expect(result[0].token).toEqual({
333
+ txid: TEST_TXID,
334
+ outputIndex: 0,
335
+ satoshis: 1,
336
+ beef: expect.any(Object)
337
+ })
338
+ }
339
+ })
340
+
341
+ it('includes token data when includeToken=true for protocolID queries', async () => {
342
+ primeResolverWithOneOutput(mockResolver)
343
+
344
+ const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { includeToken: true })
345
+
346
+ expect(Array.isArray(result)).toBe(true)
347
+ if (Array.isArray(result) && result.length > 0) {
348
+ expect(result[0].token).toBeDefined()
349
+ expect(result[0].token).toEqual({
350
+ txid: TEST_TXID,
351
+ outputIndex: 0,
352
+ satoshis: 1,
353
+ beef: expect.any(Object)
354
+ })
355
+ }
356
+ })
357
+
358
+ it('excludes token data when includeToken=false (default)', async () => {
359
+ primeResolverWithOneOutput(mockResolver)
360
+
361
+ const result = await kvStore.get({ key: TEST_KEY })
362
+
363
+ expect(Array.isArray(result)).toBe(true)
364
+ expect(result).toHaveLength(1)
365
+ if (Array.isArray(result) && result.length > 0) {
366
+ expect(result[0].token).toBeUndefined()
367
+ }
368
+ })
369
+
370
+ it('supports protocolID queries with history', async () => {
371
+ primeResolverWithOneOutput(mockResolver)
372
+ mockHistorian.buildHistory.mockResolvedValue(['oldValue', TEST_VALUE])
373
+
374
+ const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
375
+
376
+ expect(Array.isArray(result)).toBe(true)
377
+ if (Array.isArray(result) && result.length > 0) {
378
+ expect(result[0].history).toEqual(['oldValue', TEST_VALUE])
379
+ }
380
+ expect(mockHistorian.buildHistory).toHaveBeenCalled()
381
+ })
382
+
383
+ it('excludes history for protocolID queries when history=false', async () => {
384
+ primeResolverWithOneOutput(mockResolver)
385
+
386
+ const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: false })
387
+
388
+ expect(Array.isArray(result)).toBe(true)
389
+ if (Array.isArray(result) && result.length > 0) {
390
+ expect(result[0].history).toBeUndefined()
391
+ }
392
+ expect(mockHistorian.buildHistory).not.toHaveBeenCalled()
393
+ })
394
+
395
+ it('calls buildHistory for each valid token when multiple outputs exist', async () => {
396
+ // This test verifies the key behavior: history building is called for each processed token
397
+ primeResolverWithMultipleOutputs(mockResolver, 3)
398
+
399
+ // Don't worry about making unique tokens - just verify the calls
400
+ mockHistorian.buildHistory.mockResolvedValue(['sample_history'])
401
+
402
+ const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
403
+
404
+ expect(Array.isArray(result)).toBe(true)
405
+
406
+ // The key assertion: buildHistory should be called once per valid token processed
407
+ // Even if some tokens are duplicates due to mocking, we're testing the iteration logic
408
+ expect(mockHistorian.buildHistory).toHaveBeenCalledWith(
409
+ expect.any(Object),
410
+ expect.objectContaining({ key: expect.any(String) })
411
+ )
412
+
413
+ // Since we have 3 outputs but they may resolve to the same token due to mocking,
414
+ // we just verify that buildHistory was called at least once
415
+ expect(mockHistorian.buildHistory).toHaveBeenCalled()
416
+
417
+ // Verify each returned entry has history
418
+ if (Array.isArray(result)) {
419
+ result.forEach(entry => {
420
+ expect(entry.history).toEqual(['sample_history'])
421
+ })
422
+ }
423
+ })
424
+
425
+ it('handles history building failures gracefully', async () => {
426
+ primeResolverWithOneOutput(mockResolver)
427
+
428
+ // Mock history building to fail
429
+ mockHistorian.buildHistory.mockRejectedValue(new Error('History build failed'))
430
+
431
+ const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, { history: true })
432
+
433
+ expect(Array.isArray(result)).toBe(true)
434
+
435
+ // Implementation should continue processing even if history fails
436
+ // The entry should be skipped due to the continue in the catch block
437
+ if (Array.isArray(result)) {
438
+ expect(result.length).toBe(0)
439
+ }
440
+ })
441
+
442
+ it('combines includeToken and history options correctly', async () => {
443
+ primeResolverWithOneOutput(mockResolver)
444
+ mockHistorian.buildHistory.mockResolvedValue(['combined_test_history'])
445
+
446
+ const result = await kvStore.get({ protocolID: [1, 'kvstore'] }, {
447
+ history: true,
448
+ includeToken: true
449
+ })
450
+
451
+ expect(Array.isArray(result)).toBe(true)
452
+
453
+ if (Array.isArray(result) && result.length > 0) {
454
+ const entry = result[0]
455
+ // Entry should have both history and token data
456
+ expect(entry.history).toEqual(['combined_test_history'])
457
+ expect(entry.token).toBeDefined()
458
+ expect(entry.token?.txid).toBe(TEST_TXID)
459
+ expect(entry.token?.outputIndex).toBe(0)
460
+ }
461
+
462
+ // Verify buildHistory was called
463
+ expect(mockHistorian.buildHistory).toHaveBeenCalled()
464
+ })
465
+ })
466
+
467
+ describe('sad paths', () => {
468
+ it('rejects when no query parameters provided', async () => {
469
+ await expect(kvStore.get({})).rejects.toThrow('Must specify either key, controller, or protocolID')
470
+ })
471
+
472
+ it('propagates overlay errors', async () => {
473
+ mockResolver.query.mockRejectedValue(new Error('Network error'))
474
+
475
+ await expect(kvStore.get({ key: TEST_KEY })).rejects.toThrow('Network error')
476
+ })
477
+
478
+ it('skips malformed candidates and returns empty array (invalid PushDrop format)', async () => {
479
+ primeResolverWithOneOutput(mockResolver)
480
+
481
+ const originalDecode = (MockPushDrop as any).decode
482
+ ; (MockPushDrop as any).decode = jest.fn(() => {
483
+ throw new Error('Invalid PushDrop format')
484
+ })
485
+
486
+ try {
487
+ const result = await kvStore.get({ key: TEST_KEY })
488
+ expect(Array.isArray(result)).toBe(true)
489
+ expect(result).toHaveLength(0)
490
+ } finally {
491
+ ; (MockPushDrop as any).decode = originalDecode
492
+ }
493
+ })
494
+ })
495
+
496
+ describe('Query Parameter Combinations', () => {
497
+ describe('Single parameter queries (return arrays)', () => {
498
+ it('key only - returns array of entries matching key across all controllers', async () => {
499
+ primeResolverWithOneOutput(mockResolver)
500
+
501
+ const result = await kvStore.get({ key: TEST_KEY })
502
+
503
+ expect(Array.isArray(result)).toBe(true)
504
+ expect(mockResolver.query).toHaveBeenCalledWith({
505
+ service: 'ls_kvstore',
506
+ query: { key: TEST_KEY }
507
+ })
508
+ })
509
+
510
+ it('controller only - returns array of entries by specific controller', async () => {
511
+ primeResolverWithOneOutput(mockResolver)
512
+
513
+ const result = await kvStore.get({ controller: TEST_CONTROLLER })
514
+
515
+ expect(Array.isArray(result)).toBe(true)
516
+ expect(mockResolver.query).toHaveBeenCalledWith({
517
+ service: 'ls_kvstore',
518
+ query: { controller: TEST_CONTROLLER }
519
+ })
520
+ })
521
+
522
+ it('protocolID only - returns array of entries under protocol', async () => {
523
+ primeResolverWithOneOutput(mockResolver)
524
+
525
+ const result = await kvStore.get({ protocolID: [1, 'kvstore'] })
526
+
527
+ expect(Array.isArray(result)).toBe(true)
528
+ expect(mockResolver.query).toHaveBeenCalledWith({
529
+ service: 'ls_kvstore',
530
+ query: { protocolID: [1, 'kvstore'] }
531
+ })
532
+ })
533
+ })
534
+
535
+ describe('Combined parameter queries', () => {
536
+ it('key + controller - returns single result (unique combination)', async () => {
537
+ primeResolverWithOneOutput(mockResolver)
538
+
539
+ const result = await kvStore.get({
540
+ key: TEST_KEY,
541
+ controller: TEST_CONTROLLER
542
+ })
543
+
544
+ // Should return single entry, not array
545
+ expect(result).not.toBeNull()
546
+ expect(Array.isArray(result)).toBe(false)
547
+ if (result && !Array.isArray(result)) {
548
+ expect(result.key).toBe(TEST_KEY)
549
+ expect(result.controller).toBe(TEST_CONTROLLER)
550
+ }
551
+ })
552
+
553
+ it('key + protocolID - returns array (multiple results possible)', async () => {
554
+ primeResolverWithOneOutput(mockResolver)
555
+
556
+ const result = await kvStore.get({
557
+ key: TEST_KEY,
558
+ protocolID: [1, 'kvstore']
559
+ })
560
+
561
+ expect(Array.isArray(result)).toBe(true)
562
+ })
563
+
564
+ it('controller + protocolID - returns array (multiple results possible)', async () => {
565
+ primeResolverWithOneOutput(mockResolver)
566
+
567
+ const result = await kvStore.get({
568
+ controller: TEST_CONTROLLER,
569
+ protocolID: [1, 'kvstore']
570
+ })
571
+
572
+ expect(Array.isArray(result)).toBe(true)
573
+ })
574
+
575
+ it('key + controller + protocolID - returns single result (most specific)', async () => {
576
+ primeResolverWithOneOutput(mockResolver)
577
+
578
+ const result = await kvStore.get({
579
+ key: TEST_KEY,
580
+ controller: TEST_CONTROLLER,
581
+ protocolID: [1, 'kvstore']
582
+ })
583
+
584
+ // key + controller combination should return single result
585
+ expect(result).not.toBeNull()
586
+ expect(Array.isArray(result)).toBe(false)
587
+ })
588
+ })
589
+
590
+ describe('Return type consistency', () => {
591
+ it('key+controller always returns single result or undefined', async () => {
592
+ primeResolverEmpty(mockResolver)
593
+
594
+ const result = await kvStore.get({
595
+ key: TEST_KEY,
596
+ controller: TEST_CONTROLLER
597
+ })
598
+
599
+ expect(result).toBeUndefined()
600
+ expect(Array.isArray(result)).toBe(false)
601
+ })
602
+
603
+ it('all other combinations always return arrays', async () => {
604
+ primeResolverEmpty(mockResolver)
605
+
606
+ const testCases = [
607
+ { key: TEST_KEY },
608
+ { controller: TEST_CONTROLLER },
609
+ { protocolID: [1, 'kvstore'] as [1, 'kvstore'] },
610
+ { key: TEST_KEY, protocolID: [1, 'kvstore'] as [1, 'kvstore'] },
611
+ { controller: TEST_CONTROLLER, protocolID: [1, 'kvstore'] as [1, 'kvstore'] }
612
+ ]
613
+
614
+ for (const query of testCases) {
615
+ const result = await kvStore.get(query)
616
+ expect(Array.isArray(result)).toBe(true)
617
+ expect((result as any[]).length).toBe(0)
618
+ }
619
+ })
620
+ })
621
+ })
622
+ })
623
+
624
+ // --------------------------------------------------------------------------
625
+ describe('set', () => {
626
+ describe('happy paths', () => {
627
+ it('creates a new token when key does not exist', async () => {
628
+ primeResolverEmpty(mockResolver)
629
+ const outpoint = await kvStore.set(TEST_KEY, TEST_VALUE)
630
+
631
+ expect(mockWallet.createAction).toHaveBeenCalledWith(
632
+ expect.objectContaining({
633
+ description: `Create KVStore value for ${TEST_KEY}`,
634
+ outputs: expect.arrayContaining([
635
+ expect.objectContaining({
636
+ satoshis: 1,
637
+ outputDescription: 'KVStore token',
638
+ }),
639
+ ])
640
+ }),
641
+ undefined
642
+ )
643
+ expect(outpoint).toBe(`${TEST_TXID}.0`)
644
+ expect(mockBroadcaster.broadcast).toHaveBeenCalled()
645
+ })
646
+
647
+ it('updates existing token when one exists', async () => {
648
+ // Mock the queryOverlay to return an entry with a token
649
+ const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
650
+ mockQueryOverlay.mockResolvedValue([{
651
+ key: TEST_KEY,
652
+ value: 'oldValue',
653
+ controller: TEST_CONTROLLER,
654
+ token: {
655
+ txid: TEST_TXID,
656
+ outputIndex: 0,
657
+ beef: mockBeef,
658
+ satoshis: 1
659
+ }
660
+ }])
661
+
662
+ const outpoint = await kvStore.set(TEST_KEY, TEST_VALUE)
663
+
664
+ expect(mockWallet.createAction).toHaveBeenCalledWith(
665
+ expect.objectContaining({
666
+ description: `Update KVStore value for ${TEST_KEY}`,
667
+ inputs: expect.arrayContaining([
668
+ expect.objectContaining({
669
+ inputDescription: 'Previous KVStore token'
670
+ })
671
+ ])
672
+ }),
673
+ undefined
674
+ )
675
+ expect(mockWallet.signAction).toHaveBeenCalled()
676
+ expect(outpoint).toBe(`${TEST_TXID}.0`)
677
+
678
+ mockQueryOverlay.mockRestore()
679
+ })
680
+
681
+ it('is safe under concurrent operations (key locking)', async () => {
682
+ primeResolverEmpty(mockResolver)
683
+
684
+ const promise1 = kvStore.set(TEST_KEY, 'value1')
685
+ const promise2 = kvStore.set(TEST_KEY, 'value2')
686
+
687
+ await Promise.all([promise1, promise2])
688
+
689
+ // Both operations should have completed successfully
690
+ expect(mockWallet.createAction).toHaveBeenCalledTimes(2)
691
+ })
692
+ })
693
+
694
+ describe('sad paths', () => {
695
+ it('rejects invalid key', async () => {
696
+ await expect(kvStore.set('', TEST_VALUE)).rejects.toThrow('Key must be a non-empty string.')
697
+ })
698
+
699
+ it('rejects invalid value', async () => {
700
+ await expect(kvStore.set(TEST_KEY, null as any)).rejects.toThrow('Value must be a string.')
701
+ })
702
+
703
+ it('propagates wallet createAction failures', async () => {
704
+ primeResolverEmpty(mockResolver)
705
+ mockWallet.createAction.mockRejectedValue(new Error('Wallet error'))
706
+
707
+ await expect(kvStore.set(TEST_KEY, TEST_VALUE)).rejects.toThrow('Wallet error')
708
+ })
709
+
710
+ it('surface broadcast errors in set', async () => {
711
+ primeResolverEmpty(mockResolver)
712
+ mockBroadcaster.broadcast.mockRejectedValue(new Error('overlay down'))
713
+ await expect(kvStore.set(TEST_KEY, TEST_VALUE)).rejects.toThrow('overlay down')
714
+ })
715
+ })
716
+ })
717
+
718
+ // --------------------------------------------------------------------------
719
+ describe('remove', () => {
720
+ describe('happy paths', () => {
721
+ it('removes an existing token', async () => {
722
+ // Mock the queryOverlay to return an entry with a token
723
+ const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
724
+ mockQueryOverlay.mockResolvedValue([{
725
+ key: TEST_KEY,
726
+ value: TEST_VALUE,
727
+ controller: TEST_CONTROLLER,
728
+ token: {
729
+ txid: TEST_TXID,
730
+ outputIndex: 0,
731
+ beef: mockBeef,
732
+ satoshis: 1
733
+ }
734
+ }])
735
+
736
+ const txid = await kvStore.remove(TEST_KEY)
737
+
738
+ expect(mockWallet.createAction).toHaveBeenCalledWith(
739
+ expect.objectContaining({
740
+ description: `Remove KVStore value for ${TEST_KEY}`,
741
+ inputs: expect.arrayContaining([
742
+ expect.objectContaining({
743
+ inputDescription: 'KVStore token to remove'
744
+ })
745
+ ])
746
+ }),
747
+ undefined
748
+ )
749
+ expect(txid).toBe(TEST_TXID)
750
+
751
+ mockQueryOverlay.mockRestore()
752
+ })
753
+
754
+ it('supports custom outputs on removal', async () => {
755
+ // Mock the queryOverlay to return an entry with a token
756
+ const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
757
+ mockQueryOverlay.mockResolvedValue([{
758
+ key: TEST_KEY,
759
+ value: TEST_VALUE,
760
+ controller: TEST_CONTROLLER,
761
+ token: {
762
+ txid: TEST_TXID,
763
+ outputIndex: 0,
764
+ beef: mockBeef,
765
+ satoshis: 1
766
+ }
767
+ }])
768
+
769
+ const customOutputs = [
770
+ {
771
+ satoshis: 500,
772
+ lockingScript: 'customTransferScript',
773
+ outputDescription: 'Custom token transfer output',
774
+ },
775
+ ]
776
+
777
+ const txid = await kvStore.remove(TEST_KEY, customOutputs)
778
+
779
+ expect(mockWallet.createAction).toHaveBeenCalledWith(
780
+ expect.objectContaining({
781
+ outputs: customOutputs,
782
+ }),
783
+ undefined
784
+ )
785
+ expect(txid).toBe(TEST_TXID)
786
+
787
+ mockQueryOverlay.mockRestore()
788
+ })
789
+ })
790
+
791
+ describe('sad paths', () => {
792
+ it('rejects invalid key', async () => {
793
+ await expect(kvStore.remove('')).rejects.toThrow('Key must be a non-empty string.')
794
+ })
795
+
796
+ it('throws when key does not exist', async () => {
797
+ primeResolverEmpty(mockResolver)
798
+
799
+ await expect(kvStore.remove(TEST_KEY)).rejects.toThrow(
800
+ 'The item did not exist, no item was deleted.'
801
+ )
802
+ })
803
+
804
+ it('propagates wallet signAction failures', async () => {
805
+ // Mock the queryOverlay to return an entry with a token
806
+ const mockQueryOverlay = jest.spyOn(kvStore as any, 'queryOverlay')
807
+ mockQueryOverlay.mockResolvedValue([{
808
+ key: TEST_KEY,
809
+ value: TEST_VALUE,
810
+ controller: TEST_CONTROLLER,
811
+ token: {
812
+ txid: TEST_TXID,
813
+ outputIndex: 0,
814
+ beef: mockBeef,
815
+ satoshis: 1
816
+ }
817
+ }])
818
+
819
+ ; (mockWallet.signAction as jest.Mock).mockRejectedValue(new Error('Sign failed'))
820
+
821
+ await expect(kvStore.remove(TEST_KEY)).rejects.toThrow('Sign failed')
822
+
823
+ mockQueryOverlay.mockRestore()
824
+ })
825
+ })
826
+ })
827
+
828
+ // --------------------------------------------------------------------------
829
+ describe('getWithHistory', () => {
830
+ it('delegates to get(key, undefined, controller, true) and returns value + history', async () => {
831
+ primeResolverWithOneOutput(mockResolver)
832
+ mockHistorian.buildHistory.mockResolvedValue([TEST_VALUE])
833
+
834
+ const result = await kvStore.get({ key: TEST_KEY }, { history: true })
835
+
836
+ expect(Array.isArray(result)).toBe(true)
837
+ expect(result).toHaveLength(1)
838
+ if (Array.isArray(result) && result.length > 0) {
839
+ expect(result[0]).toEqual({
840
+ key: TEST_KEY,
841
+ value: TEST_VALUE,
842
+ controller: expect.any(String),
843
+ protocolID: [1, 'kvstore'],
844
+ history: [TEST_VALUE],
845
+ })
846
+ }
847
+ })
848
+
849
+ it('returns empty array when key not found', async () => {
850
+ primeResolverEmpty(mockResolver)
851
+
852
+ const result = await kvStore.get({ key: TEST_KEY }, { history: true })
853
+ expect(Array.isArray(result)).toBe(true)
854
+ expect(result).toHaveLength(0)
855
+ })
856
+ })
857
+
858
+ // --------------------------------------------------------------------------
859
+ describe('Integration-ish behaviors', () => {
860
+ it('uses PushDrop for signature verification', async () => {
861
+ primeResolverWithOneOutput(mockResolver)
862
+
863
+ await kvStore.get({ key: TEST_KEY })
864
+
865
+ expect(MockProtoWallet).toHaveBeenCalledWith('anyone')
866
+ expect(mockProtoWallet.verifySignature).toHaveBeenCalledWith({
867
+ data: expect.any(Array),
868
+ signature: expect.any(Array),
869
+ counterparty: TEST_CONTROLLER,
870
+ protocolID: [1, 'kvstore'],
871
+ keyID: TEST_KEY
872
+ })
873
+ })
874
+
875
+ it('caches identity key (single wallet.getPublicKey call across operations)', async () => {
876
+ primeResolverEmpty(mockResolver)
877
+ await kvStore.set('key1', 'value1')
878
+ await kvStore.set('key2', 'value2')
879
+
880
+ expect(mockWallet.getPublicKey).toHaveBeenCalledTimes(1)
881
+ expect(mockWallet.createAction).toHaveBeenCalledTimes(2)
882
+ })
883
+
884
+ it('properly cleans up empty lock queues to prevent memory leaks', async () => {
885
+ primeResolverEmpty(mockResolver)
886
+
887
+ // Get reference to private keyLocks Map
888
+ const keyLocks = (kvStore as any).keyLocks as Map<string, Array<() => void>>
889
+
890
+ // Initially empty
891
+ expect(keyLocks.size).toBe(0)
892
+
893
+ // Perform operations on different keys
894
+ await kvStore.set('key1', 'value1')
895
+ await kvStore.set('key2', 'value2')
896
+ await kvStore.set('key3', 'value3')
897
+
898
+ // After operations complete, keyLocks should be empty (no memory leak)
899
+ expect(keyLocks.size).toBe(0)
900
+ })
901
+ })
902
+
903
+ // --------------------------------------------------------------------------
904
+ describe('Error recovery & edge cases', () => {
905
+ it('returns empty array for empty overlay response', async () => {
906
+ primeResolverEmpty(mockResolver)
907
+ const result = await kvStore.get({ key: TEST_KEY })
908
+ expect(Array.isArray(result)).toBe(true)
909
+ expect(result).toHaveLength(0)
910
+ })
911
+
912
+ it('skips malformed transactions and returns empty array', async () => {
913
+ primeResolverWithOneOutput(mockResolver)
914
+
915
+ const originalFromBEEF = (MockTransaction as any).fromBEEF
916
+ ; (MockTransaction as any).fromBEEF = jest.fn(() => {
917
+ throw new Error('Malformed transaction data')
918
+ })
919
+
920
+ try {
921
+ const result = await kvStore.get({ key: TEST_KEY })
922
+ expect(Array.isArray(result)).toBe(true)
923
+ expect(result).toHaveLength(0)
924
+ } finally {
925
+ ; (MockTransaction as any).fromBEEF = originalFromBEEF
926
+ }
927
+ })
928
+
929
+ it('handles edge cases where no valid tokens pass full validation', async () => {
930
+ // This test verifies that when tokens exist but fail validation (signature, etc),
931
+ // the method gracefully returns empty array rather than throwing
932
+ primeResolverWithOneOutput(mockResolver)
933
+
934
+ // Make signature verification fail (this could be a realistic failure mode)
935
+ const originalVerifySignature = mockProtoWallet.verifySignature
936
+ mockProtoWallet.verifySignature = jest.fn().mockRejectedValue(new Error('Signature verification failed'))
937
+
938
+ try {
939
+ const result = await kvStore.get({ key: TEST_KEY }, { history: true })
940
+ expect(Array.isArray(result)).toBe(true)
941
+ expect(result).toHaveLength(0)
942
+ } finally {
943
+ // Restore original mock
944
+ mockProtoWallet.verifySignature = originalVerifySignature
945
+ }
946
+ })
947
+
948
+ it('when no valid outputs (decode fails), get(..., history=true) still returns empty array', async () => {
949
+ primeResolverWithOneOutput(mockResolver)
950
+
951
+ const originalDecode = (MockPushDrop as any).decode
952
+ ; (MockPushDrop as any).decode = jest.fn(() => {
953
+ throw new Error('Invalid token format')
954
+ })
955
+
956
+ try {
957
+ const result = await kvStore.get({ key: TEST_KEY }, { history: true })
958
+ expect(Array.isArray(result)).toBe(true)
959
+ expect(result).toHaveLength(0)
960
+ } finally {
961
+ ; (MockPushDrop as any).decode = originalDecode
962
+ }
963
+ })
964
+ })
965
+ })