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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ Top-level layout follows the system's data flow. Each sequencer module correspon
- `sequencer/src/harness.rs` — CLI harness: the `setup`/`run`/`flush-mempool` subcommand parser, `dispatch`, and the exit-code projection. An app's `main` is ~5 lines (`run_main` + a genesis-app closure).
- `sequencer/src/http.rs` — shared HTTP error type, JSON `ErrorResponse`, `ApiConfig`, and `axum::serve` orchestration.
- `sequencer/src/commands/` — the operator command brackets: `setup` (phase A — pin identity, initial sync, genesis snapshot, atomic `setup_complete` fact), `run` (phase B — recover, prepare, admit, and boot workers; its `workers` supervisor lives beside it), and `flush` (`flush-mempool`). `sequencer/src/commands/` also owns the command-scoped `config` and `error` taxonomy (incl. the exit-code projection); `sequencer/src/runtime/` is exactly the runtime authority capabilities — the process lock and `shutdown` (runtime scope and graceful notification) — consumed crate-wide. `L1Config` lives in `sequencer/src/l1/`; the crate-wide wall clock is `sequencer/src/clock.rs`.
- `sequencer/src/ingress/` — public write path.
- `api.rs` — `POST /tx` handler, JSON-rejection mapping.
- `sequencer/src/ingress/` — public-facing HTTP + inclusion lane.
- `api.rs` — `POST /tx` and `GET /fee` handlers, JSON-rejection mapping.
- `inclusion_lane/` — single-lane hot-path loop (`mod.rs`), catch-up replay, config, error types.
- `sequencer/src/egress/` — internal read path.
- `api/` — `/ws/subscribe`, `/livez`, `/readyz`, `/healthz`.
Expand Down Expand Up @@ -300,7 +300,7 @@ restate them here.

## HTTP Endpoints

- **Ingress** (public-facing): `POST /tx`.
- **Ingress** (public-facing): `POST /tx`, `GET /fee`.
- **Egress** (internal indexers/watchdog): `GET /ws/subscribe`, `GET /finalized_state`, `GET /finalized_state/inclusion_block`, `GET /latest_snapshot`, `GET /livez`, `GET /readyz`, `GET /healthz`. The snapshot/state endpoints are **operator-only** (no auth) and must not be exposed publicly; the streaming routes hold a GC lease for the response lifetime ([`docs/snapshots/lifecycle.md`](docs/snapshots/lifecycle.md)).

Today both sides serve from one listener; the planned API split puts each side on its own port (same binary) so internal probes and subscribers can be firewalled from public submit traffic.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Rust edition 2024 / Axum API / SQLite (rusqlite, WAL) / EIP-712 signing / SSZ en
`error` taxonomy (incl. exit-code projection).
- `runtime/` — the runtime authority capabilities, consumed crate-wide:
the exclusive process lock and the runtime scope/shutdown machinery.
- `ingress/` — public write path: `api.rs` (`POST /tx`) + `inclusion_lane/` (hot path).
- `ingress/` — public-facing: `api.rs` (`POST /tx`, `GET /fee`) + `inclusion_lane/` (hot path).
- `egress/` — internal read path: `api/` (WS subscribe + health) + `l2_tx_feed/`.
- `l1/` — reader, submitter, fee oracle, provider, partition helper.
- `recovery/` — startup preemptive-recovery procedure, runtime danger detector, mempool flusher.
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,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.
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.

### Sequenced Transaction Feed

Expand Down Expand Up @@ -166,7 +166,23 @@ 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` from any origin with any request headers; preflight permits 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` 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.

### `GET /fee`

Fee quote for setting signed user-op `max_fee` before `POST /tx`. All three fields are log-space exponents (base 129/128), the same encoding as `max_fee`. Inclusion rejects any op with `max_fee` below the open-frame `fee`.

```json
{ "fee": 1356, "recommended_fee": 1356, "suggested_max_fee": 1409 }
```

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).
- `503` with code `UNAVAILABLE` during shutdown, or if no open frame exists.

### `GET /ws/subscribe?from_offset=<u64>`

