@bsv/sdk 2.1.2 → 2.1.4

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 (47) hide show
  1. package/dist/cjs/package.json +13 -13
  2. package/dist/cjs/src/auth/Peer.js +21 -18
  3. package/dist/cjs/src/auth/Peer.js.map +1 -1
  4. package/dist/cjs/src/auth/SessionManager.js.map +1 -1
  5. package/dist/cjs/src/auth/clients/AuthFetch.js +4 -1
  6. package/dist/cjs/src/auth/clients/AuthFetch.js.map +1 -1
  7. package/dist/cjs/src/compat/Mnemonic.js +12 -0
  8. package/dist/cjs/src/compat/Mnemonic.js.map +1 -1
  9. package/dist/cjs/src/overlay-tools/LookupResolver.js +99 -28
  10. package/dist/cjs/src/overlay-tools/LookupResolver.js.map +1 -1
  11. package/dist/cjs/src/transaction/MerklePath.js +1 -1
  12. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  13. package/dist/esm/src/auth/Peer.js +28 -18
  14. package/dist/esm/src/auth/Peer.js.map +1 -1
  15. package/dist/esm/src/auth/SessionManager.js.map +1 -1
  16. package/dist/esm/src/auth/clients/AuthFetch.js +4 -1
  17. package/dist/esm/src/auth/clients/AuthFetch.js.map +1 -1
  18. package/dist/esm/src/compat/Mnemonic.js +12 -0
  19. package/dist/esm/src/compat/Mnemonic.js.map +1 -1
  20. package/dist/esm/src/overlay-tools/LookupResolver.js +99 -28
  21. package/dist/esm/src/overlay-tools/LookupResolver.js.map +1 -1
  22. package/dist/esm/src/transaction/MerklePath.js +1 -1
  23. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  24. package/dist/types/src/auth/Peer.d.ts +3 -3
  25. package/dist/types/src/auth/Peer.d.ts.map +1 -1
  26. package/dist/types/src/auth/SessionManager.d.ts +21 -0
  27. package/dist/types/src/auth/SessionManager.d.ts.map +1 -1
  28. package/dist/types/src/auth/clients/AuthFetch.d.ts +2 -2
  29. package/dist/types/src/auth/clients/AuthFetch.d.ts.map +1 -1
  30. package/dist/types/src/compat/Mnemonic.d.ts +2 -0
  31. package/dist/types/src/compat/Mnemonic.d.ts.map +1 -1
  32. package/dist/types/src/overlay-tools/LookupResolver.d.ts +1 -0
  33. package/dist/types/src/overlay-tools/LookupResolver.d.ts.map +1 -1
  34. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  35. package/dist/umd/bundle.js +3 -3
  36. package/package.json +13 -13
  37. package/src/auth/Peer.ts +30 -20
  38. package/src/auth/SessionManager.ts +22 -0
  39. package/src/auth/__tests/Peer.test.ts +47 -1
  40. package/src/auth/clients/AuthFetch.ts +6 -3
  41. package/src/compat/Mnemonic.ts +13 -0
  42. package/src/compat/__tests/Mnemonic.test.ts +49 -5
  43. package/src/overlay-tools/LookupResolver.ts +102 -25
  44. package/src/overlay-tools/__tests/LookupResolver.additional.test.ts +90 -0
  45. package/src/script/__tests/Spend.test.ts +45 -4
  46. package/src/transaction/MerklePath.ts +1 -1
  47. package/src/transaction/__tests/Transaction.test.ts +17 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -237,23 +237,23 @@
237
237
  },
238
238
  "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk#readme",
239
239
  "devDependencies": {
240
- "@eslint/js": "^9.39.1",
241
- "@jest/globals": "^30.3.0",
242
- "@rspack/cli": "^2.0.0",
243
- "@rspack/core": "^1.6.1",
240
+ "@eslint/js": "^10.0.1",
241
+ "@jest/globals": "^30.4.1",
242
+ "@rspack/cli": "^2.0.4",
243
+ "@rspack/core": "^2.0.4",
244
244
  "@types/jest": "^30.0.0",
245
- "@types/node": "^24.10.1",
246
- "eslint": "^9.39.1",
247
- "globals": "^16.5.0",
248
- "jest": "^30.3.0",
249
- "jest-environment-jsdom": "^30.3.0",
250
- "ts-jest": "^29.4.9",
245
+ "@types/node": "^25.9.1",
246
+ "eslint": "^10.4.0",
247
+ "globals": "^17.6.0",
248
+ "jest": "^30.4.2",
249
+ "jest-environment-jsdom": "^30.4.1",
250
+ "ts-jest": "^29.4.11",
251
251
  "ts-loader": "^9.5.4",
252
252
  "ts-standard": "^12.0.2",
253
253
  "ts2md": "^0.2.8",
254
254
  "tsconfig-to-dual-package": "^1.2.0",
255
- "typescript": "^5.9.3",
256
- "typescript-eslint": "^8.46.4"
255
+ "typescript": "^6.0.3",
256
+ "typescript-eslint": "^8.60.0"
257
257
  },
