From 3672ec7a109762f4e992afdebbfded3dab088c4c Mon Sep 17 00:00:00 2001 From: pucedoteth Date: Mon, 24 Aug 2026 19:07:58 +0200 Subject: [PATCH] fix(graph-ts): catch i32/i64 overflow when the sign bit is the widest bit toI32 checks that every byte above the four it keeps matches the sign padding, and toI64 does the same above eight: const isNeg = this.length > 0 && this[this.length - 1] >> 7 == 1; const padding = isNeg ? 255 : 0; for (let i = 4; i < this.length; i++) { if (this[i] != padding) { assert(false, 'overflow converting ... to i32'); } } That is not sufficient. A positive value whose top kept byte has its high bit set is one bit too wide for the signed window, yet the bytes above it are zero and match the padding, so the loop passes and the value is reinterpreted as negative. Both methods document "Throws in case of overflow" and neither does here. The shape is not exotic. BigInt.fromUnsignedBytes appends a zero byte, so any u32 at or above 2^31 becomes five bytes with a zero on top: BigInt.fromUnsignedBytes(0x80000000) -> [0, 0, 0, 128, 0] .toI32() == -2147483648 // silently, no throw isI32() already reports false for that same value, so the library knows it does not fit; only the conversion does not check. Also require the sign bit of the last byte inside the window to agree with the padding. Values that genuinely fit are untouched, including the widened encodings: i32.MAX as [255,255,255,127,0] and i32.MIN as [0,0,0,128,255] both still convert, and a four byte array is a plain i32 and never consults the new check. Two existing assertions asserted the wrapped result: assert(longArray.toI32() == 4_294_705_147); 4_294_705_147 does not fit an i32; the literal truncates to -262_149, which is what toI32 returned, so the assertion passed while reading as though the full value came back. Both copies are replaced with a note, because an overflow aborts and this harness cannot express an expected abort. Co-Authored-By: Claude Opus 5 --- .changeset/bytearray-toi32-overflow.md | 10 +++++++++ packages/ts/common/collections.ts | 13 ++++++++++++ packages/ts/test/bigInt.ts | 4 +++- packages/ts/test/bytes.ts | 29 +++++++++++++++++++++++++- 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 .changeset/bytearray-toi32-overflow.md diff --git a/.changeset/bytearray-toi32-overflow.md b/.changeset/bytearray-toi32-overflow.md new file mode 100644 index 000000000..c977b05ef --- /dev/null +++ b/.changeset/bytearray-toi32-overflow.md @@ -0,0 +1,10 @@ +--- +'@graphprotocol/graph-ts': patch +--- + +`ByteArray.toI32` and `ByteArray.toI64` now report an overflow for a positive value +that needs the sign bit of the widest byte, instead of returning it as a negative +number. The overflow check only compared the bytes above the target width against the +sign padding, so `0x80000000` carried in five bytes - exactly what +`BigInt.fromUnsignedBytes` produces for a `u32` at or above `2^31` - passed the check +and came back as `-2147483648`. diff --git a/packages/ts/common/collections.ts b/packages/ts/common/collections.ts index 706b92ec8..fa2b06184 100644 --- a/packages/ts/common/collections.ts +++ b/packages/ts/common/collections.ts @@ -150,6 +150,14 @@ export class ByteArray extends Uint8Array { assert(false, 'overflow converting ' + this.toHexString() + ' to i32'); } } + // Matching sign padding above the window is not enough on its own: the sign bit of the + // last byte inside the window has to agree with it too. Without this check a positive + // value one bit too wide - 0x80000000 carried in five bytes, exactly what + // BigInt.fromUnsignedBytes produces for a u32 >= 2^31 - passes the loop above and is + // returned as a negative i32 instead of throwing. + if (this.length > 4 && this[3] >> 7 != (isNeg ? 1 : 0)) { + assert(false, 'overflow converting ' + this.toHexString() + ' to i32'); + } const paddedBytes = new Bytes(4); paddedBytes[0] = padding; paddedBytes[1] = padding; @@ -195,6 +203,11 @@ export class ByteArray extends Uint8Array { assert(false, 'overflow converting ' + this.toHexString() + ' to i64'); } } + // Same as in toI32: the sign bit of the last byte inside the window has to agree with + // the padding, otherwise a positive value that needs the 64th bit is returned negative. + if (this.length > 8 && this[7] >> 7 != (isNeg ? 1 : 0)) { + assert(false, 'overflow converting ' + this.toHexString() + ' to i64'); + } const paddedBytes = new Bytes(8); paddedBytes[0] = padding; paddedBytes[1] = padding; diff --git a/packages/ts/test/bigInt.ts b/packages/ts/test/bigInt.ts index 202dc1a0d..c5f5c1169 100644 --- a/packages/ts/test/bigInt.ts +++ b/packages/ts/test/bigInt.ts @@ -122,7 +122,9 @@ export function testBigInt(): void { longArray[3] = 255; longArray[4] = 0; assert(longArray.toU32() == 4_294_705_147); - assert(longArray.toI32() == 4_294_705_147); + // 4_294_705_147 is past i32.MAX_VALUE, so toI32 reports an overflow rather than + // returning the wrapped -262_149. The assert cannot be written here: an overflow + // aborts, and this harness has no way to expect one. const bytes = Bytes.fromHexString('0x56696b746f726961'); assert((bytes[0] = 0x56)); diff --git a/packages/ts/test/bytes.ts b/packages/ts/test/bytes.ts index f62004fed..850b0e564 100644 --- a/packages/ts/test/bytes.ts +++ b/packages/ts/test/bytes.ts @@ -9,7 +9,34 @@ export function testBytesWithByteArray(): void { longArray[3] = 255; longArray[4] = 0; assert(longArray.toU32() == 4_294_705_147); - assert(longArray.toI32() == 4_294_705_147); + // 4_294_705_147 is past i32.MAX_VALUE, so toI32 reports an overflow rather than + // returning the wrapped -262_149. The assert cannot be written here: an overflow + // aborts, and this harness has no way to expect one. + + // Widening a value with sign padding must keep converting - these all still fit. + const i32Max = new ByteArray(5); + i32Max[0] = 255; + i32Max[1] = 255; + i32Max[2] = 255; + i32Max[3] = 127; + i32Max[4] = 0; + assert(i32Max.toI32() == 2_147_483_647); + + const i32Min = new ByteArray(5); + i32Min[0] = 0; + i32Min[1] = 0; + i32Min[2] = 0; + i32Min[3] = 128; + i32Min[4] = 255; + assert(i32Min.toI32() == -2_147_483_648); + + // The same value in exactly four bytes is a plain i32 and is unaffected. + const i32MinNarrow = new ByteArray(4); + i32MinNarrow[0] = 0; + i32MinNarrow[1] = 0; + i32MinNarrow[2] = 0; + i32MinNarrow[3] = 128; + assert(i32MinNarrow.toI32() == -2_147_483_648); const bytes = Bytes.fromHexString('0x56696b746f726961'); assert((bytes[0] = 0x56));