perf(core): back transaction and event []felt.Felt fields with felt.Slice - #3955
perf(core): back transaction and event []felt.Felt fields with felt.Slice#3955EgeCaner wants to merge 3 commits into
[]felt.Felt fields with felt.Slice#3955Conversation
Reviewing PR #3955
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3955 +/- ##
==========================================
- Coverage 74.77% 74.59% -0.19%
==========================================
Files 461 461
Lines 40928 40928
==========================================
- Hits 30605 30530 -75
- Misses 8223 8295 +72
- Partials 2100 2103 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
e2233c8 to
7731802
Compare
|
Claude finished @EgeCaner's task in 9m 44s —— View job Review: PR #3955
No correctness regressions found. 5 inline comments, all perf/consistency. BlockerNone. Important
Nit
Things I verified as safe (so you don't have to re-derive them)
Verification
I could not run the test suite — |
|
Claude finished @EgeCaner's task in 12m 12s —— View job Re-review: PR #3955 (
|
| Field | Path |
|---|---|
SierraProgram []felt.Felt (rpc/v10/transaction_types.go:433) |
getClass* — thousands of felts per response |
Calldata / Result / Keys / Data / Payload (rpc/v10/trace_invocation.go:11-30, rpc/v9/trace.go:50-75) |
traceBlockTransactions / simulate — deeply nested, largest responses the node emits |
TxnHashes []felt.Felt (rpc/v10/block.go:75, rpc/v9/block.go:239) |
every getBlockWithTxHashes |
EventFilter.Keys [][]felt.Felt (v8/v9/v10) |
getEvents request side; needs []felt.Slice[felt.Felt] |
rpc/v8/events.go:30-31 |
v8 Event.Keys/Data still unconverted, so v8 event responses get nothing |
ProofFacts on the RPC side (rpc/v10/transaction_types.go:427) has to stay *[]felt.Felt now that core does — withdrawing that nit.
Re-verified as still safe
- CBOR/DB: byte-identical, no migration.
Slice.MarshalCBORemits the same shortest-form array header +encodeLimbsthe generic encoder produced (felt.Felthas hadMarshalCBORsincecore/felt/cbor.go:12), nil →0xf6matchesCanonicalEncOptions'NilContainerAsNull, and existing records still decode via theunmarshalGenericfallback. Newly relevant forL2ToL1Message.Payload: noomitempty, so unaffected by the above. - JSON output unchanged.
jsonrpcandclients/feederboth use the v1 API, where nil slices marshal tonull— matchingMarshalJSONTo's explicitjsontext.Null. (Worth remembering that json/v2 default semantics would emit[]for a nil slice, so this only holds while the v1 API is the entry point.) felt.Slice↔core.Eventzero-copy guard holds.var _ = Event(core.Event{})still compiles in v9 and v10, so theunsafe.Pointerreinterprets atrpc/v9/transaction.go:1080andrpc/v10/adapt_transaction.go:177remain sound.- No write-through aliasing. The new
&t.TransactionSignatureinvm/transaction.go(126, 145, 185) aliases the core transaction like the RPC adapters do;vm.Transactionis marshal-only,marshalTxnmutates just its ownVersioncopy, andnilToZeroreturns a pointer to a local header. Read-only everywhere today — same standing caveat that a future write through*tx.Signaturewould touch cached core state. - Request-side decoding of untrusted input is safe.
Slice.UnmarshalJSONFromruns ondec.ReadValue()output, so the array is already syntactically valid; non-string elements and unparseable hex return errors, and every index is bounds-guarded. No panic path.
Verification
go vet ./... and GOEXPERIMENT=jsonv2 go vet ./... both pass clean across the repo. That compiles test files too, and there are no build-tagged Go files outside core/felt/slice_jsonv2.go, so every file is covered by at least one of the two runs.
I still could not run the test suite — go test needs an approval this job doesn't have, so core, rpc/v8|v9|v10, rpc/rpccore, adapters/sn2core and sequencer tests were not executed here. CI's test job remains the real gate; the risk it covers that vet doesn't is behavioural (golden JSON/CBOR fixtures), not structural.
• branch perf/felt-slice
| PaymasterData felt.Slice[felt.Felt] | ||
| // From RPC spec: data needed to deploy the account contract from which this tx will be initiated | ||
| AccountDeploymentData []felt.Felt | ||
| AccountDeploymentData felt.Slice[felt.Felt] |
There was a problem hiding this comment.
important (guard against a silent storage-format change): you're right about omitempty, and I confirmed it in the pinned dependency — so keeping ProofFacts as []felt.Felt (line 302) is the correct call, but nothing stops the next person from undoing it.
fxamacker/[email protected]/encode.go:2004:
if reflect.PointerTo(t).Implements(typeMarshaler) {
return encodeMarshalerType, alwaysNotEmpty, getIsZeroFunc(t)
}alwaysNotEmpty becomes the field's isEmptyFunc, and encodeStruct (encode.go:1561) gates purely on f.ief, so for any cbor.Marshaler the omitempty tag is a no-op regardless of OmitEmptyMode. My earlier "evaluated on the Go value before the marshaler runs" was wrong — that's encoding/json v1, not this encoder.
Consequence is milder than a migration (decoding is unaffected: absent key → nil, 0xf6 → nil via unmarshalGeneric), but every version-3 invoke record without proof facts would grow a proof_facts: null entry, silently, with no test failing. Since core/partial_cbor.go already has reflection tests asserting field-set invariants, the cheapest guard is one more in that style — walk the Transaction/TransactionReceipt struct trees and fail if any field whose type implements cbor.Marshaler carries cbor:",omitempty". That documents why ProofFacts is the odd one out and makes the trap a compile-time-ish failure instead of a silent format change. Add the guard →
| ConstructorCallData: (*[]felt.Felt)(rpcTx.ConstructorCallData), | ||
| Type: starknet.TransactionType(rpcTx.Type), | ||
| SenderAddress: rpcTx.SenderAddress, | ||
| MaxFee: rpcTx.MaxFee, |
There was a problem hiding this comment.
nit (perf, follow-up): these six (*[]felt.Felt)(...) casts are now the only fast-path opt-outs left in the PR, and the type on the other side of them is the one that would pay best.
starknet.Transaction (starknet/transaction.go:172,176,177,185,186) isn't just the outbound addTransaction body — starknet.Block.Transactions is the same type, so it's also what clients/feeder decodes on every block during sync, along with starknet.Event.Keys/Data (241-243) and L1ToL2Message/L2ToL1Message.Payload (248,256). That's the heaviest felt-slice deserialization path in the node, and Slice.UnmarshalJSONFrom is the half of felt.Slice this PR doesn't exercise anywhere yet.
I checked that it actually dispatches: clients/feeder uses encoding/json (v1 API), and under GOEXPERIMENT=jsonv2 the v1 shim still routes to MarshalJSONTo/UnmarshalJSONFrom — encoding/json/v2/arshal_methods.go:205 and :300 only fall back to the reflect path under CallMethodsWithLegacySemantics when the receiver needs a forced address (not the case for felt.Slice's value receiver) or when the value sits in an object-name position. Same reason the vm.Transaction change in this PR pays off through json.Marshal.
Fine as a separate PR — flagging so it doesn't get lost, since converting it also deletes these casts.

What
Switches the
[]felt.Feltfields on core transactions, events and L2→L1 messages — plustheir v8/v9/v10 wire counterparts and
vm.Transaction— tofelt.Slice, so they decodethrough the fixed-shape CBOR path instead of the generic reflect decoder.
Encoding is byte-identical (verified across every array-header width, nil and empty
included, under the canonical enc mode), so stored data needs no migration and stays
readable by the previous binary. JSON is unchanged too, including
nullvs[]vs omitted,in both default and
GOEXPERIMENT=jsonv2builds — so the blockifier wire format isuntouched.
Two notes:
core.InvokeTransaction.ProofFactsstays[]felt.Felt: it carriescbor:",omitempty",and
felt.Slice's value-receiverMarshalCBORdefeats that, writing anullkey forevery invoke transaction.
&t.TransactionSignatureinstead ofnew(t.Signature()), whichdrops an allocation per transaction but means the pointer aliases core state. Every use is
a read-only deref, matching what
CallData: &t.CallDataalready did.Benchmarks
starknet_getBlockWithTxsstarknet_getBlockWithReceiptsstarknet_getTransactionReceiptstarknet_getTransactionByHashstarknet_getEventsMethodology. k6 replays a fixed list of recorded requests, one at a time, against a
read-only mainnet snapshot. Each round runs
mainfirst and then this branch, so if themachine has a slow moment it hits both sides instead of just one. Three rounds per method,
50k requests each (20k for
getEvents). The node is restarted between sides, so pebble'sin-process cache starts empty for both — but the OS page cache survives, which is why round 1
is discarded:
mainran first and paid for the initial disk reads while this branch found thesame data already in memory. The figures are averages of the remaining rounds — repeat runs of
the same binary vary by about 3%, so anything smaller than that is noise.