Skip to content

Enforced events embed the whole Context, so a policy-permitted call can exceed txMaxContractEventsSizeBytes and fail #852

Description

@IvanBelyakoff

Summary

All three reference policies put the complete authorization Context into the event they publish from enforce:

Two Context variants carry unbounded data — Contract has args, and CreateContractWithCtorHostFn has constructor_args — so the event grows with what the caller passed while txMaxContractEventsSizeBytes does not. Past a certain size enforce permits the call, publishes, and the host then aborts the whole transaction on the event budget.

Both threshold policies make this reachable with an ordinary call, since neither looks at the context's contents at all: enforce counts authenticated signers against a threshold and publishes. ContextRuleType allows Default and CreateContract rules besides CallContract, so both unbounded paths are live.

Measured on testnet

Protocol 27. Policies built from v0.7.2 (a9c42169), rustc 1.91.1, stellar-cli 27.0.0, unmodified. The target is a trivial contract of my own, absorb(who: Address, payload: Bytes), doing who.require_auth() — a legitimate call whose only variable is one argument's size. ConfigSetting(contract_events_v0) gives tx_max_contract_events_size_bytes: 16384.

SimpleEnforced serializes to 528 + payload for this signer shape:

payload event bytes result tx
15 848 16 376 success, last admissible 7b16b1e905baccf9777f2e1d5bbec077c0827d1eae956a5798243d8706cfee64
15 852 16 380 failed bfefdb2872e2d33c7e3a8b026bbe44774f606f53ab7738ee3eb409d94397c898
20 000 20 528 failed 455a23410dac773f956810f09e499cb7b929fba4bffabf3d3cf1acc08003a00b

Two host messages, both Error(Budget, ExceededLimit)invokeHostFunctionResourceLimitExceededtxFailed:

total events size exceeds network config maximum | 20528 | 16384
return value pushes events size above network config maximum | 16388 | 16384

The second explains why the boundary sits four bytes below the round number: the top-level return value counts toward the same budget, so 16 376 + 8 lands exactly on 16 384. It also means the boundary is not a fixed number — it moves with the signer set, with any other event in the transaction, and with the return value.

These transactions were applied and failed — they are in ledgers with fees charged, not rejected at admission. The diagnostics show the permitted path completing first:

fn_call absorb → __check_auth → verify(true) → enforce → simple_enforced published
→ fn_return enforce → fn_return __check_auth → fn_return absorb(u32)

and only then the budget error. So from the caller's side this is not a refusal they can read: no SimpleThresholdError, no policy code, on a call the policy said yes to.

simulateTransaction returned success for every failing size, even though its response carries the events it would publish — the overflow is computable from what the RPC already returns, and nothing compares it against the cap.

Control: the identical 20 000-byte absorb, authorized through the same account's policy-free Default rule, succeeds and publishes zero contract events (8801e60564b98991d9d348b5f387d7dc66ed1f3ba78f088da6d36c48320914a3). The argument size is not the problem; embedding it in an event is.

weighted_threshold behaves identically at 532 + payload (20 000 → 20 532, failed: 03a32e30b208e53e4194241030e9d183961f5f5b17e3841db7771571c11cb66f).

spending_limit is affected but harder to reach

It checks fn_name == symbol_short!("transfer") and reads args.get(2) as an i128, but never checks arity, so arguments at index 3 and beyond ride into the event. Confirmed against a purpose-built four-argument transfer (20 000 → 20 536, failed: fe76f57ac468882d8f26b3fb30a9246a3e7d6347cec29a57ea80b4b80c75690d).

A SEP-41 token's transfer takes three fixed-size arguments, so against a real token this event cannot grow. It is reachable only where a rule targets a non-SEP-41 contract exposing a transfer-named function of arity ≥ 4 whose argument 2 is an i128.

What this is, and what it is not

Not an authorization bypass — nothing is permitted that shouldn't be. What breaks is that a permitted call cannot complete, and the failure carries a budget error rather than a policy error, which a caller cannot distinguish from a network problem.

The signer signs the auth entry over the exact arguments, so the size is chosen by whoever assembles the call. That makes it a footgun and a griefing surface rather than a remote attack.

One thing that makes the practical headroom smaller than the table suggests: the budget is per transaction. Several policies on one rule, or several rules in one transaction, each publishing a context, share the same 16 384 bytes.

