diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1e0f12f..d5749f19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ jobs: - name: Verify toolchain pin alignment run: bash scripts/verify-toolchain-pins.sh + # libclang-dev is what bindgen needs; several crates run it, c-app-engine among them - name: Install system dependencies run: | sudo apt-get update @@ -27,7 +28,8 @@ jobs: libfaketime \ lua5.4 \ liblua5.4-dev \ - libslirp-dev + libslirp-dev \ + libclang-dev - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -62,6 +64,19 @@ jobs: timeout-minutes: 15 run: cargo test --workspace --all-targets --all-features --locked + # The rest of the job only builds the C seam the way an in-workspace crate uses it. This is + # the other way, the one an application outside this repository takes: hand the engine over + # as an archive and link it through the three binding variables. c-wallet-engine's staticlib + # stands in for that archive. + - name: C application archive path + run: | + cargo build --locked -p c-wallet-engine + APPLICATION_ENGINE_LIB="$PWD/target/debug/libc_wallet_engine.a" \ + APPLICATION_ENGINE_HEADER="$PWD/examples/c-app-engine/include/application-engine.h" \ + APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT=53 \ + cargo build --locked -p c-app-sequencer + ./target/debug/c-app-sequencer --help > /dev/null + canonical-guest: runs-on: ubuntu-latest needs: rust diff --git a/AGENTS.md b/AGENTS.md index 7c628f5b..9f54a420 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -141,6 +141,10 @@ Top-level layout follows the system's data flow. Each sequencer module correspon - `sequencer-core/` — shared domain types (`Application`, `SignedUserOp`, `SequencedL2Tx`, `Batch`, `Frame`). - `examples/app-core/` — placeholder wallet app implementing the `Application` trait. - `examples/wallet-sequencer/` — binary crate: wallet app + sequencer library. The model for what an app author builds (their `Application` impl ≙ `app-core`; their binary crate ≙ this). +- `examples/c-app-engine/` — the same seam for applications that are not Rust. `include/application-engine.h` is the C mirror of the `Application` trait, and the crate is the shim that turns an engine archive implementing it into an `Application`. See [`docs/protocol/c-application-binding.md`](docs/protocol/c-application-binding.md). +- `examples/c-app-sequencer/` — host library for any C application, plus a generic binary for one that supplies its engine as an archive. Application-agnostic: its only application argument is the state file to open. +- `examples/c-wallet-engine/` — `app-core`'s wallet exported through that C API as `libc_wallet_engine.a`. The reference engine, written in Rust because the seam is an ABI and not a language. +- `examples/c-wallet-sequencer/` — binary crate: `c-wallet-engine` + `c-app-sequencer`. The C-path twin of `wallet-sequencer`, same wallet reached over the seam. - `examples/canonical-app/` — on-chain scheduler reference implementation. - `examples/canonical-test/` — e2e test harness for the canonical app. - `sdk/rust-client/` — Rust client library for the sequencer API. diff --git a/CLAUDE.md b/CLAUDE.md index b4ae18c3..a82ab59e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,10 @@ Rust edition 2024 / Axum API / SQLite (rusqlite, WAL) / EIP-712 signing / SSZ en - `sequencer-core/` — shared domain types consumed by both sequencer and scheduler. - `examples/app-core/` — placeholder wallet app implementing `Application`. - `examples/wallet-sequencer/` — binary crate: wallet app + sequencer library. +- `examples/c-app-engine/` — C binding of the `Application` trait: the header and the FFI shim. +- `examples/c-app-sequencer/` — host library for any C application, plus a generic binary. +- `examples/c-wallet-engine/` — the wallet exported through the C API as a static library. +- `examples/c-wallet-sequencer/` — binary crate: wallet engine + C host. - `examples/canonical-app/` — on-chain scheduler reference implementation. - `examples/canonical-test/` — e2e test harness for the canonical app. - `sdk/rust-client/` — Rust client library for the sequencer API. diff --git a/Cargo.lock b/Cargo.lock index 7678cc2c..3e680690 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1266,6 +1266,27 @@ dependencies = [ "serde", ] +[[package]] +name = "c-app-engine" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "bindgen", + "sequencer-core", +] + +[[package]] +name = "c-app-sequencer" +version = "0.1.0" +dependencies = [ + "c-app-engine", + "clap", + "sequencer", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "c-kzg" version = "2.1.7" @@ -1281,6 +1302,25 @@ dependencies = [ "serde", ] +[[package]] +name = "c-wallet-engine" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "app-core", + "c-app-engine", + "sequencer-core", +] + +[[package]] +name = "c-wallet-sequencer" +version = "0.1.0" +dependencies = [ + "c-app-sequencer", + "c-wallet-engine", + "tokio", +] + [[package]] name = "canonical-app" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b99284b4..9489caea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,10 @@ members = [ "sequencer-core", "sdk/rust-client", "examples/app-core", + "examples/c-app-engine", + "examples/c-app-sequencer", + "examples/c-wallet-engine", + "examples/c-wallet-sequencer", "examples/canonical-app", "examples/canonical-test", "examples/wallet-sequencer", diff --git a/README.md b/README.md index 065171f0..d68e4947 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,10 @@ released even on client disconnect. - `sequencer/src/storage/`: schema, migrations, SQLite persistence (split per writer role), and replay reads - `sequencer-core/src/`: shared domain types and interfaces (`Application`, `SignedUserOp`, `SequencedL2Tx`, feed message types) - `examples/app-core/src/`: wallet prototype implementing `Application` +- `examples/c-app-engine/`: the C binding of `Application` — the header and the FFI shim over an engine archive implementing it +- `examples/c-app-sequencer/`: host library for any C application, plus a generic binary for one supplying its engine as an archive +- `examples/c-wallet-engine/`: the wallet app exported through that C API as a static library — the reference engine +- `examples/c-wallet-sequencer/`: binary crate composing the C host with the wallet engine - `tests/benchmarks/`: benchmark harnesses and benchmark spec Related docs: @@ -235,6 +239,24 @@ docker pull ghcr.io/cartesi/sequencer-watchdog:vX # mirror: docker.io/cartesi/sequencer-watchdog:vX ``` +## Applications in C + +An application does not have to be written in Rust. `examples/c-app-engine/include/application-engine.h` is the C mirror of the `Application` trait: an application implements it into a static archive, and `examples/c-app-sequencer` links that archive into a ready-made sequencer host. + +```bash +APPLICATION_ENGINE_LIB=/path/to/libmyapp-engine.a \ +APPLICATION_ENGINE_HEADER=/path/to/application-engine.h \ +APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT=1024 \ + cargo build -p c-app-sequencer --release + +c-app-sequencer --state-file setup ... +c-app-sequencer --state-file run ... +``` + +Those three variables are the whole binding, and nothing else about the application reaches this repository — there is no create path on the seam, so the host cannot be told what application to be, and cannot ask. This matters when the application's canonical on-chain execution must stay free of Rust: the same compiled engine is linked by this host and by the application's own canonical binary, so the two agree by construction rather than by review. + +The seam is an ABI, not a language. `examples/c-wallet-engine` is the reference engine and is itself written in Rust: it exports the same `application_engine_*` symbols as `libc_wallet_engine.a` over the same `app-core::WalletApp` that `wallet-sequencer` uses directly. `examples/c-wallet-sequencer` links it, so the workspace builds the whole path without an archive path handed to it, and this repository maintains no C beyond the header. See [`docs/protocol/c-application-binding.md`](docs/protocol/c-application-binding.md) and `just c-wallet-genesis`. + ## Prototype Limits - The `Application` trait exposes snapshot dump/load capability (format in `docs/snapshots/format.md`). The inclusion lane drives the snapshot lifecycle — dump at batch close, promote to finalized on L1 observation, and garbage-collect superseded dumps — and at startup rebuilds application state by loading the latest snapshot and replaying the persisted L2-tx stream from that snapshot's offset. The lifecycle and its rationale (per-range atomic promotion, GC, leasing, crash-safety) are documented in `docs/snapshots/lifecycle.md`. The snapshot is served to the operator's watchdog/indexers over internal-only HTTP routes (`/finalized_state`, `/finalized_state/inclusion_block`, `/latest_snapshot`) — no auth, gated by network-level access control until the planned per-port api split lands. @@ -267,6 +289,7 @@ Some tests require [Foundry](https://getfoundry.sh) (`anvil` on PATH). They run - [`docs/watchdog/README.md`](docs/watchdog/README.md) — watchdog architecture, modules, and test commands. - [`sequencer-core/`](sequencer-core/) — shared domain types (`Application`, `SignedUserOp`, `Batch`, `Frame`). - [`examples/app-core/`](examples/app-core/) — placeholder wallet app implementing the `Application` trait. +- [`docs/protocol/c-application-binding.md`](docs/protocol/c-application-binding.md) — the same contract in C, for applications that are not written in Rust. ## License diff --git a/docs/protocol/application-contract.md b/docs/protocol/application-contract.md index 2ff8a184..e97df529 100644 --- a/docs/protocol/application-contract.md +++ b/docs/protocol/application-contract.md @@ -13,6 +13,10 @@ Trait Contract" is the map. The placeholder wallet ([`examples/app-core/`](../../examples/app-core/)) is the reference impl; a production app will wrap a Cartesi Machine behind the same trait. +An app that is not written in Rust implements the same contract through a C +header instead. [`c-application-binding.md`](c-application-binding.md) describes +that path; everything below binds it identically. + --- ## The execution methods diff --git a/docs/protocol/c-application-binding.md b/docs/protocol/c-application-binding.md new file mode 100644 index 00000000..57cdf7ca --- /dev/null +++ b/docs/protocol/c-application-binding.md @@ -0,0 +1,172 @@ +# C Application Binding + +How an application that is not written in Rust is sequenced. + +The sequencer reaches an application through the `Application` trait, specified in +[`application-contract.md`](application-contract.md). That document is binding here too: this one +only describes how the same contract is spelled in C, and what a C engine owes that a Rust one +gets from the type system. + +The surface is one header, `examples/c-app-engine/include/application-engine.h`. An application +implements it into a static archive. `examples/c-app-engine` links that archive and turns it into +an `Application`, and `examples/c-app-sequencer` composes that with the sequencer library into a +host. Nothing else about the application reaches this repository. + +**The seam is an ABI, not a language.** An engine only has to export these symbols with C linkage +and C layout. The reference engine, `examples/c-wallet-engine`, is written in Rust and exports +them as `libc_wallet_engine.a`, which is why this repository maintains no C beyond the header +itself. + +## Why a C seam exists + +An application whose canonical on-chain execution must stay free of Rust — a Cartesi Machine +binary whose template hash has to stay reproducible, for instance — still has to run inside the +sequencer host, which is Rust. Reimplementing the application twice is how a rollup diverges from +itself, and a divergence between the sequencer and the canonical machine is, under rollup +semantics, indistinguishable from theft. + +The C seam removes the second implementation. One compiled engine is linked by the host through +this binding and by the application's canonical binary natively, so off-chain and on-chain +execution agree by construction rather than by review. + +## The build contract + +Three environment variables, and they are the whole binding. + +| Variable | Meaning | +| --- | --- | +| `APPLICATION_ENGINE_LIB` | The static archive implementing the header. It must be self-contained: the shim links it and nothing else the engine needs. | +| `APPLICATION_ENGINE_HEADER` | The header the archive was built against. The FFI declarations are generated from it with bindgen, never restated by hand, so a signature the host cannot express fails the build instead of the runtime. | +| `APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT` | The application's largest method payload, in bytes. It is defined on the compile line of every consumer of the header, the engine's own translation units included, and becomes the sequencer's `MAX_METHOD_PAYLOAD_BYTES`. | + +Supplying `APPLICATION_ENGINE_LIB` requires the other two: the archive and the header are separate +artifacts and only that pairing is meaningful, and a bound the build guessed would be exactly the +silently wrong number the header's `#error` exists to prevent. An engine that raised its own bound +without raising the host's would never see the payloads it grew to accept. + +The generic binary in `c-app-sequencer` reads `APPLICATION_ENGINE_LIB` too, and reports that it +has no application to run when none was supplied. That is a build-script check rather than a cargo +feature, because features are additive and `--all-features` would otherwise turn the binary on in +builds that supplied no archive, which is exactly the combination that cannot link. + +With none of the three set, `c-app-engine` links no archive at all. An rlib may carry undefined +symbols, so it still builds, and the binary using it supplies the engine instead: that is how +`c-wallet-sequencer` links `c-wallet-engine` as an ordinary crate dependency, letting the whole +path build and test under a plain `cargo build --workspace` with no archive path handed to it. +The payload limit then falls back to the wallet's own, which `c-wallet-engine` asserts against +`WalletApp::MAX_METHOD_PAYLOAD_BYTES` so a drift fails that crate's compile. + +## What crosses, and what does not + +Only scalars, fixed-width byte records and borrowed byte spans. Amounts cross as 32 big-endian +bytes rather than as a number the host might not be able to spell. Records are plain C layout and +an engine must static_assert their sizes and offsets, matching the compile-time assertions the +generated bindings carry, so a compiler laying one out differently fails a build rather than the +boundary. + +**No configuration crosses inward, and there is no getter for it.** There is no create path: the +only constructor is `application_engine_from_dump`. A state is written once by a genesis tool the +application ships, and every engine afterwards opens what is there. The host therefore cannot be +told what application to be, and cannot ask. That is why `c-app-sequencer` takes exactly one +application-related argument, `--state-file`, and learns nothing from it. + +Every vocabulary in the header — statuses, invalid reasons, output kinds — is append-only and no +value is ever reused. They cross as plain integers rather than as Rust enums, so a value an engine +adds later is a number the host refuses rather than undefined behavior. + +## What a C engine owes + +These are the obligations the trait's types would otherwise carry, and nothing enforces them at +runtime. + +- **Determinism.** The same state and the same input stream must produce the same bytes, on every + architecture the application runs on. Fixed-width fields and explicit byte order, no host-endian + writes, no padding-dependent images. +- **No exception may cross.** Every entry point is `noexcept` under C++ and reports failure as a + status. A throw reaching the seam terminates, and so does a Rust `panic!` reaching an + `extern "C"` entry point, which is the same fatal-no-resume policy. +- **Validation is pure.** `application_engine_validate_user_op` mutates nothing. Catch-up replay + depends on it. +- **Execution is self-sufficient.** Replay calls `application_engine_execute_valid_user_op` + directly with no validation in front of it, so consuming the nonce and charging the fee happen + there and never in validation. +- **Both clocks advance in execution.** `max(clock, safe_block)` and `max(clock, block_number)`, + carried by execution rather than set, so an engine cannot execute and forget. They live in the + state, so they survive the dump round trip recovery depends on. +- **Every input is counted**, including the ones that decode to nothing. +- **Every entry point is total over its payload bytes.** They are attacker-influenced on all + three: a direct input is whatever was posted to L1, and a user op is whatever was signed and + sent to `POST /tx`. A method the engine cannot parse is a refusal or a counted no-op, never + `INTERNAL_ERROR` — that status aborts the host, so reporting it on unparseable input hands any + caller a one-request process kill. +- **Single-threaded per handle, reentrant without one.** The engine's handle is never used by two + threads at once, but the entry points that take no handle are called from request handlers while + an execution is in flight — `state_file_in_dump` is reached from the snapshot routes. An engine + answering out of one process-wide buffer races there; thread-local storage is the simple answer. + +## The drain protocol + +An execution reports how many outputs it produced and the caller takes exactly that many. Taking +one more is a caller bug reported as an internal error, never an empty output a host might act on, +and an engine refuses to execute while an earlier execution's outputs are still queued rather than +discarding outputs bound for the chain. That refusal is what makes a reported count belong to the +execution that reported it. + +Payload pointers are borrowed and released by the next drain, so a host copies before draining +again. + +## Errors + +Errno style. Zero is success and every failure is negative, so `status < 0` is the failure test +and a status added later cannot disturb it. `IO_ERROR` and `INTERNAL_ERROR` are a real split, not +a nominal one: a full disk is a condition a caller may act on, an engine that failed its own +invariant is not. + +The status is the contract and the accompanying message is a diagnostic. Never branch on its text. + +An `INTERNAL_ERROR` **aborts the host process**, on every path including `state_file_in_dump`, +which the snapshot routes reach — the trait's signature there is infallible, so the host has +nowhere to put the error. Returning it would hand the error +to the canonical fold, which catches application errors and continues, while the same failure in +the application's own canonical binary terminates it — on state that may already be partially +mutated. The two must not disagree about that. + +## Dumps + +A dump is a directory the engine creates, and `state_file_in_dump` names a file inside it, which +is the shape the reference engine takes. An engine whose persistence representation already *is* +its canonical state may instead make the prefix that file, which the contract's "can be the same +file" concession allows and which the runtime supports today: it creates only the enclosing dump +directory, joins `state` onto it to form the prefix, and removes the dump directory recursively. +Nothing in this repository exercises that second shape, so an engine taking it should re-check the +runtime's behaviour rather than assume it. + +`create_dump` must fsync the payload and the directory entry naming it before returning, so on +success the dump survives an immediate kernel crash. The sequencer inserts the row referencing the +dump after the call returns. + +## Not carried + +`canonical_snapshot_bytes` and `export_state` have no declaration in the header and stay +defaulted. The watchdog's `/finalized_state` compare reads the first, so an application needing +that comparison reaches its canonical bytes through the file `state_file_in_dump` names. Both +would need a header revision to cross. + +## The reference engine + +`examples/c-wallet-engine` exports `app-core`'s placeholder wallet through this API. It is the +same application `examples/wallet-sequencer` reaches directly as a Rust `Application`, so the two +binaries are the same wallet with and without the seam in between, which is what makes the seam's +cost and behaviour comparable rather than asserted. + +It is written in Rust deliberately. Nothing about the contract requires C on the engine side, and +keeping the reference in Rust means this repository maintains no C beyond the header while still +exercising every rule stated above. An engine in C, C++, Zig, or anything else with a C ABI takes +exactly the same path; only `APPLICATION_ENGINE_LIB` differs. + +**Nothing exercises the seam at runtime.** The workspace build links the reference engine into +`c-wallet-sequencer`, so a signature the two sides disagree about fails to compile, but no test +makes a call across the ABI. The rules above that carry no runtime enforcement — the drain count, +the refusal to execute over queued outputs, purity of validation, the error-message lifecycle — +are held by review alone. An engine author should assume the same of their own and test +accordingly. diff --git a/examples/c-app-engine/Cargo.toml b/examples/c-app-engine/Cargo.toml new file mode 100644 index 00000000..3896281c --- /dev/null +++ b/examples/c-app-engine/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "c-app-engine" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Application shim over a static engine library implementing the application-engine C API" +homepage.workspace = true +repository.workspace = true +readme = "../../README.md" +authors.workspace = true + +[build-dependencies] +# Generates the FFI declarations from the engine header at build time. It decides how this host +# reads every record crossing the seam, so a bump changes the ABI interpretation. +bindgen = "0.72" + +[dependencies] +sequencer-core = { path = "../../sequencer-core" } +alloy-primitives = "1.4.1" diff --git a/examples/c-app-engine/build.rs b/examples/c-app-engine/build.rs new file mode 100644 index 00000000..325098f1 --- /dev/null +++ b/examples/c-app-engine/build.rs @@ -0,0 +1,152 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Links the application's engine archive and generates the FFI declarations from its header. +//! +//! The three environment variables are the whole application-specific binding, documented in +//! `docs/protocol/c-application-binding.md`. With none of them set this crate links no archive, +//! and the binary that uses it supplies the engine instead. + +use std::env; +use std::path::{Path, PathBuf}; + +/// The in-workspace wallet engine's own bound, used when a build declares none. +/// +/// Only reachable when `c-wallet-engine` is the engine, which asserts this same value against +/// `WalletApp::MAX_METHOD_PAYLOAD_BYTES`, so a number that drifts fails that crate's compile +/// rather than reaching a host. An application outside this workspace always declares its own. +const REFERENCE_ENGINE_METHOD_PAYLOAD_LIMIT: u32 = 1 + 32 + 20; + +/// A ceiling on what an application may declare, since the bound gates ingress. +const MAX_METHOD_PAYLOAD_LIMIT: u32 = 1 << 20; + +/// Generate `sys`'s contents from the engine header, the one authoritative declaration of what +/// the archive exports, so a change on the engine side is either picked up here or fails this +/// build. +/// +/// The payload bound is defined for the parse rather than read out of the header, because the +/// header deliberately refuses to carry a default for it. +fn generate_bindings(header: &Path, method_payload_limit: u32) { + let out_path = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")).join("bindings.rs"); + let bindings = bindgen::Builder::default() + .header( + header + .to_str() + .expect("APPLICATION_ENGINE_HEADER is not valid UTF-8"), + ) + // Parse the C arm of the header. Its C++ arm only spells noexcept, which has no bearing + // on the ABI and no Rust spelling. + .clang_args(["-x", "c", "-std=c11"]) + .clang_arg(format!( + "-DAPPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT={method_payload_limit}" + )) + // Only the seam's own surface, never what stdint.h drags in behind it + .allowlist_item("^(application_engine_|ApplicationEngine|APPLICATION_ENGINE_).*") + // Plain integer constants, never Rust enums. The contract requires refusing a value the + // engine added later, which holding it in a Rust enum would make undefined behavior. + .default_enum_style(bindgen::EnumVariation::Consts) + // The C constants already carry the APPLICATION_ENGINE_ prefix, so repeating the enum's + // name in front of them would spell them differently here than in the header + .prepend_enum_name(false) + .rust_edition(bindgen::RustEdition::Edition2024) + .generate() + .expect("failed to generate bindings from APPLICATION_ENGINE_HEADER"); + bindings + .write_to_file(&out_path) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", out_path.display())); +} + +/// Link the application's own archive, the path a deployment outside this workspace takes. +/// +/// The link name is the archive's own name, the linker has no other way to spell it. +fn link_application_archive(engine_lib: &Path) { + // Fail loudly at build time instead of at link time with a confusing message + assert!( + engine_lib.is_file(), + "APPLICATION_ENGINE_LIB points at {}, which is not a file, build the engine archive first", + engine_lib.display() + ); + + let file_name = engine_lib + .file_name() + .and_then(|name| name.to_str()) + .expect("APPLICATION_ENGINE_LIB is not valid UTF-8"); + let link_name = file_name + .strip_prefix("lib") + .and_then(|name| name.strip_suffix(".a")) + .unwrap_or_else(|| { + panic!("APPLICATION_ENGINE_LIB names {file_name}, expected a static library named lib.a") + }); + // A bare file name has an empty parent, search the working directory then + let engine_lib_dir = engine_lib + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + + println!("cargo::rerun-if-changed={}", engine_lib.display()); + println!( + "cargo::rustc-link-search=native={}", + engine_lib_dir.display() + ); + println!("cargo::rustc-link-lib=static={link_name}"); + + // Engines are commonly implemented in C or C++, and one that needs no C++ runtime links this + // harmlessly + let cxx_runtime = match env::var("CARGO_CFG_TARGET_OS").expect("target os").as_str() { + "macos" => "c++", + _ => "stdc++", + }; + println!("cargo::rustc-link-lib={cxx_runtime}"); +} + +/// What an application supplying its own archive has to declare alongside it. +/// +/// Both are demanded rather than defaulted. The archive and the header are separate artifacts and +/// only that pairing is meaningful, and a bound guessed here would be exactly the silently wrong +/// number the header's own `#error` exists to prevent. +fn external_engine(engine_lib: &str) -> (PathBuf, u32) { + link_application_archive(Path::new(engine_lib)); + + let header = PathBuf::from(env::var("APPLICATION_ENGINE_HEADER").expect( + "APPLICATION_ENGINE_HEADER is unset, point it at the header the archive was built against", + )); + assert!( + header.is_file(), + "APPLICATION_ENGINE_HEADER points at {}, which is not a file", + header.display() + ); + + let declared = env::var("APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT").expect( + "APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT is unset, set it to the application's largest \ + method payload, the same value the archive was built with", + ); + let limit = declared.trim().parse::().unwrap_or_else(|err| { + panic!("APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT is not a number: {err}") + }); + // The bound gates ingress and sizes batches, so a fat-fingered value is worth refusing here + // rather than discovering as a memory bill + assert!( + limit > 0 && limit <= MAX_METHOD_PAYLOAD_LIMIT, + "APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT is {limit}, expected 1..={MAX_METHOD_PAYLOAD_LIMIT}" + ); + (header, limit) +} + +fn main() { + println!("cargo::rerun-if-env-changed=APPLICATION_ENGINE_LIB"); + println!("cargo::rerun-if-env-changed=APPLICATION_ENGINE_HEADER"); + println!("cargo::rerun-if-env-changed=APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let (header, method_payload_limit) = match env::var("APPLICATION_ENGINE_LIB") { + Ok(engine_lib) => external_engine(&engine_lib), + // Linked from inside this workspace, where `c-wallet-engine` is the engine + Err(_) => ( + manifest_dir.join("include").join("application-engine.h"), + REFERENCE_ENGINE_METHOD_PAYLOAD_LIMIT, + ), + }; + + println!("cargo::rerun-if-changed={}", header.display()); + generate_bindings(&header, method_payload_limit); +} diff --git a/examples/c-app-engine/include/application-engine.h b/examples/c-app-engine/include/application-engine.h new file mode 100644 index 00000000..9b944f86 --- /dev/null +++ b/examples/c-app-engine/include/application-engine.h @@ -0,0 +1,403 @@ +/* (c) Cartesi and individual authors (see AUTHORS) */ +/* SPDX-License-Identifier: Apache-2.0 (see LICENSE) */ + +#ifndef APPLICATION_ENGINE_H +#define APPLICATION_ENGINE_H + +#include + +/// @file +/// The C API of an application engine, and the only surface an engine exports. It mirrors the +/// Cartesi sequencer's Application contract, so the host owning the fold stays application +/// agnostic and an engine is swappable behind this header. Plain C so any host can consume it. +/// +/// An application implements these declarations into a static archive, which the `c-app-engine` +/// shim links at build time to become the sequencer's Application, and which the application's +/// own canonical binary links natively from the same objects. One compiled engine on both sides +/// is what makes off-chain and on-chain execution deterministic. An application written in C++, +/// or in any language with a C ABI, implements it the same way. +/// +/// Nothing application specific crosses. An engine is handed a dump already holding a configured +/// deployment, so a host never learns what configures the application it runs, and the path is +/// opaque, a file or a directory as the engine chooses. +/// +/// This header is the surface a host binds to, and the Rust host generates its declarations from +/// it with bindgen rather than restating them, so a signature changed here cannot disagree with +/// the host that links the engine. The records that cross are plain C layout and the +/// engine must static_assert their sizes and field offsets, so a compiler laying one out +/// differently fails its build rather than the seam. A generated binding carries the same checks +/// on the host side. +/// +/// A generated binding follows whatever this header says, so the header carries the compatibility +/// obligation on its own. Every vocabulary here is append only and no value is ever reused, since +/// a host is entitled to refuse a value it does not know rather than to have it renumbered +/// underneath. +/// +/// Widths are fixed, nothing crosses as size_t. A C enum's underlying type is implementation +/// defined, so an engine must static_assert each one's width against int32_t, which is what lets +/// a host mirror them as a plain 32-bit integer. +/// +/// Boundary rules, binding on every function here. Every entry point is total over the bytes it +/// is handed: a payload arrives from whoever signed or posted it, so a method an engine cannot +/// parse is a refusal or a counted no-op, never INTERNAL_ERROR. Reporting INTERNAL_ERROR there +/// would hand any caller a process kill, since it is fatal-no-resume. +/// +/// No exception may cross, every entry point catches and reports INTERNAL_ERROR, fatal-no-resume +/// under the declared-death policy, or IO_ERROR when what failed was a filesystem or mapping +/// operation. That split is the point of +/// the second status, an environment out of disk is a condition a caller may act on, an engine +/// that failed its own invariants is not. Only accept or reject is consensus visible, rejection +/// diagnostics are lossy by design. +/// +/// Errors are errno style. A fallible entry point returns a status (or a null pointer for +/// application_engine_state_file_in_dump) and leaves the reason for +/// application_engine_get_last_error_message. Statuses are the contract, messages are diagnostics, +/// never branch on their text. + +/// @brief Marks the public C API, exported even when the consumer builds with +/// -fvisibility=hidden. Carried by declarations only, definitions inherit it. +#if defined(__GNUC__) || defined(__clang__) +#define APPLICATION_ENGINE_API __attribute__((visibility("default"))) +#else +#define APPLICATION_ENGINE_API +#endif + +/// @brief Spells the non-throwing guarantee for a C++ consumer, C has no equivalent. +/// @details Part of every signature, a throw reaching the seam terminates rather than unwinding +/// into a caller that has no way to handle it. +#ifdef __cplusplus +#define APPLICATION_ENGINE_NOEXCEPT noexcept +#else +#define APPLICATION_ENGINE_NOEXCEPT +#endif + +/// @brief Size in bytes of an account address crossing the seam. +/// @details An engine must static_assert it against its own address type, so the two cannot +/// drift. +#define APPLICATION_ENGINE_ADDRESS_SIZE 20 + +/// @brief Size in bytes of an on-chain amount crossing the seam. +/// @details An amount is an EVM word, wider than any scalar this API carries, so it crosses as +/// raw big-endian bytes rather than as a number a host may not be able to spell. +#define APPLICATION_ENGINE_VALUE_SIZE 32 + +/// @brief The one value this header does not fix, supplied by the application's build. +/// @details The ingress bound on a single user op's method payload is an application sizing +/// decision, the largest payload any of its methods can carry, so it is defined on the compile +/// line rather than here. Every consumer of this header, the engine's own translation units and +/// the binding generation alike, must be given the same value, which is what keeps the bound the +/// host enforces and the bound the engine parses under from being two numbers. +/// +/// There is deliberately no default. A silently wrong bound is the exact failure this +/// declaration exists to prevent, so an undefined one stops the build here. +#ifndef APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT +#error "define APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT to the application's largest method payload" +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/// @brief The bounds an engine declares, spelled as constants a generated binding can read. +/// @details A binding generator sees a macro only where it is defined, and +/// APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT is defined on the compile line, so the value is +/// restated here as an enumeration constant. That is what carries it across to a host: the +/// application defines one number, and both sides read it from this declaration. +typedef enum ApplicationEngineLimits { + /// The largest method payload a user op may carry, in bytes. The host publishes it as the + /// sequencer's MAX_METHOD_PAYLOAD_BYTES and refuses anything larger, so an engine that raised + /// its own bound without raising this one would never see the payloads it grew to accept. + APPLICATION_ENGINE_MAX_METHOD_PAYLOAD_BYTES = APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT, +} ApplicationEngineLimits; + +/// @brief An account address, raw bytes and no encoding. +/// @details A named type rather than a loose buffer, so an address and an amount cannot be +/// passed for one another. +typedef struct ApplicationEngineEthereumAddress { + uint8_t bytes[APPLICATION_ENGINE_ADDRESS_SIZE]; ///< The address bytes, in on-chain order. +} ApplicationEngineEthereumAddress; + +/// @brief A 256-bit unsigned amount, big-endian. +/// @details The width every on-chain amount crosses at, whatever the engine prices it in +/// internally, so no amount is narrowed to fit through here. +typedef struct ApplicationEngineUint256 { + uint8_t bytes[APPLICATION_ENGINE_VALUE_SIZE]; ///< The amount bytes, most significant first. +} ApplicationEngineUint256; + +/// @brief A borrowed range of bytes, never owned by the side receiving it. +/// @details Const because nothing crossing here is written through, inputs and output payloads +/// alike are read only for whoever receives them. +typedef struct ApplicationEngineByteSpan { + const uint8_t *data; ///< The first byte, null only when the range is empty. + uint64_t size; ///< The number of bytes. +} ApplicationEngineByteSpan; + +/// @brief Status returned by every fallible entry point. Zero is success and every failure is +/// negative, so `status < 0` is the failure test and a value added later never disturbs it. +/// @details Append only. The status is the contract, the accompanying message is a diagnostic. +typedef enum ApplicationEngineStatus { + APPLICATION_ENGINE_STATUS_OK = 0, ///< Call succeeded. + APPLICATION_ENGINE_STATUS_INVALID = -1, ///< Refused by protocol or configuration validation. + APPLICATION_ENGINE_STATUS_INTERNAL_ERROR = -2, ///< Internal engine failure, fatal-no-resume. + APPLICATION_ENGINE_STATUS_IO_ERROR = -3, ///< A filesystem or mapping operation failed. +} ApplicationEngineStatus; + +/// @brief A user op as its sender signed it. +/// @details The engine reads the nonce and the data and carries max_fee without checking it, +/// that guard belongs to the caller, so an op arrives whole. +typedef struct ApplicationEngineUserOp { + uint32_t nonce; ///< Sender replay protection nonce. + uint16_t max_fee; ///< Highest frame fee price the sender accepts, in log space. + ApplicationEngineByteSpan data; ///< Method payload, opaque here and parsed by the engine. +} ApplicationEngineUserOp; + +/// @brief A user op that already passed validation, as the caller sequenced it. +/// @details Not the signed op: execution consumes the nonce the state expects, and the max fee +/// went with the guard the caller already settled, leaving the fee the frame charges. +typedef struct ApplicationEngineValidUserOp { + ApplicationEngineEthereumAddress sender; ///< The recovered signer. + uint16_t fee; ///< The frame fee price charged, in log space. + ApplicationEngineByteSpan data; ///< Method payload, opaque here and parsed by the engine. +} ApplicationEngineValidUserOp; + +/// @brief An input taken straight from the L1 input box. +/// @details Its sender is authenticated by the chain rather than recovered from a signature, +/// which is what lets the engine trust it without validating anything first. +typedef struct ApplicationEngineDirectInput { + ApplicationEngineEthereumAddress sender; ///< The L1 authenticated sender. + uint64_t block_number; ///< The L1 inclusion block number. + ApplicationEngineByteSpan payload; ///< The raw input payload. +} ApplicationEngineDirectInput; + +/// @brief Why an engine refused a user op, mirroring the sequencer's rejection reasons. +/// @details Append only. Written only on APPLICATION_ENGINE_STATUS_INVALID, and it selects which +/// member of ApplicationEngineInvalidValues carries the diagnostics. +typedef enum ApplicationEngineInvalidReason { + APPLICATION_ENGINE_INVALID_NONCE = 0, ///< Nonce or account binding, read `nonce`. + APPLICATION_ENGINE_INVALID_MAX_FEE = 1, ///< The caller-owned max fee guard, read `max_fee`. + APPLICATION_ENGINE_INSUFFICIENT_FEE_BALANCE = 2, ///< Cannot cover the frame fee, read `fee_balance`. +} ApplicationEngineInvalidReason; + +/// @brief Diagnostics for APPLICATION_ENGINE_INVALID_NONCE. +typedef struct ApplicationEngineInvalidNonce { + uint32_t expected; ///< The nonce the account expects next. + uint32_t got; ///< The nonce the op carried. +} ApplicationEngineInvalidNonce; + +/// @brief Diagnostics for APPLICATION_ENGINE_INVALID_MAX_FEE. +/// @details Both values are log space exponents, base 129/128. No engine produces this reason, +/// the guard belongs to the caller, it is carried so the reason vocabulary stays whole. +typedef struct ApplicationEngineInvalidMaxFee { + uint16_t max_fee; ///< The highest frame fee price the sender accepts. + uint16_t base_fee; ///< The frame fee price charged. +} ApplicationEngineInvalidMaxFee; + +/// @brief Diagnostics for APPLICATION_ENGINE_INSUFFICIENT_FEE_BALANCE. +/// @details Both amounts are in the smallest unit of whatever the engine charges fees in. An +/// all-ones required means a fee no balance could ever cover, an engine reports it that way +/// rather than reporting an amount it cannot represent. +typedef struct ApplicationEngineInsufficientFeeBalance { + ApplicationEngineUint256 required; ///< The frame fee the sender must cover. + ApplicationEngineUint256 available; ///< What the sender has free to cover it. +} ApplicationEngineInsufficientFeeBalance; + +/// @brief The diagnostics of a refusal, read through the member its reason selects. +typedef union ApplicationEngineInvalidValues { + ApplicationEngineInvalidNonce nonce; ///< APPLICATION_ENGINE_INVALID_NONCE. + ApplicationEngineInvalidMaxFee max_fee; ///< APPLICATION_ENGINE_INVALID_MAX_FEE. + ApplicationEngineInsufficientFeeBalance fee_balance; ///< APPLICATION_ENGINE_INSUFFICIENT_FEE_BALANCE. +} ApplicationEngineInvalidValues; + +/// @brief A refusal, its reason and the diagnostics that reason selects. +/// @details Written whole and only on APPLICATION_ENGINE_STATUS_INVALID. Reading a member other +/// than the one the reason names is a caller bug, the unselected members hold nothing. +typedef struct ApplicationEngineInvalid { + ApplicationEngineInvalidReason reason; ///< Why the op was refused. + ApplicationEngineInvalidValues values; ///< The diagnostics for that reason. +} ApplicationEngineInvalid; + +/// @brief The kinds of output an engine can emit, mirroring the rollup output types. +/// @details Append only. A host that meets a kind it does not know has drifted from the engine +/// and must refuse rather than guess, the kinds carry different payload meanings. +typedef enum ApplicationEngineOutputKind { + APPLICATION_ENGINE_OUTPUT_VOUCHER = 0, ///< A call to a destination, read `voucher`. + APPLICATION_ENGINE_OUTPUT_NOTICE = 1, ///< A payload only attestation, read `notice`. +} ApplicationEngineOutputKind; + +/// @brief A call the chain makes on the application's behalf. +typedef struct ApplicationEngineVoucher { + ApplicationEngineEthereumAddress destination; ///< The contract to call. + ApplicationEngineUint256 value; ///< The call value in wei. + ApplicationEngineByteSpan payload; ///< The encoded call payload. +} ApplicationEngineVoucher; + +/// @brief An output's body, read through the member its kind selects. +typedef union ApplicationEngineOutputValues { + ApplicationEngineVoucher voucher; ///< APPLICATION_ENGINE_OUTPUT_VOUCHER. + ApplicationEngineByteSpan notice; ///< APPLICATION_ENGINE_OUTPUT_NOTICE, the attested payload. +} ApplicationEngineOutputValues; + +/// @brief An output an execution emitted, its kind and the body that kind selects. +/// @details Written whole and only when a drain returns OK. Reading a member other than the one +/// the kind names is a caller bug, the unselected members hold nothing. +typedef struct ApplicationEngineOutput { + ApplicationEngineOutputKind kind; ///< What the engine emitted. + ApplicationEngineOutputValues values; ///< The body for that kind. +} ApplicationEngineOutput; + +/// @brief The engine instance behind the handle, opaque to every caller. +typedef struct ApplicationEngine ApplicationEngine; + +/// @brief Get the message describing the most recent failure. +/// @returns A NUL terminated string, never null, empty when the last fallible call succeeded. +/// @details Read it after a negative status or a null handle. Every fallible entry point clears +/// it on entry, so it always describes the call that just failed, and the next one overwrites +/// it, so copy rather than retain the pointer. Infallible entry points leave it untouched. +/// The storage is thread local, matching the ownership the seam already requires: an engine may +/// move between threads but its handle is never used by two at once. +/// +/// The entry points that take no handle are the exception and must be reentrant. A host may call +/// application_engine_state_file_in_dump from request handlers while an execution is in flight, +/// so an engine answering out of one process-wide buffer would race. This message and that path +/// are both thread local here for that reason. +APPLICATION_ENGINE_API const char *application_engine_get_last_error_message(void) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Open an engine over a dump holding an existing deployment. +/// @param prefix The dump to open, carrying whatever shape the engine gives a dump. +/// @param out_engine The engine handle, written only on OK and left untouched otherwise. +/// @returns OK, IO_ERROR when the filesystem refused, which is what a dump that is not there +/// reports, or INTERNAL_ERROR when one that is there is malformed or holds an invalid deployment. +/// @details The only way to open an engine, and it never creates. A state is written once at +/// genesis by a tool that knows the application's configuration, and every engine afterwards opens +/// what is there, which is what keeps deployment configuration off this API. +/// +/// The dump is mapped copy on write, so nothing the engine executes ever reaches a file and the +/// dump stays byte immutable for as long as the engine runs. Two engines may therefore open the +/// same dump, and deleting it under a live engine is safe. In exchange the engine holds no durable +/// state at all, only application_engine_create_dump persists anything. The base file must not be +/// mutated under a live engine, its unwritten pages are still read through. +/// +/// The deployment found there is validated before the handle is written, the backstop for a state +/// that was truncated, hand edited, or left by a genesis that died mid-write. +APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_from_dump(const char *prefix, + ApplicationEngine **out_engine) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Destroy an engine instance. +/// @param engine The engine handle. +APPLICATION_ENGINE_API void application_engine_destroy(ApplicationEngine *engine) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Validate a user op against current state, pure and read-only. +/// @param engine The engine handle. +/// @param sender The recovered signer. +/// @param user_op The op to validate, as its sender signed it. +/// @param current_fee The frame fee price in log space. +/// @param out_invalid Why the op was refused, written whole and only on INVALID. +/// @returns OK, INVALID with diagnostics, or INTERNAL_ERROR. +/// @details A rejection reports itself through out_invalid and leaves the last error message +/// empty, only INTERNAL_ERROR carries one. The max-fee guard belongs to the caller and is never +/// checked here, so APPLICATION_ENGINE_INVALID_MAX_FEE never comes back from this call. Queued +/// outputs are left alone, only an execution touches them. +APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_validate_user_op(const ApplicationEngine *engine, + const ApplicationEngineEthereumAddress *sender, const ApplicationEngineUserOp *user_op, uint16_t current_fee, + ApplicationEngineInvalid *out_invalid) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Execute a validated user op, consuming the current expected nonce. +/// @param engine The engine handle. +/// @param user_op The validated op to execute. +/// @param safe_block The covering frame safe block, folded into the clock as max(clock, it). +/// @param out_output_count How many outputs this op left waiting, written only on OK. +/// @returns OK or INTERNAL_ERROR (an engine throw is fatal-no-resume). +/// @details An op the method rejects still executed and still counts, so it reports OK. Only +/// accept or reject is consensus visible and the state carries it, the seam does not surface +/// the application's own reason. +/// +/// An execution refuses to run while an earlier execution's outputs are still queued, reporting +/// INTERNAL_ERROR without executing anything rather than discarding outputs meant to reach the +/// chain. That refusal is what makes the count reported this op's own. +APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_execute_valid_user_op(ApplicationEngine *engine, + const ApplicationEngineValidUserOp *user_op, uint64_t safe_block, + uint64_t *out_output_count) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Execute a direct input from the L1 input box. +/// @param engine The engine handle. +/// @param input The input to execute, its L1 block folded into the clock as max(clock, it). +/// @param out_output_count How many outputs this input left waiting, written only on OK. +/// @returns OK or INTERNAL_ERROR (an engine throw is fatal-no-resume). +/// @details An input the engine rejects is a counted no-op and still reports OK, the same way a +/// rejected user op does. Outputs behave as they do for a user op. +APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_execute_direct_input(ApplicationEngine *engine, + const ApplicationEngineDirectInput *input, uint64_t *out_output_count) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Take the next queued output, in emission order. +/// @param engine The engine handle. +/// @param out_output The output taken, written whole and only on OK. +/// @returns OK with an output written, or INTERNAL_ERROR. +/// @details Call it exactly as many times as the execution reported, which is what attributes +/// the outputs to the input that produced them. Taking one more than were queued is a caller bug +/// and reports INTERNAL_ERROR rather than an empty output a host might act on. The payload +/// pointer stays valid until the next drain call releases it, so copy before draining again. +/// A voucher carries its value even when an engine only ever emits zero-value ones, so a host +/// reads what the engine emitted instead of assuming it. +APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_drain_output(ApplicationEngine *engine, + ApplicationEngineOutput *out_output) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Get the maximum block carried by any executed input (the engine's safe-block clock). +/// @param engine The engine handle. +/// @returns The last executed safe block, zero when nothing has executed. +/// @details Carried by execution rather than set, so an engine cannot execute and forget to +/// advance it. It lives in the state, so a resumed one reports the block it reflects. +APPLICATION_ENGINE_API uint64_t application_engine_last_executed_safe_block( + const ApplicationEngine *engine) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Get the count of executed inputs, user ops and direct inputs alike. +/// @param engine The engine handle. +/// @returns The executed input count. +APPLICATION_ENGINE_API uint64_t application_engine_executed_input_count( + const ApplicationEngine *engine) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Create a crash durable dump of the engine state (write, fsync). +/// @param engine The engine handle. +/// @param prefix The dump to create, must not pre-exist. It carries whatever shape the engine's +/// state does, a directory or a plain file as the engine chooses. +/// @returns OK, IO_ERROR when the filesystem refused, which is what a full filesystem or an +/// exhausted quota reports, or INTERNAL_ERROR. +/// @details Must be called at a quiescent point only, no in-flight execution. On OK the dump +/// survives an immediate kernel crash, its payload and the directory entry naming it are both +/// synchronized before returning. An engine that cleans up after a failed write leaves the prefix +/// free for a clean retry, which a host cannot do on its behalf. +/// +/// The dump is the live image, not a copy of the file the engine was opened over. The write is +/// sparse, so a dump costs what the image populates rather than its whole length, while finding +/// that reads the whole length either way. +APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_create_dump(const ApplicationEngine *engine, + const char *prefix) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Delete a previously created dump. +/// @param prefix The dump to remove. +/// @returns OK, IO_ERROR when the filesystem refused or the dump was not there, or +/// INTERNAL_ERROR. +/// @details An engine still holding this dump open keeps running, its mapping outlives the name. +/// Synchronizing the directory entry before returning is what would let a caller drop its record +/// of the path on OK, so an engine that skips it can leave an orphan behind a crash. +APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_delete_dump( + const char *prefix) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Get the path of the canonical state file inside a dump. +/// @param prefix The dump to name the state file of. +/// @returns A NUL terminated path, or null on failure with the reason in the last error message. +/// @details Pure over the prefix, it touches no filesystem and needs no engine. Where the state +/// file sits follows from the shape the engine gives a dump, which is why the engine answers +/// rather than a host assuming. An engine whose dump is a directory answers with a file inside +/// it, and one whose dump is the state image itself answers with the prefix unchanged. +/// +/// The storage is engine owned and thread local, overwritten by the next call on the same +/// thread, so copy rather than retain the pointer. Being fallible, it also clears the last error +/// message like any other fallible call, so read a failure's message before asking this. +APPLICATION_ENGINE_API const char *application_engine_state_file_in_dump( + const char *prefix) APPLICATION_ENGINE_NOEXCEPT; + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* APPLICATION_ENGINE_H */ diff --git a/examples/c-app-engine/src/lib.rs b/examples/c-app-engine/src/lib.rs new file mode 100644 index 00000000..2069fa0b --- /dev/null +++ b/examples/c-app-engine/src/lib.rs @@ -0,0 +1,360 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! `Application` over an engine implementing the application-engine C API, linked at build time. +//! The contract is that header; see `docs/protocol/c-application-binding.md`. +//! +//! `canonical_snapshot_bytes` and `export_state` stay defaulted: the C API declares neither, so +//! the watchdog's compare has to reach the bytes through `state_file_in_dump` instead. + +pub mod sys; + +use std::ffi::{CStr, CString, OsStr}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +use alloy_primitives::{Address, U256}; +use sequencer_core::application::{AppError, AppOutput, AppOutputs, InvalidReason}; +use sequencer_core::l2_tx::{DirectInput, ValidUserOp}; +use sequencer_core::user_op::UserOp; + +/// Re-exported so a host binary can name the trait's methods without depending on +/// `sequencer-core` itself, which `sequencer`'s root does not re-export. +pub use sequencer_core::application::Application; + +fn path_to_cstring(path: &Path) -> CString { + CString::new(path.as_os_str().as_encoded_bytes()) + .unwrap_or_else(|_| panic!("path contains an interior NUL: {}", path.display())) +} + +/// Wrap an address the way the C API carries it. +fn abi_address(address: Address) -> sys::ApplicationEngineEthereumAddress { + sys::ApplicationEngineEthereumAddress { + bytes: address.into_array(), + } +} + +/// Borrow a byte slice the way the C API carries it. The span borrows, so the slice must +/// outlive the call it is handed to. +fn abi_span(bytes: &[u8]) -> sys::ApplicationEngineByteSpan { + sys::ApplicationEngineByteSpan { + data: bytes.as_ptr(), + size: u64::try_from(bytes.len()).expect("length exceeds the ABI's u64 width"), + } +} + +/// Copy a payload span the engine handed back. The pointer belongs to the engine and the next +/// drain releases it, so this runs before draining again. +fn payload_from(span: sys::ApplicationEngineByteSpan) -> Vec { + // A zero-length C++ vector may hand out a null data pointer, which Rust slices reject even + // for empty slices + if span.size == 0 { + return Vec::new(); + } + assert!( + !span.data.is_null(), + "non-empty output payload with a null pointer" + ); + let len = usize::try_from(span.size).expect("length exceeds usize on this host"); + unsafe { std::slice::from_raw_parts(span.data, len) }.to_vec() +} + +/// Copy the engine's message for the call that just failed. The engine overwrites the storage +/// on the next fallible call, so this must run before any further call, on the failing thread. +fn last_error_message() -> String { + let message = unsafe { sys::application_engine_get_last_error_message() }; + if message.is_null() { + // The ABI promises a non-null string, treat a broken promise as no detail + return String::new(); + } + unsafe { CStr::from_ptr(message) } + .to_string_lossy() + .into_owned() +} + +/// Turn a lifecycle call status into the `AppError` its caller propagates. +/// +/// The engine tells a failed filesystem operation apart from a failed invariant, so a full disk +/// or a vanished dump becomes `Io` and everything else `Internal`. +fn check(status: i32, what: &str) -> Result<(), AppError> { + if status == sys::APPLICATION_ENGINE_STATUS_OK { + return Ok(()); + } + let reason = format!("engine {what} failed: {}", last_error_message()); + if status == sys::APPLICATION_ENGINE_STATUS_IO_ERROR { + return Err(AppError::Io(std::io::Error::other(reason))); + } + Err(AppError::Internal { reason }) +} + +/// Enforce the declared death policy on an engine `INTERNAL` status from an execution path. +/// +/// Returning `AppError::Internal` here would hand the error to the canonical scheduler fold, +/// which catches application errors and continues, diverging from the machine where the same +/// engine throw terminates processing on possibly partially mutated state. Abort instead of +/// panicking so no unwind handler can resume past it. +fn die_on_internal(context: &str, detail: &str) -> ! { + // Not eprintln!, which panics when stderr is a dead pipe. That panic would unwind out of the + // lane task and the process would survive on partially mutated state, which is the outcome + // this policy exists to prevent. + let _ = std::io::Write::write_all( + &mut std::io::stderr(), + format!("fatal engine internal error in {context}: {detail}\n").as_bytes(), + ); + std::process::abort(); +} + +/// The engine-backed application over the C ABI. +/// +/// Owns the engine handle exclusively, the handle is freed on drop. +pub struct EngineApp { + engine: *mut sys::ApplicationEngine, +} + +// SAFETY: the engine handle is exclusively owned and only moved between threads, never +// shared for concurrent access. `Sync` licenses any consumer to share `&EngineApp` across +// threads, which the engine forbids, so soundness rests on the runtime's bound being +// declarative, no reference ever crosses threads. Nothing pins that, so it is a property the +// runtime has to keep, like `Clone` below. +unsafe impl Send for EngineApp {} +unsafe impl Sync for EngineApp {} + +impl Drop for EngineApp { + fn drop(&mut self) { + unsafe { sys::application_engine_destroy(self.engine) }; + } +} + +impl Clone for EngineApp { + /// Fail-loud by design: the runtime's entry chain declares `Clone` but never exercises it, + /// and silently aliasing or forking a live engine that maps its state would be a determinism + /// hazard. A panic here means the runtime started cloning, and the seam then needs a + /// deliberate fork or reopen entry point rather than a plausible-looking copy. + fn clone(&self) -> Self { + unimplemented!( + "EngineApp cannot be cloned, the sequencer runtime owns exactly one engine \ + instance (upstream contract change detected)" + ) + } +} + +impl EngineApp { + /// Drain the outputs of the execution that just ran into `AppOutputs`, in emission order. + /// + /// The count comes from that execution, and taking exactly it is what attributes the batch + /// to the input that produced it. A kind this host was not built against is fatal rather than + /// guessed past. + fn drain_outputs(&mut self, count: u64) -> AppOutputs { + let mut outputs = AppOutputs::new(); + for _ in 0..count { + // Fresh per iteration on purpose. The engine writes it whole on OK, but reusing one + // buffer would leave the previous drain's payload pointer readable if some engine + // ever wrote only part of it, and that pointer is already freed by then. + let mut output = sys::ApplicationEngineOutput { + kind: 0, + values: sys::ApplicationEngineOutputValues { + notice: abi_span(&[]), + }, + }; + let status = unsafe { sys::application_engine_drain_output(self.engine, &mut output) }; + if status != sys::APPLICATION_ENGINE_STATUS_OK { + die_on_internal("drain_outputs", &last_error_message()); + } + // Each arm reads the union member its kind names, which is what makes the reads sound + outputs.push(match output.kind { + sys::APPLICATION_ENGINE_OUTPUT_VOUCHER => { + let voucher = unsafe { output.values.voucher }; + AppOutput::Voucher { + destination: Address::from(voucher.destination.bytes), + value: U256::from_be_bytes(voucher.value.bytes), + payload: payload_from(voucher.payload), + } + } + sys::APPLICATION_ENGINE_OUTPUT_NOTICE => { + AppOutput::Notice(payload_from(unsafe { output.values.notice })) + } + other => die_on_internal( + "drain_outputs", + &format!("engine reported unknown output kind {other}"), + ), + }); + } + outputs + } +} + +impl Application for EngineApp { + /// The ingress bound for a single method payload, declared by the application's build and + /// read here from the header, so the bound this host enforces and the bound the engine + /// parses under are one number rather than two that can drift. Raising it widens what every + /// caller can push through the host, which is why it is a declaration the application makes + /// rather than something negotiated at runtime. + const MAX_METHOD_PAYLOAD_BYTES: usize = + sys::APPLICATION_ENGINE_MAX_METHOD_PAYLOAD_BYTES as usize; + + fn validate_user_op( + &self, + sender: Address, + user_op: &UserOp, + current_fee: u16, + ) -> Result<(), InvalidReason> { + // Written whole and only on INVALID, the reason selecting which member carries the + // diagnostics. Any variant initializes it, the engine overwrites what it reports. + let mut invalid = sys::ApplicationEngineInvalid { + reason: 0, + values: sys::ApplicationEngineInvalidValues { + nonce: sys::ApplicationEngineInvalidNonce { + expected: 0, + got: 0, + }, + }, + }; + let abi_user_op = sys::ApplicationEngineUserOp { + nonce: user_op.nonce, + max_fee: user_op.max_fee, + data: abi_span(&user_op.data), + }; + let status = unsafe { + sys::application_engine_validate_user_op( + self.engine, + &abi_address(sender), + &abi_user_op, + current_fee, + &mut invalid, + ) + }; + match status { + sys::APPLICATION_ENGINE_STATUS_OK => Ok(()), + // Decode exhaustively against the constants generated from the header. The engine + // emits only InvalidNonce and InsufficientFeeBalance, so any other value means a + // reason this host was not built against, die rather than fabricate a rejection. + // Each arm reads the union member its reason names, which makes the reads sound. + sys::APPLICATION_ENGINE_STATUS_INVALID => match invalid.reason { + sys::APPLICATION_ENGINE_INVALID_NONCE => { + let nonce = unsafe { invalid.values.nonce }; + Err(InvalidReason::InvalidNonce { + expected: nonce.expected, + got: nonce.got, + }) + } + sys::APPLICATION_ENGINE_INSUFFICIENT_FEE_BALANCE => { + // Both amounts come from the engine at their on-chain width, so no fee + // table lookup happens here, which also keeps a hostile batch submitter from + // reaching the panicking converter on a fee it reports as all ones. + let fee_balance = unsafe { invalid.values.fee_balance }; + Err(InvalidReason::InsufficientFeeBalance { + required: U256::from_be_bytes(fee_balance.required.bytes), + available: U256::from_be_bytes(fee_balance.available.bytes), + }) + } + sys::APPLICATION_ENGINE_INVALID_MAX_FEE => die_on_internal( + "validate_user_op", + "engine reported InvalidMaxFee, a caller-owned reason it never produces", + ), + other => die_on_internal( + "validate_user_op", + &format!("engine reported unknown invalid reason {other}"), + ), + }, + _ => die_on_internal("validate_user_op", &last_error_message()), + } + } + + fn execute_valid_user_op( + &mut self, + user_op: &ValidUserOp, + safe_block: u64, + ) -> Result { + let abi_user_op = sys::ApplicationEngineValidUserOp { + sender: abi_address(user_op.sender), + fee: user_op.fee, + data: abi_span(&user_op.data), + }; + let mut output_count: u64 = 0; + let status = unsafe { + sys::application_engine_execute_valid_user_op( + self.engine, + &abi_user_op, + safe_block, + &mut output_count, + ) + }; + if status != sys::APPLICATION_ENGINE_STATUS_OK { + die_on_internal("execute_valid_user_op", &last_error_message()); + } + Ok(self.drain_outputs(output_count)) + } + + fn execute_direct_input(&mut self, input: &DirectInput) -> Result { + let abi_input = sys::ApplicationEngineDirectInput { + sender: abi_address(input.sender), + block_number: input.block_number, + payload: abi_span(&input.payload), + }; + let mut output_count: u64 = 0; + let status = unsafe { + sys::application_engine_execute_direct_input(self.engine, &abi_input, &mut output_count) + }; + if status != sys::APPLICATION_ENGINE_STATUS_OK { + die_on_internal("execute_direct_input", &last_error_message()); + } + Ok(self.drain_outputs(output_count)) + } + + fn last_executed_safe_block(&self) -> u64 { + unsafe { sys::application_engine_last_executed_safe_block(self.engine) } + } + + fn executed_input_count(&self) -> u64 { + unsafe { sys::application_engine_executed_input_count(self.engine) } + } + + /// The pure constructor the trait asks for, no working copy and no process-global context. + /// The engine maps the dump copy on write, so opening the same dump twice, or deleting it + /// under a live engine, stays safe. Genesis is not ours to perform, the application's genesis + /// tool writes a state once and every engine afterwards only opens one. + fn from_dump(prefix: &Path) -> Result { + let prefix_c = path_to_cstring(prefix); + let mut engine: *mut sys::ApplicationEngine = std::ptr::null_mut(); + let status = unsafe { sys::application_engine_from_dump(prefix_c.as_ptr(), &mut engine) }; + check(status, "from_dump")?; + assert!( + !engine.is_null(), + "engine reported success from_dump without a handle" + ); + Ok(Self { engine }) + } + + fn create_dump(&self, prefix: &Path) -> Result<(), AppError> { + let prefix_c = path_to_cstring(prefix); + let status = unsafe { sys::application_engine_create_dump(self.engine, prefix_c.as_ptr()) }; + check(status, "create_dump") + } + + fn delete_dump(prefix: &Path) -> Result<(), AppError> { + let prefix_c = path_to_cstring(prefix); + let status = unsafe { sys::application_engine_delete_dump(prefix_c.as_ptr()) }; + check(status, "delete_dump") + } + + fn state_file_in_dump(prefix: &Path) -> PathBuf { + // The engine names it, so where a state file sits inside a dump follows from the shape + // the engine gives a dump instead of from an assumption made here. Pure over the path, + // as the infallible contract requires, and infallible in practice too: the only way the + // call fails is an allocation it cannot make, which is not a condition to paper over. + // + // An engine may also answer with `prefix` itself, which the trait's "can be the same + // file" concession allows. Nothing here exercises that shape, so an engine taking it + // should check the runtime's behaviour rather than assume it. + let prefix_c = path_to_cstring(prefix); + let state_file = unsafe { sys::application_engine_state_file_in_dump(prefix_c.as_ptr()) }; + if state_file.is_null() { + die_on_internal("state_file_in_dump", &last_error_message()); + } + // Taken as bytes rather than decoded: a Unix path is bytes, and decoding lossily would + // answer with a different path for a prefix this host cannot spell in UTF-8. + PathBuf::from(OsStr::from_bytes( + unsafe { CStr::from_ptr(state_file) }.to_bytes(), + )) + } +} diff --git a/examples/c-app-engine/src/sys.rs b/examples/c-app-engine/src/sys.rs new file mode 100644 index 00000000..87325e0d --- /dev/null +++ b/examples/c-app-engine/src/sys.rs @@ -0,0 +1,20 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! The engine C ABI, generated by bindgen from the header. Nothing here is written by hand, so +//! to change what crosses, change the header. +//! +//! Enums arrive as plain integer constants, never Rust enums, so a value an engine adds later is +//! a number this host can refuse rather than undefined behavior. Records carry generated layout +//! assertions. + +// Generated code follows C's naming and carries the whole surface, including what this host has +// no call for yet +#![allow( + non_upper_case_globals, + non_camel_case_types, + non_snake_case, + dead_code +)] + +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); diff --git a/examples/c-app-sequencer/Cargo.toml b/examples/c-app-sequencer/Cargo.toml new file mode 100644 index 00000000..1ad4c4d9 --- /dev/null +++ b/examples/c-app-sequencer/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "c-app-sequencer" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Sequencer host for a C application engine linked as a static library" +homepage.workspace = true +repository.workspace = true +readme = "../../README.md" +authors.workspace = true + +[[bin]] +name = "c-app-sequencer" +path = "src/main.rs" + +[dependencies] +c-app-engine = { path = "../c-app-engine" } +sequencer = { path = "../../sequencer" } +clap = { version = "4", features = ["derive", "env"] } +tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/c-app-sequencer/build.rs b/examples/c-app-sequencer/build.rs new file mode 100644 index 00000000..bf4320c2 --- /dev/null +++ b/examples/c-app-sequencer/build.rs @@ -0,0 +1,15 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Tells the generic binary whether an engine archive was supplied. +//! +//! A cargo feature would be the usual way, but features are additive and `--all-features` would +//! turn it on in builds with no archive, which is exactly the combination that cannot link. + +fn main() { + println!("cargo::rustc-check-cfg=cfg(external_engine)"); + println!("cargo::rerun-if-env-changed=APPLICATION_ENGINE_LIB"); + if std::env::var_os("APPLICATION_ENGINE_LIB").is_some() { + println!("cargo::rustc-cfg=external_engine"); + } +} diff --git a/examples/c-app-sequencer/src/lib.rs b/examples/c-app-sequencer/src/lib.rs new file mode 100644 index 00000000..269174c1 --- /dev/null +++ b/examples/c-app-sequencer/src/lib.rs @@ -0,0 +1,66 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Host wiring for a C application: the sequencer library over `c-app-engine`'s shim. +//! +//! An application's binary crate is the few lines in `c-wallet-sequencer`, the same shape +//! `wallet-sequencer` has for a Rust application: link an engine, call [`run`]. + +use std::path::PathBuf; +use std::process::ExitCode; + +use c_app_engine::{Application, EngineApp}; +use clap::Parser; +use tracing_subscriber::EnvFilter; + +/// The sequencer library's subcommands plus the one option the host owns, the engine state. +#[derive(Debug, Parser)] +#[command( + version, + about = "Rollup sequencer host for a C application.\n\n\ + Runs the application engine linked in at build time, the one implementing the \ + application-engine C API. The subcommands come from the sequencer library.\n\n\ + All options can also be set via environment variables (shown in brackets)." +)] +struct Cli { + /// Engine genesis state, read-only and load-bearing for `setup` alone, `run` opens dumps. + /// Must already hold a deployment written by the application's genesis tool + #[arg(long, env = "CARTESI_SEQUENCER_STATE_FILE", value_name = "PATH")] + state_file: PathBuf, + #[command(subcommand)] + command: sequencer::Command, +} + +/// Parse this host's arguments and run the sequencer over the linked engine. +pub async fn run() -> ExitCode { + // Parse first so `--help`/`--version` work without the engine state + let Cli { + state_file, + command, + } = Cli::parse(); + + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .init(); + + // Only `setup` starts from this file, `run` and `flush-mempool` work from the dumps the + // sequencer took, so demanding it of them would keep a warm deployment from restarting once + // the genesis state is gone. Opened here rather than inside the closure so a state the engine + // cannot read names what to do about it instead of raising the closure's panic. + let mut app = None; + if matches!(command, sequencer::Command::Setup(_)) { + match EngineApp::from_dump(&state_file) { + Ok(engine) => app = Some(engine), + Err(err) => { + tracing::error!(state = %state_file.display(), + "cannot open the engine state, write one with the application's genesis tool first: {err:?}"); + return ExitCode::FAILURE; + } + } + } + + // Runs only on `setup`, which is the one path that filled `app` above. + sequencer::dispatch(command, move || app.expect("engine opened for setup")).await +} diff --git a/examples/c-app-sequencer/src/main.rs b/examples/c-app-sequencer/src/main.rs new file mode 100644 index 00000000..ce2a970e --- /dev/null +++ b/examples/c-app-sequencer/src/main.rs @@ -0,0 +1,24 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! The generic host binary for an application supplying its engine as an archive, which needs no +//! code of its own. Built without one it has no engine to run and says so; nothing in the host is +//! reachable from that arm, which is what lets the workspace build it with no symbols to resolve. + +use std::process::ExitCode; + +#[cfg(external_engine)] +#[tokio::main] +async fn main() -> ExitCode { + c_app_sequencer::run().await +} + +#[cfg(not(external_engine))] +fn main() -> ExitCode { + eprintln!( + "built without an engine, so there is no application to run. Set \ + APPLICATION_ENGINE_LIB, APPLICATION_ENGINE_HEADER and \ + APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT, then build again." + ); + ExitCode::FAILURE +} diff --git a/examples/c-wallet-engine/Cargo.toml b/examples/c-wallet-engine/Cargo.toml new file mode 100644 index 00000000..6f41d3b5 --- /dev/null +++ b/examples/c-wallet-engine/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "c-wallet-engine" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "The placeholder wallet app exported as an application-engine C API static library" +homepage.workspace = true +repository.workspace = true +readme = "../../README.md" +authors.workspace = true + +[lib] +# `staticlib` is the artifact a C application's build would consume, and it is what proves this +# crate really does export the C API. `rlib` is what an in-workspace binary links instead, so +# `cargo build` resolves the symbols itself and needs no archive path handed to it. +crate-type = ["staticlib", "rlib"] + +[[bin]] +name = "c-wallet-genesis" +path = "src/bin/c-wallet-genesis.rs" + +[dependencies] +app-core = { path = "../app-core" } +c-app-engine = { path = "../c-app-engine" } +sequencer-core = { path = "../../sequencer-core" } +alloy-primitives = "1.4.1" diff --git a/examples/c-wallet-engine/src/bin/c-wallet-genesis.rs b/examples/c-wallet-engine/src/bin/c-wallet-genesis.rs new file mode 100644 index 00000000..ddb63090 --- /dev/null +++ b/examples/c-wallet-engine/src/bin/c-wallet-genesis.rs @@ -0,0 +1,34 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Writes a genesis state for the wallet engine. The seam has no create path, so every +//! application ships a tool like this, and the host never learns what it configured. + +use std::path::PathBuf; +use std::process::ExitCode; + +use app_core::application::WalletConfig; + +fn main() -> ExitCode { + let arguments: Vec = std::env::args().skip(1).collect(); + let config = match arguments.as_slice() { + [_, preset] if preset == "devnet" => WalletConfig::devnet(), + [_, preset] if preset == "sepolia" => WalletConfig::sepolia(), + [_] => WalletConfig::default(), + _ => { + eprintln!( + "usage: c-wallet-genesis [devnet|sepolia]\n\n\ + Writes a genesis wallet state at , which must not already exist." + ); + return ExitCode::from(2); + } + }; + + let state_dir = PathBuf::from(&arguments[0]); + if let Err(err) = c_wallet_engine::write_genesis(&state_dir, config) { + eprintln!("cannot write {}: {err:?}", state_dir.display()); + return ExitCode::FAILURE; + } + println!("wrote {}", state_dir.display()); + ExitCode::SUCCESS +} diff --git a/examples/c-wallet-engine/src/lib.rs b/examples/c-wallet-engine/src/lib.rs new file mode 100644 index 00000000..d2539838 --- /dev/null +++ b/examples/c-wallet-engine/src/lib.rs @@ -0,0 +1,436 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! `app-core`'s wallet exported through the application-engine C API, building to +//! `libc_wallet_engine.a`. The rules it implements are stated in that header. +//! +//! Records come from `c-app-engine::sys`, generated from the same header, so producer and +//! consumer read one declaration. A `panic!` here aborts, which is the policy the header wants. + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::ffi::{CString, OsStr, c_char}; +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +use alloy_primitives::Address; +use app_core::application::{WalletApp, WalletConfig}; +use c_app_engine::sys; +use sequencer_core::application::{AppError, AppOutput, AppOutputs, Application, InvalidReason}; +use sequencer_core::l2_tx::{DirectInput, ValidUserOp}; +use sequencer_core::user_op::UserOp; + +// The header carries no default for the ingress bound, so a build supplies it. This is what +// makes the two agree: a build that told the host a different number than the wallet implements +// fails here rather than at the boundary. +const _: () = assert!( + sys::APPLICATION_ENGINE_MAX_METHOD_PAYLOAD_BYTES as usize + == WalletApp::MAX_METHOD_PAYLOAD_BYTES, + "the payload bound this build declares to the host is not the wallet's own" +); + +thread_local! { + /// The last failure's message, and the buffer `state_file_in_dump` answers out of. + /// + /// Thread local because the header requires the handle-free entry points to be reentrant. + static LAST_ERROR: RefCell = RefCell::new(CString::default()); + static STATE_FILE: RefCell = RefCell::new(CString::default()); +} + +fn clear_error() { + LAST_ERROR.with(|slot| *slot.borrow_mut() = CString::default()); +} + +fn set_error(message: impl AsRef) { + // A NUL inside a diagnostic is not worth failing over, truncate at it + let message = message.as_ref(); + let bytes = message.split('\0').next().unwrap_or_default().as_bytes(); + LAST_ERROR.with(|slot| { + *slot.borrow_mut() = CString::new(bytes).unwrap_or_default(); + }); +} + +/// Report a lifecycle failure, telling a refusing filesystem apart from a broken invariant. +/// +/// The split is the point of the second status: a full disk or a vanished dump is a condition a +/// caller may act on, an engine that failed its own invariant is not. +fn report(error: &AppError, what: &str) -> sys::ApplicationEngineStatus { + match error { + AppError::Io(err) => { + set_error(format!("{what} failed: {err}")); + sys::APPLICATION_ENGINE_STATUS_IO_ERROR + } + other => { + set_error(format!("{what} failed: {other:?}")); + sys::APPLICATION_ENGINE_STATUS_INTERNAL_ERROR + } + } +} + +/// Borrow a path the way the C API carries it, raw bytes rather than text. +/// +/// A Unix path is bytes. Decoding it as UTF-8 would answer for a different path than the caller +/// named whenever it is not valid UTF-8. +/// +/// # Safety +/// `path` must be a non-null NUL terminated string that outlives the call. +unsafe fn path_from(path: *const c_char) -> PathBuf { + assert!(!path.is_null(), "the C API forbids a null path"); + let bytes = unsafe { std::ffi::CStr::from_ptr(path) }.to_bytes(); + PathBuf::from(OsStr::from_bytes(bytes)) +} + +/// Borrow a byte span the way the C API carries it. +/// +/// # Safety +/// The span must describe a readable range that outlives the call, or be empty. +unsafe fn slice_from<'a>(span: &sys::ApplicationEngineByteSpan) -> &'a [u8] { + if span.size == 0 { + // An empty span may carry a null pointer, which Rust slices reject even when empty + return &[]; + } + assert!(!span.data.is_null(), "non-empty span with a null pointer"); + let len = usize::try_from(span.size).expect("span length exceeds usize on this host"); + unsafe { std::slice::from_raw_parts(span.data, len) } +} + +/// The engine instance behind the opaque handle. +pub struct ApplicationEngine { + app: WalletApp, + /// What the last execution produced, in emission order, still to be drained. + pending: VecDeque, + /// The payload the last drain handed out. Held here because the span the caller receives + /// borrows it, and the contract keeps it alive until the next drain releases it. + drained_payload: Vec, +} + +/// Take a handle the C API was given. +/// +/// # Safety +/// `engine` must be a live handle from `application_engine_from_dump`, not yet destroyed. +unsafe fn engine_ref<'a>(engine: *const ApplicationEngine) -> &'a ApplicationEngine { + assert!(!engine.is_null(), "the C API forbids a null engine handle"); + unsafe { &*engine } +} + +/// # Safety +/// As [`engine_ref`], and no other reference to the engine may be live. +unsafe fn engine_mut<'a>(engine: *mut ApplicationEngine) -> &'a mut ApplicationEngine { + assert!(!engine.is_null(), "the C API forbids a null engine handle"); + unsafe { &mut *engine } +} + +#[unsafe(no_mangle)] +pub extern "C" fn application_engine_get_last_error_message() -> *const c_char { + LAST_ERROR.with(|slot| slot.borrow().as_ptr()) +} + +/// # Safety +/// `prefix` is a NUL terminated path and `out_engine` is writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_from_dump( + prefix: *const c_char, + out_engine: *mut *mut ApplicationEngine, +) -> sys::ApplicationEngineStatus { + clear_error(); + let prefix = unsafe { path_from(prefix) }; + match WalletApp::from_dump(&prefix) { + Ok(app) => { + let engine = Box::new(ApplicationEngine { + app, + pending: VecDeque::new(), + drained_payload: Vec::new(), + }); + unsafe { *out_engine = Box::into_raw(engine) }; + sys::APPLICATION_ENGINE_STATUS_OK + } + Err(err) => report(&err, "from_dump"), + } +} + +/// # Safety +/// `engine` is a live handle, and it is not used again afterwards. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_destroy(engine: *mut ApplicationEngine) { + if engine.is_null() { + return; + } + drop(unsafe { Box::from_raw(engine) }); +} + +/// # Safety +/// Every pointer is non-null and its pointee outlives the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_validate_user_op( + engine: *const ApplicationEngine, + sender: *const sys::ApplicationEngineEthereumAddress, + user_op: *const sys::ApplicationEngineUserOp, + current_fee: u16, + out_invalid: *mut sys::ApplicationEngineInvalid, +) -> sys::ApplicationEngineStatus { + clear_error(); + let engine = unsafe { engine_ref(engine) }; + let sender = Address::from(unsafe { (*sender).bytes }); + let user_op = unsafe { &*user_op }; + let op = UserOp { + nonce: user_op.nonce, + max_fee: user_op.max_fee, + data: unsafe { slice_from(&user_op.data) }.to_vec().into(), + }; + + match engine.app.validate_user_op(sender, &op, current_fee) { + Ok(()) => sys::APPLICATION_ENGINE_STATUS_OK, + Err(reason) => { + let invalid = match reason { + InvalidReason::InvalidNonce { expected, got } => sys::ApplicationEngineInvalid { + reason: sys::APPLICATION_ENGINE_INVALID_NONCE, + values: sys::ApplicationEngineInvalidValues { + nonce: sys::ApplicationEngineInvalidNonce { expected, got }, + }, + }, + InvalidReason::InsufficientFeeBalance { + required, + available, + } => sys::ApplicationEngineInvalid { + reason: sys::APPLICATION_ENGINE_INSUFFICIENT_FEE_BALANCE, + values: sys::ApplicationEngineInvalidValues { + fee_balance: sys::ApplicationEngineInsufficientFeeBalance { + required: sys::ApplicationEngineUint256 { + bytes: required.to_be_bytes(), + }, + available: sys::ApplicationEngineUint256 { + bytes: available.to_be_bytes(), + }, + }, + }, + }, + // The caller owns the max-fee guard and this entry point never checks it, so the + // app cannot produce this reason. Reporting it would be a lie about which union + // member carries the diagnostics. + InvalidReason::InvalidMaxFee { .. } => { + set_error("the app reported a caller-owned max fee rejection"); + return sys::APPLICATION_ENGINE_STATUS_INTERNAL_ERROR; + } + }; + unsafe { *out_invalid = invalid }; + sys::APPLICATION_ENGINE_STATUS_INVALID + } + } +} + +/// Take an execution's outputs, refusing to run over ones still queued. +/// +/// The refusal is what makes the count an execution reports its own. Discarding them instead +/// would drop outputs bound for the chain. +/// # Safety +/// `out_output_count` is writable and outlives the call. +unsafe fn execute( + engine: &mut ApplicationEngine, + out_output_count: *mut u64, + run: impl FnOnce(&mut WalletApp) -> Result, +) -> sys::ApplicationEngineStatus { + if !engine.pending.is_empty() { + set_error("an earlier execution's outputs are still queued"); + return sys::APPLICATION_ENGINE_STATUS_INTERNAL_ERROR; + } + match run(&mut engine.app) { + Ok(outputs) => { + engine.pending = outputs.into(); + // SAFETY: the caller guarantees the pointer is writable. + unsafe { *out_output_count = engine.pending.len() as u64 }; + sys::APPLICATION_ENGINE_STATUS_OK + } + // An app error on an execution path is fatal-no-resume, and the host aborts on it. The + // state may already be partially mutated, so there is nothing to resume from. + Err(err) => report(&err, "execute"), + } +} + +/// # Safety +/// Every pointer is non-null and its pointee outlives the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_execute_valid_user_op( + engine: *mut ApplicationEngine, + user_op: *const sys::ApplicationEngineValidUserOp, + safe_block: u64, + out_output_count: *mut u64, +) -> sys::ApplicationEngineStatus { + clear_error(); + let engine = unsafe { engine_mut(engine) }; + let user_op = unsafe { &*user_op }; + let op = ValidUserOp { + sender: Address::from(user_op.sender.bytes), + fee: user_op.fee, + data: unsafe { slice_from(&user_op.data) }.to_vec(), + }; + // SAFETY: the caller guarantees `out_output_count` is writable. + unsafe { + execute(engine, out_output_count, |app| { + app.execute_valid_user_op(&op, safe_block) + }) + } +} + +/// # Safety +/// Every pointer is non-null and its pointee outlives the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_execute_direct_input( + engine: *mut ApplicationEngine, + input: *const sys::ApplicationEngineDirectInput, + out_output_count: *mut u64, +) -> sys::ApplicationEngineStatus { + clear_error(); + let engine = unsafe { engine_mut(engine) }; + let input = unsafe { &*input }; + let direct = DirectInput { + sender: Address::from(input.sender.bytes), + block_number: input.block_number, + payload: unsafe { slice_from(&input.payload) }.to_vec(), + }; + // SAFETY: the caller guarantees `out_output_count` is writable. + unsafe { + execute(engine, out_output_count, |app| { + app.execute_direct_input(&direct) + }) + } +} + +/// # Safety +/// `engine` is a live handle and `out_output` is writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_drain_output( + engine: *mut ApplicationEngine, + out_output: *mut sys::ApplicationEngineOutput, +) -> sys::ApplicationEngineStatus { + clear_error(); + let engine = unsafe { engine_mut(engine) }; + // Taking one more than were queued is a caller bug, reported rather than answered with an + // empty output a host might act on + let Some(output) = engine.pending.pop_front() else { + set_error("no output is queued"); + return sys::APPLICATION_ENGINE_STATUS_INTERNAL_ERROR; + }; + + let written = match output { + AppOutput::Voucher { + destination, + value, + payload, + } => { + engine.drained_payload = payload; + sys::ApplicationEngineOutput { + kind: sys::APPLICATION_ENGINE_OUTPUT_VOUCHER, + values: sys::ApplicationEngineOutputValues { + voucher: sys::ApplicationEngineVoucher { + destination: sys::ApplicationEngineEthereumAddress { + bytes: destination.into_array(), + }, + value: sys::ApplicationEngineUint256 { + bytes: value.to_be_bytes(), + }, + payload: span_of(&engine.drained_payload), + }, + }, + } + } + AppOutput::Notice(payload) => { + engine.drained_payload = payload; + sys::ApplicationEngineOutput { + kind: sys::APPLICATION_ENGINE_OUTPUT_NOTICE, + values: sys::ApplicationEngineOutputValues { + notice: span_of(&engine.drained_payload), + }, + } + } + }; + unsafe { *out_output = written }; + sys::APPLICATION_ENGINE_STATUS_OK +} + +/// Lend a payload to the caller. It stays valid until the next drain replaces it. +fn span_of(payload: &[u8]) -> sys::ApplicationEngineByteSpan { + sys::ApplicationEngineByteSpan { + data: payload.as_ptr(), + size: payload.len() as u64, + } +} + +/// # Safety +/// `engine` is a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_last_executed_safe_block( + engine: *const ApplicationEngine, +) -> u64 { + unsafe { engine_ref(engine) }.app.last_executed_safe_block() +} + +/// # Safety +/// `engine` is a live handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_executed_input_count( + engine: *const ApplicationEngine, +) -> u64 { + unsafe { engine_ref(engine) }.app.executed_input_count() +} + +/// # Safety +/// `engine` is a live handle and `prefix` is a NUL terminated path. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_create_dump( + engine: *const ApplicationEngine, + prefix: *const c_char, +) -> sys::ApplicationEngineStatus { + clear_error(); + let engine = unsafe { engine_ref(engine) }; + let prefix = unsafe { path_from(prefix) }; + match engine.app.create_dump(&prefix) { + Ok(()) => sys::APPLICATION_ENGINE_STATUS_OK, + Err(err) => report(&err, "create_dump"), + } +} + +/// # Safety +/// `prefix` is a NUL terminated path. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_delete_dump( + prefix: *const c_char, +) -> sys::ApplicationEngineStatus { + clear_error(); + let prefix = unsafe { path_from(prefix) }; + match WalletApp::delete_dump(&prefix) { + Ok(()) => sys::APPLICATION_ENGINE_STATUS_OK, + Err(err) => report(&err, "delete_dump"), + } +} + +/// # Safety +/// `prefix` is a NUL terminated path. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn application_engine_state_file_in_dump( + prefix: *const c_char, +) -> *const c_char { + clear_error(); + let prefix = unsafe { path_from(prefix) }; + // Pure over the prefix: it touches no filesystem and needs no engine. Where the state file + // sits follows from the shape this app gives a dump, a directory with `state` inside it. + let state_file = WalletApp::state_file_in_dump(&prefix); + match CString::new(state_file.as_os_str().as_encoded_bytes()) { + Ok(path) => STATE_FILE.with(|slot| { + *slot.borrow_mut() = path; + slot.borrow().as_ptr() + }), + Err(_) => { + set_error("the dump prefix contains an interior NUL"); + std::ptr::null() + } + } +} + +/* -- genesis, the one entry point that is not part of the seam -- */ + +/// Write a genesis state at `prefix`, an empty wallet with the given deployment configuration. +/// +/// The seam has no create path, so every application ships a genesis tool. This is the wallet's. +pub fn write_genesis(prefix: &Path, config: WalletConfig) -> Result<(), AppError> { + WalletApp::new(config).create_dump(prefix) +} diff --git a/examples/c-wallet-sequencer/Cargo.toml b/examples/c-wallet-sequencer/Cargo.toml new file mode 100644 index 00000000..490cb553 --- /dev/null +++ b/examples/c-wallet-sequencer/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "c-wallet-sequencer" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Sequencer binary for the placeholder wallet app reached over the C API" +homepage.workspace = true +repository.workspace = true +readme = "../../README.md" +authors.workspace = true + +[dependencies] +c-app-sequencer = { path = "../c-app-sequencer" } +c-wallet-engine = { path = "../c-wallet-engine" } +tokio = { version = "1.35", features = ["macros", "rt-multi-thread"] } diff --git a/examples/c-wallet-sequencer/src/main.rs b/examples/c-wallet-sequencer/src/main.rs new file mode 100644 index 00000000..a418acc4 --- /dev/null +++ b/examples/c-wallet-sequencer/src/main.rs @@ -0,0 +1,16 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! The same wallet as `wallet-sequencer`, reached over the C seam instead of directly. This is +//! the model for what a C application author builds: depend on an engine, call the host. + +use std::process::ExitCode; + +// Load-bearing: nothing here calls the engine, but without the import the crate stays off the +// link line and the seam's symbols go unresolved. +use c_wallet_engine as _; + +#[tokio::main] +async fn main() -> ExitCode { + c_app_sequencer::run().await +} diff --git a/justfile b/justfile index 47acbfe2..84c311fa 100644 --- a/justfile +++ b/justfile @@ -107,3 +107,9 @@ ci: run addr="127.0.0.1:3000" data_dir="sequencer-data": rm -rf {{data_dir}} CARTESI_SEQUENCER_HTTP_ADDR={{addr}} CARTESI_SEQUENCER_DATA_DIR={{data_dir}} cargo run -p wallet-sequencer --release + +# Genesis for the C-API wallet. Any C application starts with its own genesis tool, then +# `c-wallet-sequencer --state-file setup|run`, which needs a deployed app and an L1. +c-wallet-genesis state="c-wallet-genesis-state" preset="devnet": + rm -rf {{state}} + cargo run -p c-wallet-engine --bin c-wallet-genesis -- {{state}} {{preset}}