From e092b48b7d9cfa06913503fdbf96026d0c6ceee9 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Wed, 9 Sep 2026 15:46:54 -0300 Subject: [PATCH 1/7] feat(ffi): add native application bridge and conformance tests Adapt Eduardo Bart's C application support from PR #32 to the revised Application contract. Keep exclusive movable engine ownership, native progress, fallible validation/execution, and typed missing/corrupt checkpoint errors. Add a reference wallet engine, host binaries, and tests through the actual ABI. Source: https://github.com/cartesi/sequencer/pull/32 Source-Commit: 0fa1755a882ce8aeeb6ba7877ba4ea7c479da9d1 --- Cargo.lock | 42 ++ Cargo.toml | 4 + examples/c-app-engine/Cargo.toml | 19 + examples/c-app-engine/README.md | 64 +++ examples/c-app-engine/build.rs | 159 ++++++ .../c-app-engine/include/application-engine.h | 402 ++++++++++++++++ examples/c-app-engine/src/lib.rs | 288 +++++++++++ examples/c-app-engine/src/sys.rs | 20 + examples/c-app-sequencer/Cargo.toml | 22 + examples/c-app-sequencer/build.rs | 15 + examples/c-app-sequencer/src/lib.rs | 72 +++ examples/c-app-sequencer/src/main.rs | 24 + examples/c-wallet-engine/Cargo.toml | 30 ++ .../src/bin/c-wallet-genesis.rs | 34 ++ examples/c-wallet-engine/src/lib.rs | 454 ++++++++++++++++++ examples/c-wallet-engine/tests/conformance.rs | 237 +++++++++ examples/c-wallet-sequencer/Cargo.toml | 15 + examples/c-wallet-sequencer/src/main.rs | 16 + 18 files changed, 1917 insertions(+) create mode 100644 examples/c-app-engine/Cargo.toml create mode 100644 examples/c-app-engine/README.md create mode 100644 examples/c-app-engine/build.rs create mode 100644 examples/c-app-engine/include/application-engine.h create mode 100644 examples/c-app-engine/src/lib.rs create mode 100644 examples/c-app-engine/src/sys.rs create mode 100644 examples/c-app-sequencer/Cargo.toml create mode 100644 examples/c-app-sequencer/build.rs create mode 100644 examples/c-app-sequencer/src/lib.rs create mode 100644 examples/c-app-sequencer/src/main.rs create mode 100644 examples/c-wallet-engine/Cargo.toml create mode 100644 examples/c-wallet-engine/src/bin/c-wallet-genesis.rs create mode 100644 examples/c-wallet-engine/src/lib.rs create mode 100644 examples/c-wallet-engine/tests/conformance.rs create mode 100644 examples/c-wallet-sequencer/Cargo.toml create mode 100644 examples/c-wallet-sequencer/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 942aacf4..bdac5421 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1374,6 +1374,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.8" @@ -1389,6 +1410,27 @@ dependencies = [ "serde", ] +[[package]] +name = "c-wallet-engine" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "app-core", + "c-app-engine", + "ethereum_ssz", + "sequencer-core", + "tempfile", +] + +[[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 f2622793..67a19a09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,10 @@ members = [ "examples/canonical-app", "examples/canonical-test", "examples/wallet-sequencer", + "examples/c-app-engine", + "examples/c-app-sequencer", + "examples/c-wallet-engine", + "examples/c-wallet-sequencer", "tests/benchmarks", "tests/harness", "tests/e2e", diff --git a/examples/c-app-engine/Cargo.toml b/examples/c-app-engine/Cargo.toml new file mode 100644 index 00000000..98f6443a --- /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 = { workspace = true } diff --git a/examples/c-app-engine/README.md b/examples/c-app-engine/README.md new file mode 100644 index 00000000..89b72296 --- /dev/null +++ b/examples/c-app-engine/README.md @@ -0,0 +1,64 @@ +# C application bridge + +`EngineApp` implements `sequencer_core::application::Application` using the +[application-engine C ABI](include/application-engine.h). The sequencer owns one +engine at a time. A handle can move between threads; calls on it never overlap. +The bridge is `Send`, without `Clone` or `Sync`. + +The native engine owns application state and its execution count/safe-block +clock. Successful execution advances that progress, including counted no-ops; +protocol rejection leaves it unchanged. The shared Rust execution functions +check these transitions. Fatal validation, execution, or output-drain failures +return `AppError`; callers must discard that instance. Exceptions must not cross +the C ABI. + +`NOT_FOUND` and `INVALID_DUMP` retain the distinction between missing/corrupt +checkpoint artifacts and other operational `IO_ERROR` failures. Error strings +are diagnostics and never determine classification. The engine and generated +bindings must use the same header, including these status declarations. + +A dump prefix may be a file or a directory. Opening it produces independently +mutable state without changing the source; checkpoint creation may mutate the +engine's backing arrangement, while preserving logical state and progress. +Successful checkpoints are durable and immutable under subsequent execution. +`state_file_in_dump` names the one canonical comparison file, which can be the +whole dump or a projection alongside richer restoration artifacts. `EngineApp` +does not implement the optional Rust `CanonicalState` inspection trait. + +## Reference wallet + +The reference engine exports the Rust wallet through actual `extern "C"` +functions. Its static archive is also usable by the generic host. In the +repository's development shell: + +```sh +cargo run -p c-wallet-engine --bin c-wallet-genesis -- /tmp/wallet-genesis devnet +cargo run -p c-wallet-sequencer -- --state-file /tmp/wallet-genesis setup +cargo run -p c-wallet-sequencer -- run +cargo test -p c-wallet-engine --test conformance +``` + +The ordinary setup/run environment configuration is still required. The genesis +path is required only for plain `setup`; warm startup, `flush-mempool`, and +`setup --recovery` use the sequencer's durable checkpoints. + +## External engine + +Build the application's static archive and use the corresponding header: + +```sh +APPLICATION_ENGINE_LIB=/absolute/path/libengine.a \ +APPLICATION_ENGINE_HEADER=/absolute/path/application-engine.h \ +APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT=53 \ + cargo build -p c-app-sequencer +``` + +The payload limit must match the engine's own build. Bindgen generates the Rust +records from that header, so a build needs libclang. The engine also supplies its +own genesis tool; configuration does not cross this ABI. With no external +archive configured, the generic binary reports that no engine was linked, and +`c-wallet-sequencer` supplies the reference implementation through Cargo. + +The conformance suite compares native and ABI execution over mixed inputs, +notices and vouchers, rejection/no-op progress, dump round trips, independent +instances, and fatal/error classification. diff --git a/examples/c-app-engine/build.rs b/examples/c-app-engine/build.rs new file mode 100644 index 00000000..af7f13ee --- /dev/null +++ b/examples/c-app-engine/build.rs @@ -0,0 +1,159 @@ +// (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 +//! `README.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(_) => { + assert!( + env::var_os("APPLICATION_ENGINE_HEADER").is_none() + && env::var_os("APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT").is_none(), + "external engine settings require APPLICATION_ENGINE_LIB" + ); + ( + 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..6fea11ad --- /dev/null +++ b/examples/c-app-engine/include/application-engine.h @@ -0,0 +1,402 @@ +/* (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. Fallible entry points report errors through status codes; a fatal +/// validation or execution error defines no successor and the caller must discard the instance. +/// Lifecycle statuses distinguish operational I/O from missing or malformed dump artifacts. +/// Only accept or reject is consensus visible; rejection diagnostics are descriptive. +/// +/// 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, ///< An operational filesystem or mapping failure. + APPLICATION_ENGINE_STATUS_NOT_FOUND = -4, ///< A required dump artifact is missing. + APPLICATION_ENGINE_STATUS_INVALID_DUMP = -5, ///< A dump is malformed or truncated. +} 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, NOT_FOUND for a missing artifact, INVALID_DUMP for malformed/truncated bytes, +/// IO_ERROR for other filesystem failures, or INTERNAL_ERROR for an engine invariant failure. +/// @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. +/// +/// Loading must leave the source dump immutable. Two engines may open the same dump and evolve +/// independently, and deleting the source after loading must leave both usable. Private mapping, +/// deserialization, or an independently owned working copy can implement this contract. The caller +/// never mutates a source dump underneath a live engine. +/// +/// 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 contains the current state. The engine may change backing files or reopen internal +/// handles while checkpointing, but application state and progress stay unchanged. Subsequent +/// execution must leave the completed dump immutable. +APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_create_dump(ApplicationEngine *engine, + const char *prefix) APPLICATION_ENGINE_NOEXCEPT; + +/// @brief Delete a previously created dump. +/// @param prefix The dump to remove. +/// @returns OK, NOT_FOUND when the dump is absent, IO_ERROR for other filesystem failures, +/// 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..b2edc38d --- /dev/null +++ b/examples/c-app-engine/src/lib.rs @@ -0,0 +1,288 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! An exclusively owned application engine behind the C ABI in `application-engine.h`. + +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, ApplicationProgress, InvalidReason, ValidationOutcome, +}; +use sequencer_core::history::ExecutedInputCount; +use sequencer_core::l2_tx::{DirectInput, ValidUserOp}; +use sequencer_core::user_op::UserOp; + +pub use sequencer_core::application::Application; + +fn path_to_cstring(path: &Path) -> CString { + CString::new(path.as_os_str().as_bytes()) + .unwrap_or_else(|_| panic!("path contains an interior NUL: {}", path.display())) +} + +fn abi_address(address: Address) -> sys::ApplicationEngineEthereumAddress { + sys::ApplicationEngineEthereumAddress { + bytes: address.into_array(), + } +} + +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"), + } +} + +// The engine retains each payload until its next drain call, so copy before calling again. +fn payload_from(span: sys::ApplicationEngineByteSpan) -> Vec { + if span.size == 0 { + return Vec::new(); + } + assert!( + !span.data.is_null(), + "non-empty output payload has a null pointer" + ); + let len = usize::try_from(span.size).expect("output length exceeds usize"); + unsafe { std::slice::from_raw_parts(span.data, len) }.to_vec() +} + +fn last_error_message() -> String { + let message = unsafe { sys::application_engine_get_last_error_message() }; + assert!(!message.is_null(), "engine returned a null error message"); + unsafe { CStr::from_ptr(message) } + .to_string_lossy() + .into_owned() +} + +fn internal(reason: impl Into) -> AppError { + AppError::Internal { + reason: reason.into(), + } +} + +fn check(status: i32, operation: &str) -> Result<(), AppError> { + if status == sys::APPLICATION_ENGINE_STATUS_OK { + return Ok(()); + } + let reason = format!( + "engine {operation} failed (status {status}): {}", + last_error_message() + ); + let kind = match status { + sys::APPLICATION_ENGINE_STATUS_IO_ERROR => std::io::ErrorKind::Other, + sys::APPLICATION_ENGINE_STATUS_NOT_FOUND => std::io::ErrorKind::NotFound, + sys::APPLICATION_ENGINE_STATUS_INVALID_DUMP => std::io::ErrorKind::InvalidData, + _ => return Err(internal(reason)), + }; + Err(AppError::Io(std::io::Error::new(kind, reason))) +} + +/// Owns one engine handle. Calls on that handle never overlap. +pub struct EngineApp { + engine: *mut sys::ApplicationEngine, +} + +// SAFETY: the ABI permits moving a handle between threads. Ownership is exclusive; +// EngineApp is neither Clone nor Sync, and mutation requires &mut self. +unsafe impl Send for EngineApp {} + +impl Drop for EngineApp { + fn drop(&mut self) { + unsafe { sys::application_engine_destroy(self.engine) }; + } +} + +impl EngineApp { + fn drain_outputs(&mut self, count: u64) -> Result { + let mut outputs = AppOutputs::new(); + for _ in 0..count { + let mut output = sys::ApplicationEngineOutput { + kind: 0, + values: sys::ApplicationEngineOutputValues { + notice: abi_span(&[]), + }, + }; + check( + unsafe { sys::application_engine_drain_output(self.engine, &mut output) }, + "drain_output", + )?; + 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 => { + return Err(internal(format!( + "engine reported unknown output kind {other}" + ))); + } + }); + } + Ok(outputs) + } +} + +impl Application for EngineApp { + 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 { + let mut invalid = sys::ApplicationEngineInvalid { + reason: 0, + values: sys::ApplicationEngineInvalidValues { + nonce: sys::ApplicationEngineInvalidNonce { + expected: 0, + got: 0, + }, + }, + }; + let op = sys::ApplicationEngineUserOp { + nonce: user_op.nonce, + max_fee: user_op.max_fee, + data: abi_span(user_op.data.as_ref()), + }; + let status = unsafe { + sys::application_engine_validate_user_op( + self.engine, + &abi_address(sender), + &op, + current_fee, + &mut invalid, + ) + }; + if status != sys::APPLICATION_ENGINE_STATUS_INVALID { + check(status, "validate_user_op")?; + return Ok(ValidationOutcome::Accept); + } + let reason = match invalid.reason { + sys::APPLICATION_ENGINE_INVALID_NONCE => { + let nonce = unsafe { invalid.values.nonce }; + InvalidReason::InvalidNonce { + expected: nonce.expected, + got: nonce.got, + } + } + sys::APPLICATION_ENGINE_INSUFFICIENT_FEE_BALANCE => { + let balance = unsafe { invalid.values.fee_balance }; + InvalidReason::InsufficientFeeBalance { + required: U256::from_be_bytes(balance.required.bytes), + available: U256::from_be_bytes(balance.available.bytes), + } + } + // Max-fee rejection belongs to the shared execution boundary. + other => { + return Err(internal(format!( + "engine reported unsupported invalid reason {other}" + ))); + } + }; + Ok(ValidationOutcome::Reject(reason)) + } + + fn apply_valid_user_op( + &mut self, + user_op: &ValidUserOp, + safe_block: u64, + ) -> Result { + let op = sys::ApplicationEngineValidUserOp { + sender: abi_address(user_op.sender), + fee: user_op.fee, + data: abi_span(&user_op.data), + }; + let mut count = 0; + check( + unsafe { + sys::application_engine_execute_valid_user_op( + self.engine, + &op, + safe_block, + &mut count, + ) + }, + "execute_valid_user_op", + )?; + self.drain_outputs(count) + } + + fn apply_direct_input(&mut self, input: &DirectInput) -> Result { + let direct = sys::ApplicationEngineDirectInput { + sender: abi_address(input.sender), + block_number: input.block_number, + payload: abi_span(&input.payload), + }; + let mut count = 0; + check( + unsafe { + sys::application_engine_execute_direct_input(self.engine, &direct, &mut count) + }, + "execute_direct_input", + )?; + self.drain_outputs(count) + } + + fn progress(&self) -> ApplicationProgress { + let count = unsafe { sys::application_engine_executed_input_count(self.engine) }; + let clock = unsafe { sys::application_engine_last_executed_safe_block(self.engine) }; + ApplicationProgress::try_new(ExecutedInputCount::new(count), clock) + .expect("engine returned incoherent application progress") + } + + fn from_dump(prefix: &Path) -> Result { + let prefix = path_to_cstring(prefix); + let mut engine = std::ptr::null_mut(); + check( + unsafe { sys::application_engine_from_dump(prefix.as_ptr(), &mut engine) }, + "from_dump", + )?; + assert!( + !engine.is_null(), + "engine reported successful load without a handle" + ); + Ok(Self { engine }) + } + + fn create_dump(&mut self, prefix: &Path) -> Result<(), AppError> { + let prefix = path_to_cstring(prefix); + check( + unsafe { sys::application_engine_create_dump(self.engine, prefix.as_ptr()) }, + "create_dump", + ) + } + + fn delete_dump(prefix: &Path) -> Result<(), AppError> { + let prefix = path_to_cstring(prefix); + check( + unsafe { sys::application_engine_delete_dump(prefix.as_ptr()) }, + "delete_dump", + ) + } + + fn state_file_in_dump(prefix: &Path) -> PathBuf { + let prefix = path_to_cstring(prefix); + let state_file = unsafe { sys::application_engine_state_file_in_dump(prefix.as_ptr()) }; + assert!( + !state_file.is_null(), + "engine could not name its state file: {}", + last_error_message() + ); + 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..0b04b800 --- /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 = { workspace = true, features = ["env"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } 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..32c69f45 --- /dev/null +++ b/examples/c-app-sequencer/src/lib.rs @@ -0,0 +1,72 @@ +// (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::io::IsTerminal; +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: Option, + #[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_ansi(std::io::stdout().is_terminal()) + .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(config) if !config.recovery) { + let Some(state_file) = state_file else { + tracing::error!("plain setup requires --state-file or CARTESI_SEQUENCER_STATE_FILE"); + return ExitCode::FAILURE; + }; + 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; + } + } + } + + // Only plain setup invokes the genesis constructor. + 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..6099778c --- /dev/null +++ b/examples/c-wallet-engine/Cargo.toml @@ -0,0 +1,30 @@ +[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 = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +ssz = { workspace = true } 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..56af2367 --- /dev/null +++ b/examples/c-wallet-engine/src/lib.rs @@ -0,0 +1,454 @@ +// (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, ValidationOutcome, +}; +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(); + }); +} + +/// Preserve missing/corrupt artifact classification across the ABI; diagnostics are not parsed. +fn report(error: &AppError, what: &str) -> sys::ApplicationEngineStatus { + match error { + AppError::Io(err) => { + set_error(format!("{what} failed: {err}")); + match err.kind() { + std::io::ErrorKind::NotFound => sys::APPLICATION_ENGINE_STATUS_NOT_FOUND, + std::io::ErrorKind::InvalidData + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::NotADirectory + | std::io::ErrorKind::IsADirectory => sys::APPLICATION_ENGINE_STATUS_INVALID_DUMP, + _ => 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 + } + // WalletApp::from_dump only reads and decodes; its Internal errors identify malformed bytes. + Err(AppError::Internal { reason }) => { + set_error(format!("from_dump failed: {reason}")); + sys::APPLICATION_ENGINE_STATUS_INVALID_DUMP + } + 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(ValidationOutcome::Accept) => sys::APPLICATION_ENGINE_STATUS_OK, + Err(err) => report(&err, "validate_user_op"), + Ok(ValidationOutcome::Reject(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 + } + // Application errors define no successor; the caller must discard this instance. + 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.apply_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.apply_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 + .progress() + .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 + .progress() + .executed_input_count() + .get() +} + +/// # 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: *mut ApplicationEngine, + prefix: *const c_char, +) -> sys::ApplicationEngineStatus { + clear_error(); + let engine = unsafe { engine_mut(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-engine/tests/conformance.rs b/examples/c-wallet-engine/tests/conformance.rs new file mode 100644 index 00000000..89d9ccd3 --- /dev/null +++ b/examples/c-wallet-engine/tests/conformance.rs @@ -0,0 +1,237 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +use alloy_primitives::{Address, U256}; +use app_core::application::{Method, Transfer, WalletApp, WalletConfig, Withdrawal}; +use c_app_engine::{Application, EngineApp}; +use c_wallet_engine as _; +use sequencer_core::application::{ + AppError, AppOutput, CanonicalState, ExecutionOutcome, execute_direct_input, + execute_valid_user_op, validate_and_execute_user_op, +}; +use sequencer_core::l2_tx::{DirectInput, ValidUserOp}; +use sequencer_core::user_op::UserOp; +use ssz::Encode; +use std::path::Path; + +fn fixture() -> (tempfile::TempDir, EngineApp, WalletApp, WalletConfig) { + let dir = tempfile::tempdir().unwrap(); + let config = WalletConfig::default(); + let genesis = dir.path().join("genesis"); + c_wallet_engine::write_genesis(&genesis, config).unwrap(); + let bridge = EngineApp::from_dump(&genesis).unwrap(); + let native = WalletApp::from_dump(&genesis).unwrap(); + (dir, bridge, native, config) +} + +fn state_bytes(app: &mut EngineApp, path: &Path) -> Vec { + app.create_dump(path).unwrap(); + std::fs::read(EngineApp::state_file_in_dump(path)).unwrap() +} + +fn deposit(config: WalletConfig, recipient: Address, amount: u64, block: u64) -> DirectInput { + let mut payload = Vec::new(); + payload.extend_from_slice(config.supported_erc20_token.as_slice()); + payload.extend_from_slice(recipient.as_slice()); + payload.extend_from_slice(&U256::from(amount).to_be_bytes::<32>()); + DirectInput { + sender: config.erc20_portal_address, + block_number: block, + payload, + } +} + +#[test] +fn abi_mixed_history_matches_native_outputs_progress_and_dump() { + let (dir, mut bridge, mut native, config) = fixture(); + let sender = Address::repeat_byte(0x11); + let recipient = Address::repeat_byte(0x22); + for direct in [ + deposit(config, sender, 1000, 7), + DirectInput { + sender: config.erc20_portal_address, + block_number: 4, + payload: vec![0xff], + }, + DirectInput { + sender, + block_number: 9, + payload: vec![], + }, + ] { + assert_eq!( + execute_direct_input(&mut bridge, &direct).unwrap(), + execute_direct_input(&mut native, &direct).unwrap() + ); + assert_eq!(bridge.progress(), native.progress()); + } + assert_eq!(bridge.progress().executed_input_count().get(), 3); + assert_eq!(bridge.progress().last_executed_safe_block(), 9); + + for (nonce, data, block) in [ + ( + 0, + Method::Transfer(Transfer { + amount: U256::from(100), + to: recipient, + }) + .as_ssz_bytes(), + 12, + ), + ( + 1, + Method::Withdrawal(Withdrawal { + amount: U256::from(50), + }) + .as_ssz_bytes(), + 10, + ), + (2, vec![0xff], 13), + ] { + let op = UserOp { + nonce, + max_fee: 10, + data: data.into(), + }; + let actual = validate_and_execute_user_op(&mut bridge, sender, &op, 10, block).unwrap(); + let expected = validate_and_execute_user_op(&mut native, sender, &op, 10, block).unwrap(); + assert_eq!(actual, expected); + let ExecutionOutcome::Included(receipt) = actual else { + panic!("funded operation was rejected") + }; + match nonce { + 0 => assert!(matches!(receipt.outputs.as_slice(), [AppOutput::Notice(_)])), + 1 => assert!( + matches!(receipt.outputs.as_slice(), [AppOutput::Voucher { value, .. }] if *value == U256::ZERO) + ), + 2 => assert!( + receipt.outputs.is_empty(), + "malformed method is an executed no-op" + ), + _ => unreachable!(), + } + assert_eq!(bridge.progress(), native.progress()); + } + assert_eq!(bridge.progress().executed_input_count().get(), 6); + assert_eq!(bridge.progress().last_executed_safe_block(), 13); + let checkpoint = dir.path().join("checkpoint"); + assert_eq!( + state_bytes(&mut bridge, &checkpoint), + native.canonical_snapshot_bytes().unwrap() + ); + let restored = EngineApp::from_dump(&checkpoint).unwrap(); + assert_eq!(restored.progress(), bridge.progress()); +} + +#[test] +fn abi_protocol_rejections_leave_state_and_progress_unchanged() { + let (dir, mut bridge, mut native, config) = fixture(); + let sender = Address::repeat_byte(0x11); + let direct = deposit(config, sender, 20, 7); + execute_direct_input(&mut bridge, &direct).unwrap(); + execute_direct_input(&mut native, &direct).unwrap(); + for (who, nonce, max_fee, current_fee) in [ + (sender, 9, 10, 10), + (sender, 0, 0, 10), + (Address::repeat_byte(0x55), 0, 10_000, 10_000), + ] { + let op = UserOp { + nonce, + max_fee, + data: vec![0xff].into(), + }; + let before = bridge.progress(); + let actual = validate_and_execute_user_op(&mut bridge, who, &op, current_fee, 99).unwrap(); + assert_eq!( + actual, + validate_and_execute_user_op(&mut native, who, &op, current_fee, 99).unwrap() + ); + assert!(matches!(actual, ExecutionOutcome::Invalid(_))); + assert_eq!(bridge.progress(), before); + } + assert_eq!( + state_bytes(&mut bridge, &dir.path().join("rejected")), + native.canonical_snapshot_bytes().unwrap() + ); +} + +#[test] +fn abi_restored_instances_and_checkpoints_are_independent() { + let (dir, mut first, _, config) = fixture(); + let source = dir.path().join("genesis"); + let mut second = EngineApp::from_dump(&source).unwrap(); + let original = std::fs::read(EngineApp::state_file_in_dump(&source)).unwrap(); + let input = deposit(config, Address::repeat_byte(0x11), 10, 7); + execute_direct_input(&mut first, &input).unwrap(); + assert_eq!(second.progress().executed_input_count().get(), 0); + assert_eq!( + std::fs::read(EngineApp::state_file_in_dump(&source)).unwrap(), + original + ); + let frozen = dir.path().join("frozen"); + let frozen_bytes = state_bytes(&mut first, &frozen); + execute_direct_input(&mut first, &input).unwrap(); + assert_eq!( + std::fs::read(EngineApp::state_file_in_dump(&frozen)).unwrap(), + frozen_bytes + ); + EngineApp::delete_dump(&source).unwrap(); + execute_direct_input(&mut second, &input).unwrap(); + assert_eq!( + state_bytes(&mut second, &dir.path().join("second")), + frozen_bytes + ); + drop(first); + let loaded = EngineApp::from_dump(&frozen).unwrap(); + assert_eq!(loaded.progress().executed_input_count().get(), 1); +} + +#[test] +fn abi_dump_errors_preserve_missing_and_corrupt_classification() { + let dir = tempfile::tempdir().unwrap(); + assert!( + matches!(EngineApp::from_dump(&dir.path().join("missing")), Err(AppError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound) + ); + let corrupt = dir.path().join("corrupt"); + std::fs::create_dir(&corrupt).unwrap(); + std::fs::write(EngineApp::state_file_in_dump(&corrupt), [0xff]).unwrap(); + assert!( + matches!(EngineApp::from_dump(&corrupt), Err(AppError::Io(error)) if error.kind() == std::io::ErrorKind::InvalidData) + ); +} + +#[test] +fn abi_malformed_dump_paths_remain_terminal_like_native_reads() { + let dir = tempfile::tempdir().unwrap(); + let file_prefix = dir.path().join("file-prefix"); + std::fs::write(&file_prefix, []).unwrap(); + let directory_state = dir.path().join("directory-state"); + std::fs::create_dir(&directory_state).unwrap(); + std::fs::create_dir(WalletApp::state_file_in_dump(&directory_state)).unwrap(); + for prefix in [file_prefix, directory_state] { + assert!( + matches!(WalletApp::from_dump(&prefix), Err(AppError::Io(error)) + if matches!(error.kind(), std::io::ErrorKind::NotADirectory | std::io::ErrorKind::IsADirectory)) + ); + assert!( + matches!(EngineApp::from_dump(&prefix), Err(AppError::Io(error)) + if error.kind() == std::io::ErrorKind::InvalidData) + ); + } +} + +#[test] +fn abi_execution_failure_returns_app_error_without_resuming_the_instance() { + let (_dir, mut bridge, _, _) = fixture(); + let op = ValidUserOp { + sender: Address::repeat_byte(0x11), + fee: 10, + data: vec![], + }; + // Calling the already-validated boundary on an unfunded op is an application invariant fault. + assert!(matches!( + execute_valid_user_op(&mut bridge, &op, 7), + Err(AppError::Internal { .. }) + )); + drop(bridge); +} diff --git a/examples/c-wallet-sequencer/Cargo.toml b/examples/c-wallet-sequencer/Cargo.toml new file mode 100644 index 00000000..06080aa3 --- /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 = { workspace = true, 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 +} From 9b56dd79132d6a32eae2620ca7ca7e8a7c469406 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Wed, 9 Sep 2026 16:00:02 -0300 Subject: [PATCH 2/7] feat(harness)!: support lazy fallible genesis construction Let file-backed applications load genesis only when setup needs its initial snapshot. Completed setup remains a no-op after the original genesis file is gone. Expose run_command for custom parsers so the C host shares command-task panic projection and the existing bootstrap error policy. Remove the external engine's arbitrary payload ceiling and align checkpoint deletion documentation with database-reference removal before garbage collection. BREAKING CHANGE: genesis factories passed to run_main, run_command, dispatch, and setup return Result. Wrap infallible constructors in Ok(...). --- examples/c-app-engine/README.md | 13 +++++- examples/c-app-engine/build.rs | 9 ---- .../c-app-engine/include/application-engine.h | 4 +- examples/c-app-sequencer/src/lib.rs | 36 ++++++---------- .../src/bin/wallet-sequencer-devnet.rs | 2 +- examples/wallet-sequencer/src/main.rs | 2 +- sequencer/src/commands/setup/mod.rs | 14 +++---- sequencer/src/harness.rs | 41 ++++++++++++------- sequencer/src/lib.rs | 2 +- 9 files changed, 61 insertions(+), 62 deletions(-) diff --git a/examples/c-app-engine/README.md b/examples/c-app-engine/README.md index 89b72296..39825ad2 100644 --- a/examples/c-app-engine/README.md +++ b/examples/c-app-engine/README.md @@ -39,8 +39,17 @@ cargo test -p c-wallet-engine --test conformance ``` The ordinary setup/run environment configuration is still required. The genesis -path is required only for plain `setup`; warm startup, `flush-mempool`, and -`setup --recovery` use the sequencer's durable checkpoints. +path is required only when plain `setup` needs its first snapshot; completed +setup, warm startup, `flush-mempool`, and `setup --recovery` use the sequencer's +durable checkpoints. + +The host adds `--state-file` through its own parser and passes the parsed command +to `sequencer::run_command`. This shares `run_main`'s command lifecycle and exit +policy. Both take a lazy `FnOnce() -> Result` genesis factory; +infallible Rust constructors therefore use +`run_main(|| Ok(WalletApp::new(WalletConfig::default())))`. A missing genesis file +returns an ordinary application-bootstrap I/O error, while a caught factory +panic follows the shared terminal-error policy. ## External engine diff --git a/examples/c-app-engine/build.rs b/examples/c-app-engine/build.rs index af7f13ee..b7b9e677 100644 --- a/examples/c-app-engine/build.rs +++ b/examples/c-app-engine/build.rs @@ -17,9 +17,6 @@ use std::path::{Path, PathBuf}; /// 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. @@ -123,12 +120,6 @@ fn external_engine(engine_lib: &str) -> (PathBuf, u32) { 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) } diff --git a/examples/c-app-engine/include/application-engine.h b/examples/c-app-engine/include/application-engine.h index 6fea11ad..51c97669 100644 --- a/examples/c-app-engine/include/application-engine.h +++ b/examples/c-app-engine/include/application-engine.h @@ -376,8 +376,8 @@ APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_create_dump(Ap /// @returns OK, NOT_FOUND when the dump is absent, IO_ERROR for other filesystem failures, /// 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. +/// The sequencer removes the database reference before deleting the artifact. Deletion must not +/// affect other dumps or independently loaded engines. APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_delete_dump( const char *prefix) APPLICATION_ENGINE_NOEXCEPT; diff --git a/examples/c-app-sequencer/src/lib.rs b/examples/c-app-sequencer/src/lib.rs index 32c69f45..590fcea3 100644 --- a/examples/c-app-sequencer/src/lib.rs +++ b/examples/c-app-sequencer/src/lib.rs @@ -24,8 +24,8 @@ use tracing_subscriber::EnvFilter; 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 + /// Genesis dump used only when plain setup needs its initial snapshot. + /// Created by the application's genesis tool #[arg(long, env = "CARTESI_SEQUENCER_STATE_FILE", value_name = "PATH")] state_file: Option, #[command(subcommand)] @@ -47,26 +47,14 @@ pub async fn run() -> ExitCode { ) .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(config) if !config.recovery) { - let Some(state_file) = state_file else { - tracing::error!("plain setup requires --state-file or CARTESI_SEQUENCER_STATE_FILE"); - return ExitCode::FAILURE; - }; - 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; - } - } - } - - // Only plain setup invokes the genesis constructor. - sequencer::dispatch(command, move || app.expect("engine opened for setup")).await + sequencer::run_command(command, move || { + let state_file = state_file.ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "plain setup requires --state-file or CARTESI_SEQUENCER_STATE_FILE", + ) + })?; + EngineApp::from_dump(&state_file) + }) + .await } diff --git a/examples/wallet-sequencer/src/bin/wallet-sequencer-devnet.rs b/examples/wallet-sequencer/src/bin/wallet-sequencer-devnet.rs index 0126ddc3..9921e177 100644 --- a/examples/wallet-sequencer/src/bin/wallet-sequencer-devnet.rs +++ b/examples/wallet-sequencer/src/bin/wallet-sequencer-devnet.rs @@ -20,5 +20,5 @@ async fn main() -> std::process::ExitCode { // `setup` is the only subcommand that constructs a genesis app; the // closure runs only on that path. - sequencer::run_main(|| WalletApp::new(WalletConfig::devnet())).await + sequencer::run_main(|| Ok(WalletApp::new(WalletConfig::devnet()))).await } diff --git a/examples/wallet-sequencer/src/main.rs b/examples/wallet-sequencer/src/main.rs index 40c53211..491fa25d 100644 --- a/examples/wallet-sequencer/src/main.rs +++ b/examples/wallet-sequencer/src/main.rs @@ -20,5 +20,5 @@ async fn main() -> std::process::ExitCode { // `setup` is the only subcommand that constructs a genesis app; the // closure runs only on that path. - sequencer::run_main(|| WalletApp::new(WalletConfig::default())).await + sequencer::run_main(|| Ok(WalletApp::new(WalletConfig::default()))).await } diff --git a/sequencer/src/commands/setup/mod.rs b/sequencer/src/commands/setup/mod.rs index 19b6d152..20ed23a5 100644 --- a/sequencer/src/commands/setup/mod.rs +++ b/sequencer/src/commands/setup/mod.rs @@ -25,7 +25,7 @@ //! key) and does no L1 writes. use alloy_primitives::Address; -use sequencer_core::application::Application; +use sequencer_core::application::{AppError, Application}; use sequencer_core::scheduler::{FoldInput, SchedulerConfig, fold_replay}; pub(crate) mod fill; @@ -43,7 +43,7 @@ use crate::storage::{self, DeploymentIdentity, FeeOracleIdentity}; pub async fn setup(config: SetupConfig, genesis_app: F) -> Result<(), CommandError> where A: Application + 'static, - F: FnOnce() -> A, + F: FnOnce() -> Result, { // Cross-field config validation (recovery vs the recovery-only args). A // misconfig is operator error — terminal, before any filesystem touch. @@ -83,7 +83,7 @@ async fn setup_admitted( ) -> Result<(), CommandError> where A: Application + 'static, - F: FnOnce() -> A, + F: FnOnce() -> Result, { let db_path = config.db_path(); let timing = config.timing.protocol_timing()?; @@ -332,11 +332,9 @@ where // ── Genesis snapshot ───────────────────────────────────── // Construct only after the admission facts and every - // detect-and-refuse gate. A panic leaves setup incomplete (the - // completion fact is never written, so the retry starts fresh), - // while completed no-ops and recovery never construct genesis - // state at all. - let genesis_app = genesis_app(); + // detect-and-refuse gate. Factory errors follow normal setup settlement; + // completed no-ops and recovery never construct genesis state. + let genesis_app = genesis_app()?; fill::register_genesis_finalized_snapshot::(genesis_app, &mut storage, &dumps_dir)?; } diff --git a/sequencer/src/harness.rs b/sequencer/src/harness.rs index 8f26d115..5df0c956 100644 --- a/sequencer/src/harness.rs +++ b/sequencer/src/harness.rs @@ -13,18 +13,17 @@ //! #[tokio::main] //! async fn main() -> std::process::ExitCode { //! init_tracing(); -//! sequencer::harness::run_main(|| WalletApp::new(WalletConfig::default())).await +//! sequencer::harness::run_main(|| Ok(WalletApp::new(WalletConfig::default()))).await //! } //! ``` //! -//! Genesis construction stays off the `Application` trait (it varies per impl) -//! and is supplied by this closure. When a future app needs setup-time CLI -//! args of its own (e.g. a machine-image path), the extension point is a -//! `Cli` generic on the parser — deferred until an app needs it, so -//! the harness imposes no `clap` bound on the (possibly FFI) app type. +//! Genesis construction stays off the `Application` trait (it varies per impl). +//! Apps with their own CLI options can parse [`Command`] and call [`run_command`] +//! with a lazy, fallible genesis factory. Both entry points share the command +//! lifecycle and exit policy without imposing a `clap` bound on the app type. use clap::{Parser, Subcommand}; -use sequencer_core::application::Application; +use sequencer_core::application::{AppError, Application}; use crate::commands::config::{FlushConfig, RunConfig, SetupConfig}; @@ -64,10 +63,20 @@ pub enum Command { pub async fn run_main(genesis_app: F) -> std::process::ExitCode where A: Application + 'static, - F: FnOnce() -> A + Send + 'static, + F: FnOnce() -> Result + Send + 'static, { let cli = Cli::parse(); - project_dispatch_join(tokio::spawn(dispatch(cli.command, genesis_app)).await) + run_command(cli.command, genesis_app).await +} + +/// Run a parsed command with the same panic and exit-code handling as [`run_main`]. +/// The fallible genesis factory runs only when plain setup needs its initial snapshot. +pub async fn run_command(command: Command, genesis_app: F) -> std::process::ExitCode +where + A: Application + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + project_dispatch_join(tokio::spawn(dispatch(command, genesis_app)).await) } fn project_dispatch_join( @@ -100,11 +109,12 @@ fn project_dispatch_join( /// Terminal runtime faults abort the process directly; see [`crate::run`]. /// /// `genesis_app` is called at most once — only when plain `setup` needs to -/// register the genesis snapshot. +/// register the genesis snapshot. File-backed applications return load errors through +/// `AppError`; ordinary I/O failures are not invariant panics. pub async fn dispatch(command: Command, genesis_app: F) -> std::process::ExitCode where A: Application + 'static, - F: FnOnce() -> A, + F: FnOnce() -> Result, { let result = match command { Command::Setup(config) => crate::commands::setup::setup(*config, genesis_app).await, @@ -171,9 +181,12 @@ mod tests { let constructions = Arc::new(AtomicUsize::new(0)); let observed = Arc::clone(&constructions); - let exit = dispatch::(cli.command, move || { + let exit = run_command::(cli.command, move || { observed.fetch_add(1, Ordering::SeqCst); - SweepTestApp + Err(AppError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "genesis file no longer exists", + ))) }) .await; @@ -209,7 +222,7 @@ mod tests { ]) .expect("parse run"); - let exit = dispatch::(cli.command, || SweepTestApp).await; + let exit = dispatch::(cli.command, || Ok(SweepTestApp)).await; assert_eq!(exit, std::process::ExitCode::from(30)); } diff --git a/sequencer/src/lib.rs b/sequencer/src/lib.rs index 420746bc..5c93c895 100644 --- a/sequencer/src/lib.rs +++ b/sequencer/src/lib.rs @@ -37,5 +37,5 @@ mod integration_tests; pub use commands::config::{FlushConfig, RunConfig, SetupConfig}; pub use commands::error::CommandError; pub use commands::run::run; -pub use harness::{Cli, Command, dispatch, run_main}; +pub use harness::{Cli, Command, dispatch, run_command, run_main}; pub use http::{ApiConfig, ApiError, WS_CATCHUP_WINDOW_EXCEEDED_REASON}; From 84dcdd77fe706e876e502276e277a00152a90391 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Fri, 11 Sep 2026 10:34:47 -0300 Subject: [PATCH 3/7] test(ffi): cover output ownership and external archive builds Exercise mixed outputs through a test ABI that reuses its payload buffer, including a full-width nonzero voucher value. Restore the external archive CI smoke check and libclang dependency, and clarify bridge discovery and cross-target determinism responsibilities. Validation: workspace check, strict Clippy, formatting, 718 host tests, and external archive build plus CLI smoke check passed. --- .github/workflows/ci.yml | 10 ++ README.md | 1 + docs/protocol/application-contract.md | 2 + examples/c-app-engine/README.md | 2 + .../c-app-engine/include/application-engine.h | 8 +- examples/c-app-engine/src/lib.rs | 3 + examples/c-app-engine/src/tests.rs | 103 ++++++++++++++++++ 7 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 examples/c-app-engine/src/tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1e0f12f..36212552 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: sudo apt-get update sudo apt-get install -y \ faketime \ + libclang-dev \ libfaketime \ lua5.4 \ liblua5.4-dev \ @@ -62,6 +63,15 @@ jobs: timeout-minutes: 15 run: cargo test --workspace --all-targets --all-features --locked + - 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/README.md b/README.md index a571cc1d..810d8598 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ 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/`](examples/c-app-engine/README.md): C ABI bridge, reference wallet engine, and external static-archive integration guide - `tests/benchmarks/`: benchmark harnesses and benchmark spec Related docs: diff --git a/docs/protocol/application-contract.md b/docs/protocol/application-contract.md index bbdb8131..2f5b9253 100644 --- a/docs/protocol/application-contract.md +++ b/docs/protocol/application-contract.md @@ -11,6 +11,8 @@ of a native engine, its FFI, or its canonical counterpart This document **owns** the contract. [`AGENTS.md`](../../AGENTS.md) is the map. The [wallet](../../examples/app-core/) is the reference implementation. A production application may execute natively or wrap a Cartesi Machine. +The [C application bridge](../../examples/c-app-engine/README.md) adapts a native +engine through a C ABI and includes a reference wallet integration. ## The execution methods diff --git a/examples/c-app-engine/README.md b/examples/c-app-engine/README.md index 39825ad2..705299c9 100644 --- a/examples/c-app-engine/README.md +++ b/examples/c-app-engine/README.md @@ -71,3 +71,5 @@ archive configured, the generic binary reports that no engine was linked, and The conformance suite compares native and ABI execution over mixed inputs, notices and vouchers, rejection/no-op progress, dump round trips, independent instances, and fatal/error classification. +`cargo test -p c-app-engine --lib` also checks mixed-output ordering, copying +reused engine buffers, and full-width voucher values with a small ABI fixture. diff --git a/examples/c-app-engine/include/application-engine.h b/examples/c-app-engine/include/application-engine.h index 51c97669..6adeb81f 100644 --- a/examples/c-app-engine/include/application-engine.h +++ b/examples/c-app-engine/include/application-engine.h @@ -12,10 +12,10 @@ /// 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. +/// shim links at build time to become the sequencer's Application. The same engine implementation +/// can be compiled for the native sequencer and the application's canonical target; deterministic, +/// equivalent behavior across those builds remains the application's responsibility. An engine +/// 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 diff --git a/examples/c-app-engine/src/lib.rs b/examples/c-app-engine/src/lib.rs index b2edc38d..24a4d223 100644 --- a/examples/c-app-engine/src/lib.rs +++ b/examples/c-app-engine/src/lib.rs @@ -5,6 +5,9 @@ pub mod sys; +#[cfg(test)] +mod tests; + use std::ffi::{CStr, CString, OsStr}; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; diff --git a/examples/c-app-engine/src/tests.rs b/examples/c-app-engine/src/tests.rs new file mode 100644 index 00000000..c656da7c --- /dev/null +++ b/examples/c-app-engine/src/tests.rs @@ -0,0 +1,103 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +use super::*; + +// Only the drain seam is needed here; wallet integration tests cover execution and dumps. +// Every non-empty output borrows the same allocation, overwritten by the next drain. +#[derive(Default)] +struct OutputEngine { + drained: usize, + payload: [u8; 4], +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn application_engine_drain_output( + engine: *mut sys::ApplicationEngine, + out_output: *mut sys::ApplicationEngineOutput, +) -> i32 { + // SAFETY: the test transfers a boxed OutputEngine to EngineApp; it remains exclusively + // owned until destroy. EngineApp supplies a writable output record for every call. + let engine = unsafe { &mut *engine.cast::() }; + engine.payload = match engine.drained { + 0 => *b"one!", + 1 => [0, 0xff, 0x80, 0x42], + _ => *b"last", + }; + let (kind, values) = match engine.drained { + 0 | 3 => ( + sys::APPLICATION_ENGINE_OUTPUT_NOTICE, + sys::ApplicationEngineOutputValues { + notice: abi_span(&engine.payload), + }, + ), + 1 => ( + sys::APPLICATION_ENGINE_OUTPUT_VOUCHER, + sys::ApplicationEngineOutputValues { + voucher: sys::ApplicationEngineVoucher { + destination: sys::ApplicationEngineEthereumAddress { bytes: [0x23; 20] }, + value: sys::ApplicationEngineUint256 { + bytes: [ + 129, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, + ], + }, + payload: abi_span(&engine.payload[..3]), + }, + }, + ), + 2 => ( + sys::APPLICATION_ENGINE_OUTPUT_NOTICE, + sys::ApplicationEngineOutputValues { + notice: sys::ApplicationEngineByteSpan { + data: std::ptr::null(), + size: 0, + }, + }, + ), + _ => return sys::APPLICATION_ENGINE_STATUS_INTERNAL_ERROR, + }; + engine.drained += 1; + unsafe { *out_output = sys::ApplicationEngineOutput { kind, values } }; + sys::APPLICATION_ENGINE_STATUS_OK +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn application_engine_destroy(engine: *mut sys::ApplicationEngine) { + // SAFETY: EngineApp calls this once for the allocation transferred by the test. + drop(unsafe { Box::from_raw(engine.cast::()) }); +} + +#[unsafe(no_mangle)] +extern "C" fn application_engine_get_last_error_message() -> *const std::ffi::c_char { + c"output fixture exhausted".as_ptr() +} + +#[test] +fn drain_preserves_output_order_and_copies_reused_payloads() { + let mut app = EngineApp { + engine: Box::into_raw(Box::::default()).cast(), + }; + let outputs = app.drain_outputs(4).unwrap(); + drop(app); + + assert_eq!( + outputs, + vec![ + AppOutput::Notice(b"one!".to_vec()), + AppOutput::Voucher { + destination: Address::repeat_byte(0x23), + // Independent of the fixture's big-endian bytes: limbs are least-significant first. + value: U256::from_limbs([ + 0x191a1b1c1d1e1f20, + 0x1112131415161718, + 0x090a0b0c0d0e0f10, + 0x8102030405060708, + ]), + payload: vec![0, 0xff, 0x80], + }, + AppOutput::Notice(vec![]), + AppOutput::Notice(b"last".to_vec()), + ] + ); +} From d19dc97434cab218723e061df8729d600ad0d21e Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Fri, 11 Sep 2026 21:25:14 -0300 Subject: [PATCH 4/7] fix(runtime): contain application lifecycle failures Catch application snapshot-path panics at both HTTP handlers and invoke the host terminal-abort policy. Classify deterministic application bootstrap I/O failures as terminal while keeping operational I/O restartable and genesis construction lazy. Validate both snapshot routes with subprocess SIGABRT tests and extend the exit-code table with terminal and retryable bootstrap cases. --- sequencer/src/commands/error.rs | 40 ++++++++++++++++++ sequencer/src/egress/api/snapshot.rs | 62 +++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/sequencer/src/commands/error.rs b/sequencer/src/commands/error.rs index 4194cebd..0a2034c0 100644 --- a/sequencer/src/commands/error.rs +++ b/sequencer/src/commands/error.rs @@ -156,6 +156,21 @@ impl CommandError { CommandError::AppBootstrap(AppError::Internal { .. }) => { CommandFailureVerdict::Terminal } + CommandError::AppBootstrap(AppError::Io(source)) + if matches!( + source.kind(), + std::io::ErrorKind::InvalidInput + | std::io::ErrorKind::NotFound + | std::io::ErrorKind::PermissionDenied + | std::io::ErrorKind::InvalidData + | std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::IsADirectory + | std::io::ErrorKind::NotADirectory + ) => + { + // An absent option or unusable genesis dump needs operator repair. + CommandFailureVerdict::Terminal + } CommandError::ReferencedSnapshotArtifact { source, .. } if referenced_artifact_io_is_terminal(source) => { @@ -951,6 +966,23 @@ mod tests { ) }), ); + rows.extend( + [ + std::io::ErrorKind::InvalidInput, + std::io::ErrorKind::NotFound, + std::io::ErrorKind::PermissionDenied, + std::io::ErrorKind::InvalidData, + std::io::ErrorKind::UnexpectedEof, + std::io::ErrorKind::IsADirectory, + std::io::ErrorKind::NotADirectory, + ] + .map(|kind| { + ( + CommandError::AppBootstrap(AppError::Io(std::io::Error::from(kind))), + "a missing option or unusable application genesis dump", + ) + }), + ); rows.extend([ // A deterministic signer-construction misconfig (bad RPC URL or // private key) classifies terminal in every command, matching the @@ -1252,6 +1284,14 @@ mod tests { CommandError::AppBootstrap(AppError::Io(std::io::Error::other("disk unavailable"))), "an application I/O failure at bootstrap", ), + ( + CommandError::AppBootstrap(AppError::Io(std::io::ErrorKind::Interrupted.into())), + "an interrupted genesis read remains restartable", + ), + ( + CommandError::AppBootstrap(AppError::Io(std::io::ErrorKind::TimedOut.into())), + "a timed out genesis read remains restartable", + ), ] } diff --git a/sequencer/src/egress/api/snapshot.rs b/sequencer/src/egress/api/snapshot.rs index 58d9e930..b7dc4a8c 100644 --- a/sequencer/src/egress/api/snapshot.rs +++ b/sequencer/src/egress/api/snapshot.rs @@ -128,7 +128,7 @@ async fn finalized_state( return StatusCode::NOT_MODIFIED.into_response(); } - let path = (state.snapshot.state_file_in_dump)(&leased.prefix); + let path = state_file_path(&state.snapshot, &leased.prefix); let l2_tx_index = leased.l2_tx_index; let LeasedDump { guard, .. } = leased; @@ -162,7 +162,7 @@ async fn latest_snapshot(State(state): State>) -> Response Err(err) => return internal_error("acquire latest snapshot lease", err), }; - let path = (state.snapshot.state_file_in_dump)(&leased.prefix); + let path = state_file_path(&state.snapshot, &leased.prefix); let l2_tx_index = leased.l2_tx_index; let LeasedDump { guard, .. } = leased; @@ -184,6 +184,12 @@ async fn latest_snapshot(State(state): State>) -> Response } } +fn state_file_path(state: &SnapshotState, prefix: &Path) -> PathBuf { + // HTTP request panics are otherwise isolated from the worker supervisor. + std::panic::catch_unwind(|| (state.state_file_in_dump)(prefix)) + .unwrap_or_else(|_| abort_terminal("application snapshot path callback panicked")) +} + fn stream_body(file: File, guard: LeaseGuard) -> Body { Body::from_stream(ReaderStream::new(GuardedReader { file, @@ -273,6 +279,58 @@ mod tests { use super::*; use crate::storage::test_helpers::temp_db; + #[cfg(unix)] + async fn panicking_state_path_aborts(test_name: &str, finalized: bool) { + if !crate::runtime::shutdown::abort_test_child(test_name) { + return; + } + let db = temp_db("panicking-snapshot-path"); + let mut storage = Storage::open(&db.path).expect("open storage"); + storage + .insert_finalized_dump(Path::new("/tmp/panicking-snapshot-path"), 12, 34) + .expect("insert finalized snapshot"); + drop(storage); + let state = Arc::new(SnapshotApiState { + snapshot: SnapshotState { + db_path: db.path, + state_file_in_dump: |_| panic!("application returned no state path"), + }, + shutdown: RuntimeScope::default(), + release_scheduler: Arc::new(|release| release()), + }); + + // Exercise the request-task boundary that would swallow this panic. + let result = tokio::spawn(async move { + if finalized { + finalized_state(State(state), HeaderMap::new()).await + } else { + latest_snapshot(State(state)).await + } + }) + .await; + panic!("snapshot path callback panic did not abort the process: {result:?}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[cfg(unix)] + async fn finalized_state_path_panic_aborts_process() { + panicking_state_path_aborts( + "egress::api::snapshot::tests::finalized_state_path_panic_aborts_process", + true, + ) + .await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[cfg(unix)] + async fn latest_snapshot_path_panic_aborts_process() { + panicking_state_path_aborts( + "egress::api::snapshot::tests::latest_snapshot_path_panic_aborts_process", + false, + ) + .await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[cfg(unix)] async fn corrupt_finalized_snapshot_trips_terminal_storage_fault() { From f8f2ebad36c364883110396b2bfb9cead90ef3cb Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Fri, 11 Sep 2026 21:25:14 -0300 Subject: [PATCH 5/7] fix(examples): handle genesis help without creating state Recognize --help and -h before treating the argument as a dump path. Pin successful help output and an untouched working directory in a CLI regression test, and remove the unused tracing dependency from the generic host. --- Cargo.lock | 1 - examples/c-app-sequencer/Cargo.toml | 1 - .../c-wallet-engine/src/bin/c-wallet-genesis.rs | 12 ++++++++---- examples/c-wallet-engine/tests/genesis_cli.rs | 17 +++++++++++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 examples/c-wallet-engine/tests/genesis_cli.rs diff --git a/Cargo.lock b/Cargo.lock index bdac5421..18a0ed38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1391,7 +1391,6 @@ dependencies = [ "clap", "sequencer", "tokio", - "tracing", "tracing-subscriber", ] diff --git a/examples/c-app-sequencer/Cargo.toml b/examples/c-app-sequencer/Cargo.toml index 0b04b800..050d9356 100644 --- a/examples/c-app-sequencer/Cargo.toml +++ b/examples/c-app-sequencer/Cargo.toml @@ -18,5 +18,4 @@ c-app-engine = { path = "../c-app-engine" } sequencer = { path = "../../sequencer" } clap = { workspace = true, features = ["env"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/examples/c-wallet-engine/src/bin/c-wallet-genesis.rs b/examples/c-wallet-engine/src/bin/c-wallet-genesis.rs index ddb63090..b8810c08 100644 --- a/examples/c-wallet-engine/src/bin/c-wallet-genesis.rs +++ b/examples/c-wallet-engine/src/bin/c-wallet-genesis.rs @@ -9,17 +9,21 @@ use std::process::ExitCode; use app_core::application::WalletConfig; +const USAGE: &str = "usage: c-wallet-genesis [devnet|sepolia]\n\n\ + Writes a genesis wallet state at , which must not already exist."; + fn main() -> ExitCode { let arguments: Vec = std::env::args().skip(1).collect(); + if matches!(arguments.as_slice(), [flag] if flag == "--help" || flag == "-h") { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } 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." - ); + eprintln!("{USAGE}"); return ExitCode::from(2); } }; diff --git a/examples/c-wallet-engine/tests/genesis_cli.rs b/examples/c-wallet-engine/tests/genesis_cli.rs new file mode 100644 index 00000000..817ad042 --- /dev/null +++ b/examples/c-wallet-engine/tests/genesis_cli.rs @@ -0,0 +1,17 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +#[test] +fn help_does_not_create_a_genesis_dump() { + let dir = tempfile::tempdir().unwrap(); + for flag in ["--help", "-h"] { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_c-wallet-genesis")) + .arg(flag) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8(output.stdout).unwrap().contains("usage:")); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0); + } +} From a4e300c6b8f58274c88d64cbbdcf3bec3d56654f Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Fri, 11 Sep 2026 21:25:48 -0300 Subject: [PATCH 6/7] refactor(application)!: simplify the native engine contract Read the stable method payload bound from the linked engine, return count and clock together, and let the sequencer discard self-contained checkpoint directories with ordinary filesystem deletion. Remove the duplicated payload-limit build setting and cleanup-only Application type parameters. Update the reference ABI implementation, file/directory independence tests, build instructions, and protocol guide. Keep engine-dependent output, layout, conformance, and linking work documented as follow-ups. BREAKING CHANGE: Application implementations replace MAX_METHOD_PAYLOAD_BYTES with max_method_payload_bytes() and remove delete_dump. Native archives export application_engine_max_method_payload_bytes and application_engine_progress with ApplicationEngineProgress instead of scalar progress getters and a deletion hook. External builds require only the archive and header settings. Validation: 722 host tests passed, with one existing ignored doctest; workspace check, strict Clippy, formatting, C11/C++17 header checks, and external archive build plus CLI smoke check passed. --- .github/workflows/ci.yml | 1 - AGENTS.md | 8 +- CLAUDE.md | 6 +- README.md | 7 +- docs/protocol/application-contract.md | 22 ++- docs/protocol/c-application-binding.md | 49 +++++++ docs/review/register.md | 32 +++++ docs/snapshots/README.md | 2 +- docs/snapshots/format.md | 9 +- docs/snapshots/lifecycle.md | 7 +- examples/app-core/src/application/wallet.rs | 19 ++- examples/c-app-engine/README.md | 21 ++- examples/c-app-engine/build.rs | 45 ++----- .../c-app-engine/include/application-engine.h | 126 ++++++++---------- examples/c-app-engine/src/lib.rs | 28 ++-- examples/c-app-engine/src/tests.rs | 12 +- examples/c-app-sequencer/src/main.rs | 3 +- examples/c-wallet-engine/src/lib.rs | 57 +++----- examples/c-wallet-engine/tests/conformance.rs | 7 +- sequencer-core/src/application/mod.rs | 19 +-- sequencer-core/src/scheduler/fold.rs | 7 +- sequencer-core/src/scheduler/mod.rs | 8 +- sequencer/src/commands/run/startup_hygiene.rs | 25 ++-- sequencer/src/commands/run/workers.rs | 15 +-- sequencer/src/commands/setup/fill.rs | 8 +- sequencer/src/commands/test_support.rs | 11 +- sequencer/src/http.rs | 2 +- .../src/ingress/inclusion_lane/dump_info.rs | 46 +++---- sequencer/src/ingress/inclusion_lane/mod.rs | 5 +- .../src/ingress/inclusion_lane/snapshot.rs | 25 ++-- sequencer/src/ingress/inclusion_lane/tests.rs | 45 +++---- 31 files changed, 347 insertions(+), 330 deletions(-) create mode 100644 docs/protocol/c-application-binding.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36212552..803a2a88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,6 @@ jobs: 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 diff --git a/AGENTS.md b/AGENTS.md index 78c6c5db..679cef38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,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/` — C ABI adapter implementing `Application` for a native engine. +- `examples/c-app-sequencer/` — shared C-engine CLI host and external-archive binary. +- `examples/c-wallet-engine/` — reference wallet engine exporting the C ABI, plus its genesis tool and conformance tests. +- `examples/c-wallet-sequencer/` — binary composing the C-engine host with the reference wallet engine. - `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. @@ -247,7 +251,7 @@ Logical state changes, including `ApplicationProgress`, flow through the `apply_ User ops are executed only through `sequencer_core::application::validate_and_execute_user_op`; already-validated user ops and directs use `execute_valid_user_op` / `execute_direct_input`. The shared boundary preflights the checked successor, then verifies the engine's progress after a successful hook and returns its pre-execution offset. Count zero implies clock zero. Validation purity and native mutation remain self-trusted. `AppError` is fatal and defines no canonical successor; callers discard the instance rather than resume it. The inclusion lane, canonical scheduler, catch-up, and recovery fold all use this boundary — part of the duality agreement. -`Application` requires `Send`, with neither `Clone` nor `Sync`. Dumps must be durable and immutable, and restored engines must remain independent after source deletion. The opaque app prefix may be a file or directory. Canonical inspection belongs to the separate `CanonicalState` trait; the native sequencer serves the comparison file in the checkpoint. +`Application` requires `Send`, with neither `Clone` nor `Sync`. Dumps must be durable and immutable, and restored engines must remain independent after source deletion. The opaque app prefix may be a file or directory; checkpoint disposal uses ordinary recursive filesystem deletion. Canonical inspection belongs to the separate `CanonicalState` trait; the native sequencer serves the comparison file in the checkpoint. The [C binding guide](docs/protocol/c-application-binding.md) maps the contract to native engines. ## Hot-Path Invariants @@ -455,7 +459,7 @@ Before finishing a change, ensure: - [`README.md`](README.md) — product framing, user-facing trust model, **API contract** (endpoint shapes, caps, close codes, health semantics). - [`CLAUDE.md`](CLAUDE.md) — shell setup, quick reference, pointer back here. -- [`docs/protocol/`](docs/protocol/) — the authoritative protocol contracts: [`scheduler-semantics.md`](docs/protocol/scheduler-semantics.md) (the canonical acceptance algorithm, I1) and [`application-contract.md`](docs/protocol/application-contract.md) (the `Application` FFI trait contract). +- [`docs/protocol/`](docs/protocol/) — the authoritative protocol contracts: [`scheduler-semantics.md`](docs/protocol/scheduler-semantics.md) (the canonical acceptance algorithm, I1), [`application-contract.md`](docs/protocol/application-contract.md) (the `Application` trait contract), and [`c-application-binding.md`](docs/protocol/c-application-binding.md) (the native C binding). - [`docs/invariants.md`](docs/invariants.md) — register of cross-module invariants (what's load-bearing across files) + the fail-loud check policy. - [`docs/review/register.md`](docs/review/register.md) — the review register: open findings, settled decisions, refuted proposals (do-not-re-propose), and the review history table; the dated ledgers beside it carry the evidence the table points at. - [`docs/plans/`](docs/plans/) — the architecture decision record ([`2026-08-authority-boundary-adr.md`](docs/plans/2026-08-authority-boundary-adr.md)), active coordination tracks, and in-flight design handoffs. diff --git a/CLAUDE.md b/CLAUDE.md index 56824121..f27ecb8d 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/` — native engine adapter implementing `Application` through a C ABI. +- `examples/c-app-sequencer/` — shared C-engine CLI host and external-archive binary. +- `examples/c-wallet-engine/` — reference C ABI exports, genesis tool, and conformance tests. +- `examples/c-wallet-sequencer/` — binary composing the C-engine host with the reference wallet engine. - `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. @@ -58,7 +62,7 @@ Rust edition 2024 / Axum API / SQLite (rusqlite, WAL) / EIP-712 signing / SSZ en ## Before You Start Real Work - **[`AGENTS.md`](AGENTS.md)** — mission, requirements, invariants, duality, recovery, conventions, rules. -- **[`docs/protocol/`](docs/protocol/)** — the authoritative protocol contracts: [`scheduler-semantics.md`](docs/protocol/scheduler-semantics.md) (canonical acceptance algorithm) and [`application-contract.md`](docs/protocol/application-contract.md) (the `Application` FFI trait). Read before touching the scheduler, the gold frontier, the fold, or an `Application` impl. +- **[`docs/protocol/`](docs/protocol/)** — the authoritative protocol contracts: [`scheduler-semantics.md`](docs/protocol/scheduler-semantics.md) (canonical acceptance algorithm), [`application-contract.md`](docs/protocol/application-contract.md) (the `Application` trait), and [`c-application-binding.md`](docs/protocol/c-application-binding.md) (the native C binding). Read before touching the scheduler, the gold frontier, the fold, or an `Application` impl. - **[`docs/invariants.md`](docs/invariants.md)** — cross-module invariants register + the fail-loud check policy. Check it before changing anything it lists as load-bearing. - **[`docs/review/register.md`](docs/review/register.md)** — the review register: open findings, settled decisions, refuted proposals (do-not-re-propose). Check it for open findings in code you're about to touch, and before proposing a mechanism or simplification. - **[`docs/plans/`](docs/plans/)** — the [authority-boundary ADR](docs/plans/2026-08-authority-boundary-adr.md), active coordination tracks, and in-flight design handoffs. Check before starting work that might belong to a track. diff --git a/README.md b/README.md index 810d8598..a0920cfe 100644 --- a/README.md +++ b/README.md @@ -260,10 +260,15 @@ 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/`](examples/c-app-engine/README.md): C ABI bridge, reference wallet engine, and external static-archive integration guide +- [`examples/c-app-engine/`](examples/c-app-engine/README.md): C ABI adapter and external static-archive integration guide +- `examples/c-app-sequencer/`: shared C-engine CLI host and external-archive binary +- `examples/c-wallet-engine/`: reference wallet C ABI exports, genesis tool, and conformance tests +- `examples/c-wallet-sequencer/`: binary composing the C-engine host with the reference wallet engine - `tests/benchmarks/`: benchmark harnesses and benchmark spec Related docs: + +- C application binding: [`docs/protocol/c-application-binding.md`](docs/protocol/c-application-binding.md) - App snapshots (format + lifecycle): `docs/snapshots/` - Watchdog — local dev: [`docs/watchdog/getting-started.md`](docs/watchdog/getting-started.md); Sepolia/mainnet: [`docs/watchdog/operator-deployment.md`](docs/watchdog/operator-deployment.md) diff --git a/docs/protocol/application-contract.md b/docs/protocol/application-contract.md index 2f5b9253..8d312ba6 100644 --- a/docs/protocol/application-contract.md +++ b/docs/protocol/application-contract.md @@ -11,8 +11,9 @@ of a native engine, its FFI, or its canonical counterpart This document **owns** the contract. [`AGENTS.md`](../../AGENTS.md) is the map. The [wallet](../../examples/app-core/) is the reference implementation. A production application may execute natively or wrap a Cartesi Machine. -The [C application bridge](../../examples/c-app-engine/README.md) adapts a native -engine through a C ABI and includes a reference wallet integration. +The [C application binding](c-application-binding.md) adapts this contract to +native engines; its [build guide](../../examples/c-app-engine/README.md) includes +a reference wallet integration. ## The execution methods @@ -37,9 +38,10 @@ Overflow fails before the hook runs. An error defines no successor: callers terminate the execution path and discard the instance, without attempting to roll back or inspect partially updated state. -`MAX_METHOD_PAYLOAD_BYTES` is both an ingress payload limit and a batch-sizing -input. HTTP rejects oversized method payloads; the lane uses the declared bound -plus signed-op metadata to compute batch capacity. It is not a canonical +`Application::max_method_payload_bytes()` returns the engine's stable method +payload bound without constructing an instance. Zero permits only empty method +payloads. HTTP rejects oversized method payloads; the lane uses the declared +bound plus signed-op metadata to compute batch capacity. It is not a canonical scheduler rejection rule. ## Cross-cutting contracts @@ -153,8 +155,11 @@ machine checkpoint may instead contain a separate app-state projection. The possibly `prefix` itself. Its bytes match the canonical application's deterministic comparison representation, whether obtained through inspect or from a designated state drive. -- `delete_dump(prefix)` removes the app-owned checkpoint. The sequencer owns - the outer directory and `info.toml`; the app owns its opaque `state` prefix. +- All checkpoint-owned artifacts reside at or below `prefix`, including the + canonical comparison file. Disposal requires only ordinary filesystem + deletion: the sequencer removes the enclosing directory, including its own + `info.toml`. Removing a checkpoint must leave other checkpoints and restored + engines usable; external resource cleanup is outside this contract. Mutable checkpoint creation permits flushing, replacing mappings, or changing working backing files inside an adapter. It does not permit a logical state @@ -185,6 +190,9 @@ also stays on the concrete application. 4. Implement `CanonicalState` only where canonical inspection needs it. Remove any `Clone` or `Sync` added solely to satisfy the old host bounds; justify `Send` against the native engine's ownership contract. +5. Return the stable payload bound from `max_method_payload_bytes()` rather + than an associated constant. Keep checkpoint artifacts self-contained so + recursive filesystem deletion disposes of them without an application hook. Changing these Rust interfaces preserves transaction encoding, expected rejection semantics, snapshot bytes, scheduler ordering, and the database diff --git a/docs/protocol/c-application-binding.md b/docs/protocol/c-application-binding.md new file mode 100644 index 00000000..739be090 --- /dev/null +++ b/docs/protocol/c-application-binding.md @@ -0,0 +1,49 @@ +# C application binding + +An application exposes the +[`application-engine.h`](../../examples/c-app-engine/include/application-engine.h) +C ABI in a static archive. `c-app-engine::EngineApp` adapts that engine to the +Rust `Application` trait. The [build guide](../../examples/c-app-engine/README.md) +covers linking, genesis, host commands, and reference tests. + +## Contract ownership + +- The [Application contract](application-contract.md) defines execution, + rejection, progress, deterministic outputs, and checkpoint obligations. +- [Scheduler semantics](scheduler-semantics.md) defines canonical ordering and + the acceptance boundary. The native engine does not acquire an ordering role + through this ABI. +- The [C header](../../examples/c-app-engine/include/application-engine.h) + defines record layout, statuses, pointer ownership, and call lifetimes. An + engine archive and its generated Rust bindings must agree on that header. +- [Snapshot lifecycle](../snapshots/lifecycle.md) owns checkpoint registration, + promotion, reader leases, and garbage collection. The application owns its + checkpoint representation within the supplied prefix. + +## Execution and ownership + +Each opaque engine handle owns its mutable state. It may move between threads, +and calls on that handle are exclusive. Progress is returned together as +`ApplicationEngineProgress`; the shared execution boundary checks it against the +successful transition. The payload-bound getter takes no instance and returns a +stable value for the linked engine. Zero is a valid bound. + +Expected validation rejection leaves state unchanged. An execution, validation, +or output-drain failure discards the engine; the host owns process termination +and distinguishes terminal faults from retryable operational failures. +Exceptions must not cross the ABI. The header defines the lifetimes of borrowed +output and diagnostic buffers; the adapter copies them before reuse. + +Fee fields carry base-129/128 exponents. The shared max-fee comparison operates +in log space; an application checking balances or charging fees uses a linear +amount. The reference conversion lives in +[`sequencer-core/src/fee.rs`](../../sequencer-core/src/fee.rs). Native and +canonical execution must agree on that conversion, since different amounts can +change rejection decisions and resulting balances. + +The same implementation may be compiled for native execution and the canonical +machine. This does not establish equivalent behavior across targets: the +application must preserve deterministic state and output bytes, including the +checkpoint's canonical comparison file. Reference wallet ABI tests exercise +the host integration; they do not establish private DEX conformance or +equivalence between native and machine execution. diff --git a/docs/review/register.md b/docs/review/register.md index 88fa030f..c30330ae 100644 --- a/docs/review/register.md +++ b/docs/review/register.md @@ -208,6 +208,13 @@ Open maintainer decisions: seed test digests 5,000 directs over a 7,200-block jump in one turn). - **Track 6 with Bart**: the hardlink-suitability dispute and the changed-era bootstrap contract (see the tracks doc). +- **C engine follow-up boundary** (2026-09-11): agree output storage and + checkpoint layout with Bart before replacing the drain protocol with output + arrays or the path callback with a static suffix. ABI version negotiation, + checked-in bindings, and linker policy also need concrete integration + requirements. Keep these separate from the port and the agreed contract + reductions; the private DEX engine and scheduler remain unavailable for + conformance testing. ## Owed tests @@ -271,6 +278,12 @@ Statuses swept 2026-08-22 and updated through 2026-09-04. (unit-level provider mocks instead; e2e validates only the passing path) and fsync/power-loss WAL-rewind injection (out of scope — state-construction variants cover the detectable halves). +- **External-engine conformance** (2026-09-11): a reusable runner needs + application-supplied genesis and meaningful accepted/rejected inputs; compare + canonical comparison files rather than byte-identical recovery dumps. + Native repeatability alone does not establish native/canonical-machine + equivalence. Publish fee-conversion data and vectors for the C++ integration + (finding 8), and add targeted devnet E2E coverage through the C host in CI. ## Settled decisions @@ -342,6 +355,24 @@ Each entry: the decision, its reason, and where the reasoning now lives. directory of state files. GC racing a download is an ordinary supported operation; relying on open-file unlink behavior is not the general dump lifetime contract → snapshot lifecycle, leases. +- **C bridge contract reductions** (2026-09-11): the linked engine reports its + stable payload bound at runtime, including a valid zero bound; C progress is + one count/clock record. This removes duplicated build configuration and + presents the protocol pair together without introducing another state owner. + Checkpoint artifacts are self-contained beneath their supplied prefix and + require only ordinary filesystem deletion, so the sequencer recursively + removes its enclosing dump directory without an application deletion hook. + Reader leases, SQLite-first deletion, and restored-engine independence remain + required → [C binding guide](../protocol/c-application-binding.md) and + [Application checkpoint contract](../protocol/application-contract.md#6-checkpoint-lifecycle). +- **C host failure boundaries** (2026-09-11): snapshot-path callback panics + enter the host's terminal-abort boundary; a failed HTTP task alone must not + leave sequencing active after an application invariant violation. Missing or + malformed genesis dumps and absent required options are terminal bootstrap + failures; operational I/O remains retryable. Genesis stays lazy: completed setup may + succeed after its original source has been deleted. A pre-command mandatory + state-file check would violate that lifecycle contract → snapshot handlers, + command error classification, and genesis harness tests. - **Execution-offset continuity has one enforcement point** (2026-09-07): the SQLite trigger rejects a noncanonical offset inside the physical-row transaction. The duplicate Rust loop was removed; rollback, invalidation, @@ -668,3 +699,4 @@ for `2026-06-10-correctness-review.md`, `2026-06-10-simplification.md`, | 2026-09-03 | Branch stock-take of the authority-boundary PR: first-hand reads, then a read-only fleet of seven subsystem lenses and five premise challengers, then three refuters over the eighteen highest-ranked proposals | Proportionate overall, with three residue pockets; every proposal recorded, the jury-refuted ones listed above | [`2026-09-03-branch-stocktake.md`](2026-09-03-branch-stocktake.md), the ledger of the current branch, with its "Landed" section | | 2026-09-07 | PR #28 premise review and maintainer-approved simplification | Ordered recovery replaces the phase driver; diagnosed terminal runtime faults abort immediately; ordinary shutdown and snapshot leases remain; reader drain race and duplicate offset check fixed | Current ADR and recovery design; finding 33 and settled decisions above. Validation: 692 host tests, seven targeted restart/outage E2Es, workspace check, strict Clippy, formatting, and admission TLC passed. The broader stale-batch recovery E2E reached its watchdog comparison but was blocked by the host Lua emulator 0.21 loading the pinned 0.20 image (archive version mismatch); no protocol pin was changed. | | 2026-09-09 | Application, inclusion lane, and public DEX integration branch | Native progress ownership, typed validation failures, mutable independent checkpoints, optional canonical inspection, and lane bookkeeping simplified; ingress CORS and Lua 5.4 parity restored. Reference C bridge port kept separate. | [Application/lane review](2026-09-09-application-lane-dex-review.md); current Application and snapshot contracts. Workspace check, strict Clippy, 697 host tests, and 62 watchdog tests passed; private DEX conformance remains unverified. | +| 2026-09-11 | Reference C bridge port and review boundary | Keep the current Application contract, runtime payload bound, paired progress, filesystem-owned checkpoint disposal, and host failure fixes together. Engine-dependent API refinements and external-engine conformance remain follow-ups. | Settled decisions and owed tests above; [C binding guide](../protocol/c-application-binding.md), Application contract, and snapshot lifecycle. | diff --git a/docs/snapshots/README.md b/docs/snapshots/README.md index fba2de05..48e8e7ed 100644 --- a/docs/snapshots/README.md +++ b/docs/snapshots/README.md @@ -8,7 +8,7 @@ operator's watchdog (`/finalized_state`) and indexers (`/latest_snapshot`). Two documents, split by concern: - **[`format.md`](format.md)** — the on-disk *format*: the `Application` dump - trait (`from_dump` / `create_dump` / `delete_dump` / `state_file_in_dump`) and + trait (`from_dump` / `create_dump` / `state_file_in_dump`) and the toy wallet's SSZ wire encoding. What a dump *is*. - **[`lifecycle.md`](lifecycle.md)** — the *lifecycle* and its rationale: take at diff --git a/docs/snapshots/format.md b/docs/snapshots/format.md index f07cc935..cdf04eb9 100644 --- a/docs/snapshots/format.md +++ b/docs/snapshots/format.md @@ -9,7 +9,7 @@ This document covers two things: 1. The trait shape that any `Application` implementation must satisfy to participate in snapshot lifecycle (`from_dump`, `create_dump`, - `delete_dump`, `state_file_in_dump`). + `state_file_in_dump`). 2. The wire format the toy wallet uses to encode its canonical state into the dump's state file. Checkpoint ownership and durability are defined by the [Application contract](../protocol/application-contract.md#6-checkpoint-lifecycle). @@ -27,7 +27,6 @@ trait Application: Send + Sized { fn from_dump(prefix: &Path) -> Result; fn create_dump(&mut self, prefix: &Path) -> Result<(), AppError>; - fn delete_dump(prefix: &Path) -> Result<(), AppError>; fn state_file_in_dump(prefix: &Path) -> PathBuf; } ``` @@ -35,7 +34,9 @@ trait Application: Send + Sized { Contract: - `prefix` is an opaque app-owned path, which may be a file or directory. - The sequencer owns the enclosing dump directory and its `info.toml`. + All checkpoint-owned artifacts reside at or below it. The sequencer owns the + enclosing dump directory and its `info.toml`, and disposes of the checkpoint + by removing that directory recursively. No other resource cleanup is needed. - `create_dump` creates the absent `prefix` and makes the complete checkpoint durable before returning. It may replace backing resources but preserves logical state. Later execution cannot alter a checkpoint. Restored engines @@ -108,7 +109,7 @@ and its canonical state coincide; one write per `create_dump`. - `nonce` (`u32`) - `executed_input_count` (`u64`) - `last_executed_safe_block` (`u64`) — the app's safe-block clock - (`Application::last_executed_safe_block`): max block carried by any + (reported by `Application::progress`): max block carried by any executed input. Recovery reads it as `A`, the safe block this state reflects, so it must live in the canonical state bytes (both the bare-metal and canonical-machine sides advance it identically). diff --git a/docs/snapshots/lifecycle.md b/docs/snapshots/lifecycle.md index 8ba9036d..45654e48 100644 --- a/docs/snapshots/lifecycle.md +++ b/docs/snapshots/lifecycle.md @@ -105,7 +105,7 @@ Three SQLite tables back it (`storage/migrations/0001_schema.sql`): ```text dumps// state app-owned file or directory — the prefix handed to - Application::{create_dump, from_dump, delete_dump} + Application::{create_dump, from_dump} (opaque to the sequencer; see format.md) info.toml sequencer-owned checkpoint metadata: format_version, next_batch_nonce (N), l2_tx_index, @@ -364,8 +364,9 @@ gives the create/delete orderings: the reference row is written. - **SQLite delete → file delete** (SQLite-first): `gc_unreferenced_dumps` deletes the rows inside one `write` tx and *returns* the prefixes; the lane's - `run_gc` then `A::delete_dump`s them after commit. If a directory delete - fails, an orphan file is acceptable — the next startup's `sweep_orphan_dumps` + `run_gc` then removes the enclosing dump directories recursively after + commit. If a directory delete fails, an orphan file is acceptable — the next + startup's `sweep_orphan_dumps` catches it. The reverse ordering would leave a dangling row. This is why `storage/snapshot_dumps.rs` is SQLite-only and the FS half lives in diff --git a/examples/app-core/src/application/wallet.rs b/examples/app-core/src/application/wallet.rs index 8e08c4e4..db7258f2 100644 --- a/examples/app-core/src/application/wallet.rs +++ b/examples/app-core/src/application/wallet.rs @@ -219,7 +219,9 @@ impl Default for WalletApp { } impl Application for WalletApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = WALLET_MAX_METHOD_PAYLOAD_BYTES; + fn max_method_payload_bytes() -> usize { + WALLET_MAX_METHOD_PAYLOAD_BYTES + } fn validate_user_op( &self, @@ -383,11 +385,6 @@ impl Application for WalletApp { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } @@ -869,7 +866,7 @@ mod tests { let restored = WalletApp::from_dump(&prefix).expect("load dump"); - WalletApp::delete_dump(&prefix).expect("cleanup dump"); + std::fs::remove_dir_all(&prefix).expect("cleanup dump"); assert_eq!( restored.config.erc20_portal_address, @@ -919,7 +916,7 @@ mod tests { std::fs::read(WalletApp::state_file_in_dump(&prefix)).unwrap(), before ); - WalletApp::delete_dump(&prefix).unwrap(); + std::fs::remove_dir_all(&prefix).unwrap(); execute_valid_user_op(&mut first, &user_op, 13).unwrap(); assert_eq!(first.executed_input_count().get(), 2); } @@ -994,8 +991,8 @@ mod tests { let bytes_a = std::fs::read(WalletApp::state_file_in_dump(&prefix_a)).expect("read a"); let bytes_b = std::fs::read(WalletApp::state_file_in_dump(&prefix_b)).expect("read b"); - WalletApp::delete_dump(&prefix_a).expect("cleanup a"); - WalletApp::delete_dump(&prefix_b).expect("cleanup b"); + std::fs::remove_dir_all(&prefix_a).expect("cleanup a"); + std::fs::remove_dir_all(&prefix_b).expect("cleanup b"); assert_eq!( bytes_a, bytes_b, @@ -1011,7 +1008,7 @@ mod tests { .expect("write malformed"); let err = WalletApp::from_dump(&prefix).expect_err("invalid bytes should fail"); - WalletApp::delete_dump(&prefix).expect("cleanup"); + std::fs::remove_dir_all(&prefix).expect("cleanup"); match err { AppError::Internal { reason } => assert!( diff --git a/examples/c-app-engine/README.md b/examples/c-app-engine/README.md index 705299c9..254ec262 100644 --- a/examples/c-app-engine/README.md +++ b/examples/c-app-engine/README.md @@ -4,6 +4,8 @@ [application-engine C ABI](include/application-engine.h). The sequencer owns one engine at a time. A handle can move between threads; calls on it never overlap. The bridge is `Send`, without `Clone` or `Sync`. +The [binding guide](../../docs/protocol/c-application-binding.md) maps the ABI +to the Application and scheduler contracts. The native engine owns application state and its execution count/safe-block clock. Successful execution advances that progress, including counted no-ops; @@ -21,6 +23,9 @@ A dump prefix may be a file or a directory. Opening it produces independently mutable state without changing the source; checkpoint creation may mutate the engine's backing arrangement, while preserving logical state and progress. Successful checkpoints are durable and immutable under subsequent execution. +All checkpoint artifacts reside at or below the prefix; the sequencer disposes +of them with ordinary recursive filesystem deletion. Restored engines remain +usable after source deletion. `state_file_in_dump` names the one canonical comparison file, which can be the whole dump or a projection alongside richer restoration artifacts. `EngineApp` does not implement the optional Rust `CanonicalState` inspection trait. @@ -47,8 +52,9 @@ The host adds `--state-file` through its own parser and passes the parsed comman to `sequencer::run_command`. This shares `run_main`'s command lifecycle and exit policy. Both take a lazy `FnOnce() -> Result` genesis factory; infallible Rust constructors therefore use -`run_main(|| Ok(WalletApp::new(WalletConfig::default())))`. A missing genesis file -returns an ordinary application-bootstrap I/O error, while a caught factory +`run_main(|| Ok(WalletApp::new(WalletConfig::default())))`. An absent required +genesis path or a missing/corrupt genesis dump is a terminal bootstrap error; +operational I/O failures retain their retryable classification. A caught factory panic follows the shared terminal-error policy. ## External engine @@ -58,14 +64,15 @@ Build the application's static archive and use the corresponding header: ```sh APPLICATION_ENGINE_LIB=/absolute/path/libengine.a \ APPLICATION_ENGINE_HEADER=/absolute/path/application-engine.h \ -APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT=53 \ cargo build -p c-app-sequencer ``` -The payload limit must match the engine's own build. Bindgen generates the Rust -records from that header, so a build needs libclang. The engine also supplies its -own genesis tool; configuration does not cross this ABI. With no external -archive configured, the generic binary reports that no engine was linked, and +The linked engine reports its stable payload bound through +`application_engine_max_method_payload_bytes()`; zero permits only empty method +payloads. Bindgen generates the Rust records from the header, so a build needs +libclang. The engine also supplies its own genesis tool; configuration does not +cross this ABI. With no external archive configured, the generic binary reports +that no engine was linked, and `c-wallet-sequencer` supplies the reference implementation through Cargo. The conformance suite compares native and ABI execution over mixed inputs, diff --git a/examples/c-app-engine/build.rs b/examples/c-app-engine/build.rs index b7b9e677..1307dcbf 100644 --- a/examples/c-app-engine/build.rs +++ b/examples/c-app-engine/build.rs @@ -3,27 +3,17 @@ //! 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 +//! The two environment variables are the whole application-specific binding, documented in //! `README.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; - /// 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) { +fn generate_bindings(header: &Path) { let out_path = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")).join("bindings.rs"); let bindings = bindgen::Builder::default() .header( @@ -34,9 +24,6 @@ fn generate_bindings(header: &Path, method_payload_limit: u32) { // 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 @@ -98,10 +85,8 @@ fn link_application_archive(engine_lib: &Path) { /// 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) { +/// The archive and header must come from the same engine build. +fn external_engine(engine_lib: &str) -> PathBuf { link_application_archive(Path::new(engine_lib)); let header = PathBuf::from(env::var("APPLICATION_ENGINE_HEADER").expect( @@ -113,38 +98,26 @@ fn external_engine(engine_lib: &str) -> (PathBuf, u32) { 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}") - }); - (header, limit) + header } 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") { + let header = 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(_) => { assert!( - env::var_os("APPLICATION_ENGINE_HEADER").is_none() - && env::var_os("APPLICATION_ENGINE_METHOD_PAYLOAD_LIMIT").is_none(), + env::var_os("APPLICATION_ENGINE_HEADER").is_none(), "external engine settings require APPLICATION_ENGINE_LIB" ); - ( - manifest_dir.join("include").join("application-engine.h"), - REFERENCE_ENGINE_METHOD_PAYLOAD_LIMIT, - ) + manifest_dir.join("include").join("application-engine.h") } }; println!("cargo::rerun-if-changed={}", header.display()); - generate_bindings(&header, method_payload_limit); + generate_bindings(&header); } diff --git a/examples/c-app-engine/include/application-engine.h b/examples/c-app-engine/include/application-engine.h index 6adeb81f..15562c62 100644 --- a/examples/c-app-engine/include/application-engine.h +++ b/examples/c-app-engine/include/application-engine.h @@ -21,9 +21,9 @@ /// 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 +/// The Rust host generates its declarations from this header with bindgen. The supplied header +/// must match the linked archive; binding generation does not verify that pairing. +/// 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. @@ -44,8 +44,17 @@ /// /// No exception may cross. Fallible entry points report errors through status codes; a fatal /// validation or execution error defines no successor and the caller must discard the instance. -/// Lifecycle statuses distinguish operational I/O from missing or malformed dump artifacts. -/// Only accept or reject is consensus visible; rejection diagnostics are descriptive. +/// IO_ERROR is legal from every status-returning entry point. In validation, execution, and +/// output draining it is fatal for the instance; the host may retry with a fresh instance after +/// an operational failure. NOT_FOUND and INVALID_DUMP describe lifecycle failures only. +/// Validation acceptance, state transitions, progress, and outputs must agree with the canonical +/// application. Rejection diagnostics and error messages need not be identical across builds. +/// +/// Fees are uint16_t exponents with base 129/128, denominated in the fee token's smallest unit. +/// The conversion is defined by sequencer-core/src/fee.rs and its build.rs-generated table: +/// integer fixed-point arithmetic with 64 fractional bits, including its rounding and exponent +/// bound. Native and canonical implementations must agree on this conversion; the max-fee guard +/// compares exponents, while balance validation and execution use the converted amount. /// /// 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 @@ -79,34 +88,15 @@ /// 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 The largest user-op method payload, in bytes. +/// @details Pure, infallible, and constant for the linked engine implementation, independent of +/// any loaded deployment. Zero permits only empty method payloads. The host uses this value for +/// ingress admission and batch sizing; it does not bound direct inputs from L1. +APPLICATION_ENGINE_API uint64_t application_engine_max_method_payload_bytes(void) APPLICATION_ENGINE_NOEXCEPT; /// @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 @@ -147,7 +137,7 @@ typedef enum ApplicationEngineStatus { /// 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. + uint16_t max_fee; ///< Highest frame fee exponent the sender accepts, base 129/128. ApplicationEngineByteSpan data; ///< Method payload, opaque here and parsed by the engine. } ApplicationEngineUserOp; @@ -156,13 +146,13 @@ typedef struct ApplicationEngineUserOp { /// 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. + uint16_t fee; ///< The charged frame fee exponent, base 129/128. 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. +/// @details Its sender is authenticated by the chain rather than recovered from a signature. +/// Its payload is still untrusted application input and has no method-payload bound at this ABI. typedef struct ApplicationEngineDirectInput { ApplicationEngineEthereumAddress sender; ///< The L1 authenticated sender. uint64_t block_number; ///< The L1 inclusion block number. @@ -248,6 +238,12 @@ typedef struct ApplicationEngineOutput { /// @brief The engine instance behind the handle, opaque to every caller. typedef struct ApplicationEngine ApplicationEngine; +/// @brief Progress embedded in the engine's logical state and every checkpoint. +typedef struct ApplicationEngineProgress { + uint64_t executed_input_count; ///< Next input offset; included no-ops count, rejections do not. + uint64_t last_executed_safe_block; ///< Maximum block carried by any executed input. +} ApplicationEngineProgress; + /// @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 @@ -289,12 +285,12 @@ APPLICATION_ENGINE_API void application_engine_destroy(ApplicationEngine *engine /// @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 current_fee The frame fee exponent, base 129/128. /// @param out_invalid Why the op was refused, written whole and only on INVALID. -/// @returns OK, INVALID with diagnostics, or INTERNAL_ERROR. +/// @returns OK, INVALID with diagnostics, IO_ERROR, or INTERNAL_ERROR. /// @details A rejection reports itself through out_invalid and leaves the last error message -/// empty, 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 +/// empty; IO_ERROR and INTERNAL_ERROR carry an error message. The max-fee guard belongs to the +/// caller and is never checked here, so APPLICATION_ENGINE_INVALID_MAX_FEE never comes back. Queued /// 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, @@ -305,10 +301,10 @@ APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_validate_user_ /// @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). +/// @returns OK, IO_ERROR, or INTERNAL_ERROR. An error requires discarding the instance. /// @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. +/// the resulting state, progress, and outputs cross the seam; the application's business-failure +/// diagnostics are not returned separately. /// /// 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 @@ -321,7 +317,7 @@ APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_execute_valid_ /// @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). +/// @returns OK, IO_ERROR, or INTERNAL_ERROR. An error requires discarding the instance. /// @details An input the engine rejects is a counted no-op and still reports OK, the same way a /// rejected user op does. Outputs behave as they do for a user op. APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_execute_direct_input(ApplicationEngine *engine, @@ -330,7 +326,7 @@ APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_execute_direct /// @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. +/// @returns OK with an output written, IO_ERROR, 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 @@ -340,47 +336,39 @@ APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_execute_direct 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. +/// @brief Read the engine's current progress without changing state. /// @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; +/// @param out_progress Writable record, written whole before returning. +/// @details Count zero implies clock zero. Every successful execution advances the count once +/// with checked arithmetic and takes max(previous clock, input block). Both fields survive dumps. +APPLICATION_ENGINE_API void application_engine_progress(const ApplicationEngine *engine, + ApplicationEngineProgress *out_progress) 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. +/// @returns OK, NOT_FOUND for a missing required path, IO_ERROR for other filesystem failures, +/// 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. +/// synchronized before returning. Failed creation may leave artifacts beneath prefix for the +/// host to remove with the unreferenced checkpoint directory. /// /// The dump contains the current state. The engine may change backing files or reopen internal /// handles while checkpointing, but application state and progress stay unchanged. Subsequent /// execution must leave the completed dump immutable. +/// All checkpoint-owned artifacts reside at or beneath prefix as ordinary files/directories. +/// The host discards a checkpoint by recursive filesystem deletion, with no engine callback. +/// Deletion must leave other checkpoints and independently restored engines usable. Filesystem +/// CoW sharing is allowed when writes remain isolated. +/// +/// The file named by application_engine_state_file_in_dump must byte-equal the canonical build's +/// deterministic inspection or designated state-drive representation for the same logical state. +/// The bridge does not verify this cross-build equivalence. APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_create_dump(ApplicationEngine *engine, const char *prefix) APPLICATION_ENGINE_NOEXCEPT; -/// @brief Delete a previously created dump. -/// @param prefix The dump to remove. -/// @returns OK, NOT_FOUND when the dump is absent, IO_ERROR for other filesystem failures, -/// or INTERNAL_ERROR. -/// @details An engine still holding this dump open keeps running, its mapping outlives the name. -/// The sequencer removes the database reference before deleting the artifact. Deletion must not -/// affect other dumps or independently loaded engines. -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. @@ -388,6 +376,8 @@ APPLICATION_ENGINE_API ApplicationEngineStatus application_engine_delete_dump( /// 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. +/// Its bytes must match the independent canonical build's deterministic comparison representation +/// for the same logical state, as required by application_engine_create_dump. /// /// 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 diff --git a/examples/c-app-engine/src/lib.rs b/examples/c-app-engine/src/lib.rs index 24a4d223..2ec2f35a 100644 --- a/examples/c-app-engine/src/lib.rs +++ b/examples/c-app-engine/src/lib.rs @@ -137,8 +137,10 @@ impl EngineApp { } impl Application for EngineApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = - sys::APPLICATION_ENGINE_MAX_METHOD_PAYLOAD_BYTES as usize; + fn max_method_payload_bytes() -> usize { + usize::try_from(unsafe { sys::application_engine_max_method_payload_bytes() }) + .expect("engine payload bound exceeds usize") + } fn validate_user_op( &self, @@ -240,10 +242,16 @@ impl Application for EngineApp { } fn progress(&self) -> ApplicationProgress { - let count = unsafe { sys::application_engine_executed_input_count(self.engine) }; - let clock = unsafe { sys::application_engine_last_executed_safe_block(self.engine) }; - ApplicationProgress::try_new(ExecutedInputCount::new(count), clock) - .expect("engine returned incoherent application progress") + let mut progress = sys::ApplicationEngineProgress { + executed_input_count: 0, + last_executed_safe_block: 0, + }; + unsafe { sys::application_engine_progress(self.engine, &mut progress) }; + ApplicationProgress::try_new( + ExecutedInputCount::new(progress.executed_input_count), + progress.last_executed_safe_block, + ) + .expect("engine returned incoherent application progress") } fn from_dump(prefix: &Path) -> Result { @@ -268,14 +276,6 @@ impl Application for EngineApp { ) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - let prefix = path_to_cstring(prefix); - check( - unsafe { sys::application_engine_delete_dump(prefix.as_ptr()) }, - "delete_dump", - ) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { let prefix = path_to_cstring(prefix); let state_file = unsafe { sys::application_engine_state_file_in_dump(prefix.as_ptr()) }; diff --git a/examples/c-app-engine/src/tests.rs b/examples/c-app-engine/src/tests.rs index c656da7c..194743f3 100644 --- a/examples/c-app-engine/src/tests.rs +++ b/examples/c-app-engine/src/tests.rs @@ -3,7 +3,7 @@ use super::*; -// Only the drain seam is needed here; wallet integration tests cover execution and dumps. +// This fixture covers output borrowing and a zero payload bound; wallet tests cover execution/dumps. // Every non-empty output borrows the same allocation, overwritten by the next drain. #[derive(Default)] struct OutputEngine { @@ -11,6 +11,16 @@ struct OutputEngine { payload: [u8; 4], } +#[unsafe(no_mangle)] +extern "C" fn application_engine_max_method_payload_bytes() -> u64 { + 0 +} + +#[test] +fn empty_method_payload_bound_comes_from_the_engine() { + assert_eq!(EngineApp::max_method_payload_bytes(), 0); +} + #[unsafe(no_mangle)] unsafe extern "C" fn application_engine_drain_output( engine: *mut sys::ApplicationEngine, diff --git a/examples/c-app-sequencer/src/main.rs b/examples/c-app-sequencer/src/main.rs index ce2a970e..cd391b7d 100644 --- a/examples/c-app-sequencer/src/main.rs +++ b/examples/c-app-sequencer/src/main.rs @@ -17,8 +17,7 @@ async fn main() -> ExitCode { 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." + APPLICATION_ENGINE_LIB and APPLICATION_ENGINE_HEADER, then build again." ); ExitCode::FAILURE } diff --git a/examples/c-wallet-engine/src/lib.rs b/examples/c-wallet-engine/src/lib.rs index 56af2367..05d6b54b 100644 --- a/examples/c-wallet-engine/src/lib.rs +++ b/examples/c-wallet-engine/src/lib.rs @@ -22,14 +22,10 @@ use sequencer_core::application::{ 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" -); +#[unsafe(no_mangle)] +pub extern "C" fn application_engine_max_method_payload_bytes() -> u64 { + u64::try_from(WalletApp::max_method_payload_bytes()).expect("wallet payload bound exceeds u64") +} thread_local! { /// The last failure's message, and the buffer `state_file_in_dump` answers out of. @@ -367,28 +363,19 @@ fn span_of(payload: &[u8]) -> sys::ApplicationEngineByteSpan { } /// # Safety -/// `engine` is a live handle. +/// `engine` is a live handle and `out_progress` is writable. #[unsafe(no_mangle)] -pub unsafe extern "C" fn application_engine_last_executed_safe_block( +pub unsafe extern "C" fn application_engine_progress( engine: *const ApplicationEngine, -) -> u64 { - unsafe { engine_ref(engine) } - .app - .progress() - .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 - .progress() - .executed_input_count() - .get() + out_progress: *mut sys::ApplicationEngineProgress, +) { + let progress = unsafe { engine_ref(engine) }.app.progress(); + unsafe { + *out_progress = sys::ApplicationEngineProgress { + executed_input_count: progress.executed_input_count().get(), + last_executed_safe_block: progress.last_executed_safe_block(), + } + }; } /// # Safety @@ -407,20 +394,6 @@ pub unsafe extern "C" fn application_engine_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)] diff --git a/examples/c-wallet-engine/tests/conformance.rs b/examples/c-wallet-engine/tests/conformance.rs index 89d9ccd3..2be1d7cd 100644 --- a/examples/c-wallet-engine/tests/conformance.rs +++ b/examples/c-wallet-engine/tests/conformance.rs @@ -44,6 +44,11 @@ fn deposit(config: WalletConfig, recipient: Address, amount: u64, block: u64) -> #[test] fn abi_mixed_history_matches_native_outputs_progress_and_dump() { let (dir, mut bridge, mut native, config) = fixture(); + assert_eq!( + EngineApp::max_method_payload_bytes(), + WalletApp::max_method_payload_bytes() + ); + assert_eq!(bridge.progress(), native.progress()); let sender = Address::repeat_byte(0x11); let recipient = Address::repeat_byte(0x22); for direct in [ @@ -175,7 +180,7 @@ fn abi_restored_instances_and_checkpoints_are_independent() { std::fs::read(EngineApp::state_file_in_dump(&frozen)).unwrap(), frozen_bytes ); - EngineApp::delete_dump(&source).unwrap(); + std::fs::remove_dir_all(&source).unwrap(); execute_direct_input(&mut second, &input).unwrap(); assert_eq!( state_bytes(&mut second, &dir.path().join("second")), diff --git a/sequencer-core/src/application/mod.rs b/sequencer-core/src/application/mod.rs index 05b2499f..65af049a 100644 --- a/sequencer-core/src/application/mod.rs +++ b/sequencer-core/src/application/mod.rs @@ -152,7 +152,9 @@ impl fmt::Display for InvalidReason { /// Deterministic application state with exclusive ownership and thread transfer. pub trait Application: Send + Sized { - const MAX_METHOD_PAYLOAD_BYTES: usize; + /// Maximum user-op method payload size, stable for this implementation. + /// Zero permits only empty method payloads. + fn max_method_payload_bytes() -> usize; /// Pure validation predicate over current app state: nonce match /// (user replay protection) and fee-balance coverage. Must not @@ -216,7 +218,10 @@ pub trait Application: Send + Sized { /// be a file or directory. A subsequent [`Application::from_dump`] must /// rehydrate equivalent logical state, including progress. Creating the /// dump must preserve the live instance's logical state; later execution - /// of that instance must not change the dump. + /// of that instance must not change the dump. All checkpoint-owned artifacts + /// must reside at or beneath `prefix`; discarding them uses ordinary + /// filesystem deletion and requires no application-specific cleanup. + /// Deletion must leave other checkpoints and restored instances usable. /// /// **Durability**: when this method returns `Ok`, the dump on disk /// must survive an immediate kernel crash. Concretely, the impl @@ -235,9 +240,6 @@ pub trait Application: Send + Sized { /// state file may be the same file when their representations coincide. fn create_dump(&mut self, prefix: &Path) -> Result<(), AppError>; - /// Delete a previously-created dump at `prefix`. - fn delete_dump(prefix: &Path) -> Result<(), AppError>; - /// Path of the canonical state file in a dump at `prefix` (possibly /// `prefix` itself). The returned path must point at a single file. It /// is a pure function of `prefix`: callers may invoke it without @@ -374,7 +376,9 @@ mod tests { } impl Application for ProgressApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = 0; + fn max_method_payload_bytes() -> usize { + 0 + } fn validate_user_op( &self, @@ -423,9 +427,6 @@ mod tests { fn create_dump(&mut self, _prefix: &Path) -> Result<(), AppError> { unreachable!("not used") } - fn delete_dump(_prefix: &Path) -> Result<(), AppError> { - unreachable!("not used") - } fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } diff --git a/sequencer-core/src/scheduler/fold.rs b/sequencer-core/src/scheduler/fold.rs index eff962ed..de340efc 100644 --- a/sequencer-core/src/scheduler/fold.rs +++ b/sequencer-core/src/scheduler/fold.rs @@ -187,7 +187,9 @@ mod tests { } impl Application for FoldApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = 1 + 32 + 20; + fn max_method_payload_bytes() -> usize { + 1 + 32 + 20 + } fn validate_user_op( &self, @@ -229,9 +231,6 @@ mod tests { fn create_dump(&mut self, _prefix: &std::path::Path) -> Result<(), AppError> { unimplemented!("FoldApp does not participate in snapshot lifecycle") } - fn delete_dump(_prefix: &std::path::Path) -> Result<(), AppError> { - unimplemented!("FoldApp does not participate in snapshot lifecycle") - } fn state_file_in_dump(_prefix: &std::path::Path) -> std::path::PathBuf { unimplemented!("FoldApp does not participate in snapshot lifecycle") } diff --git a/sequencer-core/src/scheduler/mod.rs b/sequencer-core/src/scheduler/mod.rs index f19b5288..0caa18bb 100644 --- a/sequencer-core/src/scheduler/mod.rs +++ b/sequencer-core/src/scheduler/mod.rs @@ -481,7 +481,9 @@ mod tests { // Mirrors the wallet app's method-payload cap (selector + amount + // address). A local literal keeps sequencer-core free of an app-core // dependency (which would invert the crate graph). - const MAX_METHOD_PAYLOAD_BYTES: usize = 1 + 32 + 20; + fn max_method_payload_bytes() -> usize { + 1 + 32 + 20 + } fn validate_user_op( &self, @@ -580,10 +582,6 @@ mod tests { unimplemented!("RecordingApp does not participate in snapshot lifecycle") } - fn delete_dump(_prefix: &std::path::Path) -> Result<(), crate::application::AppError> { - unimplemented!("RecordingApp does not participate in snapshot lifecycle") - } - fn state_file_in_dump(_prefix: &std::path::Path) -> std::path::PathBuf { unimplemented!("RecordingApp does not participate in snapshot lifecycle") } diff --git a/sequencer/src/commands/run/startup_hygiene.rs b/sequencer/src/commands/run/startup_hygiene.rs index 0d5256ad..93c47f69 100644 --- a/sequencer/src/commands/run/startup_hygiene.rs +++ b/sequencer/src/commands/run/startup_hygiene.rs @@ -31,19 +31,18 @@ use crate::commands::error::CommandError; use crate::ingress::inclusion_lane::dump_info::{self, delete_dump_dir}; -use sequencer_core::application::Application; /// Run the five-step repair pass (see the module doc for the steps and /// their ordering). -pub(super) fn run_snapshot_hygiene( +pub(super) fn run_snapshot_hygiene( storage: &mut crate::storage::Storage, dumps_dir: &std::path::Path, ) -> Result<(), CommandError> { storage.reset_dump_leases()?; require_finalized_snapshot(storage)?; restamp_finalized_promotion(storage)?; - let gc_removed = snapshot_gc_at_startup::(storage)?; - let sweep_removed = sweep_orphan_dumps::(storage, dumps_dir)?; + let gc_removed = snapshot_gc_at_startup(storage)?; + let sweep_removed = sweep_orphan_dumps(storage, dumps_dir)?; tracing::debug!( gc_removed, sweep_removed, @@ -82,12 +81,10 @@ fn restamp_finalized_promotion(storage: &mut crate::storage::Storage) -> Result< /// finalized, no leases). The companion `sweep_orphan_dumps` then /// catches anything on disk that this leaves behind, plus /// crash-during-create_dump orphans the SQLite layer never saw. -fn snapshot_gc_at_startup( - storage: &mut crate::storage::Storage, -) -> Result { +fn snapshot_gc_at_startup(storage: &mut crate::storage::Storage) -> Result { let removed = storage.gc_unreferenced_dumps()?; for row in &removed { - if let Err(err) = delete_dump_dir::(&row.prefix) { + if let Err(err) = delete_dump_dir(&row.prefix) { tracing::warn!( error = %err, prefix = ?row.prefix, @@ -111,7 +108,7 @@ fn snapshot_gc_at_startup( /// continue (the next startup retries). The post-`require_finalized_snapshot` /// ordering matters: the genesis dump's dir is in /// `list_dump_rows` by the time this runs, so we never delete it. -fn sweep_orphan_dumps( +fn sweep_orphan_dumps( storage: &mut crate::storage::Storage, dumps_dir: &std::path::Path, ) -> Result { @@ -127,7 +124,7 @@ fn sweep_orphan_dumps( if known.contains(&path) { continue; } - match delete_dump_dir::(&path) { + match delete_dump_dir(&path) { Ok(()) => removed += 1, Err(err) => { tracing::warn!( @@ -144,7 +141,7 @@ fn sweep_orphan_dumps( #[cfg(test)] mod tests { use super::*; - use crate::commands::test_support::{SweepTestApp, create_structured_dump}; + use crate::commands::test_support::create_structured_dump; use crate::storage::Storage; use crate::storage::test_helpers::temp_db; @@ -212,7 +209,7 @@ mod tests { create_structured_dump(&orphan_a); std::fs::create_dir(&orphan_b).expect("orphan b dir"); - let removed = sweep_orphan_dumps::(&mut storage, dumps_dir.path()).unwrap(); + let removed = sweep_orphan_dumps(&mut storage, dumps_dir.path()).unwrap(); assert_eq!(removed, 2); assert!(tracked.exists(), "tracked dump must survive"); assert!(!orphan_a.exists()); @@ -225,7 +222,7 @@ mod tests { let mut storage = Storage::open(db.path.as_str()).expect("open"); let dumps_dir = tempfile::tempdir().expect("dumps dir"); - let removed = sweep_orphan_dumps::(&mut storage, dumps_dir.path()).unwrap(); + let removed = sweep_orphan_dumps(&mut storage, dumps_dir.path()).unwrap(); assert_eq!(removed, 0); } @@ -251,7 +248,7 @@ mod tests { // `superseded`'s row is now unreferenced (replaced by // finalized's promotion), but the directory is still on disk. - let removed = snapshot_gc_at_startup::(&mut storage).unwrap(); + let removed = snapshot_gc_at_startup(&mut storage).unwrap(); assert_eq!(removed, 1); assert!(!superseded.exists(), "GC removed the superseded directory"); assert!(finalized.exists(), "current finalized survived"); diff --git a/sequencer/src/commands/run/workers.rs b/sequencer/src/commands/run/workers.rs index c73f9d32..a6d84d18 100644 --- a/sequencer/src/commands/run/workers.rs +++ b/sequencer/src/commands/run/workers.rs @@ -186,7 +186,7 @@ impl PreparedRuntime { // Authority-neutral snapshot repair before the boundary; the five // order-critical steps are documented in `startup_hygiene`. - super::startup_hygiene::run_snapshot_hygiene::(&mut storage, &dumps_dir)?; + super::startup_hygiene::run_snapshot_hygiene(&mut storage, &dumps_dir)?; // Prepare every remaining fallible or awaited dependency before the // authority boundary. Cancellation observes zero workers. @@ -239,7 +239,7 @@ impl PreparedRuntime { let lane_config = InclusionLaneConfig::new(l1_config.identity.batch_submitter_address, dumps_dir) .with_max_batch_open(run_config.max_batch_open()); - let api_config = ApiConfig::new(domain, A::MAX_METHOD_PAYLOAD_BYTES); + let api_config = ApiConfig::new(domain, A::max_method_payload_bytes()); let listener = tokio::net::TcpListener::bind(&run_config.http_addr).await?; let bound_addr = listener.local_addr()?; let snapshot_state = http::SnapshotState { @@ -681,7 +681,9 @@ mod tests { } impl Application for StartupProbeApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = 0; + fn max_method_payload_bytes() -> usize { + 0 + } fn validate_user_op( &self, @@ -733,13 +735,6 @@ mod tests { Ok(()) } - fn delete_dump( - prefix: &std::path::Path, - ) -> Result<(), sequencer_core::application::AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } - fn state_file_in_dump(prefix: &std::path::Path) -> std::path::PathBuf { prefix.join("state") } diff --git a/sequencer/src/commands/setup/fill.rs b/sequencer/src/commands/setup/fill.rs index e54b2491..5a4882db 100644 --- a/sequencer/src/commands/setup/fill.rs +++ b/sequencer/src/commands/setup/fill.rs @@ -234,7 +234,9 @@ mod tests { } impl Application for CountedSweepTestApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = 0; + fn max_method_payload_bytes() -> usize { + 0 + } fn validate_user_op( &self, @@ -285,10 +287,6 @@ mod tests { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - ::delete_dump(prefix) - } - fn state_file_in_dump(prefix: &Path) -> std::path::PathBuf { ::state_file_in_dump(prefix) } diff --git a/sequencer/src/commands/test_support.rs b/sequencer/src/commands/test_support.rs index c4fa3ee4..5ddc0d4b 100644 --- a/sequencer/src/commands/test_support.rs +++ b/sequencer/src/commands/test_support.rs @@ -17,8 +17,7 @@ use sequencer_core::l2_tx::ValidUserOp; use sequencer_core::user_op::UserOp; /// Application stub used in the sweep tests: `create_dump` makes -/// a directory with a marker file inside, `delete_dump` is -/// `remove_dir_all`. The actual marker content is irrelevant — +/// a directory with a marker file inside. The marker content is irrelevant — /// we only care about which directories exist post-sweep. #[derive(Clone, Default)] pub(crate) struct SweepTestApp { @@ -33,7 +32,9 @@ pub(crate) const SweepTestApp: SweepTestApp = SweepTestApp { }; impl Application for SweepTestApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = 0; + fn max_method_payload_bytes() -> usize { + 0 + } fn validate_user_op( &self, _sender: alloy_primitives::Address, @@ -68,10 +69,6 @@ impl Application for SweepTestApp { std::fs::write(prefix.join("state"), b"")?; Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } fn state_file_in_dump(prefix: &Path) -> std::path::PathBuf { prefix.join("state") } diff --git a/sequencer/src/http.rs b/sequencer/src/http.rs index eb84b5e7..9e7f5755 100644 --- a/sequencer/src/http.rs +++ b/sequencer/src/http.rs @@ -281,7 +281,7 @@ pub(crate) fn persistent_storage_error(mut error: &(dyn std::error::Error + 'sta pub struct ApiConfig { /// EIP-712 domain user-op signatures are verified against. pub domain: Eip712Domain, - /// The app's `MAX_METHOD_PAYLOAD_BYTES` bound on user-op payloads. + /// The app's `max_method_payload_bytes()` bound on user-op payloads. pub max_user_op_data_bytes: usize, pub max_body_bytes: usize, pub ws_max_subscribers: usize, diff --git a/sequencer/src/ingress/inclusion_lane/dump_info.rs b/sequencer/src/ingress/inclusion_lane/dump_info.rs index cf346004..e143cf9a 100644 --- a/sequencer/src/ingress/inclusion_lane/dump_info.rs +++ b/sequencer/src/ingress/inclusion_lane/dump_info.rs @@ -8,7 +8,7 @@ //! ```text //! dumps// //! state app-owned file or directory — the prefix handed to -//! `Application::{create_dump, from_dump, delete_dump}` +//! `Application::{create_dump, from_dump}` //! info.toml sequencer-owned checkpoint metadata (this module) //! ``` //! @@ -142,17 +142,9 @@ pub fn create_dump_dir_with_info( Ok(()) } -/// Delete one structured dump directory: the app's prefix via its -/// `delete_dump` hook (when present — an orphan from a crash between -/// dir creation and `create_dump` legitimately lacks it), then the -/// rest of the dir (`info.toml` + the dir itself). -pub fn delete_dump_dir(dump_dir: &Path) -> Result<(), AppError> { - let app_prefix = app_prefix(dump_dir); - if app_prefix.exists() { - A::delete_dump(&app_prefix)?; - } - std::fs::remove_dir_all(dump_dir)?; - Ok(()) +/// Delete a checkpoint and its metadata, including incomplete creation remnants. +pub fn delete_dump_dir(dump_dir: &Path) -> io::Result<()> { + std::fs::remove_dir_all(dump_dir) } /// Write `info.toml` into `dump_dir`, durably: temp file, fsync, rename @@ -283,7 +275,9 @@ mod tests { } impl Application for PrefixDumpApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = 0; + fn max_method_payload_bytes() -> usize { + 0 + } fn validate_user_op( &self, @@ -348,15 +342,6 @@ mod tests { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - if DIRECTORY { - std::fs::remove_dir_all(prefix)?; - } else { - std::fs::remove_file(prefix)?; - } - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { if DIRECTORY { prefix.join("progress") @@ -391,8 +376,18 @@ mod tests { assert_eq!(read_info(&dump).unwrap(), sample()); let mut first = PrefixDumpApp::::from_dump(&app_prefix(&dump)).unwrap(); - let second = PrefixDumpApp::::from_dump(&app_prefix(&dump)).unwrap(); - delete_dump_dir::>(&dump).unwrap(); + let mut second = PrefixDumpApp::::from_dump(&app_prefix(&dump)).unwrap(); + let sibling = root.path().join("sibling"); + create_dump_dir_with_info(&mut second, &sibling, &sample()).unwrap(); + delete_dump_dir(&dump).unwrap(); + assert!(!dump.exists(), "the entire checkpoint directory is removed"); + assert_eq!( + PrefixDumpApp::::from_dump(&app_prefix(&sibling)) + .unwrap() + .progress(), + checkpoint, + "other checkpoints survive deletion" + ); execute_direct_input(&mut first, &input).unwrap(); assert_eq!(app.progress(), checkpoint); assert_eq!( @@ -408,7 +403,8 @@ mod tests { first.progress(), "restored state survives source deletion" ); - delete_dump_dir::>(&successor).unwrap(); + delete_dump_dir(&successor).unwrap(); + delete_dump_dir(&sibling).unwrap(); } #[test] diff --git a/sequencer/src/ingress/inclusion_lane/mod.rs b/sequencer/src/ingress/inclusion_lane/mod.rs index 59415dbc..b60bc432 100644 --- a/sequencer/src/ingress/inclusion_lane/mod.rs +++ b/sequencer/src/ingress/inclusion_lane/mod.rs @@ -287,8 +287,7 @@ impl InclusionLane { // Stamp `B` into the freshly finalized dump's info.toml before // GC (the stamp targets the survivor; GC removes the superseded). snapshot::stamp_finalized_promotion(&mut self.storage)?; - let removed = - snapshot::run_gc::(&mut self.storage).map_err(InclusionLaneError::Gc)?; + let removed = snapshot::run_gc(&mut self.storage).map_err(InclusionLaneError::Gc)?; if removed > 0 { tracing::debug!(removed, "post-promotion GC removed unreferenced dumps"); } @@ -528,7 +527,7 @@ fn dequeue_and_execute_user_op_chunk( fn user_op_count_to_bytes(user_op_count: u64) -> u64 { let one_user_op_bytes = SignedUserOp::max_batch_metadata() - .checked_add(A::MAX_METHOD_PAYLOAD_BYTES) + .checked_add(A::max_method_payload_bytes()) .expect("one user-op wire bound overflow: contract-impossible"); let one_user_op_bytes = u64::try_from(one_user_op_bytes).expect("one user-op wire bound must fit in u64"); diff --git a/sequencer/src/ingress/inclusion_lane/snapshot.rs b/sequencer/src/ingress/inclusion_lane/snapshot.rs index c7c0a11c..b3ce78ee 100644 --- a/sequencer/src/ingress/inclusion_lane/snapshot.rs +++ b/sequencer/src/ingress/inclusion_lane/snapshot.rs @@ -70,10 +70,10 @@ pub enum StampError { /// orphan file is acceptable per the no-dangling-row invariant; only /// the reverse (SQLite row pointing at a missing path) would matter, /// and the SQL-first ordering prevents it. -pub(super) fn run_gc(storage: &mut Storage) -> Result { +pub(super) fn run_gc(storage: &mut Storage) -> Result { let removed = storage.gc_unreferenced_dumps()?; for row in &removed { - if let Err(err) = dump_info::delete_dump_dir::(&row.prefix) { + if let Err(err) = dump_info::delete_dump_dir(&row.prefix) { tracing::warn!( error = %err, prefix = ?row.prefix, @@ -314,7 +314,9 @@ mod tests { } impl Application for RecordingDumpApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = 0; + fn max_method_payload_bytes() -> usize { + 0 + } fn validate_user_op( &self, @@ -356,11 +358,6 @@ mod tests { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } @@ -465,7 +462,7 @@ mod tests { let superseded_prefix = recorded.first().expect("a dump was recorded").clone(); assert!(superseded_prefix.exists(), "pre-GC sanity"); - let removed = super::run_gc::(&mut storage).unwrap(); + let removed = super::run_gc(&mut storage).unwrap(); assert_eq!(removed, 1, "exactly one unreferenced dump cleaned"); assert!(!superseded_prefix.exists(), "filesystem prefix removed too",); @@ -482,7 +479,7 @@ mod tests { take_dump_at_batch_close(&mut app, &mut storage, dumps_dir.path(), 0).unwrap(); // Pending row references the dump; nothing eligible. - let removed = super::run_gc::(&mut storage).unwrap(); + let removed = super::run_gc(&mut storage).unwrap(); assert_eq!(removed, 0); assert!(app.recorded()[0].exists()); } @@ -495,7 +492,9 @@ mod tests { } impl Application for FailingDumpApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = 0; + fn max_method_payload_bytes() -> usize { + 0 + } fn validate_user_op( &self, @@ -536,10 +535,6 @@ mod tests { }) } - fn delete_dump(_prefix: &Path) -> Result<(), AppError> { - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } diff --git a/sequencer/src/ingress/inclusion_lane/tests.rs b/sequencer/src/ingress/inclusion_lane/tests.rs index 53de8252..f75b5824 100644 --- a/sequencer/src/ingress/inclusion_lane/tests.rs +++ b/sequencer/src/ingress/inclusion_lane/tests.rs @@ -62,7 +62,9 @@ struct TestApp { } impl Application for TestApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = WALLET_MAX_METHOD_PAYLOAD_BYTES; + fn max_method_payload_bytes() -> usize { + WALLET_MAX_METHOD_PAYLOAD_BYTES + } fn validate_user_op( &self, @@ -116,11 +118,6 @@ impl Application for TestApp { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } @@ -133,7 +130,9 @@ struct InternalUserOpApp { } impl Application for InternalUserOpApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = WALLET_MAX_METHOD_PAYLOAD_BYTES; + fn max_method_payload_bytes() -> usize { + WALLET_MAX_METHOD_PAYLOAD_BYTES + } fn validate_user_op( &self, @@ -177,11 +176,6 @@ impl Application for InternalUserOpApp { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } @@ -224,7 +218,9 @@ impl SharedCountingApp { } impl Application for SharedCountingApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = WALLET_MAX_METHOD_PAYLOAD_BYTES; + fn max_method_payload_bytes() -> usize { + WALLET_MAX_METHOD_PAYLOAD_BYTES + } fn validate_user_op( &self, @@ -268,11 +264,6 @@ impl Application for SharedCountingApp { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } @@ -298,7 +289,9 @@ impl Default for ReplayRecordingApp { } impl Application for ReplayRecordingApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = WALLET_MAX_METHOD_PAYLOAD_BYTES; + fn max_method_payload_bytes() -> usize { + WALLET_MAX_METHOD_PAYLOAD_BYTES + } fn validate_user_op( &self, @@ -346,11 +339,6 @@ impl Application for ReplayRecordingApp { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } @@ -1851,7 +1839,9 @@ impl UserOpCounterApp { } impl Application for UserOpCounterApp { - const MAX_METHOD_PAYLOAD_BYTES: usize = WALLET_MAX_METHOD_PAYLOAD_BYTES; + fn max_method_payload_bytes() -> usize { + WALLET_MAX_METHOD_PAYLOAD_BYTES + } fn validate_user_op( &self, @@ -1894,11 +1884,6 @@ impl Application for UserOpCounterApp { Ok(()) } - fn delete_dump(prefix: &Path) -> Result<(), AppError> { - std::fs::remove_dir_all(prefix)?; - Ok(()) - } - fn state_file_in_dump(prefix: &Path) -> PathBuf { prefix.join("state") } From b18a9a84e357241eda3845347d105f54e23b81b3 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Sat, 12 Sep 2026 07:07:01 -0300 Subject: [PATCH 7/7] refactor(bindings): promote reusable C integration crates --- .github/workflows/ci.yml | 9 +--- AGENTS.md | 4 +- CLAUDE.md | 4 +- Cargo.toml | 4 +- README.md | 4 +- .../c-app-engine/Cargo.toml | 0 {examples => bindings}/c-app-engine/README.md | 29 +++++++++++- {examples => bindings}/c-app-engine/build.rs | 2 +- .../c-app-engine/include/application-engine.h | 0 .../c-app-engine/src/lib.rs | 0 .../c-app-engine/src/sys.rs | 0 .../c-app-engine/src/tests.rs | 0 .../c-app-sequencer/Cargo.toml | 0 .../c-app-sequencer/build.rs | 0 .../c-app-sequencer/src/lib.rs | 0 .../c-app-sequencer/src/main.rs | 0 docs/protocol/application-contract.md | 2 +- docs/protocol/c-application-binding.md | 10 +++-- examples/c-wallet-engine/Cargo.toml | 2 +- examples/c-wallet-sequencer/Cargo.toml | 2 +- scripts/ci-c-application-smoke.sh | 44 +++++++++++++++++++ 21 files changed, 92 insertions(+), 24 deletions(-) rename {examples => bindings}/c-app-engine/Cargo.toml (100%) rename {examples => bindings}/c-app-engine/README.md (78%) rename {examples => bindings}/c-app-engine/build.rs (98%) rename {examples => bindings}/c-app-engine/include/application-engine.h (100%) rename {examples => bindings}/c-app-engine/src/lib.rs (100%) rename {examples => bindings}/c-app-engine/src/sys.rs (100%) rename {examples => bindings}/c-app-engine/src/tests.rs (100%) rename {examples => bindings}/c-app-sequencer/Cargo.toml (100%) rename {examples => bindings}/c-app-sequencer/build.rs (100%) rename {examples => bindings}/c-app-sequencer/src/lib.rs (100%) rename {examples => bindings}/c-app-sequencer/src/main.rs (100%) create mode 100644 scripts/ci-c-application-smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 803a2a88..5727266e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,13 +63,8 @@ jobs: timeout-minutes: 15 run: cargo test --workspace --all-targets --all-features --locked - - 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" \ - cargo build --locked -p c-app-sequencer - ./target/debug/c-app-sequencer --help > /dev/null + - name: C application archive and downstream consumer + run: bash scripts/ci-c-application-smoke.sh canonical-guest: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 679cef38..a25bd67d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,8 +147,8 @@ 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/` — C ABI adapter implementing `Application` for a native engine. -- `examples/c-app-sequencer/` — shared C-engine CLI host and external-archive binary. +- `bindings/c-app-engine/` — reusable C ABI adapter implementing `Application` for a native engine. +- `bindings/c-app-sequencer/` — optional C-engine CLI host and external-archive binary. - `examples/c-wallet-engine/` — reference wallet engine exporting the C ABI, plus its genesis tool and conformance tests. - `examples/c-wallet-sequencer/` — binary composing the C-engine host with the reference wallet engine. - `examples/canonical-app/` — on-chain scheduler reference implementation. diff --git a/CLAUDE.md b/CLAUDE.md index f27ecb8d..037795a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,8 +34,8 @@ 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/` — native engine adapter implementing `Application` through a C ABI. -- `examples/c-app-sequencer/` — shared C-engine CLI host and external-archive binary. +- `bindings/c-app-engine/` — reusable native engine adapter implementing `Application` through a C ABI. +- `bindings/c-app-sequencer/` — optional C-engine CLI host and external-archive binary. - `examples/c-wallet-engine/` — reference C ABI exports, genesis tool, and conformance tests. - `examples/c-wallet-sequencer/` — binary composing the C-engine host with the reference wallet engine. - `examples/canonical-app/` — on-chain scheduler reference implementation. diff --git a/Cargo.toml b/Cargo.toml index 67a19a09..4846cc62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ members = [ "sequencer", "sequencer-core", "sdk/rust-client", + "bindings/c-app-engine", + "bindings/c-app-sequencer", "examples/app-core", "examples/canonical-app", "examples/canonical-test", "examples/wallet-sequencer", - "examples/c-app-engine", - "examples/c-app-sequencer", "examples/c-wallet-engine", "examples/c-wallet-sequencer", "tests/benchmarks", diff --git a/README.md b/README.md index a0920cfe..01a0ec35 100644 --- a/README.md +++ b/README.md @@ -260,8 +260,8 @@ 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/`](examples/c-app-engine/README.md): C ABI adapter and external static-archive integration guide -- `examples/c-app-sequencer/`: shared C-engine CLI host and external-archive binary +- [`bindings/c-app-engine/`](bindings/c-app-engine/README.md): reusable C ABI adapter and external static-archive integration guide +- `bindings/c-app-sequencer/`: optional C-engine CLI host and external-archive binary - `examples/c-wallet-engine/`: reference wallet C ABI exports, genesis tool, and conformance tests - `examples/c-wallet-sequencer/`: binary composing the C-engine host with the reference wallet engine - `tests/benchmarks/`: benchmark harnesses and benchmark spec diff --git a/examples/c-app-engine/Cargo.toml b/bindings/c-app-engine/Cargo.toml similarity index 100% rename from examples/c-app-engine/Cargo.toml rename to bindings/c-app-engine/Cargo.toml diff --git a/examples/c-app-engine/README.md b/bindings/c-app-engine/README.md similarity index 78% rename from examples/c-app-engine/README.md rename to bindings/c-app-engine/README.md index 254ec262..d8ffb17c 100644 --- a/examples/c-app-engine/README.md +++ b/bindings/c-app-engine/README.md @@ -1,5 +1,8 @@ # C application bridge +The crates under `bindings/` are reusable integration libraries. The wallet +engine and binary under `examples/` demonstrate their use. + `EngineApp` implements `sequencer_core::application::Application` using the [application-engine C ABI](include/application-engine.h). The sequencer owns one engine at a time. A handle can move between threads; calls on it never overlap. @@ -59,7 +62,9 @@ panic follows the shared terminal-error policy. ## External engine -Build the application's static archive and use the corresponding header: +Build the application's static archive against the header from the chosen +sequencer revision. From a checkout of that revision, build the generic host +with that same header: ```sh APPLICATION_ENGINE_LIB=/absolute/path/libengine.a \ @@ -75,6 +80,28 @@ cross this ABI. With no external archive configured, the generic binary reports that no engine was linked, and `c-wallet-sequencer` supplies the reference implementation through Cargo. +For a binary in another repository, depend on the host directly. Replace +`` with the sequencer revision whose header the engine uses: + +```toml +[dependencies] +c-app-sequencer = { git = "https://github.com/cartesi/sequencer", rev = "" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +``` + +```rust +#[tokio::main] +async fn main() -> std::process::ExitCode { + c_app_sequencer::run().await +} +``` + +Set the same `APPLICATION_ENGINE_LIB` and `APPLICATION_ENGINE_HEADER` variables +when running `cargo build` in that binary's repository. The host's `run()` owns +CLI parsing and tracing setup. For custom host wiring, depend on `c-app-engine` +and `sequencer` at the same Git revision and compose `EngineApp` with +`sequencer::run_command` directly. No wallet crate is required in either case. + The conformance suite compares native and ABI execution over mixed inputs, notices and vouchers, rejection/no-op progress, dump round trips, independent instances, and fatal/error classification. diff --git a/examples/c-app-engine/build.rs b/bindings/c-app-engine/build.rs similarity index 98% rename from examples/c-app-engine/build.rs rename to bindings/c-app-engine/build.rs index 1307dcbf..34c43379 100644 --- a/examples/c-app-engine/build.rs +++ b/bindings/c-app-engine/build.rs @@ -108,7 +108,7 @@ fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); let header = 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 + // The final binary supplies the engine when no external archive is configured. Err(_) => { assert!( env::var_os("APPLICATION_ENGINE_HEADER").is_none(), diff --git a/examples/c-app-engine/include/application-engine.h b/bindings/c-app-engine/include/application-engine.h similarity index 100% rename from examples/c-app-engine/include/application-engine.h rename to bindings/c-app-engine/include/application-engine.h diff --git a/examples/c-app-engine/src/lib.rs b/bindings/c-app-engine/src/lib.rs similarity index 100% rename from examples/c-app-engine/src/lib.rs rename to bindings/c-app-engine/src/lib.rs diff --git a/examples/c-app-engine/src/sys.rs b/bindings/c-app-engine/src/sys.rs similarity index 100% rename from examples/c-app-engine/src/sys.rs rename to bindings/c-app-engine/src/sys.rs diff --git a/examples/c-app-engine/src/tests.rs b/bindings/c-app-engine/src/tests.rs similarity index 100% rename from examples/c-app-engine/src/tests.rs rename to bindings/c-app-engine/src/tests.rs diff --git a/examples/c-app-sequencer/Cargo.toml b/bindings/c-app-sequencer/Cargo.toml similarity index 100% rename from examples/c-app-sequencer/Cargo.toml rename to bindings/c-app-sequencer/Cargo.toml diff --git a/examples/c-app-sequencer/build.rs b/bindings/c-app-sequencer/build.rs similarity index 100% rename from examples/c-app-sequencer/build.rs rename to bindings/c-app-sequencer/build.rs diff --git a/examples/c-app-sequencer/src/lib.rs b/bindings/c-app-sequencer/src/lib.rs similarity index 100% rename from examples/c-app-sequencer/src/lib.rs rename to bindings/c-app-sequencer/src/lib.rs diff --git a/examples/c-app-sequencer/src/main.rs b/bindings/c-app-sequencer/src/main.rs similarity index 100% rename from examples/c-app-sequencer/src/main.rs rename to bindings/c-app-sequencer/src/main.rs diff --git a/docs/protocol/application-contract.md b/docs/protocol/application-contract.md index 8d312ba6..8acd89df 100644 --- a/docs/protocol/application-contract.md +++ b/docs/protocol/application-contract.md @@ -12,7 +12,7 @@ This document **owns** the contract. [`AGENTS.md`](../../AGENTS.md) is the map. The [wallet](../../examples/app-core/) is the reference implementation. A production application may execute natively or wrap a Cartesi Machine. The [C application binding](c-application-binding.md) adapts this contract to -native engines; its [build guide](../../examples/c-app-engine/README.md) includes +native engines; its [build guide](../../bindings/c-app-engine/README.md) includes a reference wallet integration. ## The execution methods diff --git a/docs/protocol/c-application-binding.md b/docs/protocol/c-application-binding.md index 739be090..ef3f6981 100644 --- a/docs/protocol/c-application-binding.md +++ b/docs/protocol/c-application-binding.md @@ -1,10 +1,12 @@ # C application binding An application exposes the -[`application-engine.h`](../../examples/c-app-engine/include/application-engine.h) +[`application-engine.h`](../../bindings/c-app-engine/include/application-engine.h) C ABI in a static archive. `c-app-engine::EngineApp` adapts that engine to the -Rust `Application` trait. The [build guide](../../examples/c-app-engine/README.md) -covers linking, genesis, host commands, and reference tests. +Rust `Application` trait. These reusable bindings live under `bindings/`; +`c-app-sequencer` supplies an optional CLI host. The +[build guide](../../bindings/c-app-engine/README.md) covers downstream dependencies, +linking, genesis, host commands, and the reference wallet under `examples/`. ## Contract ownership @@ -13,7 +15,7 @@ covers linking, genesis, host commands, and reference tests. - [Scheduler semantics](scheduler-semantics.md) defines canonical ordering and the acceptance boundary. The native engine does not acquire an ordering role through this ABI. -- The [C header](../../examples/c-app-engine/include/application-engine.h) +- The [C header](../../bindings/c-app-engine/include/application-engine.h) defines record layout, statuses, pointer ownership, and call lifetimes. An engine archive and its generated Rust bindings must agree on that header. - [Snapshot lifecycle](../snapshots/lifecycle.md) owns checkpoint registration, diff --git a/examples/c-wallet-engine/Cargo.toml b/examples/c-wallet-engine/Cargo.toml index 6099778c..82cfdd9c 100644 --- a/examples/c-wallet-engine/Cargo.toml +++ b/examples/c-wallet-engine/Cargo.toml @@ -21,7 +21,7 @@ path = "src/bin/c-wallet-genesis.rs" [dependencies] app-core = { path = "../app-core" } -c-app-engine = { path = "../c-app-engine" } +c-app-engine = { path = "../../bindings/c-app-engine" } sequencer-core = { path = "../../sequencer-core" } alloy-primitives = { workspace = true } diff --git a/examples/c-wallet-sequencer/Cargo.toml b/examples/c-wallet-sequencer/Cargo.toml index 06080aa3..31402723 100644 --- a/examples/c-wallet-sequencer/Cargo.toml +++ b/examples/c-wallet-sequencer/Cargo.toml @@ -10,6 +10,6 @@ readme = "../../README.md" authors.workspace = true [dependencies] -c-app-sequencer = { path = "../c-app-sequencer" } +c-app-sequencer = { path = "../../bindings/c-app-sequencer" } c-wallet-engine = { path = "../c-wallet-engine" } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/scripts/ci-c-application-smoke.sh b/scripts/ci-c-application-smoke.sh new file mode 100644 index 00000000..eb9cf34d --- /dev/null +++ b/scripts/ci-c-application-smoke.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Link an external engine into the generic host and an independent Cargo consumer. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${root}" +export CARGO_TARGET_DIR="${root}/target" + +unset APPLICATION_ENGINE_LIB APPLICATION_ENGINE_HEADER +cargo build --locked -p c-wallet-engine +export APPLICATION_ENGINE_LIB="${CARGO_TARGET_DIR}/debug/libc_wallet_engine.a" +export APPLICATION_ENGINE_HEADER="${root}/bindings/c-app-engine/include/application-engine.h" +cargo build --locked -p c-app-sequencer +"${CARGO_TARGET_DIR}/debug/c-app-sequencer" --help >/dev/null + +smoke_dir="$(mktemp -d "${TMPDIR:-/tmp}/sequencer-c-consumer.XXXXXX")" +trap 'rm -rf "${smoke_dir}"' EXIT +ln -s "${root}" "${smoke_dir}/source" +consumer_dir="${smoke_dir}/consumer" +mkdir -p "${consumer_dir}/src" +cat >"${consumer_dir}/Cargo.toml" <<'TOML' +[package] +name = "c-application-consumer-smoke" +version = "0.0.0" +edition = "2024" + +[workspace] + +[dependencies] +c-app-sequencer = { path = "../source/bindings/c-app-sequencer" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +TOML +cat >"${consumer_dir}/src/main.rs" <<'RUST' +#[tokio::main] +async fn main() -> std::process::ExitCode { + c_app_sequencer::run().await +} +RUST + +# Reuse CI's pinned dependencies, allowing Cargo to adjust only the consumer's lockfile. +cp Cargo.lock "${consumer_dir}/Cargo.lock" +cargo build --manifest-path "${consumer_dir}/Cargo.toml" --offline +"${CARGO_TARGET_DIR}/debug/c-application-consumer-smoke" --help >/dev/null +echo "C application archive and downstream consumer smoke passed"