From ee4cd8c273c040c8696f0ced49a4f19a374e01e4 Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Wed, 27 May 2026 20:46:31 -0700 Subject: [PATCH 1/4] Add --enable-fastdev-unsafe-for-production, subsuming unified buid --- CONTRIBUTING.md | 26 +++++------ Cargo.lock | 8 ++-- Cargo.toml | 7 +++ configure.ac | 30 ++++++++---- src/Makefile.am | 42 +++++++++++------ src/rust/Cargo.toml | 21 ++++----- src/rust/src/soroban_module_cache.rs | 51 +++++++++++++++++++-- src/rust/src/soroban_proto_all.rs | 42 +++++++++++++---- src/rust/src/soroban_test_extra_protocol.rs | 6 ++- 9 files changed, 166 insertions(+), 67 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cd924cd2f..55016a2a04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ See https://clang.llvm.org/docs/AddressSanitizer.html for more information. *Note*: ASan will ignore any memory errors in Rust code unless you build with Rust's ASan support. And building with Rust's ASan support requires configuring -with `--enable-unified-rust-unsafe-for-production`. See below on "unified Rust +with `--enable-fastdev-unsafe-for-production`. See below on "fastdev Rust builds". *Note*: Rust's ASan support also requires a nightly compiler and the rust-src @@ -156,7 +156,7 @@ See https://clang.llvm.org/docs/ThreadSanitizer.html for more information. *Note*: Since Rust code is run on multiple threads and those threads are launched _from C++_ TSan will report races in Rust code unless you build with Rust's TSan support. And building with Rust's TSan support requires configuring -with `--enable-unified-rust-unsafe-for-production`. +with `--enable-fastdev-unsafe-for-production`. *Note*: Rust's ASan support also requires a nightly compiler and the rust-src component. Install these with: @@ -294,7 +294,7 @@ files. You should then inspect to see that only the transactions you expected to see change did so. If so, commit the changes as a new set of baselines for future tests. -## Unified and non-unified Rust builds +## Fastdev and non-unified Rust builds As of protocol 20, some components of stellar-core are written in Rust (notably soroban). @@ -331,21 +331,21 @@ and it _usually_ works. But there are two cases you might not want it. the stdlib and producing some sort of link-time dependency on crates that are only used as procedural macros). -For both of these cases, we've added the ability to (optionally) switch back to -the normal way Rust expects you to build a crate that links multiple versions of -a dependency: with a single "unified" cargo invocation, at the top level. There -are two different ways to enable this: +For both of these cases, we've added a fastdev mode that switches back to the +normal way Rust expects you to build a crate, with a single cargo invocation at +the top level and only the current and next Soroban hosts compiled in. There are +two different ways to enable this: - - By configuring with `--enable-unified-rust-unsafe-for-production`, if one - wants to _build_ a stellar-core with unified rust. + - By configuring with `--enable-fastdev-unsafe-for-production`, if one wants + to _build_ a stellar-core with fastdev rust. - - By toggling the "unified" feature flag in the IDE (eg. using the "Rust + - By toggling the "fastdev" feature flag in the IDE (eg. using the "Rust Feature Toggler" editor extension in VS code) if one merely wants to _edit_ - a stellar-core with unified rust. + a stellar-core with fastdev rust. The configure flag has got such a long and unwieldy name because _it will build -soroban with slightly different versions of transitive dependencies_, a -configuration we do _not_ want to ship in production builds. +soroban with fewer host versions and slightly different versions of transitive +dependencies_, a configuration we do _not_ want to ship in production builds. It is fine for debugging though. In practice those different versions of transitive dependencies are rarely "all that different". You will _probably_ not diff --git a/Cargo.lock b/Cargo.lock index 45ecdbd1ae..414b4f1b1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1441,7 +1441,7 @@ dependencies = [ [[package]] name = "soroban-builtin-sdk-macros" version = "26.1.2" -source = "git+https://github.com/stellar/rs-soroban-env?rev=c0e58f94ff2983a09440cef6a54253349fd3c4db#c0e58f94ff2983a09440cef6a54253349fd3c4db" +source = "git+https://github.com/stellar/rs-soroban-env?rev=7fb0a840812fe8921bb48bebf7b4aa7160467ec8#7fb0a840812fe8921bb48bebf7b4aa7160467ec8" dependencies = [ "itertools 0.13.0", "proc-macro2", @@ -1549,7 +1549,7 @@ dependencies = [ [[package]] name = "soroban-env-common" version = "26.1.2" -source = "git+https://github.com/stellar/rs-soroban-env?rev=c0e58f94ff2983a09440cef6a54253349fd3c4db#c0e58f94ff2983a09440cef6a54253349fd3c4db" +source = "git+https://github.com/stellar/rs-soroban-env?rev=7fb0a840812fe8921bb48bebf7b4aa7160467ec8#7fb0a840812fe8921bb48bebf7b4aa7160467ec8" dependencies = [ "crate-git-revision", "ethnum", @@ -1773,7 +1773,7 @@ dependencies = [ [[package]] name = "soroban-env-host" version = "26.1.2" -source = "git+https://github.com/stellar/rs-soroban-env?rev=c0e58f94ff2983a09440cef6a54253349fd3c4db#c0e58f94ff2983a09440cef6a54253349fd3c4db" +source = "git+https://github.com/stellar/rs-soroban-env?rev=7fb0a840812fe8921bb48bebf7b4aa7160467ec8#7fb0a840812fe8921bb48bebf7b4aa7160467ec8" dependencies = [ "ark-bls12-381 0.5.0", "ark-bn254 0.5.0", @@ -1893,7 +1893,7 @@ dependencies = [ [[package]] name = "soroban-env-macros" version = "26.1.2" -source = "git+https://github.com/stellar/rs-soroban-env?rev=c0e58f94ff2983a09440cef6a54253349fd3c4db#c0e58f94ff2983a09440cef6a54253349fd3c4db" +source = "git+https://github.com/stellar/rs-soroban-env?rev=7fb0a840812fe8921bb48bebf7b4aa7160467ec8#7fb0a840812fe8921bb48bebf7b4aa7160467ec8" dependencies = [ "itertools 0.13.0", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index b4017c6187..d1e2639498 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,10 @@ lto = true # (or you can build with `make RUST_PROFILE=dev` to get them built with # debug-and-no-opt). debug = true + +[profile.fastdev] +inherits = "release" +lto = false +debug = "line-tables-only" +codegen-units = 16 +split-debuginfo = "unpacked" diff --git a/configure.ac b/configure.ac index 76cc595238..2de75e93e9 100644 --- a/configure.ac +++ b/configure.ac @@ -97,10 +97,24 @@ AX_APPEND_COMPILE_FLAGS($WFLAGS) AX_APPEND_COMPILE_FLAGS([-pthread]) AC_LANG_POP(C) -AC_ARG_ENABLE(unified-rust-unsafe-for-production, - AS_HELP_STRING([--enable-unified-rust-unsafe-for-production], - [Build rust crates as a single cargo library, risking version drift])) -AM_CONDITIONAL(UNIFIED_RUST, [test "x$enable_unified_rust_unsafe_for_production" = "xyes"]) +AC_ARG_ENABLE(fastdev-unsafe-for-production, + AS_HELP_STRING([--enable-fastdev-unsafe-for-production], + [Build in fast development mode UNSAFE FOR PRODUCTION])) +AS_IF([test "x$enable_fastdev_unsafe_for_production" = "xyes"], [ + AC_MSG_NOTICE([enabling fastdev build profile UNSAFE FOR PRODUCTION]) + fastdev_cxx_version=`$CXX --version 2>/dev/null` + case "$fastdev_cxx_version" in + *clang*) + CXXFLAGS="$CXXFLAGS -gline-tables-only" + AC_MSG_NOTICE([added -gline-tables-only to CXXFLAGS]) + ;; + *) + AC_MSG_ERROR([fastdev build requires clang compiler]) + ;; + esac +]) +AM_CONDITIONAL(ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION, + [test "x$enable_fastdev_unsafe_for_production" = "xyes"]) unset sanitizeopts @@ -110,8 +124,8 @@ AC_ARG_ENABLE([asan], AS_IF([test "x$enable_asan" = "xyes"], [ AC_MSG_NOTICE([ Enabling asan, see https://clang.llvm.org/docs/AddressSanitizer.html ]) - AS_IF([test "xyes" != "x$enable_unified_rust_unsafe_for_production"], [ - AC_MSG_WARN(Asan will not instrument rust without --enable-unified-rust-unsafe-for-production) + AS_IF([test "xyes" != "x$enable_fastdev_unsafe_for_production"], [ + AC_MSG_WARN(Asan will not instrument rust without --enable-fastdev-unsafe-for-production) ]) sanitizeopts="address" @@ -134,8 +148,8 @@ AC_ARG_ENABLE([threadsanitizer], AS_IF([test "x$enable_threadsanitizer" = "xyes"], [ AC_MSG_NOTICE([ enabling thread-sanitizer, see https://clang.llvm.org/docs/ThreadSanitizer.html ]) - AS_IF([test "xyes" != "x$enable_unified_rust_unsafe_for_production"], [ - AC_MSG_ERROR(Enabling tsan requires --enable-unified-rust-unsafe-for-production) + AS_IF([test "xyes" != "x$enable_fastdev_unsafe_for_production"], [ + AC_MSG_ERROR(Enabling tsan requires --enable-fastdev-unsafe-for-production) ]) AS_IF([test x != "x$sanitizeopts"], [ AC_MSG_ERROR(Cannot enable multiple sanitizers at once) diff --git a/src/Makefile.am b/src/Makefile.am index e33128a57b..5789ae167f 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -77,6 +77,12 @@ else CARGO_FEATURE_NEXT = endif +if ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION +CARGO_FEATURE_FASTDEV = --features fastdev +else +CARGO_FEATURE_FASTDEV = +endif + main/XDRFilesSha256.cpp: $(SRC_X_FILES) Makefile $(top_srcdir)/hash-xdrs.sh $(top_srcdir)/hash-xdrs.sh $(top_srcdir)/src/protocol-curr >$@ @@ -131,7 +137,7 @@ stellar_core_SOURCES += $(GENERATED_XDRQUERY_SOURCES) util/xdrquery/XDRQueryPars BUILT_SOURCES += rust/RustBridge.h $(GENERATED_RUST_SOURCES) stellar_core_SOURCES += rust/RustBridge.h $(GENERATED_RUST_SOURCES) -if UNIFIED_RUST +if ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION RUST_TOOLCHAIN_CHANNEL=nightly else RUST_TOOLCHAIN_FILE=$(top_srcdir)/rust-toolchain.toml @@ -151,22 +157,27 @@ RUST_BUILD_DIR=$(top_builddir)/src/rust RUST_BIN_DIR=$(RUST_BUILD_DIR)/bin RUST_TARGET_DIR=$(top_builddir)/target RUST_CXXBRIDGE=$(RUST_BIN_DIR)/cxxbridge + +if ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION +RUST_PROFILE=fastdev +else RUST_PROFILE=release +endif check-rust-profile: Makefile @case "$(RUST_PROFILE)" in \ - release|dev) ;; \ - *) echo "Error: RUST_PROFILE must be 'release' or 'dev', got '$(RUST_PROFILE)'" >&2; exit 1;; \ + release|dev|fastdev) ;; \ + *) echo "Error: RUST_PROFILE must be 'release', 'dev', or 'fastdev', got '$(RUST_PROFILE)'" >&2; exit 1;; \ esac # Of course, RUST_PROFILE can't be used as an argument directly because cargo # doesn't let you pass --debug, that's the default! you can only pass --release. # So we have to derive a new variable here. -RUST_PROFILE_ARG := $(if $(findstring release,$(RUST_PROFILE)),--release,) +RUST_PROFILE_ARG := $(if $(findstring fastdev,$(RUST_PROFILE)),--profile fastdev,$(if $(findstring release,$(RUST_PROFILE)),--release,)) # Also for even more nonsense reasons the debug profile name is actually `dev` # but only in the command line, the target subdirectory gets called `debug`. -RUST_PROFILE_DIR := $(if $(findstring release,$(RUST_PROFILE)),release,debug) +RUST_PROFILE_DIR := $(if $(findstring fastdev,$(RUST_PROFILE)),fastdev,$(if $(findstring release,$(RUST_PROFILE)),release,debug)) RUST_DEP_TREE_STAMP=$(RUST_BUILD_DIR)/src/dep-trees/equal-trees.stamp SOROBAN_LIBS_STAMP=$(RUST_BUILD_DIR)/soroban/soroban-libs.stamp @@ -201,7 +212,11 @@ SOROBAN_BUILD_DIR=$(abspath $(RUST_BUILD_DIR))/soroban # variable empty (and include or exclude submodules from the list of # ALL_SOROBAN_PROTOCOLS as you see fit). +if ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION +ALL_SOROBAN_PROTOCOLS=p27 +else ALL_SOROBAN_PROTOCOLS=p21 p22 p23 p24 p25 p26 p27 +endif WIP_SOROBAN_PROTOCOL= CARGO_XDR_FEATURE_FLAGS = @@ -287,23 +302,23 @@ $(SOROBAN_BUILD_DIR)/%/target/git-state.txt: $(top_srcdir)/.git/modules/src/rust printf '%s\n' "$$state" > $@.tmp; \ if cmp -s $@.tmp $@; then rm -f $@.tmp; else mv -f $@.tmp $@; fi -# The "unified" rust build is a special non-production mode that builds all of +# The fastdev rust build is a special non-production mode that builds all of # the rust dependencies of librust_stellar_core.a through a single cargo # invocation, which is actually the "normal" way cargo operates, but which also # has the negative side effect of resolving (merging) different point releases # and pre-release minor versions across transitive dependencies, which means we # can't control the _exact_ transitive dependencies as well as we'd like. # -# So we only use the unified rust build for certain special cases such as +# So we only use the fastdev rust build for certain special cases such as # testing with asan/tsan (they seem to only work well when built this way) and # use the non-unified build (with separate .a files for each separate soroban # version) for production builds. -if UNIFIED_RUST +if ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION -# In the unified build, we have to pass the --target flag. This actually breaks +# In the fastdev build, we have to pass the --target flag. This actually breaks # the non-unified build, so we wind up setting LIBRUST_STELLAR_CORE separately -# in unified and non-unified builds. +# in fastdev and non-unified builds. RUST_TARGET=$(shell rustc -vV | sed -n 's/host: //p') LIBRUST_STELLAR_CORE=$(RUST_TARGET_DIR)/$(RUST_TARGET)/$(RUST_PROFILE_DIR)/librust_stellar_core.a @@ -323,14 +338,13 @@ $(LIBRUST_STELLAR_CORE): $(RUST_HOST_DEPFILES) $(SRC_RUST_FILES) Makefile $(RUST $(CARGOFLAGS_BUILDSTD) \ --package stellar-core \ --target $(RUST_TARGET) \ - --features unified \ $(RUST_PROFILE_ARG) \ --locked \ --target-dir $(abspath $(RUST_TARGET_DIR)) \ - $(CARGO_FEATURE_TRACY) $(CARGO_FEATURE_NEXT) $(CARGO_FEATURE_TESTUTILS) + $(CARGO_FEATURE_FASTDEV) $(CARGO_FEATURE_TRACY) $(CARGO_FEATURE_NEXT) $(CARGO_FEATURE_TESTUTILS) ranlib $@ -else # !UNIFIED_RUST +else # !ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION # This next build command looks a little weird but it's necessary. We have to # provide an auxiliary metadata string (using RUSTFLAGS=-Cmetadata=$*) @@ -444,7 +458,7 @@ $(LIBRUST_STELLAR_CORE): $(RUST_HOST_DEPFILES) $(SRC_RUST_FILES) $(ALL_SOROBAN_L $(ALL_SOROBAN_DEPEND_ARGS) ranlib $@ -endif # UNIFIED_RUST +endif # ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION stellar_core_LDADD += $(LIBRUST_STELLAR_CORE) -ldl diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index b679667976..92d7979136 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -98,19 +98,19 @@ tracy-client = { version = "=0.17.0", features = [ # makes it difficult to use an IDE to work on this crate since the IDE will not # be able to resolve the hosts to anything at all. So we keep some _optional_ # copies of the host dependencies here, and allow enabling them with the -# `unified` feature here. +# `fastdev` feature here. # # To reiterate: the soroban-env-host-p{21,22,23}... dependency blocks listed # here are NOT part of the default build. The default build relies on the # versions pinned as git submodules. # -# However, you can _manually_ switch the default build by enabling the `unified` +# However, you can _manually_ switch the default build by enabling the `fastdev` # feature using a feature-toggle in an IDE (eg. using the VS Code "Rust Feature # Toggler" extension), and the build system (src/Makefile.am) can be configured -# to use the `unified` feature if you configure with -# --enable-unified-rust-unsafe-for-production. +# to use the `fastdev` feature if you configure with +# --enable-fastdev-unsafe-for-production. # -# If you do a unified build, be careful to reset any changes the IDE makes to +# If you do a fastdev build, be careful to reset any changes the IDE makes to # Cargo.lock! The unified build will re-resolve transitive dependencies and # unify them, perturbing the contents of the lockfile. @@ -118,7 +118,7 @@ tracy-client = { version = "=0.17.0", features = [ version = "=26.1.2" git = "https://github.com/stellar/rs-soroban-env" package = "soroban-env-host" -rev = "c0e58f94ff2983a09440cef6a54253349fd3c4db" +rev = "7fb0a840812fe8921bb48bebf7b4aa7160467ec8" optional = true [dependencies.soroban-env-host-p26] @@ -194,14 +194,9 @@ rev = "f67e9e2f080c2cc1a332d894c42277c64845e257" [features] -# Turn on the optional unified build. This is typically only useful in an IDE or when +# Turn on the optional fastdev build. This is typically only useful in an IDE or when # orchestrated by the build system. See note above and in docs/CONTRIBUTING.md. -unified = ["dep:soroban-env-host-p21", - "dep:soroban-env-host-p22", - "dep:soroban-env-host-p23", - "dep:soroban-env-host-p24", - "dep:soroban-env-host-p25", - "dep:soroban-env-host-p26", +fastdev = ["dep:soroban-env-host-p26", "dep:soroban-env-host-p27"] tracy = ["dep:tracy-client"] diff --git a/src/rust/src/soroban_module_cache.rs b/src/rust/src/soroban_module_cache.rs index 9270b46c79..bcfdede96e 100644 --- a/src/rust/src/soroban_module_cache.rs +++ b/src/rust/src/soroban_module_cache.rs @@ -16,13 +16,24 @@ use crate::{ rust_bridge::CxxBuf, - soroban_proto_all::{get_host_module_for_protocol, p23, p24, p25, p26, p27, protocol_agnostic}, + soroban_proto_all::{get_host_module_for_protocol, p27, protocol_agnostic}, }; +#[cfg(not(feature = "fastdev"))] +use crate::soroban_proto_all::{p23, p24, p25, p26}; + +// Uncomment when there's a p28 +//#[cfg(feature = "next")] +//use crate::soroban_proto_all::p28; + pub(crate) struct SorobanModuleCache { + #[cfg(not(feature = "fastdev"))] pub(crate) p23_cache: p23::soroban_proto_any::ProtocolSpecificModuleCache, + #[cfg(not(feature = "fastdev"))] pub(crate) p24_cache: p24::soroban_proto_any::ProtocolSpecificModuleCache, + #[cfg(not(feature = "fastdev"))] pub(crate) p25_cache: p25::soroban_proto_any::ProtocolSpecificModuleCache, + #[cfg(not(feature = "fastdev"))] pub(crate) p26_cache: p26::soroban_proto_any::ProtocolSpecificModuleCache, pub(crate) p27_cache: p27::soroban_proto_any::ProtocolSpecificModuleCache, } @@ -30,9 +41,13 @@ pub(crate) struct SorobanModuleCache { impl SorobanModuleCache { fn new() -> Result> { Ok(Self { + #[cfg(not(feature = "fastdev"))] p23_cache: p23::soroban_proto_any::ProtocolSpecificModuleCache::new()?, + #[cfg(not(feature = "fastdev"))] p24_cache: p24::soroban_proto_any::ProtocolSpecificModuleCache::new()?, + #[cfg(not(feature = "fastdev"))] p25_cache: p25::soroban_proto_any::ProtocolSpecificModuleCache::new()?, + #[cfg(not(feature = "fastdev"))] p26_cache: p26::soroban_proto_any::ProtocolSpecificModuleCache::new()?, p27_cache: p27::soroban_proto_any::ProtocolSpecificModuleCache::new()?, }) @@ -42,10 +57,15 @@ impl SorobanModuleCache { ledger_protocol: u32, _wasm: &[u8], ) -> Result<(), Box> { - match ledger_protocol { + let hm = get_host_module_for_protocol(ledger_protocol, ledger_protocol)?; + match hm.max_proto { + #[cfg(not(feature = "fastdev"))] 23 => self.p23_cache.compile(_wasm), + #[cfg(not(feature = "fastdev"))] 24 => self.p24_cache.compile(_wasm), + #[cfg(not(feature = "fastdev"))] 25 => self.p25_cache.compile(_wasm), + #[cfg(not(feature = "fastdev"))] 26 => self.p26_cache.compile(_wasm), 27 => self.p27_cache.compile(_wasm), #[cfg(feature = "next")] @@ -56,9 +76,13 @@ impl SorobanModuleCache { } pub fn shallow_clone(&self) -> Result, Box> { Ok(Box::new(Self { + #[cfg(not(feature = "fastdev"))] p23_cache: self.p23_cache.shallow_clone()?, + #[cfg(not(feature = "fastdev"))] p24_cache: self.p24_cache.shallow_clone()?, + #[cfg(not(feature = "fastdev"))] p25_cache: self.p25_cache.shallow_clone()?, + #[cfg(not(feature = "fastdev"))] p26_cache: self.p26_cache.shallow_clone()?, p27_cache: self.p27_cache.shallow_clone()?, })) @@ -69,17 +93,25 @@ impl SorobanModuleCache { .as_ref() .try_into() .map_err(|_| "Invalid contract-code key length")?; + #[cfg(not(feature = "fastdev"))] self.p23_cache.evict(&_hash)?; + #[cfg(not(feature = "fastdev"))] self.p24_cache.evict(&_hash)?; + #[cfg(not(feature = "fastdev"))] self.p25_cache.evict(&_hash)?; + #[cfg(not(feature = "fastdev"))] self.p26_cache.evict(&_hash)?; self.p27_cache.evict(&_hash)?; Ok(()) } pub fn clear(&self) -> Result<(), Box> { + #[cfg(not(feature = "fastdev"))] self.p23_cache.clear()?; + #[cfg(not(feature = "fastdev"))] self.p24_cache.clear()?; + #[cfg(not(feature = "fastdev"))] self.p25_cache.clear()?; + #[cfg(not(feature = "fastdev"))] self.p26_cache.clear()?; self.p27_cache.clear()?; Ok(()) @@ -94,10 +126,15 @@ impl SorobanModuleCache { .as_ref() .try_into() .map_err(|_| "Invalid contract-code key length")?; - match protocol { + let hm = get_host_module_for_protocol(protocol, protocol)?; + match hm.max_proto { + #[cfg(not(feature = "fastdev"))] 23 => self.p23_cache.contains_module(&_hash), + #[cfg(not(feature = "fastdev"))] 24 => self.p24_cache.contains_module(&_hash), + #[cfg(not(feature = "fastdev"))] 25 => self.p25_cache.contains_module(&_hash), + #[cfg(not(feature = "fastdev"))] 26 => self.p26_cache.contains_module(&_hash), 27 => self.p27_cache.contains_module(&_hash), #[cfg(feature = "next")] @@ -109,12 +146,16 @@ impl SorobanModuleCache { &self, ledger_protocol: u32, ) -> Result> { - #[allow(unused_mut)] + let hm = get_host_module_for_protocol(ledger_protocol, ledger_protocol)?; let mut bytes = 0; - match ledger_protocol { + match hm.max_proto { + #[cfg(not(feature = "fastdev"))] 23 => bytes = bytes.max(self.p23_cache.get_wasm_bytes_input()?), + #[cfg(not(feature = "fastdev"))] 24 => bytes = bytes.max(self.p24_cache.get_wasm_bytes_input()?), + #[cfg(not(feature = "fastdev"))] 25 => bytes = bytes.max(self.p25_cache.get_wasm_bytes_input()?), + #[cfg(not(feature = "fastdev"))] 26 => bytes = bytes.max(self.p26_cache.get_wasm_bytes_input()?), 27 => bytes = bytes.max(self.p27_cache.get_wasm_bytes_input()?), #[cfg(feature = "next")] diff --git a/src/rust/src/soroban_proto_all.rs b/src/rust/src/soroban_proto_all.rs index ccdc467866..b2220da895 100644 --- a/src/rust/src/soroban_proto_all.rs +++ b/src/rust/src/soroban_proto_all.rs @@ -43,13 +43,13 @@ pub(crate) use p27 as soroban_curr; // only compatible with with, say, a rust Dyn interface like Box). pub(crate) mod protocol_agnostic { pub(crate) fn make_error(msg: &'static str) -> Box { - super::p24::soroban_proto_any::CoreHostError::General(msg.into()).into() + super::soroban_curr::soroban_proto_any::CoreHostError::General(msg.into()).into() } // The i128 functions are protocol-agnostic because they're too simple to // ever plausibly change. If they ever _do_ change we can switch this (and // the callers) to pass a protocol number but it seems unlikely. - pub(crate) use super::p24::soroban_env_host::xdr::int128_helpers; + pub(crate) use super::soroban_curr::soroban_env_host::xdr::int128_helpers; } #[path = "."] @@ -392,6 +392,7 @@ pub(crate) mod p26 { } } +#[cfg(not(feature = "fastdev"))] #[path = "."] pub(crate) mod p25 { pub(crate) extern crate soroban_env_host_p25; @@ -565,6 +566,7 @@ pub(crate) mod p25 { } } +#[cfg(not(feature = "fastdev"))] #[path = "."] pub(crate) mod p24 { pub(crate) extern crate soroban_env_host_p24; @@ -738,6 +740,7 @@ pub(crate) mod p24 { } } +#[cfg(not(feature = "fastdev"))] #[path = "."] pub(crate) mod p23 { pub(crate) extern crate soroban_env_host_p23; @@ -911,6 +914,7 @@ pub(crate) mod p23 { } } +#[cfg(not(feature = "fastdev"))] #[path = "."] pub(crate) mod p22 { pub(crate) extern crate soroban_env_host_p22; @@ -1118,6 +1122,7 @@ pub(crate) mod p22 { } } +#[cfg(not(feature = "fastdev"))] #[path = "."] pub(crate) mod p21 { pub(crate) extern crate soroban_env_host_p21; @@ -1462,10 +1467,15 @@ macro_rules! proto_versioned_functions_for_module { // NB: this list should be in ascending order. Out of order will cause // an assert to fail in the by-protocol-number lookup function below. const HOST_MODULES: &'static [HostModule] = &[ + #[cfg(not(feature = "fastdev"))] proto_versioned_functions_for_module!(p21), + #[cfg(not(feature = "fastdev"))] proto_versioned_functions_for_module!(p22), + #[cfg(not(feature = "fastdev"))] proto_versioned_functions_for_module!(p23), + #[cfg(not(feature = "fastdev"))] proto_versioned_functions_for_module!(p24), + #[cfg(not(feature = "fastdev"))] proto_versioned_functions_for_module!(p25), proto_versioned_functions_for_module!(p26), proto_versioned_functions_for_module!(p27), @@ -1495,13 +1505,27 @@ pub(crate) fn get_host_module_for_protocol( #[test] fn protocol_dispatches_as_expected() { - assert_eq!(get_host_module_for_protocol(20, 20).unwrap().max_proto, 21); - assert_eq!(get_host_module_for_protocol(21, 21).unwrap().max_proto, 21); - assert_eq!(get_host_module_for_protocol(22, 22).unwrap().max_proto, 22); - assert_eq!(get_host_module_for_protocol(23, 23).unwrap().max_proto, 23); - assert_eq!(get_host_module_for_protocol(24, 24).unwrap().max_proto, 24); - assert_eq!(get_host_module_for_protocol(25, 25).unwrap().max_proto, 25); - assert_eq!(get_host_module_for_protocol(26, 26).unwrap().max_proto, 26); + #[cfg(not(feature = "fastdev"))] + { + assert_eq!(get_host_module_for_protocol(20, 20).unwrap().max_proto, 21); + assert_eq!(get_host_module_for_protocol(21, 21).unwrap().max_proto, 21); + assert_eq!(get_host_module_for_protocol(22, 22).unwrap().max_proto, 22); + assert_eq!(get_host_module_for_protocol(23, 23).unwrap().max_proto, 23); + assert_eq!(get_host_module_for_protocol(24, 24).unwrap().max_proto, 24); + assert_eq!(get_host_module_for_protocol(25, 25).unwrap().max_proto, 25); + assert_eq!(get_host_module_for_protocol(26, 26).unwrap().max_proto, 26); + } + + #[cfg(feature = "fastdev")] + { + assert_eq!(get_host_module_for_protocol(20, 20).unwrap().max_proto, 26); + assert_eq!(get_host_module_for_protocol(26, 26).unwrap().max_proto, 26); + } + + #[cfg(all(feature = "fastdev", feature = "next"))] + { + assert_eq!(get_host_module_for_protocol(27, 27).unwrap().max_proto, 27); + } // No protocols past the max known. let last_proto = HOST_MODULES.last().unwrap().max_proto; diff --git a/src/rust/src/soroban_test_extra_protocol.rs b/src/rust/src/soroban_test_extra_protocol.rs index ec5e8c787b..7cc99bd5d1 100644 --- a/src/rust/src/soroban_test_extra_protocol.rs +++ b/src/rust/src/soroban_test_extra_protocol.rs @@ -8,10 +8,13 @@ use std::str::FromStr; use crate::{ log::partition::TX, - soroban_proto_all::{get_host_module_for_protocol, p22, HostModule}, + soroban_proto_all::{get_host_module_for_protocol, HostModule}, CxxBuf, CxxLedgerInfo, CxxRentFeeConfiguration, InvokeHostFunctionOutput, RustBuf, SorobanModuleCache, }; + +#[cfg(not(feature = "fastdev"))] +use crate::soroban_proto_all::p22; use log::{info, warn}; pub(super) fn maybe_invoke_host_function_again_and_compare_outputs( @@ -86,6 +89,7 @@ fn modify_resources_for_extra_test_execution( new_protocol: u32, ) -> Result<(), Box> { match new_protocol { + #[cfg(not(feature = "fastdev"))] 22 => { use p22::soroban_proto_any::{ inplace_modify_cxxbuf_encoded_type, xdr::SorobanResources, From a4303e58bbec5deb8aaa79af81986526a5a8c9da Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Thu, 28 May 2026 21:28:36 -0700 Subject: [PATCH 2/4] Make fastdev a superset of next --- CONTRIBUTING.md | 6 ++++++ configure.ac | 6 ++++++ src/rust/Cargo.toml | 3 ++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55016a2a04..0109554547 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -351,3 +351,9 @@ It is fine for debugging though. In practice those different versions of transitive dependencies are rarely "all that different". You will _probably_ not be able to observe any differences. We just don't want to chance it in production. + +To reduce the set of possible configurations and flags, fastdev also acts as +a superset of `--enable-next-protocol-version-unsafe-for-production` (i.e. it +also turns on the `next` feature and links in whatever the next-protocol soroban +host is). + diff --git a/configure.ac b/configure.ac index 2de75e93e9..01ed39746e 100644 --- a/configure.ac +++ b/configure.ac @@ -572,6 +572,12 @@ AM_CONDITIONAL(USE_SPDLOG, [test x$enable_spdlog != xno]) AC_ARG_ENABLE(next-protocol-version-unsafe-for-production, AS_HELP_STRING([--enable-next-protocol-version-unsafe-for-production], [Enable next protocol version UNSAFE FOR PRODUCTION])) +AS_IF([test "x$enable_fastdev_unsafe_for_production" = "xyes"], [ + AS_IF([test "x$enable_next_protocol_version_unsafe_for_production" != "xyes"], [ + AC_MSG_NOTICE([enabling next protocol version due to fastdev build profile]) + ]) + enable_next_protocol_version_unsafe_for_production=yes +]) AM_CONDITIONAL(ENABLE_NEXT_PROTOCOL_VERSION_UNSAFE_FOR_PRODUCTION, [test x$enable_next_protocol_version_unsafe_for_production = xyes]) diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 92d7979136..22e9bc115e 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -197,7 +197,8 @@ rev = "f67e9e2f080c2cc1a332d894c42277c64845e257" # Turn on the optional fastdev build. This is typically only useful in an IDE or when # orchestrated by the build system. See note above and in docs/CONTRIBUTING.md. fastdev = ["dep:soroban-env-host-p26", - "dep:soroban-env-host-p27"] + "dep:soroban-env-host-p27", + "next"] tracy = ["dep:tracy-client"] From 19c31dc35b5d86795feb21719c5a0ec255e5dffb Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Thu, 28 May 2026 21:29:09 -0700 Subject: [PATCH 3/4] Set +nightly only when fastdev _and_ sanitizers are on. --- src/Makefile.am | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Makefile.am b/src/Makefile.am index 5789ae167f..89481d7222 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -137,11 +137,12 @@ stellar_core_SOURCES += $(GENERATED_XDRQUERY_SOURCES) util/xdrquery/XDRQueryPars BUILT_SOURCES += rust/RustBridge.h $(GENERATED_RUST_SOURCES) stellar_core_SOURCES += rust/RustBridge.h $(GENERATED_RUST_SOURCES) +RUST_TOOLCHAIN_FILE=$(top_srcdir)/rust-toolchain.toml +RUST_TOOLCHAIN_FILE_CHANNEL=$(shell sed -n 's/channel *= *"\([^"]*\)"/\1/p' $(RUST_TOOLCHAIN_FILE)) if ENABLE_FASTDEV_UNSAFE_FOR_PRODUCTION -RUST_TOOLCHAIN_CHANNEL=nightly +RUST_TOOLCHAIN_CHANNEL=$(if $(findstring -fsanitize=,$(CXXFLAGS)),nightly,$(RUST_TOOLCHAIN_FILE_CHANNEL)) else -RUST_TOOLCHAIN_FILE=$(top_srcdir)/rust-toolchain.toml -RUST_TOOLCHAIN_CHANNEL=$(shell sed -n 's/channel *= *"\([^"]*\)"/\1/p' $(RUST_TOOLCHAIN_FILE)) +RUST_TOOLCHAIN_CHANNEL=$(RUST_TOOLCHAIN_FILE_CHANNEL) endif CARGO=cargo +$(RUST_TOOLCHAIN_CHANNEL) RUSTC_WRAPPER=@RUSTC_WRAPPER@ From c3f9a7ae8c636b4d4decdd3b410c209250e2bbf4 Mon Sep 17 00:00:00 2001 From: Graydon Hoare Date: Fri, 26 Jun 2026 17:45:07 -0700 Subject: [PATCH 4/4] add new fuzz targets for scp, bucketlist and parallel-exec --- Cargo.lock | 24 +- src/Makefile.am | 19 +- src/main/CommandLine.cpp | 2 + src/rust/src/soroban_proto_all.rs | 6 +- src/test/TxTests.cpp | 26 +- src/test/TxTests.h | 14 +- src/test/fuzz/FuzzMain.cpp | 2 + src/test/fuzz/ScopedCatchResultCapture.h | 208 +++ .../fuzz/targets/BucketListFuzzTarget.cpp | 528 +++++++ .../fuzz/targets/ParallelTxFuzzTarget.cpp | 921 ++++++++++++ src/test/fuzz/targets/SCPFuzzTarget.cpp | 1267 +++++++++++++++++ 11 files changed, 3000 insertions(+), 17 deletions(-) create mode 100644 src/test/fuzz/ScopedCatchResultCapture.h create mode 100644 src/test/fuzz/targets/BucketListFuzzTarget.cpp create mode 100644 src/test/fuzz/targets/ParallelTxFuzzTarget.cpp create mode 100644 src/test/fuzz/targets/SCPFuzzTarget.cpp diff --git a/Cargo.lock b/Cargo.lock index 414b4f1b1f..cff23551d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1542,7 +1542,7 @@ dependencies = [ "soroban-env-macros 26.0.0", "soroban-wasmi", "static_assertions", - "stellar-xdr 26.0.0", + "stellar-xdr 26.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasmparser", ] @@ -1558,7 +1558,7 @@ dependencies = [ "soroban-env-macros 26.1.2", "soroban-wasmi", "static_assertions", - "stellar-xdr 26.0.0", + "stellar-xdr 26.0.0 (git+https://github.com/stellar/rs-stellar-xdr?rev=a749b69b3471aec0c20ec431b0297f3414d66421)", "wasmparser", ] @@ -1886,7 +1886,7 @@ dependencies = [ "quote", "serde", "serde_json", - "stellar-xdr 26.0.0", + "stellar-xdr 26.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "syn 2.0.39", ] @@ -1900,7 +1900,7 @@ dependencies = [ "quote", "serde", "serde_json", - "stellar-xdr 26.0.0", + "stellar-xdr 26.0.0 (git+https://github.com/stellar/rs-stellar-xdr?rev=a749b69b3471aec0c20ec431b0297f3414d66421)", "syn 2.0.39", ] @@ -1924,7 +1924,7 @@ dependencies = [ "arbitrary", "soroban-env-common 26.0.0", "soroban-env-macros 26.0.0", - "stellar-xdr 26.0.0", + "stellar-xdr 26.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "wasm-encoder", "wasmparser", ] @@ -2130,6 +2130,20 @@ dependencies = [ "stellar-strkey 0.0.13", ] +[[package]] +name = "stellar-xdr" +version = "26.0.0" +source = "git+https://github.com/stellar/rs-stellar-xdr?rev=a749b69b3471aec0c20ec431b0297f3414d66421#a749b69b3471aec0c20ec431b0297f3414d66421" +dependencies = [ + "base64 0.22.1", + "crate-git-revision", + "escape-bytes", + "ethnum", + "hex", + "sha2", + "stellar-strkey 0.0.13", +] + [[package]] name = "stellar-xdr" version = "27.0.0" diff --git a/src/Makefile.am b/src/Makefile.am index 89481d7222..3d715756aa 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -575,10 +575,10 @@ FUZZ_LINKER_FLAGS = -Wl,--whole-archive,libfuzz_common.a,--no-whole-archive $(if # - Create regression test target # List of fuzz targets (lowercase names) -FUZZ_TARGETS = tx overlay soroban_expr soroban_wasmi +FUZZ_TARGETS = tx overlay scp parallel_tx bucketlist soroban_expr soroban_wasmi # Fuzz binaries are noinst because they're development tools -noinst_PROGRAMS = fuzz_tx fuzz_overlay fuzz_soroban_expr fuzz_soroban_wasmi +noinst_PROGRAMS = fuzz_tx fuzz_overlay fuzz_scp fuzz_parallel_tx fuzz_bucketlist fuzz_soroban_expr fuzz_soroban_wasmi # Each fuzz target only compiles FuzzMain.cpp (with its specific -DFUZZ_TARGET_NAME) # and links against the shared libfuzz_common.a library. @@ -592,6 +592,21 @@ fuzz_overlay_CXXFLAGS = $(FUZZ_CXXFLAGS) -DFUZZ_TARGET_NAME=\"overlay\" fuzz_overlay_LDADD = $(FUZZ_LIBS) fuzz_overlay_LDFLAGS = $(FUZZ_LINKER_FLAGS) +fuzz_scp_SOURCES = $(FUZZ_MAIN) +fuzz_scp_CXXFLAGS = $(FUZZ_CXXFLAGS) -DFUZZ_TARGET_NAME=\"scp\" +fuzz_scp_LDADD = $(FUZZ_LIBS) +fuzz_scp_LDFLAGS = $(FUZZ_LINKER_FLAGS) + +fuzz_parallel_tx_SOURCES = $(FUZZ_MAIN) +fuzz_parallel_tx_CXXFLAGS = $(FUZZ_CXXFLAGS) -DFUZZ_TARGET_NAME=\"parallel_tx\" +fuzz_parallel_tx_LDADD = $(FUZZ_LIBS) +fuzz_parallel_tx_LDFLAGS = $(FUZZ_LINKER_FLAGS) + +fuzz_bucketlist_SOURCES = $(FUZZ_MAIN) +fuzz_bucketlist_CXXFLAGS = $(FUZZ_CXXFLAGS) -DFUZZ_TARGET_NAME=\"bucketlist\" +fuzz_bucketlist_LDADD = $(FUZZ_LIBS) +fuzz_bucketlist_LDFLAGS = $(FUZZ_LINKER_FLAGS) + # Soroban fuzz targets (require --features fuzz in Rust build) fuzz_soroban_expr_SOURCES = $(FUZZ_MAIN) fuzz_soroban_expr_CXXFLAGS = $(FUZZ_CXXFLAGS) -DFUZZ_TARGET_NAME=\"soroban_expr\" diff --git a/src/main/CommandLine.cpp b/src/main/CommandLine.cpp index fda43b6292..0f27fb3790 100644 --- a/src/main/CommandLine.cpp +++ b/src/main/CommandLine.cpp @@ -49,6 +49,7 @@ #include "simulation/ApplyLoad.h" #include "test/TestUtils.h" #include "test/fuzz/FuzzTargetRegistry.h" +#include "test/fuzz/ScopedCatchResultCapture.h" #include "test/test.h" #endif @@ -1793,6 +1794,7 @@ runFuzz(CommandLineArgs const& args) if (actual > 0) { data.resize(actual); + ScopedCatchResultCapture catchCapture; target->run(data.data(), data.size()); } diff --git a/src/rust/src/soroban_proto_all.rs b/src/rust/src/soroban_proto_all.rs index b2220da895..6f6660a76b 100644 --- a/src/rust/src/soroban_proto_all.rs +++ b/src/rust/src/soroban_proto_all.rs @@ -220,6 +220,7 @@ pub(crate) mod p27 { } } +#[cfg(not(feature = "fastdev"))] #[path = "."] pub(crate) mod p26 { pub(crate) extern crate soroban_env_host_p26; @@ -1477,6 +1478,7 @@ const HOST_MODULES: &'static [HostModule] = &[ proto_versioned_functions_for_module!(p24), #[cfg(not(feature = "fastdev"))] proto_versioned_functions_for_module!(p25), + #[cfg(not(feature = "fastdev"))] proto_versioned_functions_for_module!(p26), proto_versioned_functions_for_module!(p27), ]; @@ -1518,8 +1520,8 @@ fn protocol_dispatches_as_expected() { #[cfg(feature = "fastdev")] { - assert_eq!(get_host_module_for_protocol(20, 20).unwrap().max_proto, 26); - assert_eq!(get_host_module_for_protocol(26, 26).unwrap().max_proto, 26); + assert_eq!(get_host_module_for_protocol(20, 20).unwrap().max_proto, 27); + assert_eq!(get_host_module_for_protocol(27, 27).unwrap().max_proto, 27); } #[cfg(all(feature = "fastdev", feature = "next"))] diff --git a/src/test/TxTests.cpp b/src/test/TxTests.cpp index cd5b853a2d..7f7dda80cf 100644 --- a/src/test/TxTests.cpp +++ b/src/test/TxTests.cpp @@ -552,7 +552,8 @@ closeLedgerOn(Application& app, int day, int month, int year, TransactionResultSet closeLedger(Application& app, std::vector const& txs, - ParallelSorobanOrder const& parallelSorobanOrder) + ParallelSorobanOrder const& parallelSorobanOrder, + ApplyOrderTxCallback const& applyOrderTxCallback) { auto lastCloseTime = app.getLedgerManager() .getLastClosedLedgerHeader() @@ -561,12 +562,14 @@ closeLedger(Application& app, std::vector const& txs, auto nextLedgerSeq = app.getLedgerManager().getLastClosedLedgerNum() + 1; return closeLedgerOn(app, nextLedgerSeq, lastCloseTime, txs, true, - emptyUpgradeSteps, parallelSorobanOrder); + emptyUpgradeSteps, parallelSorobanOrder, + applyOrderTxCallback); } TransactionResultSet closeLedger(Application& app, std::vector const& txs, - bool strictOrder, xdr::xvector const& upgrades) + bool strictOrder, xdr::xvector const& upgrades, + ApplyOrderTxCallback const& applyOrderTxCallback) { auto lastCloseTime = app.getLedgerManager() .getLastClosedLedgerHeader() @@ -575,14 +578,15 @@ closeLedger(Application& app, std::vector const& txs, auto nextLedgerSeq = app.getLedgerManager().getLastClosedLedgerNum() + 1; return closeLedgerOn(app, nextLedgerSeq, lastCloseTime, txs, strictOrder, - upgrades); + upgrades, {}, applyOrderTxCallback); } TransactionResultSet closeLedgerOn(Application& app, uint32 ledgerSeq, TimePoint closeTime, std::vector const& txs, bool strictOrder, xdr::xvector const& upgrades, - ParallelSorobanOrder const& parallelSorobanOrder) + ParallelSorobanOrder const& parallelSorobanOrder, + ApplyOrderTxCallback const& applyOrderTxCallback) { // Ensure that parallelSorobanOrder is only used with strictOrder releaseAssert((parallelSorobanOrder.empty() || strictOrder)); @@ -636,6 +640,18 @@ closeLedgerOn(Application& app, uint32 ledgerSeq, TimePoint closeTime, // themselves maybe intentionally invalid for testing purpose. releaseAssert(txSet.second->checkValid(app, 0, 0)); } + if (applyOrderTxCallback) + { + size_t txIndex = 0; + for (auto const& phase : txSet.second->getPhasesInApplyOrder()) + { + for (auto const& tx : phase) + { + applyOrderTxCallback(txIndex, tx); + ++txIndex; + } + } + } app.getHerder().externalizeValue(txSet.first, ledgerSeq, closeTime, upgrades); while (app.getLedgerManager().getLastClosedLedgerNum() < ledgerSeq) diff --git a/src/test/TxTests.h b/src/test/TxTests.h index b73fd2bb94..8d914b543e 100644 --- a/src/test/TxTests.h +++ b/src/test/TxTests.h @@ -8,7 +8,9 @@ #include "herder/LedgerCloseData.h" #include "herder/Upgrades.h" #include "overlay/StellarXDR.h" +#include "transactions/TransactionFrameBase.h" #include "transactions/test/TransactionTestFrame.h" +#include #include namespace stellar @@ -23,6 +25,9 @@ class TestAccount; namespace txtest { +using ApplyOrderTxCallback = + std::function; + struct ExpectedOpResult { OperationResult mOperationResult; @@ -88,11 +93,13 @@ TransactionResultSet closeLedger(Application& app, std::vector const& txs = {}, bool strictOrder = false, - xdr::xvector const& upgrades = emptyUpgradeSteps); + xdr::xvector const& upgrades = emptyUpgradeSteps, + ApplyOrderTxCallback const& applyOrderTxCallback = {}); TransactionResultSet closeLedger(Application& app, std::vector const& txs, - ParallelSorobanOrder const& parallelSorobanOrder); + ParallelSorobanOrder const& parallelSorobanOrder, + ApplyOrderTxCallback const& applyOrderTxCallback = {}); TransactionResultSet closeLedgerOn(Application& app, int day, int month, int year, @@ -104,7 +111,8 @@ closeLedgerOn(Application& app, uint32 ledgerSeq, TimePoint closeTime, std::vector const& txs = {}, bool strictOrder = false, xdr::xvector const& upgrades = emptyUpgradeSteps, - ParallelSorobanOrder const& parallelSorobanOrder = {}); + ParallelSorobanOrder const& parallelSorobanOrder = {}, + ApplyOrderTxCallback const& applyOrderTxCallback = {}); TransactionResultSet closeLedger(Application& app, TxSetXDRFrameConstPtr txSet); diff --git a/src/test/fuzz/FuzzMain.cpp b/src/test/fuzz/FuzzMain.cpp index 7d1652d67a..a7473c09f8 100644 --- a/src/test/fuzz/FuzzMain.cpp +++ b/src/test/fuzz/FuzzMain.cpp @@ -33,6 +33,7 @@ #endif #include "test/fuzz/FuzzTargetRegistry.h" +#include "test/fuzz/ScopedCatchResultCapture.h" #include "util/Logging.h" #include "util/Math.h" @@ -95,6 +96,7 @@ LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) } // Run the target once + stellar::ScopedCatchResultCapture catchCapture; switch (gFuzzTarget->run(data, size)) { case stellar::FuzzResultCode::FUZZ_SUCCESS: diff --git a/src/test/fuzz/ScopedCatchResultCapture.h b/src/test/fuzz/ScopedCatchResultCapture.h new file mode 100644 index 0000000000..18acda98b7 --- /dev/null +++ b/src/test/fuzz/ScopedCatchResultCapture.h @@ -0,0 +1,208 @@ +// Copyright 2026 Stellar Development Foundation and contributors. Licensed +// under the Apache License, Version 2.0. See the COPYING file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +#pragma once + +#include "test/Catch2.h" + +#include +#include + +namespace stellar +{ + +// Standalone fuzz entry points do not run inside Catch's test runner, but many +// test helpers used by fuzz targets contain REQUIRE/CHECK assertions. Without a +// result capture installed, those assertions fail with Catch's internal "No +// result capture instance" error instead of reporting the real assertion. +// +// Install this at non-Catch fuzz entry points, such as FuzzMain or fuzz-one, +// around target->run(). It is intentionally a no-op when a real Catch capture is +// already present, so smoke/corpus unit tests keep their normal reporting. +class ScopedCatchResultCapture : public Catch::IResultCapture +{ + public: + ScopedCatchResultCapture() + : mPreviousCapture(Catch::getCurrentContext().getResultCapture()) + { + if (mPreviousCapture == nullptr) + { + Catch::getCurrentMutableContext().setResultCapture(this); + mInstalled = true; + } + } + + ~ScopedCatchResultCapture() override + { + if (mInstalled) + { + Catch::getCurrentMutableContext().setResultCapture(mPreviousCapture); + } + } + + bool + sectionStarted(Catch::SectionInfo const&, Catch::Counts&) override + { + return true; + } + + void + sectionEnded(Catch::SectionEndInfo const&) override + { + } + + void + sectionEndedEarly(Catch::SectionEndInfo const&) override + { + } + + Catch::IGeneratorTracker& + acquireGeneratorTracker(Catch::StringRef, + Catch::SourceLineInfo const&) override + { + throw std::runtime_error("generators are unsupported in fuzz target"); + } + +#if defined(CATCH_CONFIG_ENABLE_BENCHMARKING) + void + benchmarkPreparing(std::string const&) override + { + } + + void + benchmarkStarting(Catch::BenchmarkInfo const&) override + { + } + + void + benchmarkEnded(Catch::BenchmarkStats<> const&) override + { + } + + void + benchmarkFailed(std::string const&) override + { + throw std::runtime_error("benchmark failed in fuzz target"); + } +#endif + + void + pushScopedMessage(Catch::MessageInfo const&) override + { + } + + void + popScopedMessage(Catch::MessageInfo const&) override + { + } + + void + emplaceUnscopedMessage(Catch::MessageBuilder const&) override + { + } + + void + handleFatalErrorCondition(Catch::StringRef message) override + { + throw std::runtime_error(static_cast(message)); + } + + void + handleExpr(Catch::AssertionInfo const& info, + Catch::ITransientExpression const& expr, + Catch::AssertionReaction& reaction) override + { + mLastAssertionPassed = + expr.getResult() != Catch::isFalseTest(info.resultDisposition); + if (!mLastAssertionPassed) + { + reaction.shouldThrow = true; + } + } + + void + handleMessage(Catch::AssertionInfo const&, Catch::ResultWas::OfType result, + Catch::StringRef const& message, + Catch::AssertionReaction& reaction) override + { + mLastAssertionPassed = Catch::isOk(result); + if (!mLastAssertionPassed) + { + reaction.shouldThrow = true; + mLastMessage = static_cast(message); + } + } + + void + handleUnexpectedExceptionNotThrown(Catch::AssertionInfo const&, + Catch::AssertionReaction& reaction) override + { + mLastAssertionPassed = false; + reaction.shouldThrow = true; + } + + void + handleUnexpectedInflightException(Catch::AssertionInfo const&, + std::string const& message, + Catch::AssertionReaction& reaction) override + { + mLastAssertionPassed = false; + reaction.shouldThrow = true; + mLastMessage = message; + } + + void + handleIncomplete(Catch::AssertionInfo const&) override + { + mLastAssertionPassed = false; + } + + void + handleNonExpr(Catch::AssertionInfo const&, Catch::ResultWas::OfType result, + Catch::AssertionReaction& reaction) override + { + mLastAssertionPassed = Catch::isOk(result); + if (!mLastAssertionPassed) + { + reaction.shouldThrow = true; + } + } + + bool + lastAssertionPassed() override + { + return mLastAssertionPassed; + } + + void + assertionPassed() override + { + mLastAssertionPassed = true; + } + + std::string + getCurrentTestName() const override + { + return "fuzz target"; + } + + Catch::AssertionResult const* + getLastResult() const override + { + return nullptr; + } + + void + exceptionEarlyReported() override + { + } + + private: + Catch::IResultCapture* mPreviousCapture; + bool mInstalled{false}; + bool mLastAssertionPassed{true}; + std::string mLastMessage; +}; + +} // namespace stellar diff --git a/src/test/fuzz/targets/BucketListFuzzTarget.cpp b/src/test/fuzz/targets/BucketListFuzzTarget.cpp new file mode 100644 index 0000000000..9a555e0760 --- /dev/null +++ b/src/test/fuzz/targets/BucketListFuzzTarget.cpp @@ -0,0 +1,528 @@ +// Copyright 2026 Stellar Development Foundation and contributors. Licensed +// under the Apache License, Version 2.0. See the COPYING file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +#include "test/fuzz/FuzzTargetRegistry.h" + +#include "bucket/BucketManager.h" +#include "bucket/HotArchiveBucketList.h" +#include "bucket/LiveBucketList.h" +#include "bucket/test/BucketTestUtils.h" +#include "crypto/SHA.h" +#include "ledger/ImmutableLedgerView.h" +#include "main/Application.h" +#include "test/Catch2.h" +#include "test/fuzz/FuzzUtils.h" +#include "test/test.h" +#include "transactions/TransactionUtils.h" + +#include +#include +#include + +namespace stellar +{ +namespace +{ + +// This harness fuzzes the bucket-list storage layer directly. It decodes input +// into a small deterministic stream of live-bucket and hot-archive mutations, +// applies them with LiveBucketList::addBatch and HotArchiveBucketList::addBatch, +// and checks the resulting ApplyLedgerView against a local model. It is meant +// to catch bucket-list bugs such as stale reads after shadowing, lost updates, +// bad delete/restore tombstone handling, live/archive cross-contamination, and +// merge-order mistakes across bucket levels. +// +// It does not emulate the production Soroban expiration pipeline. In +// production, persistent entries move into the hot archive only after higher +// level ledger-close logic interprets paired TTL entries and decides that live +// entries have expired. This target starts below that abstraction boundary: an +// ARCHIVE_UPSERT means "the higher layer has already decided this entry should +// be archived." For that reason the model intentionally avoids TTL entries and +// cannot catch bugs in TTL extension, expiration discovery, or the live-to- +// archive decision process. Those belong in a higher-level Soroban archival +// lifecycle harness. + +constexpr size_t MAX_OPS = 128; +constexpr uint8_t KEY_SLOT_COUNT = 16; +constexpr size_t MAX_BATCH_CHANGES = 8; + +enum class BucketOpKind : uint8_t +{ + LIVE_UPSERT = 0, + LIVE_DELETE = 1, + ARCHIVE_UPSERT = 2, + ARCHIVE_RESTORE = 3, + FLUSH = 4, + CHECKPOINT = 5 +}; + +struct BucketOp +{ + BucketOpKind kind; + uint8_t keySlot; + uint8_t valueVariant; +}; + +struct PendingBatch +{ + std::vector initEntries; + std::vector liveEntries; + std::vector deadEntries; + std::vector archiveEntries; + std::vector restoredArchiveEntries; + std::set keys; + + bool + empty() const + { + return initEntries.empty() && liveEntries.empty() && + deadEntries.empty() && archiveEntries.empty() && + restoredArchiveEntries.empty(); + } + + size_t + size() const + { + return initEntries.size() + liveEntries.size() + deadEntries.size() + + archiveEntries.size() + restoredArchiveEntries.size(); + } + + bool + hasKey(LedgerKey const& key) const + { + return keys.find(key) != keys.end(); + } + + void + noteKey(LedgerKey const& key) + { + keys.insert(key); + } + + void + clear() + { + initEntries.clear(); + liveEntries.clear(); + deadEntries.clear(); + archiveEntries.clear(); + restoredArchiveEntries.clear(); + keys.clear(); + } +}; + +struct ExpectedState +{ + std::map live; + std::set deletedLive; + std::map archive; + std::set restoredArchive; +}; + +class ByteCursor +{ + public: + ByteCursor(uint8_t const* data, size_t size) : mData(data), mSize(size) + { + } + + bool + empty() const + { + return mPos >= mSize; + } + + uint8_t + nextU8() + { + releaseAssert(!empty()); + return mData[mPos++]; + } + + private: + uint8_t const* mData; + size_t mSize; + size_t mPos{0}; +}; + +LedgerHeader +makeHeader(uint32_t seq, uint32_t protocolVersion) +{ + LedgerHeader h; + h.ledgerSeq = seq; + h.ledgerVersion = protocolVersion; + return h; +} + +LedgerEntry +makeContractCodeEntry(uint8_t keySlot, uint8_t valueVariant, uint32_t seq) +{ + auto code = "bucketlist-fuzz-" + std::to_string(keySlot) + "-" + + std::to_string(valueVariant); + + LedgerEntry e; + e.lastModifiedLedgerSeq = seq; + e.data.type(CONTRACT_CODE); + auto& contractCode = e.data.contractCode(); + contractCode.ext.v(0); + contractCode.hash = sha256("bucketlist-fuzz-key-" + + std::to_string(keySlot)); + contractCode.code.assign(code.begin(), code.end()); + e.ext.v(0); + return e; +} + +LedgerKey +makeContractDataKey(uint8_t keySlot) +{ + LedgerKey k(CONTRACT_DATA); + auto& contractData = k.contractData(); + contractData.contract.type(SC_ADDRESS_TYPE_CONTRACT); + contractData.contract.contractId() = sha256("bucketlist-fuzz-contract"); + contractData.key = makeSymbolSCVal("bucketlist-fuzz-key-" + + std::to_string(keySlot)); + contractData.durability = PERSISTENT; + return k; +} + +LedgerEntry +makeContractDataEntry(uint8_t keySlot, uint8_t valueVariant, uint32_t seq) +{ + LedgerEntry e; + e.lastModifiedLedgerSeq = seq; + e.data.type(CONTRACT_DATA); + auto& contractData = e.data.contractData(); + contractData.ext.v(0); + contractData.contract.type(SC_ADDRESS_TYPE_CONTRACT); + contractData.contract.contractId() = sha256("bucketlist-fuzz-contract"); + contractData.key = makeSymbolSCVal("bucketlist-fuzz-key-" + + std::to_string(keySlot)); + contractData.durability = PERSISTENT; + contractData.val = makeU64SCVal(valueVariant & 0x7f); + e.ext.v(0); + return e; +} + +bool +useContractData(uint8_t valueVariant) +{ + return (valueVariant & 0x80) != 0; +} + +LedgerEntry +makeEntry(uint8_t keySlot, uint8_t valueVariant, uint32_t seq) +{ + if (useContractData(valueVariant)) + { + return makeContractDataEntry(keySlot, valueVariant, seq); + } + return makeContractCodeEntry(keySlot, valueVariant, seq); +} + +LedgerKey +keyForSlot(uint8_t keySlot, bool contractData) +{ + if (contractData) + { + return makeContractDataKey(keySlot); + } + + LedgerKey k(CONTRACT_CODE); + k.contractCode().hash = sha256("bucketlist-fuzz-key-" + + std::to_string(keySlot)); + return k; +} + +LedgerKey +keyForOp(BucketOp const& op) +{ + return keyForSlot(op.keySlot, useContractData(op.valueVariant)); +} + +std::vector +decodeOps(uint8_t const* data, size_t size) +{ + ByteCursor cursor(data, size); + std::vector ops; + ops.reserve(std::min(MAX_OPS, size / 3)); + while (!cursor.empty() && ops.size() < MAX_OPS) + { + auto kind = static_cast(cursor.nextU8() % 6); + if (cursor.empty()) + { + break; + } + auto keySlot = static_cast(cursor.nextU8() % KEY_SLOT_COUNT); + if (cursor.empty()) + { + break; + } + auto valueVariant = cursor.nextU8(); + ops.push_back({kind, keySlot, valueVariant}); + } + return ops; +} + +void +checkExpectedState(Application& app, ExpectedState const& expected) +{ + auto applyView = app.getLedgerManager().copyApplyLedgerView(); + for (auto const& [key, entry] : expected.live) + { + auto loaded = applyView.loadLiveEntry(key); + if (!loaded) + { + throw std::runtime_error("bucketlist live entry missing"); + } + if (*loaded != entry) + { + throw std::runtime_error("bucketlist live entry stale"); + } + if (applyView.loadArchiveEntry(key)) + { + throw std::runtime_error("bucketlist live entry also archived"); + } + } + + for (auto const& key : expected.deletedLive) + { + if (expected.live.find(key) == expected.live.end() && + applyView.loadLiveEntry(key)) + { + throw std::runtime_error("bucketlist deleted live entry present"); + } + } + + for (auto const& [key, entry] : expected.archive) + { + auto loaded = applyView.loadArchiveEntry(key); + if (!loaded) + { + throw std::runtime_error("bucketlist archive entry missing"); + } + if (loaded->type() != HOT_ARCHIVE_ARCHIVED || + loaded->archivedEntry() != entry) + { + throw std::runtime_error("bucketlist archive entry stale"); + } + if (applyView.loadLiveEntry(key)) + { + throw std::runtime_error("bucketlist archive entry also live"); + } + } + + for (auto const& key : expected.restoredArchive) + { + if (expected.archive.find(key) == expected.archive.end() && + applyView.loadArchiveEntry(key)) + { + throw std::runtime_error("bucketlist restored archive entry present"); + } + } + + for (uint8_t keySlot = 0; keySlot < KEY_SLOT_COUNT; ++keySlot) + { + for (bool contractData : {false, true}) + { + auto key = keyForSlot(keySlot, contractData); + if (expected.live.find(key) == expected.live.end() && + expected.deletedLive.find(key) == expected.deletedLive.end() && + applyView.loadLiveEntry(key)) + { + throw std::runtime_error("bucketlist absent live entry present"); + } + if (expected.archive.find(key) == expected.archive.end() && + expected.restoredArchive.find(key) == + expected.restoredArchive.end() && + applyView.loadArchiveEntry(key)) + { + throw std::runtime_error( + "bucketlist absent archive entry present"); + } + } + } +} + +void +flushBatch(Application& app, PendingBatch& pending, ExpectedState const& expected, + uint32_t& ledgerSeq, uint32_t protocolVersion) +{ + if (pending.empty()) + { + return; + } + + auto header = makeHeader(ledgerSeq, protocolVersion); + auto& bm = app.getBucketManager(); + bm.getLiveBucketList().addBatch(app, header.ledgerSeq, + header.ledgerVersion, pending.initEntries, + pending.liveEntries, pending.deadEntries); + bm.getHotArchiveBucketList().addBatch(app, header.ledgerSeq, + header.ledgerVersion, + pending.archiveEntries, + pending.restoredArchiveEntries); + app.getLedgerManager().updateCanonicalStateForTesting(header); + pending.clear(); + checkExpectedState(app, expected); + ++ledgerSeq; +} + +class BucketListFuzzTarget : public FuzzTarget +{ + public: + std::string + name() const override + { + return "bucketlist"; + } + + std::string + description() const override + { + return "Fuzz live/hot-archive bucketlist transitions with snapshot invariant checks"; + } + + void + initialize() override + { + reinitializeAllGlobalStateForFuzzing(1); + } + + FuzzResultCode + run(uint8_t const* data, size_t size) override + { + if (data == nullptr || size < 3 || size > maxInputSize()) + { + return FuzzResultCode::FUZZ_REJECTED; + } + + auto ops = decodeOps(data, size); + if (ops.empty()) + { + return FuzzResultCode::FUZZ_REJECTED; + } + + VirtualClock clock; + auto cfg = getFuzzConfig(601); + auto app = createTestApplication( + clock, cfg); + auto protocolVersion = BucketTestUtils::getAppLedgerVersion(*app); + auto ledgerSeq = app->getLedgerManager().getLastClosedLedgerNum() + 1; + + PendingBatch pending; + ExpectedState expected; + for (auto const& op : ops) + { + auto key = keyForOp(op); + if (pending.hasKey(key)) + { + flushBatch(*app, pending, expected, ledgerSeq, protocolVersion); + } + + switch (op.kind) + { + case BucketOpKind::LIVE_UPSERT: + { + auto isUpdate = expected.live.find(key) != expected.live.end(); + auto entry = makeEntry(op.keySlot, op.valueVariant, ledgerSeq); + if (isUpdate) + { + pending.liveEntries.push_back(entry); + } + else + { + pending.initEntries.push_back(entry); + } + expected.live[key] = entry; + expected.deletedLive.erase(key); + expected.archive.erase(key); + expected.restoredArchive.insert(key); + pending.restoredArchiveEntries.push_back(key); + pending.noteKey(key); + break; + } + case BucketOpKind::LIVE_DELETE: + pending.deadEntries.push_back(key); + expected.live.erase(key); + expected.deletedLive.insert(key); + pending.noteKey(key); + break; + case BucketOpKind::ARCHIVE_UPSERT: + { + auto entry = makeEntry(op.keySlot, op.valueVariant, ledgerSeq); + pending.archiveEntries.push_back(entry); + expected.archive[key] = entry; + expected.restoredArchive.erase(key); + expected.live.erase(key); + expected.deletedLive.insert(key); + pending.deadEntries.push_back(key); + pending.noteKey(key); + break; + } + case BucketOpKind::ARCHIVE_RESTORE: + pending.restoredArchiveEntries.push_back(key); + expected.archive.erase(key); + expected.restoredArchive.insert(key); + pending.noteKey(key); + break; + case BucketOpKind::FLUSH: + flushBatch(*app, pending, expected, ledgerSeq, protocolVersion); + break; + case BucketOpKind::CHECKPOINT: + flushBatch(*app, pending, expected, ledgerSeq, protocolVersion); + checkExpectedState(*app, expected); + break; + } + + if (pending.size() >= MAX_BATCH_CHANGES) + { + flushBatch(*app, pending, expected, ledgerSeq, protocolVersion); + } + } + + flushBatch(*app, pending, expected, ledgerSeq, protocolVersion); + checkExpectedState(*app, expected); + app->gracefulStop(); + + return FuzzResultCode::FUZZ_SUCCESS; + } + + void + shutdown() override + { + } + + size_t + maxInputSize() const override + { + return 4 * 1024; + } + + std::vector + generateSeedInput() override + { + return { + static_cast(BucketOpKind::LIVE_UPSERT), 0, 10, + static_cast(BucketOpKind::FLUSH), 0, 0, + static_cast(BucketOpKind::LIVE_UPSERT), 0, 11, + static_cast(BucketOpKind::LIVE_UPSERT), 1, 20, + static_cast(BucketOpKind::FLUSH), 0, 0, + static_cast(BucketOpKind::LIVE_DELETE), 0, 0, + static_cast(BucketOpKind::ARCHIVE_UPSERT), 2, 30, + static_cast(BucketOpKind::FLUSH), 0, 0, + static_cast(BucketOpKind::LIVE_UPSERT), 0, 12, + static_cast(BucketOpKind::ARCHIVE_RESTORE), 2, 0, + static_cast(BucketOpKind::LIVE_UPSERT), 3, 0x81, + static_cast(BucketOpKind::ARCHIVE_UPSERT), 4, 0x82, + static_cast(BucketOpKind::CHECKPOINT), 0, 0, + }; + } +}; + +REGISTER_FUZZ_TARGET( + BucketListFuzzTarget, "bucketlist", + "Fuzz live/hot-archive bucketlist transitions with snapshot invariant checks"); + +} // namespace +} // namespace stellar diff --git a/src/test/fuzz/targets/ParallelTxFuzzTarget.cpp b/src/test/fuzz/targets/ParallelTxFuzzTarget.cpp new file mode 100644 index 0000000000..1bed68e455 --- /dev/null +++ b/src/test/fuzz/targets/ParallelTxFuzzTarget.cpp @@ -0,0 +1,921 @@ +// Copyright 2026 Stellar Development Foundation and contributors. Licensed +// under the Apache License, Version 2.0. See the COPYING file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +#include "test/fuzz/FuzzTargetRegistry.h" + +#include "ledger/LedgerTxn.h" +#include "test/Catch2.h" +#include "test/TestUtils.h" +#include "test/TxTests.h" +#include "test/fuzz/FuzzUtils.h" +#include "transactions/TransactionMeta.h" +#include "transactions/test/SorobanTxTestUtils.h" + +#include +#include +#include +#include +#include +#include + +namespace stellar +{ +namespace +{ + +// This harness fuzzes Soroban intra-ledger parallel apply at the ledger-close +// level. It builds a fixed-size deterministic ledger of real storage-contract +// transactions, runs the same transaction set with different +// mLedgerMaxDependentTxClusters settings, and compares the serial and parallel +// outcomes. The oracle checks order-independent result codes, normalized write +// effects in transaction metadata, final contract-storage snapshots, and the +// values returned by HAS/GET operations in actual apply order. +// +// The target is meant to catch applyParallelPhase bugs such as bad dependency +// clustering, stale reads between transactions, lost or misordered writes, +// divergent metadata, or different final storage state under parallel apply. It +// deliberately keeps the workload narrow: current-protocol Soroban storage +// operations generated from deterministic fuzz bytes. It does not try to fuzz +// transaction validation, fee/resource accounting in general, arbitrary host +// functions, protocol-upgrade behavior, or non-Soroban ledger entries. Those are +// either above this comparison point or would make the differential oracle much +// less precise. + +constexpr size_t MIN_TX_COUNT = 32; +constexpr size_t MAX_TX_COUNT = 32; +constexpr uint32_t SHARED_STORAGE_KEY_COUNT = 16; +constexpr uint32_t TOTAL_STORAGE_KEY_COUNT = + SHARED_STORAGE_KEY_COUNT + static_cast(MAX_TX_COUNT); +constexpr size_t ENCODED_TX_PLAN_SIZE = 15; +constexpr int SERIAL_CONFIG_INSTANCE = 701; +constexpr int PARALLEL_CONFIG_INSTANCE = 702; + +class ByteCursor +{ + public: + ByteCursor(uint8_t const* data, size_t size) : mData(data), mSize(size) + { + } + + uint8_t + nextU8() + { + auto out = mData[mPos % mSize]; + ++mPos; + return out; + } + + uint64_t + nextU64() + { + uint64_t out = 0; + for (size_t i = 0; i < 8; ++i) + { + out = (out << 8) | nextU8(); + } + return out; + } + + private: + uint8_t const* mData; + size_t mSize; + size_t mPos{0}; +}; + +enum class DependencyShape : uint8_t +{ + INDEPENDENT = 0, + READ_READ = 1, + READ_WRITE = 2, + WRITE_WRITE = 3 +}; + +enum class StorageAction : uint8_t +{ + HAS = 0, + GET = 1, + PUT = 2, + DEL = 3, + RESIZE = 4 +}; + +struct TxPlan +{ + DependencyShape dependencyShape; + StorageAction preferredAction; + uint32_t sharedKeyId; + uint64_t value; + uint32_t resizeKiloBytes; + uint32_t instructions; + uint32_t readBytes; + uint32_t writeBytes; +}; + +struct ScenarioPlan +{ + std::vector txPlans; +}; + +struct RunResult +{ + std::vector submittedTxHashes; + std::vector applyOrderTxHashes; + TransactionResultSet applyResults; + std::vector> txMeta; + std::vector> storageSnapshot; +}; + +struct ModeledStorageValue +{ + bool exists{true}; + uint64_t value{0}; +}; + +void +appendOpaque(xdr::opaque_vec<>& out, xdr::opaque_vec<> const& opaque) +{ + out.insert(out.end(), opaque.begin(), opaque.end()); +} + +std::vector> +sortedResultCodes(TransactionResultSet const& results) +{ + // Different dependent-cluster limits can produce different generalized + // tx-set layouts, so result order and Soroban success hashes are not stable + // enough to compare directly. Compare only the order-independent pieces + // that say whether each transaction/operation succeeded in the same way. + std::vector> out; + out.reserve(results.results.size()); + for (auto const& result : results.results) + { + auto opaque = xdr::xdr_to_opaque(result.transactionHash); + appendOpaque(opaque, xdr::xdr_to_opaque(result.result.result.code())); + if (result.result.result.code() == txSUCCESS || + result.result.result.code() == txFAILED) + { + for (auto const& opResult : result.result.result.results()) + { + appendOpaque(opaque, xdr::xdr_to_opaque(opResult.code())); + if (opResult.code() == opINNER) + { + appendOpaque(opaque, + xdr::xdr_to_opaque(opResult.tr().type())); + switch (opResult.tr().type()) + { + case INVOKE_HOST_FUNCTION: + appendOpaque( + opaque, + xdr::xdr_to_opaque( + opResult.tr() + .invokeHostFunctionResult() + .code())); + break; + case EXTEND_FOOTPRINT_TTL: + appendOpaque( + opaque, + xdr::xdr_to_opaque( + opResult.tr() + .extendFootprintTTLResult() + .code())); + break; + case RESTORE_FOOTPRINT: + appendOpaque( + opaque, + xdr::xdr_to_opaque( + opResult.tr().restoreFootprintResult().code())); + break; + default: + break; + } + } + } + } + out.emplace_back(std::move(opaque)); + } + std::sort(out.begin(), out.end()); + return out; +} + +xdr::opaque_vec<> +normalizedChange(LedgerEntryChange const& change) +{ + xdr::opaque_vec<> out; + auto isStorageDataEntry = [](LedgerEntry const& entry) { + return entry.data.type() == CONTRACT_DATA && + entry.data.contractData().key.type() == SCV_SYMBOL; + }; + auto isStorageDataKey = [](LedgerKey const& key) { + return key.type() == CONTRACT_DATA && + key.contractData().key.type() == SCV_SYMBOL; + }; + + switch (change.type()) + { + case LEDGER_ENTRY_CREATED: + if (!isStorageDataEntry(change.created())) + { + break; + } + out.emplace_back(1); + appendOpaque(out, xdr::xdr_to_opaque(change.created())); + break; + case LEDGER_ENTRY_UPDATED: + if (!isStorageDataEntry(change.updated())) + { + break; + } + out.emplace_back(1); + appendOpaque(out, xdr::xdr_to_opaque(change.updated())); + break; + case LEDGER_ENTRY_REMOVED: + if (!isStorageDataKey(change.removed())) + { + break; + } + out.emplace_back(2); + appendOpaque(out, xdr::xdr_to_opaque(change.removed())); + break; + case LEDGER_ENTRY_RESTORED: + if (!isStorageDataEntry(change.restored())) + { + break; + } + out.emplace_back(1); + appendOpaque(out, xdr::xdr_to_opaque(change.restored())); + break; + case LEDGER_ENTRY_STATE: + break; + } + return out; +} + +std::vector> +sortedTxMetaPairs(TransactionResultSet const& results, + std::vector const& txMeta) +{ + if (results.results.size() != txMeta.size()) + { + throw std::runtime_error("parallel_tx metadata count mismatch"); + } + + std::vector> out; + out.reserve(txMeta.size()); + for (size_t i = 0; i < txMeta.size(); ++i) + { + auto opaque = xdr::xdr_to_opaque(results.results.at(i).transactionHash); + if (txtest::isSuccessResult(results.results.at(i).result)) + { + // Keep this oracle focused on write effects. Return values for + // shared-key reads are order-sensitive when legal cluster packing + // changes transaction order, and getLastClosedLedgerTxMeta() does + // not give us a transaction hash to pair metadata independently of + // result ordering. Storage-entry changes still catch lost writes + // without turning legal shared-key ordering into fuzz failures. + std::vector> changes; + for (size_t opIdx = 0; opIdx < txMeta.at(i).getNumOperations(); + ++opIdx) + { + for (auto const& change : + txMeta.at(i).getLedgerEntryChangesAtOp(opIdx)) + { + auto normalized = normalizedChange(change); + if (!normalized.empty()) + { + changes.emplace_back(std::move(normalized)); + } + } + } + std::sort(changes.begin(), changes.end()); + for (auto const& change : changes) + { + appendOpaque(opaque, change); + } + } + out.emplace_back(std::move(opaque)); + } + std::sort(out.begin(), out.end()); + return out; +} + +std::optional +findTxIndex(std::vector const& hashes, Hash const& hash) +{ + auto it = std::find(hashes.begin(), hashes.end(), hash); + if (it == hashes.end()) + { + return std::nullopt; + } + return static_cast(std::distance(hashes.begin(), it)); +} + +bool +successfulResultByHash(TransactionResultSet const& results, Hash const& hash) +{ + for (auto const& result : results.results) + { + if (result.transactionHash == hash) + { + return txtest::isSuccessResult(result.result); + } + } + throw std::runtime_error("parallel_tx missing apply result"); +} + +std::string storageKeyName(uint32_t keyId); + +std::vector> +snapshotStorage(Application& app, + txtest::ContractStorageTestClient& storageClient) +{ + // Shared keys intentionally create READ_WRITE/WRITE_WRITE dependencies. + // Their final values can depend on legal transaction order choices, so the + // final-state oracle checks only independent keys. Shared-key write effects + // are covered above through per-transaction metadata changes. + std::vector> out; + out.reserve(MAX_TX_COUNT); + + LedgerTxn ltx(app.getLedgerTxnRoot()); + auto& contract = storageClient.getContract(); + for (uint32_t keyId = SHARED_STORAGE_KEY_COUNT; + keyId < TOTAL_STORAGE_KEY_COUNT; ++keyId) + { + auto lk = contract.getDataKey(makeSymbolSCVal(storageKeyName(keyId)), + ContractDataDurability::PERSISTENT); + auto entry = ltx.load(lk); + xdr::opaque_vec<> opaque; + if (entry) + { + opaque = xdr::xdr_to_opaque(entry.current()); + opaque.insert(opaque.begin(), 1); + } + else + { + opaque.emplace_back(0); + } + out.emplace_back(std::move(opaque)); + } + + return out; +} + +ScenarioPlan +decodeScenario(uint8_t const* data, size_t size) +{ + ByteCursor cursor(data, size); + ScenarioPlan scenario; + auto nTx = static_cast( + MIN_TX_COUNT + (cursor.nextU8() % (MAX_TX_COUNT - MIN_TX_COUNT + 1))); + scenario.txPlans.reserve(nTx); + + for (size_t i = 0; i < nTx; ++i) + { + TxPlan plan; + plan.dependencyShape = + static_cast(cursor.nextU8() % 4); + plan.preferredAction = static_cast(cursor.nextU8() % 5); + plan.sharedKeyId = cursor.nextU8() % SHARED_STORAGE_KEY_COUNT; + plan.value = cursor.nextU64(); + plan.resizeKiloBytes = 1 + (cursor.nextU8() % 3); + plan.instructions = 5'000'000 + + static_cast(cursor.nextU8()) * 20'000; + plan.readBytes = 20'000 + static_cast(cursor.nextU8()) * 128; + plan.writeBytes = + 20'000 + static_cast(cursor.nextU8()) * 512; + + scenario.txPlans.emplace_back(plan); + } + + return scenario; +} + +size_t +requiredInputSize(uint8_t nTxByte) +{ + auto nTx = static_cast(MIN_TX_COUNT + + (nTxByte % + (MAX_TX_COUNT - MIN_TX_COUNT + 1))); + return 1 + nTx * ENCODED_TX_PLAN_SIZE; +} + +uint32_t +storageKeyId(TxPlan const& plan, size_t txIndex) +{ + if (plan.dependencyShape == DependencyShape::INDEPENDENT) + { + return SHARED_STORAGE_KEY_COUNT + static_cast(txIndex); + } + return plan.sharedKeyId; +} + +std::string +storageKeyName(uint32_t keyId) +{ + return "key" + std::to_string(keyId); +} + +uint64_t +initialStorageValue(uint32_t keyId) +{ + return 10'000 + keyId; +} + +uint64_t +resizeStorageValue(TxPlan const& plan) +{ + return plan.resizeKiloBytes; +} + +bool +shouldReadOnly(TxPlan const& plan, size_t txIndex) +{ + switch (plan.dependencyShape) + { + case DependencyShape::READ_READ: + return true; + case DependencyShape::READ_WRITE: + return (txIndex % 2) == 0; + case DependencyShape::INDEPENDENT: + case DependencyShape::WRITE_WRITE: + return false; + } + return false; +} + +StorageAction +storageAction(TxPlan const& plan, size_t txIndex) +{ + if (shouldReadOnly(plan, txIndex)) + { + return plan.preferredAction == StorageAction::HAS ? StorageAction::HAS + : StorageAction::GET; + } + + if (plan.dependencyShape == DependencyShape::READ_WRITE || + plan.dependencyShape == DependencyShape::WRITE_WRITE) + { + return StorageAction::PUT; + } + + switch (plan.preferredAction) + { + case StorageAction::DEL: + case StorageAction::RESIZE: + return plan.preferredAction; + case StorageAction::HAS: + case StorageAction::GET: + case StorageAction::PUT: + return StorageAction::PUT; + } + return StorageAction::PUT; +} + +void +checkExpectedReturnValue(TransactionMetaFrame const& meta, + StorageAction action, + ModeledStorageValue const& expectedValue) +{ + auto const& returnValue = meta.getReturnValue(); + switch (action) + { + case StorageAction::HAS: + if (returnValue.type() != SCV_BOOL || + returnValue.b() != expectedValue.exists) + { + throw std::runtime_error("parallel_tx stale HAS result"); + } + break; + case StorageAction::GET: + if (!expectedValue.exists || returnValue.type() != SCV_U64 || + returnValue.u64() != expectedValue.value) + { + throw std::runtime_error("parallel_tx stale GET result"); + } + break; + case StorageAction::PUT: + case StorageAction::DEL: + case StorageAction::RESIZE: + break; + } +} + +void +checkReadReturnOracle(ScenarioPlan const& scenario, + std::vector const& submittedTxHashes, + std::vector const& applyOrderTxHashes, + TransactionResultSet const& results, + std::vector const& txMeta) +{ + if (applyOrderTxHashes.size() != txMeta.size() || + results.results.size() != txMeta.size()) + { + throw std::runtime_error("parallel_tx apply order count mismatch"); + } + + std::vector storage(TOTAL_STORAGE_KEY_COUNT); + for (uint32_t keyId = 0; keyId < TOTAL_STORAGE_KEY_COUNT; ++keyId) + { + storage.at(keyId).value = initialStorageValue(keyId); + } + + for (size_t applyIndex = 0; applyIndex < applyOrderTxHashes.size(); + ++applyIndex) + { + auto txIndex = findTxIndex(submittedTxHashes, + applyOrderTxHashes.at(applyIndex)); + if (!txIndex) + { + throw std::runtime_error("parallel_tx unknown apply-order tx"); + } + + if (!successfulResultByHash(results, applyOrderTxHashes.at(applyIndex))) + { + continue; + } + + auto const& plan = scenario.txPlans.at(*txIndex); + auto action = storageAction(plan, *txIndex); + auto keyId = storageKeyId(plan, *txIndex); + auto const expectedValue = storage.at(keyId); + checkExpectedReturnValue(txMeta.at(applyIndex), action, expectedValue); + + switch (action) + { + case StorageAction::PUT: + storage.at(keyId).exists = true; + storage.at(keyId).value = plan.value; + break; + case StorageAction::DEL: + storage.at(keyId).exists = false; + break; + case StorageAction::RESIZE: + storage.at(keyId).exists = true; + storage.at(keyId).value = resizeStorageValue(plan); + break; + case StorageAction::HAS: + case StorageAction::GET: + break; + } + } +} + +txtest::SorobanInvocationSpec +withCommonResources(txtest::SorobanInvocationSpec spec, TxPlan const& plan, + uint32_t writeBytes, size_t txIndex) +{ + return spec.setInstructions(plan.instructions) + .setReadBytes(plan.readBytes) + .setWriteBytes(writeBytes) + .setInclusionFee(1000 + static_cast(txIndex)) + .setRefundableResourceFee(50'000'000); +} + +TransactionFrameBasePtr +makeStorageTx(txtest::ContractStorageTestClient& storageClient, + TxPlan const& plan, StorageAction action, std::string const& key, + TestAccount& source, size_t txIndex) +{ + auto const durability = ContractDataDurability::PERSISTENT; + auto& contract = storageClient.getContract(); + + switch (action) + { + case StorageAction::HAS: + { + auto spec = withCommonResources(storageClient.readKeySpec(key, durability), + plan, 0, txIndex); + return contract.prepareInvocation("has_persistent", + {makeSymbolSCVal(key)}, spec) + .withExactNonRefundableResourceFee() + .createTx(&source); + } + case StorageAction::GET: + { + auto spec = withCommonResources(storageClient.readKeySpec(key, durability), + plan, 0, txIndex); + return contract.prepareInvocation("get_persistent", + {makeSymbolSCVal(key)}, spec) + .withExactNonRefundableResourceFee() + .createTx(&source); + } + case StorageAction::PUT: + { + auto spec = withCommonResources( + storageClient.writeKeySpec(key, durability), plan, plan.writeBytes, + txIndex); + return storageClient + .putInvocation(key, durability, plan.value, spec) + .withExactNonRefundableResourceFee() + .createTx(&source); + } + case StorageAction::DEL: + { + auto spec = withCommonResources(storageClient.writeKeySpec(key, durability), + plan, 0, txIndex); + return storageClient.delInvocation(key, durability, spec) + .withExactNonRefundableResourceFee() + .createTx(&source); + } + case StorageAction::RESIZE: + { + auto extendTo = 100 + static_cast(plan.value % 1000); + auto threshold = 1 + static_cast(plan.value % extendTo); + auto writeBytes = std::max(plan.writeBytes, + plan.resizeKiloBytes * 1024 + 200); + auto spec = withCommonResources(storageClient.writeKeySpec(key, durability), + plan, writeBytes, txIndex); + return storageClient + .resizeStorageAndExtendInvocation(key, plan.resizeKiloBytes, + threshold, extendTo, spec) + .withExactNonRefundableResourceFee() + .createTx(&source); + } + } + + throw std::runtime_error("unknown storage action"); +} + +std::vector +getSourceAccounts(Application& app, size_t count) +{ + std::vector accounts; + accounts.reserve(count); + for (uint32_t i = 0; i < count; ++i) + { + accounts.push_back(txtest::getGenesisAccount(app, i)); + } + return accounts; +} + +void +seedStorage(txtest::SorobanTest& test, + txtest::ContractStorageTestClient& storageClient, + std::vector& accounts) +{ + std::vector setupTxs; + setupTxs.reserve(TOTAL_STORAGE_KEY_COUNT); + + for (uint32_t keyId = 0; keyId < TOTAL_STORAGE_KEY_COUNT; ++keyId) + { + auto key = storageKeyName(keyId); + auto spec = storageClient + .writeKeySpec(key, ContractDataDurability::PERSISTENT) + .setInclusionFee(1000) + .setRefundableResourceFee(50'000'000); + setupTxs.push_back( + storageClient + .putInvocation(key, ContractDataDurability::PERSISTENT, + initialStorageValue(keyId), spec) + .withExactNonRefundableResourceFee() + .createTx(&accounts.at(keyId))); + } + + auto results = txtest::closeLedger(test.getApp(), setupTxs, true); + if (results.results.size() != setupTxs.size()) + { + throw std::runtime_error("parallel_tx setup result count mismatch"); + } + + for (auto const& result : results.results) + { + if (!txtest::isSuccessResult(result.result)) + { + throw std::runtime_error("parallel_tx setup transaction failed"); + } + } +} + +RunResult +runScenario(uint32_t dependentClusterLimit, int configInstance, + ScenarioPlan const& scenario) +{ + auto cfg = getFuzzConfig(configInstance); + cfg.GENESIS_TEST_ACCOUNT_COUNT = TOTAL_STORAGE_KEY_COUNT + MAX_TX_COUNT; + cfg.LEDGER_PROTOCOL_VERSION = Config::CURRENT_LEDGER_PROTOCOL_VERSION; + cfg.TESTING_UPGRADE_LEDGER_PROTOCOL_VERSION = + Config::CURRENT_LEDGER_PROTOCOL_VERSION; + cfg.TESTING_SOROBAN_HIGH_LIMIT_OVERRIDE = true; + + txtest::SorobanTest test(cfg, false); + modifySorobanNetworkConfig( + test.getApp(), + [dependentClusterLimit](SorobanNetworkConfig& sorobanCfg) { + sorobanCfg.mLedgerMaxDependentTxClusters = dependentClusterLimit; + sorobanCfg.mLedgerMaxTxCount = 1000; + }); + + txtest::ContractStorageTestClient storageClient(test, 10'000'000); + + auto accounts = getSourceAccounts( + test.getApp(), TOTAL_STORAGE_KEY_COUNT + scenario.txPlans.size()); + seedStorage(test, storageClient, accounts); + + std::vector txs; + txs.reserve(scenario.txPlans.size()); + for (size_t i = 0; i < scenario.txPlans.size(); ++i) + { + auto const& plan = scenario.txPlans.at(i); + auto action = storageAction(plan, i); + auto& source = accounts.at(TOTAL_STORAGE_KEY_COUNT + i); + auto keyId = storageKeyId(plan, i); + auto key = storageKeyName(keyId); + txs.push_back( + makeStorageTx(storageClient, plan, action, key, source, i)); + } + + RunResult out; + out.submittedTxHashes.reserve(txs.size()); + for (auto const& tx : txs) + { + out.submittedTxHashes.push_back(tx->getContentsHash()); + } + + out.applyOrderTxHashes.reserve(txs.size()); + auto applyOrderTxCallback = + [&out](size_t txIndex, TransactionFrameBaseConstPtr const& tx) { + if (txIndex != out.applyOrderTxHashes.size()) + { + throw std::runtime_error("parallel_tx apply order index gap"); + } + out.applyOrderTxHashes.push_back(tx->getContentsHash()); + }; + + out.applyResults = txtest::closeLedger(test.getApp(), txs, false, + emptyUpgradeSteps, + applyOrderTxCallback); + checkReadReturnOracle( + scenario, out.submittedTxHashes, out.applyOrderTxHashes, + out.applyResults, + test.getApp().getLedgerManager().getLastClosedLedgerTxMeta()); + out.txMeta = sortedTxMetaPairs( + out.applyResults, + test.getApp().getLedgerManager().getLastClosedLedgerTxMeta()); + out.storageSnapshot = snapshotStorage(test.getApp(), storageClient); + return out; +} + +class ParallelTxFuzzTarget : public FuzzTarget +{ + public: + std::string + name() const override + { + return "parallel_tx"; + } + + std::string + description() const override + { + return "Fuzz Soroban applyParallelPhase equivalence across real storage-contract workloads"; + } + + void + initialize() override + { + reinitializeAllGlobalStateForFuzzing(1); + } + + FuzzResultCode + run(uint8_t const* data, size_t size) override + { + if (data == nullptr || size == 0 || size > maxInputSize()) + { + return FuzzResultCode::FUZZ_REJECTED; + } + + if (size < requiredInputSize(data[0])) + { + return FuzzResultCode::FUZZ_REJECTED; + } + auto scenario = decodeScenario(data, size); + + auto serial = runScenario(/* dependentClusterLimit */ 1, + SERIAL_CONFIG_INSTANCE, scenario); + auto parallel = runScenario(/* dependentClusterLimit */ 8, + PARALLEL_CONFIG_INSTANCE, scenario); + + if (serial.submittedTxHashes != parallel.submittedTxHashes) + { + throw std::runtime_error("parallel_tx submitted tx order mismatch"); + } + + if (sortedResultCodes(serial.applyResults) != + sortedResultCodes(parallel.applyResults)) + { + throw std::runtime_error("parallel_tx apply result mismatch"); + } + + if (serial.txMeta != parallel.txMeta) + { + throw std::runtime_error("parallel_tx metadata mismatch"); + } + + if (serial.storageSnapshot != parallel.storageSnapshot) + { + throw std::runtime_error("parallel_tx storage snapshot mismatch"); + } + + return FuzzResultCode::FUZZ_SUCCESS; + } + + void + shutdown() override + { + } + + size_t + maxInputSize() const override + { + return 8 * 1024; + } + + std::vector + generateSeedInput() override + { + std::vector seed; + seed.reserve(1 + MAX_TX_COUNT * 15); + seed.push_back(0); + + auto appendU64 = [&seed](uint64_t v) { + for (int shift = 56; shift >= 0; shift -= 8) + { + seed.push_back(static_cast(v >> shift)); + } + }; + + auto appendPlan = [&seed, &appendU64](DependencyShape shape, + StorageAction action, + uint8_t sharedKeyId, + uint64_t value, + uint8_t resizeKiloBytes, + uint8_t instructions, + uint8_t readBytes, + uint8_t writeBytes) { + seed.push_back(static_cast(shape)); + seed.push_back(static_cast(action)); + seed.push_back(sharedKeyId); + appendU64(value); + seed.push_back(resizeKiloBytes - 1); + seed.push_back(instructions); + seed.push_back(readBytes); + seed.push_back(writeBytes); + }; + + for (size_t i = 0; i < MAX_TX_COUNT; ++i) + { + switch (i % 8) + { + case 0: + appendPlan(DependencyShape::READ_READ, StorageAction::HAS, + static_cast(i % SHARED_STORAGE_KEY_COUNT), + 100 + i, 1, i, i, i); + break; + case 1: + appendPlan(DependencyShape::READ_READ, StorageAction::GET, + static_cast((i - 1) % + SHARED_STORAGE_KEY_COUNT), + 100 + i, 1, i, i, i); + break; + case 2: + appendPlan(DependencyShape::READ_WRITE, StorageAction::GET, + static_cast(i % SHARED_STORAGE_KEY_COUNT), + 100 + i, 1, i, i, i); + break; + case 3: + appendPlan(DependencyShape::READ_WRITE, StorageAction::PUT, + static_cast((i - 1) % + SHARED_STORAGE_KEY_COUNT), + 100 + i, 1, i, i, i); + break; + case 4: + appendPlan(DependencyShape::WRITE_WRITE, StorageAction::PUT, + static_cast(i % SHARED_STORAGE_KEY_COUNT), + 100 + i, 1, i, i, i); + break; + case 5: + appendPlan(DependencyShape::WRITE_WRITE, StorageAction::PUT, + static_cast((i - 1) % + SHARED_STORAGE_KEY_COUNT), + 100 + i, 1, i, i, i); + break; + case 6: + appendPlan(DependencyShape::INDEPENDENT, StorageAction::PUT, 0, + 100 + i, 1, i, i, i); + break; + case 7: + appendPlan(DependencyShape::INDEPENDENT, StorageAction::RESIZE, + 0, 100 + i, 2, i, i, i); + break; + } + } + + return seed; + } +}; + +REGISTER_FUZZ_TARGET( + ParallelTxFuzzTarget, "parallel_tx", + "Fuzz Soroban applyParallelPhase equivalence across real storage-contract workloads"); + +} // namespace +} // namespace stellar \ No newline at end of file diff --git a/src/test/fuzz/targets/SCPFuzzTarget.cpp b/src/test/fuzz/targets/SCPFuzzTarget.cpp new file mode 100644 index 0000000000..111db7a74b --- /dev/null +++ b/src/test/fuzz/targets/SCPFuzzTarget.cpp @@ -0,0 +1,1267 @@ +// Copyright 2026 Stellar Development Foundation and contributors. Licensed +// under the Apache License, Version 2.0. See the COPYING file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +#include "test/fuzz/FuzzTargetRegistry.h" + +#include "crypto/SecretKey.h" +#include "crypto/SHA.h" +#include "scp/SCP.h" +#include "scp/SCPDriver.h" +#include "test/Catch2.h" +#include "test/TxTests.h" + +#include +#include +#include +#include +#include +#include + +namespace stellar +{ +namespace +{ + +constexpr size_t MAX_ACTIONS = 64; +constexpr size_t ACTION_ENCODED_SIZE = 8; +constexpr size_t NODE_COUNT = 3; +constexpr uint8_t SLOT_COUNT = 32; +constexpr uint8_t VALUE_COUNT = 16; +constexpr uint8_t ACTION_KIND_COUNT = 11; + +enum class FuzzValueState : uint8_t +{ + AVAILABLE_VALID = 0, + AVAILABLE_INVALID = 1, + FETCHING = 2, + MAYBE_VALID = 3, + FETCH_FAILED = 4 +}; + +enum class ScenarioActionKind : uint8_t +{ + INJECT_NOMINATE = 0, + INJECT_PREPARE = 1, + INJECT_CONFIRM = 2, + INJECT_EXTERNALIZE = 3, + RUN_TIMERS = 4, + DELIVER_QUEUED = 5, + DROP_QUEUED = 6, + DRAIN_QUEUE = 7, + SET_VALUE_STATE = 8, + SET_LOCAL_QSET = 9, + COOPERATIVE_CONVERGE = 10 +}; + +struct ScenarioAction +{ + ScenarioActionKind kind; + uint8_t nodeIndex; + uint8_t slotIndex; + uint8_t valueIndex; + uint8_t ballotCounter; + uint8_t shape; + uint8_t qSetChoice; + uint8_t timerCallbacks; +}; + +struct QueuedEnvelope +{ + size_t fromNode; + SCPEnvelope envelope; +}; + +struct ScenarioSummary +{ + size_t queuedCount{}; + size_t deliveredCount{}; + size_t droppedCount{}; + Hash digest{}; + + bool + operator==(ScenarioSummary const& other) const + { + return queuedCount == other.queuedCount && + deliveredCount == other.deliveredCount && + droppedCount == other.droppedCount && digest == other.digest; + } +}; + +size_t +targetNodeForAction(ScenarioAction const& action) +{ + return (action.shape >> 2) % NODE_COUNT; +} + +uint8_t +valueIndex(Value const& value) +{ + if (value.empty()) + { + return 0; + } + return value[0] % VALUE_COUNT; +} + +std::vector +statementValues(SCPStatement const& st) +{ + std::vector values; + switch (st.pledges.type()) + { + case SCP_ST_NOMINATE: + { + auto const& nominate = st.pledges.nominate(); + values.insert(values.end(), nominate.votes.begin(), + nominate.votes.end()); + values.insert(values.end(), nominate.accepted.begin(), + nominate.accepted.end()); + break; + } + case SCP_ST_PREPARE: + { + auto const& prepare = st.pledges.prepare(); + values.push_back(prepare.ballot.value); + if (prepare.prepared) + { + values.push_back(prepare.prepared->value); + } + if (prepare.preparedPrime) + { + values.push_back(prepare.preparedPrime->value); + } + break; + } + case SCP_ST_CONFIRM: + values.push_back(st.pledges.confirm().ballot.value); + break; + case SCP_ST_EXTERNALIZE: + values.push_back(st.pledges.externalize().commit.value); + break; + } + return values; +} + +Hash +statementQSetHash(SCPStatement const& st) +{ + switch (st.pledges.type()) + { + case SCP_ST_PREPARE: + return st.pledges.prepare().quorumSetHash; + case SCP_ST_CONFIRM: + return st.pledges.confirm().quorumSetHash; + case SCP_ST_EXTERNALIZE: + return st.pledges.externalize().commitQuorumSetHash; + case SCP_ST_NOMINATE: + return st.pledges.nominate().quorumSetHash; + } + releaseAssert(false); +} + +class ByteCursor +{ + public: + ByteCursor(uint8_t const* data, size_t size) : mData(data), mSize(size) + { + } + + bool + empty() const + { + return mPos >= mSize; + } + + size_t + remaining() const + { + return mSize - mPos; + } + + uint8_t + nextU8(uint8_t defaultValue = 0) + { + if (empty()) + { + return defaultValue; + } + return mData[mPos++]; + } + + private: + uint8_t const* mData; + size_t mSize; + size_t mPos{0}; +}; + +class FuzzSCPDriver : public SCPDriver +{ + public: + explicit FuzzSCPDriver(size_t nodeIndex, NodeID const& localNode, + SCPQuorumSet const& localQSet, + std::array* values) + : mNodeIndex(nodeIndex) + , mSCP(*this, localNode, true, localQSet) + , mValueStates(values) + { + mKnownQSets[sha256(xdr::xdr_to_opaque(localQSet))] = + std::make_shared(localQSet); + } + + void + signEnvelope(SCPEnvelope&) override + { + } + +#ifdef CAP_0083 + Value + makeEmptyTxSetValueFromValue(Value const& v) const override + { + return v; + } +#endif + + SCPQuorumSetPtr + getQSet(Hash const& qSetHash) override + { + auto it = mKnownQSets.find(qSetHash); + if (it == mKnownQSets.end()) + { + return nullptr; + } + return it->second; + } + + void + rememberQSet(SCPQuorumSet const& qSet) + { + mKnownQSets[sha256(xdr::xdr_to_opaque(qSet))] = + std::make_shared(qSet); + } + + std::optional + getTxSetDownloadWaitTime(Value const& value) const override + { + if (valueState(value) == FuzzValueState::FETCHING) + { + return std::chrono::milliseconds(100); + } + return std::nullopt; + } + + std::chrono::milliseconds + getTxSetDownloadTimeout() const override + { + return std::chrono::milliseconds(200); + } + + bool + isEnvelopeReady(SCPEnvelope const& envelope) const override + { + for (auto const& value : statementValues(envelope.statement)) + { + if (valueState(value) == FuzzValueState::FETCHING) + { + return false; + } + } + return true; + } + + void + emitEnvelope(SCPEnvelope const& envelope) override + { + mEmitted.push_back(envelope); + } + + ValidationLevel + validateValue(uint64, Value const& value, bool) const override + { + switch (valueState(value)) + { + case FuzzValueState::AVAILABLE_VALID: + return kFullyValidatedValue; + case FuzzValueState::AVAILABLE_INVALID: + case FuzzValueState::FETCH_FAILED: + return kInvalidValue; + case FuzzValueState::FETCHING: + case FuzzValueState::MAYBE_VALID: + return kMaybeValidNotCurrentValue; + } + releaseAssert(false); + } + + bool + isEmptyTxSetValue(Value const&) const override + { + return false; + } + + bool + isParallelTxSetDownloadEnabled() const override + { + return false; + } + + bool + protocolAllowsEmptyTxSetValues() const override + { + return false; + } + + Hash + getHashOf(std::vector> const& vals) const override + { + SHA256 hasher; + for (auto const& v : vals) + { + hasher.add(v); + } + return hasher.finish(); + } + + ValueWrapperPtr + combineCandidates(uint64, ValueWrapperPtrSet const& candidates) override + { + if (candidates.empty()) + { + Value v; + v.push_back(0); + return wrapValue(v); + } + return *candidates.begin(); + } + + bool + hasUpgrades(Value const&) override + { + return false; + } + + ValueWrapperPtr + stripAllUpgrades(Value const& v) override + { + return wrapValue(v); + } + + uint32_t + getUpgradeNominationTimeoutLimit() const override + { + return std::numeric_limits::max(); + } + + void + setupTimer(uint64 slotIndex, int timerID, std::chrono::milliseconds timeout, + std::function cb) override + { + if (cb) + { + stopTimer(slotIndex, timerID); + mTimers.push_back({slotIndex, timerID, + mLogicalTime + + static_cast(timeout.count()), + mNextTimerSequence++, false, std::move(cb)}); + } + } + + void + stopTimer(uint64 slotIndex, int timerID) override + { + for (auto& timer : mTimers) + { + if (timer.slotIndex == slotIndex && timer.timerID == timerID) + { + timer.canceled = true; + } + } + } + + std::chrono::milliseconds + computeTimeout(uint32 roundNumber, bool) override + { + return std::chrono::milliseconds(20 + roundNumber); + } + + void + advanceTime(uint64_t ticks) + { + mLogicalTime += ticks; + } + + void + runPendingTimers(size_t maxCallbacks, bool latestFirst = false) + { + size_t n = 0; + while (n < maxCallbacks) + { + auto timerIt = mTimers.end(); + for (auto it = mTimers.begin(); it != mTimers.end(); ++it) + { + if (it->canceled || it->deadline > mLogicalTime) + { + continue; + } + if (timerIt == mTimers.end()) + { + timerIt = it; + } + else if (latestFirst && + std::tie(it->deadline, it->sequence) > + std::tie(timerIt->deadline, timerIt->sequence)) + { + timerIt = it; + } + else if (!latestFirst && + std::tie(it->deadline, it->sequence) < + std::tie(timerIt->deadline, timerIt->sequence)) + { + timerIt = it; + } + } + + if (timerIt == mTimers.end()) + { + break; + } + + auto cb = std::move(timerIt->callback); + timerIt->canceled = true; + cb(); + ++n; + } + } + + void + blockEnvelope(SCPEnvelope const& envelope) + { + mBlockedEnvelopes.push_back(envelope); + } + + size_t + blockedCount() const + { + return mBlockedEnvelopes.size(); + } + + size_t + activeTimerCount() const + { + return static_cast( + std::count_if(mTimers.begin(), mTimers.end(), [](auto const& t) { + return !t.canceled; + })); + } + + uint64_t + highestSlot() const + { + return const_cast(mSCP).getHighestKnownSlotIndex(); + } + + std::vector + takeReadyBlocked(size_t maxEnvelopes) + { + std::vector ready; + for (auto it = mBlockedEnvelopes.begin(); + it != mBlockedEnvelopes.end() && ready.size() < maxEnvelopes;) + { + if (isEnvelopeReady(*it)) + { + ready.push_back(*it); + it = mBlockedEnvelopes.erase(it); + } + else + { + ++it; + } + } + return ready; + } + + size_t + nodeIndex() const + { + return mNodeIndex; + } + + std::vector const& + emitted() const + { + return mEmitted; + } + + bool + hasEmittedBallotForSlot(uint64_t slotIndex) const + { + return std::any_of(mEmitted.begin(), mEmitted.end(), + [&](SCPEnvelope const& envelope) { + return envelope.statement.slotIndex == + slotIndex && + envelope.statement.pledges.type() != + SCP_ST_NOMINATE; + }); + } + + std::map const& + externalized() const + { + return mExternalized; + } + + struct Summary + { + uint64_t highestSlot{}; + size_t emittedCount{}; + size_t externalizedCount{}; + size_t blockedCount{}; + size_t activeTimerCount{}; + Hash digest{}; + + bool + operator==(Summary const& other) const + { + return highestSlot == other.highestSlot && + emittedCount == other.emittedCount && + externalizedCount == other.externalizedCount && + blockedCount == other.blockedCount && + activeTimerCount == other.activeTimerCount && + digest == other.digest; + } + }; + + Summary + summarize() const + { + SHA256 hasher; + for (auto const& env : mEmitted) + { + hasher.add(xdr::xdr_to_opaque(env.statement)); + } + for (auto const& env : mBlockedEnvelopes) + { + hasher.add(xdr::xdr_to_opaque(env.statement)); + } + for (auto const& [slot, val] : mExternalized) + { + xdr::opaque_vec<> slotBytes; + slotBytes.resize(sizeof(slot)); + std::memcpy(slotBytes.data(), &slot, sizeof(slot)); + hasher.add(slotBytes); + hasher.add(val); + } + + Summary s; + s.highestSlot = highestSlot(); + s.emittedCount = mEmitted.size(); + s.externalizedCount = mExternalized.size(); + s.blockedCount = mBlockedEnvelopes.size(); + s.activeTimerCount = activeTimerCount(); + s.digest = hasher.finish(); + return s; + } + + size_t mNodeIndex; + SCP mSCP; + + private: + FuzzValueState + valueState(Value const& value) const + { + return (*mValueStates)[valueIndex(value)]; + } + + void + valueExternalized(uint64 slotIndex, Value const& value) override + { + auto existing = mExternalized.find(slotIndex); + if (existing != mExternalized.end() && existing->second != value) + { + throw std::runtime_error( + "SCP fuzz node externalized conflicting values"); + } + mExternalized[slotIndex] = value; + } + + std::map mKnownQSets; + std::vector mEmitted; + std::map mExternalized; + std::vector mBlockedEnvelopes; + std::array* mValueStates; + + struct PendingTimer + { + uint64_t slotIndex; + int timerID; + uint64_t deadline; + uint64_t sequence; + bool canceled; + std::function callback; + }; + + uint64_t mLogicalTime{0}; + uint64_t mNextTimerSequence{0}; + std::vector mTimers; +}; + +Hash +getQSetHash(SCPQuorumSet const& qSet) +{ + return sha256(xdr::xdr_to_opaque(qSet)); +} + +Value +makeValue(uint8_t valueIndex, uint8_t variant) +{ + Value v; + v.push_back(valueIndex % VALUE_COUNT); + v.push_back(variant); + return v; +} + +SCPBallot +makeBallot(uint8_t counter, Value const& value) +{ + SCPBallot ballot; + ballot.counter = 1 + (counter % 8); + ballot.value = value; + return ballot; +} + +Hash +qSetHashForChoice(std::vector const& qSetHashes, uint8_t qSetChoice) +{ + if ((qSetChoice % 5) == 0) + { + return sha256("scp-fuzz-unknown-qset-" + + std::to_string(qSetChoice)); + } + return qSetHashes[qSetChoice % qSetHashes.size()]; +} + +std::vector +decodeScenario(uint8_t const* data, size_t size) +{ + ByteCursor cursor(data, size); + std::vector actions; + while (cursor.remaining() >= ACTION_ENCODED_SIZE && + actions.size() < MAX_ACTIONS) + { + ScenarioAction action; + action.kind = static_cast(cursor.nextU8() % + ACTION_KIND_COUNT); + action.nodeIndex = cursor.nextU8() % NODE_COUNT; + action.slotIndex = cursor.nextU8() % SLOT_COUNT; + action.valueIndex = cursor.nextU8() % VALUE_COUNT; + action.ballotCounter = cursor.nextU8(); + action.shape = cursor.nextU8(); + action.qSetChoice = cursor.nextU8(); + action.timerCallbacks = cursor.nextU8(); + actions.push_back(action); + } + return actions; +} + +void +addUint64(SHA256& hasher, uint64_t value) +{ + xdr::opaque_vec<> bytes; + bytes.resize(sizeof(value)); + std::memcpy(bytes.data(), &value, sizeof(value)); + hasher.add(bytes); +} + +void +appendAction(std::vector& out, ScenarioActionKind kind, + uint8_t nodeIndex, uint8_t slotIndex, uint8_t valueIndex, + uint8_t ballotCounter, uint8_t shape, uint8_t qSetChoice, + uint8_t timerCallbacks) +{ + out.push_back(static_cast(kind)); + out.push_back(nodeIndex); + out.push_back(slotIndex); + out.push_back(valueIndex); + out.push_back(ballotCounter); + out.push_back(shape); + out.push_back(qSetChoice); + out.push_back(timerCallbacks); +} + +SCPEnvelope +makeEnvelopeFromAction(ScenarioAction const& action, + std::vector const& qSetHashes, + std::array const& nodes) +{ + auto qSetHash = qSetHashForChoice(qSetHashes, action.qSetChoice); + auto value = makeValue(action.valueIndex, action.shape); + auto otherValue = makeValue(action.valueIndex + 1, action.shape ^ 0xff); + auto ballot = makeBallot(action.ballotCounter, value); + + SCPEnvelope env; + auto& st = env.statement; + st.nodeID = nodes[action.nodeIndex % nodes.size()]; + st.slotIndex = static_cast(1 + (action.slotIndex % SLOT_COUNT)); + + switch (action.kind) + { + case ScenarioActionKind::INJECT_NOMINATE: + { + st.pledges.type(SCP_ST_NOMINATE); + auto& nominate = st.pledges.nominate(); + nominate.quorumSetHash = qSetHash; + nominate.votes.emplace_back(value); + if ((action.shape & 0x01) != 0) + { + nominate.votes.emplace_back(otherValue); + std::sort(nominate.votes.begin(), nominate.votes.end()); + } + if ((action.shape & 0x02) != 0) + { + nominate.accepted.emplace_back(value); + } + break; + } + case ScenarioActionKind::INJECT_PREPARE: + { + st.pledges.type(SCP_ST_PREPARE); + auto& prepare = st.pledges.prepare(); + prepare.quorumSetHash = qSetHash; + prepare.ballot = ballot; + if ((action.shape & 0x01) != 0) + { + prepare.prepared.activate() = ballot; + } + if ((action.shape & 0x02) != 0 && ballot.counter > 1) + { + prepare.preparedPrime.activate() = + makeBallot(ballot.counter - 2, otherValue); + } + if ((action.shape & 0x04) != 0) + { + prepare.nC = 1; + prepare.nH = ballot.counter; + } + else + { + prepare.nC = 0; + prepare.nH = 0; + } + break; + } + case ScenarioActionKind::INJECT_CONFIRM: + { + st.pledges.type(SCP_ST_CONFIRM); + auto& confirm = st.pledges.confirm(); + confirm.ballot = ballot; + confirm.nPrepared = ballot.counter; + confirm.nCommit = 1; + confirm.nH = ballot.counter; + confirm.quorumSetHash = qSetHash; + break; + } + case ScenarioActionKind::INJECT_EXTERNALIZE: + { + st.pledges.type(SCP_ST_EXTERNALIZE); + auto& externalize = st.pledges.externalize(); + externalize.commit = ballot; + externalize.nH = ballot.counter; + externalize.commitQuorumSetHash = qSetHash; + break; + } + case ScenarioActionKind::RUN_TIMERS: + case ScenarioActionKind::DELIVER_QUEUED: + case ScenarioActionKind::DROP_QUEUED: + case ScenarioActionKind::DRAIN_QUEUE: + case ScenarioActionKind::SET_VALUE_STATE: + case ScenarioActionKind::SET_LOCAL_QSET: + case ScenarioActionKind::COOPERATIVE_CONVERGE: + releaseAssert(false); + } + + env.signature.clear(); + return env; +} + +class FuzzSCPNetwork +{ + public: + FuzzSCPNetwork() + { + auto const v0 = txtest::getAccount("scp-fuzz-v0"); + auto const v1 = txtest::getAccount("scp-fuzz-v1"); + auto const v2 = txtest::getAccount("scp-fuzz-v2"); + + mNodes = {v0.getPublicKey(), v1.getPublicKey(), v2.getPublicKey()}; + + buildQSetCatalog(); + + for (size_t i = 0; i < NODE_COUNT; ++i) + { + mDrivers.emplace_back( + std::make_unique(i, mNodes[i], mQSets[0], + &mValueStates)); + for (auto const& qSet : mQSets) + { + mDrivers.back()->rememberQSet(qSet); + } + mEmittedSeen.push_back(0); + } + } + + void + run(std::vector const& actions) + { + for (auto const& action : actions) + { + runAction(action); + harvestEmitted(); + checkSafety(); + } + advanceAllTime(1000); + runAllTimers(32); + harvestEmitted(); + drainQueue(64); + checkSafety(); + } + + ScenarioSummary + summarize() const + { + SHA256 hasher; + for (auto const& driver : mDrivers) + { + auto nodeBytes = xdr::xdr_to_opaque(mNodes[driver->nodeIndex()]); + hasher.add(nodeBytes); + auto driverSummary = driver->summarize(); + addUint64(hasher, driverSummary.highestSlot); + addUint64(hasher, driverSummary.emittedCount); + addUint64(hasher, driverSummary.externalizedCount); + addUint64(hasher, driverSummary.blockedCount); + addUint64(hasher, driverSummary.activeTimerCount); + hasher.add(xdr::xdr_to_opaque(driverSummary.digest)); + } + for (auto const& queued : mQueue) + { + addUint64(hasher, queued.fromNode); + hasher.add(xdr::xdr_to_opaque(queued.envelope.statement)); + } + + ScenarioSummary summary; + summary.queuedCount = mQueue.size(); + summary.deliveredCount = mDeliveredCount; + summary.droppedCount = mDroppedCount; + summary.digest = hasher.finish(); + return summary; + } + + private: + void + runAction(ScenarioAction const& action) + { + switch (action.kind) + { + case ScenarioActionKind::RUN_TIMERS: + advanceAllTime(1 + action.timerCallbacks); + runAllTimers(1 + (action.timerCallbacks % 16), + (action.shape & 0x80) != 0); + break; + case ScenarioActionKind::DELIVER_QUEUED: + deliverQueued(action); + break; + case ScenarioActionKind::DROP_QUEUED: + dropQueued(action); + break; + case ScenarioActionKind::DRAIN_QUEUE: + drainQueue(1 + (action.timerCallbacks % 16)); + break; + case ScenarioActionKind::SET_VALUE_STATE: + setValueState(action); + retryReadyBlocked(1 + (action.timerCallbacks % 16)); + break; + case ScenarioActionKind::SET_LOCAL_QSET: + setLocalQSet(action); + break; + case ScenarioActionKind::COOPERATIVE_CONVERGE: + cooperativeConverge(action); + break; + default: + injectEnvelope(action); + break; + } + } + + void + buildQSetCatalog() + { + SCPQuorumSet flatTwoOfThree; + flatTwoOfThree.threshold = 2; + flatTwoOfThree.validators = {mNodes[0], mNodes[1], mNodes[2]}; + + SCPQuorumSet flatThreeOfThree; + flatThreeOfThree.threshold = 3; + flatThreeOfThree.validators = {mNodes[0], mNodes[1], mNodes[2]}; + + SCPQuorumSet inner; + inner.threshold = 1; + inner.validators = {mNodes[1], mNodes[2]}; + + SCPQuorumSet nested; + nested.threshold = 2; + nested.validators = {mNodes[0]}; + nested.innerSets = {inner}; + + mQSets = {flatTwoOfThree, flatThreeOfThree, nested}; + for (auto const& qSet : mQSets) + { + mQSetHashes.push_back(getQSetHash(qSet)); + } + } + + void + injectEnvelope(ScenarioAction const& action) + { + auto env = makeEnvelopeFromAction(action, mQSetHashes, mNodes); + auto target = targetNodeForAction(action); + // receiveEnvelope models peer traffic. Avoid synthetic histories that + // could not arrive from a peer in this local node's current slot state. + if (env.statement.nodeID == mNodes[target]) + { + return; + } + if (env.statement.pledges.type() != SCP_ST_NOMINATE && + !mDrivers[target]->hasEmittedBallotForSlot( + env.statement.slotIndex)) + { + return; + } + deliverTo(*mDrivers[target], env); + mDrivers[target]->advanceTime(action.timerCallbacks); + mDrivers[target]->runPendingTimers(action.timerCallbacks % 4, + (action.shape & 0x80) != 0); + } + + void + setValueState(ScenarioAction const& action) + { + mValueStates[action.valueIndex % VALUE_COUNT] = + static_cast(action.shape % 5); + } + + void + setLocalQSet(ScenarioAction const& action) + { + auto target = targetNodeForAction(action); + auto const& qSet = mQSets[action.qSetChoice % mQSets.size()]; + mDrivers[target]->mSCP.updateLocalQuorumSet(qSet); + } + + void + cooperativeConverge(ScenarioAction const& action) + { + auto slot = static_cast(100 + action.slotIndex); + auto value = makeValue(action.valueIndex, action.shape); + auto previousValue = makeValue(0, 0); + + mValueStates[valueIndex(value)] = FuzzValueState::AVAILABLE_VALID; + for (auto& driver : mDrivers) + { + driver->mSCP.updateLocalQuorumSet(mQSets[0]); + driver->mSCP.nominate(slot, driver->wrapValue(value), + previousValue); + } + harvestEmitted(); + + for (size_t i = 0; i < 64; ++i) + { + drainQueue(64); + advanceAllTime(100); + runAllTimers(8); + harvestEmitted(); + if (allExternalized(slot)) + { + checkSafety(); + return; + } + } + + throw std::runtime_error( + "SCP fuzz cooperative convergence did not externalize"); + } + + bool + allExternalized(uint64_t slot) const + { + for (auto const& driver : mDrivers) + { + if (driver->externalized().find(slot) == + driver->externalized().end()) + { + return false; + } + } + return true; + } + + void + retryReadyBlocked(size_t maxRetries) + { + size_t retries = 0; + for (auto& driver : mDrivers) + { + auto ready = driver->takeReadyBlocked(maxRetries - retries); + for (auto const& env : ready) + { + deliverTo(*driver, env); + ++retries; + if (retries >= maxRetries) + { + return; + } + } + } + } + + void + harvestEmitted() + { + for (size_t i = 0; i < mDrivers.size(); ++i) + { + auto const& emitted = mDrivers[i]->emitted(); + while (mEmittedSeen[i] < emitted.size() && + mQueue.size() < MAX_ACTIONS * NODE_COUNT) + { + auto const& env = emitted[mEmittedSeen[i]++]; + if (env.statement.nodeID != mNodes[i]) + { + throw std::runtime_error( + "SCP fuzz emitted envelope from wrong node"); + } + if (std::find(mQSetHashes.begin(), mQSetHashes.end(), + statementQSetHash(env.statement)) == + mQSetHashes.end()) + { + throw std::runtime_error( + "SCP fuzz emitted envelope with unknown qset"); + } + mQueue.push_back({i, env}); + } + } + } + + void + deliverQueued(ScenarioAction const& action) + { + if (mQueue.empty()) + { + return; + } + auto index = action.valueIndex % mQueue.size(); + auto queued = mQueue[index]; + if ((action.qSetChoice & 0x02) == 0) + { + mQueue.erase(mQueue.begin() + static_cast(index)); + } + if ((action.qSetChoice & 0x01) != 0) + { + for (auto& driver : mDrivers) + { + deliverTo(*driver, queued.envelope); + } + } + else + { + deliverTo(*mDrivers[targetNodeForAction(action)], queued.envelope); + } + } + + void + dropQueued(ScenarioAction const& action) + { + if (mQueue.empty()) + { + return; + } + auto index = action.valueIndex % mQueue.size(); + mQueue.erase(mQueue.begin() + static_cast(index)); + ++mDroppedCount; + } + + void + drainQueue(size_t maxDeliveries) + { + size_t n = 0; + while (!mQueue.empty() && n < maxDeliveries) + { + ScenarioAction action{}; + action.valueIndex = 0; + action.qSetChoice = 1; + deliverQueued(action); + harvestEmitted(); + ++n; + } + } + + void + deliverTo(FuzzSCPDriver& driver, SCPEnvelope const& envelope) + { + if (!driver.isEnvelopeReady(envelope)) + { + driver.blockEnvelope(envelope); + return; + } + auto wrapped = driver.wrapEnvelope(envelope); + driver.mSCP.receiveEnvelope(wrapped); + ++mDeliveredCount; + } + + void + advanceAllTime(uint64_t ticks) + { + for (auto& driver : mDrivers) + { + driver->advanceTime(ticks); + } + } + + void + runAllTimers(size_t maxCallbacksPerNode, bool latestFirst = false) + { + for (auto& driver : mDrivers) + { + driver->runPendingTimers(maxCallbacksPerNode, latestFirst); + } + } + + void + checkSafety() const + { + std::map externalized; + for (size_t i = 0; i < mDrivers.size(); ++i) + { + auto const& driver = mDrivers[i]; + if (driver->highestSlot() < mHighestSeen[i]) + { + throw std::runtime_error( + "SCP fuzz highest known slot regressed"); + } + mHighestSeen[i] = driver->highestSlot(); + for (auto const& [slot, value] : driver->externalized()) + { + auto [it, inserted] = externalized.emplace(slot, value); + if (!inserted && it->second != value) + { + throw std::runtime_error( + "SCP fuzz nodes externalized conflicting values"); + } + } + } + } + + std::array mNodes; + std::vector mQSets; + std::vector mQSetHashes; + std::array mValueStates{}; + mutable std::array mHighestSeen{}; + std::vector> mDrivers; + std::vector mEmittedSeen; + std::vector mQueue; + size_t mDeliveredCount{0}; + size_t mDroppedCount{0}; +}; + +ScenarioSummary +runScenario(std::vector const& actions) +{ + FuzzSCPNetwork network; + network.run(actions); + return network.summarize(); +} + +class SCPFuzzTarget : public FuzzTarget +{ + public: + std::string + name() const override + { + return "scp"; + } + + std::string + description() const override + { + return "Fuzz SCP with deterministic multi-node simulation, value " + "readiness, qset variation, safety, liveness, and replay " + "checks"; + } + + void + initialize() override + { + reinitializeAllGlobalStateForFuzzing(1); + } + + FuzzResultCode + run(uint8_t const* data, size_t size) override + { + if (data == nullptr || size == 0 || size > maxInputSize()) + { + return FuzzResultCode::FUZZ_REJECTED; + } + + auto actions = decodeScenario(data, size); + if (actions.empty()) + { + return FuzzResultCode::FUZZ_REJECTED; + } + + auto s1 = runScenario(actions); + auto s2 = runScenario(actions); + if (!(s1 == s2)) + { + throw std::runtime_error("SCP fuzz replay was non-deterministic"); + } + + return FuzzResultCode::FUZZ_SUCCESS; + } + + void + shutdown() override + { + } + + size_t + maxInputSize() const override + { + return 32 * 1024; + } + + std::vector + generateSeedInput() override + { + std::vector out; + appendAction(out, ScenarioActionKind::SET_VALUE_STATE, 0, 0, 2, 0, + static_cast(FuzzValueState::FETCHING), 0, 0); + appendAction(out, ScenarioActionKind::INJECT_NOMINATE, 1, 0, 2, 1, 3, + 1, 0); + appendAction(out, ScenarioActionKind::SET_VALUE_STATE, 0, 0, 2, 0, + static_cast(FuzzValueState::AVAILABLE_VALID), 0, + 4); + appendAction(out, ScenarioActionKind::INJECT_PREPARE, 1, 1, 3, 3, 7, + 0x80, 0); + appendAction(out, ScenarioActionKind::INJECT_CONFIRM, 2, 1, 4, 4, 0, + 0, 0); + appendAction(out, ScenarioActionKind::RUN_TIMERS, 0, 0, 0, 0, 0, 1, + 8); + appendAction(out, ScenarioActionKind::COOPERATIVE_CONVERGE, 0, 2, 5, + 1, 0, 0, 0); + return out; + } +}; + +REGISTER_FUZZ_TARGET(SCPFuzzTarget, "scp", + "Fuzz SCP with deterministic multi-node simulation, value " + "readiness, qset variation, safety, liveness, and replay " + "checks"); + +} // namespace +} // namespace stellar