Why no existing test catches it

The policy tests exercise the event path with small arguments, so the event never approaches the limit, and no test covers the interaction between the event's contents and the platform limit. This is the same shape as #847 — a value in policy code colliding with a platform limit and surfacing as an untyped host failure instead of the documented typed error.

What we did on our side

We hit this in a policy generator built on this library, and settled on a digest of the whole context:

pub context_hash: BytesN<32>,   // e.crypto().sha256(&context.to_xdr(e))

Hashing the whole Context rather than picking fields out of it was deliberate: fn_name and a target contract exist only on the Contract variant, so a flat shape cannot represent either creation variant, and constructor_args needs bounding too. Our event came out at 232 bytes for any argument size in the shape we measured — constant, though not unconditionally publishable, since the budget is shared with other events and the return value. The call that aborts simple_threshold at 15 852 bytes goes through (957cf87503c6f7e4bbd9480a8c633dc1cff094bb9b03b4f1409238dcd7a6dbdd).

A digest gives up reading the arguments from the event alone, which costs less than it sounds: the Context comes from the transaction's own authorization entries, so anyone holding the transaction recomputes the digest and matches it, and the arguments were never filterable through getEvents anyway, being in the data section. It is integrity and correlation rather than recoverability — standard RPC keeps a bounded window — but the event ages out of the same window as the transaction it describes, so that is not a change.

The digest also does something dropping the field would not. A transaction can carry several authorization contexts, and a policy can be invoked more than once, in which case several events share one txHash; the digest is what says which event belongs to which context. getEvents returns txHash and operationIndex alongside each event, so the lookup is one call per transaction rather than per event.

Questions

1. Is the narrow topic set deliberate? smart_account is the only topic on these events, and context_rule_id looks like it belongs there too — it is meaningful for every context variant. Elsewhere the library goes further: Approve topics owner and spender, RoleGranted topics role (a Symbol) and account, NFT Approve topics approver and token_id, and the # Events convention in .claude/commands/code-quality.md reads the same way. We could not find a cost argument either way, since the whole serialized event is metered whichever section a field sits in. Order does matter, though: getEvents rejects a filter with more than four positional segments, so only the first four topics can be matched exactly.

2. What shape would you want the fix in? A digest of the whole context is the smallest change that covers all three variants. If filtering by target contract and function matters, that seems to need separate event types per context kind, or a bounded summary alongside the digest. We did not want to guess which you would prefer.

If you want the change from us, we are happy to open a PR with boundary tests — large call arguments, large constructor arguments, the return-value boundary, and the maximum signer set, since authenticated_signers is bounded but variable and each threshold policy publishes its own copy.

Reproduction setup (all testnet)

Smart account (deployed from the published wasm hash a12747ff6c139dc14fc2fd30d200d6bbb5da7b5d59812c047ce1f9cad226b289) CCBETA6HUL5LKKCJMWZRHREVO2JF64NJP46VSOYFCZPTTK7N7LPG3QQ2
ed25519 verifier CDI73FCOSFXMG42F4KFA7QKCWBKNKIAFYOO656CVJTHXUGWFGUXGCF4P
simple_threshold policy (code hash cb6c0bd9cd06abba05f924ff4157b41aa1dd3891803c7c93b3b158e20986e592) CDDFZBZO3FRPDBLJ5V4TCVP5NCIEZIPAESUK5GSQ6SX7BJARXN4EQVVG
weighted_threshold policy (code hash 7565d0a5…) CCBUVKMLTMECEZE7HKCFIC3L6KE5HDZBPFE5AMFYJKHRGTXCFD726J7S
spending_limit policy CC7RJTOHHE2JGHVRQZH62OUD2YP4P4LEJKG4SAR3MIS5WISESHJR2SS5
absorb target (code hash 9a424dcaa562bf20bd57fea92de6ca08e99ca480c0d8e5849e4734c20c34a09b) CD5QQM7YHNRAMWHM3YC4CTD6CQRH2RRTDBVM56J2BCJSZDIDALTPBIS5
four-argument transfer target CDJTZBZY7KYGEQVBF6DUKGSS5ND7LUTM7F5DOSAHLDDZOTRSYFFFOLFV

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions