feat(chain): cap JSON-RPC response size before lowering into the guest#309
Closed
lgahdl wants to merge 9 commits into
Closed
feat(chain): cap JSON-RPC response size before lowering into the guest#309lgahdl wants to merge 9 commits into
lgahdl wants to merge 9 commits into
Conversation
4acf3ed to
fa79d01
Compare
jean-neiverth
approved these changes
Jul 10, 2026
df69180 to
d01c759
Compare
265f467 to
b8aeb1c
Compare
d01c759 to
fa01f17
Compare
b8aeb1c to
d94a4bd
Compare
fa01f17 to
eaa7777
Compare
6db87ec to
b4734ce
Compare
eaa7777 to
0e74b87
Compare
b4734ce to
e15aa66
Compare
cdf84b1 to
fa87a85
Compare
4c508ec to
6f83ed6
Compare
fa87a85 to
52ca638
Compare
Introduce the world-neutral half of the poll loop in nexum_sdk::keeper: a ConditionalSource trait (one watch in, one outcome out, generic over the host, no venue-transport pre-abstraction), a Tick carrying the dispatch instant, and the RetryAction (try-next-block / backoff / drop) whose effects the Retrier runs through the gate and watch-set stores. RetryAction moves out of shepherd-sdk; the CoW crate re-exports it so no module rewires. shepherd-sdk keeps classify_api_error as the CoW errorType table but returns the keeper action, and encodes two fixes in the one classification path: classify_poll_error maps an unrecognised revert selector with a structured payload to DontTryAgain instead of re-polling every block forever, and DuplicatedOrder (both spellings) classifies as already-submitted - the run records the submitted: receipt and keeps the watch rather than dropping every future tranche. classify_submit_error widens the table to the whole CowApiError surface and gives Backoff its producer: a rate-limit fault with server guidance gates the watch on the epoch clock. cow::run composes the loop: watch-set scan -> gate check -> source poll -> PollOutcome dispatch, driving submission through the existing CowApiHost seam with the journal as the idempotency guard and the retry ledger as the failure dispatch. Acceptance tests run against the composed shepherd-sdk-test MockHost as integration tests; no module is rewired yet.
RpcError.data is now Bytes; the test helper takes raw Vec<u8> and wraps it (Vec -> Bytes is O(1)).
The run logged through the LoggingHost seam, which the guest tracing capture cannot observe - module tests proving behaviour identity for keeper ports assert on tracing events. Route the submit-path diagnostics through the tracing macros with the wording the legacy twap poll loop established, and give ConditionalSource a defaulted label so shared log lines attribute the strategy that produced them.
Reduce strategy.rs to decode and evaluate: log decoding persists watches through the keeper watch set, and the getTradeableOrderWithSignature evaluation moves behind ConditionalSource. The hand-rolled gate keys, watch-update lifecycle, submitted: markers, and submit-retry dispatch are deleted in favour of cow::run over the keeper stores and retry ledger, still on the legacy CowApiHost seam. The MockHost dispatch tests are untouched - the behaviour-identity proof for the port.
Rework MockLocalStore to mirror the runtime store's shape: namespaced views over one shared row map, so identical key strings in sibling namespaces never collide, plus store-wide entry and byte limits that span namespaces the way one database file does. Fault injection stays per-view.
Script per-call venue behaviour where MockCowApi replays one response: a strictly-draining queue of submit outcomes with a configurable steady-state fallback, per-route response sequences whose final entry sticks (a terminal order status persists across re-polls), and venue fault injection that overrides both without consuming them. MockHost grows a defaulted venue type parameter so the composed host carries either mock on the same CowApiHost seam. Acceptance tests drive the keeper run across multi-tick retry, backoff, and outage scenarios, and module-shaped strategy code against status sequences.
#154) Adds a configurable `[limits.chain] response_max_bytes` knob (default 1 MiB) that is enforced host-side in `chain::request()` before the response string is copied into the guest heap. Oversized responses are rejected with an `InvalidInput` fault, a `WARN` log, and a dedicated `shepherd_chain_response_capped_total` metric. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfSsJPYDQ1GQGbnSVFxATx
Review follow-ups on the chain response cap: - request_batch: the per-entry cap bounds each body, but the aggregate Vec<RpcResult> lowered into guest memory was unbounded - ~64 entries just under the cap is ~64 MiB, exactly the guest-heap saturation issue #154 exists to prevent, reachable via the block-range chunking the issue itself recommends. A running total now converts entries past the cap into typed invalid-input results. - config: renamed [limits.chain].response_max_bytes to response_body_max_bytes (u64) for exact symmetry with the [limits.http] sibling; a degenerate 0 saturates to 1, matching the logs/poison zero handling. - new harness test drives the cap through the real path - config -> ModuleLimits -> HostState -> chain::request - with the price-alert module: an over-cap oracle response surfaces to the guest as the typed fault and never reaches classify. The existing unit tests only covered the free function, so a deleted and_then would have passed the suite. - TOML parse tests for [limits.chain] (absent -> 1 MiB default, override, zero saturation), mirroring the HTTP section's tests. - engine.example.toml documents the new section; warn message dash matches house style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfSsJPYDQ1GQGbnSVFxATx
52ca638 to
74e6866
Compare
02e6276 to
6076837
Compare
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds a host-side cap on chain JSON-RPC response bodies so a misbehaving or adversarial node cannot saturate the guest heap via a single large reply.
engine_config.rsgains a new[limits.chain]TOML section with aresponse_body_max_bytesknob (default 1 MiB), resolved throughModuleLimits::chain_response_max_bytes()with the same zero-saturates-to-one handling as thehttp/logs/poisonsections.HostStatecarries achain_response_max_bytes: usize, threaded from config at boot insupervisor.rsand preserved across restarts viaLoadedModule.host/impls/chain.rsappliescheck_response_cap()torequest()after receiving the provider response; a rejection logs a warning, increments theshepherd_chain_response_capped_totalmetric, and returnsChainError::Fault(InvalidInput). Therequest-batchpath also tracks a running aggregate total against the same cap, so a wide batch of individually-legal bodies cannot lower an unboundedVec<RpcResult>into guest memory in one go.Subscription-delivered payloads (
dispatch_chain_log) are not covered by this cap; that path lowerseth_subscribenotifications rather thanchain::requestresponses, and bounding it is left as a follow-up rather than widened into this PR.Why
A chain node (or anything on-path to it) that returns an oversized JSON-RPC response body can otherwise force the guest to buffer an arbitrarily large payload, giving an adversarial or misbehaving node a way to exhaust guest memory. Capping the response host-side, before it is copied into the guest, closes that gap the same way the existing
[limits.http]cap does for outbound HTTP.Testing
chain_limits_default_when_absent,chain_limits_parse_with_override,chain_limits_saturate_degenerate_zeroinengine_config.rscover config resolution.response_at_cap_is_acceptedandresponse_over_cap_returns_invalid_inputinhost::impls::chain::testscover the per-entry cap boundary.test_utils/harness.rsdrives the cap through the realconfig -> HostState -> chain::requestwiring with the price-alert module.engine.example.tomldocuments the new[limits.chain]section.AI Assistance
Claude (Opus) used for the implementation.
Closes #154