Skip to content

feat(api): EVM signed-transaction relay with policy validation #225

Description

@Emmyt24

Depends on: #218, #223. Blocks: #224, #226, #227.

Description

POST /submit-signed is the non-custodial core: the client
signs locally, Octo validates the signed envelope
(crates/api/src/submit_validation.rs), relays to
Horizon, and records history. The private key never touches the server. Deliver the EVM equivalent.

The validation layer matters as much as the relay. Octo's Stellar validator deliberately refuses to
be a blind signing oracle — it inspects the envelope and enforces policy. The EVM version must do
the same against RLP-encoded, EIP-1559 (type 2) transactions.

Requirements and context

  • Support EIP-1559 (type 2) as the default and legacy (type 0) for compatibility. Decode with a
    strict RLP parser; reject unknown transaction types rather than guessing.
  • Recover the sender from the signature and verify it matches the authorised wallet. This is the
    central check. Also verify the chainId in the signature matches the target chain — a
    transaction signed for chain A must never be relayed to chain B
    (EIP-155 replay protection exists precisely because
    this was once possible).
  • Enforce policy on the decoded transaction, mirroring submit_validation.rs: destination
    against the withdrawal allowlist
    (0013_withdrawal_allowlist.sql),
    token contract against the registry (feat(store): ERC-20 token registry #223), and amount limits. For an ERC-20 transfer this means
    decoding the calldatatransfer(address,uint256) selector 0xa9059cbb — because the
    transaction's to is the token contract, not the recipient. Validating to alone would allow
    transfers to any recipient.
  • The withdrawal-OTP flow in submit.rs must work identically
    for EVM. Reuse it; do not fork it.
  • Generalise explain_code (Stellar result codes) into ChainAdapter::explain_failure, decoding
    EVM revert reasons (Error(string) selector 0x08c379a0) into readable messages.
  • Security: never accept a raw pre-signed blob and relay it unvalidated. Never log the full
    signed transaction. Enforce a body-size limit — RLP decoding untrusted input is an attack surface;
    use a size cap and a recursion-depth-bounded parser.

Suggested execution

Branch: feat/evm-signed-tx-relay

Implement changes

  • Add crates/api/src/evm_submit_validation.rs: decode the signed transaction, recover the sender,
    verify chain id and sender, decode ERC-20 calldata, and enforce allowlist/registry/limit policy.
  • Extend the submit route to dispatch on the wallet's chain_id, sharing the OTP, audit
    (crates/api/src/audit.rs), and webhook paths with Stellar.
  • Implement explain_failure for EVM: revert-reason decoding plus common
    pre-broadcast failures (nonce too low, replacement transaction underpriced,
    insufficient funds for gas * price + value).
  • Record the relayed transaction with chain_id, hash, and block, feeding the same ledger the
    ingest worker writes to.

Test and commit

  • Test that a transaction signed for chain A is rejected when submitted to chain B.
  • Test that a transaction whose recovered sender is not the authorised wallet is rejected.
  • Test calldata decoding: an ERC-20 transfer to a non-allowlisted recipient is rejected even
    though the transaction's to (the token contract) is registered.
    This is the test that proves
    the validator is not fooled by the indirection.
  • Test rejection of an unregistered token contract.
  • Anvil test (chore(testing): Anvil-based EVM integration test harness #219): a valid signed transfer relays, mines, and is recorded.
  • Test revert-reason decoding produces a readable message.
  • Malformed-input tests in the style of
    crates/api/tests/malformed_body_tests.rs:
    truncated RLP, oversized body, deeply nested RLP, unknown tx type — none may panic.
  • Update docs/openapi.yaml, docs/api.md, and
    docs/non-custodial-flow.md.

Example commit message

feat(api): EVM signed-transaction relay with policy validation

Mirrors the Stellar /submit-signed contract: the client signs locally
and Octo validates, relays, and records — never a blind signing oracle.

Validation recovers the sender from the signature, pins the EIP-155
chain id so a transaction signed for one chain cannot be relayed to
another, and decodes ERC-20 calldata so allowlist checks apply to the
actual recipient rather than to the token contract in `to`.

Refs #225

Guidelines

Security-sensitive. The calldata-decoding check is the piece reviewers will scrutinise — make it
obvious and well-commented.


Metadata

Metadata

Assignees

No one assigned

    Labels

    area/backendBackend crates: api, store, ingest, webhooks, bin/serverdifficulty/hardHard, complex, cross-cuttingrisk/security-sensitiveTouches key material, signing, or fund movement — review carefullytype/epicTracking issue for a multi-issue epic

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions