diff --git a/.deny.toml b/.deny.toml index b02f2046..b6680ba6 100644 --- a/.deny.toml +++ b/.deny.toml @@ -62,6 +62,7 @@ allow-git = [ "https://github.com/logos-blockchain/logos-blockchain-rust-rapidsnark.git", "https://github.com/logos-blockchain/sponges", "https://github.com/arkworks-rs/spongefish.git", + "https://github.com/keycard-tech/keycard-rs", ] unknown-git = "deny" unknown-registry = "deny" diff --git a/.github/docker/ci.Dockerfile b/.github/docker/ci.Dockerfile index a79be15f..7d5dd538 100644 --- a/.github/docker/ci.Dockerfile +++ b/.github/docker/ci.Dockerfile @@ -21,6 +21,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libclang-dev \ libssl-dev \ pkg-config \ + libpcsclite-dev \ curl \ git \ jq \ diff --git a/.gitignore b/.gitignore index 4c1a18c8..f32b258c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,5 @@ result wallet-ffi/wallet_ffi.h bedrock_signing_key integration_tests/configs/debug/ -venv/ - -keycard_wallet/python/__pycache__/ -keycard_wallet/python/keycard-py/ .DS_Store diff --git a/Cargo.lock b/Cargo.lock index ea1c7782..b470cdb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,16 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -56,16 +66,27 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", + "aead 0.5.2", + "aes 0.8.4", "cipher 0.4.4", - "ctr", + "ctr 0.9.2", "ghash", "subtle", ] @@ -268,7 +289,7 @@ dependencies = [ "digest 0.10.7", "fnv", "merlin", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -716,7 +737,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.1", "log", "url", @@ -813,7 +834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core 0.5.6", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -910,6 +931,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base256emoji" version = "1.0.2" @@ -926,6 +953,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -1048,6 +1081,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bollard" version = "0.20.2" @@ -1055,7 +1097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "async-stream", - "base64", + "base64 0.22.1", "bitflags 2.12.1", "bollard-buildkit-proto", "bollard-stubs", @@ -1112,7 +1154,7 @@ version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ - "base64", + "base64 0.22.1", "bollard-buildkit-proto", "bytes", "prost 0.14.3", @@ -1298,6 +1340,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cbindgen" version = "0.29.3" @@ -1329,6 +1380,18 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "ccm" +version = "0.6.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edea5ea70a1285565ac264767613d6c88351a9a0557e7af793a0942590baaed" +dependencies = [ + "aead 0.6.1", + "cipher 0.5.2", + "ctr 0.10.1", + "subtle", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -1369,7 +1432,7 @@ dependencies = [ ] [[package]] -name = "chain_consistency" +name = "chain_state" version = "0.1.0" dependencies = [ "anyhow", @@ -1383,6 +1446,7 @@ dependencies = [ "logos-blockchain-zone-sdk", "serde", "serde_json", + "testnet_initial_state", "thiserror 2.0.18", "tokio", ] @@ -1579,7 +1643,7 @@ version = "0.1.0" dependencies = [ "anyhow", "authenticated_transfer_core", - "base64", + "base64 0.22.1", "borsh", "clock_core", "hex", @@ -1590,7 +1654,7 @@ dependencies = [ "programs", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "system_accounts", "thiserror 2.0.18", ] @@ -1803,6 +1867,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1995,6 +2065,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2035,6 +2121,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -2042,6 +2137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -2340,7 +2436,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", + "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2419,7 +2517,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ - "base64", + "base64 0.22.1", "serde", "serde_json", ] @@ -2470,13 +2568,28 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "serdect", - "signature", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "serdect 0.2.0", + "signature 2.2.0", "spki 0.7.3", ] +[[package]] +name = "ecdsa" +version = "0.17.0-rc.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c72d1455753a703ad4b90ed2a759f2bc4562024a303176439cf6e593b5ade4" +dependencies = [ + "der 0.8.0", + "digest 0.11.3", + "elliptic-curve 0.14.0", + "rfc6979 0.6.0-pre.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -2485,7 +2598,7 @@ checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8 0.10.2", "serde", - "signature", + "signature 2.2.0", ] [[package]] @@ -2498,7 +2611,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -2543,17 +2656,38 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array 0.14.7", - "group", + "group 0.13.0", "pem-rfc7468", "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", - "serdect", + "sec1 0.7.3", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3273f1195b6f6253ebda493d6742c8baa9b26a291674cd96d92a0f09e90e9b46" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hkdf 0.13.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -2833,6 +2967,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "ff_derive" version = "0.13.1" @@ -3246,11 +3390,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "guardian" version = "1.3.0" @@ -3458,7 +3613,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -3470,6 +3634,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hmac-sha512" version = "1.1.12" @@ -3590,7 +3763,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -3698,7 +3873,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3960,7 +4135,7 @@ dependencies = [ "arc-swap", "async-stream", "borsh", - "chain_consistency", + "chain_state", "common", "cross_zone", "cross_zone_inbox_core", @@ -4024,7 +4199,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base58", - "base64", + "base64 0.22.1", "common", "hex", "indexer_core", @@ -4097,6 +4272,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding", "hybrid-array", ] @@ -4159,6 +4335,7 @@ dependencies = [ "tempfile", "test_fixtures", "test_programs", + "testnet_initial_state", "token_core", "tokio", "vault_core", @@ -4388,7 +4565,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf36eb27f8e13fa93dcb50ccb44c417e25b818cfa1a481b5470cd07b19c60b98" dependencies = [ - "base64", + "base64 0.22.1", "futures-channel", "futures-util", "gloo-net", @@ -4441,7 +4618,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790bedefcec85321e007ff3af84b4e417540d5c87b3c9779b9e247d1bcc3dab8" dependencies = [ - "base64", + "base64 0.22.1", "http-body", "hyper", "hyper-rustls", @@ -4543,12 +4720,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "once_cell", - "serdect", - "sha2", - "signature", + "serdect 0.2.0", + "sha2 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "k256" +version = "0.14.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905d38bdbb43bb506efa0a428b3e969ff244549832a86b18591492f503adfe37" +dependencies = [ + "cpubits", + "ecdsa 0.17.0-rc.22", + "elliptic-curve 0.14.0", + "primeorder", + "sha2 0.11.0", + "signature 3.0.0", + "wnaf", ] [[package]] @@ -4593,25 +4785,53 @@ dependencies = [ "hex", "hmac-sha512", "itertools 0.14.0", - "k256", + "k256 0.13.4", "lee", "lee_core", "ml-kem", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] +[[package]] +name = "keycard-rs" +version = "0.1.0" +source = "git+https://github.com/keycard-tech/keycard-rs?rev=9535a657ba04b1e6916de51777e22b4837c1a84d#9535a657ba04b1e6916de51777e22b4837c1a84d" +dependencies = [ + "aes 0.9.1", + "base64 0.21.7", + "cbc", + "ccm", + "generic-array 0.14.7", + "getrandom 0.2.17", + "getrandom 0.4.2", + "hkdf 0.13.0", + "hmac 0.13.0", + "k256 0.14.0-rc.14", + "pbkdf2", + "pcsc", + "rand_core 0.6.4", + "sha2 0.11.0", + "sha3 0.12.0", + "thiserror 1.0.69", + "typenum", + "zeroize", +] + [[package]] name = "keycard_wallet" version = "0.1.0" dependencies = [ + "bip39", + "hex", + "keycard-rs", "lee", "log", - "pyo3", - "serde", - "serde_json", + "pcsc", + "rand 0.8.6", + "thiserror 2.0.18", "zeroize", ] @@ -4677,7 +4897,7 @@ dependencies = [ "build_utils", "hex", "hex-literal 1.1.0", - "k256", + "k256 0.13.4", "lee_core", "log", "rand 0.8.6", @@ -4685,7 +4905,7 @@ dependencies = [ "risc0-zkvm", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "test-case", "test_methods", "thiserror 2.0.18", @@ -4715,7 +4935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa3982e7fe36c1de68f91f3c9083124f389a975523881f3d7e3363362feda41" dependencies = [ "any_spawner", - "base64", + "base64 0.22.1", "cfg-if", "either_of", "futures", @@ -4917,7 +5137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" dependencies = [ "any_spawner", - "base64", + "base64 0.22.1", "codee", "futures", "hydration_context", @@ -5108,7 +5328,7 @@ checksum = "d558548fa3b5a8e9b66392f785921e363c57c05dcadfda4db0d41ae82d313e4a" dependencies = [ "async-channel", "asynchronous-codec", - "base64", + "base64 0.22.1", "byteorder", "bytes", "either", @@ -5127,7 +5347,7 @@ dependencies = [ "rand 0.8.6", "regex", "serde", - "sha2", + "sha2 0.10.9", "tracing", "web-time", ] @@ -5162,13 +5382,13 @@ dependencies = [ "asn1_der", "bs58", "ed25519-dalek", - "hkdf", - "k256", + "hkdf 0.12.4", + "k256 0.13.4", "multihash", "prost 0.14.3", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", "tracing", "zeroize", @@ -5194,7 +5414,7 @@ dependencies = [ "quick-protobuf-codec", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tracing", @@ -7229,13 +7449,42 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pcsc" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da" +dependencies = [ + "bitflags 2.12.1", + "pcsc-sys", +] + +[[package]] +name = "pcsc-sys" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ef017e15d2e5592a9e39a346c1dbaea5120bab7ed7106b210ef58ebd97003" +dependencies = [ + "pkg-config", +] + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -7475,6 +7724,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e56e6d67fdf5744e9e245ae571450fe584b91f5af261d0e40163b618e53a1f6" +dependencies = [ + "elliptic-curve 0.14.0", + "once_cell", + "primefield", + "serdect 0.4.3", +] + [[package]] name = "privacy_preserving_circuit_program" version = "0.1.0" @@ -7725,63 +8000,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "pyo3" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" -dependencies = [ - "libc", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", -] - -[[package]] -name = "pyo3-build-config" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" -dependencies = [ - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "quick-protobuf" version = "0.8.1" @@ -8162,7 +8380,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -8215,10 +8433,20 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9935425142ac6e252364413291d96c8bc9898d0876a801824c7af4eae397b689" +dependencies = [ + "ctutils", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -8295,7 +8523,7 @@ dependencies = [ "directories", "hex", "rayon", - "sha2", + "sha2 0.10.9", "tempfile", ] @@ -8355,7 +8583,7 @@ dependencies = [ "risc0-sys", "risc0-zkp", "serde", - "sha2", + "sha2 0.10.9", "tracing", "zip", ] @@ -8492,7 +8720,7 @@ dependencies = [ "bytemuck", "cfg-if", "digest 0.10.7", - "ff", + "ff 0.13.1", "hex", "hex-literal 0.4.1", "metal", @@ -8506,7 +8734,7 @@ dependencies = [ "risc0-sys", "risc0-zkvm-platform", "serde", - "sha2", + "sha2 0.10.9", "stability", "tracing", ] @@ -8553,7 +8781,7 @@ dependencies = [ "rzup", "semver", "serde", - "sha2", + "sha2 0.10.9", "stability", "tempfile", "tracing", @@ -8657,7 +8885,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "spki 0.7.3", "subtle", "zeroize", @@ -8901,7 +9129,7 @@ dependencies = [ "semver", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "strum", "tempfile", "thiserror 2.0.18", @@ -8985,11 +9213,25 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der 0.7.10", "generic-array 0.14.7", "pkcs8 0.10.2", - "serdect", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.0", + "hybrid-array", "subtle", "zeroize", ] @@ -9044,7 +9286,7 @@ dependencies = [ "borsh", "bridge_core", "bytesize", - "chain_consistency", + "chain_state", "chrono", "common", "cross_zone", @@ -9075,6 +9317,7 @@ dependencies = [ "testnet_initial_state", "token_core", "tokio", + "tokio-util", "url", "vault_core", ] @@ -9090,6 +9333,7 @@ dependencies = [ "common", "env_logger", "futures", + "hex", "jsonrpsee", "lee", "log", @@ -9263,7 +9507,7 @@ version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -9308,7 +9552,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] @@ -9319,7 +9573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d60e4c1dfccd91fe0990141f69f1d5cf5679797ad53aa1b45e5bd658eb119f0" dependencies = [ "axum 0.8.9", - "base64", + "base64 0.22.1", "bytes", "const-str 1.1.0", "const_format", @@ -9399,6 +9653,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" @@ -9419,6 +9684,17 @@ dependencies = [ "keccak 0.2.0", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -9460,6 +9736,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -9529,7 +9815,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures", "http 1.4.1", @@ -9568,6 +9854,12 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "spongefish" version = "0.2.0" @@ -9838,12 +10130,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - [[package]] name = "tempfile" version = "3.27.0" @@ -9851,7 +10137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -10358,7 +10644,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum 0.8.9", - "base64", + "base64 0.22.1", "bytes", "h2", "http 1.4.1", @@ -10809,7 +11095,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "percent-encoding", @@ -10826,7 +11112,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.1", "httparse", "log", @@ -11016,13 +11302,12 @@ dependencies = [ "log", "optfield", "programs", - "pyo3", "rand 0.8.6", "rpassword", "sequencer_service_rpc", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "system_accounts", "tempfile", "testnet_initial_state", @@ -11205,7 +11490,7 @@ checksum = "d0a659ffe5c7f4538aa6357c07e3d73221cc61eba03bd9a081e14bc91ed09b8c" dependencies = [ "base16", "quote", - "sha2", + "sha2 0.10.9", "syn 2.0.117", ] @@ -11705,6 +11990,17 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86421f2a70c9e6cab8d84c99fb62d8761d355bd1285443a7e7ccad15aa515f2" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", +] + [[package]] name = "wrapped_token_core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index bac0ea4f..0b4964d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "lez", "lez/system_accounts", + "lez/chain_state", "lez/sequencer/core", "lez/sequencer/service", "lez/sequencer/service/protocol", @@ -32,7 +33,6 @@ members = [ "lez/wallet", "lez/wallet-ffi", "lez/common", - "lez/chain_consistency", "lez/programs", "lez/programs/amm", "lez/programs/associated_token_account", @@ -73,7 +73,7 @@ members = [ lee = { path = "lee/state_machine" } lee_core = { path = "lee/state_machine/core" } common = { path = "lez/common" } -chain_consistency = { path = "lez/chain_consistency" } +chain_state = { path = "lez/chain_state" } mempool = { path = "lez/mempool" } storage = { path = "lez/storage" } key_protocol = { path = "lee/key_protocol" } @@ -180,11 +180,14 @@ logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/lo logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } +keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "9535a657ba04b1e6916de51777e22b4837c1a84d" } + rocksdb = { version = "0.24.0", default-features = false, features = [ "snappy", "bindgen-runtime", ] } rand = { version = "0.8.5", features = ["std", "std_rng", "getrandom"] } +pcsc = "2" k256 = { version = "0.13.3", features = [ "ecdsa-core", "arithmetic", @@ -201,7 +204,6 @@ actix-web = { version = "4.13.0", default-features = false, features = [ axum = "0.8.4" clap = { version = "4.5.42", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] } -pyo3 = { version = "0.29", features = ["auto-initialize"] } zeroize = "1" criterion = { version = "0.8", features = ["html_reports"] } diff --git a/Justfile b/Justfile index b838f4fc..b65c7ff3 100644 --- a/Justfile +++ b/Justfile @@ -64,15 +64,17 @@ run-bedrock: docker compose up # Run Sequencer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. +# Optional home/port let a second instance run off the same config, e.g. +# `just run-sequencer "" "$TMPDIR/lez-sequencer2" 3041` for the multi-sequencer demo. [working-directory: 'lez/sequencer/service'] -run-sequencer standalone="": +run-sequencer standalone="" home="" port="3040": @echo "๐Ÿง  Running sequencer" @if [ "{{standalone}}" = "standalone" ]; then \ echo "๐Ÿงช Running in standalone mode"; \ - RUST_LOG=info cargo run --features standalone --release -p sequencer_service configs/debug/sequencer_config.json; \ + RUST_LOG=info cargo run --features standalone --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \ else \ echo "๐Ÿš€ Running in normal mode"; \ - RUST_LOG=info cargo run --release -p sequencer_service configs/debug/sequencer_config.json; \ + RUST_LOG=info cargo run --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \ fi # Run Indexer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. diff --git a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin index 4eafd160..58454db6 100644 Binary files a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin and b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin differ diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index ce19ce6f..18152451 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index f5b7b02a..cc0689a3 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index 2b90722d..94b93d26 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index 3ac001d7..9424499c 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index 91a132c8..2861b6bf 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index f8073f7c..abb3ef69 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin index a548a31e..d10ce8c5 100644 Binary files a/artifacts/lez/programs/cross_zone_outbox.bin and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index be4e884c..9b58cb09 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index d717d9d6..9cb14837 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index cddd3eb1..6a0d9341 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index 9463fa1c..8b6e83ff 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin index dfc27e67..52195fad 100644 Binary files a/artifacts/lez/programs/ping_sender.bin and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index b76eaadd..3bc7e839 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index ae08f453..bc821716 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index bb7e96dd..e8ad9c75 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/docs/LEZ testnet v0.1 tutorials/keycard.md b/docs/LEZ testnet v0.1 tutorials/keycard.md index 47573f12..66d5aacf 100644 --- a/docs/LEZ testnet v0.1 tutorials/keycard.md +++ b/docs/LEZ testnet v0.1 tutorials/keycard.md @@ -6,46 +6,45 @@ This tutorial walks you through using Keycard with Wallet CLI. Keycard is option ### Required hardware - Keycard (Blank) - a Keycard, directly, from Keycard.tech cannot (currently) be updated to support LEE. - Smartcard reader -- Applets (`math.cap` and `LEE_keycard.cap`). Eventually, both of these applets will be available in separate repos. - - `math.cap` is an applet to speed up computations on Keycard; developed by Bitgamma (Keycard-tech team). - - `LEE_keycard.cap` is an applet that contains LEE keycard protocol; developed by Bitgamma (Keycard-tech team) ### Firmware installation -Installation: -1. Install math applet on your keycard; this process only needs to be done once. In the root of repo: - ``` - sudo apt-get install -y default-jdk - wget https://github.com/martinpaljak/GlobalPlatformPro/releases/download/v25.10.20/gp.jar -P lez/keycard_wallet/keycard_applets - cd lez/keycard_wallet/keycard_applets - java -jar gp.jar --key c212e073ff8b4bbfaff4de8ab655221f --load math.cap - ``` -2. Install `keycard-desktop` from [github](https://github.com/choppu/keycard-desktop) - - Keycard Desktop is used to install the LEE key protocol to a blank keycard. - - Select (Re)Install Applet and upload the key binary (`lez/keycard_wallet/keycard_applets/LEE_keycard.cap`). - ![keycard-desktop.png](keycard-desktop.png) - - **Important:** keycard can only connect with one application at a time; if Keycard-Desktop is using keycard then Wallet CLI cannot access the same keycard, and vice-versa. - -## Wallet with Keycard -Keycard functionality is available to Wallet CLI by setting up the following Python virtual environment. The steps below can also be run via `lez/keycard_wallet/wallet_with_keycard.sh`. +LEE key protocol support (on top of standard Status Keycard commands) is built from source, from [`keycard-tech/status-keycard`](https://github.com/keycard-tech/status-keycard)'s default branch: ```bash -# Install appropriate version of `keycard-py`. -git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py - -# Set up virtual environment. -python3 -m venv venv -source venv/bin/activate -pip install pyscard mnemonic ecdsa pyaes -pip install -e lez/keycard_wallet/python/keycard-py +git clone --recurse-submodules https://github.com/keycard-tech/status-keycard.git +cd status-keycard ``` -**Important**: Keycard wallet commands only work within the virtual environment. +The build requires **OpenJDK 11 specifically** (newer JDKs aren't compatible with its Gradle/plugin versions): + ```bash -# In the root of LEE repo: -source venv/bin/activate +sudo apt-get install -y openjdk-11-jdk +export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 ``` +Gradle's default heap is too small for this build and will OOM during `buildSrc` compilation; bump it once: + +```bash +echo "org.gradle.jvmargs=-Xmx2g" >> gradle.properties +``` + +Build and install onto a connected, blank card (disconnect all other card readers first): + +```bash +./gradlew install +``` + +This uses the GlobalPlatform default keys (`404142434445464748494a4b4c4d4e4f`) or the Keycard development-card key (`c212e073ff8b4bbfaff4de8ab655221f`) to load it onto the card. + +**Warning: `./gradlew install` uninstalls and reinstalls the applet, which erases any existing personalization.** If you run this against a card that's already personalized (identity certificate, PIN, PUK, and any loaded keys), all of that is wiped, regardless of whether the firmware source changed at all โ€” reinstalling the exact same build twice has the same effect. + +### Personalizing your card + +**Personalization is mandatory, not optional โ€” every card requires it before any command will work, immediately after installing the firmware.** A freshly installed (or freshly reinstalled) card has no identity certificate, and refuses every command. + +**Important:** keycard can only connect with one application at a time; if another tool is using the keycard then Wallet CLI cannot access the same keycard, and vice-versa. + ## PIN entry Each Keycard command prompts for a PIN interactively. To avoid re-entering it across multiple commands, export it as an environment variable: @@ -60,43 +59,36 @@ Unset it when done: unset KEYCARD_PIN ``` -## Pairing password +## Default CA public key -The pairing password is used to establish a secure channel between the wallet and the card. It is set permanently on the card during `wallet keycard init` and must match on every subsequent re-pair. +`keycard-rs` verifies every card's identity certificate against a trusted CA public key before anything else happens โ€” no match, no commands, regardless of whether the firmware or PIN is correct. The baked-in default is: -The default password (`KeycardDefaultPairing`) is [recommended](https://docs.keycard.tech/en/developers/core) for most users. Wallet CLI allows advance users the flexibility to set their own pairing password. - -To use a custom pairing password, set it before `init`: - -```bash -# Note: Keep the leading space before this command. -# Leading space prevents this command from being stored in shell history -# (when HISTCONTROL=ignorespace is enabled). - export KEYCARD_PAIRING_PASSWORD=my-custom-password -wallet keycard init +``` +029ab99ee1e7a71bdf45b3f9c58c99866ff1294d2c1e304e228a86e10c3343501c ``` -After a successful initializaation, subsequent commands (`connect`, transfers) use the cached pairing index and key โ€” the pairing password is not needed again until the pairing is cleared. - -**Important:** if you initialized with a custom password, `KEYCARD_PAIRING_PASSWORD` must be set in every session where re-pairing can occur (after `disconnect`, or on a new machine). If the env var is missing then wallet CLI will attempt to use the default password. As a result, pairing will fail. - -Unset the pairing password variable when done: +Cards personalized for development/testing (see "Personalizing the card" above) are signed by a different, throwaway CA instead, so the wallet needs to be told to trust it explicitly: ```bash -unset KEYCARD_PAIRING_PASSWORD +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 +# unset KEYCARD_CA_PUBLIC_KEY when done testing against a dev card ``` +If the card's certificate doesn't match whichever CA is in effect, every command reports the card as simply "not available." + ## Keycard Commands +Keycard uses Secure Channel V2 (applet version >= 4.0) โ€” the wallet authenticates the card via its identity certificate and opens a fresh ECDHE-derived channel every session. There's no pairing step and nothing cached between commands; you'll enter your PIN each time you connect. + ### Keycard | Command | Description | |----------------------------------|-----------------------------------------------------------------------| | `wallet keycard available` | Checks whether a Keycard reader and card are accessible | | `wallet keycard init` | Initializes a blank Keycard with a PIN and a generated PUK | -| `wallet keycard connect` | Establishes and saves a pairing with the Keycard | -| `wallet keycard disconnect` | Unpairs the Keycard and clears the saved pairing | +| `wallet keycard connect` | Opens a secure channel with the Keycard and verifies the PIN | | `wallet keycard load` | Loads a mnemonic phrase onto the Keycard | +| `wallet keycard factory-reset` | Wipes PIN/PUK/keys back to uninitialized, for re-`init` โ€” **debug builds only** (see below) | | `wallet keycard get-private-keys`| Prints NSK and VSK for a BIP-32 path โ€” **debug builds only** (see below) | 1. Check keycard availability @@ -118,13 +110,13 @@ Record this PUK and store it somewhere safe. It cannot be recovered. โœ… Keycard initialized successfully. ``` -3. Connect (pair and save pairing for subsequent commands) +3. Connect (open a secure channel and verify the PIN) ```bash wallet keycard connect # Output: Keycard PIN: -โœ… Keycard paired and ready. +โœ… Keycard connected and PIN verified. ``` 4. Load a mnemonic phrase @@ -140,25 +132,24 @@ Keycard PIN: โœ… Mnemonic phrase loaded successfully. ``` -5. Disconnect (unpair and clear saved pairing) +5. `factory-reset` + +Wipes the card's PIN, PUK, and loaded keys back to an uninitialized state, so it can be re-`init`ialized โ€” the counterpart to `init`. It does **not** remove the identity certificate, so the card doesn't need re-personalizing afterward. Irreversibly destroys any keys currently on the card, so it requires `--confirm`: ```bash -wallet keycard disconnect +wallet keycard factory-reset --confirm # Output: -Keycard PIN: -โœ… Keycard unpaired and pairing cleared. +โœ… Keycard factory-reset. Run `wallet keycard init` to reinitialize it. ``` -6. Get private keys for a BIP-32 path (**debug builds only**) +6. `get-private-keys` (**debug builds only**) -`get-private-keys` exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption โ€” either key is sufficient to fully compromise that account's privacy. The command is only available in debug builds and requires `--reveal` to confirm intent. - -First install the wallet with the `keycard-debug` feature: +Requires building the wallet with the `keycard-debug` feature: ```bash cargo install --path lez/wallet --force --features keycard-debug ``` -Then run the command: +Exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption โ€” either key is sufficient to fully compromise that account's privacy. Requires `--reveal` to confirm intent: ```bash wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/0" --reveal @@ -515,20 +506,4 @@ bash lez/keycard_wallet/tests/keycard_tests.sh bash lez/keycard_wallet/tests/keycard_tests_2.sh bash lez/keycard_wallet/tests/keycard_test_3.sh bash lez/keycard_wallet/tests/keycard_power_recovery_tests.sh -``` - -## SigningGroup - -`SigningGroup` (`lez/wallet/src/signing.rs`) partitions a transaction's signers into two buckets โ€” local accounts and Keycard accounts. This ensures that Python GIL is only used at most once per transaction, regardless of how many Keycard accounts are involved. - -Local signers are resolved and signed in pure Rust. Keycard signers store only their BIP32 key path; all of them are signed inside a single Python session (`connect` / `close_session`) when `sign_all` is called. The command calls `needs_pin` to decide whether to prompt for a PIN before signing. - -Foreign recipient accounts โ€” those with no local key and no Keycard path โ€” are silently skipped and require neither a signature nor a nonce. - -``` -SigningGroup { - local: [(AccountId, PrivateKey)], // signed in pure Rust - keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session -} -``` ``` \ No newline at end of file diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index a225d657..060e10f3 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -36,6 +36,7 @@ indexer_service_protocol.workspace = true system_accounts.workspace = true programs.workspace = true test_programs.workspace = true +testnet_initial_state.workspace = true logos-blockchain-http-api-common.workspace = true logos-blockchain-core.workspace = true diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index f68301c3..919b3dbc 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -16,7 +16,7 @@ //! needs source verification, where a value-bearing target checks the message //! originated from `bridge_lock`; that is out of scope for the demo. -use std::{net::SocketAddr, time::Duration}; +use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; @@ -24,14 +24,14 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, setup_bedrock_node, setup_indexer}, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; use lee::{ AccountId, PrivateKey, PublicKey, PublicTransaction, public_transaction::{Message, WitnessSet}, }; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction}; -use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use sequencer_service_rpc::RpcClient as _; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); @@ -95,9 +95,7 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { // Wait until zone B's indexer reflects the verified mint. let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); - let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr()) - .context("Failed to build indexer URL")?; - let indexer = IndexerClient::new(&indexer_url) + let indexer = indexer_client(idx_b.addr()) .await .context("Failed to build indexer client")?; @@ -171,14 +169,6 @@ fn build_lock_tx( LeeTransaction::Public(PublicTransaction::new(message, witness)) } -fn sequencer_client(addr: SocketAddr) -> Result { - let url = config::addr_to_url(config::UrlProtocol::Http, addr) - .context("Failed to build sequencer URL")?; - SequencerClientBuilder::default() - .build(url) - .context("Failed to build sequencer client") -} - /// Polls zone B's indexer until the recipient's wrapped holding is non-zero. async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result { let account_id = indexer_service_protocol::AccountId { diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs index fe5fe7fc..86731477 100644 --- a/integration_tests/tests/cross_zone_ingress_guard.rs +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -9,8 +9,6 @@ //! inbox guest's caller-is-none assertion passes for a top-level user tx, so the //! sequencer ingress guard is the only thing that stops this. -use std::net::SocketAddr; - use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; use cross_zone_inbox_core::{ @@ -18,13 +16,13 @@ use cross_zone_inbox_core::{ }; use integration_tests::{ config::{self, SequencerPartialConfig}, - setup::{SequencerSetup, setup_bedrock_node}, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, }; use lee::{ PublicTransaction, public_transaction::{Message, WitnessSet}, }; -use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use sequencer_service_rpc::RpcClient as _; use tokio::test; #[test] @@ -73,11 +71,3 @@ async fn user_origin_inbox_call_rejected() -> Result<()> { ); Ok(()) } - -fn sequencer_client(addr: SocketAddr) -> Result { - let url = config::addr_to_url(config::UrlProtocol::Http, addr) - .context("Failed to build sequencer URL")?; - SequencerClientBuilder::default() - .build(url) - .context("Failed to build sequencer client") -} diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index a3ed0c0c..f106bda1 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -11,20 +11,20 @@ //! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone, //! sequencer-trusted, with no indexer re-derivation (that is M4). -use std::{net::SocketAddr, time::Duration}; +use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, - setup::{SequencerSetup, setup_bedrock_node}, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; -use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480); @@ -123,14 +123,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio )) } -fn sequencer_client(addr: SocketAddr) -> Result { - let url = config::addr_to_url(config::UrlProtocol::Http, addr) - .context("Failed to build sequencer URL")?; - SequencerClientBuilder::default() - .build(url) - .context("Failed to build sequencer client") -} - /// Polls zone B's sequencer until the ping record PDA holds a payload. async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result> { let wait = async { diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index aa2ac06a..cc21c42a 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -9,7 +9,7 @@ //! payload landing in the indexer's state proves verification passed; a forgery //! would have halted the indexer instead. -use std::{net::SocketAddr, time::Duration}; +use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; @@ -17,13 +17,13 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, setup_bedrock_node, setup_indexer}, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; -use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use sequencer_service_rpc::RpcClient as _; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); @@ -83,9 +83,7 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { // Wait until zone B's indexer records the delivered payload. The indexer only // applies the dispatch after re-deriving and verifying it. let record_id = ping_record_pda(receiver_id); - let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr()) - .context("Failed to build indexer URL")?; - let indexer = IndexerClient::new(&indexer_url) + let indexer = indexer_client(idx_b.addr()) .await .context("Failed to build indexer client")?; @@ -130,14 +128,6 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio )) } -fn sequencer_client(addr: SocketAddr) -> Result { - let url = config::addr_to_url(config::UrlProtocol::Http, addr) - .context("Failed to build sequencer URL")?; - SequencerClientBuilder::default() - .build(url) - .context("Failed to build sequencer client") -} - /// Polls zone B's indexer until the ping record PDA holds a payload. async fn wait_for_indexer_delivery( indexer: &IndexerClient, diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs new file mode 100644 index 00000000..dfca2cf2 --- /dev/null +++ b/integration_tests/tests/multi_sequencer.rs @@ -0,0 +1,236 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Two sequencers share one channel: A starts solo as channel admin, live- +//! accredits `[A, B]` with round-robin rotation, B joins and syncs, both +//! produce on their turns, and A, B and an indexer converge on the same chain. + +use std::time::Duration; + +use anyhow::{Context as _, Result, ensure}; +use indexer_service_rpc::RpcClient as _; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + indexer_client::IndexerClient, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; +use sequencer_core::{block_publisher::post_channel_config, config::BedrockConfig}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; +use tokio::test; + +/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled +/// turn after ~30 s (bounds the stall while B is accredited but not started). +const POSTING_TIMEFRAME_SLOTS: u32 = 20; +const POSTING_TIMEOUT_SLOTS: u32 = 30; +const PHASE_TIMEOUT: Duration = Duration::from_secs(360); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const TRANSFER_AMOUNT: u128 = 10; +/// โ‰ˆ4 turn windows past B's join (5 s blocks, ~20 s turns โ†’ ~4 blocks/window). +const ROTATION_BLOCKS: u64 = 8; + +#[test] +async fn multi_sequencer_committee_converges() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up Bedrock node")?; + + // Fixed seeds so A can accredit B's public key before B exists. + let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE]; + let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE]; + let pub_a = Ed25519Key::from_bytes(&key_a).public_key(); + let pub_b = Ed25519Key::from_bytes(&key_b).public_key(); + + let partial = SequencerPartialConfig { + block_create_timeout: Duration::from_secs(5), + ..SequencerPartialConfig::default() + }; + + // Phase 1: A solo (its first inscription creates the channel), plus an indexer. + let (seq_a, _a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_genesis(vec![]) + .with_bedrock_signing_key(key_a) + .setup() + .await + .context("Failed to set up sequencer A")?; + let a = sequencer_client(seq_a.addr())?; + let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None) + .await + .context("Failed to set up indexer")?; + let indexer = indexer_client(idx.addr()).await?; + + wait_for_height(&a, 2, "sequencer A to produce past genesis").await?; + + // Phase 2: live roster change to [A, B] with rotation enabled, posted + // straight to bedrock with A's admin key (the operator one-shot path). + post_channel_config( + &BedrockConfig { + channel_id: config::bedrock_channel_id(), + node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, + auth: None, + }, + &Ed25519Key::from_bytes(&key_a), + vec![pub_a, pub_b], + POSTING_TIMEFRAME_SLOTS, + POSTING_TIMEOUT_SLOTS, + 1, + 1, + ) + .await + .context("Failed to configure the channel committee")?; + + let height_at_config = a.get_last_block_id().await?; + wait_for_height( + &a, + height_at_config + 1, + "A to produce after the roster change", + ) + .await?; + + // Phase 3: B joins live and syncs the existing chain. + let (seq_b, _b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_genesis(vec![]) + .with_bedrock_signing_key(key_b) + .setup() + .await + .context("Failed to set up sequencer B")?; + let b = sequencer_client(seq_b.addr())?; + + let join_height = a.get_last_block_id().await?; + wait_for_height(&b, join_height, "B to sync to A's height at join").await?; + + // Phase 4: rotation + convergence over โ‰ˆ4 turn windows. + let rotation_target = join_height + ROTATION_BLOCKS; + wait_for_height( + &a, + rotation_target, + "the chain to advance across turn windows", + ) + .await?; + wait_for_height(&b, rotation_target, "B to follow across turn windows").await?; + assert_same_chain(&a, &b).await?; + + // Phase 5: a tx submitted only to B is included by B and visible on A. + let accounts = initial_public_user_accounts(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone(); + + let to_balance_before = a.get_account_balance(to).await?; + let nonce = b.get_accounts_nonces(vec![from]).await?[0]; + let tx = common::test_utils::create_transaction_native_token_transfer( + from, + nonce.0, + to, + TRANSFER_AMOUNT, + &sign_key, + ); + b.send_transaction(tx) + .await + .context("Failed to submit the transfer to B")?; + + wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?; + + // Phase 6: the indexer finalizes the same chain, with no stall. + wait_for_finalized(&indexer, join_height).await?; + let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); + for id in 1..=finalized { + let block_i = indexer + .get_block_by_id(id) + .await? + .with_context(|| format!("Indexer is missing finalized block {id}"))?; + let block_a = a + .get_block(id) + .await? + .with_context(|| format!("A is missing block {id}"))?; + ensure!( + block_i.header.hash == indexer_service_protocol::HashType::from(block_a.header.hash), + "Indexer diverges from A at block {id}" + ); + } + let status = indexer.get_status().await?; + ensure!( + status.stall_reason.is_none(), + "Indexer is stalled: {:?}", + status.stall_reason + ); + + Ok(()) +} + +/// Polls the sequencer until its chain height reaches `target`. +async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> { + let wait = async { + loop { + if client.get_last_block_id().await? >= target { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .with_context(|| format!("Timed out waiting for {what} (target height {target})"))? +} + +/// Polls the sequencer until `account`'s balance reaches `expected`. +async fn wait_for_balance( + client: &SequencerClient, + account: lee::AccountId, + expected: u128, +) -> Result<()> { + let wait = async { + loop { + if client.get_account_balance(account).await? == expected { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .context("Timed out waiting for the cross-sequencer transfer to reach A")? +} + +/// Polls the indexer until its finalized height reaches `target`. +async fn wait_for_finalized(indexer: &IndexerClient, target: u64) -> Result<()> { + let wait = async { + loop { + if indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= target { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .context("Timed out waiting for the indexer to finalize")? +} + +/// Asserts A and B hold byte-identical block hashes over their common prefix. +async fn assert_same_chain(a: &SequencerClient, b: &SequencerClient) -> Result<()> { + let common = a + .get_last_block_id() + .await? + .min(b.get_last_block_id().await?); + for id in 1..=common { + let block_a = a + .get_block(id) + .await? + .with_context(|| format!("A is missing block {id}"))?; + let block_b = b + .get_block(id) + .await? + .with_context(|| format!("B is missing block {id}"))?; + ensure!( + block_a.header.hash == block_b.header.hash, + "Chain divergence at block {id}: A {:?} vs B {:?}", + block_a.header.hash, + block_b.header.hash + ); + } + Ok(()) +} diff --git a/integration_tests/tests/sequencer_bootstrap.rs b/integration_tests/tests/sequencer_bootstrap.rs index 5261c412..9dd2b586 100644 --- a/integration_tests/tests/sequencer_bootstrap.rs +++ b/integration_tests/tests/sequencer_bootstrap.rs @@ -8,7 +8,7 @@ reason = "Integration tests live at crate root and don't care about these lints" )] -use std::{net::SocketAddr, path::Path, time::Duration}; +use std::{path::Path, time::Duration}; use anyhow::{Context as _, Result, bail}; use indexer_service_rpc::RpcClient as _; @@ -16,11 +16,11 @@ use integration_tests::L2_TO_L1_TIMEOUT; use lee::{AccountId, PrivateKey, PublicKey}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use sequencer_core::config::GenesisAction; -use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use test_fixtures::{ config::{SequencerPartialConfig, UrlProtocol, addr_to_url}, indexer_client::IndexerClient, - setup::{SequencerSetup, setup_bedrock_node, setup_indexer}, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer}, }; use tokio::test; @@ -41,13 +41,6 @@ fn slow_blocks() -> SequencerPartialConfig { } } -fn sequencer_client(addr: SocketAddr) -> Result { - let url = addr_to_url(UrlProtocol::Http, addr).context("Failed to build sequencer URL")?; - SequencerClientBuilder::default() - .build(url) - .context("Failed to build sequencer client") -} - /// Polls the indexer's last finalized block id until it reaches `target`. The /// indexer reads finalized channel history, so this is our oracle for "block /// `target` is finalized on Bedrock" โ€” exactly what a reconstructing sequencer diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index 71fb132b..7141303e 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -294,6 +294,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_vpk, random_seed: [0; 32], + view_tag: 0, nsk: sender_nsk, membership_proof: proof, identifier: 0, diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index c9543e05..c5d962d6 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -4,7 +4,7 @@ use lee_core::{ PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey, account::{Account, AccountId, Nonce}, compute_digest_for_path, - encryption::ViewingPublicKey, + encryption::{ViewTag, ViewingPublicKey}, }; use crate::execution_state::ExecutionState; @@ -66,6 +66,7 @@ pub fn compute_circuit_output( *commitment_root, ); let new_nonce = Nonce::private_account_nonce_init(&account_id); + let view_tag = EncryptedAccountData::compute_view_tag(&npk, vpk); emit_private_output( &mut output, @@ -73,7 +74,7 @@ pub fn compute_circuit_output( post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - &npk, + view_tag, vpk, random_seed, new_nullifier, @@ -83,6 +84,7 @@ pub fn compute_circuit_output( InputAccountIdentity::PrivateAuthorizedUpdate { vpk, random_seed, + view_tag, nsk, membership_proof, identifier, @@ -110,7 +112,7 @@ pub fn compute_circuit_output( post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - &npk, + *view_tag, vpk, random_seed, new_nullifier, @@ -142,6 +144,7 @@ pub fn compute_circuit_output( *commitment_root, ); let new_nonce = Nonce::private_account_nonce_init(&account_id); + let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk); emit_private_output( &mut output, @@ -149,7 +152,7 @@ pub fn compute_circuit_output( post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - npk, + view_tag, vpk, random_seed, new_nullifier, @@ -190,6 +193,7 @@ pub fn compute_circuit_output( let (authority_program_id, seed) = pda_seed_by_position .get(&pos) .expect("PrivatePdaInit position must be in pda_seed_by_position"); + let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk); emit_private_output( &mut output, &mut output_index, @@ -200,7 +204,7 @@ pub fn compute_circuit_output( seed: *seed, identifier: *identifier, }, - npk, + view_tag, vpk, random_seed, new_nullifier, @@ -210,6 +214,7 @@ pub fn compute_circuit_output( InputAccountIdentity::PrivatePdaUpdate { vpk, random_seed, + view_tag, nsk, membership_proof, identifier, @@ -234,7 +239,6 @@ pub fn compute_circuit_output( let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk); let account_id = pre_state.account_id; - let npk = NullifierPublicKey::from(nsk); let (authority_program_id, seed) = pda_seed_by_position .get(&pos) .expect("PrivatePdaUpdate position must be in pda_seed_by_position"); @@ -248,7 +252,7 @@ pub fn compute_circuit_output( seed: *seed, identifier: *identifier, }, - &npk, + *view_tag, vpk, random_seed, new_nullifier, @@ -271,7 +275,7 @@ fn emit_private_output( post_state: Account, account_id: &AccountId, kind: &PrivateAccountKind, - npk: &NullifierPublicKey, + view_tag: ViewTag, vpk: &ViewingPublicKey, random_seed: &[u8; 32], new_nullifier: (Nullifier, CommitmentSetDigest), @@ -287,15 +291,6 @@ fn emit_private_output( let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce); let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(vpk, &esk); - // Currently the view tag is properlty generated for all accounts. - // To increase privacy, this will be changed in the later version - // to only be generated explicitly for initialized accounts and - // fed by the prover directly for updated accounts. - // - // See issue 573: - // https://github.com/logos-blockchain/logos-execution-zone/issues/573 - let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk); - let encrypted_account = EncryptionScheme::encrypt( &post_with_updated_nonce, kind, diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index d441ff19..8db29342 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -4,7 +4,7 @@ use crate::{ Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, account::{Account, AccountWithMetadata}, - encryption::{EncryptedAccountData, ViewingPublicKey}, + encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey}, program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow}, }; @@ -42,6 +42,7 @@ pub enum InputAccountIdentity { PrivateAuthorizedUpdate { vpk: ViewingPublicKey, random_seed: [u8; 32], + view_tag: ViewTag, nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, @@ -79,6 +80,7 @@ pub enum InputAccountIdentity { PrivatePdaUpdate { vpk: ViewingPublicKey, random_seed: [u8; 32], + view_tag: ViewTag, nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, diff --git a/lee/state_machine/src/error.rs b/lee/state_machine/src/error.rs index 2f073746..94e66e8b 100644 --- a/lee/state_machine/src/error.rs +++ b/lee/state_machine/src/error.rs @@ -131,6 +131,11 @@ pub enum InvalidProgramBehaviorError { #[error("Called program {program_id:?} which is not listed in dependencies")] UndeclaredProgramDependency { program_id: ProgramId }, + + #[error( + "Account {account_id} was declared in the transaction but is missing from the program output" + )] + DeclaredAccountMissingFromOutput { account_id: AccountId }, } #[cfg(test)] diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index 238ac5a7..86f357e1 100644 --- a/lee/state_machine/src/lib.rs +++ b/lee/state_machine/src/lib.rs @@ -77,6 +77,14 @@ mod test_methods { ) } + #[must_use] + pub const fn dropped_account() -> Program { + Program::new_unchecked( + test_methods::DROPPED_ACCOUNT_ID, + Cow::Borrowed(test_methods::DROPPED_ACCOUNT_ELF), + ) + } + #[must_use] pub const fn program_owner_changer() -> Program { Program::new_unchecked( diff --git a/lee/state_machine/src/merkle_tree/mod.rs b/lee/state_machine/src/merkle_tree/mod.rs index ee8106c5..37e706f7 100644 --- a/lee/state_machine/src/merkle_tree/mod.rs +++ b/lee/state_machine/src/merkle_tree/mod.rs @@ -8,8 +8,8 @@ mod default_values; type Value = [u8; 32]; type Node = [u8; 32]; -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] -#[derive(Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct MerkleTree { nodes: Vec, capacity: usize, diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs index 005a9230..fb66186b 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -1,8 +1,8 @@ #![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] use lee_core::{ - Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, EphemeralSecretKey, Nullifier, - PrivacyPreservingCircuitOutput, SharedSecretKey, + Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey, + Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, program::{PdaSeed, PrivateAccountKind}, }; @@ -198,6 +198,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: commitment_set .get_proof_for(&commitment_sender) @@ -242,6 +243,76 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { assert_eq!(recipient_post, expected_private_account_2); } +#[test] +fn init_note_view_tag_is_derived_from_account_keys() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 0; + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let account = AccountWithMetadata::new(Account::default(), false, account_id); + + let (output, proof) = execute_and_prove( + vec![account], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateUnauthorized { + vpk: keys.vpk(), + random_seed: [0; 32], + npk: keys.npk(), + identifier, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &program.into(), + ) + .unwrap(); + + assert!(proof.is_valid_for(&output)); + assert_eq!(output.encrypted_private_post_states.len(), 1); + assert_eq!( + output.encrypted_private_post_states[0].view_tag, + EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()), + ); +} + +#[test] +fn update_note_view_tag_is_the_supplied_value() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let account = Account { + program_owner: program.id(), + balance: 1, + ..Account::default() + }; + let commitment = Commitment::new(&account_id, &account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&commitment)); + let sender = AccountWithMetadata::new(account, true, account_id); + + // A tag deliberately different from the address-derived one, so a passthrough is + // distinguishable from re-derivation. + let fed_tag = EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()).wrapping_add(1); + + let (output, proof) = execute_and_prove( + vec![sender], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: keys.vpk(), + random_seed: [0; 32], + view_tag: fed_tag, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), + identifier, + }], + &program.into(), + ) + .unwrap(); + + assert!(proof.is_valid_for(&output)); + assert_eq!(output.encrypted_private_post_states.len(), 1); + assert_eq!(output.encrypted_private_post_states[0].view_tag, fed_tag); +} + #[test] fn circuit_fails_when_chained_validity_windows_have_empty_intersection() { let account_keys = test_private_account_keys_1(); @@ -574,6 +645,7 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() { vec![InputAccountIdentity::PrivateAuthorizedUpdate { vpk: keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: keys.nsk, membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), identifier, @@ -630,6 +702,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() { InputAccountIdentity::PrivatePdaUpdate { vpk: keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: keys.nsk, membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), identifier, @@ -708,6 +781,7 @@ fn private_pda_update_identifier_mismatch_fails() { InputAccountIdentity::PrivatePdaUpdate { vpk: keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: keys.nsk, membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), identifier: 99, diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index a011c422..8d278ea5 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -20,8 +20,8 @@ use crate::{ pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; -#[derive(Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] pub struct CommitmentSet { merkle_tree: MerkleTree, commitments: HashMap, @@ -67,8 +67,8 @@ impl CommitmentSet { } } -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] -#[derive(Clone)] +#[cfg_attr(test, derive(Debug))] +#[derive(Clone, PartialEq, Eq)] struct NullifierSet(BTreeSet); impl NullifierSet { @@ -109,8 +109,8 @@ impl BorshDeserialize for NullifierSet { } } -#[derive(Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] pub struct V03State { public_state: HashMap, private_state: (CommitmentSet, NullifierSet), diff --git a/lee/state_machine/src/state/tests/changer_claimer.rs b/lee/state_machine/src/state/tests/changer_claimer.rs index 9a581c63..f6bb4932 100644 --- a/lee/state_machine/src/state/tests/changer_claimer.rs +++ b/lee/state_machine/src/state/tests/changer_claimer.rs @@ -74,6 +74,7 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() { vec![InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -104,6 +105,7 @@ fn private_changer_claimer_data_change_no_claim_fails() { vec![InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs index c13a7370..0b234b30 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -64,6 +64,7 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: recipient_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -113,6 +114,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -162,6 +164,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -211,6 +214,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided( InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -260,6 +264,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -307,6 +312,7 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (0, vec![]), identifier: 0, @@ -694,6 +700,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (1, vec![]), identifier: 0, @@ -701,6 +708,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: (1, vec![]), identifier: 0, @@ -1020,6 +1028,7 @@ fn two_private_pda_family_members_receive_and_spend() { InputAccountIdentity::PrivatePdaUpdate { vpk: alice_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: alice_keys.nsk, membership_proof: state .get_proof_for_commitment(&commitment_pda_0) @@ -1057,6 +1066,7 @@ fn two_private_pda_family_members_receive_and_spend() { InputAccountIdentity::PrivatePdaUpdate { vpk: alice_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: alice_keys.nsk, membership_proof: state .get_proof_for_commitment(&commitment_pda_1) @@ -1108,6 +1118,7 @@ fn two_private_pda_family_members_receive_and_spend() { InputAccountIdentity::PrivatePdaUpdate { vpk: alice_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: alice_keys.nsk, membership_proof: state .get_proof_for_commitment(&commitment_pda_1_after_spend) diff --git a/lee/state_machine/src/state/tests/claiming.rs b/lee/state_machine/src/state/tests/claiming.rs index d45f39bd..3bdc315c 100644 --- a/lee/state_machine/src/state/tests/claiming.rs +++ b/lee/state_machine/src/state/tests/claiming.rs @@ -328,6 +328,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: state .get_proof_for_commitment(&sender_commitment) @@ -443,6 +444,7 @@ fn private_chained_call(number_of_calls: u32) { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: from_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: from_keys.nsk, membership_proof: state .get_proof_for_commitment(&from_commitment) @@ -452,6 +454,7 @@ fn private_chained_call(number_of_calls: u32) { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: to_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: to_keys.nsk, membership_proof: state .get_proof_for_commitment(&to_commitment) diff --git a/lee/state_machine/src/state/tests/mod.rs b/lee/state_machine/src/state/tests/mod.rs index 55a99ead..d6acaa2e 100644 --- a/lee/state_machine/src/state/tests/mod.rs +++ b/lee/state_machine/src/state/tests/mod.rs @@ -48,6 +48,7 @@ impl V03State { self.insert_program(crate::test_methods::nonce_changer()); self.insert_program(crate::test_methods::extra_output()); self.insert_program(crate::test_methods::missing_output()); + self.insert_program(crate::test_methods::dropped_account()); self.insert_program(crate::test_methods::program_owner_changer()); self.insert_program(crate::test_methods::data_changer()); self.insert_program(crate::test_methods::minter()); @@ -330,6 +331,7 @@ fn private_balance_transfer_for_tests( InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: state .get_proof_for_commitment(&sender_commitment) @@ -384,6 +386,7 @@ fn deshielded_balance_transfer_for_tests( InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.nsk, membership_proof: state .get_proof_for_commitment(&sender_commitment) diff --git a/lee/state_machine/src/state/tests/privacy_preserving.rs b/lee/state_machine/src/state/tests/privacy_preserving.rs index 174aa1b3..bc51ce4f 100644 --- a/lee/state_machine/src/state/tests/privacy_preserving.rs +++ b/lee/state_machine/src/state/tests/privacy_preserving.rs @@ -524,6 +524,7 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: recipient_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: recipient_keys.nsk, membership_proof: state .get_proof_for_commitment(&recipient_commitment) diff --git a/lee/state_machine/src/state/tests/public_program_rules.rs b/lee/state_machine/src/state/tests/public_program_rules.rs index 1c67aeff..236bddcf 100644 --- a/lee/state_machine/src/state/tests/public_program_rules.rs +++ b/lee/state_machine/src/state/tests/public_program_rules.rs @@ -79,6 +79,58 @@ fn program_should_fail_with_missing_output_accounts() { )); } +/// A program can drop an entire account from its own output โ€” both its `pre_state` and +/// `post_state` together, not just one side โ€” while staying internally consistent +/// (`pre_states.len() == post_states.len()` within its own report, so `validate_execution`'s +/// length check alone can't catch it). This must still be rejected: every account the caller +/// declared in the transaction must appear somewhere in the final diff. +#[test] +fn program_should_fail_if_it_drops_a_declared_account() { + // Both accounts need a non-default program_owner: an account left at DEFAULT_PROGRAM_ID with + // non-default data would itself violate the (separate, pre-existing) "claim before mutating a + // default-owned account" rule the moment it's echoed back โ€” unrelated to what this test + // targets. `with_public_account_balances` leaves program_owner at DEFAULT_PROGRAM_ID, so use + // `with_public_accounts` to set it explicitly instead. + let mut state = V03State::new() + .with_public_accounts([ + ( + AccountId::new([1; 32]), + Account { + program_owner: crate::test_methods::dropped_account().id(), + balance: 100, + ..Account::default() + }, + ), + ( + AccountId::new([2; 32]), + Account { + program_owner: crate::test_methods::dropped_account().id(), + balance: 0, + ..Account::default() + }, + ), + ]) + .with_test_programs(); + let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])]; + let program_id = crate::test_methods::dropped_account().id(); + let message = + public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!( + matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::DeclaredAccountMissingFromOutput { account_id } + )) if account_id == AccountId::new([2; 32]) + ), + "expected DeclaredAccountMissingFromOutput for the dropped account, got {result:?}" + ); +} + #[test] fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() { let initial_data = [( diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs index 8fae4fee..e4a92d63 100644 --- a/lee/state_machine/src/validated_state_diff/mod.rs +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -293,6 +293,17 @@ impl ValidatedStateDiff { ); } + // Every account the caller declared as part of the transaction must appear in the final + // diff. + for account_id in &message.account_ids { + ensure!( + state_diff.contains_key(account_id), + InvalidProgramBehaviorError::DeclaredAccountMissingFromOutput { + account_id: *account_id + } + ); + } + Ok(Self(StateDiff { signer_account_ids, public_diff: state_diff, diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs index c471676c..e8bb3f14 100644 --- a/lee/state_machine/src/validated_state_diff/tests.rs +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -167,6 +167,7 @@ fn privacy_malicious_programs_cannot_drain_public_victim() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: attacker_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: attacker_keys.nsk, membership_proof, identifier: 0, @@ -327,6 +328,7 @@ fn privacy_malicious_programs_cannot_drain_private_victim() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: attacker_keys.vpk(), random_seed: [0; 32], + view_tag: 0, nsk: attacker_keys.nsk, membership_proof, identifier: 0, diff --git a/lee/state_machine/test_methods/guest/src/bin/dropped_account.rs b/lee/state_machine/test_methods/guest/src/bin/dropped_account.rs new file mode 100644 index 00000000..348eefa8 --- /dev/null +++ b/lee/state_machine/test_methods/guest/src/bin/dropped_account.rs @@ -0,0 +1,35 @@ +use lee_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_lee_inputs}; + +type Instruction = (); + +/// Silently drops the second account entirely from its own output: given two `pre_states`, it +/// returns only one `(pre, post)` pair, echoing the first account back unchanged. +/// +/// Differs from `missing_output` because the `pre_state` and `post_states` lengths match. We +/// simply drop the account from both before returning them as part of the program's output. +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_lee_inputs::(); + + let Ok([pre1, _pre2]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + let account_pre1 = pre1.account.clone(); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre1], + vec![AccountPostState::new(account_pre1)], + ) + .write(); +} diff --git a/lez/chain_consistency/src/apply.rs b/lez/chain_consistency/src/apply.rs deleted file mode 100644 index 28961502..00000000 --- a/lez/chain_consistency/src/apply.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Validating and applying channel L2 blocks to a `V03State`. -//! -//! These primitives are shared by the consumers that reconstruct L2 state from -//! the Bedrock channel. - -use common::{ - HashType, - block::Block, - transaction::{LeeTransaction, clock_invocation}, -}; -use lee::{GENESIS_BLOCK_ID, V03State}; -use lee_core::BlockId; -use serde::{Deserialize, Serialize}; - -/// Why a channel L2 block could not be validated or applied. -/// -/// Persisted in `RocksDB` (via the Indexer's stall reason), so every variant -/// must be `Clone + Serialize + Deserialize`. -#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)] -pub enum BlockIngestError { - #[error("Failed to deserialize L2 block: {0}")] - /// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`]. - Deserialize(String), - #[error("Unexpected block id: expected {expected}, got {got}")] - UnexpectedBlockId { expected: BlockId, got: BlockId }, - #[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")] - BrokenChainLink { - expected_prev: HashType, - got_prev: HashType, - }, - #[error("Block hash mismatch: computed {computed}, header {header}")] - HashMismatch { - computed: HashType, - header: HashType, - }, - #[error("Block has no transactions")] - EmptyBlock, - #[error("Last transaction must be the public clock invocation for the block timestamp")] - InvalidClockTransaction, - #[error("Genesis block must contain only public transactions")] - NonPublicGenesisTransaction, - #[error("State transition failed at transaction {tx_index}: {reason}")] - StateTransition { - /// Index of the failing transaction within the block body. - tx_index: u64, - /// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`. - /// - /// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we - /// cannot store it directly. - reason: String, - }, -} - -impl BlockIngestError { - /// Whether the failure may be transient rather than a property of the block. - /// - /// FIXME: `StateTransition` is too coarse โ€” its `reason` string mixes genuine - /// state-transition rejections with infra failures (risc0 executor teardown, - /// storage errors). Once it carries a structured cause, narrow this so only - /// infra failures retry. - #[must_use] - pub const fn is_retryable(&self) -> bool { - matches!(self, Self::StateTransition { .. }) - } -} - -/// The last successfully applied block a candidate must extend. -#[derive(Debug, Copy, Clone)] -pub struct Tip { - pub block_id: BlockId, - pub hash: HashType, -} - -/// Checks that `block` is the valid continuation of `tip`: hash integrity, -/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip -/// (cold store) expects the genesis block. -pub fn validate_against_tip(tip: Option, block: &Block) -> Result<(), BlockIngestError> { - let computed = block.recompute_hash(); - if computed != block.header.hash { - return Err(BlockIngestError::HashMismatch { - computed, - header: block.header.hash, - }); - } - - match tip { - None => { - if block.header.block_id != GENESIS_BLOCK_ID { - return Err(BlockIngestError::UnexpectedBlockId { - expected: GENESIS_BLOCK_ID, - got: block.header.block_id, - }); - } - } - Some(tip) => { - let expected = tip - .block_id - .checked_add(1) - .expect("block id should not overflow"); - if block.header.block_id != expected { - return Err(BlockIngestError::UnexpectedBlockId { - expected, - got: block.header.block_id, - }); - } - if block.header.prev_block_hash != tip.hash { - return Err(BlockIngestError::BrokenChainLink { - expected_prev: tip.hash, - got_prev: block.header.prev_block_hash, - }); - } - } - } - Ok(()) -} - -/// Applies a block's transactions to `state`. -pub fn apply_block(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> { - let (clock_tx, user_txs) = block - .body - .transactions - .split_last() - .ok_or(BlockIngestError::EmptyBlock)?; - - let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp)); - if *clock_tx != expected_clock { - return Err(BlockIngestError::InvalidClockTransaction); - } - - let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; - for (tx_index, transaction) in user_txs.iter().enumerate() { - let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition { - tx_index: tx_index.try_into().expect("tx index fits in u64"), - reason: format!("{err:#}"), - }; - if is_genesis { - let LeeTransaction::Public(public_tx) = transaction else { - return Err(BlockIngestError::NonPublicGenesisTransaction); - }; - state - .transition_from_public_transaction( - public_tx, - block.header.block_id, - block.header.timestamp, - ) - .map_err(|err| state_transition(err.into()))?; - } else { - transaction - .clone() - .execute_on_state(state, block.header.block_id, block.header.timestamp) - .map_err(|err| state_transition(err.into()))?; - } - } - - let LeeTransaction::Public(clock_public_tx) = clock_tx else { - return Err(BlockIngestError::InvalidClockTransaction); - }; - state - .transition_from_public_transaction( - clock_public_tx, - block.header.block_id, - block.header.timestamp, - ) - .map_err(|err| BlockIngestError::StateTransition { - tx_index: user_txs.len().try_into().expect("tx index fits in u64"), - reason: format!("{:#}", anyhow::Error::from(err)), - })?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn serializes_and_round_trips_externally_tagged() { - let err = BlockIngestError::UnexpectedBlockId { - expected: 5, - got: 7, - }; - let value = serde_json::to_value(&err).expect("serialize"); - assert_eq!( - value, - serde_json::json!({ "UnexpectedBlockId": { "expected": 5, "got": 7 } }) - ); - let back: BlockIngestError = serde_json::from_value(value).expect("deserialize"); - assert!(matches!( - back, - BlockIngestError::UnexpectedBlockId { - expected: 5, - got: 7 - } - )); - } -} diff --git a/lez/chain_consistency/src/lib.rs b/lez/chain_consistency/src/lib.rs deleted file mode 100644 index d2b45ca9..00000000 --- a/lez/chain_consistency/src/lib.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Reconstructing and verifying L2 chain state from a Bedrock (L1) channel. - -pub use apply::{BlockIngestError, Tip, apply_block, validate_against_tip}; -pub use consistency::{ - Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency, -}; - -pub mod apply; -pub mod consistency; diff --git a/lez/chain_consistency/Cargo.toml b/lez/chain_state/Cargo.toml similarity index 83% rename from lez/chain_consistency/Cargo.toml rename to lez/chain_state/Cargo.toml index 02bf0ea0..0a0610a5 100644 --- a/lez/chain_consistency/Cargo.toml +++ b/lez/chain_state/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "chain_consistency" +name = "chain_state" version = "0.1.0" edition = "2024" -license.workspace = true +license = { workspace = true } [lints] workspace = true @@ -11,9 +11,9 @@ workspace = true common.workspace = true lee.workspace = true lee_core.workspace = true - -logos-blockchain-zone-sdk.workspace = true logos-blockchain-core.workspace = true +logos-blockchain-zone-sdk.workspace = true + anyhow.workspace = true borsh.workspace = true futures.workspace = true @@ -23,4 +23,5 @@ thiserror.workspace = true tokio.workspace = true [dev-dependencies] +testnet_initial_state.workspace = true serde_json.workspace = true diff --git a/lez/chain_state/src/apply.rs b/lez/chain_state/src/apply.rs new file mode 100644 index 00000000..05134b88 --- /dev/null +++ b/lez/chain_state/src/apply.rs @@ -0,0 +1,308 @@ +//! The single validate-then-apply entry point shared by the sequencer and the +//! indexer. Pure and storage-free: callers apply on a scratch clone of state and +//! commit only on `Ok`. + +use common::{ + HashType, + block::{Block, BlockMeta}, + transaction::{LeeTransaction, clock_invocation}, +}; +use lee::{GENESIS_BLOCK_ID, V03State}; + +use crate::ingest_error::BlockIngestError; + +/// The parent the next block must chain on. +// `l1_slot` will be added here when the `ChainState` anchor layer lands. +#[derive(Debug, Clone)] +pub struct Tip { + pub block_id: u64, + pub hash: HashType, +} + +impl From<&Block> for Tip { + fn from(block: &Block) -> Self { + Self { + block_id: block.header.block_id, + hash: block.header.hash, + } + } +} + +impl From for Tip { + fn from(meta: BlockMeta) -> Self { + Self { + block_id: meta.id, + hash: meta.hash, + } + } +} + +impl From<&Tip> for BlockMeta { + fn from(tip: &Tip) -> Self { + Self { + id: tip.block_id, + hash: tip.hash, + } + } +} + +/// Outcome of feeding a parsed L2 block to a validated tip. +pub enum AcceptOutcome { + /// Chained and applied; the tip advances. + Applied, + /// A duplicate re-delivery of an already-applied block. No state change. + AlreadyApplied, + /// Did not chain or failed to apply; the tip stays frozen. + Parked(BlockIngestError), + /// Chained but failed to apply, possibly transiently + /// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state + /// untouched. The caller retries and parks once it gives up. + /// + /// TODO: Only the indexer's `accept_block` emits this today; the sequencer's + /// `ChainState` parks on all failures without retrying (see `on_follow`). + RetryableFailure(BlockIngestError), +} + +/// Validates `block` against `tip`, then applies it to `state`. +/// +/// Mutates `state` in place, so callers pass a scratch clone and commit on `Ok`. +pub fn apply_block( + tip: Option<&Tip>, + block: &Block, + state: &mut V03State, +) -> Result<(), BlockIngestError> { + validate_against_tip(tip, block)?; + apply_block_to_state(block, state)?; + Ok(()) +} + +/// Checks that `block` is the valid continuation of `tip`: hash integrity, +/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip +/// (cold state) expects the genesis block. +pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> { + let computed = block.recompute_hash(); + if computed != block.header.hash { + return Err(BlockIngestError::HashMismatch { + computed, + header: block.header.hash, + }); + } + + match tip { + None => { + if block.header.block_id != GENESIS_BLOCK_ID { + return Err(BlockIngestError::UnexpectedBlockId { + expected: GENESIS_BLOCK_ID, + got: block.header.block_id, + }); + } + } + Some(tip) => { + let expected = tip + .block_id + .checked_add(1) + .expect("block id should not overflow"); + if block.header.block_id != expected { + return Err(BlockIngestError::UnexpectedBlockId { + expected, + got: block.header.block_id, + }); + } + if block.header.prev_block_hash != tip.hash { + return Err(BlockIngestError::BrokenChainLink { + expected_prev: tip.hash, + got_prev: block.header.prev_block_hash, + }); + } + } + } + Ok(()) +} + +/// Applies a block's transactions to `state`, mapping every failure to a +/// [`BlockIngestError`] so the caller can park rather than crash. Operates in +/// place; the caller commits only on `Ok`. +pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> { + let (clock_tx, user_txs) = block + .body + .transactions + .split_last() + .ok_or(BlockIngestError::EmptyBlock)?; + + let LeeTransaction::Public(clock_tx) = clock_tx else { + return Err(BlockIngestError::InvalidClockTransaction); + }; + if *clock_tx != clock_invocation(block.header.timestamp) { + return Err(BlockIngestError::InvalidClockTransaction); + } + + let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; + for (tx_index, transaction) in user_txs.iter().enumerate() { + let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition { + tx_index: tx_index.try_into().expect("tx index fits in u64"), + reason: format!("{err:#}"), + }; + if is_genesis { + let LeeTransaction::Public(public_tx) = transaction else { + return Err(BlockIngestError::NonPublicGenesisTransaction); + }; + state + .transition_from_public_transaction( + public_tx, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| state_transition(err.into()))?; + } else { + transaction + .clone() + .execute_on_state(state, block.header.block_id, block.header.timestamp) + .map_err(|err| state_transition(err.into()))?; + } + } + + state + .transition_from_public_transaction(clock_tx, block.header.block_id, block.header.timestamp) + .map_err(|err| BlockIngestError::StateTransition { + tx_index: user_txs.len().try_into().expect("tx index fits in u64"), + reason: format!("{:#}", anyhow::Error::from(err)), + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use common::{ + block::HashableBlockData, + test_utils::{ + create_transaction_native_token_transfer, produce_dummy_block, + produce_dummy_empty_transaction, sequencer_sign_key_for_testing, + }, + }; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + fn tip_of(block: &Block) -> Tip { + Tip::from(block) + } + + #[test] + fn genesis_applies_on_empty_tip() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + } + + #[test] + fn non_genesis_first_block_is_unexpected_id() { + let mut state = initial_state(); + let block = produce_dummy_block(2, None, vec![]); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::UnexpectedBlockId { + expected: 1, + got: 2 + } + )); + } + + #[test] + fn skip_ahead_block_is_unexpected_id() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + + // Tip is at 1; a block with id 3 skips ahead. + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + let err = + apply_block(Some(&tip_of(&genesis)), &bad, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::UnexpectedBlockId { + expected: 2, + got: 3 + } + )); + } + + #[test] + fn broken_chain_link_detected() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + + // Correct id (2), wrong parent hash. + let block2 = produce_dummy_block(2, Some(HashType([9_u8; 32])), vec![]); + let err = + apply_block(Some(&tip_of(&genesis)), &block2, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::BrokenChainLink { .. })); + } + + #[test] + fn hash_mismatch_detected() { + let mut state = initial_state(); + let mut genesis = produce_dummy_block(1, None, vec![]); + // Tampering with the header invalidates the stored hash. + genesis.header.timestamp = 999; + let err = apply_block(None, &genesis, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::HashMismatch { .. })); + } + + #[test] + fn empty_block_rejected() { + let mut state = initial_state(); + // A block with no transactions at all (not even the mandatory clock tx). + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 0, + transactions: vec![], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::EmptyBlock)); + } + + #[test] + fn missing_clock_tail_is_invalid_clock() { + let mut state = initial_state(); + // Last tx is not the expected clock invocation for the timestamp. + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 50, + transactions: vec![produce_dummy_empty_transaction()], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::InvalidClockTransaction)); + } + + #[test] + fn applies_transfers_and_advances_state() { + let mut state = initial_state(); + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + // Genesis (block 1): clock-only. + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + let mut tip = tip_of(&genesis); + + // Blocks 2..=11: one native transfer of 10 each (nonces 0..=9). + for i in 0..10_u64 { + let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key); + let block = produce_dummy_block(i + 2, Some(tip.hash), vec![tx]); + apply_block(Some(&tip), &block, &mut state).expect("transfer applies"); + tip = tip_of(&block); + } + + assert_eq!(state.get_account_by_id(from).balance, 9900); + assert_eq!(state.get_account_by_id(to).balance, 20100); + } +} diff --git a/lez/chain_state/src/chain.rs b/lez/chain_state/src/chain.rs new file mode 100644 index 00000000..39e1efba --- /dev/null +++ b/lez/chain_state/src/chain.rs @@ -0,0 +1,1034 @@ +//! Two-tier chain state: a reorg-able `head` the sequencer builds on, plus an +//! irreversible `final` tier. + +use common::block::Block; +use lee::V03State; +use log::warn; +use logos_blockchain_core::mantle::ops::channel::MsgId; +use logos_blockchain_zone_sdk::Slot; + +use crate::{ + AcceptOutcome, BlockIngestError, StallReason, + apply::{Tip, apply_block}, +}; + +/// A head block plus the channel message that carried it. +pub struct HeadEntry { + pub this_msg: MsgId, + pub block: Block, +} + +/// The head tier (reorg-able, from `adopted`/`orphaned`) over the final tier +/// (irreversible, from `finalized`). +/// +/// `head_state` is given by `final_state` replayed through `head_blocks`. +/// +/// Only the final tier stalls: an invalid `adopted` block just freezes the +/// head tip and self-heals via reorg or finalization. +pub struct ChainState { + final_state: V03State, + final_tip: Option, + final_stall: Option, + + head_state: V03State, + head_blocks: Vec, +} + +impl ChainState { + /// Fresh state anchored at the genesis/initial state, no blocks applied. + #[must_use] + pub fn new(initial_state: V03State) -> Self { + Self::from_final(initial_state, None) + } + + /// State restored from a persisted final tier; head mirrors final. + #[must_use] + pub fn from_final(final_state: V03State, final_tip: Option) -> Self { + Self { + head_state: final_state.clone(), + final_state, + final_tip, + head_blocks: Vec::new(), + final_stall: None, + } + } + + /// State the sequencer builds its next block on. + #[must_use] + pub const fn head_state(&self) -> &V03State { + &self.head_state + } + + /// Mutable access to the head state. Bypasses the `head_blocks` invariant, so + /// it is meant for tests and low-level callers. + #[must_use] + pub const fn head_state_mut(&mut self) -> &mut V03State { + &mut self.head_state + } + + #[must_use] + pub const fn final_state(&self) -> &V03State { + &self.final_state + } + + /// Parent the next produced block must chain on. + #[must_use] + pub fn head_tip(&self) -> Option { + self.head_blocks + .last() + .map(|entry| Tip::from(&entry.block)) + .or_else(|| self.final_tip.clone()) + } + + #[must_use] + pub fn final_tip(&self) -> Option { + self.final_tip.clone() + } + + #[must_use] + pub const fn final_stall(&self) -> Option<&StallReason> { + self.final_stall.as_ref() + } + + /// Position of a head entry, matched by `MsgId` or block hash (restored + /// entries carry sentinel `MsgId`s; re-inscriptions arrive under fresh ones) + /// โ€” always at the same claimed height: a hash or `MsgId` collision with a + /// different `block_id` is malformed and must fall through to validation. + fn head_position_of(&self, this_msg: MsgId, block: &Block) -> Option { + self.head_blocks.iter().position(|entry| { + entry.block.header.block_id == block.header.block_id + && (entry.this_msg == this_msg || entry.block.header.hash == block.header.hash) + }) + } + + /// Applies an adopted head block. + /// + /// The adopted stream is authoritative: a competitor at a height + /// the head already holds reorgs the head back to that height with + /// no orphan event required. + /// + /// On failure the head stays unchanged and no stall is recorded. + pub fn apply_adopted(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { + if self.head_position_of(this_msg, block).is_some() { + return AcceptOutcome::AlreadyApplied; + } + + // If we receive a pre-final adoption, its an SDK fault; just log and ignore it + if let Some(final_tip) = &self.final_tip + && block.header.block_id <= final_tip.block_id + { + // The final tier is irreversible: a matching block here is a stale + // re-delivery, a conflicting one an SDK contract breach. + if block.header.block_id == final_tip.block_id && block.header.hash != final_tip.hash { + warn!( + "Ignoring adopted block {} with hash {} conflicting with the \ + finalized block ({}) at this height", + block.header.block_id, block.header.hash, final_tip.hash + ); + } + return AcceptOutcome::AlreadyApplied; + } + + // A tip extension applies on the current head state, a lower-or-equal + // id rebuilds the state at the competitor's parent instead. + // + // If adoptions are over the current head, `reorg_at` is None. + let reorg_at = self + .head_blocks + .iter() + .position(|entry| entry.block.header.block_id >= block.header.block_id); + let (mut scratch, tip) = match reorg_at { + // continue from the tip + None => (self.head_state.clone(), self.head_tip()), + // reorg upto `idx` + Some(idx) => self.replay_head_prefix(idx), + }; + + match apply_block(tip.as_ref(), block, &mut scratch) { + Ok(()) => { + // now that `apply_block` succeeded, actually reorg the head + if let Some(idx) = reorg_at { + self.head_blocks.truncate(idx); + } + self.head_state = scratch; + self.head_blocks.push(HeadEntry { + this_msg, + block: block.to_owned(), + }); + AcceptOutcome::Applied + } + Err(err) => AcceptOutcome::Parked(err), + } + } + + /// Applies a block we produced ourselves. + /// + /// Unlike [`Self::apply_adopted`] this never reorgs: our block is not on + /// the channel yet, so it may only *extend* the head. A head already at + /// (or past) this height means a peer's block won the race on the + /// channel โ€” ours is stale and the caller drops it. + pub fn apply_produced(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { + if self + .head_tip() + .is_some_and(|tip| block.header.block_id <= tip.block_id) + { + return AcceptOutcome::AlreadyApplied; + } + self.apply_adopted(this_msg, block) + } + + /// Reverts an orphaned head block and everything after it, then re-derives head. + pub fn revert_orphan(&mut self, this_msg: MsgId, block: &Block) { + if let Some(idx) = self.head_position_of(this_msg, block) { + self.head_blocks.truncate(idx); + self.rederive_head(); + } + } + + /// One channel update: revert every `orphaned` (one truncate + re-derive), + /// then apply every `adopted` in order. Outcomes align with `adopted`. + pub fn apply_channel_update( + &mut self, + orphaned: &[(MsgId, Block)], + adopted: &[(MsgId, Block)], + ) -> Vec { + let earliest = orphaned + .iter() + .filter_map(|(msg, block)| self.head_position_of(*msg, block)) + .min(); + if let Some(idx) = earliest { + self.head_blocks.truncate(idx); + self.rederive_head(); + } + adopted + .iter() + .map(|(msg, block)| self.apply_adopted(*msg, block)) + .collect() + } + + /// Rebuilds one head entry from a persisted block, applying it in place (the + /// caller treats `Err` as fatal). + /// + /// The entry gets a hash-derived sentinel `MsgId` (the real one is not + /// persisted โ€” that would need a sidecar `block_id -> MsgId` cell); later + /// orphan/finalize events correlate by block hash. + pub fn restore_head_block(&mut self, block: Block) -> Result<(), BlockIngestError> { + apply_block(self.head_tip().as_ref(), &block, &mut self.head_state)?; + let this_msg = MsgId::from(block.header.hash.0); + self.head_blocks.push(HeadEntry { this_msg, block }); + Ok(()) + } + + /// A finalized inscription. In steady state the block is already in head and is + /// moved into `final`; on backfill (not in head) it is applied directly and may + /// set `final_stall`. + pub fn apply_finalized( + &mut self, + this_msg: MsgId, + block: &Block, + l1_slot: Slot, + ) -> AcceptOutcome { + // Match by `MsgId` or block hash (re-inscriptions, restored entries). + if let Some(idx) = self.head_position_of(this_msg, block) { + self.finalize_through(idx); + return AcceptOutcome::Applied; + } + + // Finality is prefix-monotone: a finalized block chaining on an + // unfinalized head entry finalizes that prefix too. + if let Some(idx) = self + .head_blocks + .iter() + .position(|entry| entry.block.header.hash == block.header.prev_block_hash) + { + self.finalize_through(idx); + } + self.apply_finalized_direct(block, l1_slot) + } + + /// Moves `head_blocks[0..=idx]` into the final tier (already validated in head). + fn finalize_through(&mut self, idx: usize) { + let finalized: Vec = self.head_blocks.drain(0..=idx).collect(); + for entry in finalized { + apply_block(self.final_tip.as_ref(), &entry.block, &mut self.final_state) + .expect("validated head block must apply to the final tier"); + self.final_tip = Some(Tip::from(&entry.block)); + } + self.final_stall = None; + } + + /// Applies a finalized block straight to the final tier. On success the + /// finalized chain is authoritative, so head rebases onto it. + fn apply_finalized_direct(&mut self, block: &Block, l1_slot: Slot) -> AcceptOutcome { + // A finalized block at or below the final tip is a re-delivery: + // idempotent. A *different* block at the tip height falls through + // to validation and parks. + if let Some(tip) = &self.final_tip + && (block.header.block_id < tip.block_id + || (block.header.block_id == tip.block_id && block.header.hash == tip.hash)) + { + return AcceptOutcome::AlreadyApplied; + } + + let mut scratch = self.final_state.clone(); + match apply_block(self.final_tip.as_ref(), block, &mut scratch) { + Ok(()) => { + self.final_state = scratch; + self.final_tip = Some(Tip::from(block)); + self.final_stall = None; + // Any head suffix dropped here was already reverted as + // `orphaned` earlier in the same channel update (the sdk + // orders orphans before their finalized replacement), so its + // txs are back in the caller's mempool. + self.head_blocks.clear(); + self.head_state = self.final_state.clone(); + AcceptOutcome::Applied + } + Err(err) => { + self.record_final_stall(block, l1_slot, err.clone()); + AcceptOutcome::Parked(err) + } + } + } + + /// Rebuilds `head_state` from the final tier plus the current `head_blocks`. + fn rederive_head(&mut self) { + self.head_state = self.replay_head_prefix(self.head_blocks.len()).0; + } + + /// State and tip after replaying `head_blocks[..count]` on the final tier. + fn replay_head_prefix(&self, count: usize) -> (V03State, Option) { + let mut state = self.final_state.clone(); + let mut tip = self.final_tip.clone(); + for entry in &self.head_blocks[..count] { + apply_block(tip.as_ref(), &entry.block, &mut state) + .expect("validated head blocks must replay"); + tip = Some(Tip::from(&entry.block)); + } + (state, tip) + } + + /// First stall is stored verbatim; later ones only bump `orphans_since`. + fn record_final_stall(&mut self, block: &Block, l1_slot: Slot, error: BlockIngestError) { + self.final_stall = Some(self.final_stall.take().map_or_else( + || StallReason::new(Some(&block.header), l1_slot, error), + StallReason::escalate, + )); + } +} + +#[cfg(test)] +mod tests { + use common::{ + HashType, + test_utils::{create_transaction_native_token_transfer, produce_dummy_block}, + }; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + fn msg(n: u8) -> MsgId { + MsgId::from([n; 32]) + } + + fn slot(n: u64) -> Slot { + Slot::from(n) + } + + /// `head_state` equals `final_state` replayed through `head_blocks`. + fn assert_head_matches_replay(chain: &ChainState) { + let mut state = chain.final_state.clone(); + let mut tip = chain.final_tip.clone(); + for entry in &chain.head_blocks { + apply_block(tip.as_ref(), &entry.block, &mut state).expect("head blocks must replay"); + tip = Some(Tip::from(&entry.block)); + } + assert_eq!( + borsh::to_vec(&state).expect("state serializes"), + borsh::to_vec(chain.head_state()).expect("state serializes"), + "head_state must equal final_state replayed through head_blocks" + ); + } + + #[test] + fn adopted_blocks_advance_head() { + let mut chain = ChainState::new(initial_state()); + + let genesis = produce_dummy_block(1, None, vec![]); + assert!(matches!( + chain.apply_adopted(msg(1), &genesis), + AcceptOutcome::Applied + )); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(2), &block2), + AcceptOutcome::Applied + )); + + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + // Nothing finalized yet. + assert!(chain.final_tip().is_none()); + } + + #[test] + fn adopted_bad_block_freezes_head_without_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // Skips ahead (id 3 while head tip is 1). + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(3), &bad), + AcceptOutcome::Parked(BlockIngestError::UnexpectedBlockId { + expected: 2, + got: 3 + }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!( + chain.final_stall().is_none(), + "head freeze records no stall" + ); + } + + #[test] + fn adopted_is_idempotent() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + assert!(matches!( + chain.apply_adopted(msg(1), &genesis), + AcceptOutcome::AlreadyApplied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + } + + #[test] + fn orphan_reverts_head() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + chain.revert_orphan(msg(3), &block3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + + // A competing block 3 now applies cleanly on block 2. + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(13), &block3_prime), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn channel_update_reverts_then_applies() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let outcomes = chain.apply_channel_update(&[(msg(3), block3)], &[(msg(13), block3_prime)]); + assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied])); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn finalize_moves_head_into_final() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // Finalize through block 2. + assert!(matches!( + chain.apply_finalized(msg(2), &block2, slot(100)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + // Head tip unchanged; head still ends at 3. + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn backfill_applies_directly_to_final() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + assert!(matches!( + chain.apply_finalized(msg(1), &genesis, slot(10)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 1); + // Head mirrors final during backfill. + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + } + + #[test] + fn invalid_finalized_block_sets_final_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + // Skip-ahead finalized block, not in head: parks the final tier. + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(3), &bad, slot(20)), + AcceptOutcome::Parked(_) + )); + let stall = chain.final_stall().expect("final stall recorded"); + assert_eq!(stall.block_id, Some(3)); + } + + #[test] + fn orphaning_a_suffix_rederives_head_state() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key); + let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]); + chain.apply_adopted(msg(4), &block4); + + // Orphaning block 3 drops the whole suffix (3 and 4). + chain.revert_orphan(msg(3), &block3); + + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn channel_update_replaces_multi_block_suffix() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key); + let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]); + chain.apply_adopted(msg(4), &block4); + + // A competing branch replaces blocks 3 and 4; orphans arrive unordered. + let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 20, &sign_key); + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![tx3_prime]); + let tx4_prime = create_transaction_native_token_transfer(from, 2, to, 30, &sign_key); + let block4_prime = produce_dummy_block(4, Some(block3_prime.header.hash), vec![tx4_prime]); + + let outcomes = chain.apply_channel_update( + &[(msg(4), block4), (msg(3), block3)], + &[(msg(13), block3_prime), (msg(14), block4_prime)], + ); + + assert!(matches!( + outcomes.as_slice(), + [AcceptOutcome::Applied, AcceptOutcome::Applied] + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 4); + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9940); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20060); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_only_channel_update_replaces_suffix() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + // The replacement branch arrives with no orphan events: the adopted + // list alone reorgs the head. + let tx2_prime = create_transaction_native_token_transfer(from, 0, to, 20, &sign_key); + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2_prime]); + let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 30, &sign_key); + let block3_prime = produce_dummy_block(3, Some(block2_prime.header.hash), vec![tx3_prime]); + + let outcomes = + chain.apply_channel_update(&[], &[(msg(12), block2_prime), (msg(13), block3_prime)]); + + assert!(matches!( + outcomes.as_slice(), + [AcceptOutcome::Applied, AcceptOutcome::Applied] + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20050); + assert_head_matches_replay(&chain); + } + + #[test] + fn channel_update_ignores_unknown_orphan() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let unknown = produce_dummy_block(9, Some(HashType([7; 32])), vec![]); + let outcomes = chain.apply_channel_update(&[(msg(99), unknown)], &[(msg(3), block3)]); + + assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied])); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_competitor_reorgs_head_without_orphan_event() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + // A valid competitor at height 2, no orphan events: the head reorgs + // back onto it, dropping the old 2..=3 suffix and its transfers. + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &block2_prime), + AcceptOutcome::Applied + )); + let tip = chain.head_tip().expect("head tip"); + assert_eq!(tip.block_id, 2); + assert_eq!(tip.hash, block2_prime.header.hash); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000); + assert_head_matches_replay(&chain); + } + + #[test] + fn produced_block_losing_a_race_does_not_reorg_the_head() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // A peer's block wins height 2 on the channel. + let peer = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(2), &peer); + + // Our own block at that height is not on the channel, so โ€” unlike an + // adopted competitor โ€” it must not reorg the head onto itself. + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + chain.apply_produced(msg(12), &ours), + AcceptOutcome::AlreadyApplied + )); + assert_eq!(chain.head_tip().expect("head tip").hash, peer.header.hash); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000); + assert_head_matches_replay(&chain); + } + + #[test] + fn produced_block_extending_the_head_applies() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_produced(msg(2), &ours), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").hash, ours.header.hash); + assert_head_matches_replay(&chain); + } + + #[test] + fn invalid_adopted_competitor_leaves_head_intact() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // A competitor at height 2 with a bogus parent parks; the truncation + // is not committed, so the 2..=3 suffix survives. + let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &bad), + AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_conflicting_with_final_tip_is_ignored() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // Finalized is irreversible: an adopted competitor at (or below) the + // final tip is ignored, not reorged onto. + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &block2_prime), + AcceptOutcome::AlreadyApplied + )); + assert_eq!( + chain.final_tip().expect("final tip").hash, + block2.header.hash + ); + assert_eq!(chain.head_tip().expect("head tip").hash, block2.header.hash); + assert_head_matches_replay(&chain); + } + + #[test] + fn restore_head_block_rebuilds_head_and_correlates_by_hash() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + // Restart shape: final tier from a persisted snapshot, head rebuilt from + // stored blocks under hash-derived sentinel MsgIds. + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + let mut chain = ChainState::from_final(state, Some(Tip::from(&genesis))); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + for block in [&block2, &block3] { + chain + .restore_head_block(block.clone()) + .expect("stored blocks must replay"); + } + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + + // The L1 orphans restored block 3 under its real (unknown-to-us) MsgId: + // correlated by hash, the revert works and a competitor applies. + chain.revert_orphan(msg(33), &block3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(13), &block3_prime), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn restore_head_block_rejects_non_chaining_block() { + let mut chain = ChainState::new(initial_state()); + let skipped = produce_dummy_block(3, Some(HashType([9; 32])), vec![]); + assert!(chain.restore_head_block(skipped).is_err()); + } + + #[test] + fn finalized_hash_alias_with_wrong_id_is_not_absorbed() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // A malformed message reusing genesis's hash under a different claimed + // id must not match the held entry as a re-delivery; it falls through + // to validation and parks. + let mut alias = genesis.clone(); + alias.header.block_id = 6; + assert!(matches!( + chain.apply_finalized(msg(66), &alias, slot(10)), + AcceptOutcome::Parked(_) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!(chain.final_tip().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_reinscription_matches_by_block_hash() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // Block 2 finalizes re-inscribed under a fresh MsgId: matched by hash, + // finalized through, and the head above it survives. + assert!(matches!( + chain.apply_finalized(msg(42), &block2, slot(5)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert!(chain.final_stall().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalize_through_preserves_head_state_and_advances_final_state() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + chain.apply_finalized(msg(2), &block2, slot(10)); + + // Head still reflects both transfers + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20020); + // ...while final reflects only the finalized prefix. + assert_eq!(chain.final_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn head_self_heals_with_valid_competitor_after_park() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // Correct id, wrong parent: parked, head frozen at 1, no stall. + let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_adopted(msg(2), &bad), + AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!(chain.final_stall().is_none()); + + // A valid competitor at the same height applies without any reorg event. + let good = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &good), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn repeated_invalid_finalized_bumps_orphans_since() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + let bad3 = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(3), &bad3, slot(20)); + let bad5 = produce_dummy_block(5, Some(bad3.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(5), &bad5, slot(30)), + AcceptOutcome::Parked(_) + )); + + let stall = chain.final_stall().expect("final stall recorded"); + assert_eq!(stall.block_id, Some(3), "first stall reason is preserved"); + assert_eq!(stall.orphans_since, 1); + } + + #[test] + fn valid_finalized_successor_clears_final_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(3), &bad, slot(20)); + assert!(chain.final_stall().is_some()); + + // The valid successor of the frozen final tip finalizes: stall clears. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(2), &block2, slot(30)), + AcceptOutcome::Applied + )); + assert!(chain.final_stall().is_none()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_successor_of_head_entry_finalizes_the_prefix() { + // Head holds unfinalized blocks 1..=2 (e.g. restored after a restart); + // a peer block 3 we never saw adopted arrives finalized. Its ancestry + // finalizes our prefix implicitly, then 3 applies to final directly. + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + assert!(chain.final_tip().is_none()); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(3), &block3, slot(10)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert!(chain.final_stall().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_redelivery_at_or_below_final_tip_is_already_applied() { + // Restart shape: the store's tip (incl. not-yet-finalized blocks) is + // restored as the final tier, so their later finalization arrives for + // blocks that were never in `head_blocks`. + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // Below the tip, and at the tip with a matching hash: idempotent. + assert!(matches!( + chain.apply_finalized(msg(41), &genesis, slot(30)), + AcceptOutcome::AlreadyApplied + )); + assert!(matches!( + chain.apply_finalized(msg(42), &block2, slot(30)), + AcceptOutcome::AlreadyApplied + )); + assert!(chain.final_stall().is_none()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn conflicting_finalized_at_final_tip_parks() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // A different finalized block at the final height: finalized is + // irreversible, so this is a genuine stall, not a re-delivery. + let block2_prime = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_finalized(msg(22), &block2_prime, slot(30)), + AcceptOutcome::Parked(_) + )); + assert!(chain.final_stall().is_some()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + } + + #[test] + fn finalized_unknown_block_rebases_head() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + // Head advances on a competing branchโ€ฆ + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(2), &block2a); + + // โ€ฆbut a different block 2 finalizes. The finalized chain is + // authoritative, so head rebases onto it. + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + chain.apply_finalized(msg(22), &block2b, slot(20)), + AcceptOutcome::Applied + )); + + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn head_state_reflects_applied_transfers() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + chain.apply_adopted(msg(2), &block2); + + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + } +} diff --git a/lez/chain_consistency/src/consistency.rs b/lez/chain_state/src/consistency.rs similarity index 100% rename from lez/chain_consistency/src/consistency.rs rename to lez/chain_state/src/consistency.rs diff --git a/lez/chain_state/src/ingest_error.rs b/lez/chain_state/src/ingest_error.rs new file mode 100644 index 00000000..d259b5f7 --- /dev/null +++ b/lez/chain_state/src/ingest_error.rs @@ -0,0 +1,80 @@ +use common::HashType; +use serde::{Deserialize, Serialize}; + +/// Why an L2 block from the channel could not be applied. +/// +/// Persisted in `RocksDB` (as part of [`crate::StallReason`]), so every variant +/// must be `Clone + Serialize + Deserialize`. +#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)] +pub enum BlockIngestError { + #[error("Failed to deserialize L2 block: {0}")] + /// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`]. + Deserialize(String), + #[error("Unexpected block id: expected {expected}, got {got}")] + UnexpectedBlockId { expected: u64, got: u64 }, + #[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")] + BrokenChainLink { + expected_prev: HashType, + got_prev: HashType, + }, + #[error("Block hash mismatch: computed {computed}, header {header}")] + HashMismatch { + computed: HashType, + header: HashType, + }, + #[error("Block has no transactions")] + EmptyBlock, + #[error("Last transaction must be the public clock invocation for the block timestamp")] + InvalidClockTransaction, + #[error("Genesis block must contain only public transactions")] + NonPublicGenesisTransaction, + #[error("State transition failed at transaction {tx_index}: {reason}")] + StateTransition { + /// Index of the failing transaction within the block body. + tx_index: u64, + /// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`. + /// + /// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we + /// cannot store it directly. + reason: String, + }, +} + +impl BlockIngestError { + /// Whether the failure may be transient rather than a property of the block. + /// + /// FIXME: `StateTransition` is too coarse โ€” its `reason` string mixes genuine + /// state-transition rejections with infra failures (risc0 executor teardown, + /// storage errors). Once it carries a structured cause, narrow this so only + /// infra failures retry. + #[must_use] + pub const fn is_retryable(&self) -> bool { + matches!(self, Self::StateTransition { .. }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_and_round_trips_externally_tagged() { + let err = BlockIngestError::UnexpectedBlockId { + expected: 5, + got: 7, + }; + let value = serde_json::to_value(&err).expect("serialize"); + assert_eq!( + value, + serde_json::json!({ "UnexpectedBlockId": { "expected": 5, "got": 7 } }) + ); + let back: BlockIngestError = serde_json::from_value(value).expect("deserialize"); + assert!(matches!( + back, + BlockIngestError::UnexpectedBlockId { + expected: 5, + got: 7 + } + )); + } +} diff --git a/lez/chain_state/src/lib.rs b/lez/chain_state/src/lib.rs new file mode 100644 index 00000000..89357af0 --- /dev/null +++ b/lez/chain_state/src/lib.rs @@ -0,0 +1,17 @@ +//! Storage-free chain-state core shared by the LEZ sequencer and indexer: +//! the [`apply_block`] entry point plus [`BlockIngestError`], [`StallReason`], +//! [`Tip`], and [`AcceptOutcome`]. See [`ChainState`] for the two-tier model. + +pub use apply::{AcceptOutcome, Tip, apply_block, apply_block_to_state, validate_against_tip}; +pub use chain::{ChainState, HeadEntry}; +pub use consistency::{ + Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency, +}; +pub use ingest_error::BlockIngestError; +pub use stall_reason::StallReason; + +pub mod apply; +pub mod chain; +pub mod consistency; +pub mod ingest_error; +pub mod stall_reason; diff --git a/lez/chain_state/src/stall_reason.rs b/lez/chain_state/src/stall_reason.rs new file mode 100644 index 00000000..e1d9b3ac --- /dev/null +++ b/lez/chain_state/src/stall_reason.rs @@ -0,0 +1,50 @@ +use common::{HashType, block::BlockHeader}; +use logos_blockchain_zone_sdk::Slot; +use serde::{Deserialize, Serialize}; + +use crate::ingest_error::BlockIngestError; + +/// Diagnostic record of the first block that broke the L2 chain. +/// +/// The block-derived fields are `None` for a deserialize break (no header was +/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at. +/// `first_seen` is the breaking block's L2 timestamp (`None` for a deserialize break). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StallReason { + pub block_id: Option, + pub block_hash: Option, + pub prev_block_hash: Option, + pub l1_slot: Slot, + pub error: BlockIngestError, + pub first_seen: Option, + /// Number of later non-chaining blocks (orphans, since the tip is frozen). + /// + /// TODO: We could store a different "branch" of blocks following this break, but for now we + /// just count them. + pub orphans_since: u64, +} + +impl StallReason { + /// First stall for a break, built from the breaking block's header + /// (`None` for a deserialize break). + #[must_use] + pub fn new(header: Option<&BlockHeader>, l1_slot: Slot, error: BlockIngestError) -> Self { + Self { + block_id: header.map(|header| header.block_id), + block_hash: header.map(|header| header.hash), + prev_block_hash: header.map(|header| header.prev_block_hash), + first_seen: header.map(|header| header.timestamp), + l1_slot, + error, + orphans_since: 0, + } + } + + /// A later stall on the same break: bumps `orphans_since`, preserving the + /// original cause. + #[must_use] + pub const fn escalate(mut self) -> Self { + self.orphans_since = self.orphans_since.saturating_add(1); + self + } +} diff --git a/lez/common/src/block.rs b/lez/common/src/block.rs index b22eec9f..53a2f033 100644 --- a/lez/common/src/block.rs +++ b/lez/common/src/block.rs @@ -13,6 +13,15 @@ pub struct BlockMeta { pub hash: BlockHash, } +impl From<&Block> for BlockMeta { + fn from(block: &Block) -> Self { + Self { + id: block.header.block_id, + hash: block.header.hash, + } + } +} + #[derive(Debug, Clone)] /// Our own hasher. /// Currently it is SHA256 hasher wrapper. May change in a future. diff --git a/lez/indexer/core/Cargo.toml b/lez/indexer/core/Cargo.toml index cbe0b7e8..afba6f29 100644 --- a/lez/indexer/core/Cargo.toml +++ b/lez/indexer/core/Cargo.toml @@ -12,8 +12,8 @@ default = [] testnet = [] [dependencies] +chain_state.workspace = true common.workspace = true -chain_consistency.workspace = true logos-blockchain-zone-sdk.workspace = true lee.workspace = true lee_core.workspace = true diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index 16dc266d..ce6cfc61 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -1,7 +1,9 @@ use std::{path::Path, sync::Arc}; use anyhow::{Context as _, Result}; -use chain_consistency::{BlockIngestError, Tip}; +use chain_state::{ + AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip, +}; use common::{ block::{BedrockStatus, Block, BlockHeader}, transaction::LeeTransaction, @@ -14,23 +16,6 @@ use logos_blockchain_zone_sdk::Slot; use storage::indexer::RocksDBIO; use tokio::sync::RwLock; -use crate::status::StallReason; - -/// Outcome of feeding a parsed L2 block to the validated tip. -pub enum AcceptOutcome { - /// Chained and applied; tip and L1 read cursor both advance. - Applied, - /// A duplicate re-delivery of the current tip. Just L2 advances. - AlreadyApplied, - /// Did not chain or failed to apply; tip stays frozen, stall recorded. - Parked(BlockIngestError), - /// Chained but failed to apply, possibly transiently - /// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state - /// untouched. The caller retries and parks via - /// [`IndexerStore::record_stall`] once it gives up. - RetryableFailure(BlockIngestError), -} - #[derive(Clone)] pub struct IndexerStore { dbio: Arc, @@ -201,10 +186,7 @@ impl IndexerStore { let Some(block) = self.dbio.get_block(block_id)? else { return Ok(None); }; - Ok(Some(Tip { - block_id, - hash: block.header.hash, - })) + Ok(Some(Tip::from(&block))) } /// Record the stall reason. @@ -217,22 +199,10 @@ impl IndexerStore { l1_slot: Slot, error: BlockIngestError, ) -> Result<()> { - let stall = match self.get_stall_reason()? { - Some(mut existing) => { - existing.orphans_since = existing.orphans_since.saturating_add(1); - existing - } - None => StallReason { - // need to map out of `header` because they are not ser/de - block_id: header.map(|h| h.block_id), - block_hash: header.map(|h| h.hash), - prev_block_hash: header.map(|h| h.prev_block_hash), - first_seen: header.map(|h| h.timestamp), - l1_slot, - error, - orphans_since: 0, - }, - }; + let stall = self.get_stall_reason()?.map_or_else( + || StallReason::new(header, l1_slot, error), + StallReason::escalate, + ); self.set_stall_reason(&Some(stall)) } @@ -252,14 +222,16 @@ impl IndexerStore { return Ok(AcceptOutcome::AlreadyApplied); } - if let Err(err) = chain_consistency::validate_against_tip(tip, block) { + // Validate before paying for the scratch clone; validation failures + // are never retryable, so parking immediately is exact. + if let Err(err) = validate_against_tip(tip.as_ref(), block) { self.record_stall(Some(&block.header), l1_slot, err.clone())?; return Ok(AcceptOutcome::Parked(err)); } // TODO: we use scratch state to be atomic, but need to revisit how expensive a clone is let mut scratch = self.current_state.read().await.clone(); - if let Err(err) = chain_consistency::apply_block(block, &mut scratch) { + if let Err(err) = apply_block_to_state(block, &mut scratch) { if err.is_retryable() { return Ok(AcceptOutcome::RetryableFailure(err)); } @@ -434,7 +406,6 @@ mod tests { #[cfg(test)] mod accept_tests { - use chain_consistency::BlockIngestError; use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block}; use super::*; diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index e6cd9191..4cf7d58f 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -2,8 +2,8 @@ use std::{path::Path, sync::Arc}; use anyhow::Result; use arc_swap::ArcSwap; -pub use chain_consistency::BlockIngestError; -use chain_consistency::{Anchor, ChainConsistency}; +pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason}; +use chain_state::{Anchor, ChainConsistency}; use common::block::Block; // TODO: Remove after testnet use futures::StreamExt as _; @@ -12,10 +12,9 @@ use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, }; use retry::ApplyRetryGate; -pub use status::StallReason; use crate::{ - block_store::{AcceptOutcome, IndexerStore}, + block_store::IndexerStore, config::IndexerConfig, cross_zone_verifier::CrossZoneVerifier, status::{IndexerStatus, IndexerSyncStatus}, @@ -128,8 +127,7 @@ impl IndexerCore { return Ok(ChainConsistency::Inconclusive); }; - chain_consistency::verify_chain_consistency(&self.node, self.config.channel_id, &anchor) - .await + chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &anchor).await } /// Builds the anchor for the startup check. diff --git a/lez/indexer/core/src/status.rs b/lez/indexer/core/src/status.rs index 1307238b..aa182c23 100644 --- a/lez/indexer/core/src/status.rs +++ b/lez/indexer/core/src/status.rs @@ -1,27 +1,5 @@ -use chain_consistency::BlockIngestError; -use common::HashType; -use logos_blockchain_zone_sdk::Slot; -use serde::{Deserialize, Serialize}; - -/// Diagnostic record of the first block that broke the L2 chain. -/// -/// The block-derived fields are `None` for a deserialize break (no header was -/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at. -/// `first_seen` is the breaking block's L2 timestamp (`None` for a deserialize break). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StallReason { - pub block_id: Option, - pub block_hash: Option, - pub prev_block_hash: Option, - pub l1_slot: Slot, - pub error: BlockIngestError, - pub first_seen: Option, - /// Number of later non-chaining blocks (orphans, since the tip is frozen). - /// - /// TODO: We could store a different "branch" of blocks following this break, but for now we - /// just count them. - pub orphans_since: u64, -} +use chain_state::StallReason; +use serde::Serialize; /// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell /// "still catching up" apart from "something went wrong". @@ -138,6 +116,7 @@ mod tests { #[test] fn stalled_status_serializes_with_stall_reason() { + use chain_state::{BlockIngestError, StallReason}; use logos_blockchain_zone_sdk::Slot; let status = IndexerStatus { diff --git a/lez/keycard_wallet/Cargo.toml b/lez/keycard_wallet/Cargo.toml index 4abff1f1..33fabfb4 100644 --- a/lez/keycard_wallet/Cargo.toml +++ b/lez/keycard_wallet/Cargo.toml @@ -9,8 +9,11 @@ workspace = true [dependencies] lee.workspace = true -pyo3.workspace = true +keycard-rs.workspace = true +bip39.workspace = true +hex.workspace = true log.workspace = true -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true +pcsc.workspace = true +rand.workspace = true +thiserror.workspace = true zeroize.workspace = true diff --git a/lez/keycard_wallet/keycard_applets/LEE_keycard.cap b/lez/keycard_wallet/keycard_applets/LEE_keycard.cap deleted file mode 100644 index b2e71d56..00000000 Binary files a/lez/keycard_wallet/keycard_applets/LEE_keycard.cap and /dev/null differ diff --git a/lez/keycard_wallet/keycard_applets/math.cap b/lez/keycard_wallet/keycard_applets/math.cap deleted file mode 100644 index b9c0e99f..00000000 Binary files a/lez/keycard_wallet/keycard_applets/math.cap and /dev/null differ diff --git a/lez/keycard_wallet/python/keycard_wallet.py b/lez/keycard_wallet/python/keycard_wallet.py deleted file mode 100644 index 21e966cb..00000000 --- a/lez/keycard_wallet/python/keycard_wallet.py +++ /dev/null @@ -1,221 +0,0 @@ -from smartcard.System import readers -from keycard.exceptions import APDUError, TransportError -from ecdsa import VerifyingKey, SECP256k1 - -from keycard.keycard import KeyCard -from keycard.commands.export_lee_key import export_lee_key -from mnemonic import Mnemonic -from keycard import constants - -import os -import secrets - -DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing" - -def _pairing_password() -> str: - return os.environ.get("KEYCARD_PAIRING_PASSWORD", DEFAULT_PAIRING_PASSWORD) - -class KeycardWallet: - def __init__(self): - self.card = KeyCard() - - def _is_smart_card_reader_detected(self) -> bool: - try: - return len(readers()) > 0 - except Exception: - return False - - def _is_keycard_detected(self) -> bool: - try: - KeyCard().select() - return True - except (TransportError, APDUError, Exception): - # No readers, no card, or card doesn't respond. - return False - - def is_unpaired_keycard_available(self) -> bool: - if not self._is_smart_card_reader_detected(): - return False - elif not self._is_keycard_detected(): - return False - return True - - def initialize(self, pin: str, pairing_password: str | None = None) -> bool: - try: - self.card.select() - - if self.card.is_initialized: - raise RuntimeError("Card is already initialized") - - puk = ''.join(secrets.choice('0123456789') for _ in range(12)) - self.card.init(pin, puk, pairing_password or _pairing_password()) - print(f"Keycard PUK: {puk}") - print("Record this PUK and store it somewhere safe. It cannot be recovered.") - return True - except Exception as e: - raise RuntimeError(f"Error initializing keycard: {e}") from e - - def _reconnect(self) -> None: - self.card = KeyCard() - self.card.select() - - def _pair(self, pin: str, password: str) -> tuple[int, bytes]: - self.card.select() - - if not self.card.is_initialized: - raise RuntimeError("Card is not initialized โ€” run 'wallet keycard init' first") - - pairing_index, pairing_key = self.card.pair(password) - self.pairing_index = pairing_index - self.pairing_key = pairing_key - - try: - self.card.open_secure_channel(pairing_index, pairing_key) - self.card.verify_pin(pin) - except Exception as e: - try: - self.card.unpair(pairing_index) - except Exception: - pass - raise RuntimeError(f"Error opening secure channel after fresh pair: {e}") from e - - return pairing_index, pairing_key - - def pair(self, pin: str, password: str | None = None) -> tuple[int, bytes]: - password = password or _pairing_password() - try: - return self._pair(pin, password) - except TransportError as e: - print(f"Transport error during fresh pair ({e}), attempting card reset and retry...") - try: - self._reconnect() - result = self._pair(pin, password) - print("Retry succeeded after card reset.") - return result - except TransportError as e2: - raise RuntimeError( - "Card lost power and did not recover after reset. " - "Try reseating the card in the reader." - ) from e2 - - def _setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: - self.card.select() - - if not self.card.is_initialized: - raise RuntimeError("Card is not initialized โ€” run 'wallet keycard init' first") - - self.pairing_index = pairing_index - self.pairing_key = pairing_key - - try: - self.card.open_secure_channel(pairing_index, pairing_key) - self.card.verify_pin(pin) - except Exception as e: - raise RuntimeError(f"Error setting up communication with stored pairing: {e}") from e - - return True - - def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: - try: - return self._setup_communication_with_pairing(pin, pairing_index, pairing_key) - except TransportError as e: - print(f"Transport error during stored pairing ({e}), attempting card reset and retry...") - try: - self._reconnect() - result = self._setup_communication_with_pairing(pin, pairing_index, pairing_key) - print("Retry succeeded after card reset.") - return result - except TransportError as e2: - raise RuntimeError( - "Card lost power and did not recover after reset. " - "Try reseating the card in the reader." - ) from e2 - - def close_session(self) -> bool: - return True - - def load_mnemonic(self, mnemonic: str) -> bool: - try: - # Convert mnemonic to seed - mnemo = Mnemonic("english") - if not mnemo.check(mnemonic): - raise RuntimeError("Invalid mnemonic phrase โ€” check spelling and word count") - seed = mnemo.to_seed(mnemonic) - - # Load the LEE seed onto the card - result = self.card.load_key( - key_type = constants.LoadKeyType.LEE_SEED, - lee_seed = seed - ) - return True - except Exception as e: - raise RuntimeError(f"Error loading mnemonic: {e}") from e - - def disconnect(self) -> bool: - try: - if not self.card.is_secure_channel_open: - return False - - self.card.unpair(self.pairing_index) - - return True - except Exception as e: - raise RuntimeError(f"Error during disconnect: {e}") from e - - def get_public_key_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - public_key = self.card.export_key( - derivation_option = constants.DerivationOption.DERIVE, - public_only = True, - keypath = path - ) - - public_key = public_key.public_key - public_key = VerifyingKey.from_string(public_key[1:], curve=SECP256k1) - public_key = public_key.to_string("compressed")[1:] - - return public_key - - except Exception as e: - raise RuntimeError(f"Error getting public key: {e}") from e - - - def sign_message_for_path(self, message: bytes, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - signature = self.card.sign_with_path( - digest = message, - path = path, - algorithm = constants.SigningAlgorithm.SCHNORR_BIP340, - make_current = False - ) - - return signature.signature - - except Exception as e: - raise RuntimeError(f"Error signing message: {e}") from e - - def get_private_keys_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - private_keys = export_lee_key( - self.card, - constants.DerivationOption.DERIVE, - path - ) - - nsk = private_keys.lee_nsk - vsk = private_keys.lee_vsk - - return (nsk, vsk) - - except Exception as e: - raise RuntimeError(f"Error getting private keys: {e}") from e - diff --git a/lez/keycard_wallet/src/bin/force_unpower.rs b/lez/keycard_wallet/src/bin/force_unpower.rs new file mode 100644 index 00000000..9cf07f1b --- /dev/null +++ b/lez/keycard_wallet/src/bin/force_unpower.rs @@ -0,0 +1,52 @@ +#![expect( + clippy::print_stdout, + reason = "This is a CLI test helper, printing to stdout is expected and convenient" +)] + +//! Forces the card in the first available reader into the unpowered state via PC/SC +//! `SCARD_UNPOWER_CARD`. Run immediately before a wallet command to simulate the power-loss +//! condition reported on some USB reader/driver combinations. +//! +//! Either: +//! - pcscd re-powers the card on the next `SCardConnect`, so wallet commands will succeed without +//! triggering the retry path. +//! - the card stays unpowered, triggering a PC/SC transport error (`keycard_rs::Error::Io`) and +//! exercising the reconnect-and-retry wrapper in `KeycardWallet::connect()`. + +fn main() { + let context = match pcsc::Context::establish(pcsc::Scope::User) { + Ok(context) => context, + Err(e) => { + println!("force_unpower: failed to establish PC/SC context ({e}), skipping."); + return; + } + }; + + let readers = match context.list_readers_owned() { + Ok(readers) => readers, + Err(e) => { + println!("force_unpower: failed to list readers ({e}), skipping."); + return; + } + }; + + let Some(reader) = readers.first() else { + println!("force_unpower: no readers found, skipping."); + return; + }; + + let card = match context.connect(reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY) { + Ok(card) => card, + Err(e) => { + println!("force_unpower: connect failed ({e}), skipping."); + return; + } + }; + + if let Err((_card, e)) = card.disconnect(pcsc::Disposition::UnpowerCard) { + println!("force_unpower: disconnect failed ({e}), skipping."); + return; + } + + println!("force_unpower: card powered down."); +} diff --git a/lez/keycard_wallet/src/lib.rs b/lez/keycard_wallet/src/lib.rs index a3ecd140..f7d521cd 100644 --- a/lez/keycard_wallet/src/lib.rs +++ b/lez/keycard_wallet/src/lib.rs @@ -1,159 +1,230 @@ -use std::path::PathBuf; +use std::str::FromStr as _; +use keycard_rs::{ + KeycardCommandSet, PcscChannel, + constants::sign_p2, + parsing::Bip32KeyPair, + secure_channel::SecureChannelVersion, + tlv::{BerTlvReader, TLV_KEY_TEMPLATE, TLV_PUB_KEY, TLV_SIGNATURE_TEMPLATE}, +}; use lee::{AccountId, PublicKey, Signature}; -use pyo3::{prelude::*, types::PyAny}; -use serde::{Deserialize, Serialize}; +use rand::Rng as _; use zeroize::Zeroizing; -pub mod python_path; +/// LEE-applet extension tags. `keycard-rs` implements *sending* the LEE commands +/// (`load_lee_key`, `export_lee_key`) but never added parsing for their LEE-specific responses, +/// so these tag values โ€” owned by the applet, not either client library โ€” have to be hardcoded +/// here. Matches `KeycardApplet.TLV_LEE_NSK`/`TLV_LEE_VSK` in `status-keycard` +/// (`KeycardApplet.java:97-98`), the applet `LEE_keycard.cap` was almost certainly built from. +const TLV_LEE_NSK: u8 = 0x83; +const TLV_LEE_VSK: u8 = 0x84; +/// Raw Schnorr signature (64 bytes: `r || s`, no ASN.1/DER wrapping) nested inside the standard +/// `TLV_SIGNATURE_TEMPLATE` alongside the usual `TLV_PUB_KEY`. Confirmed against real hardware โ€” +/// the LEE applet's `SIGN` response for `sign_p2::BIP340_SCHNORR` is +/// `0xA0 { 0x80 <65-byte pubkey>, 0x88 <64-byte r||s> }` โ€” and matches +/// `SECP256k1.TLV_RAW_SIGNATURE` in `status-keycard` (`SECP256k1.java:64`). +const TLV_LEE_RAW_SIGNATURE: u8 = 0x88; /// NSK (32 bytes) and VSK (64 bytes, the ML-KEM-768 seed `d || z`) as fixed-length zeroizing byte /// arrays. type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 64]>); -// TODO: encrypt at rest alongside broader wallet storage encryption work. -#[derive(Serialize, Deserialize)] -pub struct KeycardPairingData { - pub index: u8, - pub key: Vec, +#[derive(Debug, thiserror::Error)] +pub enum KeycardWalletError { + #[error(transparent)] + Keycard(#[from] keycard_rs::Error), + #[error("keycard is already initialized")] + AlreadyInitialized, + #[error( + "this wallet only supports Secure Channel V2 keycards (applet version >= 4.0); detected {0:?}" + )] + UnsupportedSecureChannel(Option), + #[error("invalid mnemonic phrase: {0}")] + InvalidMnemonic(String), + #[error("invalid key material from keycard: {0}")] + InvalidKeyMaterial(String), + #[error("keycard returned a signature that does not verify against its own public key")] + SignatureVerificationFailed, + #[error("invalid KEYCARD_CA_PUBLIC_KEY: {0}")] + InvalidCaPublicKey(String), } -impl KeycardPairingData { - const fn is_valid(&self) -> bool { - self.key.len() == 32 && self.index <= 4 - } -} - -/// Rust wrapper around the Python `KeycardWallet` class. +/// Rust wrapper around `keycard-rs`, talking to the LEE-flavored Keycard applet over PC/SC. +/// Only Secure Channel V2 cards are supported โ€” see `require_secure_channel_v2`. pub struct KeycardWallet { - instance: Py, + command_set: KeycardCommandSet, } impl KeycardWallet { - /// Create a new Python `KeycardWallet` instance. - pub fn new(py: Python) -> PyResult { - let module = py.import("keycard_wallet")?; - let class = module.getattr("KeycardWallet")?; - - let instance = class.call0()?; - + /// Connects to the first available PC/SC reader. Does not select the applet yet โ€” callers + /// that need application info (`initialize`, `connect`, ...) do that themselves. + /// + /// Verifies the card's identity certificate against `keycard-rs`'s default production CA, + /// unless overridden via `KEYCARD_CA_PUBLIC_KEY` โ€” see `ca_public_key_override`. + pub fn new() -> Result { + let channel = PcscChannel::connect()?; Ok(Self { - instance: instance.into(), + command_set: Self::build_command_set(channel)?, }) } - pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult { - self.instance - .bind(py) - .call_method0("is_unpaired_keycard_available")? - .extract() + fn build_command_set(channel: PcscChannel) -> Result { + Ok(match ca_public_key_override()? { + Some(ca) => KeycardCommandSet::new_with_ca(channel, ca), + None => KeycardCommandSet::new(channel), + }) } - pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult { - self.instance - .bind(py) - .call_method1("initialize", (pin,))? - .extract() - } - - pub fn pair(&self, py: Python<'_>, pin: &str) -> PyResult<(u8, Vec)> { - self.instance - .bind(py) - .call_method1("pair", (pin,))? - .extract() - } - - pub fn setup_communication_with_pairing( - &self, - py: Python<'_>, - pin: &str, - index: u8, - key: &[u8], - ) -> PyResult { - self.instance - .bind(py) - .call_method1( - "setup_communication_with_pairing", - (pin, index, key.to_vec()), - )? - .extract() - } - - pub fn close_session(&self, py: Python<'_>) -> PyResult { - self.instance - .bind(py) - .call_method0("close_session")? - .extract() - } - - /// Connect using a stored pairing if available, falling back to a fresh pair. - /// Saves any newly established pairing to disk. - pub fn connect(&self, py: Python<'_>, pin: &str) -> PyResult<()> { - if let Some(pairing) = load_pairing().filter(KeycardPairingData::is_valid) - && self - .setup_communication_with_pairing(py, pin, pairing.index, &pairing.key) - .is_ok() - { - return Ok(()); + /// Returns whether a smart card reader and a selectable, Secure-Channel-V2 Keycard are both + /// present. + #[must_use] + pub fn is_keycard_available() -> bool { + let Ok(channel) = PcscChannel::connect() else { + return false; + }; + let Ok(mut command_set) = Self::build_command_set(channel) else { + return false; + }; + if !command_set.select().is_ok_and(|resp| resp.is_ok()) { + return false; } - let (index, key) = self.pair(py, pin)?; - save_pairing(&KeycardPairingData { index, key }); + command_set.secure_channel_version() == Some(SecureChannelVersion::V2) + } + + fn select(&mut self) -> Result<(), KeycardWalletError> { + self.command_set.select()?.check_ok()?; Ok(()) } - pub fn disconnect(&self, py: Python) -> PyResult { - self.instance.bind(py).call_method0("disconnect")?.extract() + /// Rejects any card that isn't running Secure Channel V2 (older applets, or a card that + /// hasn't advertised a secure channel at all). Call right after `select()`. + fn require_secure_channel_v2(&self) -> Result<(), KeycardWalletError> { + match self.command_set.secure_channel_version() { + Some(SecureChannelVersion::V2) => Ok(()), + other => Err(KeycardWalletError::UnsupportedSecureChannel(other)), + } } - pub fn get_public_key_for_path(&self, py: Python, path: &str) -> PyResult { - let public_key: Vec = self - .instance - .bind(py) - .call_method1("get_public_key_for_path", (path,))? - .extract()?; - - let public_key: [u8; 32] = public_key.try_into().map_err(|vec: Vec| { - PyErr::new::(format!( - "expected 32-byte public key from keycard, got {} bytes", - vec.len() - )) - })?; - - PublicKey::try_new(public_key) - .map_err(|e| PyErr::new::(e.to_string())) + /// Rebuilds the PC/SC channel and command set, then retries `op` once, if `op` failed with a + /// transport-level error (e.g. the card lost power mid-session). + fn with_reconnect_on_transport_error( + &mut self, + op: impl Fn(&mut Self) -> Result, + ) -> Result { + match op(self) { + Err(KeycardWalletError::Keycard(keycard_rs::Error::Io(io_err))) => { + log::warn!( + "transport error during keycard operation ({io_err}), reconnecting and retrying once" + ); + *self = Self::new()?; + op(self) + } + result => result, + } } - pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let pub_key = wallet.get_public_key_for_path(py, path); - drop(wallet.close_session(py)); - pub_key + /// Initializes an uninitialized card, returning the generated PUK. The caller is responsible + /// for surfacing the PUK to the operator โ€” it cannot be recovered afterward. + pub fn initialize(&mut self, pin: &str) -> Result { + self.select()?; + self.require_secure_channel_v2()?; + let already_initialized = self + .command_set + .app_info() + .expect("select() populates app_info on success") + .is_initialized(); + if already_initialized { + return Err(KeycardWalletError::AlreadyInitialized); + } + + // V2's INIT has no shared-secret field (confirmed against real hardware and the + // applet's own reference command set: a payload containing one is rejected outright) โ€” + // pass an empty secret and let the card default the PIN/PUK retry counts. + let puk = generate_puk(); + self.command_set + .init_with_secret(pin, None, &puk, &[], 0, 0)? + .check_ok()?; + Ok(puk) + } + + /// Wipes the card's PIN, PUK, and loaded keys, returning it to an uninitialized state โ€” + /// the counterpart to `initialize()`. Does **not** remove the identity certificate + /// provisioned via `IdentApplet`, so the card can be re-`initialize()`d afterward without + /// re-personalizing it. Irreversibly destroys any keys currently loaded on the card. + pub fn factory_reset(&mut self) -> Result<(), KeycardWalletError> { + self.select()?; + self.require_secure_channel_v2()?; + self.command_set.factory_reset()?.check_ok()?; + Ok(()) + } + + /// Opens the secure channel and verifies the PIN. Secure Channel V2 re-authenticates from + /// the card's certificate every session โ€” there's no pairing step and nothing to persist. + pub fn connect(&mut self, pin: &str) -> Result<(), KeycardWalletError> { + self.with_reconnect_on_transport_error(|wallet| { + wallet.select()?; + wallet.require_secure_channel_v2()?; + wallet.command_set.auto_open_secure_channel()?; + wallet.command_set.verify_pin(pin)?.check_auth_ok()?; + Ok(()) }) } + pub fn get_public_key_for_path(&mut self, path: &str) -> Result { + let resp = self.command_set.export_key(path, false, true)?; + resp.check_ok()?; + let keypair = Bip32KeyPair::from_tlv(resp.data())?; + + // Uncompressed SEC1 point (0x04 || X || Y); the BIP340 x-only public key is just its X + // coordinate, since secp256k1 (what the card signs with) and k256's Schnorr verifying key + // are the same curve. + let public_key = keypair.public_key(); + let x_only: [u8; 32] = match public_key.split_first() { + Some((&0x04, xy)) if xy.len() == 64 => xy + .split_at(32) + .0 + .try_into() + .expect("split_at(32) of a 64-byte slice"), + _ => { + return Err(KeycardWalletError::InvalidKeyMaterial(format!( + "expected a 65-byte uncompressed secp256k1 public key from keycard, got {} bytes", + public_key.len() + ))); + } + }; + + PublicKey::try_new(x_only) + .map_err(|e| KeycardWalletError::InvalidKeyMaterial(e.to_string())) + } + + pub fn get_public_key_for_path_with_connect( + pin: &str, + path: &str, + ) -> Result { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + wallet.get_public_key_for_path(path) + } + pub fn sign_message_for_path( - &self, - py: Python, + &mut self, path: &str, message: &[u8; 32], - ) -> PyResult<(Signature, PublicKey)> { - let py_signature: Vec = self - .instance - .bind(py) - .call_method1("sign_message_for_path", (message, path))? - .extract()?; + ) -> Result<(Signature, PublicKey), KeycardWalletError> { + let resp = self.command_set.sign_with_path_and_algo( + message, + path, + sign_p2::BIP340_SCHNORR, + false, + )?; + resp.check_ok()?; let sig = Signature { - value: normalize_keycard_signature(py_signature)?, + value: parse_schnorr_signature(resp.data())?, }; - let pub_key = self.get_public_key_for_path(py, path)?; + let pub_key = self.get_public_key_for_path(path)?; if !sig.is_valid_for(message, &pub_key) { - return Err(PyErr::new::( - "keycard returned a signature that does not verify against its own public key", - )); + return Err(KeycardWalletError::SignatureVerificationFailed); } Ok((sig, pub_key)) } @@ -162,39 +233,40 @@ impl KeycardWallet { pin: &str, path: &str, message: &[u8; 32], - ) -> PyResult<(Signature, PublicKey)> { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let result = wallet.sign_message_for_path(py, path, message); - drop(wallet.close_session(py)); - result - }) + ) -> Result<(Signature, PublicKey), KeycardWalletError> { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + wallet.sign_message_for_path(path, message) } - pub fn load_mnemonic(&self, py: Python, mnemonic: &str) -> PyResult<()> { - self.instance - .bind(py) - .call_method1("load_mnemonic", (mnemonic,))?; + pub fn load_mnemonic(&mut self, mnemonic: &str) -> Result<(), KeycardWalletError> { + let mnemonic = bip39::Mnemonic::from_str(mnemonic) + .map_err(|e| KeycardWalletError::InvalidMnemonic(e.to_string()))?; + let seed = mnemonic.to_seed(""); + self.command_set.load_lee_key(&seed)?.check_ok()?; Ok(()) } pub fn get_public_account_id_for_path_with_connect( pin: &str, key_path: &str, - ) -> PyResult { + ) -> Result { let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?; Ok(format!("Public/{}", AccountId::from(&public_key))) } - pub fn get_private_keys_for_path(&self, py: Python, path: &str) -> PyResult { - let (raw_nsk, raw_vsk): (Vec, Vec) = self - .instance - .bind(py) - .call_method1("get_private_keys_for_path", (path,))? - .extract()?; + pub fn get_private_keys_for_path( + &mut self, + path: &str, + ) -> Result { + let resp = self.command_set.export_lee_key(path)?; + resp.check_ok()?; + + let mut reader = BerTlvReader::new(resp.data()); + reader.enter_constructed(TLV_KEY_TEMPLATE)?; + let raw_nsk = reader.read_primitive(TLV_LEE_NSK)?; + let raw_vsk = reader.read_primitive(TLV_LEE_VSK)?; let nsk = zeroizing_fixed_bytes::<32>("nullifier secret key", Zeroizing::new(raw_nsk))?; let vsk = zeroizing_fixed_bytes::<64>("view secret key", Zeroizing::new(raw_vsk))?; @@ -205,46 +277,66 @@ impl KeycardWallet { pub fn get_private_keys_for_path_with_connect( pin: &str, path: &str, - ) -> PyResult { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let result = wallet.get_private_keys_for_path(py, path); - drop(wallet.disconnect(py)); - result - }) + ) -> Result { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + wallet.get_private_keys_for_path(path) } } -/// The keycard Python library strips leading zeros from S when S < 2^(8k) for some k. -/// Left-pad S back to 32 bytes so the full signature is always 64 bytes (R || S). -#[expect( - clippy::arithmetic_side_effects, - reason = "64 - s_stripped.len() is safe: s_stripped.len() โ‰ค 31 because py_signature.len() is in [32, 63]" -)] -fn normalize_keycard_signature(py_signature: Vec) -> PyResult<[u8; 64]> { - if py_signature.len() < 64 { - if py_signature.len() < 32 { - return Err(PyErr::new::(format!( - "signature from keycard too short: {} bytes", - py_signature.len() - ))); - } - let s_stripped = &py_signature[32..]; - let mut padded = [0_u8; 64]; - padded[..32].copy_from_slice(&py_signature[..32]); - padded[(64 - s_stripped.len())..].copy_from_slice(s_stripped); - Ok(padded) - } else { - py_signature.try_into().map_err(|vec: Vec| { - PyErr::new::(format!( - "Invalid signature length: expected 64 bytes, got {} (bytes: {:02x?})", - vec.len(), - vec - )) - }) +fn generate_puk() -> String { + let mut rng = rand::rngs::OsRng; + std::iter::repeat_with(|| char::from(rng.gen_range(b'0'..=b'9'))) + .take(12) + .collect() +} + +/// Optional override for the CA public key used to verify a card's identity certificate, read +/// from `KEYCARD_CA_PUBLIC_KEY` as 66 hex characters (a 33-byte compressed secp256k1 key). +/// Falls back to `keycard-rs`'s production default when unset. +/// +/// This exists purely for testing against cards that weren't personalized through the real +/// production process โ€” e.g. `status-keycard`'s own `JUnit` suite signs test cards with a fixed, +/// throwaway CA that will never match the production default. Real users' cards should need no +/// override at all. +fn ca_public_key_override() -> Result, KeycardWalletError> { + let Ok(hex_str) = std::env::var("KEYCARD_CA_PUBLIC_KEY") else { + return Ok(None); + }; + let mut bytes = [0_u8; 33]; + hex::decode_to_slice(hex_str.trim(), &mut bytes) + .map_err(|e| KeycardWalletError::InvalidCaPublicKey(e.to_string()))?; + Ok(Some(bytes)) +} + +/// Parses a BIP340 Schnorr signature from a LEE `SIGN` response. +/// +/// Confirmed against real hardware: `TLV_SIGNATURE_TEMPLATE` (0xA0) contains the usual +/// `TLV_PUB_KEY` (0x80, 65 bytes, unused here โ€” the caller fetches the pubkey separately via +/// `export_key`) followed by `TLV_LEE_RAW_SIGNATURE` (0x88, 64 bytes: `r || s` with no ASN.1/DER +/// wrapping). `keycard-rs`'s own `RecoverableSignature` parser doesn't apply โ€” it's ECDSA-only +/// (attempts point recovery, which Schnorr doesn't have). +fn parse_schnorr_signature(data: &[u8]) -> Result<[u8; 64], KeycardWalletError> { + parse_schnorr_signature_inner(data).map_err(|e| { + KeycardWalletError::InvalidKeyMaterial(format!( + "failed to parse schnorr signature response ({e}); raw response bytes: {data:02x?}" + )) + }) +} + +fn parse_schnorr_signature_inner(data: &[u8]) -> Result<[u8; 64], KeycardWalletError> { + let mut reader = BerTlvReader::new(data); + reader.enter_constructed(TLV_SIGNATURE_TEMPLATE)?; + if reader.next_tag_is(TLV_PUB_KEY) { + reader.read_primitive(TLV_PUB_KEY)?; } + let sig = reader.read_primitive(TLV_LEE_RAW_SIGNATURE)?; + sig.try_into().map_err(|v: Vec| { + KeycardWalletError::InvalidKeyMaterial(format!( + "expected a 64-byte raw schnorr signature, got {} bytes", + v.len() + )) + }) } #[expect( @@ -254,9 +346,9 @@ fn normalize_keycard_signature(py_signature: Vec) -> PyResult<[u8; 64]> { fn zeroizing_fixed_bytes( label: &str, raw: Zeroizing>, -) -> PyResult> { +) -> Result, KeycardWalletError> { if raw.len() != N { - return Err(PyErr::new::(format!( + return Err(KeycardWalletError::InvalidKeyMaterial(format!( "expected {N}-byte {label} from keycard, got {} bytes", raw.len() ))); @@ -265,35 +357,3 @@ fn zeroizing_fixed_bytes( arr.copy_from_slice(&raw); Ok(arr) } - -fn pairing_file_path() -> Option { - let home = std::env::var("LEE_WALLET_HOME_DIR") - .map(PathBuf::from) - .or_else(|_| { - std::env::home_dir() - .map(|h| h.join(".lee").join("wallet")) - .ok_or(()) - }) - .ok()?; - Some(home.join("keycard_pairing.json")) -} - -fn load_pairing() -> Option { - let path = pairing_file_path()?; - let file = std::fs::File::open(path).ok()?; - serde_json::from_reader(file).ok() -} - -fn save_pairing(data: &KeycardPairingData) { - if let Some(path) = pairing_file_path() - && let Ok(json) = serde_json::to_vec_pretty(data) - { - drop(std::fs::write(path, json)); - } -} - -pub fn clear_pairing() { - if let Some(path) = pairing_file_path() { - drop(std::fs::remove_file(path)); - } -} diff --git a/lez/keycard_wallet/src/python_path.rs b/lez/keycard_wallet/src/python_path.rs deleted file mode 100644 index 61196ad5..00000000 --- a/lez/keycard_wallet/src/python_path.rs +++ /dev/null @@ -1,69 +0,0 @@ -use std::{env, path::PathBuf}; - -use pyo3::{prelude::*, types::PyList}; - -fn collect_python_paths() -> Vec { - let current_dir = env::current_dir().expect("Failed to get current working directory"); - - let python_base = env::var("VIRTUAL_ENV") - .ok() - .and_then(|v| PathBuf::from(v).parent().map(PathBuf::from)) - .unwrap_or_else(|| current_dir.clone()); - - let mut paths = vec![ - python_base - .join("lez") - .join("keycard_wallet") - .join("python"), - python_base - .join("lez") - .join("keycard_wallet") - .join("python") - .join("keycard-py"), - ]; - - // pyo3's embedded interpreter does not inherit sys.path from the shell, - // so venv site-packages must be added explicitly. - if let Ok(venv) = env::var("VIRTUAL_ENV") { - let lib = PathBuf::from(&venv).join("lib"); - if let Ok(entries) = std::fs::read_dir(&lib) { - for entry in entries.flatten() { - let site_packages = entry.path().join("site-packages"); - if site_packages.exists() { - paths.push(site_packages); - } - } - } - } - - paths -} - -/// Adds the project's `python/` directory and venv site-packages to Python's sys.path. -pub fn add_python_path(py: Python<'_>) -> PyResult<()> { - let paths = collect_python_paths(); - - for path in &paths { - if !path.exists() { - log::info!("Warning: Python path does not exist: {}", path.display()); - } - } - - let sys = PyModule::import(py, "sys")?; - let binding = sys.getattr("path")?; - let sys_path = binding.cast::()?; - - for path in &paths { - let path_str = path.to_str().expect("Invalid path"); - - let already_present = sys_path - .iter() - .any(|p| p.extract::<&str>().is_ok_and(|s| s == path_str)); - - if !already_present { - sys_path.insert(0, path_str)?; - } - } - - Ok(()) -} diff --git a/lez/keycard_wallet/tests/force_unpower.py b/lez/keycard_wallet/tests/force_unpower.py deleted file mode 100755 index 427d2028..00000000 --- a/lez/keycard_wallet/tests/force_unpower.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -""" -Forces the card in the first available reader into the unpowered state via -PC/SC SCARD_UNPOWER_CARD. Run immediately before a wallet command to simulate -the power-loss condition reported on some USB reader/driver combinations. - -Either: -- pcscd re-powers the card on the next SCardConnect, so wallet -commands will succeed without triggering the retry path. -- the card stays unpowered, triggering TransportError -and exercising the retry wrapper in pair() / setup_communication_with_pairing(). -""" -import sys -from smartcard.scard import ( - SCardEstablishContext, SCardListReaders, SCardConnect, SCardDisconnect, - SCARD_SCOPE_USER, SCARD_SHARE_SHARED, - SCARD_PROTOCOL_T0, SCARD_PROTOCOL_T1, - SCARD_UNPOWER_CARD, -) - -hresult, hcontext = SCardEstablishContext(SCARD_SCOPE_USER) -hresult, reader_list = SCardListReaders(hcontext, []) - -if not reader_list: - print("force_unpower: no readers found, skipping.") - sys.exit(0) - -hresult, hcard, _ = SCardConnect( - hcontext, - reader_list[0], - SCARD_SHARE_SHARED, - SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1, -) - -if hresult != 0: - print(f"force_unpower: SCardConnect failed (hresult={hresult:#010x}), skipping.") - sys.exit(0) - -SCardDisconnect(hcard, SCARD_UNPOWER_CARD) -print("force_unpower: card powered down.") diff --git a/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh index 3d8301f7..7440f735 100755 --- a/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh +++ b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh @@ -4,14 +4,13 @@ # Forces a card power cycle before each keycard-backed wallet command to verify # commands survive mid-session power loss. -source venv/bin/activate - export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" unpower() { - python "$SCRIPT_DIR/force_unpower.py" + cargo run -q --manifest-path "$SCRIPT_DIR/../Cargo.toml" --bin force_unpower } echo "Test: wallet keycard available" diff --git a/lez/keycard_wallet/tests/keycard_test_3.sh b/lez/keycard_wallet/tests/keycard_test_3.sh index d80e2aca..59e0ec6b 100755 --- a/lez/keycard_wallet/tests/keycard_test_3.sh +++ b/lez/keycard_wallet/tests/keycard_test_3.sh @@ -2,14 +2,12 @@ # keycard_test_3.sh โ€” tests for `wallet keycard get-private-keys`. # # Prerequisites: -# 1. Run wallet_with_keycard.sh once to install dependencies. -# 2. Keycard reader inserted with card loaded (wallet keycard load has been run). - -source venv/bin/activate +# 1. Keycard reader inserted with card loaded (wallet keycard load has been run). cargo install --path lez/wallet --force --features keycard-debug export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 echo "=== Test: wallet keycard get-private-keys path 10 ===" wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/10" --reveal diff --git a/lez/keycard_wallet/tests/keycard_tests.sh b/lez/keycard_wallet/tests/keycard_tests.sh index dfa30461..3d7018b8 100755 --- a/lez/keycard_wallet/tests/keycard_tests.sh +++ b/lez/keycard_wallet/tests/keycard_tests.sh @@ -1,9 +1,8 @@ #!/bin/bash -# Run wallet_with_keycard.sh first - -source venv/bin/activate # Load the appropriate virtual environment +# Run `cargo install --path lez/wallet --force` first export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 # Tests wallet keycard available # - Checks whether smart reader and keycard are both available. diff --git a/lez/keycard_wallet/tests/keycard_tests_2.sh b/lez/keycard_wallet/tests/keycard_tests_2.sh index cbff19fe..b574e6a3 100755 --- a/lez/keycard_wallet/tests/keycard_tests_2.sh +++ b/lez/keycard_wallet/tests/keycard_tests_2.sh @@ -2,7 +2,7 @@ # keycard_tests_2.sh โ€” comprehensive token + AMM keycard integration tests. # # Prerequisites: -# 1. Run wallet_with_keycard.sh once to install dependencies. +# 1. Run `cargo install --path lez/wallet --force` once to install the wallet CLI. # 2. Reset the local chain so all accounts are uninitialized. # 3. Keycard reader inserted with card loaded. # @@ -23,8 +23,8 @@ # amm-lee-fund โ†’ public LEE holding used to seed the AMM pool # (LP holding for amm new is created fresh each run โ€” no persistent label) -source venv/bin/activate export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 # ============================================================================= # Keycard setup diff --git a/lez/keycard_wallet/wallet_with_keycard.sh b/lez/keycard_wallet/wallet_with_keycard.sh deleted file mode 100755 index 7a43bb57..00000000 --- a/lez/keycard_wallet/wallet_with_keycard.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -cargo install --path lez/wallet --force - -# Install appropriate version of `keycard-py`. -git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py - -# Set up virtual environment. -python3 -m venv venv -source venv/bin/activate -pip install pyscard mnemonic ecdsa pyaes -pip install -e lez/keycard_wallet/python/keycard-py \ No newline at end of file diff --git a/lez/mempool/src/lib.rs b/lez/mempool/src/lib.rs index 1b36eaf7..0006f2c3 100644 --- a/lez/mempool/src/lib.rs +++ b/lez/mempool/src/lib.rs @@ -65,6 +65,11 @@ impl MemPoolHandle { pub async fn push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::SendError> { self.sender.send(item).await } + + /// Send an item to the mempool, failing _immediately_ if it is full. + pub fn try_push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::TrySendError> { + self.sender.try_send(item) + } } #[cfg(test)] @@ -123,6 +128,19 @@ mod tests { assert_eq!(pool.pop(), Some(2)); } + #[test] + async fn try_push_fails_when_full_without_blocking() { + let (mut pool, handle) = MemPool::new(1); + + handle.try_push(1).unwrap(); + assert!(handle.try_push(2).is_err(), "full mempool must not accept"); + + // Popping frees capacity again. + assert_eq!(pool.pop(), Some(1)); + handle.try_push(2).unwrap(); + assert_eq!(pool.pop(), Some(2)); + } + #[test] async fn push_front() { let (mut pool, handle) = MemPool::new(10); diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index 1fa7494d..c8590c2a 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -10,8 +10,8 @@ workspace = true [dependencies] lee.workspace = true lee_core.workspace = true +chain_state.workspace = true common.workspace = true -chain_consistency.workspace = true storage.workspace = true mempool.workspace = true logos-blockchain-zone-sdk.workspace = true @@ -34,6 +34,7 @@ tempfile.workspace = true chrono.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio-util.workspace = true rand.workspace = true borsh.workspace = true bytesize.workspace = true diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index 3f2b2a62..8f2f2031 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -1,23 +1,44 @@ use std::{pin::Pin, sync::Arc, time::Duration}; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, ensure}; use common::block::Block; use futures::Stream; use log::{info, warn}; -pub use logos_blockchain_core::mantle::ops::channel::MsgId; -use logos_blockchain_core::mantle::ops::channel::{ChannelId, inscribe::Inscription}; -pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey}; +pub use logos_blockchain_core::mantle::ops::channel::{Ed25519PublicKey, MsgId}; +use logos_blockchain_core::{ + mantle::{ + MantleTx, SignedMantleTx, Transaction as _, + channel::{SlotTimeframe, SlotTimeout}, + ops::{ + Op, OpProof, + channel::{ + ChannelId, + config::{ChannelConfigOp, Keys}, + inscribe::Inscription, + }, + }, + }, + proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature}, +}; +pub use logos_blockchain_key_management_system_service::keys::{ + ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey, +}; pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::{Node as _, NodeHttpClient}, indexer::ZoneIndexer, sequencer::{ - DepositInfo, Event, FinalizedOp, InscriptionInfo, - SequencerConfig as ZoneSdkSequencerConfig, WithdrawArg, WithdrawInfo, ZoneSequencer, + DepositInfo, Event, FinalizedOp, InscriptionInfo, OrphanedTx, + SequencerConfig as ZoneSdkSequencerConfig, TurnNotification, WithdrawArg, WithdrawInfo, + ZoneSequencer, }, }; -use tokio::{sync::mpsc, task::JoinHandle}; +use tokio::{ + sync::{mpsc, oneshot, watch}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; use crate::config::BedrockConfig; @@ -43,6 +64,36 @@ pub type OnDepositEventSink = pub type OnWithdrawEventSink = Box Pin + Send>> + Send + 'static>; +/// The channel delta the follow path consumes from one `Event::BlocksProcessed`, +/// with inscription payloads decoded into `(MsgId, Block)` pairs. +pub struct FollowUpdate { + /// Inscriptions newly on the followed L1 branch, in channel order: they + /// extend (or, after a reorg, replace part of) the `head` tier. + pub adopted: Vec<(MsgId, Block)>, + /// Inscriptions dropped from the branch by an L1 reorg: their blocks are + /// reverted from the `head` and their user txs resubmitted to the mempool. + pub orphaned: Vec<(MsgId, Block)>, + /// Inscriptions whose containing L1 block reached finality: their blocks + /// move into the irreversible `final` tier. + pub finalized: Vec<(MsgId, Block)>, +} + +/// Sink for the follow path: apply adopted/finalized blocks to chain state and +/// revert orphaned ones. +pub type OnFollowSink = Box; + +/// Commands the drive task executes with `&mut sequencer`. +enum Command { + /// Publish an inscription (+ atomic withdrawals); responds with the assigned `MsgId`. + Publish { + inscription: Inscription, + withdrawals: Vec, + resp: oneshot::Sender>, + }, +} + +type CommandSender = mpsc::Sender; + #[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")] pub trait BlockPublisherTrait: Sized { #[expect( @@ -58,14 +109,23 @@ pub trait BlockPublisherTrait: Sized { on_finalized_block: FinalizedBlockSink, on_deposit_event: OnDepositEventSink, on_withdraw_event: OnWithdrawEventSink, + on_follow: OnFollowSink, ) -> Result; - /// Fire-and-forget publish. Zone-sdk drives the actual submission and - /// retries internally; this just hands the payload off. - async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result<()>; + /// Publish a block and return the `MsgId` zone-sdk assigned its inscription. + /// Zone-sdk drives the actual submission and retries internally. + async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result; fn channel_id(&self) -> ChannelId; + /// Whether this sequencer is currently authorized to write to the channel. + fn is_our_turn(&self) -> bool; + + /// A [`CancellationToken`] cancelled when the publisher's background driver + /// terminates (a panicked sink, an ended event stream). No channel events + /// are processed past that point, so the node must halt. + fn driver_cancellation(&self) -> CancellationToken; + /// Current channel frontier slot on the connected chain, or `None` if the /// channel does not exist there. Drives the startup frontier check. async fn channel_tip_slot(&self) -> Result>; @@ -85,7 +145,10 @@ pub struct ZoneSdkPublisher { /// Direct node handle retained for channel reads (startup consistency check /// and reconstruction); the sequencer itself lives in the drive task. node: NodeHttpClient, - publish_tx: mpsc::Sender<(Inscription, Vec)>, + command_tx: CommandSender, + turn_rx: watch::Receiver, + // Cancelled when the drive task ends for any reason, including a panic. + driver_cancellation: CancellationToken, // Aborts the drive task when the last clone is dropped. _drive_task: Arc, indexer: ZoneIndexer, @@ -109,6 +172,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { on_finalized_block: FinalizedBlockSink, on_deposit_event: OnDepositEventSink, on_withdraw_event: OnWithdrawEventSink, + on_follow: OnFollowSink, ) -> Result { let basic_auth = config.auth.clone().map(Into::into); let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone()); @@ -129,11 +193,18 @@ impl BlockPublisherTrait for ZoneSdkPublisher { // Grab readiness receiver before moving the sequencer into the drive // task so we can await cold-start completion below. let mut ready_rx = sequencer.subscribe_ready(); + // Grab the turn watch before the move; the sdk actor keeps it current. + let turn_rx = sequencer.subscribe_turn_to_write(); - let (publish_tx, mut publish_rx) = - mpsc::channel::<(Inscription, Vec)>(PUBLISH_INBOX_CAPACITY); + let (command_tx, mut command_rx): (CommandSender, _) = + mpsc::channel(PUBLISH_INBOX_CAPACITY); + let driver_cancellation = CancellationToken::new(); + let driver_guard = driver_cancellation.clone().drop_guard(); let drive_task = tokio::spawn(async move { + // Dropped when this task ends (including panics in the sinks), + // cancelling every `driver_cancellation`. + let _driver_guard = driver_guard; loop { #[expect( clippy::integer_division_remainder_used, @@ -141,32 +212,38 @@ impl BlockPublisherTrait for ZoneSdkPublisher { )] { tokio::select! { - // Drain external publish requests by calling the - // borrowing handle โ€” `&mut sequencer` is only - // available here. - Some((data_bounded, withdrawals)) = publish_rx.recv() => { - let data_byte_size = data_bounded.len(); - if withdrawals.is_empty() { - if let Err(e) = sequencer.handle() - .publish(data_bounded) - .context("Failed to publish block") { - warn!("zone-sdk publish failed: {e:?}"); - } - - info!("Published block with the size of {data_byte_size} bytes"); - } else { + // 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(); - if let Err(e) = sequencer.handle() - .publish_atomic_withdraw(data_bounded, withdrawals) - .context("Failed to publish block with withdrawals") { - warn!("zone-sdk publish failed: {e:?}"); - } + let published = if withdrawals.is_empty() { + sequencer.handle() + .publish(data_bounded) + .context("Failed to publish block") + } else { + sequencer.handle() + .publish_atomic_withdraw(data_bounded, withdrawals) + .context("Failed to publish block with withdrawals") + }; - info!( - "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", - ); + let msg_result = published + .map(|(result, _checkpoint)| result.tx.inscription().this_msg); + match &msg_result { + Ok(_) if withdraw_count == 0 => { + info!("Published block with the size of {data_byte_size} bytes"); + } + Ok(_) => { + 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); } - } + }, event = sequencer.next_event() => { let Some(event) = event else { continue; @@ -174,17 +251,32 @@ impl BlockPublisherTrait for ZoneSdkPublisher { match event { Event::BlocksProcessed { checkpoint, - channel_update: _, + channel_update, finalized, } => { on_checkpoint(checkpoint); + + let adopted = channel_update + .adopted + .iter() + .filter_map(block_from_inscription) + .collect(); + let orphaned = channel_update + .orphaned + .iter() + .map(orphan_inscription) + .filter_map(block_from_inscription) + .collect(); + + let mut finalized_blocks = Vec::new(); for op in finalized.into_iter().flat_map(|item| item.ops) { match op { FinalizedOp::Inscription(inscription) => { - if let Some(block_id) = - block_id_from_inscription(&inscription) + if let Some((msg, block)) = + block_from_inscription(&inscription) { - on_finalized_block(block_id); + on_finalized_block(block.header.block_id); + finalized_blocks.push((msg, block)); } } FinalizedOp::Deposit(deposit) => { @@ -195,8 +287,22 @@ impl BlockPublisherTrait for ZoneSdkPublisher { } } } + + on_follow(FollowUpdate { + adopted, + orphaned, + finalized: finalized_blocks, + }); + } + Event::Ready => {} + Event::TurnNotification { notification } => { + info!( + "Turn update: our_turn={}, starting_slot={:?}, ends_at_slot={:?}", + notification.our_turn_to_write, + notification.starting_slot, + notification.ends_at_slot + ); } - Event::Ready | Event::TurnNotification { .. } => {} } } } @@ -215,29 +321,46 @@ impl BlockPublisherTrait for ZoneSdkPublisher { channel_id: config.channel_id, indexer: ZoneIndexer::new(config.channel_id, node.clone()), node, - publish_tx, + command_tx, + turn_rx, + driver_cancellation, _drive_task: Arc::new(DriveTaskGuard(drive_task)), }) } - async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result<()> { + async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result { let data = borsh::to_vec(block).context("Failed to serialize block")?; let data_bounded: Inscription = data .try_into() .context("Block data exceeds maximum allowed size")?; - self.publish_tx - .send((data_bounded, withdrawals)) + let (resp_tx, resp_rx) = oneshot::channel(); + self.command_tx + .send(Command::Publish { + inscription: data_bounded, + withdrawals, + resp: resp_tx, + }) .await .map_err(|_closed| anyhow!("Drive task is no longer running"))?; - Ok(()) + resp_rx + .await + .map_err(|_closed| anyhow!("Drive task dropped the publish response"))? } fn channel_id(&self) -> ChannelId { self.channel_id } + fn is_our_turn(&self) -> bool { + self.turn_rx.borrow().our_turn_to_write + } + + fn driver_cancellation(&self) -> CancellationToken { + self.driver_cancellation.clone() + } + async fn channel_tip_slot(&self) -> Result> { Ok(self .node @@ -260,13 +383,87 @@ impl BlockPublisherTrait for ZoneSdkPublisher { } } -/// Deserialize inscription payload as a `Block` and return it's`block_id`. -/// Bad payloads are logged and skipped. -fn block_id_from_inscription(inscription: &InscriptionInfo) -> Option { +/// Deserialize an inscription payload into `(this_msg, Block)`. Bad payloads are +/// logged and skipped. +fn block_from_inscription(inscription: &InscriptionInfo) -> Option<(MsgId, Block)> { borsh::from_slice::(&inscription.payload) .inspect_err(|err| { warn!("Failed to deserialize block from inscription: {err:?}"); }) .ok() - .map(|block| block.header.block_id) + .map(|block| (inscription.this_msg, block)) +} + +/// The inscription carried by an orphaned tx (plain or atomic-withdraw bundle). +const fn orphan_inscription(orphan: &OrphanedTx) -> &InscriptionInfo { + match orphan { + OrphanedTx::Inscription(info) => info, + OrphanedTx::AtomicWithdraw(bundle) => &bundle.inscription, + } +} + +/// Signs a `ChannelConfig` op (accredited keys + rotation params) with +/// `signing_key` and posts it straight to the bedrock node. +/// +/// A standalone one-shot โ€” no running sequencer involved, so authorization is +/// holding the admin key: the L1 rejects non-admin signers. `Ok(())` means the +/// node accepted the transaction; channel acceptance is asynchronous and a +/// rejection only shows up in node logs and on-chain behavior. +pub async fn post_channel_config( + config: &BedrockConfig, + signing_key: &Ed25519Key, + keys: Vec, + posting_timeframe: u32, + posting_timeout: u32, + configuration_threshold: u16, + withdraw_threshold: u16, +) -> Result<()> { + ensure!(!keys.is_empty(), "Channel key list must not be empty"); + for (name, threshold) in [ + ("configuration_threshold", configuration_threshold), + ("withdraw_threshold", withdraw_threshold), + ] { + ensure!( + threshold >= 1 && usize::from(threshold) <= keys.len(), + "{name} must be between 1 and the key count ({}), got {threshold}", + keys.len() + ); + } + ensure!( + posting_timeframe > 0 && posting_timeout >= posting_timeframe, + "posting_timeframe must be nonzero and posting_timeout at least as long, \ + got {posting_timeframe} and {posting_timeout}" + ); + + let keys = Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?; + let config_op = ChannelConfigOp { + channel: config.channel_id, + keys, + posting_timeframe: SlotTimeframe::from(posting_timeframe), + posting_timeout: SlotTimeout::from(posting_timeout), + configuration_threshold, + withdraw_threshold, + }; + + let mantle_tx = MantleTx([Op::ChannelConfig(config_op)].into()); + let tx_hash = mantle_tx.hash(); + // The admin key is `keys[0]`, hence signature index 0. + let signature = IndexedSignature::new( + 0, + signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()), + ); + let proof = ChannelMultiSigProof::new(vec![signature]) + .map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?; + let signed_tx = SignedMantleTx { + ops_proofs: vec![OpProof::ChannelMultiSigProof(proof)], + mantle_tx, + }; + + let node = NodeHttpClient::new( + CommonHttpClient::new(config.auth.clone().map(Into::into)), + config.node_url.clone(), + ); + node.post_transaction(signed_tx) + .await + .context("Failed to post channel config transaction") } diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index 3a2de375..37394352 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -218,6 +218,16 @@ impl SequencerStore { pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult { self.dbio.is_deposit_event_submitted(deposit_op_id) } + + /// Marks the given deposit events submitted in `block_id`, in one write. + pub fn mark_deposit_events_submitted( + &self, + deposit_op_ids: &[HashType], + submitted_block_id: u64, + ) -> DbResult<()> { + self.dbio + .mark_deposit_events_submitted(deposit_op_ids, submitted_block_id) + } } pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap { diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 0c925f95..7ee5fe73 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1,17 +1,23 @@ -use std::{path::Path, sync::Arc, time::Instant}; +use std::{ + path::Path, + sync::{Arc, Mutex}, + time::Instant, +}; use anyhow::{Context as _, Result, anyhow}; use borsh::BorshDeserialize; -use chain_consistency::{Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, Tip}; +use chain_state::{ + AcceptOutcome, Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, ChainState, Tip, +}; use common::{ HashType, - block::{BedrockStatus, Block, HashableBlockData}, + block::{BedrockStatus, Block, BlockMeta, HashableBlockData}, transaction::{LeeTransaction, clock_invocation}, }; use config::{GenesisAction, SequencerConfig}; use futures::StreamExt as _; use itertools::Itertools as _; -use lee::{AccountId, PublicTransaction, V03State, public_transaction::Message}; +use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::GENESIS_BLOCK_ID; use log::{error, info, warn}; use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; @@ -30,7 +36,7 @@ use storage::sequencer::{ }; use crate::{ - block_publisher::{BlockPublisherTrait, ZoneSdkPublisher}, + block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher}, block_store::SequencerStore, }; @@ -56,18 +62,13 @@ struct DepositMetadata { recipient_id: lee::AccountId, } -impl DepositMetadata { - fn decode(bytes: &[u8]) -> Result { - Self::try_from_slice(bytes) - } -} - pub struct SequencerCore { - state: lee::V03State, + /// Two-tier chain state: production builds on its head; the publisher's + /// `on_follow` sink feeds adopted/orphaned/finalized peer blocks into it. + chain: Arc>, store: SequencerStore, mempool: MemPool<(TransactionOrigin, LeeTransaction)>, sequencer_config: SequencerConfig, - chain_height: u64, block_publisher: BP, } @@ -120,14 +121,66 @@ impl SequencerCore { } } + /// Rebuilds the two-tier [`ChainState`]: the final tier from the persisted + /// final snapshot (pre-genesis state when absent), the head tier by replaying + /// every stored block above it, so a post-restart orphan can still revert. + fn restore_chain_state( + config: &SequencerConfig, + store: &SequencerStore, + stored_head_state: &lee::V03State, + ) -> ChainState { + let final_snapshot = store + .dbio() + .get_final_snapshot() + .expect("Failed to read final snapshot from store"); + let (final_state, final_tip) = match final_snapshot { + Some((state, meta)) => (state, Some(Tip::from(meta))), + // Nothing finalized yet: replay the whole stored chain. + None => (build_initial_state(config), None), + }; + let boundary = final_tip.as_ref().map_or(0, |tip| tip.block_id); + + let mut head_blocks = store + .get_all_blocks() + .filter_ok(|block| block.header.block_id > boundary) + .collect::, _>>() + .expect("Failed to read blocks from store while restoring chain state"); + head_blocks.sort_unstable_by_key(|block| block.header.block_id); + + let mut chain = ChainState::from_final(final_state, final_tip); + for block in head_blocks { + let block_id = block.header.block_id; + chain.restore_head_block(block).unwrap_or_else(|err| { + panic!("Stored block {block_id} does not replay while restoring chain state: {err}") + }); + } + + // The replayed head must reproduce the persisted state, else store + // and config disagree (e.g. edited genesis actions). + assert!( + chain.head_state() == stored_head_state, + "Persisted state does not match the replayed chain; reset the store or restore the original config" + ); + + chain + } + pub async fn start_from_config( config: SequencerConfig, ) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) { let bedrock_signing_key = load_or_create_signing_key(&config.home.join("bedrock_signing_key")) .expect("Failed to load or create bedrock signing key"); + info!( + "Bedrock signing public key: {}", + hex::encode(bedrock_signing_key.public_key().to_bytes()) + ); - let (mut store, mut state) = Self::open_or_create_store(&config); + let (store, state) = Self::open_or_create_store(&config); + + let chain = Arc::new(Mutex::new(Self::restore_chain_state( + &config, &store, &state, + ))); let initial_checkpoint = store .get_zone_checkpoint() @@ -146,6 +199,7 @@ impl SequencerCore { Self::on_finalized_block(store.dbio()), Self::on_deposit_event(store.dbio(), mempool_handle.clone()), Self::on_withdraw_event(store.dbio()), + Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()), ) .await .expect("Failed to initialize Block Publisher"); @@ -163,15 +217,15 @@ impl SequencerCore { // Before producing, verify our local state still belongs to the chain // the channel serves and replay any channel blocks we are missing // (e.g. from other sequencers). - let chanel_was_empty = - Self::verify_and_reconstruct(&block_publisher, &mut store, &mut state, is_fresh_start) + let channel_was_empty = + Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start) .await .expect("Failed to verify/reconstruct sequencer state from Bedrock"); // Publish our blocks only when bootstrapping an empty channel. If the // channel already has blocks (another sequencer bootstrapped it), we // adopted them during reconstruction instead. - if is_fresh_start && chanel_was_empty { + if is_fresh_start && channel_was_empty { let mut pending_blocks = store .get_all_blocks() .filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending)) @@ -199,16 +253,10 @@ impl SequencerCore { } } - let latest_block_meta = store - .latest_block_meta() - .expect("Failed to read latest block meta from store") - .expect("Sequencer store should have at least the genesis block after reconstruction"); - let sequencer_core = Self { - state, + chain, store, mempool, - chain_height: latest_block_meta.id, sequencer_config: config, block_publisher, }; @@ -224,8 +272,8 @@ impl SequencerCore { /// Returns whatever channel was empty or not. async fn verify_and_reconstruct( publisher: &BP, - store: &mut SequencerStore, - state: &mut V03State, + store: &SequencerStore, + chain: &Mutex, is_fresh_start: bool, ) -> Result { let anchor_record = store @@ -322,7 +370,11 @@ impl SequencerCore { ) })?; channel_is_empty = false; - Self::apply_reconstructed_block(store, state, &block, slot)?; + // Locked per message (not across the stream `await`): concurrent + // follow events interleave safely โ€” both paths apply idempotently + // and persist under this same lock. + let mut chain = chain.lock().expect("chain state mutex poisoned"); + Self::apply_reconstructed_block(store, &mut chain, &block, slot)?; } Ok(channel_is_empty) @@ -332,8 +384,8 @@ impl SequencerCore { /// blocks we already hold (verifying their hash), a validated continuation /// for new ones. Advances the persisted anchor to the block's slot. fn apply_reconstructed_block( - store: &mut SequencerStore, - state: &mut V03State, + store: &SequencerStore, + chain: &mut ChainState, block: &Block, slot: Slot, ) -> Result<()> { @@ -379,21 +431,31 @@ impl SequencerCore { } } - // New continuation: validate it chains onto the tip, then apply. - let cc_tip = tip.as_ref().map(|tip| Tip { - block_id: tip.id, - hash: tip.hash, - }); - chain_consistency::validate_against_tip(cc_tip, block).map_err(|err| { - anyhow!( - "Channel block {block_id} does not extend local tip {:?}: {err}", - tip.map(|tip| tip.id) - ) - })?; + // New continuation: channel history is finalized, so it goes through + // the final tier โ€” validation happens inside `apply_finalized`. + match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) { + AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {} + AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => { + return Err(anyhow!( + "Channel block {block_id} does not extend local tip {:?}: {err}", + tip.map(|tip| tip.id) + )); + } + } - let mut scratch = state.clone(); - chain_consistency::apply_block(block, &mut scratch) - .map_err(|err| anyhow!("Failed to apply channel block {block_id}: {err}"))?; + // Persist like the follow path: the tip meta stays pinned to the head + // tip even when the reconstructed block lands below it. + let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head)); + let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta)); + store + .dbio() + .store_followed_blocks( + &[(block, true)], + head_tip.as_ref(), + chain.head_state(), + final_meta.as_ref().map(|meta| (chain.final_state(), meta)), + ) + .context("Failed to persist reconstructed block")?; // Mark the deposits' pending records submitted so the production-time // guard drops the mints cold-start backfill re-queued for them. Withdraw @@ -406,11 +468,10 @@ impl SequencerCore { .iter() .filter_map(extract_bridge_deposit_id) .collect(); - store - .update(block, &deposit_event_ids, Vec::new(), &scratch) - .context("Failed to persist reconstructed block")?; - *state = scratch; + .mark_deposit_events_submitted(&deposit_event_ids, block_id) + .context("Failed to mark reconstructed deposits submitted")?; + store .set_zone_anchor(&record) .context("Failed to persist zone anchor")?; @@ -547,6 +608,17 @@ impl SequencerCore { }) } + /// Publisher sink adapter over [`apply_follow_update`]. + fn on_follow( + dbio: Arc, + chain: Arc>, + mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + ) -> block_publisher::OnFollowSink { + Box::new(move |update: block_publisher::FollowUpdate| { + apply_follow_update(&dbio, &chain, &mempool_handle, update); + }) + } + /// Produces a new block from mempool transactions and publishes it via zone-sdk. pub async fn produce_new_block(&mut self) -> Result { let BlockWithMeta { @@ -563,26 +635,77 @@ impl SequencerCore { .collect::>() .context("Failed to build reconciliation keys for block withdrawals")?; - self.block_publisher + let this_msg = self + .block_publisher .publish_block(&block, withdrawals) .await .context("Failed to publish block to Bedrock")?; - self.store.update( + self.record_produced_block( + this_msg, &block, &deposit_event_ids, withdrawal_reconciliation_keys, - &self.state, )?; - Ok(self.chain_height) + Ok(block.header.block_id) + } + + /// Applies our own freshly-published block to the head with the [`MsgId`] the + /// publish assigned it, so the head advances and the later adopted + /// redelivery dedups, then persists it. + /// + /// Persistence is gated on the block actually becoming the head: if a peer + /// block won this height while we were publishing (`AlreadyApplied`, or + /// `Parked` when the head reorged to a different parent), the canonical + /// block is persisted by the follow path instead, and our invalidated + /// inscription comes back via `orphaned`. + fn record_produced_block( + &mut self, + this_msg: MsgId, + block: &Block, + deposit_event_ids: &[HashType], + withdrawal_reconciliation_keys: Vec, + ) -> Result<()> { + let mut chain = self.chain.lock().expect("chain state mutex poisoned"); + match chain.apply_produced(this_msg, block) { + AcceptOutcome::Applied => { + // Persisted under the lock so disk writes land in apply order + // with the follow path. + self.store.update( + block, + deposit_event_ids, + withdrawal_reconciliation_keys, + chain.head_state(), + )?; + } + AcceptOutcome::AlreadyApplied => { + warn!( + "Produced block {} lost a competing-write race, skipping persistence", + block.header.block_id + ); + } + AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => { + warn!( + "Produced block {} no longer chains on the head, skipping persistence: {err}", + block.header.block_id + ); + } + } + + Ok(()) } /// Validates and applies a single mempool transaction to the current state. /// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if /// it was skipped due to validation failure. + #[expect( + clippy::too_many_arguments, + reason = "splitting the produce-path accumulators into a struct buys nothing" + )] fn apply_mempool_transaction( - &mut self, + store: &SequencerStore, + state: &mut lee::V03State, origin: TransactionOrigin, tx: &LeeTransaction, block_height: u64, @@ -593,11 +716,7 @@ impl SequencerCore { let tx_hash = tx.hash(); match origin { TransactionOrigin::User => { - let validated_diff = match tx.validate_on_state( - &self.state, - block_height, - timestamp, - ) { + let validated_diff = match tx.validate_on_state(state, block_height, timestamp) { Ok(diff) => diff, Err(err) => { error!( @@ -611,7 +730,7 @@ impl SequencerCore { withdrawals.push(withdraw_data); } - self.state.apply_state_diff(validated_diff); + state.apply_state_diff(validated_diff); } TransactionOrigin::Sequencer => { let LeeTransaction::Public(public_tx) = tx else { @@ -619,8 +738,7 @@ impl SequencerCore { }; if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) { - if self - .store + if store .is_deposit_event_submitted(deposit_op_id) .context("Failed to check whether deposit was already submitted")? { @@ -630,7 +748,7 @@ impl SequencerCore { deposit_event_ids.push(deposit_op_id); } - self.state + state .transition_from_public_transaction(public_tx, block_height, timestamp) .context("Failed to execute sequencer-generated transaction")?; } @@ -643,7 +761,18 @@ impl SequencerCore { fn build_block_from_mempool(&mut self) -> Result { let now = Instant::now(); - let new_block_height = self.next_block_id(); + // Build on the head: its tip is the parent, its state the validation base. + let (prev_block_hash, new_block_height, mut working_state) = { + let chain = self.chain.lock().expect("chain state mutex poisoned"); + let tip = chain.head_tip(); + let height = tip.as_ref().map_or(GENESIS_BLOCK_ID, |head| { + head.block_id + .checked_add(1) + .expect("block id should not overflow") + }); + let prev = tip.map_or(HashType([0; 32]), |head| head.hash); + (prev, height, chain.head_state().clone()) + }; let mut valid_transactions = Vec::new(); let mut deposit_event_ids = Vec::new(); @@ -652,12 +781,6 @@ impl SequencerCore { let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64()) .expect("`max_block_size` should fit into usize"); - let latest_block_meta = self - .store - .latest_block_meta() - .context("Failed to get latest block meta from store")? - .context("Sequencer store should have at least the genesis block")?; - let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis()) .expect("Timestamp must be positive"); @@ -676,7 +799,7 @@ impl SequencerCore { let temp_hashable_data = HashableBlockData { block_id: new_block_height, transactions: temp_valid_transactions, - prev_block_hash: latest_block_meta.hash, + prev_block_hash, timestamp: new_block_timestamp, }; @@ -693,7 +816,9 @@ impl SequencerCore { break; } - if self.apply_mempool_transaction( + if Self::apply_mempool_transaction( + &self.store, + &mut working_state, origin, &tx, new_block_height, @@ -709,7 +834,7 @@ impl SequencerCore { } } - self.state + working_state .transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp) .context("Clock transaction failed. Aborting block production.")?; valid_transactions.push(clock_lee_tx); @@ -717,7 +842,7 @@ impl SequencerCore { let hashable_data = HashableBlockData { block_id: new_block_height, transactions: valid_transactions, - prev_block_hash: latest_block_meta.hash, + prev_block_hash, timestamp: new_block_timestamp, }; @@ -725,8 +850,6 @@ impl SequencerCore { .clone() .into_pending_block(self.store.signing_key()); - self.chain_height = new_block_height; - log::info!( "Created block with {} transactions in {} seconds", hashable_data.transactions.len(), @@ -740,16 +863,27 @@ impl SequencerCore { }) } - pub const fn state(&self) -> &lee::V03State { - &self.state + /// Reads the current head state under the lock without cloning it, so callers + /// reuse `V03State`'s own API (accounts, nonces, proofs) with no whole-state copy. + pub fn with_state(&self, f: impl FnOnce(&lee::V03State) -> R) -> R { + f(self + .chain + .lock() + .expect("chain state mutex poisoned") + .head_state()) } pub const fn block_store(&self) -> &SequencerStore { &self.store } - pub const fn chain_height(&self) -> u64 { - self.chain_height + #[must_use] + pub fn chain_height(&self) -> u64 { + self.chain + .lock() + .expect("chain state mutex poisoned") + .head_tip() + .map_or(0, |tip| tip.block_id) } pub const fn sequencer_config(&self) -> &SequencerConfig { @@ -786,10 +920,17 @@ impl SequencerCore { &self.block_publisher } - fn next_block_id(&self) -> u64 { - self.chain_height - .checked_add(1) - .unwrap_or_else(|| panic!("Max block height reached: {}", self.chain_height)) + /// Whether this sequencer is currently authorized to write to the channel. + #[must_use] + pub fn is_our_turn(&self) -> bool { + self.block_publisher.is_our_turn() + } + + /// Shared handle to the two-tier follow state, for tests to drive the + /// follow path directly. + #[cfg(all(test, feature = "mock"))] + fn chain(&self) -> Arc> { + Arc::clone(&self.chain) } } @@ -799,6 +940,105 @@ struct BlockWithMeta { withdrawals: Vec, } +/// Feed one channel delta into the follow state and mirror it to the store: +/// revert orphaned, then apply and persist adopted and finalized blocks. +/// Production builds on this same head. Wired to the publisher via +/// [`SequencerCore::on_follow`]; a free function so tests can drive it directly. +/// +/// TODO: unlike the indexer's ingest loop, this path does not retry +/// `is_retryable` (transient) apply failures โ€” a failed block just parks and +/// relies on a valid successor or a restart. `ChainState` never emits +/// `AcceptOutcome::RetryableFailure` yet; adding retry parity here is a +/// follow-up. +fn apply_follow_update( + dbio: &RocksDBIO, + chain: &Mutex, + mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + update: block_publisher::FollowUpdate, +) { + let block_publisher::FollowUpdate { + adopted, + orphaned, + finalized, + } = update; + + // The lock is held across the persist below so disk writes land in apply + // order โ€” the produce path persists under this same lock. + let resubmit_txs = { + let mut chain = chain.lock().expect("chain state mutex poisoned"); + + // User txs of orphaned blocks, returned to the mempool below. + let resubmit_txs: Vec = orphaned + .iter() + .flat_map(|(_, block)| resubmittable_txs(block)) + .collect(); + + // Outcomes align with `adopted`. + let outcomes = chain.apply_channel_update(&orphaned, &adopted); + let mut to_persist: Vec<(&Block, bool)> = adopted + .iter() + .zip(&outcomes) + .filter(|(_, outcome)| matches!(outcome, AcceptOutcome::Applied)) + .map(|((_, block), _)| (block, false)) + .collect(); + + let mut final_advanced = false; + for (this_msg, block) in &finalized { + // FIXME: thread the finalized inscription's L1 slot once the + // sdk surfaces it; only used for the invalid-finalized stall. + if matches!( + chain.apply_finalized(*this_msg, block, Slot::from(0)), + AcceptOutcome::Applied + ) { + to_persist.push((block, true)); + final_advanced = true; + } + } + // Snapshot the advanced final tier so a restart re-anchors on it. + let final_meta = final_advanced.then(|| { + let tip = chain.final_tip().expect("advanced final tier has a tip"); + BlockMeta::from(&tip) + }); + let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip)); + + // One atomic write for the whole update: blocks, tip meta and the + // state after the last block land together, so a crash can never + // leave the stored state ahead of the stored blocks. A persist + // failure is fatal: the in-memory chain has already advanced, and + // continuing would leave a permanent gap in the store. + // + // The `panic!` ends the drive task, whose cancellation halts the node. + // + // TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` + // before this write; a crash in between resumes past these blocks + // without them ever landing in the store. Full `BlocksProcessed` + // atomicity (checkpoint + blocks + state in one batch, per the sdk's + // event contract) is a follow-up. + dbio.store_followed_blocks( + &to_persist, + head_tip.as_ref(), + chain.head_state(), + final_meta.as_ref().map(|meta| (chain.final_state(), meta)), + ) + .unwrap_or_else(|err| panic!("Failed to persist followed blocks: {err:#}")); + + resubmit_txs + }; + + // Rebuild orphaned work: return its user txs to the mempool so the + // next on-turn production re-includes them on the new head. + // + // We use [`try_push`] here because this is called from the + // publisher's drive task, and only the block production drains the mempool. + // A blocking push on a full mempool would deadlock here. + for tx in resubmit_txs { + let tx_hash = tx.hash(); + if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) { + warn!("Dropping orphaned transaction {tx_hash} on resubmit: {err}"); + } + } +} + /// Checks the database for any pending deposit events that have not yet been marked as submitted in /// a block, and re-queues them in the mempool in a separate async task for inclusion in the next /// block. @@ -848,21 +1088,30 @@ fn replay_unfulfilled_deposit_events( }); } -/// Builds the initial genesis state from `testnet_initial_state` plus configured genesis -/// transactions. Returns the final state and the list of [`LeeTransaction`]s that should be -/// committed to the genesis block so external observers can replay them. -fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec) { +/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings, +/// the only accounts seeded outside any transaction. Cross-zone config is seeded +/// by genesis InitConfig transactions and reconstructed by replaying them. +fn build_initial_state(config: &SequencerConfig) -> lee::V03State { #[cfg(not(feature = "testnet"))] let base = testnet_initial_state::initial_state(); #[cfg(feature = "testnet")] let base = testnet_initial_state::initial_state_testnet(); - // Bridge-lock holdings belong to the source side, seeded regardless of receiving config. - let holdings: Vec<_> = bridge_lock_holdings(&config.genesis) - .map(|(holder, amount)| cross_zone::build_holding_account(holder, amount)) - .collect(); - let mut state = base.with_public_accounts(holdings); + // Bridge-lock holder balances belong to the source side and are not produced by + // any transaction, so seed them directly. Cross-zone config is seeded by genesis + // InitConfig transactions in `build_genesis_state`, not here. + let holdings = bridge_lock_holdings(&config.genesis) + .map(|(holder, amount)| cross_zone::build_holding_account(holder, amount)); + base.with_public_accounts(holdings) +} + +/// Builds the initial genesis state from [`build_initial_state`] plus configured +/// genesis transactions. Returns the final state and the list of +/// [`LeeTransaction`]s that should be committed to the genesis block so external +/// observers can replay them. +fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec) { + let mut state = build_initial_state(config); // Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's. info!( @@ -892,12 +1141,11 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec { Some(build_supply_bridge_account_genesis_transaction(*balance)) } - // Seeded directly (holdings via `build_holding_account`), not by a genesis tx. + // Seeded directly in `build_initial_state` (holdings via `build_holding_account`), not a + // genesis tx. GenesisAction::SupplyBridgeLockHolding { .. } => None, }); - // Applied in order with the mandatory clock last, then committed to the genesis - // block so external observers can replay them. Every genesis tx is public. let genesis_txs = wrapped_token_config_tx .chain(inbox_config_tx) .chain(supply_txs) @@ -984,7 +1232,7 @@ fn pending_deposit_event_record(deposit: &DepositInfo) -> PendingDepositEventRec } fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Result { - let metadata = DepositMetadata::decode(&event.metadata) + let metadata = DepositMetadata::try_from_slice(&event.metadata) .context("Failed to decode finalized Bedrock deposit metadata")?; let bridge_program_id = programs::bridge().id(); @@ -1012,6 +1260,26 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu ))) } +/// User transactions of an orphaned block to return to the mempool: everything +/// except the trailing clock tx, sequencer-generated bridge deposits (replayed +/// from their own bedrock events) and sequencer-only cross-zone txs (replayed +/// by the watcher; the ingress guard rejects them as `User`). +fn resubmittable_txs(block: &Block) -> Vec { + let Some((_clock, rest)) = block.body.transactions.split_last() else { + return Vec::new(); + }; + rest.iter() + .filter(|tx| extract_bridge_deposit_id(tx).is_none() && !is_sequencer_only_tx(tx)) + .cloned() + .collect() +} + +#[must_use] +fn is_sequencer_only_tx(tx: &LeeTransaction) -> bool { + matches!(tx, LeeTransaction::Public(tx) + if is_sequencer_only_program(tx.message().program_id)) +} + #[must_use] fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option { let LeeTransaction::Public(tx) = tx else { @@ -1101,7 +1369,7 @@ fn withdraw_event_reconciliation_key( } /// Load signing key from file or generate a new one if it doesn't exist. -fn load_or_create_signing_key(path: &Path) -> Result { +pub fn load_or_create_signing_key(path: &Path) -> Result { if path.exists() { let key_bytes = std::fs::read(path)?; diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index f8349fac..4f2552b7 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -3,13 +3,14 @@ use std::time::Duration; use anyhow::Result; use common::block::Block; use futures::Stream; -use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId}; use logos_blockchain_key_management_system_service::keys::Ed25519Key; use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg}; +use tokio_util::sync::CancellationToken; use crate::{ block_publisher::{ - BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, + BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, OnFollowSink, OnWithdrawEventSink, SequencerCheckpoint, }, config::BedrockConfig, @@ -20,6 +21,8 @@ pub type SequencerCoreWithMockClients = crate::SequencerCore #[derive(Clone)] pub struct MockBlockPublisher { channel_id: ChannelId, + // Never cancelled: the mock driver never dies. + driver_cancellation: CancellationToken, /// Canned channel frontier returned by [`Self::channel_tip_slot`]. tip_slot: Option, /// Canned finalized channel history returned by [`Self::read_channel_after`]. @@ -31,13 +34,14 @@ impl MockBlockPublisher { /// and consistency tests. The default (via [`BlockPublisherTrait::new`]) /// serves an empty channel. #[must_use] - pub const fn with_canned_channel( + pub fn with_canned_channel( channel_id: ChannelId, tip_slot: Option, messages: Vec<(ZoneMessage, Slot)>, ) -> Self { Self { channel_id, + driver_cancellation: CancellationToken::new(), tip_slot, messages, } @@ -54,9 +58,11 @@ impl BlockPublisherTrait for MockBlockPublisher { _on_finalized_block: FinalizedBlockSink, _on_deposit_event: OnDepositEventSink, _on_withdraw_event: OnWithdrawEventSink, + _on_follow: OnFollowSink, ) -> Result { Ok(Self { channel_id: config.channel_id, + driver_cancellation: CancellationToken::new(), tip_slot: None, messages: Vec::new(), }) @@ -64,16 +70,27 @@ impl BlockPublisherTrait for MockBlockPublisher { async fn publish_block( &self, - _block: &Block, + block: &Block, _bridge_withdrawals: Vec, - ) -> Result<()> { - Ok(()) + ) -> Result { + // Deterministic per-block id so head dedup behaves in tests. + // + // TODO: should we allow more "mockability" here? + Ok(MsgId::from(block.header.hash.0)) } fn channel_id(&self) -> ChannelId { self.channel_id } + fn is_our_turn(&self) -> bool { + true + } + + fn driver_cancellation(&self) -> CancellationToken { + self.driver_cancellation.clone() + } + async fn channel_tip_slot(&self) -> Result> { Ok(self.tip_slot) } diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index b398e160..4b6e4cae 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -4,7 +4,7 @@ use std::{pin::pin, time::Duration}; use common::{ HashType, - block::HashableBlockData, + block::{BedrockStatus, HashableBlockData}, test_utils::sequencer_sign_key_for_testing, transaction::{LeeTransaction, clock_invocation}, }; @@ -22,19 +22,21 @@ use lee_core::{ account::{AccountWithMetadata, Nonce}, program::PdaSeed, }; -use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId}; use mempool::MemPoolHandle; use storage::sequencer::sequencer_cells::PendingDepositEventRecord; use tempfile::tempdir; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; use crate::{ - TransactionOrigin, + TransactionOrigin, apply_follow_update, + block_publisher::FollowUpdate, block_store::SequencerStore, - build_genesis_state, + build_bridge_deposit_tx_from_event, build_genesis_state, config::{BedrockConfig, SequencerConfig}, is_sequencer_only_program, mock::SequencerCoreWithMockClients, + resubmittable_txs, }; mod reconstruction; @@ -148,14 +150,14 @@ async fn start_from_config() { let (sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; - assert_eq!(sequencer.chain_height, 1); + assert_eq!(sequencer.chain_height(), 1); assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10); let acc1_account_id = initial_public_user_accounts()[0].account_id; let acc2_account_id = initial_public_user_accounts()[1].account_id; - let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance; - let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance; + let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance); + let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance); assert_eq!(10000, balance_acc_1); assert_eq!(20000, balance_acc_2); @@ -188,7 +190,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() { let (sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; - assert_eq!(sequencer.chain_height, 1); + assert_eq!(sequencer.chain_height(), 1); assert!(sequencer.store.latest_block_meta().is_ok()); } @@ -297,7 +299,7 @@ async fn transaction_pre_check_native_transfer_valid() { #[tokio::test] async fn transaction_pre_check_native_transfer_other_signature() { - let (mut sequencer, _mempool_handle) = common_setup().await; + let (sequencer, _mempool_handle) = common_setup().await; let acc1 = initial_public_user_accounts()[0].account_id; let acc2 = initial_public_user_accounts()[1].account_id; @@ -311,7 +313,15 @@ async fn transaction_pre_check_native_transfer_other_signature() { let tx = tx.transaction_stateless_check().unwrap(); // Signature is not from sender. Execution fails - let result = tx.execute_check_on_state(&mut sequencer.state, 0, 0); + let result = tx.execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ); assert!(matches!( result, @@ -321,7 +331,7 @@ async fn transaction_pre_check_native_transfer_other_signature() { #[tokio::test] async fn transaction_pre_check_native_transfer_sent_too_much() { - let (mut sequencer, _mempool_handle) = common_setup().await; + let (sequencer, _mempool_handle) = common_setup().await; let acc1 = initial_public_user_accounts()[0].account_id; let acc2 = initial_public_user_accounts()[1].account_id; @@ -337,9 +347,15 @@ async fn transaction_pre_check_native_transfer_sent_too_much() { // Passed pre-check assert!(result.is_ok()); - let result = result - .unwrap() - .execute_check_on_state(&mut sequencer.state, 0, 0); + let result = result.unwrap().execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ); let is_failed_at_balance_mismatch = matches!( result.err().unwrap(), lee::error::LeeError::ProgramExecutionFailed(_) @@ -350,7 +366,7 @@ async fn transaction_pre_check_native_transfer_sent_too_much() { #[tokio::test] async fn transaction_execute_native_transfer() { - let (mut sequencer, _mempool_handle) = common_setup().await; + let (sequencer, _mempool_handle) = common_setup().await; let acc1 = initial_public_user_accounts()[0].account_id; let acc2 = initial_public_user_accounts()[1].account_id; @@ -361,11 +377,19 @@ async fn transaction_execute_native_transfer() { acc1, 0, acc2, 100, &sign_key1, ); - tx.execute_check_on_state(&mut sequencer.state, 0, 0) - .unwrap(); + tx.execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ) + .unwrap(); - let bal_from = sequencer.state.get_account_by_id(acc1).balance; - let bal_to = sequencer.state.get_account_by_id(acc2).balance; + let bal_from = sequencer.with_state(|s| s.get_account_by_id(acc1).balance); + let bal_to = sequencer.with_state(|s| s.get_account_by_id(acc2).balance); assert_eq!(bal_from, 9900); assert_eq!(bal_to, 20100); @@ -402,7 +426,7 @@ async fn push_tx_into_mempool_blocks_until_mempool_is_full() { #[tokio::test] async fn build_block_from_mempool() { let (mut sequencer, mempool_handle) = common_setup().await; - let genesis_height = sequencer.chain_height; + let genesis_height = sequencer.chain_height(); let tx = common::test_utils::produce_dummy_empty_transaction(); mempool_handle @@ -412,7 +436,8 @@ async fn build_block_from_mempool() { let result = sequencer.build_block_from_mempool(); assert!(result.is_ok()); - assert_eq!(sequencer.chain_height, genesis_height + 1); + // Building itself does not advance the head; only apply-after-publish does. + assert_eq!(sequencer.chain_height(), genesis_height); } #[tokio::test] @@ -444,7 +469,7 @@ async fn replay_transactions_are_rejected_in_the_same_block() { sequencer.produce_new_block().await.unwrap(); let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); @@ -479,7 +504,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() { sequencer.produce_new_block().await.unwrap(); let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); assert_eq!( @@ -498,7 +523,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() { sequencer.produce_new_block().await.unwrap(); let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); // The replay is rejected, so only the clock tx is in the block. @@ -540,7 +565,7 @@ async fn restart_from_storage() { sequencer.produce_new_block().await.unwrap(); let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); assert_eq!( @@ -556,8 +581,8 @@ async fn restart_from_storage() { // with the above transaction and update the state to reflect that. let (sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; - let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance; - let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance; + let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance); + let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance); // Balances should be consistent with the stored block assert_eq!( @@ -655,7 +680,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() { // Step 5: Verify the new block has correct previous block metadata let new_block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); @@ -707,7 +732,7 @@ async fn transactions_touching_clock_account_are_dropped_from_block() { let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); @@ -762,7 +787,7 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { let block = sequencer .store - .get_block_at_id(sequencer.chain_height) + .get_block_at_id(sequencer.chain_height()) .unwrap() .unwrap(); @@ -781,10 +806,13 @@ async fn block_production_aborts_when_clock_account_data_is_corrupted() { // Corrupt the clock 01 account data so the clock program panics on deserialization. let clock_account_id = system_accounts::clock_account_ids()[0]; - let mut corrupted = sequencer.state.get_account_by_id(clock_account_id); + let mut corrupted = sequencer.with_state(|s| s.get_account_by_id(clock_account_id)); corrupted.data = vec![0xff; 3].try_into().unwrap(); sequencer - .state + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut() .force_insert_account(clock_account_id, corrupted); // Push a dummy transaction so the mempool is non-empty. @@ -864,6 +892,7 @@ fn private_bridge_withdraw_invocation_is_dropped() { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: sender_keys.viewing_public_key.clone(), random_seed: [0; 32], + view_tag: 0, nsk: sender_keys.private_key_holder.nullifier_secret_key, membership_proof: state .get_proof_for_commitment(&sender_commitment) @@ -1247,3 +1276,509 @@ fn pda_mechanism_with_pinata_token_program() { expected_winner_token_holding_post ); } + +#[test] +fn resubmittable_txs_drops_clock_and_bridge_deposits() { + let user_tx = common::test_utils::produce_dummy_empty_transaction(); + let deposit_tx = build_bridge_deposit_tx_from_event(&PendingDepositEventRecord { + deposit_op_id: HashType([13; 32]), + source_tx_hash: HashType([7; 32]), + amount: 1, + metadata: borsh::to_vec(&DepositMetadataForEncoding { + recipient_id: initial_public_user_accounts()[0].account_id, + }) + .unwrap(), + submitted_in_block_id: None, + }) + .unwrap(); + let withdraw_tx = { + let message = lee::public_transaction::Message::try_new( + programs::bridge().id(), + vec![system_accounts::bridge_account_id()], + vec![], + bridge_core::Instruction::Withdraw { + amount: 1, + bedrock_account_pk: [0; 32], + }, + ) + .unwrap(); + LeeTransaction::Public(PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) + }; + + let block = common::test_utils::produce_dummy_block( + 2, + Some(HashType([1; 32])), + vec![user_tx.clone(), deposit_tx, withdraw_tx.clone()], + ); + + // The trailing clock tx and the sequencer-generated deposit are dropped; + // user txs (withdrawals included) are returned. + assert_eq!(resubmittable_txs(&block), vec![user_tx, withdraw_tx]); +} + +#[test] +fn resubmittable_txs_of_blocks_without_user_txs_is_empty() { + // No transactions at all (not even the mandatory clock tx). + let empty = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0; 32]), + timestamp: 0, + transactions: vec![], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + assert!(resubmittable_txs(&empty).is_empty()); + + let clock_only = common::test_utils::produce_dummy_block(1, None, vec![]); + assert!(resubmittable_txs(&clock_only).is_empty()); +} + +#[tokio::test] +async fn follow_adopted_peer_block_applies_and_persists() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from([1; 32]), peer_block.clone())], + orphaned: vec![], + finalized: vec![], + }, + ); + + assert_eq!(sequencer.chain_height(), 2); + let stored = sequencer + .store + .get_block_at_id(2) + .unwrap() + .expect("adopted peer block should be persisted"); + assert_eq!(stored.header.hash, peer_block.header.hash); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc2).balance), + 20010 + ); +} + +#[tokio::test] +async fn follow_redelivery_of_own_block_is_deduped() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + // The channel redelivers our own block under the MsgId the mock publisher + // assigned at publish time. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from(block2.header.hash.0), block2)], + orphaned: vec![], + finalized: vec![], + }, + ); + + assert_eq!(sequencer.chain_height(), 2); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc2).balance), + 20010, + "the transfer must not be double-applied" + ); +} + +#[tokio::test] +async fn follow_orphan_reverts_head_and_requeues_user_txs() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![(MsgId::from(block2.header.hash.0), block2)], + finalized: vec![], + }, + ); + + assert_eq!(sequencer.chain_height(), 1); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc1).balance), + 10000, + "the orphaned transfer must be reverted from the head" + ); + let (origin, requeued) = sequencer + .mempool + .pop() + .expect("orphaned user tx should be requeued"); + assert!(matches!(origin, TransactionOrigin::User)); + assert_eq!(requeued, tx); + assert!( + sequencer.mempool.pop().is_none(), + "the clock tx must not be requeued" + ); +} + +#[tokio::test] +async fn follow_finalized_own_block_moves_final_tier_and_marks_store() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from(block2.header.hash.0), block2)], + }, + ); + + let final_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .final_tip() + .expect("final tip set"); + assert_eq!(final_tip.block_id, 2); + assert_eq!(sequencer.chain_height(), 2, "head is unchanged"); + let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[tokio::test] +async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + // A peer block we never saw as adopted arrives straight from the + // finalized (backfill) stream. + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from([2; 32]), peer_block.clone())], + }, + ); + + assert_eq!( + sequencer.chain_height(), + 2, + "head mirrors final on backfill" + ); + let stored = sequencer + .store + .get_block_at_id(2) + .unwrap() + .expect("backfilled block should be persisted"); + assert_eq!(stored.header.hash, peer_block.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[tokio::test] +async fn restart_restores_head_tier_and_recovers_from_orphan() { + let config = setup_sequencer_config(); + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + // Produce block 2 (a user transfer), then "crash" before it finalizes. + let (tx, block2) = { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + (tx, sequencer.store.get_block_at_id(2).unwrap().unwrap()) + }; + + // Restart: nothing is finalized, so block 2 must come back as *head*, not + // final โ€” the L1 can still orphan it. + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + assert_eq!(sequencer.chain_height(), 2); + + // The L1 orphans block 2 under its real MsgId (which we never persisted) + // and adopts a competing empty block 2'. + let genesis = sequencer.store.get_block_at_id(1).unwrap().unwrap(); + let block2_prime = + common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]); + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())], + orphaned: vec![(MsgId::from([20; 32]), block2)], + finalized: vec![], + }, + ); + + // The head reorged onto 2': transfer reverted, store overwritten, and the + // orphaned user tx returned to the mempool. + assert_eq!(sequencer.chain_height(), 2); + let head_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_tip() + .expect("head tip set"); + assert_eq!(head_tip.hash, block2_prime.header.hash); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc1).balance), + 10000, + "the orphaned transfer must be reverted" + ); + let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert_eq!(stored.header.hash, block2_prime.header.hash); + let (origin, requeued) = sequencer + .mempool + .pop() + .expect("orphaned user tx should be requeued"); + assert!(matches!(origin, TransactionOrigin::User)); + assert_eq!(requeued, tx); +} + +#[tokio::test] +async fn restart_reanchors_on_the_persisted_final_snapshot() { + let config = setup_sequencer_config(); + + // Produce block 2 and follow its finalization, which persists the final + // snapshot; then "crash". + { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from(block2.header.hash.0), block2)], + }, + ); + } + + // Restart: the final tier re-anchors on the snapshot instead of treating + // the whole stored chain as final. + let (sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let chain = sequencer.chain(); + let chain = chain.lock().expect("chain mutex poisoned"); + assert_eq!(chain.final_tip().expect("final tip set").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip set").block_id, 2); +} + +#[tokio::test] +async fn record_produced_block_skips_persistence_on_lost_race() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + // A peer block wins height 2 while "our" block is in flight. + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]); + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .apply_adopted(MsgId::from([9; 32]), &peer_block); + + // Our competing block at the same height: same parent, different content. + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + sequencer + .record_produced_block( + MsgId::from(our_block.header.hash.0), + &our_block, + &[], + vec![], + ) + .unwrap(); + + // The lost-race block must not reach the store; the head keeps the peer block. + assert!(sequencer.store.get_block_at_id(2).unwrap().is_none()); + let head_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_tip() + .expect("head tip"); + assert_eq!(head_tip.hash, peer_block.header.hash); +} + +#[tokio::test] +async fn record_produced_block_skips_persistence_when_block_no_longer_chains() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + // The head reorged under us: our block's parent is no longer the tip. + let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + sequencer + .record_produced_block(MsgId::from(stale.header.hash.0), &stale, &[], vec![]) + .unwrap(); + + assert!(sequencer.store.get_block_at_id(2).unwrap().is_none()); + assert_eq!(sequencer.chain_height(), 1, "head is unchanged"); +} + +#[tokio::test] +async fn follow_update_persists_blocks_meta_and_state_atomically() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let block2 = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]); + + // One update carrying several blocks: both adopted, block 2 also finalized. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![ + (MsgId::from([2; 32]), block2.clone()), + (MsgId::from([3; 32]), block3.clone()), + ], + orphaned: vec![], + finalized: vec![(MsgId::from([2; 32]), block2)], + }, + ); + + // Blocks, tip meta and state all reflect the end of the batch: a late + // finalized entry for an earlier block must not drag the tip meta back. + let meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("meta is set"); + assert_eq!(meta.id, 3); + assert_eq!(meta.hash, block3.header.hash); + let stored2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized)); + let stored_balance = sequencer + .store + .get_lee_state() + .unwrap() + .get_account_by_id(acc2) + .balance; + assert_eq!(stored_balance, 20010); +} diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index 40cbaa6c..e52c3364 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -4,6 +4,9 @@ reason = "We don't care about it in tests" )] +use std::sync::Mutex; + +use chain_state::ChainState; use common::block::Block; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::{Slot, ZoneBlock, ZoneMessage}; @@ -14,6 +17,16 @@ use crate::{ SequencerCore, block_store::SequencerStore, config::GenesisAction, mock::MockBlockPublisher, }; +/// Fresh `(store, chain)` pair for a reconstruction target, as +/// `start_from_config` would build them before the publisher starts. +fn fresh_store_and_chain(config: &SequencerConfig) -> (SequencerStore, Mutex) { + let (store, state) = SequencerCore::::open_or_create_store(config); + let chain = Mutex::new(SequencerCore::::restore_chain_state( + config, &store, &state, + )); + (store, chain) +} + fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) { let bytes = borsh::to_vec(block).expect("serialize block"); let message = ZoneMessage::Block(ZoneBlock { @@ -53,15 +66,11 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() { // Sequencer B starts from a fresh store and reconstructs A's chain. let config_b = setup_sequencer_config(); - let (mut store_b, mut state_b) = - SequencerCore::::open_or_create_store(&config_b); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); let channel_was_empty = SequencerCore::::verify_and_reconstruct( - &mock_b, - &mut store_b, - &mut state_b, - true, + &mock_b, &store_b, &chain_b, true, ) .await .expect("reconstruct"); @@ -72,10 +81,12 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() { assert_eq!(tip_b.hash, tip_a.hash); // State matches: initial account balances agree with sequencer A. + let state_b = chain_b.lock().unwrap().head_state().clone(); + let state_a = seq_a.chain().lock().unwrap().head_state().clone(); for account in initial_public_user_accounts() { assert_eq!( state_b.get_account_by_id(account.account_id).balance, - seq_a.state().get_account_by_id(account.account_id).balance, + state_a.get_account_by_id(account.account_id).balance, ); } @@ -85,10 +96,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() { // Re-running is idempotent: everything is already applied, no error. let channel_was_empty = SequencerCore::::verify_and_reconstruct( - &mock_b, - &mut store_b, - &mut state_b, - true, + &mock_b, &store_b, &chain_b, true, ) .await .expect("reconstruct idempotent"); @@ -99,7 +107,7 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() { #[tokio::test] async fn fails_when_channel_serves_a_divergent_block() { let config = setup_sequencer_config(); - let (mut store, mut state) = SequencerCore::::open_or_create_store(&config); + let (store, chain) = fresh_store_and_chain(&config); // Anchor on the local genesis at some slot. let genesis_id = store.genesis_id(); @@ -123,17 +131,16 @@ async fn fails_when_channel_serves_a_divergent_block() { messages, ); - let result = SequencerCore::::verify_and_reconstruct( - &mock, &mut store, &mut state, true, - ) - .await; + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; assert!(result.is_err(), "divergent channel must abort startup"); } #[tokio::test] async fn fails_when_channel_is_missing() { let config = setup_sequencer_config(); - let (mut store, mut state) = SequencerCore::::open_or_create_store(&config); + let (store, chain) = fresh_store_and_chain(&config); let genesis_id = store.genesis_id(); let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap(); store @@ -147,10 +154,9 @@ async fn fails_when_channel_is_missing() { // Anchor present, but the channel does not exist on the connected chain. let mock = MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]); - let result = SequencerCore::::verify_and_reconstruct( - &mock, &mut store, &mut state, true, - ) - .await; + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; assert!(result.is_err(), "missing channel must abort startup"); } @@ -161,7 +167,7 @@ async fn fails_when_channel_is_missing() { #[tokio::test] async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() { let config = setup_sequencer_config(); - let (mut store, mut state) = SequencerCore::::open_or_create_store(&config); + let (store, chain) = fresh_store_and_chain(&config); // Fresh store, no anchor. The channel serves a genesis at the same id but a // different hash โ€” a foreign chain reinscribing genesis. @@ -174,10 +180,9 @@ async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() { Some(Slot::from(10)), messages, ); - let result = SequencerCore::::verify_and_reconstruct( - &mock, &mut store, &mut state, true, - ) - .await; + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; assert!( result.is_err(), "a reinscribed genesis with a different hash must abort startup" @@ -209,10 +214,7 @@ async fn fails_when_a_stored_block_hash_diverges_from_the_channel() { messages, ); let result = SequencerCore::::verify_and_reconstruct( - &mock, - &mut seq.store, - &mut seq.state, - true, + &mock, &seq.store, &seq.chain, true, ) .await; assert!( @@ -224,7 +226,7 @@ async fn fails_when_a_stored_block_hash_diverges_from_the_channel() { #[tokio::test] async fn fails_when_a_channel_block_is_missing_locally() { let config = setup_sequencer_config(); - let (mut store, mut state) = SequencerCore::::open_or_create_store(&config); + let (store, chain) = fresh_store_and_chain(&config); // A block numbered below our genesis is at/below the local tip yet absent from // the store โ€” a foreign chain with a lower numbering. @@ -237,10 +239,9 @@ async fn fails_when_a_channel_block_is_missing_locally() { Some(Slot::from(10)), messages, ); - let result = SequencerCore::::verify_and_reconstruct( - &mock, &mut store, &mut state, true, - ) - .await; + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; assert!( result.is_err(), "a channel block below the local range must abort startup" @@ -250,7 +251,7 @@ async fn fails_when_a_channel_block_is_missing_locally() { #[tokio::test] async fn fails_when_a_channel_block_does_not_extend_the_tip() { let config = setup_sequencer_config(); - let (mut store, mut state) = SequencerCore::::open_or_create_store(&config); + let (store, chain) = fresh_store_and_chain(&config); // A block claiming an id far past genesis does not chain onto the local tip. let mut orphan = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); @@ -262,10 +263,9 @@ async fn fails_when_a_channel_block_does_not_extend_the_tip() { Some(Slot::from(10)), messages, ); - let result = SequencerCore::::verify_and_reconstruct( - &mock, &mut store, &mut state, true, - ) - .await; + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; assert!( result.is_err(), "a non-contiguous channel block must abort startup" @@ -386,13 +386,12 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() { let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); SequencerCore::::verify_and_reconstruct( &mock_b, - &mut seq_b.store, - &mut seq_b.state, + &seq_b.store, + &seq_b.chain, true, ) .await .expect("reconstruct"); - seq_b.chain_height = seq_b.store.latest_block_meta().unwrap().unwrap().id; let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); assert_eq!(tip_b.id, tip_a.id); @@ -402,15 +401,17 @@ async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() { let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient); let bridge_id = system_accounts::bridge_account_id(); + let state_b = seq_b.chain().lock().unwrap().head_state().clone(); + let state_a = seq_a.chain().lock().unwrap().head_state().clone(); for account in [vault_id, bridge_id, recipient] { assert_eq!( - seq_b.state().get_account_by_id(account).balance, - seq_a.state().get_account_by_id(account).balance, + state_b.get_account_by_id(account).balance, + state_a.get_account_by_id(account).balance, "reconstructed balance mismatch for {account:?}", ); } assert_eq!( - seq_b.state().get_account_by_id(vault_id).balance, + state_b.get_account_by_id(vault_id).balance, u128::from(deposit_amount), "deposit must mint into the recipient vault exactly once, not twice" ); @@ -489,17 +490,11 @@ async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() { // Sequencer B reconstructs A's chain from a fresh store. let config_b = bridge_funded_config(); - let (mut store_b, mut state_b) = - SequencerCore::::open_or_create_store(&config_b); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); - SequencerCore::::verify_and_reconstruct( - &mock_b, - &mut store_b, - &mut state_b, - true, - ) - .await - .expect("reconstruct"); + SequencerCore::::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true) + .await + .expect("reconstruct"); assert!( !store_b.dbio().consume_unseen_withdraw_count(key).unwrap(), @@ -540,8 +535,7 @@ async fn reconstruction_reconciles_already_finished_deposit() { // pre-seeded, as the cold-start backfill would when it re-observes this // already-finalized deposit. let config_b = bridge_funded_config(); - let (mut store_b, mut state_b) = - SequencerCore::::open_or_create_store(&config_b); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); assert!( store_b .dbio() @@ -550,19 +544,19 @@ async fn reconstruction_reconciles_already_finished_deposit() { ); let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); - SequencerCore::::verify_and_reconstruct( - &mock_b, - &mut store_b, - &mut state_b, - true, - ) - .await - .expect("reconstruct"); + SequencerCore::::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true) + .await + .expect("reconstruct"); // The mint was applied exactly once. let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient); assert_eq!( - state_b.get_account_by_id(vault_id).balance, + chain_b + .lock() + .unwrap() + .head_state() + .get_account_by_id(vault_id) + .balance, u128::from(deposit_amount), "already-finished deposit must be applied exactly once" ); @@ -599,17 +593,16 @@ async fn committed_local_against_missing_channel_fails_without_anchor() { // Reopen: blocks beyond genesis, no anchor. `is_fresh_start = false` stands in // for a checkpoint persisted by a prior sync (the mock never emits one). - let (mut store, mut state) = SequencerCore::::open_or_create_store(&config); + let (store, chain) = fresh_store_and_chain(&config); assert!(store.get_zone_anchor().unwrap().is_none()); assert!(store.latest_block_meta().unwrap().unwrap().id > 1); // The channel is gone: no tip, no messages. let mock = MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]); - let result = SequencerCore::::verify_and_reconstruct( - &mock, &mut store, &mut state, false, - ) - .await; + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, false) + .await; assert!( result.is_err(), "committed blocks against a missing channel must abort startup" diff --git a/lez/sequencer/service/Cargo.toml b/lez/sequencer/service/Cargo.toml index 3427dc22..338aac0d 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "sequencer_service" version = "0.1.0" +default-run = "sequencer_service" edition = "2024" license = { workspace = true } @@ -19,6 +20,7 @@ programs.workspace = true clap = { workspace = true, features = ["derive", "env"] } anyhow.workspace = true env_logger.workspace = true +hex.workspace = true log.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/lez/sequencer/service/rpc/src/lib.rs b/lez/sequencer/service/rpc/src/lib.rs index f32f6bd2..a1d2acfb 100644 --- a/lez/sequencer/service/rpc/src/lib.rs +++ b/lez/sequencer/service/rpc/src/lib.rs @@ -90,6 +90,4 @@ pub trait Rpc { #[method(name = "getChannelId")] async fn get_channel_id(&self) -> Result; - - // ============================================================================================= } diff --git a/lez/sequencer/service/src/bin/bedrock_pubkey.rs b/lez/sequencer/service/src/bin/bedrock_pubkey.rs new file mode 100644 index 00000000..ad602be6 --- /dev/null +++ b/lez/sequencer/service/src/bin/bedrock_pubkey.rs @@ -0,0 +1,35 @@ +//! Prints the sequencer's bedrock signing public key (hex) without booting it. +//! +//! Loads `/bedrock_signing_key` from the given sequencer config, creating +//! the key if it doesn't exist yet, so that a node can be accredited into the +//! channel committee before its first boot. + +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; + +#[derive(Debug, Parser)] +#[clap(version)] +struct Args { + #[clap(name = "config")] + config_path: PathBuf, + /// Override the config's home directory, matching the sequencer's --home. + #[clap(long)] + home: Option, +} + +#[expect( + clippy::print_stdout, + reason = "the hex pubkey on stdout is this binary's output" +)] +fn main() -> Result<()> { + let Args { config_path, home } = Args::parse(); + + let config = sequencer_service::SequencerConfig::from_path(&config_path)?; + let home = home.unwrap_or(config.home); + let key = sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?; + println!("{}", hex::encode(key.public_key().to_bytes())); + + Ok(()) +} diff --git a/lez/sequencer/service/src/bin/configure_channel.rs b/lez/sequencer/service/src/bin/configure_channel.rs new file mode 100644 index 00000000..023e32b0 --- /dev/null +++ b/lez/sequencer/service/src/bin/configure_channel.rs @@ -0,0 +1,71 @@ +//! Posts a `ChannelConfig` op (accredited keys + rotation params) to bedrock, +//! signed with `/bedrock_signing_key`, without booting the sequencer. +//! +//! Authorization is holding the admin key file โ€” the L1 rejects non-admin +//! signers. Acceptance is asynchronous: a rejection only shows up in node +//! logs and on-chain behavior. + +use std::path::PathBuf; + +use anyhow::{Context as _, Result, anyhow}; +use clap::Parser; +use sequencer_core::block_publisher::{Ed25519PublicKey, post_channel_config}; + +#[derive(Debug, Parser)] +#[clap(version)] +struct Args { + #[clap(name = "config")] + config_path: PathBuf, + /// Override the config's home directory, matching the sequencer's --home. + #[clap(long)] + home: Option, + /// Accredited ed25519 public keys (hex), admin (this node's key) first. + #[clap(long, required = true, value_delimiter = ',')] + keys: Vec, + /// Slots a sequencer's posting turn lasts. + #[clap(long)] + posting_timeframe: u32, + /// Slots after which a stalled turn can be taken over. + #[clap(long)] + posting_timeout: u32, + /// Signatures required for future config changes. + #[clap(long, default_value_t = 1)] + configuration_threshold: u16, + /// Signatures required for channel withdrawals. + #[clap(long, default_value_t = 1)] + withdraw_threshold: u16, +} + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::init(); + let args = Args::parse(); + + let config = sequencer_service::SequencerConfig::from_path(&args.config_path)?; + let home = args.home.unwrap_or(config.home); + let signing_key = + sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?; + let keys = args + .keys + .iter() + .map(|key| parse_key(key)) + .collect::>>()?; + + post_channel_config( + &config.bedrock_config, + &signing_key, + keys, + args.posting_timeframe, + args.posting_timeout, + args.configuration_threshold, + args.withdraw_threshold, + ) + .await +} + +fn parse_key(hex_key: &str) -> Result { + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(hex_key, &mut bytes) + .with_context(|| format!("Invalid hex-encoded key {hex_key}"))?; + Ed25519PublicKey::from_bytes(&bytes).map_err(|err| anyhow!("Invalid Ed25519 public key: {err}")) +} diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index 687ee424..a8aa856e 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -11,10 +11,11 @@ use mempool::MemPoolHandle; use sequencer_core::SequencerCore; #[cfg(feature = "standalone")] use sequencer_core::SequencerCoreWithMockClients as SequencerCore; -use sequencer_core::TransactionOrigin; pub use sequencer_core::config::*; +use sequencer_core::{TransactionOrigin, block_publisher::BlockPublisherTrait as _}; use sequencer_service_rpc::RpcServer as _; use tokio::{sync::Mutex, task::JoinHandle}; +use tokio_util::sync::CancellationToken; pub mod service; @@ -28,6 +29,9 @@ pub struct SequencerHandle { /// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`. server_handle: Option, main_loop_handle: JoinHandle>, + /// Cancelled when the publisher's drive task terminates (e.g. a panicked + /// persist sink); no channel events are processed past that point. + driver_cancellation: CancellationToken, } impl SequencerHandle { @@ -35,11 +39,13 @@ impl SequencerHandle { addr: SocketAddr, server_handle: ServerHandle, main_loop_handle: JoinHandle>, + driver_cancellation: CancellationToken, ) -> Self { Self { addr, server_handle: Some(server_handle), main_loop_handle, + driver_cancellation, } } @@ -53,6 +59,7 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, + driver_cancellation, } = &mut self; let server_handle = server_handle.take().expect("Server handle is set"); @@ -65,6 +72,9 @@ impl SequencerHandle { .context("Main loop task panicked")? .context("Main loop exited unexpectedly") } + () = driver_cancellation.cancelled() => { + Err(anyhow!("Publisher drive task terminated")) + } } } @@ -78,10 +88,12 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, + driver_cancellation, } = self; let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped) - || main_loop_handle.is_finished(); + || main_loop_handle.is_finished() + || driver_cancellation.is_cancelled(); !stopped } @@ -97,6 +109,7 @@ impl Drop for SequencerHandle { addr: _, server_handle, main_loop_handle, + driver_cancellation: _, } = self; main_loop_handle.abort(); @@ -111,21 +124,23 @@ impl Drop for SequencerHandle { } } -pub async fn run(config: SequencerConfig, port: u16) -> Result { +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; - let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await; + let (sequencer_core, mempool_handle): (SequencerCore, _) = + SequencerCore::start_from_config(config).await; info!("Sequencer core set up"); + let driver_cancellation = sequencer_core.block_publisher().driver_cancellation(); let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core)); let mempool_handle_for_server = mempool_handle.clone(); let (server_handle, addr) = run_server( Arc::clone(&seq_core_wrapped), mempool_handle_for_server, - port, + listen_addr, max_block_size.as_u64(), ) .await?; @@ -136,13 +151,18 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result let _ = mempool_handle; - Ok(SequencerHandle::new(addr, server_handle, main_loop_handle)) + Ok(SequencerHandle::new( + addr, + server_handle, + main_loop_handle, + driver_cancellation, + )) } async fn run_server( sequencer: Arc>, mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - port: u16, + listen_addr: SocketAddr, max_block_size: u64, ) -> Result<(ServerHandle, SocketAddr)> { let server = jsonrpsee::server::ServerBuilder::with_config( @@ -153,7 +173,7 @@ async fn run_server( ) .build(), ) - .build(SocketAddr::from(([0, 0, 0, 0], port))) + .build(listen_addr) .await .context("Failed to build RPC server")?; @@ -172,16 +192,15 @@ async fn main_loop(seq_core: Arc>, block_timeout: Duration) loop { tokio::time::sleep(block_timeout).await; - info!("Collecting transactions from mempool, block creation"); + let mut state = seq_core.lock().await; - let id = { - let mut state = seq_core.lock().await; - - state.produce_new_block().await? - }; + // Only produce on our turn. + if !state.is_our_turn() { + continue; + } + info!("Our turn: collecting transactions from mempool, creating block"); + let id = state.produce_new_block().await?; info!("Block with id {id} created"); - - info!("Waiting for new transactions"); } } diff --git a/lez/sequencer/service/src/main.rs b/lez/sequencer/service/src/main.rs index 8b577bb8..7c50ece6 100644 --- a/lez/sequencer/service/src/main.rs +++ b/lez/sequencer/service/src/main.rs @@ -1,4 +1,7 @@ -use std::path::PathBuf; +use std::{ + net::{IpAddr, SocketAddr}, + path::PathBuf, +}; use anyhow::Result; use clap::Parser; @@ -12,6 +15,14 @@ struct Args { config_path: PathBuf, #[clap(short, long, default_value = "3040")] port: u16, + /// Interface the RPC server binds to. The RPC has no caller auth โ€” + /// bind loopback unless the port is firewalled. + #[clap(long, default_value = "0.0.0.0")] + listen_address: IpAddr, + /// Override the config's home directory (`RocksDB` + bedrock signing key), + /// so multiple instances can share one config file. + #[clap(long)] + home: Option, } #[tokio::main] @@ -22,14 +33,23 @@ struct Args { async fn main() -> Result<()> { env_logger::init(); - let Args { config_path, port } = Args::parse(); + let Args { + config_path, + port, + listen_address, + home, + } = Args::parse(); // TODO: handle this cancellation token more gracefully within Sequencer service // similar to how we do in Indexer let cancellation_token = listen_for_shutdown_signal(); - let config = sequencer_service::SequencerConfig::from_path(&config_path)?; - let sequencer_handle = sequencer_service::run(config, port).await?; + let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?; + if let Some(home) = home { + config.home = home; + } + let sequencer_handle = + sequencer_service::run(config, SocketAddr::new(listen_address, port)).await?; tokio::select! { () = cancellation_token.cancelled() => { diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index d0850837..7ab9ed3c 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -40,7 +40,7 @@ impl SequencerService { } #[async_trait] -impl sequencer_service_rpc::RpcServer +impl sequencer_service_rpc::RpcServer for SequencerService { async fn send_transaction(&self, tx: LeeTransaction) -> Result { @@ -138,8 +138,8 @@ impl sequencer_service_rpc::RpcServer async fn get_account_balance(&self, account_id: AccountId) -> Result { let sequencer = self.sequencer.lock().await; - let account = sequencer.state().get_account_by_id(account_id); - Ok(account.balance) + let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance); + Ok(balance) } async fn get_transaction( @@ -155,10 +155,12 @@ impl sequencer_service_rpc::RpcServer account_ids: Vec, ) -> Result, ErrorObjectOwned> { let sequencer = self.sequencer.lock().await; - let nonces = account_ids - .into_iter() - .map(|account_id| sequencer.state().get_account_by_id(account_id).nonce) - .collect(); + let nonces = sequencer.with_state(|state| { + account_ids + .into_iter() + .map(|account_id| state.get_account_by_id(account_id).nonce) + .collect() + }); Ok(nonces) } @@ -167,17 +169,18 @@ impl sequencer_service_rpc::RpcServer commitments: Vec, ) -> Result<(Vec>, CommitmentSetDigest), ErrorObjectOwned> { let sequencer = self.sequencer.lock().await; - let state = sequencer.state(); - let proofs = commitments - .iter() - .map(|commitment| state.get_proof_for_commitment(commitment)) - .collect(); - Ok((proofs, state.commitment_root())) + Ok(sequencer.with_state(|state| { + let proofs = commitments + .iter() + .map(|commitment| state.get_proof_for_commitment(commitment)) + .collect(); + (proofs, state.commitment_root()) + })) } async fn get_account(&self, account_id: AccountId) -> Result { let sequencer = self.sequencer.lock().await; - Ok(sequencer.state().get_account_by_id(account_id)) + Ok(sequencer.with_state(|state| state.get_account_by_id(account_id))) } async fn get_program_ids(&self) -> Result, ErrorObjectOwned> { diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 47ebc41b..0150a99f 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -19,10 +19,12 @@ use crate::{ }, error::DbError, sequencer::sequencer_cells::{ - LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned, - LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned, - PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey, - ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef, + FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned, + FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, + LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord, + PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell, + WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, + ZoneSdkCheckpointCellRef, }, }; @@ -46,6 +48,10 @@ pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count"; /// Key base for storing the LEE state. pub const DB_LEE_STATE_KEY: &str = "lee_state"; +/// Key base for storing the LEE state at the last L1-finalized block. +pub const DB_FINAL_LEE_STATE_KEY: &str = "final_lee_state"; +/// Key base for storing `(id, hash)` of the last L1-finalized block. +pub const DB_FINAL_BLOCK_META_KEY: &str = "final_block_meta"; /// Name of state column family. pub const CF_LEE_STATE_NAME: &str = "cf_lee_state"; @@ -403,6 +409,25 @@ impl RocksDBIO { Ok(true) } + /// Marks the given deposit events submitted in `block_id`, in one write. + pub fn mark_deposit_events_submitted( + &self, + deposit_op_ids: &[HashType], + submitted_block_id: u64, + ) -> DbResult<()> { + if deposit_op_ids.is_empty() { + return Ok(()); + } + let mut batch = WriteBatch::default(); + self.mark_pending_deposit_events_submitted(deposit_op_ids, submitted_block_id, &mut batch)?; + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to mark deposit events submitted".to_owned()), + ) + }) + } + fn mark_pending_deposit_events_submitted( &self, deposit_op_ids: &[HashType], @@ -503,23 +528,36 @@ impl RocksDBIO { } pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> { - let cf_block = self.block_column(); - if !first { + // A produced block is the new head tip by construction: pin the + // tip meta and drop any stale higher blocks a preceding reorg left + // behind (mirrors `store_followed_blocks`). let last_curr_block = self.get_meta_last_block_in_db()?; - - if block.header.block_id > last_curr_block { - self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?; - self.put_meta_latest_block_meta_batch( - &BlockMeta { - id: block.header.block_id, - hash: block.header.hash, - }, - batch, - )?; + for stale_id in block.header.block_id.saturating_add(1)..=last_curr_block { + self.delete_block_payload(stale_id, batch)?; } + self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?; + self.put_meta_latest_block_meta_batch(&BlockMeta::from(block), batch)?; } + self.put_block_payload(block, batch) + } + + /// Stages deletion of a block payload into `batch`. + fn delete_block_payload(&self, block_id: u64, batch: &mut WriteBatch) -> DbResult<()> { + let cf_block = self.block_column(); + batch.delete_cf( + &cf_block, + borsh::to_vec(&block_id).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) + })?, + ); + Ok(()) + } + + /// Stages just the block payload into `batch`, without touching the tip meta. + fn put_block_payload(&self, block: &Block, batch: &mut WriteBatch) -> DbResult<()> { + let cf_block = self.block_column(); batch.put_cf( &cf_block, borsh::to_vec(&block.header.block_id).map_err(|err| { @@ -537,6 +575,26 @@ impl RocksDBIO { .map(|opt| opt.map(|val| val.0)) } + /// `(state, meta)` at the last L1-finalized block; `None` until the first + /// finalization is observed. + pub fn get_final_snapshot(&self) -> DbResult> { + let Some(meta) = self.get_opt::(())? else { + return Ok(None); + }; + let state = self.get::(())?; + Ok(Some((state.0, meta.0))) + } + + fn put_final_snapshot_batch( + &self, + state: &V03State, + meta: &BlockMeta, + batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&FinalLeeStateCellRef(state), (), batch)?; + self.put_batch(&FinalBlockMetaCellRef(meta), (), batch) + } + pub fn get_lee_state(&self) -> DbResult { self.get::(()).map(|val| val.0) } @@ -633,6 +691,97 @@ impl RocksDBIO { Ok(()) } + /// One-block form of [`Self::store_followed_blocks`], with the block as the + /// head tip and no final snapshot. Production always uses the batch form. + #[cfg(test)] + fn store_followed_block( + &self, + block: &Block, + state: &V03State, + finalized: bool, + ) -> DbResult<()> { + self.store_followed_blocks( + &[(block, finalized)], + Some(&BlockMeta::from(block)), + state, + None, + ) + } + + /// Persists a batch of followed blocks, the caller's head-tip `state`, and + /// the optional final-tier snapshot in one atomic write. + /// + /// The tip meta is pinned to `head_tip`, and blocks stored above it (left + /// behind by a net-shortening reorg) are deleted in the same write, so + /// restart replay never walks past the tip. + /// + /// Per block: skips the payload write when the store already holds it (by + /// id and hash), unless `finalized` is set, which rewrites it with the + /// finalized status. A no-op update (nothing to write, tip unchanged) + /// writes nothing. + /// + /// TODO: the zone-sdk checkpoint is persisted by `on_checkpoint` *before* + /// this write. Full `BlocksProcessed` atomicity (checkpoint, blocks, state + /// and orphan reverts in one batch) is a follow-up. + pub fn store_followed_blocks( + &self, + blocks: &[(&Block, bool)], + head_tip: Option<&BlockMeta>, + state: &V03State, + final_snapshot: Option<(&V03State, &BlockMeta)>, + ) -> DbResult<()> { + let last_block_in_db = self.get_meta_last_block_in_db()?; + let mut batch = WriteBatch::default(); + + for (block, finalized) in blocks { + let already_stored = self + .get_block(block.header.block_id)? + .filter(|stored| stored.header.hash == block.header.hash); + + let mut to_write = match already_stored { + Some(_) if !finalized => continue, + Some(stored) => stored, + None => (*block).clone(), + }; + if *finalized { + to_write.bedrock_status = BedrockStatus::Finalized; + } + self.put_block_payload(&to_write, &mut batch)?; + } + + // `head_tip` is `None` only for a chain holding no blocks at all, which + // implies nothing was applied โ€” and the store, created with genesis, + // cannot represent it. No tip to pin, nothing to persist. + let Some(tip) = head_tip else { + debug_assert!(batch.is_empty() && final_snapshot.is_none()); + return Ok(()); + }; + + // A shrink-only update (orphans without adopted replacements) has no + // payloads to write but must still rewind the tip meta, or the stored + // state tears against the stale disk head on the next produce. + if batch.is_empty() && final_snapshot.is_none() && tip.id == last_block_in_db { + return Ok(()); + } + + for stale_id in tip.id.saturating_add(1)..=last_block_in_db { + self.delete_block_payload(stale_id, &mut batch)?; + } + self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?; + self.put_meta_latest_block_meta_batch(tip, &mut batch)?; + self.put_lee_state_in_db_batch(state, &mut batch)?; + if let Some((final_state, final_meta)) = final_snapshot { + self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?; + } + + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to write followed blocks batch".to_owned()), + ) + }) + } + pub fn get_all_blocks(&self) -> impl Iterator> { let cf_block = self.block_column(); self.db @@ -682,3 +831,6 @@ impl RocksDBIO { }) } } + +#[cfg(test)] +mod tests; diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 368fb648..c10b3d30 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -7,10 +7,10 @@ use crate::{ cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell}, error::DbError, sequencer::{ - CF_LEE_STATE_NAME, DB_LEE_STATE_KEY, DB_META_LAST_FINALIZED_BLOCK_ID, - DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY, - DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_CURSOR_KEY, - DB_META_ZONE_SDK_CHECKPOINT_KEY, + CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY, + DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY, + DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY, + DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, }, }; @@ -44,6 +44,72 @@ impl SimpleWritableCell for LEEStateCellRef<'_> { } } +/// State at the last L1-finalized block, written atomically with +/// [`FinalBlockMetaCellRef`]. +#[derive(BorshDeserialize)] +pub struct FinalLeeStateCellOwned(pub V03State); + +impl SimpleStorableCell for FinalLeeStateCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY; + const CF_NAME: &'static str = CF_LEE_STATE_NAME; +} + +impl SimpleReadableCell for FinalLeeStateCellOwned {} + +#[derive(BorshSerialize)] +pub struct FinalLeeStateCellRef<'state>(pub &'state V03State); + +impl SimpleStorableCell for FinalLeeStateCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY; + const CF_NAME: &'static str = CF_LEE_STATE_NAME; +} + +impl SimpleWritableCell for FinalLeeStateCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize final state".to_owned())) + }) + } +} + +/// `(id, hash)` of the last L1-finalized block, paired with [`FinalLeeStateCellRef`]. +#[derive(BorshDeserialize)] +pub struct FinalBlockMetaCellOwned(pub BlockMeta); + +impl SimpleStorableCell for FinalBlockMetaCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for FinalBlockMetaCellOwned {} + +#[derive(BorshSerialize)] +pub struct FinalBlockMetaCellRef<'blockmeta>(pub &'blockmeta BlockMeta); + +impl SimpleStorableCell for FinalBlockMetaCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for FinalBlockMetaCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize final block meta".to_owned()), + ) + }) + } +} + #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct LastFinalizedBlockIdCell(pub Option); diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs new file mode 100644 index 00000000..0b6ce862 --- /dev/null +++ b/lez/storage/src/sequencer/tests.rs @@ -0,0 +1,283 @@ +use common::test_utils::produce_dummy_block; +use lee::{Account, AccountId}; +use tempfile::tempdir; + +use super::*; + +fn marker_id() -> AccountId { + AccountId::new([1; 32]) +} + +/// A state distinguishable by the marker account's balance, so tests can tell +/// which snapshot a write persisted. +/// +/// TODO: is this a bit too much of a hot-fix for test snapshot? +fn state_with_balance(balance: u128) -> V03State { + V03State::new().with_public_accounts([( + marker_id(), + Account { + balance, + ..Account::default() + }, + )]) +} + +fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) { + let genesis = produce_dummy_block(1, None, vec![]); + let dbio = RocksDBIO::create(path, &genesis, &state_with_balance(100)).unwrap(); + (dbio, genesis) +} + +fn stored_balance(dbio: &RocksDBIO) -> u128 { + dbio.get_lee_state() + .unwrap() + .get_account_by_id(marker_id()) + .balance +} + +#[test] +fn store_followed_block_persists_new_block_and_state() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored.header.hash, block2.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Pending)); + assert_eq!( + dbio.latest_block_meta().unwrap().expect("meta is set").id, + 2 + ); + assert_eq!(stored_balance(&dbio), 200); +} + +#[test] +fn store_followed_block_finalized_marks_block() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), true) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[test] +fn store_followed_block_redelivery_is_a_noop_and_keeps_finalized() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), true) + .unwrap(); + dbio.store_followed_block(&block2, &state_with_balance(300), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!( + matches!(stored.bedrock_status, BedrockStatus::Finalized), + "re-delivery must not demote a finalized block" + ); + assert_eq!( + stored_balance(&dbio), + 200, + "re-delivery must not overwrite the persisted state" + ); +} + +#[test] +fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + // Block 2 is already stored (own production); one update then finalizes it + // and adopts block 3. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let head_tip = BlockMeta { + id: 3, + hash: block3.header.hash, + }; + dbio.store_followed_blocks( + &[(&block2, true), (&block3, false)], + Some(&head_tip), + &state_with_balance(300), + None, + ) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized)); + let stored3 = dbio.get_block(3).unwrap().expect("block 3 is stored"); + assert!(matches!(stored3.bedrock_status, BedrockStatus::Pending)); + + // Meta and state land together on the last block of the batch. + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 3); + assert_eq!(meta.hash, block3.header.hash); + assert_eq!(stored_balance(&dbio), 300); +} + +#[test] +fn final_snapshot_round_trips_and_is_absent_on_fresh_store() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + // Fresh store: no finalization observed yet. + assert!(dbio.get_final_snapshot().unwrap().is_none()); + + // A follow update that finalizes block 2 lands the snapshot in the same batch. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let final_meta = BlockMeta { + id: 2, + hash: block2.header.hash, + }; + dbio.store_followed_blocks( + &[(&block2, true)], + Some(&final_meta), + &state_with_balance(300), + Some((&state_with_balance(200), &final_meta)), + ) + .unwrap(); + + let (final_state, meta) = dbio + .get_final_snapshot() + .unwrap() + .expect("final snapshot is stored"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2.header.hash); + assert_eq!(final_state.get_account_by_id(marker_id()).balance, 200); + // The head state is stored independently of the final snapshot. + assert_eq!(stored_balance(&dbio), 300); +} + +#[test] +fn store_followed_block_overwrites_competing_block_at_same_id() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + + // A reorg replaces block 2: the competing block wins the slot. + let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + dbio.store_followed_block(&block2b, &state_with_balance(300), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored.header.hash, block2b.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Pending)); + assert_eq!(stored_balance(&dbio), 300); + + // The tip meta must follow the reorg winner, or a restart seeds the chain + // from the orphaned block's hash. + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); +} + +#[test] +fn net_shortening_reorg_drops_stale_blocks() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // A shorter competing chain wins: block 2 is replaced, block 3 gets no + // replacement. + let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + let head_tip = BlockMeta { + id: 2, + hash: block2b.header.hash, + }; + dbio.store_followed_blocks( + &[(&block2b, false)], + Some(&head_tip), + &state_with_balance(400), + None, + ) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored2.header.hash, block2b.header.hash); + assert!( + dbio.get_block(3).unwrap().is_none(), + "stale block above the new head must be deleted, or restart replay panics on its broken link" + ); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); + assert_eq!(stored_balance(&dbio), 400); +} + +#[test] +fn shrink_only_reorg_rewinds_tip_meta() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // Orphan-only update: block 3 falls off the branch with no replacement. + let head_tip = BlockMeta { + id: 2, + hash: block2.header.hash, + }; + dbio.store_followed_blocks(&[], Some(&head_tip), &state_with_balance(200), None) + .unwrap(); + + assert!( + dbio.get_block(3).unwrap().is_none(), + "the orphaned block must not survive the tip rewind" + ); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2.header.hash); + assert_eq!(stored_balance(&dbio), 200); +} + +#[test] +fn produced_block_below_disk_head_pins_meta_and_prunes() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // Producing at height 2 while the disk head is still 3: the produce path + // pins the tip meta to the produced block and drops the stale suffix in + // the same write, mirroring the follow path. + let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.atomic_update(&block2b, &[], vec![], &state_with_balance(400)) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored2.header.hash, block2b.header.hash); + assert!(dbio.get_block(3).unwrap().is_none()); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); + assert_eq!(stored_balance(&dbio), 400); +} diff --git a/lez/wallet/Cargo.toml b/lez/wallet/Cargo.toml index 974d6a71..c03c1505 100644 --- a/lez/wallet/Cargo.toml +++ b/lez/wallet/Cargo.toml @@ -25,7 +25,6 @@ system_accounts.workspace = true associated_token_account_core.workspace = true bip39.workspace = true -pyo3.workspace = true rpassword = "7" zeroize.workspace = true diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index 824a994d..37f8761d 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -1,14 +1,14 @@ use core::fmt; use anyhow::Result; -use keycard_wallet::{KeycardWallet, python_path}; +use keycard_wallet::KeycardWallet; use lee::{AccountId, PrivateKey, PublicKey, Signature}; use lee_core::{ Commitment, CommitmentSetDigest, Identifier, InputAccountIdentity, MembershipProof, NullifierPublicKey, NullifierSecretKey, SharedSecretKey, account::{AccountWithMetadata, Nonce}, compute_digest_for_path, - encryption::ViewingPublicKey, + encryption::{ViewTag, ViewingPublicKey}, }; use rand::{RngCore as _, rngs::OsRng}; @@ -237,14 +237,7 @@ impl AccountManager { if pin.is_none() { pin = Some( crate::helperfunctions::read_pin() - .map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::< - pyo3::exceptions::PyRuntimeError, - _, - >( - e.to_string() - )) - })? + .map_err(ExecutionFailureKind::SignError)? .as_str() .to_owned(), ); @@ -411,6 +404,7 @@ impl AccountManager { (Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate { vpk: pre.vpk.clone(), random_seed: pre.random_seed, + view_tag: random_view_tag(), nsk, membership_proof, identifier: pre.identifier, @@ -430,6 +424,7 @@ impl AccountManager { InputAccountIdentity::PrivateAuthorizedUpdate { vpk: pre.vpk.clone(), random_seed: pre.random_seed, + view_tag: random_view_tag(), nsk, membership_proof, identifier: pre.identifier, @@ -498,17 +493,11 @@ impl AccountManager { .collect(); if let Some(pin) = self.pin.clone() { - pyo3::Python::attach(|py| -> pyo3::PyResult<()> { - python_path::add_python_path(py)?; - let wallet = KeycardWallet::new(py)?; - wallet.connect(py, &pin)?; - for path in keycard_paths { - sigs.push(wallet.sign_message_for_path(py, path, &message_hash)?); - } - let _res = wallet.close_session(py); - Ok(()) - }) - .map_err(anyhow::Error::from)?; + let mut wallet = KeycardWallet::new()?; + wallet.connect(&pin)?; + for path in keycard_paths { + sigs.push(wallet.sign_message_for_path(path, &message_hash)?); + } } Ok(sigs) @@ -650,6 +639,13 @@ fn validate_proofs_against_root( Ok(()) } +/// Generate random byte using OS randomness. +fn random_view_tag() -> ViewTag { + let mut byte: [u8; 1] = [0; 1]; + OsRng.fill_bytes(&mut byte); + byte[0] +} + #[cfg(test)] mod tests { use super::*; diff --git a/lez/wallet/src/cli/account.rs b/lez/wallet/src/cli/account.rs index c700d2e9..c165deee 100644 --- a/lez/wallet/src/cli/account.rs +++ b/lez/wallet/src/cli/account.rs @@ -111,8 +111,9 @@ pub enum NewSubcommand { #[arg(long, requires = "pda")] /// Program ID as hex string. program_id: Option, - #[arg(long, requires = "pda")] - /// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family. + #[arg(long)] + /// Identifier selecting the shared account. + /// Co-owners must supply the same value to derive the same account. /// Defaults to a random value if not specified. identifier: Option, }, @@ -197,7 +198,7 @@ impl NewSubcommand { Ok(SubcommandReturnValue::RegisterAccount { account_id }) } - fn handle_private_gms( + async fn handle_private_gms( group: &Label, label: Option