diff --git a/.deny.toml b/.deny.toml index b6680ba63..b1c1ee481 100644 --- a/.deny.toml +++ b/.deny.toml @@ -15,6 +15,8 @@ ignore = [ { id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is pulled transitively via risc0-zkvm; waiting on upstream fix (see https://github.com/risc0/risc0/issues/3453)" }, { id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, { id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, + { id = "RUSTSEC-2026-0194", reason = "`quick-xml` v0.31 is pulled transitively by Cucumber's JUnit report writer; this path only serializes XML with `quick_xml::Writer` and does not exercise the vulnerable XML parsing APIs" }, + { id = "RUSTSEC-2026-0195", reason = "`quick-xml` v0.31 is pulled transitively by Cucumber's JUnit report writer; this path only serializes XML and does not use the vulnerable `NsReader` namespace parsing path" }, { id = "RUSTSEC-2024-0370", reason = "transitive dependency of `logos-blockchain-http-api-common`, can't do anything than wait for upstream fix" }, { id = "RUSTSEC-2026-0173", reason = "`proc-macro-error2` is unmaintained; pulled in transitively via `leptos_macro` and `overwatch-derive`, waiting on upstream fix" }, @@ -30,6 +32,7 @@ multiple-versions = "allow" allow = [ "Apache-2.0 WITH LLVM-exception", "Apache-2.0", + "BlueOak-1.0.0", "BSD-2-Clause", "BSD-3-Clause", "BSL-1.0", @@ -60,6 +63,7 @@ allow-git = [ "https://github.com/logos-blockchain/logos-blockchain.git", "https://github.com/logos-blockchain/logos-blockchain-circuits.git", "https://github.com/logos-blockchain/logos-blockchain-rust-rapidsnark.git", + "https://github.com/logos-blockchain/logos-blockchain-testing.git", "https://github.com/logos-blockchain/sponges", "https://github.com/arkworks-rs/spongefish.git", "https://github.com/keycard-tech/keycard-rs", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed66e91f1..cd0ed74e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -284,8 +284,20 @@ jobs: RISC0_DEV_MODE=1 RUST_LOG=info run: | - source .github/scripts/with_retry.sh - with_retry 'cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})"' 5 + source .github/scripts/with_retry.sh + + if [[ "${{ matrix.target }}" == "tf_bedrock_builder" || \ + "${{ matrix.target }}" == "tf_app_deployments" ]]; then + # The nextest archive contains the absolute Cargo git checkout + # path compiled into the Logos testing framework. This is a fresh + # runner, so materialize the pinned Logos checkout again before + # allowing TF to use its local build provider. + cargo fetch --locked + + with_retry 'env -u GITHUB_ACTIONS -u CI cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})"' 5 + else + with_retry 'cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})"' 5 + fi valid-proof-test: needs: ci-image diff --git a/.gitignore b/.gitignore index 3befdb9cb..0a3ed582b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ bedrock_signing_key integration_tests/configs/debug/ .DS_Store + +/integration_tests/cucumber_tests/temp/ +MyLogFile.log diff --git a/Cargo.lock b/Cargo.lock index 103bca295..30cbd2a02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,14 +21,23 @@ checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "cpp_demangle", "fallible-iterator", - "gimli", + "gimli 0.31.1", "memmap2", - "object", + "object 0.36.7", "rustc-demangle", "smallvec", "typed-arena", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli 0.32.3", +] + [[package]] name = "adler2" version = "2.0.1" @@ -98,6 +107,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -499,7 +509,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 2.0.18", @@ -601,6 +611,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -613,6 +635,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-ctrlc" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907279f6e91a51c8ec7cac24711e8308f21da7c10c7700ca2f7e125694ed2df1" +dependencies = [ + "ctrlc", +] + [[package]] name = "async-io" version = "2.6.0" @@ -823,7 +854,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -859,7 +890,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-tungstenite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -911,9 +942,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ "fastrand", + "gloo-timers 0.3.0", "tokio", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line 0.25.1", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base-x" version = "0.2.11" @@ -1091,6 +1138,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bollard" version = "0.20.2" @@ -1236,6 +1292,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "build_utils" version = "0.1.0" @@ -1250,6 +1316,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1406,7 +1478,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1421,6 +1493,42 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cfgsync-adapter" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain-testing.git?rev=db880d61155db3a4f32177ef16b0f1e4bf22b1f6#db880d61155db3a4f32177ef16b0f1e4bf22b1f6" +dependencies = [ + "cfgsync-artifacts", + "cfgsync-core", + "serde", + "serde_json", +] + +[[package]] +name = "cfgsync-artifacts" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain-testing.git?rev=db880d61155db3a4f32177ef16b0f1e4bf22b1f6#db880d61155db3a4f32177ef16b0f1e4bf22b1f6" +dependencies = [ + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "cfgsync-core" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain-testing.git?rev=db880d61155db3a4f32177ef16b0f1e4bf22b1f6#db880d61155db3a4f32177ef16b0f1e4bf22b1f6" +dependencies = [ + "anyhow", + "axum 0.7.9", + "cfgsync-artifacts", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "chacha20" version = "0.10.0" @@ -1473,7 +1581,29 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", ] [[package]] @@ -1555,6 +1685,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -1632,6 +1763,19 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -2154,6 +2298,17 @@ dependencies = [ "cipher 0.5.2", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -2164,6 +2319,64 @@ dependencies = [ "subtle", ] +[[package]] +name = "cucumber" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18c09939b8de21501b829a3839fa8a01ef6cc226e6bc1f5f163f7104bd5e847d" +dependencies = [ + "anyhow", + "clap", + "console", + "cucumber-codegen", + "cucumber-expressions", + "derive_more", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools 0.14.0", + "junit-report", + "linked-hash-map", + "pin-project", + "ref-cast", + "regex", + "sealed", + "smart-default", +] + +[[package]] +name = "cucumber-codegen" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f5afe541b5147a7b986816153ccfd502622bb37789420cfff412685f27c0a95" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools 0.14.0", + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +dependencies = [ + "derive_more", + "either", + "nom 8.0.0", + "nom_locate", + "regex", + "regex-syntax", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -2318,7 +2531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2350,7 +2563,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint 0.4.6", "num-traits", "rusticata-macros", @@ -2377,6 +2590,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive-getters" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2c35ab6e03642397cdda1dd58abbc05d418aef8e36297f336d5aba060fe8df" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive-where" version = "1.6.1" @@ -2453,6 +2677,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -2504,7 +2734,19 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.12.1", + "block2", + "libc", + "objc2", ] [[package]] @@ -2878,7 +3120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2967,6 +3209,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "eyre" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" +dependencies = [ + "autocfg", + "indenter", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -3119,6 +3372,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -3126,7 +3388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -3140,12 +3402,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "fork_stream" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc54cf296aa5a82dfffcc911fc7a37b0dcba605725bbb4db486f7b24d7667f9d" +dependencies = [ + "futures", + "pin-project", +] + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3284,7 +3562,7 @@ version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" dependencies = [ - "gloo-timers", + "gloo-timers 0.4.0", "send_wrapper", ] @@ -3340,8 +3618,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link", - "windows-result", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3419,6 +3697,23 @@ dependencies = [ "polyval", ] +[[package]] +name = "gherkin" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70197ce7751bfe8bc828e3a855502d3a869a1e9416b58b10c4bde5cf8a0a3cb3" +dependencies = [ + "heck", + "peg", + "quote", + "serde", + "serde_json", + "syn 2.0.117", + "textwrap", + "thiserror 2.0.18", + "typed-builder", +] + [[package]] name = "gimli" version = "0.31.1" @@ -3430,12 +3725,42 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.12.1", + "ignore", + "walkdir", +] + [[package]] name = "gloo-net" version = "0.6.0" @@ -3457,6 +3782,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-timers" version = "0.4.0" @@ -3586,6 +3923,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -3781,6 +4120,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -3841,6 +4191,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "humantime" version = "2.3.0" @@ -3953,6 +4312,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -3970,7 +4345,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -4003,7 +4378,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -4160,7 +4535,7 @@ dependencies = [ "rtnetlink", "system-configuration 0.7.0", "tokio", - "windows", + "windows 0.62.2", ] [[package]] @@ -4184,12 +4559,34 @@ dependencies = [ "xmltree", ] +[[package]] +name = "ignore" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b17771570a2b94107741a7b033f19132c2eee21d59d21b24d2ced26500bd66e" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "include_bytes_aligned" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee796ad498c8d9a1d68e477df8f754ed784ef875de1414ebdaf169f70a6a784" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexer_core" version = "0.1.0" @@ -4322,6 +4719,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + [[package]] name = "inout" version = "0.1.4" @@ -4365,6 +4768,7 @@ name = "integration_tests" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "authenticated_transfer_core", "bridge_core", "bridge_lock_core", @@ -4373,9 +4777,11 @@ dependencies = [ "cross_zone_inbox_core", "cross_zone_marker_core", "cross_zone_outbox_core", + "cucumber", "faucet_core", "hex", "indexer_ffi", + "indexer_service", "indexer_service_protocol", "indexer_service_rpc", "key_protocol", @@ -4385,10 +4791,12 @@ dependencies = [ "logos-blockchain-core", "logos-blockchain-key-management-system-service", "logos-blockchain-zone-sdk", + "num-bigint 0.4.6", "ping_core", "programs", "risc0-zkvm", "sequencer_core", + "sequencer_service", "sequencer_service_rpc", "sequencer_stake_core", "serde_json", @@ -4396,8 +4804,14 @@ dependencies = [ "tempfile", "test_fixtures", "test_programs", + "testing-framework", + "testing-framework-app", + "testing-framework-core", "testnet_initial_state", + "thiserror 2.0.18", "tokio", + "tracing", + "tracing-subscriber 0.3.23", "vault_core", "wallet", "wallet-ffi", @@ -4428,7 +4842,7 @@ dependencies = [ "socket2 0.6.4", "widestring", "windows-registry", - "windows-result", + "windows-result 0.4.1", "windows-sys 0.61.2", ] @@ -4601,6 +5015,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "json-pretty-compact" version = "0.1.2" @@ -4611,6 +5037,29 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "jsonpath-rust" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e07021eefc09f897611b067ba7897b5e9266edfc902768418968772e5bb06a7" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "jsonrpsee" version = "0.26.0" @@ -4677,7 +5126,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "tower", + "tower 0.5.3", "tracing", "wasm-bindgen-futures", ] @@ -4701,7 +5150,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tower", + "tower 0.5.3", "url", ] @@ -4741,7 +5190,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", - "tower", + "tower 0.5.3", "tracing", ] @@ -4766,7 +5215,7 @@ dependencies = [ "jsonrpsee-client-transport", "jsonrpsee-core", "jsonrpsee-types", - "tower", + "tower 0.5.3", ] [[package]] @@ -4779,10 +5228,22 @@ dependencies = [ "jsonrpsee-client-transport", "jsonrpsee-core", "jsonrpsee-types", - "tower", + "tower 0.5.3", "url", ] +[[package]] +name = "junit-report" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06c3a3342e6720a82d7d179f380e9841b73a1dd49344e33959fdfe571ce56b55" +dependencies = [ + "derive-getters", + "quick-xml", + "strip-ansi-escapes", + "time", +] + [[package]] name = "k256" version = "0.13.4" @@ -4813,6 +5274,18 @@ dependencies = [ "wnaf", ] +[[package]] +name = "k8s-openapi" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b326f5219dd55872a72c1b6ddd1b830b8334996c667449c29391d657d78d5e" +dependencies = [ + "base64 0.22.1", + "jiff", + "serde", + "serde_json", +] + [[package]] name = "kameo" version = "0.22.2" @@ -4945,6 +5418,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kinded" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce4bdbb2f423660b19f0e9f7115182214732d8dd5f840cd0a3aee3e22562f34c" +dependencies = [ + "kinded_macros", +] + +[[package]] +name = "kinded_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13b4ddc5dcb32f45dac3d6f606da2a52fdb9964a18427e63cd5ef6c0d13288d" +dependencies = [ + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "konst" version = "0.2.20" @@ -4960,6 +5454,98 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "kube" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc5a6a69da2975ed9925d56b5dcfc9cc739b66f37add06785b7c9f6d1e88741" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcaf2d1f1a91e1805d4cd82e8333c022767ae8ffd65909bbef6802733a7dd40" +dependencies = [ + "base64 0.22.1", + "bytes", + "either", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jiff", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f126d2db7a8b532ec1d839ece2a71e2485dc3bbca6cc3c3f929becaa810e719e" +dependencies = [ + "derive_more", + "form_urlencoded", + "http", + "jiff", + "json-patch", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "kube-runtime" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c072737075826ee74d3e615e80334e41e617ca3d14fb46ef7cdfda822d6f15f2" +dependencies = [ + "ahash", + "async-broadcast", + "async-stream", + "backon", + "educe", + "futures", + "hashbrown 0.16.1", + "hostname 0.4.2", + "json-patch", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "lazy-regex" version = "3.6.0" @@ -5110,7 +5696,7 @@ dependencies = [ "server_fn", "tachys", "tokio", - "tower", + "tower 0.5.3", "tower-http", ] @@ -5292,7 +5878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5761,6 +6347,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -5788,6 +6380,48 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +[[package]] +name = "logos-blockchain-api-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "bytes", + "futures", + "logos-blockchain-blend-service", + "logos-blockchain-chain-broadcast-service", + "logos-blockchain-chain-leader-service", + "logos-blockchain-chain-service", + "logos-blockchain-core", + "logos-blockchain-ledger", + "logos-blockchain-libp2p", + "logos-blockchain-log-targets", + "logos-blockchain-network-service", + "logos-blockchain-sdp-service", + "logos-blockchain-storage-service", + "logos-blockchain-time-service", + "logos-blockchain-tx-service", + "overwatch", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "logos-blockchain-blend" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "logos-blockchain-blend-crypto", + "logos-blockchain-blend-message", + "logos-blockchain-blend-network", + "logos-blockchain-blend-proofs", + "logos-blockchain-blend-scheduling", +] + [[package]] name = "logos-blockchain-blend-crypto" version = "0.0.0" @@ -5827,6 +6461,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "logos-blockchain-blend-network" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "either", + "futures", + "futures-timer", + "hex", + "libp2p", + "logos-blockchain-blend-message", + "logos-blockchain-blend-proofs", + "logos-blockchain-blend-scheduling", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-libp2p", + "logos-blockchain-log-targets", + "tracing", +] + [[package]] name = "logos-blockchain-blend-proofs" version = "0.0.0" @@ -5848,6 +6503,71 @@ dependencies = [ "zeroize", ] +[[package]] +name = "logos-blockchain-blend-scheduling" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "derivative", + "fork_stream", + "futures", + "hex", + "logos-blockchain-blend-message", + "logos-blockchain-blend-proofs", + "logos-blockchain-codec", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-log-targets", + "logos-blockchain-utils", + "multiaddr", + "rand 0.8.6", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "logos-blockchain-blend-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "fork_stream", + "futures", + "hex", + "libp2p", + "libp2p-stream", + "logos-blockchain-blend", + "logos-blockchain-chain-service", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-service", + "logos-blockchain-ledger", + "logos-blockchain-libp2p", + "logos-blockchain-log-targets", + "logos-blockchain-network-service", + "logos-blockchain-poq", + "logos-blockchain-sdp-service", + "logos-blockchain-services-utils", + "logos-blockchain-time-service", + "logos-blockchain-tracing", + "logos-blockchain-utils", + "overwatch", + "rand 0.8.6", + "serde", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "logos-blockchain-chain-broadcast-service" version = "0.0.0" @@ -5862,6 +6582,72 @@ dependencies = [ "tracing", ] +[[package]] +name = "logos-blockchain-chain-leader-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "futures", + "logos-blockchain-blend-service", + "logos-blockchain-chain-network-service", + "logos-blockchain-chain-service", + "logos-blockchain-codec", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-key-management-system-service", + "logos-blockchain-ledger", + "logos-blockchain-services-utils", + "logos-blockchain-storage-service", + "logos-blockchain-time-service", + "logos-blockchain-tracing", + "logos-blockchain-tx-service", + "logos-blockchain-utils", + "logos-blockchain-wallet-service", + "overwatch", + "rand 0.8.6", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "tracing-futures", +] + +[[package]] +name = "logos-blockchain-chain-network-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "backon", + "futures", + "itertools 0.14.0", + "logos-blockchain-chain-service", + "logos-blockchain-codec", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-cryptarchia-sync", + "logos-blockchain-ledger", + "logos-blockchain-network-service", + "logos-blockchain-services-utils", + "logos-blockchain-storage-service", + "logos-blockchain-time-service", + "logos-blockchain-tracing", + "logos-blockchain-tx-service", + "logos-blockchain-utils", + "lru", + "overwatch", + "rand 0.8.6", + "serde", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "tracing-futures", +] + [[package]] name = "logos-blockchain-chain-service" version = "0.0.0" @@ -5891,6 +6677,7 @@ dependencies = [ "tokio", "tracing", "tracing-futures", + "utoipa", ] [[package]] @@ -6016,6 +6803,26 @@ dependencies = [ "url", ] +[[package]] +name = "logos-blockchain-config" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "hex", + "logos-blockchain-codec", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-service", + "logos-blockchain-libp2p", + "logos-blockchain-node", + "logos-blockchain-tracing", + "logos-blockchain-utils", + "num-bigint 0.4.6", + "rand 0.8.6", + "time", +] + [[package]] name = "logos-blockchain-core" version = "0.0.0" @@ -6325,6 +7132,69 @@ dependencies = [ "tokio", "tokio-stream", "tracing", + "utoipa", +] + +[[package]] +name = "logos-blockchain-node" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "axum 0.7.9", + "clap", + "color-eyre", + "futures", + "hex", + "http", + "libp2p", + "logos-blockchain-api-service", + "logos-blockchain-blend", + "logos-blockchain-blend-service", + "logos-blockchain-chain-broadcast-service", + "logos-blockchain-chain-leader-service", + "logos-blockchain-chain-network-service", + "logos-blockchain-chain-service", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-http-api-common", + "logos-blockchain-key-management-system-service", + "logos-blockchain-ledger", + "logos-blockchain-libp2p", + "logos-blockchain-log-targets", + "logos-blockchain-network-service", + "logos-blockchain-pol", + "logos-blockchain-sdp-service", + "logos-blockchain-services-utils", + "logos-blockchain-storage-service", + "logos-blockchain-system-sig-service", + "logos-blockchain-time-service", + "logos-blockchain-tracing", + "logos-blockchain-tracing-service", + "logos-blockchain-tui-zone", + "logos-blockchain-tx-service", + "logos-blockchain-utils", + "logos-blockchain-wallet-service", + "num-bigint 0.4.6", + "nutype", + "overwatch", + "rand 0.8.6", + "serde", + "serde_json", + "serde_with", + "serde_yaml", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-http", + "tracing", + "url", + "utoipa", + "utoipa-swagger-ui", + "validator", ] [[package]] @@ -6405,6 +7275,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "logos-blockchain-sdp-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "logos-blockchain-chain-service", + "logos-blockchain-core", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-services-utils", + "logos-blockchain-tracing", + "overwatch", + "serde", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", +] + [[package]] name = "logos-blockchain-services-utils" version = "0.0.0" @@ -6436,12 +7325,25 @@ dependencies = [ "logos-blockchain-tracing", "logos-blockchain-utils", "overwatch", + "rocksdb", "serde", "thiserror 2.0.18", "tokio", "tracing", ] +[[package]] +name = "logos-blockchain-system-sig-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-ctrlc", + "async-trait", + "logos-blockchain-log-targets", + "overwatch", + "tracing", +] + [[package]] name = "logos-blockchain-time-service" version = "0.0.0" @@ -6491,6 +7393,74 @@ dependencies = [ "url", ] +[[package]] +name = "logos-blockchain-tracing-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "logos-blockchain-log-targets", + "logos-blockchain-tracing", + "overwatch", + "serde", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber 0.3.23", +] + +[[package]] +name = "logos-blockchain-tui-zone" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "bincode", + "chrono", + "clap", + "hex", + "logos-blockchain-codec", + "logos-blockchain-core", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-service", + "logos-blockchain-zone-sdk", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber 0.3.23", + "uuid", +] + +[[package]] +name = "logos-blockchain-tx-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "futures", + "indexmap 2.14.0", + "logos-blockchain-core", + "logos-blockchain-log-targets", + "logos-blockchain-network-service", + "logos-blockchain-services-utils", + "logos-blockchain-storage-service", + "logos-blockchain-tracing", + "logos-blockchain-utils", + "overwatch", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "utoipa", +] + [[package]] name = "logos-blockchain-utils" version = "0.0.0" @@ -6529,6 +7499,53 @@ dependencies = [ "serde", ] +[[package]] +name = "logos-blockchain-wallet" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-key-management-system-keys", + "logos-blockchain-ledger", + "logos-blockchain-log-targets", + "logos-blockchain-mmr", + "logos-blockchain-utils", + "num-bigint 0.4.6", + "rpds", + "serde", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "logos-blockchain-wallet-service" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "async-trait", + "bytes", + "futures", + "hex", + "logos-blockchain-chain-service", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-service", + "logos-blockchain-ledger", + "logos-blockchain-log-targets", + "logos-blockchain-mmr", + "logos-blockchain-services-utils", + "logos-blockchain-storage-service", + "logos-blockchain-utils", + "logos-blockchain-wallet", + "overwatch", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "logos-blockchain-zksign" version = "0.0.0" @@ -6595,6 +7612,12 @@ dependencies = [ "tracing-subscriber 0.3.23", ] +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" + [[package]] name = "lru-slab" version = "0.1.2" @@ -6789,7 +7812,7 @@ dependencies = [ "bitflags 2.12.1", "block", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -7028,6 +8051,23 @@ dependencies = [ "unsigned-varint 0.7.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "natpmp" version = "0.5.0" @@ -7176,6 +8216,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_strings" version = "0.1.3" @@ -7192,13 +8244,42 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom_locate" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" +dependencies = [ + "bytecount", + "memchr", + "nom 8.0.0", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7335,11 +8416,36 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "nutype" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70587a780088c67dad31bf722b875c5616096b4d8c0ded8b7de03294ffb9bbb5" +dependencies = [ + "nutype_macros", +] + +[[package]] +name = "nutype_macros" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "148934b975faeddd8f0679d7cf11280b50c4c5495d6fe72bdaf07c932874b404" +dependencies = [ + "cfg-if", + "kinded", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "urlencoding", +] + [[package]] name = "nvtx" version = "1.3.0" @@ -7358,6 +8464,40 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.12.1", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -7369,6 +8509,15 @@ dependencies = [ "ruzstd", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "oco_ref" version = "0.2.1" @@ -7416,12 +8565,49 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.12.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.31.0" @@ -7537,6 +8723,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "overwatch" version = "0.1.0" @@ -7564,6 +8759,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "page_size" version = "0.6.0" @@ -7600,7 +8801,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7628,6 +8829,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "paste" version = "1.0.15" @@ -7675,6 +8885,33 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + [[package]] name = "pem" version = "3.0.6" @@ -7700,6 +8937,86 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -8136,6 +9453,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prometheus-http-query" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcebfa99f03ae51220778316b37d24981e36322c82c24848f48c5bd0f64cbdb" +dependencies = [ + "enum-as-inner", + "mime", + "reqwest", + "serde", + "time", + "url", +] + [[package]] name = "proptest" version = "1.11.0" @@ -8265,6 +9596,15 @@ dependencies = [ "unsigned-varint 0.8.0", ] +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -8279,7 +9619,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -8317,7 +9657,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", "windows-sys 0.59.0", ] @@ -8662,9 +10002,11 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -8675,9 +10017,10 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -8852,7 +10195,7 @@ dependencies = [ "serde", "sha2 0.10.9", "tracing", - "zip", + "zip 2.4.2", ] [[package]] @@ -9012,7 +10355,7 @@ version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22b7eafb5d85be59cbd9da83f662cf47d834f1b836e14f675d1530b12c666867" dependencies = [ - "addr2line", + "addr2line 0.24.2", "anyhow", "bincode", "borsh", @@ -9023,13 +10366,13 @@ dependencies = [ "enum-map", "gdbstub", "gdbstub_arch", - "gimli", + "gimli 0.31.1", "hex", "keccak 0.1.6", "lazy-regex", "num-bigint 0.4.6", "num-traits", - "object", + "object 0.36.7", "prost 0.13.5", "rand 0.9.4", "rayon", @@ -9186,7 +10529,7 @@ dependencies = [ "netlink-packet-route 0.28.0", "netlink-proto", "netlink-sys", - "nix", + "nix 0.30.1", "thiserror 1.0.69", "tokio", ] @@ -9223,6 +10566,41 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rust-rapidsnark" version = "0.1.3" @@ -9262,7 +10640,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -9275,7 +10653,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9482,6 +10860,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "sec1" version = "0.7.3" @@ -9511,6 +10900,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -9601,6 +10999,7 @@ dependencies = [ "tokio", "tokio-retry", "tokio-util", + "trait-variant", "url", "vault_core", ] @@ -9772,6 +11171,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_arrays" version = "0.2.0" @@ -9993,7 +11402,7 @@ dependencies = [ "thiserror 2.0.18", "throw_error", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "url", "wasm-bindgen", @@ -10176,12 +11585,39 @@ dependencies = [ "version_check", ] +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "snap" version = "1.1.1" @@ -10325,6 +11761,15 @@ dependencies = [ "zstd", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -10462,6 +11907,53 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "synthez" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" +dependencies = [ + "syn 2.0.117", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" +dependencies = [ + "syn 2.0.117", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -10571,7 +12063,39 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.6", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -10718,6 +12242,157 @@ dependencies = [ "uuid", ] +[[package]] +name = "testing-framework" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" +dependencies = [ + "anyhow", + "async-trait", + "cfgsync-adapter", + "cfgsync-artifacts", + "flate2", + "futures", + "hex", + "logos-blockchain-blend-service", + "logos-blockchain-chain-leader-service", + "logos-blockchain-chain-network-service", + "logos-blockchain-chain-service", + "logos-blockchain-common-http-client", + "logos-blockchain-config", + "logos-blockchain-core", + "logos-blockchain-cryptarchia-engine", + "logos-blockchain-cryptarchia-sync", + "logos-blockchain-groth16", + "logos-blockchain-http-api-common", + "logos-blockchain-key-management-system-service", + "logos-blockchain-ledger", + "logos-blockchain-libp2p", + "logos-blockchain-network-service", + "logos-blockchain-node", + "logos-blockchain-time-service", + "logos-blockchain-tx-service", + "logos-blockchain-utils", + "num-bigint 0.4.6", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sysinfo", + "tar", + "tempfile", + "testing-framework-core", + "testing-framework-runner-compose", + "testing-framework-runner-k8s", + "testing-framework-runner-local", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "testing-framework-app" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain-testing.git?rev=db880d61155db3a4f32177ef16b0f1e4bf22b1f6#db880d61155db3a4f32177ef16b0f1e4bf22b1f6" +dependencies = [ + "async-trait", + "testing-framework-core", + "testing-framework-runner-local", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "testing-framework-core" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain-testing.git?rev=db880d61155db3a4f32177ef16b0f1e4bf22b1f6#db880d61155db3a4f32177ef16b0f1e4bf22b1f6" +dependencies = [ + "anyhow", + "async-trait", + "cfgsync-adapter", + "cfgsync-artifacts", + "cfgsync-core", + "futures", + "parking_lot", + "prometheus-http-query", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "testing-framework-runner-compose" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain-testing.git?rev=db880d61155db3a4f32177ef16b0f1e4bf22b1f6#db880d61155db3a4f32177ef16b0f1e4bf22b1f6" +dependencies = [ + "anyhow", + "async-trait", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tera", + "testing-framework-core", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "testing-framework-runner-k8s" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain-testing.git?rev=db880d61155db3a4f32177ef16b0f1e4bf22b1f6#db880d61155db3a4f32177ef16b0f1e4bf22b1f6" +dependencies = [ + "anyhow", + "async-trait", + "cfgsync-artifacts", + "cfgsync-core", + "k8s-openapi", + "kube", + "reqwest", + "rustls", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "testing-framework-core", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tracing", + "url", +] + +[[package]] +name = "testing-framework-runner-local" +version = "0.1.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain-testing.git?rev=db880d61155db3a4f32177ef16b0f1e4bf22b1f6#db880d61155db3a4f32177ef16b0f1e4bf22b1f6" +dependencies = [ + "async-trait", + "fs_extra", + "reqwest", + "serde", + "serde_yaml", + "sha2 0.10.9", + "tempfile", + "testing-framework-core", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tracing", +] + [[package]] name = "testnet_initial_state" version = "0.1.0" @@ -10730,6 +12405,17 @@ dependencies = [ "system_accounts", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -10909,6 +12595,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-retry" version = "0.3.2" @@ -10965,6 +12661,7 @@ dependencies = [ "futures-io", "futures-sink", "pin-project-lite", + "slab", "tokio", ] @@ -11105,7 +12802,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-stream", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -11122,6 +12819,21 @@ dependencies = [ "tonic", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -11147,6 +12859,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ + "base64 0.22.1", "bitflags 2.12.1", "bytes", "futures-core", @@ -11162,9 +12875,10 @@ dependencies = [ "pin-project-lite", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", + "tracing", "url", ] @@ -11245,7 +12959,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "hostname", + "hostname 0.3.1", "serde_json", "thiserror 1.0.69", "tokio", @@ -11338,6 +13052,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "trait-variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "triomphe" version = "0.1.15" @@ -11438,6 +13163,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uint" version = "0.10.0" @@ -11468,6 +13199,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -11631,6 +13368,30 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "utoipa-swagger-ui" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943e0ff606c6d57d410fd5663a4d7c074ab2c5f14ab903b9514565e59fa1189e" +dependencies = [ + "axum 0.7.9", + "mime_guess", + "regex", + "reqwest", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "utoipa-swagger-ui-vendored", + "zip 1.1.4", +] + +[[package]] +name = "utoipa-swagger-ui-vendored" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" + [[package]] name = "uuid" version = "1.23.2" @@ -11639,6 +13400,7 @@ checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -11708,6 +13470,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -12037,7 +13808,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -12046,16 +13817,38 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -12064,7 +13857,20 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -12075,9 +13881,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", ] [[package]] @@ -12086,9 +13903,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -12113,20 +13930,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.62.2", + "windows-link 0.2.1", ] [[package]] @@ -12135,9 +13968,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -12146,7 +13988,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -12155,7 +14006,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -12191,7 +14042,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -12225,13 +14076,22 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -12503,7 +14363,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 2.0.18", @@ -12684,6 +14544,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cc23c04387f4da0374be4533ad1208cbb091d5c11d070dfef13676ad6497164" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index ebc21a02a..e6d6c9b6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,11 +138,14 @@ tokio = { version = "1.50", features = [ "fs", ] } tokio-util = "0.7.18" +async-trait = "0.1" +trait-variant = "0.1.2" risc0-zkvm = { version = "3.0.5", default-features = false, features = ['std'] } risc0-build = "3.0.5" kameo = "0.22.2" kameo_actors = "0.8.1" anyhow = "1.0.98" +cucumber = { default-features = false, version = "=0.22.0" } derive_more = "2.1.1" num_cpus = "1.13.1" openssl = { version = "0.10", features = ["vendored"] } @@ -163,6 +166,8 @@ metrics = "0.24.6" metrics-exporter-prometheus = "0.18.3" lru = "0.16.3" thiserror = "2.0" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } sha2 = "0.10.8" ed25519-dalek = { version = "2.2.0", default-features = false } hex = "0.4.3" @@ -214,6 +219,9 @@ logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blo logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +logos-blockchain-testing-framework = { package = "testing-framework", git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +testing-framework-app = { git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "db880d61155db3a4f32177ef16b0f1e4bf22b1f6" } +testing-framework-core = { git = "https://github.com/logos-blockchain/logos-blockchain-testing.git", rev = "db880d61155db3a4f32177ef16b0f1e4bf22b1f6" } keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "9535a657ba04b1e6916de51777e22b4837c1a84d" } diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 816bac69d..56aab0f18 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -8,6 +8,7 @@ license = { workspace = true } workspace = true [dependencies] +# LEZ and workspace crates. test_fixtures.workspace = true lee_core = { workspace = true, features = ["host"] } lee.workspace = true @@ -28,7 +29,9 @@ bridge_lock_core.workspace = true wrapped_token_core.workspace = true risc0-zkvm.workspace = true indexer_service_rpc = { workspace = true, features = ["client"] } +indexer_service.workspace = true sequencer_service_rpc = { workspace = true, features = ["client"] } +sequencer_service.workspace = true wallet-ffi.workspace = true indexer_ffi.workspace = true indexer_service_protocol.workspace = true @@ -39,11 +42,34 @@ test_programs.workspace = true testnet_initial_state.workspace = true logos-blockchain-core.workspace = true -logos-blockchain-zone-sdk.workspace = true logos-blockchain-key-management-system-service.workspace = true +logos-blockchain-testing-framework.workspace = true +logos-blockchain-zone-sdk.workspace = true +testing-framework-app.workspace = true +testing-framework-core.workspace = true + +# External dependencies. anyhow.workspace = true -log.workspace = true -tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } -hex.workspace = true -tempfile.workspace = true +async-trait.workspace = true bytesize.workspace = true +cucumber = { features = [ + "macros", + "output-junit", +], workspace = true, optional = true } +hex.workspace = true +log.workspace = true +num-bigint.workspace = true +tempfile.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time"] } +thiserror.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[features] +cucumber = ["dep:cucumber"] + +[[test]] +harness = false # Cucumber has its own main() function to run tests. +name = "cucumber" +path = "cucumber_tests/cucumber.rs" +required-features = ["cucumber"] diff --git a/integration_tests/cucumber_tests/cucumber.rs b/integration_tests/cucumber_tests/cucumber.rs new file mode 100644 index 000000000..c1e82ee21 --- /dev/null +++ b/integration_tests/cucumber_tests/cucumber.rs @@ -0,0 +1,236 @@ +use std::{ + collections::HashMap, + fs::OpenOptions, + io, + path::{Path, PathBuf}, + sync::{Arc, Mutex, atomic::AtomicBool}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::Context as _; +use cucumber::{ + StatsWriter as _, World as _, WriterExt as _, event::ScenarioFinished, writer, + writer::Verbosity, +}; +use integration_tests::cucumber::{ + default::{ + ARTEFACTS, CUCUMBER_REMOVE_ARTEFACTS_IF_SUCCESSFUL, MAX_CUCUMBER_CONCURRENT_SCENARIOS, + RUST_LOG, TF_KEEP_LOGS, create_scenario_output_dir, get_feature_path, get_retries, + }, + world::CucumberWorld, +}; +use logos_blockchain_testing_framework::{ + hash_str, is_truthy_env, reap_all_stale_port_blocks, release_reserved_port_block, +}; +use tracing::{info, warn}; +use wallet::SUPPRESS_VERBOSE_PRINTS; + +pub const TARGET: &str = "cucumber_main"; +type ScenarioAttempts = Arc>>; + +fn main() -> anyhow::Result<()> { + logos_blockchain_testing_framework::env::set_default_env(SUPPRESS_VERBOSE_PRINTS, "true"); + logos_blockchain_testing_framework::env::set_default_env(RUST_LOG, "info"); + logos_blockchain_testing_framework::env::set_default_env(TF_KEEP_LOGS, "true"); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("failed to build Cucumber Tokio runtime")?; + runtime.block_on(async_main()) +} + +async fn async_main() -> anyhow::Result<()> { + integration_tests::cucumber::default::init_tracing(); + reap_all_stale_port_blocks(); + info!(target: TARGET, "args: {:?}", std::env::args()); + + let scenario_attempts: ScenarioAttempts = Arc::new(Mutex::new(HashMap::new())); + let teardown_failed = Arc::new(AtomicBool::new(false)); + + let output_dir = create_scenario_output_dir()?; + let junit_xml_file = OpenOptions::new() + .append(true) + .create(true) + .open(output_dir.join("cucumber-output-junit.xml")) + .context("failed to create or open Cucumber JUnit output file")?; + let mut world = CucumberWorld::cucumber() + // Re-outputs Failed steps for easier navigation. + .repeat_failed() + // .fail_fast() // Remove comment to enable fail-fast behavior for development + .max_concurrent_scenarios(get_max_concurrent_scenarios()) + // Ensure that all the steps were covered. + .fail_on_skipped() + // Replaces Writer. + .with_writer( + writer::Summarize::new(writer::Basic::new( + io::stdout(), + // With `writer::Coloring::Auto`, cucumber treats the output as a TTY and using the + // underlying termcolor/console behaviour that can rewrite/clear lines when + // printing step statuses (✔ ...). That can visually clobber the + // immediately adjacent tracing line, especially the one emitted + // right as the step transitions from “running” to “passed”. + writer::Coloring::Never, + Verbosity::ShowWorldAndDocString, + )) + .tee::(writer::JUnit::for_tee(junit_xml_file, 0)) + .normalized(), + ) + // Sets a hook, executed on each Scenario before running all its Steps, including Background + // ones. + .before(move |feature, _rule, scenario, world| { + Box::pin({ + let output_dir_clone = output_dir.clone(); + let scenario_attempts_clone = ScenarioAttempts::clone(&scenario_attempts); + async move { + info!(target: TARGET, + "\nStarting - {}: {} ({}: {})\n", + scenario.keyword, scenario.name, feature.keyword, feature.name, + ); + prepare_world_for_scenario( + world, + &output_dir_clone, + &scenario_attempts_clone, + &feature.name, + &scenario.name, + ); + } + }) + }); + + if let Some(retries) = get_retries()? { + // Makes failed Scenarios being retried the specified number of times. + world = world.retries(retries); + } + + let teardown_failure_flag = Arc::clone(&teardown_failed); + let failed = world + .after(move |feature, _rule, scenario, scenario_finished, world| { + let teardown_failure_flag = Arc::clone(&teardown_failure_flag); + Box::pin(async move { + // Runs after the scenario has completed; useful for capturing final state/logs. + info!(target: TARGET, + "\nFinished - {}: {} ({}: {})\n", + scenario.keyword, scenario.name, feature.keyword, feature.name, + ); + + if let Some(world) = world { + let path = world.scenario_base_dir.join("debug_dump_file.log"); + if let Some(parent) = path.parent() { + let _unused = std::fs::create_dir_all(parent); + } + let _initial_debug_write = std::fs::write(&path, world.full_debug_info_string()); + + let teardown_result = world.stop_runtime().await; + if let Err(error) = &teardown_result { + teardown_failure_flag.store(true, std::sync::atomic::Ordering::Relaxed); + warn!(target: TARGET, "Cucumber runtime teardown failed: {error}"); + } + + // Rewrite the dump after teardown so teardown state and + // any teardown error are retained for diagnostics. The + // first write above deliberately captures the live + // runtime state before handles are released. + let _final_debug_write = std::fs::write(&path, world.full_debug_info_string()); + + if teardown_result.is_ok() + && matches!(scenario_finished, ScenarioFinished::StepPassed) + && is_truthy_env(CUCUMBER_REMOVE_ARTEFACTS_IF_SUCCESSFUL) + { + info!(target: TARGET, + "Env var '{CUCUMBER_REMOVE_ARTEFACTS_IF_SUCCESSFUL}' set, removing all \ + artefacts\n" + ); + if let Err(e) = world.clear_scenario_artifacts() { + warn!(target: TARGET, "{e}"); + } + } + } + }) + }) + // Runs Cucumber. Features sourced from a Parser are fed to a Runner, which + // produces events handled by a Writer. + .run(get_feature_path()?) + .await; + + // Clean up manually reserved handshake port block files for this process + release_reserved_port_block(); + + if failed.execution_has_failed() || teardown_failed.load(std::sync::atomic::Ordering::Relaxed) { + anyhow::bail!("Cucumber scenarios failed"); + } + + Ok(()) +} + +// Get the maximum number of concurrent scenarios from env var, defaults to 1 +fn get_max_concurrent_scenarios() -> usize { + std::env::var(MAX_CUCUMBER_CONCURRENT_SCENARIOS) + .ok() + .and_then(|val| val.parse().ok()) + .unwrap_or(1) +} + +fn prepare_world_for_scenario( + world: &mut CucumberWorld, + output_dir: &Path, + scenario_attempts: &ScenarioAttempts, + feature_name: &str, + scenario_name: &str, +) { + let scenario_dir = + scenario_output_dir(output_dir, scenario_attempts, feature_name, scenario_name); + + if let Err(err) = std::fs::create_dir_all(&scenario_dir) { + warn!(target: TARGET, + "Failed to create scenario artifact directory '{}': {err}", + scenario_dir.display() + ); + } + + world.set_scenario_base_dir(&scenario_dir); + world.apply_deployment_config_override_path(); + + let started_at_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + + let raw_context = format!("{}::{started_at_ns}", scenario_dir.display()); + world.set_test_context(hash_str(&raw_context)); +} + +fn scenario_output_dir( + output_dir: &Path, + scenario_attempts: &ScenarioAttempts, + feature_name: &str, + scenario_name: &str, +) -> PathBuf { + let run_attempt = increment_attempts(scenario_attempts, feature_name, scenario_name); + + output_dir + .join(ARTEFACTS) + .join(feature_name) + .join(scenario_name.trim().replace(' ', "_")) + .join(run_attempt) +} + +// Increment and return the attempt count for the given scenario. Counts +// are tracked per-scenario, and keyed by a combination of feature and +// scenario name. +fn increment_attempts( + scenario_attempts: &ScenarioAttempts, + feature: &str, + scenario: &str, +) -> String { + let key = format!("{feature}::{scenario}"); + let attempt = { + let mut guard = scenario_attempts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let entry = guard.entry(key).or_insert(0); + *entry = entry.wrapping_add(1); + *entry + }; + format!("attempt_{attempt}") +} diff --git a/integration_tests/cucumber_tests/features/authenticated_transfers.feature b/integration_tests/cucumber_tests/features/authenticated_transfers.feature new file mode 100644 index 000000000..4b3f3623a --- /dev/null +++ b/integration_tests/cucumber_tests/features/authenticated_transfers.feature @@ -0,0 +1,79 @@ +Feature: Authenticated transfers + + @auth_transfer_ci + # Mirrors integration_tests/tests/auth_transfer/public.rs::successful_transfer_to_existing_account. + # Coverage is equivalent for the transfer behavior and stronger for lifecycle coverage: + # Cucumber also verifies indexer convergence and explicit runtime teardown. It uses balance + # deltas instead of the legacy test's fixed 9900/20100 values. + Scenario: Transfer funds between configured public accounts + Given a LEZ stack with configured public accounts + When I transfer 100 from the first configured public account to the second as "PUBLIC_TRANSFER" + Then the sender balance for transfer "PUBLIC_TRANSFER" decreases by 100 + And the receiver balance for transfer "PUBLIC_TRANSFER" increases by 100 + And transfer "PUBLIC_TRANSFER" is included in a block within 120 seconds + And only the sender signs transfer "PUBLIC_TRANSFER" + And the indexer catches up to transfer "PUBLIC_TRANSFER" within 120 seconds + Then I stop the runtime + + @auth_transfer_ci + # Mirrors integration_tests/tests/auth_transfer/public.rs::failed_transfer_with_insufficient_balance. + # Coverage is equivalent or stronger: it uses 10001 rather than 1_000_000, and also verifies + # indexer convergence and explicit runtime teardown. + Scenario: Reject a public transfer with insufficient sender balance + Given a LEZ stack with configured public accounts + When I attempt to transfer 10001 from the first configured public account to the second + Then the transfer is rejected + And the sender balance remains unchanged + And the receiver balance remains unchanged + And the indexer catches up to the sequencer within 120 seconds + Then I stop the runtime + + @auth_transfer_ci + # Mirrors integration_tests/tests/auth_transfer/public.rs::two_consecutive_successful_transfers. + # Coverage is equivalent for the final balances and nonce progression, with additional checks + # for both inclusions, sender-only signatures, indexer convergence, and runtime teardown. It + # does not retain the legacy test's intermediate balance checkpoint after the first transfer. + Scenario: Execute two consecutive transfers between configured public accounts + Given a LEZ stack with configured public accounts + When I transfer 100 from the first configured public account to the second as "FIRST_TRANSFER" + And I transfer 100 from the first configured public account to the second as "SECOND_TRANSFER" + Then the sender balance across transfers "FIRST_TRANSFER" and "SECOND_TRANSFER" decreases by 200 + And the receiver balance across transfers "FIRST_TRANSFER" and "SECOND_TRANSFER" increases by 200 + And transfer "FIRST_TRANSFER" is included in a block within 120 seconds + And transfer "SECOND_TRANSFER" is included in a block within 120 seconds + And only the sender signs transfer "FIRST_TRANSFER" + And only the sender signs transfer "SECOND_TRANSFER" + And the sender nonce advances across transfers "FIRST_TRANSFER" and "SECOND_TRANSFER" + And the indexer catches up to transfer "SECOND_TRANSFER" within 120 seconds + Then I stop the runtime + + @auth_transfer_ci + # Mirrors integration_tests/tests/auth_transfer/public.rs::successful_transfer_to_new_account. + # Coverage is equivalent for funding a previously absent public account. Cucumber additionally + # verifies the recipient is absent before submission, transaction inclusion, both required + # signatures, indexer convergence, and explicit runtime teardown. + Scenario: Transfer funds to a new public account + Given a LEZ stack with configured public accounts + And a new public account + When I transfer 100 from the first configured public account to the new account as "NEW_ACCOUNT_TRANSFER" + Then the sender balance for transfer "NEW_ACCOUNT_TRANSFER" decreases by 100 + And the new account balance for transfer "NEW_ACCOUNT_TRANSFER" is 100 + And transfer "NEW_ACCOUNT_TRANSFER" is included in a block within 120 seconds + And the sender and new account sign transfer "NEW_ACCOUNT_TRANSFER" + And the indexer catches up to transfer "NEW_ACCOUNT_TRANSFER" within 120 seconds + Then I stop the runtime + + @auth_transfer_ci + # Mirrors integration_tests/tests/auth_transfer/private.rs::private_transfer_to_owned_account. + # Coverage preserves the post-transfer commitment checks and additionally verifies private + # balances, transaction inclusion, indexer convergence, and explicit runtime teardown. + Scenario: Transfer funds between configured private accounts + Given a LEZ stack with configured private accounts + When I transfer 100 from the first configured private account to the second as "PRIVATE_TRANSFER" + Then the sender private balance for transfer "PRIVATE_TRANSFER" decreases by 100 + And the receiver private balance for transfer "PRIVATE_TRANSFER" increases by 100 + And the sender private commitment for transfer "PRIVATE_TRANSFER" is in sequencer state + And the receiver private commitment for transfer "PRIVATE_TRANSFER" is in sequencer state + And transfer "PRIVATE_TRANSFER" is included in a block within 120 seconds + And the indexer catches up to transfer "PRIVATE_TRANSFER" within 120 seconds + Then I stop the runtime diff --git a/integration_tests/cucumber_tests/features/indexer_state_consistency.feature b/integration_tests/cucumber_tests/features/indexer_state_consistency.feature new file mode 100644 index 000000000..39c85b3bd --- /dev/null +++ b/integration_tests/cucumber_tests/features/indexer_state_consistency.feature @@ -0,0 +1,35 @@ +Feature: LEZ indexer state consistency + + @indexer_state_ci + # Mirrors the public-transfer and public-indexer-state portion of + # integration_tests/tests/indexer_state_consistency.rs::indexer_state_consistency. + # This is intentionally not a complete replacement: the legacy test also performs a + # private transfer, whose indexer transition remains outside this Cucumber scenario. + # Cucumber additionally uses transaction-anchored convergence, relative balance deltas, + # and explicit runtime teardown. + Scenario: Public indexer state remains consistent after a transfer + Given a LEZ stack with configured public accounts + When I transfer 100 from the first configured public account to the second as "INDEXER_TRANSFER" + Then the sender balance for transfer "INDEXER_TRANSFER" decreases by 100 + And the receiver balance for transfer "INDEXER_TRANSFER" increases by 100 + And transfer "INDEXER_TRANSFER" is included in a block within 120 seconds + And the indexer catches up to transfer "INDEXER_TRANSFER" within 120 seconds + Then the transferred public account states for transfer "INDEXER_TRANSFER" match between the sequencer and indexer + Then I stop the runtime + + @indexer_state_ci + # Mirrors integration_tests/tests/indexer_state_consistency_with_labels.rs::indexer_state_consistency_with_labels. + # Coverage is equivalent for label resolution, the public balance movement, and the + # sequencer/indexer state comparison. Cucumber additionally verifies transaction + # inclusion, transaction-anchored convergence, and explicit runtime teardown, while + # expressing the balance assertion as a relative delta. + Scenario: Indexer state remains consistent after a label-based transfer + Given a LEZ stack with configured public accounts + And the configured public accounts have sender label "idx-sender-label" and receiver label "idx-receiver-label" + When I transfer 100 using the configured public account labels as "LABEL_TRANSFER" + Then the sender balance for transfer "LABEL_TRANSFER" decreases by 100 + And the receiver balance for transfer "LABEL_TRANSFER" increases by 100 + And transfer "LABEL_TRANSFER" is included in a block within 120 seconds + And the indexer catches up to transfer "LABEL_TRANSFER" within 120 seconds + Then the transferred public account states for transfer "LABEL_TRANSFER" match between the sequencer and indexer + Then I stop the runtime diff --git a/integration_tests/cucumber_tests/features/multi_sequencer.feature b/integration_tests/cucumber_tests/features/multi_sequencer.feature new file mode 100644 index 000000000..845414bfe --- /dev/null +++ b/integration_tests/cucumber_tests/features/multi_sequencer.feature @@ -0,0 +1,34 @@ +Feature: LEZ multi-sequencer committee convergence + + @multi_sequencer_ci +# Mirrors the committee convergence, cross-sequencer transfer, and indexer checks in +# integration_tests/tests/multi_sequencer.rs::multi_sequencer_committee_converges. +# Cucumber preserves the legacy lifecycle: configured committee members are staked in +# shared genesis, A creates the channel, B joins later, and the scenario waits for B's +# posting turn. Cucumber additionally uses parameterized aliases and explicit runtime +# teardown. + Scenario: A multi-sequencer committee converges across sequencers and the indexer + Given a LEZ multi-sequencer environment with 1 validator and 0 Blend nodes + And the following LEZ sequencers are registered + | alias | signing_key | + | SEQ_A | 0xA1 | + | SEQ_B | 0xB2 | + And sequencer "SEQ_A" configures the committee + | authorized_sequencers | + | SEQ_A, SEQ_B | + When I start sequencer "SEQ_A" + And sequencer "SEQ_A" reaches block 2 within 120 seconds + And the committee configured by sequencer "SEQ_A" is active within 120 seconds + And I start sequencer "SEQ_B" + And sequencer "SEQ_B" synchronizes to sequencer "SEQ_A" within 120 seconds + When sequencers "SEQ_A" and "SEQ_B" advance across 8 rotation blocks within 120 seconds + And sequencer "SEQ_B" becomes the posting turn within 120 seconds + Then sequencers "SEQ_A" and "SEQ_B" have identical common block hashes within 120 seconds + When I record deterministic public account 1 balance on sequencer "SEQ_A" + When I submit 10 from deterministic public account 0 to account 1 through sequencer "SEQ_B" as "COMMITTEE_TRANSFER" + Then transfer "COMMITTEE_TRANSFER" is included in a block on sequencer "SEQ_B" within 120 seconds + Then sequencer "SEQ_A" observes the receiver balance increase for transfer "COMMITTEE_TRANSFER" by 10 within 120 seconds + And the indexer finalizes transfer "COMMITTEE_TRANSFER" on the committee chain within 120 seconds + Then finalized indexer blocks match sequencer "SEQ_A" + And the indexer is not stalled + Then I stop the runtime diff --git a/integration_tests/cucumber_tests/features/smoke.feature b/integration_tests/cucumber_tests/features/smoke.feature new file mode 100644 index 000000000..e7dcd9b57 --- /dev/null +++ b/integration_tests/cucumber_tests/features/smoke.feature @@ -0,0 +1,26 @@ +Feature: LEZ integration environment + + @smoke_ci + Scenario: LEZ smoke stack exposes its configured public account + Given a LEZ smoke stack + When I query the balance of the first configured public account + Then its balance matches the configured initial balance + And the indexer catches up to the sequencer within 120 seconds + Then I stop the runtime + + @smoke_ci + Scenario: LEZ private smoke stack initializes its configured private account + Given a LEZ private smoke stack + When I query the balance of the first configured private account + Then its balance matches the configured initial balance + And the indexer catches up to the sequencer within 120 seconds + Then I stop the runtime + + @smoke_ci + @multi_node + Scenario: LEZ operates over a multi-node Bedrock cluster + Given a LEZ stack with a multi-node Bedrock cluster + When I query the balance of the first configured public account + Then its balance matches the configured initial balance + And the indexer catches up to the sequencer within 120 seconds + Then I stop the runtime diff --git a/integration_tests/src/cucumber/context.rs b/integration_tests/src/cucumber/context.rs new file mode 100644 index 000000000..e8af8c136 --- /dev/null +++ b/integration_tests/src/cucumber/context.rs @@ -0,0 +1,256 @@ +use common::HashType; +use lee::AccountId; +use sequencer_service_rpc::SequencerClient; +use testing_framework_app::{AppHostEnv, DeployContext}; + +use crate::{ + cucumber::error::StepError, + indexer_client::IndexerClient, + testing_framework::{ + BedrockCluster, LezIndexerClient, LezRuntime, LezSequencerClient, + LezSequencerRegistryClient, LezStackHandle, + }, +}; + +/// Cucumber's view of an already deployed LEZ stack. +/// +/// `TestContext` is intentionally not reused here: it owns a separate Docker +/// Compose Bedrock deployment and the complete service lifecycle. The TF +/// applications provide the same low-level setup and deterministic +/// configuration, while this context mirrors only the useful `TestContext` API. +/// Deployment ownership remains in `crate::testing_framework`; Cucumber owns cloned handles +/// and scenario state only. +pub struct LezScenarioContext { + stack: LezStackHandle, +} + +/// Cucumber's view of the TF-owned multi-sequencer registry deployment. +pub struct LezSequencerRegistryScenarioContext { + bedrock: BedrockCluster, + indexer: LezIndexerClient, + registry: LezSequencerRegistryClient, +} + +impl LezSequencerRegistryScenarioContext { + /// Clones the sequencer registry handles exposed by the TF deployment registry. + pub fn from_deployment(deployment: &DeployContext) -> Result { + Ok(Self { + bedrock: deployment.require::().map_err(|error| { + StepError::MissingComponent { + component: "BedrockCluster", + message: error.to_string(), + } + })?, + indexer: deployment.require::().map_err(|error| { + StepError::MissingComponent { + component: "LezIndexerClient", + message: error.to_string(), + } + })?, + registry: deployment + .require::() + .map_err(|error| StepError::MissingComponent { + component: "LezSequencerRegistryClient", + message: error.to_string(), + })?, + }) + } + + /// Returns the deployed Bedrock cluster handle. + #[must_use] + pub const fn bedrock(&self) -> &BedrockCluster { + &self.bedrock + } + + /// Returns the deployed LEZ indexer handle. + #[must_use] + pub const fn indexer(&self) -> &LezIndexerClient { + &self.indexer + } + + /// Returns the JSON-RPC client for the deployed LEZ indexer. + #[must_use] + pub fn indexer_client(&self) -> &IndexerClient { + self.indexer().client() + } + + /// Returns the TF-owned sequencer registry handle. + #[must_use] + pub const fn registry(&self) -> &LezSequencerRegistryClient { + &self.registry + } +} + +impl LezScenarioContext { + /// Creates the Cucumber view from the complete-stack capability returned + /// by `LezLocalApp::deploy`. + #[must_use] + pub const fn from_stack(stack: LezStackHandle) -> Self { + Self { stack } + } + + /// Returns the deployed Bedrock cluster handle. + #[must_use] + pub const fn bedrock(&self) -> &BedrockCluster { + self.stack.bedrock() + } + + /// Returns the deployed LEZ indexer handle. + #[must_use] + pub const fn indexer(&self) -> &LezIndexerClient { + self.stack.indexer() + } + + /// Returns the JSON-RPC client for the deployed LEZ indexer. + #[must_use] + pub fn indexer_client(&self) -> &IndexerClient { + self.indexer().client() + } + + /// Returns the deployed LEZ sequencer handle. + #[must_use] + pub const fn sequencer(&self) -> &LezSequencerClient { + self.stack.sequencer() + } + + /// Returns the JSON-RPC client for the deployed LEZ sequencer. + #[must_use] + pub fn sequencer_client(&self) -> &SequencerClient { + self.sequencer().client() + } + + /// Returns the deployed LEZ wallet runtime handle. + #[must_use] + pub const fn wallet(&self) -> &LezRuntime { + self.stack.wallet() + } + + /// Returns the public account IDs currently imported into the wallet. + pub async fn existing_public_accounts(&self) -> Result, StepError> { + self.wallet() + .existing_public_accounts() + .await + .map_err(StepError::query_failed_boxed) + } + + /// Returns the private account IDs currently imported into the wallet. + pub async fn existing_private_accounts(&self) -> Result, StepError> { + self.wallet() + .existing_private_accounts() + .await + .map_err(StepError::query_failed_boxed) + } + + /// Returns the locally synchronized balance of an imported private account. + pub async fn private_account_balance( + &self, + account_id: AccountId, + ) -> Result, StepError> { + self.wallet() + .private_account_balance(account_id) + .await + .map_err(StepError::query_failed_boxed) + } + + /// Returns the current commitment for an imported private account. + pub async fn private_account_commitment( + &self, + account_id: AccountId, + ) -> Result, StepError> { + self.wallet() + .private_account_commitment(account_id) + .await + .map_err(StepError::query_failed_boxed) + } + + /// Returns the public signing key for an imported public account. + pub async fn public_account_signing_key( + &self, + account_id: AccountId, + ) -> Result, StepError> { + self.wallet() + .public_account_signing_key(account_id) + .await + .map_err(StepError::query_failed_boxed) + } + + /// Executes an authenticated transfer between two owned public accounts. + pub async fn public_transfer( + &self, + from: AccountId, + to: AccountId, + amount: u128, + ) -> Result { + self.wallet() + .public_transfer(from, to, amount) + .await + .map_err(StepError::query_failed_boxed) + } + + /// Executes an authenticated transfer between two owned private accounts. + pub async fn private_transfer( + &self, + from: AccountId, + to: AccountId, + amount: u128, + ) -> Result { + self.wallet() + .private_transfer(from, to, amount) + .await + .map_err(StepError::query_failed_boxed) + } + + /// Synchronizes the wallet with the latest sequencer block. + pub async fn sync_wallet_to_latest_block(&self) -> Result<(), StepError> { + self.wallet() + .sync_to_latest_block() + .await + .map_err(StepError::query_failed_boxed) + } + + /// Creates a fresh public account in the scenario wallet. + pub async fn new_public_account(&self) -> Result { + self.wallet() + .new_public_account() + .await + .map_err(StepError::query_failed_boxed) + } + + /// Executes an authenticated transfer that claims a fresh public account. + pub async fn public_transfer_to_new_account( + &self, + from: AccountId, + to: AccountId, + amount: u128, + ) -> Result { + self.wallet() + .public_transfer_to_new_account(from, to, amount) + .await + .map_err(StepError::query_failed_boxed) + } + + /// Assigns a label to an imported public account through the wallet actor. + pub async fn set_public_account_label( + &self, + account_id: AccountId, + label: wallet::account::Label, + ) -> Result<(), StepError> { + self.wallet() + .set_public_account_label(account_id, label) + .await + .map_err(StepError::query_failed_boxed) + } + + /// Executes an authenticated public transfer using wallet-resolved labels. + pub async fn public_transfer_by_labels( + &self, + from: wallet::account::Label, + to: wallet::account::Label, + amount: u128, + ) -> Result { + self.wallet() + .public_transfer_by_labels(from, to, amount) + .await + .map_err(StepError::query_failed_boxed) + } +} diff --git a/integration_tests/src/cucumber/default.rs b/integration_tests/src/cucumber/default.rs new file mode 100644 index 000000000..fa899a6b9 --- /dev/null +++ b/integration_tests/src/cucumber/default.rs @@ -0,0 +1,73 @@ +use std::{fs, path::PathBuf}; + +use anyhow::{Context as _, Result, anyhow}; +use tracing_subscriber::{EnvFilter, fmt}; + +const FEATURES_DIR_REL: &str = "cucumber_tests/features/"; +/// Environment variable controlling the maximum number of concurrent scenarios. +pub const MAX_CUCUMBER_CONCURRENT_SCENARIOS: &str = "MAX_CUCUMBER_CONCURRENT_SCENARIOS"; +/// Relative directory used for Cucumber scenario output. +pub const SCENARIO_OUTPUT_DIR_REL: &str = "cucumber_tests/temp"; +/// Directory name containing per-scenario artifacts. +pub const ARTEFACTS: &str = "cucumber_artefacts"; +/// Environment variable controlling the number of retries for failed scenarios. +const CUCUMBER_RETRIES: &str = "CUCUMBER_RETRIES"; +/// Environment variable controlling whether to keep logs for successful scenarios. +pub const TF_KEEP_LOGS: &str = "TF_KEEP_LOGS"; +/// Environment variable controlling the log level for Cucumber tests. +pub const RUST_LOG: &str = "RUST_LOG"; +/// Environment variable enabling removal of successful scenario artifacts. +pub const CUCUMBER_REMOVE_ARTEFACTS_IF_SUCCESSFUL: &str = "CUCUMBER_REMOVE_ARTEFACTS_IF_SUCCESSFUL"; +/// Environment variable selecting an existing node deployment configuration. +pub const CUCUMBER_NODE_CONFIG_OVERRIDE: &str = "CUCUMBER_NODE_CONFIG_OVERRIDE"; + +/// Installs the Cucumber tracing subscriber using `RUST_LOG` or an `info` default. +pub fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let _unused = fmt() + .with_env_filter(filter) + .with_target(true) + .with_writer(std::io::stderr) + .try_init(); +} + +/// Returns the path to the features directory. +#[expect(clippy::print_stdout, reason = "Cucumber logs test code")] +pub fn get_feature_path() -> Result { + let feature_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FEATURES_DIR_REL); + if !feature_path.is_dir() { + return Err(anyhow!( + "Feature path does not exist: {}", + feature_path.display() + )); + } + println!("Feature path: {}", feature_path.display()); + Ok(feature_path) +} + +/// Creates the output directory for the current scenario and returns its path. +#[expect(clippy::print_stdout, reason = "Cucumber logs test code")] +pub fn create_scenario_output_dir() -> Result { + let current_dir = std::env::current_dir().context("failed to determine current directory")?; + println!("Current directory: {}", current_dir.display()); + let output_dir = current_dir.join(SCENARIO_OUTPUT_DIR_REL); + fs::create_dir_all(&output_dir).context("failed to create Cucumber output directory")?; + println!("Output directory: {}", output_dir.display()); + Ok(output_dir) +} + +/// Get the number of retries for failed scenarios from the `CUCUMBER_RETRIES` +/// environment variable. Retries are opt-in: an unset variable and an explicit +/// zero both disable retries. +pub fn get_retries() -> Result> { + std::env::var_os(CUCUMBER_RETRIES).map_or_else( + || Ok(None), + |raw_retries| { + let retries_value = raw_retries.to_string_lossy(); + let retries = retries_value.parse::().with_context(|| { + format!("Invalid value for {CUCUMBER_RETRIES}: '{retries_value}'") + })?; + Ok((retries != 0).then_some(retries)) + }, + ) +} diff --git a/integration_tests/src/cucumber/error.rs b/integration_tests/src/cucumber/error.rs new file mode 100644 index 000000000..2e224d194 --- /dev/null +++ b/integration_tests/src/cucumber/error.rs @@ -0,0 +1,121 @@ +use testing_framework_core::scenario::DynError; +use thiserror::Error; + +/// Errors reported by Cucumber step implementations. +#[derive(Debug, Error)] +pub enum StepError { + /// A Gherkin argument or table value is malformed or out of range. + #[error("Invalid Cucumber argument: {message}")] + InvalidArgument { message: String }, + /// A local test-harness or filesystem operation failed. + #[error("Logical error: {message}")] + LogicalError { message: String }, + /// The scenario attempted to access the LEZ fixture before deployment. + #[error("Fixture has not been deployed")] + FixtureNotDeployed, + /// The scenario attempted to deploy the LEZ fixture more than once. + #[error("Fixture has already been deployed")] + FixtureAlreadyDeployed, + /// A required component was absent from the deployment registry. + #[error("Missing {component} from deployment: {message}")] + MissingComponent { + /// Name of the component that was requested. + component: &'static str, + /// Original deployment-registry diagnostic. + message: String, + }, + /// No account was selected by an earlier scenario step. + #[error("No selected account is available")] + MissingSelectedAccount, + /// No account balance was recorded by an earlier scenario step. + #[error("No observed balance is available")] + MissingObservedBalance, + /// A required scenario observation was not recorded by an earlier step. + #[error("Missing scenario observation: {field}")] + MissingObservation { field: &'static str }, + /// A named successful transfer does not exist in the scenario registry. + #[error("Unknown transfer artifact '{name}'")] + UnknownTransferArtifact { name: String }, + /// A scenario attempted to reuse a successful-transfer artifact name. + #[error("Duplicate transfer artifact '{name}'")] + DuplicateTransferArtifact { name: String }, + /// A named transfer has not yet been observed in a block. + #[error("Transfer artifact '{name}' has no recorded inclusion block")] + MissingTransferInclusion { name: String }, + /// LEZ application deployment failed. + #[error("Deployment failed: {message}")] + DeploymentFailed { message: String }, + /// An operational deployment error with its original source preserved. + #[error("Deployment failed: {source}")] + DeploymentFailedSource { + /// Original deployment error. + #[source] + source: anyhow::Error, + }, + /// An RPC query failed. + #[error("Query failed: {message}")] + QueryFailed { message: String }, + /// An operational query error with its original source preserved. + #[error("Query failed: {source}")] + QueryFailedSource { + /// Original query error. + #[source] + source: anyhow::Error, + }, + /// A polling operation exceeded its configured timeout. + #[error("Timed out: {message}")] + Timeout { message: String }, + /// A scenario assertion did not hold. + #[error("Assertion failed: {message}")] + AssertionFailed { message: String }, + /// Runtime teardown failed after the scenario completed or failed. + #[error("Runtime teardown failed: {message}")] + TeardownFailed { message: String }, + /// An operational teardown error with its original source preserved. + #[error("Runtime teardown failed: {source}")] + TeardownFailedSource { + /// Original teardown error. + #[source] + source: anyhow::Error, + }, +} + +impl StepError { + /// Retains the source of an operational deployment failure while adding + /// the scenario-specific context. + pub fn deployment_failed(error: E, context: impl Into) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::DeploymentFailedSource { + source: anyhow::Error::new(error).context(context.into()), + } + } + + /// Retains a testing-framework boxed deployment error with context. + pub fn deployment_failed_boxed(error: DynError, context: impl Into) -> Self { + Self::DeploymentFailedSource { + source: anyhow::Error::from_boxed(error).context(context.into()), + } + } + + /// Retains the source of an operational query failure. + pub fn query_failed(error: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::QueryFailedSource { + source: anyhow::Error::new(error), + } + } + + /// Retains a testing-framework boxed query error. + pub fn query_failed_boxed(error: DynError) -> Self { + Self::QueryFailedSource { + source: anyhow::Error::from_boxed(error), + } + } +} + +/// Result type returned by Cucumber step implementations. +pub type StepResult = Result<(), StepError>; diff --git a/integration_tests/src/cucumber/mod.rs b/integration_tests/src/cucumber/mod.rs new file mode 100644 index 000000000..01d08c878 --- /dev/null +++ b/integration_tests/src/cucumber/mod.rs @@ -0,0 +1,11 @@ +/// Cucumber context containing handles for a deployed LEZ stack. +pub mod context; +/// Cucumber runner configuration and filesystem helpers. +pub mod default; +mod error; +/// Cucumber step implementations. +pub mod steps; +/// Per-scenario Cucumber world and lifecycle management. +pub mod world; + +pub const TARGET: &str = "cucumber"; diff --git a/integration_tests/src/cucumber/steps/accounts/actions.rs b/integration_tests/src/cucumber/steps/accounts/actions.rs new file mode 100644 index 000000000..f23cf4215 --- /dev/null +++ b/integration_tests/src/cucumber/steps/accounts/actions.rs @@ -0,0 +1,74 @@ +use cucumber::{gherkin::Step, given}; +use lee::Account; +use sequencer_service_rpc::RpcClient as _; +use tracing::warn; +use wallet::account::Label; + +use super::super::{TARGET, log_step}; +use crate::cucumber::{ + error::{StepError, StepResult}, + world::CucumberWorld, +}; + +#[given("a new public account")] +async fn create_new_public_account(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let context = world.lez()?; + let account = context.new_public_account().await?; + match context.sequencer_client().get_account(account).await { + Ok(state) if state == Account::default() => {} + Ok(state) => { + warn!(target: TARGET, + "Cucumber step '{}' found non-default state for fresh public account {account:?}", + step.value + ); + return Err(StepError::AssertionFailed { + message: format!( + "new public account {account:?} already has sequencer state: {state:?}" + ), + }); + } + Err(error) => { + warn!(target: TARGET, + "Cucumber step '{}' failed to query fresh public account {account:?}: {error}", + step.value + ); + return Err(StepError::QueryFailed { + message: format!("failed to query fresh public account {account:?}: {error}"), + }); + } + } + world.environment.accounts.new_public_account = Some(account); + Ok(()) +} + +#[given( + expr = "the configured public accounts have sender label {string} and receiver label {string}" +)] +async fn label_configured_public_accounts( + world: &mut CucumberWorld, + step: &Step, + sender_label: String, + receiver_label: String, +) -> StepResult { + log_step(step); + let context = world.lez()?; + let accounts = context.existing_public_accounts().await?; + let sender = accounts + .first() + .copied() + .ok_or(StepError::MissingSelectedAccount)?; + let receiver = accounts + .get(1) + .copied() + .ok_or(StepError::MissingSelectedAccount)?; + context + .set_public_account_label(sender, Label::new(&sender_label)) + .await?; + context + .set_public_account_label(receiver, Label::new(&receiver_label)) + .await?; + world.environment.accounts.public_sender_label = Some(sender_label); + world.environment.accounts.public_receiver_label = Some(receiver_label); + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/accounts/assertions.rs b/integration_tests/src/cucumber/steps/accounts/assertions.rs new file mode 100644 index 000000000..d2d463d0b --- /dev/null +++ b/integration_tests/src/cucumber/steps/accounts/assertions.rs @@ -0,0 +1,44 @@ +use cucumber::{gherkin::Step, then}; + +use super::super::log_step; +use crate::cucumber::{ + error::{StepError, StepResult}, + world::CucumberWorld, +}; + +#[then("its balance matches the configured initial balance")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step functions require `&mut World` as the first parameter" +)] +fn assert_first_public_account_balance(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let account = world + .environment + .accounts + .selected_account + .ok_or(StepError::MissingSelectedAccount)?; + let observed_balance = world + .environment + .accounts + .observed_balance + .ok_or(StepError::MissingObservedBalance)?; + let expected_balance = + world + .environment + .accounts + .expected_balance + .ok_or_else(|| StepError::AssertionFailed { + message: "expected balance was not recorded".to_owned(), + })?; + + if observed_balance != expected_balance { + return Err(StepError::AssertionFailed { + message: format!( + "account {account:?} has balance {observed_balance}, expected {expected_balance}" + ), + }); + } + + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/accounts/helpers.rs b/integration_tests/src/cucumber/steps/accounts/helpers.rs new file mode 100644 index 000000000..bad2074ab --- /dev/null +++ b/integration_tests/src/cucumber/steps/accounts/helpers.rs @@ -0,0 +1,11 @@ +use test_fixtures::config::default_public_accounts_for_wallet; + +pub(super) fn expected_public_balance(account: lee::AccountId) -> Option { + default_public_accounts_for_wallet() + .into_iter() + .find_map(|(private_key, balance)| { + let configured_account = + lee::AccountId::from(&lee::PublicKey::new_from_private_key(&private_key)); + (configured_account == account).then_some(balance) + }) +} diff --git a/integration_tests/src/cucumber/steps/accounts/mod.rs b/integration_tests/src/cucumber/steps/accounts/mod.rs new file mode 100644 index 000000000..232cb0bd2 --- /dev/null +++ b/integration_tests/src/cucumber/steps/accounts/mod.rs @@ -0,0 +1,4 @@ +mod actions; +mod assertions; +mod helpers; +mod queries; diff --git a/integration_tests/src/cucumber/steps/accounts/queries.rs b/integration_tests/src/cucumber/steps/accounts/queries.rs new file mode 100644 index 000000000..7c207de7b --- /dev/null +++ b/integration_tests/src/cucumber/steps/accounts/queries.rs @@ -0,0 +1,74 @@ +use cucumber::{gherkin::Step, when}; +use sequencer_service_rpc::RpcClient as _; + +use super::super::log_step; +use crate::{ + config::default_private_accounts_for_wallet, + cucumber::{ + error::{StepError, StepResult}, + steps::accounts::helpers::expected_public_balance, + world::CucumberWorld, + }, +}; + +#[when("I query the balance of the first configured public account")] +async fn query_first_public_account_balance(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let context = world.lez()?; + let account = context + .existing_public_accounts() + .await? + .into_iter() + .next() + .ok_or(StepError::MissingSelectedAccount)?; + + let observed_balance = context + .sequencer_client() + .get_account_balance(account) + .await + .map_err(StepError::query_failed)?; + + let expected_balance = + expected_public_balance(account).ok_or_else(|| StepError::QueryFailed { + message: format!("account {account:?} is not in the configured public accounts"), + })?; + + world.environment.accounts.selected_account = Some(account); + world.environment.accounts.observed_balance = Some(observed_balance); + world.environment.accounts.expected_balance = Some(expected_balance); + Ok(()) +} + +#[when("I query the balance of the first configured private account")] +async fn query_first_private_account_balance(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let context = world.lez()?; + let account = context + .existing_private_accounts() + .await? + .into_iter() + .next() + .ok_or(StepError::MissingSelectedAccount)?; + + let observed_balance = context + .private_account_balance(account) + .await? + .ok_or_else(|| StepError::QueryFailed { + message: format!("private account {account:?} has no synchronized balance"), + })?; + + let expected_balance = default_private_accounts_for_wallet() + .into_iter() + .find(|configured| configured.account_id() == account) + .map(|configured| configured.balance) + .ok_or_else(|| StepError::QueryFailed { + message: format!( + "private account {account:?} is not in the configured private accounts" + ), + })?; + + world.environment.accounts.selected_account = Some(account); + world.environment.accounts.observed_balance = Some(observed_balance); + world.environment.accounts.expected_balance = Some(expected_balance); + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/committee/actions.rs b/integration_tests/src/cucumber/steps/committee/actions.rs new file mode 100644 index 000000000..3897f44bb --- /dev/null +++ b/integration_tests/src/cucumber/steps/committee/actions.rs @@ -0,0 +1,514 @@ +use std::time::Duration; + +use cucumber::{gherkin::Step, given, when}; +use logos_blockchain_key_management_system_service::keys::Ed25519Key; +use sequencer_core::{ + block_publisher::{Ed25519PublicKey, read_channel_state}, + config::BedrockConfig, +}; +use sequencer_service_rpc::RpcClient as _; +use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; + +use super::{ + super::{ + log_step, + transfers::helpers::{ensure_transfer_name_available, insert_transfer_artifact}, + }, + parse_committee_config, parse_sequencer_registrations, require_sequencer, +}; +use crate::{ + config::{self, UrlProtocol}, + cucumber::{ + error::{StepError, StepResult}, + world::{CucumberWorld, TransferArtifact, TransferKind}, + }, +}; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +async fn wait_for_height( + client: &sequencer_service_rpc::SequencerClient, + target: u64, + timeout_seconds: u64, + description: &str, +) -> StepResult { + let timeout = Duration::from_secs(timeout_seconds); + let wait = async { + loop { + if client + .get_last_block_id() + .await + .map_err(StepError::query_failed)? + >= target + { + return Ok::<(), StepError>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(timeout, wait) + .await + .map_err(|_elapsed| StepError::Timeout { + message: format!( + "timed out waiting for {description} at block {target} within {timeout:?}" + ), + })??; + Ok(()) +} + +#[given("the following LEZ sequencers are registered")] +#[expect( + clippy::needless_pass_by_ref_mut, + clippy::unused_async, + reason = "Cucumber step handlers use the framework's async mutable-world signature" +)] +async fn register_lez_sequencers(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let registrations = parse_sequencer_registrations(step)?; + let registry = world.sequencer_registry()?.registry(); + for (alias, signing_key) in registrations { + registry + .register(alias, signing_key) + .map_err(|error| StepError::InvalidArgument { + message: format!( + "step '{}' could not register sequencer: {error}", + step.value + ), + })?; + } + Ok(()) +} + +#[given(expr = "sequencer {string} configures the committee")] +#[expect( + clippy::unused_async, + reason = "Cucumber step handlers use the framework's mutable-world signature" +)] +async fn configure_committee( + world: &mut CucumberWorld, + step: &Step, + leader_alias: String, +) -> StepResult { + log_step(step); + let authorized_sequencers = parse_committee_config(step, &leader_alias)?; + let registry = world.sequencer_registry()?.registry(); + if registry.signing_key(&leader_alias).is_none() { + return Err(StepError::InvalidArgument { + message: format!("committee leader '{leader_alias}' is not registered"), + }); + } + for alias in &authorized_sequencers { + if registry.signing_key(alias).is_none() { + return Err(StepError::InvalidArgument { + message: format!("authorized sequencer '{alias}' is not registered"), + }); + } + } + registry + .configure_initial_committee(&authorized_sequencers) + .map_err(|error| StepError::InvalidArgument { + message: format!( + "step '{}' could not configure the committee: {error}", + step.value + ), + })?; + world.environment.committee.committee_configuration = + Some(crate::cucumber::world::CommitteeConfiguration { + leader_alias, + authorized_sequencers, + }); + Ok(()) +} + +#[when(expr = "I start sequencer {string}")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers use the framework's mutable-world signature" +)] +async fn start_sequencer(world: &mut CucumberWorld, step: &Step, alias: String) -> StepResult { + log_step(step); + world + .sequencer_registry()? + .registry() + .start(&alias) + .await + .map_err(|error| { + StepError::deployment_failed_boxed( + error, + format!("step '{}' failed to start sequencer '{alias}'", step.value), + ) + }) +} + +#[when(expr = "sequencer {string} reaches block {int} within {int} seconds")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers use the framework's mutable-world signature" +)] +async fn sequencer_reaches_block( + world: &mut CucumberWorld, + step: &Step, + alias: String, + target: u64, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let sequencer = require_sequencer(world.sequencer_registry()?.registry(), &alias)?; + wait_for_height( + sequencer.client(), + target, + timeout_seconds, + &format!("sequencer '{alias}' to reach block {target}"), + ) + .await +} + +#[when(expr = "sequencer {string} synchronizes to sequencer {string} within {int} seconds")] +async fn sequencer_synchronizes( + world: &mut CucumberWorld, + step: &Step, + joining_alias: String, + source_alias: String, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let registry = world.sequencer_registry()?.registry(); + let source = require_sequencer(registry, &source_alias)?; + let join_height = source + .client() + .get_last_block_id() + .await + .map_err(StepError::query_failed)?; + let joining = require_sequencer(registry, &joining_alias)?; + wait_for_height( + joining.client(), + join_height, + timeout_seconds, + &format!("sequencer '{joining_alias}' to synchronize to '{source_alias}'"), + ) + .await?; + world.environment.committee.committee_join_height = Some(join_height); + Ok(()) +} + +#[when(expr = "the committee configured by sequencer {string} is active within {int} seconds")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers receive the framework's mutable-world signature" +)] +async fn committee_is_active( + world: &mut CucumberWorld, + step: &Step, + leader_alias: String, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let configuration = world + .environment + .committee + .committee_configuration + .as_ref() + .ok_or(StepError::MissingObservation { + field: "committee configuration", + })?; + if configuration.leader_alias != leader_alias { + return Err(StepError::AssertionFailed { + message: format!( + "committee was configured by '{}', not '{leader_alias}'", + configuration.leader_alias + ), + }); + } + let context = world.sequencer_registry()?; + require_sequencer(context.registry(), &leader_alias)?; + let expected_keys = configuration + .authorized_sequencers + .iter() + .map(|alias| { + let signing_key = context.registry().signing_key(alias).ok_or_else(|| { + StepError::InvalidArgument { + message: format!("authorized sequencer '{alias}' is not registered"), + } + })?; + Ok(Ed25519Key::from_bytes(&signing_key).public_key().to_bytes()) + }) + .collect::, StepError>>()?; + let bedrock_config = BedrockConfig { + channel_id: config::bedrock_channel_id(), + node_url: config::addr_to_url(UrlProtocol::Http, context.bedrock().primary_api_addr()) + .map_err(|source| StepError::QueryFailedSource { source })?, + funding_key: config::bedrock_funding_key(), + auth: None, + priority_fee: 10_000, + }; + let timeout = Duration::from_secs(timeout_seconds); + let wait = async { + loop { + let state = read_channel_state(&bedrock_config) + .await + .map_err(|source| StepError::QueryFailedSource { source })?; + let active = state.is_some_and(|state| { + let mut actual_keys = state + .accredited_keys + .iter() + .map(Ed25519PublicKey::to_bytes) + .collect::>(); + actual_keys.sort_unstable(); + let mut expected_keys = expected_keys.clone(); + expected_keys.sort_unstable(); + actual_keys == expected_keys + }); + if active { + return Ok::<(), StepError>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(timeout, wait) + .await + .map_err(|_elapsed| StepError::Timeout { + message: format!( + "committee configured by '{leader_alias}' was not active within {timeout:?}" + ), + })??; + Ok(()) +} + +#[when(expr = "sequencer {string} becomes the posting turn within {int} seconds")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers receive the framework's mutable-world signature" +)] +async fn sequencer_becomes_posting_turn( + world: &mut CucumberWorld, + step: &Step, + alias: String, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let context = world.sequencer_registry()?; + let signing_key = + context + .registry() + .signing_key(&alias) + .ok_or_else(|| StepError::InvalidArgument { + message: format!("sequencer '{alias}' is not registered"), + })?; + let expected_key: Ed25519PublicKey = Ed25519Key::from_bytes(&signing_key).public_key(); + let bedrock_config = BedrockConfig { + channel_id: config::bedrock_channel_id(), + node_url: config::addr_to_url(UrlProtocol::Http, context.bedrock().primary_api_addr()) + .map_err(|source| StepError::QueryFailedSource { source })?, + funding_key: config::bedrock_funding_key(), + auth: None, + priority_fee: 10_000, + }; + let timeout = Duration::from_secs(timeout_seconds); + let wait = async { + loop { + let is_turn = read_channel_state(&bedrock_config) + .await + .map_err(|source| StepError::QueryFailedSource { source })? + .and_then(|state| { + state + .accredited_keys + .get(usize::from(state.tip_sequencer)) + .copied() + }) + == Some(expected_key); + if is_turn { + return Ok::<(), StepError>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(timeout, wait) + .await + .map_err(|_elapsed| StepError::Timeout { + message: format!( + "sequencer '{alias}' did not become the posting turn within {timeout:?}" + ), + })??; + Ok(()) +} + +#[when( + expr = "sequencers {string} and {string} advance across {int} rotation blocks within {int} seconds" +)] +async fn sequencers_advance_across_rotation_blocks( + world: &mut CucumberWorld, + step: &Step, + first_alias: String, + second_alias: String, + rotation_blocks: u64, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let join_height = + world + .environment + .committee + .committee_join_height + .ok_or(StepError::MissingObservation { + field: "committee join height", + })?; + let target = + join_height + .checked_add(rotation_blocks) + .ok_or_else(|| StepError::AssertionFailed { + message: "committee rotation target overflowed".to_owned(), + })?; + let registry = world.sequencer_registry()?.registry(); + let first = require_sequencer(registry, &first_alias)?; + let second = require_sequencer(registry, &second_alias)?; + wait_for_height( + first.client(), + target, + timeout_seconds, + &format!("sequencer '{first_alias}' across committee rotation windows"), + ) + .await?; + wait_for_height( + second.client(), + target, + timeout_seconds, + &format!("sequencer '{second_alias}' across committee rotation windows"), + ) + .await?; + world.environment.committee.committee_rotation_target = Some(target); + Ok(()) +} + +#[when( + expr = "I submit {int} from deterministic public account {int} to account {int} through sequencer {string} as {string}" +)] +async fn submit_committee_transfer( + world: &mut CucumberWorld, + step: &Step, + amount: u128, + sender_index: usize, + receiver_index: usize, + sequencer_alias: String, + transfer_name: String, +) -> StepResult { + log_step(step); + ensure_transfer_name_available(world, &transfer_name)?; + let registry = world.sequencer_registry()?.registry(); + let sequencer = require_sequencer(registry, &sequencer_alias)?; + let accounts = initial_public_user_accounts(); + let sender = accounts + .get(sender_index) + .ok_or_else(|| StepError::InvalidArgument { + message: format!("sender account index {sender_index} is out of range"), + })? + .account_id; + let receiver = accounts + .get(receiver_index) + .ok_or_else(|| StepError::InvalidArgument { + message: format!("receiver account index {receiver_index} is out of range"), + })? + .account_id; + let pending_observation = world + .environment + .transfers + .pending_observation + .as_ref() + .ok_or(StepError::MissingObservation { + field: "committee transfer receiver baseline", + })?; + if pending_observation.receiver != receiver { + return Err(StepError::AssertionFailed { + message: format!( + "transfer receiver {receiver:?} does not match the recorded observer baseline" + ), + }); + } + let nonce = sequencer + .client() + .get_accounts_nonces(vec![sender]) + .await + .map_err(StepError::query_failed)? + .into_iter() + .next() + .ok_or_else(|| StepError::QueryFailed { + message: format!("no nonce returned for deterministic sender {sender:?}"), + })?; + let sender_balance_before = sequencer + .client() + .get_account_balance(sender) + .await + .map_err(StepError::query_failed)?; + let signing_key = initial_pub_accounts_private_keys() + .get(sender_index) + .ok_or_else(|| StepError::InvalidArgument { + message: format!("sender account index {sender_index} has no signing key"), + })? + .pub_sign_key + .clone(); + let transaction = common::test_utils::create_transaction_native_token_transfer( + sender, + nonce.0, + receiver, + amount, + &signing_key, + ); + let transaction_hash = sequencer + .client() + .send_transaction(transaction) + .await + .map_err(StepError::query_failed)?; + let submitted_observation = world + .environment + .transfers + .pending_observation + .take() + .ok_or(StepError::MissingObservation { + field: "committee transfer receiver baseline", + })?; + insert_transfer_artifact( + world, + transfer_name, + TransferArtifact { + hash: transaction_hash, + sender, + receiver, + amount, + kind: TransferKind::Public, + sender_balance_before, + receiver_balance_before: submitted_observation.receiver_balance_before, + sender_nonce_before: Some(nonce), + receiver_balance_observer: Some(submitted_observation.observer_alias), + inclusion_block: None, + }, + )?; + Ok(()) +} + +#[when(expr = "I record deterministic public account {int} balance on sequencer {string}")] +async fn record_committee_balance_baseline( + world: &mut CucumberWorld, + step: &Step, + account_index: usize, + observer_alias: String, +) -> StepResult { + log_step(step); + let account = initial_public_user_accounts() + .get(account_index) + .ok_or_else(|| StepError::InvalidArgument { + message: format!("account index {account_index} is out of range"), + })? + .account_id; + let observer = require_sequencer(world.sequencer_registry()?.registry(), &observer_alias)?; + let balance = observer + .client() + .get_account_balance(account) + .await + .map_err(StepError::query_failed)?; + world.environment.transfers.pending_observation = + Some(crate::cucumber::world::PendingTransferObservation { + receiver: account, + receiver_balance_before: balance, + observer_alias, + }); + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/committee/assertions.rs b/integration_tests/src/cucumber/steps/committee/assertions.rs new file mode 100644 index 000000000..3cdbf0ef0 --- /dev/null +++ b/integration_tests/src/cucumber/steps/committee/assertions.rs @@ -0,0 +1,296 @@ +use std::time::{Duration, Instant}; + +use cucumber::{gherkin::Step, then}; +use indexer_service_rpc::RpcClient as IndexerRpcClient; +use sequencer_service_rpc::RpcClient as SequencerRpcClient; + +use super::{ + super::{ + log_step, + transfers::helpers::{transfer_artifact, wait_for_transfer_inclusion}, + }, + require_sequencer, +}; +use crate::{ + cucumber::{ + error::{StepError, StepResult}, + steps::indexer::convergence::wait_for_named_transfer_indexed, + world::CucumberWorld, + }, + testing_framework::{IndexerCatchUpError, wait_for_indexer_to_reach_with_timeout}, +}; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +async fn common_hash_state( + first: &sequencer_service_rpc::SequencerClient, + second: &sequencer_service_rpc::SequencerClient, +) -> Result<(u64, u64, Option), StepError> { + let first_height = SequencerRpcClient::get_last_block_id(first) + .await + .map_err(StepError::query_failed)?; + let second_height = SequencerRpcClient::get_last_block_id(second) + .await + .map_err(StepError::query_failed)?; + for block_id in 1..=first_height.min(second_height) { + let first_block = SequencerRpcClient::get_block(first, block_id) + .await + .map_err(StepError::query_failed)?; + let second_block = SequencerRpcClient::get_block(second, block_id) + .await + .map_err(StepError::query_failed)?; + if !matches!((&first_block, &second_block), (Some(a), Some(b)) if a.header.hash == b.header.hash) + { + return Ok((first_height, second_height, Some(block_id))); + } + } + Ok((first_height, second_height, None)) +} + +#[then( + expr = "transfer {string} is included in a block on sequencer {string} within {int} seconds" +)] +async fn assert_committee_transfer_is_included( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + sequencer_alias: String, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let sequencer = require_sequencer(world.sequencer_registry()?.registry(), &sequencer_alias)?; + let block_id = wait_for_transfer_inclusion( + sequencer.client(), + &artifact, + Duration::from_secs(timeout_seconds), + &format!("transfer '{transfer_name}' was not included on sequencer '{sequencer_alias}'"), + ) + .await?; + world + .environment + .transfers + .artifacts + .get_mut(&transfer_name) + .ok_or_else(|| StepError::UnknownTransferArtifact { + name: transfer_name.clone(), + })? + .inclusion_block = Some(block_id); + Ok(()) +} + +#[then( + expr = "sequencer {string} observes the receiver balance increase for transfer {string} by {int} within {int} seconds" +)] +async fn sequencer_observes_receiver_balance_increase( + world: &mut CucumberWorld, + step: &Step, + observer_alias: String, + transfer_name: String, + amount: u128, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let context = world.sequencer_registry()?; + let registry = context.registry(); + let observer = require_sequencer(registry, &observer_alias)?; + if artifact.receiver_balance_observer.as_deref() != Some(&observer_alias) { + return Err(StepError::AssertionFailed { + message: format!( + "receiver baseline was not recorded on observing sequencer '{observer_alias}'" + ), + }); + } + let receiver = artifact.receiver; + let initial_balance = artifact.receiver_balance_before; + if artifact.amount != amount { + return Err(StepError::AssertionFailed { + message: format!( + "transfer '{transfer_name}' has amount {}, expected {amount}", + artifact.amount + ), + }); + } + let expected_balance = initial_balance + .checked_add(artifact.amount) + .ok_or_else(|| StepError::AssertionFailed { + message: format!("receiver balance overflow after adding {amount}"), + })?; + let timeout = Duration::from_secs(timeout_seconds); + let wait = async { + loop { + let observed = observer + .client() + .get_account_balance(receiver) + .await + .map_err(StepError::query_failed)?; + if observed == expected_balance { + return Ok::<(), StepError>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(timeout, wait) + .await + .map_err(|_elapsed| StepError::Timeout { + message: format!( + "sequencer '{observer_alias}' did not observe receiver balance {expected_balance} within {timeout:?}" + ), + })??; + world.environment.indexer.committee_target_height = Some( + observer + .client() + .get_last_block_id() + .await + .map_err(StepError::query_failed)?, + ); + Ok(()) +} + +#[then( + expr = "sequencers {string} and {string} have identical common block hashes within {int} seconds" +)] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers receive mutable world references" +)] +async fn sequencers_have_identical_common_block_hashes( + world: &mut CucumberWorld, + step: &Step, + first_alias: String, + second_alias: String, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let registry = world.sequencer_registry()?.registry(); + let first = require_sequencer(registry, &first_alias)?; + let second = require_sequencer(registry, &second_alias)?; + let timeout = Duration::from_secs(timeout_seconds); + let started = Instant::now(); + loop { + let (first_height, second_height, first_divergent_block) = + common_hash_state(first.client(), second.client()).await?; + if first_divergent_block.is_none() { + return Ok(()); + } + let elapsed = started.elapsed(); + if elapsed >= timeout { + return Err(StepError::Timeout { + message: format!( + "sequencers '{first_alias}' and '{second_alias}' did not converge through block {} within {timeout:?}; heights were {first_height} and {second_height}", + first_divergent_block.unwrap_or_default() + ), + }); + } + let remaining = timeout.saturating_sub(elapsed); + tokio::time::sleep(POLL_INTERVAL.min(remaining)).await; + } +} + +#[then( + expr = "the indexer finalizes transfer {string} on the committee chain within {int} seconds" +)] +async fn indexer_finalizes_committee_chain( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let context = world.sequencer_registry()?; + let artifact = transfer_artifact(world, &transfer_name)?; + let timeout = Duration::from_secs(timeout_seconds); + let mut height = + wait_for_named_transfer_indexed(context.indexer(), &artifact, &transfer_name, timeout) + .await?; + if let Some(committee_target) = world.environment.indexer.committee_target_height + && committee_target > height + { + height = wait_for_indexer_to_reach_with_timeout( + context.indexer(), + committee_target, + timeout, + ) + .await + .map_err(|error| match error { + IndexerCatchUpError::Timeout { + target, + last_observed, + elapsed, + } => StepError::Timeout { + message: format!( + "indexer did not reach committee target block {target}; last observed {last_observed} after {elapsed:?}" + ), + }, + IndexerCatchUpError::SequencerQuery { message } + | IndexerCatchUpError::IndexerQuery { message } => StepError::QueryFailed { message }, + })?; + } + world.environment.indexer.committee_finalized_height = Some(height); + Ok(()) +} + +#[then(expr = "finalized indexer blocks match sequencer {string}")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers receive mutable world references" +)] +async fn finalized_indexer_blocks_match_sequencer( + world: &mut CucumberWorld, + step: &Step, + sequencer_alias: String, +) -> StepResult { + log_step(step); + let context = world.sequencer_registry()?; + let sequencer = require_sequencer(context.registry(), &sequencer_alias)?; + let finalized = world.environment.indexer.committee_finalized_height.ok_or( + StepError::MissingObservation { + field: "committee indexer finalized height", + }, + )?; + for block_id in 1..=finalized { + let indexer_block = + IndexerRpcClient::get_block_by_id(&**context.indexer_client(), block_id) + .await + .map_err(StepError::query_failed)? + .ok_or_else(|| StepError::QueryFailed { + message: format!("indexer is missing finalized block {block_id}"), + })?; + let sequencer_block = SequencerRpcClient::get_block(sequencer.client(), block_id) + .await + .map_err(StepError::query_failed)? + .ok_or_else(|| StepError::QueryFailed { + message: format!("sequencer '{sequencer_alias}' is missing block {block_id}"), + })?; + if indexer_block.header.hash + != indexer_service_protocol::HashType::from(sequencer_block.header.hash) + { + return Err(StepError::AssertionFailed { + message: format!( + "indexer diverges from sequencer '{sequencer_alias}' at block {block_id}" + ), + }); + } + } + Ok(()) +} + +#[then("the indexer is not stalled")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers receive mutable world references" +)] +async fn indexer_is_not_stalled(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let context = world.sequencer_registry()?; + let status = IndexerRpcClient::get_status(&**context.indexer_client()) + .await + .map_err(StepError::query_failed)?; + if let Some(reason) = status.stall_reason { + return Err(StepError::AssertionFailed { + message: format!("indexer is stalled: {reason:?}"), + }); + } + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/committee/mod.rs b/integration_tests/src/cucumber/steps/committee/mod.rs new file mode 100644 index 000000000..d78504ec0 --- /dev/null +++ b/integration_tests/src/cucumber/steps/committee/mod.rs @@ -0,0 +1,140 @@ +use std::collections::HashSet; + +use cucumber::gherkin::Step; +use logos_blockchain_key_management_system_service::keys::ED25519_SECRET_KEY_SIZE; + +use crate::{ + cucumber::error::StepError, + testing_framework::{LezSequencerClient, LezSequencerRegistryClient}, +}; + +mod actions; +mod assertions; + +pub(crate) fn parse_sequencer_registrations( + step: &Step, +) -> Result, StepError> { + let table = step + .table + .as_ref() + .ok_or_else(|| StepError::InvalidArgument { + message: "sequencer registration requires a table".to_owned(), + })?; + if table.rows.first() != Some(&vec!["alias".to_owned(), "signing_key".to_owned()]) { + return Err(StepError::InvalidArgument { + message: "sequencer registration table must use `alias`, `signing_key` headers" + .to_owned(), + }); + } + let mut aliases = HashSet::new(); + table + .rows + .iter() + .skip(1) + .map(|row| { + if row.len() != 2 { + return Err(StepError::InvalidArgument { + message: format!( + "sequencer registration rows require 2 columns, got {}", + row.len() + ), + }); + } + let alias = row[0].trim().to_owned(); + if alias.is_empty() { + return Err(StepError::InvalidArgument { + message: "sequencer alias cannot be empty".to_owned(), + }); + } + if !aliases.insert(alias.clone()) { + return Err(StepError::InvalidArgument { + message: format!("sequencer alias '{alias}' is duplicated"), + }); + } + let seed = row[1].trim(); + let seed = seed + .strip_prefix("0x") + .or_else(|| seed.strip_prefix("0X")) + .ok_or_else(|| StepError::InvalidArgument { + message: format!("signing key seed '{seed}' must use 0xNN notation"), + })?; + let seed = + u8::from_str_radix(seed, 16).map_err(|_error| StepError::InvalidArgument { + message: format!("signing key seed '{}' is not a byte", row[1].trim()), + })?; + Ok((alias, [seed; ED25519_SECRET_KEY_SIZE])) + }) + .collect() +} + +pub(crate) fn parse_committee_config( + step: &Step, + leader_alias: &str, +) -> Result, StepError> { + let table = step + .table + .as_ref() + .ok_or_else(|| StepError::InvalidArgument { + message: "committee configuration requires a table".to_owned(), + })?; + let expected = ["authorized_sequencers"]; + if table.rows.first() != Some(&expected.iter().map(ToString::to_string).collect::>()) { + return Err(StepError::InvalidArgument { + message: "committee configuration table has invalid headers".to_owned(), + }); + } + let row = table + .rows + .get(1) + .ok_or_else(|| StepError::InvalidArgument { + message: "committee configuration requires one data row".to_owned(), + })?; + if row.len() != expected.len() || table.rows.len() != 2 { + return Err(StepError::InvalidArgument { + message: "committee configuration requires exactly one complete data row".to_owned(), + }); + } + let authorized_sequencers = row[0] + .split(',') + .map(str::trim) + .filter(|alias| !alias.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + if authorized_sequencers.is_empty() { + return Err(StepError::InvalidArgument { + message: "authorized_sequencers must contain at least one alias".to_owned(), + }); + } + let mut unique_aliases = HashSet::new(); + if authorized_sequencers + .iter() + .any(|alias| !unique_aliases.insert(alias)) + { + return Err(StepError::InvalidArgument { + message: "authorized_sequencers must not contain duplicate aliases".to_owned(), + }); + } + if !authorized_sequencers + .iter() + .any(|alias| alias == leader_alias) + { + return Err(StepError::InvalidArgument { + message: format!( + "committee leader '{leader_alias}' must be included in authorized_sequencers" + ), + }); + } + Ok(authorized_sequencers) +} + +pub(crate) fn require_sequencer( + registry: &LezSequencerRegistryClient, + alias: &str, +) -> Result { + registry + .sequencer(alias) + .ok_or_else(|| StepError::MissingComponent { + component: "LezSequencerRegistryClient", + message: format!("sequencer alias '{alias}' was not started"), + }) +} diff --git a/integration_tests/src/cucumber/steps/environment/helpers.rs b/integration_tests/src/cucumber/steps/environment/helpers.rs new file mode 100644 index 000000000..dcc605650 --- /dev/null +++ b/integration_tests/src/cucumber/steps/environment/helpers.rs @@ -0,0 +1,130 @@ +use std::{path::PathBuf, time::Duration}; + +use cucumber::gherkin::Step; +use tracing::warn; + +use super::super::TARGET; +use crate::{ + config::SequencerPartialConfig, + cucumber::{ + context::LezScenarioContext, + error::{StepError, StepResult}, + world::CucumberWorld, + }, + testing_framework::{BedrockApp, IndexerApp, LezLocalApp, LezSequencerRegistryApp}, +}; + +pub(crate) async fn deploy_lez_stack( + world: &mut CucumberWorld, + bedrock: BedrockApp, + initialize_private_accounts: bool, + step: &Step, +) -> StepResult { + if world.lez.is_some() { + return Err(StepError::FixtureAlreadyDeployed); + } + + let entropy = world + .test_context + .clone() + .unwrap_or_else(|| "unknown-time".to_owned()); + let scenario_base_dir = world.scenario_base_dir.join(entropy); + let app = LezLocalApp::new() + .with_bedrock(bedrock) + .with_scenario_base_dir(scenario_base_dir) + .with_priority_fee(10_000); + let app = if initialize_private_accounts { + app + } else { + // The public smoke scenario deliberately exercises only the + // public-account path. The private smoke scenario uses the default + // fixture so private-account initialization is covered separately. + app.without_private_account_initialization() + }; + + let stack = world.deployment_mut().deploy(app).await.map_err(|error| { + warn!(target: TARGET, + "Cucumber step '{}' failed during deployment: {error:?}", + step.value + ); + StepError::deployment_failed_boxed(error, "Cucumber LEZ stack deployment failed") + })?; + + world.set_lez(LezScenarioContext::from_stack(stack)) +} + +pub(crate) async fn deploy_lez_sequencer_registry( + world: &mut CucumberWorld, + bedrock: BedrockApp, + step: &Step, +) -> StepResult { + if world.lez.is_some() || world.sequencer_registry.is_some() { + return Err(StepError::FixtureAlreadyDeployed); + } + let entropy = world + .test_context + .clone() + .unwrap_or_else(|| "unknown-time".to_owned()); + let scenario_base_dir = world.scenario_base_dir.join(entropy); + let bedrock = world + .deployment_mut() + .deploy_and_expose(bedrock.with_scenario_base_dir(scenario_base_dir.join("node"))) + .await + .map_err(|error| { + StepError::deployment_failed_boxed( + error, + format!( + "Cucumber step '{}' failed during Bedrock deployment", + step.value + ), + ) + })?; + bedrock.wait_for_first_block().await.map_err(|error| { + StepError::deployment_failed_boxed( + error, + format!( + "Cucumber step '{}' failed waiting for Bedrock funding readiness", + step.value + ), + ) + })?; + let indexer = IndexerApp::new(bedrock.primary_api_addr()) + .with_state_dir(scenario_base_dir.join("lez/indexer")); + world + .deployment_mut() + .deploy_and_expose(indexer) + .await + .map_err(|error| { + StepError::deployment_failed_boxed( + error, + format!( + "Cucumber step '{}' failed during indexer deployment", + step.value + ), + ) + })?; + let sequencer_config = SequencerPartialConfig { + block_create_timeout: Duration::from_secs(5), + priority_fee: 10_000, + ..SequencerPartialConfig::default() + }; + let registry = LezSequencerRegistryApp::new(sequencer_config, bedrock.primary_api_addr()) + .with_scenario_base_dir(PathBuf::from(&scenario_base_dir)); + world + .deployment_mut() + .deploy_and_expose(registry) + .await + .map_err(|error| { + StepError::deployment_failed_boxed( + error, + format!( + "Cucumber step '{}' failed during sequencer registry deployment", + step.value + ), + ) + })?; + let context = crate::cucumber::context::LezSequencerRegistryScenarioContext::from_deployment( + world.deployment(), + )?; + world.set_sequencer_registry(context) +} diff --git a/integration_tests/src/cucumber/steps/environment/mod.rs b/integration_tests/src/cucumber/steps/environment/mod.rs new file mode 100644 index 000000000..1fd69586b --- /dev/null +++ b/integration_tests/src/cucumber/steps/environment/mod.rs @@ -0,0 +1,3 @@ +pub mod helpers; +mod setup; +mod teardown; diff --git a/integration_tests/src/cucumber/steps/environment/setup.rs b/integration_tests/src/cucumber/steps/environment/setup.rs new file mode 100644 index 000000000..7b051efba --- /dev/null +++ b/integration_tests/src/cucumber/steps/environment/setup.rs @@ -0,0 +1,69 @@ +use cucumber::{gherkin::Step, given}; + +use super::super::log_step; +use crate::{ + cucumber::{ + error::StepResult, + steps::environment::helpers::{deploy_lez_sequencer_registry, deploy_lez_stack}, + world::CucumberWorld, + }, + testing_framework::BedrockApp, +}; + +#[given("a LEZ smoke stack")] +#[given("a LEZ stack with configured public accounts")] +async fn deploy_lez_public_stack(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + deploy_lez_stack( + world, + BedrockApp::nodes_with_blend_core_nodes(1, 0, world.test_context()), + false, + step, + ) + .await +} + +#[given(expr = "a LEZ multi-sequencer environment with {int} validator and {int} Blend nodes")] +async fn deploy_lez_multi_sequencer_environment( + world: &mut CucumberWorld, + step: &Step, + validators: usize, + blend_core_nodes: usize, +) -> StepResult { + log_step(step); + deploy_lez_sequencer_registry( + world, + BedrockApp::nodes_with_committee_funding( + validators, + blend_core_nodes, + world.test_context(), + ), + step, + ) + .await +} + +#[given("a LEZ private smoke stack")] +#[given("a LEZ stack with configured private accounts")] +async fn deploy_lez_private_stack(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + deploy_lez_stack( + world, + BedrockApp::nodes_with_blend_core_nodes(1, 0, world.test_context()), + true, + step, + ) + .await +} + +#[given("a LEZ stack with a multi-node Bedrock cluster")] +async fn deploy_lez_multi_node_stack(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + deploy_lez_stack( + world, + BedrockApp::nodes_with_blend_core_nodes(5, 2, world.test_context()), + false, + step, + ) + .await +} diff --git a/integration_tests/src/cucumber/steps/environment/teardown.rs b/integration_tests/src/cucumber/steps/environment/teardown.rs new file mode 100644 index 000000000..5124acca1 --- /dev/null +++ b/integration_tests/src/cucumber/steps/environment/teardown.rs @@ -0,0 +1,10 @@ +use cucumber::{gherkin::Step, then}; + +use super::super::log_step; +use crate::cucumber::{error::StepResult, world::CucumberWorld}; + +#[then("I stop the runtime")] +async fn stop_runtime(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + world.stop_runtime().await +} diff --git a/integration_tests/src/cucumber/steps/indexer/convergence.rs b/integration_tests/src/cucumber/steps/indexer/convergence.rs new file mode 100644 index 000000000..053cfaaed --- /dev/null +++ b/integration_tests/src/cucumber/steps/indexer/convergence.rs @@ -0,0 +1,140 @@ +use std::time::Duration; + +use cucumber::{gherkin::Step, then}; +use indexer_service_rpc::RpcClient as IndexerRpcClient; +use sequencer_service_rpc::RpcClient as SequencerRpcClient; + +use super::super::log_step; +use crate::{ + cucumber::{ + error::{StepError, StepResult}, + steps::transfers::helpers::transfer_artifact, + world::{CucumberWorld, TransferArtifact}, + }, + testing_framework::{ + IndexerCatchUpError, LezIndexerClient, wait_for_indexer_to_catch_up_with_timeout, + wait_for_indexer_to_index_transactions_with_timeout, + }, +}; + +pub async fn wait_for_named_transfer_indexed( + indexer: &LezIndexerClient, + artifact: &TransferArtifact, + transfer_name: &str, + timeout: Duration, +) -> Result { + let target_block = + artifact + .inclusion_block + .ok_or_else(|| StepError::MissingTransferInclusion { + name: transfer_name.to_owned(), + })?; + wait_for_indexer_to_index_transactions_with_timeout( + indexer, + &[artifact.hash], + target_block, + timeout, + ) + .await + .map_err(|error| match error { + IndexerCatchUpError::Timeout { + target, + last_observed, + elapsed, + } => StepError::Timeout { + message: format!( + "indexer did not index transfer '{transfer_name}' through target block {target}; \ + last observed indexer block {last_observed} after {elapsed:?}" + ), + }, + IndexerCatchUpError::SequencerQuery { message } + | IndexerCatchUpError::IndexerQuery { message } => StepError::QueryFailed { message }, + }) +} + +#[then( + expr = "the transferred public account states for transfer {string} match between the sequencer and indexer" +)] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers receive mutable world references" +)] +async fn assert_transferred_public_account_states_match( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, +) -> StepResult { + log_step(step); + let context = world.lez()?; + let artifact = transfer_artifact(world, &transfer_name)?; + let accounts = [artifact.sender, artifact.receiver]; + for account in accounts { + let sequencer_state = SequencerRpcClient::get_account(context.sequencer_client(), account) + .await + .map_err(StepError::query_failed)?; + let indexer_state = + IndexerRpcClient::get_account(&**context.indexer_client(), account.into()) + .await + .map_err(StepError::query_failed)?; + if indexer_state != sequencer_state.into() { + return Err(StepError::AssertionFailed { + message: format!( + "indexer and sequencer states differ for public account {account:?}" + ), + }); + } + } + Ok(()) +} + +#[then(expr = "the indexer catches up to transfer {string} within {int} seconds")] +async fn wait_for_named_transfer_indexer( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let context = world.lez()?; + let height = wait_for_named_transfer_indexed( + context.indexer(), + &artifact, + &transfer_name, + Duration::from_secs(timeout_seconds), + ) + .await?; + world.environment.indexer.observed_height = Some(height); + Ok(()) +} + +#[then(expr = "the indexer catches up to the sequencer within {int} seconds")] +async fn wait_for_indexer( + world: &mut CucumberWorld, + step: &Step, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let timeout = Duration::from_secs(timeout_seconds); + let context = world.lez()?; + let result = + wait_for_indexer_to_catch_up_with_timeout(context.indexer(), context.sequencer(), timeout) + .await; + let height = result.map_err(|error| match error { + IndexerCatchUpError::Timeout { + target, + last_observed, + elapsed, + } => StepError::Timeout { + message: format!( + "indexer did not reach target block {target}; last observed indexer \ + block {last_observed} after {elapsed:?}" + ), + }, + IndexerCatchUpError::SequencerQuery { message } + | IndexerCatchUpError::IndexerQuery { message } => StepError::QueryFailed { message }, + })?; + + world.environment.indexer.observed_height = Some(height); + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/indexer/mod.rs b/integration_tests/src/cucumber/steps/indexer/mod.rs new file mode 100644 index 000000000..29cf7933f --- /dev/null +++ b/integration_tests/src/cucumber/steps/indexer/mod.rs @@ -0,0 +1 @@ +pub(crate) mod convergence; diff --git a/integration_tests/src/cucumber/steps/mod.rs b/integration_tests/src/cucumber/steps/mod.rs new file mode 100644 index 000000000..09840000c --- /dev/null +++ b/integration_tests/src/cucumber/steps/mod.rs @@ -0,0 +1,13 @@ +use cucumber::gherkin::Step; + +pub mod accounts; +pub mod committee; +pub mod environment; +pub mod indexer; +pub mod transfers; + +pub const TARGET: &str = "cucumber_steps"; + +pub(super) fn log_step(step: &Step) { + tracing::info!(target: TARGET, "Executing Cucumber step: {}", step.value); +} diff --git a/integration_tests/src/cucumber/steps/transfers/assertions.rs b/integration_tests/src/cucumber/steps/transfers/assertions.rs new file mode 100644 index 000000000..b265b9590 --- /dev/null +++ b/integration_tests/src/cucumber/steps/transfers/assertions.rs @@ -0,0 +1,508 @@ +use std::time::Duration; + +use common::transaction::LeeTransaction; +use cucumber::{gherkin::Step, then}; +use lee_core::account::Nonce; +use sequencer_service_rpc::RpcClient as _; + +use super::{ + super::log_step, + helpers::{ + assert_private_balance_delta, assert_public_balance_delta, expected_public_signing_key, + get_transfer_transaction, rejected_transfer_details, transfer_artifact, transfer_details, + wait_for_transfer_inclusion, + }, +}; +use crate::cucumber::{ + error::{StepError, StepResult}, + steps::transfers::helpers::assert_private_commitment_in_state, + world::CucumberWorld, +}; + +fn two_transfer_details( + world: &CucumberWorld, + first_name: &str, + second_name: &str, + sender: bool, +) -> Result<(lee::AccountId, u128, u128), StepError> { + let first = transfer_artifact(world, first_name)?; + let second = transfer_artifact(world, second_name)?; + let first_account = if sender { first.sender } else { first.receiver }; + let second_account = if sender { + second.sender + } else { + second.receiver + }; + if first_account != second_account { + return Err(StepError::AssertionFailed { + message: format!( + "transfers '{first_name}' and '{second_name}' do not have the same {} account", + if sender { "sender" } else { "receiver" } + ), + }); + } + let amount = + first + .amount + .checked_add(second.amount) + .ok_or_else(|| StepError::AssertionFailed { + message: format!( + "combined amount for transfers '{first_name}' and '{second_name}' overflowed" + ), + })?; + let initial_balance = if sender { + first.sender_balance_before + } else { + first.receiver_balance_before + }; + Ok((first_account, initial_balance, amount)) +} + +#[then(expr = "the sender balance for transfer {string} decreases by {int}")] +async fn assert_sender_balance_decreased( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + expected_amount: u128, +) -> StepResult { + log_step(step); + let (sender, initial_balance, amount) = transfer_details(world, &transfer_name, true)?; + let observed_balance = assert_public_balance_delta( + world, + sender, + initial_balance, + amount, + expected_amount, + false, + "sender", + ) + .await?; + world.environment.accounts.sender_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then(expr = "the receiver balance for transfer {string} increases by {int}")] +async fn assert_receiver_balance_increased( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + expected_amount: u128, +) -> StepResult { + log_step(step); + let (receiver, initial_balance, amount) = transfer_details(world, &transfer_name, false)?; + let observed_balance = assert_public_balance_delta( + world, + receiver, + initial_balance, + amount, + expected_amount, + true, + "receiver", + ) + .await?; + world.environment.accounts.receiver_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then(expr = "the sender balance across transfers {string} and {string} decreases by {int}")] +async fn assert_sender_balance_decreased_across_transfers( + world: &mut CucumberWorld, + step: &Step, + first_name: String, + second_name: String, + expected_amount: u128, +) -> StepResult { + log_step(step); + let (sender, initial_balance, amount) = + two_transfer_details(world, &first_name, &second_name, true)?; + let observed_balance = assert_public_balance_delta( + world, + sender, + initial_balance, + amount, + expected_amount, + false, + "sender", + ) + .await?; + world.environment.accounts.sender_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then(expr = "the receiver balance across transfers {string} and {string} increases by {int}")] +async fn assert_receiver_balance_increased_across_transfers( + world: &mut CucumberWorld, + step: &Step, + first_name: String, + second_name: String, + expected_amount: u128, +) -> StepResult { + log_step(step); + let (receiver, initial_balance, amount) = + two_transfer_details(world, &first_name, &second_name, false)?; + let observed_balance = assert_public_balance_delta( + world, + receiver, + initial_balance, + amount, + expected_amount, + true, + "receiver", + ) + .await?; + world.environment.accounts.receiver_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then(expr = "the new account balance for transfer {string} is {int}")] +async fn assert_new_account_balance( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + expected_balance: u128, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let account = world + .environment + .accounts + .new_public_account + .ok_or(StepError::MissingSelectedAccount)?; + if artifact.receiver != account { + return Err(StepError::AssertionFailed { + message: format!( + "transfer '{transfer_name}' receiver {:?} is not the new public account {account:?}", + artifact.receiver + ), + }); + } + let observed_balance = world + .lez()? + .sequencer_client() + .get_account_balance(account) + .await + .map_err(StepError::query_failed)?; + if observed_balance != expected_balance { + return Err(StepError::AssertionFailed { + message: format!( + "new public account {account:?} has balance {observed_balance}, expected {expected_balance}" + ), + }); + } + world.environment.accounts.receiver_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then(expr = "the sender private balance for transfer {string} decreases by {int}")] +async fn assert_sender_private_balance_decreased( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + expected_amount: u128, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let account = artifact.sender; + let initial_balance = artifact.sender_balance_before; + let amount = artifact.amount; + let observed_balance = assert_private_balance_delta( + world, + account, + initial_balance, + amount, + expected_amount, + false, + "private sender", + ) + .await?; + world.environment.accounts.private_sender_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then(expr = "the receiver private balance for transfer {string} increases by {int}")] +async fn assert_receiver_private_balance_increased( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + expected_amount: u128, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let account = artifact.receiver; + let initial_balance = artifact.receiver_balance_before; + let amount = artifact.amount; + let observed_balance = assert_private_balance_delta( + world, + account, + initial_balance, + amount, + expected_amount, + true, + "private receiver", + ) + .await?; + world.environment.accounts.private_receiver_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then(expr = "the sender private commitment for transfer {string} is in sequencer state")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers receive mutable world references" +)] +async fn assert_sender_private_commitment_in_state( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, +) -> StepResult { + log_step(step); + assert_private_commitment_in_state(world, &transfer_name, true, "sender").await +} + +#[then(expr = "the receiver private commitment for transfer {string} is in sequencer state")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step handlers receive mutable world references" +)] +async fn assert_receiver_private_commitment_in_state( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, +) -> StepResult { + log_step(step); + assert_private_commitment_in_state(world, &transfer_name, false, "receiver").await +} + +#[then("the transfer is rejected")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step functions require `&mut World` as the first parameter" +)] +fn assert_transfer_is_rejected(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + if world.environment.transfers.rejected.is_none() { + return Err(StepError::AssertionFailed { + message: "expected the insufficient-balance transfer to be rejected".to_owned(), + }); + } + Ok(()) +} + +#[then("the sender balance remains unchanged")] +async fn assert_sender_balance_unchanged(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let (sender, initial_balance, _) = rejected_transfer_details(world, true)?; + let observed_balance = world + .lez()? + .sequencer_client() + .get_account_balance(sender) + .await + .map_err(StepError::query_failed)?; + if observed_balance != initial_balance { + return Err(StepError::AssertionFailed { + message: format!( + "sender {sender:?} has balance {observed_balance}, expected unchanged balance {initial_balance}" + ), + }); + } + world.environment.accounts.sender_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then("the receiver balance remains unchanged")] +async fn assert_receiver_balance_unchanged(world: &mut CucumberWorld, step: &Step) -> StepResult { + log_step(step); + let (receiver, initial_balance, _) = rejected_transfer_details(world, false)?; + let observed_balance = world + .lez()? + .sequencer_client() + .get_account_balance(receiver) + .await + .map_err(StepError::query_failed)?; + if observed_balance != initial_balance { + return Err(StepError::AssertionFailed { + message: format!( + "receiver {receiver:?} has balance {observed_balance}, expected unchanged balance {initial_balance}" + ), + }); + } + world.environment.accounts.receiver_observed_balance = Some(observed_balance); + Ok(()) +} + +#[then(expr = "transfer {string} is included in a block within {int} seconds")] +async fn assert_transfer_is_included( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, + timeout_seconds: u64, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let context = world.lez()?; + let block_id = wait_for_transfer_inclusion( + context.sequencer_client(), + &artifact, + Duration::from_secs(timeout_seconds), + &format!("transfer '{transfer_name}' was not included in a block"), + ) + .await?; + world + .environment + .transfers + .artifacts + .get_mut(&transfer_name) + .ok_or_else(|| StepError::UnknownTransferArtifact { + name: transfer_name.clone(), + })? + .inclusion_block = Some(block_id); + Ok(()) +} + +#[then(expr = "only the sender signs transfer {string}")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step functions require `&mut World` as the first parameter" +)] +async fn assert_only_sender_signs( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let sender = artifact.sender; + let context = world.lez()?; + let (transaction, _) = + get_transfer_transaction(context.sequencer_client(), artifact.hash).await?; + let LeeTransaction::Public(transaction) = transaction else { + return Err(StepError::AssertionFailed { + message: "expected the transfer to be public".to_owned(), + }); + }; + let expected_sender = + expected_public_signing_key(sender).ok_or_else(|| StepError::QueryFailed { + message: format!("sender {sender:?} is not in the configured public accounts"), + })?; + let signers: Vec<_> = transaction + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, public_key)| public_key) + .collect(); + if signers != vec![&expected_sender] { + return Err(StepError::AssertionFailed { + message: format!("expected only sender {expected_sender:?} to sign, got {signers:?}"), + }); + } + Ok(()) +} + +#[then(expr = "the sender and new account sign transfer {string}")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step functions require `&mut World` as the first parameter" +)] +async fn assert_sender_and_new_account_sign( + world: &mut CucumberWorld, + step: &Step, + transfer_name: String, +) -> StepResult { + log_step(step); + let artifact = transfer_artifact(world, &transfer_name)?; + let sender = artifact.sender; + let receiver = world + .environment + .accounts + .new_public_account + .ok_or(StepError::MissingSelectedAccount)?; + let context = world.lez()?; + let (transaction, _) = + get_transfer_transaction(context.sequencer_client(), artifact.hash).await?; + let LeeTransaction::Public(transaction) = transaction else { + return Err(StepError::AssertionFailed { + message: "expected the transfer to be public".to_owned(), + }); + }; + let expected_sender = + expected_public_signing_key(sender).ok_or_else(|| StepError::QueryFailed { + message: format!("sender {sender:?} is not in the configured public accounts"), + })?; + let expected_receiver = context + .public_account_signing_key(receiver) + .await? + .ok_or_else(|| StepError::QueryFailed { + message: format!("new account {receiver:?} has no wallet signing key"), + })?; + let signers: Vec<_> = transaction + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, public_key)| public_key) + .collect(); + if signers != vec![&expected_sender, &expected_receiver] { + return Err(StepError::AssertionFailed { + message: format!( + "expected sender {expected_sender:?} and new account {expected_receiver:?} to sign, got {signers:?}" + ), + }); + } + Ok(()) +} + +#[then(expr = "the sender nonce advances across transfers {string} and {string}")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step functions require `&mut World` as the first parameter" +)] +async fn assert_sender_nonce_advances( + world: &mut CucumberWorld, + step: &Step, + first_name: String, + second_name: String, +) -> StepResult { + log_step(step); + let first = transfer_artifact(world, &first_name)?; + let second = transfer_artifact(world, &second_name)?; + if first.sender != second.sender { + return Err(StepError::AssertionFailed { + message: "the two transfers have different senders".to_owned(), + }); + } + let sender = first.sender; + let initial_nonce = first + .sender_nonce_before + .ok_or(StepError::MissingObservation { + field: "first transfer sender nonce", + })?; + let expected_nonce = + Nonce( + initial_nonce + .0 + .checked_add(2) + .ok_or_else(|| StepError::AssertionFailed { + message: format!( + "sender nonce overflow after two transfers: {initial_nonce:?}" + ), + })?, + ); + let observed_nonce = world + .lez()? + .sequencer_client() + .get_accounts_nonces(vec![sender]) + .await + .map_err(StepError::query_failed)? + .into_iter() + .next() + .ok_or_else(|| StepError::QueryFailed { + message: format!("no nonce returned for sender {sender:?}"), + })?; + if observed_nonce != expected_nonce { + return Err(StepError::AssertionFailed { + message: format!( + "sender {sender:?} nonce is {observed_nonce:?}, expected {expected_nonce:?}" + ), + }); + } + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/transfers/helpers.rs b/integration_tests/src/cucumber/steps/transfers/helpers.rs new file mode 100644 index 000000000..b711ed512 --- /dev/null +++ b/integration_tests/src/cucumber/steps/transfers/helpers.rs @@ -0,0 +1,321 @@ +use std::time::Duration; + +use common::transaction::LeeTransaction; +use lee::{AccountId, PublicKey}; +use lee_core::account::Nonce; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; + +use crate::{ + config::default_public_accounts_for_wallet, + cucumber::{ + error::{StepError, StepResult}, + world::{CucumberWorld, TransferArtifact, TransferKind}, + }, +}; + +pub fn ensure_transfer_name_available(world: &CucumberWorld, name: &str) -> Result<(), StepError> { + if world.environment.transfers.artifacts.contains_key(name) { + return Err(StepError::DuplicateTransferArtifact { + name: name.to_owned(), + }); + } + Ok(()) +} + +pub fn transfer_artifact(world: &CucumberWorld, name: &str) -> Result { + world + .environment + .transfers + .artifacts + .get(name) + .cloned() + .ok_or_else(|| StepError::UnknownTransferArtifact { + name: name.to_owned(), + }) +} + +pub fn insert_transfer_artifact( + world: &mut CucumberWorld, + name: String, + artifact: TransferArtifact, +) -> Result<(), StepError> { + ensure_transfer_name_available(world, &name)?; + world.environment.transfers.artifacts.insert(name, artifact); + Ok(()) +} + +pub fn assert_transaction_kind( + artifact: &TransferArtifact, + transaction: &LeeTransaction, +) -> Result<(), StepError> { + let matches_kind = matches!( + (artifact.kind, transaction), + (TransferKind::Public, LeeTransaction::Public(_)) + | (TransferKind::Private, LeeTransaction::PrivacyPreserving(_)) + ); + if !matches_kind { + return Err(StepError::AssertionFailed { + message: format!( + "transfer artifact declared {:?}, but transaction has a different kind", + artifact.kind + ), + }); + } + Ok(()) +} + +pub(super) fn expected_balance_after( + initial_balance: u128, + amount: u128, + expected_amount: u128, + increase: bool, + role: &str, +) -> Result { + if amount != expected_amount { + return Err(StepError::AssertionFailed { + message: format!( + "expected {role} balance {} by {expected_amount}, got transfer amount {amount}", + if increase { "increase" } else { "decrease" } + ), + }); + } + if increase { + initial_balance + .checked_add(amount) + .ok_or_else(|| StepError::AssertionFailed { + message: format!("{role} balance overflow for transfer amount {amount}"), + }) + } else { + initial_balance + .checked_sub(amount) + .ok_or_else(|| StepError::AssertionFailed { + message: format!( + "{role} initial balance {initial_balance} is below transfer amount {amount}" + ), + }) + } +} + +pub(super) async fn assert_public_balance_delta( + world: &CucumberWorld, + account: AccountId, + initial_balance: u128, + transfer_amount: u128, + expected_amount: u128, + increase: bool, + role: &str, +) -> Result { + let expected_balance = expected_balance_after( + initial_balance, + transfer_amount, + expected_amount, + increase, + role, + )?; + let observed_balance = world + .lez()? + .sequencer_client() + .get_account_balance(account) + .await + .map_err(StepError::query_failed)?; + if observed_balance != expected_balance { + return Err(StepError::AssertionFailed { + message: format!( + "{role} {account:?} has balance {observed_balance}, expected {expected_balance}" + ), + }); + } + Ok(observed_balance) +} + +pub(super) async fn assert_private_balance_delta( + world: &CucumberWorld, + account: AccountId, + initial_balance: u128, + transfer_amount: u128, + expected_amount: u128, + increase: bool, + role: &str, +) -> Result { + let expected_balance = expected_balance_after( + initial_balance, + transfer_amount, + expected_amount, + increase, + role, + )?; + let observed_balance = world + .lez()? + .private_account_balance(account) + .await? + .ok_or_else(|| StepError::QueryFailed { + message: format!("{role} {account:?} has no synchronized wallet balance"), + })?; + if observed_balance != expected_balance { + return Err(StepError::AssertionFailed { + message: format!( + "{role} {account:?} has balance {observed_balance}, expected {expected_balance}" + ), + }); + } + Ok(observed_balance) +} + +pub(super) fn expected_public_signing_key(account: AccountId) -> Option { + default_public_accounts_for_wallet() + .into_iter() + .find_map(|(private_key, _)| { + let public_key = PublicKey::new_from_private_key(&private_key); + (AccountId::from(&public_key) == account).then_some(public_key) + }) +} + +pub(super) async fn snapshot_public_sender( + client: &SequencerClient, + sender: AccountId, +) -> Result<(u128, Nonce), StepError> { + let sender_balance = client + .get_account_balance(sender) + .await + .map_err(StepError::query_failed)?; + let sender_nonce = client + .get_accounts_nonces(vec![sender]) + .await + .map_err(StepError::query_failed)? + .into_iter() + .next() + .ok_or_else(|| StepError::QueryFailed { + message: format!("no nonce returned for sender {sender:?}"), + })?; + Ok((sender_balance, sender_nonce)) +} + +pub(super) async fn snapshot_public_transfer( + client: &SequencerClient, + sender: AccountId, + receiver: AccountId, +) -> Result<(u128, u128, Nonce), StepError> { + let (sender_balance, sender_nonce) = snapshot_public_sender(client, sender).await?; + let receiver_balance = client + .get_account_balance(receiver) + .await + .map_err(StepError::query_failed)?; + Ok((sender_balance, receiver_balance, sender_nonce)) +} + +pub(super) fn transfer_details( + world: &CucumberWorld, + name: &str, + sender: bool, +) -> Result<(AccountId, u128, u128), StepError> { + let artifact = transfer_artifact(world, name)?; + let account = if sender { + artifact.sender + } else { + artifact.receiver + }; + let initial_balance = if sender { + artifact.sender_balance_before + } else { + artifact.receiver_balance_before + }; + Ok((account, initial_balance, artifact.amount)) +} + +pub(super) fn rejected_transfer_details( + world: &CucumberWorld, + sender: bool, +) -> Result<(AccountId, u128, u128), StepError> { + let rejected = + world + .environment + .transfers + .rejected + .as_ref() + .ok_or(StepError::MissingObservation { + field: "rejected transfer attempt", + })?; + let account = if sender { + rejected.sender + } else { + rejected.receiver + }; + let initial_balance = if sender { + rejected.sender_balance_before + } else { + rejected.receiver_balance_before + }; + let amount = rejected.amount; + Ok((account, initial_balance, amount)) +} + +pub async fn get_transfer_transaction( + client: &sequencer_service_rpc::SequencerClient, + transfer_hash: common::HashType, +) -> Result<(LeeTransaction, u64), StepError> { + client + .get_transaction(transfer_hash) + .await + .map_err(StepError::query_failed)? + .ok_or_else(|| StepError::QueryFailed { + message: format!("transfer {transfer_hash} was not found in the sequencer"), + }) +} + +/// Polls a sequencer until a named transfer is included and validates its +/// declared transaction kind at the same observation point. +pub async fn wait_for_transfer_inclusion( + client: &SequencerClient, + artifact: &TransferArtifact, + timeout: Duration, + description: &str, +) -> Result { + const POLL_INTERVAL: Duration = Duration::from_secs(2); + let wait = async { + loop { + if let Some((transaction, block_id)) = client + .get_transaction(artifact.hash) + .await + .map_err(StepError::query_failed)? + { + assert_transaction_kind(artifact, &transaction)?; + return Ok::(block_id); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(timeout, wait) + .await + .map_err(|_elapsed| StepError::Timeout { + message: format!("{description} within {timeout:?}"), + })? +} + +pub async fn assert_private_commitment_in_state( + world: &CucumberWorld, + transfer_name: &str, + sender: bool, + role: &str, +) -> StepResult { + let artifact = transfer_artifact(world, transfer_name)?; + let account = if sender { + artifact.sender + } else { + artifact.receiver + }; + let context = world.lez()?; + let commitment = context + .private_account_commitment(account) + .await? + .ok_or_else(|| StepError::QueryFailed { + message: format!("private {role} {account:?} has no current commitment"), + })?; + if !crate::verify_commitment_is_in_state(commitment, context.sequencer_client()).await { + return Err(StepError::AssertionFailed { + message: format!( + "private {role} commitment for account {account:?} is not in sequencer state" + ), + }); + } + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/transfers/mod.rs b/integration_tests/src/cucumber/steps/transfers/mod.rs new file mode 100644 index 000000000..917fe994c --- /dev/null +++ b/integration_tests/src/cucumber/steps/transfers/mod.rs @@ -0,0 +1,4 @@ +mod assertions; +pub(crate) mod helpers; +mod private; +mod public; diff --git a/integration_tests/src/cucumber/steps/transfers/private.rs b/integration_tests/src/cucumber/steps/transfers/private.rs new file mode 100644 index 000000000..54a9a0145 --- /dev/null +++ b/integration_tests/src/cucumber/steps/transfers/private.rs @@ -0,0 +1,73 @@ +use cucumber::{gherkin::Step, when}; + +use super::{ + super::log_step, + helpers::{ensure_transfer_name_available, insert_transfer_artifact}, +}; +use crate::cucumber::{ + error::{StepError, StepResult}, + world::{CucumberWorld, TransferArtifact, TransferKind}, +}; + +#[when( + expr = "I transfer {int} from the first configured private account to the second as {string}" +)] +async fn transfer_between_configured_private_accounts( + world: &mut CucumberWorld, + step: &Step, + amount: u128, + transfer_name: String, +) -> StepResult { + log_step(step); + ensure_transfer_name_available(world, &transfer_name)?; + let context = world.lez()?; + context.sync_wallet_to_latest_block().await?; + let accounts = context.existing_private_accounts().await?; + let sender = accounts + .first() + .copied() + .ok_or_else(|| StepError::AssertionFailed { + message: "expected at least two configured private accounts, found none".to_owned(), + })?; + let receiver = accounts + .get(1) + .copied() + .ok_or_else(|| StepError::AssertionFailed { + message: format!( + "expected at least two configured private accounts, found {}", + accounts.len() + ), + })?; + let sender_initial_balance = + context + .private_account_balance(sender) + .await? + .ok_or_else(|| StepError::QueryFailed { + message: format!("private sender {sender:?} has no synchronized wallet balance"), + })?; + let receiver_initial_balance = context + .private_account_balance(receiver) + .await? + .ok_or_else(|| StepError::QueryFailed { + message: format!("private receiver {receiver:?} has no synchronized wallet balance"), + })?; + let transfer_hash = context.private_transfer(sender, receiver, amount).await?; + + insert_transfer_artifact( + world, + transfer_name, + TransferArtifact { + hash: transfer_hash, + sender, + receiver, + amount, + kind: TransferKind::Private, + sender_balance_before: sender_initial_balance, + receiver_balance_before: receiver_initial_balance, + sender_nonce_before: None, + receiver_balance_observer: None, + inclusion_block: None, + }, + )?; + Ok(()) +} diff --git a/integration_tests/src/cucumber/steps/transfers/public.rs b/integration_tests/src/cucumber/steps/transfers/public.rs new file mode 100644 index 000000000..3cabb1a3e --- /dev/null +++ b/integration_tests/src/cucumber/steps/transfers/public.rs @@ -0,0 +1,214 @@ +use cucumber::{gherkin::Step, when}; +use wallet::account::Label; + +use super::{ + super::log_step, + helpers::{ + ensure_transfer_name_available, insert_transfer_artifact, snapshot_public_sender, + snapshot_public_transfer, + }, +}; +use crate::cucumber::{ + error::{StepError, StepResult}, + world::{CucumberWorld, RejectedTransferAttempt, TransferArtifact, TransferKind}, +}; + +#[when( + expr = "I transfer {int} from the first configured public account to the second as {string}" +)] +async fn transfer_between_configured_public_accounts( + world: &mut CucumberWorld, + step: &Step, + amount: u128, + transfer_name: String, +) -> StepResult { + log_step(step); + ensure_transfer_name_available(world, &transfer_name)?; + let context = world.lez()?; + let accounts = context.existing_public_accounts().await?; + let sender = accounts + .first() + .copied() + .ok_or(StepError::MissingSelectedAccount)?; + let receiver = accounts + .get(1) + .copied() + .ok_or(StepError::MissingSelectedAccount)?; + let (sender_initial_balance, receiver_initial_balance, sender_initial_nonce) = + snapshot_public_transfer(context.sequencer_client(), sender, receiver).await?; + let transfer_hash = context.public_transfer(sender, receiver, amount).await?; + + insert_transfer_artifact( + world, + transfer_name, + TransferArtifact { + hash: transfer_hash, + sender, + receiver, + amount, + kind: TransferKind::Public, + sender_balance_before: sender_initial_balance, + receiver_balance_before: receiver_initial_balance, + sender_nonce_before: Some(sender_initial_nonce), + receiver_balance_observer: None, + inclusion_block: None, + }, + )?; + Ok(()) +} + +#[when(expr = "I transfer {int} using the configured public account labels as {string}")] +async fn transfer_between_labeled_public_accounts( + world: &mut CucumberWorld, + step: &Step, + amount: u128, + transfer_name: String, +) -> StepResult { + log_step(step); + ensure_transfer_name_available(world, &transfer_name)?; + let context = world.lez()?; + let sender_label = world + .environment + .accounts + .public_sender_label + .clone() + .ok_or(StepError::MissingObservation { + field: "public sender label", + })?; + let receiver_label = world + .environment + .accounts + .public_receiver_label + .clone() + .ok_or(StepError::MissingObservation { + field: "public receiver label", + })?; + let accounts = context.existing_public_accounts().await?; + let sender = accounts + .first() + .copied() + .ok_or(StepError::MissingSelectedAccount)?; + let receiver = accounts + .get(1) + .copied() + .ok_or(StepError::MissingSelectedAccount)?; + let (sender_initial_balance, receiver_initial_balance, sender_initial_nonce) = + snapshot_public_transfer(context.sequencer_client(), sender, receiver).await?; + let transfer_hash = context + .public_transfer_by_labels( + Label::new(&sender_label), + Label::new(&receiver_label), + amount, + ) + .await?; + + insert_transfer_artifact( + world, + transfer_name, + TransferArtifact { + hash: transfer_hash, + sender, + receiver, + amount, + kind: TransferKind::Public, + sender_balance_before: sender_initial_balance, + receiver_balance_before: receiver_initial_balance, + sender_nonce_before: Some(sender_initial_nonce), + receiver_balance_observer: None, + inclusion_block: None, + }, + )?; + Ok(()) +} + +#[when( + expr = "I transfer {int} from the first configured public account to the new account as {string}" +)] +async fn transfer_to_new_public_account( + world: &mut CucumberWorld, + step: &Step, + amount: u128, + transfer_name: String, +) -> StepResult { + log_step(step); + ensure_transfer_name_available(world, &transfer_name)?; + let context = world.lez()?; + let sender = context + .existing_public_accounts() + .await? + .into_iter() + .next() + .ok_or(StepError::MissingSelectedAccount)?; + let receiver = world + .environment + .accounts + .new_public_account + .ok_or(StepError::MissingSelectedAccount)?; + let (sender_initial_balance, sender_initial_nonce) = + snapshot_public_sender(context.sequencer_client(), sender).await?; + let receiver_initial_balance = 0; + let transfer_hash = context + .public_transfer_to_new_account(sender, receiver, amount) + .await?; + + insert_transfer_artifact( + world, + transfer_name, + TransferArtifact { + hash: transfer_hash, + sender, + receiver, + amount, + kind: TransferKind::Public, + sender_balance_before: sender_initial_balance, + receiver_balance_before: receiver_initial_balance, + sender_nonce_before: Some(sender_initial_nonce), + receiver_balance_observer: None, + inclusion_block: None, + }, + )?; + Ok(()) +} + +#[when(expr = "I attempt to transfer {int} from the first configured public account to the second")] +async fn attempt_insufficient_public_transfer( + world: &mut CucumberWorld, + step: &Step, + amount: u128, +) -> StepResult { + log_step(step); + let context = world.lez()?; + let accounts = context.existing_public_accounts().await?; + let sender = accounts + .first() + .copied() + .ok_or(StepError::MissingSelectedAccount)?; + let receiver = accounts + .get(1) + .copied() + .ok_or(StepError::MissingSelectedAccount)?; + let (sender_initial_balance, receiver_initial_balance, sender_initial_nonce) = + snapshot_public_transfer(context.sequencer_client(), sender, receiver).await?; + + let rejection = match context.public_transfer(sender, receiver, amount).await { + Ok(transfer_hash) => { + return Err(StepError::AssertionFailed { + message: format!( + "insufficient-balance transfer unexpectedly succeeded with hash {transfer_hash}" + ), + }); + } + Err(error) => error.to_string(), + }; + + world.environment.transfers.rejected = Some(RejectedTransferAttempt { + sender, + receiver, + amount, + sender_balance_before: sender_initial_balance, + receiver_balance_before: receiver_initial_balance, + sender_nonce_before: sender_initial_nonce, + error: rejection, + }); + Ok(()) +} diff --git a/integration_tests/src/cucumber/world.rs b/integration_tests/src/cucumber/world.rs new file mode 100644 index 000000000..3d74ca2b2 --- /dev/null +++ b/integration_tests/src/cucumber/world.rs @@ -0,0 +1,470 @@ +use std::{ + collections::HashMap, + env, fmt, + fmt::Debug, + path::{Path, PathBuf}, +}; + +use cucumber::World; +use testing_framework_app::{AppHostEnv, AppHostTopology, DeployContext}; +use testing_framework_core::scenario::NodeClients; + +use crate::{ + cucumber::{ + context::{LezScenarioContext, LezSequencerRegistryScenarioContext}, + default::CUCUMBER_NODE_CONFIG_OVERRIDE, + error::{StepError, StepResult}, + }, + testing_framework::shutdown_lez_deployment, +}; + +/// Classifies a successful transfer recorded by a Cucumber scenario. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransferKind { + /// A public authenticated transfer. + Public, + /// A privacy-preserving authenticated transfer. + Private, +} + +/// Identifies one successful transfer and records the observations needed to +/// assert its lifecycle. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferArtifact { + /// Transaction hash returned by the sequencer. + pub hash: common::HashType, + /// Sending account. + pub sender: lee::AccountId, + /// Receiving account. + pub receiver: lee::AccountId, + /// Amount transferred. + pub amount: u128, + /// Transaction kind. + pub kind: TransferKind, + /// Sender balance observed immediately before submission. + pub sender_balance_before: u128, + /// Receiver balance observed immediately before submission. + pub receiver_balance_before: u128, + /// Sender nonce observed immediately before submission, when available. + pub sender_nonce_before: Option, + /// Sequencer alias on which the receiver baseline was observed, when it + /// was intentionally recorded on a different sequencer. + pub receiver_balance_observer: Option, + /// Block containing the transaction, once observed. + pub inclusion_block: Option, +} + +/// Observations staged before a successful transfer artifact can be created. +/// +/// The committee scenario records its receiver baseline on one sequencer in a +/// separate step, before submitting the transaction through another sequencer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingTransferObservation { + /// Receiver whose balance was observed. + pub receiver: lee::AccountId, + /// Receiver balance observed before submission. + pub receiver_balance_before: u128, + /// Sequencer alias on which the observation was made. + pub observer_alias: String, +} + +/// State retained for a rejected transfer attempt. Rejected attempts do not +/// produce successful transfer artifacts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RejectedTransferAttempt { + /// Sending account. + pub sender: lee::AccountId, + /// Receiving account. + pub receiver: lee::AccountId, + /// Amount requested by the rejected transfer. + pub amount: u128, + /// Sender balance observed before submission. + pub sender_balance_before: u128, + /// Receiver balance observed before submission. + pub receiver_balance_before: u128, + /// Sender nonce observed before submission. + pub sender_nonce_before: lee_core::account::Nonce, + /// Error returned by the wallet submission. + pub error: String, +} + +/// Successful and rejected transfer observations collected by a scenario. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TransferState { + /// Successful transfers indexed by their scenario-provided names. + pub artifacts: HashMap, + /// Baseline recorded before a committee transfer is submitted. + pub pending_observation: Option, + /// The latest rejected transfer attempt, if one was made. + pub rejected: Option, +} + +/// Lifecycle state recorded for explicit and fallback runtime teardown. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub enum RuntimeTeardownState { + /// No teardown attempt has started. + #[default] + NotAttempted, + /// All exposed LEZ services stopped successfully. + Succeeded, + /// Teardown failed; the original diagnostic is retained for later calls. + Failed(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +/// Committee configuration declared by a multi-sequencer Cucumber scenario. +pub struct CommitteeConfiguration { + /// Alias of the sequencer that declares the configuration. + pub leader_alias: String, + /// Registered aliases authorized in the founding committee. + pub authorized_sequencers: Vec, +} + +/// Account observations recorded during a scenario. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AccountState { + /// Account selected for the balance assertion. + pub selected_account: Option, + /// Balance returned by the sequencer. + pub observed_balance: Option, + /// Balance configured for the selected account. + pub expected_balance: Option, + /// Fresh recipient account created for the new-account transfer scenario. + pub new_public_account: Option, + /// Sender balance observed after the public transfer. + pub sender_observed_balance: Option, + /// Receiver balance observed after the public transfer. + pub receiver_observed_balance: Option, + /// Sender private balance observed after the private transfer. + pub private_sender_observed_balance: Option, + /// Receiver private balance observed after the private transfer. + pub private_receiver_observed_balance: Option, + /// Label assigned to the public transfer sender. + pub public_sender_label: Option, + /// Label assigned to the public transfer receiver. + pub public_receiver_label: Option, +} + +/// Indexer observations recorded during a scenario. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct IndexerState { + /// Last indexer block observed by a generic convergence step. + pub observed_height: Option, + /// Sequencer height used as the committee indexer convergence target. + pub committee_target_height: Option, + /// Actual indexer finalized height reached during committee convergence. + pub committee_finalized_height: Option, +} + +/// Committee observations recorded during a multi-sequencer scenario. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CommitteeState { + /// Source sequencer height recorded when another sequencer joins. + pub committee_join_height: Option, + /// Target height reached during the committee rotation phase. + pub committee_rotation_target: Option, + /// Committee configuration declared before sequencer startup. + pub committee_configuration: Option, +} + +/// Observable state recorded during a scenario. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct EnvironmentState { + /// Account observations. + pub accounts: AccountState, + /// Transfer artifacts and transfer attempts. + pub transfers: TransferState, + /// Indexer observations. + pub indexer: IndexerState, + /// Multi-sequencer committee observations. + pub committee: CommitteeState, +} + +/// Per-scenario state for Cucumber tests that deploy LEZ applications. +/// +/// Cucumber creates a fresh world for each scenario. Its deployment context +/// starts empty; `Given` steps can deploy and expose only the applications the +/// scenario needs. Dropping the world releases all TF-managed resources. +#[derive(World)] +#[world(init = Self::default)] +pub struct CucumberWorld { + /// Testing-framework deployment registry owning exposed application handles. + pub deployment: DeployContext, + /// Scenario-owned clones of the LEZ application handles. + pub lez: Option, + /// Scenario-owned view of the multi-sequencer registry. + pub sequencer_registry: Option, + /// Runtime observations collected by scenario steps. + pub environment: EnvironmentState, + /// A unique per-scenario context string used to isolate runtime resources. + pub test_context: Option, + /// Base directory for scenario artifacts like logs and generated configs. + pub scenario_base_dir: PathBuf, + /// If set, nodes use a `DeploymentSettings` loaded from disk + /// bypassing generated genesis/test deployment. + pub deployment_config_override_path: Option, + /// Sticky state shared by explicit and fallback runtime teardown. + pub runtime_teardown: RuntimeTeardownState, + /// Runtime observations preserved while releasing scenario handles. + pub teardown_environment: Option, +} + +impl CucumberWorld { + /// Returns the application deployment context for querying exposed + /// handles. + #[must_use] + pub const fn deployment(&self) -> &DeployContext { + &self.deployment + } + + /// Returns the application deployment context used by setup steps to + /// deploy and expose applications. + #[must_use] + pub const fn deployment_mut(&mut self) -> &mut DeployContext { + &mut self.deployment + } + + /// Returns the unique per-scenario context string used to isolate runtime resources. + #[must_use] + pub fn test_context(&self) -> String { + self.test_context.clone().unwrap_or_default() + } + + /// Returns the deployed LEZ handles, or a typed error before setup. + pub fn lez(&self) -> Result<&LezScenarioContext, StepError> { + self.lez.as_ref().ok_or(StepError::FixtureNotDeployed) + } + + /// Stores the deployed LEZ handles, rejecting duplicate setup. + pub fn set_lez(&mut self, context: LezScenarioContext) -> StepResult { + if self.lez.is_some() { + return Err(StepError::FixtureAlreadyDeployed); + } + + self.lez = Some(context); + Ok(()) + } + + /// Stores the specialized sequencer registry context, rejecting duplicate setup. + pub fn set_sequencer_registry( + &mut self, + context: LezSequencerRegistryScenarioContext, + ) -> StepResult { + if self.sequencer_registry.is_some() || self.lez.is_some() { + return Err(StepError::FixtureAlreadyDeployed); + } + + self.sequencer_registry = Some(context); + Ok(()) + } + + /// Returns the deployed sequencer registry context, or a typed error before setup. + pub fn sequencer_registry(&self) -> Result<&LezSequencerRegistryScenarioContext, StepError> { + self.sequencer_registry + .as_ref() + .ok_or(StepError::FixtureNotDeployed) + } + + /// Stop all runtime services and release both scenario and registry-owned + /// handles. This is intentionally explicit because artifact cleanup must + /// never race a still-running LEZ service. + pub async fn stop_runtime(&mut self) -> StepResult { + match &self.runtime_teardown { + RuntimeTeardownState::NotAttempted => {} + RuntimeTeardownState::Succeeded => return Ok(()), + RuntimeTeardownState::Failed(message) => { + return Err(StepError::TeardownFailed { + message: message.clone(), + }); + } + } + + let observations = self.environment.clone(); + drop(self.lez.take()); + drop(self.sequencer_registry.take()); + + let shutdown_result = shutdown_lez_deployment(&self.deployment).await; + let deployment = std::mem::replace( + &mut self.deployment, + DeployContext::new(AppHostTopology, NodeClients::default()), + ); + drop(deployment); + self.teardown_environment = Some(observations); + self.environment = EnvironmentState::default(); + + match shutdown_result { + Ok(()) => { + self.runtime_teardown = RuntimeTeardownState::Succeeded; + Ok(()) + } + Err(error) => { + let message = error.to_string(); + self.runtime_teardown = RuntimeTeardownState::Failed(message); + Err(StepError::TeardownFailedSource { + source: anyhow::Error::from_boxed(error), + }) + } + } + } + + /// Set the directory where scenario artifacts should be stored. + pub fn set_scenario_base_dir(&mut self, log_dir: &Path) { + let log_dir = PathBuf::from(log_dir); + self.scenario_base_dir.clone_from(&log_dir); + } + + /// Returns the same output as `full_debug_info`, but as an owned `String`. + #[must_use] + pub fn full_debug_info_string(&self) -> String { + struct FullDebugInfo<'ab>(&'ab CucumberWorld); + + impl Debug for FullDebugInfo<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.full_debug_info(f) + } + } + + format!("{:?}", FullDebugInfo(self)) + } + + /// Writes a secret-free diagnostic representation of the world. + pub fn full_debug_info(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { + let diagnostic_environment = self + .teardown_environment + .as_ref() + .unwrap_or(&self.environment); + f.debug_struct("CucumberWorld") + .field("deployment", &"managed deployment context") + .field("lez", &self.lez.as_ref().map(|_| "deployed")) + .field( + "sequencer_registry", + &self.sequencer_registry.as_ref().map(|_| "deployed"), + ) + .field("environment", &self.environment) + .field( + "runtime_teardown_attempted", + &!matches!(&self.runtime_teardown, RuntimeTeardownState::NotAttempted), + ) + .field( + "runtime_teardown_completed", + &matches!(&self.runtime_teardown, RuntimeTeardownState::Succeeded), + ) + .field("runtime_teardown", &self.runtime_teardown) + .field( + "teardown_error", + &match &self.runtime_teardown { + RuntimeTeardownState::Failed(message) => Some(message), + RuntimeTeardownState::NotAttempted | RuntimeTeardownState::Succeeded => None, + }, + ) + .field("accounts", &diagnostic_environment.accounts) + .field("transfers", &diagnostic_environment.transfers) + .field("indexer", &diagnostic_environment.indexer) + .field("committee", &diagnostic_environment.committee) + .field("test_context", &self.test_context) + .field("scenario_base_dir", &self.scenario_base_dir) + .field( + "deployment_config_override_path", + &deployment_config_override_path_display( + self.deployment_config_override_path.as_ref(), + ), + ) + .finish() + } + + /// Remove all scenario artifacts from the scenario base directory. This is + /// useful for ensuring a clean state before starting a new scenario. + pub fn clear_scenario_artifacts(&self) -> StepResult { + if self.scenario_base_dir.is_dir() { + std::fs::remove_dir_all(&self.scenario_base_dir).map_err(|e| { + StepError::LogicalError { + message: format!( + "Failed to clear scenario artifacts in '{}': {e}", + self.scenario_base_dir.display() + ), + } + })?; + } + Ok(()) + } + + /// Helper to set the `deployment_config_override_path` in the world based + /// on the `CUCUMBER_NODE_CONFIG_OVERRIDE` environment variable. This + /// allows scenarios to specify a custom deployment config on disk that + /// will be used when starting nodes, bypassing the generated + /// genesis/test deployment. + pub fn apply_deployment_config_override_path(&mut self) { + self.deployment_config_override_path = env::var(CUCUMBER_NODE_CONFIG_OVERRIDE) + .ok() + .map(PathBuf::from); + } + + /// Stores the unique context used to isolate this scenario's resources. + pub fn set_test_context(&mut self, test_context: String) { + self.test_context = Some(test_context); + } +} + +impl Default for CucumberWorld { + fn default() -> Self { + Self { + deployment: DeployContext::new(AppHostTopology, NodeClients::default()), + lez: None, + sequencer_registry: None, + environment: EnvironmentState::default(), + test_context: None, + scenario_base_dir: PathBuf::default(), + deployment_config_override_path: None, + runtime_teardown: RuntimeTeardownState::NotAttempted, + teardown_environment: None, + } + } +} + +impl fmt::Debug for CucumberWorld { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CucumberWorld").finish_non_exhaustive() + } +} + +fn deployment_config_override_path_display( + deployment_config_override_path: Option<&PathBuf>, +) -> String { + deployment_config_override_path.as_ref().map_or_else( + || "None".to_owned(), + |path| format!("Some({})", path.display()), + ) +} + +#[cfg(test)] +mod tests { + use super::{CucumberWorld, RuntimeTeardownState}; + + #[tokio::test] + async fn empty_runtime_teardown_is_idempotently_successful() { + let mut world = CucumberWorld::default(); + + world.stop_runtime().await.unwrap(); + world.stop_runtime().await.unwrap(); + + assert_eq!(world.runtime_teardown, RuntimeTeardownState::Succeeded); + } + + #[tokio::test] + async fn failed_runtime_teardown_state_is_sticky() { + let mut world = CucumberWorld { + runtime_teardown: RuntimeTeardownState::Failed("original failure".to_owned()), + ..CucumberWorld::default() + }; + + let first_error = world.stop_runtime().await.unwrap_err().to_string(); + let second_error = world.stop_runtime().await.unwrap_err().to_string(); + + assert_eq!(first_error, "Runtime teardown failed: original failure"); + assert_eq!(second_error, first_error); + assert_eq!( + world.runtime_teardown, + RuntimeTeardownState::Failed("original failure".to_owned()) + ); + } +} diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 1a9783ee8..a8bb6a06b 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -3,256 +3,13 @@ //! non-test consumers (e.g. `integration_bench`) can depend on them without //! pulling in the test files. -use std::time::Duration; +/// Cucumber world, configuration, and step support for integration tests. +#[cfg(feature = "cucumber")] +pub mod cucumber; +/// Testing-framework application deployments and lifecycle helpers. +pub mod testing_framework; +/// Shared integration-test utility functions. +pub mod utils; -use anyhow::{Context as _, Result}; -use key_protocol::key_management::key_tree::chain_index::ChainIndex; -use lee::AccountId; -use sequencer_service_rpc::RpcClient as _; pub use test_fixtures::*; -use wallet::{ - AccountIdentity, - cli::{ - CliAccountMention, Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, - programs::{ - native_token_transfer::AuthTransferSubcommand, token::TokenProgramAgnosticSubcommand, - }, - }, - program_facades::{native_token_transfer::NativeTokenTransfer, token::Token}, - storage::key_chain::FoundPrivateAccount, -}; - -/// Maximum time to wait for the indexer to catch up to the sequencer. -pub const L2_TO_L1_TIMEOUT: Duration = Duration::from_mins(6); - -/// Create a private or public account at the given chain index and return its ID. -/// Pass `cci: None` to use the wallet's next available chain index. -pub async fn new_account( - ctx: &mut TestContext, - private: bool, - cci: Option, -) -> Result { - let subcommand = if private { - NewSubcommand::Private { cci, label: None } - } else { - NewSubcommand::Public { cci, label: None } - }; - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(subcommand)), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { account_id } = result else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - Ok(account_id) -} - -/// Send `amount` from `from` to `to` via an authenticated transfer (identifier 0). -pub async fn send( - ctx: &mut TestContext, - from: CliAccountMention, - to: CliAccountMention, - amount: u128, -) -> Result<()> { - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from, - to: Some(to), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount, - }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - Ok(()) -} - -/// Like [`send`], but for a `to` that is still a fresh, unclaimed account. -/// -/// The wallet CLI's `AuthTransfer::Send` never signs with the recipient's key (by design: the -/// sender's wallet must not sign on behalf of an account it doesn't own). But claiming a fresh -/// account is only possible if that account's own key signs the transaction, so this bypasses -/// the CLI and calls the program facade directly with an explicit `AccountIdentity::Public` for -/// the recipient, using the key the test wallet holds for the account it just created. -/// -/// Unlike `send`, this doesn't go through the CLI's own poll-until-included step, so it waits -/// for block creation itself before returning. -pub async fn send_claiming_new_account( - ctx: &mut TestContext, - from: AccountId, - to: AccountId, - amount: u128, -) -> Result<()> { - NativeTokenTransfer(ctx.wallet()) - .send_public_transfer( - AccountIdentity::Public(from), - AccountIdentity::Public(to), - amount, - ) - .await?; - log::info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - Ok(()) -} - -/// Create a token (New) and wait for the block to be included. -pub async fn create_token( - ctx: &mut TestContext, - definition_account_id: CliAccountMention, - supply_account_id: CliAccountMention, - name: impl Into, - total_supply: u128, -) -> Result<()> { - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id, - supply_account_id, - name: name.into(), - total_supply, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - log::info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - Ok(()) -} - -/// Send tokens and wait for the block to be included. -pub async fn token_send( - ctx: &mut TestContext, - from: CliAccountMention, - to: CliAccountMention, - amount: u128, -) -> Result<()> { - let subcommand = TokenProgramAgnosticSubcommand::Send { - from, - to: Some(to), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - log::info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - Ok(()) -} - -/// Like [`token_send`], but for a `to` that is still a fresh, unclaimed holding account. See -/// [`send_claiming_new_account`] for why the CLI can't be used here. -pub async fn token_send_claiming_new_account( - ctx: &mut TestContext, - from: AccountId, - to: AccountId, - amount: u128, -) -> Result<()> { - Token(ctx.wallet()) - .send_transfer_transaction( - AccountIdentity::Public(from), - AccountIdentity::Public(to), - amount, - ) - .await?; - log::info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - Ok(()) -} - -/// Retrieve the native token balance for `account_id`. -pub async fn account_balance(ctx: &TestContext, account_id: AccountId) -> Result { - Ok(ctx - .sequencer_client() - .get_account_balance(account_id) - .await?) -} - -/// Fetch the full account state for `account_id` from the sequencer. -pub async fn get_account(ctx: &TestContext, account_id: AccountId) -> Result { - Ok(ctx.sequencer_client().get_account(account_id).await?) -} - -/// Fetch the current commitment for `account_id` and assert it is present in the sequencer state. -pub async fn assert_private_commitment_in_state( - ctx: &TestContext, - account_id: AccountId, - label: &str, -) -> Result<()> { - let commitment = ctx - .wallet() - .get_private_account_commitment(account_id) - .with_context(|| format!("Failed to get commitment for {label}"))?; - assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); - Ok(()) -} - -/// Sync the wallet's private accounts. -pub async fn sync_private(ctx: &mut TestContext) -> Result<()> { - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::SyncPrivate {}), - ) - .await?; - Ok(()) -} - -/// Look up a restored private account for `account_id`, panicking with `label` if absent. -#[must_use] -pub fn restored_private_account<'ctx>( - ctx: &'ctx TestContext, - account_id: AccountId, - label: &str, -) -> FoundPrivateAccount<'ctx> { - ctx.wallet() - .storage() - .key_chain() - .private_account(account_id) - .unwrap_or_else(|| panic!("{label} should be restored")) -} - -/// Assert that a restored public account's signing key exists, panicking with `label` if absent. -pub fn assert_public_account_restored(ctx: &TestContext, account_id: AccountId, label: &str) { - ctx.wallet() - .storage() - .key_chain() - .pub_account_signing_key(account_id) - .unwrap_or_else(|| panic!("{label} should be restored")); -} - -/// Poll the indexer until its last finalized block id reaches the sequencer's -/// current last block id or until [`L2_TO_L1_TIMEOUT`] elapses. -/// Returns the last indexer block id observed. -pub async fn wait_for_indexer_to_catch_up(ctx: &TestContext) -> Result { - use indexer_service_rpc::RpcClient as _; - - let block_id_to_catch_up = - sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()).await?; - let mut last_ind: u64 = 1; - let inner = async { - loop { - let ind = ctx - .indexer_client() - .get_last_finalized_block_id() - .await? - .unwrap_or(0); - last_ind = ind; - if ind >= block_id_to_catch_up { - let last_seq = - sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()) - .await?; - log::info!( - "Indexer caught up. Indexer last block id: {ind}. Current sequencer last block id: {last_seq}" - ); - return Ok(ind); - } - tokio::time::sleep(Duration::from_secs(2)).await; - } - }; - tokio::time::timeout(L2_TO_L1_TIMEOUT, inner) - .await - .with_context(|| { - format!( - "Indexer failed to catch up within {L2_TO_L1_TIMEOUT:?}. Last indexer block id observed: {last_ind}, but needed to catch up to at least {block_id_to_catch_up}" - ) - })? -} +pub use utils::*; diff --git a/integration_tests/src/testing_framework/apps/bedrock.rs b/integration_tests/src/testing_framework/apps/bedrock.rs new file mode 100644 index 000000000..91967736f --- /dev/null +++ b/integration_tests/src/testing_framework/apps/bedrock.rs @@ -0,0 +1,275 @@ +use std::{net::SocketAddr, num::NonZeroU32, path::PathBuf, sync::Arc, time::Duration}; + +use anyhow::{Context as _, anyhow}; +use async_trait::async_trait; +use logos_blockchain_key_management_system_service::keys::ZkKey; +use logos_blockchain_testing_framework::{ + DeploymentBuilder, LbcEnv, TopologyConfig, + configs::wallet::{WalletAccount, WalletConfig}, +}; +use num_bigint::BigUint; +use tempfile::TempDir; +use testing_framework_app::{AppDeployment, AppHostEnv, DeployContext, LocalAppCluster}; +use testing_framework_core::scenario::DynError; +use tokio::time::{sleep, timeout}; + +/// A TF-managed Logos blockchain cluster used as LEZ's Bedrock layer. +/// +/// Logos owns node configuration generation through [`TopologyConfig`] and +/// [`DeploymentBuilder`]. Callers can use the default node-count constructor or +/// provide a configured builder without duplicating Logos deployment logic. +#[derive(Clone)] +pub struct BedrockApp { + builder: DeploymentBuilder, + scenario_base_dir: Option, +} + +impl Default for BedrockApp { + fn default() -> Self { + Self::nodes(Self::DEFAULT_NODES, "BEDROCK_DEFAULT".to_owned()) + } +} + +impl BedrockApp { + /// Default number of validators in a local Bedrock cluster. + pub const DEFAULT_NODES: usize = 2; + + /// Creates a Bedrock deployment with the requested validator count. + #[must_use] + pub fn nodes(nodes: usize, test_context: String) -> Self { + Self::nodes_with_blend_core_nodes(nodes, nodes, test_context) + } + + /// Creates a Bedrock deployment with explicit validator and Blend counts. + #[must_use] + pub fn nodes_with_blend_core_nodes( + nodes: usize, + blend_core_nodes: usize, + test_context: String, + ) -> Self { + Self::from_builder(lez_builder( + nodes, + blend_core_nodes, + test_context, + lez_funding_wallet(), + true, + )) + } + + /// Creates a Bedrock deployment with separate funding notes for a + /// multi-sequencer committee. + #[must_use] + pub fn nodes_with_committee_funding( + nodes: usize, + blend_core_nodes: usize, + test_context: String, + ) -> Self { + Self::from_builder(lez_builder( + nodes, + blend_core_nodes, + test_context, + committee_funding_wallet(), + true, + )) + } + + /// Creates a Bedrock deployment from a Logos deployment builder. + /// + /// The application's temporary state directory is applied when the + /// deployment starts; all other builder settings are preserved. + #[must_use] + pub const fn from_builder(builder: DeploymentBuilder) -> Self { + Self { + builder, + scenario_base_dir: None, + } + } + + /// Replaces the validator count while preserving other builder settings. + #[must_use] + pub fn with_nodes(mut self, nodes: usize) -> Self { + self.builder = self.builder.with_node_count(nodes); + self + } + + /// Places Bedrock node runtime directories below the supplied scenario + /// artifact directory. + #[must_use] + pub fn with_scenario_base_dir(mut self, dir: impl Into) -> Self { + self.scenario_base_dir = Some(dir.into()); + self + } +} + +#[async_trait] +impl AppDeployment for BedrockApp { + type Handle = BedrockCluster; + + async fn deploy(self, ctx: &mut DeployContext) -> Result { + let (scenario_base_dir, state_dir); + if let Some(dir) = self.scenario_base_dir { + std::fs::create_dir_all(&dir).context("failed to create Bedrock scenario directory")?; + scenario_base_dir = dir; + state_dir = None; + } else { + let temp_dir = + tempfile::tempdir().context("failed to create Bedrock state directory")?; + scenario_base_dir = temp_dir.path().to_owned(); + state_dir = Some(Arc::new(temp_dir)); + } + let deployment = self + .builder + .scenario_base_dir(scenario_base_dir) + .build() + .context("failed to build Bedrock cluster deployment")?; + let cluster = Box::pin(ctx.deploy_local_cluster::(deployment)).await?; + let primary_api_addr = first_api_addr(&cluster)?; + + Ok(BedrockCluster { + cluster, + primary_api_addr, + _state_dir: state_dir, + }) + } +} + +/// Client access and lifetime ownership for the deployed Bedrock cluster. +#[derive(Clone)] +pub struct BedrockCluster { + cluster: LocalAppCluster, + primary_api_addr: SocketAddr, + _state_dir: Option>, +} + +impl BedrockCluster { + /// Returns the underlying TF cluster handle for node-level control. + #[must_use] + pub const fn cluster(&self) -> &LocalAppCluster { + &self.cluster + } + + /// Returns the node API selected as the primary endpoint for LEZ services. + /// + /// The current local stack intentionally connects both the indexer and the + /// sequencer to the first Bedrock node. This is not a failover endpoint. + #[must_use] + pub const fn primary_api_addr(&self) -> SocketAddr { + self.primary_api_addr + } + + /// Returns the number of Logos nodes in the Bedrock cluster. + #[must_use] + pub fn node_count(&self) -> usize { + self.cluster.node_count() + } + + /// Queries every node's consensus state. + pub async fn cryptarchia_info(&self) -> Result<(), DynError> { + for client in self.cluster.clients() { + client.consensus_info().await?; + } + Ok(()) + } + + /// Waits until the Bedrock node has produced its first post-genesis block. + /// + /// The node wallet indexes genesis funding while processing the live chain; + /// callers that immediately submit a wallet-funded transaction must wait for + /// this transition first. + pub async fn wait_for_first_block(&self) -> Result<(), DynError> { + const TIMEOUT: Duration = Duration::from_secs(60); + const POLL_INTERVAL: Duration = Duration::from_millis(250); + let mut last_error = None; + + if timeout(TIMEOUT, async { + loop { + match self.cluster.first_client() { + Some(client) => match client.consensus_info().await { + Ok(info) if info.cryptarchia_info.height > 0 => return, + Ok(_) => {} + Err(error) => last_error = Some(error.to_string()), + }, + None => last_error = Some("Bedrock cluster has no node clients".to_owned()), + } + sleep(POLL_INTERVAL).await; + } + }) + .await + .is_ok() + { + return Ok(()); + } + + Err(anyhow!( + "Bedrock did not produce a post-genesis block after {TIMEOUT:?}: {}", + last_error.unwrap_or_else(|| "no readiness response".to_owned()) + ) + .into()) + } +} + +fn first_api_addr(cluster: &LocalAppCluster) -> Result { + let client = cluster + .first_client() + .ok_or_else(|| anyhow!("Bedrock cluster has no node clients"))?; + client + .base_url() + .socket_addrs(|| None)? + .into_iter() + .next() + .ok_or_else(|| anyhow!("Bedrock node URL has no socket address").into()) +} + +fn lez_builder( + nodes: usize, + blend_core_nodes: usize, + test_context: String, + wallet: WalletConfig, + allow_multiple_genesis_tokens: bool, +) -> DeploymentBuilder { + DeploymentBuilder::new( + TopologyConfig::with_node_numbers(nodes) + .with_blend_core_nodes(blend_core_nodes) + .with_test_context(Some(test_context)) + .with_allow_multiple_genesis_tokens(allow_multiple_genesis_tokens), + ) + .with_security_param(NonZeroU32::new(5).expect("five is non-zero")) + .with_slot_activation_coeff(1, NonZeroU32::new(2).expect("two is non-zero")) + .with_wallet_config(wallet) +} + +fn lez_funding_account() -> WalletAccount { + const FUNDING_SECRET_KEY: [u8; 32] = [ + 0x6c, 0x64, 0x5c, 0xd4, 0x63, 0x6d, 0x9c, 0x4c, 0x36, 0xa3, 0x7a, 0x9a, 0xea, 0xbc, 0xaa, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]; + + WalletAccount { + label: "lez-sequencer-funding".to_owned(), + secret_key: ZkKey::from(BigUint::from_bytes_le(&FUNDING_SECRET_KEY)), + value: 1_000_000_000, + } +} + +fn lez_funding_wallet() -> WalletConfig { + WalletConfig::new( + std::iter::repeat_with(|| WalletAccount { + value: 50_000_000, + ..lez_funding_account() + }) + .take(20) + .collect(), + ) +} + +fn committee_funding_wallet() -> WalletConfig { + WalletConfig::new( + std::iter::repeat_with(|| WalletAccount { + value: 1_000_000, + ..lez_funding_account() + }) + .take(20) + .collect(), + ) +} diff --git a/integration_tests/src/testing_framework/apps/indexer.rs b/integration_tests/src/testing_framework/apps/indexer.rs new file mode 100644 index 000000000..d1c595e9d --- /dev/null +++ b/integration_tests/src/testing_framework/apps/indexer.rs @@ -0,0 +1,123 @@ +use std::{ + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use anyhow::Context as _; +use async_trait::async_trait; +use indexer_service::IndexerHandle; +use tempfile::TempDir; +use testing_framework_app::{AppDeployment, AppHostEnv, DeployContext}; +use testing_framework_core::scenario::DynError; + +use crate::{ + config::{self, UrlProtocol}, + indexer_client::IndexerClient, + setup::setup_indexer, +}; + +struct IndexerInstance { + client: Arc, + service: Mutex>, + _state_dir: Option, +} + +/// Client and lifetime handle for a deployed LEZ indexer. +/// +/// Clones share the existing indexer client and keep the indexer service and +/// its state directory alive until the final clone is dropped. +#[derive(Clone)] +pub struct LezIndexerClient(Arc); + +impl LezIndexerClient { + fn new(client: Arc, service: IndexerHandle, state_dir: Option) -> Self { + Self(Arc::new(IndexerInstance { + client, + service: Mutex::new(Some(service)), + _state_dir: state_dir, + })) + } + + /// Returns the existing LEZ indexer client. + #[must_use] + pub fn client(&self) -> &IndexerClient { + &self.0.client + } + + /// Stops the indexer service and waits for its RPC server to terminate. + /// Repeated calls are harmless. + pub async fn shutdown(&self) -> Result<(), DynError> { + let service = self + .0 + .service + .lock() + .map_err(|error| anyhow::anyhow!("LEZ indexer shutdown lock poisoned: {error}"))? + .take(); + + if let Some(service) = service { + service.shutdown().await?; + } + + Ok(()) + } +} + +/// Deployable LEZ indexer configured with an explicit Bedrock API address. +#[derive(Clone)] +pub struct IndexerApp { + bedrock_addr: SocketAddr, + state_dir: Option, +} + +impl IndexerApp { + /// Creates an indexer deployment connected to `bedrock_addr`. + #[must_use] + pub const fn new(bedrock_addr: SocketAddr) -> Self { + Self { + bedrock_addr, + state_dir: None, + } + } + + /// Places indexer state and logs below the supplied scenario artifact + /// directory. + #[must_use] + pub fn with_state_dir(mut self, dir: impl Into) -> Self { + self.state_dir = Some(dir.into()); + self + } +} + +#[async_trait] +impl AppDeployment for IndexerApp { + type Handle = LezIndexerClient; + + async fn deploy(self, _ctx: &mut DeployContext) -> Result { + // If the indexer moves to a separate process, create a `LocalProcessApp` + // with its `LaunchSpec` here so TF owns the process lifecycle. + let (service, state_dir); + if let Some(home) = self.state_dir { + service = crate::setup::setup_indexer_at( + self.bedrock_addr, + config::bedrock_channel_id(), + None, + &home, + ) + .await + .context("failed to set up LEZ indexer")?; + state_dir = None; + } else { + let (indexer, temp_dir) = + setup_indexer(self.bedrock_addr, config::bedrock_channel_id(), None) + .await + .context("failed to set up LEZ indexer")?; + service = indexer; + state_dir = Some(temp_dir); + } + let url = config::addr_to_url(UrlProtocol::Ws, service.addr())?; + let client = Arc::new(IndexerClient::new(&url).await?); + + Ok(LezIndexerClient::new(client, service, state_dir)) + } +} diff --git a/integration_tests/src/testing_framework/apps/mod.rs b/integration_tests/src/testing_framework/apps/mod.rs new file mode 100644 index 000000000..72f1854a7 --- /dev/null +++ b/integration_tests/src/testing_framework/apps/mod.rs @@ -0,0 +1,13 @@ +//! Independently deployable applications used to compose the LEZ test stack. + +pub use bedrock::{BedrockApp, BedrockCluster}; +pub use indexer::{IndexerApp, LezIndexerClient}; +pub use sequencer::{LezSequencerClient, SequencerApp}; +pub use sequencer_registry::{LezSequencerRegistryApp, LezSequencerRegistryClient}; +pub use wallet::{LezRuntime, WalletApp}; + +mod bedrock; +mod indexer; +mod sequencer; +mod sequencer_registry; +mod wallet; diff --git a/integration_tests/src/testing_framework/apps/sequencer.rs b/integration_tests/src/testing_framework/apps/sequencer.rs new file mode 100644 index 000000000..1b1de09b6 --- /dev/null +++ b/integration_tests/src/testing_framework/apps/sequencer.rs @@ -0,0 +1,176 @@ +use std::{ + future::Future, + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use anyhow::Context as _; +use async_trait::async_trait; +use lee::PrivateKey; +use sequencer_service::SequencerHandle; +use sequencer_service_rpc::{SequencerClient, SequencerClientBuilder}; +use tempfile::TempDir; +use testing_framework_app::{AppDeployment, AppHostEnv, DeployContext}; +use testing_framework_core::scenario::DynError; + +use crate::{ + config::{self, InitialPrivateAccountForWallet, SequencerPartialConfig, UrlProtocol}, + setup::SequencerSetup, +}; + +struct SequencerInstance { + client: SequencerClient, + addr: SocketAddr, + public_accounts: Vec<(PrivateKey, u128)>, + private_accounts: Vec, + service: Mutex>, + _state_dir: Option, +} + +/// Client and lifetime handle for a deployed LEZ sequencer. +/// +/// Clones share the existing sequencer client and keep the sequencer service +/// and its state directory alive until the final clone is dropped. +#[derive(Clone)] +pub struct LezSequencerClient(Arc); + +impl LezSequencerClient { + pub(crate) fn new( + client: SequencerClient, + addr: SocketAddr, + public_accounts: Vec<(PrivateKey, u128)>, + private_accounts: Vec, + service: SequencerHandle, + state_dir: Option, + ) -> Self { + Self(Arc::new(SequencerInstance { + client, + addr, + public_accounts, + private_accounts, + service: Mutex::new(Some(service)), + _state_dir: state_dir, + })) + } + + /// Returns the existing LEZ sequencer RPC client. + #[must_use] + pub fn client(&self) -> &SequencerClient { + &self.0.client + } + + /// Returns the sequencer RPC address. + #[must_use] + pub fn addr(&self) -> SocketAddr { + self.0.addr + } + + /// Stops the sequencer service and waits for its owned tasks and store to + /// be released. Repeated calls are harmless. + pub async fn shutdown(&self) -> Result<(), DynError> { + let service = self + .0 + .service + .lock() + .map_err(|error| anyhow::anyhow!("LEZ sequencer shutdown lock poisoned: {error}"))? + .take(); + + if let Some(service) = service { + service.shutdown().await; + } + + Ok(()) + } + + pub(super) fn public_accounts(&self) -> &[(PrivateKey, u128)] { + &self.0.public_accounts + } + + pub(super) fn private_accounts(&self) -> &[InitialPrivateAccountForWallet] { + &self.0.private_accounts + } +} + +/// Deployable LEZ sequencer configured with an explicit Bedrock API address. +#[derive(Clone)] +pub struct SequencerApp { + config: SequencerPartialConfig, + bedrock_addr: SocketAddr, + state_dir: Option, +} + +impl SequencerApp { + /// Creates a sequencer deployment connected to `bedrock_addr`. + #[must_use] + pub const fn new(config: SequencerPartialConfig, bedrock_addr: SocketAddr) -> Self { + Self { + config, + bedrock_addr, + state_dir: None, + } + } + + /// Places sequencer state and logs below the supplied scenario artifact + /// directory. + #[must_use] + pub fn with_state_dir(mut self, dir: impl Into) -> Self { + self.state_dir = Some(dir.into()); + self + } + + #[expect( + clippy::manual_async_fn, + reason = "Explicit Send future works around rust-lang/rust#100013" + )] + fn deploy_inner( + self, + ) -> impl Future> + Send + 'static { + async move { + let public_accounts = config::default_public_accounts_for_wallet(); + let private_accounts = config::default_private_accounts_for_wallet(); + let genesis = config::genesis_from_accounts(&public_accounts, &private_accounts); + // If the sequencer moves to a separate process, create a `LocalProcessApp` + // with its `LaunchSpec` here so TF owns the process lifecycle. + let setup = SequencerSetup::new(self.config, self.bedrock_addr).with_genesis(genesis); + let (service, state_dir); + if let Some(home) = self.state_dir { + std::fs::create_dir_all(&home) + .context("failed to create LEZ sequencer state directory")?; + service = setup + .setup_at(&home) + .await + .context("failed to set up LEZ sequencer")?; + state_dir = None; + } else { + let (sequencer, temp_dir) = setup + .setup() + .await + .context("failed to set up LEZ sequencer")?; + service = sequencer; + state_dir = Some(temp_dir); + } + let addr = service.addr(); + let url = config::addr_to_url(UrlProtocol::Http, addr)?; + let client = SequencerClientBuilder::default().build(url)?; + + Ok(LezSequencerClient::new( + client, + addr, + public_accounts, + private_accounts, + service, + state_dir, + )) + } + } +} + +#[async_trait] +impl AppDeployment for SequencerApp { + type Handle = LezSequencerClient; + + async fn deploy(self, _ctx: &mut DeployContext) -> Result { + self.deploy_inner().await + } +} diff --git a/integration_tests/src/testing_framework/apps/sequencer_registry.rs b/integration_tests/src/testing_framework/apps/sequencer_registry.rs new file mode 100644 index 000000000..67083246d --- /dev/null +++ b/integration_tests/src/testing_framework/apps/sequencer_registry.rs @@ -0,0 +1,358 @@ +use std::{ + collections::HashMap, + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use anyhow::{Context as _, anyhow}; +use async_trait::async_trait; +use logos_blockchain_key_management_system_service::keys::ED25519_SECRET_KEY_SIZE; +use sequencer_core::config::GenesisAction; +use sequencer_service_rpc::SequencerClientBuilder; +use testing_framework_app::{AppDeployment, AppHostEnv, DeployContext}; +use testing_framework_core::scenario::DynError; + +use super::LezSequencerClient; +use crate::{ + config::{self, SequencerPartialConfig, UrlProtocol}, + setup::SequencerSetup, +}; + +#[derive(Clone, Copy)] +struct RegisteredSequencer { + config: SequencerPartialConfig, + signing_key: [u8; ED25519_SECRET_KEY_SIZE], +} + +struct SequencerRegistryInstance { + config: SequencerPartialConfig, + registered: Mutex>, + started: Mutex>, + initial_committee_aliases: Mutex>>, + genesis: Mutex>>, + bedrock_addr: SocketAddr, + scenario_base_dir: Option, +} + +/// TF-owned sequencer lifecycles for a Cucumber sequencer-registry scenario. +#[derive(Clone)] +pub struct LezSequencerRegistryClient(Arc); + +impl LezSequencerRegistryClient { + fn new( + config: SequencerPartialConfig, + bedrock_addr: SocketAddr, + scenario_base_dir: Option, + ) -> Self { + Self(Arc::new(SequencerRegistryInstance { + config, + registered: Mutex::new(HashMap::new()), + started: Mutex::new(HashMap::new()), + initial_committee_aliases: Mutex::new(None), + genesis: Mutex::new(None), + bedrock_addr, + scenario_base_dir, + })) + } + + /// Registers a sequencer alias and its signing identity. + pub fn register( + &self, + alias: impl Into, + signing_key: [u8; ED25519_SECRET_KEY_SIZE], + ) -> Result<(), DynError> { + let alias = alias.into(); + if self + .0 + .genesis + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))? + .is_some() + { + return Err(anyhow!( + "cannot register sequencer alias '{alias}' after sequencer startup" + ) + .into()); + } + let mut registered = self + .0 + .registered + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))?; + if registered.contains_key(&alias) { + return Err(anyhow!("sequencer alias '{alias}' is already registered").into()); + } + registered.insert( + alias, + RegisteredSequencer { + config: self.config(), + signing_key, + }, + ); + Ok(()) + } + + fn config(&self) -> SequencerPartialConfig { + self.0.config + } + + /// Selects the registered aliases that will be staked in shared genesis. + pub fn configure_initial_committee(&self, aliases: &[String]) -> Result<(), DynError> { + if aliases.is_empty() { + return Err(anyhow!("the initial committee must contain at least one alias").into()); + } + + let genesis = self + .0 + .genesis + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))?; + if genesis.is_some() { + return Err( + anyhow!("cannot configure the initial committee after sequencer startup").into(), + ); + } + + let registered = self + .0 + .registered + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))?; + for alias in aliases { + if !registered.contains_key(alias) { + return Err(anyhow!("initial committee alias '{alias}' is not registered").into()); + } + } + drop(registered); + + let mut configured = self + .0 + .initial_committee_aliases + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))?; + if configured.is_some() { + return Err(anyhow!("the initial committee is already configured").into()); + } + *configured = Some(aliases.to_vec()); + Ok(()) + } + + fn genesis(&self) -> Result, DynError> { + let mut genesis = self + .0 + .genesis + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))?; + if let Some(genesis) = genesis.as_ref() { + return Ok(genesis.clone()); + } + + let aliases = self + .0 + .initial_committee_aliases + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))? + .clone() + .ok_or_else(|| anyhow!("the initial committee has not been configured"))?; + let registered = self + .0 + .registered + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))?; + let signing_keys = aliases + .iter() + .map(|alias| { + registered + .get(alias) + .map(|registration| registration.signing_key) + .ok_or_else(|| anyhow!("initial committee alias '{alias}' is not registered")) + }) + .collect::, _>>()?; + let generated = test_fixtures::config::genesis_sequencer_stakes(&signing_keys) + .context("failed to build founding sequencer stakes")?; + *genesis = Some(generated.clone()); + Ok(generated) + } + + /// Starts the registered sequencer identified by `alias`. + pub async fn start(&self, alias: &str) -> Result<(), DynError> { + let registration = self + .0 + .registered + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))? + .get(alias) + .copied() + .ok_or_else(|| anyhow!("sequencer alias '{alias}' is not registered"))?; + if self + .0 + .started + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))? + .contains_key(alias) + { + return Ok(()); + } + + let state_dir = self + .0 + .scenario_base_dir + .as_ref() + .map(|dir| dir.join("lez").join(format!("sequencer-{alias}"))); + let genesis = self.genesis()?; + let sequencer = deploy_registered_sequencer( + registration.config, + self.0.bedrock_addr, + registration.signing_key, + genesis, + state_dir, + ) + .await?; + + let mut sequencer = Some(sequencer); + let duplicate = { + let mut started = self + .0 + .started + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))?; + if started.contains_key(alias) { + true + } else { + started.insert( + alias.to_owned(), + sequencer.take().expect("sequencer is present"), + ); + false + } + }; + if duplicate { + return sequencer + .expect("duplicate sequencer remains available") + .shutdown() + .await; + } + Ok(()) + } + + /// Returns a started sequencer identified by `alias`. + #[must_use] + pub fn sequencer(&self, alias: &str) -> Option { + let sequencers = self.0.started.lock().ok()?; + Some(sequencers.get(alias)?.clone()) + } + + /// Returns the registered signing key for a sequencer alias. + #[must_use] + pub fn signing_key(&self, alias: &str) -> Option<[u8; ED25519_SECRET_KEY_SIZE]> { + let sequencers = self.0.registered.lock().ok()?; + Some(sequencers.get(alias)?.signing_key) + } + + /// Stops every started sequencer and preserves all component failures. + pub async fn shutdown(&self) -> Result<(), DynError> { + let started = self + .0 + .started + .lock() + .map_err(|error| anyhow!("sequencer registry lock poisoned: {error}"))? + .drain() + .collect::>(); + let mut failures = Vec::new(); + for (alias, sequencer) in started { + if let Err(error) = sequencer.shutdown().await { + failures.push(format!("sequencer '{alias}': {error}")); + } + } + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "sequencer registry shutdown failed:\n- {}", + failures.join("\n- ") + ) + .into()) + } + } +} + +/// Deploys an empty alias-based sequencer registry. +#[derive(Clone)] +pub struct LezSequencerRegistryApp { + config: SequencerPartialConfig, + bedrock_addr: SocketAddr, + scenario_base_dir: Option, +} + +impl LezSequencerRegistryApp { + /// Creates an empty sequencer registry connected to the Bedrock endpoint. + #[must_use] + pub const fn new(config: SequencerPartialConfig, bedrock_addr: SocketAddr) -> Self { + Self { + config, + bedrock_addr, + scenario_base_dir: None, + } + } + + /// Places registered sequencer state below the scenario artifact directory. + #[must_use] + pub fn with_scenario_base_dir(mut self, dir: impl Into) -> Self { + self.scenario_base_dir = Some(dir.into()); + self + } +} + +#[async_trait] +impl AppDeployment for LezSequencerRegistryApp { + type Handle = LezSequencerRegistryClient; + + async fn deploy(self, _ctx: &mut DeployContext) -> Result { + Ok(LezSequencerRegistryClient::new( + self.config, + self.bedrock_addr, + self.scenario_base_dir, + )) + } +} + +async fn deploy_registered_sequencer( + config: SequencerPartialConfig, + bedrock_addr: SocketAddr, + signing_key: [u8; ED25519_SECRET_KEY_SIZE], + genesis: Vec, + state_dir: Option, +) -> Result { + let setup = SequencerSetup::new(config, bedrock_addr) + .with_genesis(genesis) + .with_bedrock_signing_key(signing_key); + let (service, owned_state_dir) = if let Some(state_dir) = state_dir { + std::fs::create_dir_all(&state_dir) + .context("failed to create registered sequencer state directory")?; + ( + setup + .setup_at(&state_dir) + .await + .context("failed to set up registered sequencer")?, + None, + ) + } else { + let (service, temporary_state_dir) = setup + .setup() + .await + .context("failed to set up registered sequencer")?; + (service, Some(temporary_state_dir)) + }; + let addr = service.addr(); + let url = config::addr_to_url(UrlProtocol::Http, addr)?; + let client = SequencerClientBuilder::default().build(url)?; + Ok(LezSequencerClient::new( + client, + addr, + Vec::new(), + Vec::new(), + service, + owned_state_dir, + )) +} diff --git a/integration_tests/src/testing_framework/apps/wallet.rs b/integration_tests/src/testing_framework/apps/wallet.rs new file mode 100644 index 000000000..ed48e8194 --- /dev/null +++ b/integration_tests/src/testing_framework/apps/wallet.rs @@ -0,0 +1,726 @@ +use std::{ + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex}, + thread::JoinHandle, +}; + +use anyhow::{Context as _, anyhow}; +use async_trait::async_trait; +use common::HashType; +use lee::{AccountId, PrivateKey, PublicKey}; +use tempfile::TempDir; +use testing_framework_app::{AppDeployment, AppHostEnv, DeployContext}; +use testing_framework_core::scenario::DynError; +use tokio::sync::{mpsc, oneshot}; +use wallet::{ + AccountIdentity, WalletCore, + account::{AccountIdWithPrivacy, Label}, + cli::{ + CliAccountMention, Command, SubcommandReturnValue, account::AccountSubcommand, + programs::native_token_transfer::AuthTransferSubcommand, + }, + config::WalletConfigOverrides, + program_facades::native_token_transfer::NativeTokenTransfer, +}; + +use super::LezSequencerClient; +use crate::{ + config::InitialPrivateAccountForWallet, + setup::{ + setup_private_accounts_with_initial_supply, setup_public_accounts_with_initial_supply, + setup_wallet, + }, +}; + +struct WalletComponents { + wallet: WalletCore, + _state_dir: Option, + password: String, +} + +enum WalletRequest { + ExistingPublicAccounts { + response: oneshot::Sender, String>>, + }, + ExistingPrivateAccounts { + response: oneshot::Sender, String>>, + }, + PrivateAccountBalance { + account_id: AccountId, + response: oneshot::Sender, String>>, + }, + PrivateAccountCommitment { + account_id: AccountId, + response: oneshot::Sender, String>>, + }, + PublicAccountSigningKey { + account_id: AccountId, + response: oneshot::Sender, String>>, + }, + FirstPublicAccount { + response: oneshot::Sender, String>>, + }, + PublicTransfer { + from: AccountId, + to: AccountId, + amount: u128, + response: oneshot::Sender>, + }, + PrivateTransfer { + from: AccountId, + to: AccountId, + amount: u128, + response: oneshot::Sender>, + }, + SyncToLatestBlock { + response: oneshot::Sender>, + }, + NewPublicAccount { + response: oneshot::Sender>, + }, + PublicTransferToNewAccount { + from: AccountId, + to: AccountId, + amount: u128, + response: oneshot::Sender>, + }, + SetPublicAccountLabel { + account_id: AccountId, + label: Label, + response: oneshot::Sender>, + }, + PublicTransferByLabels { + from: Label, + to: Label, + amount: u128, + response: oneshot::Sender>, + }, + WalletPassword { + response: oneshot::Sender>, + }, + Shutdown { + response: oneshot::Sender>, + }, +} + +struct WalletActor { + requests: mpsc::Sender, + join_handle: Mutex>>, +} + +impl WalletActor { + fn new(components: WalletComponents) -> Result { + let (requests, mut receiver) = mpsc::channel(16); + let join_handle = std::thread::Builder::new() + .name("lez-wallet".to_owned()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("LEZ wallet actor runtime should be constructible"); + + runtime.block_on(async move { + let mut components = components; + while let Some(request) = receiver.recv().await { + match request { + WalletRequest::ExistingPublicAccounts { response } => { + let accounts = components + .wallet + .storage() + .key_chain() + .public_account_ids() + .map(|(account_id, _)| account_id) + .collect(); + let _unused = response.send(Ok(accounts)); + } + WalletRequest::ExistingPrivateAccounts { response } => { + let accounts = components + .wallet + .storage() + .key_chain() + .private_account_ids() + .map(|(account_id, _)| account_id) + .collect(); + let _unused = response.send(Ok(accounts)); + } + WalletRequest::PrivateAccountBalance { + account_id, + response, + } => { + let balance = components + .wallet + .get_account_private(account_id) + .map(|account| account.balance); + let _unused = response.send(Ok(balance)); + } + WalletRequest::PrivateAccountCommitment { + account_id, + response, + } => { + let commitment = components + .wallet + .get_private_account_commitment(account_id); + let _unused = response.send(Ok(commitment)); + } + WalletRequest::PublicAccountSigningKey { + account_id, + response, + } => { + let public_key = components + .wallet + .get_account_public_signing_key(account_id) + .map(PublicKey::new_from_private_key); + let _unused = response.send(Ok(public_key)); + } + WalletRequest::FirstPublicAccount { response } => { + let account = components + .wallet + .storage() + .key_chain() + .public_account_ids() + .next() + .map(|(account_id, _)| account_id); + let _unused = response.send(Ok(account)); + } + WalletRequest::PublicTransfer { + from, + to, + amount, + response, + } => { + let result = wallet::cli::execute_subcommand( + &mut components.wallet, + Command::AuthTransfer(AuthTransferSubcommand::Send { + from: CliAccountMention::Id(AccountIdWithPrivacy::Public( + from, + )), + to: Some(CliAccountMention::Id( + AccountIdWithPrivacy::Public(to), + )), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount, + }), + ) + .await + .and_then(|result| { + #[expect( + clippy::wildcard_enum_match_arm, + reason = "Only TransactionExecuted is valid for a transfer request" + )] + match result { + SubcommandReturnValue::TransactionExecuted { tx_hash } => { + Ok(tx_hash) + } + other => { + anyhow::bail!( + "expected TransactionExecuted, got {other:?}" + ) + } + } + }) + .map_err(|error| error.to_string()); + let _unused = response.send(result); + } + WalletRequest::PrivateTransfer { + from, + to, + amount, + response, + } => { + let result = wallet::cli::execute_subcommand( + &mut components.wallet, + Command::AuthTransfer(AuthTransferSubcommand::Send { + from: CliAccountMention::Id( + AccountIdWithPrivacy::Private(from), + ), + to: Some(CliAccountMention::Id( + AccountIdWithPrivacy::Private(to), + )), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount, + }), + ) + .await + .and_then(|result| { + #[expect( + clippy::wildcard_enum_match_arm, + reason = "Only TransactionExecuted is valid for a transfer request" + )] + match result { + SubcommandReturnValue::TransactionExecuted { tx_hash } => { + Ok(tx_hash) + } + other => { + anyhow::bail!( + "expected TransactionExecuted, got {other:?}" + ) + } + } + }) + .map_err(|error| error.to_string()); + let _unused = response.send(result); + } + WalletRequest::SyncToLatestBlock { response } => { + let result = components + .wallet + .sync_to_latest_block() + .await + .map(|_| ()) + .map_err(|error| error.to_string()); + let _unused = response.send(result); + } + WalletRequest::NewPublicAccount { response } => { + let (account_id, _) = + components.wallet.create_new_account_public(None); + let result = components + .wallet + .store_persistent_data() + .map(|()| account_id) + .map_err(|error| error.to_string()); + let _unused = response.send(result); + } + WalletRequest::PublicTransferToNewAccount { + from, + to, + amount, + response, + } => { + let result = async { + let tx_hash = NativeTokenTransfer(&components.wallet) + .send_public_transfer( + AccountIdentity::Public(from), + AccountIdentity::Public(to), + amount, + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + components + .wallet + .poll_transaction(tx_hash) + .await + .map_err(|error| anyhow!(error.to_string()))?; + components + .wallet + .store_persistent_data() + .map_err(|error| anyhow!(error.to_string()))?; + Ok(tx_hash) + } + .await + .map_err(|error: anyhow::Error| error.to_string()); + let _unused = response.send(result); + } + WalletRequest::SetPublicAccountLabel { + account_id, + label, + response, + } => { + let result = wallet::cli::execute_subcommand( + &mut components.wallet, + Command::Account(AccountSubcommand::Label { + account_id: CliAccountMention::Id( + AccountIdWithPrivacy::Public(account_id), + ), + label, + }), + ) + .await + .map(|_| ()) + .map_err(|error| error.to_string()); + let _unused = response.send(result); + } + WalletRequest::PublicTransferByLabels { + from, + to, + amount, + response, + } => { + let result = wallet::cli::execute_subcommand( + &mut components.wallet, + Command::AuthTransfer(AuthTransferSubcommand::Send { + from: CliAccountMention::Label(from), + to: Some(CliAccountMention::Label(to)), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount, + }), + ) + .await + .and_then(|result| { + #[expect( + clippy::wildcard_enum_match_arm, + reason = "Only TransactionExecuted is valid for a transfer request" + )] + match result { + SubcommandReturnValue::TransactionExecuted { tx_hash } => { + Ok(tx_hash) + } + other => { + anyhow::bail!( + "expected TransactionExecuted, got {other:?}" + ) + } + } + }) + .map_err(|error| error.to_string()); + let _unused = response.send(result); + } + WalletRequest::WalletPassword { response } => { + let _unused = response.send(Ok(components.password.clone())); + } + WalletRequest::Shutdown { response } => { + let _unused = response.send(Ok(())); + break; + } + } + } + }); + }) + .context("failed to start LEZ wallet actor")?; + + Ok(Self { + requests, + join_handle: Mutex::new(Some(join_handle)), + }) + } +} + +/// Runtime handle for the deployed LEZ wallet and its state. +#[derive(Clone)] +pub struct LezRuntime { + actor: Arc, +} + +impl LezRuntime { + fn new( + wallet: WalletCore, + state_dir: Option, + password: String, + ) -> Result { + Ok(Self { + actor: Arc::new(WalletActor::new(WalletComponents { + wallet, + _state_dir: state_dir, + password, + })?), + }) + } + + async fn request( + &self, + request: impl FnOnce(oneshot::Sender>) -> WalletRequest, + ) -> Result { + let (response, receiver) = oneshot::channel(); + self.actor + .requests + .send(request(response)) + .await + .map_err(|error| anyhow!("LEZ wallet actor is no longer running: {error}"))?; + receiver + .await + .map_err(|error| anyhow!("LEZ wallet actor dropped its response: {error}"))? + .map_err(|error| anyhow!(error).into()) + } + + /// Returns the first public account configured in the wallet. + pub async fn first_public_account(&self) -> Result { + self.request(|response| WalletRequest::FirstPublicAccount { response }) + .await? + .ok_or_else(|| anyhow!("LEZ wallet has no public account").into()) + } + + /// Returns all public account IDs configured in the wallet. + pub async fn existing_public_accounts(&self) -> Result, DynError> { + self.request(|response| WalletRequest::ExistingPublicAccounts { response }) + .await + } + + /// Returns all private account IDs configured in the wallet. + pub async fn existing_private_accounts(&self) -> Result, DynError> { + self.request(|response| WalletRequest::ExistingPrivateAccounts { response }) + .await + } + + /// Returns the locally synchronized balance of an imported private account. + pub async fn private_account_balance( + &self, + account_id: AccountId, + ) -> Result, DynError> { + self.request(|response| WalletRequest::PrivateAccountBalance { + account_id, + response, + }) + .await + } + + /// Returns the current commitment for an imported private account. + pub async fn private_account_commitment( + &self, + account_id: AccountId, + ) -> Result, DynError> { + self.request(|response| WalletRequest::PrivateAccountCommitment { + account_id, + response, + }) + .await + } + + /// Returns the public signing key for an imported public account. + pub async fn public_account_signing_key( + &self, + account_id: AccountId, + ) -> Result, DynError> { + self.request(|response| WalletRequest::PublicAccountSigningKey { + account_id, + response, + }) + .await + } + + /// Returns the password used to open the test wallet. + pub async fn wallet_password(&self) -> Result { + self.request(|response| WalletRequest::WalletPassword { response }) + .await + } + + /// Executes an authenticated transfer between two owned public accounts. + pub async fn public_transfer( + &self, + from: AccountId, + to: AccountId, + amount: u128, + ) -> Result { + self.request(|response| WalletRequest::PublicTransfer { + from, + to, + amount, + response, + }) + .await + } + + /// Executes an authenticated transfer between two owned private accounts. + pub async fn private_transfer( + &self, + from: AccountId, + to: AccountId, + amount: u128, + ) -> Result { + self.request(|response| WalletRequest::PrivateTransfer { + from, + to, + amount, + response, + }) + .await + } + + /// Synchronizes the wallet with the latest sequencer block. + pub async fn sync_to_latest_block(&self) -> Result<(), DynError> { + self.request(|response| WalletRequest::SyncToLatestBlock { response }) + .await + } + + /// Creates and persists a fresh public account in the wallet. + pub async fn new_public_account(&self) -> Result { + self.request(|response| WalletRequest::NewPublicAccount { response }) + .await + } + + /// Executes a public transfer that claims a fresh recipient account. + pub async fn public_transfer_to_new_account( + &self, + from: AccountId, + to: AccountId, + amount: u128, + ) -> Result { + self.request(|response| WalletRequest::PublicTransferToNewAccount { + from, + to, + amount, + response, + }) + .await + } + + /// Assigns a label to an imported public account through the wallet actor. + pub async fn set_public_account_label( + &self, + account_id: AccountId, + label: Label, + ) -> Result<(), DynError> { + self.request(|response| WalletRequest::SetPublicAccountLabel { + account_id, + label, + response, + }) + .await + } + + /// Executes an authenticated public transfer using wallet-resolved labels. + pub async fn public_transfer_by_labels( + &self, + from: Label, + to: Label, + amount: u128, + ) -> Result { + self.request(|response| WalletRequest::PublicTransferByLabels { + from, + to, + amount, + response, + }) + .await + } + + /// Stop the wallet actor and wait for its owning thread to finish. + pub async fn shutdown(&self) -> Result<(), DynError> { + let join_handle = self + .actor + .join_handle + .lock() + .map_err(|error| anyhow!("LEZ wallet actor join lock poisoned: {error}"))? + .take(); + + let Some(join_handle) = join_handle else { + return Ok(()); + }; + + let request_result = self + .request(|response| WalletRequest::Shutdown { response }) + .await; + let join_result = tokio::task::spawn_blocking(move || join_handle.join()) + .await + .map_err(|error| anyhow!("failed to join LEZ wallet actor: {error}"))?; + if join_result.is_err() { + return Err(anyhow!("LEZ wallet actor panicked").into()); + } + + request_result + } +} + +/// Deployable LEZ wallet configured from a deployed sequencer client. +#[derive(Clone)] +pub struct WalletApp { + sequencer_addr: SocketAddr, + public_accounts: Vec<(PrivateKey, u128)>, + private_accounts: Vec, + state_dir: Option, + initialize_private_accounts: bool, +} + +impl WalletApp { + /// Creates a wallet deployment using a snapshot of sequencer connection and + /// genesis-account data. + #[must_use] + pub fn from_sequencer(sequencer: &LezSequencerClient) -> Self { + Self { + sequencer_addr: sequencer.addr(), + public_accounts: sequencer.public_accounts().to_vec(), + private_accounts: sequencer.private_accounts().to_vec(), + state_dir: None, + initialize_private_accounts: true, + } + } + + /// Places wallet state and logs below the supplied scenario artifact + /// directory. + #[must_use] + pub fn with_state_dir(mut self, dir: impl Into) -> Self { + self.state_dir = Some(dir.into()); + self + } + + /// Skip privacy-preserving account funding when the caller only needs the + /// public-account fixture. The normal TF wallet fixture keeps this enabled + /// so it matches [`test_fixtures::TestContext`] initialization semantics. + #[must_use] + pub const fn without_private_account_initialization(mut self) -> Self { + self.initialize_private_accounts = false; + self + } +} + +#[async_trait] +impl AppDeployment for WalletApp { + type Handle = LezRuntime; + + async fn deploy(self, _ctx: &mut DeployContext) -> Result { + let Self { + sequencer_addr, + public_accounts, + private_accounts, + state_dir: configured_state_dir, + initialize_private_accounts: initialize_private_account_funding, + } = self; + // WalletCore initialization currently exposes a non-general borrowed + // lifetime in its async API. Keep that implementation detail inside a + // dedicated blocking thread/runtime; scenario operations use the + // actor below and never create nested runtimes. + let (wallet, state_dir, password) = tokio::task::spawn_blocking( + move || -> anyhow::Result<(WalletCore, Option, String)> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to create LEZ wallet setup runtime")?; + runtime.block_on(async move { + let (wallet, initialized_state_dir, password) = match configured_state_dir { + Some(setup_home) => crate::setup::setup_wallet_at( + std::slice::from_ref(&sequencer_addr), + &public_accounts, + &private_accounts, + WalletConfigOverrides::default(), + &setup_home, + ) + .await + .context("failed to set up LEZ wallet") + .map(|(wallet, _, password)| (wallet, None, password)), + None => setup_wallet( + std::slice::from_ref(&sequencer_addr), + &public_accounts, + &private_accounts, + WalletConfigOverrides::default(), + ) + .await + .context("failed to set up LEZ wallet") + .map(|(wallet, wallet_state_dir, password)| { + (wallet, Some(wallet_state_dir), password) + }), + }?; + let mut wallet = wallet; + setup_public_accounts_with_initial_supply(&mut wallet, &public_accounts) + .await + .context("failed to initialize LEZ public wallet accounts")?; + if initialize_private_account_funding { + for private_account in &private_accounts { + setup_private_accounts_with_initial_supply( + &mut wallet, + std::slice::from_ref(private_account), + ) + .await + .context("failed to initialize LEZ private wallet account")?; + wallet + .sync_to_latest_block() + .await + .context("failed to synchronize LEZ private wallet accounts")?; + } + } + Ok((wallet, initialized_state_dir, password)) + }) + }, + ) + .await + .context("LEZ wallet setup task failed")??; + + LezRuntime::new(wallet, state_dir, password) + } +} diff --git a/integration_tests/src/testing_framework/mod.rs b/integration_tests/src/testing_framework/mod.rs new file mode 100644 index 000000000..498fd5452 --- /dev/null +++ b/integration_tests/src/testing_framework/mod.rs @@ -0,0 +1,21 @@ +//! Testing-framework deployments and Cucumber support for LEZ tests. + +/// Independently deployable LEZ applications. +pub use apps::{ + BedrockApp, BedrockCluster, IndexerApp, LezIndexerClient, LezRuntime, LezSequencerClient, + LezSequencerRegistryApp, LezSequencerRegistryClient, SequencerApp, WalletApp, +}; +/// Complete-stack deployment helpers. +pub use stack::{LezLocalApp, LezStackHandle, shutdown_lez_deployment}; +/// Indexer convergence polling helpers and errors. +pub use wait::{ + IndexerCatchUpError, wait_for_indexer_to_catch_up, wait_for_indexer_to_catch_up_with_timeout, + wait_for_indexer_to_index_transactions_with_timeout, wait_for_indexer_to_reach_with_timeout, +}; + +mod apps; +mod stack; +/// Indexer convergence polling implementation. +pub mod wait; + +pub const TARGET: &str = "testing_framework"; diff --git a/integration_tests/src/testing_framework/stack.rs b/integration_tests/src/testing_framework/stack.rs new file mode 100644 index 000000000..e024208c8 --- /dev/null +++ b/integration_tests/src/testing_framework/stack.rs @@ -0,0 +1,242 @@ +use std::{path::PathBuf, time::Duration}; + +use async_trait::async_trait; +use testing_framework_app::{AppDeployment, AppHostEnv, DeployContext}; +use testing_framework_core::scenario::DynError; +use tokio::time::{Instant, sleep}; + +use super::{BedrockApp, BedrockCluster, IndexerApp, SequencerApp, WalletApp}; +use crate::config::SequencerPartialConfig; + +/// Complete process-based LEZ stack: Bedrock, indexer, sequencer, and wallet. +#[derive(Clone)] +pub struct LezLocalApp { + bedrock: BedrockApp, + sequencer: SequencerPartialConfig, + scenario_base_dir: Option, + initialize_private_accounts: bool, +} + +impl Default for LezLocalApp { + fn default() -> Self { + Self::new() + } +} + +/// Capability for the complete deployed LEZ stack. +/// +/// The same component handles are also exposed individually through the +/// deployment registry so teardown can stop them in dependency order. +#[derive(Clone)] +pub struct LezStackHandle { + bedrock: BedrockCluster, + indexer: super::LezIndexerClient, + sequencer: super::LezSequencerClient, + wallet: super::LezRuntime, +} + +#[cfg_attr( + not(feature = "cucumber"), + expect( + dead_code, + reason = "Cucumber consumes the component accessors when its module is enabled" + ) +)] +impl LezStackHandle { + pub(crate) const fn new( + bedrock: BedrockCluster, + indexer: super::LezIndexerClient, + sequencer: super::LezSequencerClient, + wallet: super::LezRuntime, + ) -> Self { + Self { + bedrock, + indexer, + sequencer, + wallet, + } + } + + pub(crate) const fn bedrock(&self) -> &BedrockCluster { + &self.bedrock + } + + pub(crate) const fn indexer(&self) -> &super::LezIndexerClient { + &self.indexer + } + + pub(crate) const fn sequencer(&self) -> &super::LezSequencerClient { + &self.sequencer + } + + pub(crate) const fn wallet(&self) -> &super::LezRuntime { + &self.wallet + } +} + +impl LezLocalApp { + /// Creates a complete LEZ deployment with default configuration. + #[must_use] + pub fn new() -> Self { + Self { + bedrock: BedrockApp::default(), + sequencer: SequencerPartialConfig::default(), + scenario_base_dir: None, + initialize_private_accounts: true, + } + } + + /// Replaces the sequencer configuration used by the stack. + #[must_use] + pub const fn with_sequencer_config(mut self, sequencer: SequencerPartialConfig) -> Self { + self.sequencer = sequencer; + self + } + + /// Sets the priority fee used by the LEZ sequencer when publishing to Bedrock. + #[must_use] + pub const fn with_priority_fee(mut self, priority_fee: u64) -> Self { + self.sequencer.priority_fee = priority_fee; + self + } + + /// Sets the number of Bedrock nodes in the stack. + #[must_use] + pub fn with_bedrock_nodes(mut self, nodes: usize) -> Self { + self.bedrock = self.bedrock.with_nodes(nodes); + self + } + + /// Replaces the stack's Bedrock deployment, including any Logos builder + /// overrides supplied by the caller. + #[must_use] + pub fn with_bedrock(mut self, bedrock: BedrockApp) -> Self { + self.bedrock = bedrock; + self + } + + /// Places every LEZ component below a unique scenario artifact tree. + #[must_use] + pub fn with_scenario_base_dir(mut self, dir: impl Into) -> Self { + self.scenario_base_dir = Some(dir.into()); + self + } + + /// Skip privacy-preserving account funding for a smoke fixture that only + /// exercises the configured public account and indexer lifecycle. + #[must_use] + pub const fn without_private_account_initialization(mut self) -> Self { + self.initialize_private_accounts = false; + self + } +} + +#[async_trait] +impl AppDeployment for LezLocalApp { + type Handle = LezStackHandle; + + async fn deploy(self, ctx: &mut DeployContext) -> Result { + let scenario_base_dir = self.scenario_base_dir; + let bedrock_app = scenario_base_dir.as_ref().map_or_else( + || self.bedrock.clone(), + |dir| { + self.bedrock + .clone() + .with_scenario_base_dir(dir.join("node")) + }, + ); + let bedrock = ctx.deploy_and_expose(bedrock_app).await?; + wait_for_bedrock_ready(&bedrock).await?; + + let indexer = IndexerApp::new(bedrock.primary_api_addr()); + let indexer = scenario_base_dir.as_ref().map_or_else( + || indexer.clone(), + |dir| indexer.clone().with_state_dir(dir.join("lez/indexer")), + ); + let indexer = ctx.deploy_and_expose(indexer).await?; + + let sequencer = SequencerApp::new(self.sequencer, bedrock.primary_api_addr()); + let sequencer = scenario_base_dir.as_ref().map_or_else( + || sequencer.clone(), + |dir| sequencer.clone().with_state_dir(dir.join("lez/sequencer")), + ); + let sequencer = ctx.deploy_and_expose(sequencer).await?; + + let wallet = WalletApp::from_sequencer(&sequencer); + let wallet = if self.initialize_private_accounts { + wallet + } else { + wallet.without_private_account_initialization() + }; + let wallet = scenario_base_dir.as_ref().map_or_else( + || wallet.clone(), + |dir| wallet.clone().with_state_dir(dir.join("lez/wallet")), + ); + let wallet = ctx.deploy_and_expose(wallet).await?; + + Ok(LezStackHandle::new(bedrock, indexer, sequencer, wallet)) + } +} + +async fn wait_for_bedrock_ready(bedrock: &BedrockCluster) -> Result<(), DynError> { + const TIMEOUT: Duration = Duration::from_secs(60); + let started = Instant::now(); + let mut last_error = None; + + while started.elapsed() < TIMEOUT { + match bedrock.cryptarchia_info().await { + Ok(()) => return Ok(()), + Err(error) => { + last_error = Some(error.to_string()); + sleep(Duration::from_secs(1)).await; + } + } + } + + Err(anyhow::anyhow!( + "Bedrock did not become ready after {TIMEOUT:?}: {}", + last_error.unwrap_or_else(|| "no readiness response".to_owned()) + ) + .into()) +} + +/// Stops all exposed LEZ services in dependency order. +/// +/// The deployment registry remains owned by the caller and must be dropped +/// after this function returns so Bedrock and its managed state can be +/// released only after the wallet, indexer, and sequencer have stopped. +pub async fn shutdown_lez_deployment( + deployment: &DeployContext, +) -> Result<(), DynError> { + let mut failures = Vec::new(); + + if let Some(wallet) = deployment.get::() + && let Err(error) = wallet.shutdown().await + { + failures.push(format!("wallet: {error}")); + } + + if let Some(indexer) = deployment.get::() + && let Err(error) = indexer.shutdown().await + { + failures.push(format!("indexer: {error}")); + } + + if let Some(sequencer) = deployment.get::() + && let Err(error) = sequencer.shutdown().await + { + failures.push(format!("sequencer: {error}")); + } + + if let Some(registry) = deployment.get::() + && let Err(error) = registry.shutdown().await + { + failures.push(format!("sequencer registry: {error}")); + } + + if failures.is_empty() { + Ok(()) + } else { + Err(anyhow::anyhow!("LEZ runtime shutdown failed:\n- {}", failures.join("\n- ")).into()) + } +} diff --git a/integration_tests/src/testing_framework/wait.rs b/integration_tests/src/testing_framework/wait.rs new file mode 100644 index 000000000..84f11be5b --- /dev/null +++ b/integration_tests/src/testing_framework/wait.rs @@ -0,0 +1,149 @@ +use std::time::Duration; + +use common::HashType; +use indexer_service_protocol::HashType as IndexerHashType; +use indexer_service_rpc::RpcClient as _; +use sequencer_service_rpc::RpcClient as _; +use thiserror::Error; + +use super::{LezIndexerClient, LezSequencerClient}; + +/// Failure modes while waiting for the indexer to reach the sequencer. +#[derive(Debug, Error)] +pub enum IndexerCatchUpError { + /// The sequencer height could not be queried. + #[error("failed to query sequencer while waiting for indexer: {message}")] + SequencerQuery { + /// Diagnostic returned by the sequencer RPC client. + message: String, + }, + /// The indexer height could not be queried. + #[error("failed to query indexer while waiting for catch-up: {message}")] + IndexerQuery { + /// Diagnostic returned by the indexer RPC client. + message: String, + }, + /// The indexer did not reach the target within the configured duration. + #[error( + "indexer failed to catch up to sequencer block {target}; last observed indexer block {last_observed} after {elapsed:?}" + )] + Timeout { + /// Sequencer block height the indexer was expected to reach. + target: u64, + /// Highest indexer block height observed while polling. + last_observed: u64, + /// Duration spent polling before timing out. + elapsed: Duration, + }, +} + +/// Polls the indexer until it reaches the sequencer's current last block. +pub async fn wait_for_indexer_to_catch_up( + indexer: &LezIndexerClient, + sequencer: &LezSequencerClient, +) -> Result { + wait_for_indexer_to_catch_up_with_timeout(indexer, sequencer, Duration::from_secs(360)).await +} + +/// Polls the indexer until it reaches the sequencer's current last block or the timeout elapses. +pub async fn wait_for_indexer_to_catch_up_with_timeout( + indexer: &LezIndexerClient, + sequencer: &LezSequencerClient, + timeout: Duration, +) -> Result { + let block_id_to_catch_up = sequencer + .client() + .get_last_block_id() + .await + .map_err(|error| IndexerCatchUpError::SequencerQuery { + message: error.to_string(), + })?; + wait_for_indexer_to_reach_with_timeout(indexer, block_id_to_catch_up, timeout).await +} + +/// Polls the indexer until it reaches a specific finalized block or the timeout elapses. +pub async fn wait_for_indexer_to_reach_with_timeout( + indexer: &LezIndexerClient, + block_id_to_reach: u64, + timeout: Duration, +) -> Result { + let mut last_indexer_block = 0; + + let poll = async { + loop { + let indexer_block = indexer + .client() + .get_last_finalized_block_id() + .await + .map_err(|error| error.to_string())? + .unwrap_or(0); + last_indexer_block = indexer_block; + + if indexer_block >= block_id_to_reach && indexer_block > 0 { + return Ok::(indexer_block); + } + + tokio::time::sleep(Duration::from_secs(2)).await; + } + }; + + match tokio::time::timeout(timeout, poll).await { + Ok(Ok(height)) => Ok(height), + Ok(Err(error)) => Err(IndexerCatchUpError::IndexerQuery { message: error }), + Err(_elapsed) => Err(IndexerCatchUpError::Timeout { + target: block_id_to_reach, + last_observed: last_indexer_block, + elapsed: timeout, + }), + } +} + +/// Polls the indexer until all supplied transactions are present in its +/// finalized transaction store or the timeout elapses. +pub async fn wait_for_indexer_to_index_transactions_with_timeout( + indexer: &LezIndexerClient, + transaction_hashes: &[HashType], + target_block: u64, + timeout: Duration, +) -> Result { + let mut last_indexer_block = 0; + + let poll = async { + loop { + last_indexer_block = indexer + .client() + .get_last_finalized_block_id() + .await + .map_err(|error| error.to_string())? + .unwrap_or(0); + + let mut all_indexed = true; + for hash in transaction_hashes { + let indexed = indexer_service_rpc::RpcClient::get_transaction( + &**indexer.client(), + IndexerHashType(hash.0), + ) + .await + .map_err(|error| error.to_string())? + .is_some(); + all_indexed &= indexed; + } + + if all_indexed && last_indexer_block >= target_block { + return Ok::(last_indexer_block); + } + + tokio::time::sleep(Duration::from_secs(2)).await; + } + }; + + match tokio::time::timeout(timeout, poll).await { + Ok(Ok(height)) => Ok(height), + Ok(Err(error)) => Err(IndexerCatchUpError::IndexerQuery { message: error }), + Err(_elapsed) => Err(IndexerCatchUpError::Timeout { + target: target_block, + last_observed: last_indexer_block, + elapsed: timeout, + }), + } +} diff --git a/integration_tests/src/utils.rs b/integration_tests/src/utils.rs new file mode 100644 index 000000000..1f3bed9ff --- /dev/null +++ b/integration_tests/src/utils.rs @@ -0,0 +1,254 @@ +use std::time::Duration; + +use anyhow::Context as _; +use key_protocol::key_management::key_tree::chain_index::ChainIndex; +use lee_core::account::AccountId; +use log::info; +use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, verify_commitment_is_in_state}; +use wallet::{ + AccountIdentity, + cli::{ + CliAccountMention, Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::{ + native_token_transfer::AuthTransferSubcommand, token::TokenProgramAgnosticSubcommand, + }, + }, + program_facades::{native_token_transfer::NativeTokenTransfer, token::Token}, + storage::key_chain::FoundPrivateAccount, +}; + +/// Maximum time to wait for the indexer to catch up to the sequencer. +pub const L2_TO_L1_TIMEOUT: Duration = Duration::from_mins(6); + +/// Create a private or public account at the given chain index and return its ID. +/// Pass `cci: None` to use the wallet's next available chain index. +pub async fn new_account( + ctx: &mut TestContext, + private: bool, + cci: Option, +) -> anyhow::Result { + let subcommand = if private { + NewSubcommand::Private { cci, label: None } + } else { + NewSubcommand::Public { cci, label: None } + }; + let result = wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(subcommand)), + ) + .await?; + let SubcommandReturnValue::RegisterAccount { account_id } = result else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + Ok(account_id) +} + +/// Send `amount` from `from` to `to` via an authenticated transfer (identifier 0). +pub async fn send( + ctx: &mut TestContext, + from: CliAccountMention, + to: CliAccountMention, + amount: u128, +) -> anyhow::Result<()> { + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { + from, + to: Some(to), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount, + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + Ok(()) +} + +/// Like [`send`], but for a `to` that is still a fresh, unclaimed account. +/// +/// The wallet CLI's `AuthTransfer::Send` never signs with the recipient's key (by design: the +/// sender's wallet must not sign on behalf of an account it doesn't own). But claiming a fresh +/// account is only possible if that account's own key signs the transaction, so this bypasses +/// the CLI and calls the program facade directly with an explicit `AccountIdentity::Public` for +/// the recipient, using the key the test wallet holds for the account it just created. +/// +/// Unlike `send`, this doesn't go through the CLI's own poll-until-included step, so it waits +/// for block creation itself before returning. +pub async fn send_claiming_new_account( + ctx: &mut TestContext, + from: AccountId, + to: AccountId, + amount: u128, +) -> anyhow::Result<()> { + NativeTokenTransfer(ctx.wallet()) + .send_public_transfer( + AccountIdentity::Public(from), + AccountIdentity::Public(to), + amount, + ) + .await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + +/// Create a token (New) and wait for the block to be included. +pub async fn create_token( + ctx: &mut TestContext, + definition_account_id: CliAccountMention, + supply_account_id: CliAccountMention, + name: impl Into, + total_supply: u128, +) -> anyhow::Result<()> { + let subcommand = TokenProgramAgnosticSubcommand::New { + definition_account_id, + supply_account_id, + name: name.into(), + total_supply, + }; + wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + +/// Send tokens and wait for the block to be included. +pub async fn token_send( + ctx: &mut TestContext, + from: CliAccountMention, + to: CliAccountMention, + amount: u128, +) -> anyhow::Result<()> { + let subcommand = TokenProgramAgnosticSubcommand::Send { + from, + to: Some(to), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount, + }; + wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + +/// Like [`token_send`], but for a `to` that is still a fresh, unclaimed holding account. See +/// [`send_claiming_new_account`] for why the CLI can't be used here. +pub async fn token_send_claiming_new_account( + ctx: &mut TestContext, + from: AccountId, + to: AccountId, + amount: u128, +) -> anyhow::Result<()> { + Token(ctx.wallet()) + .send_transfer_transaction( + AccountIdentity::Public(from), + AccountIdentity::Public(to), + amount, + ) + .await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + +/// Retrieve the native token balance for `account_id`. +pub async fn account_balance(ctx: &TestContext, account_id: AccountId) -> anyhow::Result { + Ok(ctx + .sequencer_client() + .get_account_balance(account_id) + .await?) +} + +/// Fetch the full account state for `account_id` from the sequencer. +pub async fn get_account(ctx: &TestContext, account_id: AccountId) -> anyhow::Result { + Ok(ctx.sequencer_client().get_account(account_id).await?) +} + +/// Fetch the current commitment for `account_id` and assert it is present in the sequencer state. +pub async fn assert_private_commitment_in_state( + ctx: &TestContext, + account_id: AccountId, + label: &str, +) -> anyhow::Result<()> { + let commitment = ctx + .wallet() + .get_private_account_commitment(account_id) + .with_context(|| format!("Failed to get commitment for {label}"))?; + assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); + Ok(()) +} + +/// Sync the wallet's private accounts. +pub async fn sync_private(ctx: &mut TestContext) -> anyhow::Result<()> { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::SyncPrivate {}), + ) + .await?; + Ok(()) +} + +/// Look up a restored private account for `account_id`, panicking with `label` if absent. +#[must_use] +pub fn restored_private_account<'ctx>( + ctx: &'ctx TestContext, + account_id: AccountId, + label: &str, +) -> FoundPrivateAccount<'ctx> { + ctx.wallet() + .storage() + .key_chain() + .private_account(account_id) + .unwrap_or_else(|| panic!("{label} should be restored")) +} + +/// Assert that a restored public account's signing key exists, panicking with `label` if absent. +pub fn assert_public_account_restored(ctx: &TestContext, account_id: AccountId, label: &str) { + ctx.wallet() + .storage() + .key_chain() + .pub_account_signing_key(account_id) + .unwrap_or_else(|| panic!("{label} should be restored")); +} + +/// Poll the indexer until its last finalized block id reaches the sequencer's +/// current last block id or until [`L2_TO_L1_TIMEOUT`] elapses. +/// Returns the last indexer block id observed. +pub async fn wait_for_indexer_to_catch_up(ctx: &TestContext) -> anyhow::Result { + use indexer_service_rpc::RpcClient as _; + + let block_id_to_catch_up = + sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()).await?; + let mut last_ind: u64 = 1; + let inner = async { + loop { + let ind = ctx + .indexer_client() + .get_last_finalized_block_id() + .await? + .unwrap_or(0); + last_ind = ind; + if ind >= block_id_to_catch_up { + let last_seq = + sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()) + .await?; + info!( + "Indexer caught up. Indexer last block id: {ind}. Current sequencer last block id: {last_seq}" + ); + return Ok(ind); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }; + tokio::time::timeout(L2_TO_L1_TIMEOUT, inner) + .await + .with_context(|| { + format!( + "Indexer failed to catch up within {L2_TO_L1_TIMEOUT:?}. Last indexer block id observed: {last_ind}, but needed to catch up to at least {block_id_to_catch_up}" + ) + })? +} diff --git a/integration_tests/tests/account.rs b/integration_tests/tests/account.rs index edfdda01f..298e5029a 100644 --- a/integration_tests/tests/account.rs +++ b/integration_tests/tests/account.rs @@ -4,7 +4,10 @@ )] use anyhow::{Context as _, Result}; -use integration_tests::{TestContext, get_account, new_account, private_mention}; +use integration_tests::{ + TestContext, private_mention, + utils::{get_account, new_account}, +}; use key_protocol::key_management::KeyChain; use lee::Data; use lee_core::account::Nonce; diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 3fa15e3d6..ca7ced2ab 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -3,9 +3,13 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, - assert_private_commitment_in_state, fetch_privacy_preserving_tx, get_account, new_account, - private_mention, public_mention, send, sync_private, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention, + public_mention, + utils::{ + account_balance, assert_private_commitment_in_state, get_account, new_account, send, + sync_private, + }, + verify_commitment_is_in_state, }; use lee::{ AccountId, execute_and_prove, privacy_preserving_transaction::circuit::ProgramWithDependencies, diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index 1a48b4a0e..d9d28c7d1 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -3,8 +3,8 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, new_account, - public_mention, send, send_claiming_new_account, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention, + utils::{account_balance, get_account, new_account, send, send_claiming_new_account}, }; use lee::{PublicKey, public_transaction}; use sequencer_service_rpc::RpcClient as _; diff --git a/integration_tests/tests/block_size_limit.rs b/integration_tests/tests/block_size_limit.rs index 237a5e279..b8fab7daa 100644 --- a/integration_tests/tests/block_size_limit.rs +++ b/integration_tests/tests/block_size_limit.rs @@ -27,6 +27,7 @@ async fn reject_oversized_transaction() -> Result<()> { max_block_size: ByteSize::mib(1), mempool_max_size: 1000, block_create_timeout: Duration::from_secs(10), + priority_fee: sequencer_core::config::default_priority_fee(), }), ) .build() @@ -73,6 +74,7 @@ async fn accept_transaction_within_limit() -> Result<()> { max_block_size: ByteSize::mib(1), mempool_max_size: 1000, block_create_timeout: Duration::from_secs(10), + priority_fee: sequencer_core::config::default_priority_fee(), }), ) .build() @@ -117,6 +119,7 @@ async fn transaction_deferred_to_next_block_when_current_full() -> Result<()> { max_block_size: block_size, mempool_max_size: 1000, block_create_timeout: Duration::from_secs(10), + priority_fee: sequencer_core::config::default_priority_fee(), }), ) .build() diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 84c86daa4..6fc48c6a8 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -8,7 +8,8 @@ use std::time::Duration; use anyhow::Context as _; use common::transaction::LeeTransaction; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, + utils::{account_balance, get_account}, }; use lee::{ execute_and_prove, privacy_preserving_transaction, program::Program, public_transaction, @@ -16,7 +17,6 @@ use lee::{ use lee_core::{InputAccountIdentity, account::AccountWithMetadata}; use sequencer_service_rpc::RpcClient as _; use tokio::test; - // const TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK: Duration = Duration::from_mins(2); #[test] diff --git a/integration_tests/tests/indexer_ffi_helpers/mod.rs b/integration_tests/tests/indexer_ffi_helpers/mod.rs index bd8d8e57e..1bbb7f7b5 100644 --- a/integration_tests/tests/indexer_ffi_helpers/mod.rs +++ b/integration_tests/tests/indexer_ffi_helpers/mod.rs @@ -17,7 +17,7 @@ use indexer_ffi::{ types::{FfiAccountId, FfiOption, FfiVec, account::FfiAccount, block::FfiBlock}, }, }; -use integration_tests::BlockingTestContext; +use integration_tests::{BlockingTestContext, L2_TO_L1_TIMEOUT}; use tempfile::TempDir; use test_fixtures::{ MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, @@ -114,10 +114,10 @@ pub fn wait_for_indexer_ffi_block(indexer: &IndexerServiceFFI, min_block_id: u64 if res.error.is_ok() && res.is_some && res.block_id >= min_block_id { return Ok(res.block_id); } - if start.elapsed() >= integration_tests::L2_TO_L1_TIMEOUT { + if start.elapsed() >= L2_TO_L1_TIMEOUT { anyhow::bail!( "Indexer FFI did not reach block {min_block_id} within {:?}. Last observed block id: {}", - integration_tests::L2_TO_L1_TIMEOUT, + L2_TO_L1_TIMEOUT, res.block_id ); } diff --git a/integration_tests/tests/indexer_ffi_state_consistency.rs b/integration_tests/tests/indexer_ffi_state_consistency.rs index 593b1ea76..2e6a3fa9e 100644 --- a/integration_tests/tests/indexer_ffi_state_consistency.rs +++ b/integration_tests/tests/indexer_ffi_state_consistency.rs @@ -10,7 +10,7 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use indexer_service_protocol::Account; use integration_tests::{ - L2_TO_L1_TIMEOUT, TIME_TO_WAIT_FOR_BLOCK_SECONDS, private_mention, public_mention, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, private_mention, public_mention, utils::L2_TO_L1_TIMEOUT, verify_commitment_is_in_state, }; use lee::AccountId; diff --git a/integration_tests/tests/indexer_state_consistency.rs b/integration_tests/tests/indexer_state_consistency.rs index bdd3e816c..e074956a8 100644 --- a/integration_tests/tests/indexer_state_consistency.rs +++ b/integration_tests/tests/indexer_state_consistency.rs @@ -8,9 +8,11 @@ use std::time::Duration; use anyhow::Result; use indexer_service_rpc::RpcClient as _; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, - assert_private_commitment_in_state, get_account, private_mention, public_mention, send, - wait_for_indexer_to_catch_up, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, + utils::{ + account_balance, assert_private_commitment_in_state, get_account, send, + wait_for_indexer_to_catch_up, + }, }; use lee::AccountId; diff --git a/integration_tests/tests/indexer_state_consistency_with_labels.rs b/integration_tests/tests/indexer_state_consistency_with_labels.rs index d8af09342..5c5298060 100644 --- a/integration_tests/tests/indexer_state_consistency_with_labels.rs +++ b/integration_tests/tests/indexer_state_consistency_with_labels.rs @@ -9,8 +9,8 @@ use std::time::Duration; use anyhow::Result; use indexer_service_rpc::RpcClient as _; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, public_mention, - send, wait_for_indexer_to_catch_up, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention, + utils::{account_balance, get_account, send, wait_for_indexer_to_catch_up}, }; use wallet::{ account::Label, diff --git a/integration_tests/tests/keys.rs b/integration_tests/tests/keys.rs index 10b6845c1..4a3a63518 100644 --- a/integration_tests/tests/keys.rs +++ b/integration_tests/tests/keys.rs @@ -8,9 +8,13 @@ use std::{str::FromStr as _, time::Duration}; use anyhow::{Context as _, Result}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, assert_public_account_restored, - fetch_privacy_preserving_tx, new_account, private_mention, public_mention, - restored_private_account, send, send_claiming_new_account, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention, + public_mention, + utils::{ + assert_public_account_restored, new_account, restored_private_account, send, + send_claiming_new_account, + }, + verify_commitment_is_in_state, }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use lee::AccountId; diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs index 8f5bbf474..a78c4f085 100644 --- a/integration_tests/tests/private_pda.rs +++ b/integration_tests/tests/private_pda.rs @@ -9,7 +9,7 @@ use anyhow::{Context as _, Result}; use authenticated_transfer_core::Instruction as AuthTransferInstruction; use common::transaction::LeeTransaction; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, sync_private, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, utils::sync_private, verify_commitment_is_in_state, }; use lee::{ AccountId, PrivacyPreservingTransaction, ProgramId, diff --git a/integration_tests/tests/private_transaction_padding.rs b/integration_tests/tests/private_transaction_padding.rs index 925d058d8..cdb85ea5c 100644 --- a/integration_tests/tests/private_transaction_padding.rs +++ b/integration_tests/tests/private_transaction_padding.rs @@ -4,7 +4,9 @@ )] use anyhow::Result; -use integration_tests::{TestContext, fetch_privacy_preserving_tx, new_account, private_mention}; +use integration_tests::{ + TestContext, fetch_privacy_preserving_tx, private_mention, utils::new_account, +}; use tokio::test; use wallet::cli::{ Command, SubcommandReturnValue, programs::native_token_transfer::AuthTransferSubcommand, diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index 83615540d..ac7c3429a 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -19,7 +19,8 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, sync_private, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, + utils::sync_private, }; use tokio::test; use wallet::{ diff --git a/integration_tests/tests/tf_app_deployments.rs b/integration_tests/tests/tf_app_deployments.rs new file mode 100644 index 000000000..55755ccb3 --- /dev/null +++ b/integration_tests/tests/tf_app_deployments.rs @@ -0,0 +1,199 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +use anyhow::Result; +use integration_tests::{ + config::{ + SequencerPartialConfig, default_private_accounts_for_wallet, + default_public_accounts_for_wallet, + }, + testing_framework::{ + BedrockApp, BedrockCluster, IndexerApp, LezIndexerClient, LezLocalApp, LezRuntime, + LezSequencerClient, SequencerApp, WalletApp, shutdown_lez_deployment, + wait_for_indexer_to_catch_up, + }, +}; +use sequencer_service_rpc::RpcClient as _; +use tempfile::tempdir; +use testing_framework_app::{AppHostEnv, AppHostTopology, DeployContext}; +use testing_framework_core::scenario::NodeClients; + +#[tokio::test] +async fn complete_lez_stack_can_be_deployed_as_one_app() -> Result<()> { + let mut deployment = new_deployment(); + + deployment + .deploy(LezLocalApp::new().with_bedrock_nodes(2)) + .await + .map_err(anyhow::Error::from_boxed)?; + + assert_lez_stack_works(&deployment).await?; + shutdown_lez_deployment(&deployment) + .await + .map_err(anyhow::Error::from_boxed)?; + + Ok(()) +} + +#[tokio::test] +async fn lez_apps_can_be_deployed_individually() -> Result<()> { + let mut deployment = new_deployment(); + + let bedrock = deployment + .deploy_and_expose(BedrockApp::nodes( + 2, + "lez_apps_can_be_deployed_individually".to_owned(), + )) + .await + .map_err(anyhow::Error::from_boxed)?; + + deployment + .deploy_and_expose(IndexerApp::new(bedrock.primary_api_addr())) + .await + .map_err(anyhow::Error::from_boxed)?; + + let sequencer = deployment + .deploy_and_expose(SequencerApp::new( + SequencerPartialConfig::default(), + bedrock.primary_api_addr(), + )) + .await + .map_err(anyhow::Error::from_boxed)?; + + deployment + .deploy_and_expose(WalletApp::from_sequencer(&sequencer)) + .await + .map_err(anyhow::Error::from_boxed)?; + + assert_lez_stack_works(&deployment).await?; + shutdown_lez_deployment(&deployment) + .await + .map_err(anyhow::Error::from_boxed)?; + + Ok(()) +} + +#[tokio::test] +async fn lez_services_can_be_redeployed_after_explicit_shutdown() -> Result<()> { + let state_dir = tempdir()?; + let mut bedrock_deployment = new_deployment(); + let bedrock = bedrock_deployment + .deploy_and_expose(BedrockApp::nodes( + 2, + "lez_services_can_be_redeployed_after_explicit_shutdown".to_owned(), + )) + .await + .map_err(anyhow::Error::from_boxed)?; + + for _ in 0..2 { + let mut deployment = new_deployment(); + deployment.expose(bedrock.clone())?; + let bedrock_addr = bedrock.primary_api_addr(); + deployment + .deploy_and_expose( + IndexerApp::new(bedrock_addr).with_state_dir(state_dir.path().join("lez/indexer")), + ) + .await + .map_err(anyhow::Error::from_boxed)?; + let sequencer = deployment + .deploy_and_expose( + SequencerApp::new(SequencerPartialConfig::default(), bedrock_addr) + .with_state_dir(state_dir.path().join("lez/sequencer")), + ) + .await + .map_err(anyhow::Error::from_boxed)?; + + bedrock + .cryptarchia_info() + .await + .map_err(anyhow::Error::from_boxed)?; + sequencer + .client() + .check_health() + .await + .map_err(anyhow::Error::new)?; + let indexer = deployment.require::()?; + wait_for_indexer_to_catch_up(&indexer, &sequencer).await?; + shutdown_lez_deployment(&deployment) + .await + .map_err(anyhow::Error::from_boxed)?; + } + + Ok(()) +} + +fn new_deployment() -> DeployContext { + DeployContext::new(AppHostTopology, NodeClients::default()) +} + +async fn assert_lez_stack_works(deployment: &DeployContext) -> Result<()> { + let bedrock = deployment.require::()?; + let indexer = deployment.require::()?; + let sequencer = deployment.require::()?; + let wallet = deployment.require::()?; + + bedrock + .cryptarchia_info() + .await + .map_err(anyhow::Error::from_boxed)?; + sequencer + .client() + .check_health() + .await + .map_err(anyhow::Error::new)?; + + let public_accounts = wallet + .existing_public_accounts() + .await + .map_err(anyhow::Error::from_boxed)?; + let expected_public_accounts = default_public_accounts_for_wallet() + .into_iter() + .map(|(private_key, balance)| { + ( + lee::AccountId::from(&lee::PublicKey::new_from_private_key(&private_key)), + balance, + ) + }) + .collect::>(); + for (account, _) in &expected_public_accounts { + assert!(public_accounts.contains(account)); + } + + let private_accounts = wallet + .existing_private_accounts() + .await + .map_err(anyhow::Error::from_boxed)?; + let expected_private_accounts = default_private_accounts_for_wallet(); + for account in &expected_private_accounts { + assert!(private_accounts.contains(&account.account_id())); + } + + let (wallet_account, expected_balance) = expected_public_accounts + .first() + .copied() + .expect("default public accounts should not be empty"); + assert_eq!( + sequencer + .client() + .get_account_balance(wallet_account) + .await?, + expected_balance + ); + + let private_account = expected_private_accounts + .first() + .expect("default private accounts should not be empty"); + assert_eq!( + wallet + .private_account_balance(private_account.account_id()) + .await + .map_err(anyhow::Error::from_boxed)?, + Some(private_account.balance) + ); + + wait_for_indexer_to_catch_up(&indexer, &sequencer).await?; + + Ok(()) +} diff --git a/integration_tests/tests/tf_bedrock_builder.rs b/integration_tests/tests/tf_bedrock_builder.rs new file mode 100644 index 000000000..817aa1277 --- /dev/null +++ b/integration_tests/tests/tf_bedrock_builder.rs @@ -0,0 +1,30 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +use anyhow::Result; +use integration_tests::testing_framework::BedrockApp; +use logos_blockchain_testing_framework::{DeploymentBuilder, TopologyConfig}; +use testing_framework_app::{AppHostEnv, AppHostTopology, DeployContext}; +use testing_framework_core::{scenario::NodeClients, topology::DeploymentSeed}; + +#[tokio::test] +async fn bedrock_can_be_deployed_from_a_configured_builder() -> Result<()> { + let mut deployment = DeployContext::::new(AppHostTopology, NodeClients::default()); + let builder = DeploymentBuilder::new(TopologyConfig::with_node_numbers(2)) + .with_deployment_seed(DeploymentSeed::new([0x4c; 32])) + .with_test_context("lez-tf-bedrock-builder"); + + let bedrock = deployment + .deploy_and_expose(BedrockApp::from_builder(builder)) + .await + .map_err(anyhow::Error::from_boxed)?; + + bedrock + .cryptarchia_info() + .await + .map_err(anyhow::Error::from_boxed)?; + + Ok(()) +} diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index ef33425e6..f4f6145cc 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -169,6 +169,7 @@ impl TpsTestManager { max_block_size: ByteSize::mb(500), mempool_max_size: 10_000, block_create_timeout: Duration::from_secs(12), + priority_fee: sequencer_core::config::default_priority_fee(), } } } diff --git a/lez/indexer/service/src/lib.rs b/lez/indexer/service/src/lib.rs index 24c056464..7b8bc5dc4 100644 --- a/lez/indexer/service/src/lib.rs +++ b/lez/indexer/service/src/lib.rs @@ -16,13 +16,19 @@ pub struct IndexerHandle { addr: SocketAddr, /// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`. server_handle: Option, + subscription_shutdown: Option, } impl IndexerHandle { - const fn new(addr: SocketAddr, server_handle: ServerHandle) -> Self { + const fn new( + addr: SocketAddr, + server_handle: ServerHandle, + subscription_shutdown: Option, + ) -> Self { Self { addr, server_handle: Some(server_handle), + subscription_shutdown, } } @@ -41,6 +47,25 @@ impl IndexerHandle { handle.stopped().await; } + /// Stops the ingestion task and RPC server, waiting for both to terminate. + pub async fn shutdown(mut self) -> Result<()> { + let handle = self + .server_handle + .take() + .expect("Indexer server handle is set"); + + let subscription_result = + if let Some(subscription_shutdown) = self.subscription_shutdown.take() { + subscription_shutdown.shutdown().await + } else { + Ok(()) + }; + let stop_result = handle.stop().context("failed to stop Indexer RPC server"); + handle.stopped().await; + subscription_result?; + stop_result + } + #[must_use] pub fn is_healthy(&self) -> bool { self.server_handle @@ -54,6 +79,7 @@ impl Drop for IndexerHandle { let Self { addr: _, server_handle, + subscription_shutdown: _, } = self; let Some(handle) = server_handle else { @@ -87,14 +113,18 @@ pub async fn run_server( log::info!("Starting Indexer Service RPC server on {addr}"); #[cfg(not(feature = "mock-responses"))] - let handle = { + let (handle, subscription_shutdown) = { let service = service::IndexerService::new(config, storage_dir, shutdown.child_token()) .await .context("Failed to initialize indexer service")?; - server.start(service.into_rpc()) + let subscription_shutdown = Some(service.subscription_shutdown()); + (server.start(service.into_rpc()), subscription_shutdown) }; #[cfg(feature = "mock-responses")] - let handle = server.start(mock_service::MockIndexerService::new_with_mock_blocks().into_rpc()); + let (handle, subscription_shutdown) = ( + server.start(mock_service::MockIndexerService::new_with_mock_blocks().into_rpc()), + None, + ); - Ok(IndexerHandle::new(addr, handle)) + Ok(IndexerHandle::new(addr, handle, subscription_shutdown)) } diff --git a/lez/indexer/service/src/service.rs b/lez/indexer/service/src/service.rs index 77373775a..2bafb6ee2 100644 --- a/lez/indexer/service/src/service.rs +++ b/lez/indexer/service/src/service.rs @@ -1,4 +1,8 @@ -use std::{path::Path, pin::pin, sync::Arc}; +use std::{ + path::Path, + pin::pin, + sync::{Arc, Mutex}, +}; use anyhow::{Context as _, Result, bail}; use arc_swap::ArcSwap; @@ -13,7 +17,7 @@ use jsonrpsee::{ types::{ErrorCode, ErrorObject, ErrorObjectOwned}, }; use log::{debug, error, warn}; -use tokio::sync::mpsc::UnboundedSender; +use tokio::{sync::mpsc::UnboundedSender, task::JoinHandle}; use tokio_util::sync::CancellationToken; pub struct IndexerService { @@ -35,6 +39,11 @@ impl IndexerService { indexer, }) } + + #[cfg(not(feature = "mock-responses"))] + pub(crate) fn subscription_shutdown(&self) -> SubscriptionShutdown { + self.subscription_service.shutdown_handle() + } } #[async_trait] @@ -173,7 +182,7 @@ impl indexer_service_rpc::RpcServer for IndexerService { } struct SubscriptionService { - parts: ArcSwap, + parts: Arc>, indexer: IndexerCore, /// Cancellation token that is used to signal the subscription service to shut down. /// @@ -184,15 +193,25 @@ struct SubscriptionService { impl SubscriptionService { pub fn spawn_new(indexer: IndexerCore, shutdown: CancellationToken) -> Self { - let parts = Self::spawn_respond_subscribers_loop(indexer.clone(), shutdown.clone()); + let parts = Arc::new(ArcSwap::new(Arc::new( + Self::spawn_respond_subscribers_loop(indexer.clone(), shutdown.clone()), + ))); Self { - parts: ArcSwap::new(Arc::new(parts)), + parts, indexer, shutdown, } } + #[cfg(not(feature = "mock-responses"))] + fn shutdown_handle(&self) -> SubscriptionShutdown { + SubscriptionShutdown { + parts: Arc::clone(&self.parts), + shutdown: self.shutdown.clone(), + } + } + pub async fn add_subscription(&self, subscription: Subscription) -> Result<()> { let guard = self.parts.load(); if let Err(send_err) = guard.new_subscription_sender.send(subscription) { @@ -201,7 +220,12 @@ impl SubscriptionService { ); // Respawn the subscription service loop if it has finished (either with error or panic) - if guard.handle.is_finished() && !self.shutdown.is_cancelled() { + let loop_finished = guard + .handle + .lock() + .ok() + .is_some_and(|handle| handle.as_ref().is_some_and(JoinHandle::is_finished)); + if loop_finished && !self.shutdown.is_cancelled() { // A halt outside the accept-list would only re-derive the same // verdict, so respawning would churn: verify, halt, die, respawn. if self.halted_outside_accept_list() { @@ -219,15 +243,22 @@ impl SubscriptionService { let old_parts = Arc::into_inner(old_handle_and_sender) .expect("There should be no other references to the old handle and sender"); - match old_parts.handle.await { - Ok(Ok(())) => {} - Ok(Err(err)) => { - error!( - "Subscription service loop has unexpectedly finished with error: {err:#}" - ); - } - Err(err) => { - error!("Subscription service loop has panicked with err: {err:#}"); + if let Some(handle) = old_parts + .handle + .lock() + .ok() + .and_then(|mut handle| handle.take()) + { + match handle.await { + Ok(Ok(())) => {} + Ok(Err(err)) => { + error!( + "Subscription service loop has unexpectedly finished with error: {err:#}" + ); + } + Err(err) => { + error!("Subscription service loop has panicked with err: {err:#}"); + } } } } @@ -314,7 +345,7 @@ impl SubscriptionService { res }); SubscriptionLoopParts { - handle, + handle: Mutex::new(Some(handle)), new_subscription_sender, } } @@ -323,15 +354,49 @@ impl SubscriptionService { impl Drop for SubscriptionService { fn drop(&mut self) { self.shutdown.cancel(); - self.parts.load().handle.abort(); + if let Ok(mut handle) = self.parts.load().handle.lock() + && let Some(handle) = handle.take() + { + handle.abort(); + } } } struct SubscriptionLoopParts { - handle: tokio::task::JoinHandle>, + handle: Mutex>>>, new_subscription_sender: UnboundedSender>, } +#[derive(Clone)] +pub(crate) struct SubscriptionShutdown { + parts: Arc>, + shutdown: CancellationToken, +} + +impl SubscriptionShutdown { + pub(crate) async fn shutdown(&self) -> Result<()> { + self.shutdown.cancel(); + + let handle = self + .parts + .load() + .handle + .lock() + .ok() + .and_then(|mut handle| handle.take()); + + let Some(handle) = handle else { + return Ok(()); + }; + + match handle.await { + Ok(result) => result, + Err(error) if error.is_cancelled() => Ok(()), + Err(error) => Err(error.into()), + } + } +} + struct Subscription { sink: SubscriptionSink, _marker: std::marker::PhantomData, diff --git a/lez/sequencer/actors/executor/src/actor.rs b/lez/sequencer/actors/executor/src/actor.rs index 64ba7663d..af92b1598 100644 --- a/lez/sequencer/actors/executor/src/actor.rs +++ b/lez/sequencer/actors/executor/src/actor.rs @@ -1,3 +1,5 @@ +use std::future::Future; + use common::{block::Block, transaction::LeeTransaction}; use futures::{StreamExt as _, TryFutureExt as _, TryStreamExt as _, future::ready, stream}; use kameo::{ @@ -53,22 +55,31 @@ pub struct ExecutorActor, } -impl ExecutorActor { - pub async fn new(config: SequencerConfig, storage_ref: ActorRef) -> Self { - // TODO: Leave storage_ref as a top-level field only in `ExecutorActor`, - // while moving `SequencerCore` code into this actor. - let (sequencer, mempool_handle) = - SequencerCore::::start_from_config(config, storage_ref.clone()).await; +impl ExecutorActor { + #[expect( + clippy::manual_async_fn, + reason = "Explicit Send future works around rust-lang/rust#100013" + )] + pub fn new( + config: SequencerConfig, + storage_ref: ActorRef, + ) -> impl Future + Send + 'static { + async move { + // TODO: Leave storage_ref as a top-level field only in `ExecutorActor`, + // while moving `SequencerCore` code into this actor. + let (sequencer, mempool_handle) = + SequencerCore::::start_from_config(config, storage_ref.clone()).await; - let driver_cancellation = sequencer.block_publisher().driver_cancellation(); - let background_tasks = sequencer.background_tasks(); + let driver_cancellation = sequencer.block_publisher().driver_cancellation(); + let background_tasks = sequencer.background_tasks(); - Self { - mempool_handle, - sequencer, - storage_ref, - driver_cancellation, - background_tasks, + Self { + mempool_handle, + sequencer, + storage_ref, + driver_cancellation, + background_tasks, + } } } diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index d3b3414fc..c77592c38 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -8,6 +8,7 @@ license = { workspace = true } workspace = true [dependencies] +trait-variant.workspace = true lee.workspace = true lee_core.workspace = true chain_state.workspace = true diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index 80e058716..d2909e09f 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -120,11 +120,8 @@ enum Command { type CommandSender = mpsc::Sender; -#[expect( - async_fn_in_trait, - reason = "Only the methods reached from the executor actor need an explicitly Send future" -)] -pub trait BlockPublisherTrait: Sized + Sync { +#[trait_variant::make(BlockPublisherTrait: Send)] +pub trait LocalBlockPublisherTrait: Sized + Sync { async fn new( config: &BedrockConfig, bedrock_signing_key: Ed25519Key, @@ -142,11 +139,11 @@ pub trait BlockPublisherTrait: Sized + Sync { /// /// The checkpoint must be persisted with the block — restoring an older one /// drops the inscription from the pending set, and it is never resubmitted. - fn publish_block<'blk, 'pbl: 'blk>( - &'pbl self, - block: &'blk Block, + async fn publish_block( + &self, + block: &Block, withdrawals: Vec, - ) -> impl Future> + Send + 'blk; + ) -> Result; /// Create the channel and write `block` into it in one Mantle tx. Only valid /// while the channel does not exist, and `keys[0]` must be this sequencer's @@ -159,16 +156,13 @@ pub trait BlockPublisherTrait: Sized + Sync { /// Live (adopted, possibly not yet finalized) accredited-key snapshot for /// this channel, read directly from the connected Bedrock node. - fn accredited_keys(&self) -> impl Future>> + Send; + async fn accredited_keys(&self) -> Result>; /// Submit a committee `ChannelConfigOp` as its own, independent Mantle /// tx (not bundled with any block publish). `new_keys` is the full /// replacement accredited-keys list; the channel administration /// parameters posted alongside it are the `system_accounts` defaults. - fn submit_channel_config( - &self, - new_keys: Vec, - ) -> impl Future> + Send; + async fn submit_channel_config(&self, new_keys: Vec) -> Result<()>; fn channel_id(&self) -> ChannelId; @@ -190,7 +184,7 @@ pub trait BlockPublisherTrait: Sized + Sync { /// Current channel frontier slot on the connected chain, or `None` if the /// channel does not exist there. Drives the startup frontier check. - fn channel_tip_slot(&self) -> impl Future>> + Send; + async fn channel_tip_slot(&self) -> Result>; /// Finalized channel messages from `after_slot` (exclusive) up to LIB, used /// for the startup consistency check and reconstruction. Pass `None` to read @@ -198,7 +192,7 @@ pub trait BlockPublisherTrait: Sized + Sync { async fn read_channel_after( &self, after_slot: Option, - ) -> Result + '_>; + ) -> Result + Send + '_>; } /// Real block publisher backed by zone-sdk's `ZoneSequencer`. @@ -291,93 +285,93 @@ impl BlockPublisherTrait for ZoneSdkPublisher { )] { tokio::select! { - // Drain external commands by calling the borrowing - // handle — `&mut sequencer` is only available here. - Some(command) = command_rx.recv() => match command { - Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => { - let data_byte_size = data_bounded.len(); - let withdraw_count = withdrawals.len(); - let published = if withdrawals.is_empty() { - sequencer.handle() - .publish(data_bounded) + // Drain external commands by calling the borrowing + // handle — `&mut sequencer` is only available here. + Some(command) = command_rx.recv() => match command { + Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => { + let data_byte_size = data_bounded.len(); + let withdraw_count = withdrawals.len(); + let published = if withdrawals.is_empty() { + sequencer.handle() + .publish(data_bounded) + .await + .context("Failed to publish block") + } else { + sequencer.handle() + .publish_atomic_withdraw(data_bounded, withdrawals) + .await + .context("Failed to publish block with withdrawals") + }; + + let msg_result = published.map(|(result, checkpoint)| PublishOutcome { + this_msg: result.tx.inscription().this_msg, + checkpoint, + released_notes: released_notes(&result.tx), + }); + match &msg_result { + Ok(_) if withdraw_count == 0 => { + log::info!("Published block with the size of {data_byte_size} bytes"); + } + Ok(_) => { + log::info!( + "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", + ); + } + Err(e) => warn!("zone-sdk publish failed: {e:?}"), + } + let _dontcare = resp_tx.send(msg_result); + } + Command::SubmitChannelConfig { + new_keys, + resp: resp_tx, + } => { + // zone-sdk funds from the node wallet, signs, + // and enqueues this as its own independent + // Mantle tx onto the drive loop's in-flight + // pool — no manual bundling with any block + // inscription. + let result = sequencer + .handle() + .channel_config( + new_keys, + SlotTimeframe::from( + system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEFRAME, + ), + SlotTimeout::from( + system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEOUT, + ), + system_accounts::DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD, + system_accounts::DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD, + ) .await - .context("Failed to publish block") - } else { - sequencer.handle() - .publish_atomic_withdraw(data_bounded, withdrawals) - .await - .context("Failed to publish block with withdrawals") - }; + .map(|_| ()) + .context("Failed to submit channel-config update"); - let msg_result = published.map(|(result, checkpoint)| PublishOutcome { - this_msg: result.tx.inscription().this_msg, - checkpoint, - released_notes: released_notes(&result.tx), - }); - match &msg_result { - Ok(_) if withdraw_count == 0 => { - log::info!("Published block with the size of {data_byte_size} bytes"); + match &result { + Ok(()) => info!("Submitted committee channel-config update"), + Err(err) => { + warn!("Channel-config update submission failed: {err:?}"); + } } - Ok(_) => { - log::info!( - "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", - ); - } - Err(e) => warn!("zone-sdk publish failed: {e:?}"), - } - let _dontcare = resp_tx.send(msg_result); - } - Command::SubmitChannelConfig { - new_keys, - resp: resp_tx, - } => { - // zone-sdk funds from the node wallet, signs, - // and enqueues this as its own independent - // Mantle tx onto the drive loop's in-flight - // pool — no manual bundling with any block - // inscription. - let result = sequencer - .handle() - .channel_config( - new_keys, - SlotTimeframe::from( - system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEFRAME, - ), - SlotTimeout::from( - system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEOUT, - ), - system_accounts::DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD, - system_accounts::DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD, - ) - .await - .map(|_| ()) - .context("Failed to submit channel-config update"); - match &result { - Ok(()) => info!("Submitted committee channel-config update"), - Err(err) => { - warn!("Channel-config update submission failed: {err:?}"); + let _dontcare = resp_tx.send(result); + } + Command::SubmitSignedTx { tx, msg_id, resp: resp_tx } => { + let submitted = sequencer + .handle() + .submit_signed_tx(*tx, msg_id) + .context("Failed to submit pre-built channel transaction"); + let msg_result = submitted.map(|(result, checkpoint)| PublishOutcome { + this_msg: result.tx.inscription().this_msg, + checkpoint, + released_notes: released_notes(&result.tx), + }); + if let Err(e) = &msg_result { + warn!("zone-sdk rejected the pre-built transaction: {e:?}"); } + let _dontcare = resp_tx.send(msg_result); } - - let _dontcare = resp_tx.send(result); - } - Command::SubmitSignedTx { tx, msg_id, resp: resp_tx } => { - let submitted = sequencer - .handle() - .submit_signed_tx(*tx, msg_id) - .context("Failed to submit pre-built channel transaction"); - let msg_result = submitted.map(|(result, checkpoint)| PublishOutcome { - this_msg: result.tx.inscription().this_msg, - checkpoint, - released_notes: released_notes(&result.tx), - }); - if let Err(e) = &msg_result { - warn!("zone-sdk rejected the pre-built transaction: {e:?}"); - } - let _dontcare = resp_tx.send(msg_result); - } - }, + }, event = sequencer.next_event() => { match event { Event::BlocksProcessed { @@ -404,8 +398,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { for op in finalized.into_iter().flat_map(|item| item.ops) { match op { FinalizedOp::Inscription(inscription) => { - if let Some(entry) = - block_from_inscription(&inscription) + if let Some(entry) = block_from_inscription(&inscription) { finalized_blocks.push(entry); } @@ -464,9 +457,9 @@ impl BlockPublisherTrait for ZoneSdkPublisher { }) } - async fn publish_block<'blk, 'pbl: 'blk>( - &'pbl self, - block: &'blk Block, + async fn publish_block( + &self, + block: &Block, withdrawals: Vec, ) -> Result { let data = borsh::to_vec(block).context("Failed to serialize block")?; @@ -608,7 +601,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { async fn read_channel_after( &self, after_slot: Option, - ) -> Result + '_> { + ) -> Result + Send + '_> { let stream = self .indexer .next_messages(after_slot) diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index 8a70bd7b1..4cfe6775d 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -75,9 +75,9 @@ impl BlockPublisherTrait for MockBlockPublisher { }) } - async fn publish_block<'blk, 'pbl: 'blk>( - &'pbl self, - block: &'blk Block, + async fn publish_block( + &self, + block: &Block, withdrawals: Vec, ) -> Result { // Deterministic per-block id so head dedup behaves in tests. @@ -125,7 +125,7 @@ impl BlockPublisherTrait for MockBlockPublisher { async fn read_channel_after( &self, after_slot: Option, - ) -> Result + '_> { + ) -> Result + Send + '_> { // Mirror `next_messages`: `after_slot` is exclusive. let messages = self .messages diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index 537e2ff2d..cfc733636 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -1,4 +1,4 @@ -use std::net::SocketAddr; +use std::{future::Future, net::SocketAddr}; use anyhow::{Context as _, Result}; use futures::never::Never; @@ -142,82 +142,91 @@ impl SequencerHandle { } } -pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result { - let block_timeout = config.block_create_timeout; - let max_block_size = config.max_block_size; +#[expect( + clippy::manual_async_fn, + reason = "Explicit Send future works around rust-lang/rust#100013" +)] +pub fn run( + config: SequencerConfig, + listen_addr: SocketAddr, +) -> impl Future> + Send + 'static { + async move { + let block_timeout = config.block_create_timeout; + let max_block_size = config.max_block_size; - let gossip_config = config.gossip.clone(); - let bedrock_config = config.bedrock_config.clone(); - let sequencer_home = config.home.clone(); + let gossip_config = config.gossip.clone(); + let bedrock_config = config.bedrock_config.clone(); + let sequencer_home = config.home.clone(); - let storage = - StorageActor::new(&config.db_path()).context("Failed to initialize Storage Actor")?; - let storage_ref = StorageActor::spawn(storage); - info!("Storage Actor spawned"); + let storage = + StorageActor::new(&config.db_path()).context("Failed to initialize Storage Actor")?; + let storage_ref = StorageActor::spawn(storage); + info!("Storage Actor spawned"); - let executor = ExecutorActor::new(config, storage_ref.clone()).await; - let mempool_handle = executor.mempool_handle(); - let executor_ref = ExecutorActor::spawn(executor); - info!("Executor Actor spawned"); + let executor = ExecutorActor::new(config, storage_ref.clone()).await; + let mempool_handle = executor.mempool_handle(); + let executor_ref = ExecutorActor::spawn(executor); + info!("Executor Actor spawned"); - // TODO: Should be a separate actor - let gossip_network = match gossip_config { - None => None, - Some(gossip_config) => { - // The node's L1 bedrock signing key is deliberately reused as the - // libp2p identity; `GossipNetwork::start` derives the keypair. - let signing_key = - load_or_create_signing_key(&sequencer_home.join("bedrock_signing_key"))?; - let channel_id = *bedrock_config.channel_id.as_ref(); - let network = sequencer_core::gossip::GossipNetwork::start( - gossip_config, - channel_id, - signing_key, - mempool_handle, - max_block_size.as_u64(), - ) - .await - .context("Failed to start sequencer gossip network")?; - info!("Gossip network started as {}", network.local_peer_id()); - Some(network) - } - }; - let tx_publisher = gossip_network - .as_ref() - .map(sequencer_core::gossip::GossipNetwork::tx_publisher); + // TODO: Should be a separate actor + let gossip_network = match gossip_config { + None => None, + Some(gossip_config) => { + // The node's L1 bedrock signing key is deliberately reused as the + // libp2p identity; `GossipNetwork::start` derives the keypair. + let signing_key = + load_or_create_signing_key(&sequencer_home.join("bedrock_signing_key"))?; + let channel_id = *bedrock_config.channel_id.as_ref(); + let network = sequencer_core::gossip::GossipNetwork::start( + gossip_config, + channel_id, + signing_key, + mempool_handle, + max_block_size.as_u64(), + ) + .await + .context("Failed to start sequencer gossip network")?; + info!("Gossip network started as {}", network.local_peer_id()); + Some(network) + } + }; + let tx_publisher = gossip_network + .as_ref() + .map(sequencer_core::gossip::GossipNetwork::tx_publisher); - let rpc_server = RpcServerActor::new( - listen_addr, - max_block_size, - executor_ref.clone(), - tx_publisher, - ) - .await - .context("Failed to initialize RPC Server Actor")?; - let addr = rpc_server.addr(); - let rpc_server_ref = RpcServerActor::spawn(rpc_server); - info!("RPC Server Actor spawned"); - - let scheduler_ref = Scheduler::spawn(Scheduler::new()); - scheduler_ref - .tell( - SetInterval::new( - executor_ref.downgrade(), - block_timeout, - sequencer_executor_actor::protocol::ProduceBlock, - ) - .start_delay(block_timeout) - .set_missed_tick_behaviour(tokio::time::MissedTickBehavior::Delay), + let rpc_server = RpcServerActor::new( + listen_addr, + max_block_size, + executor_ref.clone(), + tx_publisher, ) - .await?; - info!("Block production scheduler started"); + .await + .context("Failed to initialize RPC Server Actor")?; + let addr = rpc_server.addr(); + let rpc_server_ref = RpcServerActor::spawn(rpc_server); + info!("RPC Server Actor spawned"); - Ok(SequencerHandle::new( - ActorHandle::new(scheduler_ref), - ActorHandle::new(rpc_server_ref), - ActorHandle::new(executor_ref), - ActorHandle::new(storage_ref), - addr, - gossip_network, - )) + let scheduler_ref = Scheduler::spawn(Scheduler::new()); + scheduler_ref + .tell( + SetInterval::new( + executor_ref.downgrade(), + block_timeout, + sequencer_executor_actor::protocol::ProduceBlock, + ) + .start_delay(block_timeout) + .set_missed_tick_behaviour(tokio::time::MissedTickBehavior::Delay), + ) + .await?; + info!("Block production scheduler started"); + + Ok(SequencerHandle::new( + ActorHandle::new(scheduler_ref), + ActorHandle::new(rpc_server_ref), + ActorHandle::new(executor_ref), + ActorHandle::new(storage_ref), + addr, + gossip_network, + )) + } } diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index 96c25a1d8..56a673b82 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -53,6 +53,8 @@ pub mod poller; pub mod program_facades; pub mod storage; +pub const SUPPRESS_VERBOSE_PRINTS: &str = "SUPPRESS_VERBOSE_PRINTS"; + pub const HOME_DIR_ENV_VAR: &str = "LEE_WALLET_HOME_DIR"; pub enum AccDecodeData { @@ -715,7 +717,9 @@ impl WalletCore { println!("Transaction hash is {tx_hash}"); let (tx, block_id) = self.poll_transaction(tx_hash).await?; println!("Transaction is included in block {block_id}"); - println!("Transaction data is {tx:?}"); + if std::env::var_os(SUPPRESS_VERBOSE_PRINTS).is_none() { + println!("Transaction data is {tx:?}"); + } self.store_persistent_data()?; Ok(cli::SubcommandReturnValue::TransactionExecuted { tx_hash }) } @@ -729,7 +733,9 @@ impl WalletCore { println!("Transaction hash is {tx_hash}"); let (tx, block_id) = self.poll_transaction(tx_hash).await?; println!("Transaction is included in block {block_id}"); - println!("Transaction data is {tx:?}"); + if std::env::var_os(SUPPRESS_VERBOSE_PRINTS).is_none() { + println!("Transaction data is {tx:?}"); + } if let common::transaction::LeeTransaction::PrivacyPreserving(private_tx) = tx { self.decode_insert_privacy_preserving_transaction_results( &private_tx, diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index f9bc67b7e..c64086f13 100644 --- a/test_fixtures/src/config.rs +++ b/test_fixtures/src/config.rs @@ -60,6 +60,7 @@ pub struct SequencerPartialConfig { pub max_block_size: ByteSize, pub mempool_max_size: usize, pub block_create_timeout: Duration, + pub priority_fee: u64, } impl Default for SequencerPartialConfig { @@ -69,6 +70,7 @@ impl Default for SequencerPartialConfig { max_block_size: ByteSize::mib(1), mempool_max_size: 10_000, block_create_timeout: Duration::from_secs(10), + priority_fee: sequencer_core::config::default_priority_fee(), } } } @@ -124,6 +126,7 @@ pub fn sequencer_config( max_block_size, mempool_max_size, block_create_timeout, + priority_fee, } = partial; Ok(SequencerConfig { @@ -141,7 +144,7 @@ pub fn sequencer_config( .context("Failed to convert bedrock addr to URL")?, funding_key, auth: None, - priority_fee: sequencer_core::config::default_priority_fee(), + priority_fee, }, cross_zone, metrics_address: Some(SequencerConfig::DEFAULT_METRICS_ADDRESS), @@ -374,3 +377,38 @@ pub fn bedrock_funding_key() -> ZkPublicKey { let bytes = hex::decode(PUBLIC_KEY_HEX).expect("Fixed funding key must be valid hex"); ZkPublicKey::from(BigUint::from_bytes_le(&bytes)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_priority_fee_matches_sequencer_default() { + assert_eq!( + SequencerPartialConfig::default().priority_fee, + sequencer_core::config::default_priority_fee() + ); + } + + #[test] + fn custom_priority_fee_reaches_bedrock_config() { + let priority_fee = 1_000; + let config = sequencer_config( + SequencerPartialConfig { + priority_fee, + ..SequencerPartialConfig::default() + }, + PathBuf::from("test-sequencer"), + SocketAddr::from(([127, 0, 0, 1], 1234)), + bedrock_channel_id(), + bedrock_funding_key(), + Vec::new(), + None, + None, + None, + ) + .expect("custom priority fee should produce a valid sequencer config"); + + assert_eq!(config.bedrock_config.priority_fee, priority_fee); + } +} diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs index b08d34001..a128fd3ce 100644 --- a/test_fixtures/src/setup.rs +++ b/test_fixtures/src/setup.rs @@ -106,15 +106,22 @@ impl SequencerSetup { let temp_sequencer_dir = tempfile::tempdir().context("Failed to create temp dir for sequencer home")?; - let sequencer_handle = self.setup_at(temp_sequencer_dir.path()).await?; + let sequencer_handle = self + .setup_owned(temp_sequencer_dir.path().to_owned()) + .await?; Ok((sequencer_handle, temp_sequencer_dir)) } /// Set up the sequencer in an explicit `home` directory owned by the caller. /// + /// The caller is responsible for creating and retaining the directory. /// Useful for tests that restart the sequencer against the same on-disk store. pub async fn setup_at(self, home: &Path) -> Result { + self.setup_owned(home.to_owned()).await + } + + async fn setup_owned(self, home: PathBuf) -> Result { let Self { partial, bedrock_addr, @@ -161,7 +168,7 @@ impl SequencerSetup { let config = config::sequencer_config( partial, - home.to_owned(), + home.clone(), bedrock_addr, channel_id, config::bedrock_funding_key(), @@ -288,23 +295,39 @@ pub async fn setup_indexer( let temp_indexer_dir = tempfile::tempdir().context("Failed to create temp dir for indexer home")?; - debug!( - "Using temp indexer home at {}", - temp_indexer_dir.path().display() - ); + let handle = setup_indexer_at( + bedrock_addr, + channel_id, + cross_zone, + temp_indexer_dir.path(), + ) + .await?; + + Ok((handle, temp_indexer_dir)) +} + +/// Set up the indexer in an explicit home directory owned by the caller. +pub async fn setup_indexer_at( + bedrock_addr: SocketAddr, + channel_id: ChannelId, + cross_zone: Option, + home: &Path, +) -> Result { + std::fs::create_dir_all(home).context("Failed to create indexer home")?; + + debug!("Using indexer home at {}", home.display()); let indexer_config = config::indexer_config(bedrock_addr, channel_id, cross_zone) .context("Failed to create Indexer config")?; indexer_service::run_server( indexer_config, - temp_indexer_dir.path(), + home, 0, tokio_util::sync::CancellationToken::new(), ) .await .context("Failed to run Indexer Service") - .map(|handle| (handle, temp_indexer_dir)) } pub async fn setup_wallet( @@ -313,20 +336,40 @@ pub async fn setup_wallet( initial_private_accounts: &[InitialPrivateAccountForWallet], config_overrides: WalletConfigOverrides, ) -> Result<(WalletCore, TempDir, String)> { + let temp_wallet_dir = + tempfile::tempdir().context("Failed to create temp dir for wallet home")?; + let (wallet, _state_dir, password) = setup_wallet_at( + sequencer_addrs, + initial_public_accounts, + initial_private_accounts, + config_overrides, + temp_wallet_dir.path(), + ) + .await?; + + Ok((wallet, temp_wallet_dir, password)) +} + +/// Set up the wallet in an explicit home directory owned by the caller. +pub async fn setup_wallet_at( + sequencer_addrs: &[SocketAddr], + initial_public_accounts: &[(PrivateKey, u128)], + initial_private_accounts: &[InitialPrivateAccountForWallet], + config_overrides: WalletConfigOverrides, + home: &Path, +) -> Result<(WalletCore, PathBuf, String)> { let config = config::wallet_config(sequencer_addrs).context("Failed to create Wallet config")?; let config_serialized = serde_json::to_string_pretty(&config).context("Failed to serialize Wallet config")?; - let temp_wallet_dir = - tempfile::tempdir().context("Failed to create temp dir for wallet home")?; + std::fs::create_dir_all(home).context("Failed to create wallet home")?; - let config_path = temp_wallet_dir.path().join("wallet_config.json"); - std::fs::write(&config_path, config_serialized) - .context("Failed to write wallet config in temp dir")?; + let config_path = home.join("wallet_config.json"); + std::fs::write(&config_path, config_serialized).context("Failed to write wallet config")?; - let storage_path = temp_wallet_dir.path().join("storage.json"); - let metrics_path = temp_wallet_dir.path().join("metrics.json"); + let storage_path = home.join("storage.json"); + let metrics_path = home.join("metrics.json"); let wallet_password = "test_pass".to_owned(); let (mut wallet, _mnemonic) = WalletCore::new_init_storage( @@ -362,7 +405,7 @@ pub async fn setup_wallet( .store_persistent_data() .context("Failed to store wallet persistent data")?; - Ok((wallet, temp_wallet_dir, wallet_password)) + Ok((wallet, home.to_owned(), wallet_password)) } pub async fn setup_public_accounts_with_initial_supply(