Skip to content

feat(chain): typed chain-error with an rpc case carrying revert bytes#214

Open
mfw78 wants to merge 1 commit into
feat/fault-generic-interfacesfrom
feat/fault-chain-error
Open

feat(chain): typed chain-error with an rpc case carrying revert bytes#214
mfw78 wants to merge 1 commit into
feat/fault-generic-interfacesfrom
feat/fault-chain-error

Conversation

@mfw78

@mfw78 mfw78 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #209; part of the typed-fault train stacked on the epic-80 stack, merges in stack order.

This gives the chain interface a typed error shape instead of the unified host-error envelope. Guests no longer trim and hex-decode a JSON-quoted revert string by hand, and a JSON-RPC error code travels as a proper s32 instead of being smushed into a domain-relative field.

Changes

wit/nexum-host/chain.wit adds record rpc-error { code: s32, message: string, data: option<list<u8>> } and variant chain-error { fault(fault), rpc(rpc-error) }. request, request-batch, and rpc-result.err all carry chain-error in place of host-error.

provider_pool.rs hex-decodes the upstream JSON error.data once, in the request closure, so ProviderError::Rpc.data is Option<Vec<u8>> rather than a raw string.

host/error.rs replaces From<ProviderError> for HostError with From<ProviderError> for ChainError. A structured ErrorResp becomes ChainError::Rpc; a transport failure becomes ChainError::Fault via a new transport_fault classifier (429 to rate-limited, 503 or backend-gone or pubsub to unavailable, a timeout substring to timeout, otherwise unavailable). A denied request becomes a chain-specific chain_denied that also returns ChainError.

host/impls/chain.rs updates request, request_batch, and resolve_method to return ChainError.

nexum-sdk mirrors RpcError and ChainError on the guest side, implements HostFault for ChainError, and adds a From<ChainError> for HostError bridge so init and on_event (which still return host-error pending their own migration) can bubble a chain failure without a breaking signature change.

decode_revert_hex is deleted; decode_revert(&[u8]) is now the sole entry point, taking raw bytes rather than a hex string.

modules/twap-monitor and the three example modules are updated to match: the poll path matches ChainError::Rpc to decode_revert(bytes) and ChainError::Fault to TryNextBlock.

Test plan

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo test --workspace --all-features (all green, including doctests)
  • Module wasms rebuilt in-tree against the updated chain.wit; nexum-runtime supervisor::tests::e2e and twap_monitor_without_cow_extension_fails_to_boot pass against the rebuilt wasm, which exercises the regenerated world and the wit-bindgen chain-error adapter link in a real component
  • New coverage added: an upstream-hex-to-decoded-bytes round trip in provider_pool, RPC code preservation (including out-of-range saturation to the internal fallback code), and the timeout, unavailable, backend-gone, and rate-limited fault classifications

Notes for reviewer

The decode lives in provider_pool, where alloy's ErrorPayload is already being inspected, so the From<ProviderError> for ChainError impl in error.rs stays a pure projection over already-decoded bytes. A non-hex error.data (a structured object rather than a revert string) decodes to None, which matches the old decode_revert_hex behaviour for a non-revert payload.

The rpc-versus-fault split keys on whether a code is present: a JSON-RPC ErrorResp always carries one, a transport failure never does, so the existing capture logic discriminates the two cleanly without new state.

host-error is deliberately untouched on every other interface (local-store, messaging, identity, remote-store, cow-api) and on init/on_event; those migrate on their own steps in this train, which is why the SDK carries a From<ChainError> for HostError bridge for now rather than a wider signature change.

Rate-limited was added to the transport classifier for a 429 response even though the issue named only timeout and unavailable; it is a faithful mapping onto the rate-limit record already present in the shared fault vocabulary, not scope creep.

Follow-ups intentionally left out of this PR: parsing a structured retry-after hint from rate-limit backoff into Fault::RateLimited.retry_after_ms (currently always None); migrating the remaining interfaces off host-error so the bridge can eventually retire; and a short rustdoc note on request-batch distinguishing a whole-batch fault from a per-entry rpc-result.err.

AI Assistance: Claude Code (Opus 4.8 implementation and adversarial review, Sonnet 5 PR authoring) used for the full change.

Replace the unified host-error return of nexum:host/chain with a typed
chain-error variant: a shared fault or a structured rpc-error record
carrying the node code and any revert payload. The host now hex-decodes
the upstream JSON-RPC error.data once into raw bytes, so a guest receives
the abi-encoded revert body directly and hands it straight to
decode_revert; the quote-stripping decode_revert_hex bridge is deleted.

Project ProviderError into the new shape in host/error.rs: a structured
ErrorResp becomes chain-error.rpc, and a transport failure classifies
into a fault (timeout, unavailable, rate-limited). Mirror ChainError and
RpcError in nexum-sdk with a HostFault impl and a From<ChainError> for
HostError bridge so a module can still bubble a chain failure out of
on_event. Update the wit-bindgen adapter, the chainlink helper, and the
twap-monitor poll path to the typed surface.
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.

1 participant