@bsv/sdk 1.9.19 → 1.9.20

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 (39) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/certificates/MasterCertificate.js +9 -2
  3. package/dist/cjs/src/auth/certificates/MasterCertificate.js.map +1 -1
  4. package/dist/cjs/src/primitives/Hash.js +6 -5
  5. package/dist/cjs/src/primitives/Hash.js.map +1 -1
  6. package/dist/cjs/src/primitives/hex.js +33 -0
  7. package/dist/cjs/src/primitives/hex.js.map +1 -0
  8. package/dist/cjs/src/primitives/utils.js +69 -59
  9. package/dist/cjs/src/primitives/utils.js.map +1 -1
  10. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  11. package/dist/esm/src/auth/certificates/MasterCertificate.js +9 -2
  12. package/dist/esm/src/auth/certificates/MasterCertificate.js.map +1 -1
  13. package/dist/esm/src/primitives/Hash.js +6 -5
  14. package/dist/esm/src/primitives/Hash.js.map +1 -1
  15. package/dist/esm/src/primitives/hex.js +29 -0
  16. package/dist/esm/src/primitives/hex.js.map +1 -0
  17. package/dist/esm/src/primitives/utils.js +69 -59
  18. package/dist/esm/src/primitives/utils.js.map +1 -1
  19. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  20. package/dist/types/src/auth/certificates/MasterCertificate.d.ts.map +1 -1
  21. package/dist/types/src/primitives/Hash.d.ts.map +1 -1
  22. package/dist/types/src/primitives/hex.d.ts +3 -0
  23. package/dist/types/src/primitives/hex.d.ts.map +1 -0
  24. package/dist/types/src/primitives/utils.d.ts.map +1 -1
  25. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  26. package/dist/umd/bundle.js +3 -3
  27. package/dist/umd/bundle.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/auth/__tests/Peer.test.ts +2 -1
  30. package/src/auth/certificates/MasterCertificate.ts +9 -2
  31. package/src/auth/certificates/__tests/MasterCertificate.test.ts +46 -9
  32. package/src/primitives/Hash.ts +9 -6
  33. package/src/primitives/__tests/HMAC.test.ts +13 -2
  34. package/src/primitives/__tests/Hash.test.ts +24 -0
  35. package/src/primitives/__tests/hex.test.ts +57 -0
  36. package/src/primitives/__tests/utils.test.ts +39 -0
  37. package/src/primitives/hex.ts +35 -0
  38. package/src/primitives/utils.ts +71 -65
  39. package/src/script/__tests/Script.test.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "1.9.19",
3
+ "version": "1.9.20",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -12,6 +12,7 @@ import { SimplifiedFetchTransport } from '../../auth/transports/SimplifiedFetchT
12
12
  const certifierPrivKey = new PrivateKey(21)
13
13
  const alicePrivKey = new PrivateKey(22)
14
14
  const bobPrivKey = new PrivateKey(23)
15
+ const DUMMY_REVOCATION_OUTPOINT_HEX = '00'.repeat(36)
15
16
 
16
17
  jest.mock('../../auth/utils/getVerifiableCertificates')
17
18
 
