Skip to content

validate loaded accounts data size limit in transaction messages - #2005

Open
skyyycodes wants to merge 5 commits into
anza-xyz:mainfrom
skyyycodes:validate-loaded-accounts-data-size-limit
Open

validate loaded accounts data size limit in transaction messages#2005
skyyycodes wants to merge 5 commits into
anza-xyz:mainfrom
skyyycodes:validate-loaded-accounts-data-size-limit

Conversation

@skyyycodes

Copy link
Copy Markdown
Contributor

Problem

Follow up to #2004. Same class of thing as #1961 / #1962 which #1972 handled for heap size and compute units, except this one has a wrinkle.

The loaded accounts data size limit has runtime bounds and Kit doesn't check them. Anything you hand to setTransactionMessageLoadedAccountsDataSizeLimit goes straight through and you find out at simulate/send time. The runtime takes an integer in [1, 64 * 1024 * 1024]. Unlike heap it doesn't have to be a multiple of anything, and unlike the other two fields 0 is not a legal value.

Summary of Changes

Added assertIsValidLoadedAccountsDataSizeLimit to resource-limit-validation.ts plus one more error code, and called it from setTransactionMessageLoadedAccountsDataSizeLimit and setTransactionMessageConfig. Sits above the version switch like the other two so legacy and v0 get it as well, the limit applies whether the value travels as a compute budget instruction or a v1 config.

I went and read agave for the exact behaviour this time instead of going off the issue text, since I got that wrong on the last PR. sanitize_and_convert_to_compute_budget_limits does NonZeroU32::new(requested).ok_or(TransactionError::InvalidLoadedAccountsDataSizeLimit)? and then .min(MAX_LOADED_ACCOUNTS_DATA_SIZE_BYTES), so zero genuinely errors and over-max genuinely clamps, exactly as you described. There's an agave test asserting the zero case too. The stronger thing is that SVMTransactionExecutionAndFeeBudgetLimits.loaded_accounts_data_size_limit is itself a NonZeroU32, so zero isn't representable in the budget at all no matter which path it arrives by.

One thing I couldn't pin down, and I'd rather ask than guess: sanitize_config in agave-sdk transaction-view only checks heap size, it doesn't look at the loaded accounts data size limit. So for a v1 transaction specifically I couldn't find the code that rejects the zero. The NonZeroU32 means the semantics aren't in question, but the docblock currently links the compute budget instruction path and I'd like to link the right thing. If you know where a v1 config gets converted into budget limits, point me at it and I'll fix the link.

The provisory limit

This is the bit the issue actually asks about. I went with 1.

It's the smallest legal value, so nobody sets it deliberately and it works as a sentinel. The obvious alternative is 64 MiB, since that's what agave falls back to when the field is absent, but estimateAndSetResourceLimits already documents that an explicitly set maximum is left alone on purpose, so using the max as the sentinel would collide with behaviour that's already there. The field is a fixed width u32 so 0 and 1 encode to the same bytes either way and the space reserved for the eventual estimate doesn't change. I checked that rather than assuming it, a v1 message compiles to the same 82 message bytes / 196 wire bytes with either value. Compute unit limit still uses 0, that's still legal.

There's a subtle thing that fell out of moving the sentinel and I want to flag it because it was green before I caught it. estimateAndSetResourceLimits decides whether to re-estimate by comparing the existing value against the provisory one. If it only compares against the new 1, then a message still carrying 0 — built by the current version of Kit, or decoded from a transaction that was — suddenly reads as an explicit deliberate choice and gets preserved. You'd then compile and send a transaction the runtime rejects outright, with nothing failing locally. Which is the exact failure this issue exists to prevent. So 0 is still treated as provisory alongside 1. Zero is never a legal limit, so it can't be someone's deliberate choice.

Happy to change the sentinel if you'd rather it were something else, it's a one line change plus a couple of test values.

Decoding is unaffected, same as last time. I extended the existing decompile test so a v1 message carrying a 0 still decodes rather than throwing.

Testing

Everything green on node and browser.

  • @solana/transaction-messages 1397 unit tests, up from 1380
  • @solana/kit 165, up from 160
  • @solana/errors 263
  • 327 turbo tasks across errors transaction-messages transactions signers instruction-plans transaction-introspection rpc and kit, typecheck lint prettier and treeshakability included
  • typedoc 0 errors, and I diffed the warning counts against a clean main to confirm I wasn't adding any

Beyond the usual boundary and NaN / Infinity / non-integer cases across all three versions, three things I added that felt worth it:

A differential sweep of the setter over ~23 adversarial inputs against both encodings, to confirm the accept boundary is exactly the integers in [1, 64 MiB] on the v1 config path and the legacy instruction path alike, and that they reject identically. Worth noting the raw u32 encoder is perfectly happy with 0 and 2^32-1, so this validation is the only thing constraining it. Non numbers coming from untyped callers (null, "60000", 60000n) fail at the setter rather than reaching the encoder, only undefined clears.

A full wire round trip, message through compile, encode, decode and decompile, asserting the limit comes back unchanged at both ends of the range and that the encoded size is constant across it. That's the property the provisory placeholder actually depends on, so it seemed better to assert it than to claim it in the changeset.

And the workflow itself, fillTransactionMessageProvisoryResourceLimits followed by estimateAndSetResourceLimits. Both halves were tested separately but never composed, which is exactly the seam the sentinel bug above was hiding in.

I also pinned the behaviour where a value from a custom estimator wrapper lands out of range.

Left out