Expand Down Expand Up @@ -237,7 +253,7 @@ released even on client disconnect.
- `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 write path — `POST /tx` (`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` and `GET /fee` (`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
2 changes: 1 addition & 1 deletion docs/threat-model/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ What we are protecting:
| Batch-submitter private key | Private | Held in operator infra. Not reachable by the network. |
| Sequencer's own code | Trusted (bug-free is a precondition) | Bugs are prevented through tests/review and contained by fail-loud runtime invariant checks; they are not treated as adversarial behavior that the protocol can recover around. See "self-trust" below. |
| **L1 mempool and block builders** | **Fully adversarial** | May reorder, delay, drop, or selectively include submitted transactions. Private mempools mean "dropped" is indistinguishable from "delayed indefinitely." |
| HTTP clients at `POST /tx` | Untrusted | Arbitrary public callers. May submit malformed, malicious, or replay payloads. |
| HTTP clients at `POST /tx` and `GET /fee` | Untrusted | Arbitrary public callers. May submit malformed, malicious, or replay payloads. `GET /fee` is an intentional public quote of the open-frame fee. |
| WebSocket subscribers at `/ws/subscribe` | Internal, but untrusted for data-exposure | Intended for internal indexers. Treat as public for what is exposed. |
| Direct-input senders on L1 | Untrusted | Arbitrary L1 accounts calling InputBox. May submit any calldata. |

Expand Down
6 changes: 3 additions & 3 deletions docs/watchdog/operator-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ For **local development only** (Anvil + `sequencer-devnet`, CI smoke tests), use
## Two deployment tiers

