Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ Paths below are relative to `sequencer/src/`:

- API validates the EIP-712 signature and enqueues a `SignedUserOp`. Method payload decoding happens during application execution, not at ingress.
- **Deposits are direct-input-only** (L1 → L2) and must not be represented as user ops.
- Rejections (`InvalidNonce`, `InvalidMaxFee`, `InsufficientFeeBalance`) produce no state mutation and are not persisted. These are protocol-level rejection semantics every app must implement: nonces prevent user-op replay, fees prevent spam against the sequencer's DA budget. ("Fee", not "gas" — the fee tracks DA; compute metering, if it ever exists, is a separate future concept.)
- Rejections (`InvalidNonce`, `NonceExhausted`, `InvalidMaxFee`, `InsufficientFeeBalance`) produce no state mutation and are not persisted. These are protocol-level rejection semantics every app must implement: nonces prevent user-op replay, fees prevent spam against the sequencer's DA budget. ("Fee", not "gas" — the fee tracks DA; compute metering, if it ever exists, is a separate future concept.)
- **The user-nonce rule is contract, not wallet detail:** accounts start at 0, an op must carry exactly the expected nonce, each included op advances it by one, and direct inputs never touch it. `GET /nonce` derives from persisted user ops under this rule instead of asking the app ([application contract](docs/protocol/application-contract.md#user-nonces)).
- Included txs are persisted as frame/batch data in `batches`, `frames`, `user_ops`, `safe_inputs`, and `application_inputs`. Recovery metadata lives in `safe_accepted_batches`; batch lifecycle state (sealed/invalidated) lives on the `batches` row itself as write-once timestamps.
- Frame fee is persisted in `frames.fee` and is fixed for the lifetime of that frame. The next frame's fee is currently sampled from `batch_policy_derived.recommended_fee` at rotation; oracle bootstrap writes the price before any Tip can sample it, and `log_slack` applies the 10× margin in log space. This is present behavior, not a reason for the five-block clock policy; hoisting fee to the batch is a later design with its own trade-offs.
- Wallet balances and nonces live in memory between checkpoints; restart restores
Expand Down Expand Up @@ -239,7 +240,7 @@ Implementors of the `Application` trait must respect these contracts. The shared
The sequencer persists every included user op and every ingested direct input. On restart, catch-up replays them in order against a fresh `Application` instance to rebuild state. **Any input that succeeded live must succeed on replay.**

- `apply_direct_input` and `apply_valid_user_op` must not return `AppError::Internal` for any byte sequence that previously executed successfully. The canonical scheduler, catch-up, and recovery fold treat `Internal` as fatal: no canonical successor is defined.
- Validation returns `Accept`, `Reject(InvalidReason)`, or fatal `AppError`. A rejected op changes no state. Included business failures and malformed direct-input no-ops still advance progress; do not turn them into validation rejections. See the application contract for the wallet's nonce/fee semantics.
- Validation returns `Accept`, `Reject(InvalidReason)`, or fatal `AppError`. A rejected op changes no state. Included business failures and malformed direct-input no-ops still advance progress; do not turn them into validation rejections. See the application contract for the nonce rule and the wallet's fee semantics.
- `validate_user_op` must be pure over the current app state. No side effects, no time dependence, no randomness.

### No implicit state
Expand Down Expand Up @@ -283,7 +284,7 @@ enforces write-once batch lifecycle, Tip uniqueness, and user-op identity.

## HTTP Endpoints

- **Ingress** (public-facing): `POST /tx`, `GET /fee`.
- **Ingress** (public-facing): `POST /tx`, `GET /fee`, `GET /nonce`, `GET /domain`.
- **Egress** (internal indexers/watchdog): application-input subscriptions,
snapshot/state downloads, and health probes. Snapshot/state endpoints have no
authentication and **must not be exposed publicly**. Downloads hold a GC lease
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 35 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ The sequencer is designed to handle:

### User Operations

Users submit signed operations via `POST /tx` (JSON). Operations are signed with EIP-712 using the rollup's chain ID and app address. The sequencer validates the signature, executes the operation against the current app state, and returns a soft confirmation. `GET /fee` quotes the live frame fee, the next-frame recommendation, and a suggested `max_fee` a wallet can sign.
Users submit signed operations via `POST /tx` (JSON). Operations are signed with EIP-712 using the rollup's chain ID and app address. The sequencer validates the signature, executes the operation against the current app state, and returns a soft confirmation. To sign, a wallet reads `GET /fee` (the live frame fee, the next-frame recommendation, and a suggested `max_fee`), `GET /nonce?sender=` (the nonce to sign next), and `GET /domain` (the EIP-712 domain, to assert against the one it pins).

### Sequenced Transaction Feed

Expand Down Expand Up @@ -167,8 +167,9 @@ Most queue sizes, polling intervals, and safety limits are now internal runtime

## API

JSON `sender` fields in successful `POST /tx` responses and WebSocket messages
use EIP-55 checksum casing. Address fields in `/history` and `sender` fields in
JSON `sender` fields in successful `POST /tx` and `GET /nonce` responses and
WebSocket messages, and `verifyingContract` in `GET /domain`, use EIP-55
checksum casing. Address fields in `/history` and `sender` fields in
`/historical-l1-inputs` use lowercase hex. Clients must compare decoded 20-byte
addresses and use one normalized encoding for account or projection keys across
these routes.
Expand Down Expand Up @@ -197,7 +198,7 @@ Notes:
- payload size is bounded at ingress; oversized requests are rejected before entering the hot path.
- overload is enforced at queue admission: if the inclusion-lane queue is full, `POST /tx` returns HTTP `429` with code `OVERLOADED` and message `queue full`.
- queue capacity is an internal runtime constant tuned alongside inclusion-lane chunking to absorb short bursts; if this starts triggering persistently, it is a signal to revisit runtime sizing or throughput rather than add another admission layer.
- Browser wallets can call `POST /tx` and `GET /fee` from any origin with any request headers; preflight permits GET and POST and is cached for one hour. CORS is applied only to ingress. Egress routes remain operator-only and require network access controls.
- Browser wallets can call `POST /tx`, `GET /fee`, `GET /nonce`, and `GET /domain` from any origin with any request headers; preflight permits GET and POST and is cached for one hour. CORS is applied only to ingress. Egress routes remain operator-only and require network access controls.

Success response after inclusion:

Expand All @@ -222,9 +223,37 @@ Notes:
- `fee` is frozen for the lifetime of the open frame (the live inclusion check).
- `recommended_fee` is what the next frame will sample at rotation (currently after five newly-safe L1 blocks, best-effort).
- `suggested_max_fee` is `max(fee, recommended_fee)` plus 1.5× log-space slack. Wallets can copy this into signed `max_fee`; the user pays the frame fee, not this cap. Clients that want their own policy can ignore it and combine the two facts themselves.
- `200` while an open frame exists (the admitted runtime always has one).
- `200` while an open frame exists (the admitted runtime always has one). Responses carry `Cache-Control: no-store`.
- `503` with code `UNAVAILABLE` during shutdown, or if no open frame exists.

### `GET /nonce?sender=<address>`

The nonce to sign into `sender`'s next user op.

```json
{ "sender": "0xAbC...", "next_nonce": 7 }
```

Notes:

- `sender` is parsed like the `POST /tx` field: `0x` plus 40 hex digits in any case, echoed in EIP-55 casing. A missing or malformed parameter is `400` with code `BAD_REQUEST`.
- `next_nonce` is one past the sender's latest included op, or 0 if it has none. It counts soft-confirmed ops: an op acknowledged with `200` before this request is counted. (The `nonce` in a `POST /tx` response or WS message is instead the nonce that op consumed.)
- Keep one op in flight per sender: sign `next_nonce`, submit, and increment on `200`. Concurrent submits from one sender are unsupported; they can reach the sequencer out of order and be rejected. After a submit times out, a `next_nonce` above the op's nonce means it was included; an unchanged one is inconclusive, since the op may still be queued, so resubmit the same signed op rather than signing a new one at that nonce.
- `next_nonce` of 4294967295 (`u32::MAX`) means the account is exhausted: that nonce has no successor, so `POST /tx` can include no further op from it.
- The value is a hint, not a reservation; the application is authoritative. It can go down, because a restart that runs automatic recovery may invalidate soft-confirmed ops. After a `422` bad-nonce rejection, query again rather than incrementing.
- After an operator rebuild from a checkpoint (`setup --recovery`), a sender with no surviving op since the rebuild reads 0 even when its nonce in the rebuilt baseline is higher; this includes a sender whose post-rebuild ops a later recovery invalidated. A `422` bad-nonce rejection names the expected nonce (`bad nonce: expected N, got M`).
- Responses carry `Cache-Control: no-store`. `503` with code `UNAVAILABLE` during shutdown.

### `GET /domain`

The EIP-712 domain `POST /tx` verifies signatures against, keyed as `eth_signTypedData_v4` expects:

```json
{ "name": "CartesiAppSequencer", "version": "1", "chainId": 31337, "verifyingContract": "0x..." }
```

Clients pin their own domain and assert that it matches this one, the way a wallet checks `eth_chainId`. `chainId` is a JSON number, exact in JavaScript below 2^53. That covers every chain browser wallets accept (MetaMask refuses IDs above 4503599627370476); a larger ID fails closed, because a signature over a rounded `chainId` recovers a different sender at `POST /tx`. Do not sign with a served domain unchecked: `chainId` and `verifyingContract` are what keep a signature from replaying on another deployment, and the name and version are the same everywhere.

### `GET /ws/subscribe?era_id=<uuid>&recovery_generation=<u64>&next_input=<u64>`

WebSocket stream of the current application history, replaying from the inclusive
Expand Down Expand Up @@ -450,7 +479,7 @@ They do not certify L1 freshness, submitter balance, or canonical agreement.
- `examples/wallet-sequencer/`: binary crate composing the sequencer library with the placeholder wallet app
- `sequencer/src/http.rs`: shared HTTP error type, JSON error shape, and `axum::serve` orchestration
- `sequencer/src/runtime/`: process lock and shutdown scope; command bootstrap and config live in `commands/`, the shared clock in `clock.rs`, and EIP-712 domain construction in `sequencer-core/`
- `sequencer/src/ingress/`: public-facing — `POST /tx` and `GET /fee` (`api.rs`) and the inclusion lane (`inclusion_lane/`: hot-path loop, chunk/frame/batch rotation, catch-up, snapshot lifecycle)
- `sequencer/src/ingress/`: public-facing — `POST /tx`, `GET /fee`, `GET /nonce`, and `GET /domain` (`api.rs`) and the inclusion lane (`inclusion_lane/`: hot-path loop, chunk/frame/batch rotation, catch-up, snapshot lifecycle)
- `sequencer/src/egress/`: internal read path — WS subscribe + health probes (`api/`) and the DB-backed ordered-L2Tx feed (`l2_tx_feed/`)
- `sequencer/src/l1/`: L1 client surface — input reader, batch submitter, fee oracle, shared EIP-1559 estimation, provider, partition helper
- `sequencer/src/recovery/`: preemptive recovery startup, runtime danger detector, mempool flusher
Expand Down
21 changes: 15 additions & 6 deletions bindings/c-app-engine/include/application-engine.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
/// Validation acceptance, state transitions, progress, and outputs must agree with the canonical
/// application. Rejection diagnostics and error messages need not be identical across builds.
///
/// Nonces follow one rule, which the host relies on to serve each sender's next nonce from the
/// ops it persisted: a genesis state starts every account at 0, validation accepts only the
/// sender's expected nonce, each executed user op advances its sender's nonce by exactly one, and
/// nothing else changes a nonce. UINT32_MAX has no successor, so the caller rejects an op carrying
/// it even when validation accepts it. See docs/protocol/application-contract.md.
///
/// Fees are uint16_t exponents with base 129/128, denominated in the fee token's smallest unit.
/// The conversion is defined by sequencer-core/src/fee.rs and its build.rs-generated table:
/// integer fixed-point arithmetic with 64 fractional bits, including its rounding and exponent
Expand Down Expand Up @@ -142,8 +148,9 @@ typedef struct ApplicationEngineUserOp {
} ApplicationEngineUserOp;

/// @brief A user op that already passed validation, as the caller sequenced it.
/// @details Not the signed op: execution consumes the nonce the state expects, and the max fee
/// went with the guard the caller already settled, leaving the fee the frame charges.
/// @details Not the signed op: execution consumes the nonce the state expects, advancing it by
/// exactly one, and the max fee went with the guard the caller already settled, leaving the fee
/// the frame charges.
typedef struct ApplicationEngineValidUserOp {
ApplicationEngineEthereumAddress sender; ///< The recovered signer.
uint16_t fee; ///< The charged frame fee exponent, base 129/128.
Expand All @@ -166,6 +173,7 @@ typedef enum ApplicationEngineInvalidReason {
APPLICATION_ENGINE_INVALID_NONCE = 0, ///< Nonce or account binding, read `nonce`.
APPLICATION_ENGINE_INVALID_MAX_FEE = 1, ///< The caller-owned max fee guard, read `max_fee`.
APPLICATION_ENGINE_INSUFFICIENT_FEE_BALANCE = 2, ///< Cannot cover the frame fee, read `fee_balance`.
APPLICATION_ENGINE_NONCE_EXHAUSTED = 3, ///< The caller-owned UINT32_MAX nonce guard, no values.
} ApplicationEngineInvalidReason;

/// @brief Diagnostics for APPLICATION_ENGINE_INVALID_NONCE.
Expand Down Expand Up @@ -289,14 +297,15 @@ APPLICATION_ENGINE_API void application_engine_destroy(ApplicationEngine *engine
/// @param out_invalid Why the op was refused, written whole and only on INVALID.
/// @returns OK, INVALID with diagnostics, IO_ERROR, or INTERNAL_ERROR.
/// @details A rejection reports itself through out_invalid and leaves the last error message
/// empty; IO_ERROR and INTERNAL_ERROR carry an error message. The max-fee guard belongs to the
/// caller and is never checked here, so APPLICATION_ENGINE_INVALID_MAX_FEE never comes back. Queued
/// empty; IO_ERROR and INTERNAL_ERROR carry an error message. The max-fee and exhausted-nonce
/// guards belong to the caller and are never checked here, so APPLICATION_ENGINE_INVALID_MAX_FEE
/// and APPLICATION_ENGINE_NONCE_EXHAUSTED never come back. Queued
/// outputs are left alone, only an execution touches them.
APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_validate_user_op(const ApplicationEngine *engine,
const ApplicationEngineEthereumAddress *sender, const ApplicationEngineUserOp *user_op, uint16_t current_fee,
ApplicationEngineInvalid *out_invalid) APPLICATION_ENGINE_NOEXCEPT;

/// @brief Execute a validated user op, consuming the current expected nonce.
/// @brief Execute a validated user op, advancing its sender's expected nonce by exactly one.
/// @param engine The engine handle.
/// @param user_op The validated op to execute.
/// @param safe_block The covering frame safe block, folded into the clock as max(clock, it).
Expand All @@ -319,7 +328,7 @@ APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_execute_valid_
/// @param out_output_count How many outputs this input left waiting, written only on OK.
/// @returns OK, IO_ERROR, or INTERNAL_ERROR. An error requires discarding the instance.
/// @details An input the engine rejects is a counted no-op and still reports OK, the same way a
/// rejected user op does. Outputs behave as they do for a user op.
/// rejected user op does. Outputs behave as they do for a user op. It never changes a nonce.
APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_execute_direct_input(ApplicationEngine *engine,
const ApplicationEngineDirectInput *input, uint64_t *out_output_count) APPLICATION_ENGINE_NOEXCEPT;

Expand Down
2 changes: 1 addition & 1 deletion bindings/c-app-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ impl Application for EngineApp {
available: U256::from_be_bytes(balance.available.bytes),
}
}
// Max-fee rejection belongs to the shared execution boundary.
// Max-fee and exhausted-nonce rejections belong to the shared execution boundary.
other => {
return Err(internal(format!(
"engine reported unsupported invalid reason {other}"
Expand Down
9 changes: 9 additions & 0 deletions docs/plans/2026-07-coordination-tracks.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ Remaining checks need the actual consumer:
- Decide output storage, checkpoint layout, ABI version negotiation, generated
bindings, and linker policy from concrete engine requirements. The current
drain protocol and path callback remain the contract until then.
- Make `GET /nonce` exact for senders idle since a rebuilt baseline; today
they read 0 ([application contract](../protocol/application-contract.md#user-nonces)).
Proposed: an `Application` nonce query plus an enumeration method. Setup
already holds the baseline engine when it publishes the baseline, so it can
enumerate it into a write-once `baseline_nonces` table in the same
transaction, the user-nonce counterpart of `batch_tree_anchor`. The point
query also lets the shared execution boundary check the one-step nonce
advance per op, as it checks progress. Both need C ABI exports and the
engine owner's agreement.

Additional checkpoint primitives or asynchronous scheduling need a measured
requirement. Any future microbatch priority scheme must preserve per-account
Expand Down
Loading
Loading