Skip to content

fix(codecs-core): resolve negative toArrayBuffer offsets against the end of the buffer - #1977

Open
edycutjong wants to merge 1 commit into
anza-xyz:mainfrom
edycutjong:fix/to-array-buffer-negative-offset
Open

fix(codecs-core): resolve negative toArrayBuffer offsets against the end of the buffer#1977
edycutjong wants to merge 1 commit into
anza-xyz:mainfrom
edycutjong:fix/to-array-buffer-negative-offset

Conversation

@edycutjong

Copy link
Copy Markdown

Problem

toArrayBuffer forwards a negative offset straight to buffer.slice(), so the derived end index bytesOffset + bytesLength can cross zero. An offset of -1 with a length of 1 becomes slice(-1, 0), which returns an empty buffer rather than the final byte. Offsets whose end index stays negative, such as -3 with a length of 1, happen to return the right bytes, which is likely why this went unnoticed.

The existing it.each block does cover (-1, 1) and (-2, 1), but only asserts .not.toBe(byteArray.buffer) — it checks identity, never contents — so it passes against the broken behaviour.

Summary of Changes

The offset is now resolved against the end of the buffer before the end index is derived, the same way Array.prototype.slice does. This also subsumes the previous bytesOffset === -buffer.byteLength special case, since that offset normalises to 0, which is why the early-return condition gets simpler rather than gaining a branch.

The new tests assert the returned contents for negative offsets and check parity with native slice. Six of them fail on main and pass with this change. No internal caller is affected: of the two call sites that pass an offset, packages/fixed-points/src/codecs.ts and packages/codecs-numbers/src/utils.ts, both take it from decoder read positions, which are never negative.

Verified with the full @solana/codecs-core suite in both Node and browser environments (137 tests each), typecheck, lint, and prettier, plus the unit suites of the four downstream packages that call toArrayBuffer (codecs-numbers, fixed-points, codecs-strings, keys).

One coordination note: #1970 is open against the same function for #1959. The changes are independent — that one touches the SharedArrayBuffer branch, this one touches the return — but they overlap textually. Happy to rebase on top of it once it lands.

Fixes #1960

…end of the buffer

Passing a negative offset to `toArrayBuffer` forwarded it straight to `buffer.slice()`, so the derived end index `bytesOffset + bytesLength` could cross zero. An offset of -1 with a length of 1 became `slice(-1, 0)`, which returns an empty buffer rather than the final byte. Offsets whose end index stayed negative, such as -3 with a length of 1, happened to work, which is why the bug went unnoticed. The offset is now resolved against the end of the buffer before the end index is derived, matching `Array.prototype.slice`. The existing `it.each` cases covered these offsets but only asserted that a new buffer was returned, never its contents, so they passed against the broken behaviour; the new cases assert contents and check parity with native `slice`.

Fixes anza-xyz#1960.
@changeset-bot

changeset-bot Bot commented Aug 24, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 77aadde

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

This PR includes changesets to release 48 packages
Name Type
@solana/codecs-core Patch
@solana/accounts Patch
@solana/addresses Patch
@solana/codecs-data-structures Patch
@solana/codecs-numbers Patch
@solana/codecs-strings Patch
@solana/codecs Patch
@solana/compat Patch
@solana/fixed-points Patch
@solana/instructions Patch
@solana/keys Patch
@solana/offchain-messages Patch
@solana/options Patch
@solana/program-client-core Patch
@solana/rpc-api Patch
@solana/rpc-types Patch
@solana/signers Patch
@solana/sysvars Patch
@solana/transaction-confirmation Patch
@solana/transaction-introspection Patch
@solana/transaction-messages Patch
@solana/transactions Patch
@solana/wallet-account-signer Patch
@solana/kit Patch
@solana/plugin-interfaces Patch
@solana/instruction-plans Patch
@solana/programs Patch
@solana/rpc-graphql Patch
@solana/rpc-parsed-types Patch
@solana/rpc-subscriptions-api Patch
@solana/rpc-subscriptions Patch
@solana/rpc Patch
@solana/rpc-transformers Patch
@solana/react Patch
@solana/assertions Patch
@solana/errors Patch
@solana/fast-stable-stringify Patch
@solana/functional Patch
@solana/nominal-types Patch
@solana/plugin-core Patch
@solana/promises Patch
@solana/rpc-spec-types Patch
@solana/rpc-spec Patch
@solana/rpc-subscriptions-channel-websocket Patch
@solana/rpc-subscriptions-spec Patch
@solana/rpc-transport-http Patch
@solana/subscribable Patch
@solana/webcrypto-ed25519-polyfill 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