258
258
  "ts-standard": {
259
259
  "project": "tsconfig.eslint.json",
package/src/auth/Peer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { SessionManager } from './SessionManager.js'
1
+ import { SessionManager, AsyncSessionManager } from './SessionManager.js'
2
2
  import {
3
3
  createNonce,
4
4
  verifyNonce,
@@ -28,6 +28,13 @@ const BufferCtor =
28
28
  * This version supports multiple concurrent sessions per peer identityKey.
29
29
  */
30
30
  export class Peer {
31
+ // Declared as the synchronous {@link SessionManager} for back-compat with
32
+ // pre-existing consumers that read `peer.sessionManager.getSession(...)`
33
+ // and friends as synchronous calls. The constructor also accepts an
34
+ // {@link AsyncSessionManager}; in that case the runtime value's methods
35
+ // return Promises. Peer always awaits internal calls so both work, but
36
+ // external code that reaches in directly should match the implementation
37
+ // it injected. See `AsyncSessionManager` for the opt-in async contract.
31
38
  public sessionManager: SessionManager
32
39
  private readonly transport: Transport
33
40
  private readonly wallet: WalletInterface
@@ -97,7 +104,7 @@ export class Peer {
97
104
  * @param {WalletInterface} wallet - The wallet instance used for cryptographic operations.
98
105
  * @param {Transport} transport - The transport mechanism used for sending and receiving messages.
99
106
  * @param {RequestedCertificateSet} [certificatesToRequest] - Optional set of certificates to request from a peer during the initial handshake.
100
- * @param {SessionManager} [sessionManager] - Optional SessionManager to be used for managing peer sessions.
107
+ * @param {SessionManager | AsyncSessionManager} [sessionManager] - Optional session store. Pass an {@link AsyncSessionManager} for shared/durable storage in load-balanced deployments; otherwise the default in-process {@link SessionManager} is used.
101
108
  * @param {boolean} [autoPersistLastSession] - Whether to auto-persist the session with the last-interacted-with peer. Defaults to true.
102
109
  * @param {OriginatorDomainNameStringUnder250Bytes} [originator] - Optional originator domain name.
103
110
  */
@@ -105,7 +112,7 @@ export class Peer {
105
112
  wallet: WalletInterface,
106
113
  transport: Transport,
107
114
  certificatesToRequest?: RequestedCertificateSet,
108
- sessionManager?: SessionManager,
115
+ sessionManager?: SessionManager | AsyncSessionManager,
109
116
  autoPersistLastSession?: boolean,
110
117
  originator?: OriginatorDomainNameStringUnder250Bytes
111
118
  ) {
@@ -117,8 +124,11 @@ export class Peer {
117
124
  types: {}
118
125
  }
119
126
  this.ready = this.transport.onData(this.handleIncomingMessage.bind(this)) // NOSONAR(typescript:S7059): listener must register synchronously — see ready field comment
127
+ // Cast keeps the public field typed as the synchronous `SessionManager`
128
+ // for back-compat. When an `AsyncSessionManager` is injected, the actual
129
+ // runtime methods return Promises — Peer awaits them internally below.
120
130
  this.sessionManager =
121
- sessionManager ?? new SessionManager()
131
+ (sessionManager ?? new SessionManager()) as SessionManager
122
132
  if (autoPersistLastSession === false) {
123
133
  this.autoPersistLastSession = false
124
134
  } else {
@@ -178,7 +188,7 @@ export class Peer {
178
188
  }
179
189
 
180
190
  peerSession.lastUpdate = Date.now()
181
- this.sessionManager.updateSession(peerSession)
191
+ await this.sessionManager.updateSession(peerSession)
182
192
 
183
193
  try {
184
194
  await this.transport.send(generalMessage)
@@ -233,7 +243,7 @@ export class Peer {
233
243
 
234
244
  // Update last-used timestamp
235
245
  peerSession.lastUpdate = Date.now()
236
- this.sessionManager.updateSession(peerSession)
246
+ await this.sessionManager.updateSession(peerSession)
237
247
 
238
248
  try {
239
249
  await this.transport.send(certRequestMessage)
@@ -262,7 +272,7 @@ export class Peer {
262
272
 
263
273
  let peerSession: PeerSession | undefined
264
274
  if (typeof identityKey === 'string') {
265
- peerSession = this.sessionManager.getSession(identityKey)
275
+ peerSession = await this.sessionManager.getSession(identityKey)
266
276
  }
267
277
 
268
278
  // If that session doesn't exist or isn't authenticated, initiate handshake
@@ -270,7 +280,7 @@ export class Peer {
270
280
  // This will create a brand-new session
271
281
  const sessionNonce = await this.initiateHandshake(identityKey)
272
282
  // Now retrieve it by the sessionNonce
273
- peerSession = this.sessionManager.getSession(sessionNonce)
283
+ peerSession = await this.sessionManager.getSession(sessionNonce)
274
284
  if (peerSession?.isAuthenticated !== true) {
275
285
  throw new Error('Unable to establish mutual authentication with peer!')
276
286
  }
@@ -367,7 +377,7 @@ export class Peer {
367
377
  const certificatesRequired =
368
378
  this.certificatesToRequest.certifiers.length > 0
369
379
 
370
- this.sessionManager.addSession({
380
+ await this.sessionManager.addSession({
371
381
  isAuthenticated: false,
372
382
  sessionNonce,
373
383
  peerIdentityKey: identityKey,
@@ -511,7 +521,7 @@ export class Peer {
511
521
  Array.isArray(this.certificatesToRequest?.certifiers) &&
512
522
  this.certificatesToRequest.certifiers.length > 0
513
523
 
514
- this.sessionManager.addSession({
524
+ await this.sessionManager.addSession({
515
525
  isAuthenticated: true,
516
526
  sessionNonce,
517
527
  peerNonce: message.initialNonce,
@@ -588,7 +598,7 @@ export class Peer {
588
598
  )
589
599
  }
590
600
 
591
- const peerSession = this.sessionManager.getSession(message.yourNonce as string)
601
+ const peerSession = await this.sessionManager.getSession(message.yourNonce as string)
592
602
  if (peerSession == null) {
593
603
  throw new Error(`Peer session not found for peer: ${message.identityKey}`)
594
604
  }
@@ -624,7 +634,7 @@ export class Peer {
624
634
  peerSession.certificatesValidated = !peerSession.certificatesRequired
625
635
 
626
636
  peerSession.lastUpdate = Date.now()
627
- this.sessionManager.updateSession(peerSession)
637
+ await this.sessionManager.updateSession(peerSession)
628
638
 
629
639
  // --- Validate certificates if provided ---
630
640
  if (
@@ -641,7 +651,7 @@ export class Peer {
641
651
 
642
652
  peerSession.certificatesValidated = true
643
653
  peerSession.lastUpdate = Date.now()
644
- this.sessionManager.updateSession(peerSession)
654
+ await this.sessionManager.updateSession(peerSession)
645
655
 
646
656
  // Resolve any promises waiting for certificate validation
647
657
  if (peerSession.sessionNonce != null) {
@@ -711,7 +721,7 @@ export class Peer {
711
721
  `Unable to verify nonce for certificate request message from: ${message.identityKey}`
712
722
  )
713
723
  }
714
- const peerSession = this.sessionManager.getSession(message.yourNonce as string)
724
+ const peerSession = await this.sessionManager.getSession(message.yourNonce as string)
715
725
  if (peerSession == null) {
716
726
  throw new Error(`Session not found for nonce: ${message.yourNonce as string}`)
717
727
  }
@@ -731,7 +741,7 @@ export class Peer {
731
741
 
732
742
  // Update usage
733
743
  peerSession.lastUpdate = Date.now()
734
- this.sessionManager.updateSession(peerSession)
744
+ await this.sessionManager.updateSession(peerSession)
735
745
 
736
746
  if (
737
747
  (message.requestedCertificates != null) &&
@@ -789,7 +799,7 @@ export class Peer {
789
799
 
790
800
  // Update usage
791
801
  peerSession.lastUpdate = Date.now()
792
- this.sessionManager.updateSession(peerSession)
802
+ await this.sessionManager.updateSession(peerSession)
793
803
 
794
804
  try {
795
805
  await this.transport.send(certificateResponse)
@@ -813,7 +823,7 @@ export class Peer {
813
823
  )
814
824
  }
815
825
 
816
- const peerSession = this.sessionManager.getSession(message.yourNonce as string)
826
+ const peerSession = await this.sessionManager.getSession(message.yourNonce as string)
817
827
  if (peerSession == null) {
818
828
  throw new Error(`Session not found for nonce: ${message.yourNonce as string}`)
819
829
  }
@@ -843,7 +853,7 @@ export class Peer {
843
853
 
844
854
  peerSession.certificatesValidated = true
845
855
  peerSession.lastUpdate = Date.now()
846
- this.sessionManager.updateSession(peerSession)
856
+ await this.sessionManager.updateSession(peerSession)
847
857
 
848
858
  // Resolve any promises waiting for certificate validation
849
859
  if (peerSession.sessionNonce != null) {
@@ -878,7 +888,7 @@ export class Peer {
878
888
  )
879
889
  }
880
890
 
881
- const peerSession = this.sessionManager.getSession(message.yourNonce as string)
891
+ const peerSession = await this.sessionManager.getSession(message.yourNonce as string)
882
892
  if (peerSession == null) {
883
893
  throw new Error(`Session not found for nonce: ${message.yourNonce as string}`)
884
894
  }
@@ -942,7 +952,7 @@ export class Peer {
942
952
 
943
953
  // Mark last usage
944
954
  peerSession.lastUpdate = Date.now()
945
- this.sessionManager.updateSession(peerSession)
955
+ await this.sessionManager.updateSession(peerSession)
946
956
 
947
957
  // Update lastInteractedWithPeer
948
958
  this.lastInteractedWithPeer = message.identityKey
@@ -1,5 +1,27 @@
1
1
  import { PeerSession } from './types.js'
2
2
 
3
+ /**
4
+ * Opt-in async session-manager contract for horizontally scaled deployments.
5
+ *
6
+ * The default in-process {@link SessionManager} stores BRC-103 nonce/session
7
+ * state in memory and is synchronous. Multi-instance HTTP servers that need
8
+ * every instance to resolve the same handshake state — e.g. behind a load
9
+ * balancer without sticky routing — can implement `AsyncSessionManager`
10
+ * against a shared store such as Redis or SQL and pass it to {@link Peer}
11
+ * or `createAuthMiddleware` instead.
12
+ *
13
+ * {@link Peer} accepts `SessionManager | AsyncSessionManager` and awaits every
14
+ * call internally, so sync stores incur no extra latency while async stores
15
+ * work transparently.
16
+ */
17
+ export interface AsyncSessionManager {
18
+ addSession: (session: PeerSession) => Promise<void>
19
+ updateSession: (session: PeerSession) => Promise<void>
20
+ getSession: (identifier: string) => Promise<PeerSession | undefined>
21
+ removeSession: (session: PeerSession) => Promise<void>
22
+ hasSession: (identifier: string) => Promise<boolean>
23
+ }
24
+
3
25
  /**
4
26
  * Manages sessions for peers, allowing multiple concurrent sessions
5
27
  * per identity key. Primary lookup is always by `sessionNonce`.
@@ -1,5 +1,5 @@
1
1
  import { Peer } from '../../auth/Peer.js'
2
- import { AuthMessage, Transport } from '../../auth/types.js'
2
+ import { AuthMessage, PeerSession, Transport } from '../../auth/types.js'
3
3
  import { jest } from '@jest/globals'
4
4
  import { WalletInterface } from '../../wallet/Wallet.interfaces.js'
5
5
  import { Utils, PrivateKey } from '../../primitives/index.js'
@@ -8,6 +8,7 @@ import { MasterCertificate } from '../../auth/certificates/MasterCertificate.js'
8
8
  import { getVerifiableCertificates } from '../../auth/utils/getVerifiableCertificates.js'
9
9
  import { CompletedProtoWallet } from '../certificates/__tests/CompletedProtoWallet.js'
10
10
  import { SimplifiedFetchTransport } from '../../auth/transports/SimplifiedFetchTransport.js'
11
+ import { SessionManager, AsyncSessionManager } from '../../auth/SessionManager.js'
11
12
 
12
13
  const certifierPrivKey = new PrivateKey(21)
13
14
  const alicePrivKey = new PrivateKey(22)
@@ -51,6 +52,35 @@ class LocalTransport implements Transport {
51
52
  }
52
53
  }
53
54
 
55
+ class AsyncSessionStore implements AsyncSessionManager {
56
+ private readonly sessions = new SessionManager()
57
+
58
+ async addSession(session: PeerSession): Promise<void> {
59
+ await Promise.resolve()
60
+ this.sessions.addSession(session)
61
+ }
62
+
63
+ async updateSession(session: PeerSession): Promise<void> {
64
+ await Promise.resolve()
65
+ this.sessions.updateSession(session)
66
+ }
67
+
68
+ async getSession(identifier: string): Promise<PeerSession | undefined> {
69
+ await Promise.resolve()
70
+ return this.sessions.getSession(identifier)
71
+ }
72
+
73
+ async removeSession(session: PeerSession): Promise<void> {
74
+ await Promise.resolve()
75
+ this.sessions.removeSession(session)
76
+ }
77
+
78
+ async hasSession(identifier: string): Promise<boolean> {
79
+ await Promise.resolve()
80
+ return this.sessions.hasSession(identifier)
81
+ }
82
+ }
83
+
54
84
  function waitForNextGeneralMessage(
55
85
  peer: Peer,
56
86
  handler?: (senderPublicKey: string, payload: number[]) => void
@@ -290,6 +320,22 @@ describe('Peer class mutual authentication and certificate exchange', () => {
290
320
  expect(certificatesReceivedByBob).toEqual([])
291
321
  }, 15000)
292
322
 
323
+ it('supports asynchronous shared session managers', async () => {
324
+ alice = new Peer(walletA, transportA, undefined, new AsyncSessionStore())
325
+ bob = new Peer(walletB, transportB, undefined, new AsyncSessionStore())
326
+
327
+ const bobReceivedGeneralMessage = waitForNextGeneralMessage(bob)
328
+ const aliceReceivedGeneralMessage = waitForNextGeneralMessage(alice)
329
+
330
+ bob.listenForGeneralMessages((senderPublicKey) => {
331
+ void bob.toPeer(Utils.toArray('Hello Alice!'), senderPublicKey)
332
+ })
333
+
334
+ await alice.toPeer(Utils.toArray('Hello Bob!'))
335
+ await bobReceivedGeneralMessage
336
+ await aliceReceivedGeneralMessage
337
+ }, 15000)
338
+
293
339
  it('Alice talks to Bob across two devices, Bob can respond across both sessions', async () => {
294
340
  const transportA1 = new LocalTransport()
295
341
  const transportA2 = new LocalTransport()
@@ -7,7 +7,7 @@ import { OriginatorDomainNameStringUnder250Bytes, WalletInterface } from '../../
7
7
  import { createNonce } from '../utils/createNonce.js'
8
8
  import { Peer } from '../Peer.js'
9
9
  import { SimplifiedFetchTransport } from '../transports/SimplifiedFetchTransport.js'
10
- import { SessionManager } from '../SessionManager.js'
10
+ import { SessionManager, AsyncSessionManager } from '../SessionManager.js'
11
11
  import { RequestedCertificateSet } from '../types.js'
12
12
  import { VerifiableCertificate } from '../certificates/VerifiableCertificate.js'
13
13
  import { Writer } from '../../primitives/utils.js'
@@ -79,10 +79,13 @@ export class AuthFetch {
79
79
  * @param wallet - The wallet instance for signing and authentication.
80
80
  * @param requestedCertificates - Optional set of certificates to request from peers.
81
81
  */
82
- constructor(wallet: WalletInterface, requestedCertificates?: RequestedCertificateSet, sessionManager?: SessionManager, originator?: OriginatorDomainNameStringUnder250Bytes) {
82
+ constructor(wallet: WalletInterface, requestedCertificates?: RequestedCertificateSet, sessionManager?: SessionManager | AsyncSessionManager, originator?: OriginatorDomainNameStringUnder250Bytes) {
83
83
  this.wallet = wallet
84
84
  this.requestedCertificates = requestedCertificates
85
- this.sessionManager = sessionManager ?? new SessionManager()
85
+ // See `Peer.sessionManager`: field stays typed as the synchronous
86
+ // `SessionManager` for back-compat; if an `AsyncSessionManager` is
87
+ // injected, the underlying Peer awaits all calls internally.
88
+ this.sessionManager = (sessionManager ?? new SessionManager()) as SessionManager
86
89
  this.originator = originator
87
90
  }
88
91
 
@@ -125,9 +125,22 @@ export default class Mnemonic {
125
125
  * Sets the mnemonic for the instance from a string.
126
126
  * @param {string} mnemonic - The mnemonic phrase as a string.
127
127
  * @returns {this} The Mnemonic instance with the set mnemonic.
128
+ * @throws {Error} If the mnemonic does not pass BIP-39 validation
129
+ * (unknown words, invalid length, or bad checksum).
128
130
  */
129
131
  public fromString (mnemonic: string): this {
130
132
  this.mnemonic = mnemonic
133
+ let valid = false
134
+ try {
135
+ valid = this.check()
136
+ } catch {
137
+ valid = false
138
+ }
139
+ if (!valid) {
140
+ throw new Error(
141
+ 'Mnemonic does not pass the check - was the mnemonic typed incorrectly? Are there extra spaces?'
142
+ )
143
+ }
131
144
  return this
132
145
  }
133
146
 
@@ -49,15 +49,21 @@ describe('Mnemonic', function () {
49
49
  mnemonic = m.mnemonic
50
50
 
51
51
  // mnemonics with extra whitespace do not pass the check
52
- m = new Mnemonic().fromString(mnemonic + ' ')
53
- expect(m.check()).toEqual(false)
52
+ const trailingSpace = mnemonic + ' '
53
+ const trailing = new Mnemonic()
54
+ trailing.mnemonic = trailingSpace
55
+ expect(trailing.check()).toEqual(false)
56
+ expect(() => new Mnemonic().fromString(trailingSpace)).toThrow()
54
57
 
55
58
  // mnemonics with a word replaced do not pass the check
56
59
  const words = mnemonic.split(' ')
57
60
  expect(words[words.length - 1]).not.toEqual('zoo')
58
61
  words[words.length - 1] = 'zoo'
59
- mnemonic = words.join(' ')
60
- expect(new Mnemonic().fromString(mnemonic).check()).toEqual(false)
62
+ const badWord = words.join(' ')
63
+ const replaced = new Mnemonic()
64
+ replaced.mnemonic = badWord
65
+ expect(replaced.check()).toEqual(false)
66
+ expect(() => new Mnemonic().fromString(badWord)).toThrow()
61
67
  })
62
68
 
63
69
  describe('#toBinary', () => {
@@ -132,13 +138,51 @@ describe('Mnemonic', function () {
132
138
  })
133
139
 
134
140
  describe('#fromString', () => {
135
- it('should throw an error in invalid mnemonic', () => {
141
+ it('should throw an error on invalid mnemonic when toSeed is called', () => {
136
142
  expect(() => {
137
143
  new Mnemonic().fromString('invalid mnemonic').toSeed()
138
144
  }).toThrow(
139
145
  'Mnemonic does not pass the check - was the mnemonic typed incorrectly? Are there extra spaces?'
140
146
  )
141
147
  })
148
+
149
+ it('should throw immediately on nonsense input', () => {
150
+ expect(() => {
151
+ Mnemonic.fromString('this is not a real bip39 mnemonic phrase at all')
152
+ }).toThrow(
153
+ 'Mnemonic does not pass the check - was the mnemonic typed incorrectly? Are there extra spaces?'
154
+ )
155
+ })
156
+
157
+ it('should throw on garbage string', () => {
158
+ expect(() => {
159
+ Mnemonic.fromString('asdfghjkl qwertyuiop zxcvbnm')
160
+ }).toThrow()
161
+ })
162
+
163
+ it('should throw on empty string', () => {
164
+ expect(() => {
165
+ Mnemonic.fromString('')
166
+ }).toThrow()
167
+ })
168
+
169
+ it('should throw on valid words but wrong checksum', () => {
170
+ // 12 valid wordlist entries but checksum will not match
171
+ expect(() => {
172
+ Mnemonic.fromString(
173
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon'
174
+ )
175
+ }).toThrow()
176
+ })
177
+
178
+ it('should accept a known-valid BIP-39 phrase', () => {
179
+ const m = Mnemonic.fromString(
180
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
181
+ )
182
+ expect(m.mnemonic).toEqual(
183
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
184
+ )
185
+ })
142
186
  })
143
187
 
144
188
  describe('@isValid', () => {
@@ -105,6 +105,73 @@ export const DEFAULT_TESTNET_SLAP_TRACKERS: string[] = [
105
105
 
106
106
  const MAX_TRACKER_WAIT_TIME = 5000
107
107
 
108
+ /** A wall-clock deadline that rejects after `timeoutMs`, optionally aborting a controller. */
109
+ interface Deadline {
110
+ /** Rejects with `Error('Request timed out')` once the timer fires. */
111
+ promise: Promise<never>
112
+ /** Clears the underlying timer. Safe to call after the timer has already fired. */
113
+ cancel: () => void
114
+ /** Returns true once the timer has fired. */
115
+ didTimeOut: () => boolean
116
+ }
117
+
118
+ function createDeadline (timeoutMs: number, controller?: AbortController): Deadline {
119
+ let expired = false
120
+ let timer: ReturnType<typeof setTimeout> | null = null
121
+ const promise = new Promise<never>((_, reject) => {
122
+ timer = setTimeout(() => {
123
+ expired = true
124
+ try { controller?.abort() } catch { /* noop */ }
125
+ reject(new Error('Request timed out'))
126
+ }, timeoutMs)
127
+ })
128
+ return {
129
+ promise,
130
+ cancel: () => {
131
+ if (timer !== null) clearTimeout(timer)
132
+ },
133
+ didTimeOut: () => expired
134
+ }
135
+ }
136
+
137
+ function normalizeLookupError (err: unknown, timedOut: boolean): Error {
138
+ if (timedOut) return new Error('Request timed out')
139
+ if ((err as { name?: string })?.name === 'AbortError') return new Error('Request timed out')
140
+ if (err instanceof Error) return err
141
+ return new Error(stringifyErrorValue(err))
142
+ }
143
+
144
+ /**
145
+ * Coerce a non-Error thrown value to a human-readable string without falling
146
+ * back to the default `'[object Object]'` for plain objects.
147
+ */
148
+ function stringifyErrorValue (value: unknown): string {
149
+ if (value === null) return 'null'
150
+ if (value === undefined) return 'undefined'
151
+ if (typeof value === 'string') return value
152
+ if (typeof value === 'number') return value.toString()
153
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
154
+ if (typeof value === 'bigint') return value.toString()
155
+ const message = (value as { message?: unknown }).message
156
+ if (typeof message === 'string' && message.length > 0) return message
157
+ try {
158
+ return JSON.stringify(value) ?? 'Unknown error'
159
+ } catch {
160
+ return 'Unknown error'
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Returns true when the given Content-Type header value represents
166
+ * `application/octet-stream`, ignoring case and any media-type parameters
167
+ * (e.g. `; charset=utf-8`).
168
+ */
169
+ function isOctetStream (contentType: string | null): boolean {
170
+ if (typeof contentType !== 'string') return false
171
+ const baseType = contentType.split(';', 1)[0].trim().toLowerCase()
172
+ return baseType === 'application/octet-stream'
173
+ }
174
+
108
175
  /** Internal cache options. Kept optional to preserve drop-in compatibility. */
109
176
  interface CacheOptions {
110
177
  /** How long (ms) a hosts entry is considered fresh. Default 5 minutes. */
@@ -181,36 +248,46 @@ export class HTTPSOverlayLookupFacilitator implements OverlayLookupFacilitator {
181
248
  }
182
249
 
183
250
  const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
184
- const timer = setTimeout(() => {
185
- try { controller?.abort() } catch { /* noop */ }
186
- }, timeout)
251
+ const deadline = createDeadline(timeout, controller)
187
252
 
188
- try {
189
- const fco: RequestInit = {
190
- method: 'POST',
191
- headers: {
192
- 'Content-Type': 'application/json',
193
- 'X-Aggregation': 'yes'
194
- },
195
- body: JSON.stringify({ service: question.service, query: question.query }),
196
- signal: controller?.signal
197
- }
198
- const response: Response = await this.fetchClient(`${url}/lookup`, fco)
253
+ // Hard wall-clock deadline: in some environments (e.g. browser/Electron CORS
254
+ // failures) the underlying fetch can stall without ever settling, and the
255
+ // AbortController signal alone is insufficient to make the returned promise
256
+ // resolve or reject. Race the fetch against a setTimeout-backed reject so
257
+ // the consumer-facing promise always settles within `timeout` ms.
258
+ const fetchPromise = this.performLookupRequest(url, question, controller?.signal)
259
+ // Swallow background rejection if the deadline wins first.
260
+ fetchPromise.catch(() => { /* noop */ })
199
261
 
200
- if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
201
- if (response.headers.get('content-type') === 'application/octet-stream') {
202
- return await this.parseOctetStreamLookup(response)
203
- }
204
- return await response.json()
262
+ try {
263
+ return await Promise.race([fetchPromise, deadline.promise])
205
264
  } catch (e) {
206
- // Normalize timeouts to a consistent error message
207
- if ((e as { name?: string })?.name === 'AbortError') {
208
- throw new Error('Request timed out')
209
- }
210
- throw e
265
+ throw normalizeLookupError(e, deadline.didTimeOut())
211
266
  } finally {
212
- clearTimeout(timer)
267
+ deadline.cancel()
268
+ }
269
+ }
270
+
271
+ private async performLookupRequest (
272
+ url: string,
273
+ question: LookupQuestion,
274
+ signal: AbortSignal | undefined
275
+ ): Promise<LookupAnswer> {
276
+ const fco: RequestInit = {
277
+ method: 'POST',
278
+ headers: {
279
+ 'Content-Type': 'application/json',
280
+ 'X-Aggregation': 'yes'
281
+ },
282
+ body: JSON.stringify({ service: question.service, query: question.query }),
283
+ signal
284
+ }
285
+ const response: Response = await this.fetchClient(`${url}/lookup`, fco)
286
+ if (!response.ok) throw new Error(`Failed to facilitate lookup (HTTP ${response.status})`)
287
+ if (isOctetStream(response.headers.get('content-type'))) {
288
+ return await this.parseOctetStreamLookup(response)
213
289
  }
290
+ return await response.json()
214
291
  }
215
292
 
216
293
  /** Parse the aggregated octet-stream lookup response into an output-list LookupAnswer. */