```text
┌─────────────────────────────────────┐
Internet / users │ Public ingress (POST /tx, WS) │ ← benchmarks, wallets
└────────────────────────────────────┘
┌──────────────────────────────────────────────
Internet / users │ Public ingress (POST /tx, GET /fee, WS) │ ← benchmarks, wallets
└──────────────────────┬───────────────────────┘
┌─────────────────▼───────────────────┐
Operator network │ Sequencer process │
Expand Down
10 changes: 10 additions & 0 deletions sdk/rust-client/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ pub enum SubmitRejected {
Decode(String),
}

#[derive(Debug, Error)]
pub enum GetFeeError {
#[error("fee request failed: {0}")]
Transport(#[from] SubmitTxError),
#[error("/fee rejected with status {status}: {body}")]
Http { status: u16, body: String },
#[error("invalid /fee success body: {0}")]
Decode(String),
}

#[derive(Debug, Error)]
pub enum SubscribeError {
#[error("invalid endpoint: {0}")]
Expand Down
23 changes: 21 additions & 2 deletions sdk/rust-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

mod errors;

pub use errors::{ClientBuildError, SubmitRejected, SubmitTxError, SubscribeError};
pub use errors::{ClientBuildError, GetFeeError, SubmitRejected, SubmitTxError, SubscribeError};

use sequencer_core::api::{TxRequest, TxResponse};
use sequencer_core::api::{FeeResponse, TxRequest, TxResponse};
use std::time::Duration;
use tokio::net::TcpStream;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
Expand Down Expand Up @@ -102,6 +102,25 @@ impl SequencerClient {
serde_json::from_str::<TxResponse>(&body).map_err(|e| SubmitRejected::Decode(e.to_string()))
}

pub async fn get_fee(&self) -> Result<FeeResponse, GetFeeError> {
let url = format!("{}/fee", self.endpoint.trim_end_matches('/'));
let response = self
.http_client
.get(&url)
.send()
.await
.map_err(map_reqwest_error)?;
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|e| SubmitTxError::IoRead(e.to_string()))?;
if status != 200 {
return Err(GetFeeError::Http { status, body });
}
serde_json::from_str::<FeeResponse>(&body).map_err(|e| GetFeeError::Decode(e.to_string()))
}

pub async fn subscribe(&self, from_offset: u64) -> Result<SubscribeStream, SubscribeError> {
let url = self.ws_subscribe_url(from_offset);
let (stream, _response) = connect_async(url.as_str())
Expand Down
24 changes: 24 additions & 0 deletions sequencer-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,30 @@ pub struct TxResponse {
pub nonce: u32,
}

/// Fee quote for wallets that need to set signed `max_fee` before `POST /tx`.
///
/// `fee` is frozen on the open frame (the live inclusion check).
/// `recommended_fee` is what the next frame will sample.
/// `suggested_max_fee` is `max(fee, recommended_fee)` plus 1.5× log-space
/// slack — the value a wallet can copy into `max_fee` so the signature
/// survives a frame rotation. The user pays the frame fee, not this cap.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FeeResponse {
pub fee: u16,
pub recommended_fee: u16,
pub suggested_max_fee: u16,
}

impl FeeResponse {
pub fn quote(fee: u16, recommended_fee: u16) -> Self {
Self {
fee,
recommended_fee,
suggested_max_fee: crate::fee::suggested_signing_max_fee(fee, recommended_fee),
}
}
}

pub type WsTxMessage = BroadcastTxMessage;

fn decode_hex_0x(value: &str) -> Result<Vec<u8>, String> {
Expand Down
46 changes: 46 additions & 0 deletions sequencer-core/src/fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,26 @@ pub fn log_fee_ratio(num: u64, denom: u64) -> i32 {
}
}

/// Smallest log-space exponent `n` such that `(129/128)^n >= 3/2`.
///
/// Signing slack for [`suggested_signing_max_fee`]: wallets may over-estimate
/// `max_fee` because the user pays the frame fee, not the signed cap. 1.5×
/// sits on top of `max(open-frame fee, recommended_fee)` so a quote can
/// survive a frame rotation. Distinct from `batch_policy.log_slack` (the 10×
/// DA margin already baked into both inputs).
pub const SUGGESTED_MAX_FEE_SLACK: u16 = 53;

/// Suggested user-op `max_fee`: `max(frame_fee, recommended_fee)` plus
/// [`SUGGESTED_MAX_FEE_SLACK`] (1.5× in log space), clamped to
/// [`MAX_EXPONENT`].
pub fn suggested_signing_max_fee(frame_fee: u16, recommended_fee: u16) -> u16 {
let base = frame_fee.max(recommended_fee);
match base.checked_add(SUGGESTED_MAX_FEE_SLACK) {
Some(sum) if sum <= MAX_EXPONENT => sum,
_ => MAX_EXPONENT,
}
}

/// Fixed-point multiplication: `(a * b) >> FRAC_BITS`.
///
/// Uses a 512-bit intermediate to avoid overflow.
Expand Down Expand Up @@ -313,6 +333,32 @@ mod tests {
assert_eq!(log_fee_ratio(10, 1), 296);
}

#[test]
fn suggested_max_fee_slack_is_ceil_three_halves() {
assert_eq!(SUGGESTED_MAX_FEE_SLACK, 53);
// Nearest-rounding log(1.5) undercharges; the signing slack is ceil.
assert!(log_fee_ratio(3, 2) < i32::from(SUGGESTED_MAX_FEE_SLACK));
let base = 1000_u16;
let suggested = suggested_signing_max_fee(base, 0);
assert_eq!(suggested, base + SUGGESTED_MAX_FEE_SLACK);
assert!(
fee_to_linear(suggested) * U256::from(2u64) >= fee_to_linear(base) * U256::from(3u64)
);
}

#[test]
fn suggested_signing_max_fee_uses_the_higher_input_and_clamps() {
assert_eq!(
suggested_signing_max_fee(100, 200),
200 + SUGGESTED_MAX_FEE_SLACK
);
assert_eq!(suggested_signing_max_fee(MAX_EXPONENT, 0), MAX_EXPONENT);
assert_eq!(
suggested_signing_max_fee(MAX_EXPONENT - 1, MAX_EXPONENT - 10),
MAX_EXPONENT
);
}

#[test]
fn fee_from_linear_zero_and_one() {
assert_eq!(fee_from_linear(U256::ZERO), 0);
Expand Down
2 changes: 1 addition & 1 deletion sequencer/src/commands/run/workers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ impl<A: Application + 'static> PreparedRuntime<A> {
let submitter = submitter.start_preflighted(shutdown.clone());
let detector = detector.start_preflighted(shutdown.signal());
let fee_oracle = fee_oracle.map(|oracle| oracle.start(shutdown.signal()));
// HTTP server (ingress /tx + egress /ws/subscribe + /health, currently merged).
// HTTP server (ingress /tx + /fee + egress /ws/subscribe + /health, currently merged).
let server = http::start_on_listener(
listener,
tx,
Expand Down
Loading