Skip to content

fix(event): limit contract ABI size when decoding events - #7091

Open
0xbigapple wants to merge 4 commits into
tronprotocol:release_v4.8.3from
0xbigapple:fix/event-abi-size-limit
Open

0xbigapple wants to merge 4 commits into
tronprotocol:release_v4.8.3from
0xbigapple:fix/event-abi-size-limit

Conversation

@0xbigapple

@0xbigapple 0xbigapple commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds size checks to ContractEventParserAbi and applies them on the event-subscription path (version 0 in ContractTriggerCapsule.processTrigger, version 1 in BlockEventGet.processTrigger) so a contract ABI is size-checked before it is decoded. The check is node-local and reads each log's own ABI with no shared state:

  • an ABI with more than MAX_ABI_ENTRIES (1024) entries, or more than MAX_ABI_EVENTS (512) non-anonymous events, is not decoded; its logs fall back to raw contract logs, which are delivered to subscribers that have the contractlog/soliditylog trigger enabled;
  • an event entry with more than MAX_ABI_ENTRY_PARAMS (64) parameters, or an identifier longer than MAX_ABI_IDENTIFIER_LENGTH (256), is skipped.

Why are these changes required?

Decoding a log into an event scans the whole contract ABI, synchronously during block application. Because the ABI is deployer-supplied and bounded only by the transaction size, this cost has no useful bound of its own on an event-subscribing node.

The limits were set from the distribution of deployed contract ABIs (mainnet abi store, 714,295 contracts with a non-empty ABI); no real contract reaches them:

dimension observed max limit headroom
entries per ABI 412 1024 2.5x
non-anonymous events per ABI 104 512 4.9x
parameters per event entry 16 64 4x
event identifier length 99 256 2.6x

The two count checks bound different costs: MAX_ABI_ENTRIES is an O(1) check on how many entries are iterated (functions included); MAX_ABI_EVENTS is a tighter cap on just the non-anonymous events, which are the ones that actually hash a signature.

This PR has been tested by:

  • Unit Tests — ContractEventParserAbiTest covers the predicate boundaries, with the entry-count and event-count limits exercised independently (mixed function/event ABIs). ContractAbiSizeLimitTest adds behavior tests on both decode paths (version 0 ContractTriggerCapsule, version 1 BlockEventGet): normal decoding, over-limit fallback to raw contract logs with the raw data preserved, and an oversized entry skipped while a valid matching entry still decodes. Each over-limit case carries a topic0 that would decode without the guard, so removing a call-site guard fails the test.
  • Manual Testing — private chain with one block producer and two followers (one on this branch, one on the base release); under the same input the base follower stalls or runs out of memory on an oversized ABI while the follower on this branch stays in sync.

Follow up

These limits bound the per-ABI decode cost, but not the total in-limit event volume, so a sustained stream of log-dense blocks can still delay event processing on a subscribing node. A transaction-scoped topic0 -> entry index — reusing the shared ABI instance so each contract's logs are hashed once per transaction instead of per log — is left as a follow-up.

Extra details

Node-local and non-consensus. Release note: an over-limit ABI is not decoded into events — its logs are delivered as raw contract logs, which reach subscribers that have the contractlog/soliditylog trigger enabled; a subscriber with only contractevent/solidityevent stops receiving the affected events and does not automatically receive the raw logs.

Matching a log to an event (ContractTriggerCapsule and BlockEventGet)
rebuilds and hashes a signature string for every ABI entry, synchronously
during block application. An ABI is bounded only by the transaction size
limit, so the work of decoding a log grows with the ABI with no useful
bound of its own.

Add size checks to ContractEventParserAbi and apply them on the
event-subscription path, which is node-local: it affects only what
subscribers receive, not block validity. The check reads each log's own ABI
with no shared state, so one contract or transaction cannot affect decoding
of another:

- an ABI with more than MAX_ABI_ENTRIES entries, or more than MAX_ABI_EVENTS
  non-anonymous events, is not decoded; its logs are delivered as raw
  contract logs;
- an entry with more than MAX_ABI_ENTRY_PARAMS parameters, or an identifier
  longer than MAX_ABI_IDENTIFIER_LENGTH, is skipped so its signature is
  never built.