@mcintyre94

Copy link
Copy Markdown
Member

@trevor-cortex

@bundlemon

bundlemon Bot commented Aug 27, 2026

Copy link
Copy Markdown

BundleMon

Files updated (8)
Status Path Size Limits
codecs-core/dist/index.native.mjs
3.65KB (+24B +0.65%) -
codecs-core/dist/index.node.mjs
3.65KB (+24B +0.65%) -
codecs-core/dist/index.browser.mjs
3.65KB (+23B +0.62%) -
codecs-strings/dist/index.browser.mjs
2.54KB (-95B -3.52%) -
@solana/kit production bundle
kit/dist/index.production.min.js
56KB (-182B -0.32%) -
instruction-plans/dist/index.browser.mjs
7.06KB (-280B -3.73%) -
instruction-plans/dist/index.node.mjs
7.06KB (-280B -3.73%) -
instruction-plans/dist/index.native.mjs
7.06KB (-281B -3.74%) -
Unchanged files (142)
Status Path Size Limits
errors/dist/index.node.mjs
21.85KB -
errors/dist/index.browser.mjs
21.82KB -
errors/dist/index.native.mjs
21.82KB -
rpc-graphql/dist/index.browser.mjs
18.87KB -
rpc-graphql/dist/index.native.mjs
18.87KB -
rpc-graphql/dist/index.node.mjs
18.86KB -
wallet-account-signer/dist/index.node.mjs
18.53KB -
wallet-account-signer/dist/index.native.mjs
18.51KB -
wallet-account-signer/dist/index.browser.mjs
18.51KB -
transaction-messages/dist/index.browser.mjs
11.34KB -
transaction-messages/dist/index.native.mjs
11.34KB -
transaction-messages/dist/index.node.mjs
11.34KB -
react/dist/index.browser.mjs
5.32KB -
react/dist/index.native.mjs
5.32KB -
react/dist/index.node.mjs
5.32KB -
codecs-data-structures/dist/index.browser.mjs
5.29KB -
codecs-data-structures/dist/index.native.mjs
5.29KB -
codecs-data-structures/dist/index.node.mjs
5.29KB -
offchain-messages/dist/index.browser.mjs
5.25KB -
offchain-messages/dist/index.native.mjs
5.24KB -
offchain-messages/dist/index.node.mjs
5.24KB -
fixed-points/dist/index.browser.mjs
5.08KB -
fixed-points/dist/index.native.mjs
5.07KB -
fixed-points/dist/index.node.mjs
5.07KB -
kit/dist/index.browser.mjs
4.69KB -
kit/dist/index.native.mjs
4.69KB -
kit/dist/index.node.mjs
4.69KB -
transactions/dist/index.browser.mjs
4.05KB -
transactions/dist/index.native.mjs
4.05KB -
transactions/dist/index.node.mjs
4.05KB -
webcrypto-ed25519-polyfill/dist/index.node.mj
s
3.61KB -
webcrypto-ed25519-polyfill/dist/index.browser
.mjs
3.59KB -
webcrypto-ed25519-polyfill/dist/index.native.
mjs
3.57KB -
rpc-subscriptions/dist/index.browser.mjs
3.37KB -
rpc-subscriptions/dist/index.node.mjs
3.34KB -
rpc-subscriptions/dist/index.native.mjs
3.31KB -
signers/dist/index.browser.mjs
3.26KB -
signers/dist/index.native.mjs
3.26KB -
signers/dist/index.node.mjs
3.26KB -
subscribable/dist/index.node.mjs
3.13KB -
rpc-transformers/dist/index.browser.mjs
3.1KB -
rpc-transformers/dist/index.native.mjs
3.1KB -
rpc-transformers/dist/index.node.mjs
3.1KB -
keys/dist/index.node.mjs
3.06KB -
subscribable/dist/index.native.mjs
3.06KB -
subscribable/dist/index.browser.mjs
3.05KB -
addresses/dist/index.browser.mjs
2.93KB -
addresses/dist/index.native.mjs
2.92KB -
addresses/dist/index.node.mjs
2.92KB -
keys/dist/index.browser.mjs
2.85KB -
keys/dist/index.native.mjs
2.85KB -
transaction-introspection/dist/index.browser.
mjs
2.73KB -
transaction-introspection/dist/index.native.m
js
2.73KB -
transaction-introspection/dist/index.node.mjs
2.73KB -
codecs-strings/dist/index.node.mjs
2.5KB -
codecs-strings/dist/index.native.mjs
2.47KB -
transaction-confirmation/dist/index.node.mjs
2.42KB -
transaction-confirmation/dist/index.native.mj
s
2.37KB -
sysvars/dist/index.browser.mjs
2.37KB -
sysvars/dist/index.native.mjs
2.37KB -
transaction-confirmation/dist/index.browser.m
js
2.37KB -
sysvars/dist/index.node.mjs
2.37KB -
rpc-subscriptions-spec/dist/index.node.mjs
2.33KB -
rpc-subscriptions-spec/dist/index.native.mjs
2.29KB -
rpc-subscriptions-spec/dist/index.browser.mjs
2.29KB -
rpc/dist/index.node.mjs
1.95KB -
codecs-numbers/dist/index.browser.mjs
1.95KB -
codecs-numbers/dist/index.native.mjs
1.95KB -
codecs-numbers/dist/index.node.mjs
1.94KB -
rpc-types/dist/index.browser.mjs
1.9KB -
rpc-types/dist/index.native.mjs
1.9KB -
rpc-types/dist/index.node.mjs
1.9KB -
rpc-transport-http/dist/index.browser.mjs
1.89KB -
rpc-transport-http/dist/index.native.mjs
1.89KB -
rpc/dist/index.native.mjs
1.81KB -
rpc/dist/index.browser.mjs
1.8KB -
rpc-transport-http/dist/index.node.mjs
1.71KB -
rpc-spec-types/dist/index.browser.mjs
1.54KB -
rpc-spec-types/dist/index.native.mjs
1.54KB -
rpc-spec-types/dist/index.node.mjs
1.54KB -
rpc-subscriptions-channel-websocket/dist/inde
x.node.mjs
1.33KB -
rpc-subscriptions-channel-websocket/dist/inde
x.native.mjs
1.27KB -
rpc-subscriptions-channel-websocket/dist/inde
x.browser.mjs
1.26KB -
program-client-core/dist/index.browser.mjs
1.21KB -
program-client-core/dist/index.native.mjs
1.21KB -
program-client-core/dist/index.node.mjs
1.21KB -
plugin-core/dist/index.browser.mjs
1.18KB -
plugin-core/dist/index.native.mjs
1.18KB -
plugin-core/dist/index.node.mjs
1.18KB -
options/dist/index.browser.mjs
1.18KB -
options/dist/index.native.mjs
1.18KB -
options/dist/index.node.mjs
1.17KB -
accounts/dist/index.browser.mjs
1.17KB -
accounts/dist/index.native.mjs
1.17KB -
accounts/dist/index.node.mjs
1.16KB -
rpc-api/dist/index.browser.mjs
1.04KB -
rpc-api/dist/index.native.mjs
1.04KB -
rpc-api/dist/index.node.mjs
1.04KB -
compat/dist/index.browser.mjs
969B -
compat/dist/index.native.mjs
968B -
compat/dist/index.node.mjs
966B -
rpc-spec/dist/index.browser.mjs
928B -
rpc-spec/dist/index.native.mjs
928B -
rpc-spec/dist/index.node.mjs
926B -
promises/dist/index.native.mjs
841B -
promises/dist/index.node.mjs
840B -
promises/dist/index.browser.mjs
839B -
rpc-subscriptions-api/dist/index.browser.mjs
810B -
rpc-subscriptions-api/dist/index.native.mjs
808B -
rpc-subscriptions-api/dist/index.node.mjs
807B -
assertions/dist/index.browser.mjs
783B -
instructions/dist/index.browser.mjs
771B -
instructions/dist/index.native.mjs
770B -
instructions/dist/index.node.mjs
768B -
fast-stable-stringify/dist/index.browser.mjs
726B -
fast-stable-stringify/dist/index.native.mjs
725B -
assertions/dist/index.native.mjs
724B -
fast-stable-stringify/dist/index.node.mjs
724B -
assertions/dist/index.node.mjs
723B -
programs/dist/index.browser.mjs
329B -
programs/dist/index.native.mjs
327B -
programs/dist/index.node.mjs
325B -
fs-impl/dist/index.browser.mjs
245B -
event-target-impl/dist/index.node.mjs
230B -
functional/dist/index.browser.mjs
154B -
functional/dist/index.native.mjs
152B -
text-encoding-impl/dist/index.native.mjs
152B -
functional/dist/index.node.mjs
151B -
codecs/dist/index.browser.mjs
145B -
codecs/dist/index.native.mjs
144B -
codecs/dist/index.node.mjs
142B -
event-target-impl/dist/index.browser.mjs
133B -
ws-impl/dist/index.node.mjs
131B -
text-encoding-impl/dist/index.browser.mjs
122B -
fs-impl/dist/index.node.mjs
120B -
text-encoding-impl/dist/index.node.mjs
119B -
ws-impl/dist/index.browser.mjs
113B -
crypto-impl/dist/index.node.mjs
111B -
crypto-impl/dist/index.browser.mjs
109B -
rpc-parsed-types/dist/index.browser.mjs
66B -
rpc-parsed-types/dist/index.native.mjs
65B -
rpc-parsed-types/dist/index.node.mjs
63B -

