diff --git a/.changeset/tidy-vlq-uints.md b/.changeset/tidy-vlq-uints.md new file mode 100644 index 00000000..58357364 --- /dev/null +++ b/.changeset/tidy-vlq-uints.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/serializer": patch +--- + +Fix VLQ encoding and decoding of unsigned 32-bit integers at and above 2^31. `writeVLQ` and `readVLQ` used JS bitwise operators, which truncate to 32 bits: `writeUInt(2147483648)` serialized as `0`, `writeUInt(4294967295)` as `127`, and the reference encoding of `4294967295` decoded as `-1`. Both now use arithmetic that covers the full unsigned 32-bit range, and values outside it throw a `RangeError`, matching the reference implementation's `putUInt`/`getUInt` bounds. This backs `SigmaByteWriter.writeUInt` / `SigmaByteReader.readUInt`, so box creation heights, ErgoTree sizes, and length prefixes in that range no longer corrupt silently. diff --git a/packages/serializer/src/coders/numRanges.ts b/packages/serializer/src/coders/numRanges.ts index 1400dbe2..ae25c6c5 100644 --- a/packages/serializer/src/coders/numRanges.ts +++ b/packages/serializer/src/coders/numRanges.ts @@ -1,6 +1,8 @@ export const MIN_U8 = 0x00; export const MAX_U8 = 0xff; +export const MAX_U32 = 0xffffffff; + export const MIN_I8 = -0x80; export const MAX_I8 = 0x7f; diff --git a/packages/serializer/src/coders/vlq.spec.ts b/packages/serializer/src/coders/vlq.spec.ts index ab094cbb..b9983eab 100644 --- a/packages/serializer/src/coders/vlq.spec.ts +++ b/packages/serializer/src/coders/vlq.spec.ts @@ -17,7 +17,10 @@ describe("VLQ encoding/decoding", () => { { uint: 2097151, bytes: Uint8Array.from([0xff, 0xff, 0x7f]) }, { uint: 2097152, bytes: Uint8Array.from([0x80, 0x80, 0x80, 0x01]) }, { uint: 268435455, bytes: Uint8Array.from([0xff, 0xff, 0xff, 0x7f]) }, - { uint: 268435456, bytes: Uint8Array.from([0x80, 0x80, 0x80, 0x80, 0x01]) } + { uint: 268435456, bytes: Uint8Array.from([0x80, 0x80, 0x80, 0x80, 0x01]) }, + { uint: 2147483647, bytes: Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0x07]) }, + { uint: 2147483648, bytes: Uint8Array.from([0x80, 0x80, 0x80, 0x80, 0x08]) }, + { uint: 4294967295, bytes: Uint8Array.from([0xff, 0xff, 0xff, 0xff, 0x0f]) } ]; function toVLQBytes(value: number) { @@ -34,10 +37,23 @@ describe("VLQ encoding/decoding", () => { }).toThrow(); }); + it("Should fail trying to encode values above the unsigned 32-bit range", () => { + expect(() => { + toVLQBytes(4294967296); + }).toThrow(RangeError); + }); + test.each(testVectors)("Should decode", (tv) => { expect(readVLQ(new SigmaByteReader(tv.bytes))).toEqual(tv.uint); }); + it("Should fail trying to decode values above the unsigned 32-bit range", () => { + // 2^32, encoded as VLQ + expect(() => { + readVLQ(new SigmaByteReader(Uint8Array.from([0x80, 0x80, 0x80, 0x80, 0x10]))); + }).toThrow(RangeError); + }); + it("Should decode empty Buffer to 0", () => { expect(readVLQ(new SigmaByteReader(Uint8Array.from([])))).toEqual(0); }); @@ -50,7 +66,7 @@ describe("VLQ encoding/decoding", () => { it("Should encode/decode radom numbers", () => { fc.assert( - fc.property(fc.integer({ min: 0, max: 100000000 }), (n) => { + fc.property(fc.integer({ min: 0, max: 4294967295 }), (n) => { expect(readVLQ(new SigmaByteReader(toVLQBytes(n)))).to.be.equal(n); }) ); diff --git a/packages/serializer/src/coders/vlq.ts b/packages/serializer/src/coders/vlq.ts index fb44fcc5..075d6867 100644 --- a/packages/serializer/src/coders/vlq.ts +++ b/packages/serializer/src/coders/vlq.ts @@ -1,4 +1,5 @@ import { _0n, _7n, _127n, _128n, ensureBigInt } from "@fleet-sdk/common"; +import { MAX_U32 } from "./numRanges"; import type { SigmaByteReader } from "./sigmaByteReader"; import type { SigmaByteWriter } from "./sigmaByteWriter"; @@ -22,11 +23,16 @@ export function writeVLQ(writer: SigmaByteWriter, value: number): SigmaByteWrite if (value < 0) { throw new RangeError("Variable Length Quantity not supported for negative numbers."); } + if (value > MAX_U32) { + throw new RangeError(`Value ${value} is out of range for an unsigned 32-bit integer`); + } + // Divide instead of shifting: JS bitwise operators truncate to 32 bits, so + // `>>` corrupts every value >= 2^31. let val = value; do { - let lower7bits = val & 0x7f; - val >>= 7; + let lower7bits = val % 128; + val = Math.floor(val / 128); if (val > 0) lower7bits |= 0x80; writer.write(lower7bits); } while (val > 0); @@ -50,10 +56,16 @@ export function readVLQ(reader: SigmaByteReader): number { do { lower7bits = reader.readByte(); - value |= (lower7bits & 0x7f) << shift; + // Multiply instead of shifting: JS bitwise operators truncate to 32 bits, + // so `<<` corrupts every value >= 2^31. + value += (lower7bits & 0x7f) * 2 ** shift; shift += 7; } while ((lower7bits & 0x80) !== 0); + if (value > MAX_U32) { + throw new RangeError(`Value ${value} is out of range for an unsigned 32-bit integer`); + } + return value; }