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 ca8c2a0f..7ddd3458 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" @@ -1368,6 +1431,26 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chain_state" +version = "0.1.0" +dependencies = [ + "anyhow", + "borsh", + "common", + "futures", + "lee", + "lee_core", + "log", + "logos-blockchain-core", + "logos-blockchain-zone-sdk", + "serde", + "serde_json", + "testnet_initial_state", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "chkstk_stub" version = "0.1.0" @@ -1560,7 +1643,7 @@ version = "0.1.0" dependencies = [ "anyhow", "authenticated_transfer_core", - "base64", + "base64 0.22.1", "borsh", "clock_core", "hex", @@ -1571,7 +1654,7 @@ dependencies = [ "programs", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "system_accounts", "thiserror 2.0.18", ] @@ -1784,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" @@ -1974,6 +2063,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" @@ -2014,6 +2119,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" @@ -2021,6 +2135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -2319,7 +2434,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]] @@ -2398,7 +2515,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", ] @@ -2449,13 +2566,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" @@ -2464,7 +2596,7 @@ checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8 0.10.2", "serde", - "signature", + "signature 2.2.0", ] [[package]] @@ -2477,7 +2609,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -2522,17 +2654,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", ] @@ -2812,6 +2965,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" @@ -3225,11 +3388,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" @@ -3437,7 +3611,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]] @@ -3449,6 +3632,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" @@ -3569,7 +3761,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -3677,7 +3871,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", @@ -3939,6 +4133,7 @@ dependencies = [ "arc-swap", "async-stream", "borsh", + "chain_state", "common", "cross_zone", "cross_zone_inbox_core", @@ -3958,7 +4153,6 @@ dependencies = [ "storage", "tempfile", "testnet_initial_state", - "thiserror 2.0.18", "tokio", "url", ] @@ -4003,7 +4197,7 @@ version = "0.1.0" dependencies = [ "anyhow", "base58", - "base64", + "base64 0.22.1", "common", "hex", "indexer_core", @@ -4076,6 +4270,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding", "hybrid-array", ] @@ -4138,6 +4333,7 @@ dependencies = [ "tempfile", "test_fixtures", "test_programs", + "testnet_initial_state", "token_core", "tokio", "vault_core", @@ -4367,7 +4563,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", @@ -4420,7 +4616,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", @@ -4522,12 +4718,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]] @@ -4572,25 +4783,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", ] @@ -4656,7 +4895,7 @@ dependencies = [ "build_utils", "hex", "hex-literal 1.1.0", - "k256", + "k256 0.13.4", "lee_core", "log", "rand 0.8.6", @@ -4664,7 +4903,7 @@ dependencies = [ "risc0-zkvm", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "test-case", "test_methods", "thiserror 2.0.18", @@ -4694,7 +4933,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", @@ -4896,7 +5135,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", @@ -5087,7 +5326,7 @@ checksum = "d558548fa3b5a8e9b66392f785921e363c57c05dcadfda4db0d41ae82d313e4a" dependencies = [ "async-channel", "asynchronous-codec", - "base64", + "base64 0.22.1", "byteorder", "bytes", "either", @@ -5106,7 +5345,7 @@ dependencies = [ "rand 0.8.6", "regex", "serde", - "sha2", + "sha2 0.10.9", "tracing", "web-time", ] @@ -5141,13 +5380,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", @@ -5173,7 +5412,7 @@ dependencies = [ "quick-protobuf-codec", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tracing", @@ -7208,13 +7447,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", ] @@ -7454,6 +7722,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" @@ -7704,63 +7998,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" @@ -8141,7 +8378,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", @@ -8194,10 +8431,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" @@ -8274,7 +8521,7 @@ dependencies = [ "directories", "hex", "rayon", - "sha2", + "sha2 0.10.9", "tempfile", ] @@ -8334,7 +8581,7 @@ dependencies = [ "risc0-sys", "risc0-zkp", "serde", - "sha2", + "sha2 0.10.9", "tracing", "zip", ] @@ -8471,7 +8718,7 @@ dependencies = [ "bytemuck", "cfg-if", "digest 0.10.7", - "ff", + "ff 0.13.1", "hex", "hex-literal 0.4.1", "metal", @@ -8485,7 +8732,7 @@ dependencies = [ "risc0-sys", "risc0-zkvm-platform", "serde", - "sha2", + "sha2 0.10.9", "stability", "tracing", ] @@ -8532,7 +8779,7 @@ dependencies = [ "rzup", "semver", "serde", - "sha2", + "sha2 0.10.9", "stability", "tempfile", "tracing", @@ -8636,7 +8883,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "spki 0.7.3", "subtle", "zeroize", @@ -8880,7 +9127,7 @@ dependencies = [ "semver", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "strum", "tempfile", "thiserror 2.0.18", @@ -8964,11 +9211,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", ] @@ -9023,6 +9284,7 @@ dependencies = [ "borsh", "bridge_core", "bytesize", + "chain_state", "chrono", "common", "cross_zone", @@ -9053,6 +9315,7 @@ dependencies = [ "testnet_initial_state", "token_core", "tokio", + "tokio-util", "url", "vault_core", ] @@ -9068,6 +9331,7 @@ dependencies = [ "common", "env_logger", "futures", + "hex", "jsonrpsee", "lee", "log", @@ -9241,7 +9505,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", @@ -9286,7 +9550,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", ] @@ -9297,7 +9571,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", @@ -9377,6 +9651,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" @@ -9397,6 +9682,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" @@ -9438,6 +9734,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" @@ -9507,7 +9813,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", @@ -9546,6 +9852,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" @@ -9816,12 +10128,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" @@ -9829,7 +10135,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", @@ -10337,7 +10643,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum 0.8.9", - "base64", + "base64 0.22.1", "bytes", "h2", "http 1.4.1", @@ -10788,7 +11094,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", @@ -10805,7 +11111,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", @@ -10995,13 +11301,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", @@ -11184,7 +11489,7 @@ checksum = "d0a659ffe5c7f4538aa6357c07e3d73221cc61eba03bd9a081e14bc91ed09b8c" dependencies = [ "base16", "quote", - "sha2", + "sha2 0.10.9", "syn 2.0.117", ] @@ -11684,6 +11989,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 164b64cc..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", @@ -72,6 +73,7 @@ members = [ lee = { path = "lee/state_machine" } lee_core = { path = "lee/state_machine/core" } common = { path = "lez/common" } +chain_state = { path = "lez/chain_state" } mempool = { path = "lez/mempool" } storage = { path = "lez/storage" } key_protocol = { path = "lee/key_protocol" } @@ -178,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", @@ -199,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 277403c7..b65c7ff3 100644 --- a/Justfile +++ b/Justfile @@ -11,13 +11,19 @@ ARTIFACTS := "artifacts" # Linux/CI, which is unaffected. DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" } -# Build risc0 program artifacts. +# Build risc0 program artifacts and test fixture. build-artifacts: @echo "๐Ÿ”จ Building artifacts" @rm -rf {{ARTIFACTS}} @just build-artifact lee/privacy_preserving_circuit @just build-artifact lez/programs programs + @if [ "${GITHUB_ACTIONS:-}" = "true" ]; then \ + echo "Skipping test fixture regeneration because CI doesn't need it"; \ + else \ + just regenerate-test-fixture; \ + fi + build-artifact methods_path features="": @echo "Building artifacts for {{methods_path}}" @rm -rf target/{{methods_path}}/riscv32im-risc0-zkvm-elf/docker/*.bin @@ -42,7 +48,7 @@ test: # Regenerate the prebuilt sequencer db dump for fast TestContext::new() (needs Docker; commit the dump). regenerate-test-fixture: - @echo "๐Ÿงช Regenerating test fixtures" + @echo "๐Ÿงช Regenerating test fixture" RISC0_DEV_MODE=1 cargo run -p test_fixtures --bin regenerate_test_fixture # Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup). @@ -58,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..a68f0e30 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..04479ed6 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..0214489a 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..dbb2621b 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..9bf1f42b 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 b0b68094..f57e7d9c 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 02af9294..7cd5a775 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..d2e4330e 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..b6e7af31 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..cf6b1106 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..7b9f72b3 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..31d8687c 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..f2fbb6f6 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..2f328d28 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..c008b597 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 dc9e3454..2f3f4abe 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/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 655ca1a9..bc5c650b 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -624,7 +624,7 @@ async fn prove_init_with_commitment_root( let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); let recipient_account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); - let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id); + let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id); let (output, _) = execute_and_prove( vec![sender_pre, recipient], @@ -633,7 +633,7 @@ async fn prove_init_with_commitment_root( })?, vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk, random_seed: [0; 32], npk, diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 63b14c95..9107314a 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -9,7 +9,7 @@ //! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged; //! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new. -use std::{net::SocketAddr, time::Duration}; +use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; @@ -17,14 +17,14 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{setup_bedrock_node, setup_indexer, setup_sequencer}, + 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); @@ -62,18 +62,19 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { holder: holder_id, amount: INITIAL_BALANCE, }]; - let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None) + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(genesis_a) + .setup() .await .context("Failed to set up zone A sequencer")?; - let (_seq_b, _seq_b_home) = setup_sequencer( - partial, - bedrock_addr, - vec![], - channel_b, - Some(cross_zone.clone()), - ) - .await - .context("Failed to set up zone B sequencer")?; + let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone.clone()) + .setup() + .await + .context("Failed to set up zone B sequencer")?; let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) .await .context("Failed to set up zone B indexer")?; @@ -87,9 +88,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")?; @@ -167,14 +166,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 5f401869..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::{setup_bedrock_node, setup_sequencer}, + 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] @@ -34,7 +32,10 @@ async fn user_origin_inbox_call_rejected() -> Result<()> { .context("Failed to set up Bedrock node")?; let partial = SequencerPartialConfig::default(); let channel = config::bedrock_channel_id(); - let (seq, _seq_home) = setup_sequencer(partial, bedrock_addr, vec![], channel, None) + let (seq, _seq_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel) + .with_genesis(vec![]) + .setup() .await .context("Failed to set up sequencer")?; @@ -70,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 19223b5f..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::{setup_bedrock_node, setup_sequencer}, + 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); @@ -54,13 +54,19 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { }], }; - let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None) + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() .await .context("Failed to set up zone A sequencer")?; - let (seq_b, _seq_b_home) = - setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone)) - .await - .context("Failed to set up zone B sequencer")?; + let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone) + .setup() + .await + .context("Failed to set up zone B sequencer")?; // Submit the ping on zone A, addressed to ping_receiver on zone B. let ping = build_ping_tx(zone_b, receiver_id); @@ -117,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 e2f016c0..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::{setup_bedrock_node, setup_indexer, setup_sequencer}, + 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); @@ -53,21 +53,22 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { // Zone A: source. Zone B: destination, with the watcher on its sequencer and // the verifier on its indexer. - let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None) + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() .await .context("Failed to set up zone A sequencer")?; let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) .await .context("Failed to set up zone A indexer")?; - let (_seq_b, _seq_b_home) = setup_sequencer( - partial, - bedrock_addr, - vec![], - channel_b, - Some(cross_zone.clone()), - ) - .await - .context("Failed to set up zone B sequencer")?; + let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone.clone()) + .setup() + .await + .context("Failed to set up zone B sequencer")?; let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) .await .context("Failed to set up zone B indexer")?; @@ -82,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")?; @@ -129,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 new file mode 100644 index 00000000..9dd2b586 --- /dev/null +++ b/integration_tests/tests/sequencer_bootstrap.rs @@ -0,0 +1,538 @@ +//! End-to-end tests for the sequencer's startup bootstrap/reconstruction from +//! Bedrock (verify-and-reconstruct). Each test drives a real Bedrock node and one +//! or more sequencer instances sharing the same channel, exercising how a +//! sequencer reconciles its local store against what the channel serves. + +#![expect( + clippy::tests_outside_test_module, + reason = "Integration tests live at crate root and don't care about these lints" +)] + +use std::{path::Path, time::Duration}; + +use anyhow::{Context as _, Result, bail}; +use indexer_service_rpc::RpcClient as _; +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}; +use test_fixtures::{ + config::{SequencerPartialConfig, UrlProtocol, addr_to_url}, + indexer_client::IndexerClient, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use tokio::test; + +/// Block cadence for the tests: short so we don't wait long for local production. +fn fast_blocks() -> SequencerPartialConfig { + SequencerPartialConfig { + block_create_timeout: Duration::from_secs(2), + ..SequencerPartialConfig::default() + } +} + +/// Block cadence for a sequencer we don't want producing during the inspection +/// window, so its post-reconstruction tip stays put while we read it. +fn slow_blocks() -> SequencerPartialConfig { + SequencerPartialConfig { + block_create_timeout: Duration::from_secs(30), + ..SequencerPartialConfig::default() + } +} + +/// 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 +/// can read back. +async fn wait_for_finalized( + indexer: &IndexerClient, + target: u64, + timeout: Duration, +) -> Result { + let poll = async { + loop { + let finalized = indexer + .get_last_finalized_block_id() + .await + .context("Failed to read indexer last finalized block id")? + .unwrap_or(0); + if finalized >= target { + return Ok::<_, anyhow::Error>(finalized); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }; + tokio::time::timeout(timeout, poll) + .await + .with_context(|| format!("Timed out waiting for indexer to finalize block {target}"))? +} + +/// Polls the sequencer's last block id until it reaches `target` or `timeout` elapses. +async fn wait_for_block_id( + client: &SequencerClient, + target: u64, + timeout: Duration, +) -> Result { + let poll = async { + loop { + let id = client + .get_last_block_id() + .await + .context("Failed to read sequencer last block id")?; + if id >= target { + return Ok::<_, anyhow::Error>(id); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }; + tokio::time::timeout(timeout, poll) + .await + .with_context(|| format!("Timed out waiting for block id {target}"))? +} + +/// Best-effort extraction of a panic payload's message (panics carry a `String` +/// or `&str`), for asserting a startup aborted for the *expected* reason. +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_owned())) + .unwrap_or_else(|| "".to_owned()) +} + +/// A `SupplyAccount` genesis action for a fresh account, returning the vault +/// account id the funds land in (genesis supply goes into a claimable vault, not +/// the account directly), so tests can assert genesis state is present. +fn supplied_account(balance: u128) -> (AccountId, GenesisAction) { + let account_id = AccountId::from(&PublicKey::new_from_private_key( + &PrivateKey::new_os_random(), + )); + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), account_id); + ( + vault_id, + GenesisAction::SupplyAccount { + account_id, + balance, + }, + ) +} + +/// Recursively copies the contents of `src` into `dst` (used to snapshot/restore a +/// sequencer's rocksdb directory while it is stopped). +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + std::fs::create_dir_all(dst) + .with_context(|| format!("Failed to create dir {}", dst.display()))?; + for entry in + std::fs::read_dir(src).with_context(|| format!("Failed to read dir {}", src.display()))? + { + let entry = entry.context("Failed to read dir entry")?; + let target = dst.join(entry.file_name()); + if entry + .file_type() + .context("Failed to read file type")? + .is_dir() + { + copy_dir_recursive(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), &target) + .with_context(|| format!("Failed to copy into {}", target.display()))?; + } + } + Ok(()) +} + +/// Case 1: local store is empty and the Bedrock channel is empty. +/// +/// The sequencer bootstraps genesis state, finds nothing to reconstruct, publishes +/// its own genesis to open the channel, and starts producing. +#[test] +async fn empty_local_and_empty_bedrock_bootstraps_from_genesis() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + + let (vault_id, supply) = supplied_account(12_345); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + let handle = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to start sequencer")?; + let client = sequencer_client(handle.addr())?; + + // Fresh store + empty channel: startup bootstrapped genesis state directly. + assert_eq!( + client.get_account_balance(vault_id).await?, + 12_345, + "genesis-supplied vault balance must be present after bootstrap" + ); + + // The sequencer is live and producing on the freshly opened channel. + let last = wait_for_block_id(&client, 3, Duration::from_secs(60)).await?; + assert!( + last >= 3, + "sequencer should keep producing blocks, last={last}" + ); + assert!(handle.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} + +/// Case 2: local store is empty, but the Bedrock channel already has blocks. +/// +/// A first sequencer opens the channel and produces blocks; a second sequencer +/// starts from an empty store on the same channel (reusing the first's bedrock +/// signing key) and reconstructs the finalized history into its state. +#[test] +async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> { + const PRODUCED_TARGET: u64 = 3; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(7_777); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // Sequencer A opens the channel and produces a few blocks. + let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer A")?; + let client_a = sequencer_client(handle_a.addr())?; + wait_for_block_id(&client_a, PRODUCED_TARGET, Duration::from_secs(60)).await?; + + // Wait until those blocks are finalized on Bedrock โ€” reconstruction only + // reads finalized history. A stays alive so its publish task keeps flushing. + let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, L2_TO_L1_TIMEOUT).await?; + + // Stop A, then wipe just its L2 store (keeping the bedrock signing key) so it + // restarts from an empty store on the same channel/identity โ€” a sequencer that + // lost its local DB. + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + std::fs::remove_dir_all(home_a.path().join("rocksdb")) + .context("Failed to wipe sequencer L2 store")?; + + // Sequencer B restarts on the same home from that empty store and reconstructs. + let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer B")?; + let client_b = sequencer_client(handle_b.addr())?; + + // Reconstruction ran synchronously during B's startup: even though its local + // store was empty, its tip is past genesis, matching the finalized channel. + let tip_b = client_b.get_last_block_id().await?; + assert!( + tip_b >= finalized, + "B should reconstruct at least the finalized blocks; tip_b={tip_b}, finalized={finalized}" + ); + assert!( + tip_b > 1, + "B should have reconstructed blocks beyond genesis; tip_b={tip_b}" + ); + + // Genesis state was rebuilt as part of the reconstruction. + assert_eq!( + client_b.get_account_balance(vault_id).await?, + 7_777, + "reconstructed genesis vault balance must be present" + ); + assert!(handle_b.is_healthy(), "sequencer B must stay healthy"); + + Ok(()) +} + +/// Case 3: local store is not empty, but the Bedrock channel is empty. +/// +/// A sequencer produces blocks (committing to a channel), is stopped, and is +/// restarted against a fresh/empty channel โ€” i.e. the channel it committed to +/// was wiped or the node points at a different chain. Startup must fail rather +/// than silently resume onto a foreign channel. Crucially this must hold even +/// though the sequencer only ever *produced* (so it never recorded a per-block +/// anchor): the committed-but-missing-channel invariant catches it. +#[test] +async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { + const PRODUCED_TARGET: u64 = 3; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + + let (_vault_id, supply) = supplied_account(1); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // A opens the channel and produces blocks. They land in its local store + // immediately, so no need to wait for finalization. + let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer A")?; + wait_for_block_id( + &sequencer_client(handle_a.addr())?, + PRODUCED_TARGET, + Duration::from_secs(60), + ) + .await?; + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + + // Restart on the SAME home (A's committed store: blocks + checkpoint) but + // pointed at a fresh, never-used channel โ€” the channel it committed to is gone. + let empty_channel = ChannelId::from([0x5a_u8; 32]); + + // Startup aborts on the missing-channel invariant (a panic in + // `start_from_config`). Run it on a dedicated OS thread with its own runtime + // so the panic is isolated to `join()` instead of failing the test thread. + // The `timeout` future must be created *inside* `block_on` (it needs a running + // reactor), so build it in an `async` block rather than as an eager argument. + let home_a_path = home_a.path().to_owned(); + let outcome = std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("Failed to build runtime"); + runtime.block_on(async { + tokio::time::timeout( + Duration::from_secs(90), + SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_channel_id(empty_channel) + .with_genesis(genesis) + .setup_at(&home_a_path), + ) + .await + }) + }) + .join(); + + match outcome { + // Expected: `start_from_config` panicked on the missing-channel invariant. + // Assert the *reason*, so an unrelated panic fails the test rather than + // masquerading as success. + Err(panic) => { + let message = panic_message(&*panic); + assert!( + message.contains("Refusing to resume onto a foreign channel"), + "startup panicked for an unexpected reason: {message}" + ); + } + Ok(Err(_elapsed)) => { + bail!("Sequencer startup hung instead of failing against an empty channel") + } + Ok(Ok(Err(err))) => { + bail!("Sequencer expected to panic, but it failed with error: {err:#?}") + } + Ok(Ok(Ok(_handle))) => { + bail!("Sequencer startup unexpectedly succeeded against an empty channel") + } + } + + Ok(()) +} + +/// Case 4: both non-empty, but the local store is *ahead* of the finalized channel. +/// +/// A sequencer produces blocks faster than Bedrock finalizes them (the normal +/// steady state), so on restart its local tip leads the channel's finalized tip. +/// Startup must succeed: reconstruction re-verifies the finalized blocks it already +/// holds and leaves the extra, not-yet-finalized local blocks untouched. +#[test] +async fn local_ahead_of_channel_resumes() -> Result<()> { + const FINALIZED_TARGET: u64 = 2; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(4_242); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // A produces continuously (fast cadence) while Bedrock finalizes slowly, so + // its local tip runs well ahead of the channel's finalized tip. + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to start sequencer A")?; + let client_a = sequencer_client(handle_a.addr())?; + let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?; + let tip_before = client_a.get_last_block_id().await?; + assert!( + tip_before > finalized, + "local tip {tip_before} should lead the finalized tip {finalized}" + ); + + // Restart on the same home; slow cadence so its tip stays put while we inspect. + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to restart sequencer")?; + let client_b = sequencer_client(handle_b.addr())?; + + // Reconstruction verified the finalized prefix and preserved the extra blocks. + let tip_b = client_b.get_last_block_id().await?; + assert!( + tip_b >= tip_before, + "restart must not lose locally-produced blocks; tip_b={tip_b}, before={tip_before}" + ); + assert_eq!( + client_b.get_account_balance(vault_id).await?, + 4_242, + "genesis state must survive the restart" + ); + assert!(handle_b.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} + +/// Case 5: both non-empty, but the local store is *behind* the finalized channel. +/// +/// We snapshot a sequencer's store at an early tip, let it keep extending and +/// finalizing the channel, then restore the early snapshot and restart. Startup +/// must reconstruct forward โ€” replay the finalized blocks the local store is +/// missing โ€” catching the local tip up to the channel. +/// +/// The snapshot/restore is essential here (not a gratuitous copy): a live +/// sequencer's local tip always leads finalization, so the only way to obtain a +/// local store that *lags* the finalized channel is to preserve an earlier state +/// while the same channel advances past it. +#[test] +async fn local_behind_channel_reconstructs_forward() -> Result<()> { + const SNAPSHOT_TIP: u64 = 2; + const FINALIZED_TARGET: u64 = 4; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(5_005); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + let rocksdb = home.path().join("rocksdb"); + + // Bring the sequencer up to an early tip, then stop it so its store is at rest. + { + let handle = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to start sequencer")?; + wait_for_block_id( + &sequencer_client(handle.addr())?, + SNAPSHOT_TIP, + Duration::from_secs(120), + ) + .await?; + drop(handle); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Snapshot the early store (tip == SNAPSHOT_TIP), safe because it is at rest. + let snapshot = tempfile::tempdir().context("Failed to create snapshot dir")?; + copy_dir_recursive(&rocksdb, snapshot.path()).context("Failed to snapshot store")?; + + // Resume the sequencer (fast cadence) so it extends and finalizes the channel + // beyond the snapshot. + let finalized = { + let handle = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to resume sequencer")?; + let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, L2_TO_L1_TIMEOUT).await?; + drop(handle); + tokio::time::sleep(Duration::from_secs(2)).await; + finalized + }; + + // Restore the early snapshot: the local store now lags the finalized channel. + std::fs::remove_dir_all(&rocksdb).context("Failed to remove store before restore")?; + copy_dir_recursive(snapshot.path(), &rocksdb).context("Failed to restore snapshot")?; + + // Restart: reconstruction must catch the lagging store up to the channel. + let handle = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to restart sequencer from a lagging store")?; + let client = sequencer_client(handle.addr())?; + let tip = client.get_last_block_id().await?; + assert!( + tip >= finalized, + "lagging store must reconstruct forward to the finalized tip; tip={tip}, finalized={finalized}" + ); + assert!( + tip > SNAPSHOT_TIP, + "reconstruction must advance beyond the snapshot tip; tip={tip}" + ); + assert_eq!( + client.get_account_balance(vault_id).await?, + 5_005, + "genesis state must be intact after reconstruction" + ); + assert!(handle.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index 71fb132b..bfcd4c64 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -272,7 +272,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { let recipient_npk = NullifierPublicKey::from(&recipient_nsk); let recipient_pre = AccountWithMetadata::new( Account::default(), - false, + true, AccountId::for_regular_private_account(&recipient_npk, &recipient_vpk, 0), ); @@ -294,11 +294,12 @@ 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, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_vpk, random_seed: [0; 32], npk: recipient_npk, diff --git a/integration_tests/tests/two_zone.rs b/integration_tests/tests/two_zone.rs index c895acd1..8f4b2697 100644 --- a/integration_tests/tests/two_zone.rs +++ b/integration_tests/tests/two_zone.rs @@ -13,7 +13,7 @@ use indexer_service_rpc::RpcClient as _; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{setup_bedrock_node, setup_indexer, setup_sequencer}, + setup::{SequencerSetup, setup_bedrock_node, setup_indexer}, }; use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder}; use tokio::test; @@ -35,13 +35,19 @@ async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> { let channel_b = config::bedrock_channel_id_b(); // Empty genesis is enough: the clock transaction drives block production. - let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None) + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() .await .context("Failed to set up zone A sequencer")?; let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) .await .context("Failed to set up zone A indexer")?; - let (seq_b, _seq_b_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None) + let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .setup() .await .context("Failed to set up zone B sequencer")?; let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None) diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index c9543e05..57ab588a 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,14 +112,14 @@ pub fn compute_circuit_output( post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - &npk, + *view_tag, vpk, random_seed, new_nullifier, new_nonce, ); } - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk, random_seed, npk, @@ -133,8 +135,8 @@ pub fn compute_circuit_output( "Found new private account with non default values", ); assert!( - !pre_state.is_authorized, - "Found new private account marked as authorized." + pre_state.is_authorized, + "Found new private account marked as unauthorized." ); let 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..47b97833 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,13 +42,14 @@ pub enum InputAccountIdentity { PrivateAuthorizedUpdate { vpk: ViewingPublicKey, random_seed: [u8; 32], + view_tag: ViewTag, nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, }, /// Init of a standalone private account the caller does not own (e.g. a recipient who /// doesn't yet exist on chain). No `nsk`, no membership proof. - PrivateUnauthorized { + PrivateForeignInit { vpk: ViewingPublicKey, random_seed: [u8; 32], npk: NullifierPublicKey, @@ -79,6 +80,7 @@ pub enum InputAccountIdentity { PrivatePdaUpdate { vpk: ViewingPublicKey, random_seed: [u8; 32], + view_tag: ViewTag, nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, @@ -125,7 +127,7 @@ impl InputAccountIdentity { Self::Public | Self::PrivateAuthorizedInit { .. } | Self::PrivateAuthorizedUpdate { .. } - | Self::PrivateUnauthorized { .. } => None, + | Self::PrivateForeignInit { .. } => None, } } } 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 f8cc034a..bd1f940c 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..4a82ad9b 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}, }; @@ -60,7 +60,7 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0); - let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id); + let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id); let balance_to_move: u128 = 37; @@ -89,7 +89,7 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() Program::serialize_instruction(balance_to_move).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -144,7 +144,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0); - let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id); + let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id); let balance_to_move: u128 = 37; let mut commitment_set = CommitmentSet::with_capacity(2); @@ -198,13 +198,14 @@ 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) .expect("sender's commitment must be in the set"), identifier: 0, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -242,12 +243,82 @@ 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(), true, account_id); + + let (output, proof) = execute_and_prove( + vec![account], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateForeignInit { + 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(); let pre = AccountWithMetadata::new( Account::default(), - false, + true, AccountId::for_regular_private_account(&account_keys.npk(), &account_keys.vpk(), 0), ); @@ -271,7 +342,7 @@ fn circuit_fails_when_chained_validity_windows_have_empty_intersection() { let result = execute_and_prove( vec![pre], instruction, - vec![InputAccountIdentity::PrivateUnauthorized { + vec![InputAccountIdentity::PrivateForeignInit { vpk: account_keys.vpk(), random_seed: [0; 32], npk: account_keys.npk(), @@ -422,7 +493,7 @@ fn private_pda_withdraw() { /// Shared regular private account: receives funds via `authenticated_transfer` directly, /// no custom program needed. This demonstrates the non-PDA shared account flow where /// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account -/// uses the standard unauthorized private account path and works with auth-transfer's +/// uses the standard foreign private account path and works with auth-transfer's /// transfer path like any other private account. #[test] fn shared_account_receives_via_simple_transfer() { @@ -443,9 +514,9 @@ fn shared_account_receives_via_simple_transfer() { sender_id, ); - // Recipient: shared private account (new, unauthorized) + // Recipient: shared private account (new, foreign) let shared_account_id = AccountId::from((&shared_npk, &shared_keys.vpk(), shared_identifier)); - let recipient = AccountWithMetadata::new(Account::default(), false, shared_account_id); + let recipient = AccountWithMetadata::new(Account::default(), true, shared_account_id); let balance_to_move: u128 = 100; let instruction = Program::serialize_instruction(balance_to_move).unwrap(); @@ -455,7 +526,7 @@ fn shared_account_receives_via_simple_transfer() { instruction, vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: shared_keys.vpk(), random_seed: [0; 32], npk: shared_npk, @@ -507,10 +578,10 @@ fn private_authorized_init_encrypts_regular_kind_with_identifier() { ); } -/// `PrivateUnauthorized` with a non-default identifier produces a ciphertext that decrypts +/// `PrivateForeignInit` with a non-default identifier produces a ciphertext that decrypts /// to `PrivateAccountKind::Regular` carrying the correct identifier. #[test] -fn private_unauthorized_init_encrypts_regular_kind_with_identifier() { +fn private_foreign_init_encrypts_regular_kind_with_identifier() { let program = crate::test_methods::claimer(); let keys = test_private_account_keys_1(); let identifier: u128 = 99; @@ -521,12 +592,12 @@ fn private_unauthorized_init_encrypts_regular_kind_with_identifier() { &Nonce::private_account_nonce_init(&recipient_id), ); let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; - let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id); + let recipient = AccountWithMetadata::new(Account::default(), true, recipient_id); let (output, _) = execute_and_prove( vec![recipient], Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::PrivateUnauthorized { + vec![InputAccountIdentity::PrivateForeignInit { vpk: keys.vpk(), random_seed: [0; 32], npk: keys.npk(), @@ -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, @@ -658,7 +731,7 @@ fn private_pda_init_identifier_mismatch_fails() { let npk = keys.npk(); let seed = PdaSeed::new([42; 32]); let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5); - let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + let pre_state = AccountWithMetadata::new(Account::default(), true, account_id); let result = execute_and_prove( vec![pre_state], @@ -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 94d919ca..6a59cadb 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..17492824 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -49,7 +49,7 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() { ); let private_account_2 = AccountWithMetadata::new( Account::default(), - false, + true, (&recipient_keys.npk(), &recipient_keys.vpk(), 0), ); @@ -64,11 +64,12 @@ 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, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -102,7 +103,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid balance: 1, ..Account::default() }, - false, + true, (&recipient_keys.npk(), &recipient_keys.vpk(), 0), ); @@ -113,11 +114,12 @@ 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, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -151,7 +153,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ program_owner: [0, 1, 2, 3, 4, 5, 6, 7], ..Account::default() }, - false, + true, (&recipient_keys.npk(), &recipient_keys.vpk(), 0), ); @@ -162,11 +164,12 @@ 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, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -200,7 +203,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided( data: b"hola mundo".to_vec().try_into().unwrap(), ..Account::default() }, - false, + true, (&recipient_keys.npk(), &recipient_keys.vpk(), 0), ); @@ -211,11 +214,12 @@ 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, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -249,7 +253,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided nonce: Nonce(0xdead_beef), ..Account::default() }, - false, + true, (&recipient_keys.npk(), &recipient_keys.vpk(), 0), ); @@ -260,11 +264,12 @@ 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, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -279,7 +284,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided } #[test] -fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_but_marked_as_authorized() +fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_but_marked_as_unauthorized() { let program = crate::test_methods::simple_balance_transfer(); let sender_keys = test_private_account_keys_1(); @@ -295,8 +300,8 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu ); let private_account_2 = AccountWithMetadata::new( Account::default(), - // This should be set to false in normal circumstances - true, + // This should be set to true in normal circumstances + false, (&recipient_keys.npk(), &recipient_keys.vpk(), 0), ); @@ -307,11 +312,12 @@ 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, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -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, @@ -773,7 +781,7 @@ fn private_unauthorized_uninitialized_account_can_still_be_claimed() { // remains allowed. let unauthorized_account = AccountWithMetadata::new( Account::default(), - false, + true, (&private_keys.npk(), &private_keys.vpk(), 0), ); @@ -782,7 +790,7 @@ fn private_unauthorized_uninitialized_account_can_still_be_claimed() { let (output, proof) = execute_and_prove( vec![unauthorized_account], Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::PrivateUnauthorized { + vec![InputAccountIdentity::PrivateForeignInit { vpk: private_keys.vpk(), random_seed: [0; 32], npk: private_keys.npk(), @@ -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..399d268f 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()); @@ -269,7 +270,7 @@ fn shielded_balance_transfer_for_tests( let recipient = AccountWithMetadata::new( Account::default(), - false, + true, (&recipient_keys.npk(), &recipient_keys.vpk(), 0), ); @@ -278,7 +279,7 @@ fn shielded_balance_transfer_for_tests( Program::serialize_instruction(balance_to_move).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -319,7 +320,7 @@ fn private_balance_transfer_for_tests( ); let recipient_pre = AccountWithMetadata::new( Account::default(), - false, + true, (&recipient_keys.npk(), &recipient_keys.vpk(), 0), ); @@ -330,13 +331,14 @@ 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) .expect("sender's commitment must be in state"), identifier: 0, }, - InputAccountIdentity::PrivateUnauthorized { + InputAccountIdentity::PrivateForeignInit { vpk: recipient_keys.vpk(), random_seed: [0; 32], npk: recipient_keys.npk(), @@ -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/state/tests/validity_window.rs b/lee/state_machine/src/state/tests/validity_window.rs index 12e7c229..7e314bc2 100644 --- a/lee/state_machine/src/state/tests/validity_window.rs +++ b/lee/state_machine/src/state/tests/validity_window.rs @@ -126,7 +126,7 @@ fn validity_window_works_in_privacy_preserving_transactions( let account_keys = test_private_account_keys_1(); let pre = AccountWithMetadata::new( Account::default(), - false, + true, (&account_keys.npk(), &account_keys.vpk(), 0), ); let mut state = V03State::new().with_test_programs(); @@ -138,7 +138,7 @@ fn validity_window_works_in_privacy_preserving_transactions( let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove( vec![pre], Program::serialize_instruction(instruction).unwrap(), - vec![InputAccountIdentity::PrivateUnauthorized { + vec![InputAccountIdentity::PrivateForeignInit { vpk: account_keys.vpk(), random_seed: [0; 32], npk: account_keys.npk(), @@ -191,7 +191,7 @@ fn timestamp_validity_window_works_in_privacy_preserving_transactions( let account_keys = test_private_account_keys_1(); let pre = AccountWithMetadata::new( Account::default(), - false, + true, (&account_keys.npk(), &account_keys.vpk(), 0), ); let mut state = V03State::new().with_test_programs(); @@ -203,7 +203,7 @@ fn timestamp_validity_window_works_in_privacy_preserving_transactions( let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove( vec![pre], Program::serialize_instruction(instruction).unwrap(), - vec![InputAccountIdentity::PrivateUnauthorized { + vec![InputAccountIdentity::PrivateForeignInit { vpk: account_keys.vpk(), random_seed: [0; 32], npk: account_keys.npk(), 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_state/Cargo.toml b/lez/chain_state/Cargo.toml new file mode 100644 index 00000000..0a0610a5 --- /dev/null +++ b/lez/chain_state/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "chain_state" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +common.workspace = true +lee.workspace = true +lee_core.workspace = true +logos-blockchain-core.workspace = true +logos-blockchain-zone-sdk.workspace = true + +anyhow.workspace = true +borsh.workspace = true +futures.workspace = true +log.workspace = true +serde.workspace = true +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/indexer/core/src/chain_consistency.rs b/lez/chain_state/src/consistency.rs similarity index 53% rename from lez/indexer/core/src/chain_consistency.rs rename to lez/chain_state/src/consistency.rs index df4b5ba6..5c87fcad 100644 --- a/lez/indexer/core/src/chain_consistency.rs +++ b/lez/chain_state/src/consistency.rs @@ -1,18 +1,18 @@ -//! Startup check that the local store still belongs to the chain the -//! connected channel serves. +//! Startup check that a local store still belongs to the chain the connected +//! channel serves. use anyhow::Result; use common::{HashType, block::Block}; use futures::StreamExt as _; +use lee_core::BlockId; use log::warn; -use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter::Node as _}; - -use crate::IndexerCore; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter, indexer::ZoneIndexer}; /// Upper bound on the channel reads of the startup consistency check. const CHANNEL_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); -/// Result of comparing the indexer's stored chain against the channel. +/// Result of comparing a caller's stored chain against the channel. pub enum ChainConsistency { /// Channel still serves our anchor block (the stored tip position, or the /// parked block while stalled). @@ -24,7 +24,7 @@ pub enum ChainConsistency { /// - or the channel read was inconclusive (timeout / error / empty stream) /// /// NOTE: None of these prove a reset, so the caller proceeds. - /// A genuine divergence is still caught later when the ingest loop tries to apply and parks. + /// A genuine divergence is still caught later when applying the channel history. Inconclusive, /// Positive evidence that the channel is a different chain than the store. /// @@ -36,13 +36,13 @@ pub enum ChainConsistency { pub enum ChainMismatch { /// The channel serves a different block at the anchor's id. Block { - ours: (u64, HashType), - channel: (u64, HashType), + ours: (BlockId, HashType), + channel: (BlockId, HashType), }, /// The channel serves a block at/below the anchor's id past the anchor /// slot; on the same chain those ids live at earlier slots. ReinscribedBlock { - channel: (u64, HashType), + channel: (BlockId, HashType), slot: Slot, anchor_slot: Slot, }, @@ -97,18 +97,26 @@ impl std::fmt::Display for ChainMismatch { /// A block that must still be inscribed at `slot` if the channel is the chain /// the store was built from: the tip at the read cursor, or the recorded /// parked block while stalled. -struct Anchor { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Anchor { slot: Slot, /// The anchor block's `(id, hash)`. /// - /// `None` when parked on an undeserializable inscription (no header was recorded). - block: Option<(u64, HashType)>, + /// `None` when anchored on an undeserializable inscription (no header was recorded). + block: Option<(BlockId, HashType)>, } impl Anchor { + /// Builds an anchor at `slot` on the block `(id, hash)`, or a headerless + /// anchor (`None`) when only the slot is known. + #[must_use] + pub const fn new(slot: Slot, block: Option<(BlockId, HashType)>) -> Self { + Self { slot, block } + } + /// Probes a channel message read at/after the anchor slot. - /// See [`IndexerCore::verify_chain_at_anchor`]. - pub fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe { + /// See [`verify_chain_consistency`]. + fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe { if slot < self.slot { return AnchorProbe::KeepLooking; } @@ -157,6 +165,66 @@ impl Anchor { } } +/// Classifies a stored chain against the channel one message at a time. +/// +/// The shared driver behind both consistency consumers: +/// [`verify_chain_consistency`] runs it over a channel it reads itself, while a +/// caller that already streams the channel history (e.g. a reconstructing +/// sequencer) feeds it the same messages it replays. Run [`Self::check_frontier`] +/// once the channel tip is known, then feed messages in slot order via +/// [`Self::observe`] until a verdict is reached. +pub struct AnchorConsistencyCheck { + anchor: Anchor, + verdict: Option, +} + +impl AnchorConsistencyCheck { + /// New checker for `anchor` with an undetermined verdict. + #[must_use] + pub const fn new(anchor: Anchor) -> Self { + Self { + anchor, + verdict: None, + } + } + + /// Applies the frontier check against a known channel tip. Skip it when the + /// tip could not be read, leaving the verdict to the message scan. + pub fn check_frontier(&mut self, channel_tip_slot: Option) { + if self.verdict.is_none() { + self.verdict = frontier_verdict(self.anchor.slot, channel_tip_slot) + .map(ChainConsistency::Inconsistent); + } + } + + /// Feeds the next channel message in slot order. Returns the verdict once it + /// is known so the caller can stop, or `None` while still undetermined. + pub fn observe(&mut self, msg: &ZoneMessage, slot: Slot) -> Option<&ChainConsistency> { + if self.verdict.is_none() { + self.verdict = match self.anchor.probe_anchor_slot(msg, slot) { + AnchorProbe::SameChain => Some(ChainConsistency::Consistent), + AnchorProbe::Mismatch(mismatch) => Some(ChainConsistency::Inconsistent(mismatch)), + AnchorProbe::Bail => Some(ChainConsistency::Inconclusive), + AnchorProbe::KeepLooking => None, + }; + } + self.verdict.as_ref() + } + + /// The verdict reached so far, or `None` while undetermined. + #[must_use] + pub const fn verdict(&self) -> Option<&ChainConsistency> { + self.verdict.as_ref() + } + + /// Consumes the checker, defaulting an undetermined verdict (the anchor slot + /// was never observed) to [`ChainConsistency::Inconclusive`]. + #[must_use] + pub fn finish(self) -> ChainConsistency { + self.verdict.unwrap_or(ChainConsistency::Inconclusive) + } +} + /// What a single channel message tells the anchored consistency check. enum AnchorProbe { /// The anchor is still in place: same chain. @@ -168,114 +236,65 @@ enum AnchorProbe { KeepLooking, } -#[expect( - clippy::multiple_inherent_impl, - reason = "split for clarity & isolation of relevant code" -)] -impl IndexerCore { - /// Verifies whether the channel still serves the same chain the store was built from. - /// This may change frequently during development where we reset the chain from time to - /// time in devnet/testnet, but we do not expect [`ChainConsistency::Inconsistent`] in - /// production. - /// - /// To compare the chains, we use an [`Anchor`] block that is either the parked L2 block - /// while stalled, or the tip L2 block at its own inscription L1 slot. - pub(crate) async fn verify_chain_consistency(&self) -> Result { - let Some(anchor) = self.get_startup_anchor()? else { - // empty or cold store: nothing to compare - return Ok(ChainConsistency::Inconclusive); - }; - - self.verify_chain_at_anchor(&anchor).await +/// Detects when a local store belongs to a different chain than the connected +/// L1 (e.g. a wiped/restarted Bedrock) so startup can react instead of silently +/// diverging. +/// +/// Verifies the channel still carries the anchor block at its slot. The anchor +/// was finalized at `anchor.slot`, so the same chain must still serve it there, +/// while a reset chain re-inscribes its content only at later wall-clock slots. +/// Only positive evidence of a different chain yields +/// [`ChainConsistency::Inconsistent`]; absence of data stays +/// [`ChainConsistency::Inconclusive`]. +/// +/// `node` need only implement the zone-sdk [`adapter::Node`] trait; a throwaway +/// [`ZoneIndexer`] is built internally for the channel read. +pub async fn verify_chain_consistency( + node: &N, + channel_id: ChannelId, + anchor: &Anchor, +) -> Result +where + N: adapter::Node + Clone + Sync, +{ + let mut check = AnchorConsistencyCheck::new(anchor.clone()); + match node.channel_state(channel_id).await { + Ok(state) => check.check_frontier(state.map(|s| s.tip_slot)), + Err(err) => warn!("Failed to read channel state for the consistency check: {err:#}"), + } + if check.verdict().is_some() { + return Ok(check.finish()); } - /// Builds the anchor for the startup check. - /// - /// - If stalled, returns the recorded _parked_ block - /// - If not stalled, returns the validated tip at its _own_ inscription slot. - /// - If the store is empty, returns `None`. - fn get_startup_anchor(&self) -> Result> { - if let Some(stall) = self.store.get_stall_reason()? { - return Ok(Some(Anchor { - slot: stall.l1_slot, - block: stall.block_id.zip(stall.block_hash), - })); - } + // `next_messages` is exclusive, so `slot - 1` includes the anchor slot. + let Some(from_slot) = anchor.slot.into_inner().checked_sub(1) else { + return Ok(ChainConsistency::Inconclusive); + }; - // not stalled, so anchor on the tip at its own inscription slot - let Some(slot) = self.store.get_tip_slot()?.or(self.store.get_zone_cursor()?) else { - return Ok(None); - }; - let Some(tip_id) = self.store.get_last_block_id()? else { - return Ok(None); - }; - let Some(tip) = self.store.get_block_at_id(tip_id)? else { - return Ok(None); - }; - Ok(Some(Anchor { - slot, - block: Some((tip_id, tip.header.hash)), - })) - } + let zone_indexer = ZoneIndexer::new(channel_id, node.clone()); + let scan = async { + let stream = zone_indexer + .next_messages(Some(Slot::from(from_slot))) + .await?; + let mut stream = std::pin::pin!(stream); - /// Verifies the channel still carries the anchor block at its slot. - /// - /// The anchor was finalized at `anchor.slot`, so the same chain must still - /// serve it there, while a reset chain re-inscribes its content only at - /// later wall-clock slots. - /// - /// Only positive evidence of a different chain yields `Inconsistent`. - /// Absence of data stays `Inconclusive`. - async fn verify_chain_at_anchor(&self, anchor: &Anchor) -> Result { - match self.node.channel_state(self.config.channel_id).await { - Ok(state) => { - if let Some(mismatch) = frontier_verdict(anchor.slot, state.map(|s| s.tip_slot)) { - return Ok(ChainConsistency::Inconsistent(mismatch)); - } - } - Err(err) => { - warn!("Failed to read channel state for the consistency check: {err:#}"); + while let Some((msg, slot)) = stream.next().await { + if check.observe(&msg, slot).is_some() { + break; } } + Ok::<_, anyhow::Error>(()) + }; - // `next_messages` is exclusive, so `slot - 1` includes the anchor slot. - let Some(from_slot) = anchor.slot.into_inner().checked_sub(1) else { - return Ok(ChainConsistency::Inconclusive); - }; - - let scan = async { - let stream = self - .zone_indexer - .next_messages(Some(Slot::from(from_slot))) - .await?; - let mut stream = std::pin::pin!(stream); - - while let Some((msg, slot)) = stream.next().await { - match anchor.probe_anchor_slot(&msg, slot) { - AnchorProbe::SameChain => return Ok(Some(ChainConsistency::Consistent)), - AnchorProbe::Mismatch(mismatch) => { - return Ok(Some(ChainConsistency::Inconsistent(mismatch))); - } - AnchorProbe::Bail => return Ok(Some(ChainConsistency::Inconclusive)), - AnchorProbe::KeepLooking => { /* dont do anything */ } - } - } - Ok::<_, anyhow::Error>(None) - }; - - match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await { - Ok(Ok(Some(outcome))) => Ok(outcome), - Ok(Ok(None)) => Ok(ChainConsistency::Inconclusive), - Ok(Err(err)) => { - warn!( - "Failed to read the anchor slot for the consistency check; proceeding: {err:#}" - ); - Ok(ChainConsistency::Inconclusive) - } - Err(_elapsed) => { - warn!("Timed out reading the anchor slot for the consistency check; proceeding"); - Ok(ChainConsistency::Inconclusive) - } + match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await { + Ok(Ok(())) => Ok(check.finish()), + Ok(Err(err)) => { + warn!("Failed to read the anchor slot for the consistency check; proceeding: {err:#}"); + Ok(ChainConsistency::Inconclusive) + } + Err(_elapsed) => { + warn!("Timed out reading the anchor slot for the consistency check; proceeding"); + Ok(ChainConsistency::Inconclusive) } } } @@ -297,35 +316,13 @@ fn frontier_verdict(anchor_slot: Slot, channel_tip_slot: Option) -> Option #[cfg(test)] mod tests { - use std::time::Duration; - use common::block::HashableBlockData; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; use super::*; - use crate::{ - BlockIngestError, - block_store::AcceptOutcome, - config::{ChannelId, ClientConfig, IndexerConfig}, - }; - fn unreachable_core(dir: &std::path::Path) -> IndexerCore { - let config = IndexerConfig { - consensus_info_polling_interval: Duration::from_secs(1), - bedrock_config: ClientConfig { - addr: "http://localhost:1".parse().expect("url"), - auth: None, - }, - channel_id: ChannelId::from([1; 32]), - allow_chain_reset: false, - cross_zone: None, - bridge_lock_holdings: Vec::new(), - }; - IndexerCore::open(config, dir).expect("open core") - } - - fn test_block(block_id: u64, timestamp: u64) -> Block { + fn test_block(block_id: BlockId, timestamp: u64) -> Block { HashableBlockData { block_id, prev_block_hash: HashType([0; 32]), @@ -344,98 +341,7 @@ mod tests { } fn anchor_for(block: &Block, slot: Slot) -> Anchor { - Anchor { - slot, - block: Some((block.header.block_id, block.header.hash)), - } - } - - #[tokio::test] - async fn cold_store_is_inconclusive() { - // An empty store has no cursor, so there is nothing to compare: the check - // must be Inconclusive (not Consistent), and it returns before any L1 read. - let dir = tempfile::tempdir().expect("tempdir"); - let core = unreachable_core(dir.path()); - assert!(matches!( - core.verify_chain_consistency().await.expect("verify"), - ChainConsistency::Inconclusive - )); - } - - #[tokio::test] - async fn parked_store_with_unreachable_node_is_inconclusive() { - // Network failure is not evidence of a reset: a parked store must stay - // parked (Inconclusive), not error out or trip the wipe path. - let dir = tempfile::tempdir().expect("tempdir"); - let core = unreachable_core(dir.path()); - let parked = test_block(5, 42); - core.store - .record_stall( - Some(&parked.header), - Slot::from(1_000), - BlockIngestError::EmptyBlock, - ) - .expect("record stall"); - assert!(matches!( - core.verify_chain_consistency().await.expect("verify"), - ChainConsistency::Inconclusive - )); - } - - #[tokio::test] - async fn caught_up_store_with_unreachable_node_is_inconclusive() { - let dir = tempfile::tempdir().expect("tempdir"); - let core = unreachable_core(dir.path()); - let genesis = common::test_utils::produce_dummy_block(1, None, vec![]); - assert!(matches!( - core.store - .accept_block(&genesis, Slot::from(1_000)) - .await - .expect("accept"), - AcceptOutcome::Applied - )); - core.store - .set_zone_cursor(&Slot::from(1_000)) - .expect("set cursor"); - assert!(matches!( - core.verify_chain_consistency().await.expect("verify"), - ChainConsistency::Inconclusive - )); - } - - #[tokio::test] - async fn startup_anchor_prefers_tip_slot_over_lagging_cursor() { - // Cursor persist failures are warn-only, so the read cursor can lag the - // tip by several blocks. The anchor must pair the tip with its own - // inscription slot; pairing it with the stale cursor would make the scan - // misread the chain's intermediate blocks as re-inscriptions. - let dir = tempfile::tempdir().expect("tempdir"); - let core = unreachable_core(dir.path()); - - let genesis = common::test_utils::produce_dummy_block(1, None, vec![]); - core.store - .accept_block(&genesis, Slot::from(1_000)) - .await - .expect("accept"); - let block2 = common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]); - core.store - .accept_block(&block2, Slot::from(1_005)) - .await - .expect("accept"); - let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]); - core.store - .accept_block(&block3, Slot::from(1_010)) - .await - .expect("accept"); - - // Cursor last persisted at the genesis slot: two blocks behind the tip. - core.store - .set_zone_cursor(&Slot::from(1_000)) - .expect("set cursor"); - - let anchor = core.get_startup_anchor().expect("anchor").expect("present"); - assert_eq!(anchor.slot, Slot::from(1_010)); - assert_eq!(anchor.block, Some((3, block3.header.hash))); + Anchor::new(slot, Some((block.header.block_id, block.header.hash))) } #[test] @@ -516,10 +422,7 @@ mod tests { fn probe_accepts_any_message_for_a_headerless_anchor() { // A deserialize park records no header: any message still present at // the anchor slot means the history is intact. - let anchor = Anchor { - slot: Slot::from(1_000), - block: None, - }; + let anchor = Anchor::new(Slot::from(1_000), None); let garbage = ZoneMessage::Block(ZoneBlock { id: MsgId::from([0_u8; 32]), data: Inscription::try_from(&[1_u8, 2, 3][..]).expect("inscription"), diff --git a/lez/indexer/core/src/ingest_error.rs b/lez/chain_state/src/ingest_error.rs similarity index 93% rename from lez/indexer/core/src/ingest_error.rs rename to lez/chain_state/src/ingest_error.rs index 299013d7..d259b5f7 100644 --- a/lez/indexer/core/src/ingest_error.rs +++ b/lez/chain_state/src/ingest_error.rs @@ -1,10 +1,10 @@ use common::HashType; use serde::{Deserialize, Serialize}; -/// Why the indexer could not apply an L2 block from the channel. +/// Why an L2 block from the channel could not be applied. /// -/// Persisted in `RocksDB`, so every variant must have the following -/// traits: `Clone + Serialize + Deserialize`. +/// 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}")] 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/indexer/core/src/stall_reason.rs b/lez/chain_state/src/stall_reason.rs similarity index 51% rename from lez/indexer/core/src/stall_reason.rs rename to lez/chain_state/src/stall_reason.rs index c5118fcc..e1d9b3ac 100644 --- a/lez/indexer/core/src/stall_reason.rs +++ b/lez/chain_state/src/stall_reason.rs @@ -1,4 +1,4 @@ -use common::HashType; +use common::{HashType, block::BlockHeader}; use logos_blockchain_zone_sdk::Slot; use serde::{Deserialize, Serialize}; @@ -23,3 +23,28 @@ pub struct StallReason { /// 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 14941773..afba6f29 100644 --- a/lez/indexer/core/Cargo.toml +++ b/lez/indexer/core/Cargo.toml @@ -12,6 +12,7 @@ default = [] testnet = [] [dependencies] +chain_state.workspace = true common.workspace = true logos-blockchain-zone-sdk.workspace = true lee.workspace = true @@ -32,7 +33,6 @@ futures.workspace = true url.workspace = true logos-blockchain-core.workspace = true serde_json.workspace = true -thiserror.workspace = true async-stream.workspace = true tokio.workspace = true risc0-zkvm.workspace = true diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index 8c974399..a5d66a23 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -1,12 +1,14 @@ use std::{path::Path, sync::Arc}; use anyhow::{Context as _, Result}; -use common::{ - HashType, - block::{BedrockStatus, Block, BlockHeader}, - transaction::{LeeTransaction, clock_invocation}, +use chain_state::{ + AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip, }; -use lee::{Account, AccountId, GENESIS_BLOCK_ID, V03State}; +use common::{ + block::{BedrockStatus, Block, BlockHeader}, + transaction::LeeTransaction, +}; +use lee::{Account, AccountId, V03State}; use lee_core::BlockId; use log::warn; use logos_blockchain_core::header::HeaderId; @@ -14,28 +16,6 @@ use logos_blockchain_zone_sdk::Slot; use storage::indexer::RocksDBIO; use tokio::sync::RwLock; -use crate::{ingest_error::BlockIngestError, stall_reason::StallReason}; - -struct Tip { - block_id: u64, - hash: HashType, -} - -/// 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, @@ -207,10 +187,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. @@ -223,22 +200,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)) } @@ -258,6 +223,8 @@ impl IndexerStore { return Ok(AcceptOutcome::AlreadyApplied); } + // 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)); @@ -265,7 +232,7 @@ impl IndexerStore { // 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) = apply_block_to_scratch(block, &mut scratch) { + if let Err(err) = apply_block_to_state(block, &mut scratch) { if err.is_retryable() { return Ok(AcceptOutcome::RetryableFailure(err)); } @@ -290,112 +257,11 @@ impl IndexerStore { } } -/// 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. -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 on a -/// scratch state; the caller commits only on `Ok`. -fn apply_block_to_scratch(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 stall_reason_tests { use common::HashType; use super::*; - use crate::{ingest_error::BlockIngestError, stall_reason::StallReason}; #[tokio::test] async fn stall_reason_roundtrips_and_clears() { @@ -544,7 +410,6 @@ mod accept_tests { use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block}; use super::*; - use crate::ingest_error::BlockIngestError; fn signing_key() -> lee::PrivateKey { lee::PrivateKey::try_new([7_u8; 32]).expect("valid key") diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index 7bee6387..5a4909f4 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -2,31 +2,28 @@ use std::{path::Path, sync::Arc}; use anyhow::Result; use arc_swap::ArcSwap; +pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason}; +use chain_state::{Anchor, ChainConsistency}; use common::block::Block; // TODO: Remove after testnet use futures::StreamExt as _; -pub use ingest_error::BlockIngestError; use log::{error, info, warn}; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, }; use retry::ApplyRetryGate; -pub use stall_reason::StallReason; use crate::{ - block_store::{AcceptOutcome, IndexerStore}, - chain_consistency::ChainConsistency, + block_store::IndexerStore, config::IndexerConfig, cross_zone_verifier::CrossZoneVerifier, status::{IndexerStatus, IndexerSyncStatus}, }; + pub mod block_store; -pub mod chain_consistency; pub mod config; pub mod cross_zone_verifier; -pub mod ingest_error; mod retry; -pub mod stall_reason; pub mod status; /// Consecutive failed apply attempts of the same block before parking. @@ -125,6 +122,52 @@ impl IndexerCore { }) } + /// Verifies whether the channel still serves the same chain the store was built from. + /// This may change frequently during development where we reset the chain from time to + /// time in devnet/testnet, but we do not expect [`ChainConsistency::Inconsistent`] in + /// production. + /// + /// To compare the chains, we use an [`Anchor`] block that is either the parked L2 block + /// while stalled, or the tip L2 block at its own inscription L1 slot. + pub(crate) async fn verify_chain_consistency(&self) -> Result { + let Some(anchor) = self.get_startup_anchor()? else { + // empty or cold store: nothing to compare + return Ok(ChainConsistency::Inconclusive); + }; + + chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &anchor).await + } + + /// Builds the anchor for the startup check. + /// + /// - If stalled, returns the recorded _parked_ block + /// - If not stalled, returns the validated tip at its _own_ inscription slot. + /// - If the store is empty, returns `None`. + fn get_startup_anchor(&self) -> Result> { + if let Some(stall) = self.store.get_stall_reason()? { + return Ok(Some(Anchor::new( + stall.l1_slot, + stall.block_id.zip(stall.block_hash), + ))); + } + + // not stalled, so anchor on the tip at its own inscription slot + let Some(slot) = self + .store + .get_tip_slot()? + .map_or_else(|| self.store.get_zone_cursor(), |slot| Ok(Some(slot)))? + else { + return Ok(None); + }; + let Some(tip_id) = self.store.get_last_block_id()? else { + return Ok(None); + }; + let Some(tip) = self.store.get_block_at_id(tip_id)? else { + return Ok(None); + }; + Ok(Some(Anchor::new(slot, Some((tip_id, tip.header.hash))))) + } + /// Snapshot of the current ingestion status (sync state + indexed tip). /// /// Combines the ingest loop's live status with the L2 tip read fresh from the @@ -207,8 +250,8 @@ impl IndexerCore { let mut cursor = initial_cursor; let mut retry_gate = ApplyRetryGate::new(); - if cursor.is_some() { - info!("Resuming indexer from cursor {cursor:?}"); + if let Some(slot) = &cursor { + info!("Resuming indexer from cursor {slot:?}"); } else { info!("Starting indexer from beginning of channel"); } @@ -370,3 +413,127 @@ impl IndexerCore { } } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use common::{HashType, block::HashableBlockData}; + use logos_blockchain_zone_sdk::Slot; + + use super::*; + use crate::config::{ChannelId, ClientConfig, IndexerConfig}; + + fn unreachable_core(dir: &std::path::Path) -> IndexerCore { + let config = IndexerConfig { + consensus_info_polling_interval: Duration::from_secs(1), + bedrock_config: ClientConfig { + addr: "http://localhost:1".parse().expect("url"), + auth: None, + }, + channel_id: ChannelId::from([1; 32]), + allow_chain_reset: false, + cross_zone: None, + bridge_lock_holdings: Vec::new(), + }; + IndexerCore::open(config, dir).expect("open core") + } + + fn test_block(block_id: u64, timestamp: u64) -> Block { + HashableBlockData { + block_id, + prev_block_hash: HashType([0; 32]), + timestamp, + transactions: vec![], + } + .into_pending_block(&lee::PrivateKey::try_new([7; 32]).expect("valid key")) + } + + #[tokio::test] + async fn cold_store_is_inconclusive() { + // An empty store has no cursor, so there is nothing to compare: the check + // must be Inconclusive (not Consistent), and it returns before any L1 read. + let dir = tempfile::tempdir().expect("tempdir"); + let core = unreachable_core(dir.path()); + assert!(matches!( + core.verify_chain_consistency().await.expect("verify"), + ChainConsistency::Inconclusive + )); + } + + #[tokio::test] + async fn parked_store_with_unreachable_node_is_inconclusive() { + // Network failure is not evidence of a reset: a parked store must stay + // parked (Inconclusive), not error out or trip the wipe path. + let dir = tempfile::tempdir().expect("tempdir"); + let core = unreachable_core(dir.path()); + let parked = test_block(5, 42); + core.store + .record_stall( + Some(&parked.header), + Slot::from(1_000), + BlockIngestError::EmptyBlock, + ) + .expect("record stall"); + assert!(matches!( + core.verify_chain_consistency().await.expect("verify"), + ChainConsistency::Inconclusive + )); + } + + #[tokio::test] + async fn caught_up_store_with_unreachable_node_is_inconclusive() { + let dir = tempfile::tempdir().expect("tempdir"); + let core = unreachable_core(dir.path()); + let genesis = common::test_utils::produce_dummy_block(1, None, vec![]); + assert!(matches!( + core.store + .accept_block(&genesis, Slot::from(1_000)) + .await + .expect("accept"), + AcceptOutcome::Applied + )); + core.store + .set_zone_cursor(&Slot::from(1_000)) + .expect("set cursor"); + assert!(matches!( + core.verify_chain_consistency().await.expect("verify"), + ChainConsistency::Inconclusive + )); + } + + #[tokio::test] + async fn startup_anchor_prefers_tip_slot_over_lagging_cursor() { + // Cursor persist failures are warn-only, so the read cursor can lag the + // tip by several blocks. The anchor must pair the tip with its own + // inscription slot; pairing it with the stale cursor would make the scan + // misread the chain's intermediate blocks as re-inscriptions. + let dir = tempfile::tempdir().expect("tempdir"); + let core = unreachable_core(dir.path()); + + let genesis = common::test_utils::produce_dummy_block(1, None, vec![]); + core.store + .accept_block(&genesis, Slot::from(1_000)) + .await + .expect("accept"); + let block2 = common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]); + core.store + .accept_block(&block2, Slot::from(1_005)) + .await + .expect("accept"); + let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]); + core.store + .accept_block(&block3, Slot::from(1_010)) + .await + .expect("accept"); + + // Cursor last persisted at the genesis slot: two blocks behind the tip. + core.store + .set_zone_cursor(&Slot::from(1_000)) + .expect("set cursor"); + + let anchor = core.get_startup_anchor().expect("anchor").expect("present"); + let expected = Anchor::new(Slot::from(1_010), Some((3, block3.header.hash))); + assert_eq!(anchor, expected); + } +} diff --git a/lez/indexer/core/src/status.rs b/lez/indexer/core/src/status.rs index a483fde6..aa182c23 100644 --- a/lez/indexer/core/src/status.rs +++ b/lez/indexer/core/src/status.rs @@ -1,7 +1,6 @@ +use chain_state::StallReason; use serde::Serialize; -use crate::stall_reason::StallReason; - /// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell /// "still catching up" apart from "something went wrong". #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -117,10 +116,9 @@ mod tests { #[test] fn stalled_status_serializes_with_stall_reason() { + use chain_state::{BlockIngestError, StallReason}; use logos_blockchain_zone_sdk::Slot; - use crate::{ingest_error::BlockIngestError, stall_reason::StallReason}; - let status = IndexerStatus { sync: IndexerSyncStatus::stalled("broken chain link".to_owned()), indexed_block_id: Some(41), 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 cd89f223..c8590c2a 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lee.workspace = true lee_core.workspace = true +chain_state.workspace = true common.workspace = true storage.workspace = true mempool.workspace = true @@ -33,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 21551131..8f2f2031 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -1,21 +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, - adapter::NodeHttpClient, + 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; @@ -41,8 +64,38 @@ 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: Clone { +pub trait BlockPublisherTrait: Sized { #[expect( clippy::too_many_arguments, reason = "Looks better than bundling all those callbacks into a struct" @@ -56,22 +109,49 @@ pub trait BlockPublisherTrait: Clone { 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>; + + /// Finalized channel messages from `after_slot` (exclusive) up to LIB, used + /// for the startup consistency check and reconstruction. Pass `None` to read + /// from the channel's genesis. + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_>; } /// Real block publisher backed by zone-sdk's `ZoneSequencer`. -#[derive(Clone)] pub struct ZoneSdkPublisher { channel_id: ChannelId, - publish_tx: mpsc::Sender<(Inscription, Vec)>, + /// Direct node handle retained for channel reads (startup consistency check + /// and reconstruction); the sequencer itself lives in the drive task. + node: NodeHttpClient, + 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, } struct DriveTaskGuard(JoinHandle<()>); @@ -92,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()); @@ -104,7 +185,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { let mut sequencer = ZoneSequencer::init_with_config( config.channel_id, bedrock_signing_key, - node, + node.clone(), zone_sdk_config, initial_checkpoint, ); @@ -112,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, @@ -124,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; @@ -157,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) => { @@ -178,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 { .. } => {} } } } @@ -196,37 +319,151 @@ impl BlockPublisherTrait for ZoneSdkPublisher { Ok(Self { channel_id: config.channel_id, - publish_tx, + indexer: ZoneIndexer::new(config.channel_id, node.clone()), + node, + 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 + .channel_state(self.channel_id) + .await + .context("Failed to read channel state")? + .map(|state| state.tip_slot)) + } + + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_> { + let stream = self + .indexer + .next_messages(after_slot) + .await + .context("Failed to start channel read stream")?; + Ok(stream) + } } -/// 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 77b57661..37394352 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -12,7 +12,7 @@ use log::info; use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use storage::sequencer::{ RocksDBIO, - sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey}, + sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord}, }; pub use storage::{DbResult, sequencer::DbDump}; @@ -28,29 +28,17 @@ impl SequencerStore { /// Open existing database at the given location. Fails if no database is found. pub fn open_db(location: &Path, signing_key: lee::PrivateKey) -> DbResult { let dbio = Arc::new(RocksDBIO::open(location)?); - let genesis_id = dbio.get_meta_first_block_in_db()?; - let last_id = dbio.latest_block_meta()?.id; + Self::from_dbio_and_signing_key(dbio, signing_key) + } - info!("Preparing block cache"); - let mut tx_hash_to_block_map = HashMap::new(); - for i in genesis_id..=last_id { - let block = dbio - .get_block(i)? - .expect("Block should be present in the database"); - - tx_hash_to_block_map.extend(block_to_transactions_map(&block)); - } - info!( - "Block cache prepared. Total blocks in cache: {}", - tx_hash_to_block_map.len() - ); - - Ok(Self { - dbio, - tx_hash_to_block_map, - genesis_id, - signing_key, - }) + /// Create a fresh rocksdb at `location` from `dump`. + pub fn restore_db_from_dump( + location: &Path, + dump: &DbDump, + signing_key: lee::PrivateKey, + ) -> DbResult { + let dbio = Arc::new(RocksDBIO::restore_from_dump(location, dump)?); + Self::from_dbio_and_signing_key(dbio, signing_key) } /// Starting database at the start of new chain. @@ -75,6 +63,38 @@ impl SequencerStore { }) } + fn from_dbio_and_signing_key( + dbio: Arc, + signing_key: lee::PrivateKey, + ) -> DbResult { + let genesis_id = dbio.get_meta_first_block_in_db()?; + let last_id = dbio.latest_block_meta()?.map(|meta| meta.id); + + let mut tx_hash_to_block_map = HashMap::new(); + + if let Some(last_id) = last_id { + info!("Preparing block cache"); + for i in genesis_id..=last_id { + let block = dbio + .get_block(i)? + .expect("Block should be present in the database"); + + tx_hash_to_block_map.extend(block_to_transactions_map(&block)); + } + info!( + "Block cache prepared. Total blocks in cache: {}", + tx_hash_to_block_map.len() + ); + } + + Ok(Self { + dbio, + tx_hash_to_block_map, + genesis_id, + signing_key, + }) + } + /// Shared handle to the underlying rocksdb. Used to persist the zone-sdk /// checkpoint from the sequencer's drive task without needing &mut to the /// store. @@ -114,7 +134,7 @@ impl SequencerStore { ); } - pub fn latest_block_meta(&self) -> DbResult { + pub fn latest_block_meta(&self) -> DbResult> { self.dbio.latest_block_meta() } @@ -165,13 +185,6 @@ impl SequencerStore { self.dbio.dump_all() } - /// Create a fresh rocksdb at `location` from `dump`, closing it before returning so a sequencer - /// can open it normally afterwards. - pub fn restore_db_from_dump(location: &Path, dump: &DbDump) -> DbResult<()> { - RocksDBIO::restore_from_dump(location, dump)?; - Ok(()) - } - pub fn get_zone_checkpoint(&self) -> Result> { let Some(bytes) = self.dbio.get_zone_sdk_checkpoint_bytes()? else { return Ok(None); @@ -188,9 +201,33 @@ impl SequencerStore { Ok(()) } + /// The last channel block read back and verified from Bedrock (L1 slot + + /// `id`/`hash`), or `None` before any block has been read from the channel. + pub fn get_zone_anchor(&self) -> DbResult> { + self.dbio.get_zone_anchor() + } + + pub fn set_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> { + self.dbio.put_zone_anchor(anchor) + } + pub fn get_unfulfilled_deposit_events(&self) -> DbResult> { self.dbio.get_pending_deposit_events() } + + 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 { @@ -275,7 +312,7 @@ mod tests { .unwrap(); // Verify that initially the latest block hash equals genesis hash - let latest_meta = node_store.latest_block_meta().unwrap(); + let latest_meta = node_store.latest_block_meta().unwrap().unwrap(); assert_eq!(latest_meta.hash, genesis_hash); } @@ -313,7 +350,7 @@ mod tests { .unwrap(); // Verify that the latest block meta now equals the new block's hash - let latest_meta = node_store.latest_block_meta().unwrap(); + let latest_meta = node_store.latest_block_meta().unwrap().unwrap(); assert_eq!(latest_meta.hash, block_hash); } diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 3a96e6d5..c33dcac4 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1,19 +1,30 @@ -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_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, 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}; -use logos_blockchain_zone_sdk::sequencer::{DepositInfo, WithdrawArg}; +use logos_blockchain_zone_sdk::{ + Slot, ZoneMessage, + sequencer::{DepositInfo, WithdrawArg}, +}; use mempool::{MemPool, MemPoolHandle}; #[cfg(feature = "mock")] pub use mock::SequencerCoreWithMockClients; @@ -21,11 +32,11 @@ use num_bigint::BigUint; pub use storage::error::DbError; use storage::sequencer::{ RocksDBIO, - sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey}, + sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord}, }; use crate::{ - block_publisher::{BlockPublisherTrait, ZoneSdkPublisher}, + block_publisher::{BlockPublisherTrait, MsgId, ZoneSdkPublisher}, block_store::SequencerStore, }; @@ -51,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, } @@ -72,7 +78,7 @@ impl SequencerCore { /// assumed to represent the correct latest state consistent with Bedrock-finalized data. /// If no database is found, the sequencer performs a fresh start from genesis, /// initializing its state with the accounts defined in the configuration file. - fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State, Block) { + fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State) { let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); let db_path = config.home.join("rocksdb"); @@ -86,11 +92,7 @@ impl SequencerCore { let state = store .get_lee_state() .expect("Failed to read state from store"); - let genesis_block = store - .get_block_at_id(store.genesis_id()) - .expect("Failed to read genesis block from store") - .expect("Genesis block not found in store"); - (store, state, genesis_block) + (store, state) } else { warn!( "Database not found at {}, starting from genesis", @@ -115,22 +117,70 @@ impl SequencerCore { ) .expect("Failed to create database with genesis block"); - (store, genesis_state, genesis_block) + (store, genesis_state) } } + /// 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 (store, state, _genesis_block) = Self::open_or_create_store(&config); + let (store, state) = Self::open_or_create_store(&config); - let latest_block_meta = store - .latest_block_meta() - .expect("Failed to read latest block meta from store"); + let chain = Arc::new(Mutex::new(Self::restore_chain_state( + &config, &store, &state, + ))); let initial_checkpoint = store .get_zone_checkpoint() @@ -149,12 +199,33 @@ 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"); - // Fresh start (no checkpoint): republish all pending blocks - if is_fresh_start { + // Cross-zone messaging: start a watcher per configured peer. The inbox + // config account is seeded into genesis state in `build_genesis_state`. + if let Some(cross_zone) = &config.cross_zone { + cross_zone_watcher::spawn_watchers( + &config.bedrock_config, + cross_zone, + config.block_create_timeout, + &mempool_handle, + ); + } + // 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 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 && channel_was_empty { let mut pending_blocks = store .get_all_blocks() .filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending)) @@ -182,22 +253,10 @@ impl SequencerCore { } } - // Cross-zone messaging: start a watcher per configured peer. The inbox - // config account is seeded into genesis state in `build_genesis_state`. - if let Some(cross_zone) = &config.cross_zone { - cross_zone_watcher::spawn_watchers( - &config.bedrock_config, - cross_zone, - config.block_create_timeout, - &mempool_handle, - ); - } - let sequencer_core = Self { - state, + chain, store, mempool, - chain_height: latest_block_meta.id, sequencer_config: config, block_publisher, }; @@ -205,6 +264,220 @@ impl SequencerCore { (sequencer_core, mempool_handle) } + /// Verifies the local store still belongs to the chain the connected channel + /// serves and replays any finalized channel blocks missing locally into + /// `state`/`store`, recording each block's L1 inscription slot as the new + /// anchor. Fails (never parks) on any divergence. + /// + /// Returns whatever channel was empty or not. + async fn verify_and_reconstruct( + publisher: &BP, + store: &SequencerStore, + chain: &Mutex, + is_fresh_start: bool, + ) -> Result { + let anchor_record = store + .get_zone_anchor() + .context("Failed to read zone anchor")?; + + let after_slot = anchor_record + .and_then(|record| record.slot.checked_sub(1)) + .map(Slot::from); + let channel_tip_slot = publisher + .channel_tip_slot() + .await + .context("Failed to read channel tip slot")?; + + // If this sequencer has already committed blocks to the channel, that + // channel must still exist. A missing channel then means a wiped/rewound + // Bedrock or a node pointing at a different chain, so refuse to resume + // onto a foreign channel. + // + // "Committed" requires *both* a non-genesis tip and a checkpoint that was + // persisted before this startup: the tip alone is set the moment we produce + // (before the channel confirms it), while a checkpoint alone is written by + // zone-sdk's cold-start backfill even on a brand-new empty channel before we + // publish genesis. We must read the checkpoint presence from before `BP::new` + // ran (`!is_fresh_start`), because its cold-start backfill re-persists a + // checkpoint by the time we reach here โ€” reading the store now would always + // see one. Together they mean we produced blocks and zone-sdk processed + // channel activity in a prior run. + let local_tip = store + .latest_block_meta() + .context("Failed to read latest block meta")? + .map(|meta| meta.id); + let had_checkpoint_before_start = !is_fresh_start; + if let Some(local_tip) = local_tip + && had_checkpoint_before_start + && channel_tip_slot.is_none() + { + return Err(anyhow!( + "Sequencer holds committed blocks (tip {local_tip}) but the Bedrock channel \ + no longer exists on the connected chain โ€” the channel was wiped or the node \ + points at a different chain. Refusing to resume onto a foreign channel." + )); + } + + let divergence_error = |mismatch: &ChainMismatch| { + anyhow!( + "Sequencer store diverges from the Bedrock channel ({mismatch}). \ + Delete the sequencer storage directory or point at the correct channel." + ) + }; + + // With a recorded anchor, probe the channel for positive evidence of a + // different chain: the frontier upfront (a missing/behind channel serves + // no messages to scan), then the anchor block as messages stream in. + let mut consistency_check = anchor_record.map(|record| { + let anchor = Anchor::new( + Slot::from(record.slot), + Some((record.block_id, record.hash)), + ); + let mut check = AnchorConsistencyCheck::new(anchor); + check.check_frontier(channel_tip_slot); + check + }); + if let Some(ChainConsistency::Inconsistent(mismatch)) = consistency_check + .as_ref() + .and_then(AnchorConsistencyCheck::verdict) + { + return Err(divergence_error(mismatch)); + } + + // Verify each message against the anchor and replay the + // blocks (applying the ones we miss, checking the ones we hold). + let messages = publisher + .read_channel_after(after_slot) + .await + .context("Failed to read channel history for reconstruction")?; + let mut messages = std::pin::pin!(messages); + let mut channel_is_empty = true; + while let Some((message, slot)) = messages.next().await { + if let Some(check) = &mut consistency_check + && let Some(ChainConsistency::Inconsistent(mismatch)) = + check.observe(&message, slot) + { + return Err(divergence_error(mismatch)); + } + + let ZoneMessage::Block(zone_block) = message else { + continue; + }; + let block: Block = borsh::from_slice(&zone_block.data).map_err(|err| { + anyhow!( + "Failed to deserialize channel block at slot {}: {err}", + slot.into_inner() + ) + })?; + channel_is_empty = false; + // 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) + } + + /// Applies a single channel block during reconstruction: idempotent for + /// 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: &SequencerStore, + chain: &mut ChainState, + block: &Block, + slot: Slot, + ) -> Result<()> { + let tip = store + .latest_block_meta() + .context("Failed to read latest block meta")?; + let block_id = block.header.block_id; + let block_hash = block.header.hash; + + let record = ZoneAnchorRecord { + slot: slot.into_inner(), + block_id, + hash: block_hash, + }; + + // A block at/below the tip must match what we already stored, otherwise + // the channel is a different chain. + if let Some(tip) = &tip + && block_id <= tip.id + { + match store + .get_block_at_id(block_id) + .context("Failed to read stored block")? + { + Some(stored) if stored.header.hash == block_hash => { + store + .set_zone_anchor(&record) + .context("Failed to persist zone anchor")?; + return Ok(()); + } + Some(stored) => { + return Err(anyhow!( + "Channel block {block_id} hash {block_hash} does not match stored hash {}", + stored.header.hash + )); + } + None => { + return Err(anyhow!( + "Channel block {block_id} is at/below local tip {} but is missing locally", + 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) + )); + } + } + + // 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 + // intents are deliberately not counted: backfill already re-delivered and + // dropped their finalized L1 events, so an increment here would never be + // consumed and would leave a phantom count. + let deposit_event_ids: Vec<_> = block + .body + .transactions + .iter() + .filter_map(extract_bridge_deposit_id) + .collect(); + store + .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")?; + Ok(()) + } + fn on_checkpoint(dbio: Arc) -> block_publisher::CheckpointSink { Box::new(move |cp| { let bytes = match serde_json::to_vec(&cp) { @@ -335,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 { @@ -351,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, @@ -381,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!( @@ -399,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 { @@ -407,10 +738,17 @@ impl SequencerCore { }; if let Some(deposit_op_id) = extract_bridge_deposit_id(tx) { + if store + .is_deposit_event_submitted(deposit_op_id) + .context("Failed to check whether deposit was already submitted")? + { + info!("Skipping already-submitted bridge deposit {deposit_op_id}"); + return Ok(false); + } 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")?; } @@ -423,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(); @@ -432,11 +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")?; - let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis()) .expect("Timestamp must be positive"); @@ -455,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, }; @@ -472,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, @@ -488,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); @@ -496,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, }; @@ -504,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(), @@ -519,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 { @@ -561,14 +916,21 @@ impl SequencerCore { .collect()) } - pub fn block_publisher(&self) -> BP { - self.block_publisher.clone() + pub const fn block_publisher(&self) -> &BP { + &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) } } @@ -578,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. @@ -627,41 +1088,15 @@ 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 accounts seeded outside +/// any transaction (bridge-lock holdings, the cross-zone inbox config). +fn build_initial_state(config: &SequencerConfig) -> lee::V03State { #[cfg(not(feature = "testnet"))] let mut state = testnet_initial_state::initial_state(); #[cfg(feature = "testnet")] let mut state = testnet_initial_state::initial_state_testnet(); - let genesis_txs = config - .genesis - .iter() - .filter_map(|genesis_tx| match genesis_tx { - GenesisAction::SupplyAccount { - account_id, - balance, - } => Some(build_supply_account_genesis_transaction( - account_id, *balance, - )), - GenesisAction::SupplyBridgeAccount { balance } => { - Some(build_supply_bridge_account_genesis_transaction(*balance)) - } - // Force-inserted below: bridge_lock has no mint transaction. - GenesisAction::SupplyBridgeLockHolding { .. } => None, - }) - .chain(std::iter::once(clock_invocation(0))) - .inspect(|tx| { - state - .transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0) - .expect("Failed to execute genesis transaction"); - }) - .map(LeeTransaction::Public) - .collect(); - // Seed bridge-lock holder balances directly: they are not produced by any tx. for action in &config.genesis { if let GenesisAction::SupplyBridgeLockHolding { holder, amount } = action { @@ -679,6 +1114,41 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec (lee::V03State, Vec) { + let mut state = build_initial_state(config); + + let genesis_txs = config + .genesis + .iter() + .filter_map(|genesis_tx| match genesis_tx { + GenesisAction::SupplyAccount { + account_id, + balance, + } => Some(build_supply_account_genesis_transaction( + account_id, *balance, + )), + GenesisAction::SupplyBridgeAccount { balance } => { + Some(build_supply_bridge_account_genesis_transaction(*balance)) + } + // Force-inserted in `build_initial_state`: bridge_lock has no mint transaction. + GenesisAction::SupplyBridgeLockHolding { .. } => None, + }) + .chain(std::iter::once(clock_invocation(0))) + .inspect(|tx| { + state + .transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0) + .expect("Failed to execute genesis transaction"); + }) + .map(LeeTransaction::Public) + .collect(); + (state, genesis_txs) } @@ -743,7 +1213,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(); @@ -771,6 +1241,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 { @@ -860,7 +1350,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 39f635f9..4f2552b7 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -2,13 +2,15 @@ use std::time::Duration; use anyhow::Result; use common::block::Block; -use logos_blockchain_core::mantle::ops::channel::ChannelId; +use futures::Stream; +use logos_blockchain_core::mantle::ops::channel::{ChannelId, MsgId}; use logos_blockchain_key_management_system_service::keys::Ed25519Key; -use logos_blockchain_zone_sdk::sequencer::WithdrawArg; +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, @@ -19,6 +21,31 @@ 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`]. + messages: Vec<(ZoneMessage, Slot)>, +} + +impl MockBlockPublisher { + /// Builds a mock publisher backed by a canned channel, for reconstruction + /// and consistency tests. The default (via [`BlockPublisherTrait::new`]) + /// serves an empty channel. + #[must_use] + 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, + } + } } impl BlockPublisherTrait for MockBlockPublisher { @@ -31,21 +58,53 @@ 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(), }) } 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) + } + + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_> { + // Mirror `next_messages`: `after_slot` is exclusive. + let messages = self + .messages + .iter() + .filter(move |(_, slot)| after_slot.is_none_or(|after| *slot > after)) + .cloned(); + Ok(futures::stream::iter(messages)) + } } diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 05e2ec4c..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,21 +22,25 @@ 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; + #[derive(borsh::BorshSerialize)] struct DepositMetadataForEncoding { recipient_id: lee::AccountId, @@ -146,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); @@ -186,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()); } @@ -295,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; @@ -309,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, @@ -319,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; @@ -335,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(_) @@ -348,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; @@ -359,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); @@ -400,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 @@ -410,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] @@ -442,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(); @@ -477,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!( @@ -496,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. @@ -538,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!( @@ -554,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!( @@ -625,7 +652,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() { sequencer.produce_new_block().await.unwrap(); // Get the metadata of the last block produced - sequencer.store.latest_block_meta().unwrap() + sequencer.store.latest_block_meta().unwrap().unwrap() }; // Step 2: Restart sequencer from the same storage @@ -653,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(); @@ -705,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(); @@ -760,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(); @@ -779,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. @@ -862,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) @@ -1245,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 new file mode 100644 index 00000000..e52c3364 --- /dev/null +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -0,0 +1,610 @@ +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + 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}; +use storage::sequencer::sequencer_cells::ZoneAnchorRecord; + +use super::*; +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 { + id: MsgId::from([0_u8; 32]), + data: Inscription::try_from(bytes.as_slice()).expect("inscription"), + }); + (message, Slot::from(slot)) +} + +/// Collects a sequencer's whole chain (genesis..=tip) into a canned channel, +/// one block per slot at `slot_step` spacing. +fn channel_from_store(store: &SequencerStore, slot_step: u64) -> Vec<(ZoneMessage, Slot)> { + let genesis_id = store.genesis_id(); + let tip_id = store.latest_block_meta().expect("tip").expect("present").id; + (genesis_id..=tip_id) + .enumerate() + .map(|(index, id)| { + let block = store.get_block_at_id(id).expect("read").expect("present"); + block_to_channel_message(&block, (index as u64 + 1) * slot_step) + }) + .collect() +} + +#[tokio::test] +async fn reconstructs_missing_channel_blocks_into_fresh_store() { + // Sequencer A produces a few blocks; treat its chain as the channel. + let config_a = setup_sequencer_config(); + let (mut seq_a, _handle_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + seq_a.produce_new_block().await.unwrap(); + seq_a.produce_new_block().await.unwrap(); + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B starts from a fresh store and reconstructs A's chain. + let config_b = setup_sequencer_config(); + 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, &store_b, &chain_b, true, + ) + .await + .expect("reconstruct"); + assert!(!channel_was_empty); + + let tip_b = store_b.latest_block_meta().unwrap().unwrap(); + assert_eq!(tip_b.id, tip_a.id); + 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, + state_a.get_account_by_id(account.account_id).balance, + ); + } + + let anchor = store_b.get_zone_anchor().unwrap().expect("anchor recorded"); + assert_eq!(anchor.block_id, tip_a.id); + assert_eq!(anchor.slot, tip_slot.into_inner()); + + // Re-running is idempotent: everything is already applied, no error. + let channel_was_empty = SequencerCore::::verify_and_reconstruct( + &mock_b, &store_b, &chain_b, true, + ) + .await + .expect("reconstruct idempotent"); + assert!(!channel_was_empty); + assert_eq!(store_b.latest_block_meta().unwrap().unwrap().id, tip_a.id); +} + +#[tokio::test] +async fn fails_when_channel_serves_a_divergent_block() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // Anchor on the local genesis at some slot. + let genesis_id = store.genesis_id(); + let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap(); + let anchor_slot = 100_u64; + store + .set_zone_anchor(&ZoneAnchorRecord { + slot: anchor_slot, + block_id: genesis_id, + hash: genesis.header.hash, + }) + .unwrap(); + + // The channel serves a different block at the anchor id/slot. + let mut tampered = genesis.clone(); + tampered.header.hash = HashType([9_u8; 32]); + let messages = vec![block_to_channel_message(&tampered, anchor_slot)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(anchor_slot)), + messages, + ); + + 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 (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 + .set_zone_anchor(&ZoneAnchorRecord { + slot: 100, + block_id: genesis_id, + hash: genesis.header.hash, + }) + .unwrap(); + + // 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, &store, &chain, true) + .await; + assert!(result.is_err(), "missing channel must abort startup"); +} + +// The following cases exercise the divergence branches of +// `apply_reconstructed_block` reached with no recorded anchor, so the block's own +// validation fires rather than the up-front `AnchorConsistencyCheck`. + +#[tokio::test] +async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() { + let config = setup_sequencer_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. + let mut reinscribed = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); + reinscribed.header.hash = HashType([0xAB_u8; 32]); + + let messages = vec![block_to_channel_message(&reinscribed, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + 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" + ); +} + +#[tokio::test] +async fn fails_when_a_stored_block_hash_diverges_from_the_channel() { + // A sequencer that committed blocks past genesis but never recorded an anchor. + let config = setup_sequencer_config(); + let (mut seq, _handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; + seq.produce_new_block().await.unwrap(); + seq.produce_new_block().await.unwrap(); + + // A below-tip block re-served with a corrupted hash: we already hold this id + // with a different hash, so the channel is a different chain. + let below_tip_id = seq.block_store().genesis_id() + 1; + let mut block = seq + .block_store() + .get_block_at_id(below_tip_id) + .unwrap() + .unwrap(); + block.header.hash = HashType([0xCD_u8; 32]); + + let messages = vec![block_to_channel_message(&block, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = SequencerCore::::verify_and_reconstruct( + &mock, &seq.store, &seq.chain, true, + ) + .await; + assert!( + result.is_err(), + "a diverging below-tip block hash must abort startup" + ); +} + +#[tokio::test] +async fn fails_when_a_channel_block_is_missing_locally() { + let config = setup_sequencer_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. + let mut foreign = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); + foreign.header.block_id = store.genesis_id() - 1; + + let messages = vec![block_to_channel_message(&foreign, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + 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" + ); +} + +#[tokio::test] +async fn fails_when_a_channel_block_does_not_extend_the_tip() { + let config = setup_sequencer_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(); + orphan.header.block_id = store.genesis_id() + 5; + + let messages = vec![block_to_channel_message(&orphan, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!( + result.is_err(), + "a non-contiguous channel block must abort startup" + ); +} + +/// A sequencer config whose genesis funds the bridge account, so replayed bridge +/// deposit transactions have a source balance to mint from. +fn bridge_funded_config() -> SequencerConfig { + let mut config = setup_sequencer_config(); + config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }]; + config +} + +/// Builds an unfulfilled pending deposit event for `recipient`, matching the +/// encoding `build_bridge_deposit_tx_from_event` expects. +fn deposit_event_record( + op_id: [u8; 32], + amount: u64, + recipient: lee::AccountId, +) -> PendingDepositEventRecord { + PendingDepositEventRecord { + deposit_op_id: HashType(op_id), + source_tx_hash: HashType([0_u8; 32]), + amount, + metadata: borsh::to_vec(&DepositMetadataForEncoding { + recipient_id: recipient, + }) + .unwrap(), + submitted_in_block_id: None, + } +} + +/// Builds a signed public bridge `Withdraw` transaction (the normal user path). +fn build_public_withdraw_tx( + sender: lee::AccountId, + nonce: u128, + amount: u64, + bedrock_account_pk: [u8; 32], + signing_key: &lee::PrivateKey, +) -> LeeTransaction { + let message = lee::public_transaction::Message::try_new( + programs::bridge().id(), + vec![sender, system_accounts::bridge_account_id()], + vec![nonce.into()], + bridge_core::Instruction::Withdraw { + amount, + bedrock_account_pk, + }, + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[signing_key]); + LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set)) +} + +/// The cold-start backfill re-delivers an already-finalized deposit into the +/// mempool before reconstruction applies the same deposit block, and the queued +/// mint cannot be pulled back out. Since the bridge program does not dedup on +/// `l1_deposit_op_id`, block production must skip the already-submitted deposit +/// so the vault is minted exactly once. +#[tokio::test] +async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() { + let recipient = initial_public_user_accounts()[0].account_id; + let deposit_amount = 500_u64; + let withdraw_amount = 100_u64; + let bedrock_account_pk = [0x22_u8; 32]; + let deposit_op_id = [0x0d_u8; 32]; + + // Sequencer A produces a deposit block then a withdraw block. + let config_a = bridge_funded_config(); + let (mut seq_a, mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + + let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient); + let deposit_tx = + crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx"); + mempool_a + .push((TransactionOrigin::Sequencer, deposit_tx.clone())) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let withdraw_tx = build_public_withdraw_tx( + recipient, + 0, + withdraw_amount, + bedrock_account_pk, + &create_signing_key_for_account1(), + ); + mempool_a + .push((TransactionOrigin::User, withdraw_tx.clone())) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + let config_b = bridge_funded_config(); + let (mut seq_b, mempool_b) = SequencerCoreWithMockClients::start_from_config(config_b).await; + + // Backfill re-delivery: persist the pending record and queue the mint, as + // `on_deposit_event` does, before reconstruction runs. + assert!( + seq_b + .block_store() + .dbio() + .add_pending_deposit_event(deposit_record.clone()) + .unwrap() + ); + mempool_b + .push((TransactionOrigin::Sequencer, deposit_tx)) + .await + .unwrap(); + + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + SequencerCore::::verify_and_reconstruct( + &mock_b, + &seq_b.store, + &seq_b.chain, + true, + ) + .await + .expect("reconstruct"); + + let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); + assert_eq!(tip_b.id, tip_a.id); + assert_eq!(tip_b.hash, tip_a.hash); + + seq_b.produce_new_block().await.unwrap(); + + 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!( + state_b.get_account_by_id(account).balance, + state_a.get_account_by_id(account).balance, + "reconstructed balance mismatch for {account:?}", + ); + } + assert_eq!( + state_b.get_account_by_id(vault_id).balance, + u128::from(deposit_amount), + "deposit must mint into the recipient vault exactly once, not twice" + ); + + let produced = seq_b + .block_store() + .get_block_at_id(tip_b.id + 1) + .unwrap() + .expect("produced block present"); + assert!( + !produced + .body + .transactions + .iter() + .any(|tx| crate::extract_bridge_deposit_id(tx) == Some(HashType(deposit_op_id))), + "the re-delivered mint must be skipped, not re-included in a block" + ); + + // A reconstructed withdraw's finalized L1 event was already re-delivered (and + // dropped) by cold-start backfill, so it will never be consumed again. + // Reconstruction must not count it, or the count stays phantom-inflated forever. + let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data"); + let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key"); + assert!( + !seq_b + .block_store() + .dbio() + .consume_unseen_withdraw_count(key) + .unwrap(), + "reconstruction must not leave a phantom unseen-withdraw count" + ); +} + +/// A reconstructed withdraw block must not touch the unseen-withdraw counter. +/// Its finalized L1 Withdraw event was already re-delivered (and dropped as a +/// no-op) by cold-start backfill, so counting it during reconstruction would +/// leave a permanent phantom that nothing ever consumes. +#[tokio::test] +async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() { + let recipient = initial_public_user_accounts()[0].account_id; + let withdraw_amount = 100_u64; + let bedrock_account_pk = [0x33_u8; 32]; + + // Sequencer A produces a single withdraw block; treat its chain as the channel. + let config_a = bridge_funded_config(); + let (mut seq_a, mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + let withdraw_tx = build_public_withdraw_tx( + recipient, + 0, + withdraw_amount, + bedrock_account_pk, + &create_signing_key_for_account1(), + ); + mempool_a + .push((TransactionOrigin::User, withdraw_tx.clone())) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let withdraw_arg = crate::extract_bridge_withdraw_data(&withdraw_tx).expect("withdraw data"); + let key = crate::withdraw_event_reconciliation_key(&withdraw_arg.outputs).expect("recon key"); + // Producing the withdraw counts it as unseen, awaiting its L1 event. + assert!( + seq_a + .block_store() + .dbio() + .consume_unseen_withdraw_count(key) + .unwrap(), + "producing a withdraw must count it as unseen" + ); + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B reconstructs A's chain from a fresh store. + let config_b = bridge_funded_config(); + 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, &store_b, &chain_b, true) + .await + .expect("reconstruct"); + + assert!( + !store_b.dbio().consume_unseen_withdraw_count(key).unwrap(), + "reconstruction must not leave a phantom unseen-withdraw count" + ); +} + +/// A deposit whose L1 event was observed (an unfulfilled pending record +/// exists) and whose L2 mint is already contained in a finalized channel block. +/// Reconstruction must reconcile the pending record against that block โ€” marking +/// it submitted so the startup replay does not re-inject it โ€” and apply the mint +/// exactly once. +#[tokio::test] +async fn reconstruction_reconciles_already_finished_deposit() { + let recipient = initial_public_user_accounts()[0].account_id; + let deposit_amount = 400_u64; + let deposit_op_id = [0x1a_u8; 32]; + + // Sequencer A: a single block that fully processes the bridge deposit. + let config_a = bridge_funded_config(); + let (mut seq_a, mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient); + let deposit_tx = + crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx"); + mempool_a + .push((TransactionOrigin::Sequencer, deposit_tx)) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + let deposit_block_id = seq_a.block_store().latest_block_meta().unwrap().unwrap().id; + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B: fresh store, but with the *unfulfilled* pending deposit event + // pre-seeded, as the cold-start backfill would when it re-observes this + // already-finalized deposit. + let config_b = bridge_funded_config(); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); + assert!( + store_b + .dbio() + .add_pending_deposit_event(deposit_record.clone()) + .unwrap() + ); + + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + 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!( + 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" + ); + + // The pending event is now marked submitted in the reconstructed block, so the + // startup replay would not re-queue it โ€” no double mint on restart. + let record = store_b + .get_unfulfilled_deposit_events() + .unwrap() + .into_iter() + .find(|event| event.deposit_op_id == HashType(deposit_op_id)) + .expect("pending deposit event should still be recorded"); + assert_eq!( + record.submitted_in_block_id, + Some(deposit_block_id), + "reconstruction must reconcile the already-finished deposit against its channel block" + ); +} + +#[tokio::test] +async fn committed_local_against_missing_channel_fails_without_anchor() { + // A sequencer that has committed blocks โ€” a non-genesis tip plus a persisted + // checkpoint โ€” but only ever produced (so it never recorded a per-block + // anchor). Restarting it against a wiped/missing channel must still fail, + // driven by the committed-blocks invariant rather than an anchor probe. + let config = setup_sequencer_config(); + { + let (mut seq, _handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + seq.produce_new_block().await.unwrap(); + seq.produce_new_block().await.unwrap(); + assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1); + } // drop releases the store so we can reopen it + + // 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 (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, &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 1a3a1886..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, - ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef, + FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned, + FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, + LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PendingDepositEventRecord, + PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell, + WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, + ZoneSdkCheckpointCellRef, }, }; @@ -34,6 +36,10 @@ pub const DB_META_LAST_FINALIZED_BLOCK_ID: &str = "last_finalized_block_id"; pub const DB_META_LATEST_BLOCK_META_KEY: &str = "latest_block_meta"; /// Key base for storing the zone-sdk sequencer checkpoint (opaque bytes). pub const DB_META_ZONE_SDK_CHECKPOINT_KEY: &str = "zone_sdk_checkpoint"; +/// Key base for storing the last channel block read back and verified from +/// Bedrock (its L1 slot + `id`/`hash`) โ€” the anchor for the startup +/// consistency check and the resume point for reconstruction. +pub const DB_META_ZONE_CURSOR_KEY: &str = "zone_cursor"; /// Key base for storing queued deposit events that were not yet /// fulfilled on L2. pub const DB_META_PENDING_DEPOSIT_EVENTS_KEY: &str = "pending_deposit_events"; @@ -42,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"; @@ -340,8 +350,9 @@ impl RocksDBIO { self.put_batch(&LatestBlockMetaCellRef(block_meta), (), batch) } - pub fn latest_block_meta(&self) -> DbResult { - self.get::(()).map(|val| val.0) + pub fn latest_block_meta(&self) -> DbResult> { + self.get_opt::(()) + .map(|val| val.map(|cell| cell.0)) } pub fn get_zone_sdk_checkpoint_bytes(&self) -> DbResult>> { @@ -359,6 +370,14 @@ impl RocksDBIO { self.del::(()) } + pub fn get_zone_anchor(&self) -> DbResult> { + Ok(self.get_opt::(())?.map(|cell| cell.0)) + } + + pub fn put_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> { + self.put(&ZoneAnchorCell(*anchor), ()) + } + pub fn get_pending_deposit_events(&self) -> DbResult> { Ok(self .get_opt::(())? @@ -390,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], @@ -434,6 +472,14 @@ impl RocksDBIO { Ok(removed) } + /// Whether a bridge deposit for `deposit_op_id` is already recorded as + /// included in a block (its pending record is marked submitted). + pub fn is_deposit_event_submitted(&self, deposit_op_id: HashType) -> DbResult { + Ok(self.get_pending_deposit_events()?.iter().any(|record| { + record.deposit_op_id == deposit_op_id && record.submitted_in_block_id.is_some() + })) + } + fn increment_unseen_withdraw_count( &self, withdrawal: WithdrawalReconciliationKey, @@ -482,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| { @@ -516,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) } @@ -612,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 @@ -661,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 7672e271..c10b3d30 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -7,9 +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_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, }, }; @@ -43,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); @@ -132,6 +199,39 @@ impl SimpleWritableCell for ZoneSdkCheckpointCellRef<'_> { } } +/// The last channel block read back and verified from Bedrock. +/// +/// Holds its L1 inscription `slot` plus the block's `id`/`hash`, and serves as +/// both the anchor for the startup consistency check and the resume point for +/// reconstruction. `slot` is stored as a raw `u64` because the zone-sdk `Slot` +/// does not derive borsh; the caller converts to/from `Slot`. +#[derive(Debug, Clone, Copy, BorshSerialize, BorshDeserialize)] +pub struct ZoneAnchorRecord { + pub slot: u64, + pub block_id: u64, + pub hash: HashType, +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct ZoneAnchorCell(pub ZoneAnchorRecord); + +impl SimpleStorableCell for ZoneAnchorCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_ZONE_CURSOR_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for ZoneAnchorCell {} + +impl SimpleWritableCell for ZoneAnchorCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize zone cursor".to_owned())) + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct PendingDepositEventRecord { pub deposit_op_id: HashType, 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 eb423650..f7f32f0c 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(), ); @@ -263,7 +256,7 @@ impl AccountManager { identifier, } => { let acc = lee_core::account::Account::default(); - let auth_acc = AccountWithMetadata::new(acc, false, (&npk, &vpk, identifier)); + let auth_acc = AccountWithMetadata::new(acc, true, (&npk, &vpk, identifier)); let mut random_seed: [u8; 32] = [0; 32]; OsRng.fill_bytes(&mut random_seed); let pre = AccountPreparedData { @@ -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, @@ -442,7 +437,7 @@ impl AccountManager { identifier: pre.identifier, commitment_root: self.dummy_commitment_root, }, - (None, _) => InputAccountIdentity::PrivateUnauthorized { + (None, _) => InputAccountIdentity::PrivateForeignInit { vpk: pre.vpk.clone(), random_seed: pre.random_seed, npk: pre.npk, @@ -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