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) → invokeHostFunctionResourceLimitExceeded → txFailed:
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 |
Summary
All three reference policies put the complete authorization
Contextinto the event they publish fromenforce:simple_threshold.rs#L62weighted_threshold.rs#L78spending_limit.rs#L49Two
Contextvariants carry unbounded data —Contracthasargs, andCreateContractWithCtorHostFnhasconstructor_args— so the event grows with what the caller passed whiletxMaxContractEventsSizeBytesdoes not. Past a certain sizeenforcepermits 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:
enforcecounts authenticated signers against a threshold and publishes.ContextRuleTypeallowsDefaultandCreateContractrules besidesCallContract, 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), doingwho.require_auth()— a legitimate call whose only variable is one argument's size.ConfigSetting(contract_events_v0)givestx_max_contract_events_size_bytes: 16384.SimpleEnforcedserializes to528 + payloadfor this signer shape:7b16b1e905baccf9777f2e1d5bbec077c0827d1eae956a5798243d8706cfee64bfefdb2872e2d33c7e3a8b026bbe44774f606f53ab7738ee3eb409d94397c898455a23410dac773f956810f09e499cb7b929fba4bffabf3d3cf1acc08003a00bTwo host messages, both
Error(Budget, ExceededLimit)→invokeHostFunctionResourceLimitExceeded→txFailed: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:
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.simulateTransactionreturned success for every failing size, even though its response carries theeventsit 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-freeDefaultrule, succeeds and publishes zero contract events (8801e60564b98991d9d348b5f387d7dc66ed1f3ba78f088da6d36c48320914a3). The argument size is not the problem; embedding it in an event is.weighted_thresholdbehaves identically at532 + payload(20 000 → 20 532, failed:03a32e30b208e53e4194241030e9d183961f5f5b17e3841db7771571c11cb66f).spending_limitis affected but harder to reachIt checks
fn_name == symbol_short!("transfer")and readsargs.get(2)as ani128, but never checks arity, so arguments at index 3 and beyond ride into the event. Confirmed against a purpose-built four-argumenttransfer(20 000 → 20 536, failed:fe76f57ac468882d8f26b3fb30a9246a3e7d6347cec29a57ea80b4b80c75690d).A SEP-41 token's
transfertakes 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 atransfer-named function of arity ≥ 4 whose argument 2 is ani128.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:
Hashing the whole
Contextrather than picking fields out of it was deliberate:fn_nameand a target contract exist only on theContractvariant, so a flat shape cannot represent either creation variant, andconstructor_argsneeds 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 abortssimple_thresholdat 15 852 bytes goes through (957cf87503c6f7e4bbd9480a8c633dc1cff094bb9b03b4f1409238dcd7a6dbdd).A digest gives up reading the arguments from the event alone, which costs less than it sounds: the
Contextcomes from the transaction's own authorization entries, so anyone holding the transaction recomputes the digest and matches it, and the arguments were never filterable throughgetEventsanyway, 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.getEventsreturnstxHashandoperationIndexalongside each event, so the lookup is one call per transaction rather than per event.Questions
1. Is the narrow topic set deliberate?
smart_accountis the only topic on these events, andcontext_rule_idlooks like it belongs there too — it is meaningful for every context variant. Elsewhere the library goes further:Approvetopicsownerandspender,RoleGrantedtopicsrole(aSymbol) andaccount, NFTApprovetopicsapproverandtoken_id, and the# Eventsconvention in.claude/commands/code-quality.mdreads 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:getEventsrejects 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_signersis bounded but variable and each threshold policy publishes its own copy.Reproduction setup (all testnet)
a12747ff6c139dc14fc2fd30d200d6bbb5da7b5d59812c047ce1f9cad226b289)CCBETA6HUL5LKKCJMWZRHREVO2JF64NJP46VSOYFCZPTTK7N7LPG3QQ2CDI73FCOSFXMG42F4KFA7QKCWBKNKIAFYOO656CVJTHXUGWFGUXGCF4Psimple_thresholdpolicy (code hashcb6c0bd9cd06abba05f924ff4157b41aa1dd3891803c7c93b3b158e20986e592)CDDFZBZO3FRPDBLJ5V4TCVP5NCIEZIPAESUK5GSQ6SX7BJARXN4EQVVGweighted_thresholdpolicy (code hash7565d0a5…)CCBUVKMLTMECEZE7HKCFIC3L6KE5HDZBPFE5AMFYJKHRGTXCFD726J7Sspending_limitpolicyCC7RJTOHHE2JGHVRQZH62OUD2YP4P4LEJKG4SAR3MIS5WISESHJR2SS5absorbtarget (code hash9a424dcaa562bf20bd57fea92de6ca08e99ca480c0d8e5849e4734c20c34a09b)CD5QQM7YHNRAMWHM3YC4CTD6CQRH2RRTDBVM56J2BCJSZDIDALTPBIS5transfertargetCDJTZBZY7KYGEQVBF6DUKGSS5ND7LUTM7F5DOSAHLDDZOTRSYFFFOLFV