@@ -101,7 +102,7 @@ describe('Peer class mutual authentication and certificate exchange', () => {
101
102
  subjectPubKey,
102
103
  fields,
103
104
  certificateType,
104
- async () => 'revocationOutpoint' // or any revocation outpoint logic you want
105
+ async () => DUMMY_REVOCATION_OUTPOINT_HEX
105
106
  )
106
107
 
107
108
  // For test consistency, you could override the auto-generated serialNumber:
@@ -232,7 +232,7 @@ export class MasterCertificate extends Certificate {
232
232
  certificateType: string,
233
233
  getRevocationOutpoint = async (_serial: string): Promise<string> => {
234
234
  void _serial // Explicitly acknowledge unused parameter
235
- return 'Certificate revocation not tracked.'
235
+ return '00'.repeat(32)
236
236
  },
237
237
  serialNumber?: string
238
238
  ): Promise<MasterCertificate> {
@@ -246,11 +246,18 @@ export class MasterCertificate extends Certificate {
246
246
  // 3. Obtain a revocation outpoint
247
247
  const revocationOutpoint = await getRevocationOutpoint(finalSerialNumber)
248
248
 
249
+ let subjectIdentityKey: string
250
+ if (subject === 'self') {
251
+ subjectIdentityKey = (await certifierWallet.getPublicKey({ identityKey: true })).publicKey
252
+ } else {
253
+ subjectIdentityKey = subject
254
+ }
255
+
249
256
  // 4. Create new MasterCertificate instance
250
257
  const certificate = new MasterCertificate(
251
258
  certificateType,
252
259
  finalSerialNumber,
253
- subject,
260
+ subjectIdentityKey,
254
261
  (await certifierWallet.getPublicKey({ identityKey: true })).publicKey,
255
262
  revocationOutpoint,
256
263
  certificateFields,
@@ -14,7 +14,8 @@ const verifierKey2 = new PrivateKey(81)
14
14
 
15
15
  // A mock revocation outpoint for testing
16
16
  const mockRevocationOutpoint =
17
- 'deadbeefdeadbeefdeadbeefdeadbeef00000000000000000000000000000000.1'
17
+ 'deadbeefdeadbeefdeadbeefdeadbeef00000001'
18
+
18
19
 
19
20
  // Arbitrary certificate data (in plaintext)
20
21
  const plaintextFields = {
@@ -355,33 +356,69 @@ describe('MasterCertificate', () => {
355
356
  expect(newCert.fields[fieldName]).toMatch(/^[A-Za-z0-9+/]+=*$/)
356
357
  }
357
358
  })
359
+
358
360
  it('should allow issuing a self-signed certificate and decrypt it with the same wallet', async () => {
359
- // In a self-signed scenario, the subject and certifier are the same
360
361
  const subjectWallet = new CompletedProtoWallet(subjectKey2)
361
362
 
362
- // Some sample fields
363
363
  const selfSignedFields = {
364
364
  owner: 'Bob',
365
365
  organization: 'SelfCo'
366
366
  }
367
367
 
368
- // Issue the certificate for "self"
368
+ const subjectIdentityKey = (
369
+ await subjectWallet.getPublicKey({ identityKey: true })
370
+ ).publicKey
371
+
372
+ // Issue the certificate: subject = actual identity key (valid hex)
369
373
  const selfSignedCert = await MasterCertificate.issueCertificateForSubject(
370
- subjectWallet, // act as certifier
371
- 'self',
374
+ subjectWallet, // acts as certifier
375
+ subjectIdentityKey, // <-- was 'self', now real hex
372
376
  selfSignedFields,
373
377
  'SELF_SIGNED_TEST'
374
378
  )
375
379
 
376
- // Now we attempt to decrypt the fields with the same wallet
380
+ // Decrypt with the same wallet
377
381
  const decrypted = await MasterCertificate.decryptFields(
378
382
  subjectWallet,
379
383
  selfSignedCert.masterKeyring,
380
384
  selfSignedCert.fields,
381
- 'self'
385
+ 'self' // still fine here if decryptFields treats 'self' specially
382
386
  )
383
387
 
384
388
  expect(decrypted).toEqual(selfSignedFields)
385
389
  })
390
+
391
+ it('resolves subject === "self" to the certifier wallet identity key', async () => {
392
+ const certifierWallet = new CompletedProtoWallet(new PrivateKey(99))
393
+
394
+ const certifierIdentityKey = (
395
+ await certifierWallet.getPublicKey({ identityKey: true })
396
+ ).publicKey
397
+
398
+ const cert = await MasterCertificate.issueCertificateForSubject(
399
+ certifierWallet,
400
+ 'self',
401
+ { name: 'Alice' },
402
+ 'TEST_CERT'
403
+ )
404
+
405
+ expect(cert.subject).toBe(certifierIdentityKey)
406
+ })
407
+
408
+ it('uses provided subjectIdentityKey when subject is a valid hex string', async () => {
409
+ const certifierWallet = new CompletedProtoWallet(new PrivateKey(42))
410
+
411
+ const validPubkey =
412
+ '0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798'
413
+
414
+ const cert = await MasterCertificate.issueCertificateForSubject(
415
+ certifierWallet,
416
+ validPubkey,
417
+ { name: 'Alice' },
418
+ 'TEST_CERT'
419
+ )
420
+
421
+ expect(cert.subject).toBe(validPubkey)
422
+ })
386
423
  })
387
- })
424
+ })
@@ -1,6 +1,8 @@
1
1
 
2
2
  // @ts-nocheck
3
3
  /* eslint-disable @typescript-eslint/naming-convention */
4
+ import { assertValidHex, normalizeHex } from './hex.js'
5
+
4
6
  const assert = (
5
7
  expression: unknown,
6
8
  message: string = 'Hash assertion failed'
@@ -169,6 +171,10 @@ abstract class BaseHash {
169
171
  */
170
172
  private _pad (): number[] {
171
173
  const len = this.pendingTotal
174
+ if (!Number.isSafeInteger(len) || len < 0) {
175
+ throw new Error('Message too long for this hash function')
176
+ }
177
+
172
178
  const bytes = this._delta8
173
179
  const k = bytes - ((len + this.padLength) % bytes)
174
180
  const res = new Array(k + this.padLength)
@@ -177,8 +183,6 @@ abstract class BaseHash {
177
183
  for (i = 1; i < k; i++) {
178
184
  res[i] = 0
179
185
  }
180
-
181
- // Append length
182
186
  const lengthBytes = this.padLength
183
187
  const maxBits = 1n << BigInt(lengthBytes * 8)
184
188
  let totalBits = BigInt(len) * 8n
@@ -204,6 +208,7 @@ abstract class BaseHash {
204
208
  totalBits >>= 8n
205
209
  }
206
210
  }
211
+
207
212
  return res
208
213
  }
209
214
  }
@@ -262,10 +267,8 @@ export function toArray (
262
267
  }
263
268
  }
264
269
  } else {
265
- msg = msg.replace(/[^a-z0-9]+/gi, '')
266
- if (msg.length % 2 !== 0) {
267
- msg = '0' + msg
268
- }
270
+ assertValidHex(msg)
271
+ msg = normalizeHex(msg)
269
272
  for (let i = 0; i < msg.length; i += 2) {
270
273
  res.push(parseInt(msg[i] + msg[i + 1], 16))
271
274
  }
@@ -48,11 +48,22 @@ describe('HMAC', function () {
48
48
  res: 'cf5ad5984f9e43917aa9087380dac46e410ddc8a7731859c84e9d0f31bd43655'
49
49
  })
50
50
 
51
+ function normalizeKey (key: string | number[]): string | number[] {
52
+ if (typeof key === 'string') {
53
+ // test-only helper: remove whitespace between hex groups
54
+ return key.replace(/\s+/g, '')
55
+ }
56
+ return key
57
+ }
58
+
51
59
  function test (opt): void {
52
60
  it(`should not fail at ${opt.name as string}`, function (): void {
53
- let h = new SHA256HMAC(opt.key)
61
+ const key = normalizeKey(opt.key)
62
+
63
+ let h = new SHA256HMAC(key as any)
54
64
  expect(h.update(opt.msg, opt.msgEnc).digestHex()).toEqual(opt.res)
55
- h = h = new SHA256HMAC(opt.key)
65
+
66
+ h = new SHA256HMAC(key as any)
56
67
  expect(
57
68
  h
58
69
  .update(opt.msg.slice(0, 10), opt.msgEnc)
@@ -3,6 +3,7 @@ import * as hash from '../../primitives/Hash'
3
3
  import * as crypto from 'crypto'
4
4
  import PBKDF2Vectors from './PBKDF2.vectors'
5
5
  import { toArray, toHex } from '../../primitives/utils'
6
+ import { SHA1 } from '../..//primitives/Hash'
6
7
 
7
8
  describe('Hash', function () {
8
9
  function test (Hash, cases): void {
@@ -177,4 +178,27 @@ describe('Hash', function () {
177
178
  })
178
179
  }
179
180
  })
181
+
182
+ describe('Hash strict length validation (TOB-21)', () => {
183
+
184
+ it('throws when pendingTotal is not a safe integer', () => {
185
+ const h = new SHA1()
186
+
187
+ h.pendingTotal = Number.MAX_SAFE_INTEGER + 10
188
+
189
+ expect(() => {
190
+ h.digest()
191
+ }).toThrow('Message too long for this hash function')
192
+ })
193
+
194
+ it('throws when pendingTotal is negative', () => {
195
+ const h = new SHA1()
196
+
197
+ h.pendingTotal = -5
198
+
199
+ expect(() => {
200
+ h.digest()
201
+ }).toThrow('Message too long for this hash function')
202
+ })
203
+ })
180
204
  })
@@ -0,0 +1,57 @@
1
+ /* eslint-env jest */
2
+
3
+ import { assertValidHex, normalizeHex } from '../../primitives/hex'
4
+
5
+ describe('hex utils', () => {
6
+ describe('assertValidHex', () => {
7
+ it('should not throw on valid hex strings', () => {
8
+ expect(() => assertValidHex('')).not.toThrow() // empty is allowed
9
+ expect(() => assertValidHex('00')).not.toThrow()
10
+ expect(() => assertValidHex('abcdef')).not.toThrow()
11
+ expect(() => assertValidHex('ABCDEF')).not.toThrow()
12
+ expect(() => assertValidHex('1234567890')).not.toThrow()
13
+ })
14
+
15
+ it('should throw on non-hex characters', () => {
16
+ expect(() => assertValidHex('zz')).toThrow('Invalid hex string')
17
+ expect(() => assertValidHex('0x1234')).toThrow('Invalid hex string')
18
+ expect(() => assertValidHex('12 34')).toThrow('Invalid hex string')
19
+ expect(() => assertValidHex('g1')).toThrow('Invalid hex string')
20
+ })
21
+
22
+ // ❌ old behavior: empty string was considered invalid
23
+ // it('should throw on empty string', () => {
24
+ // expect(() => assertValidHex('')).toThrow('Invalid hex string')
25
+ // })
26
+
27
+ it('should throw on undefined or null', () => {
28
+ expect(() => assertValidHex(undefined as any)).toThrow('Invalid hex string')
29
+ expect(() => assertValidHex(null as any)).toThrow('Invalid hex string')
30
+ })
31
+ })
32
+
33
+ describe('normalizeHex', () => {
34
+ it('should return lowercase hex', () => {
35
+ expect(normalizeHex('ABCD')).toBe('abcd')
36
+ })
37
+
38
+ it('should prepend 0 to odd-length hex strings', () => {
39
+ expect(normalizeHex('abc')).toBe('0abc')
40
+ expect(normalizeHex('f')).toBe('0f')
41
+ })
42
+
43
+ it('should leave even-length hex strings untouched (except lowercase)', () => {
44
+ expect(normalizeHex('AABB')).toBe('aabb')
45
+ expect(normalizeHex('001122')).toBe('001122')
46
+ })
47
+
48
+ it('should return empty string unchanged', () => {
49
+ expect(normalizeHex('')).toBe('')
50
+ })
51
+
52
+ it('should throw on invalid hex', () => {
53
+ expect(() => normalizeHex('xyz')).toThrow('Invalid hex string')
54
+ expect(() => normalizeHex('12 34')).toThrow('Invalid hex string')
55
+ })
56
+ })
57
+ })
@@ -320,3 +320,42 @@ describe('verifyNotNull', () => {
320
320
  expect(() => verifyNotNull(undefined, 'Another custom error')).toThrow('Another custom error')
321
321
  })
322
322
  })
323
+
324
+ describe('toUTF8 strict UTF-8 decoding (TOB-21)', () => {
325
+
326
+ it('replaces invalid 2-byte sequences with U+FFFD', () => {
327
+ // 0xC2 should expect a continuation byte 0x80–0xBF
328
+ const arr = [0xC2, 0x20] // 0x20 is INVALID continuation
329
+ const str = toUTF8(arr)
330
+ expect(str).toBe('\uFFFD')
331
+ })
332
+
333
+ it('decodes valid 3-byte sequences', () => {
334
+ const euro = [0xE2, 0x82, 0xAC]
335
+ expect(toUTF8(euro)).toBe('€')
336
+ })
337
+
338
+ it('replaces invalid 3-byte sequences', () => {
339
+ // Middle byte invalid
340
+ const arr = [0xE2, 0x20, 0xAC]
341
+ expect(toUTF8(arr)).toBe('\uFFFD')
342
+ })
343
+
344
+ it('decodes valid 4-byte sequences into surrogate pairs', () => {
345
+ const smile = [0xF0, 0x9F, 0x98, 0x80] // 😀
346
+ expect(toUTF8(smile)).toBe('😀')
347
+ })
348
+
349
+ it('replaces invalid 4-byte sequences with U+FFFD', () => {
350
+ // 0x9F is valid, 0x20 is INVALID continuation for byte 3
351
+ const arr = [0xF0, 0x9F, 0x20, 0x80]
352
+ expect(toUTF8(arr)).toBe('\uFFFD')
353
+ })
354
+
355
+ it('replaces incomplete UTF-8 sequence at end', () => {
356
+ const arr = [0xE2] // incomplete 3-byte seq
357
+ expect(toUTF8(arr)).toBe('\uFFFD')
358
+ })
359
+
360
+ })
361
+
@@ -0,0 +1,35 @@
1
+ // src/primitives/hex.ts
2
+
3
+ // Accepts empty string because empty byte arrays are valid in Bitcoin.
4
+ const PURE_HEX_REGEX = /^[0-9a-fA-F]*$/
5
+
6
+ export function assertValidHex (msg: string): void {
7
+ if (typeof msg !== 'string') {
8
+ console.error('assertValidHex FAIL (non-string):', msg)
9
+ throw new Error('Invalid hex string')
10
+ }
11
+
12
+ // allow empty
13
+ if (msg.length === 0) return
14
+
15
+ if (!PURE_HEX_REGEX.test(msg)) {
16
+ console.error('assertValidHex FAIL (bad hex):', msg)
17
+ throw new Error('Invalid hex string')
18
+ }
19
+ }
20
+
21
+ export function normalizeHex (msg: string): string {
22
+ assertValidHex(msg)
23
+
24
+ // If empty, return empty — never force to "00"
25
+ if (msg.length === 0) return ''
26
+
27
+ let normalized = msg.toLowerCase()
28
+
29
+ // Pad odd-length hex
30
+ if (normalized.length % 2 !== 0) {
31
+ normalized = '0' + normalized
32
+ }
33
+
34
+ return normalized
35
+ }
@@ -1,11 +1,11 @@
1
1
  import BigNumber from './BigNumber.js'
2
2
  import { hash256 } from './Hash.js'
3
+ import { assertValidHex } from './hex.js'
3
4
 
4
5
  const BufferCtor =
5
6
  typeof globalThis !== 'undefined' ? (globalThis as any).Buffer : undefined
6
7
  const CAN_USE_BUFFER =
7
8
  BufferCtor != null && typeof BufferCtor.from === 'function'
8
- const PURE_HEX_REGEX = /^[0-9a-fA-F]+$/
9
9
 
10
10
  /**
11
11
  * Prepends a '0' to an odd character length word to ensure it has an even number of characters.
@@ -80,28 +80,19 @@ for (let i = 0; i < 6; i++) {
80
80
  }
81
81
 
82
82
  const hexToArray = (msg: string): number[] => {
83
- if (CAN_USE_BUFFER && PURE_HEX_REGEX.test(msg)) {
84
- const normalized = msg.length % 2 === 0 ? msg : '0' + msg
83
+ assertValidHex(msg)
84
+ const normalized = msg.length % 2 === 0 ? msg : '0' + msg
85
+ if (CAN_USE_BUFFER) {
85
86
  return Array.from(BufferCtor.from(normalized, 'hex'))
86
87
  }
87
- const res: number[] = new Array(Math.ceil(msg.length / 2))
88
- let nibble = -1
89
- let size = 0
90
- for (let i = 0; i < msg.length; i++) {
91
- const value = HEX_CHAR_TO_VALUE[msg.charCodeAt(i)]
92
- if (value === -1) continue
93
- if (nibble === -1) {
94
- nibble = value
95
- } else {
96
- res[size++] = (nibble << 4) | value
97
- nibble = -1
98
- }
99
- }
100
- if (nibble !== -1) {
101
- res[size++] = nibble
88
+ const out = new Array(normalized.length / 2)
89
+ let o = 0
90
+ for (let i = 0; i < normalized.length; i += 2) {
91
+ const hi = HEX_CHAR_TO_VALUE[normalized.charCodeAt(i)]
92
+ const lo = HEX_CHAR_TO_VALUE[normalized.charCodeAt(i + 1)]
93
+ out[o++] = (hi << 4) | lo
102
94
  }
103
- if (size !== res.length) res.length = size
104
- return res
95
+ return out
105
96
  }
106
97
 
107
98
  export function base64ToArray (msg: string): number[] {
@@ -233,69 +224,84 @@ function utf8ToArray (str: string): number[] {
233
224
  */
234
225
  export const toUTF8 = (arr: number[]): string => {
235
226
  let result = ''
236
- let skip = 0
237
-
227
+ const replacementChar = '\uFFFD'
238
228
  for (let i = 0; i < arr.length; i++) {
239
- const byte = arr[i]
240
- // this byte is part of a multi-byte sequence, skip it
241
- // added to avoid modifying i within the loop which is considered unsafe.
242
- if (skip > 0) {
243
- skip--
229
+ const byte1 = arr[i]
230
+ if (byte1 <= 0x7f) {
231
+ result += String.fromCharCode(byte1)
244
232
  continue
245
233
  }
246
-
247
- // 1-byte sequence (0xxxxxxx)
248
- if (byte <= 0x7f) {
249
- result += String.fromCharCode(byte)
250
- continue
234
+ const emitReplacement = (): void => {
235
+ result += replacementChar
251
236
  }
252
-
253
- // 2-byte sequence (110xxxxx 10xxxxxx)
254
- if (byte >= 0xc0 && byte <= 0xdf) {
255
- const avail = arr.length - (i + 1)
256
- const byte2 = avail >= 1 ? arr[i + 1] : 0
257
- skip = Math.min(1, avail)
258
-
259
- const codePoint = ((byte & 0x1f) << 6) | (byte2 & 0x3f)
237
+ if (byte1 >= 0xc0 && byte1 <= 0xdf) {
238
+ if (i + 1 >= arr.length) {
239
+ emitReplacement()
240
+ continue
241
+ }
242
+ const byte2 = arr[i + 1]
243
+ if ((byte2 & 0xc0) !== 0x80) {
244
+ emitReplacement()
245
+ i += 1
246
+ continue
247
+ }
248
+ const codePoint = ((byte1 & 0x1f) << 6) | (byte2 & 0x3f)
260
249
  result += String.fromCharCode(codePoint)
250
+ i += 1
261
251
  continue
262
252
  }
263
-
264
- // 3-byte sequence (1110xxxx 10xxxxxx 10xxxxxx)
265
- if (byte >= 0xe0 && byte <= 0xef) {
266
- const avail = arr.length - (i + 1)
267
- const byte2 = avail >= 1 ? arr[i + 1] : 0
268
- const byte3 = avail >= 2 ? arr[i + 2] : 0
269
- skip = Math.min(2, avail)
270
-
253
+ if (byte1 >= 0xe0 && byte1 <= 0xef) {
254
+ if (i + 2 >= arr.length) {
255
+ emitReplacement()
256
+ continue
257
+ }
258
+ const byte2 = arr[i + 1]
259
+ const byte3 = arr[i + 2]
260
+ if ((byte2 & 0xc0) !== 0x80 || (byte3 & 0xc0) !== 0x80) {
261
+ emitReplacement()
262
+ i += 2
263
+ continue
264
+ }
271
265
  const codePoint =
272
- ((byte & 0x0f) << 12) | ((byte2 & 0x3f) << 6) | (byte3 & 0x3f)
266
+ ((byte1 & 0x0f) << 12) |
267
+ ((byte2 & 0x3f) << 6) |
268
+ (byte3 & 0x3f)
269
+
273
270
  result += String.fromCharCode(codePoint)
271
+ i += 2
274
272
  continue
275
273
  }
276
-
277
- // 4-byte sequence (11110xxx 10xxxxxx 10xxxxxx 10xxxxxx)
278
- if (byte >= 0xf0 && byte <= 0xf7) {
279
- const avail = arr.length - (i + 1)
280
- const byte2 = avail >= 1 ? arr[i + 1] : 0
281
- const byte3 = avail >= 2 ? arr[i + 2] : 0
282
- const byte4 = avail >= 3 ? arr[i + 3] : 0
283
- skip = Math.min(3, avail)
284
-
274
+ if (byte1 >= 0xf0 && byte1 <= 0xf7) {
275
+ if (i + 3 >= arr.length) {
276
+ emitReplacement()
277
+ continue
278
+ }
279
+ const byte2 = arr[i + 1]
280
+ const byte3 = arr[i + 2]
281
+ const byte4 = arr[i + 3]
282
+ if (
283
+ (byte2 & 0xc0) !== 0x80 ||
284
+ (byte3 & 0xc0) !== 0x80 ||
285
+ (byte4 & 0xc0) !== 0x80
286
+ ) {
287
+ emitReplacement()
288
+ i += 3
289
+ continue
290
+ }
285
291
  const codePoint =
286
- ((byte & 0x07) << 18) |
292
+ ((byte1 & 0x07) << 18) |
287
293
  ((byte2 & 0x3f) << 12) |
288
294
  ((byte3 & 0x3f) << 6) |
289
295
  (byte4 & 0x3f)
290
-
291
- // Convert to UTF-16 surrogate pair
292
- const surrogate1 = 0xd800 + ((codePoint - 0x10000) >> 10)
293
- const surrogate2 = 0xdc00 + ((codePoint - 0x10000) & 0x3ff)
294
- result += String.fromCharCode(surrogate1, surrogate2)
296
+ const offset = codePoint - 0x10000
297
+ const highSurrogate = 0xd800 + (offset >> 10)
298
+ const lowSurrogate = 0xdc00 + (offset & 0x3ff)
299
+ result += String.fromCharCode(highSurrogate, lowSurrogate)
300
+ i += 3
295
301
  continue
296
302
  }
303
+ emitReplacement()
297
304
  }
298
-
299
305
  return result
300
306
  }
301
307
 
@@ -286,7 +286,7 @@ describe('Script', () => {
286
286
  }
287
287
 
288
288
  // Expect the function to throw an error with the specified message
289
- expect(createScript).toThrow('invalid hex string in script')
289
+ expect(createScript).toThrow('Invalid hex string')
290
290
  })
291
291
 
292
292
  it('should parse this long PUSHDATA1 script in ASM', () => {