Skip to content
Open
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
5 changes: 5 additions & 0 deletions Cargo.lock

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

32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,19 @@ When batches risk becoming stale on L1, the sequencer stops serving and startup
determines the required repair. A lost or untrustworthy local state instead
requires an operator rebuild. Both recovery modes are described below.

The current signing/batch format uses domain version 2 and requires
`valid_until_block`. It requires a new canonical image and fresh setup; old
signatures, pending batches, and baseline databases are incompatible.

## Trust Model

The sequencer is a **centralized, single-writer** system. It cannot steal funds or forge invalid state — the rollup validates everything independently, and the proof system later enforces it. But the sequencer can:
The sequencer is a **centralized, single-writer** system. The rollup independently validates canonical execution, but fund availability after foreclosure requires an [application-specific exit](docs/protocol/application-contract.md#8-foreclosure-and-emergency-exits), demonstrated by the reference wallet and required of each production app. The sequencer can:

- **Censor** — refuse to include a user's operations.
- **Go offline** — stop providing soft confirmations.
- **Diverge** — if batches fail to land on L1 in time, soft confirmations that were issued become invalid.

**Direct inputs** (L1 → L2 messages, used for deposits) bypass the sequencer entirely. They are posted directly to L1 and are **uncensorable** by the sequencer — the scheduler drains them at every `safe_block` boundary. A censoring sequencer can delay when a direct input is executed (up to `MAX_WAIT_BLOCKS`, ~4h), but cannot prevent it.
**Direct inputs** (L1 → L2 messages, used for deposits) bypass the sequencer entirely. They are posted directly to L1 and are **uncensorable** by the sequencer — the scheduler drains them at every `safe_block` boundary. A censoring sequencer can delay when a direct input is executed (up to `MAX_WAIT_BLOCKS`, ~4h), but cannot prevent it while later L1 inputs continue to drive the scheduler.

During normal operation the sequencer advances logical frame time after five
newly-safe blocks have accumulated. That clock tick drains every covered direct
Expand Down Expand Up @@ -155,12 +159,17 @@ environment:
CARTESI_SEQUENCER_ALLOW_INSECURE_RPC: "true"
```

Startup omits the RPC URL, plaintext opt-in warnings show only its origin, and
request transport errors strip reqwest's attached URL before retry wrappers
format it. RPC response bodies and dependency debug/trace logging can still
contain server-supplied secrets; keep diagnostic logs private.

Process exit codes follow the orchestrator exit-code contract: `0` clean shutdown, `10` restart (expect a recovery boot), `20` transient refusal (retry with backoff), `30` terminal command refusal (operator required — e.g. setup not complete, identity mismatch, canonical divergence, persistent storage/application invariant failure), `40` a previous instance left work past the checkpoint (wipe the data directory and run `setup --recovery`), and `1` for an unclassified operational failure. Diagnosed terminal runtime faults, including worker panics, immediately call `abort()` (SIGABRT, status 134), without worker drain or database settlement. Supervisors must treat SIGABRT as terminal class. Ordinary shutdown, expected recovery, and transient errors still drain gracefully. Startup panics caught by the command harness are projected to `30`; panics after runtime scope creation abort; `101` remains possible before the command harness starts. The constants live in `sequencer/src/commands/error.rs`; supervisor recipes are in [`docs/watchdog/operator-deployment.md`](docs/watchdog/operator-deployment.md).

Fixed protocol identity (EIP-712):

- domain name: `CartesiAppSequencer`
- domain version: `1`
- domain version: `2`
- `chain_id` and `verifying_contract` come from `CARTESI_SEQUENCER_BLOCKCHAIN_ID` and `CARTESI_SEQUENCER_APP_ADDRESS`

Most queue sizes, polling intervals, and safety limits are now internal runtime constants instead of public launch-time configuration.
Expand Down Expand Up @@ -195,7 +204,8 @@ Request shape:
{
"message": {
"nonce": 0,
"max_fee": 1,
"max_fee": 1418,
"valid_until_block": 1120,
"data": "0x..."
},
"signature": "0x...",
Expand Down Expand Up @@ -226,10 +236,10 @@ Success response after inclusion:

### `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`.
Fee quote for setting signed user-op `max_fee` before `POST /tx`. The three fee 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 }
{ "fee": 1365, "recommended_fee": 1365, "suggested_max_fee": 1418, "safe_block": 1000 }
```

Notes:
Expand All @@ -240,6 +250,13 @@ Notes:
- `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.

Choose `UserOp.valid_until_block` from `safe_block` plus an explicit signing
lifetime (for example 120 blocks). The inclusive deadline uses the frame clock,
not wall time or L1 inclusion time. Expiry consumes neither nonce nor fee. A
delayed batch retains its frame clock, while scheduler staleness independently
bounds its inclusion delay. `u64::MAX` explicitly chooses no practical expiry;
it is not a default. Price/slippage constraints belong in app method payloads.

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

The nonce to sign into `sender`'s next user op.
Expand All @@ -253,6 +270,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.
- Recovery can rewind the nonce. A revoked confirmation does not revoke its signature; see [signature validity after recovery](docs/recovery/README.md#revoked-confirmations-and-signed-operations) before replacing an operation.
- `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`).
Expand All @@ -263,7 +281,7 @@ Notes:
The EIP-712 domain `POST /tx` verifies signatures against, keyed as `eth_signTypedData_v4` expects:

```json
{ "name": "CartesiAppSequencer", "version": "1", "chainId": 31337, "verifyingContract": "0x..." }
{ "name": "CartesiAppSequencer", "version": "2", "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.
Expand Down
10 changes: 9 additions & 1 deletion docs/cartesi-machine.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ identifiers; loading rejects any mismatch. A CM bump therefore invalidates every
stored image, release tarball, and watchdog checkpoint: rebuild the canonical
images and re-`init` watchdog state directories. The kernel
(`machine-linux-image`) and guest tools (`machine-guest-tools`) are paired with
the emulator release and move with it. The development shell pins the same
the emulator release and move with it. The kernel download must match the
SHA-512 pinned in `toolchain-pins.env`; the release manifest records that pin. The development shell pins the same
emulator through the parent flake.

**A deployed application is pinned to its emulator version.** Its on-chain
Expand Down Expand Up @@ -52,6 +53,13 @@ Any other host, including the watchdog, must match it exactly:
A fixed point is permanent: once the canonical machine reaches one, no later
input is ever processed for that application.

## Merkle proof geometry

In v0.21, `get_proof` returns `sibling_hashes` from leaf to root, matching the
rollups-contracts proof arrays. Account paths stop at the accounts-drive root;
drive paths continue to the machine's 64-bit root. The machine uses 32-byte
leaves. Keep the proof exporter pinned to these rules when changing versions.

## Storage, sharing, and snapshots

- **Loading** defaults to `SHARING_NONE`: every backing file is mapped
Expand Down
42 changes: 41 additions & 1 deletion docs/protocol/application-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ a reference wallet integration.
| `progress() -> ApplicationProgress` | Return the count/clock pair from the engine by value. |

Execution callers use `validate_and_execute_user_op`, `execute_valid_user_op`,
and `execute_direct_input`. The first enforces `max_fee >= current_fee` before
and `execute_direct_input`. The first enforces the inclusive signed block deadline and `max_fee >= current_fee` before
app validation and rejects an accepted op at the exhausted nonce `u32::MAX`
after it. App validation checks the nonce and fee balance; a rejection is not
persisted. Trusted replay
Expand Down Expand Up @@ -269,3 +269,43 @@ Canonical extraction belongs to application-specific recovery tooling. The
runtime `Application` trait stays independent of machine image layouts and host
export tooling; requiring a method to compile would establish its availability,
not the correctness or operational readiness of the recovery path.

### 8. Foreclosure and emergency exits

Canonical state integrity does not establish a withdrawal path after foreclosure.
Each custody application supplies a canonical accounts drive and withdrawal
builder compatible with its deployed contracts, covering credited balances and
finalized but unexecuted deposits. Contract refunds cover only unfinalized inputs;
the scheduler's pending-direct queue is custody even before the app credits it.
Stopping inputs also stops the input-driven age backstop. Recovery checkpoints
and comparison files do not by themselves constitute an exit proof.

The reference wallet publishes 64-byte records (32-byte ABI address followed by
a full 256-bit big-endian balance), sorted by address, in the `accounts` raw flash
drive before each accepted guest yield. Its entitlement is the current balance
plus valid supported-token deposits still queued by the scheduler. Draining moves
a deposit between those terms, preserving the sum; normal withdrawals debit the
balance before producing their independently executable voucher.

The example drive starts at `0x90000000000000`, has length 64 MiB, and holds up to
2^20 nonzero accounts. Unused records are zero, and publication clears vacated
slots and flushes guest writes. This is a bounded reference integration, subject
to the wallet's guest-memory/compute capacity; it is not a capacity promise for a
production DeFi engine. A production integration must size and test its own state
layout and supported workload before custody.

Deploy with the matching image and the [wallet withdrawal builder](../../examples/app-core/contracts/WalletWithdrawalOutputBuilder.sol):
`log2LeavesPerAccount=1`, `log2MaxNumOfAccounts=20`, and
`accountsDriveStartIndex=0x90000000000000 >> 26`. Pin the builder's token to the
same token as the guest. The standard USD builder has a 96-bit amount format and
cannot decode these records. A nonzero guardian is required to enable foreclosure.

From an independently trusted last-finalized machine checkpoint, run
`lua5.4 scripts/wallet-exit-proof.lua SNAPSHOT OWNER` at the repository root.
The exporter verifies the hash tree and emits the drive root/path and account
bytes/path. After foreclosure, submit `proveAccountsDriveMerkleRoot` once, then
`withdraw(account, {accountIndex, accountRootSiblings})` for each entitlement.
Match the exported machine root to the deployed consensus's last-finalized root;
soft-confirmed or merely safe local snapshots are insufficient. Anyone may submit
a proof, but the builder pays the proved owner, and the contract prevents a second
withdrawal at that account index.
17 changes: 13 additions & 4 deletions docs/protocol/scheduler-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ Before *anything* else, drain every fridge direct that has gone **overdue**:
([`force_execute_overdue`](../../sequencer-core/src/scheduler/mod.rs)). This is
the censorship-resistance guarantee: a direct cannot be held in the fridge
forever by a sequencer that freezes `safe_block` or stops submitting batches —
once it ages out, it executes regardless of any frame. It runs on every input
once it ages out, it executes on the next input regardless of any frame. The
backstop requires a later L1 input; wall-clock time alone does not execute it.
It runs on every input
tick (even a malformed batch advances `current_block` and can trip the
backstop). Drained directs execute in FIFO (ascending `inclusion_block`) order.

Expand All @@ -71,7 +73,7 @@ case) **consumes the nonce**.
| a | SSZ-decode the payload as `Batch` | `BatchRejected(DecodeFailed)` | not consumed |
| b | `batch.nonce == next_expected_batch_nonce` | `BatchRejected(WrongNonce)` | not consumed |
| c | *empty frames?* → accept as a no-op | `BatchExecuted` | **consumed** |
| d | structural frame check (below) | `BatchRejected(SafeBlockAboveInclusionBlock \| NonMonotonicSafeBlocks)` | not consumed |
| d | structural frame check (below) | `BatchRejected(SafeBlockAboveInclusionBlock \| NonMonotonicSafeBlocks \| FeePriceOutOfRange)` | not consumed |
| e | staleness: `inclusion_block − frames[0].safe_block ≥ MAX_WAIT_BLOCKS` | `BatchSkippedStale` | **not consumed** |
| f | execute every frame (below) | `BatchExecuted` | **consumed** |

Expand All @@ -82,7 +84,14 @@ case) **consumes the nonce**.
accounted for L1 state it was included before) — else
`SafeBlockAboveInclusionBlock`;
- frame `safe_block`s are **non-decreasing** across the batch — else
`NonMonotonicSafeBlocks`.
`NonMonotonicSafeBlocks`;
- every frame's `fee_price ≤ MAX_EXPONENT` from the [fee conversion](../../sequencer-core/src/fee.rs) — else `FeePriceOutOfRange`.

All frames pass these checks before any frame drain or user-op execution. The
overdue-direct backstop still runs before batch validation.
The fee bound is a canonical guest rule: rebuild and deploy the matching machine
image to enforce it on chain; updating the host alone does not change an
existing application's template.

**Gate e — staleness** is measured against the **first** frame only
(`has_elapsed_since`, mirrored off-chain by
Expand All @@ -106,7 +115,7 @@ For each frame, in order ([`process_batch_payload`](../../sequencer-core/src/sch
with `(frame.fee_price, frame.safe_block)`. A user op is silently skipped
(no state change, no output) when its signature is unrecoverable, when app
`validate_user_op` rejects it, or when a protocol guard fails
(`max_fee ≥ fee_price`, or a nonce of `u32::MAX`, which has no
(`safe_block ≤ valid_until_block`, `max_fee ≥ fee_price`, or a nonce of `u32::MAX`, which has no
successor) — the fold is a pure deterministic function and emits no
diagnostics at the library seam.

Expand Down
Loading
Loading