Repository navigation
fix(decode): support legacy Cairo 0 CallArray multicall layout - #89
Conversation
Deprecated (Cairo 0) accounts serialize `__execute__` calldata as `[n, (to, selector, data_offset, data_len) * n, calldata_len, blob...]` — fixed-size 4-felt entries indexing into one flat blob appended after the array. `parse_multicall` only understood the modern Cairo 1 `Array<Call>` layout, which inlines each call's data instead. Read as the modern layout, a legacy multicall whose first call has `data_len = 0` makes the parser mistake the following felt — the first entry's `data_len` — for the *second* call's target, producing absurd targets like `0x3`, and everything after that is garbage. Call 1 still looked right (its `to`/selector sit at the same indices in both layouts), so the misparse showed up as a truncated call list with one bogus low-integer target. That also sent pointless `get_class_hash_at` lookups for non-existent contracts. These accounts are still live and still relay INVOKE v3 transactions, so the layout is a property of the account class, not the transaction version — there is nothing in the transaction to switch on. Instead, pick whichever layout is internally self-consistent, trying the modern one first, since a false positive there mangles only the rare format while the reverse would mangle the common one. If neither validates (truncated calldata from an external indexer), fall back to the previous lenient walk so partial data still renders. The legacy check keys off an exact length equation plus `felt_to_u64_checked`, a new non-truncating converter: the old `felt_to_u64` keeps the low 64 bits, so a 251-bit address in an offset or length position could truncate to a small number and slip past a bounds check. Verified against mainnet: 314 recent INVOKE transactions all match the modern layout with zero ambiguity, and across 60 transactions from an affected legacy account the new parse reproduces every trace-derived call — target, selector and calldata — for all 53 that expose a trace.
There was a problem hiding this comment.
Pull request overview
This PR updates the calldata decoder to correctly parse Starknet account multicalls that use the legacy Cairo 0 CallArray* ((to, selector, data_offset, data_len)) layout, while preserving the existing Cairo 1 inline Array<Call> decoding behavior and maintaining a best-effort fallback for malformed/truncated inputs.
Changes:
- Add strict parsing for modern inline multicalls and legacy
CallArray*multicalls, selecting the first internally self-consistent layout. - Introduce
felt_to_u64_checkedto reject non-fitting (truncation-prone) felts in strict parsing paths. - Add regression tests covering legacy parsing, ambiguity resolution, truncation rejection, overlapping offsets, and fallback behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/utils.rs | Adds a non-truncating felt→u64 conversion helper used to harden strict parsing of lengths/offsets. |
| src/decode/functions.rs | Implements strict modern/legacy multicall parsers with a safe selection strategy, refactors RawCall construction, and adds targeted regression tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -69,14 +173,7 @@ pub fn parse_call_array( | |||
| }; | |||
| offset += data_len; | |||
|
|
|||
| calls.push(RawCall { | |||
| contract_address, | |||
| selector, | |||
| data, | |||
| function_name: None, | |||
| function_def: None, | |||
| contract_abi: None, | |||
| }); | |||
| calls.push(RawCall::new(contract_address, selector, data)); | |||
There was a problem hiding this comment.
Confirmed and fixed in 1169350.
Reproduced with [1, to, sel, 0xFFFFFFFFFFFFFFFF]:
debug → attempt to add with overflow (functions.rs:168)
release → slice index starts at 4 but ends at 3 (functions.rs:169)
The release case is the one that matters: 4 + usize::MAX wraps to 3, which passes the <= calldata.len() guard, so it slices [4..3] and panics. Now uses checked_add for the bound, and saturating_add for offset — saturating rather than wrapping matters because callers read an offset past the end as "did not consume cleanly", which a wrapped small value would hide.
Two notes for the record. This predates the CallArray work: master has the identical unchecked add, and the old parse_multicall called straight into it, so the same input panics there too. And while checking, the same pattern turned up in outside_execution.rs on an untrusted sig_len — the validation add panicked in debug and in release wrapped to a value that happened not to equal data.len(), so malformed input was rejected by luck rather than by the check. Both sites are now checked, though the extraction one was gated behind the validation and not reachable on its own.
Regression tests at all three sites, passing in debug and release, each confirmed to fail against the pre-fix code.
`parse_call_array` read `data_len` from a felt and added it to `offset`
unchecked. That felt is untrusted and can hold up to `u64::MAX`, so in
release builds the add wraps to a value *below* `offset`, slips past the
`<= calldata.len()` bounds check, and then panics slicing backwards:
debug attempt to add with overflow
release slice index starts at 4 but ends at 3
Reachable through `parse_multicall`: the strict layout parsers reject
such a felt via `felt_to_u64_checked`, so control falls through to this
lenient walk, which still uses the truncating `felt_to_u64`. Calldata
carrying it crashes the process rather than merely decoding oddly.
Use `checked_add` for the slice bound, and saturate rather than wrap when
advancing `offset` — callers read an offset past the end as "did not
consume cleanly", which a wrapped small value would hide.
`outside_execution.rs` consumed an untrusted `sig_len` the same way. The
validation add panicked in debug builds; in release it wrapped to a value
that happened not to equal `data.len()`, so the malformed input was
rejected by luck rather than by the check. The extraction add is gated
behind that validation and so was not reachable on its own, but is made
checked too rather than left depending on the gate.
Both faults predate the CallArray work — `parse_call_array` carried the
same unchecked add before it — but the fallback path this series
documents is what routes malformed calldata into them.
Regression tests cover all three sites and pass in debug and release;
each was confirmed to fail against the pre-fix code.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/decode/functions.rs:80
data_lenis treated as a strict, validated length here; usingas usizecan truncate on 32-bit targets and make the strict parser accept malformed calldata. Use a checked conversion tousizeand returnNoneif it does not fit.
let data_len = felt_to_u64_checked(&calldata[offset + 2])? as usize;
src/decode/functions.rs:111
- In the strict legacy parser,
num_callsshould also be converted tousizewith a checked conversion.as usizecan truncate on 32-bit builds, undermining the strict length equation checks.
let num_calls = felt_to_u64_checked(&calldata[0])? as usize;
src/decode/functions.rs:119
blob_lenparticipates in the strict "exact consumption" equation. Converting viaas usizecan truncate on 32-bit targets and could make invalid calldata appear self-consistent. Prefer a checked conversion tousize.
let blob_len = felt_to_u64_checked(&calldata[blob_len_idx])? as usize;
src/decode/functions.rs:129
data_offset/data_lenare treated as strict bounds-checked indices into the blob;as usizecan truncate on 32-bit targets and turn large values into plausible small ones. Use checked conversions tousize(returningNoneon overflow) before doing arithmetic/bounds checks.
let data_offset = felt_to_u64_checked(&calldata[base + 2])? as usize;
let data_len = felt_to_u64_checked(&calldata[base + 3])? as usize;
src/decode/functions.rs:69
felt_to_u64_checkedensures the felt fits in a u64, butas usizecan still silently truncate on 32-bit targets. Since this function is intended to be a strict validator, prefer a checked conversion tousize(returningNoneon overflow) rather than truncation.
This issue also appears in the following locations of the same file:
- line 80
- line 111
- line 119
- line 128
let num_calls = felt_to_u64_checked(&calldata[0])? as usize;
`felt_to_u64_checked` existed to stop a 251-bit felt truncating into a plausible small length that passes a bounds check. Every caller then wrote `? as usize`, which re-opens exactly that hole wherever `usize` is narrower than 64 bits: `0x1_0000_0003` becomes `3`, and the strict parsers' bounds and exact-consumption checks validate against the wrong number. Not reachable on any target this builds for — `usize` is 64 bits on x86_64 and aarch64, so the cast is currently lossless, and CI is x86_64 only. But the casts contradicted the validators' documented intent, and a checked narrowing costs nothing. Return `usize` directly rather than adding a second helper, so no caller has a `u64` to cast in the first place. Renamed to `felt_to_usize_checked` to match. The lenient fallback path keeps `felt_to_u64` + `as usize`: it is explicitly best-effort, and its arithmetic is already checked/saturating, so a truncated length there shortens a salvaged slice rather than overflowing. Unit tests cover both directions of the conversion, including a full-width felt whose low 64 bits are zero — where the truncating conversion reports a perfectly plausible length of 0.
|
Addressed the 5 suppressed comments from the latest review pass (all one concern: The point lands on a real inconsistency — It is not reachable on any target this builds for: Rather than convert each of the 6 sites individually, the helper now returns The lenient fallback deliberately keeps Verified the refactor did not disturb parsing: the real legacy transaction still decodes trace-exact in both debug and release, and the full suite is 276 passing. |
Problem
parse_multicallonly understood the modern Cairo 1Array<Call>calldata layout, which inlines each call's arguments:Deprecated (Cairo 0) accounts serialize
__execute__differently — fixed-size 4-felt entries that index into a single flat blob appended after the array:Read as the modern layout, a legacy multicall whose first entry has
data_offset = 0is misinterpreted as "call 1 takes zero arguments". The parser then advances one felt too few and reads the first entry'sdata_lenas the second call's target address — producing absurd targets like0x3, with everything after that shifted and the walk collapsing early.The user-visible symptom is a truncated call list whose first call looks correct (
to/selectorsit at indices 1/2 in both layouts) followed by one bogus low-integer target. It also triggered pointlessget_class_hash_at/ class-history lookups for non-existent contracts.Approach
These accounts are still live and still relay INVOKE v3 transactions, so the layout is a property of the account class, not of the transaction version — there is nothing in the transaction to switch on, and no version gate would work. Resolving it via a class lookup would make a hot, synchronous parser async.
Instead
parse_multicallpicks whichever layout is internally self-consistent:ncalls present and consuming the array exactly, nothing trailing.calldata_lenfelt whose value makes the trailing blob fill the array precisely.Modern is tried first on risk asymmetry: a false positive there mangles only the rare format, whereas a false positive on the legacy reading would mangle the common one.
Two details worth calling out:
felt_to_usize_checked(new, inutils): the existingfelt_to_u64keeps the low 64 bits, so a 251-bit value sitting in an offset or length position could truncate to a plausible small number and slip past a bounds check. Both strict parsers reject any such felt outright. It returnsusizerather thanu64so no caller needs anas usizethat would re-open the same hole whereusizeis narrower than 64 bits.parse_call_array's layout handling is deliberately left alone: its SNIP-9 outside-execution callers are Cairo 1 only. (Its arithmetic is hardened in the follow-up commit below.)Verification
Against mainnet, not just a clean build:
starknet_traceTransaction— target, selector and full calldata — for all 53 that expose a trace. Confirmed the same check fails on pre-fix code (2 calls parsed instead of 5), so it is not vacuous.cargo fmt,cargo clippyandcargo build --releaseclean.Tests
Regression tests built from synthetic placeholder felts reproducing the structure only: the legacy layout end-to-end, modern-wins-when-consistent (including the zero-argument first call that triggers the misread), overlapping legacy offsets, rejection of a truncating length felt, and the lenient fallback on truncated input.
Follow-up: overflow panics on untrusted length felts (2nd commit)
Review flagged that the lenient fallback could panic, which turned out to be correct and worth fixing here since this PR is what documents that path as the malformed-input route.
parse_call_arrayadded an untrusteddata_lentooffsetunchecked. That felt can hold up tou64::MAX, so in release builds the add wraps belowoffset, passes the<= calldata.len()bounds check, and panics slicing backwards:Now
checked_addfor the slice bound, andsaturating_addwhen advancingoffset— saturating rather than wrapping matters because callers read an offset past the end as "did not consume cleanly", which a wrapped small value would hide.The same pattern appeared in
outside_execution.rson an untrustedsig_len: the validation add panicked in debug builds, while in release it wrapped to a value that happened not to equaldata.len(), so malformed input was rejected by luck rather than by the check. The extraction add is gated behind that validation and was not reachable on its own; it is made checked too rather than left depending on the gate.Both faults predate this series —
mastercarries the identical unchecked add inparse_call_array, and the oldparse_multicallcalled straight into it. Regression tests cover all three sites, pass in debug and release, and were each confirmed to fail against the pre-fix code.Follow-up: checked
usizenarrowing (3rd commit)A second review pass noted that
felt_to_u64_checked(...)? as usizere-opens the truncation hole the helper exists to close, whereverusizeis narrower than 64 bits —0x1_0000_0003reads as3, and the strict bounds and exact-consumption checks then validate against the wrong number.Not reachable on any target this builds for (
usizeis 64-bit on x86_64 and aarch64; CI is x86_64 only), so the casts were lossless in practice — but they contradicted the validators' documented intent, and a checked narrowing costs nothing.Rather than add a second helper,
felt_to_u64_checkednow returnsusizedirectly and is renamedfelt_to_usize_checked, so no caller has au64to cast in the first place. The lenient fallback keepsfelt_to_u64+as usize: it is explicitly best-effort, and its arithmetic is already checked/saturating, so a truncated length there shortens a salvaged slice rather than overflowing.Note for existing users
The parser fix does not rewrite already-persisted rows. Cached
address_txs/address_callsentries for a legacy-layout account keep their old derived labels until cleared, at which point they are re-derived correctly.🤖 Generated with Claude Code