diff --git a/Cargo.lock b/Cargo.lock index 7ff8e9c4..1f04e576 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1461,9 +1461,11 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "app-core", + "ethereum_ssz", "k256", "rollups-types", "sequencer-core", + "tempfile", "trolley", ] @@ -3854,11 +3856,13 @@ dependencies = [ name = "rollups-e2e" version = "0.1.0" dependencies = [ + "alloy", "alloy-primitives", "alloy-sol-types", "app-core", "c-app-engine", "c-wallet-engine", + "cartesi-rollups-contracts", "ethereum_ssz", "futures", "libtest-mimic", @@ -4291,6 +4295,7 @@ dependencies = [ "tokio-util", "toml", "tower-http", + "tower-service", "tracing", "url", ] diff --git a/README.md b/README.md index d60fff13..52e71c87 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -195,7 +204,8 @@ Request shape: { "message": { "nonce": 0, - "max_fee": 1, + "max_fee": 1418, + "valid_until_block": 1120, "data": "0x..." }, "signature": "0x...", @@ -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: @@ -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=
` The nonce to sign into `sender`'s next user op. @@ -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`). @@ -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. diff --git a/docs/cartesi-machine.md b/docs/cartesi-machine.md index 288dbebf..44e51b66 100644 --- a/docs/cartesi-machine.md +++ b/docs/cartesi-machine.md @@ -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 @@ -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 diff --git a/docs/protocol/application-contract.md b/docs/protocol/application-contract.md index 7776b9e3..f61431bd 100644 --- a/docs/protocol/application-contract.md +++ b/docs/protocol/application-contract.md @@ -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 @@ -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. diff --git a/docs/protocol/scheduler-semantics.md b/docs/protocol/scheduler-semantics.md index b76a43d6..293a0a1f 100644 --- a/docs/protocol/scheduler-semantics.md +++ b/docs/protocol/scheduler-semantics.md @@ -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. @@ -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** | @@ -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 @@ -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. diff --git a/docs/recovery/README.md b/docs/recovery/README.md index 1a45fa52..51946b65 100644 --- a/docs/recovery/README.md +++ b/docs/recovery/README.md @@ -43,6 +43,23 @@ or the scheduler's next nonce after cockroach recovery. Production needs no accepted ancestor or submitted sentinel to repair a fully invalidated branch ([I16](../invariants.md#i16-the-batch-tree-has-exactly-one-valid-parentless-root-carrying-the-deployments-anchor-nonce)). +## Revoked confirmations and signed operations + +Recovery revokes the predicted inclusion, not the user's signature. A rolled-back +operation can be submitted by anyone who obtained its bytes, including from a +pending L1 batch, and can execute again when its nonce is current and validation +passes, including the signed `valid_until_block` deadline at the replacement +frame clock. Reusing the same signed operation remains a valid retry only +within that deadline. + +To supersede a sequence of revoked operations, the sender must get a replacement +included at every affected nonce; replacing only the first leaves later signed +operations executable. The reference wallet has no dedicated cancellation method. +Applications with price-sensitive operations must define deterministic signed +expiry and price/slippage constraints in their payload and enforce them during +canonical execution. A sequencer-local signature blacklist cannot revoke a +signature in the canonical scheduler. + ## Lifecycle and startup dispatch The recovery cycle crosses a process boundary: @@ -56,7 +73,8 @@ The recovery cycle crosses a process boundary: deliberate restart. Process ownership and shutdown belong to the [authority-boundary ADR](../plans/2026-08-authority-boundary-adr.md). 3. **Recover under the process lock, with no workers.** Check local terminal - facts before any provider call, attempt initial L1 sync, select at most one + facts before any provider call, require a fresh open-application status, + attempt initial L1 sync, select at most one repair, then inspect again after a repair. 4. **Prepare, admit, launch.** Prepare resources without starting tasks. A final current inspection must still authorize serving. It creates a single-use @@ -75,8 +93,19 @@ checkpoint. After initial sync, one consistent `RecoveryInspection` selects: | `EstimatedBatchInDanger(N)` | Retry; an estimate alone cannot authorize invalidation. | | `CanonicalDivergence(N)` or missing recovery checkpoint | Refuse; automatic recovery cannot repair this trust failure. | -Only an initial-sync **provider failure** may fall back to a still-usable -persisted view. Other failures keep their typed retry/refuse classification. +Before repair, startup checks the pinned chain and `isForeclosed()` at Latest. +Foreclosure refuses startup terminally; an unavailable status read retries +without repair, even with a fresh persisted input view. During operation the +submitter repeats this check on every poll, including idle polls, and decodes +`ApplicationForeclosed(address)` from gas estimation as terminal. Runtime +observation follows the existing terminal worker policy and stops the process; +health probes then become unavailable. Detection waits for the submitter's +current RPC work and poll cadence. It does not make admission atomic with an +L1 contract transition. A Latest reorg can cause a conservative stop; a deliberate +restart reads status again. + +After the open-application check, only an initial-sync **provider failure** may +fall back to a still-usable persisted view. Other failures keep their typed retry/refuse classification. Post-flush sync has no such fallback: it must establish the view required by the cascade. @@ -331,8 +360,8 @@ facts; the flush observation and admission witness do not survive. Its invariants cover terminal dominance, repair preconditions, a caught-up post-flush view, and admission soundness. It abstracts successful repair as producing a Tip; concrete transactions and rollback are checked by Rust tests. -Neither model covers external era/generation metadata, the application-input -projection, or snapshot artifact/lease/GC durability. Those obligations remain +Neither model covers the external foreclosure-status gate, external +era/generation metadata, the application-input projection, or snapshot artifact/lease/GC durability. Those obligations remain in storage constraints, tests, and the [snapshot lifecycle](../snapshots/lifecycle.md). Run the configured checks with: diff --git a/docs/review/register.md b/docs/review/register.md index 9d83bc5f..12b3c29f 100644 --- a/docs/review/register.md +++ b/docs/review/register.md @@ -122,16 +122,15 @@ and `classify_input_reader` in [startup recovery](../../sequencer/src/recovery/m Next: classify failures by the setup phase and pin the external exit code; preserve genuinely transient provider failures. -### Startup logs the full RPC URL - -The `sequencer startup` event includes `eth_rpc_url` verbatim. Operator URLs can -carry credentials in userinfo, paths, or query parameters; private-key -redaction does not cover this field. - -Evidence: [`commands/run/mod.rs`](../../sequencer/src/commands/run/mod.rs). -Next: omit the field or define a safe endpoint representation, and check -diagnostic/help paths with a synthetic credential-bearing URL. No credential -exposure in an actual deployment was established by this review. +### Production custody exit conformance + +The reference wallet's [exit integration](../protocol/application-contract.md#8-foreclosure-and-emergency-exits) +is an example, not verification of the production DeFi engine. Before production +custody, pin that engine's drive/builder layout and independently demonstrate +credited-balance and finalized-pending-deposit exits against its deployed +contracts, including its supported capacity. The reference scenario uses real +canonical execution and Application proofs with a finalization fixture; it does +not validate Dave's dispute/epoch finalization pipeline. ## Bounded investigations and cleanup diff --git a/docs/threat-model/README.md b/docs/threat-model/README.md index 1641ee5f..773e7572 100644 --- a/docs/threat-model/README.md +++ b/docs/threat-model/README.md @@ -10,7 +10,7 @@ What we are protecting: - **Rollup state integrity.** The canonical on-chain state must reflect a deterministic replay of user operations and direct inputs. Any divergence between the sequencer's off-chain view and the scheduler's on-chain execution is a state-integrity failure. - **Soft-confirmation honesty.** Every soft confirmation issued by the sequencer must land on L1 as promised, or be explicitly revoked via recovery. -- **User funds.** No user operation, replay, or protocol break can cause users to lose funds. +- **User funds.** Canonical execution enforces application authorization and accounting; fund availability after foreclosure depends on the application's [exit integration](../protocol/application-contract.md#8-foreclosure-and-emergency-exits). - **Batch-submitter key.** Held in operator infra; not hijackable by network attackers. ## Actors and trust diff --git a/docs/watchdog/README.md b/docs/watchdog/README.md index aa92aea9..e3043fdc 100644 --- a/docs/watchdog/README.md +++ b/docs/watchdog/README.md @@ -49,6 +49,12 @@ retry: a failed tick's retry is the next scheduled one. ### Canonical execution +External starting checkpoints used by `init` and `replay` pass full hash-tree +verification before trust checks. Reading the cached root alone can miss a +modified backing file whose hash sidecars are unchanged. This verifies storage +integrity; a non-genesis checkpoint still requires an independently trusted +canonical provenance. + The watchdog drives the machine in-process through the `cartesi` Lua module, with the reference rollup host semantics the `cartesi-machine` CLI and Dave implement ([details](../cartesi-machine.md#rollup-host-semantics)). It clones diff --git a/examples/app-core/contracts/WalletWithdrawalOutputBuilder.sol b/examples/app-core/contracts/WalletWithdrawalOutputBuilder.sol new file mode 100644 index 00000000..71379c36 --- /dev/null +++ b/examples/app-core/contracts/WalletWithdrawalOutputBuilder.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.28; + +/// @notice Reference wallet exits preserve the full uint256 token amount. +contract WalletWithdrawalOutputBuilder { + address public immutable token; + + constructor(address token_) { + token = token_; + } + + function buildWithdrawalOutput(address, bytes calldata account) + external view returns (bytes memory) + { + require(account.length == 64, "invalid wallet account length"); + (address owner, uint256 balance) = abi.decode(account, (address, uint256)); + return abi.encodeWithSignature( + "Voucher(address,uint256,bytes)", token, uint256(0), + abi.encodeWithSignature("transfer(address,uint256)", owner, balance) + ); + } +} diff --git a/examples/app-core/src/application/wallet.rs b/examples/app-core/src/application/wallet.rs index 31387377..58a629fd 100644 --- a/examples/app-core/src/application/wallet.rs +++ b/examples/app-core/src/application/wallet.rs @@ -1,7 +1,7 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -124,6 +124,37 @@ impl WalletApp { }) } + /// Exit entitlement includes deposits finalized before their scheduled execution. + pub fn exit_accounts<'a>( + &self, + pending: impl Iterator, + ) -> Result, AppError> { + let mut balances: BTreeMap<_, _> = self + .balances + .iter() + .filter(|(_, balance)| !balance.is_zero()) + .map(|(owner, balance)| (*owner, *balance)) + .collect(); + for (sender, payload) in pending { + if sender != self.config.erc20_portal_address { + continue; + } + if let Ok(deposit) = Erc20Deposit::decode(payload) + && deposit.token == self.config.supported_erc20_token + && !deposit.value.is_zero() + { + let balance = balances.entry(deposit.sender).or_default(); + *balance = + balance + .checked_add(deposit.value) + .ok_or_else(|| AppError::Internal { + reason: "exit balance overflow".into(), + })?; + } + } + Ok(balances.into_iter().collect()) + } + // Accessors for the canonical snapshot encoder (`crate::wallet_snapshot`). pub(crate) fn config(&self) -> &WalletConfig { &self.config @@ -426,6 +457,7 @@ mod tests { app.balances.insert(sender, U256::from(10_u64)); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 1, data: Vec::::new().into(), @@ -453,6 +485,7 @@ mod tests { app.balances.insert(sender, U256::from(10_u64)); app.nonces.insert(sender, u32::MAX); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: u32::MAX, max_fee: 0, data: Vec::::new().into(), @@ -485,6 +518,7 @@ mod tests { app.balances.insert(sender, U256::from(10_u64)); let before = app.canonical_snapshot_bytes().unwrap(); let mut user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 0, data: Vec::new().into(), diff --git a/examples/c-wallet-engine/src/lib.rs b/examples/c-wallet-engine/src/lib.rs index 1ab6d592..844b832e 100644 --- a/examples/c-wallet-engine/src/lib.rs +++ b/examples/c-wallet-engine/src/lib.rs @@ -180,6 +180,7 @@ pub unsafe extern "C" fn application_engine_validate_user_op( let sender = Address::from(unsafe { (*sender).bytes }); let user_op = unsafe { &*user_op }; let op = UserOp { + valid_until_block: u64::MAX, nonce: user_op.nonce, max_fee: user_op.max_fee, data: unsafe { slice_from(&user_op.data) }.to_vec().into(), @@ -215,7 +216,9 @@ pub unsafe extern "C" fn application_engine_validate_user_op( // The caller owns these guards and this entry point never checks them, so the app // cannot produce these reasons. Reporting one would be a lie about which union // member carries the diagnostics. - InvalidReason::InvalidMaxFee { .. } | InvalidReason::NonceExhausted => { + InvalidReason::InvalidMaxFee { .. } + | InvalidReason::NonceExhausted + | InvalidReason::Expired { .. } => { set_error("the app reported a caller-owned rejection"); return sys::APPLICATION_ENGINE_STATUS_INTERNAL_ERROR; } diff --git a/examples/c-wallet-engine/tests/conformance.rs b/examples/c-wallet-engine/tests/conformance.rs index 2be1d7cd..82b6bfaf 100644 --- a/examples/c-wallet-engine/tests/conformance.rs +++ b/examples/c-wallet-engine/tests/conformance.rs @@ -94,6 +94,7 @@ fn abi_mixed_history_matches_native_outputs_progress_and_dump() { (2, vec![0xff], 13), ] { let op = UserOp { + valid_until_block: u64::MAX, nonce, max_fee: 10, data: data.into(), @@ -141,6 +142,7 @@ fn abi_protocol_rejections_leave_state_and_progress_unchanged() { (Address::repeat_byte(0x55), 0, 10_000, 10_000), ] { let op = UserOp { + valid_until_block: u64::MAX, nonce, max_fee, data: vec![0xff].into(), diff --git a/examples/canonical-app/Cargo.toml b/examples/canonical-app/Cargo.toml index 85d66c5d..4b88614e 100644 --- a/examples/canonical-app/Cargo.toml +++ b/examples/canonical-app/Cargo.toml @@ -17,4 +17,6 @@ alloy-primitives = { workspace = true } rollups-types = { workspace = true } [dev-dependencies] +ssz = { workspace = true } +tempfile = { workspace = true } k256 = { workspace = true } diff --git a/examples/canonical-app/justfile b/examples/canonical-app/justfile index 9aceb468..02298181 100644 --- a/examples/canonical-app/justfile +++ b/examples/canonical-app/justfile @@ -3,17 +3,14 @@ set shell := ["bash", "-euo", "pipefail", "-c"] pins := "../../toolchain-pins.env" cartesi_machine_version := `sed -n 's/^CARTESI_MACHINE_VERSION=v//p' ../../toolchain-pins.env` linux_image_release := `grep '^CARTESI_LINUX_IMAGE_RELEASE=' ../../toolchain-pins.env | cut -d= -f2` +linux_kernel_sha512 := `grep '^CARTESI_LINUX_KERNEL_SHA512=' ../../toolchain-pins.env | cut -d= -f2` linux_kernel_filename := `grep '^CARTESI_LINUX_KERNEL_FILENAME=' ../../toolchain-pins.env | cut -d= -f2` out_dir := "out" source_date_epoch := "0" -# Cache the kernel under its pinned filename so a changed pin misses the cache -# and re-downloads (a generic name would verify a stale kernel against its own -# stale checksum). +# Cache by release filename; every cached/downloaded kernel must match the pin. linux_kernel := out_dir + "/" + linux_kernel_filename -linux_kernel_sha512 := linux_kernel + ".sha512" linux_kernel_url := "https://github.com/cartesi/machine-linux-image/releases/download/" + linux_image_release + "/" + linux_kernel_filename -linux_kernel_sha512_url := linux_kernel_url + ".sha512" rootfs_tar := out_dir + "/canonical-rootfs.tar" rootfs_ext2 := out_dir + "/canonical-rootfs.ext2" machine_image := out_dir + "/canonical-machine-image" @@ -24,16 +21,14 @@ machine_image_sepolia := out_dir + "/canonical-machine-image-sepolia" download-deps: @mkdir -p {{out_dir}} - @kernel_tmp="{{linux_kernel}}.tmp"; checksum_tmp="{{linux_kernel_sha512}}.tmp"; \ - verify_kernel() { (cd {{out_dir}} && shasum -a 512 -c "$(basename {{linux_kernel_sha512}})" >/dev/null); }; \ - if [[ ! -s {{linux_kernel}} || ! -s {{linux_kernel_sha512}} ]] || ! verify_kernel; then \ - rm -f "{{linux_kernel}}" "{{linux_kernel_sha512}}" "$kernel_tmp" "$checksum_tmp"; \ + @kernel_tmp="{{linux_kernel}}.tmp"; \ + verify_kernel() { echo "{{linux_kernel_sha512}} $1" | shasum -a 512 -c - >/dev/null; }; \ + if [[ ! -s {{linux_kernel}} ]] || ! verify_kernel "{{linux_kernel}}"; then \ + rm -f "{{linux_kernel}}" "$kernel_tmp"; \ wget "{{linux_kernel_url}}" -O "$kernel_tmp"; \ - wget "{{linux_kernel_sha512_url}}" -O "$checksum_tmp"; \ + verify_kernel "$kernel_tmp" \ + || { echo "kernel does not match CARTESI_LINUX_KERNEL_SHA512" >&2; exit 1; }; \ mv "$kernel_tmp" "{{linux_kernel}}"; \ - sed "s# artifacts/[^ ]*\$# $(basename {{linux_kernel}})#" "$checksum_tmp" > "{{linux_kernel_sha512}}"; \ - rm -f "$checksum_tmp"; \ - verify_kernel; \ fi build-dapp: build-dapp-devnet @@ -86,6 +81,8 @@ build-machine-image: clean-machine-image build-rootfs-devnet --ram-length=128Mi \ --ram-image={{linux_kernel}} \ --flash-drive=label:root,data_filename:{{rootfs_ext2}} \ + --flash-drive=label:accounts,start:0x90000000000000,length:64Mi,mke2fs:false,mount:false \ + --append-init="busybox chown dapp:dapp /dev/pmem1" \ --append-init=WORKDIR="/dapp" \ --append-entrypoint="/dapp/dapp" \ --assert-rolling-template --final-hash \ @@ -98,6 +95,8 @@ build-machine-image-sepolia: clean-machine-image-sepolia build-rootfs-sepolia --ram-length=128Mi \ --ram-image={{linux_kernel}} \ --flash-drive=label:root,data_filename:{{rootfs_ext2}} \ + --flash-drive=label:accounts,start:0x90000000000000,length:64Mi,mke2fs:false,mount:false \ + --append-init="busybox chown dapp:dapp /dev/pmem1" \ --append-init=WORKDIR="/dapp" \ --append-entrypoint="/dapp/dapp" \ --assert-rolling-template --final-hash \ diff --git a/examples/canonical-app/src/bin/canonical-app-devnet.rs b/examples/canonical-app/src/bin/canonical-app-devnet.rs index 3480a213..757bbe10 100644 --- a/examples/canonical-app/src/bin/canonical-app-devnet.rs +++ b/examples/canonical-app/src/bin/canonical-app-devnet.rs @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 (see LICENSE) use app_core::application::{DEVNET_SEQUENCER_ADDRESS, WalletApp, WalletConfig}; -use canonical_app::{SchedulerConfig, run_scheduler_forever}; +use canonical_app::{SchedulerConfig, run_wallet_scheduler_forever}; use trolley::cmt::RollupCmt; fn main() { let rollup = RollupCmt::try_new().expect("failed to initialize rollup"); let app = WalletApp::new(WalletConfig::devnet()); - run_scheduler_forever(rollup, app, SchedulerConfig::new(DEVNET_SEQUENCER_ADDRESS)); + run_wallet_scheduler_forever(rollup, app, SchedulerConfig::new(DEVNET_SEQUENCER_ADDRESS)); } diff --git a/examples/canonical-app/src/bin/canonical-app-sepolia.rs b/examples/canonical-app/src/bin/canonical-app-sepolia.rs index f0139bdc..9581c33d 100644 --- a/examples/canonical-app/src/bin/canonical-app-sepolia.rs +++ b/examples/canonical-app/src/bin/canonical-app-sepolia.rs @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 (see LICENSE) use app_core::application::{SEPOLIA_SEQUENCER_ADDRESS, WalletApp, WalletConfig}; -use canonical_app::{SchedulerConfig, run_scheduler_forever}; +use canonical_app::{SchedulerConfig, run_wallet_scheduler_forever}; use trolley::cmt::RollupCmt; fn main() { let rollup = RollupCmt::try_new().expect("failed to initialize rollup"); let app = WalletApp::new(WalletConfig::sepolia()); - run_scheduler_forever(rollup, app, SchedulerConfig::new(SEPOLIA_SEQUENCER_ADDRESS)); + run_wallet_scheduler_forever(rollup, app, SchedulerConfig::new(SEPOLIA_SEQUENCER_ADDRESS)); } diff --git a/examples/canonical-app/src/exit_drive.rs b/examples/canonical-app/src/exit_drive.rs new file mode 100644 index 00000000..63067421 --- /dev/null +++ b/examples/canonical-app/src/exit_drive.rs @@ -0,0 +1,133 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Wallet foreclosure accounts, published before every accepted guest yield. + +use std::fs::{File, OpenOptions}; +use std::io::{Seek, SeekFrom, Write}; + +use app_core::application::WalletApp; +use sequencer_core::application::AppError; +use sequencer_core::scheduler::Scheduler; + +pub const START: u64 = 0x0090_0000_0000_0000; +pub const LOG2_SIZE: u32 = 26; +pub const ACCOUNT_BYTES: usize = 64; + +pub struct ExitDrive { + file: File, + previous: Vec<[u8; ACCOUNT_BYTES]>, +} + +impl ExitDrive { + pub fn open(path: &str) -> std::io::Result { + let mut file = OpenOptions::new().read(true).write(true).open(path)?; + assert_eq!( + file.seek(SeekFrom::End(0))?, + 1 << LOG2_SIZE, + "exit drive size mismatch" + ); + Ok(Self { + file, + previous: Vec::new(), + }) + } + + pub fn publish(&mut self, scheduler: &Scheduler) -> Result<(), AppError> { + let accounts = scheduler + .application() + .exit_accounts(scheduler.pending_direct_payloads())?; + assert!( + accounts.len() <= (1 << LOG2_SIZE) / ACCOUNT_BYTES, + "wallet exit capacity exceeded" + ); + let records: Vec<_> = accounts + .into_iter() + .map(|(owner, balance)| { + let mut record = [0; ACCOUNT_BYTES]; + record[12..32].copy_from_slice(owner.as_slice()); + record[32..].copy_from_slice(&balance.to_be_bytes::<32>()); + record + }) + .collect(); + for index in 0..records.len().max(self.previous.len()) { + let record = records.get(index).copied().unwrap_or([0; ACCOUNT_BYTES]); + if self.previous.get(index) != Some(&record) { + self.file + .seek(SeekFrom::Start((index * ACCOUNT_BYTES) as u64))?; + self.file.write_all(&record)?; + } + } + self.file.sync_all()?; + self.previous = records; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Address, U256}; + use app_core::application::WalletConfig; + use sequencer_core::batch::{Batch, Frame}; + use sequencer_core::scheduler::{SchedulerConfig, SchedulerInput}; + use ssz::Encode; + + #[test] + fn queued_and_credited_deposits_have_the_same_full_width_exit() { + let config = WalletConfig::devnet(); + let owner = Address::repeat_byte(1); + let amount: U256 = (U256::from(1) << 128) + U256::from(37); + let mut payload = config.supported_erc20_token.as_slice().to_vec(); + payload.extend(owner.as_slice()); + payload.extend(amount.to_be_bytes::<32>()); + let mut scheduler = Scheduler::new( + WalletApp::new(config), + SchedulerConfig::new(config.sequencer_address), + ); + let domain = sequencer_core::build_input_domain(31337, Address::ZERO); + scheduler + .process_input(SchedulerInput { + sender: config.erc20_portal_address, + inclusion_block: 10, + domain: domain.clone(), + payload, + }) + .unwrap(); + let file = tempfile::NamedTempFile::new().unwrap(); + file.as_file().set_len(1 << LOG2_SIZE).unwrap(); + let mut drive = ExitDrive::open(file.path().to_str().unwrap()).unwrap(); + drive.publish(&scheduler).unwrap(); + let pending = drive.previous.clone(); + assert_eq!(pending.len(), 1); + assert_eq!(&pending[0][12..32], owner.as_slice()); + assert_eq!(&pending[0][32..], &amount.to_be_bytes::<32>()); + assert_eq!( + scheduler.application().current_user_balance(owner), + U256::ZERO + ); + scheduler + .process_input(SchedulerInput { + sender: config.sequencer_address, + inclusion_block: 11, + domain, + payload: Batch { + nonce: 0, + frames: vec![Frame { + safe_block: 10, + fee_price: 0, + user_ops: vec![], + }], + } + .as_ssz_bytes(), + }) + .unwrap(); + drive.publish(&scheduler).unwrap(); + assert_eq!( + drive.previous, pending, + "draining must neither drop nor double count custody" + ); + assert_eq!(scheduler.queued_direct_len(), 0); + assert_eq!(scheduler.application().current_user_balance(owner), amount); + } +} diff --git a/examples/canonical-app/src/lib.rs b/examples/canonical-app/src/lib.rs index 845c33e2..05789a9c 100644 --- a/examples/canonical-app/src/lib.rs +++ b/examples/canonical-app/src/lib.rs @@ -1,6 +1,18 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) +pub mod exit_drive; pub mod scheduler; pub use scheduler::{SchedulerConfig, run_scheduler_forever}; + +pub fn run_wallet_scheduler_forever( + rollup: R, + app: app_core::application::WalletApp, + config: SchedulerConfig, +) -> ! { + let mut drive = exit_drive::ExitDrive::open("/dev/pmem1").expect("open wallet exit drive"); + scheduler::run_scheduler_observed(rollup, app, config, move |scheduler| { + drive.publish(scheduler) + }) +} diff --git a/examples/canonical-app/src/scheduler/mod.rs b/examples/canonical-app/src/scheduler/mod.rs index dc92b673..62939003 100644 --- a/examples/canonical-app/src/scheduler/mod.rs +++ b/examples/canonical-app/src/scheduler/mod.rs @@ -21,11 +21,21 @@ use trolley::{Rollup, RollupRequest}; pub use sequencer_core::scheduler::{STATE_INSPECT_QUERY, SchedulerConfig}; pub fn run_scheduler_forever( + rollup: R, + app: A, + scheduler_config: SchedulerConfig, +) -> ! { + run_scheduler_observed(rollup, app, scheduler_config, |_| Ok(())) +} + +pub(crate) fn run_scheduler_observed( mut rollup: R, app: A, scheduler_config: SchedulerConfig, + mut publish: impl FnMut(&Scheduler) -> Result<(), sequencer_core::application::AppError>, ) -> ! { let mut scheduler = Scheduler::new(app, scheduler_config); + publish(&scheduler).expect("publish genesis exit accounts"); loop { match rollup.next_input() { @@ -44,6 +54,7 @@ pub fn run_scheduler_forever( let result = scheduler .process_input(input) .unwrap_or_else(|err| panic!("canonical application execution failed: {err}")); + publish(&scheduler).expect("publish exit accounts"); for output in &result.outputs { emit_app_output(&mut rollup, output) .unwrap_or_else(|err| panic!("scheduler failed to emit app output: {err}")); diff --git a/examples/canonical-test/src/main.rs b/examples/canonical-test/src/main.rs index e93dfe9d..0fee0118 100644 --- a/examples/canonical-test/src/main.rs +++ b/examples/canonical-test/src/main.rs @@ -24,6 +24,52 @@ const INVALID_BATCH_REPORT: &[u8] = b"scheduler dropped invalid batch"; const TEST_CHAIN_ID: u64 = 31337; const TEST_DAPP_ADDRESS: Address = Address::ZERO; +#[testsi::test_dapp(kind("scheduler"))] +pub fn scheduler_expiry_and_fee_bounds_keep_the_guest_live() -> TestResult { + let mut machine = devnet_machine()?; + let config = WalletConfig::devnet(); + let key = signing_key(1); + let owner = address_from_signing_key(&key); + let amount = U256::from(250); + machine.advance_state(portal_input( + 10, + config.supported_erc20_token, + owner, + amount, + ))?; + let rejected = batch_with_frame_fee( + 0, + 10, + sequencer_core::fee::MAX_EXPONENT + 1, + vec![signed_user_op_with_fee(&key, 0, u16::MAX, vec![])], + ); + let (outputs, reports) = machine.advance_state(batch_input(11, rejected))?; + assert_invalid_batch_step(&outputs, &reports); + let (outputs, reports) = + machine.advance_state(batch_input(11, batch_with_safe_blocks(0, &[10])))?; + assert_deposit_notice( + &outputs, + &reports, + config.supported_erc20_token, + owner, + amount, + ); + let to = Address::repeat_byte(9); + let data = ssz::Encode::as_ssz_bytes(&Method::Transfer(Transfer { + to, + amount: U256::from(10), + })); + let expired = signed_user_op_with_deadline(&key, 0, 0, 10, data.clone()); + let (outputs, reports) = + machine.advance_state(batch_input(11, batch_with_frame(1, 11, vec![expired])))?; + assert_no_outputs_or_reports(&outputs, &reports); + let fresh = signed_user_op_with_deadline(&key, 0, 0, 11, data); + let (outputs, reports) = + machine.advance_state(batch_input(12, batch_with_frame(2, 11, vec![fresh])))?; + assert_transfer_notice(&outputs, &reports, owner, to, U256::from(10)); + Ok(()) +} + #[testsi::test_dapp(kind("scheduler"))] pub fn scheduler_reports_invalid_batch_from_guest() -> TestResult { let mut machine = devnet_machine()?; @@ -336,8 +382,19 @@ fn signed_user_op_with_fee( nonce: u32, max_fee: u16, data: Vec, +) -> WireUserOp { + signed_user_op_with_deadline(signing_key, nonce, max_fee, u64::MAX, data) +} + +fn signed_user_op_with_deadline( + signing_key: &SigningKey, + nonce: u32, + max_fee: u16, + valid_until_block: u64, + data: Vec, ) -> WireUserOp { let user_op = UserOp { + valid_until_block, nonce, max_fee, data: data.clone().into(), @@ -360,6 +417,7 @@ fn signed_user_op_with_fee( .expect("recoverable parity for signature"); WireUserOp { + valid_until_block, nonce, max_fee, data, diff --git a/openapi.yaml b/openapi.yaml index b4afe92d..592c3299 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -72,7 +72,8 @@ paths: example: message: nonce: 0 - max_fee: 1409 + max_fee: 1418 + valid_until_block: 1120 data: "0x00" signature: "0x1b2c3d4e5f60718293a4b5c6d7e8f9011b2c3d4e5f60718293a4b5c6d7e8f9011b2c3d4e5f60718293a4b5c6d7e8f9011b2c3d4e5f60718293a4b5c6d7e8f9011b" sender: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" @@ -107,7 +108,7 @@ paths: "422": description: | The application rejected the op; no state changed and nothing was - persisted. Reasons: bad nonce, exhausted nonce, `max_fee` below + persisted. Reasons: expired deadline, bad nonce, exhausted nonce, `max_fee` below the open-frame fee, or insufficient balance for the fee. After a bad-nonce rejection, query `GET /nonce` again. content: @@ -118,7 +119,7 @@ paths: badNonce: value: { ok: false, code: EXECUTION_REJECTED, message: "bad nonce: expected 7, got 6" } maxFee: - value: { ok: false, code: EXECUTION_REJECTED, message: max fee 1300 below base fee 1356 } + value: { ok: false, code: EXECUTION_REJECTED, message: max fee 1300 below base fee 1365 } "429": description: The inclusion-lane queue is full. content: @@ -150,7 +151,7 @@ paths: application/json: schema: $ref: "#/components/schemas/FeeResponse" - example: { fee: 1356, recommended_fee: 1356, suggested_max_fee: 1409 } + example: { fee: 1365, recommended_fee: 1365, suggested_max_fee: 1418, safe_block: 1000 } "500": $ref: "#/components/responses/InternalError" "503": @@ -229,7 +230,7 @@ paths: $ref: "#/components/schemas/DomainResponse" example: name: CartesiAppSequencer - version: "1" + version: "2" chainId: 31337 verifyingContract: "0x1111111111111111111111111111111111111111" @@ -793,7 +794,7 @@ components: UserOp: type: object - required: [nonce, max_fee, data] + required: [nonce, max_fee, valid_until_block, data] description: The EIP-712 message the user signs. properties: nonce: @@ -808,6 +809,11 @@ components: minimum: 0 maximum: 65535 description: Log-space fee exponent (base 129/128) the user is willing to pay. + valid_until_block: + type: integer + format: uint64 + minimum: 0 + description: Inclusive frame safe-block deadline; expiry consumes neither nonce nor fee. data: allOf: - $ref: "#/components/schemas/HexBytes" @@ -848,8 +854,13 @@ components: FeeResponse: type: object - required: [fee, recommended_fee, suggested_max_fee] + required: [fee, recommended_fee, suggested_max_fee, safe_block] properties: + safe_block: + type: integer + format: uint64 + minimum: 0 + description: Clock of the open frame; choose a signed deadline relative to this value. fee: type: integer format: uint16 diff --git a/scripts/generate-release-manifest.sh b/scripts/generate-release-manifest.sh index b7474b04..649ecc5d 100755 --- a/scripts/generate-release-manifest.sh +++ b/scripts/generate-release-manifest.sh @@ -79,6 +79,11 @@ manifest = { "rust": os.environ["RUST_TOOLCHAIN"], "xgenext2fs": os.environ["XGENEXT2FS_VERSION"], "cartesi_machine": os.environ["CARTESI_MACHINE_VERSION"], + "linux_kernel": { + "release": os.environ["CARTESI_LINUX_IMAGE_RELEASE"], + "filename": os.environ["CARTESI_LINUX_KERNEL_FILENAME"], + "sha512": os.environ["CARTESI_LINUX_KERNEL_SHA512"], + }, }, "artifacts": artifacts, "alignment": { diff --git a/scripts/wallet-exit-proof.lua b/scripts/wallet-exit-proof.lua new file mode 100644 index 00000000..f586ec8c --- /dev/null +++ b/scripts/wallet-exit-proof.lua @@ -0,0 +1,52 @@ +-- (c) Cartesi and individual authors (see AUTHORS) +-- SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +--- Export withdrawal arguments from a trusted finalized canonical machine. +--- Run at repo root: lua5.4 scripts/wallet-exit-proof.lua SNAPSHOT OWNER + +local cartesi = require("cartesi") +local abi = require("watchdog.abi") +local json = require("watchdog.json").new() +local start, log2_size, account_size = 0x90000000000000, 26, 64 +local dir, owner = assert(arg[1], "snapshot required"), assert(arg[2], "owner required") +assert(owner:match("^0x%x+$") and #owner == 42, "owner must be a 20-byte address") +local wanted = abi.bytes_from_hex(owner) +assert(cartesi.VERSION == "0.21.0", "use the repository-pinned emulator version") +local m = cartesi.new() +m:load(dir, {}, cartesi.SHARING_NONE) +assert(m:verify_hash_tree(), "snapshot fails hash-tree verification") +local matching_range = false +for _, range in ipairs(m:get_initial_config().flash_drive or {}) do + if range.label == "accounts" and range.start == start and range.length == (1 << log2_size) then + matching_range = true + end +end +assert(matching_range, "snapshot has no reference-wallet accounts drive") +local function siblings(proof, count) + local result = {} + for i = 1, count do + result[#result + 1] = "0x" .. abi.hex_from_bytes(proof.sibling_hashes[i]) + end + return result +end +local found +for index = 0, (1 << log2_size) // account_size - 1 do + local account = m:read_memory(start + index * account_size, account_size) + if account == string.rep("\0", account_size) then break end + if account:sub(13, 32) == wanted then + local drive = m:get_proof(start, log2_size) + local proof = m:get_proof(start + index * account_size, 6) + found = { + machine_root = "0x" .. abi.hex_from_bytes(drive.root_hash), + accounts_drive_root = "0x" .. abi.hex_from_bytes(drive.target_hash), + accounts_drive_siblings = siblings(drive, #drive.sibling_hashes), + account = "0x" .. abi.hex_from_bytes(account), + account_index = index, + account_root_siblings = siblings(proof, 20), + } + break + end +end +m:destroy() +assert(found, "owner has no exit entitlement in this snapshot") +io.write(json.encode(found), "\n") diff --git a/sequencer-core/src/api.rs b/sequencer-core/src/api.rs index 0404eb87..7546c5b4 100644 --- a/sequencer-core/src/api.rs +++ b/sequencer-core/src/api.rs @@ -133,14 +133,16 @@ pub struct TxResponse { /// survives a frame rotation. The user pays the frame fee, not this cap. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FeeResponse { + pub safe_block: u64, pub fee: u16, pub recommended_fee: u16, pub suggested_max_fee: u16, } impl FeeResponse { - pub fn quote(fee: u16, recommended_fee: u16) -> Self { + pub fn quote(fee: u16, recommended_fee: u16, safe_block: u64) -> Self { Self { + safe_block, fee, recommended_fee, suggested_max_fee: crate::fee::suggested_signing_max_fee(fee, recommended_fee), diff --git a/sequencer-core/src/application/mod.rs b/sequencer-core/src/application/mod.rs index d4710716..f6d4d32a 100644 --- a/sequencer-core/src/application/mod.rs +++ b/sequencer-core/src/application/mod.rs @@ -110,6 +110,10 @@ pub struct ExecutedInput { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InvalidReason { + Expired { + valid_until_block: u64, + safe_block: u64, + }, InvalidNonce { expected: u32, got: u32, @@ -133,6 +137,13 @@ pub enum InvalidReason { impl fmt::Display for InvalidReason { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Expired { + valid_until_block, + safe_block, + } => write!( + f, + "operation expired at block {valid_until_block}; frame clock is {safe_block}" + ), Self::InvalidNonce { expected, got } => { write!(f, "bad nonce: expected {expected}, got {got}") } @@ -163,7 +174,7 @@ pub trait Application: Send + Sized { /// equals the sender's expected nonce (user replay protection), and the /// sender covers the fee. Must not mutate state. /// [`validate_and_execute_user_op`] enforces the protocol - /// `max_fee >= current_fee` guard before calling here, and rejects an + /// signed deadline and `max_fee >= current_fee` guards before calling here, and rejects an /// accepted op at nonce `u32::MAX` after. Rejection leaves the app /// unchanged; `AppError` is fatal and defines no successor. fn validate_user_op( @@ -275,6 +286,12 @@ pub fn validate_and_execute_user_op( current_fee: u16, safe_block: u64, ) -> Result { + if safe_block > user_op.valid_until_block { + return Ok(ExecutionOutcome::Invalid(InvalidReason::Expired { + valid_until_block: user_op.valid_until_block, + safe_block, + })); + } // Protocol invariant: max_fee must cover the current frame fee. if user_op.max_fee < current_fee { return Ok(ExecutionOutcome::Invalid(InvalidReason::InvalidMaxFee { @@ -450,6 +467,7 @@ mod tests { fn user_op() -> UserOp { UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 0, data: Vec::new().into(), @@ -464,6 +482,28 @@ mod tests { } } + #[test] + fn expiry_is_inclusive_and_rejects_before_app_validation_or_mutation() { + let mut op = user_op(); + op.valid_until_block = 9; + let mut app = ProgressApp::new(0); + app.fail_validation = true; + assert_eq!( + validate_and_execute_user_op(&mut app, Address::ZERO, &op, 0, 10).unwrap(), + ExecutionOutcome::Invalid(InvalidReason::Expired { + valid_until_block: 9, + safe_block: 10 + }) + ); + assert_eq!(app.applied, 0); + app.fail_validation = false; + assert!(matches!( + validate_and_execute_user_op(&mut app, Address::ZERO, &op, 0, 9).unwrap(), + ExecutionOutcome::Included(_) + )); + assert_eq!(app.applied, 1); + } + #[test] fn native_progress_defines_receipt_offsets_and_monotonic_clock() { let mut app = ProgressApp::new(0); diff --git a/sequencer-core/src/batch.rs b/sequencer-core/src/batch.rs index 9ab4050b..292ee99f 100644 --- a/sequencer-core/src/batch.rs +++ b/sequencer-core/src/batch.rs @@ -63,6 +63,7 @@ pub struct WireUserOp { pub nonce: u32, /// Log-space fee exponent (base 129/128). See [`crate::fee`]. pub max_fee: u16, + pub valid_until_block: u64, pub data: Vec, pub signature: Vec, } @@ -72,6 +73,7 @@ impl WireUserOp { pub fn to_user_op(&self) -> UserOp { UserOp { + valid_until_block: self.valid_until_block, nonce: self.nonce, max_fee: self.max_fee, data: self.data.clone().into(), @@ -86,6 +88,7 @@ mod tests { fn sample_user_op(nonce: u32) -> WireUserOp { WireUserOp { + valid_until_block: u64::MAX, nonce, max_fee: 100, data: vec![0xaa, 0xbb, 0xcc, 0xdd], diff --git a/sequencer-core/src/lib.rs b/sequencer-core/src/lib.rs index d8ab1f3c..a357e592 100644 --- a/sequencer-core/src/lib.rs +++ b/sequencer-core/src/lib.rs @@ -24,7 +24,7 @@ pub const MAX_WAIT_BLOCKS: u64 = 1200; pub const DOMAIN_NAME: &str = "CartesiAppSequencer"; /// EIP-712 domain version shared between sequencer and scheduler. -pub const DOMAIN_VERSION: &str = "1"; +pub const DOMAIN_VERSION: &str = "2"; /// Build the canonical EIP-712 domain for user-op signing and verification. /// diff --git a/sequencer-core/src/protocol.rs b/sequencer-core/src/protocol.rs index 549c4ef6..4eccc592 100644 --- a/sequencer-core/src/protocol.rs +++ b/sequencer-core/src/protocol.rs @@ -354,11 +354,12 @@ impl ProtocolTiming { /// `batch_reject_reason_for_block` additionally rejects a batch whose frames /// have a `safe_block` above the inclusion block /// (`SafeBlockAboveInclusionBlock`) or that decrease across frames - /// (`NonMonotonicSafeBlocks`). This predicate does not re-check those: a + /// (`NonMonotonicSafeBlocks`), or whose fee exceeds `MAX_EXPONENT` + /// (`FeePriceOutOfRange`). This predicate does not re-check those: a /// well-formed batch submitter never builds such a batch, so they are /// reachable only under a buggy or forked submitter — already outside the /// duality boundary. The I1 duality test pins this as a *documented* - /// asymmetry; mirror those two checks here if the submitter ever stops being + /// asymmetry; mirror those checks here if the submitter ever stops being /// trusted to be well-formed. pub fn scheduler_accepts( &self, diff --git a/sequencer-core/src/scheduler/mod.rs b/sequencer-core/src/scheduler/mod.rs index 0caa18bb..f571603f 100644 --- a/sequencer-core/src/scheduler/mod.rs +++ b/sequencer-core/src/scheduler/mod.rs @@ -102,6 +102,7 @@ pub enum BatchRejectReason { WrongNonce { expected: u64, got: u64 }, SafeBlockAboveInclusionBlock, NonMonotonicSafeBlocks, + FeePriceOutOfRange, } #[derive(Debug)] @@ -144,6 +145,17 @@ impl Scheduler { Self::resume_at(app, config, 0) } + pub fn application(&self) -> &A { + &self.app + } + + /// Pending inputs remain part of canonical custody before app execution. + pub fn pending_direct_payloads(&self) -> impl Iterator { + self.direct_q + .iter() + .map(|input| (input.sender, input.payload.as_slice())) + } + /// Number of directs still queued in the fridge. The recovery fold asserts /// this is zero after draining at the stop block `C`: any leftover direct /// means an input arrived with `inclusion_block > C` (a caller contract @@ -313,6 +325,13 @@ impl Scheduler { } } + if std::iter::once(head) + .chain(tail) + .any(|frame| frame.fee_price > crate::fee::MAX_EXPONENT) + { + return Some(BatchRejectReason::FeePriceOutOfRange); + } + None } @@ -642,6 +661,7 @@ mod tests { data: Vec, ) -> WireUserOp { let user_op = UserOp { + valid_until_block: u64::MAX, nonce, max_fee, data: data.clone().into(), @@ -665,6 +685,7 @@ mod tests { .expect("recoverable parity for signature"); WireUserOp { + valid_until_block: u64::MAX, nonce, max_fee, data, @@ -812,6 +833,70 @@ mod tests { ); } + #[test] + fn fee_bounds_reject_the_whole_batch_and_preserve_the_backstop() { + use crate::fee::MAX_EXPONENT; + + for fee in [MAX_EXPONENT + 1, u16::MAX] { + for invalid_position in [0, 1] { + let mut scheduler = Scheduler::new( + RecordingApp::default(), + SchedulerConfig { + sequencer_address: SEQUENCER, + max_wait_blocks: 10, + }, + ); + process_ok(&mut scheduler, direct_input(1, 1)); + process_ok(&mut scheduler, direct_input(8, 2)); + let key = SigningKey::from_bytes((&[1_u8; 32]).into()).unwrap(); + scheduler.app.credit(address_from_signing_key(&key), 1); + let frame = Frame { + safe_block: 8, + fee_price: 0, + user_ops: vec![sign_wire_user_op( + &test_domain(), + &key, + 0, + u16::MAX, + vec![3], + )], + }; + let mut frames = vec![frame.clone(), frame]; + frames[invalid_position].fee_price = fee; + assert_eq!( + process_ok(&mut scheduler, batch_input(11, Batch { nonce: 0, frames })), + ProcessOutcome::BatchRejected(BatchRejectReason::FeePriceOutOfRange), + ); + // Only the overdue direct executes: no frame drain or user op. + assert_eq!(scheduler.app.events(), [RecordedTx::Direct(1)]); + assert_eq!(scheduler.queued_direct_len(), 1); + assert_eq!(scheduler.next_expected_batch_nonce(), 0); + assert_eq!( + process_ok( + &mut scheduler, + batch_input( + 11, + Batch { + nonce: 0, + frames: vec![Frame { + safe_block: 8, + fee_price: MAX_EXPONENT, + user_ops: vec![] + }], + } + ) + ), + ProcessOutcome::BatchExecuted, + ); + assert_eq!( + scheduler.app.events(), + [RecordedTx::Direct(1), RecordedTx::Direct(2)] + ); + assert_eq!(scheduler.next_expected_batch_nonce(), 1); + } + } + } + #[test] fn non_monotonic_safe_blocks_invalidate_batch() { let mut scheduler = Scheduler::new( @@ -984,6 +1069,7 @@ mod tests { nonce: 0, frames: vec![Frame { user_ops: vec![WireUserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 0, data: vec![7], @@ -1526,6 +1612,19 @@ mod tests { offchain={offchain}" ); + let out_of_range_fee = ssz(&Batch { + nonce: 0, + frames: vec![Frame { + fee_price: crate::fee::MAX_EXPONENT + 1, + ..empty_frame(10) + }], + }); + let (canonical, offchain) = duality_run(SEQUENCER, 12, 0, &out_of_range_fee); + assert!( + !canonical && offchain, + "fee range is a documented structural omission" + ); + let frame_above_inclusion = ssz(&Batch { nonce: 0, frames: vec![empty_frame(20)], diff --git a/sequencer-core/src/user_op.rs b/sequencer-core/src/user_op.rs index f14d6d86..23651bac 100644 --- a/sequencer-core/src/user_op.rs +++ b/sequencer-core/src/user_op.rs @@ -11,6 +11,7 @@ sol! { uint32 nonce; /// Log-space fee exponent (base 129/128). See [`crate::fee`]. uint16 max_fee; + uint64 valid_until_block; bytes data; } } @@ -28,7 +29,7 @@ impl SignedUserOp { pub const MAX_FEE_BYTES: usize = 2; pub const MAX_BATCH_METADATA_BYTES: usize = - Self::SIGNATURE_BYTES + Self::NONCE_BYTES + Self::MAX_FEE_BYTES; + Self::SIGNATURE_BYTES + Self::NONCE_BYTES + Self::MAX_FEE_BYTES + 8; pub const fn max_batch_metadata() -> usize { Self::MAX_BATCH_METADATA_BYTES diff --git a/sequencer/Cargo.toml b/sequencer/Cargo.toml index 331cdefa..ff8cd10a 100644 --- a/sequencer/Cargo.toml +++ b/sequencer/Cargo.toml @@ -28,6 +28,7 @@ alloy = { workspace = true, features = ["contract", "network", "reqwest", "rpc-t alloy-network-primitives = "2.5" alloy-transport = "2.5" url = "2.5" +tower-service = "0.3" thiserror = { workspace = true } ssz = { workspace = true } ssz_derive = { workspace = true } diff --git a/sequencer/src/commands/run/mod.rs b/sequencer/src/commands/run/mod.rs index d2384930..e7cd5df0 100644 --- a/sequencer/src/commands/run/mod.rs +++ b/sequencer/src/commands/run/mod.rs @@ -138,7 +138,6 @@ where tracing::info!( http_addr = %config.http_addr, data_dir = %config.data_dir, - eth_rpc_url = %l1_config.eth_rpc_url, input_box_address = %l1_config.identity.input_box_address, app_deployment_block = l1_config.identity.app_deployment_block, chain_id = l1_config.identity.chain_id, diff --git a/sequencer/src/egress/l2_tx_feed/tests.rs b/sequencer/src/egress/l2_tx_feed/tests.rs index e23a1d94..ef59107b 100644 --- a/sequencer/src/egress/l2_tx_feed/tests.rs +++ b/sequencer/src/egress/l2_tx_feed/tests.rs @@ -100,6 +100,7 @@ async fn subscription_accepts_more_than_fifty_thousand_inputs_without_a_total_ca sender: Address::repeat_byte(0x11), signature: Signature::test_signature(), user_op: UserOp { + valid_until_block: u64::MAX, nonce: offset, max_fee: u16::MAX, data: vec![0x42].into(), @@ -350,6 +351,7 @@ fn seed_ordered_txs(db_path: &str) { sender: Address::from_slice(&[0x11; 20]), signature: Signature::test_signature(), user_op: UserOp { + valid_until_block: u64::MAX, nonce: 7, max_fee: 3, data: vec![0x42].into(), diff --git a/sequencer/src/ingress/api.rs b/sequencer/src/ingress/api.rs index e88e574e..8dd90af8 100644 --- a/sequencer/src/ingress/api.rs +++ b/sequencer/src/ingress/api.rs @@ -164,9 +164,11 @@ async fn get_fee(State(state): State>) -> Result { - Ok(no_store(Json(FeeResponse::quote(fee, recommended_fee)))) - } + Ok(Some((fee, recommended_fee, safe_block))) => Ok(no_store(Json(FeeResponse::quote( + fee, + recommended_fee, + safe_block, + )))), Ok(None) => Err(ApiError::unavailable("no open frame")), Err(err) => { tracing::warn!(error = %err, "GET /fee failed"); @@ -299,6 +301,7 @@ mod tests { sender: Address::ZERO, signature: Signature::test_signature(), user_op: UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 0, data: Vec::new().into(), @@ -338,6 +341,7 @@ mod tests { let signing_key = SigningKey::from_bytes((&[7_u8; 32]).into()).expect("create signing key"); let sender = address_from_signing_key(&signing_key); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 0, data: Vec::new().into(), diff --git a/sequencer/src/ingress/inclusion_lane/tests.rs b/sequencer/src/ingress/inclusion_lane/tests.rs index 9f58bfa0..3cad140c 100644 --- a/sequencer/src/ingress/inclusion_lane/tests.rs +++ b/sequencer/src/ingress/inclusion_lane/tests.rs @@ -433,8 +433,9 @@ fn make_pending_user_op( let sender = Address::from_slice(&[seed; 20]); let (respond_to, recv) = oneshot::channel(); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, - // Must be >= the DB default recommended_fee (1356) to pass the + // Must be >= the DB default recommended_fee (1365) to pass the // protocol-level max_fee >= fee_price check in the trait default. max_fee: u16::MAX, data: vec![seed; 4].into(), diff --git a/sequencer/src/integration_tests/chain_id_validation.rs b/sequencer/src/integration_tests/chain_id_validation.rs index 324bf82f..e23aa093 100644 --- a/sequencer/src/integration_tests/chain_id_validation.rs +++ b/sequencer/src/integration_tests/chain_id_validation.rs @@ -24,7 +24,7 @@ use std::time::Duration; use alloy_primitives::{Address, address}; use clap::Parser; use sequencer::commands::{BootstrapError, CommandError, IdentityError}; -use sequencer::l1::reader::InputReaderError; +use sequencer::l1::application_status::ApplicationStatusError; use sequencer::recovery::{RecoveryError, RecoveryFailure}; use sequencer::storage::{DeploymentIdentity, Storage}; use sequencer::{Cli, Command}; @@ -178,14 +178,14 @@ async fn run_refuses_on_wrong_chain_rpc() { match result { Err(CommandError::Bootstrap(BootstrapError::Recovery(RecoveryError::Refuse(failure)))) => { match *failure { - RecoveryFailure::InputReader(InputReaderError::ChainIdMismatch { + RecoveryFailure::ApplicationStatus(ApplicationStatusError::ChainIdMismatch { rpc, expected, }) => { assert_eq!(rpc, 99); assert_eq!(expected, 31_337); } - other => panic!("expected input-reader chain-id mismatch, got: {other:?}"), + other => panic!("expected application-status chain-id mismatch, got: {other:?}"), } } other => panic!("expected ChainIdMismatch, got: {other:?}"), @@ -200,6 +200,16 @@ async fn run_accepts_matching_chain_rpc() { // task-free runtime preparation, where loading that artifact fails. require_anvil(); let anvil = alloy::node_bindings::Anvil::default().spawn(); // chain id 31337 + use alloy::providers::{ProviderBuilder, ext::AnvilApi}; + let provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + // Minimal isForeclosed() response: the app remains open. + provider + .anvil_set_code( + TEST_APP_ADDR, + alloy_primitives::bytes!("600060005260206000f3"), + ) + .await + .unwrap(); let dir = TempDir::new().expect("tempdir"); let data_dir = dir.path().to_str().unwrap(); let db_path = format!("{data_dir}/sequencer.db"); @@ -220,3 +230,42 @@ async fn run_accepts_matching_chain_rpc() { "matching chain id must reach snapshot preparation, got: {result:?}" ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_refuses_a_foreclosed_app_on_every_restart_without_sending() { + use alloy::providers::{Provider, ProviderBuilder, ext::AnvilApi}; + require_anvil(); + let anvil = alloy::node_bindings::Anvil::default().spawn(); + let provider = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + // isForeclosed() = true, without needing a full contracts deployment. + provider + .anvil_set_code( + TEST_APP_ADDR, + alloy_primitives::bytes!("600160005260206000f3"), + ) + .await + .unwrap(); + let dir = TempDir::new().unwrap(); + let data_dir = dir.path().to_str().unwrap(); + seed_setup_complete(&format!("{data_dir}/sequencer.db"), 31337, ANVIL_ADDR); + let before = provider.get_transaction_count(ANVIL_ADDR).await.unwrap(); + for _ in 0..2 { + let error = sequencer::run::(run_config( + data_dir, + &anvil.endpoint(), + ANVIL_KEY, + )) + .await + .unwrap_err(); + assert_eq!(error.exit_code(), sequencer::commands::error::EXIT_TERMINAL); + assert!( + matches!(error, CommandError::Bootstrap(BootstrapError::Recovery( + RecoveryError::Refuse(failure) + )) if matches!(*failure, RecoveryFailure::ApplicationStatus(ApplicationStatusError::Foreclosed { .. }))) + ); + } + assert_eq!( + provider.get_transaction_count(ANVIL_ADDR).await.unwrap(), + before + ); +} diff --git a/sequencer/src/integration_tests/e2e_sequencer.rs b/sequencer/src/integration_tests/e2e_sequencer.rs index 0011a6e0..2eefb61c 100644 --- a/sequencer/src/integration_tests/e2e_sequencer.rs +++ b/sequencer/src/integration_tests/e2e_sequencer.rs @@ -61,6 +61,7 @@ fn v1_regression_shared_domain_recovers_signer() { let domain = sequencer_core::build_input_domain(chain_id, app); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 1_200, data: vec![0x01, 0x02, 0x03].into(), @@ -111,6 +112,7 @@ fn v1_regression_name_none_domain_recovers_different_address() { }; let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 1_200, data: vec![0x01, 0x02, 0x03].into(), @@ -152,6 +154,7 @@ fn v1_regression_domain_fields_all_affect_recovery() { let app = Address::from_slice(&[0xaa; 20]); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 1_200, data: vec![0x01].into(), @@ -227,6 +230,7 @@ async fn e2e_submit_tx_ack_and_broadcast() { amount: U256::from(0_u64), }); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: TEST_MAX_FEE, data: ssz::Encode::as_ssz_bytes(&method).into(), @@ -280,7 +284,7 @@ async fn e2e_submit_tx_ack_and_broadcast() { assert_eq!(offset, 1); assert_eq!(ws_sender, sender.to_string()); // Frame fee includes the default tenfold log-space slack. - assert_eq!(fee, 1356); + assert_eq!(fee, 1365); assert_eq!( decode_hex_prefixed(data.as_str()), ssz::Encode::as_ssz_bytes(&method) @@ -502,6 +506,7 @@ async fn api_rejects_user_op_payloads_above_application_limit() { let signing_key = SigningKey::from_bytes((&[7_u8; 32]).into()).expect("create signing key"); let sender = address_from_signing_key(&signing_key); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: TEST_MAX_FEE, data: vec![0_u8; MAX_METHOD_PAYLOAD_BYTES + 1].into(), @@ -612,7 +617,7 @@ async fn api_payload_size_check_fires_before_signature_recovery() { let oversized_data_hex = "00".repeat(MAX_METHOD_PAYLOAD_BYTES + 1); let bogus_sig_hex = format!("0x{}", "00".repeat(65)); let body = format!( - "{{\"message\":{{\"nonce\":0,\"max_fee\":0,\"data\":\"0x{oversized_data_hex}\"}},\ + "{{\"message\":{{\"nonce\":0,\"max_fee\":0,\"valid_until_block\":100,\"data\":\"0x{oversized_data_hex}\"}},\ \"signature\":\"{bogus_sig_hex}\",\ \"sender\":\"0x0000000000000000000000000000000000000001\"}}", ); @@ -727,6 +732,7 @@ async fn api_rejects_sender_claim_that_mismatches_signature_recovery() { assert_ne!(address_a, address_b, "test setup: A and B must differ"); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: TEST_MAX_FEE, data: Vec::new().into(), @@ -782,6 +788,7 @@ async fn api_rejects_user_op_with_nonce_gap() { // Current user nonce is 0 — a fresh sender has never submitted. Nonce 7 // leaves a six-slot gap. let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 7, max_fee: TEST_MAX_FEE, data: ssz::Encode::as_ssz_bytes(&Method::Withdrawal(Withdrawal { @@ -824,9 +831,9 @@ async fn api_accepts_user_op_with_max_fee_equal_to_current_frame_fee() { // Fund with enough to cover gas at the frame fee. bootstrap_open_frame_with_deposits(db.path.as_str(), &[(sender, U256::from(1_000_000_u64))]); - // `bootstrap_open_frame` asserts frame_fee == 1356; use that exact value + // `bootstrap_open_frame` asserts frame_fee == 1365; use that exact value // for the boundary case. - const FRAME_FEE_BOUNDARY: u16 = 1356; + const FRAME_FEE_BOUNDARY: u16 = 1365; let Some(runtime) = start_full_server(db.path.as_str(), domain.clone()).await else { return; @@ -837,6 +844,7 @@ async fn api_accepts_user_op_with_max_fee_equal_to_current_frame_fee() { .expect("build sequencer client"); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: FRAME_FEE_BOUNDARY, data: ssz::Encode::as_ssz_bytes(&Method::Withdrawal(Withdrawal { @@ -877,7 +885,7 @@ async fn api_quotes_open_frame_fee() { .expect("build sequencer client"); let quoted = client.get_fee().await.expect("GET /fee"); assert_eq!( - quoted.fee, 1356, + quoted.fee, 1365, "quoted fee must match the bootstrapped open-frame fee" ); let raw = reqwest::get(format!("http://{}/fee", runtime.addr)) @@ -885,7 +893,7 @@ async fn api_quotes_open_frame_fee() { .expect("raw GET /fee"); assert_eq!(raw.headers()["cache-control"], "no-store"); assert_eq!( - quoted.recommended_fee, 1356, + quoted.recommended_fee, 1365, "bootstrapped recommended_fee matches the open-frame fee" ); assert_eq!( @@ -931,8 +939,9 @@ async fn api_serves_the_next_nonce_after_each_ack_and_the_signing_domain() { "a read after the previous 200 must see that op" ); let user_op = UserOp { + valid_until_block: u64::MAX, nonce, - max_fee: 1356, + max_fee: 1365, data: ssz::Encode::as_ssz_bytes(&Method::Withdrawal(Withdrawal { amount: U256::from(0_u64), })) @@ -960,7 +969,7 @@ async fn api_rejects_user_op_when_balance_below_fee_cost() { // user op must be rejected with 422 `InsufficientFeeBalance` and leave // state unchanged. Exercises the balance check in // `WalletApp::validate_user_op` (app-core). A fresh sender with no - // deposits has balance 0, well below `fee_to_linear(1356)` (the + // deposits has balance 0, well below `fee_to_linear(1365)` (the // bootstrapped frame fee). let db = temp_db("insufficient-fee-balance"); let domain = test_domain(); @@ -978,6 +987,7 @@ async fn api_rejects_user_op_when_balance_below_fee_cost() { .expect("build sequencer client"); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: TEST_MAX_FEE, data: ssz::Encode::as_ssz_bytes(&Method::Withdrawal(Withdrawal { @@ -1024,6 +1034,7 @@ async fn api_concurrent_same_nonce_leaves_exactly_one_committed() { }; let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: TEST_MAX_FEE, data: ssz::Encode::as_ssz_bytes(&Method::Withdrawal(Withdrawal { @@ -1227,8 +1238,8 @@ async fn start_full_server_with_max_body( .open_state() .expect("load open state") .expect("open state exists"); - // Default log_gas_price=0 -> 0+296+20+419+621 = 1356. - assert_eq!(head.frame_fee, 1356); + // Default log_gas_price=0 -> 0+296+20+419+630 = 1365. + assert_eq!(head.frame_fee, 1365); let (tx, lane_handle) = InclusionLane::::start( 128, @@ -1425,6 +1436,7 @@ fn make_valid_request(domain: &Eip712Domain) -> TxRequest { amount: U256::from(0_u64), }); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: TEST_MAX_FEE, data: ssz::Encode::as_ssz_bytes(&method).into(), diff --git a/sequencer/src/integration_tests/historical_bootstrap.rs b/sequencer/src/integration_tests/historical_bootstrap.rs index 4cb1bbc1..e0ab6859 100644 --- a/sequencer/src/integration_tests/historical_bootstrap.rs +++ b/sequencer/src/integration_tests/historical_bootstrap.rs @@ -87,6 +87,7 @@ fn transfer_batch( amount: u64, ) -> IngestedSafeInput { let op = UserOp { + valid_until_block: u64::MAX, nonce, max_fee: 0, data: Method::Transfer(Transfer { @@ -108,6 +109,7 @@ fn transfer_batch( safe_block, fee_price: 0, user_ops: vec![WireUserOp { + valid_until_block: u64::MAX, nonce, max_fee: op.max_fee, data: op.data.to_vec(), diff --git a/sequencer/src/integration_tests/historical_bootstrap/recovery_compatibility.rs b/sequencer/src/integration_tests/historical_bootstrap/recovery_compatibility.rs index 165ed235..a02089f4 100644 --- a/sequencer/src/integration_tests/historical_bootstrap/recovery_compatibility.rs +++ b/sequencer/src/integration_tests/historical_bootstrap/recovery_compatibility.rs @@ -65,6 +65,7 @@ fn append_transfer( amount: u64, ) { let op = UserOp { + valid_until_block: u64::MAX, nonce: app.current_user_nonce(signer.address()), max_fee: head.frame_fee, data: Method::Transfer(Transfer { @@ -180,6 +181,7 @@ async fn replay_one(stream: &mut SubscribeStream, app: &mut WalletApp, history: }; assert_eq!(offset, app.executed_input_count().get()); let op = UserOp { + valid_until_block: u64::MAX, nonce, max_fee: fee, data: alloy_primitives::hex::decode(data).unwrap().into(), diff --git a/sequencer/src/integration_tests/ws_broadcaster.rs b/sequencer/src/integration_tests/ws_broadcaster.rs index a057e93a..22744b00 100644 --- a/sequencer/src/integration_tests/ws_broadcaster.rs +++ b/sequencer/src/integration_tests/ws_broadcaster.rs @@ -289,6 +289,7 @@ fn seed_ordered_txs(db_path: &str) { sender: Address::from_slice(&[0x11; 20]), signature: Signature::test_signature(), user_op: UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 3, data: vec![0x42].into(), diff --git a/sequencer/src/l1/application_status.rs b/sequencer/src/l1/application_status.rs new file mode 100644 index 00000000..99cda165 --- /dev/null +++ b/sequencer/src/l1/application_status.rs @@ -0,0 +1,123 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Foreclosure is irreversible: refuse admission and further batch submission. + +use alloy::providers::Provider; +use alloy::rpc::types::BlockNumberOrTag; +use alloy_primitives::Address; +use cartesi_rollups_contracts::i_application::IApplication; + +#[derive(Debug, thiserror::Error)] +pub enum ApplicationStatusError { + #[error("application {app} is foreclosed; automatic recovery cannot resume service")] + Foreclosed { app: Address }, + #[error("application status provider/transport: {0}")] + Provider(String), + #[error("invalid application status response: {0}")] + InvalidResponse(String), + #[error("application status RPC chain id {rpc} does not match pinned chain id {expected}")] + ChainIdMismatch { rpc: u64, expected: u64 }, +} + +impl ApplicationStatusError { + pub(crate) fn is_terminal(&self) -> bool { + match self { + Self::Foreclosed { .. } | Self::ChainIdMismatch { .. } | Self::InvalidResponse(_) => { + true + } + Self::Provider(_) => false, + } + } +} + +pub(crate) async fn require_open( + provider: &impl Provider, + app: Address, + expected_chain_id: u64, +) -> Result<(), ApplicationStatusError> { + let rpc = provider + .get_chain_id() + .await + .map_err(|error| ApplicationStatusError::Provider(error.to_string()))?; + if rpc != expected_chain_id { + return Err(ApplicationStatusError::ChainIdMismatch { + rpc, + expected: expected_chain_id, + }); + } + // Latest detects closure before safe finality. A transient reorg may cause + // a conservative stop; a deliberate restart rechecks the contract. + let foreclosed = IApplication::new(app, provider) + .isForeclosed() + .block(BlockNumberOrTag::Latest.into()) + .call() + .await + .map_err(|error| match error { + alloy::contract::Error::TransportError(_) => { + ApplicationStatusError::Provider(error.to_string()) + } + _ => ApplicationStatusError::InvalidResponse(error.to_string()), + })?; + if foreclosed { + return Err(ApplicationStatusError::Foreclosed { app }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::providers::ProviderBuilder; + use alloy::transports::mock::Asserter; + use alloy_primitives::{Bytes, U64}; + + #[tokio::test] + async fn status_distinguishes_unavailable_rpc_from_invalid_contract_response() { + for malformed in [false, true] { + let mock = Asserter::new(); + mock.push_success(&U64::from(31337)); + if malformed { + mock.push_success(&Bytes::new()); + } else { + mock.push_failure_msg("temporarily unavailable"); + } + let provider = ProviderBuilder::new().connect_mocked_client(mock); + let error = require_open(&provider, Address::repeat_byte(1), 31337) + .await + .unwrap_err(); + assert_eq!(error.is_terminal(), malformed); + } + } + + #[tokio::test] + async fn status_requires_the_right_chain_and_an_open_application() { + let app = Address::repeat_byte(1); + for foreclosed in [false, true] { + let mock = Asserter::new(); + mock.push_success(&U64::from(31337)); + let mut result = vec![0; 32]; + result[31] = u8::from(foreclosed); + mock.push_success(&Bytes::from(result)); + let provider = ProviderBuilder::new().connect_mocked_client(mock); + let result = require_open(&provider, app, 31337).await; + if foreclosed { + assert!( + matches!(result, Err(ApplicationStatusError::Foreclosed { app: a }) if a == app) + ); + } else { + result.unwrap(); + } + } + let mock = Asserter::new(); + mock.push_success(&U64::from(1)); + let provider = ProviderBuilder::new().connect_mocked_client(mock); + assert!(matches!( + require_open(&provider, app, 31337).await, + Err(ApplicationStatusError::ChainIdMismatch { + rpc: 1, + expected: 31337 + }) + )); + } +} diff --git a/sequencer/src/l1/mod.rs b/sequencer/src/l1/mod.rs index d0f3cb17..cfa3e5cf 100644 --- a/sequencer/src/l1/mod.rs +++ b/sequencer/src/l1/mod.rs @@ -5,6 +5,7 @@ //! batches back out (`submitter`), and shares L1 utilities (`provider`, //! `partition`). +pub mod application_status; pub mod eip1559; pub mod fee_oracle; pub mod partition; diff --git a/sequencer/src/l1/provider.rs b/sequencer/src/l1/provider.rs index 702f2458..1855f48d 100644 --- a/sequencer/src/l1/provider.rs +++ b/sequencer/src/l1/provider.rs @@ -4,6 +4,7 @@ use std::str::FromStr; use std::time::Duration; +use alloy::rpc::json_rpc::{RequestPacket, ResponsePacket}; use alloy::{ providers::{DynProvider, Provider, ProviderBuilder}, rpc::client::RpcClient, @@ -11,6 +12,8 @@ use alloy::{ transports::http::{Http, reqwest, reqwest::Url}, }; use alloy_transport::layers::RetryBackoffLayer; +use alloy_transport::{TransportError, TransportErrorKind, TransportFut}; +use tower_service::Service; use url::Host; // Public Ethereum providers (Infura, Alchemy) commonly take 30–60s on heavy @@ -44,7 +47,7 @@ fn create_client(url: &str, allow_insecure: bool) -> Result { } if remote_plaintext { tracing::warn!( - url = %url, + endpoint = %url.origin().ascii_serialization(), "insecure plaintext RPC to a non-loopback host — explicitly allowed via allow-insecure-rpc" ); } @@ -65,7 +68,40 @@ fn create_client(url: &str, allow_insecure: bool) -> Result { Ok(RpcClient::builder() .layer(retry) - .transport(transport, is_local)) + .transport(UrlSafeTransport(transport), is_local)) +} + +// Redact at the transport boundary, before retry wrappers or callers turn +// errors into text. Keep the reqwest error and its cause, without its URL. +#[derive(Clone, Debug)] +struct UrlSafeTransport(Http); + +impl Service for UrlSafeTransport { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.0.poll_ready(cx) + } + + fn call(&mut self, request: RequestPacket) -> Self::Future { + let future = self.0.call(request); + Box::pin(async move { + future.await.map_err(|error| match error { + TransportError::Transport(TransportErrorKind::Custom(cause)) => { + match cause.downcast::() { + Ok(cause) => TransportErrorKind::custom(cause.without_url()), + Err(cause) => TransportError::Transport(TransportErrorKind::Custom(cause)), + } + } + error => error, + }) + }) + } } /// Whether a parsed URL host refers to a loopback address. @@ -271,6 +307,30 @@ mod tests { .expect("valid key must be accepted"); } + #[tokio::test] + async fn rpc_transport_errors_omit_credentials_from_display_debug_and_causes() { + use std::error::Error; + let provider = create_provider( + "http://user:SECRET_PASSWORD@127.0.0.1:9/v2/SECRET_PATH?apikey=SECRET_QUERY", + false, + ) + .unwrap(); + let error = provider.get_chain_id().await.unwrap_err(); + let mut rendered = format!("{error} {error:?}"); + let mut cause = error.source(); + while let Some(error) = cause { + rendered.push_str(&format!(" {error} {error:?}")); + cause = error.source(); + } + for secret in ["SECRET_PASSWORD", "SECRET_PATH", "SECRET_QUERY"] { + assert!(!rendered.contains(secret), "credential leaked: {rendered}"); + } + assert!( + rendered.to_ascii_lowercase().contains("connect"), + "retain the failure reason: {rendered}" + ); + } + // ── Keyed-write chain-id gate (review): the guarded constructor must // confirm the served chain matches the pinned one before signing ─ diff --git a/sequencer/src/l1/submitter/poster.rs b/sequencer/src/l1/submitter/poster.rs index 6db3ef6f..d08dbef4 100644 --- a/sequencer/src/l1/submitter/poster.rs +++ b/sequencer/src/l1/submitter/poster.rs @@ -55,6 +55,8 @@ pub struct BatchPosterConfig { pub enum BatchPosterError { #[error("provider/transport: {0}")] Provider(String), + #[error(transparent)] + ApplicationStatus(#[from] crate::l1::application_status::ApplicationStatusError), #[error("rpc chain id {rpc} does not match pinned chain id {expected}")] ChainIdMismatch { rpc: u64, expected: u64 }, #[error( @@ -77,6 +79,7 @@ impl BatchPosterError { // here, not silently default to restartable. match self { Self::ChainIdMismatch { .. } | Self::WalletNonceRangeUnrepresentable { .. } => true, + Self::ApplicationStatus(source) => source.is_terminal(), Self::Watermark(source) => source.is_persistent_invariant(), Self::Provider(_) | Self::Shutdown => false, } @@ -276,14 +279,27 @@ impl EthereumBatchPoster { let gas = pad_gas_estimate( call.estimate_gas() .await - .map_err(|err| BatchPosterError::Provider(err.to_string()))?, + .map_err(|err| self.classify_contract_error(err))?, ); call.gas(gas) .nonce(nonce) .send() .await - .map_err(|err| BatchPosterError::Provider(err.to_string())) + .map_err(|err| self.classify_contract_error(err)) + } + + fn classify_contract_error(&self, error: alloy::contract::Error) -> BatchPosterError { + if let Some(revert) = error + .as_decoded_error::() + && revert.appContract == self.config.app_address + { + return crate::l1::application_status::ApplicationStatusError::Foreclosed { + app: revert.appContract, + } + .into(); + } + BatchPosterError::Provider(error.to_string()) } /// Wait serially for each tx to reach `confirmation_depth + 1` @@ -494,6 +510,12 @@ impl BatchPoster for EthereumBatchPoster { &self, from_block: u64, ) -> Result, BatchPosterError> { + crate::l1::application_status::require_open( + &self.provider, + self.config.app_address, + self.config.expected_chain_id, + ) + .await?; let latest = self .provider .get_block_number() @@ -905,6 +927,81 @@ mod tests { ); } + #[test] + fn foreclosure_revert_is_terminal_only_for_the_pinned_application() { + use alloy::providers::ProviderBuilder; + use alloy::transports::mock::Asserter; + use alloy_primitives::{Address, Bytes}; + use alloy_sol_types::SolError; + let app = Address::repeat_byte(2); + let provider = ProviderBuilder::new() + .connect_mocked_client(Asserter::new()) + .erased(); + let poster = EthereumBatchPoster::new( + provider, + BatchPosterConfig { + l1_submit_address: Address::repeat_byte(1), + app_address: app, + batch_submitter_address: SUBMITTER, + start_block: 0, + confirmation_depth: 0, + seconds_per_block: 1, + long_block_range_error_codes: vec![], + expected_chain_id: 31337, + }, + RuntimeScope::default(), + ); + for address in [app, Address::repeat_byte(3)] { + let revert = + cartesi_rollups_contracts::ether_portal::EtherPortal::ApplicationForeclosed { + appContract: address, + }; + let payload = serde_json::from_value(serde_json::json!({ + "code": 3, "message": "execution reverted", "data": Bytes::from(revert.abi_encode()), + })).unwrap(); + let error = alloy::contract::Error::TransportError( + alloy_transport::TransportError::ErrorResp(payload), + ); + let error = poster.classify_contract_error(error); + assert_eq!(error.is_terminal_invariant(), address == app); + } + } + + #[tokio::test] + async fn foreclosed_application_stops_even_an_idle_poster() { + use alloy::providers::ProviderBuilder; + use alloy::transports::mock::Asserter; + use alloy_primitives::{Address, Bytes, U64}; + let mock = Asserter::new(); + mock.push_success(&U64::from(31337)); + let mut result = vec![0; 32]; + result[31] = 1; + mock.push_success(&Bytes::from(result)); + let provider = ProviderBuilder::new().connect_mocked_client(mock).erased(); + let poster = EthereumBatchPoster::new( + provider, + BatchPosterConfig { + l1_submit_address: Address::repeat_byte(1), + app_address: Address::repeat_byte(2), + batch_submitter_address: SUBMITTER, + start_block: 0, + confirmation_depth: 0, + seconds_per_block: 1, + long_block_range_error_codes: vec![], + expected_chain_id: 31337, + }, + RuntimeScope::default(), + ); + let error = poster.observed_submitted_batch_nonces(0).await.unwrap_err(); + assert!(matches!( + &error, + BatchPosterError::ApplicationStatus( + crate::l1::application_status::ApplicationStatusError::Foreclosed { .. } + ) + )); + assert!(error.is_terminal_invariant()); + } + #[tokio::test] async fn mock_poster_tracks_requested_suffix_start_block() { let poster = MockBatchPoster::new(); diff --git a/sequencer/src/recovery/mod.rs b/sequencer/src/recovery/mod.rs index 3bf9b9f9..7dcd32fc 100644 --- a/sequencer/src/recovery/mod.rs +++ b/sequencer/src/recovery/mod.rs @@ -35,6 +35,8 @@ pub enum RecoveryError { /// verdict. #[derive(Debug, Error)] pub enum RecoveryFailure { + #[error(transparent)] + ApplicationStatus(#[from] crate::l1::application_status::ApplicationStatusError), #[error(transparent)] PolicyRetry(#[from] RecoveryRetryReason), #[error(transparent)] @@ -212,6 +214,7 @@ fn inspect_recovery( /// Only L1 operations are abstracted: tests keep the real local inspections /// and repair transactions and substitute the external system. trait RecoveryL1 { + async fn require_open_application(&mut self) -> Result<(), RecoveryError>; async fn sync(&mut self) -> Result<(), InputReaderError>; async fn flush(&mut self) -> Result; } @@ -224,6 +227,31 @@ struct StartupL1<'a> { } impl RecoveryL1 for StartupL1<'_> { + async fn require_open_application(&mut self) -> Result<(), RecoveryError> { + let provider = crate::l1::provider::create_provider( + &self.l1_config.eth_rpc_url, + self.l1_config.allow_insecure_rpc, + ) + .map_err(|message| { + RecoveryError::refuse(RecoveryFailure::InputReader(InputReaderError::Bootstrap( + message, + ))) + })?; + crate::l1::application_status::require_open( + &provider, + self.l1_config.identity.app_address, + self.l1_config.identity.chain_id, + ) + .await + .map_err(|error| { + if error.is_terminal() { + RecoveryError::refuse(RecoveryFailure::ApplicationStatus(error)) + } else { + RecoveryError::retry(RecoveryFailure::ApplicationStatus(error)) + } + }) + } + async fn sync(&mut self) -> Result<(), InputReaderError> { self.input_reader.sync_to_current_safe_head().await } @@ -264,6 +292,7 @@ async fn recover_startup( l1: &mut impl RecoveryL1, ) -> Result<(), RecoveryError> { refuse_local_terminal(inspect_recovery(db_path, protocol)?)?; + l1.require_open_application().await?; match l1.sync().await { Ok(()) => tracing::info!("L1 safe head synced"), // An unreachable provider does not invalidate a still-fresh persisted @@ -706,6 +735,8 @@ mod tests { syncs: VecDeque, flush_block: u64, calls: Vec<&'static str>, + application_error: Option, + application_checks: usize, } impl<'a> TestL1<'a> { @@ -716,11 +747,21 @@ mod tests { syncs: syncs.into(), flush_block: protocol.danger_threshold(), calls: Vec::new(), + application_error: None, + application_checks: 0, } } } impl RecoveryL1 for TestL1<'_> { + async fn require_open_application(&mut self) -> Result<(), RecoveryError> { + self.application_checks += 1; + match self.application_error.take() { + Some(error) => Err(error), + None => Ok(()), + } + } + async fn sync(&mut self) -> Result<(), InputReaderError> { self.calls.push("sync"); match self.syncs.pop_front().expect("unexpected L1 sync") { @@ -782,6 +823,39 @@ mod tests { .unwrap() } + #[tokio::test] + async fn foreclosure_or_unknown_application_status_prevents_repair() { + use crate::l1::application_status::ApplicationStatusError; + for foreclosed in [false, true] { + let (db, protocol) = danger_fixture("recovery-app-status", true); + // A deliberate restart must perform the same gate again. + for _ in 0..2 { + let mut l1 = TestL1::new(&db.path, &protocol, vec![]); + l1.application_error = Some(if foreclosed { + RecoveryError::refuse(RecoveryFailure::ApplicationStatus( + ApplicationStatusError::Foreclosed { + app: alloy_primitives::Address::ZERO, + }, + )) + } else { + RecoveryError::retry(RecoveryFailure::ApplicationStatus( + ApplicationStatusError::Provider("offline".into()), + )) + }); + let result = recover_startup(&db.path, &protocol, &mut l1) + .await + .unwrap_err(); + assert_eq!(result.is_retryable(), !foreclosed); + assert_eq!(l1.application_checks, 1); + assert!( + l1.calls.is_empty(), + "no sync or flush after a refused status gate" + ); + assert!(invalidated_batches(&db.path).is_empty()); + } + } + } + #[tokio::test] async fn initial_provider_failure_uses_only_a_fresh_persisted_view() { let (db, protocol) = admission_fixture("recovery-warm-provider-failure", true, true); @@ -818,6 +892,7 @@ mod tests { .unwrap_err(), ); assert!(l1.calls.is_empty()); + assert_eq!(l1.application_checks, 0); } } diff --git a/sequencer/src/storage/egress/canonical/tests.rs b/sequencer/src/storage/egress/canonical/tests.rs index 63edf933..b4bd8430 100644 --- a/sequencer/src/storage/egress/canonical/tests.rs +++ b/sequencer/src/storage/egress/canonical/tests.rs @@ -28,6 +28,7 @@ fn included(nonce: u32, offset: u64, payload: u8) -> IncludedUserOp { sender: SENDER_B, signature: Signature::test_signature(), user_op: UserOp { + valid_until_block: u64::MAX, nonce, max_fee: u16::MAX, data: vec![payload].into(), diff --git a/sequencer/src/storage/ingress.rs b/sequencer/src/storage/ingress.rs index e39a6c9b..99576302 100644 --- a/sequencer/src/storage/ingress.rs +++ b/sequencer/src/storage/ingress.rs @@ -60,13 +60,17 @@ impl Storage { } /// Open-frame fee and current `recommended_fee`. `None` if there is no Tip. - pub fn current_fee_quote(&mut self) -> Result> { + pub fn current_fee_quote(&mut self) -> Result> { self.read(|tx| { let Some(head) = load_current_write_head(tx)? else { return Ok(None); }; let policy = query_batch_policy(tx)?; - Ok(Some((head.frame_fee, policy.recommended_fee))) + Ok(Some(( + head.frame_fee, + policy.recommended_fee, + head.safe_block, + ))) }) } @@ -711,8 +715,8 @@ fn insert_user_op_iter<'a>( let mut stmt = tx.prepare_cached( "INSERT INTO user_ops ( batch_index, frame_in_batch, pos_in_frame, - sender, nonce, max_fee, data, sig, received_at_ms - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + sender, nonce, max_fee, data, sig, received_at_ms, valid_until_block + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", )?; for (offset, item) in user_ops.into_iter().enumerate() { let offset = @@ -731,6 +735,11 @@ fn insert_user_op_iter<'a>( item.signed.user_op.data.as_ref(), &sig[..], to_unix_ms(item.received_at), + item.signed + .user_op + .valid_until_block + .to_le_bytes() + .as_slice(), ])?; } Ok(()) @@ -792,6 +801,7 @@ mod tests { sender: Address::ZERO, signature: Signature::test_signature(), user_op: UserOp { + valid_until_block: u64::MAX, nonce, max_fee: u16::MAX, data: vec![].into(), @@ -822,6 +832,27 @@ mod tests { .expect("pin deployment identity"); } + #[test] + fn expiry_survives_storage_and_batch_encoding_without_truncation() { + let db = temp_db("expiry-round-trip"); + let mut storage = Storage::open(&db.path).unwrap(); + storage + .initialize_open_state(7, SafeInputRange::empty_at(0)) + .unwrap(); + let mut head = storage.open_state().unwrap().unwrap(); + let mut op = pending_user_op(0); + op.signed.user_op.valid_until_block = u64::MAX - 17; + storage.append_user_ops_chunk(&mut head, &[op]).unwrap(); + storage.close_frame_and_batch(&mut head, 7).unwrap(); + let pending = storage.pending_batches(0).unwrap(); + let decoded: sequencer_core::batch::Batch = + ssz::Decode::from_ssz_bytes(&pending[0].encoded).unwrap(); + assert_eq!( + decoded.frames[0].user_ops[0].valid_until_block, + u64::MAX - 17 + ); + } + #[test] fn snapshot_registration_failure_rolls_back_batch_close_and_cached_head() { let db = temp_db("snapshot-close-rollback"); @@ -910,8 +941,8 @@ mod tests { assert_eq!(head_a.batch_index, head_b.batch_index); assert_eq!(head_a.frame_in_batch, head_b.frame_in_batch); assert_eq!(head_a.frame_fee, head_b.frame_fee); - // Default log_recommended_fee = 0+296+20+419+621 = 1356 - assert_eq!(head_a.frame_fee, 1356); + // Default log_recommended_fee = 0+296+20+419+630 = 1365 + assert_eq!(head_a.frame_fee, 1365); let mut head_c = head_b; let next_safe_block = head_c.safe_block; @@ -974,6 +1005,7 @@ mod tests { sender: Address::ZERO, signature: Signature::test_signature(), user_op: UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: u16::MAX, data: vec![].into(), @@ -1193,8 +1225,8 @@ mod tests { let db = temp_db("batch-policy-fee"); let mut storage = Storage::open(db.path.as_str()).expect("open storage"); let policy = storage.batch_policy().expect("default policy"); - // Default: log_gas_price=0, log_recommended_fee = 0+296+20+419+621 = 1356 - assert_eq!(policy.recommended_fee, 1356); + // Default: log_gas_price=0, log_recommended_fee = 0+296+20+419+630 = 1365 + assert_eq!(policy.recommended_fee, 1365); storage.set_log_gas_price(100).expect("set log gas price"); @@ -1207,8 +1239,8 @@ mod tests { .expect("rotate batch"); let policy = storage.batch_policy().expect("read policy"); - // log_recommended_fee = 100+296+20+419+621 = 1456 - assert_eq!(head.frame_fee, 1456); + // log_recommended_fee = 100+296+20+419+630 = 1465 + assert_eq!(head.frame_fee, 1465); assert_eq!(head.frame_fee, policy.recommended_fee); assert!( head.max_batch_user_op_bytes > 0, @@ -1232,8 +1264,8 @@ mod tests { .expect("initialize open state"); let original_batch_index = head.batch_index; let original_frame_in_batch = head.frame_in_batch; - // Default: log_gas_price=0 → log_recommended_fee = 0+296+20+419+621 = 1356 - assert_eq!(head.frame_fee, 1356); + // Default: log_gas_price=0 → log_recommended_fee = 0+296+20+419+630 = 1365 + assert_eq!(head.frame_fee, 1365); // Simulate an operator policy update mid-frame: fee oracle reports a // higher gas price. The derived view reflects the new fee immediately. @@ -1242,11 +1274,11 @@ mod tests { .expect("set higher log gas price"); let new_policy = storage.batch_policy().expect("read updated policy"); assert_eq!( - new_policy.recommended_fee, 1456, + new_policy.recommended_fee, 1465, "policy-derived fee should reflect the new gas price", ); - // Invariant: the already-open frame's persisted fee stays at 1356. + // Invariant: the already-open frame's persisted fee stays at 1365. let persisted_frame_fee: i64 = storage .conn .query_row( @@ -1256,35 +1288,35 @@ mod tests { ) .expect("query open frame fee"); assert_eq!( - persisted_frame_fee, 1356, + persisted_frame_fee, 1365, "open frame's committed fee must not change across policy updates", ); // And the in-memory WriteHead mirror must also be stable — the lane // submitting against this head should see a consistent fee. assert_eq!( - head.frame_fee, 1356, + head.frame_fee, 1365, "WriteHead.frame_fee must stay stable until advance_frame runs", ); assert_eq!( storage.current_fee_quote().expect("quote"), - Some((1356, 1456)), + Some((1365, 1465, head.safe_block)), "quote splits the frozen open-frame fee from the advanced recommended_fee", ); // Closing the frame picks up the new policy — the *next* frame opens - // at 1456. This is the expected policy-flow boundary. + // at 1465. This is the expected policy-flow boundary. let next_safe_block = head.safe_block; storage .close_frame_only(&mut head, next_safe_block, SafeInputRange::empty_at(0)) .expect("rotate within same batch"); assert_eq!( - head.frame_fee, 1456, + head.frame_fee, 1465, "the next frame must use the updated policy's fee (policy flows in at close)", ); assert_eq!( storage.current_fee_quote().expect("quote"), - Some((1456, 1456)), + Some((1465, 1465, head.safe_block)), "after rotation the quote's two fees agree again", ); } diff --git a/sequencer/src/storage/l1_submission.rs b/sequencer/src/storage/l1_submission.rs index 89e150ed..687deebd 100644 --- a/sequencer/src/storage/l1_submission.rs +++ b/sequencer/src/storage/l1_submission.rs @@ -244,7 +244,7 @@ pub(super) fn load_batch_frames_in( let mut frames = Vec::with_capacity(frame_headers.len()); for header in frame_headers { let mut stmt = conn.prepare_cached( - "SELECT nonce, max_fee, data, sig FROM user_ops \ + "SELECT nonce, max_fee, data, sig, valid_until_block FROM user_ops \ WHERE batch_index = ?1 AND frame_in_batch = ?2 \ ORDER BY pos_in_frame ASC", )?; @@ -252,6 +252,11 @@ pub(super) fn load_batch_frames_in( params![u64_to_i64(batch_index), i64::from(header.frame_in_batch)], |row| { Ok(WireUserOp { + valid_until_block: u64::from_le_bytes( + row.get::<_, Vec>(4)? + .try_into() + .expect("expiry has eight bytes"), + ), nonce: i64_to_u32(row.get(0)?), max_fee: i64_to_u16(row.get(1)?), data: row.get(2)?, @@ -315,8 +320,8 @@ mod tests { let frame = &decoded.frames[0]; assert!(frame.user_ops.is_empty()); assert_eq!(frame.safe_block, 12); - // Default log_recommended_fee = 0+296+20+419+621 = 1356. - assert_eq!(frame.fee_price, 1356); + // Default log_recommended_fee = 0+296+20+419+630 = 1365. + assert_eq!(frame.fee_price, 1365); } #[test] diff --git a/sequencer/src/storage/migrations/0001_schema.sql b/sequencer/src/storage/migrations/0001_schema.sql index 1299a75f..9e716f0e 100644 --- a/sequencer/src/storage/migrations/0001_schema.sql +++ b/sequencer/src/storage/migrations/0001_schema.sql @@ -224,6 +224,7 @@ CREATE TABLE IF NOT EXISTS user_ops ( sender BLOB NOT NULL CHECK (length(sender) = 20), nonce INTEGER NOT NULL CHECK (nonce >= 0), max_fee INTEGER NOT NULL CHECK (max_fee >= 0), + valid_until_block BLOB NOT NULL CHECK (length(valid_until_block) = 8), data BLOB NOT NULL, sig BLOB NOT NULL CHECK (length(sig) = 65), received_at_ms INTEGER NOT NULL, @@ -592,11 +593,11 @@ CREATE TABLE IF NOT EXISTS batch_policy ( -- log_one_plus_alpha = log_{129/128}(1.168) = 20 -- log_base_gas = log_{129/128}(55000) = 1403 -- log_delta = log_{129/128}(26) = 419 --- log_user_op_bytes = log_{129/128}(126) = 621 +-- log_user_op_bytes = ceil(log_{129/128}(134)) = 630 -- log_max_batch_bytes = log_{129/128}(32000) = 1333 -- -- Derived by view: --- log_recommended_fee = 0 + 296 + 20 + 419 + 621 = 1356 +-- log_recommended_fee = 0 + 296 + 20 + 419 + 630 = 1365 -- log_batch_size_target = 1403 - (-229) - 419 = 1213 INSERT OR IGNORE INTO batch_policy( singleton_id, @@ -610,7 +611,7 @@ VALUES ( -229, 20, 0, 0, 296, - 1403, 419, 621, 1333 + 1403, 419, 630, 1333 ); -- Derived view for reads. All outputs are log-space exponents (base 129/128). diff --git a/sequencer/src/storage/recovery_tests.rs b/sequencer/src/storage/recovery_tests.rs index 30fc2687..40064af9 100644 --- a/sequencer/src/storage/recovery_tests.rs +++ b/sequencer/src/storage/recovery_tests.rs @@ -2382,7 +2382,7 @@ mod schema_invariants { // Batch 0 is sealed. Any direct insert into its frames must fail. let err = storage.conn.execute( "INSERT INTO frames (batch_index, frame_in_batch, created_at_ms, fee, safe_block) \ - VALUES (0, 1, 100, 1356, 0)", + VALUES (0, 1, 100, 1365, 0)", [], ); assert!( @@ -2402,7 +2402,7 @@ mod schema_invariants { storage.insert_invalid_batch(0).expect("invalidate tip"); let err = storage.conn.execute( "INSERT INTO frames (batch_index, frame_in_batch, created_at_ms, fee, safe_block) \ - VALUES (0, 1, 100, 1356, 0)", + VALUES (0, 1, 100, 1365, 0)", [], ); assert!( @@ -2609,8 +2609,8 @@ mod schema_invariants { .expect("initialize"); let err = storage.conn.execute( "INSERT INTO user_ops \ - (batch_index, frame_in_batch, pos_in_frame, sender, nonce, max_fee, data, sig, received_at_ms) \ - VALUES (0, 0, 0, X'010203', 0, 0, X'', ?1, 0)", + (batch_index, frame_in_batch, pos_in_frame, sender, nonce, max_fee, data, sig, received_at_ms, valid_until_block) \ + VALUES (0, 0, 0, X'010203', 0, 0, X'', ?1, 0, X'ffffffffffffffff')", params![vec![0u8; 65]], ); assert!( @@ -2633,8 +2633,8 @@ mod schema_invariants { let short_sig = vec![0u8; 32]; // Should be 65. let err = storage.conn.execute( "INSERT INTO user_ops \ - (batch_index, frame_in_batch, pos_in_frame, sender, nonce, max_fee, data, sig, received_at_ms) \ - VALUES (0, 0, 0, ?1, 0, 0, X'', ?2, 0)", + (batch_index, frame_in_batch, pos_in_frame, sender, nonce, max_fee, data, sig, received_at_ms, valid_until_block) \ + VALUES (0, 0, 0, ?1, 0, 0, X'', ?2, 0, X'ffffffffffffffff')", params![valid_sender, short_sig], ); assert!( diff --git a/sequencer/src/storage/snapshot_dumps.rs b/sequencer/src/storage/snapshot_dumps.rs index e2503dec..e912839d 100644 --- a/sequencer/src/storage/snapshot_dumps.rs +++ b/sequencer/src/storage/snapshot_dumps.rs @@ -632,6 +632,7 @@ mod tests { sender: Address::ZERO, signature: Signature::test_signature(), user_op: UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: u16::MAX, data: vec![].into(), diff --git a/tests/benchmarks/README.md b/tests/benchmarks/README.md index 79c3a695..236032e7 100644 --- a/tests/benchmarks/README.md +++ b/tests/benchmarks/README.md @@ -35,7 +35,7 @@ just --justfile tests/benchmarks/justfile all-and-compare ``` The CLI and `just` recipes default `max_fee=2000`, above the self-contained -harness's initial frame fee of 1356. External deployments can charge a different +harness's initial frame fee of 1365. External deployments can charge a different fee. A run whose `--max-fee` is below the frame fee rejects its transactions with `422 EXECUTION_REJECTED`; choose a sufficient limit and require accepted transactions and matching WS events before interpreting latency results. diff --git a/tests/benchmarks/src/runtime.rs b/tests/benchmarks/src/runtime.rs index 381d79e0..88b4e3ac 100644 --- a/tests/benchmarks/src/runtime.rs +++ b/tests/benchmarks/src/runtime.rs @@ -66,7 +66,7 @@ pub async fn bootstrap_funded_workload( } // The funding signer will do one L2 transfer per plan. Each transfer costs gas - // at the frame fee (currently 1356), so we must deposit enough to cover both the + // at the frame fee (currently 1365), so we must deposit enough to cover both the // workload balances AND the funding transfers themselves. let transfer_count = plans .iter() diff --git a/tests/benchmarks/src/workload.rs b/tests/benchmarks/src/workload.rs index 8acf4f6c..1e92e889 100644 --- a/tests/benchmarks/src/workload.rs +++ b/tests/benchmarks/src/workload.rs @@ -129,6 +129,7 @@ impl WorkerContext { ))); } let user_op = UserOp { + valid_until_block: u64::MAX, nonce, max_fee: self.max_fee, data: data.into(), diff --git a/tests/contracts/foundry.toml b/tests/contracts/foundry.toml index 0aeb2485..0ac6e0b5 100644 --- a/tests/contracts/foundry.toml +++ b/tests/contracts/foundry.toml @@ -4,3 +4,6 @@ out = "out" libs = [] cache_path = "cache" solc_version = "0.8.28" + +allow_paths = ["../.."] +remappings = ["wallet/=../../examples/app-core/contracts/"] diff --git a/tests/contracts/src/CheckpointValidator.sol b/tests/contracts/src/CheckpointValidator.sol new file mode 100644 index 00000000..8f608a5c --- /dev/null +++ b/tests/contracts/src/CheckpointValidator.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.28; + +/// @dev Test fixture: consensus finalization is supplied by the scenario. +contract CheckpointValidator { + bytes32 public root; + uint256 public inputCount; + + function finalize(bytes32 root_, uint256 inputCount_) external { + root = root_; + inputCount = inputCount_; + } + + function supportsInterface(bytes4) external pure returns (bool) { return true; } + function getLastFinalizedMachineMerkleRoot(address) external view returns (bytes32) { return root; } + function wasInputFinalized(address, uint256 index, uint256) external view returns (bool) { return index < inputCount; } + function isOutputsMerkleRootValid(address, bytes32) external pure returns (bool) { return false; } +} diff --git a/tests/contracts/src/WalletExitHarnessBuilder.sol b/tests/contracts/src/WalletExitHarnessBuilder.sol new file mode 100644 index 00000000..ef6dd45d --- /dev/null +++ b/tests/contracts/src/WalletExitHarnessBuilder.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.28; +import {WalletWithdrawalOutputBuilder} from "wallet/WalletWithdrawalOutputBuilder.sol"; + +contract WalletExitHarnessBuilder is WalletWithdrawalOutputBuilder { + constructor(address token) WalletWithdrawalOutputBuilder(token) {} +} diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index 05c72d1a..9559e95d 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -14,6 +14,8 @@ name = "devnet-stack" path = "src/bin/devnet_stack.rs" [dependencies] +alloy = { workspace = true, features = ["contract", "network", "reqwest", "rpc-types", "signer-local"] } +cartesi-rollups-contracts = { workspace = true } rollups-harness = { path = "../harness" } tracing-subscriber = { workspace = true } app-core = { path = "../../examples/app-core" } diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index 8a9889eb..18779c26 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -3,6 +3,7 @@ mod cold_replica; pub mod test_cases; +mod wallet_exit; mod watchdog_compare; // Link the reference engine's C symbols for the EngineApp replica scenarios. diff --git a/tests/e2e/src/main.rs b/tests/e2e/src/main.rs index 9b0e521c..b564510e 100644 --- a/tests/e2e/src/main.rs +++ b/tests/e2e/src/main.rs @@ -22,13 +22,17 @@ fn main() { default_devnet_sequencer_config(log_prefix) }; let scenario_name = name.strip_prefix("c_host_").unwrap_or(name); + if scenario_name.starts_with("guardian_") { + spawn_config.guardian = true; + spawn_config.faketime = false; + } if scenario_name == "watchdog_genesis_compare_test" || scenario_name == "deposit_transfer_withdrawal_test" || scenario_name == "watchdog_divergence_drill_test" { spawn_config.faketime = false; } else if scenario_name == "fixed_fee_oracle_sets_frame_fee_test" { - // 100 → recommended fee 1456, under the wallet client's + // 100 → recommended fee 1465, under the wallet client's // DEFAULT_MAX_FEE (2500) so transfers still admit. spawn_config.fee_oracle_fixed_log_gas_price = Some(100); } diff --git a/tests/e2e/src/test_cases.rs b/tests/e2e/src/test_cases.rs index 371c79fb..201e58c1 100644 --- a/tests/e2e/src/test_cases.rs +++ b/tests/e2e/src/test_cases.rs @@ -18,8 +18,8 @@ use sequencer_rust_client::{HistoryPolicyError, SequencerClient, SubscribeError} const NO_WS_MESSAGE_WAIT: Duration = Duration::from_secs(1); -/// Default log_recommended_fee exponent (0 + 296 + 20 + 419 + 621 = 1356). -const DEFAULT_FRAME_FEE: u16 = 1356; +/// Default log_recommended_fee exponent (0 + 296 + 20 + 419 + 630 = 1365). +const DEFAULT_FRAME_FEE: u16 = 1365; /// Max fee used for raw TxRequest construction. Must be >= DEFAULT_FRAME_FEE. const DEFAULT_MAX_FEE: u16 = 2500; @@ -173,6 +173,13 @@ pub fn test_cases() -> Vec<(&'static str, ScenarioFn)> { "cold_replica_snapshot_backlog_live_recovery_test", |runtime| Box::pin(crate::cold_replica::run(runtime)), ), + ( + "guardian_exit_recovers_balances_and_queued_deposits_test", + |runtime| Box::pin(crate::wallet_exit::run(runtime)), + ), + ("guardian_foreclosure_stops_admission_test", |runtime| { + Box::pin(run_guardian_foreclosure_stops_admission_test(runtime)) + }), ("deposit_transfer_withdrawal_test", |runtime| { Box::pin(run_deposit_transfer_withdrawal_test(runtime)) }), @@ -210,6 +217,9 @@ pub fn test_cases() -> Vec<(&'static str, ScenarioFn)> { "restart_after_committed_tx_replays_cleanly_test", |runtime| Box::pin(run_restart_after_committed_tx_replays_cleanly_test(runtime)), ), + ("recovery_expires_revoked_signature_test", |runtime| { + Box::pin(run_recovery_expires_revoked_signature_test(runtime)) + }), ("recovery_after_stale_batches_test", |runtime| { Box::pin(run_recovery_after_stale_batches_test(runtime)) }), @@ -230,12 +240,9 @@ pub fn test_cases() -> Vec<(&'static str, ScenarioFn)> { ("provider_outage_wall_clock_refuses_boot_test", |runtime| { Box::pin(run_provider_outage_wall_clock_refuses_boot_test(runtime)) }), - ( - "warm_restart_from_fresh_persisted_facts_with_l1_down_test", - |runtime| { - Box::pin(run_warm_restart_from_fresh_persisted_facts_with_l1_down_test(runtime)) - }, - ), + ("warm_restart_requires_application_status_test", |runtime| { + Box::pin(run_warm_restart_requires_application_status_test(runtime)) + }), ( "wall_clock_backward_jump_retries_then_recovers_test", |runtime| { @@ -457,7 +464,7 @@ async fn prepare_non_genesis_watchdog_state(runtime: &mut ManagedSequencer) -> S /// time, so the submitter and promoter get to run without synthesizing a future /// L1 clock; returns the new inclusion block, or times out loudly. Robust /// against CI jitter — it waits exactly as long as promotion takes, no more. -async fn mine_until_finalized_advances( +pub(crate) async fn mine_until_finalized_advances( runtime: &ManagedSequencer, floor: u64, ) -> ScenarioResult { @@ -963,7 +970,7 @@ async fn run_unsupported_token_deposit_noop_test( Ok(()) } -async fn apply_reconciled_supported_deposit( +pub(crate) async fn apply_reconciled_supported_deposit( runtime: &ManagedSequencer, ws: &mut WsClient, replay: &mut ReplayWalletApp, @@ -1000,10 +1007,11 @@ async fn run_fee_below_minimum_rejected_test(runtime: &mut ManagedSequencer) -> apply_reconciled_supported_deposit(runtime, &mut ws, &mut replay, &alice_l1, deposit_amount) .await?; - // Submit user-op with max_fee=0, which is below the default frame fee (1356). + // Submit user-op with max_fee=0, which is below the default frame fee (1365). let client = SequencerClient::new(runtime.endpoint())?; let domain = eip712_domain(runtime); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: 0, data: ssz_encode_transfer(alice_address, U256::from(100_u64)).into(), @@ -1026,13 +1034,13 @@ async fn run_fee_below_minimum_rejected_test(runtime: &mut ManagedSequencer) -> Ok(()) } -/// Fixed fee-oracle exponent 100 → recommended frame fee 1456 -/// (`100 + 296 + 20 + 419 + 621`), sampled by the first frame. +/// Fixed fee-oracle exponent 100 → recommended frame fee 1465 +/// (`100 + 296 + 20 + 419 + 630`), sampled by the first frame. async fn run_fixed_fee_oracle_sets_frame_fee_test( runtime: &mut ManagedSequencer, ) -> ScenarioResult<()> { const FIXED_LOG_GAS_PRICE: u16 = 100; - const EXPECTED_FRAME_FEE: u16 = FIXED_LOG_GAS_PRICE + 296 + 20 + 419 + 621; + const EXPECTED_FRAME_FEE: u16 = FIXED_LOG_GAS_PRICE + 296 + 20 + 419 + 630; assert_eq!( runtime.first_frame_fee()?, @@ -1074,6 +1082,7 @@ async fn run_forged_signature_rejected_test(runtime: &mut ManagedSequencer) -> S let client = SequencerClient::new(runtime.endpoint())?; let domain = eip712_domain(runtime); let user_op = UserOp { + valid_until_block: u64::MAX, nonce: 0, max_fee: DEFAULT_MAX_FEE, data: ssz_encode_transfer(bob_address, U256::from(100_u64)).into(), @@ -1995,98 +2004,45 @@ async fn run_provider_outage_wall_clock_refuses_boot_test( Ok(()) } -// Warm restart from a fresh persisted L1 view while the RPC is unreachable. -// -// This is the production-shaped discriminator for startup admission: an -// initial refresh attempt may fail, but a recent persisted view that still -// reduces to `Admit` is sufficient authority to prepare and launch the -// runtime. The test keeps the proxy disconnected through readiness, one full -// background-worker retry cadence, POST /tx, and WS broadcast. A boot path -// that makes provider reachability an unconditional admission gate fails at -// `respawn`; a runtime that launches without a usable inclusion lane fails the -// transaction or WS assertion. -async fn run_warm_restart_from_fresh_persisted_facts_with_l1_down_test( +// A fresh input view cannot prove that an unreachable application remains open. +async fn run_warm_restart_requires_application_status_test( runtime: &mut ManagedSequencer, ) -> ScenarioResult<()> { - const STABILITY_WINDOW: Duration = Duration::from_secs(3); - let alice = TestSigner::from_default(1)?; let bob = TestSigner::from_default(2)?; - let alice_address = alice.address(); - let bob_address = bob.address(); - let deposit_amount = U256::from(600_000_u64); - let transfer_amount = U256::from(100_000_u64); - let fee = fee_to_linear(DEFAULT_FRAME_FEE); - - // Persist a recent safe-head observation plus replayable wallet state, - // then stop cleanly so the next run boots over fresh facts without a - // recovery phase. let alice_l1 = runtime.wallet_l1(alice.clone()).await?; let mut ws = runtime.ws(0).await?; let mut replay = ReplayWalletApp::devnet(); - apply_reconciled_supported_deposit(runtime, &mut ws, &mut replay, &alice_l1, deposit_amount) - .await?; + apply_reconciled_supported_deposit( + runtime, + &mut ws, + &mut replay, + &alice_l1, + U256::from(600_000), + ) + .await?; drop(ws); - runtime.stop().await?; - - // Route the next process through an already-disconnected gateway. The - // proxy stays down until after the admitted runtime serves the write and - // its corresponding ordered-tx feed event. + runtime.stop_expecting_clean_exit().await?; + let before = runtime.count_batches()?; let proxy = TcpProxy::spawn(runtime.l1_endpoint()).await?; runtime.set_l1_endpoint_override(Some(proxy.endpoint())); proxy.disconnect(); - runtime.respawn().await?; - assert!( - !proxy.is_connected(), - "warm restart must reach readiness without reconnecting the L1 proxy", - ); - - // Cross the 2s input-reader/danger-detector cadence before submitting, so - // this proves more than racing a request ahead of the first failed RPC - // retry. Fresh local facts must keep the process admitted and live. - let exit = runtime.observe_for(STABILITY_WINDOW).await?; - assert!( - exit.is_none(), - "warm runtime must remain live while the fresh persisted L1 view is valid; got {exit:?}", - ); - - let mut ws_after = runtime.ws(0).await?; - let mut replay_after = ReplayWalletApp::devnet(); - replay_after.apply( - ws_after - .expect_direct_input_from(runtime.erc20_portal_address()) - .await?, - )?; - let mut alice_l2 = runtime.wallet_l2(alice)?; - alice_l2.transfer(bob_address, transfer_amount).await?; - replay_after.apply(ws_after.expect_user_op_from(alice_address).await?)?; - - assert_wallet_state( - &replay_after, - ExpectedWalletState { - address: alice_address, - balance: deposit_amount - transfer_amount - fee, - nonce: 1, - }, - ExpectedWalletState { - address: bob_address, - balance: transfer_amount, - nonce: 0, - }, - 2, - ); assert!( - !proxy.is_connected(), - "POST /tx and WS must succeed before L1 connectivity is restored", + runtime.respawn().await.is_err(), + "unknown foreclosure status must refuse admission" ); assert_eq!( - runtime.count_batches()?.invalidated, - 0, - "a fresh-view warm restart must not invalidate the batch tree", + runtime.count_batches()?, + before, + "status refusal cannot repair or flush history" ); - proxy.reconnect(); runtime.set_l1_endpoint_override(None); + runtime.respawn().await?; + let mut alice_l2 = runtime.wallet_l2(alice)?; + alice_l2 + .transfer(bob.address(), U256::from(100_000)) + .await?; proxy.shutdown().await?; Ok(()) } @@ -2280,7 +2236,7 @@ async fn run_provider_outage_pre_danger_sequencer_continues_test( let bob_address = bob.address(); // Step 1: Deposit big — Alice needs to cover 150+ transfers and their fees. - // Default fee per user-op ≈ 38276 units (log-fee 1356); reserve margin. + // Default fee per user-op ≈ 38276 units (log-fee 1365); reserve margin. let alice_l1 = runtime.wallet_l1(alice.clone()).await?; let deposit_amount = U256::from(10_000_000_u64); let mut replay = ReplayWalletApp::devnet(); @@ -4043,3 +3999,120 @@ fn assert_wallet_state( assert_eq!(replay.current_user_nonce(second.address), second.nonce); assert_eq!(replay.executed_input_count(), executed_input_count); } + +async fn run_guardian_foreclosure_stops_admission_test( + runtime: &mut ManagedSequencer, +) -> ScenarioResult<()> { + use std::os::unix::process::ExitStatusExt; + let alice = TestSigner::from_default(1)?; + let mut ws = runtime.ws(0).await?; + let alice_l1 = runtime.wallet_l1(alice.clone()).await?; + let mut alice_l2 = runtime.wallet_l2(alice.clone())?; + let mut replay = ReplayWalletApp::devnet(); + apply_reconciled_supported_deposit( + runtime, + &mut ws, + &mut replay, + &alice_l1, + U256::from(10_000_000_u64), + ) + .await?; + alice_l2.transfer(alice.address(), U256::from(1)).await?; + replay.apply(ws.expect_user_op_from(alice.address()).await?)?; + assert_eq!(runtime.health_status().await?, Some(200)); + runtime.foreclose().await?; + let status = runtime.wait_for_exit(Duration::from_secs(30)).await?; + assert_eq!( + status.signal(), + Some(6), + "runtime foreclosure must take the terminal abort path" + ); + assert!( + alice_l2 + .transfer(alice.address(), U256::from(1)) + .await + .is_err(), + "no admission after detection" + ); + assert_ne!(runtime.health_status().await?, Some(200)); + let stopped_batches = runtime.count_batches()?; + let nonce = runtime.submitter_nonce().await?; + for _ in 0..2 { + assert!( + runtime.respawn().await.is_err(), + "foreclosure must refuse restart" + ); + assert_eq!( + runtime.submitter_nonce().await?, + nonce, + "restart must not flush or send" + ); + assert_eq!( + runtime.count_batches()?, + stopped_batches, + "restart must not reopen history" + ); + } + Ok(()) +} + +async fn run_recovery_expires_revoked_signature_test( + runtime: &mut ManagedSequencer, +) -> ScenarioResult<()> { + use app_core::application::{Method, Transfer}; + use ssz::Encode; + let alice = TestSigner::from_default(1)?; + let bob = TestSigner::from_default(2)?; + let alice_l1 = runtime.wallet_l1(alice.clone()).await?; + let mut ws = runtime.ws(0).await?; + let mut replay = ReplayWalletApp::devnet(); + apply_reconciled_supported_deposit( + runtime, + &mut ws, + &mut replay, + &alice_l1, + U256::from(10_000_000), + ) + .await?; + let client = SequencerClient::new(runtime.endpoint().to_owned())?; + let fee = client.get_fee().await?; + let request = alice.signed_request( + &sequencer_core::build_input_domain( + runtime.domain_chain_id(), + runtime.verifying_contract(), + ), + UserOp { + nonce: 0, + max_fee: DEFAULT_MAX_FEE, + valid_until_block: fee.safe_block + 10, + data: Method::Transfer(Transfer { + to: bob.address(), + amount: U256::from(100_000), + }) + .as_ssz_bytes() + .into(), + }, + )?; + client.submit_tx(&request).await?; + replay.apply(ws.expect_user_op_from(alice.address()).await?)?; + drop(ws); + runtime.stop_expecting_clean_exit().await?; + runtime + .advance_wall_and_mine(blocks_as_duration(MAX_WAIT_BLOCKS)) + .await?; + runtime.respawn().await?; + let client = SequencerClient::new(runtime.endpoint().to_owned())?; + assert_eq!( + client.get_nonce(alice.address()).await?.next_nonce, + 0, + "recovery must have revoked the op" + ); + let (status, body) = client.submit_tx_with_status(&request).await?; + assert_eq!(status, 422); + assert!( + body.contains("operation expired"), + "must reject expiry, not an old nonce: {body}" + ); + assert_eq!(client.get_nonce(alice.address()).await?.next_nonce, 0); + Ok(()) +} diff --git a/tests/e2e/src/wallet_exit.rs b/tests/e2e/src/wallet_exit.rs new file mode 100644 index 00000000..324f0ee1 --- /dev/null +++ b/tests/e2e/src/wallet_exit.rs @@ -0,0 +1,224 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Real canonical execution and Application proofs; consensus finalization is a fixture. + +use alloy::network::EthereumWallet; +use alloy::providers::ProviderBuilder; +use alloy::signers::local::PrivateKeySigner; +use alloy_primitives::{B256, Bytes, U256}; +use app_core::application::default_private_keys; +use cartesi_rollups_contracts::i_application::IApplication::{self, AccountValidityProof}; +use cartesi_rollups_contracts::input_box::InputBox; +use rollups_harness::{ManagedSequencer, ReplayWalletApp, TestSigner}; +use serde_json::Value; +use tokio::process::Command; + +use crate::ScenarioResult; + +alloy::sol! { + #[sol(rpc)] + interface FinalizationFixture { function finalize(bytes32 root, uint256 count) external; } + #[sol(rpc)] + interface Token { function balanceOf(address owner) external view returns (uint256); } +} + +async fn lua(arguments: &[String]) -> ScenarioResult { + let result = Command::new("lua5.4").args(arguments).output().await?; + if !result.status.success() { + return Err(format!("Lua failed: {}", String::from_utf8_lossy(&result.stderr)).into()); + } + Ok(serde_json::from_slice(&result.stdout)?) +} + +fn hashes(value: &Value) -> ScenarioResult> { + value + .as_array() + .ok_or("missing proof siblings")? + .iter() + .map(|value| Ok(value.as_str().ok_or("invalid proof hash")?.parse()?)) + .collect() +} + +pub(crate) async fn run(runtime: &mut ManagedSequencer) -> ScenarioResult<()> { + use crate::test_cases::{apply_reconciled_supported_deposit, mine_until_finalized_advances}; + runtime.set_max_batch_open_seconds(Some(1)); + runtime.stop_expecting_clean_exit().await?; + runtime.respawn().await?; + let alice = TestSigner::from_default(1)?; + let bob = TestSigner::from_default(2)?; + let alice_l1 = runtime.wallet_l1(alice.clone()).await?; + let bob_l1 = runtime.wallet_l1(bob.clone()).await?; + let mut ws = runtime.ws(0).await?; + let mut replay = ReplayWalletApp::devnet(); + let credited: U256 = (U256::from(1) << 128) + U256::from(10_000_000); + let pending = U256::from(123_456); + apply_reconciled_supported_deposit(runtime, &mut ws, &mut replay, &alice_l1, credited).await?; + mine_until_finalized_advances(runtime, 0).await?; + runtime.stop_expecting_clean_exit().await?; + bob_l1.mint_and_deposit_supported_token(pending).await?; + runtime.mine_live_l1_blocks(2).await?; + let block = runtime.l1_safe_block_number().await?; + let work = tempfile::tempdir()?; + let snapshot = work.path().join("canonical"); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize()?; + let checkpoint = lua(&[ + "tests/e2e/wallet-exit-replay.lua".into(), + runtime.l1_endpoint().into(), + runtime.app_address().to_string(), + runtime.input_box_address().to_string(), + root.join("examples/canonical-app/out/canonical-machine-image") + .display() + .to_string(), + block.to_string(), + snapshot.display().to_string(), + ]) + .await?; + let mut proofs = Vec::new(); + for owner in [alice.address(), bob.address()] { + proofs.push( + lua(&[ + "scripts/wallet-exit-proof.lua".into(), + snapshot.display().to_string(), + owner.to_string(), + ]) + .await?, + ); + } + let signer: PrivateKeySigner = default_private_keys()[0].parse()?; + let provider = ProviderBuilder::new() + .wallet(EthereumWallet::from(signer)) + .connect_http(runtime.l1_endpoint().parse()?); + let app = IApplication::new(runtime.app_address(), &provider); + let validator = app.getOutputsMerkleRootValidator().call().await?; + FinalizationFixture::new(validator, &provider) + .finalize( + proofs[0]["machine_root"] + .as_str() + .ok_or("missing machine root")? + .parse()?, + U256::from( + checkpoint["input_count"] + .as_u64() + .ok_or("missing input count")?, + ), + ) + .send() + .await? + .get_receipt() + .await?; + runtime.foreclose().await?; + let actual_index = app.getAccountsDriveStartIndex().call().await?; + let log2_leaves = app.getLog2LeavesPerAccount().call().await?; + let log2_accounts = app.getLog2MaxNumOfAccounts().call().await?; + assert_eq!(actual_index, 0x0090_0000_0000_0000_u64 >> 26); + assert_eq!((log2_leaves, log2_accounts), (1, 20)); + let expected_root: B256 = proofs[0]["machine_root"].as_str().unwrap().parse()?; + let mut calculated: B256 = proofs[0]["accounts_drive_root"].as_str().unwrap().parse()?; + for (height, sibling) in hashes(&proofs[0]["accounts_drive_siblings"])? + .into_iter() + .enumerate() + { + let mut pair = Vec::with_capacity(64); + if (actual_index >> height) & 1 == 0 { + pair.extend(calculated); + pair.extend(sibling); + } else { + pair.extend(sibling); + pair.extend(calculated); + } + calculated = alloy_primitives::keccak256(pair); + } + assert_eq!( + calculated, expected_root, + "exported drive proof does not reconstruct machine root" + ); + // The pending deposit is finalized, so its only available path is the drive. + let events = InputBox::new(runtime.input_box_address(), &provider) + .InputAdded_filter() + .topic1(runtime.app_address().into_word()) + .from_block(0) + .query() + .await?; + let (last, _) = events.last().ok_or("missing queued deposit")?; + let refund = app + .issueRefund(last.index, last.input.clone()) + .call() + .await + .err() + .ok_or("finalized input refund unexpectedly succeeded")?; + assert!( + refund + .as_decoded_error::() + .is_some() + ); + assert!( + app.proveAccountsDriveMerkleRoot( + B256::ZERO, + hashes(&proofs[0]["accounts_drive_siblings"])? + ) + .call() + .await + .is_err() + ); + let receipt = app + .proveAccountsDriveMerkleRoot( + proofs[0]["accounts_drive_root"] + .as_str() + .ok_or("missing drive root")? + .parse()?, + hashes(&proofs[0]["accounts_drive_siblings"])?, + ) + .send() + .await? + .get_receipt() + .await?; + assert!(receipt.status()); + let token = Token::new( + app_core::application::WalletConfig::devnet().supported_erc20_token, + &provider, + ); + for ((owner, expected), proof) in [(alice.address(), credited), (bob.address(), pending)] + .into_iter() + .zip(proofs) + { + let account: Bytes = proof["account"] + .as_str() + .ok_or("missing account")? + .parse()?; + let validity = AccountValidityProof { + accountIndex: proof["account_index"] + .as_u64() + .ok_or("missing account index")?, + accountRootSiblings: hashes(&proof["account_root_siblings"])?, + }; + let mut tampered = account.to_vec(); + tampered[63] ^= 1; + assert!( + app.withdraw(tampered.into(), validity.clone()) + .call() + .await + .is_err() + ); + let before = token.balanceOf(owner).call().await?; + let receipt = app + .withdraw(account.clone(), validity.clone()) + .send() + .await? + .get_receipt() + .await?; + assert!(receipt.status()); + assert_eq!(token.balanceOf(owner).call().await? - before, expected); + assert!( + app.withdraw(account, validity).call().await.is_err(), + "duplicate withdrawal must fail" + ); + } + assert_eq!( + token.balanceOf(runtime.app_address()).call().await?, + U256::ZERO + ); + Ok(()) +} diff --git a/tests/e2e/wallet-exit-replay.lua b/tests/e2e/wallet-exit-replay.lua new file mode 100644 index 00000000..971b204f --- /dev/null +++ b/tests/e2e/wallet-exit-replay.lua @@ -0,0 +1,18 @@ +-- (c) Cartesi and individual authors (see AUTHORS) +-- SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +--- Replay the real InputBox stream to produce the exit scenario's checkpoint. + +package.cpath = ".deps/lua/?.so;" .. package.cpath +local json = require("watchdog.json").new() +local cfg = { + rpc_url = arg[1], app_address = arg[2]:lower(), input_box_address = arg[3]:lower(), + chain_id = 31337, state_source = { kind = "inspect" }, +} +local rpc = require("watchdog.jsonrpc").new(require("watchdog.http").new(), json, cfg.rpc_url) +local result = require("watchdog.replay").run(cfg, { + machine = require("watchdog.machine").new(), + l1 = require("watchdog.l1").new(rpc, cfg), +}, { from_dir = arg[4], from_block = 0, to_block = assert(tonumber(arg[5])), out_dir = arg[6] }) +assert(not result.stop, "canonical guest stopped during exit replay") +io.write(json.encode(result), "\n") diff --git a/tests/harness/src/rollups.rs b/tests/harness/src/rollups.rs index 8f9741b5..721a7ba8 100644 --- a/tests/harness/src/rollups.rs +++ b/tests/harness/src/rollups.rs @@ -12,6 +12,7 @@ use alloy::providers::{Provider, ProviderBuilder}; use alloy::rpc::types::{BlockNumberOrTag, TransactionRequest}; use alloy::signers::local::PrivateKeySigner; use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_sol_types::SolValue; use app_core::application::{WalletConfig, default_private_keys}; use cartesi_rollups_contracts::application_factory::ApplicationFactory::{self, WithdrawalConfig}; use tokio::process::{Child, Command}; @@ -41,12 +42,21 @@ pub struct DevnetRollupsStack { impl DevnetRollupsStack { pub async fn spawn(log_prefix: &str, logs_dir: &Path) -> HarnessResult { + Self::spawn_with_guardian(log_prefix, logs_dir, false).await + } + + pub async fn spawn_with_guardian( + log_prefix: &str, + logs_dir: &Path, + guardian: bool, + ) -> HarnessResult { let anvil = ManagedAnvil::spawn(log_prefix, logs_dir).await?; let mock_erc20_address = deploy_devnet_mock_erc20(anvil.endpoint.as_str()).await?; let app_address = deploy_devnet_application( anvil.endpoint.as_str(), anvil.application_factory_address, anvil.input_box_address, + guardian, ) .await?; @@ -126,6 +136,28 @@ impl DevnetRollupsStack { self.anvil.drop_all_pending_txs().await } + pub async fn foreclose(&self) -> HarnessResult<()> { + let signer: PrivateKeySigner = default_private_keys()[3].parse()?; + let provider = ProviderBuilder::new() + .wallet(EthereumWallet::from(signer)) + .connect(self.l1_endpoint()) + .await?; + let app = + cartesi_rollups_contracts::i_application::IApplication::new(self.app_address, provider); + let receipt = app.foreclose().send().await?.get_receipt().await?; + if !receipt.status() { + return Err(io_other("guardian foreclosure reverted").into()); + } + Ok(()) + } + + pub async fn submitter_nonce(&self) -> HarnessResult { + let provider = ProviderBuilder::new().connect(self.l1_endpoint()).await?; + Ok(provider + .get_transaction_count(WalletConfig::devnet().sequencer_address) + .await?) + } + pub async fn shutdown(self) -> HarnessResult<()> { self.anvil.shutdown().await } @@ -326,7 +358,10 @@ fn read_deployment_address(path: &Path, contract_name: &str) -> HarnessResult HarnessResult { - let artifact_path = paths::mock_erc20_artifact_path(); + load_init_code(paths::mock_erc20_artifact_path()) +} + +fn load_init_code(artifact_path: PathBuf) -> HarnessResult { ensure_exists( artifact_path.as_path(), format!( @@ -491,6 +526,7 @@ async fn deploy_devnet_application( endpoint: &str, application_factory_address: Address, input_box_address: Address, + guardian: bool, ) -> HarnessResult
{ let private_key = default_private_keys() .first() @@ -509,20 +545,62 @@ async fn deploy_devnet_application( )) })?; + let validator = if guardian { + let code = load_init_code(paths::resolve_from_workspace( + "tests/contracts/out/CheckpointValidator.sol/CheckpointValidator.json", + ))?; + let receipt = provider + .send_transaction(TransactionRequest::default().with_deploy_code(code)) + .await? + .get_receipt() + .await?; + if !receipt.status() { + return Err(io_other("checkpoint validator deployment reverted").into()); + } + receipt + .contract_address + .ok_or_else(|| io_other("missing checkpoint validator address"))? + } else { + Address::ZERO + }; let factory = ApplicationFactory::new(application_factory_address, &provider); let template_hash = load_template_hash(paths::devnet_machine_image_path().as_path())?; - // Withdrawals disabled: a zero guardian can never `foreclose()`, so the - // InputBox's v3 `notForeclosed` gate on `addInput` stays permanently open, - // and no withdrawal output builder is needed for the placeholder wallet. + // Normal scenarios cannot foreclose; foreclosure scenarios use a + // dedicated guardian account and the reference wallet withdrawal builder. + let withdrawal_builder = if guardian { + let mut code = load_init_code(paths::resolve_from_workspace( + "tests/contracts/out/WalletWithdrawalOutputBuilder.sol/WalletWithdrawalOutputBuilder.json", + ))?.to_vec(); + code.extend((WalletConfig::devnet().supported_erc20_token,).abi_encode()); + let receipt = provider + .send_transaction(TransactionRequest::default().with_deploy_code(Bytes::from(code))) + .await? + .get_receipt() + .await?; + if !receipt.status() { + return Err(io_other("wallet withdrawal builder deployment reverted").into()); + } + receipt + .contract_address + .ok_or_else(|| io_other("missing withdrawal builder address"))? + } else { + Address::ZERO + }; let withdrawal_config = WithdrawalConfig { - guardian: Address::ZERO, - log2LeavesPerAccount: 0, - log2MaxNumOfAccounts: 0, - accountsDriveStartIndex: 0, - withdrawalOutputBuilder: Address::ZERO, + guardian: if guardian { + default_private_keys()[3] + .parse::()? + .address() + } else { + Address::ZERO + }, + log2LeavesPerAccount: 1, + log2MaxNumOfAccounts: 20, + accountsDriveStartIndex: 0x0090_0000_0000_0000 >> 26, + withdrawalOutputBuilder: withdrawal_builder, }; let create_application = factory.newApplication_0( - Address::ZERO, + validator, app_owner, template_hash, input_box_address, diff --git a/tests/harness/src/sequencer.rs b/tests/harness/src/sequencer.rs index a783bb3c..d00ddf83 100644 --- a/tests/harness/src/sequencer.rs +++ b/tests/harness/src/sequencer.rs @@ -59,6 +59,7 @@ pub struct ManagedSequencerConfig { pub faketime: bool, /// Fixed fee-oracle exponent pinned by the `setup` phase for Anvil/devnet. pub fee_oracle_fixed_log_gas_price: Option, + pub guardian: bool, } /// Snapshot of the `batches` table. Returned by @@ -190,6 +191,7 @@ pub fn default_devnet_sequencer_config(log_prefix: impl Into) -> Managed logs_dir: PathBuf::from(DEFAULT_TEST_LOGS_DIR), faketime: true, fee_oracle_fixed_log_gas_price: None, + guardian: false, } } @@ -224,7 +226,12 @@ impl ManagedSequencer { .as_ref() .map(paths::resolve_from_workspace); let log_prefix = config.log_prefix; - let rollups = DevnetRollupsStack::spawn(log_prefix.as_str(), logs_dir.as_path()).await?; + let rollups = DevnetRollupsStack::spawn_with_guardian( + log_prefix.as_str(), + logs_dir.as_path(), + config.guardian, + ) + .await?; fs::create_dir_all(logs_dir.as_path())?; let data_dir = TempDir::new() @@ -861,6 +868,18 @@ impl ManagedSequencer { self.endpoint.as_str() } + pub async fn health_status(&self) -> HarnessResult> { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(1)) + .build()?; + Ok(client + .get(format!("{}/healthz", self.endpoint())) + .send() + .await + .ok() + .map(|response| response.status().as_u16())) + } + pub fn pid(&self) -> Option { self.child.id() } @@ -893,6 +912,14 @@ impl ManagedSequencer { self.rollups.app_address() } + pub async fn foreclose(&self) -> HarnessResult<()> { + self.rollups.foreclose().await + } + + pub async fn submitter_nonce(&self) -> HarnessResult { + self.rollups.submitter_nonce().await + } + pub fn input_box_address(&self) -> Address { self.rollups.input_box_address() } diff --git a/tests/harness/src/wallet.rs b/tests/harness/src/wallet.rs index 96eeccbe..67f256a2 100644 --- a/tests/harness/src/wallet.rs +++ b/tests/harness/src/wallet.rs @@ -24,7 +24,7 @@ use crate::util::io_other; const DEFAULT_SEQUENCER_CLIENT_TIMEOUT: Duration = Duration::from_secs(5); /// Default max_fee for harness-submitted transactions. -/// Must exceed the default log_recommended_fee (1356) and live oracle prices. +/// Must exceed the default log_recommended_fee (1365) and live oracle prices. const DEFAULT_MAX_FEE: u16 = 2500; sol! { @@ -66,7 +66,7 @@ impl TestSigner { &self.signing_key } - pub(crate) fn signed_request( + pub fn signed_request( &self, domain: &Eip712Domain, message: UserOp, @@ -276,9 +276,17 @@ impl WalletL2Client { async fn submit_method(&mut self, method: Method) -> HarnessResult { let expected_nonce = self.next_nonce; + let valid_until_block = self + .client + .get_fee() + .await? + .safe_block + .checked_add(120) + .ok_or_else(|| io_other("signing deadline overflow"))?; let request = self.signer.signed_request( &self.domain, UserOp { + valid_until_block, nonce: expected_nonce, max_fee: DEFAULT_MAX_FEE, data: ssz::Encode::as_ssz_bytes(&method).into(), diff --git a/tests/scripts/demo_sepolia.py b/tests/scripts/demo_sepolia.py index b3e8bbfc..aa298bc0 100755 --- a/tests/scripts/demo_sepolia.py +++ b/tests/scripts/demo_sepolia.py @@ -34,7 +34,7 @@ ["cast", "wallet", "address", "--private-key", PRIVATE_KEY], text=True ).strip() -# ── EIP-712 typed data (matches deployed Sepolia sequencer: max_fee is uint32) ─ +# EIP-712 domain v2; target a matching deployment. TYPED_DATA_TEMPLATE = { "types": { @@ -46,14 +46,15 @@ ], "UserOp": [ {"name": "nonce", "type": "uint32"}, - {"name": "max_fee", "type": "uint32"}, + {"name": "max_fee", "type": "uint16"}, + {"name": "valid_until_block", "type": "uint64"}, {"name": "data", "type": "bytes"}, ], }, "primaryType": "UserOp", "domain": { "name": "CartesiAppSequencer", - "version": "1", + "version": "2", "chainId": CHAIN_ID, "verifyingContract": APP_ADDRESS, }, @@ -66,8 +67,14 @@ def build_transfer_payload(amount: int, recipient: str) -> str: def sign_and_build_request(nonce: int, max_fee: int, data_hex: str) -> str: + with urllib.request.urlopen(f"{SEQUENCER_URL.rstrip('/')}/domain", timeout=10) as response: + if json.load(response) != TYPED_DATA_TEMPLATE["domain"]: + raise ValueError("sequencer signing domain does not match pinned domain version 2") + with urllib.request.urlopen(f"{SEQUENCER_URL.rstrip('/')}/fee", timeout=10) as response: + quote = json.load(response) + message = {"nonce": nonce, "max_fee": max_fee, "valid_until_block": quote["safe_block"] + 120, "data": data_hex} typed_data = dict(TYPED_DATA_TEMPLATE) - typed_data["message"] = {"nonce": nonce, "max_fee": max_fee, "data": data_hex} + typed_data["message"] = message td_json = json.dumps(typed_data, separators=(",", ":")) sig = subprocess.check_output( @@ -77,7 +84,7 @@ def sign_and_build_request(nonce: int, max_fee: int, data_hex: str) -> str: return json.dumps( { - "message": {"nonce": nonce, "max_fee": max_fee, "data": data_hex}, + "message": message, "signature": sig, "sender": SENDER, }, diff --git a/toolchain-pins.env b/toolchain-pins.env index 5ba947a0..216722e4 100644 --- a/toolchain-pins.env +++ b/toolchain-pins.env @@ -17,6 +17,7 @@ CARTESI_MACHINE_SHA256_ARM64=866f0bde2db53b9b8e6a6eac85e5ad4116338379fa25cdfcc5e # machine-linux-image release paired with CARTESI_MACHINE_VERSION (canonical-app justfile). CARTESI_LINUX_IMAGE_RELEASE=v0.21.0 CARTESI_LINUX_KERNEL_FILENAME=linux-6.5.13-ctsi-2-v0.21.0.bin +CARTESI_LINUX_KERNEL_SHA512=6fbda6fdf2aa2114fa0cda4d6823182592997fa177439313b0b98d01683f91290824da3ee6d9e34711f4f759ab9bb86a36158869f627f39d8b9366bbc8b041a1 # lua-cURLv3 is vendored in-tree under watchdog/third_party/lua-curl/src (no pin # to track -- the compiled bytes are the in-tree source). See its UPSTREAM file. diff --git a/watchdog/canonical.lua b/watchdog/canonical.lua index 792b447d..9060ddd3 100644 --- a/watchdog/canonical.lua +++ b/watchdog/canonical.lua @@ -16,6 +16,7 @@ local canonical = {} --- the on-chain template hash), so an image from other source or another --- network cannot start a canonical history. function canonical.trusted_start(machine, l1, dir, block) + machine.verify(dir) l1:require_safe_head(block) local input_count = l1:input_count_at(block) if input_count == 0 and machine.root_hash(dir) ~= l1:template_hash() then diff --git a/watchdog/machine.lua b/watchdog/machine.lua index 59b383bb..c4a13725 100644 --- a/watchdog/machine.lua +++ b/watchdog/machine.lua @@ -114,6 +114,15 @@ function machine.new(cartesi) m:rename_stored(from_dir, to_dir) end + function api.verify(dir) + local m = load(dir, SHARING_NONE) + local ok, valid = pcall(m.verify_hash_tree, m) + m:destroy() + if not ok or not valid then + errors.operator("stored machine %s fails hash-tree verification", dir) + end + end + function api.root_hash(dir) local m = load(dir, SHARING_NONE) local root = m:get_root_hash() diff --git a/watchdog/test-guest/justfile b/watchdog/test-guest/justfile index 93f8ba33..9d7f8d38 100644 --- a/watchdog/test-guest/justfile +++ b/watchdog/test-guest/justfile @@ -2,6 +2,7 @@ set shell := ["bash", "-euo", "pipefail", "-c"] cartesi_machine_version := `sed -n 's/^CARTESI_MACHINE_VERSION=v//p' ../../toolchain-pins.env` linux_kernel_filename := `grep '^CARTESI_LINUX_KERNEL_FILENAME=' ../../toolchain-pins.env | cut -d= -f2` +linux_kernel_sha512 := `grep '^CARTESI_LINUX_KERNEL_SHA512=' ../../toolchain-pins.env | cut -d= -f2` # The kernel is the canonical image's; `just canonical download-deps` fetches # and verifies it. @@ -38,7 +39,7 @@ build-rootfs: build-guest # Stores the template at the first accept yield, with an all-zero NVRAM `state`. build-image: clean build-rootfs test -s {{linux_kernel}} || { echo "missing {{linux_kernel}}; run 'just canonical download-deps'"; exit 1; } - cd {{kernel_dir}} && shasum -a 512 -c {{linux_kernel_filename}}.sha512 >/dev/null + echo "{{linux_kernel_sha512}} {{linux_kernel}}" | shasum -a 512 -c - >/dev/null cartesi-machine \ --assert-version={{cartesi_machine_version}} \ --ram-length=64Mi \ diff --git a/watchdog/tests/e2e.lua b/watchdog/tests/e2e.lua index b5d21fb3..55613647 100644 --- a/watchdog/tests/e2e.lua +++ b/watchdog/tests/e2e.lua @@ -158,6 +158,31 @@ test("an unknown range label is an operator error", function() end) end) +test("a stored machine with stale hash sidecars fails verification", function() + local image = require_image(WALLET_IMAGE) + local dir = support.tmpdir() + local copy = dir .. "/machine" + machine.clone(image, copy) + -- Replace the clone's RAM file to preserve the source even if storage + -- sharing changes. Leave all cached hash files untouched. + local path = copy .. "/0000000080000000-8000000.bin" + local ram_input = assert(io.open(path, "rb")) + local output = assert(io.open(path .. ".tmp", "wb")) + local byte = assert(ram_input:read(1)):byte() + output:write(string.char(byte ~ 1)) + while true do + local chunk = ram_input:read(1024 * 1024) + if not chunk then break end + output:write(chunk) + end + ram_input:close() + output:close() + assert(os.rename(path .. ".tmp", path)) + eq(hex(machine.root_hash(copy)), hex(machine.root_hash(image)), "cached root alone misses corruption") + raises("operator", "hash-tree verification", function() machine.verify(copy) end) + support.remove_tree(dir) +end) + test("the canonical wallet image answers the inspect query with its golden genesis state", function() local image = require_image(WALLET_IMAGE) local golden = abi.bytes_from_hex(support.read("tests/fixtures/wallet_snapshot_empty.hex"):gsub("%s", "")) diff --git a/watchdog/tests/run.lua b/watchdog/tests/run.lua index 959591d4..7036e708 100644 --- a/watchdog/tests/run.lua +++ b/watchdog/tests/run.lua @@ -37,6 +37,19 @@ end -- ── abi ─────────────────────────────────────────────────────────────────── +test("trusted checkpoints reject corrupt hash trees at genesis and after inputs", function() + local canonical = require("watchdog.canonical") + for _, count in ipairs({ 0, 1 }) do + local machine = support.fake_machine({ corrupt_tree = true }) + raises("operator", "hash-tree verification", function() + canonical.trusted_start(machine, { + require_safe_head = function() end, + input_count_at = function() return count end, + }, "/snapshot", 0) + end) + end +end) + test("abi decodes an InputAdded log and keeps the raw envelope", function() local fixture = dofile("watchdog/tests/fixtures/input_added_evm_advance.lua") local input = abi.decode_input_added_log(fixture.log) diff --git a/watchdog/tests/support.lua b/watchdog/tests/support.lua index de69aadf..b1a5b6db 100644 --- a/watchdog/tests/support.lua +++ b/watchdog/tests/support.lua @@ -250,6 +250,11 @@ function support.fake_machine(script) errors.operator("bad source") end end + function fake.verify() + if script.corrupt_tree then + errors.operator("stored machine fails hash-tree verification") + end + end function fake.root_hash() return script.root_hash or string.rep("\171", 32) end