@bsv/sdk 2.0.12 → 2.0.13

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 (77) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js +827 -0
  3. package/dist/cjs/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  4. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +654 -0
  5. package/dist/cjs/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  6. package/dist/cjs/src/transaction/MerklePath.js +132 -0
  7. package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js +825 -0
  10. package/dist/esm/src/auth/clients/__tests__/AuthFetch.additional.test.js.map +1 -0
  11. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js +619 -0
  12. package/dist/esm/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.js.map +1 -0
  13. package/dist/esm/src/transaction/MerklePath.js +132 -0
  14. package/dist/esm/src/transaction/MerklePath.js.map +1 -1
  15. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  16. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts +21 -0
  17. package/dist/types/src/auth/clients/__tests__/AuthFetch.additional.test.d.ts.map +1 -0
  18. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts +2 -0
  19. package/dist/types/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.d.ts.map +1 -0
  20. package/dist/types/src/transaction/MerklePath.d.ts +27 -0
  21. package/dist/types/src/transaction/MerklePath.d.ts.map +1 -1
  22. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  23. package/dist/umd/bundle.js +1 -1
  24. package/dist/umd/bundle.js.map +1 -1
  25. package/docs/reference/storage.md +1 -1
  26. package/docs/reference/transaction.md +40 -0
  27. package/package.json +1 -1
  28. package/src/auth/clients/__tests__/AuthFetch.additional.test.ts +1131 -0
  29. package/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +770 -0
  30. package/src/compat/__tests/Mnemonic.additional.test.ts +64 -0
  31. package/src/identity/__tests/IdentityClient.additional.test.ts +767 -0
  32. package/src/kvstore/__tests/LocalKVStore.additional.test.ts +611 -0
  33. package/src/kvstore/__tests/kvStoreInterpreter.test.ts +327 -0
  34. package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +561 -0
  35. package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +612 -0
  36. package/src/overlay-tools/__tests/withDoubleSpendRetry.test.ts +278 -0
  37. package/src/primitives/__tests/BigNumber.additional.test.ts +79 -0
  38. package/src/primitives/__tests/Curve.additional.test.ts +208 -0
  39. package/src/primitives/__tests/ECDSA.additional.test.ts +122 -0
  40. package/src/primitives/__tests/Hash.additional.test.ts +59 -0
  41. package/src/primitives/__tests/JacobianPoint.test.ts +308 -0
  42. package/src/primitives/__tests/Point.additional.test.ts +503 -0
  43. package/src/primitives/__tests/PublicKey.additional.test.ts +383 -0
  44. package/src/primitives/__tests/Random.additional.test.ts +262 -0
  45. package/src/primitives/__tests/Signature.test.ts +333 -0
  46. package/src/primitives/__tests/TransactionSignature.additional.test.ts +241 -0
  47. package/src/registry/__tests/RegistryClient.additional.test.ts +750 -0
  48. package/src/remittance/__tests/BasicBRC29.additional.test.ts +657 -0
  49. package/src/remittance/__tests/RemittanceManager.additional.test.ts +1272 -0
  50. package/src/script/__tests/LockingUnlockingScript.test.ts +79 -0
  51. package/src/script/__tests/Script.additional.test.ts +100 -0
  52. package/src/script/__tests/ScriptEvaluationError.test.ts +98 -0
  53. package/src/script/__tests/Spend.additional.test.ts +837 -0
  54. package/src/script/templates/__tests/RPuzzle.test.ts +134 -0
  55. package/src/transaction/MerklePath.ts +155 -0
  56. package/src/transaction/__tests/BeefParty.additional.test.ts +22 -0
  57. package/src/transaction/__tests/Broadcaster.test.ts +159 -0
  58. package/src/transaction/__tests/MerklePath.bench.test.ts +105 -0
  59. package/src/transaction/__tests/MerklePath.test.ts +80 -0
  60. package/src/transaction/__tests/Transaction.additional.test.ts +225 -0
  61. package/src/transaction/broadcasters/__tests/ARC.additional.test.ts +585 -0
  62. package/src/transaction/broadcasters/__tests/Teranode.test.ts +349 -0
  63. package/src/transaction/chaintrackers/__tests/BlockHeadersService.test.ts +253 -0
  64. package/src/transaction/chaintrackers/__tests/DefaultChainTracker.test.ts +44 -0
  65. package/src/transaction/chaintrackers/__tests/WhatsOnChain.additional.test.ts +193 -0
  66. package/src/transaction/fee-models/__tests/SatoshisPerKilobyte.test.ts +262 -0
  67. package/src/transaction/http/__tests/BinaryFetchClient.test.ts +212 -0
  68. package/src/transaction/http/__tests/DefaultHttpClient.additional.test.ts +192 -0
  69. package/src/transaction/http/__tests/DefaultHttpClient.test.ts +71 -0
  70. package/src/wallet/__tests/ProtoWallet.additional.test.ts +134 -0
  71. package/src/wallet/__tests/WERR.test.ts +212 -0
  72. package/src/wallet/__tests/WalletClient.additional.test.ts +699 -0
  73. package/src/wallet/__tests/WalletClient.substrate.test.ts +759 -0
  74. package/src/wallet/__tests/WalletError.test.ts +290 -0
  75. package/src/wallet/__tests/validationHelpers.test.ts +1218 -0
  76. package/src/wallet/substrates/__tests/HTTPWalletJSON.test.ts +496 -0
  77. package/src/wallet/substrates/__tests/HTTPWalletWire.test.ts +273 -0
@@ -0,0 +1,1272 @@
1
+ import type { CommsLayer } from '../CommsLayer.js'
2
+ import type { IdentityLayer } from '../IdentityLayer.js'
3
+ import type { RemittanceModule } from '../RemittanceModule.js'
4
+ import type { ComposeInvoiceInput } from '../RemittanceManager.js'
5
+ import type { PeerMessage, RemittanceEnvelope, Termination, ThreadId } from '../types.js'
6
+ import type { WalletInterface, PubKeyHex } from '../../wallet/Wallet.interfaces.js'
7
+ import { RemittanceManager, DEFAULT_REMITTANCE_MESSAGEBOX } from '../RemittanceManager.js'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Shared test infrastructure (mirrors the existing test file's helpers)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ class MessageBus {
14
+ private messages: PeerMessage[] = []
15
+ private nextId = 1
16
+
17
+ send (sender: PubKeyHex, recipient: PubKeyHex, messageBox: string, body: string): string {
18
+ const messageId = `msg-${this.nextId++}`
19
+ this.messages.push({ messageId, sender, recipient, messageBox, body })
20
+ return messageId
21
+ }
22
+
23
+ list (recipient: PubKeyHex, messageBox: string): PeerMessage[] {
24
+ return this.messages.filter((m) => m.recipient === recipient && m.messageBox === messageBox)
25
+ }
26
+
27
+ ack (recipient: PubKeyHex, messageIds: string[]): void {
28
+ this.messages = this.messages.filter(
29
+ (m) => m.recipient !== recipient || !messageIds.includes(m.messageId)
30
+ )
31
+ }
32
+
33
+ all (): PeerMessage[] { return [...this.messages] }
34
+ }
35
+
36
+ class TestComms implements CommsLayer {
37
+ constructor (private readonly owner: PubKeyHex, private readonly bus: MessageBus) {}
38
+
39
+ async sendMessage (args: { recipient: PubKeyHex; messageBox: string; body: string }): Promise<string> {
40
+ return this.bus.send(this.owner, args.recipient, args.messageBox, args.body)
41
+ }
42
+
43
+ async listMessages (args: { messageBox: string; host?: string }): Promise<PeerMessage[]> {
44
+ return this.bus.list(this.owner, args.messageBox)
45
+ }
46
+
47
+ async acknowledgeMessage (args: { messageIds: string[] }): Promise<void> {
48
+ this.bus.ack(this.owner, args.messageIds)
49
+ }
50
+ }
51
+
52
+ const makeWallet = (identityKey: PubKeyHex): WalletInterface =>
53
+ ({ getPublicKey: async () => ({ publicKey: identityKey }) } as unknown as WalletInterface)
54
+
55
+ const makeInvoiceInput = (overrides: Partial<ComposeInvoiceInput> = {}): ComposeInvoiceInput => ({
56
+ lineItems: [],
57
+ total: { value: '1000', unit: { namespace: 'bsv', code: 'sat', decimals: 0 } },
58
+ note: 'Test invoice',
59
+ invoiceNumber: 'INV-1',
60
+ ...overrides
61
+ })
62
+
63
+ const parseEnvelope = (msg: PeerMessage): RemittanceEnvelope => JSON.parse(msg.body) as RemittanceEnvelope
64
+
65
+ const makeThreadIdFactory = (): (() => ThreadId) => {
66
+ let i = 0
67
+ return () => `thread-${++i}` as ThreadId
68
+ }
69
+
70
+ const tick = async (): Promise<void> => await new Promise((resolve) => setTimeout(resolve, 0))
71
+
72
+ const makeModule = (overrides: Partial<RemittanceModule<any, any, any>> = {}): RemittanceModule<any, any, any> => ({
73
+ id: 'test-module',
74
+ name: 'Test Module',
75
+ allowUnsolicitedSettlements: false,
76
+ createOption: async () => ({}),
77
+ buildSettlement: async () => ({ action: 'settle', artifact: {} }),
78
+ acceptSettlement: async () => ({ action: 'accept', receiptData: {} }),
79
+ ...overrides
80
+ })
81
+
82
+ const makeIdentityLayer = (): IdentityLayer => ({
83
+ determineCertificatesToRequest: async ({ threadId }) => ({
84
+ kind: 'identityVerificationRequest',
85
+ threadId,
86
+ request: { types: { basic: ['name'] }, certifiers: ['certifier-key'] }
87
+ }),
88
+ respondToRequest: async ({ threadId }) => ({
89
+ action: 'respond',
90
+ response: {
91
+ kind: 'identityVerificationResponse',
92
+ threadId,
93
+ certificates: [{
94
+ type: 'YmFzaWM=',
95
+ certifier: 'certifier-key',
96
+ subject: 'subject-key',
97
+ fields: { name: 'QWxpY2U=' },
98
+ signature: 'deadbeef',
99
+ serialNumber: 'c2VyaWFs',
100
+ revocationOutpoint: 'outpoint',
101
+ keyringForVerifier: { name: 'a2V5' }
102
+ }]
103
+ }
104
+ }),
105
+ assessReceivedCertificateSufficiency: async (_cp, _received, threadId) => ({
106
+ kind: 'identityVerificationAcknowledgment',
107
+ threadId
108
+ })
109
+ })
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Tests
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe('RemittanceManager additional coverage', () => {
116
+ describe('init() and state persistence', () => {
117
+ it('init does nothing when stateLoader is not configured', async () => {
118
+ const bus = new MessageBus()
119
+ const manager = new RemittanceManager(
120
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
121
+ makeWallet('k1'),
122
+ new TestComms('k1', bus)
123
+ )
124
+ await expect(manager.init()).resolves.toBeUndefined()
125
+ })
126
+
127
+ it('init loads state from stateLoader and sets defaultPaymentOptionId', async () => {
128
+ const bus = new MessageBus()
129
+ const state = {
130
+ v: 1 as const,
131
+ threads: [],
132
+ defaultPaymentOptionId: 'test-module'
133
+ }
134
+ const manager = new RemittanceManager(
135
+ {
136
+ remittanceModules: [makeModule()],
137
+ threadIdFactory: makeThreadIdFactory(),
138
+ stateLoader: async () => state
139
+ },
140
+ makeWallet('k1'),
141
+ new TestComms('k1', bus)
142
+ )
143
+ await manager.init()
144
+ expect((manager as any).defaultPaymentOptionId).toBe('test-module')
145
+ })
146
+
147
+ it('init skips state loading when stateLoader returns undefined', async () => {
148
+ const bus = new MessageBus()
149
+ const manager = new RemittanceManager(
150
+ {
151
+ remittanceModules: [makeModule()],
152
+ threadIdFactory: makeThreadIdFactory(),
153
+ stateLoader: async () => undefined
154
+ },
155
+ makeWallet('k1'),
156
+ new TestComms('k1', bus)
157
+ )
158
+ await expect(manager.init()).resolves.toBeUndefined()
159
+ expect(manager.threads).toHaveLength(0)
160
+ })
161
+
162
+ it('saveState returns a serializable snapshot including threads', async () => {
163
+ const bus = new MessageBus()
164
+ const manager = new RemittanceManager(
165
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
166
+ makeWallet('k1'),
167
+ new TestComms('k1', bus)
168
+ )
169
+ await manager.sendInvoice('k2', makeInvoiceInput())
170
+ const state = manager.saveState()
171
+ expect(state.v).toBe(1)
172
+ expect(state.threads).toHaveLength(1)
173
+ })
174
+
175
+ it('loadState throws on unsupported version', () => {
176
+ const bus = new MessageBus()
177
+ const manager = new RemittanceManager(
178
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
179
+ makeWallet('k1'),
180
+ new TestComms('k1', bus)
181
+ )
182
+ expect(() => manager.loadState({ v: 2 as any, threads: [] })).toThrow('Unsupported RemittanceManagerState version')
183
+ })
184
+
185
+ it('loadState restores threads', async () => {
186
+ const bus = new MessageBus()
187
+ const manager = new RemittanceManager(
188
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
189
+ makeWallet('k1'),
190
+ new TestComms('k1', bus)
191
+ )
192
+ await manager.sendInvoice('k2', makeInvoiceInput())
193
+ const snapshot = manager.saveState()
194
+
195
+ const manager2 = new RemittanceManager(
196
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
197
+ makeWallet('k1'),
198
+ new TestComms('k1', bus)
199
+ )
200
+ manager2.loadState(snapshot)
201
+ expect(manager2.threads).toHaveLength(1)
202
+ })
203
+
204
+ it('persistState calls stateSaver with current state', async () => {
205
+ const bus = new MessageBus()
206
+ const stateSaver = jest.fn()
207
+ const manager = new RemittanceManager(
208
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory(), stateSaver },
209
+ makeWallet('k1'),
210
+ new TestComms('k1', bus)
211
+ )
212
+ await manager.persistState()
213
+ expect(stateSaver).toHaveBeenCalledWith(expect.objectContaining({ v: 1 }))
214
+ })
215
+
216
+ it('persistState does nothing without stateSaver', async () => {
217
+ const bus = new MessageBus()
218
+ const manager = new RemittanceManager(
219
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
220
+ makeWallet('k1'),
221
+ new TestComms('k1', bus)
222
+ )
223
+ await expect(manager.persistState()).resolves.toBeUndefined()
224
+ })
225
+ })
226
+
227
+ describe('thread accessors', () => {
228
+ it('getThread returns undefined for unknown threadId', async () => {
229
+ const bus = new MessageBus()
230
+ const manager = new RemittanceManager(
231
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
232
+ makeWallet('k1'),
233
+ new TestComms('k1', bus)
234
+ )
235
+ expect(manager.getThread('does-not-exist' as ThreadId)).toBeUndefined()
236
+ })
237
+
238
+ it('getThreadOrThrow throws for unknown threadId', async () => {
239
+ const bus = new MessageBus()
240
+ const manager = new RemittanceManager(
241
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
242
+ makeWallet('k1'),
243
+ new TestComms('k1', bus)
244
+ )
245
+ expect(() => manager.getThreadOrThrow('nope' as ThreadId)).toThrow('Unknown thread: nope')
246
+ })
247
+
248
+ it('getThreadHandle returns a handle with the correct threadId', async () => {
249
+ const bus = new MessageBus()
250
+ const manager = new RemittanceManager(
251
+ { remittanceModules: [makeModule()], threadIdFactory: makeThreadIdFactory() },
252
+ makeWallet('k1'),
253
+ new TestComms('k1', bus)
254
+ )
255
+ const handle = await manager.sendInvoice('k2', makeInvoiceInput())
256
+ const h2 = manager.getThreadHandle(handle.threadId)
257
+ expect(h2.threadId).toBe(handle.threadId)
258
+ })
259
+ })
260
+
261
+ describe('preselectPaymentOption', () => {
262
+ it('uses preselectPaymentOption when no optionId is given to pay()', async () => {
263
+ const bus = new MessageBus()
264
+ const buildSettlement = jest.fn(async () => ({ action: 'settle' as const, artifact: {} }))
265
+ const mod = makeModule({ id: 'selected-mod', buildSettlement })
266
+
267
+ const maker = new RemittanceManager(
268
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
269
+ makeWallet('maker-key'),
270
+ new TestComms('maker-key', bus)
271
+ )
272
+ const taker = new RemittanceManager(
273
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
274
+ makeWallet('taker-key'),
275
+ new TestComms('taker-key', bus)
276
+ )
277
+
278
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
279
+ await taker.syncThreads()
280
+
281
+ taker.preselectPaymentOption('selected-mod')
282
+ await taker.pay(handle.threadId)
283
+
284
+ expect(buildSettlement).toHaveBeenCalled()
285
+ })
286
+ })
287
+
288
+ describe('findInvoicesPayable and findReceivableInvoices', () => {
289
+ it('findInvoicesPayable returns threads where we are taker and invoice not yet paid', async () => {
290
+ const bus = new MessageBus()
291
+ const mod = makeModule()
292
+
293
+ const maker = new RemittanceManager(
294
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
295
+ makeWallet('maker-key'),
296
+ new TestComms('maker-key', bus)
297
+ )
298
+ const taker = new RemittanceManager(
299
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
300
+ makeWallet('taker-key'),
301
+ new TestComms('taker-key', bus)
302
+ )
303
+
304
+ await maker.sendInvoice('taker-key', makeInvoiceInput())
305
+ await taker.syncThreads()
306
+
307
+ const payable = taker.findInvoicesPayable()
308
+ expect(payable).toHaveLength(1)
309
+ })
310
+
311
+ it('findInvoicesPayable filters by counterparty when provided', async () => {
312
+ const bus = new MessageBus()
313
+ const mod = makeModule()
314
+
315
+ const maker = new RemittanceManager(
316
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
317
+ makeWallet('maker-key'),
318
+ new TestComms('maker-key', bus)
319
+ )
320
+ const taker = new RemittanceManager(
321
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
322
+ makeWallet('taker-key'),
323
+ new TestComms('taker-key', bus)
324
+ )
325
+
326
+ await maker.sendInvoice('taker-key', makeInvoiceInput())
327
+ await taker.syncThreads()
328
+
329
+ expect(taker.findInvoicesPayable('maker-key')).toHaveLength(1)
330
+ expect(taker.findInvoicesPayable('other-key')).toHaveLength(0)
331
+ })
332
+
333
+ it('findReceivableInvoices returns threads where we are maker and waiting on payment', async () => {
334
+ const bus = new MessageBus()
335
+ const mod = makeModule()
336
+
337
+ const maker = new RemittanceManager(
338
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
339
+ makeWallet('maker-key'),
340
+ new TestComms('maker-key', bus)
341
+ )
342
+
343
+ await maker.sendInvoice('taker-key', makeInvoiceInput())
344
+ const receivable = maker.findReceivableInvoices()
345
+ expect(receivable).toHaveLength(1)
346
+ expect(maker.findReceivableInvoices('taker-key')).toHaveLength(1)
347
+ expect(maker.findReceivableInvoices('other')).toHaveLength(0)
348
+ })
349
+ })
350
+
351
+ describe('sendInvoiceForThread', () => {
352
+ it('throws when called on a non-maker thread', async () => {
353
+ const bus = new MessageBus()
354
+ const mod = makeModule()
355
+
356
+ const maker = new RemittanceManager(
357
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
358
+ makeWallet('maker-key'),
359
+ new TestComms('maker-key', bus)
360
+ )
361
+ const taker = new RemittanceManager(
362
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
363
+ makeWallet('taker-key'),
364
+ new TestComms('taker-key', bus)
365
+ )
366
+
367
+ await maker.sendInvoice('taker-key', makeInvoiceInput())
368
+ await taker.syncThreads()
369
+
370
+ const takerThread = taker.threads[0]
371
+ await expect(
372
+ taker.sendInvoiceForThread(takerThread.threadId, makeInvoiceInput())
373
+ ).rejects.toThrow('Only makers can send invoices')
374
+ })
375
+
376
+ it('throws when the thread already has an invoice', async () => {
377
+ const bus = new MessageBus()
378
+ const mod = makeModule()
379
+
380
+ const maker = new RemittanceManager(
381
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
382
+ makeWallet('maker-key'),
383
+ new TestComms('maker-key', bus)
384
+ )
385
+
386
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
387
+ await expect(
388
+ maker.sendInvoiceForThread(handle.threadId, makeInvoiceInput())
389
+ ).rejects.toThrow('Thread already has an invoice')
390
+ })
391
+
392
+ it('throws when thread is in error state', async () => {
393
+ const bus = new MessageBus()
394
+ const mod = makeModule()
395
+
396
+ const maker = new RemittanceManager(
397
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
398
+ makeWallet('maker-key'),
399
+ new TestComms('maker-key', bus)
400
+ )
401
+
402
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
403
+ const thread = maker.getThreadOrThrow(handle.threadId)
404
+ // Force error state
405
+ thread.flags.error = true
406
+
407
+ await expect(
408
+ maker.sendInvoiceForThread(handle.threadId, makeInvoiceInput())
409
+ ).rejects.toThrow('Thread is in error state')
410
+ })
411
+ })
412
+
413
+ describe('pay() edge cases', () => {
414
+ it('throws when trying to pay a thread with no invoice', async () => {
415
+ const bus = new MessageBus()
416
+ const mod = makeModule({ allowUnsolicitedSettlements: true })
417
+
418
+ const taker = new RemittanceManager(
419
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
420
+ makeWallet('taker-key'),
421
+ new TestComms('taker-key', bus)
422
+ )
423
+
424
+ const handle = await taker.sendUnsolicitedSettlement('maker-key', { moduleId: mod.id, option: {} })
425
+ await expect(
426
+ taker.pay(handle.threadId)
427
+ ).rejects.toThrow('Thread has no invoice to pay')
428
+ })
429
+
430
+ it('throws when invoice is expired', async () => {
431
+ const bus = new MessageBus()
432
+ const mod = makeModule()
433
+
434
+ const maker = new RemittanceManager(
435
+ {
436
+ remittanceModules: [mod],
437
+ options: { invoiceExpirySeconds: 1 },
438
+ threadIdFactory: makeThreadIdFactory(),
439
+ // Invoice created at t=1_000_000ms; expires at t=1_001_000ms
440
+ now: () => 1_000_000
441
+ },
442
+ makeWallet('maker-key'),
443
+ new TestComms('maker-key', bus)
444
+ )
445
+ const taker = new RemittanceManager(
446
+ {
447
+ remittanceModules: [mod],
448
+ options: { receiptProvided: false },
449
+ threadIdFactory: makeThreadIdFactory(),
450
+ // Taker's clock is at t=2_000_000ms, well past the expiry
451
+ now: () => 2_000_000
452
+ },
453
+ makeWallet('taker-key'),
454
+ new TestComms('taker-key', bus)
455
+ )
456
+
457
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
458
+ await taker.syncThreads()
459
+
460
+ const takerThreadId = taker.threads[0].threadId
461
+ await expect(taker.pay(takerThreadId)).rejects.toThrow('Invoice is expired')
462
+ })
463
+
464
+ it('throws when no remittance options are available', async () => {
465
+ const bus = new MessageBus()
466
+
467
+ // Module that creates no option (no createOption fn)
468
+ const mod: RemittanceModule<any, any, any> = {
469
+ id: 'no-option-module',
470
+ name: 'No Option',
471
+ allowUnsolicitedSettlements: false,
472
+ buildSettlement: async () => ({ action: 'settle', artifact: {} }),
473
+ acceptSettlement: async () => ({ action: 'accept', receiptData: {} })
474
+ }
475
+
476
+ const maker = new RemittanceManager(
477
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
478
+ makeWallet('maker-key'),
479
+ new TestComms('maker-key', bus)
480
+ )
481
+ const taker = new RemittanceManager(
482
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
483
+ makeWallet('taker-key'),
484
+ new TestComms('taker-key', bus)
485
+ )
486
+
487
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
488
+ await taker.syncThreads()
489
+ const takerThread = taker.threads[0]
490
+
491
+ await expect(taker.pay(takerThread.threadId)).rejects.toThrow('No remittance options available on invoice')
492
+ })
493
+
494
+ it('throws when trying to pay a thread already in error state', async () => {
495
+ const bus = new MessageBus()
496
+ const mod = makeModule()
497
+
498
+ const maker = new RemittanceManager(
499
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
500
+ makeWallet('maker-key'),
501
+ new TestComms('maker-key', bus)
502
+ )
503
+ const taker = new RemittanceManager(
504
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
505
+ makeWallet('taker-key'),
506
+ new TestComms('taker-key', bus)
507
+ )
508
+
509
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
510
+ await taker.syncThreads()
511
+
512
+ const takerThread = taker.threads[0]
513
+ takerThread.flags.error = true
514
+
515
+ await expect(taker.pay(takerThread.threadId)).rejects.toThrow('Thread is in error state')
516
+ })
517
+
518
+ it('throws when trying to pay an invoice that is already settled', async () => {
519
+ const bus = new MessageBus()
520
+ const mod = makeModule({ allowUnsolicitedSettlements: false })
521
+
522
+ const maker = new RemittanceManager(
523
+ { remittanceModules: [mod], options: { receiptProvided: false, autoIssueReceipt: false }, threadIdFactory: makeThreadIdFactory() },
524
+ makeWallet('maker-key'),
525
+ new TestComms('maker-key', bus)
526
+ )
527
+ const taker = new RemittanceManager(
528
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
529
+ makeWallet('taker-key'),
530
+ new TestComms('taker-key', bus)
531
+ )
532
+
533
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
534
+ await taker.syncThreads()
535
+
536
+ const takerThread = taker.threads[0]
537
+ await taker.pay(takerThread.threadId)
538
+
539
+ await expect(taker.pay(takerThread.threadId)).rejects.toThrow('Invoice already paid')
540
+ })
541
+
542
+ it('throws when module is not found for the chosen option', async () => {
543
+ const bus = new MessageBus()
544
+ const mod = makeModule()
545
+
546
+ const maker = new RemittanceManager(
547
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
548
+ makeWallet('maker-key'),
549
+ new TestComms('maker-key', bus)
550
+ )
551
+ const taker = new RemittanceManager(
552
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
553
+ makeWallet('taker-key'),
554
+ new TestComms('taker-key', bus)
555
+ )
556
+
557
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
558
+ await taker.syncThreads()
559
+
560
+ const takerThread = taker.threads[0]
561
+ await expect(taker.pay(takerThread.threadId, 'unknown-module-id')).rejects.toThrow(
562
+ 'No configured remittance module for option: unknown-module-id'
563
+ )
564
+ })
565
+ })
566
+
567
+ describe('sendUnsolicitedSettlement edge cases', () => {
568
+ it('throws when the module does not allow unsolicited settlements', async () => {
569
+ const bus = new MessageBus()
570
+ const mod = makeModule({ id: 'no-unsolicited', allowUnsolicitedSettlements: false })
571
+
572
+ const taker = new RemittanceManager(
573
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
574
+ makeWallet('taker-key'),
575
+ new TestComms('taker-key', bus)
576
+ )
577
+
578
+ await expect(
579
+ taker.sendUnsolicitedSettlement('maker-key', { moduleId: 'no-unsolicited', option: {} })
580
+ ).rejects.toThrow('does not allow unsolicited settlements')
581
+ })
582
+
583
+ it('throws when module id is unknown', async () => {
584
+ const bus = new MessageBus()
585
+ const mod = makeModule({ allowUnsolicitedSettlements: true })
586
+
587
+ const taker = new RemittanceManager(
588
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
589
+ makeWallet('taker-key'),
590
+ new TestComms('taker-key', bus)
591
+ )
592
+
593
+ await expect(
594
+ taker.sendUnsolicitedSettlement('maker-key', { moduleId: 'no-such-module', option: {} })
595
+ ).rejects.toThrow('No configured remittance module for option: no-such-module')
596
+ })
597
+
598
+ it('sends termination when buildSettlement returns terminate for unsolicited', async () => {
599
+ const bus = new MessageBus()
600
+ const termination: Termination = { code: 'build.fail', message: 'build failed' }
601
+ const mod = makeModule({
602
+ id: 'term-mod',
603
+ allowUnsolicitedSettlements: true,
604
+ buildSettlement: async () => ({ action: 'terminate', termination })
605
+ })
606
+
607
+ const taker = new RemittanceManager(
608
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
609
+ makeWallet('taker-key'),
610
+ new TestComms('taker-key', bus)
611
+ )
612
+
613
+ const handle = await taker.sendUnsolicitedSettlement('maker-key', { moduleId: 'term-mod', option: {} })
614
+ const thread = taker.getThreadOrThrow(handle.threadId)
615
+ expect(thread.state).toBe('terminated')
616
+ })
617
+ })
618
+
619
+ describe('inbound message edge cases', () => {
620
+ it('ignores messages that do not parse as valid envelopes', async () => {
621
+ const bus = new MessageBus()
622
+ const mod = makeModule()
623
+ const manager = new RemittanceManager(
624
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
625
+ makeWallet('k1'),
626
+ new TestComms('k1', bus)
627
+ )
628
+
629
+ // Send an unparseable message
630
+ bus.send('k2', 'k1', DEFAULT_REMITTANCE_MESSAGEBOX, 'not json at all')
631
+ bus.send('k2', 'k1', DEFAULT_REMITTANCE_MESSAGEBOX, JSON.stringify({ v: 1, kind: 'invoice' })) // missing threadId
632
+ bus.send('k2', 'k1', DEFAULT_REMITTANCE_MESSAGEBOX, JSON.stringify({ v: 2, kind: 'invoice', threadId: 't', id: 'i' })) // wrong version
633
+
634
+ await manager.syncThreads()
635
+ expect(manager.threads).toHaveLength(0)
636
+ })
637
+
638
+ it('deduplicates already-processed message IDs', async () => {
639
+ const bus = new MessageBus()
640
+ const mod = makeModule()
641
+
642
+ const maker = new RemittanceManager(
643
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
644
+ makeWallet('maker-key'),
645
+ new TestComms('maker-key', bus)
646
+ )
647
+ const taker = new RemittanceManager(
648
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
649
+ makeWallet('taker-key'),
650
+ new TestComms('taker-key', bus)
651
+ )
652
+
653
+ await maker.sendInvoice('taker-key', makeInvoiceInput())
654
+ // Sync twice — second sync should see the same message re-listed (bus doesn't ack in this test manually)
655
+ // To simulate re-delivery, don't ack by using a non-acking comms
656
+ const msgs = bus.list('taker-key', DEFAULT_REMITTANCE_MESSAGEBOX)
657
+ expect(msgs).toHaveLength(1)
658
+
659
+ await taker.syncThreads()
660
+ expect(taker.threads).toHaveLength(1)
661
+ const threadId = taker.threads[0].threadId
662
+
663
+ // simulate re-delivery
664
+ await taker.syncThreads()
665
+ // Still only one thread, not duplicated
666
+ expect(taker.threads).toHaveLength(1)
667
+ expect(taker.getThreadOrThrow(threadId).processedMessageIds).toHaveLength(1)
668
+ })
669
+
670
+ it('receives identity verification request but sends termination when no identity layer configured', async () => {
671
+ const bus = new MessageBus()
672
+ const mod = makeModule()
673
+
674
+ const identityLayer = makeIdentityLayer()
675
+ const sender = new RemittanceManager(
676
+ {
677
+ remittanceModules: [mod],
678
+ identityLayer,
679
+ options: { identityOptions: { makerRequestIdentity: 'beforeInvoicing' }, identityTimeoutMs: 100, identityPollIntervalMs: 5 },
680
+ threadIdFactory: makeThreadIdFactory()
681
+ },
682
+ makeWallet('sender-key'),
683
+ new TestComms('sender-key', bus)
684
+ )
685
+
686
+ // receiver has NO identity layer
687
+ const receiver = new RemittanceManager(
688
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
689
+ makeWallet('receiver-key'),
690
+ new TestComms('receiver-key', bus)
691
+ )
692
+
693
+ // Sender sends an identity verification request
694
+ const identityRequestEnv: RemittanceEnvelope = {
695
+ v: 1,
696
+ id: 'env-id-req',
697
+ kind: 'identityVerificationRequest',
698
+ threadId: 'thread-noid' as ThreadId,
699
+ createdAt: 1,
700
+ payload: {
701
+ kind: 'identityVerificationRequest',
702
+ threadId: 'thread-noid',
703
+ request: { types: { basic: ['name'] }, certifiers: ['c'] }
704
+ }
705
+ }
706
+ bus.send('sender-key', 'receiver-key', DEFAULT_REMITTANCE_MESSAGEBOX, JSON.stringify(identityRequestEnv))
707
+
708
+ await receiver.syncThreads()
709
+ // Should have sent a termination back
710
+ const termMsgs = bus.list('sender-key', DEFAULT_REMITTANCE_MESSAGEBOX)
711
+ const termEnv = JSON.parse(termMsgs[0].body) as RemittanceEnvelope
712
+ expect(termEnv.kind).toBe('termination')
713
+ })
714
+
715
+ it('receives identity verification response but sends termination when no identity layer configured', async () => {
716
+ const bus = new MessageBus()
717
+ const mod = makeModule()
718
+
719
+ const receiver = new RemittanceManager(
720
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
721
+ makeWallet('receiver-key'),
722
+ new TestComms('receiver-key', bus)
723
+ )
724
+
725
+ const identityResponseEnv: RemittanceEnvelope = {
726
+ v: 1,
727
+ id: 'env-id-resp',
728
+ kind: 'identityVerificationResponse',
729
+ threadId: 'thread-noid-resp' as ThreadId,
730
+ createdAt: 1,
731
+ payload: {
732
+ kind: 'identityVerificationResponse',
733
+ threadId: 'thread-noid-resp',
734
+ certificates: []
735
+ }
736
+ }
737
+ bus.send('sender-key', 'receiver-key', DEFAULT_REMITTANCE_MESSAGEBOX, JSON.stringify(identityResponseEnv))
738
+
739
+ await receiver.syncThreads()
740
+ const termMsgs = bus.list('sender-key', DEFAULT_REMITTANCE_MESSAGEBOX)
741
+ const termEnv = JSON.parse(termMsgs[0].body) as RemittanceEnvelope
742
+ expect(termEnv.kind).toBe('termination')
743
+ })
744
+
745
+ it('settlement received when module not found sends termination', async () => {
746
+ const bus = new MessageBus()
747
+ const mod = makeModule({ id: 'known-mod' })
748
+
749
+ const maker = new RemittanceManager(
750
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
751
+ makeWallet('maker-key'),
752
+ new TestComms('maker-key', bus)
753
+ )
754
+
755
+ const settlementEnv: RemittanceEnvelope = {
756
+ v: 1,
757
+ id: 'env-settle',
758
+ kind: 'settlement',
759
+ threadId: 'thread-settle-unknown' as ThreadId,
760
+ createdAt: 1,
761
+ payload: {
762
+ kind: 'settlement',
763
+ threadId: 'thread-settle-unknown',
764
+ moduleId: 'unknown-mod', // not registered
765
+ optionId: 'unknown-mod',
766
+ sender: 'taker-key',
767
+ createdAt: 1,
768
+ artifact: {}
769
+ }
770
+ }
771
+
772
+ bus.send('taker-key', 'maker-key', DEFAULT_REMITTANCE_MESSAGEBOX, JSON.stringify(settlementEnv))
773
+ await maker.syncThreads()
774
+
775
+ const termMsgs = bus.list('taker-key', DEFAULT_REMITTANCE_MESSAGEBOX)
776
+ expect(termMsgs.length).toBeGreaterThan(0)
777
+ const termEnv = JSON.parse(termMsgs[0].body) as RemittanceEnvelope
778
+ expect(termEnv.kind).toBe('termination')
779
+ })
780
+
781
+ it('settlement received for a module that does not allow unsolicited settlements sends termination', async () => {
782
+ const bus = new MessageBus()
783
+ const mod = makeModule({ id: 'no-unsolicited', allowUnsolicitedSettlements: false })
784
+
785
+ const maker = new RemittanceManager(
786
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
787
+ makeWallet('maker-key'),
788
+ new TestComms('maker-key', bus)
789
+ )
790
+
791
+ const settlementEnv: RemittanceEnvelope = {
792
+ v: 1,
793
+ id: 'env-settle2',
794
+ kind: 'settlement',
795
+ threadId: 'thread-settle-nosolicited' as ThreadId,
796
+ createdAt: 1,
797
+ payload: {
798
+ kind: 'settlement',
799
+ threadId: 'thread-settle-nosolicited',
800
+ moduleId: 'no-unsolicited',
801
+ optionId: 'no-unsolicited',
802
+ sender: 'taker-key',
803
+ createdAt: 1,
804
+ artifact: {}
805
+ }
806
+ }
807
+
808
+ bus.send('taker-key', 'maker-key', DEFAULT_REMITTANCE_MESSAGEBOX, JSON.stringify(settlementEnv))
809
+ await maker.syncThreads()
810
+
811
+ const termMsgs = bus.list('taker-key', DEFAULT_REMITTANCE_MESSAGEBOX)
812
+ expect(termMsgs.length).toBeGreaterThan(0)
813
+ })
814
+
815
+ it('termination received triggers processTermination on module when settlement exists', async () => {
816
+ const bus = new MessageBus()
817
+ const processTermination = jest.fn()
818
+ const mod = makeModule({
819
+ id: 'term-receiver',
820
+ allowUnsolicitedSettlements: false,
821
+ processTermination
822
+ })
823
+
824
+ const maker = new RemittanceManager(
825
+ { remittanceModules: [mod], options: { receiptProvided: true, autoIssueReceipt: false }, threadIdFactory: makeThreadIdFactory() },
826
+ makeWallet('maker-key'),
827
+ new TestComms('maker-key', bus)
828
+ )
829
+ const taker = new RemittanceManager(
830
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
831
+ makeWallet('taker-key'),
832
+ new TestComms('taker-key', bus)
833
+ )
834
+
835
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
836
+ await taker.syncThreads()
837
+ const takerThread = taker.threads[0]
838
+
839
+ // Taker pays invoice
840
+ await taker.pay(takerThread.threadId)
841
+
842
+ // Maker processes settlement
843
+ await maker.syncThreads()
844
+
845
+ // Simulate taker receiving a termination
846
+ const terminationEnv: RemittanceEnvelope = {
847
+ v: 1,
848
+ id: 'env-term',
849
+ kind: 'termination',
850
+ threadId: takerThread.threadId,
851
+ createdAt: 1,
852
+ payload: { code: 'rejected', message: 'Payment rejected' }
853
+ }
854
+ bus.send('maker-key', 'taker-key', DEFAULT_REMITTANCE_MESSAGEBOX, JSON.stringify(terminationEnv))
855
+ await taker.syncThreads()
856
+
857
+ expect(processTermination).toHaveBeenCalled()
858
+ })
859
+
860
+ it('unknown envelope kind causes thread to enter errored state', async () => {
861
+ const bus = new MessageBus()
862
+ const mod = makeModule()
863
+
864
+ const manager = new RemittanceManager(
865
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
866
+ makeWallet('k1'),
867
+ new TestComms('k1', bus)
868
+ )
869
+
870
+ const unknownEnv = {
871
+ v: 1,
872
+ id: 'env-unknown',
873
+ kind: 'unknown-kind',
874
+ threadId: 'thread-unknown',
875
+ createdAt: 1,
876
+ payload: {}
877
+ }
878
+ bus.send('k2', 'k1', DEFAULT_REMITTANCE_MESSAGEBOX, JSON.stringify(unknownEnv))
879
+ await manager.syncThreads()
880
+
881
+ const thread = manager.getThreadOrThrow('thread-unknown' as ThreadId)
882
+ expect(thread.state).toBe('errored')
883
+ })
884
+ })
885
+
886
+ describe('event listeners', () => {
887
+ it('onEvent registers and fires for each remittance lifecycle event', async () => {
888
+ const bus = new MessageBus()
889
+ const mod = makeModule({ id: 'ev-mod', allowUnsolicitedSettlements: true })
890
+
891
+ const events: string[] = []
892
+ const manager = new RemittanceManager(
893
+ {
894
+ remittanceModules: [mod],
895
+ onEvent: (e) => events.push(e.type),
896
+ threadIdFactory: makeThreadIdFactory()
897
+ },
898
+ makeWallet('k1'),
899
+ new TestComms('k1', bus)
900
+ )
901
+
902
+ await manager.sendUnsolicitedSettlement('k2', { moduleId: 'ev-mod', option: {} })
903
+ expect(events).toContain('threadCreated')
904
+ expect(events).toContain('settlementSent')
905
+ })
906
+
907
+ it('onEvent listener can be removed via returned disposer', async () => {
908
+ const bus = new MessageBus()
909
+ const mod = makeModule()
910
+
911
+ const events: string[] = []
912
+ const manager = new RemittanceManager(
913
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
914
+ makeWallet('k1'),
915
+ new TestComms('k1', bus)
916
+ )
917
+
918
+ const dispose = manager.onEvent((e) => events.push(e.type))
919
+ dispose()
920
+
921
+ await manager.sendInvoice('k2', makeInvoiceInput())
922
+ expect(events).toHaveLength(0)
923
+ })
924
+
925
+ it('fires all event handler callbacks configured via events object', async () => {
926
+ const bus = new MessageBus()
927
+ const mod = makeModule({
928
+ id: 'events-all',
929
+ allowUnsolicitedSettlements: false,
930
+ createOption: async () => ({}),
931
+ buildSettlement: async () => ({ action: 'settle', artifact: {} }),
932
+ acceptSettlement: async () => ({ action: 'accept', receiptData: {} })
933
+ })
934
+
935
+ const onThreadCreated = jest.fn()
936
+ const onStateChanged = jest.fn()
937
+ const onInvoiceSent = jest.fn()
938
+ const onInvoiceReceived = jest.fn()
939
+ const onSettlementSent = jest.fn()
940
+ const onSettlementReceived = jest.fn()
941
+ const onReceiptSent = jest.fn()
942
+ const onReceiptReceived = jest.fn()
943
+
944
+ const maker = new RemittanceManager(
945
+ {
946
+ remittanceModules: [mod],
947
+ options: { receiptProvided: true, autoIssueReceipt: true },
948
+ events: {
949
+ onThreadCreated,
950
+ onStateChanged,
951
+ onInvoiceSent,
952
+ onSettlementReceived,
953
+ onReceiptSent
954
+ },
955
+ threadIdFactory: makeThreadIdFactory()
956
+ },
957
+ makeWallet('maker-key'),
958
+ new TestComms('maker-key', bus)
959
+ )
960
+ const taker = new RemittanceManager(
961
+ {
962
+ remittanceModules: [mod],
963
+ options: { receiptProvided: true },
964
+ events: {
965
+ onInvoiceReceived,
966
+ onSettlementSent,
967
+ onReceiptReceived
968
+ },
969
+ threadIdFactory: makeThreadIdFactory()
970
+ },
971
+ makeWallet('taker-key'),
972
+ new TestComms('taker-key', bus)
973
+ )
974
+
975
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
976
+ await taker.syncThreads()
977
+ const payPromise = taker.pay(handle.threadId, 'events-all')
978
+ await tick()
979
+ await maker.syncThreads()
980
+ await payPromise
981
+
982
+ expect(onThreadCreated).toHaveBeenCalled()
983
+ expect(onInvoiceSent).toHaveBeenCalled()
984
+ expect(onStateChanged).toHaveBeenCalled()
985
+ expect(onInvoiceReceived).toHaveBeenCalled()
986
+ expect(onSettlementSent).toHaveBeenCalled()
987
+ expect(onSettlementReceived).toHaveBeenCalled()
988
+ expect(onReceiptSent).toHaveBeenCalled()
989
+ expect(onReceiptReceived).toHaveBeenCalled()
990
+ })
991
+ })
992
+
993
+ describe('startListening', () => {
994
+ it('throws when CommsLayer does not support live messages', async () => {
995
+ const bus = new MessageBus()
996
+ const mod = makeModule()
997
+ const manager = new RemittanceManager(
998
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
999
+ makeWallet('k1'),
1000
+ new TestComms('k1', bus) // TestComms does not implement listenForLiveMessages
1001
+ )
1002
+
1003
+ await expect(manager.startListening()).rejects.toThrow('CommsLayer does not support live message listening')
1004
+ })
1005
+ })
1006
+
1007
+ describe('sendEnvelope with live message fallback', () => {
1008
+ it('falls back to sendMessage when sendLiveMessage fails', async () => {
1009
+ const bus = new MessageBus()
1010
+ const mod = makeModule()
1011
+
1012
+ const sendLiveMessage = jest.fn(async () => { throw new Error('live failed') })
1013
+ const sendMessage = jest.fn(async () => 'msg-123')
1014
+ const listMessages = jest.fn(async () => [])
1015
+ const acknowledgeMessage = jest.fn(async () => undefined)
1016
+
1017
+ const comms: CommsLayer = { sendMessage, listMessages, acknowledgeMessage, sendLiveMessage }
1018
+ const logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }
1019
+
1020
+ const manager = new RemittanceManager(
1021
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory(), logger },
1022
+ makeWallet('k1'),
1023
+ comms
1024
+ )
1025
+
1026
+ await manager.sendInvoice('k2', makeInvoiceInput())
1027
+ expect(sendLiveMessage).toHaveBeenCalled()
1028
+ expect(sendMessage).toHaveBeenCalled()
1029
+ })
1030
+ })
1031
+
1032
+ describe('waitForReceipt timeout', () => {
1033
+ it('throws when receipt does not arrive within the timeout', async () => {
1034
+ const bus = new MessageBus()
1035
+ const mod = makeModule({ allowUnsolicitedSettlements: false })
1036
+
1037
+ const maker = new RemittanceManager(
1038
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
1039
+ makeWallet('maker-key'),
1040
+ new TestComms('maker-key', bus)
1041
+ )
1042
+
1043
+ const handle = await maker.sendInvoice('k2', makeInvoiceInput())
1044
+ const thread = maker.getThreadOrThrow(handle.threadId)
1045
+ // Manually set the thread to 'settled' without an actual receipt
1046
+ thread.flags.hasPaid = true
1047
+ thread.state = 'settled'
1048
+
1049
+ await expect(
1050
+ maker.waitForReceipt(handle.threadId, { timeoutMs: 10, pollIntervalMs: 5 })
1051
+ ).rejects.toThrow('Timed out waiting for receipt')
1052
+ })
1053
+ })
1054
+
1055
+ describe('waitForSettlement timeout', () => {
1056
+ it('throws when settlement does not arrive within the timeout', async () => {
1057
+ const bus = new MessageBus()
1058
+ const mod = makeModule()
1059
+
1060
+ const maker = new RemittanceManager(
1061
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
1062
+ makeWallet('maker-key'),
1063
+ new TestComms('maker-key', bus)
1064
+ )
1065
+
1066
+ const handle = await maker.sendInvoice('k2', makeInvoiceInput())
1067
+
1068
+ await expect(
1069
+ maker.waitForSettlement(handle.threadId, { timeoutMs: 10, pollIntervalMs: 5 })
1070
+ ).rejects.toThrow('Timed out waiting for settlement')
1071
+ })
1072
+
1073
+ it('returns settlement immediately if already set', async () => {
1074
+ const bus = new MessageBus()
1075
+ const mod = makeModule({ allowUnsolicitedSettlements: false })
1076
+
1077
+ const maker = new RemittanceManager(
1078
+ { remittanceModules: [mod], options: { receiptProvided: true, autoIssueReceipt: true }, threadIdFactory: makeThreadIdFactory() },
1079
+ makeWallet('maker-key'),
1080
+ new TestComms('maker-key', bus)
1081
+ )
1082
+ const taker = new RemittanceManager(
1083
+ { remittanceModules: [mod], options: { receiptProvided: false }, threadIdFactory: makeThreadIdFactory() },
1084
+ makeWallet('taker-key'),
1085
+ new TestComms('taker-key', bus)
1086
+ )
1087
+
1088
+ const handle = await maker.sendInvoice('taker-key', makeInvoiceInput())
1089
+ await taker.syncThreads()
1090
+ await taker.pay(handle.threadId, 'test-module')
1091
+ await maker.syncThreads()
1092
+
1093
+ const settlement = await maker.waitForSettlement(handle.threadId, { timeoutMs: 1000 })
1094
+ expect(settlement).toBeDefined()
1095
+ expect((settlement as any).kind).toBe('settlement')
1096
+ })
1097
+ })
1098
+
1099
+ describe('waitForState errors', () => {
1100
+ it('throws immediately when thread is already in errored state', async () => {
1101
+ const bus = new MessageBus()
1102
+ const mod = makeModule()
1103
+
1104
+ const manager = new RemittanceManager(
1105
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
1106
+ makeWallet('k1'),
1107
+ new TestComms('k1', bus)
1108
+ )
1109
+
1110
+ const handle = await manager.sendInvoice('k2', makeInvoiceInput())
1111
+ const thread = manager.getThreadOrThrow(handle.threadId)
1112
+ thread.state = 'errored'
1113
+
1114
+ await expect(
1115
+ manager.waitForState(handle.threadId, 'receipted', { timeoutMs: 50 })
1116
+ ).rejects.toThrow('Thread entered terminal state: errored')
1117
+ })
1118
+
1119
+ it('throws immediately when thread is already in terminated state', async () => {
1120
+ const bus = new MessageBus()
1121
+ const mod = makeModule()
1122
+
1123
+ const manager = new RemittanceManager(
1124
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
1125
+ makeWallet('k1'),
1126
+ new TestComms('k1', bus)
1127
+ )
1128
+
1129
+ const handle = await manager.sendInvoice('k2', makeInvoiceInput())
1130
+ const thread = manager.getThreadOrThrow(handle.threadId)
1131
+ thread.state = 'terminated'
1132
+
1133
+ await expect(
1134
+ manager.waitForState(handle.threadId, 'receipted', { timeoutMs: 50 })
1135
+ ).rejects.toThrow('Thread entered terminal state: terminated')
1136
+ })
1137
+ })
1138
+
1139
+ describe('shouldRequestIdentity missing identityLayer', () => {
1140
+ it('throws when identityOptions requires identity but no identityLayer is set', async () => {
1141
+ const bus = new MessageBus()
1142
+ const mod = makeModule()
1143
+
1144
+ const maker = new RemittanceManager(
1145
+ {
1146
+ remittanceModules: [mod],
1147
+ // NO identityLayer
1148
+ options: {
1149
+ identityOptions: { makerRequestIdentity: 'beforeInvoicing' }
1150
+ },
1151
+ threadIdFactory: makeThreadIdFactory()
1152
+ },
1153
+ makeWallet('maker-key'),
1154
+ new TestComms('maker-key', bus)
1155
+ )
1156
+
1157
+ await expect(maker.sendInvoice('taker-key', makeInvoiceInput())).rejects.toThrow(
1158
+ 'Identity layer is required by runtime options but is not configured'
1159
+ )
1160
+ })
1161
+ })
1162
+
1163
+ describe('constructor with initial threads', () => {
1164
+ it('accepts pre-hydrated threads and restores their state', async () => {
1165
+ const bus = new MessageBus()
1166
+ const mod = makeModule()
1167
+
1168
+ const manager1 = new RemittanceManager(
1169
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
1170
+ makeWallet('k1'),
1171
+ new TestComms('k1', bus)
1172
+ )
1173
+ await manager1.sendInvoice('k2', makeInvoiceInput())
1174
+ const { threads } = manager1.saveState()
1175
+
1176
+ const manager2 = new RemittanceManager(
1177
+ { remittanceModules: [mod], threadIdFactory: makeThreadIdFactory() },
1178
+ makeWallet('k1'),
1179
+ new TestComms('k1', bus),
1180
+ threads
1181
+ )
1182
+ expect(manager2.threads).toHaveLength(1)
1183
+ expect(manager2.threads[0].state).toBe('invoiced')
1184
+ })
1185
+ })
1186
+
1187
+ describe('custom messageBox', () => {
1188
+ it('uses the configured messageBox for sending and listing', async () => {
1189
+ const bus = new MessageBus()
1190
+ const mod = makeModule()
1191
+
1192
+ const customBox = 'custom_inbox'
1193
+ const sendMessage = jest.fn(async (args: any, _hostOverride?: string) => {
1194
+ bus.send('k1', args.recipient, args.messageBox, args.body)
1195
+ return 'mid-custom'
1196
+ })
1197
+ const listMessages = jest.fn(async () => [])
1198
+ const acknowledgeMessage = jest.fn(async () => undefined)
1199
+ const comms: CommsLayer = { sendMessage, listMessages, acknowledgeMessage }
1200
+
1201
+ const manager = new RemittanceManager(
1202
+ { remittanceModules: [mod], messageBox: customBox, threadIdFactory: makeThreadIdFactory() },
1203
+ makeWallet('k1'),
1204
+ comms
1205
+ )
1206
+
1207
+ await manager.sendInvoice('k2', makeInvoiceInput())
1208
+ expect(sendMessage).toHaveBeenCalledWith(expect.objectContaining({ messageBox: customBox }), undefined)
1209
+ })
1210
+ })
1211
+
1212
+ describe('identity verification response – assessReceivedCertificateSufficiency rejects', () => {
1213
+ it('sends termination when certificate assessment fails', async () => {
1214
+ const bus = new MessageBus()
1215
+ const mod = makeModule({ createOption: async () => ({}) })
1216
+
1217
+ const rejectingIdentityLayer: IdentityLayer = {
1218
+ ...makeIdentityLayer(),
1219
+ assessReceivedCertificateSufficiency: async () => ({
1220
+ code: 'cert.insufficient',
1221
+ message: 'Certs not sufficient',
1222
+ details: {}
1223
+ } as any)
1224
+ }
1225
+
1226
+ const maker = new RemittanceManager(
1227
+ {
1228
+ remittanceModules: [mod],
1229
+ identityLayer: rejectingIdentityLayer,
1230
+ options: {
1231
+ identityOptions: { makerRequestIdentity: 'beforeInvoicing' },
1232
+ identityTimeoutMs: 200,
1233
+ identityPollIntervalMs: 5
1234
+ },
1235
+ threadIdFactory: makeThreadIdFactory()
1236
+ },
1237
+ makeWallet('maker-key'),
1238
+ new TestComms('maker-key', bus)
1239
+ )
1240
+ const taker = new RemittanceManager(
1241
+ {
1242
+ remittanceModules: [mod],
1243
+ identityLayer: makeIdentityLayer(),
1244
+ options: { identityOptions: { makerRequestIdentity: 'beforeInvoicing' }, identityTimeoutMs: 200, identityPollIntervalMs: 5 },
1245
+ threadIdFactory: makeThreadIdFactory()
1246
+ },
1247
+ makeWallet('taker-key'),
1248
+ new TestComms('taker-key', bus)
1249
+ )
1250
+
1251
+ const sendPromise = maker.sendInvoice('taker-key', makeInvoiceInput())
1252
+ await tick()
1253
+
1254
+ // Taker responds to identity request
1255
+ await taker.syncThreads()
1256
+
1257
+ // Maker receives response and rejects via assessReceivedCertificateSufficiency
1258
+ await maker.syncThreads()
1259
+
1260
+ // The taker should receive a termination
1261
+ const takerMsgs = bus.list('taker-key', DEFAULT_REMITTANCE_MESSAGEBOX)
1262
+ const termMsg = takerMsgs.find(m => {
1263
+ const env = JSON.parse(m.body) as RemittanceEnvelope
1264
+ return env.kind === 'termination'
1265
+ })
1266
+ expect(termMsg).toBeDefined()
1267
+
1268
+ // sendInvoice should timeout because identity was rejected
1269
+ await expect(sendPromise).rejects.toThrow()
1270
+ })
1271
+ })
1272
+ })