Real contracts stay well within these limits; an over-limit ABI simply has
its logs delivered undecoded, which subscribers can still decode
client-side.
@github-actions
github-actions Bot requested a review from xxo1shine November 6, 2026 07:26
@halibobo1205 halibobo1205 added this to the GreatVoyage-v4.8.3 milestone Nov 6, 2026
@lvs0075
lvs0075 requested a review from yanghang8612 November 6, 2026 08:28

@waynercheung waynercheung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the PR. Keeping these limits node-local is appropriate, and the mainnet distribution provides useful compatibility evidence.

[SHOULD] The guards reduce the work for oversized ABIs, but accepted ABIs still rebuild and hash event signatures for every log. On the block-application path, version 0 decodes synchronously while pushBlock holds the Manager monitor. Version 1 decodes inside synchronized (manager) in BlockEventLoad.load(), using the same monitor. Expensive decoding can therefore delay block processing in either version. Please provide reproducible stress results for in-limit ABIs with log-heavy blocks on both versions, including processing latency and allocation/GC behavior. Microbenchmarks show the remaining repeated work, but they do not by themselves establish node-level availability.

[SHOULD] Please qualify the raw-log fallback in the description and release note. On both paths, the fallback only reaches subscribers that have the corresponding contractlog/soliditylog trigger enabled. Event-only subscribers stop receiving the affected events, and they do not automatically receive the raw logs instead.

[DISCUSS] Version 0 applies matchFilter after decoding (ContractTriggerCapsule.java:138), while version 1 filters first (BlockEventGet.java:102). Moving the version 0 filter ahead of decoding would avoid work for excluded logs. This predates the PR and can be handled separately.

Comment on lines +58 to +59
&& !ContractEventParserAbi.hasTooManyEntries(abi)
&& !ContractEventParserAbi.hasTooManyEvents(abi)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[DISCUSS] Both paths rebuild and hash event signatures for every log, even when the ABI is shared within a transaction. A transaction/block-scoped signature index could avoid this repeated work. A longer-lived cache would need bounded capacity, correct ABI identity/reorg handling, and preservation of first-match behavior for duplicate signatures. This optimization can be a follow-up. The immediate request is the threshold validation described in the main comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good suggestion — caching the per-ABI signatures would cut the repeated hashing. Happy to discuss further.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks. One note for the follow-up: moving decoding off the lock or making it asynchronous keeps the total CPU the same, so under a sustained stream it turns the stall into an unbounded backlog. Version 0 is also synchronous on purpose (Manager.java:2483: it avoids the race between an async queue and the reorg cache clearing). The repeated work itself goes away with the index: all logs of a contract within a transaction share the same ABI instance (abiMap in LogInfoTriggerParser, and in BlockEventGet.parseLogs), so a transaction-scoped topic0 -> entry map built once would replace 12,000 x 512 signature hashes with 512 for the case you measured. I'd suggest the follow-up start there.

Comment on lines +38 to +45
@Test
public void normalSizedAbiIsDecoded() {
assertFalse(hasTooManyEntries(abiWithEntries(MAX_ABI_ENTRIES)));
}