The estimateResourceLimits u32 cap I mentioned on #1972 is still there, and it's slightly more live now. The docs invite custom estimator wrappers that buffer the returned values, and a wrapper doing * 1.1 will now throw at the setter where it used to truncate at encode time. I added tests pinning that rather than changing it, since failing locally seems better than silently sending something the runtime rejects. If you'd rather that specific path clamped instead of threw, say the word and I'll do it here or in a follow up.

Fixes

closes #2004

The runtime accepts an integer in [1, 64 MiB]. Zero is rejected outright, and a
value above the maximum is silently clamped down, so the transaction runs against
a budget other than the one requested. Neither showed up until simulate or send.

setTransactionMessageLoadedAccountsDataSizeLimit and setTransactionMessageConfig
now throw at the point the value is set. Because zero is no longer a legal limit,
the provisory limit that fillTransactionMessageProvisoryResourceLimits writes for
version 1 messages moves from 0 to 1; the value occupies a fixed-width u32 either
way, so the space reserved for the eventual estimate is unchanged.
Verified both halves of the claim against the Agave sanitizer rather than
restating the issue: zero hits NonZeroU32::new(..).ok_or(InvalidLoadedAccounts-
DataSizeLimit)?, and the top end is .min(MAX_LOADED_ACCOUNTS_DATA_SIZE_BYTES),
so it clamps rather than fails. Link the expression that does both.

Also call out in the changeset the break a consumer is most likely to hit: a
buffered estimate that is no longer an integer, and passing 0 to mean "no
limit".
The v1 sanitize_config in agave-sdk transaction-view checks heap size only, so
the compute-budget-instruction error is not necessarily the operative path for a
v1 transaction config. What does hold for every path is the budget type itself:
loaded_accounts_data_size_limit is a NonZeroU32, so zero cannot be honored
regardless of how it arrives. Cite both rather than generalising the one error.
Moving the v1 provisory value from 0 to 1 silently changed how estimateAndSet-
ResourceLimits reads a message that still carries 0. It compared only against
the new sentinel, so 0 started reading as an explicit, deliberate choice and was
preserved rather than replaced. A message built by an earlier version of this
package, or decoded from a transaction that was, would have been compiled and
sent with a limit the runtime rejects outright, with nothing failing locally.

Zero is never a legal limit, so it cannot be a deliberate choice. Treat it as
provisory alongside 1.

Also adds tests for behaviour that was agreed in review but never pinned: a
decoded config carrying an illegal value stays editable field by field, the
accepted range round trips through the config codec at a constant four bytes,
and an out-of-range value from a custom estimator wrapper surfaces as a
validation error rather than being encoded.
Adds tests from a differential sweep of the setter against both encodings.
The accept boundary is exactly the integers in [1, 64 MiB] on the v1 config path
and the legacy compute budget instruction path alike, and every accepted value
survives compile, encode, decode and decompile unchanged while encoding to a
constant size — which is what lets the provisory placeholder reserve the space
its estimate will need. Non-numbers reaching the setter from untyped callers
fail there rather than at the encoder; only undefined clears the limit.

Also covers the documented workflow end to end: limits written by fill-
TransactionMessageProvisoryResourceLimits are the ones estimateAndSetResource-
Limits replaces.
@changeset-bot

changeset-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddb8427

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

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

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

@bundlemon

bundlemon Bot commented Aug 28, 2026

Copy link
Copy Markdown

BundleMon

Files updated (13)
Status Path Size Limits
transaction-messages/dist/index.node.mjs
11.93KB (+97B +0.8%) -
transaction-messages/dist/index.browser.mjs
11.93KB (+96B +0.79%) -
transaction-messages/dist/index.native.mjs
11.93KB (+96B +0.79%) -
@solana/kit production bundle
kit/dist/index.production.min.js
56.44KB (+78B +0.14%) -
errors/dist/index.browser.mjs
22KB (+45B +0.2%) -
errors/dist/index.native.mjs
21.99KB (+45B +0.2%) -
errors/dist/index.node.mjs
22.02KB (+45B +0.2%) -
wallet-account-signer/dist/index.node.mjs
18.69KB (+38B +0.2%) -
wallet-account-signer/dist/index.browser.mjs
18.67KB (+36B +0.19%) -
wallet-account-signer/dist/index.native.mjs
18.67KB (+36B +0.19%) -
kit/dist/index.node.mjs
4.72KB (+28B +0.58%) -
kit/dist/index.browser.mjs
4.72KB (+27B +0.56%) -
kit/dist/index.native.mjs
4.72KB (+27B +0.56%) -
Unchanged files (137)
Status Path Size Limits
rpc-graphql/dist/index.browser.mjs
18.87KB -
rpc-graphql/dist/index.native.mjs
18.87KB -
rpc-graphql/dist/index.node.mjs
18.86KB -
instruction-plans/dist/index.browser.mjs
7.33KB -
instruction-plans/dist/index.native.mjs
7.33KB -
instruction-plans/dist/index.node.mjs
7.33KB -
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 -
transactions/dist/index.browser.mjs
4.05KB -
transactions/dist/index.native.mjs
4.05KB -
transactions/dist/index.node.mjs
4.05KB -
codecs-core/dist/index.browser.mjs
3.63KB -
codecs-core/dist/index.native.mjs
3.63KB -
codecs-core/dist/index.node.mjs
3.62KB -
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.browser.mjs
2.64KB -
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 -
sysvars/dist/index.node.mjs
2.37KB -
transaction-confirmation/dist/index.browser.m
js
2.36KB -
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 -
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/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.8KB -
rpc/dist/index.browser.mjs
1.79KB -
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 +694B +0.12%

Final result: ✅

View report in BundleMon website ➡️


Current branch size history | Target branch size history

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.

Transaction messages: validate loaded accounts data size limit config

1 participant