Total files change -1015B -0.18%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

@trevor-cortex trevor-cortex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Submitting as COMMENT since I don't have approve permissions on this repo — this would be an approval.)

This fixes a real bug: toArrayBuffer forwarded negative offsets straight to buffer.slice(), so the derived end index bytesOffset + bytesLength could cross zero and yield an empty buffer (e.g. (-1, 1)slice(-1, 0)). The offset is now normalized against the end of the buffer before the end index is derived, matching Array.prototype.slice semantics, and the old bytesOffset === -buffer.byteLength special case correctly collapses into the simpler startIndex === 0 check.

I verified the arithmetic against native slice for the covered cases, including clamping (-10 on a 5-byte buffer → start 0) and the previously-broken omitted-length path ((bytes, -2) used to produce slice(-2, 3) → empty). The new content-asserting tests close the gap left by the old identity-only assertions, and the changeset (patch bump) is appropriate for a bug fix.

Two non-blocking observations, both inline:

  1. Views with non-zero byteOffset: because bytesOffset = bytes.byteOffset + offset, a negative user offset on a subarray view can flip positive and be treated as buffer-absolute, so the slice-like semantics only strictly hold when bytes.byteOffset === 0. Internal callers never pass negative offsets, so this is academic, but worth a doc note at some point.
  2. Identity shortcut broadening: offsets ≤ -byteLength with a full-buffer length now hit the identity return (same buffer object) where the old code returned an empty copy. Correct per slice semantics, but the identity aspect isn't covered by a test.

For subsequent reviewers: the coordination note about #1970 checks out — that PR touches the SharedArrayBuffer copy branch while this one touches the offset resolution and return, so they're semantically independent but will conflict textually; whichever lands second needs a trivial rebase. Also note that in the SAB path, buffer.byteLength is the length of the copied view rather than the original buffer, so how negative offsets resolve there for offset views is tied to the #1959 discussion, not this change.

Comment on lines +27 to +28
const startIndex =
bytesOffset < 0 ? Math.max(buffer.byteLength + bytesOffset, 0) : Math.min(bytesOffset, buffer.byteLength);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: since bytesOffset = bytes.byteOffset + (offset ?? 0), a negative user offset on a view with non-zero byteOffset can end up positive and take the Math.min branch, where it's treated as an absolute index into the underlying buffer rather than being resolved against the end. So the Array.prototype.slice analogy in the comment strictly holds only when bytes.byteOffset === 0. All internal callers pass non-negative offsets, so nothing is broken — but a short JSDoc note on the offset parameter documenting that negative offsets resolve against the underlying buffer (not the view) would prevent surprises for external callers.

[-3, 1, [3]],
[-3, 3, [3, 4, 5]],
[-5, 2, [1, 2]],
[-10, 2, [1, 2]],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The (-10, 2) case nicely covers clamping for contents, but there's a related behavior change that isn't locked down: with the new normalization, an offset ≤ -byteLength combined with a full-buffer length (e.g. (-10, 5) here) now hits the identity shortcut and returns the same buffer object, where the old code returned an empty copy. That's correct per slice semantics, but consider adding a case like [-10, 3] to the earlier returns the buffer without modification it.each (around line 14, outside this diff — hence anchoring here) to assert the identity return for over-shooting negative offsets.

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.

toArrayBuffer: Negative-offset semantics are confusing/probably wrong

3 participants