@Test
public void oversizedAbiIsNotDecoded() {
assertTrue(hasTooManyEntries(abiWithEntries(MAX_ABI_ENTRIES + 1)));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[SHOULD] These tests only exercise the predicates, so removing either call-site guard would leave them green. Please add behavior tests for both production paths covering normal decoding, over-limit fallback, and raw-data preservation. Each over-limit case should contain a matching event/topic0 that would decode without the guard.

Also, abiWithEntries(1024) creates 1,024 events. That exceeds the event limit, so the ABI in normalSizedAbiIsDecoded would not be decoded in production, despite the test name. Please use mixed function/event ABIs to cover the two count limits independently:

  • 1,023 functions + 1 event (1,024 entries): still decoded;
  • 1,024 functions + 1 event: only the entry limit is exceeded;
  • 513 events + a few functions (at most 1,024 entries): only the event limit is exceeded.

Please also cover an oversized entry followed by a valid matching entry, to verify that only the oversized entry is skipped.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in a82450d.

Behavior tests — ContractAbiSizeLimitTest covers both paths (ContractTriggerCapsule, BlockEventGet): normal decoding, over-limit fallback to a raw log with the raw topics/data preserved, and an oversized entry followed by a valid matching entry where only the oversized one is skipped. Each over-limit case carries a topic0 matching an event present in the ABI, so it would decode without the guard — removing either call-site guard fails these tests.

Predicate tests — ContractEventParserAbiTest now uses mixed function/event ABIs to exercise the two count limits independently: 1,023 functions + 1 event (decoded), 1,024 functions + 1 event (only the entry limit exceeded), 513 events + 3 functions (only the event limit exceeded).

@0xbigapple

Copy link
Copy Markdown
Collaborator Author

Thanks for the PR. Keeping these limits node-local is appropriate, and the mainnet distribution provides useful compatibility evidence.

[SHOULD] The guards reduce the work for oversized ABIs, but accepted ABIs still rebuild and hash event signatures for every log. On the block-application path, version 0 decodes synchronously while pushBlock holds the Manager monitor. Version 1 decodes inside synchronized (manager) in BlockEventLoad.load(), using the same monitor. Expensive decoding can therefore delay block processing in either version. Please provide reproducible stress results for in-limit ABIs with log-heavy blocks on both versions, including processing latency and allocation/GC behavior. Microbenchmarks show the remaining repeated work, but they do not by themselves establish node-level availability.

[SHOULD] Please qualify the raw-log fallback in the description and release note. On both paths, the fallback only reaches subscribers that have the corresponding contractlog/soliditylog trigger enabled. Event-only subscribers stop receiving the affected events, and they do not automatically receive the raw logs instead.

[DISCUSS] Version 0 applies matchFilter after decoding (ContractTriggerCapsule.java:138), while version 1 filters first (BlockEventGet.java:102). Moving the version 0 filter ahead of decoding would avoid work for excluded logs. This predates the PR and can be handled separately.

Thanks for the careful review.

You're right that the limits don't address the per-log work for accepted ABIs. As you noted, decoding is synchronous on the block-application path in both versions (version 0 in pushBlock, version 1 inside synchronized (manager) in BlockEventLoad.load()), so the natural fix for the residual is to move it off that path — asynchronous, without holding the Manager monitor. Separately, moving matchFilter ahead of decoding in version 0 is a good optimization on its own: it avoids decoding logs that will be filtered out anyway. Both are good follow-ups. This PR works mainly at the ABI dimension. Before it, the per-log work was bounded only by the transaction size (and, at broadcast, the field-count admission guard) — up to roughly 16,000–25,000 events, or one very long identifier, per ABI, far above any real contract. This PR tightens that loose bound to the observed distribution and eliminates the OOM that a single over-limit request can cause. It lowers the worst-case per-log cost, but does not by itself keep a subscribing node caught up under a sustained, packed, in-limit log attack.

Here is data from a local environment (macOS, JDK 17, -Xms9g -Xmx9g per node — production-like heap). Setup: one SR (event off) plus two followers on this branch, version 0 and version 1, both subscribed to contractevent/solidityevent. For the decode-cost numbers below the followers' events are drained in real time by a ZMQ consumer, so the measurement reflects decode alone and is not distorted by consumer backlog. The contract is the in-limit worst case — 512 non-anonymous events (= MAX_ABI_EVENTS), the matching event last so every log scans the whole ABI, 64-parameter event, 256-char identifiers.

Each transaction emits ~12,000 LOG1 against this contract. The table below is the followers' PushBlock time for the block containing these transactions (its decode cost), as a function of how many such transactions the block holds:

transactions in block logs v0 PushBlock v1 PushBlock cost field
1 12,000 11.9 s 145 ms
2 24,000 22.4 s 177 ms
4 48,000 46.9 s 364 ms
6 72,000 70.3 s 588 ms

The two columns measure different things. On v0, decoding runs inside PushBlock, so the cost field includes the decode cost and block application itself is slowed — linear at ~11.7 s per 12,000-log transaction, so even a single such transaction (11.9 s) already exceeds the 3 s block interval and a v0 subscriber starts falling behind on block height. On v1, decoding runs in a separate event-processing thread rather than in PushBlock itself, so an isolated PushBlock stays cheap; but the per-log decode cost is the same, and that thread holds the Manager lock (synchronized (manager)) while it decodes — so under a sustained stream v1 also starts to fall behind, slightly at first and increasingly as the backlog of pending blocks grows (each pass decodes a larger batch and holds the lock longer, so PushBlock waits longer).

On GC and memory: no Full GC or OutOfMemoryError occurred in the measurements above; but under a sustained stream of full, in-limit log events it is possible. Bounding that is part of the event-pipeline follow-up, not these ABI limits.

Separately, I'll qualify the raw-log fallback in the description and release note: it only reaches subscribers that have the contractlog/soliditylog trigger enabled; a subscriber with only contractevent/solidityevent stops receiving the affected events and does not automatically receive the raw logs.

&& !ArrayUtils.isEmpty(topics.get(0).getData())
&& !ContractEventParserAbi.hasTooManyEntries(abi)
&& !ContractEventParserAbi.hasTooManyEvents(abi)
&& Args.getInstance().getStorage().isContractParseSwitch()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Small suggestion: could we check isContractParseSwitch() before hasTooManyEvents(abi), both here and in BlockEventGet.processTrigger (v1)? The current order can still scan the ABI when parsing is disabled. Reordering the existing condition would avoid that work while keeping the raw-log path below intact.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 9b78007 — moved isContractParseSwitch() ahead of the two count checks in both paths, so a parsing-disabled node short-circuits before any ABI scan.

… paths

Also make the predicate tests exercise the entry-count and event-count limits independently (mixed function/event ABIs).
@0xbigapple
0xbigapple force-pushed the fix/event-abi-size-limit branch from 8eb4ae4 to a82450d Compare November 9, 2026 11:29
Short-circuit isContractParseSwitch() ahead of hasTooManyEntries/
hasTooManyEvents in both decode paths so a parsing-disabled node skips
the O(n) ABI scan per log.

@waynercheung waynercheung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the updates and the stress data. The raw-log compatibility note, the call-site guard tests and the parse-switch ordering are resolved. Caching, async decoding and moving matchFilter are fine as follow-ups and are not needed here.

[SHOULD] Two test gaps remain (verified with mutants):

  • The fallback tests build the log with empty data (trigger() uses new byte[0]), so "raw data is preserved" compares two empty strings. Replacing setData(logInfo.getHexData()) with setData("") on either path still passes. Please use non-empty data.
  • Version 0 has no "oversized entry followed by a valid matching entry" case (only v1_oversizedEntrySkippedButValidEntryMatches exists), so changing continue to break in ContractTriggerCapsule still passes.

[NIT] "Follow up: None" reads as if the decode cost is fully addressed. Your stress results show in-limit load can still delay block processing, so a sentence on the planned follow-up in the description would help.

Use non-empty log data so the raw-log data-preservation assertions
compare real hex, and add a v0 oversized-then-valid-entry case so a
continue->break mutation in ContractTriggerCapsule is caught.
@0xbigapple

Copy link
Copy Markdown
Collaborator Author

Thanks for the updates and the stress data. The raw-log compatibility note, the call-site guard tests and the parse-switch ordering are resolved. Caching, async decoding and moving matchFilter are fine as follow-ups and are not needed here.

[SHOULD] Two test gaps remain (verified with mutants):

  • The fallback tests build the log with empty data (trigger() uses new byte[0]), so "raw data is preserved" compares two empty strings. Replacing setData(logInfo.getHexData()) with setData("") on either path still passes. Please use non-empty data.
  • Version 0 has no "oversized entry followed by a valid matching entry" case (only v1_oversizedEntrySkippedButValidEntryMatches exists), so changing continue to break in ContractTriggerCapsule still passes.

[NIT] "Follow up: None" reads as if the decode cost is fully addressed. Your stress results show in-limit load can still delay block processing, so a sentence on the planned follow-up in the description would help.

Thanks — both gaps fixed, each confirmed against the mutant you described.

  • Non-empty log data: trigger() now builds the log with non-empty data (64 bytes), so the raw-log "data is preserved" assertions compare real hex on both paths. Mutating the raw-log setter to setData("") now fails v0_overEventLimitFallsBackToRawLog / v1_overEventLimitFallsBackToRawLog.
  • v0 oversized-then-valid: added v0_oversizedEntrySkippedButValidEntryMatches (oversized entry first, valid matching entry second). Changing the per-entry continue to break in ContractTriggerCapsule now fails it.

Also updated the description's "Follow up" to note the residual in-limit case and the planned event-processing follow-up.

@317787106

Copy link
Copy Markdown
Collaborator

[SHOULD] Please supplement the ABI statistics for both mainnet and Nile with the snapshot block height, sample count, and observed maxima for each limit enforced by this PR. You can also offer the scanning script/command, but it is not requeired.

If any limit is exceeded, please provide the affected-contract count and representative addresses. Also, please clarify whether the statistics cover currently retained ABIs or all historical deployments, and scope the compatibility claim accordingly.

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.

5 participants