Skip to content

fix(graph-ts): catch i32/i64 overflow when the sign bit is the widest bit - #2155

Open
pucedoteth wants to merge 1 commit into
graphprotocol:mainfrom
pucedoteth:fix-bytearray-toi32-overflow
Open

fix(graph-ts): catch i32/i64 overflow when the sign bit is the widest bit#2155
pucedoteth wants to merge 1 commit into
graphprotocol:mainfrom
pucedoteth:fix-bytearray-toi32-overflow

Conversation

@pucedoteth

Copy link
Copy Markdown

The bug

ByteArray.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 ' + this.toHexString() + ' 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 — but the bytes above it are zero and match the padding, so the loop passes and the value comes back 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 lands in exactly this state.

BigInt.fromUnsignedBytes(0x80000000)  ->  [0, 0, 0, 128, 0]
  .toI32()  ==  -2147483648        // silently, no throw
  .isI32()  ==  false              // the library already knows it does not fit

For a subgraph that means a large uint32 — a counter, a packed field, a timestamp — is written to the store as a negative number with nothing raised. isI32() reports false for the same value, so the range check exists; only the conversion skips it.

The fix

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:

bytes (LE) value before after
[255,255,255,127] 2147483647 2147483647 2147483647
[0,0,0,128] −2147483648 −2147483648 −2147483648
[255,255,255,127,0] 2147483647 widened 2147483647 2147483647
[0,0,0,128,255] −2147483648 widened −2147483648 −2147483648
[0,0,0,128,0] 2147483648 −2147483648 overflow
[251,255,251,255,0] 4294705147 −262149 overflow

A four-byte array is a plain i32 and never consults the new check, so the common path is unchanged. toI64 gets the same treatment at byte 7.

This is a behavior change — worth your call

Values that silently wrapped now abort the mapping. I think that is right: it is the documented contract, toU32 already throws for its own range, and a negative count in the store is worse than a loud failure. But it will stop a subgraph that is currently indexing wrapped values, so it is your call whether this rides a patch or waits for a major. Happy to gate it or downgrade it to a log.warning instead if you would rather not throw.

About the tests

Two existing assertions asserted the wrapped result, in test/bytes.ts and again in test/bigInt.ts:

assert(longArray.toI32() == 4_294_705_147);

4_294_705_147 does not fit an i32; the literal truncates to -262_149, which is exactly what toI32 returned — so the assertion passed while reading as though the full value came back. Both copies are replaced with a note explaining why the value now overflows.

I could not add a positive "it throws" test: an overflow aborts, and this harness has no way to expect an abort. Worth knowing that this is pre-existing — I checked the already-shipped overflow path (a byte above the window that does not match the padding) on unmodified code and it surfaces the same way, as RuntimeError: memory access out of bounds, because the assert message calls toHexString() and the harness stubs that host function to a no-op. My change behaves identically to the existing overflow path.

What I did verify, by running it:

  • every row in the table above, through both ByteArray and BigInt
  • the overflow rows abort with the fix and return the wrapped value without it
  • pnpm run test:ts — full suite green
  • pnpm run lint — prettier and eslint clean

Changeset included.


🤖 Written with Claude Code. All results above are from a local run.

… 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 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3672ec7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@graphprotocol/graph-ts Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant