diff --git a/Cargo.lock b/Cargo.lock index 7f7605515..35bfc584d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -648,17 +648,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "async-stream" version = "0.3.6" @@ -720,17 +709,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "attohttpc" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9a9bf8b79a749ee0b911b91b671cc2b6c670bdbc7e3dfd537576ddc94bb2a2" -dependencies = [ - "http 0.2.12", - "log", - "url", -] - [[package]] name = "attohttpc" version = "0.30.1" @@ -738,7 +716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ "base64 0.22.1", - "http 1.4.1", + "http", "log", "url", ] @@ -827,7 +805,7 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http 1.4.1", + "http", "http-body", "http-body-util", "hyper", @@ -861,7 +839,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.1", + "http", "http-body", "http-body-util", "hyper", @@ -896,7 +874,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.1", + "http", "http-body", "http-body-util", "mime", @@ -915,7 +893,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http", "http-body", "http-body-util", "mime", @@ -1129,7 +1107,7 @@ dependencies = [ "futures-util", "hex", "home", - "http 1.4.1", + "http", "http-body-util", "hyper", "hyper-named-pipe", @@ -1225,6 +1203,7 @@ name = "bridge_lock_core" version = "0.1.0" dependencies = [ "lee_core", + "risc0-zkvm", "serde", ] @@ -2002,6 +1981,7 @@ dependencies = [ "log", "ping_core", "programs", + "rand 0.8.6", "risc0-zkvm", "sequencer_service_rpc", "serde", @@ -2975,6 +2955,18 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.4", + "siphasher", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3427,7 +3419,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils", - "http 1.4.1", + "http", "js-sys", "pin-project", "serde", @@ -3502,7 +3494,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.1", + "http", "indexmap 2.14.0", "slab", "tokio", @@ -3558,7 +3550,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -3656,11 +3647,10 @@ checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" [[package]] name = "hickory-proto" -version = "0.25.0-alpha.5" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d00147af6310f4392a31680db52a3ed45a2e0f68eb18e8c3fe5537ecc96d9e2" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" dependencies = [ - "async-recursion", "async-trait", "cfg-if", "data-encoding", @@ -3672,6 +3662,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.9.4", + "ring", "socket2 0.5.10", "thiserror 2.0.18", "tinyvec", @@ -3682,9 +3673,9 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.25.0-alpha.5" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5762f69ebdbd4ddb2e975cd24690bf21fe6b2604039189c26acddbc427f12887" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" dependencies = [ "cfg-if", "futures-util", @@ -3772,17 +3763,6 @@ dependencies = [ "utf8-width", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.1" @@ -3800,7 +3780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http", ] [[package]] @@ -3811,7 +3791,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http", "http-body", "pin-project-lite", ] @@ -3889,7 +3869,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.4.1", + "http", "http-body", "httparse", "httpdate", @@ -3921,7 +3901,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.1", + "http", "hyper", "hyper-util", "log", @@ -3956,7 +3936,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.1", + "http", "http-body", "hyper", "ipnet", @@ -4156,27 +4136,6 @@ dependencies = [ "windows", ] -[[package]] -name = "igd-next" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b0d7d4541def58a37bf8efc559683f21edce7c82f0d866c93ac21f7e098f93" -dependencies = [ - "async-trait", - "attohttpc 0.24.1", - "bytes", - "futures", - "http 1.4.1", - "http-body-util", - "hyper", - "hyper-util", - "log", - "rand 0.8.6", - "tokio", - "url", - "xmltree", -] - [[package]] name = "igd-next" version = "0.16.2" @@ -4184,10 +4143,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" dependencies = [ "async-trait", - "attohttpc 0.30.1", + "attohttpc", "bytes", "futures", - "http 1.4.1", + "http", "http-body-util", "hyper", "hyper-util", @@ -4394,11 +4353,15 @@ dependencies = [ "lee", "lee_core", "log", + "logos-blockchain-core", + "logos-blockchain-key-management-system-service", + "logos-blockchain-zone-sdk", "ping_core", "programs", "risc0-zkvm", "sequencer_core", "sequencer_service_rpc", + "sequencer_stake_core", "serde_json", "system_accounts", "tempfile", @@ -4647,7 +4610,7 @@ dependencies = [ "futures-channel", "futures-util", "gloo-net", - "http 1.4.1", + "http", "jsonrpsee-core", "pin-project", "rustls", @@ -4672,7 +4635,7 @@ dependencies = [ "bytes", "futures-timer", "futures-util", - "http 1.4.1", + "http", "http-body", "http-body-util", "jsonrpsee-types", @@ -4733,7 +4696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c51b7c290bb68ce3af2d029648148403863b982f138484a73f02a9dd52dbd7f" dependencies = [ "futures-util", - "http 1.4.1", + "http", "http-body", "http-body-util", "hyper", @@ -4759,7 +4722,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc88ff4688e43cc3fa9883a8a95c6fa27aa2e76c96e610b737b6554d650d7fd5" dependencies = [ - "http 1.4.1", + "http", "serde", "serde_json", "thiserror 2.0.18", @@ -4783,7 +4746,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6fceceeb05301cc4c065ab3bd2fa990d41ff4eb44e4ca1b30fa99c057c3e79" dependencies = [ - "http 1.4.1", + "http", "jsonrpsee-client-transport", "jsonrpsee-core", "jsonrpsee-types", @@ -5331,9 +5294,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libp2p" -version = "0.55.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b72dc443ddd0254cb49a794ed6b6728400ee446a0f7ab4a07d0209ee98de20e9" +checksum = "ce71348bf5838e46449ae240631117b487073d5f347c06d434caddcb91dceb5a" dependencies = [ "bytes", "either", @@ -5363,9 +5326,9 @@ dependencies = [ [[package]] name = "libp2p-allow-block-list" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38944b7cb981cc93f2f0fb411ff82d0e983bd226fbcc8d559639a3a73236568b" +checksum = "d16ccf824ee859ca83df301e1c0205270206223fd4b1f2e512a693e1912a8f4a" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5374,9 +5337,9 @@ dependencies = [ [[package]] name = "libp2p-autonat" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e297bfc6cabb70c6180707f8fa05661b77ecb9cb67e8e8e1c469301358fa21d0" +checksum = "fab5e25c49a7d48dac83d95d8f3bac0a290d8a5df717012f6e34ce9886396c0b" dependencies = [ "async-trait", "asynchronous-codec", @@ -5399,9 +5362,9 @@ dependencies = [ [[package]] name = "libp2p-connection-limits" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efe9323175a17caa8a2ed4feaf8a548eeef5e0b72d03840a0eab4bcb0210ce1c" +checksum = "a18b8b607cf3bfa2f8c57db9c7d8569a315d5cc0a282e6bfd5ebfc0a9840b2a0" dependencies = [ "libp2p-core", "libp2p-identity", @@ -5435,9 +5398,9 @@ dependencies = [ [[package]] name = "libp2p-dns" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b780a1150214155b0ed1cdf09fbd2e1b0442604f9146a431d1b21d23eef7bd7" +checksum = "0b770c1c8476736ca98c578cba4b505104ff8e842c2876b528925f9766379f9a" dependencies = [ "async-trait", "futures", @@ -5451,9 +5414,9 @@ dependencies = [ [[package]] name = "libp2p-gossipsub" -version = "0.48.0" +version = "0.49.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d558548fa3b5a8e9b66392f785921e363c57c05dcadfda4db0d41ae82d313e4a" +checksum = "3573f3d8e30bd62cda336df5c7c1041a1caa40a648376b8e1e274d585c0ed25c" dependencies = [ "async-channel", "asynchronous-codec", @@ -5470,7 +5433,6 @@ dependencies = [ "libp2p-core", "libp2p-identity", "libp2p-swarm", - "prometheus-client", "quick-protobuf", "quick-protobuf-codec", "rand 0.8.6", @@ -5483,9 +5445,9 @@ dependencies = [ [[package]] name = "libp2p-identify" -version = "0.46.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c06862544f02d05d62780ff590cc25a75f5c2b9df38ec7a370dcae8bb873cf" +checksum = "8ab792a8b68fdef443a62155b01970c81c3aadab5e659621b063ef252a8e65e8" dependencies = [ "asynchronous-codec", "either", @@ -5525,9 +5487,9 @@ dependencies = [ [[package]] name = "libp2p-kad" -version = "0.47.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bab0466a27ebe955bcbc27328fae5429c5b48c915fd6174931414149802ec23" +checksum = "13d3fd632a5872ec804d37e7413ceea20588f69d027a0fa3c46f82574f4dee60" dependencies = [ "asynchronous-codec", "bytes", @@ -5553,9 +5515,9 @@ dependencies = [ [[package]] name = "libp2p-mdns" -version = "0.47.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d0ba095e1175d797540e16b62e7576846b883cb5046d4159086837b36846cc" +checksum = "c66872d0f1ffcded2788683f76931be1c52e27f343edb93bc6d0bcd8887be443" dependencies = [ "futures", "hickory-proto", @@ -5572,9 +5534,9 @@ dependencies = [ [[package]] name = "libp2p-metrics" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce58c64292e87af624fcb86465e7dd8342e46a388d71e8fec0ab37ee789630a" +checksum = "805a555148522cb3414493a5153451910cb1a146c53ffbf4385708349baf62b7" dependencies = [ "futures", "libp2p-core", @@ -5590,9 +5552,9 @@ dependencies = [ [[package]] name = "libp2p-quic" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41432a159b00424a0abaa2c80d786cddff81055ac24aa127e0cf375f7858d880" +checksum = "9dcc597d70bf7f6f30cbe07081802c836184e48416e89e9ce73a0ba2c56a319e" dependencies = [ "futures", "futures-timer", @@ -5601,6 +5563,7 @@ dependencies = [ "libp2p-identity", "libp2p-tls", "quinn", + "quinn-proto", "rand 0.8.6", "ring", "rustls", @@ -5612,9 +5575,9 @@ dependencies = [ [[package]] name = "libp2p-request-response" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "548fe44a80ff275d400f1b26b090d441d83ef73efabbeb6415f4ce37e5aed865" +checksum = "a9f1cca83488b90102abac7b67d5c36fc65bc02ed47620228af7ed002e6a1478" dependencies = [ "async-trait", "futures", @@ -5629,9 +5592,9 @@ dependencies = [ [[package]] name = "libp2p-stream" -version = "0.3.0-alpha" +version = "0.4.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "826716f1ee125895f1fb44911413cba023485b552ff96c7a2159bd037ac619bb" +checksum = "1d6bd8025c80205ec2810cfb28b02f362ab48a01bee32c50ab5f12761e033464" dependencies = [ "futures", "libp2p-core", @@ -5643,20 +5606,19 @@ dependencies = [ [[package]] name = "libp2p-swarm" -version = "0.46.0" +version = "0.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803399b4b6f68adb85e63ab573ac568154b193e9a640f03e0f2890eabbcb37f8" +checksum = "ce88c6c4bf746c8482480345ea3edfd08301f49e026889d1cbccfa1808a9ed9e" dependencies = [ "either", "fnv", "futures", "futures-timer", + "hashlink 0.10.0", "libp2p-core", "libp2p-identity", "libp2p-swarm-derive", - "lru", "multistream-select", - "once_cell", "rand 0.8.6", "smallvec", "tokio", @@ -5666,28 +5628,27 @@ dependencies = [ [[package]] name = "libp2p-swarm-derive" -version = "0.35.0" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206e0aa0ebe004d778d79fb0966aa0de996c19894e2c0605ba2f8524dd4443d8" +checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c" dependencies = [ "heck", - "proc-macro2", "quote", "syn 2.0.117", ] [[package]] name = "libp2p-tcp" -version = "0.43.0" +version = "0.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65346fb4d36035b23fec4e7be4c320436ba53537ce9b6be1d1db1f70c905cad0" +checksum = "fb6585b9309699f58704ec9ab0bb102eca7a3777170fa91a8678d73ca9cafa93" dependencies = [ "futures", "futures-timer", "if-watch", "libc", "libp2p-core", - "socket2 0.5.10", + "socket2 0.6.4", "tokio", "tracing", ] @@ -5713,13 +5674,13 @@ dependencies = [ [[package]] name = "libp2p-upnp" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d457b9ecceb66e7199f049926fad447f1f17f040e8d29d690c086b4cab8ed14a" +checksum = "4757e65fe69399c1a243bbb90ec1ae5a2114b907467bf09f3575e899815bb8d3" dependencies = [ "futures", "futures-timer", - "igd-next 0.15.1", + "igd-next", "libp2p-core", "libp2p-swarm", "tokio", @@ -5798,20 +5759,10 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" -[[package]] -name = "logos-blockchain-blake2btree" -version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" -dependencies = [ - "blake2", - "logos-blockchain-dynamic-merkle", - "logos-blockchain-merkle-tree", -] - [[package]] name = "logos-blockchain-blend-crypto" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "blake2", "logos-blockchain-groth16", @@ -5825,7 +5776,7 @@ dependencies = [ [[package]] name = "logos-blockchain-blend-message" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "blake2", "derivative", @@ -5850,7 +5801,7 @@ dependencies = [ [[package]] name = "logos-blockchain-blend-proofs" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "ed25519-dalek", "generic-array 1.4.3", @@ -5871,7 +5822,7 @@ dependencies = [ [[package]] name = "logos-blockchain-chain-broadcast-service" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "derivative", @@ -5885,7 +5836,7 @@ dependencies = [ [[package]] name = "logos-blockchain-chain-service" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "bytes", @@ -5915,8 +5866,8 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-build" -version = "0.5.3" -source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +version = "0.5.5" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.5#32f1ac43f331f0eeff3a2a0aec0f153564d03583" dependencies = [ "dirs", "fd-lock", @@ -5927,16 +5878,16 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-common" -version = "0.5.3" -source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +version = "0.5.5" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.5#32f1ac43f331f0eeff3a2a0aec0f153564d03583" dependencies = [ "logos-blockchain-circuits-types", ] [[package]] name = "logos-blockchain-circuits-poc-sys" -version = "0.5.3" -source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +version = "0.5.5" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.5#32f1ac43f331f0eeff3a2a0aec0f153564d03583" dependencies = [ "logos-blockchain-circuits-build", "logos-blockchain-circuits-common", @@ -5945,8 +5896,8 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-pol-sys" -version = "0.5.3" -source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +version = "0.5.5" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.5#32f1ac43f331f0eeff3a2a0aec0f153564d03583" dependencies = [ "logos-blockchain-circuits-build", "logos-blockchain-circuits-common", @@ -5955,8 +5906,8 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-poq-sys" -version = "0.5.3" -source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +version = "0.5.5" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.5#32f1ac43f331f0eeff3a2a0aec0f153564d03583" dependencies = [ "logos-blockchain-circuits-build", "logos-blockchain-circuits-common", @@ -5966,15 +5917,15 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-prover" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "rust-rapidsnark", ] [[package]] name = "logos-blockchain-circuits-signature-sys" -version = "0.5.3" -source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +version = "0.5.5" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.5#32f1ac43f331f0eeff3a2a0aec0f153564d03583" dependencies = [ "logos-blockchain-circuits-build", "logos-blockchain-circuits-common", @@ -5983,8 +5934,8 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-types" -version = "0.5.3" -source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.3#127626881faa975aa8e9868422cf6bbb08fcb512" +version = "0.5.5" +source = "git+https://github.com/logos-blockchain/logos-blockchain-circuits.git?tag=v0.5.5#32f1ac43f331f0eeff3a2a0aec0f153564d03583" dependencies = [ "bytes", "libc", @@ -5993,7 +5944,7 @@ dependencies = [ [[package]] name = "logos-blockchain-codec" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "hex", "logos-blockchain-codec-macros", @@ -6005,7 +5956,7 @@ dependencies = [ [[package]] name = "logos-blockchain-codec-macros" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "hex", "proc-macro2", @@ -6016,7 +5967,7 @@ dependencies = [ [[package]] name = "logos-blockchain-common-http-client" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "futures", "hex", @@ -6039,7 +5990,7 @@ dependencies = [ [[package]] name = "logos-blockchain-core" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "ark-ff", "bincode", @@ -6047,7 +5998,6 @@ dependencies = [ "bytes", "const-hex", "hex", - "logos-blockchain-blake2btree", "logos-blockchain-blend-proofs", "logos-blockchain-codec", "logos-blockchain-cryptarchia-engine", @@ -6062,6 +6012,7 @@ dependencies = [ "logos-blockchain-utxotree", "multiaddr", "num-bigint 0.4.6", + "rpds", "serde", "strum 0.27.2", "thiserror 2.0.18", @@ -6072,7 +6023,7 @@ dependencies = [ [[package]] name = "logos-blockchain-cryptarchia-engine" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "logos-blockchain-codec", "logos-blockchain-pol", @@ -6089,7 +6040,7 @@ dependencies = [ [[package]] name = "logos-blockchain-cryptarchia-sync" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "bytes", "futures", @@ -6108,7 +6059,7 @@ dependencies = [ [[package]] name = "logos-blockchain-dynamic-merkle" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "rpds", "serde", @@ -6117,7 +6068,7 @@ dependencies = [ [[package]] name = "logos-blockchain-groth16" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "ark-bn254", "ark-ec", @@ -6136,7 +6087,7 @@ dependencies = [ [[package]] name = "logos-blockchain-http-api-common" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "axum 0.7.9", "logos-blockchain-core", @@ -6157,7 +6108,7 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-keys" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "bytes", @@ -6185,7 +6136,7 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-macros" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "proc-macro2", "quote", @@ -6195,7 +6146,7 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-operators" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "logos-blockchain-blend-proofs", @@ -6213,7 +6164,7 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-service" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "logos-blockchain-key-management-system-keys", @@ -6230,7 +6181,7 @@ dependencies = [ [[package]] name = "logos-blockchain-ledger" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "derivative", "logos-blockchain-blend-crypto", @@ -6256,7 +6207,7 @@ dependencies = [ [[package]] name = "logos-blockchain-libp2p" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "backon", @@ -6264,7 +6215,7 @@ dependencies = [ "either", "futures", "hex", - "igd-next 0.16.2", + "igd-next", "libp2p", "logos-blockchain-cryptarchia-sync", "logos-blockchain-log-targets", @@ -6285,7 +6236,7 @@ dependencies = [ [[package]] name = "logos-blockchain-log-targets" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "logos-blockchain-log-targets-macros", ] @@ -6293,7 +6244,7 @@ dependencies = [ [[package]] name = "logos-blockchain-log-targets-macros" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "proc-macro2", "quote", @@ -6303,7 +6254,7 @@ dependencies = [ [[package]] name = "logos-blockchain-merkle-tree" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "logos-blockchain-dynamic-merkle", "rpds", @@ -6314,7 +6265,7 @@ dependencies = [ [[package]] name = "logos-blockchain-mmr" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "ark-ff", "logos-blockchain-groth16", @@ -6328,7 +6279,7 @@ dependencies = [ [[package]] name = "logos-blockchain-network-service" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "futures", @@ -6350,7 +6301,7 @@ dependencies = [ [[package]] name = "logos-blockchain-poc" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "logos-blockchain-circuits-poc-sys", "logos-blockchain-circuits-prover", @@ -6367,7 +6318,7 @@ dependencies = [ [[package]] name = "logos-blockchain-pol" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "astro-float", "logos-blockchain-circuits-pol-sys", @@ -6387,7 +6338,7 @@ dependencies = [ [[package]] name = "logos-blockchain-poq" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "logos-blockchain-circuits-poq-sys", "logos-blockchain-circuits-prover", @@ -6406,7 +6357,7 @@ dependencies = [ [[package]] name = "logos-blockchain-poseidon2" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "ark-bn254", "ark-ff", @@ -6417,7 +6368,7 @@ dependencies = [ [[package]] name = "logos-blockchain-proofs-error" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "logos-blockchain-circuits-types", "logos-blockchain-groth16", @@ -6428,7 +6379,7 @@ dependencies = [ [[package]] name = "logos-blockchain-services-utils" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "bytes", @@ -6444,7 +6395,7 @@ dependencies = [ [[package]] name = "logos-blockchain-storage-service" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "bytes", @@ -6465,7 +6416,7 @@ dependencies = [ [[package]] name = "logos-blockchain-time-service" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "futures", @@ -6488,7 +6439,7 @@ dependencies = [ [[package]] name = "logos-blockchain-tracing" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "flate2", "logos-blockchain-log-targets", @@ -6514,7 +6465,7 @@ dependencies = [ [[package]] name = "logos-blockchain-utils" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "blake2", @@ -6539,7 +6490,7 @@ dependencies = [ [[package]] name = "logos-blockchain-utxotree" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "ark-ff", "logos-blockchain-dynamic-merkle", @@ -6552,7 +6503,7 @@ dependencies = [ [[package]] name = "logos-blockchain-zksign" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "logos-blockchain-circuits-prover", "logos-blockchain-circuits-signature-sys", @@ -6572,7 +6523,7 @@ dependencies = [ [[package]] name = "logos-blockchain-zone-sdk" version = "0.0.0" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=e2a1c3b7ef2191c224f998b94332c5926c789f9d#e2a1c3b7ef2191c224f998b94332c5926c789f9d" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=97d5e104fc20ce6e2fb404f8a9364afcb181a3e5#97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" dependencies = [ "async-trait", "futures", @@ -6615,15 +6566,6 @@ dependencies = [ "tracing-subscriber 0.3.23", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -6968,7 +6910,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.1", + "http", "httparse", "memchr", "mime", @@ -7396,6 +7338,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -7454,7 +7400,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http", "opentelemetry", "reqwest", ] @@ -7465,7 +7411,7 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ - "http 1.4.1", + "http", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", @@ -7746,7 +7692,9 @@ dependencies = [ name = "ping_core" version = "0.1.0" dependencies = [ + "borsh", "lee_core", + "risc0-zkvm", "serde", ] @@ -7754,6 +7702,7 @@ dependencies = [ name = "ping_receiver_program" version = "0.1.0" dependencies = [ + "cross_zone_inbox_core", "lee_core", "ping_core", ] @@ -8075,6 +8024,7 @@ dependencies = [ "lee_core", "ping_core", "risc0-zkvm", + "sequencer_stake_core", "token_core", "token_program", "vault_core", @@ -8083,9 +8033,9 @@ dependencies = [ [[package]] name = "prometheus-client" -version = "0.22.3" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504ee9ff529add891127c4827eb481bd69dc0ebc72e9a682e187db4caa60c3ca" +checksum = "cf41c1a7c32ed72abe5082fb19505b969095c12da9f5732a4bc9878757fd087c" dependencies = [ "dtoa", "itoa", @@ -8261,6 +8211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "bytes", + "fastbloom", "getrandom 0.3.4", "lru-slab", "rand 0.9.4", @@ -8624,7 +8575,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.1", + "http", "http-body", "http-body-util", "hyper", @@ -9525,6 +9476,7 @@ name = "sequencer_core" version = "0.1.0" dependencies = [ "anyhow", + "authenticated_transfer_core", "borsh", "bridge_core", "bytesize", @@ -9541,6 +9493,7 @@ dependencies = [ "key_protocol", "lee", "lee_core", + "libp2p", "log", "logos-blockchain-core", "logos-blockchain-http-api-common", @@ -9553,6 +9506,7 @@ dependencies = [ "rand 0.8.6", "risc0-zkvm", "sequencer_core_metrics", + "sequencer_stake_core", "serde", "serde_json", "storage", @@ -9562,6 +9516,7 @@ dependencies = [ "testnet_initial_state", "token_core", "tokio", + "tokio-retry", "tokio-util", "url", "vault_core", @@ -9633,19 +9588,25 @@ name = "sequencer_service" version = "0.1.0" dependencies = [ "anyhow", + "authenticated_transfer_core", "clap", "env_logger", "futures", "hex", "kameo", "kameo_actors", + "lee", "log", "metrics-exporter-prometheus", + "programs", "sequencer_core", "sequencer_executor_actor", "sequencer_rpc_server_actor", + "sequencer_stake_core", + "system_accounts", "tokio", "tokio-util", + "wallet", ] [[package]] @@ -9668,6 +9629,24 @@ dependencies = [ "sequencer_service_protocol", ] +[[package]] +name = "sequencer_stake_core" +version = "0.1.0" +dependencies = [ + "borsh", + "ed25519-dalek", + "lee_core", + "serde", +] + +[[package]] +name = "sequencer_stake_program" +version = "0.1.0" +dependencies = [ + "lee_core", + "sequencer_stake_core", +] + [[package]] name = "serde" version = "1.0.228" @@ -9891,7 +9870,7 @@ dependencies = [ "const_format", "futures", "gloo-net", - "http 1.4.1", + "http", "http-body-util", "hyper", "inventory", @@ -10064,6 +10043,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "sketches-ddsketch" version = "0.3.1" @@ -10136,7 +10121,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures", - "http 1.4.1", + "http", "httparse", "log", "rand 0.8.6", @@ -10412,6 +10397,7 @@ dependencies = [ "faucet_core", "lee_core", "programs", + "sequencer_stake_core", ] [[package]] @@ -10538,6 +10524,7 @@ dependencies = [ "sequencer_core", "sequencer_service", "sequencer_service_rpc", + "sequencer_stake_core", "serde", "serde_json", "tempfile", @@ -10600,7 +10587,7 @@ dependencies = [ "etcetera", "ferroid", "futures", - "http 1.4.1", + "http", "itertools 0.14.0", "log", "memchr", @@ -10808,6 +10795,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-retry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" +dependencies = [ + "pin-project-lite", + "rand 0.10.1", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -10981,7 +10979,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http 1.4.1", + "http", "http-body", "http-body-util", "hyper", @@ -11039,7 +11037,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.1", + "http", "http-body", "http-body-util", "http-range-header", @@ -11246,7 +11244,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.1", + "http", "httparse", "log", "rand 0.9.4", @@ -11447,7 +11445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ "base64 0.22.1", - "http 1.4.1", + "http", "httparse", "log", ] @@ -12340,6 +12338,7 @@ dependencies = [ name = "wrapped_token_core" version = "0.1.0" dependencies = [ + "borsh", "lee_core", "risc0-zkvm", "serde", @@ -12349,6 +12348,7 @@ dependencies = [ name = "wrapped_token_program" version = "0.1.0" dependencies = [ + "cross_zone_inbox_core", "lee_core", "wrapped_token_core", ] diff --git a/Cargo.toml b/Cargo.toml index 5c79e6bf5..87c43e884 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ members = [ "lez/programs/wrapped_token", "lez/programs/ping_sender", "lez/programs/ping_receiver", + "lez/programs/sequencer_stake", "lez/cross_zone", "test_programs", @@ -118,6 +119,7 @@ cross_zone_outbox_core = { path = "lez/programs/cross_zone_outbox/core" } bridge_lock_core = { path = "lez/programs/bridge_lock/core" } wrapped_token_core = { path = "lez/programs/wrapped_token/core" } ping_core = { path = "lez/programs/ping_core" } +sequencer_stake_core = { path = "lez/programs/sequencer_stake/core" } cross_zone = { path = "lez/cross_zone" } build_utils = { path = "build_utils" } test_programs = { path = "test_programs" } @@ -158,12 +160,23 @@ metrics-exporter-prometheus = "0.18.3" lru = "0.16.3" thiserror = "2.0" sha2 = "0.10.8" +ed25519-dalek = { version = "2.2.0", default-features = false } hex = "0.4.3" bytemuck = "1.24.0" bytesize = { version = "2.3.1", features = ["serde"] } humantime-serde = "1.1" arc-swap = "1.7" humantime = "2.1" +libp2p = { version = "0.56", features = [ + "ed25519", + "gossipsub", + "identify", + "kad", + "macros", + "mdns", + "quic", + "tokio", +] } aes-gcm = "0.10.3" toml = "0.9.8" bincode = "1.3.3" @@ -188,14 +201,14 @@ schemars = "1.2" async-stream = "0.3.6" strum = { version = "0.28.0", features = ["derive"] } -logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" } -logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" } -logos-blockchain-codec = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" } -logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" } -logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" } -logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" } -logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" } -logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "e2a1c3b7ef2191c224f998b94332c5926c789f9d" } +logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +logos-blockchain-codec = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } +logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "97d5e104fc20ce6e2fb404f8a9364afcb181a3e5" } keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "9535a657ba04b1e6916de51777e22b4837c1a84d" } diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index 412f58b80..0d9e312c7 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 98969806c..e541359e5 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 b6ed736d7..532694b14 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 9c2c3b91f..789a70005 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 04626a0dd..3b832058a 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/clock.bin b/artifacts/lez/programs/clock.bin index 1e3bc48a4..aabe59be9 100644 Binary files a/artifacts/lez/programs/clock.bin and b/artifacts/lez/programs/clock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index 2d69904d3..649ff500b 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 e9ead7b36..ef51d4858 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 b87cd0818..9debd7146 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 4b5f9b6c8..360a7b2da 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 16bb21bef..699fcd7c0 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 c5f5e3f38..cd242c58e 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 d9b1fb66a..c7935ee7f 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/sequencer_stake.bin b/artifacts/lez/programs/sequencer_stake.bin new file mode 100644 index 000000000..c179dfbb7 Binary files /dev/null and b/artifacts/lez/programs/sequencer_stake.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index baab01244..aba29e5c2 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 cf2e5d54e..608c9dee4 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 416cb2b5b..ce3ea46aa 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/bedrock/deployment-settings.yaml b/bedrock/deployment-settings.yaml index 005beeb4d..20514a0f2 100644 --- a/bedrock/deployment-settings.yaml +++ b/bedrock/deployment-settings.yaml @@ -41,7 +41,7 @@ cryptarchia: version: Bedrock parent_block: '0000000000000000000000000000000000000000000000000000000000000000' slot: 0 - block_root: cb5951ac1ffa1aa5d0e585fb54e784bd9c025b28d752324e98b3837f34648692 + block_root: '379bace32490b92ea47583b9aad4a32b059312a920d45b3b7386cffbc2737e18' proof_of_leadership: proof: '0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' entropy_contribution: '0000000000000000000000000000000000000000000000000000000000000000' @@ -153,6 +153,8 @@ cryptarchia: pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' - value: 100000 pk: '6b2bcd3029fba573cff0c332dc4de7430faf5e261383d693d8dbb5b97665660a' + - value: 1000000000000 + pk: ed266e6e887b9b97059dc1aa1b7b2e19b934291753c6336a163fe4ebaa28e717 - value: 18446744073709551615 pk: c2a6a4a0981d5bdcf8ddeb8d7934fd8c5510efeb1053f613b45871670b6f7b19 - opcode: 17 @@ -168,7 +170,7 @@ cryptarchia: - /ip4/65.109.51.37/udp/3400/quic-v1 provider_id: '59c662860b737f4e2515599adb3434856db8070b373a449ff66955ad3da6b473' zk_id: '6b2bcd3029fba573cff0c332dc4de7430faf5e261383d693d8dbb5b97665660a' - locked_note_id: '7e449a14172fc90679f6fca7b49a2d58c305ebf7ac42ef20202e533c31115222' + locked_note_id: '67e2ec02c536a82f0eb80800fcfb8f2d66c5423d57cd081dfb0f237234f6ac26' ops_proofs: - !ZkSig pi_a: '0000000000000000000000000000000000000000000000000000000000000000' diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 8e4991b75..1037c803b 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -32,10 +32,14 @@ wallet-ffi.workspace = true indexer_ffi.workspace = true indexer_service_protocol.workspace = true system_accounts.workspace = true +sequencer_stake_core.workspace = true programs.workspace = true test_programs.workspace = true testnet_initial_state.workspace = true +logos-blockchain-core.workspace = true +logos-blockchain-zone-sdk.workspace = true +logos-blockchain-key-management-system-service.workspace = true anyhow.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 6e300672d..b9c8d41ae 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -9,12 +9,10 @@ //! 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. //! -//! Not production-safe. The inbox allowlist gates the target program, not the -//! source emitter, and `extract_emission` recognizes any known emitter, so in a -//! zone that allows `wrapped_token` as a target a permissionless `ping_sender` -//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe -//! needs source verification, where a value-bearing target checks the message -//! originated from `bridge_lock`; that is out of scope for the demo. +//! A `ping_sender` send carrying a `wrapped_token::Mint` is refused as long as no +//! operator writes a `(ping_sender, wrapped_token)` route: the allowlist is a +//! source-and-target pair. Nothing forbids writing that route, and the token +//! still trusts the table rather than checking its own sources, which is #673. use std::time::Duration; @@ -163,14 +161,14 @@ fn build_lock_tx( target_program_id: wrapped_token_id, target_accounts, payload, - outbox_program_id: outbox_id, ordinal, }; let accounts = vec![ + bridge_lock_core::config_account_id(bridge_lock_id), holder_id, bridge_lock_core::escrow_account_id(bridge_lock_id), - outbox_pda(outbox_id, &target_zone, ordinal), + outbox_pda(outbox_id, bridge_lock_id, &target_zone, ordinal), ]; // One nonce per signature: the holder signs, at its genesis nonce 0. let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock) diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index 5fa32bae1..fb00410d2 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -19,7 +19,10 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::config::{self, SequencerPartialConfig}; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use test_fixtures::{ @@ -119,18 +122,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 119d09cb2..47027eb2d 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -10,11 +10,9 @@ //! `outbox::Emit`). Fast, so they pin guest logic before the e2e exercises the //! plumbing. Run with `RISC0_DEV_MODE=1`. -use std::collections::BTreeMap; - use cross_zone_inbox_core::{ - CrossZoneMessage, CrossZoneRoute, InboxConfig, Instruction as InboxInstruction, SeenShard, - inbox_config_account_id, inbox_seen_shard_account_id, + CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard, + inbox_config_account_id, inbox_seen_shard_account_id, inbox_source_marker_account_id, }; use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; use lee::{ @@ -22,7 +20,10 @@ use lee::{ public_transaction::{Message, WitnessSet}, }; use lee_core::account::Account; -use ping_core::{ReceiverInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, outbox_bytes, ping_record_pda, read_outbox, receiver_config_account_id, + sender_config_account_id, +}; const INITIAL_BALANCE: u128 = 100; const LOCK_AMOUNT: u128 = 30; @@ -35,33 +36,17 @@ fn base_state() -> V03State { V03State::new().with_programs([ programs::cross_zone_inbox(), programs::cross_zone_outbox(), + programs::ping_sender(), programs::ping_receiver(), programs::bridge_lock(), programs::wrapped_token(), ]) } -/// Seeds an inbox config (inbox-owned) allowing `src_zone -> target`. -fn seed_inbox_config( - state: &mut V03State, - self_zone: [u8; 32], - src_zone: [u8; 32], - src_program_id: lee_core::program::ProgramId, - target: lee_core::program::ProgramId, -) { +/// Seeds the inbox config (inbox-owned), which is now just this zone's id. +fn seed_inbox_config(state: &mut V03State, self_zone: [u8; 32]) { let inbox_id = programs::cross_zone_inbox().id(); - let mut allowed_routes = BTreeMap::new(); - allowed_routes.insert( - src_zone, - vec![CrossZoneRoute { - src_program_id, - target_program_id: target, - }], - ); - let config = InboxConfig { - self_zone, - allowed_routes, - }; + let config = InboxConfig { self_zone }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( inbox_config_account_id(inbox_id), Account { @@ -76,23 +61,130 @@ fn seed_inbox_config( )]); } -/// Seeds the wrapped-token config account pinning the inbox as authorized minter, -/// matching what genesis seeds for a real zone. -fn seed_wrapped_config(state: &mut V03State) { +/// Seeds the wrapped-token config pinning the inbox as minter and `sources` as the +/// peer pairs it will mint for, matching what genesis seeds for a real zone. +fn seed_wrapped_config( + state: &mut V03State, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>, +) { let wrapped_token_id = programs::wrapped_token().id(); + let config = wrapped_token_core::WrappedTokenConfig { + minter: programs::cross_zone_inbox().id(), + sources, + }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( wrapped_token_core::config_account_id(wrapped_token_id), Account { program_owner: wrapped_token_id, - data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id()) - .to_vec() + data: config + .to_bytes() .try_into() - .expect("minter id fits in account data"), + .expect("wrapped-token config fits in account data"), ..Default::default() }, )]); } +/// Seeds the ping-receiver config pinning the inbox as deliverer and `sources` as +/// the peer pairs it accepts a delivery from. +fn seed_receiver_config( + state: &mut V03State, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>, +) { + let receiver_id = programs::ping_receiver().id(); + let config = ping_core::ReceiverConfig { + deliverer: programs::cross_zone_inbox().id(), + sources, + }; + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + receiver_config_account_id(receiver_id), + Account { + program_owner: receiver_id, + data: config + .to_bytes() + .try_into() + .expect("receiver config fits in account data"), + ..Default::default() + }, + )]); +} + +/// Seeds the ping-sender config account pinning the real outbox, matching what +/// genesis seeds for a real zone. +fn seed_ping_sender_config(state: &mut V03State) { + let sender_id = programs::ping_sender().id(); + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + sender_config_account_id(sender_id), + Account { + program_owner: sender_id, + data: outbox_bytes(programs::cross_zone_outbox().id()) + .to_vec() + .try_into() + .expect("outbox id fits in account data"), + ..Default::default() + }, + )]); +} + +/// Seeds the bridge-lock config account pinning the real outbox and the wrapped +/// token, matching what genesis seeds for a real zone. +fn seed_bridge_lock_config(state: &mut V03State) { + let bridge_lock_id = programs::bridge_lock().id(); + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + bridge_lock_core::config_account_id(bridge_lock_id), + Account { + program_owner: bridge_lock_id, + data: bridge_lock_core::config_bytes( + programs::cross_zone_outbox().id(), + programs::wrapped_token().id(), + ) + .to_vec() + .try_into() + .expect("pinned ids fit in account data"), + ..Default::default() + }, + )]); +} + +/// The account list a dispatch declares, mirroring `cross_zone::build_inbox_dispatch_tx`: +/// config, seen shard, source marker, then the target's own accounts. +fn dispatch_accounts( + inbox_id: lee_core::program::ProgramId, + msg: &CrossZoneMessage, + targets: Vec, +) -> Vec { + let mut ids = vec![ + inbox_config_account_id(inbox_id), + inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id), + inbox_source_marker_account_id(inbox_id, &msg.src_zone, msg.src_program_id), + ]; + ids.extend(targets); + ids +} + +/// A `ping_sender::Send` carrying `payload` to `target_zone`, over the accounts +/// given rather than the correct ones, so tests can vary them. +fn send_tx(accounts: Vec, target_zone: [u8; 32], ordinal: u32) -> PublicTransaction { + let receiver_id = programs::ping_receiver().id(); + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: b"ping".to_vec(), + }) + .expect("serialize ping instruction"); + let send = ping_core::SenderInstruction::Send { + target_zone, + target_program_id: receiver_id, + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], + payload: words.iter().flat_map(|word| word.to_le_bytes()).collect(), + ordinal, + }; + let message = Message::try_new(programs::ping_sender().id(), accounts, vec![], send) + .expect("build ping_sender message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) +} + /// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone /// payload (risc0 words, little-endian bytes). fn mint_payload() -> Vec { @@ -117,14 +209,8 @@ fn dispatch_mint(amount: u128) -> Result Result PublicTransaction { + let wrapped_token_id = programs::wrapped_token().id(); + lock_tx_to( + holder_key, + holder_id, + zone_b, + ordinal, + nonce, + wrapped_token_id, + mint_target_accounts(wrapped_token_id), + ) +} + +/// The mint's own account list: the wrapped-token config, then the recipient's +/// holding. What `wrapped_token::Mint` requires on the destination zone. +fn mint_target_accounts(wrapped_token_id: lee_core::program::ProgramId) -> Vec<[u8; 32]> { + vec![ + wrapped_token_core::config_account_id(wrapped_token_id).into_value(), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(), + ] +} + +/// The same lock aimed at `target_program_id` over `target_accounts`, so a test +/// can vary what the destination would be asked to do. +fn lock_tx_to( + holder_key: &PrivateKey, + holder_id: AccountId, + zone_b: [u8; 32], + ordinal: u32, + nonce: u128, + target_program_id: lee_core::program::ProgramId, + target_accounts: Vec<[u8; 32]>, +) -> PublicTransaction { + let bridge_lock_id = programs::bridge_lock().id(); + let outbox_id = programs::cross_zone_outbox().id(); + + let lock = bridge_lock_core::Instruction::Lock { + amount: LOCK_AMOUNT, + target_zone: zone_b, + target_program_id, + target_accounts, + payload: mint_payload(), + ordinal, + }; + let message = Message::try_new( + bridge_lock_id, + vec![ + bridge_lock_core::config_account_id(bridge_lock_id), + holder_id, + bridge_lock_core::escrow_account_id(bridge_lock_id), + outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal), + ], + vec![nonce.into()], + lock, + ) + .expect("build lock message"); + let witness = WitnessSet::for_message(&message, &[holder_key]); + PublicTransaction::new(message, witness) +} + +/// A slot holds one message for ever, so a second emission into it fails rather +/// than replacing the record. Without this a later emitter silently destroys an +/// earlier one, and for a bridge that means an escrow with no record of what it +/// was for. +#[test] +fn a_second_emit_at_the_same_slot_is_rejected() { + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: programs::bridge_lock().id(), + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + let first = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0); + let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0) + .expect("the first lock executes"); + state.apply_state_diff(diff); + + // Same slot, fresh nonce, so the only thing that can reject it is the slot + // already holding a record. Matched on the guest's own message rather than + // any error, or a future change that rejected it earlier for an unrelated + // reason would keep this passing. + let second = lock_tx(&holder_key, holder_id, zone_b, ordinal, 1); + let Err(err) = ValidatedStateDiff::from_public_transaction(&second, &state, 2, 0) else { + panic!("a second emission into a written slot must not execute"); + }; + assert!( + format!("{err:?}").contains("Outbox slot already written"), + "rejected for the wrong reason: {err:?}" + ); + + // Control: the same second lock into a fresh ordinal executes, so the + // refusal above is the slot and not the transaction's shape. + let elsewhere = lock_tx(&holder_key, holder_id, zone_b, ordinal + 1, 1); + ValidatedStateDiff::from_public_transaction(&elsewhere, &state, 2, 0) + .expect("a lock into an unwritten slot executes"); +} + +/// Two programs emitting to one zone and ordinal address two different slots, +/// so neither can overwrite or block the other. +#[test] +fn two_emitters_share_an_ordinal_without_colliding() { + let outbox_id = programs::cross_zone_outbox().id(); + let sender_id = programs::ping_sender().id(); + let bridge_lock_id = programs::bridge_lock().id(); + let receiver_id = programs::ping_receiver().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_ping_sender_config(&mut state); + seed_bridge_lock_config(&mut state); + + let lock_slot = outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal); + let send_slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); + assert_ne!( + lock_slot, send_slot, + "the same zone and ordinal under two emitters are two slots" + ); + + let lock = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0); + let diff = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) + .expect("the lock executes"); + state.apply_state_diff(diff); + + let send = send_tx( + vec![sender_config_account_id(sender_id), send_slot], + zone_b, + ordinal, + ); + let send_diff = ValidatedStateDiff::from_public_transaction(&send, &state, 2, 0) + .expect("the send executes into its own slot, not the lock's"); + + let record = OutboxRecord::from_bytes( + &send_diff.public_diff()[&send_slot] + .data + .clone() + .into_inner(), + ) + .expect("outbox PDA holds an OutboxRecord"); + assert_eq!(record.emitter, sender_id); + assert_eq!(record.target_program_id, receiver_id); + + // And the lock's own slot is untouched by it. + let lock_record = + OutboxRecord::from_bytes(&state.get_account_by_id(lock_slot).data.into_inner()) + .expect("the lock's record survives"); + assert_eq!(lock_record.emitter, bridge_lock_id); +} + +/// A caller can no longer aim an emission at a program of their own and still +/// succeed, leaving no record of it. With the program no longer an instruction +/// field, the account is the only way left to try. +#[test] +fn a_send_into_a_foreign_outbox_slot_is_rejected() { + let sender_id = programs::ping_sender().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let mut state = base_state(); + seed_ping_sender_config(&mut state); + + // A slot under some other program, which is what the caller would have to + // pass to reach it. + let foreign_slot = outbox_pda([3; 8], sender_id, &zone_b, ordinal); + let send = send_tx( + vec![sender_config_account_id(sender_id), foreign_slot], + zone_b, + ordinal, + ); + + // Refused inside the pinned outbox, not by the sender: the chained call goes + // there whatever account the caller passes, which is the point. + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send into a slot outside the pinned outbox must not execute"); + }; + assert!( + format!("{err:?}").contains("Account must be the outbox PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Nothing releases an escrow, so a message the destination will refuse is a +/// burn: debited here, never minted there. The refusal has to come before the +/// debit. +#[test] +fn a_lock_naming_another_target_program_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + let elsewhere = programs::ping_receiver().id(); + let lock = lock_tx_to( + &holder_key, + holder_id, + zone_b, + 0, + 0, + elsewhere, + mint_target_accounts(elsewhere), + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock aimed at another program must not execute"); + }; + assert!( + format!("{err:?}").contains("only mints through the wrapped token it is pinned to"), + "rejected for the wrong reason: {err:?}" + ); + assert_eq!( + state.get_account_by_id(holder_id).balance, + INITIAL_BALANCE, + "a refused lock leaves the holder's balance alone" + ); +} + +/// The same burn by a different route: the right target program, the wrong +/// accounts for it. `wrapped_token::Mint` fails its own address asserts on the +/// destination, so the escrow has to be refused here instead. +#[test] +fn a_lock_naming_other_mint_accounts_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + // A holding under someone other than the payload's recipient: a mint the + // destination would credit to the wrong account if it credited it at all. + let other_holding = + wrapped_token_core::holding_account_id(wrapped_token_id, &[4; 32]).into_value(); + let lock = lock_tx_to( + &holder_key, + holder_id, + zone_b, + 0, + 0, + wrapped_token_id, + vec![ + wrapped_token_core::config_account_id(wrapped_token_id).into_value(), + other_holding, + ], + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock over the wrong mint accounts must not execute"); + }; + assert!( + format!("{err:?}").contains("target accounts must be the mint's config"), + "rejected for the wrong reason: {err:?}" + ); + assert_eq!( + state.get_account_by_id(holder_id).balance, + INITIAL_BALANCE, + "a refused lock leaves the holder's balance alone" + ); +} + +/// The config is read by address, so substituting another account for it fails +/// rather than reading the pins out of whatever that account holds. Without the +/// address check, 64 bytes a caller controls would re-pin both for one lock. +#[test] +fn a_lock_with_a_substituted_config_account_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + // A bridge-lock-owned account holding pins of the caller's choosing, so only + // the address check stands between it and being read as the config. + let decoy_key = PrivateKey::try_new([8; 32]).expect("valid key"); + let decoy_id = AccountId::from(&PublicKey::new_from_private_key(&decoy_key)); + let mut state = base_state().with_public_accounts([ + ( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + ), + ( + decoy_id, + Account { + program_owner: bridge_lock_id, + data: bridge_lock_core::config_bytes([3; 8], [4; 8]) + .to_vec() + .try_into() + .expect("pinned ids fit in account data"), + ..Default::default() + }, + ), + ]); + seed_bridge_lock_config(&mut state); + + let lock = bridge_lock_core::Instruction::Lock { + amount: LOCK_AMOUNT, + target_zone: zone_b, + target_program_id: wrapped_token_id, + target_accounts: mint_target_accounts(wrapped_token_id), + payload: mint_payload(), + ordinal, + }; + let message = Message::try_new( + bridge_lock_id, + vec![ + decoy_id, + holder_id, + bridge_lock_core::escrow_account_id(bridge_lock_id), + outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal), + ], + vec![0_u128.into()], + lock, + ) + .expect("build lock message"); + let tx = PublicTransaction::new( + message.clone(), + WitnessSet::for_message(&message, &[&holder_key]), + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a lock over a substituted config account must not execute"); + }; + assert!( + format!("{err:?}").contains("must be the bridge-lock config PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// A bridge with no pin cannot fall back to caller-named programs: it stops +/// locking. The state a zone reaches by skipping the genesis init. +#[test] +fn a_lock_before_the_pins_are_set_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + + let lock = lock_tx(&holder_key, holder_id, zone_b, 0, 0); + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock with nothing pinned must not execute"); + }; + assert!( + format!("{err:?}").contains("config account holds an outbox and a mint target"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Written once, on the same terms as the sender's: an identical re-init is the +/// genesis replay, a different one would redirect every lock on the zone. +#[test] +fn the_bridge_pins_are_written_once_and_replayable() { + let bridge_lock_id = programs::bridge_lock().id(); + let config_id = bridge_lock_core::config_account_id(bridge_lock_id); + let outbox_id = programs::cross_zone_outbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + + let init = |outbox: lee_core::program::ProgramId, target: lee_core::program::ProgramId| { + let message = Message::try_new( + bridge_lock_id, + vec![config_id], + vec![], + bridge_lock_core::Instruction::InitConfig { + outbox_program_id: outbox, + target_program_id: target, + }, + ) + .expect("build InitConfig message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) + }; + + let mut state = base_state(); + + let diff = ValidatedStateDiff::from_public_transaction( + &init(outbox_id, wrapped_token_id), + &state, + 1, + 0, + ) + .expect("the first init claims the config PDA"); + state.apply_state_diff(diff); + assert_eq!( + bridge_lock_core::read_config(&state.get_account_by_id(config_id).data.into_inner()), + Some((outbox_id, wrapped_token_id)), + "the config pins both programs after genesis" + ); + + ValidatedStateDiff::from_public_transaction(&init(outbox_id, wrapped_token_id), &state, 2, 0) + .expect("replaying the identical init is a no-op, not a failure"); + + // Either half moving is a redirect: the outbox decides whether the emission is + // recorded, the target where the value lands. + for (outbox, target, what) in [ + ([3; 8], wrapped_token_id, "outbox"), + (outbox_id, [3; 8], "mint target"), + ] { + let Err(err) = + ValidatedStateDiff::from_public_transaction(&init(outbox, target), &state, 3, 0) + else { + panic!("a re-init naming a different {what} must not execute"); + }; + assert!( + format!("{err:?}").contains("already pins a different outbox or mint target"), + "rejected for the wrong reason: {err:?}" + ); + } +} + +/// An emitter with no pin cannot fall back to a caller-named outbox: it stops +/// emitting. The state a zone reaches by skipping the genesis init. +#[test] +fn a_send_before_the_pin_is_set_is_rejected() { + let sender_id = programs::ping_sender().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let state = base_state(); + let slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); + let send = send_tx( + vec![sender_config_account_id(sender_id), slot], + zone_b, + ordinal, + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send with no outbox pinned must not execute"); + }; + assert!( + format!("{err:?}").contains("config account holds an outbox program id"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// The config is read by address, so substituting another account for it fails +/// rather than pinning the outbox to whatever that account happens to hold. +#[test] +fn a_send_with_a_substituted_config_account_is_rejected() { + let sender_id = programs::ping_sender().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let mut state = base_state(); + seed_ping_sender_config(&mut state); + + let slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); + let send = send_tx(vec![ping_record_pda(sender_id), slot], zone_b, ordinal); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send over a substituted config account must not execute"); + }; + assert!( + format!("{err:?}").contains("must be the ping-sender config PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Written once: an identical re-init has to succeed, since genesis is replayed +/// during multi-sequencer reconstruction, while one naming a different outbox has +/// to fail, or anyone could redirect every emission on the zone after genesis. +#[test] +fn the_outbox_pin_is_written_once_and_replayable() { + let sender_id = programs::ping_sender().id(); + let config_id = sender_config_account_id(sender_id); + + // Unsigned and nonce-free, as genesis builds it: the config PDA has no signer. + let init = |outbox: lee_core::program::ProgramId| { + let message = Message::try_new( + sender_id, + vec![config_id], + vec![], + ping_core::SenderInstruction::InitConfig { + outbox_program_id: outbox, + }, + ) + .expect("build InitConfig message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) + }; + + let mut state = base_state(); + let outbox_id = programs::cross_zone_outbox().id(); + + let first = init(outbox_id); + let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0) + .expect("the first init claims the config PDA"); + state.apply_state_diff(diff); + assert_eq!( + read_outbox(&state.get_account_by_id(config_id).data.into_inner()), + Some(outbox_id), + "the config pins the outbox after genesis" + ); + + ValidatedStateDiff::from_public_transaction(&init(outbox_id), &state, 2, 0) + .expect("replaying the identical init is a no-op, not a failure"); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&init([3; 8]), &state, 3, 0) else { + panic!("a re-init naming a different outbox must not execute"); + }; + assert!( + format!("{err:?}").contains("already pins a different outbox"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// A token that authorizes nothing mints for nobody. The state a zone reaches with +/// no peers configured, where the config is still seeded so its PDA cannot be +/// claimed by a first initializer. +#[test] +fn a_mint_is_refused_when_the_token_authorizes_no_source() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![]); + + let msg = CrossZoneMessage { + src_zone, + src_block_id: 5, + src_block_hash: SRC_BLOCK_HASH, + src_tx_index: 0, + src_program_id: programs::bridge_lock().id(), + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + let message = Message::try_new( + inbox_id, + dispatch_accounts( + inbox_id, + &msg, + vec![ + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + ), + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a token authorizing nothing must not mint"); + }; + assert!( + format!("{err:?}").contains("peer source this token authorizes"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// The marker only means something because the caller is pinned to the inbox. +/// Invoked directly, with the caller handing in the marker themselves, the mint +/// must refuse before it ever looks at it. +#[test] +fn a_top_level_mint_is_refused() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let src_zone = [2_u8; 32]; + let src_program_id = programs::bridge_lock().id(); + + let mut state = base_state(); + seed_wrapped_config(&mut state, vec![(src_zone, src_program_id)]); + + let marker_id = inbox_source_marker_account_id(inbox_id, &src_zone, src_program_id); + let message = Message::try_new( + wrapped_token_id, + vec![ + marker_id, + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + vec![], + wrapped_token_core::Instruction::Mint { + recipient: RECIPIENT, + amount: LOCK_AMOUNT, + }, + ) + .expect("build mint message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a directly invoked mint must not execute"); + }; + assert!( + format!("{err:?}").contains("only callable by the authorized minter"), + "rejected for the wrong reason: {err:?}" + ); +} + /// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject) /// and asserts it chains into `wrapped_token::Mint`, crediting the recipient. #[test] @@ -328,12 +1054,11 @@ fn inbox_dispatch_mints_wrapped_token() { ); } -/// A zone that bridges must allow `wrapped_token` as a target. When that -/// allowance was per peer rather than per source program, it was enough for any -/// emitter on the peer to reach it, and `ping_sender` lets its caller choose the -/// target and payload freely. Any user on the peer could therefore mint wrapped -/// tokens with no lock and no escrow behind them, by routing a `Mint` payload -/// through the ping emitter. The route is the pair, so this must not execute. +/// `ping_sender` lets its caller choose the target and payload freely, so any user +/// on a peer can aim a `Mint` payload at `wrapped_token`. The inbox no longer +/// refuses it; the token does, because the marker names `ping_sender` and the +/// token authorized only the bridge. This is the check that replaced the central +/// route table, so it must be the thing that rejects here. #[test] fn a_mint_from_an_unrouted_emitter_is_rejected() { let inbox_id = programs::cross_zone_inbox().id(); @@ -345,14 +1070,8 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() { let mut state = base_state(); // The config a bridging zone writes: the lock program may mint, nothing else. - seed_inbox_config( - &mut state, - self_zone, - src_zone, - programs::bridge_lock().id(), - wrapped_token_id, - ); - seed_wrapped_config(&mut state); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, programs::bridge_lock().id())]); let msg = CrossZoneMessage { src_zone, @@ -366,27 +1085,24 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() { l1_inclusion_witness: None, }; - let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); let message = Message::try_new( inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a delivery from a source the token did not authorize must not mint"); + }; assert!( - ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0).is_err(), - "a delivery from an emitter with no route to wrapped_token must not mint" + format!("{err:?}").contains("peer source this token authorizes"), + "rejected for the wrong reason: {err:?}" ); } @@ -404,14 +1120,8 @@ fn a_mint_from_the_routed_emitter_is_accepted() { let src_block_id = 5; let mut state = base_state(); - seed_inbox_config( - &mut state, - self_zone, - src_zone, - bridge_lock_id, - wrapped_token_id, - ); - seed_wrapped_config(&mut state); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, programs::bridge_lock().id())]); let msg = CrossZoneMessage { src_zone, @@ -424,18 +1134,12 @@ fn a_mint_from_the_routed_emitter_is_accepted() { l1_inclusion_witness: None, }; - let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); let message = Message::try_new( inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) @@ -464,14 +1168,8 @@ fn mint_replay_rejected() { let src_tx_index = 0; let mut state = base_state(); - seed_inbox_config( - &mut state, - self_zone, - src_zone, - [9_u32; 8], - wrapped_token_id, - ); - seed_wrapped_config(&mut state); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, [9_u32; 8])]); // Seed the seen-shard as already holding this delivery, so the inbox takes // the replay no-op branch. The shard is inbox-owned (claimed on a prior @@ -509,12 +1207,7 @@ fn mint_replay_rejected() { let message = Message::try_new( inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) @@ -555,7 +1248,8 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { let other_block_hash = [8_u8; 32]; let mut state = base_state(); - seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id); + seed_inbox_config(&mut state, self_zone); + seed_receiver_config(&mut state, vec![(src_zone, [9_u32; 8])]); // The shard as the first delivery left it: bound, holding transaction 0. let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); @@ -596,7 +1290,11 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { let record_id = ping_record_pda(receiver_id); let message = Message::try_new( inbox_id, - vec![inbox_config_account_id(inbox_id), seen_id, record_id], + dispatch_accounts( + inbox_id, + &msg, + vec![receiver_config_account_id(receiver_id), record_id], + ), vec![], InboxInstruction::Dispatch(msg), ) @@ -629,7 +1327,11 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { }; let control_message = Message::try_new( inbox_id, - vec![inbox_config_account_id(inbox_id), seen_id, record_id], + dispatch_accounts( + inbox_id, + &control_msg, + vec![receiver_config_account_id(receiver_id), record_id], + ), vec![], InboxInstruction::Dispatch(control_msg), ) diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index b1dd28cad..21cb4e102 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -20,7 +20,10 @@ use integration_tests::{ }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::RpcClient as _; use test_fixtures::{ @@ -112,18 +115,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs index 86dfc7059..e30875201 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -25,7 +25,10 @@ use integration_tests::{ }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -190,18 +193,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/integration_tests/tests/gossip.rs b/integration_tests/tests/gossip.rs new file mode 100644 index 000000000..3627d4cee --- /dev/null +++ b/integration_tests/tests/gossip.rs @@ -0,0 +1,129 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Gossip end-to-end: two sequencers share one channel with p2p gossip on, +//! and B's block production is disabled. A transfer submitted to B's RPC can +//! therefore only be included if gossip hands it to A — without gossip it +//! would sit in B's mempool forever and the test times out. + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use integration_tests::config::{self, SequencerPartialConfig}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; +use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; +use tokio::test; + +const PHASE_TIMEOUT: Duration = Duration::from_secs(360); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const TRANSFER_AMOUNT: u128 = 10; + +#[test] +async fn gossiped_transaction_reaches_producing_sequencer() -> Result<()> { + let bedrock_channel_id = config::bedrock_channel_id(); + let partial = SequencerPartialConfig { + block_create_timeout: Duration::from_secs(5), + ..SequencerPartialConfig::default() + }; + // B never produces: its production timer is longer than the test, so its + // local mempool is a dead end and inclusion proves gossip delivery to A. + let follower_partial = SequencerPartialConfig { + block_create_timeout: Duration::from_secs(100_000), + ..SequencerPartialConfig::default() + }; + + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 2, + bedrock_channel: bedrock_channel_id, + }) + .disable_wallet() + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_follower_sequencer_partial_config(follower_partial) + .with_gossip() + .with_genesis(vec![]), + ) + .build() + .await?; + + let mut seq_iterator = ctx.sequencer_components_iter(bedrock_channel_id).unwrap(); + + let seq_client_a = &(seq_iterator.next().unwrap().sequencer_client); + let seq_client_b = &(seq_iterator.next().unwrap().sequencer_client); + + wait_for_height(seq_client_a, 2, "sequencer A to produce past genesis").await?; + + // B follows the chain via L1 even though it never produces. + let sync_target = seq_client_a.get_last_block_id().await?; + wait_for_height(seq_client_b, sync_target, "B to sync to A's height").await?; + + 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 = seq_client_a.get_account_balance(to).await?; + let nonce = seq_client_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, + ); + seq_client_b + .send_transaction(tx) + .await + .context("Failed to submit the transfer to B")?; + + // Only A produces, so the balance changing on A proves the transaction + // crossed the gossip mesh from B. + wait_for_balance(seq_client_a, to, to_balance_before + TRANSFER_AMOUNT).await?; + + Ok(()) +} + +/// Polls the sequencer until its chain height reaches `target`. +async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> { + log::info!("Waiting for {what:?}, target is {target}"); + + 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<()> { + log::info!("Waiting for {account} to have {expected} tokens"); + + 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 gossiped transfer to be included by A")? +} diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs index ee2cde05e..11211e072 100644 --- a/integration_tests/tests/multi_sequencer.rs +++ b/integration_tests/tests/multi_sequencer.rs @@ -3,8 +3,8 @@ 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 +//! Two sequencers share one channel: both are staked at genesis, so the channel +//! is created already accrediting `[A, B]`, B syncs the chain A began, both //! produce on their turns, and A, B and an indexer converge on the same chain. use std::time::Duration; @@ -13,7 +13,12 @@ use anyhow::{Context as _, Result, ensure}; use indexer_service_rpc::RpcClient as _; use integration_tests::{ config::{self, SequencerPartialConfig}, - indexer_client::IndexerClient, + init_logger, +}; +use logos_blockchain_key_management_system_service::keys::Ed25519Key; +use sequencer_core::{ + block_publisher::{Ed25519PublicKey, read_channel_state}, + config::BedrockConfig, }; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use test_fixtures::{ @@ -25,12 +30,14 @@ use tokio::test; 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). +/// ≈4 turn windows, at the `system_accounts` posting timeframe and 5 s blocks. const ROTATION_BLOCKS: u64 = 8; #[test] async fn multi_sequencer_committee_converges() -> Result<()> { - let bedrock_channel_id = config::bedrock_channel_id(); + init_logger(); + + let channel = config::bedrock_channel_id(); let partial = SequencerPartialConfig { block_create_timeout: Duration::from_secs(5), ..SequencerPartialConfig::default() @@ -40,73 +47,70 @@ async fn multi_sequencer_committee_converges() -> Result<()> { .with_zone( ZoneTestContextBuilder::new(MultiNodeTestContextConfig { num_nodes: 2, - bedrock_channel: bedrock_channel_id, + bedrock_channel: channel, }) .disable_wallet() - .with_sequencer_partial_config(partial) - .with_genesis(vec![]), + .with_sequencer_partial_config(partial), ) .build() - .await?; - - let mut seq_iterator = ctx.sequencer_components_iter(bedrock_channel_id).unwrap(); - - let seq_client_a = &(seq_iterator.next().unwrap().sequencer_client); - let seq_client_b = &(seq_iterator.next().unwrap().sequencer_client); + .await + .context("Failed to build the two-sequencer test context")?; + let a = ctx + .sequencer_client_by_node_ids(channel, 0) + .context("Missing sequencer A")?; + let b = ctx + .sequencer_client_by_node_ids(channel, 1) + .context("Missing sequencer B")?; let indexer = ctx.indexer_client(); - wait_for_height(seq_client_a, 2, "sequencer A to produce past genesis").await?; + let pub_a = Ed25519Key::from_bytes(&config::SEQUENCER_SIGNING_KEY).public_key(); + let pub_b = Ed25519Key::from_bytes(&config::sequencer_signing_key_from_seed(1)).public_key(); - log::info!("Passed wait for height A to be at least 2"); + let bedrock_config = BedrockConfig { + channel_id: channel, + node_url: config::addr_to_url(config::UrlProtocol::Http, ctx.bedrock_addr())?, + funding_key: config::bedrock_funding_key(), + auth: None, + priority_fee: sequencer_core::config::default_priority_fee(), + }; - let height_at_config = seq_client_a.get_last_block_id().await?; - wait_for_height( - seq_client_a, - height_at_config + 1, - "A to produce after the roster change", - ) + // Phase 1: both keys accredited from channel creation. + let mut want = vec![pub_a.to_bytes(), pub_b.to_bytes()]; + want.sort_unstable(); + wait_until("Bedrock to accredit both staked keys", || async { + Ok(committee(&bedrock_config).await?.0 == want) + }) .await?; - log::info!( - "Passed wait for height A to be at least {}", - height_at_config + 1 - ); + // Phase 2: B follows the chain A began. + let join_height = a.get_last_block_id().await?.max(1); + wait_for_height(b, join_height, "B to sync to A's height").await?; - let join_height = seq_client_a.get_last_block_id().await?; - wait_for_height(seq_client_b, join_height, "B to sync to A's height at join").await?; - - log::info!("Passed wait for height B to be at least {join_height}"); - - // Phase 4: rotation + convergence over ≈4 turn windows. + // Phase 3: rotation + convergence; without the turn check, a chain A + // produces alone satisfies everything below. let rotation_target = join_height + ROTATION_BLOCKS; wait_for_height( - seq_client_a, + a, rotation_target, "the chain to advance across turn windows", ) .await?; - - log::info!("Passed wait for height A to be at least {rotation_target}"); - - wait_for_height( - seq_client_b, - rotation_target, - "B to follow across turn windows", - ) + wait_for_height(b, rotation_target, "B to follow across turn windows").await?; + wait_until("the round-robin turn to reach B", || async { + Ok(committee(&bedrock_config).await?.1 == Some(pub_b)) + }) .await?; - assert_same_chain(seq_client_a, seq_client_b).await?; + assert_same_chain(a, b).await?; - log::info!("Passed wait for height B to be at least {rotation_target}"); - - // Phase 5: a tx submitted only to B is included by B and visible on A. + // Phase 4: 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 = seq_client_a.get_account_balance(to).await?; - let nonce = seq_client_b.get_accounts_nonces(vec![from]).await?[0]; + 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, @@ -114,30 +118,28 @@ async fn multi_sequencer_committee_converges() -> Result<()> { TRANSFER_AMOUNT, &sign_key, ); - seq_client_b - .send_transaction(tx) + b.send_transaction(tx) .await .context("Failed to submit the transfer to B")?; - wait_for_balance(seq_client_a, to, to_balance_before + TRANSFER_AMOUNT).await?; - - log::info!( - "Passed wait for height balance {to} to be {}", - to_balance_before + TRANSFER_AMOUNT - ); - - // Phase 6: the indexer finalizes the same chain, with no stall. - wait_for_finalized(indexer, join_height).await?; - - log::info!("Passed indexer to see finalized {join_height}"); + let expected = to_balance_before + TRANSFER_AMOUNT; + wait_until("the cross-sequencer transfer to reach A", || async { + Ok(a.get_account_balance(to).await? == expected) + }) + .await?; + // Phase 5: the indexer finalizes the same chain, with no stall. + wait_until("the indexer to finalize", || async { + Ok(indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= 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 = seq_client_a + let block_a = a .get_block(id) .await? .with_context(|| format!("A is missing block {id}"))?; @@ -156,59 +158,47 @@ async fn multi_sequencer_committee_converges() -> Result<()> { Ok(()) } +/// Polls `check` until it reports ready, failing with `what` on timeout. +async fn wait_until(what: &str, mut check: F) -> Result<()> +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let wait = async { + while !check().await? { + tokio::time::sleep(POLL_INTERVAL).await; + } + Ok::<(), anyhow::Error>(()) + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .with_context(|| format!("Timed out waiting for {what}"))? +} + /// Polls the sequencer until its chain height reaches `target`. async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> { - log::info!("Waiting for {what:?}, target is {target}"); - - 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})"))? + wait_until(&format!("{what} (target height {target})"), || async { + Ok(client.get_last_block_id().await? >= target) + }) + .await } -/// Polls the sequencer until `account`'s balance reaches `expected`. -async fn wait_for_balance( - client: &SequencerClient, - account: lee::AccountId, - expected: u128, -) -> Result<()> { - log::info!("Waiting for {account} to have {expected} tokens"); - - let wait = async { - loop { - if client.get_account_balance(account).await? == expected { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(POLL_INTERVAL).await; - } +/// The channel's accredited keys, sorted, plus whose turn the tip was written on. +async fn committee(config: &BedrockConfig) -> Result<(Vec<[u8; 32]>, Option)> { + let Some(state) = read_channel_state(config).await? else { + return Ok((Vec::new(), None)); }; - 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<()> { - log::info!("Waiting for indexer to see target finalized, target is {target}"); - - 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")? + let turn = state + .accredited_keys + .get(usize::from(state.tip_sequencer)) + .copied(); + let mut keys: Vec<_> = state + .accredited_keys + .iter() + .map(Ed25519PublicKey::to_bytes) + .collect(); + keys.sort_unstable(); + Ok((keys, turn)) } /// Asserts A and B hold byte-identical block hashes over their common prefix. diff --git a/integration_tests/tests/sequencer_stake_demo.rs b/integration_tests/tests/sequencer_stake_demo.rs new file mode 100644 index 000000000..9c5733a32 --- /dev/null +++ b/integration_tests/tests/sequencer_stake_demo.rs @@ -0,0 +1,394 @@ +//! End-to-end demo of the sequencer self-join flow. + +#![expect( + clippy::tests_outside_test_module, + reason = "Integration tests live at crate root and don't care about these lints" +)] + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use integration_tests::{account_balance, get_account, new_account}; +use lee::{AccountId, PrivateKey, PublicKey, program::Program}; +use log::info; +use logos_blockchain_core::mantle::ops::channel::Ed25519PublicKey; +use logos_blockchain_key_management_system_service::keys::Ed25519Key; +use logos_blockchain_zone_sdk::{ + CommonHttpClient, + adapter::{Node as _, NodeHttpClient}, +}; +use sequencer_core::config::GenesisAction; +use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, TestContext, ZoneTestContextBuilder, + config::{ + MultiNodeTestContextConfig, SequencerPartialConfig, UrlProtocol, addr_to_url, + bedrock_channel_id, + }, + setup::{SequencerSetup, sequencer_client}, +}; +use tokio::test; +use wallet::AccountIdentity; + +/// Comfortably above `system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE`. +const FUNDING_BALANCE: u128 = 2 * system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + +/// Bedrock signing key of the sequencer that stakes its way in. +const JOINER_SIGNING_KEY: [u8; 32] = [0x42; 32]; + +/// Short block cadence for the demo. +fn fast_blocks() -> SequencerPartialConfig { + SequencerPartialConfig { + block_create_timeout: Duration::from_secs(2), + ..SequencerPartialConfig::default() + } +} + +#[test] +async fn stake_transaction_joins_the_bedrock_committee() -> Result<()> { + let demo_sequencer_key = Ed25519Key::from_bytes(&JOINER_SIGNING_KEY).public_key(); + let demo_stake_key = sequencer_stake_core::SequencerKey::new(demo_sequencer_key.to_bytes()) + .expect("a Bedrock key is a valid Ed25519 public key"); + + let funding_private_key = PrivateKey::new_os_random(); + let funding_id = AccountId::from(&PublicKey::new_from_private_key(&funding_private_key)); + + let mut ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(fast_blocks()) + .with_genesis(vec![GenesisAction::SupplyAccount { + account_id: funding_id, + balance: FUNDING_BALANCE, + }]), + ) + .build() + .await + .context("Failed to build test context")?; + + // Import the funding key directly; it's not one of the wallet's default accounts. + ctx.wallet_mut() + .storage_mut() + .key_chain_mut() + .add_imported_public_account(funding_private_key); + + // Claim the genesis supply out of its vault. + let owner_vault_id = vault_core::compute_vault_account_id(programs::vault().id(), funding_id); + let claim_instruction_data = Program::serialize_instruction(vault_core::Instruction::Claim { + amount: FUNDING_BALANCE, + }) + .context("Failed to serialize vault Claim instruction")?; + ctx.wallet() + .send_pub_tx( + vec![ + AccountIdentity::Public(funding_id), + AccountIdentity::PublicNoSign(owner_vault_id), + ], + claim_instruction_data, + programs::vault().id(), + ) + .await + .map_err(|err| { + anyhow::anyhow!( + "Failed to claim the demo funding account from its genesis vault: {err:?}" + ) + })?; + info!("Waiting for the vault-claim transaction's block to land"); + poll_until("vault claim to land", 30, || async { + Ok(account_balance(&ctx, funding_id).await? == FUNDING_BALANCE) + }) + .await?; + info!("Funded demo account {funding_id} with {FUNDING_BALANCE} native balance"); + + let ownership_id = new_account(&mut ctx, false, None) + .await + .context("Failed to create a fresh stake ownership account")?; + info!("Fresh stake ownership account: {ownership_id}"); + + let mover_instruction_data = + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount: FUNDING_BALANCE, + }) + .context("Failed to serialize mover instruction")?; + let stake_instruction_data = + Program::serialize_instruction(sequencer_stake_core::Instruction::Stake { + sequencer_key: demo_stake_key, + amount: FUNDING_BALANCE, + mover_program_id: programs::authenticated_transfer().id(), + mover_instruction_data, + }) + .context("Failed to serialize Stake instruction")?; + + info!( + "Submitting Stake transaction for sequencer key {}", + hex::encode(demo_sequencer_key.to_bytes()) + ); + let config_id = system_accounts::sequencer_stake_config_account_id(); + ctx.wallet() + .send_pub_tx( + vec![ + AccountIdentity::Public(funding_id), + AccountIdentity::Public(ownership_id), + AccountIdentity::PublicNoSign(config_id), + ], + stake_instruction_data, + programs::sequencer_stake().id(), + ) + .await + .map_err(|err| anyhow::anyhow!("Failed to submit Stake transaction: {err:?}"))?; + + info!("Waiting for the Stake transaction's block to land"); + poll_until("stake to take ownership", 30, || async { + Ok( + get_account(&ctx, ownership_id).await?.program_owner + == programs::sequencer_stake().id(), + ) + }) + .await?; + + let ownership_account = get_account(&ctx, ownership_id) + .await + .context("Failed to read the stake ownership account")?; + assert_eq!( + ownership_account.program_owner, + programs::sequencer_stake().id(), + "ownership account should now be owned by sequencer_stake" + ); + assert_eq!( + ownership_account.balance, FUNDING_BALANCE, + "ownership account should hold the staked balance" + ); + let record = sequencer_stake_core::StakeRecord::from_bytes(ownership_account.data.as_ref()) + .context("ownership account data did not decode as a StakeRecord")?; + assert_eq!(record.sequencer_key, demo_stake_key); + info!( + "Ownership account confirmed: {} staked for sequencer key {}", + ownership_account.balance, + hex::encode(record.sequencer_key) + ); + + let bedrock_url = addr_to_url(UrlProtocol::Http, ctx.bedrock_addr()) + .context("Failed to build the Bedrock node URL")?; + let node = NodeHttpClient::new(CommonHttpClient::new(None), bedrock_url); + + // The committee-config update is a separate tx from the block's own + // publish, so it may land a moment later — poll a few times before failing. + let mut channel_state = None; + for _ in 0..10 { + let state = node + .channel_state(bedrock_channel_id()) + .await + .context("Failed to read Bedrock channel state")? + .context("Bedrock channel does not exist")?; + if state + .accredited_keys + .iter() + .any(|key: &Ed25519PublicKey| *key == demo_sequencer_key) + { + channel_state = Some(state); + break; + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + let channel_state = channel_state.context( + "demo sequencer key should have been discovered and accredited after the Stake transaction", + )?; + info!( + "Bedrock channel now accredits {} key(s), including the demo sequencer key — self-join complete", + channel_state.accredited_keys.len() + ); + + // Only now start a node behind the key, against a channel that already has a chain. + let (joiner, _joiner_home) = SequencerSetup::new(fast_blocks(), ctx.bedrock_addr()) + .with_channel_id(bedrock_channel_id()) + .with_bedrock_signing_key(JOINER_SIGNING_KEY) + .joining_existing_channel() + .setup() + .await + .context("Failed to start the joining sequencer")?; + let joiner_client = sequencer_client(joiner.addr())?; + + let joined_at = ctx.sequencer_client().get_last_block_id().await?; + poll_until("the joining sequencer to sync the existing chain", 120, { + let joiner_client = &joiner_client; + move || async move { Ok(joiner_client.get_last_block_id().await? >= joined_at) } + }) + .await?; + info!("Joining sequencer synced to block {joined_at}"); + + // A tip past `joined_at` under the demo key is a block this node built. + poll_until("the joining sequencer to build a block on its turn", 180, { + let node = &node; + let ctx = &ctx; + move || async move { + let Some(state) = node.channel_state(bedrock_channel_id()).await? else { + return Ok(false); + }; + let turn = state + .accredited_keys + .get(usize::from(state.tip_sequencer)) + .copied(); + Ok(turn == Some(demo_sequencer_key) + && ctx.sequencer_client().get_last_block_id().await? > joined_at) + } + }) + .await?; + info!("Joining sequencer produced a block on its round-robin turn"); + + // Both nodes agree, block for block, over everything they share. + let leader_client = ctx.sequencer_client(); + let common = leader_client + .get_last_block_id() + .await? + .min(joiner_client.get_last_block_id().await?); + for id in 1..=common { + let leader_block = leader_client + .get_block(id) + .await? + .with_context(|| format!("Leader is missing block {id}"))?; + let joiner_block = joiner_client + .get_block(id) + .await? + .with_context(|| format!("Joining sequencer is missing block {id}"))?; + anyhow::ensure!( + leader_block.header.hash == joiner_block.header.hash, + "Chain divergence at block {id}: leader {:?} vs joiner {:?}", + leader_block.header.hash, + joiner_block.header.hash + ); + } + info!("Leader and joining sequencer agree on all {common} shared blocks"); + + // Exit flow: full UnstakeRequest, wait for the committee removal to land on + // Bedrock, then check the sequencer's own FinalizeUnstake releases the stake. + // + // FinalizeUnstake is unsigned/permissionless, so it can't claim a fresh + // destination account (that needs the owner's own signature, same as + // authenticated_transfer's Transfer). Reuse funding_id: already + // authenticated_transfer-owned, drained to 0 by the Stake above. + let destination_id = funding_id; + + let unstake_request_data = + Program::serialize_instruction(sequencer_stake_core::Instruction::UnstakeRequest { + amount: FUNDING_BALANCE, + destination: destination_id, + }) + .context("Failed to serialize UnstakeRequest instruction")?; + ctx.wallet() + .send_pub_tx( + vec![ + AccountIdentity::Public(ownership_id), + AccountIdentity::PublicNoSign(config_id), + ], + unstake_request_data, + programs::sequencer_stake().id(), + ) + .await + .map_err(|err| anyhow::anyhow!("Failed to submit UnstakeRequest transaction: {err:?}"))?; + info!("Submitted full UnstakeRequest for the demo sequencer key"); + + // A full drain crosses below the minimum, so discovery removes the key. + // Wide window: the removal has to wait for this sequencer to regain its + // round-robin turn (posting_timeframe/posting_timeout reclaim), on top of + // normal Bedrock confirmation latency. + let mut removed = false; + for _ in 0..30 { + let state = node + .channel_state(bedrock_channel_id()) + .await + .context("Failed to read Bedrock channel state")? + .context("Bedrock channel does not exist")?; + if !state + .accredited_keys + .iter() + .any(|key: &Ed25519PublicKey| *key == demo_sequencer_key) + { + removed = true; + break; + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + anyhow::ensure!( + removed, + "demo sequencer key should have been removed after the full UnstakeRequest" + ); + info!("Demo sequencer key removed from the Bedrock committee"); + + // Once removed, the sequencer injects FinalizeUnstake itself; this test + // never submits one. + poll_until( + "FinalizeUnstake to drain the ownership account", + 90, + || async { Ok(get_account(&ctx, ownership_id).await?.balance == 0) }, + ) + .await?; + + let drained_ownership_account = get_account(&ctx, ownership_id) + .await + .context("Failed to read the drained ownership account")?; + assert_eq!( + drained_ownership_account.balance, 0, + "ownership account should be fully drained" + ); + let drained_record = + sequencer_stake_core::StakeRecord::from_bytes(drained_ownership_account.data.as_ref()) + .context("drained ownership account data did not decode as a StakeRecord")?; + assert!( + drained_record.pending_unstake.is_none(), + "pending unstake should be cleared" + ); + + let destination_balance = account_balance(&ctx, destination_id).await?; + assert_eq!( + destination_balance, FUNDING_BALANCE, + "destination should receive the released stake" + ); + + // Nothing is at stake for this key any more: a fully drained account has + // its config entry removed outright. + assert!( + stake_entry(&ctx, config_id, demo_stake_key) + .await? + .is_none(), + "the config entry should be gone once the stake is fully released" + ); + info!( + "FinalizeUnstake auto-included: {FUNDING_BALANCE} released to {destination_id}, nothing left at stake" + ); + + Ok(()) +} + +/// The `sequencer_stake` config entry for `sequencer_key`, or `None` if the key +/// has nothing at stake. +async fn stake_entry( + ctx: &TestContext, + config_id: AccountId, + sequencer_key: sequencer_stake_core::SequencerKey, +) -> Result> { + let config_account = get_account(ctx, config_id) + .await + .context("Failed to read the sequencer_stake config account")?; + let config = + sequencer_stake_core::SequencerStakeConfig::from_bytes(config_account.data.as_ref()) + .context("config account data did not decode as a SequencerStakeConfig")?; + Ok(config.entries.get(&sequencer_key).copied()) +} + +/// Polls `check` once a second, up to `max_attempts` times, replacing fixed +/// block-wait sleeps: the accelerated devnet crosses an epoch boundary every +/// ~100 slots, so every second of wall-clock spent sleeping increases the +/// chance of straddling one. +async fn poll_until(what: &str, max_attempts: u32, mut check: F) -> Result<()> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for _ in 0..max_attempts { + if check().await.unwrap_or(false) { + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + anyhow::bail!("timed out waiting for {what}") +} diff --git a/lee/state_machine/src/signature/mod.rs b/lee/state_machine/src/signature/mod.rs index ba049419c..873a274c5 100644 --- a/lee/state_machine/src/signature/mod.rs +++ b/lee/state_machine/src/signature/mod.rs @@ -5,11 +5,14 @@ use k256::ecdsa::signature::hazmat::PrehashVerifier as _; pub use private_key::PrivateKey; pub use public_key::PublicKey; use rand::{RngCore as _, rngs::OsRng}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; mod private_key; mod public_key; -#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[derive( + Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, SerializeDisplay, DeserializeFromStr, +)] pub struct Signature { pub value: [u8; 64], } diff --git a/lez/common/src/block.rs b/lez/common/src/block.rs index 53a2f0337..0147071dc 100644 --- a/lez/common/src/block.rs +++ b/lez/common/src/block.rs @@ -28,7 +28,8 @@ impl From<&Block> for BlockMeta { pub struct OwnHasher; impl OwnHasher { - fn hash(data: &[u8]) -> HashType { + #[must_use] + pub fn hash(data: &[u8]) -> HashType { let mut hasher = Sha256::new(); hasher.update(data); diff --git a/lez/configs/docker-all-in-one/sequencer_config.json b/lez/configs/docker-all-in-one/sequencer_config.json index cd94eea50..88e77460d 100644 --- a/lez/configs/docker-all-in-one/sequencer_config.json +++ b/lez/configs/docker-all-in-one/sequencer_config.json @@ -79,4 +79,4 @@ 37, 37 ] -} \ No newline at end of file +} diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 7ea3fdf0f..58d9344dc 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -9,12 +9,10 @@ //! own block-reading, emission-extraction, delivery-building, and trust model; a //! shared trait is best lifted from that first real adapter, not from this one. -use std::collections::BTreeMap; - pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; use cross_zone_inbox_core::{ CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id, - inbox_seen_shard_account_id, + inbox_seen_shard_account_id, inbox_source_marker_account_id, }; use lee_core::{ account::{Account, AccountId, Balance}, @@ -64,13 +62,18 @@ pub fn is_sequencer_only_program(program_id: ProgramId) -> bool { #[must_use] pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option { if program_id == programs::ping_sender().id() { - let ping_core::SenderInstruction::Send { + // Not every transaction to an emitter emits: `InitConfig` is one of its + // instructions, so a non-`Send` decode is an ordinary non-emitting tx. + let Ok(ping_core::SenderInstruction::Send { target_zone, target_program_id, target_accounts, payload, .. - } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + }) = risc0_zkvm::serde::from_slice(instruction_data) + else { + return None; + }; Some(Emission { target_zone, target_program_id, @@ -78,13 +81,16 @@ pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Opti payload, }) } else if program_id == programs::bridge_lock().id() { - let bridge_lock_core::Instruction::Lock { + let Ok(bridge_lock_core::Instruction::Lock { target_zone, target_program_id, target_accounts, payload, .. - } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + }) = risc0_zkvm::serde::from_slice(instruction_data) + else { + return None; + }; Some(Emission { target_zone, target_program_id, @@ -103,13 +109,21 @@ fn build_inbox_dispatch_tx( msg: &CrossZoneMessage, target_account_ids: Vec, ) -> lee::PublicTransaction { - let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2)); + let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(3)); account_ids.push(inbox_config_account_id(inbox_id)); account_ids.push(inbox_seen_shard_account_id( inbox_id, &msg.src_zone, msg.src_block_id, )); + // Declared here rather than derived by the guest, since a guest cannot + // conjure an account. Both the watcher and the verifier build it through this + // one function, so they cannot disagree about the source a target will see. + account_ids.push(inbox_source_marker_account_id( + inbox_id, + &msg.src_zone, + msg.src_program_id, + )); account_ids.extend(target_account_ids); let message = lee::public_transaction::Message::try_new( @@ -156,33 +170,18 @@ pub fn build_dispatch_from_emission( build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids) } -/// The inbox config a zone derives from its cross-zone config: the per-peer -/// delivery routes plus its own zone id. -fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig { - let mut allowed_routes = BTreeMap::new(); - for peer in &cross_zone.peers { - allowed_routes.insert(peer.channel_id, peer.allowed_routes.clone()); - } - InboxConfig { - self_zone, - allowed_routes, - } -} - /// The genesis transaction that initializes this zone's inbox config PDA. /// -/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the -/// same account on every node, keeping their state consistent. +/// The operator's per-peer routes no longer live here. They are fanned out into +/// each target program's own config, so all the inbox keeps is its zone id. +/// Replaying this seeds the same account on every node. #[must_use] -pub fn build_inbox_init_config_tx( - self_zone: ZoneId, - cross_zone: &CrossZoneConfig, -) -> lee::PublicTransaction { +pub fn build_inbox_init_config_tx(self_zone: ZoneId) -> lee::PublicTransaction { let inbox_id = programs::cross_zone_inbox().id(); genesis_public_tx( inbox_id, vec![inbox_config_account_id(inbox_id)], - Instruction::InitConfig(inbox_config(self_zone, cross_zone)), + Instruction::InitConfig(InboxConfig { self_zone }), ) } @@ -202,19 +201,104 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, } /// The genesis transaction that pins the cross-zone inbox as the wrapped-token -/// minter, without importing the inbox id into the guest. +/// minter and names the peer sources it may mint for, without importing either id +/// into the guest. +/// +/// The sources are the operator's own peer routes aimed at this token, moved from +/// the inbox's allowlist to the token's own config: the same information, enforced +/// by the program that owns the value. A zone with no peers gets an empty list, +/// which authorizes nothing, and the config is still seeded so its PDA cannot be +/// claimed by a first initializer. #[must_use] -pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction { +pub fn build_wrapped_token_init_config_tx( + cross_zone: Option<&CrossZoneConfig>, +) -> lee::PublicTransaction { let wrapped_token_id = programs::wrapped_token().id(); + let sources = cross_zone + .map(|cross_zone| { + cross_zone + .peers + .iter() + .flat_map(|peer| { + peer.allowed_routes + .iter() + .filter(|route| route.target_program_id == wrapped_token_id) + .map(|route| (peer.channel_id, route.src_program_id)) + }) + .collect() + }) + .unwrap_or_default(); genesis_public_tx( wrapped_token_id, vec![wrapped_token_core::config_account_id(wrapped_token_id)], - wrapped_token_core::Instruction::InitConfig { + wrapped_token_core::Instruction::InitConfig(wrapped_token_core::WrappedTokenConfig { minter: programs::cross_zone_inbox().id(), + sources, + }), + ) +} + +/// The genesis transaction that pins the outbox `ping_sender` chains into, +/// without importing the outbox id into the guest. +#[must_use] +pub fn build_ping_sender_init_config_tx() -> lee::PublicTransaction { + let ping_sender_id = programs::ping_sender().id(); + genesis_public_tx( + ping_sender_id, + vec![ping_core::sender_config_account_id(ping_sender_id)], + ping_core::SenderInstruction::InitConfig { + outbox_program_id: programs::cross_zone_outbox().id(), }, ) } +/// The genesis transaction that pins the outbox `bridge_lock` chains into and the +/// wrapped token it mints, without importing either id into the guest. +#[must_use] +pub fn build_bridge_lock_init_config_tx() -> lee::PublicTransaction { + let bridge_lock_id = programs::bridge_lock().id(); + genesis_public_tx( + bridge_lock_id, + vec![bridge_lock_core::config_account_id(bridge_lock_id)], + bridge_lock_core::Instruction::InitConfig { + outbox_program_id: programs::cross_zone_outbox().id(), + target_program_id: programs::wrapped_token().id(), + }, + ) +} + +/// The genesis transaction naming the peer sources `ping_receiver` accepts a +/// delivery from, fanned out of the operator's routes exactly as the wrapped +/// token's is. +#[must_use] +pub fn build_ping_receiver_init_config_tx( + cross_zone: Option<&CrossZoneConfig>, +) -> lee::PublicTransaction { + let receiver_id = programs::ping_receiver().id(); + let sources = cross_zone + .map(|cross_zone| { + cross_zone + .peers + .iter() + .flat_map(|peer| { + peer.allowed_routes + .iter() + .filter(|route| route.target_program_id == receiver_id) + .map(|route| (peer.channel_id, route.src_program_id)) + }) + .collect() + }) + .unwrap_or_default(); + genesis_public_tx( + receiver_id, + vec![ping_core::receiver_config_account_id(receiver_id)], + ping_core::ReceiverInstruction::InitConfig(ping_core::ReceiverConfig { + deliverer: programs::cross_zone_inbox().id(), + sources, + }), + ) +} + /// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction` /// on `program_id` over `account_ids`. fn genesis_public_tx( diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 16233df12..be17e6f3c 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -718,7 +718,7 @@ mod tests { }; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; - use ping_core::{SenderInstruction, ping_record_pda}; + use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; use super::*; @@ -746,10 +746,12 @@ mod tests { fn emission(payload: &[u8]) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); let send = SenderInstruction::Send { - outbox_program_id: programs::cross_zone_outbox().id(), target_zone: SELF_ZONE, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload: payload.to_vec(), ordinal: 0, }; @@ -846,7 +848,10 @@ mod tests { src_program_id: programs::ping_sender().id(), }, receiver_id, - &[ping_record_pda(receiver_id).into_value()], + &[ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload.to_vec(), )) } diff --git a/lez/programs/Cargo.toml b/lez/programs/Cargo.toml index 707c3d6b4..0e96679f4 100644 --- a/lez/programs/Cargo.toml +++ b/lez/programs/Cargo.toml @@ -84,6 +84,11 @@ name = "wrapped_token" path = "wrapped_token/src/main.rs" required-features = ["programs"] +[[bin]] +name = "sequencer_stake" +path = "sequencer_stake/src/main.rs" +required-features = ["programs"] + [features] # TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved. # default = ["artifacts"] @@ -113,6 +118,7 @@ programs = [ "dep:bridge_lock_core", "dep:wrapped_token_core", "dep:ping_core", + "dep:sequencer_stake_core", ] [dependencies] @@ -132,6 +138,7 @@ cross_zone_outbox_core = { workspace = true, optional = true } bridge_lock_core = { workspace = true, optional = true } wrapped_token_core = { workspace = true, optional = true } ping_core = { workspace = true, optional = true } +sequencer_stake_core = { workspace = true, optional = true } amm_program = { path = "amm", optional = true } associated_token_account_program = { path = "associated_token_account", optional = true } diff --git a/lez/programs/bridge_lock/core/Cargo.toml b/lez/programs/bridge_lock/core/Cargo.toml index 190fc4f26..1251d459e 100644 --- a/lez/programs/bridge_lock/core/Cargo.toml +++ b/lez/programs/bridge_lock/core/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } + +[dev-dependencies] +risc0-zkvm.workspace = true diff --git a/lez/programs/bridge_lock/core/src/lib.rs b/lez/programs/bridge_lock/core/src/lib.rs index 6d2aaf495..77836140b 100644 --- a/lez/programs/bridge_lock/core/src/lib.rs +++ b/lez/programs/bridge_lock/core/src/lib.rs @@ -9,23 +9,41 @@ use lee_core::{ use serde::{Deserialize, Serialize}; const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/"; +const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockCfg/0000000/"; +/// Variants are append-only. risc0 serde encodes the variant as a bare leading +/// tag word, so inserting one ahead of `Lock` shifts every existing encoding. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Instruction { /// Lock `amount` of the holder's balance and emit a cross-zone message - /// minting the wrapped token on `target_zone`. The emission fields mirror - /// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly. + /// minting the wrapped token on `target_zone`. /// - /// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA. + /// `target_program_id` and `target_accounts` are supplied though the guest + /// accepts one value for each: `cross_zone::extract_emission` reads them off + /// the transaction, decoding every emitter through one shape. + /// + /// `target_zone` is the caller's, so a lock to a zone that will not route it + /// escrows and never mints. TODO: bound it source-side. + /// + /// Required accounts (4): config PDA, holder holding (authorized), escrow + /// PDA, outbox PDA. Lock { amount: u128, target_zone: [u8; 32], target_program_id: ProgramId, target_accounts: Vec<[u8; 32]>, payload: Vec, - outbox_program_id: ProgramId, ordinal: u32, }, + /// Pins the outbox program and the mint target, written once into a default + /// config PDA at genesis. A re-run naming different programs is refused; an + /// identical one is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the config PDA. + InitConfig { + outbox_program_id: ProgramId, + target_program_id: ProgramId, + }, } /// PDA accumulating all locked balance on this zone. @@ -39,6 +57,49 @@ pub const fn escrow_seed() -> PdaSeed { PdaSeed::new(ESCROW_SEED_DOMAIN) } +/// PDA holding the outbox program id and the mint target, seeded at genesis so +/// the guest can pin both without importing their image ids. +#[must_use] +pub fn config_account_id(bridge_lock_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&bridge_lock_id, &config_seed()) +} + +#[must_use] +pub const fn config_seed() -> PdaSeed { + PdaSeed::new(CONFIG_SEED_DOMAIN) +} + +/// Encodes the pinned outbox and mint target for the config account's data. +#[must_use] +pub fn config_bytes(outbox_program_id: ProgramId, target_program_id: ProgramId) -> [u8; 64] { + let mut bytes = [0_u8; 64]; + for (word, chunk) in outbox_program_id + .iter() + .chain(target_program_id.iter()) + .zip(bytes.chunks_exact_mut(4)) + { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +/// Decodes the pinned outbox and mint target from the config account's data. +#[must_use] +pub fn read_config(data: &[u8]) -> Option<(ProgramId, ProgramId)> { + if data.len() < 64 { + return None; + } + let mut ids = [0_u32; 16]; + for (word, chunk) in ids.iter_mut().zip(data[..64].chunks_exact(4)) { + *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); + } + let (outbox, target) = ids.split_at(8); + Some(( + outbox.try_into().unwrap_or_else(|_| unreachable!()), + target.try_into().unwrap_or_else(|_| unreachable!()), + )) +} + #[cfg(test)] mod tests { use super::*; @@ -48,4 +109,31 @@ mod tests { let id: ProgramId = [4; 8]; assert_eq!(escrow_account_id(id), escrow_account_id(id)); } + + #[test] + fn config_ids_round_trip() { + let outbox: ProgramId = [3; 8]; + let target: ProgramId = [5; 8]; + assert_eq!( + read_config(&config_bytes(outbox, target)), + Some((outbox, target)) + ); + } + + /// `extract_emission` decodes `Lock` off peer transactions, so its tag word is + /// wire format: a variant inserted ahead of it would silently shift every + /// existing encoding. + #[test] + fn lock_is_the_first_variant() { + let lock = Instruction::Lock { + amount: 1, + target_zone: [7; 32], + target_program_id: [1; 8], + target_accounts: vec![], + payload: vec![], + ordinal: 0, + }; + let words = risc0_zkvm::serde::to_vec(&lock).expect("Lock serializes"); + assert_eq!(words[0], 0); + } } diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index ec4ac7b4a..b7c5997e5 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -1,8 +1,14 @@ -use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed}; +use bridge_lock_core::{ + Instruction, config_account_id, config_bytes, config_seed, escrow_account_id, escrow_seed, + read_config, +}; use cross_zone_outbox_core::Instruction as OutboxInstruction; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, }; use wrapped_token_core::{Instruction as WrappedInstruction, MAX_MINT_AMOUNT}; @@ -22,20 +28,74 @@ fn main() { "bridge_lock is only invoked as a top-level user transaction" ); - let Instruction::Lock { - amount, - target_zone, - target_program_id, - target_accounts, - payload, - outbox_program_id, - ordinal, - } = instruction; + match instruction { + Instruction::Lock { + amount, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } => lock( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + amount, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + ), + Instruction::InitConfig { + outbox_program_id, + target_program_id, + } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + outbox_program_id, + target_program_id, + ), + } +} + +#[expect( + clippy::too_many_arguments, + reason = "the emission fields are passed through verbatim" +)] +fn lock( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + amount: u128, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, +) { + // pre_states: [config PDA, holder holding (authorized), escrow PDA, outbox PDA]. + let [config, holder, escrow, outbox] = <[AccountWithMetadata; 4]>::try_from(pre_states) + .expect("Lock requires config, holder, escrow, and outbox accounts"); + + // Pinned rather than caller-named: chaining elsewhere would debit the escrow + // and leave no record of what it was for. + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "first account must be the bridge-lock config PDA" + ); + let (outbox_program_id, pinned_target) = read_config(&config.account.data.clone().into_inner()) + .expect("config account holds an outbox and a mint target"); // Value conservation: the forwarded payload must mint exactly what is locked. let WrappedInstruction::Mint { + recipient, amount: mint_amount, - .. } = decode_mint(&payload) else { panic!("bridge_lock payload must be a wrapped-token mint"); @@ -44,18 +104,27 @@ fn main() { mint_amount, amount, "locked amount must equal the wrapped mint amount" ); - // Before the debit, not on the destination: nothing releases an escrow, so - // an amount the destination will not mint has to fail in the submitter's own - // transaction. + + // All before the debit: nothing releases an escrow, so a message the + // destination refuses is a burn. `target_zone` is not checkable here, so a + // lock aimed at a zone that will not route it still burns. + assert_eq!( + target_program_id, pinned_target, + "bridge_lock only mints through the wrapped token it is pinned to" + ); + assert_eq!( + target_accounts, + vec![ + wrapped_token_core::config_account_id(pinned_target).into_value(), + wrapped_token_core::holding_account_id(pinned_target, &recipient).into_value(), + ], + "target accounts must be the mint's config and the recipient's holding" + ); assert!( amount <= MAX_MINT_AMOUNT, "locked amount exceeds what the wrapped token will mint" ); - // pre_states: [holder holding (authorized), escrow PDA, outbox PDA]. - let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states) - .expect("Lock requires holder, escrow, and outbox accounts"); - assert!(holder.is_authorized, "holder must authorize the lock"); // The holder holding is bridge_lock-owned, so bridge_lock may debit its native // balance directly (state-machine rule 5). This also pins the transfer to a @@ -68,7 +137,7 @@ fn main() { assert_eq!( escrow.account_id, escrow_account_id(self_program_id), - "second account must be the escrow PDA" + "third account must be the escrow PDA" ); // Move the real native balance holder -> escrow. bridge_lock owns both accounts, @@ -106,12 +175,15 @@ fn main() { }, ); + let config_post = AccountPostState::new(config.account.clone()); + ProgramOutput::new( self_program_id, caller_program_id, instruction_words, - vec![holder, escrow, outbox.clone()], + vec![config, holder, escrow, outbox.clone()], vec![ + config_post, holder_post, escrow_post, AccountPostState::new(outbox.account), @@ -121,6 +193,58 @@ fn main() { .write(); } +/// Writes the outbox program and the mint target into the config PDA exactly once +/// at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + outbox_program_id: ProgramId, + target_program_id: ProgramId, +) { + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "account must be the bridge-lock config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already pin exactly these programs, since + // genesis is replayed onto seeded state during multi-sequencer reconstruction. + // `new_claimed_if_default` alone would not stop a later self-owned rewrite. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "bridge-lock config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + config_bytes(outbox_program_id, target_program_id).to_vec(), + "bridge-lock config already pins a different outbox or mint target" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = config_bytes(outbox_program_id, target_program_id) + .to_vec() + .try_into() + .expect("pinned ids fit in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], + ) + .write(); +} + /// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the /// wrapped-token instruction it carries. fn decode_mint(payload: &[u8]) -> WrappedInstruction { diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index c0dcf64b2..9184e0342 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ @@ -13,6 +13,7 @@ const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; /// indistinguishable under one domain. Belt and braces, since the image id /// already relocates every PDA in this crate whenever the crate changes. const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/01/"; +const SOURCE_MARKER_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneSource/00000/"; /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. pub type ZoneId = [u8; 32]; @@ -84,32 +85,20 @@ pub struct CrossZoneMessage { pub l1_inclusion_witness: Option>, } -/// Per-peer delivery routes, plus this inbox's own zone id. +/// This inbox's own zone id. +/// +/// It no longer decides who may deliver what. Each target program authorizes its +/// own sources against the marker the inbox passes, so the only thing the inbox +/// still needs to know is which zone it is, to refuse a message addressed to +/// itself. #[derive( Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, )] pub struct InboxConfig { pub self_zone: ZoneId, - /// Which deliveries each peer may make. A peer absent from this map may - /// deliver nothing. - pub allowed_routes: BTreeMap>, } impl InboxConfig { - /// Whether `src_zone` may deliver from `src_program_id` to - /// `target_program_id`. A peer with no routes may deliver nothing. - #[must_use] - pub fn permits( - &self, - src_zone: &ZoneId, - src_program_id: ProgramId, - target_program_id: ProgramId, - ) -> bool { - self.allowed_routes - .get(src_zone) - .is_some_and(|routes| routes_permit(routes, src_program_id, target_program_id)) - } - /// Borsh-encoded form stored in the inbox config account. #[must_use] pub fn to_bytes(&self) -> Vec { @@ -204,25 +193,6 @@ pub enum Instruction { InitConfig(InboxConfig), } -/// Whether `routes` authorize a delivery from `src_program_id` to -/// `target_program_id`. -/// -/// The one place the rule lives. The inbox guest decides with it and the -/// sequencer's watcher drops unroutable messages with it, and those two must -/// agree: a watcher stricter than the guest loses messages silently, and one -/// looser records deliveries the guest will refuse, which production then feeds -/// in and gives up on. -#[must_use] -pub fn routes_permit( - routes: &[CrossZoneRoute], - src_program_id: ProgramId, - target_program_id: ProgramId, -) -> bool { - routes.iter().any(|route| { - route.src_program_id == src_program_id && route.target_program_id == target_program_id - }) -} - /// Content-addressed replay key for a delivered message. /// /// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator. @@ -286,6 +256,48 @@ pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { .unwrap_or_else(|_| unreachable!()); PdaSeed::new(seed) } + +/// The account naming who sent a delivery, which the inbox passes at position 0 +/// of the chained call so the target can authenticate its own sources. +/// +/// Nothing writes or claims it, so the state machine's uninitialized-account rule +/// skips it for being unchanged rather than for being default: anyone may send it +/// balance, and the inbox and the targets all round-trip it untouched. +/// +/// The address is derivable by anyone, so it is not a secret and not a +/// capability. What makes it mean something is that a target checks it only after +/// pinning its caller to the inbox, and only the inbox can be that caller. +#[must_use] +pub fn inbox_source_marker_account_id( + inbox_id: ProgramId, + src_zone: &ZoneId, + src_program_id: ProgramId, +) -> AccountId { + AccountId::for_public_pda( + &inbox_id, + &inbox_source_marker_seed(src_zone, src_program_id), + ) +} + +/// Seed of the source marker, exposed so a target can re-derive the address of +/// the one source it accepts and compare. +#[must_use] +pub fn inbox_source_marker_seed(src_zone: &ZoneId, src_program_id: ProgramId) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 96]; + bytes[..32].copy_from_slice(&SOURCE_MARKER_SEED_DOMAIN); + bytes[32..64].copy_from_slice(src_zone); + for (word, chunk) in src_program_id.iter().zip(bytes[64..].chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} #[cfg(test)] mod tests { use lee_core::account::data::DATA_MAX_LENGTH; @@ -296,63 +308,6 @@ mod tests { [b; 32] } - fn program(n: u32) -> ProgramId { - [n; 8] - } - - /// The route is the pair. Two entries that are each reasonable on their own, - /// a lock program that may mint and a ping emitter that may reach a - /// receiver, must not compose into the lock program's target being - /// reachable from the ping emitter: that emitter lets its caller choose the - /// target, so it would mint with nothing locked behind it. - #[test] - fn a_route_authorizes_one_pair_and_does_not_compose() { - let lock = program(1); - let wrapped_token = program(2); - let ping_sender = program(3); - let ping_receiver = program(4); - - let mut allowed_routes = BTreeMap::new(); - allowed_routes.insert( - zone(9), - vec![ - CrossZoneRoute { - src_program_id: lock, - target_program_id: wrapped_token, - }, - CrossZoneRoute { - src_program_id: ping_sender, - target_program_id: ping_receiver, - }, - ], - ); - let config = InboxConfig { - self_zone: zone(1), - allowed_routes, - }; - - assert!(config.permits(&zone(9), lock, wrapped_token)); - assert!(config.permits(&zone(9), ping_sender, ping_receiver)); - - assert!( - !config.permits(&zone(9), ping_sender, wrapped_token), - "an emitter whose caller picks the target must not reach the bridge's target" - ); - assert!( - !config.permits(&zone(9), lock, ping_receiver), - "a route grants its own target, not every target the peer has" - ); - } - - #[test] - fn a_peer_with_no_routes_may_deliver_nothing() { - let config = InboxConfig { - self_zone: zone(1), - allowed_routes: BTreeMap::new(), - }; - assert!(!config.permits(&zone(9), program(1), program(2))); - } - #[test] fn message_key_is_stable_and_content_addressed() { assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index 4d2181c23..ff61a8bc9 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -1,6 +1,7 @@ use cross_zone_inbox_core::{ CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id, inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, + inbox_source_marker_account_id, }; use lee_core::{ account::{Account, AccountWithMetadata}, @@ -61,10 +62,11 @@ fn dispatch( "l1_inclusion_witness must be None in v1" ); - // pre_states layout: [config, seen_shard, then the target accounts]. + // pre_states layout: [config, seen_shard, source marker, then the target accounts]. let mut accounts = pre_states.into_iter(); let config = accounts.next().expect("config account required"); let seen = accounts.next().expect("seen shard account required"); + let marker = accounts.next().expect("source marker account required"); let target_accounts: Vec = accounts.collect(); assert_eq!( @@ -77,6 +79,14 @@ fn dispatch( inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id), "Second account must be the seen-shard PDA" ); + // The one value the chained call carries about where the message came from. + // The target re-derives this address from the source it accepts, so binding it + // here is what makes a target's own check meaningful. + assert_eq!( + marker.account_id, + inbox_source_marker_account_id(self_program_id, &msg.src_zone, msg.src_program_id), + "Third account must be the source marker PDA for this message" + ); let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner()) .expect("inbox config decodes"); @@ -85,15 +95,6 @@ fn dispatch( msg.src_zone != cfg.self_zone, "Source zone must not be this zone" ); - // Checked as a pair. The emitting program is as much a part of the - // authorization as the target: an emitter whose caller chooses the target - // reaches everything the peer may reach, so a target allowlist on its own - // lets any such emitter stand in for every other one. - assert!( - cfg.permits(&msg.src_zone, msg.src_program_id, msg.target_program_id), - "No route from this source program to this target program for this peer" - ); - let mut shard = SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); @@ -137,19 +138,23 @@ fn dispatch( .map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!()))) .collect(); + // The marker leads, so a target reads its source at a fixed position + // without knowing anything about the accounts that follow it. + let mut call_pre_states = vec![marker.clone()]; + call_pre_states.extend(target_accounts.clone()); let call = ChainedCall { program_id: msg.target_program_id, - pre_states: target_accounts.clone(), + pre_states: call_pre_states, instruction_data, pda_seeds: vec![], }; (seen_post, vec![call]) }; - let mut post_states = vec![unchanged(&config), seen_post]; + let mut post_states = vec![unchanged(&config), seen_post, unchanged(&marker)]; post_states.extend(target_accounts.iter().map(unchanged)); - let mut output_pre_states = vec![config, seen]; + let mut output_pre_states = vec![config, seen, marker]; output_pre_states.extend(target_accounts); ProgramOutput::new( @@ -163,8 +168,7 @@ fn dispatch( .write(); } -/// Writes the inbox config (peer + target allowlists) into the config PDA exactly -/// once at genesis. +/// Writes the inbox config into the config PDA exactly once at genesis. fn init_config( self_program_id: ProgramId, caller_program_id: Option, @@ -181,9 +185,8 @@ fn init_config( "account must be the inbox config PDA" ); // Init-once, idempotent under genesis replay: a `default` config is a first - // init; an already-owned config must already hold exactly these allowlists (the - // genesis block is replayed onto seeded state during multi-sequencer - // reconstruction), otherwise reject a post-genesis attempt to change them. + // init; an already-owned config must already hold exactly this, since genesis + // is replayed onto seeded state during multi-sequencer reconstruction. // `new_claimed_if_default` alone would not stop the owning program from // rewriting its own config data on a later call. if config_meta.account != Account::default() { @@ -194,7 +197,7 @@ fn init_config( assert_eq!( config_meta.account.data.clone().into_inner(), config.to_bytes(), - "inbox config already initialized with different allowlists" + "inbox config already initialized differently" ); } diff --git a/lez/programs/cross_zone_outbox/core/src/lib.rs b/lez/programs/cross_zone_outbox/core/src/lib.rs index 736b5fa23..66f5dc111 100644 --- a/lez/programs/cross_zone_outbox/core/src/lib.rs +++ b/lez/programs/cross_zone_outbox/core/src/lib.rs @@ -5,7 +5,11 @@ use lee_core::{ }; use serde::{Deserialize, Serialize}; -const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/"; +/// Versions the seed layout: bump on any change to its field list or offsets, +/// so slots under an old layout can never be re-derived. Redundant with the +/// image id, which relocates every PDA in this crate whenever the crate changes, +/// but the two version different things. +const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00001/"; /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. pub type ZoneId = [u8; 32]; @@ -14,6 +18,10 @@ pub type ZoneId = [u8; 32]; pub enum Instruction { /// Records an outbound cross-zone message as a write to a self-owned PDA. /// + /// The slot is written once: a second `Emit` at the same + /// `(emitter, target_zone, ordinal)` fails the transaction rather than + /// replacing the record. + /// /// Required accounts (1): /// - Outbox PDA account Emit { @@ -28,12 +36,21 @@ pub enum Instruction { }, } -/// The message as stored in an outbox PDA. The destination zone's watcher reads -/// this from the inscribed block; the source coordinates are filled by the -/// watcher, not stored here. +/// One emitted message, as stored in its outbox PDA. +/// +/// Carries the slot it occupies as well as the message, so a reader holding the +/// bytes knows who wrote them and where without inverting the address, which is +/// a hash. The source zone and block coordinates are filled by the destination's +/// watcher and are not stored here. #[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct OutboxRecord { + /// The program that called `Emit`, which is the immediate chained caller. + /// Cross-zone discovery names the top-level program instead, so joining a + /// record against a delivery is only sound while every emitter refuses to be + /// called by another program. + pub emitter: ProgramId, pub target_zone: ZoneId, + pub ordinal: u32, pub target_program_id: ProgramId, pub target_accounts: Vec<[u8; 32]>, pub payload: Vec, @@ -52,22 +69,34 @@ impl OutboxRecord { } } -/// PDA holding one emitted message, keyed by destination zone and a per-zone -/// ordinal. +/// PDA holding one emitted message, keyed by the emitting program, the +/// destination zone, and a per-emitter per-zone ordinal. +/// +/// `emitter` is the program that called `Emit`, which the guest takes from +/// `caller_program_id` rather than from the instruction. Without it in the +/// address two programs share a slot and one overwrites the other. #[must_use] -pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId { - AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal)) +pub fn outbox_pda( + outbox_id: ProgramId, + emitter: ProgramId, + target_zone: &ZoneId, + ordinal: u32, +) -> AccountId { + AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(emitter, target_zone, ordinal)) } /// Seed of an outbox message PDA, exposed so the guest can claim the account. #[must_use] -pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { +pub fn outbox_pda_seed(emitter: ProgramId, target_zone: &ZoneId, ordinal: u32) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; - let mut bytes = [0_u8; 68]; + let mut bytes = [0_u8; 100]; bytes[..32].copy_from_slice(&OUTBOX_SEED_DOMAIN); - bytes[32..64].copy_from_slice(target_zone); - bytes[64..].copy_from_slice(&ordinal.to_le_bytes()); + for (word, chunk) in emitter.iter().zip(bytes[32..64].chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes[64..96].copy_from_slice(target_zone); + bytes[96..].copy_from_slice(&ordinal.to_le_bytes()); let seed: [u8; 32] = Impl::hash_bytes(&bytes) .as_bytes() @@ -80,14 +109,55 @@ pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { mod tests { use super::*; + const OUTBOX: ProgramId = [3; 8]; + const EMITTER: ProgramId = [4; 8]; + #[test] fn outbox_pda_is_unique_per_zone_and_ordinal() { - let id: ProgramId = [3; 8]; let zone_a = [1; 32]; let zone_b = [2; 32]; - assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0)); - assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1)); - assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0)); + assert_eq!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_a, 0) + ); + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_a, 1) + ); + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_b, 0) + ); + } + + /// Two programs emitting to the same zone and ordinal must not share a slot, + /// or the second silently overwrites the first. + #[test] + fn outbox_pda_is_unique_per_emitter() { + let zone = [1; 32]; + let other: ProgramId = [5; 8]; + + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone, 0), + outbox_pda(OUTBOX, other, &zone, 0) + ); + } + + #[test] + fn outbox_record_round_trips() { + let record = OutboxRecord { + emitter: EMITTER, + target_zone: [1; 32], + ordinal: 7, + target_program_id: [6; 8], + target_accounts: vec![[9; 32]], + payload: b"payload".to_vec(), + }; + + assert_eq!( + OutboxRecord::from_bytes(&record.to_bytes()).expect("record decodes"), + record + ); } } diff --git a/lez/programs/cross_zone_outbox/src/main.rs b/lez/programs/cross_zone_outbox/src/main.rs index 432e8d9c9..a4b674df1 100644 --- a/lez/programs/cross_zone_outbox/src/main.rs +++ b/lez/programs/cross_zone_outbox/src/main.rs @@ -1,6 +1,6 @@ use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed}; use lee_core::{ - account::AccountWithMetadata, + account::{Account, AccountWithMetadata}, program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; @@ -15,10 +15,14 @@ fn main() { instruction_words, ) = read_lee_inputs::(); - assert!( - caller_program_id.is_some(), - "Outbox is only callable through a chain call from a user program" - ); + // The emitter, and the only identity here the state machine verifies: it + // checks a guest's claimed caller against the real one. Note this is the + // immediate chained caller, not the top-level program that cross-zone + // discovery names; the two coincide only while every emitter refuses to be + // called by another program, which both do today. + let Some(emitter) = caller_program_id else { + panic!("Outbox is only callable through a chain call from a user program"); + }; let (target_zone, target_program_id, target_accounts, payload, ordinal) = match instruction { Instruction::Emit { @@ -41,13 +45,32 @@ fn main() { assert_eq!( outbox.account_id, - outbox_pda(self_program_id, &target_zone, ordinal), - "Account must be the outbox PDA for (target_zone, ordinal)" + outbox_pda(self_program_id, emitter, &target_zone, ordinal), + "Account must be the outbox PDA for (emitter, target_zone, ordinal)" + ); + + // A slot holds one message for ever. Identity first, so a wrong account that + // happens to be free is reported as the wrong account rather than as a used + // slot. + // + // This is the same predicate the state machine already requires of a first + // write, so guest and host agree by construction rather than by coincidence. + // + // It also means a slot can be denied to its intended writer: the ordinal is + // caller-chosen in a namespace every user of an emitter shares, and an + // emission needs no signature, so anyone can occupy one. A client must pick + // an ordinal the chain does not already hold rather than counting from zero. + assert_eq!( + outbox.account, + Account::default(), + "Outbox slot already written: one Emit per (emitter, target_zone, ordinal)" ); let mut post_account = outbox.account.clone(); post_account.data = OutboxRecord { + emitter, target_zone, + ordinal, target_program_id, target_accounts, payload, @@ -56,9 +79,10 @@ fn main() { .try_into() .expect("OutboxRecord fits in account data"); - let post = AccountPostState::new_claimed_if_default( + // Unconditional, since the pre-state is provably default by the assert above. + let post = AccountPostState::new_claimed( post_account, - Claim::Pda(outbox_pda_seed(&target_zone, ordinal)), + Claim::Pda(outbox_pda_seed(emitter, &target_zone, ordinal)), ); ProgramOutput::new( diff --git a/lez/programs/ping_core/Cargo.toml b/lez/programs/ping_core/Cargo.toml index 29870630f..554962ca4 100644 --- a/lez/programs/ping_core/Cargo.toml +++ b/lez/programs/ping_core/Cargo.toml @@ -8,5 +8,9 @@ license = { workspace = true } workspace = true [dependencies] +borsh.workspace = true lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } + +[dev-dependencies] +risc0-zkvm.workspace = true diff --git a/lez/programs/ping_core/src/lib.rs b/lez/programs/ping_core/src/lib.rs index 80b272479..672560b60 100644 --- a/lez/programs/ping_core/src/lib.rs +++ b/lez/programs/ping_core/src/lib.rs @@ -1,3 +1,4 @@ +use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::AccountId, program::{PdaSeed, ProgramId}, @@ -5,24 +6,79 @@ use lee_core::{ use serde::{Deserialize, Serialize}; const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/"; +const SENDER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingSenderCfg/0000000/"; +const RECEIVER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingReceiverCfg/00000/"; +/// Raw 32-byte zone (channel) id, matching the inbox's. +pub type ZoneId = [u8; 32]; -/// Instruction delivered to `ping_receiver` by the inbox: record the payload. +/// Instruction to `ping_receiver`. +/// +/// Variants are append-only, for the same reason `SenderInstruction`'s are. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ReceiverInstruction { + /// Record the payload, delivered by the inbox on behalf of a peer source + /// this receiver authorizes. + /// + /// Required accounts (3): the source marker, the receiver config PDA, then + /// the record PDA. Record { payload: Vec }, + /// Pins the deliverer and the peer sources it may deliver from, written once + /// into a default config PDA at genesis. A re-run holding anything different + /// is refused; an identical one is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the receiver config PDA. + InitConfig(ReceiverConfig), } -/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`. +/// Who may deliver to this receiver, and which peer sources they may deliver from. +/// +/// `ping_receiver` holds nothing worth stealing, so this is not about value. It is +/// about the record meaning something: without it any program on any configured +/// peer can overwrite the record, and a delivery proves only that some peer sent +/// it. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +pub struct ReceiverConfig { + /// The program allowed to call `Record`: the cross-zone inbox. + pub deliverer: ProgramId, + /// The `(src_zone, src_program_id)` pairs a delivery may originate from. + pub sources: Vec<(ZoneId, ProgramId)>, +} + +impl ReceiverConfig { + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("receiver config serializes") + } + + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + borsh::from_slice(bytes).ok() + } +} + +/// Instruction to `ping_sender`. `Send`'s emission fields are forwarded verbatim +/// into `cross_zone_outbox::Instruction::Emit`. +/// +/// Variants are append-only. risc0 serde encodes the variant as a bare leading +/// tag word, so inserting one ahead of `Send` shifts every existing encoding. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SenderInstruction { + /// Emit a cross-zone message through the pinned outbox. + /// + /// Required accounts (2): the sender config PDA, then the outbox PDA. Send { - outbox_program_id: ProgramId, target_zone: [u8; 32], target_program_id: ProgramId, target_accounts: Vec<[u8; 32]>, payload: Vec, ordinal: u32, }, + /// Pins the outbox program, written once into a default config PDA at + /// genesis. A re-run naming a different outbox is refused; an identical one + /// is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the sender config PDA. + InitConfig { outbox_program_id: ProgramId }, } /// The account a `ping_receiver` records the latest delivered payload into. @@ -36,3 +92,99 @@ pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId { pub const fn ping_record_seed() -> PdaSeed { PdaSeed::new(PING_RECORD_SEED) } + +/// PDA holding the outbox program id, seeded at genesis so the guest can pin the +/// program it chains into without importing the outbox image id. +#[must_use] +pub fn sender_config_account_id(sender_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&sender_id, &sender_config_seed()) +} + +#[must_use] +pub const fn sender_config_seed() -> PdaSeed { + PdaSeed::new(SENDER_CONFIG_SEED) +} + +/// PDA holding the sources `ping_receiver` accepts a delivery from. +#[must_use] +pub fn receiver_config_account_id(receiver_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&receiver_id, &receiver_config_seed()) +} + +#[must_use] +pub const fn receiver_config_seed() -> PdaSeed { + PdaSeed::new(RECEIVER_CONFIG_SEED) +} + +/// Encodes the pinned outbox program id for the config account's data. +#[must_use] +pub fn outbox_bytes(outbox_program_id: ProgramId) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + for (word, chunk) in outbox_program_id.iter().zip(bytes.chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +/// Decodes the pinned outbox program id from the config account's data. +#[must_use] +pub fn read_outbox(data: &[u8]) -> Option { + if data.len() < 32 { + return None; + } + let mut outbox_program_id = [0_u32; 8]; + for (word, chunk) in outbox_program_id.iter_mut().zip(data[..32].chunks_exact(4)) { + *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); + } + Some(outbox_program_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `extract_emission` decodes `Send` off peer transactions, so its tag word is + /// wire format: a variant inserted ahead of it would silently shift every + /// existing encoding. + #[test] + fn send_is_the_first_variant() { + let send = SenderInstruction::Send { + target_zone: [7; 32], + target_program_id: [1; 8], + target_accounts: vec![], + payload: vec![], + ordinal: 0, + }; + let words = risc0_zkvm::serde::to_vec(&send).expect("Send serializes"); + assert_eq!(words[0], 0); + } + + /// `Record` is serialized by the source zone into the emission payload and + /// decoded by the destination, so its tag word is wire format. + #[test] + fn record_is_the_first_variant() { + let record = ReceiverInstruction::Record { payload: vec![] }; + let words = risc0_zkvm::serde::to_vec(&record).expect("Record serializes"); + assert_eq!(words[0], 0); + } + + #[test] + fn an_empty_receiver_config_does_not_decode() { + assert_eq!(ReceiverConfig::from_bytes(&[]), None); + } + + #[test] + fn receiver_config_round_trips() { + let config = ReceiverConfig { + deliverer: [1; 8], + sources: vec![([7; 32], [9; 8])], + }; + assert_eq!(ReceiverConfig::from_bytes(&config.to_bytes()), Some(config)); + } + + #[test] + fn outbox_id_round_trips() { + let outbox: ProgramId = [9; 8]; + assert_eq!(read_outbox(&outbox_bytes(outbox)), Some(outbox)); + } +} diff --git a/lez/programs/ping_receiver/Cargo.toml b/lez/programs/ping_receiver/Cargo.toml index a1d88f399..73bbddc44 100644 --- a/lez/programs/ping_receiver/Cargo.toml +++ b/lez/programs/ping_receiver/Cargo.toml @@ -8,5 +8,6 @@ license = { workspace = true } workspace = true [dependencies] +cross_zone_inbox_core.workspace = true lee_core.workspace = true ping_core.workspace = true diff --git a/lez/programs/ping_receiver/src/main.rs b/lez/programs/ping_receiver/src/main.rs index 4fd9679fb..2c74baf25 100644 --- a/lez/programs/ping_receiver/src/main.rs +++ b/lez/programs/ping_receiver/src/main.rs @@ -1,8 +1,12 @@ +use cross_zone_inbox_core::inbox_source_marker_account_id; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{AccountPostState, Claim, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use ping_core::{ + ReceiverConfig, ReceiverInstruction, ping_record_pda, ping_record_seed, + receiver_config_account_id, receiver_config_seed, }; -use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed}; fn main() { let ( @@ -15,21 +19,61 @@ fn main() { instruction_words, ) = read_lee_inputs::(); + match instruction { + ReceiverInstruction::Record { payload } => record( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + payload, + ), + ReceiverInstruction::InitConfig(config) => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &config, + ), + } +} + +fn record( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + payload: Vec, +) { + // pre_states: [source marker, config PDA, record PDA]. + let [marker, config, record] = <[AccountWithMetadata; 3]>::try_from(pre_states) + .expect("Record requires the source marker, config, and record accounts"); + + assert_eq!( + config.account_id, + receiver_config_account_id(self_program_id), + "Second account must be the receiver config PDA" + ); + let cfg = ReceiverConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("config account holds a receiver config"); + assert_eq!( + caller_program_id, + Some(cfg.deliverer), + "Record is only callable by the authorized deliverer (the cross-zone inbox)" + ); + // Which peer sent it is this program's own business. Without this the record + // says only that some program on some configured peer wrote it. assert!( - caller_program_id.is_some(), - "ping_receiver is only callable through a chained call" + cfg.sources.iter().any(|(src_zone, src_program_id)| { + marker.account_id + == inbox_source_marker_account_id(cfg.deliverer, src_zone, *src_program_id) + }), + "Record is only callable for a peer source this receiver authorizes" ); - let payload = match instruction { - ReceiverInstruction::Record { payload } => payload, - }; - - let [record] = <[AccountWithMetadata; 1]>::try_from(pre_states) - .expect("Record requires exactly 1 account"); assert_eq!( record.account_id, ping_record_pda(self_program_id), - "Account must be the ping record PDA" + "Third account must be the ping record PDA" ); let mut post_account = record.account.clone(); @@ -41,8 +85,70 @@ fn main() { self_program_id, caller_program_id, instruction_words, - vec![record], - vec![post], + vec![marker.clone(), config.clone(), record], + vec![ + AccountPostState::new(marker.account), + AccountPostState::new(config.account), + post, + ], + ) + .write(); +} + +/// Writes the deliverer and the authorized peer sources into the config PDA +/// exactly once at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + config_value: &ReceiverConfig, +) { + assert!( + caller_program_id.is_none(), + "InitConfig is a top-level genesis transaction" + ); + + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + receiver_config_account_id(self_program_id), + "account must be the receiver config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already hold exactly this, since genesis is + // replayed onto seeded state during multi-sequencer reconstruction. + // `new_claimed_if_default` alone would not stop a later self-owned rewrite. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "receiver config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + config_value.to_bytes(), + "receiver config already initialized differently" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = config_value + .to_bytes() + .try_into() + .expect("receiver config fits in account data"); + let config_post = AccountPostState::new_claimed_if_default( + config_account, + Claim::Pda(receiver_config_seed()), + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], ) .write(); } diff --git a/lez/programs/ping_sender/src/main.rs b/lez/programs/ping_sender/src/main.rs index d0ad04f56..7ae0ea038 100644 --- a/lez/programs/ping_sender/src/main.rs +++ b/lez/programs/ping_sender/src/main.rs @@ -1,9 +1,14 @@ use cross_zone_outbox_core::Instruction as OutboxInstruction; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, +}; +use ping_core::{ + SenderInstruction, outbox_bytes, read_outbox, sender_config_account_id, sender_config_seed, }; -use ping_core::SenderInstruction; fn main() { let ( @@ -21,19 +26,63 @@ fn main() { "ping_sender is only invoked as a top-level user transaction" ); - let SenderInstruction::Send { - outbox_program_id, - target_zone, - target_program_id, - target_accounts, - payload, - ordinal, - } = instruction; + match instruction { + SenderInstruction::Send { + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } => send( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + ), + SenderInstruction::InitConfig { outbox_program_id } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + outbox_program_id, + ), + } +} - // The single account is the outbox PDA the chained call writes into; the - // outbox claims it, so ping_sender forwards it unchanged. - let [outbox] = - <[AccountWithMetadata; 1]>::try_from(pre_states).expect("Send requires exactly 1 account"); +#[expect( + clippy::too_many_arguments, + reason = "the emission fields are passed through verbatim" +)] +fn send( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, +) { + // pre_states: [config PDA, outbox PDA]. The outbox claims its own slot, so + // ping_sender forwards it unchanged. + let [config, outbox] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("Send requires the config and outbox accounts"); + + // Pinned rather than caller-named: chaining elsewhere would let an emission + // skip the real outbox and leave no record of itself. + assert_eq!( + config.account_id, + sender_config_account_id(self_program_id), + "first account must be the ping-sender config PDA" + ); + let outbox_program_id = read_outbox(&config.account.data.clone().into_inner()) + .expect("config account holds an outbox program id"); let call = ChainedCall::new( outbox_program_id, @@ -47,13 +96,65 @@ fn main() { }, ); + let config_post = AccountPostState::new(config.account.clone()); + ProgramOutput::new( self_program_id, caller_program_id, instruction_words, - vec![outbox.clone()], - vec![AccountPostState::new(outbox.account)], + vec![config, outbox.clone()], + vec![config_post, AccountPostState::new(outbox.account)], ) .with_chained_calls(vec![call]) .write(); } + +/// Writes the outbox program id into the config PDA exactly once at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + outbox_program_id: ProgramId, +) { + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + sender_config_account_id(self_program_id), + "account must be the ping-sender config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already pin exactly this outbox, since + // genesis is replayed onto seeded state during multi-sequencer reconstruction. + // `new_claimed_if_default` alone would not stop a later self-owned rewrite. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "ping-sender config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + outbox_bytes(outbox_program_id).to_vec(), + "ping-sender config already pins a different outbox" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = outbox_bytes(outbox_program_id) + .to_vec() + .try_into() + .expect("outbox id fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(sender_config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], + ) + .write(); +} diff --git a/lez/programs/sequencer_stake/Cargo.toml b/lez/programs/sequencer_stake/Cargo.toml new file mode 100644 index 000000000..61327d1ed --- /dev/null +++ b/lez/programs/sequencer_stake/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sequencer_stake_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +sequencer_stake_core.workspace = true +lee_core.workspace = true diff --git a/lez/programs/sequencer_stake/core/Cargo.toml b/lez/programs/sequencer_stake/core/Cargo.toml new file mode 100644 index 000000000..702762610 --- /dev/null +++ b/lez/programs/sequencer_stake/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "sequencer_stake_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, default-features = false } +borsh.workspace = true +ed25519-dalek.workspace = true diff --git a/lez/programs/sequencer_stake/core/src/lib.rs b/lez/programs/sequencer_stake/core/src/lib.rs new file mode 100644 index 000000000..fdc135619 --- /dev/null +++ b/lez/programs/sequencer_stake/core/src/lib.rs @@ -0,0 +1,358 @@ +//! Core types for the `sequencer_stake` program. + +use std::collections::BTreeMap; + +pub use lee_core::program::PdaSeed; +use lee_core::{ + account::AccountId, + program::{InstructionData, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const INVALID_KEY: &str = "invalid Ed25519 public key"; +const SEQUENCER_STAKE_CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/MinSequencerStake/0000"; + +/// The Bedrock sequencer identity a stake backs. Holds only a valid Ed25519 +/// public key. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SequencerKey([u8; 32]); + +impl SequencerKey { + /// `None` if `bytes` is not a valid Ed25519 public key. + #[must_use] + pub fn new(bytes: [u8; 32]) -> Option { + ed25519_dalek::VerifyingKey::from_bytes(&bytes) + .is_ok() + .then_some(Self(bytes)) + } + + #[must_use] + pub const fn to_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl AsRef<[u8]> for SequencerKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl Serialize for SequencerKey { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for SequencerKey { + fn deserialize>(deserializer: D) -> Result { + let bytes = <[u8; 32]>::deserialize(deserializer)?; + Self::new(bytes).ok_or_else(|| serde::de::Error::custom(INVALID_KEY)) + } +} + +impl borsh::BorshSerialize for SequencerKey { + fn serialize(&self, writer: &mut W) -> borsh::io::Result<()> { + borsh::BorshSerialize::serialize(&self.0, writer) + } +} + +impl borsh::BorshDeserialize for SequencerKey { + fn deserialize_reader(reader: &mut R) -> borsh::io::Result { + let bytes = <[u8; 32]>::deserialize_reader(reader)?; + Self::new(bytes) + .ok_or_else(|| borsh::io::Error::new(borsh::io::ErrorKind::InvalidData, INVALID_KEY)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Locks `amount` into the ownership account for `sequencer_key`. First + /// use claims the account; later calls top up the same account. + Stake { + sequencer_key: SequencerKey, + amount: u128, + mover_program_id: ProgramId, + mover_instruction_data: InstructionData, + }, + + /// Self-chained only: verifies the mover deposited `expected_balance_after`. + ConfirmStake { expected_balance_after: u128 }, + + /// Records a request to release `amount` to `destination`; no balance + /// moves yet. Must leave the account at zero or at/above the minimum. + UnstakeRequest { + amount: u128, + destination: AccountId, + }, + + /// Unsigned, permissionless: releases a pending `UnstakeRequest`. + /// Block-inclusion validity is enforced outside this program. + FinalizeUnstake, +} + +/// Tag written into a claimed ownership account: which key it backs, plus any pending unstake. +#[derive(Clone, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)] +pub struct StakeRecord { + pub sequencer_key: SequencerKey, + pub pending_unstake: Option, +} + +impl StakeRecord { + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("StakeRecord serialization should not fail") + } + + /// Returns `None` on malformed input. + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + borsh::from_slice(bytes).ok() + } +} + +/// Fixed under the staker's signature at `UnstakeRequest` time — `FinalizeUnstake` needs no +/// signature of its own. +#[derive(Clone, Copy, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)] +pub struct PendingUnstake { + pub amount: u128, + pub destination: AccountId, +} + +/// The single program-owned config account: minimum stake plus per-key standing, kept current +/// incrementally. +#[derive(Clone, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)] +pub struct SequencerStakeConfig { + pub minimum_sequencer_stake: u128, + pub entries: BTreeMap, +} + +impl SequencerStakeConfig { + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("SequencerStakeConfig serialization should not fail") + } + + /// Returns `None` on malformed input. + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + borsh::from_slice(bytes).ok() + } +} + +/// One key's standing. `account_id` makes the ownership account findable — a plain account's id +/// can't be recomputed from the key. +#[derive(Clone, Copy, Debug, PartialEq, Eq, borsh::BorshSerialize, borsh::BorshDeserialize)] +pub struct SequencerEntry { + pub account_id: AccountId, + pub total_staked: u128, + pub total_pending_unstake: u128, +} + +impl SequencerEntry { + /// Stake still backing this key once every pending release has been + /// finalized. Candidacy and every release check measure this, never the + /// ownership account's balance: only balance decreases require owning an + /// account, so anyone can credit one and push its balance above + /// `total_staked`. + #[must_use] + pub const fn net_stake(&self) -> u128 { + self.total_staked.saturating_sub(self.total_pending_unstake) + } + + /// Whether releasing `amount` is a legal `UnstakeRequest` against this + /// entry: covered by the stake tracked here, and leaving the key either + /// fully exited or still at or above `minimum`. + #[must_use] + pub const fn allows_unstake_request(&self, amount: u128, minimum: u128) -> bool { + match self.net_stake().checked_sub(amount) { + None => false, + Some(remaining) => remaining == 0 || remaining >= minimum, + } + } +} + +/// Seed of the PDA holding the [`SequencerStakeConfig`]. +#[must_use] +pub const fn sequencer_stake_config_seed() -> PdaSeed { + PdaSeed::new(SEQUENCER_STAKE_CONFIG_SEED_DOMAIN) +} + +#[must_use] +pub fn sequencer_stake_config_account_id(program_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&program_id, &sequencer_stake_config_seed()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PROGRAM_ID: ProgramId = [9; 8]; + + fn test_destination() -> AccountId { + AccountId::new([3; 32]) + } + + /// A distinct valid key per `seed`. + fn test_key(seed: u8) -> SequencerKey { + let bytes = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]) + .verifying_key() + .to_bytes(); + SequencerKey::new(bytes).expect("a derived public key is a curve point") + } + + #[test] + fn a_non_curve_point_is_not_a_sequencer_key() { + let off_curve = [2_u8; 32]; + assert!(SequencerKey::new(off_curve).is_none()); + + // 32 key bytes then a `None` discriminant: a `StakeRecord` with no + // pending unstake. + let record = [&off_curve[..], &[0_u8][..]].concat(); + assert_eq!(StakeRecord::from_bytes(&record), None); + } + + #[test] + fn stake_record_roundtrip() { + let record = StakeRecord { + sequencer_key: test_key(7), + pending_unstake: None, + }; + let bytes = record.to_bytes(); + assert_eq!(StakeRecord::from_bytes(&bytes), Some(record)); + } + + #[test] + fn stake_record_with_pending_unstake_roundtrip() { + let record = StakeRecord { + sequencer_key: test_key(7), + pending_unstake: Some(PendingUnstake { + amount: 42, + destination: test_destination(), + }), + }; + let bytes = record.to_bytes(); + assert_eq!(StakeRecord::from_bytes(&bytes), Some(record)); + } + + fn test_config() -> SequencerStakeConfig { + let mut entries = BTreeMap::new(); + entries.insert( + test_key(1), + SequencerEntry { + account_id: test_destination(), + total_staked: 1_000_000, + total_pending_unstake: 0, + }, + ); + SequencerStakeConfig { + minimum_sequencer_stake: 1_000_000, + entries, + } + } + + #[test] + fn sequencer_stake_config_does_not_decode_as_stake_record() { + let bytes = test_config().to_bytes(); + assert_eq!(StakeRecord::from_bytes(&bytes), None); + } + + #[test] + fn stake_record_does_not_decode_as_sequencer_stake_config() { + // Secondary to the config account's id check, which is what actually + // keeps an ownership account from being passed as the config. + for pending_unstake in [ + None, + Some(PendingUnstake { + amount: 0, + destination: AccountId::new([0; 32]), + }), + ] { + let bytes = StakeRecord { + sequencer_key: test_key(0), + pending_unstake, + } + .to_bytes(); + assert_eq!(SequencerStakeConfig::from_bytes(&bytes), None); + } + } + + #[test] + fn sequencer_stake_config_roundtrip() { + let config = test_config(); + let bytes = config.to_bytes(); + assert_eq!(SequencerStakeConfig::from_bytes(&bytes), Some(config)); + } + + fn entry(total_staked: u128, total_pending_unstake: u128) -> SequencerEntry { + SequencerEntry { + account_id: test_destination(), + total_staked, + total_pending_unstake, + } + } + + #[test] + fn net_stake_discounts_what_is_already_pending() { + assert_eq!(entry(1_000, 0).net_stake(), 1_000); + assert_eq!(entry(1_000, 400).net_stake(), 600); + assert_eq!(entry(1_000, 1_000).net_stake(), 0); + } + + #[test] + fn unstake_request_may_fully_exit_or_stay_at_the_minimum() { + let minimum = 1_000; + let entry = entry(3_000, 0); + + assert!(entry.allows_unstake_request(3_000, minimum), "full exit"); + assert!( + entry.allows_unstake_request(2_000, minimum), + "leaves exactly the minimum" + ); + assert!( + entry.allows_unstake_request(0, minimum), + "no-op leaves everything" + ); + } + + #[test] + fn unstake_request_may_not_leave_a_nonzero_balance_below_the_minimum() { + let minimum = 1_000; + assert!(!entry(3_000, 0).allows_unstake_request(2_500, minimum)); + } + + #[test] + fn unstake_request_may_not_exceed_the_tracked_stake() { + // A donation can push the account's balance above `total_staked`; a + // request sized off that balance is rejected here. + let minimum = 1_000; + let donated_balance = 3_001; + let entry = entry(3_000, 0); + + assert!(!entry.allows_unstake_request(donated_balance, minimum)); + assert!(entry.allows_unstake_request(entry.total_staked, minimum)); + } + + #[test] + fn unstake_request_is_measured_against_stake_not_already_pending() { + let minimum = 1_000; + let entry = entry(3_000, 2_000); + + assert!( + !entry.allows_unstake_request(3_000, minimum), + "2000 is already spoken for" + ); + assert!( + entry.allows_unstake_request(1_000, minimum), + "exits what is left" + ); + } + + #[test] + fn sequencer_stake_config_account_id_is_deterministic() { + assert_eq!( + sequencer_stake_config_account_id(PROGRAM_ID), + sequencer_stake_config_account_id(PROGRAM_ID) + ); + } +} diff --git a/lez/programs/sequencer_stake/src/main.rs b/lez/programs/sequencer_stake/src/main.rs new file mode 100644 index 000000000..c8190e103 --- /dev/null +++ b/lez/programs/sequencer_stake/src/main.rs @@ -0,0 +1,403 @@ +use std::collections::btree_map::Entry; + +use lee_core::{ + account::{AccountId, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, DEFAULT_PROGRAM_ID, InstructionData, ProgramId, + ProgramInput, ProgramOutput, read_lee_inputs, + }, +}; +use sequencer_stake_core::{ + Instruction, PendingUnstake, SequencerEntry, SequencerKey, SequencerStakeConfig, StakeRecord, + sequencer_stake_config_account_id, +}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + let (post_states, chained_calls) = match instruction { + Instruction::Stake { + sequencer_key, + amount, + mover_program_id, + mover_instruction_data, + } => { + assert!( + caller_program_id.is_none(), + "Stake is only invoked as a top-level user transaction" + ); + stake( + self_program_id, + pre_states.clone(), + sequencer_key, + amount, + mover_program_id, + mover_instruction_data, + ) + } + Instruction::ConfirmStake { + expected_balance_after, + } => { + assert_eq!( + caller_program_id, + Some(self_program_id), + "ConfirmStake can only be invoked as a self-chained call" + ); + let post = confirm_stake(pre_states.clone(), expected_balance_after); + (post, Vec::new()) + } + Instruction::UnstakeRequest { + amount, + destination, + } => { + assert!( + caller_program_id.is_none(), + "UnstakeRequest is only invoked as a top-level user transaction" + ); + let post = unstake_request(self_program_id, pre_states.clone(), amount, destination); + (post, Vec::new()) + } + Instruction::FinalizeUnstake => { + assert!( + caller_program_id.is_none(), + "FinalizeUnstake is only invoked as a top-level user transaction" + ); + let post = finalize_unstake(self_program_id, pre_states.clone()); + (post, Vec::new()) + } + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +} + +fn decode_config( + config_account: &AccountWithMetadata, + self_program_id: ProgramId, +) -> SequencerStakeConfig { + // By id, not just by owner: every ownership account is owned by this + // program too, and its data is caller-influenced. + assert_eq!( + config_account.account_id, + sequencer_stake_config_account_id(self_program_id), + "not the sequencer_stake config account" + ); + assert_eq!( + config_account.account.program_owner, self_program_id, + "config account is not owned by sequencer_stake" + ); + SequencerStakeConfig::from_bytes(config_account.account.data.as_ref()) + .expect("config account data should decode as SequencerStakeConfig") +} + +fn stake( + self_program_id: ProgramId, + pre_states: Vec, + sequencer_key: SequencerKey, + amount: u128, + mover_program_id: ProgramId, + mover_instruction_data: InstructionData, +) -> (Vec, Vec) { + let [funding_account, ownership_account, config_account] = + <[AccountWithMetadata; 3]>::try_from(pre_states).expect( + "Stake requires a funding account, an ownership account, and the config account", + ); + + assert!( + ownership_account.is_authorized, + "must sign for the ownership account" + ); + + let mut config = decode_config(&config_account, self_program_id); + let minimum_sequencer_stake = config.minimum_sequencer_stake; + + let balance_before = ownership_account.account.balance; + let expected_balance_after = balance_before + .checked_add(amount) + .expect("stake amount overflow"); + + // An ownership account stays claimed after a full exit, so what a call is + // doing follows from the config entry, not from the account's owner. + let is_claimed = ownership_account.account.program_owner != DEFAULT_PROGRAM_ID; + if is_claimed { + assert_eq!( + ownership_account.account.program_owner, self_program_id, + "not a sequencer_stake ownership account" + ); + let record = StakeRecord::from_bytes(ownership_account.account.data.as_ref()) + .expect("claimed ownership account should decode as StakeRecord"); + assert_eq!( + record.sequencer_key, sequencer_key, + "ownership account backs a different sequencer key" + ); + assert!( + record.pending_unstake.is_none(), + "cannot top up while an unstake request is pending" + ); + } + + match config.entries.entry(sequencer_key) { + Entry::Occupied(mut occupied) => { + // top up: same already-claimed account only + assert!( + is_claimed, + "this sequencer key already has an ownership account" + ); + let entry = occupied.get_mut(); + assert_eq!( + entry.account_id, ownership_account.account_id, + "config entry points at a different ownership account" + ); + entry.total_staked = entry + .total_staked + .checked_add(amount) + .expect("total staked overflow"); + } + Entry::Vacant(vacant) => { + // first stake for this key, or a new one after a full exit + assert!( + amount >= minimum_sequencer_stake, + "an initial stake must already meet the minimum" + ); + vacant.insert(SequencerEntry { + account_id: ownership_account.account_id, + total_staked: amount, + total_pending_unstake: 0, + }); + } + } + + // pass-through: propagates authorization into the nested mover call + let funding_account_post = AccountPostState::new(funding_account.account.clone()); + + // claim is a no-op on a top-up (already owned) + let mut ownership_account_data = ownership_account.account.clone(); + ownership_account_data.data = StakeRecord { + sequencer_key, + pending_unstake: None, + } + .to_bytes() + .try_into() + .expect("StakeRecord should fit in account data"); + let ownership_account_post = + AccountPostState::new_claimed_if_default(ownership_account_data.clone(), Claim::Authorized); + + let mut config_account_new = config_account.account; + config_account_new.data = config + .to_bytes() + .try_into() + .expect("SequencerStakeConfig should fit in account data"); + let config_account_post = AccountPostState::new(config_account_new); + + // chained-call pre-states reflect state as of when each call runs + let mut ownership_account_claimed = ownership_account; + ownership_account_claimed.account = ownership_account_data; + ownership_account_claimed.account.program_owner = self_program_id; + + let mover_call = ChainedCall { + program_id: mover_program_id, + pre_states: vec![funding_account, ownership_account_claimed.clone()], + instruction_data: mover_instruction_data, + pda_seeds: Vec::new(), + }; + + // expected balance after the mover call + let mut ownership_account_after_mover = ownership_account_claimed; + ownership_account_after_mover.account.balance = expected_balance_after; + + let confirm_call = ChainedCall::new( + self_program_id, + vec![ownership_account_after_mover], + &Instruction::ConfirmStake { + expected_balance_after, + }, + ); + + ( + vec![ + funding_account_post, + ownership_account_post, + config_account_post, + ], + vec![mover_call, confirm_call], + ) +} + +fn confirm_stake( + pre_states: Vec, + expected_balance_after: u128, +) -> Vec { + let [ownership_account] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("ConfirmStake requires exactly the ownership account"); + + assert_eq!( + ownership_account.account.balance, expected_balance_after, + "mover call did not deposit the expected amount into the ownership account" + ); + + vec![AccountPostState::new(ownership_account.account)] +} + +fn unstake_request( + self_program_id: ProgramId, + pre_states: Vec, + amount: u128, + destination: AccountId, +) -> Vec { + let [ownership_account, config_account] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("UnstakeRequest requires the ownership account and the config account"); + + assert!( + ownership_account.is_authorized, + "must sign for the ownership account" + ); + assert_eq!( + ownership_account.account.program_owner, self_program_id, + "not a sequencer_stake ownership account" + ); + + let mut record = StakeRecord::from_bytes(ownership_account.account.data.as_ref()) + .expect("ownership account should decode as StakeRecord"); + assert!( + record.pending_unstake.is_none(), + "an unstake request is already pending" + ); + + let mut config = decode_config(&config_account, self_program_id); + let minimum_sequencer_stake = config.minimum_sequencer_stake; + let entry = config + .entries + .get_mut(&record.sequencer_key) + .expect("staked key must already have a config entry"); + assert_eq!( + entry.account_id, ownership_account.account_id, + "config entry points at a different ownership account" + ); + + // Sized against the tracked stake, never the account balance: anyone can + // credit a program-owned account, so balance can exceed `total_staked`. + // Covers both "not more than is staked" and "zero or at least the minimum". + assert!( + entry.allows_unstake_request(amount, minimum_sequencer_stake), + "unstake request must be covered by the staked total and leave the key at zero or at/above the minimum" + ); + + record.pending_unstake = Some(PendingUnstake { + amount, + destination, + }); + entry.total_pending_unstake = entry + .total_pending_unstake + .checked_add(amount) + .expect("total pending unstake overflow"); + + // only data changes here; transfer happens in FinalizeUnstake + let mut ownership_account_new = ownership_account.account; + ownership_account_new.data = record + .to_bytes() + .try_into() + .expect("StakeRecord should fit in account data"); + + let mut config_account_new = config_account.account; + config_account_new.data = config + .to_bytes() + .try_into() + .expect("SequencerStakeConfig should fit in account data"); + + vec![ + AccountPostState::new(ownership_account_new), + AccountPostState::new(config_account_new), + ] +} + +fn finalize_unstake( + self_program_id: ProgramId, + pre_states: Vec, +) -> Vec { + let [ownership_account, destination_account, config_account] = + <[AccountWithMetadata; 3]>::try_from(pre_states).expect( + "FinalizeUnstake requires the ownership account, a destination account, and the config account", + ); + + assert_eq!( + ownership_account.account.program_owner, self_program_id, + "not a sequencer_stake ownership account" + ); + + let mut record = StakeRecord::from_bytes(ownership_account.account.data.as_ref()) + .expect("ownership account should decode as StakeRecord"); + let pending = record + .pending_unstake + .take() + .expect("no unstake request pending on this account"); + assert_eq!( + destination_account.account_id, pending.destination, + "destination does not match the recorded unstake request" + ); + + // no signature check: already authorized back in UnstakeRequest + let mut ownership_account_new = ownership_account.account.clone(); + ownership_account_new.balance = ownership_account_new + .balance + .checked_sub(pending.amount) + .expect("insufficient staked balance"); + ownership_account_new.data = record + .to_bytes() + .try_into() + .expect("StakeRecord should fit in account data"); + + let mut destination_new = destination_account.account; + destination_new.balance = destination_new + .balance + .checked_add(pending.amount) + .expect("finalize unstake amount overflow"); + + let mut config = decode_config(&config_account, self_program_id); + let entry = config + .entries + .get_mut(&record.sequencer_key) + .expect("staked key must already have a config entry"); + assert_eq!( + entry.account_id, ownership_account.account_id, + "config entry points at a different ownership account" + ); + entry.total_staked = entry + .total_staked + .checked_sub(pending.amount) + .expect("total staked underflow"); + entry.total_pending_unstake = entry + .total_pending_unstake + .checked_sub(pending.amount) + .expect("total pending unstake underflow"); + // Full drain is defined on the tracked stake, not the balance. + if entry.total_staked == 0 { + config.entries.remove(&record.sequencer_key); + } + + let mut config_account_new = config_account.account; + config_account_new.data = config + .to_bytes() + .try_into() + .expect("SequencerStakeConfig should fit in account data"); + + vec![ + AccountPostState::new(ownership_account_new), + AccountPostState::new(destination_new), + AccountPostState::new(config_account_new), + ] +} diff --git a/lez/programs/src/lib.rs b/lez/programs/src/lib.rs index fb448038f..066c5f812 100644 --- a/lez/programs/src/lib.rs +++ b/lez/programs/src/lib.rs @@ -14,8 +14,8 @@ mod inner { BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, CLOCK_ID, CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, PING_RECEIVER_ELF, - PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, - VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID, + PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, SEQUENCER_STAKE_ELF, SEQUENCER_STAKE_ID, + TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID, }; use lee::program::Program; @@ -126,6 +126,12 @@ mod inner { Program::new_unchecked(WRAPPED_TOKEN_ID, Cow::Borrowed(WRAPPED_TOKEN_ELF)) } + #[must_use] + #[inline] + pub const fn sequencer_stake() -> Program { + Program::new_unchecked(SEQUENCER_STAKE_ID, Cow::Borrowed(SEQUENCER_STAKE_ELF)) + } + #[cfg(test)] mod tests { use super::*; @@ -138,6 +144,7 @@ mod inner { let faucet_program = faucet(); let bridge_program = bridge(); let pinata_program = pinata(); + let sequencer_stake_program = sequencer_stake(); assert_eq!(auth_transfer_program.id(), AUTHENTICATED_TRANSFER_ID); assert_eq!(auth_transfer_program.elf(), AUTHENTICATED_TRANSFER_ELF); @@ -151,6 +158,8 @@ mod inner { assert_eq!(bridge_program.elf(), BRIDGE_ELF); assert_eq!(pinata_program.id(), PINATA_ID); assert_eq!(pinata_program.elf(), PINATA_ELF); + assert_eq!(sequencer_stake_program.id(), SEQUENCER_STAKE_ID); + assert_eq!(sequencer_stake_program.elf(), SEQUENCER_STAKE_ELF); } #[test] @@ -172,6 +181,7 @@ mod inner { (PING_RECEIVER_ELF, PING_RECEIVER_ID), (BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID), (WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID), + (SEQUENCER_STAKE_ELF, SEQUENCER_STAKE_ID), ]; for (elf, expected_id) in cases { let program = Program::new((*elf).into()).unwrap(); diff --git a/lez/programs/wrapped_token/Cargo.toml b/lez/programs/wrapped_token/Cargo.toml index a80f60fff..3ec0022d6 100644 --- a/lez/programs/wrapped_token/Cargo.toml +++ b/lez/programs/wrapped_token/Cargo.toml @@ -8,5 +8,6 @@ license = { workspace = true } workspace = true [dependencies] +cross_zone_inbox_core.workspace = true lee_core.workspace = true wrapped_token_core.workspace = true diff --git a/lez/programs/wrapped_token/core/Cargo.toml b/lez/programs/wrapped_token/core/Cargo.toml index ef0aabbc8..1791a2d3e 100644 --- a/lez/programs/wrapped_token/core/Cargo.toml +++ b/lez/programs/wrapped_token/core/Cargo.toml @@ -8,6 +8,7 @@ license = { workspace = true } workspace = true [dependencies] +borsh.workspace = true lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } risc0-zkvm.workspace = true diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index 2d5e0d775..2fba37c88 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -2,6 +2,7 @@ //! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces //! this by reading the authorized minter from a genesis-seeded config account. +use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::AccountId, program::{PdaSeed, ProgramId}, @@ -22,21 +23,49 @@ pub const MAX_MINT_AMOUNT: u128 = 0xFFFF_FFFF_FFFF_FFFF; const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000"; +/// Raw 32-byte zone (channel) id, matching the inbox's. +pub type ZoneId = [u8; 32]; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Instruction { /// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by - /// the cross-zone inbox. + /// the cross-zone inbox, and only for a peer source this token authorizes. /// - /// Required accounts (2): the wrapped-token config PDA, then the recipient's - /// holding PDA. + /// Required accounts (3): the source marker, the wrapped-token config PDA, + /// then the recipient's holding PDA. Mint { recipient: [u8; 32], amount: u128 }, - /// Pins `minter` (the cross-zone inbox) as the authorized minter, written once - /// into a default config PDA at genesis. The guest refuses a non-default - /// pre-state, so it cannot be re-run to hijack the minter. + /// Pins the minter and the peer sources it may mint for, written once into a + /// default config PDA at genesis. A re-run holding anything different is + /// refused; an identical one is a no-op, which is what genesis replay does. /// /// Required accounts (1): the wrapped-token config PDA. - InitConfig { minter: ProgramId }, + InitConfig(WrappedTokenConfig), +} + +/// Who may mint, and which peer sources they may mint for. +/// +/// The source list is what makes this token authorize its own inbound value +/// rather than trusting a central route table to have done it. Borsh because the +/// list is variable length. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +pub struct WrappedTokenConfig { + /// The program allowed to call `Mint`: the cross-zone inbox. + pub minter: ProgramId, + /// The `(src_zone, src_program_id)` pairs a mint may originate from. Empty on + /// a zone with no peers, which authorizes nothing. + pub sources: Vec<(ZoneId, ProgramId)>, +} + +impl WrappedTokenConfig { + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("wrapped-token config serializes") + } + + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + borsh::from_slice(bytes).ok() + } } /// PDA holding the authorized minter program id (the cross-zone inbox), seeded at @@ -71,29 +100,6 @@ pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed { PdaSeed::new(seed) } -/// Encodes the authorized minter program id for the config account's data. -#[must_use] -pub fn minter_bytes(minter: ProgramId) -> [u8; 32] { - let mut bytes = [0_u8; 32]; - for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) { - chunk.copy_from_slice(&word.to_le_bytes()); - } - bytes -} - -/// Decodes the authorized minter program id from the config account's data. -#[must_use] -pub fn read_minter(data: &[u8]) -> Option { - if data.len() < 32 { - return None; - } - let mut minter = [0_u32; 8]; - for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) { - *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); - } - Some(minter) -} - /// Reads a wrapped-token balance from account data; empty data is a zero balance. #[must_use] pub fn read_balance(data: &[u8]) -> u128 { @@ -113,9 +119,34 @@ mod tests { use super::*; #[test] - fn minter_round_trips() { - let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8]; - assert_eq!(read_minter(&minter_bytes(minter)), Some(minter)); + fn config_round_trips() { + let config = WrappedTokenConfig { + minter: [1, 2, 3, 4, 5, 6, 7, 8], + sources: vec![([7; 32], [9; 8]), ([8; 32], [4; 8])], + }; + assert_eq!( + WrappedTokenConfig::from_bytes(&config.to_bytes()), + Some(config) + ); + } + + /// An unclaimed config reads as empty, which must not decode to a config that + /// authorizes anything. + #[test] + fn an_empty_config_does_not_decode() { + assert_eq!(WrappedTokenConfig::from_bytes(&[]), None); + } + + /// The peer's `bridge_lock` serializes `Mint` into the emission payload, so + /// its tag word is wire format. + #[test] + fn mint_is_the_first_variant() { + let mint = Instruction::Mint { + recipient: [3; 32], + amount: 1, + }; + let words = risc0_zkvm::serde::to_vec(&mint).expect("Mint serializes"); + assert_eq!(words[0], 0); } #[test] diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index 88cb17887..52097b57b 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -1,10 +1,11 @@ +use cross_zone_inbox_core::inbox_source_marker_account_id; use lee_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; use wrapped_token_core::{ - Instruction, MAX_MINT_AMOUNT, balance_bytes, config_account_id, config_seed, - holding_account_id, holding_seed, minter_bytes, read_balance, read_minter, + Instruction, MAX_MINT_AMOUNT, WrappedTokenConfig, balance_bytes, config_account_id, + config_seed, holding_account_id, holding_seed, read_balance, }; fn main() { @@ -27,12 +28,12 @@ fn main() { recipient, amount, ), - Instruction::InitConfig { minter } => init_config( + Instruction::InitConfig(config) => init_config( self_program_id, caller_program_id, pre_states, instruction_words, - minter, + &config, ), } } @@ -45,29 +46,40 @@ fn mint( recipient: [u8; 32], amount: u128, ) { - // pre_states: [config PDA, recipient holding PDA]. - let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states) - .expect("Mint requires the config and recipient holding accounts"); + // pre_states: [source marker, config PDA, recipient holding PDA]. + let [marker, config, holding] = <[AccountWithMetadata; 3]>::try_from(pre_states) + .expect("Mint requires the source marker, config, and recipient holding accounts"); // The config PDA is genesis-seeded with the authorized minter (the cross-zone // inbox). Pin the caller to it, since the guest cannot import the inbox id. assert_eq!( config.account_id, config_account_id(self_program_id), - "first account must be the wrapped-token config PDA" + "second account must be the wrapped-token config PDA" ); - let minter = read_minter(&config.account.data.clone().into_inner()) - .expect("config account holds an authorized minter id"); + let cfg = WrappedTokenConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("config account holds a wrapped-token config"); assert_eq!( caller_program_id, - Some(minter), + Some(cfg.minter), "Mint is only callable by the authorized minter (the cross-zone inbox)" ); + // The inbox vouches only that the message arrived; which peer sent it is this + // token's own business, and unbacked value is what gets minted if it takes + // anyone's word for it. The marker's address is the source, so re-deriving it + // from an authorized pair is the whole check. + assert!( + cfg.sources.iter().any(|(src_zone, src_program_id)| { + marker.account_id + == inbox_source_marker_account_id(cfg.minter, src_zone, *src_program_id) + }), + "Mint is only callable for a peer source this token authorizes" + ); assert_eq!( holding.account_id, holding_account_id(self_program_id, &recipient), - "second account must be the recipient holding PDA" + "third account must be the recipient holding PDA" ); assert!( @@ -93,19 +105,24 @@ fn mint( self_program_id, caller_program_id, instruction_words, - vec![config, holding], - vec![config_post, holding_post], + vec![marker.clone(), config, holding], + vec![ + AccountPostState::new(marker.account), + config_post, + holding_post, + ], ) .write(); } -/// Writes the authorized minter into the config PDA exactly once at genesis. +/// Writes the minter and the authorized peer sources into the config PDA exactly +/// once at genesis. fn init_config( self_program_id: lee_core::program::ProgramId, caller_program_id: Option, pre_states: Vec, instruction_words: Vec, - minter: lee_core::program::ProgramId, + config_value: &WrappedTokenConfig, ) { assert!( caller_program_id.is_none(), @@ -133,16 +150,16 @@ fn init_config( ); assert_eq!( config.account.data.clone().into_inner(), - minter_bytes(minter).to_vec(), - "wrapped-token config already initialized with a different minter" + config_value.to_bytes(), + "wrapped-token config already initialized differently" ); } let mut config_account = config.account.clone(); - config_account.data = minter_bytes(minter) - .to_vec() + config_account.data = config_value + .to_bytes() .try_into() - .expect("minter id fits in account data"); + .expect("wrapped-token config fits in account data"); let config_post = AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed())); diff --git a/lez/sequencer/actors/executor/src/actor.rs b/lez/sequencer/actors/executor/src/actor.rs index 9affe4551..96dc9f039 100644 --- a/lez/sequencer/actors/executor/src/actor.rs +++ b/lez/sequencer/actors/executor/src/actor.rs @@ -60,6 +60,13 @@ impl ExecutorActor { background_tasks, } } + + /// Handle to the sequencer's mempool, for feeding externally-received + /// (e.g. gossiped) transactions in. + #[must_use] + pub fn mempool_handle(&self) -> MemPoolHandle<(TransactionOrigin, LeeTransaction)> { + self.mempool_handle.clone() + } } impl Actor for ExecutorActor { @@ -136,10 +143,10 @@ impl Message for Executo return Ok(()); } - info!("Our turn: collecting transactions from mempool, creating block"); + info!("Our turn: producing a block and any committee update"); let id = self .sequencer - .produce_new_block() + .run_production_turn() .await .map_err(Error::BlockProductionFailed)?; diff --git a/lez/sequencer/actors/executor/src/tests.rs b/lez/sequencer/actors/executor/src/tests.rs index 07bf61292..6995f3053 100644 --- a/lez/sequencer/actors/executor/src/tests.rs +++ b/lez/sequencer/actors/executor/src/tests.rs @@ -36,6 +36,7 @@ fn sequencer_config() -> (SequencerConfig, tempfile::TempDir) { genesis: Vec::new(), cross_zone: None, metrics_address: None, + gossip: None, }; (config, home) diff --git a/lez/sequencer/actors/rpc_server/src/actor.rs b/lez/sequencer/actors/rpc_server/src/actor.rs index b90b3f4f6..dfc8a551d 100644 --- a/lez/sequencer/actors/rpc_server/src/actor.rs +++ b/lez/sequencer/actors/rpc_server/src/actor.rs @@ -4,7 +4,7 @@ use bytesize::ByteSize; use jsonrpsee::server::ServerHandle; use kameo::{Actor, actor::ActorRef, mailbox::Signal}; use log::info; -use sequencer_core::block_publisher::BlockPublisherTrait; +use sequencer_core::{block_publisher::BlockPublisherTrait, gossip::GossipTxPublisher}; use sequencer_service_rpc::RpcServer as _; use tokio::select; @@ -24,6 +24,7 @@ impl RpcServerActor { executor_ref: ActorRef>, listen_addr: SocketAddr, max_block_size: ByteSize, + gossip_tx_publisher: Option, ) -> Result { let server = jsonrpsee::server::ServerBuilder::with_config( jsonrpsee::server::ServerConfigBuilder::new() @@ -43,7 +44,7 @@ impl RpcServerActor { info!("Starting RPC Server on {addr}"); - let service = service::Service::new(executor_ref, max_block_size); + let service = service::Service::new(executor_ref, max_block_size, gossip_tx_publisher); let server_handle = server.start(service.into_rpc()); Ok(Self { diff --git a/lez/sequencer/actors/rpc_server/src/actor/service.rs b/lez/sequencer/actors/rpc_server/src/actor/service.rs index 71f463242..f087c5523 100644 --- a/lez/sequencer/actors/rpc_server/src/actor/service.rs +++ b/lez/sequencer/actors/rpc_server/src/actor/service.rs @@ -8,7 +8,7 @@ use jsonrpsee::{ }; use kameo::actor::ActorRef; use log::{error, warn}; -use sequencer_core::block_publisher::BlockPublisherTrait; +use sequencer_core::{block_publisher::BlockPublisherTrait, gossip::GossipTxPublisher}; use sequencer_service_protocol::{ Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, CrossZoneDeadLetter, CrossZoneDeadLetterReport, HashType, MembershipProof, Nonce, ProgramId, @@ -17,18 +17,21 @@ use sequencer_service_protocol::{ pub struct Service { executor_ref: ActorRef>, max_block_size: ByteSize, + gossip_tx_publisher: Option, } impl Service { pub fn new( executor_ref: ActorRef>, max_block_size: ByteSize, + gossip_tx_publisher: Option, ) -> Self { sequencer_rpc_server_actor_metrics::init(); Self { executor_ref, max_block_size, + gossip_tx_publisher, } } } @@ -97,6 +100,12 @@ impl sequencer_service_rpc::RpcServer error!("Transaction failed before reaching mempool: {err:#?}"); })?; + // Publish to the gossip mesh before the local mempool admission so a + // full mempool doesn't delay propagation. + if let Some(publisher) = &self.gossip_tx_publisher { + publisher.publish(authenticated_tx.clone()); + } + self.executor_ref .ask(sequencer_executor_actor::protocol::Transaction { transaction: authenticated_tx, diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index a2d8a21c3..c6d1a4c75 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -18,12 +18,14 @@ mempool.workspace = true logos-blockchain-zone-sdk.workspace = true testnet_initial_state.workspace = true faucet_core.workspace = true +authenticated_transfer_core.workspace = true bridge_core.workspace = true vault_core.workspace = true programs.workspace = true system_accounts.workspace = true cross_zone.workspace = true cross_zone_inbox_core.workspace = true +sequencer_stake_core.workspace = true logos-blockchain-key-management-system-service.workspace = true logos-blockchain-core.workspace = true @@ -37,6 +39,7 @@ chrono.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tokio-util.workspace = true +tokio-retry.workspace = true rand.workspace = true borsh.workspace = true bytesize.workspace = true @@ -46,12 +49,15 @@ num-bigint.workspace = true risc0-zkvm.workspace = true futures.workspace = true itertools.workspace = true +libp2p.workspace = true [features] default = [] testnet = [] # Generate mock external clients implementations for testing mock = [] +# Enable mDNS-based local peer discovery for gossip. +mdns = [] [dev-dependencies] futures.workspace = true diff --git a/lez/sequencer/core/metrics/src/record.rs b/lez/sequencer/core/metrics/src/record.rs index e6f7e8d33..2807b4564 100644 --- a/lez/sequencer/core/metrics/src/record.rs +++ b/lez/sequencer/core/metrics/src/record.rs @@ -16,6 +16,7 @@ use crate::names; pub enum TransactionOrigin { User, Sequencer, + Gossip, } #[derive(Debug, Clone, Copy, strum::IntoStaticStr, strum::EnumIter)] diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index 0a3591170..3462103b0 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, ensure}; use common::block::Block; use futures::Stream; -use log::warn; +use log::{info, warn}; pub use logos_blockchain_core::mantle::{ ledger::NoteId, ops::channel::{Ed25519PublicKey, MsgId}, @@ -11,24 +11,24 @@ pub use logos_blockchain_core::mantle::{ use logos_blockchain_core::{ mantle::{ SignedMantleTx, - channel::{SlotTimeframe, SlotTimeout}, + channel::{ChannelState, SlotTimeframe, SlotTimeout}, gas::GasCost, ops::{ Op, OpProof, channel::{ ChannelId, config::{ChannelConfigOp, Keys}, - inscribe::Inscription, + inscribe::{Inscription, InscriptionOp}, }, }, traits::Hashable as _, - transactions::{MantleTxBuilder, OpsProofs}, + transactions::{MantleTxBuilder, OpsProofs, states::Unverified}, }, proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature}, }; use logos_blockchain_http_api_common::bodies::wallet::fund::WalletFundRequestBody; pub use logos_blockchain_key_management_system_service::keys::{ - ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey, + ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey, ZkPublicKey, }; pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use logos_blockchain_zone_sdk::{ @@ -103,12 +103,28 @@ enum Command { withdrawals: Vec, resp: oneshot::Sender>, }, + /// Submit a committee `ChannelConfigOp` as its own, independent Mantle tx + /// — not bundled with any block publish. + SubmitChannelConfig { + new_keys: Keys, + resp: oneshot::Sender>, + }, + /// Hand zone-sdk a pre-built tx to track and post, keyed by the channel tip + /// it leaves behind. + SubmitSignedTx { + tx: Box>, + msg_id: MsgId, + resp: oneshot::Sender>, + }, } type CommandSender = mpsc::Sender; -#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")] -pub trait BlockPublisherTrait: Sized { +#[expect( + async_fn_in_trait, + reason = "Only the methods reached from the executor actor need an explicitly Send future" +)] +pub trait BlockPublisherTrait: Sized + Sync { async fn new( config: &BedrockConfig, bedrock_signing_key: Ed25519Key, @@ -117,6 +133,10 @@ pub trait BlockPublisherTrait: Sized { on_follow: OnFollowSink, ) -> Result; + /// Whether the channel already exists, checked before anything else is + /// set up (no instance, no store, no genesis yet). + async fn channel_exists(config: &BedrockConfig) -> Result; + /// Publish a block and return what zone-sdk made of it. Zone-sdk drives the /// actual submission and retries internally. /// @@ -128,6 +148,28 @@ pub trait BlockPublisherTrait: Sized { withdrawals: Vec, ) -> impl Future> + Send + 'blk; + /// Create the channel and write `block` into it in one Mantle tx. Only valid + /// while the channel does not exist, and `keys[0]` must be this sequencer's + /// own key, since creation hands the first turn to index 0. + async fn publish_genesis_creating_channel( + &self, + block: &Block, + keys: Vec, + ) -> Result; + + /// Live (adopted, possibly not yet finalized) accredited-key snapshot for + /// this channel, read directly from the connected Bedrock node. + fn accredited_keys(&self) -> impl Future>> + Send; + + /// Submit a committee `ChannelConfigOp` as its own, independent Mantle + /// tx (not bundled with any block publish). `new_keys` is the full + /// replacement accredited-keys list; the channel administration + /// parameters posted alongside it are the `system_accounts` defaults. + fn submit_channel_config( + &self, + new_keys: Vec, + ) -> impl Future> + Send; + fn channel_id(&self) -> ChannelId; /// Whether this sequencer is currently authorized to write to the channel. @@ -148,7 +190,7 @@ pub trait BlockPublisherTrait: Sized { /// 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>; + fn channel_tip_slot(&self) -> impl Future>> + Send; /// Finalized channel messages from `after_slot` (exclusive) up to LIB, used /// for the startup consistency check and reconstruction. Pass `None` to read @@ -173,9 +215,33 @@ pub struct ZoneSdkPublisher { // path wait until it has actually stopped. drive_task: TaskGroup, indexer: ZoneIndexer, + bedrock_signing_key: Ed25519Key, + funding_key: ZkPublicKey, + priority_fee: u64, +} + +impl ZoneSdkPublisher { + /// Runs one [`Command`] on the drive task and waits for its reply. + async fn dispatch( + &self, + command: impl FnOnce(oneshot::Sender>) -> Command, + ) -> Result { + let (resp_tx, resp_rx) = oneshot::channel(); + self.command_tx + .send(command(resp_tx)) + .await + .map_err(|_closed| anyhow!("Drive task is no longer running"))?; + resp_rx + .await + .map_err(|_closed| anyhow!("Drive task dropped the response"))? + } } impl BlockPublisherTrait for ZoneSdkPublisher { + async fn channel_exists(config: &BedrockConfig) -> Result { + Ok(read_channel_state(config).await?.is_some()) + } + async fn new( config: &BedrockConfig, bedrock_signing_key: Ed25519Key, @@ -188,17 +254,16 @@ impl BlockPublisherTrait for ZoneSdkPublisher { let zone_sdk_config = ZoneSdkSequencerConfig { resubmit_interval, - funding: Some(FundingConfig { + ..ZoneSdkSequencerConfig::new(FundingConfig { funding_pk: config.funding_key, max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX), priority_fee: config.priority_fee, - }), - ..ZoneSdkSequencerConfig::default() + }) }; let mut sequencer = ZoneSequencer::init_with_config( config.channel_id, - bedrock_signing_key, + bedrock_signing_key.clone(), node.clone(), zone_sdk_config, initial_checkpoint, @@ -262,6 +327,56 @@ impl BlockPublisherTrait for ZoneSdkPublisher { } let _dontcare = resp_tx.send(msg_result); } + Command::SubmitChannelConfig { + new_keys, + resp: resp_tx, + } => { + // zone-sdk funds from the node wallet, signs, + // and enqueues this as its own independent + // Mantle tx onto the drive loop's in-flight + // pool — no manual bundling with any block + // inscription. + let result = sequencer + .handle() + .channel_config( + new_keys, + SlotTimeframe::from( + system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEFRAME, + ), + SlotTimeout::from( + system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEOUT, + ), + system_accounts::DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD, + system_accounts::DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD, + ) + .await + .map(|_| ()) + .context("Failed to submit channel-config update"); + + match &result { + Ok(()) => info!("Submitted committee channel-config update"), + Err(err) => { + warn!("Channel-config update submission failed: {err:?}"); + } + } + + let _dontcare = resp_tx.send(result); + } + Command::SubmitSignedTx { tx, msg_id, resp: resp_tx } => { + let submitted = sequencer + .handle() + .submit_signed_tx(*tx, msg_id) + .context("Failed to submit pre-built channel transaction"); + let msg_result = submitted.map(|(result, checkpoint)| PublishOutcome { + this_msg: result.tx.inscription().this_msg, + checkpoint, + released_notes: released_notes(&result.tx), + }); + if let Err(e) = &msg_result { + warn!("zone-sdk rejected the pre-built transaction: {e:?}"); + } + let _dontcare = resp_tx.send(msg_result); + } }, event = sequencer.next_event() => { match event { @@ -347,6 +462,9 @@ impl BlockPublisherTrait for ZoneSdkPublisher { turn_rx, driver_cancellation, drive_task: TaskGroup::new(vec![drive_task]), + bedrock_signing_key, + funding_key: config.funding_key, + priority_fee: config.priority_fee, }) } @@ -360,19 +478,110 @@ impl BlockPublisherTrait for ZoneSdkPublisher { .try_into() .context("Block data exceeds maximum allowed size")?; - 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"))?; + self.dispatch(|resp| Command::Publish { + inscription: data_bounded, + withdrawals, + resp, + }) + .await + } - resp_rx + async fn publish_genesis_creating_channel( + &self, + block: &Block, + keys: Vec, + ) -> Result { + let own_key = self.bedrock_signing_key.public_key(); + ensure!( + keys.first() == Some(&own_key), + "Creating the channel requires our own key first; creation gives the turn to index 0" + ); + let key_count = keys.len(); + let keys = + Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?; + + let config_op = ChannelConfigOp { + channel: self.channel_id, + keys, + posting_timeframe: SlotTimeframe::from( + system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEFRAME, + ), + posting_timeout: SlotTimeout::from(system_accounts::DEFAULT_SEQUENCER_POSTING_TIMEOUT), + configuration_threshold: system_accounts::DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD, + transfer_threshold: system_accounts::DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD, + }; + + let data = borsh::to_vec(block).context("Failed to serialize genesis block")?; + let inscription: Inscription = data + .try_into() + .context("Genesis block exceeds maximum allowed size")?; + // The config op runs first and becomes the tip, so it is the parent. + let inscribe_op = InscriptionOp { + channel_id: self.channel_id, + inscription, + parent: config_op.id(), + signer: own_key, + }; + let msg_id = inscribe_op.id(); + + let funded = fund_ops( + &self.node, + self.funding_key, + self.priority_fee, + [ + Op::ChannelConfig(config_op), + Op::ChannelInscribe(inscribe_op), + ], + ) + .await?; + let mantle_tx = funded.funded_tx; + + let signature = self + .bedrock_signing_key + .sign_payload(mantle_tx.hash().as_signing_bytes().as_ref()); + // Creation skips the channel-config signature check, but the proof must + // still be well formed; index 0 is our own key. + let config_proof = + ChannelMultiSigProof::try_new(IndexedSignature::new(0, signature).into()) + .map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?; + + let mut ops_proofs: OpsProofs = OpProof::ChannelMultiSigProof(config_proof).into(); + ops_proofs + .try_push(OpProof::Ed25519Sig(signature)) + .map_err(|err| anyhow!("Too many operation proofs: {err:?}"))?; + if let Some(transfer_proof) = funded.transfer_proof { + ops_proofs + .try_push(transfer_proof) + .map_err(|err| anyhow!("Too many operation proofs: {err:?}"))?; + } + + info!("Creating the channel with {key_count} accredited key(s), genesis block bundled"); + + let tx = Box::new(SignedMantleTx::new(mantle_tx, ops_proofs)); + self.dispatch(|resp| Command::SubmitSignedTx { tx, msg_id, resp }) + .await + } + + async fn accredited_keys(&self) -> Result> { + Ok(self + .node + .channel_state(self.channel_id) + .await + .context("Failed to read channel state")? + .map(|state| state.accredited_keys.to_vec()) + .unwrap_or_default()) + } + + async fn submit_channel_config(&self, new_keys: Vec) -> Result<()> { + ensure!( + !new_keys.is_empty(), + "Refusing to submit a committee update with no accredited keys" + ); + let new_keys = + Keys::try_from(new_keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?; + + self.dispatch(|resp| Command::SubmitChannelConfig { new_keys, resp }) .await - .map_err(|_closed| anyhow!("Drive task dropped the publish response"))? } fn channel_id(&self) -> ChannelId { @@ -446,6 +655,41 @@ const fn channel_update_inscription(orphan: &ChannelUpdateTx) -> Option<&Inscrip } } +/// Funds `ops` from the node's wallet, which appends a fee transfer (paid from +/// `funding_key`, change back to it) and returns its proof. +async fn fund_ops( + node: &NodeHttpClient, + funding_key: ZkPublicKey, + priority_fee: u64, + ops: impl IntoIterator, +) -> Result { + let tx_builder = MantleTxBuilder::new() + .extend_ops(ops) + .map_err(|err| anyhow!("Too many ops in channel transaction: {err:?}"))?; + node.fund_tx(WalletFundRequestBody { + tip: None, + tx_builder, + change_public_key: funding_key, + funding_public_keys: vec![funding_key], + max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX), + priority_fee, + }) + .await + .context("Failed to fund channel transaction") +} + +/// Reads the channel's committee state from the bedrock node, without a running +/// sequencer. `None` means the channel does not exist yet. +pub async fn read_channel_state(config: &BedrockConfig) -> Result> { + let node = NodeHttpClient::new( + CommonHttpClient::new(config.auth.clone().map(Into::into)), + config.node_url.clone(), + ); + node.channel_state(config.channel_id) + .await + .context("Failed to read channel state") +} + /// Signs a `ChannelConfig` op (accredited keys + rotation params) with /// `signing_key`, funds it from `config.funding_key` via the node's wallet, /// and posts it straight to the bedrock node. @@ -495,22 +739,13 @@ pub async fn post_channel_config( config.node_url.clone(), ); - // Fund the op from the node's wallet: the node appends a fee transfer - // (paid from `funding_key`, change back to it) and returns its proof. - let tx_builder = MantleTxBuilder::new() - .extend_ops([Op::ChannelConfig(config_op)]) - .map_err(|err| anyhow!("Too many ops in channel config transaction: {err:?}"))?; - let funded = node - .fund_tx(WalletFundRequestBody { - tip: None, - tx_builder, - change_public_key: config.funding_key, - funding_public_keys: vec![config.funding_key], - max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX), - priority_fee: FundingConfig::DEFAULT_PRIORITY_FEE, - }) - .await - .context("Failed to fund channel config transaction")?; + let funded = fund_ops( + &node, + config.funding_key, + config.priority_fee, + [Op::ChannelConfig(config_op)], + ) + .await?; let mantle_tx = funded.funded_tx; // Sign the funded tx: the appended fee transfer changes the hash. diff --git a/lez/sequencer/core/src/committee_discovery.rs b/lez/sequencer/core/src/committee_discovery.rs new file mode 100644 index 000000000..c521fd813 --- /dev/null +++ b/lez/sequencer/core/src/committee_discovery.rs @@ -0,0 +1,385 @@ +//! Discovery process for the `sequencer_stake` committee. + +use log::warn; +use sequencer_stake_core::{PendingUnstake, SequencerKey, SequencerStakeConfig, StakeRecord}; + +/// The accredited-keys list LEZ state says the channel should have, or `None` +/// if it already matches the live Bedrock committee. +/// +/// Level-triggered: re-fires on every block where the two disagree, not just +/// the block a key crossed the minimum in, so a submission that never lands +/// on Bedrock gets retried instead of being asked for once and forgotten. +/// +/// Doesn't cover channel administration params like `posting_timeframe` — +/// those are fixed constants supplied separately when building the +/// `ChannelConfigOp`. +#[must_use] +pub fn committee_update( + state: &lee::V03State, + live_accredited_keys: &[SequencerKey], +) -> Option> { + let config = read_config(state)?; + + // Sorted by key bytes so the list is deterministic across calls: a + // `ChannelConfigOp`'s `keys` field must reproduce the same order every + // time given the same state, since Bedrock's accredited-key index is + // positional. + let mut desired: Vec = config + .entries + .iter() + .filter(|(_, entry)| entry.net_stake() >= config.minimum_sequencer_stake) + .map(|(key, _)| *key) + .collect(); + desired.sort_unstable(); + + if desired.is_empty() { + warn!( + "No staked sequencer key meets the minimum; leaving the live committee untouched \ + since a channel cannot have zero accredited keys" + ); + return None; + } + + let mut live = live_accredited_keys.to_vec(); + live.sort_unstable(); + + (desired != live).then_some(desired) +} + +/// Ownership-account id + pending-release details for every entry with a +/// pending unstake — candidates *worth attempting*, not necessarily valid yet. +/// +/// Whether one is actually includable in a block is a separate check, +/// [`finalize_unstake_is_valid`], applied uniformly to every `FinalizeUnstake` +/// a block builder considers, regardless of whether it came from here (the +/// sequencer's own proactive construction) or from the mempool (anyone else +/// submitting it directly, per spec). +#[must_use] +pub fn finalize_unstake_candidates(state: &lee::V03State) -> Vec<(lee::AccountId, PendingUnstake)> { + let Some(config) = read_config(state) else { + return Vec::new(); + }; + + config + .entries + .into_values() + .filter_map(|entry| { + let record = stake_record(state, entry.account_id)?; + Some((entry.account_id, record.pending_unstake?)) + }) + .collect() +} + +/// Block-validity rule for a `FinalizeUnstake` on `ownership_id`. +/// +/// A partial release is always valid: `UnstakeRequest` already guarantees it +/// leaves the key at or above the minimum, so committee membership is +/// unaffected. A full drain — measured against tracked stake, not balance, +/// which anyone can inflate — is valid only once the key is no longer +/// accredited. An unknown account, no pending request, or no config entry +/// also counts as valid; the program itself rejects those cases anyway. +/// +/// TODO: checks live Bedrock membership, not an ordered history walk like the +/// spec calls for. Fine for the sequencer building the next block, but a +/// follower re-checking an already-adopted block has no independent way to +/// verify it this way. Switch once zone-sdk exposes ordered `ChannelConfigOp` +/// data to LEZ. +#[must_use] +pub fn finalize_unstake_is_valid( + state: &lee::V03State, + ownership_id: lee::AccountId, + live_accredited_keys: &[SequencerKey], +) -> bool { + let Some(record) = stake_record(state, ownership_id) else { + return true; + }; + let Some(pending) = record.pending_unstake else { + return true; + }; + let Some(entry) = + read_config(state).and_then(|config| config.entries.get(&record.sequencer_key).copied()) + else { + return true; + }; + + let fully_drains = entry.total_staked == pending.amount; + !fully_drains || !live_accredited_keys.contains(&record.sequencer_key) +} + +/// Reads the `sequencer_stake` config account — a single account read, not a +/// scan, since every `Stake`/`UnstakeRequest`/`FinalizeUnstake` keeps its +/// `entries` map current as it executes. `None` only if the account is absent +/// or undecodable, which genesis rules out. +fn read_config(state: &lee::V03State) -> Option { + let Some(account) = + state.get_account_by_id_ref(system_accounts::sequencer_stake_config_account_id()) + else { + warn!("sequencer_stake config account is absent"); + return None; + }; + let config = SequencerStakeConfig::from_bytes(account.data.as_ref()); + if config.is_none() { + warn!("sequencer_stake config account did not decode as SequencerStakeConfig"); + } + config +} + +/// The `StakeRecord` an ownership account carries: which key it backs, plus +/// whatever release is pending against it. +fn stake_record(state: &lee::V03State, ownership_id: lee::AccountId) -> Option { + let account = state.get_account_by_id_ref(ownership_id)?; + StakeRecord::from_bytes(account.data.as_ref()) +} + +#[must_use] +pub fn config_is_readable(state: &lee::V03State) -> bool { + read_config(state).is_some() +} + +#[cfg(test)] +mod tests { + use lee_core::account::Account; + use sequencer_stake_core::SequencerEntry; + + use super::*; + + const MINIMUM: u128 = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + + /// One staked key: the config entry plus the ownership account backing it. + #[derive(Clone, Copy)] + struct Staked { + key: SequencerKey, + account_id: lee::AccountId, + /// The config entry's tracked stake. + total: u128, + pending: Option, + /// The ownership account's, which sits above `total_staked` once + /// anyone donates to it. + balance: u128, + } + + impl Staked { + fn new(tag: u8, total: u128) -> Self { + Self { + key: test_key(tag), + account_id: lee::AccountId::new([tag.wrapping_add(100); 32]), + total, + pending: None, + balance: total, + } + } + + fn pending(mut self, amount: u128) -> Self { + self.pending = Some(PendingUnstake { + amount, + destination: lee::AccountId::new([200; 32]), + }); + self + } + + fn donated(mut self, amount: u128) -> Self { + self.balance = self.balance.saturating_add(amount); + self + } + } + + /// LEZ state holding the config account plus one ownership account per key. + fn state_with(stakes: impl IntoIterator) -> lee::V03State { + let stakes: Vec = stakes.into_iter().collect(); + + let ownership_accounts = stakes.iter().map(|staked| { + ( + staked.account_id, + Account { + program_owner: programs::sequencer_stake().id(), + balance: staked.balance, + data: StakeRecord { + sequencer_key: staked.key, + pending_unstake: staked.pending, + } + .to_bytes() + .try_into() + .expect("stake record fits"), + ..Account::default() + }, + ) + }); + + let config = Account { + program_owner: programs::sequencer_stake().id(), + data: SequencerStakeConfig { + minimum_sequencer_stake: MINIMUM, + entries: stakes + .iter() + .map(|staked| { + ( + staked.key, + SequencerEntry { + account_id: staked.account_id, + total_staked: staked.total, + total_pending_unstake: staked + .pending + .map_or(0, |pending| pending.amount), + }, + ) + }) + .collect(), + } + .to_bytes() + .try_into() + .expect("config fits"), + ..Account::default() + }; + + lee::V03State::new() + .with_public_accounts(ownership_accounts) + .with_public_accounts([(system_accounts::sequencer_stake_config_account_id(), config)]) + } + + /// A distinct valid key per `tag`. + fn test_key(tag: u8) -> SequencerKey { + let bytes = crate::block_publisher::Ed25519Key::from_bytes(&[tag; 32]) + .public_key() + .to_bytes(); + SequencerKey::new(bytes).expect("a derived public key is a curve point") + } + + #[test] + fn candidate_below_minimum_is_not_accredited() { + let staked = Staked::new(1, MINIMUM - 1); + + assert!(committee_update(&state_with([staked]), &[]).is_none()); + } + + #[test] + fn candidate_above_minimum_but_missing_live_is_added() { + let staked = Staked::new(2, MINIMUM); + + assert_eq!( + committee_update(&state_with([staked]), &[]), + Some(vec![staked.key]) + ); + } + + #[test] + fn already_matching_live_committee_is_not_re_submitted() { + let staked = Staked::new(3, MINIMUM); + + assert!(committee_update(&state_with([staked]), &[staked.key]).is_none()); + } + + #[test] + fn key_below_minimum_but_still_live_is_removed() { + let exiting = Staked::new(4, MINIMUM).pending(MINIMUM); + let staying = Staked::new(6, MINIMUM); + + assert_eq!( + committee_update(&state_with([exiting, staying]), &[exiting.key, staying.key]), + Some(vec![staying.key]) + ); + } + + #[test] + fn a_pending_unstake_discounts_the_stake_backing_a_key() { + let discounted = Staked::new(5, 2 * MINIMUM).pending(2 * MINIMUM); + let staying = Staked::new(7, MINIMUM); + + assert_eq!( + committee_update( + &state_with([discounted, staying]), + &[discounted.key, staying.key] + ), + Some(vec![staying.key]) + ); + } + + #[test] + fn an_empty_committee_is_never_submitted() { + let exiting = Staked::new(4, MINIMUM).pending(MINIMUM); + + assert_eq!( + committee_update(&state_with([exiting]), &[exiting.key]), + None + ); + } + + #[test] + fn mismatch_keeps_firing_until_live_matches() { + // A submission that never landed on Bedrock must be retried, not + // asked for once and forgotten. + let state = state_with([Staked::new(8, MINIMUM)]); + + assert!(committee_update(&state, &[]).is_some()); + assert!(committee_update(&state, &[]).is_some()); + } + + #[test] + fn accredited_keys_are_deterministically_sorted() { + let high = Staked::new(9, MINIMUM); + let low = Staked::new(1, MINIMUM); + + assert_eq!( + committee_update(&state_with([high, low]), &[]), + Some(vec![low.key, high.key]) + ); + } + + #[test] + fn every_pending_unstake_is_a_candidate_regardless_of_validity() { + // Even a not-yet-valid full drain is a candidate — validity is decided + // separately, by `finalize_unstake_is_valid`, uniformly for every + // FinalizeUnstake a block builder considers. + let staked = Staked::new(5, MINIMUM).pending(MINIMUM); + + assert_eq!( + finalize_unstake_candidates(&state_with([staked])), + vec![(staked.account_id, staked.pending.unwrap())] + ); + } + + #[test] + fn an_entry_with_no_pending_unstake_is_not_a_candidate() { + let staked = Staked::new(7, MINIMUM); + + assert!(finalize_unstake_candidates(&state_with([staked])).is_empty()); + } + + #[test] + fn a_partial_release_is_always_valid() { + let staked = Staked::new(5, MINIMUM + 10).pending(10); + let state = state_with([staked]); + + assert!(finalize_unstake_is_valid( + &state, + staked.account_id, + &[staked.key] + )); + assert!(finalize_unstake_is_valid(&state, staked.account_id, &[])); + } + + #[test] + fn a_full_drain_is_valid_only_once_absent_from_the_live_committee() { + let staked = Staked::new(6, MINIMUM).pending(MINIMUM); + let state = state_with([staked]); + + assert!(!finalize_unstake_is_valid( + &state, + staked.account_id, + &[staked.key] + )); + assert!(finalize_unstake_is_valid(&state, staked.account_id, &[])); + } + + #[test] + fn a_donated_balance_does_not_make_a_full_drain_look_partial() { + // The drain is measured against the tracked stake, so a donation + // sitting on the ownership account does not reclassify it as partial. + let staked = Staked::new(7, MINIMUM).pending(MINIMUM).donated(1); + + assert!(!finalize_unstake_is_valid( + &state_with([staked]), + staked.account_id, + &[staked.key] + )); + } +} diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index a044ea168..f992ef0d3 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -11,7 +11,7 @@ use bytesize::ByteSize; use common::config::BasicAuth; pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use humantime_serde; -use lee::{AccountId, Balance}; +use lee::{AccountId, Balance, PublicKey, Signature}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_key_management_system_service::keys::ZkPublicKey; use serde::{Deserialize, Serialize}; @@ -33,12 +33,31 @@ pub enum GenesisAction { holder: AccountId, amount: Balance, }, + /// Stakes `sequencer_key` at genesis. + StakeSequencer { + sequencer_key: sequencer_stake_core::SequencerKey, + ownership_public_key: PublicKey, + stake_signature: Signature, + }, +} + +/// Sequencer p2p gossip configuration. Absent (`None`) disables gossip +/// entirely: no sockets, no background tasks. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GossipConfig { + /// Multiaddr to listen on. + #[serde(default = "default_gossip_listen_addr")] + pub listen_addr: libp2p::Multiaddr, + /// Peer multiaddrs to dial at startup, optionally with `/p2p/`. + #[serde(default)] + pub bootstrap_peers: Vec, } // TODO: Provide default values #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SequencerConfig { - /// Home dir of sequencer storage. + /// Home dir of sequencer storage. Holds `bedrock_signing_key`, and + /// `sequencer_stake_signing_key` when a solo sequencer creates the channel. pub home: PathBuf, /// Maximum number of user transactions in a block (excludes the mandatory clock transaction). pub max_num_tx_in_block: usize, @@ -67,6 +86,9 @@ pub struct SequencerConfig { /// Address the Prometheus metrics exporter binds to. #[serde(default = "default_metrics_address")] pub metrics_address: Option, + /// Sequencer p2p gossip configuration. `None` disables gossip. + #[serde(default)] + pub gossip: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -109,12 +131,20 @@ const fn default_max_block_size() -> ByteSize { ByteSize::mib(1) } +fn default_gossip_listen_addr() -> libp2p::Multiaddr { + "/ip4/0.0.0.0/udp/0/quic-v1" + .parse() + .expect("hardcoded default gossip listen addr is a valid multiaddr") +} + #[expect(clippy::unnecessary_wraps, reason = "Required by serde")] const fn default_metrics_address() -> Option { Some(SequencerConfig::DEFAULT_METRICS_ADDRESS) } +/// Extra fee added to every funded Bedrock transaction, covering a gas price +/// rise before it is mined. #[must_use] pub const fn default_priority_fee() -> u64 { - logos_blockchain_zone_sdk::sequencer::FundingConfig::DEFAULT_PRIORITY_FEE + 10_000 } diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index b48a56e14..9a910df10 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -1,8 +1,10 @@ use std::{sync::Arc, time::Duration}; use common::{HashType, block::Block, transaction::LeeTransaction}; -use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission}; -use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit}; +use cross_zone::{ + EmissionSource, build_dispatch_from_emission, extract_emission, is_sequencer_only_program, +}; +use cross_zone_inbox_core::message_key; use futures::{Stream, StreamExt as _}; use lee::{GENESIS_BLOCK_ID, PublicKey}; use log::{debug, error, warn}; @@ -36,7 +38,6 @@ const STUCK_SLOT_ALERT_PASSES: u32 = 20; struct PeerContext { peer_zone: [u8; 32], self_zone: [u8; 32], - allowed_routes: Vec, expected_pubkey: Option, } @@ -280,7 +281,6 @@ pub fn spawn_watchers( PeerContext { peer_zone: peer.channel_id, self_zone, - allowed_routes: peer.allowed_routes, expected_pubkey, }, poll_interval, @@ -556,7 +556,6 @@ fn record_block_deliveries( ) -> bool { let peer_zone = peer.peer_zone; let self_zone = peer.self_zone; - let allowed_routes = peer.allowed_routes.as_slice(); // Collected and written once. The pending list is a single value, so a write // per delivery would rewrite the whole list once per message, which is // quadratic in a peer block that carries many of them, on a task holding the @@ -574,16 +573,15 @@ fn record_block_deliveries( if emission.target_zone != self_zone { continue; } - // Mirrors the inbox guest, which is the authority. Dropping here keeps - // an unroutable message from becoming a record that production would - // feed in and give up on three blocks later. - if !routes_permit( - allowed_routes, - message.program_id, - emission.target_program_id, - ) { + // Targets authorize their own sources now, so this is not authorization, + // it is hygiene: a delivery the zone will certainly refuse still costs a + // pending-list slot and three execution attempts before it is dead + // lettered. Kept host-side only, never in `extract_emission` or the + // verifier's re-derivation, where a check that depends on this build would + // make the two disagree and halt ingestion. + if is_sequencer_only_program(emission.target_program_id) { warn!( - "Watcher dropping message from peer {}: no route from that source program to that target", + "Watcher dropping message from peer {}: a peer may not dispatch into a sequencer-only program", hex::encode(peer_zone) ); continue; @@ -667,7 +665,7 @@ mod tests { }; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; - use ping_core::{SenderInstruction, ping_record_pda}; + use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; use storage::sequencer::{DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, RocksDBIO}; use tempfile::TempDir; @@ -680,10 +678,6 @@ mod tests { PeerContext { peer_zone: PEER_ZONE, self_zone: SELF_ZONE, - allowed_routes: vec![CrossZoneRoute { - src_program_id: programs::ping_sender().id(), - target_program_id: programs::ping_receiver().id(), - }], expected_pubkey: None, } } @@ -707,10 +701,12 @@ mod tests { fn emission_to(target_program_id: lee_core::program::ProgramId) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); let send = SenderInstruction::Send { - outbox_program_id: programs::cross_zone_outbox().id(), target_zone: SELF_ZONE, target_program_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload: b"hi".to_vec(), ordinal: 0, }; @@ -1080,13 +1076,49 @@ mod tests { } #[tokio::test] - async fn a_delivery_with_no_route_is_never_recorded() { - // The peer is routed to ping_receiver only. A bridging zone would also - // route its lock program to wrapped_token, and `ping_sender` lets its - // caller name wrapped_token as the target, so without the pair check - // this emission would be recorded and delivered, minting with nothing - // locked behind it. The guest rejects it too; dropping here keeps it - // from becoming a record production feeds in and gives up on. + async fn a_delivery_into_a_sequencer_only_program_is_never_recorded() { + // Targets authorize their own sources, so the watcher no longer decides + // who may reach what. It still refuses to queue a delivery the zone will + // certainly refuse: the inbox is injected by this node alone, so a peer + // naming it as a target is junk that would cost a pending slot and three + // execution attempts. + let (_dir, dbio) = store(); + let mut cursor = None; + let mut tip = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg_to( + 1, + 0, + programs::cross_zone_inbox().id(), + )]), + &peer_context(), + &dbio, + &mut cursor, + &mut tip, + ) + .await; + + assert_eq!( + outcome, + PassOutcome::Drained, + "a message the watcher drops is not a failure" + ); + assert!( + recorded_keys(&dbio).is_empty(), + "a message aimed at a sequencer-only program must not be recorded" + ); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + Some(Slot::from(0)), + "the slot was fully read, so the floor still advances" + ); + } + + #[tokio::test] + async fn a_delivery_to_an_unrelated_target_is_still_recorded() { + // The watcher is not the authorization point any more. A target it knows + // nothing about is recorded and delivered, and that target decides. let (_dir, dbio) = store(); let mut cursor = None; let mut tip = None; @@ -1104,19 +1136,11 @@ mod tests { ) .await; + assert_eq!(outcome, PassOutcome::Drained); assert_eq!( - outcome, - PassOutcome::Drained, - "an unroutable message is not a failure" - ); - assert!( - recorded_keys(&dbio).is_empty(), - "a message with no route must not be recorded" - ); - assert_eq!( - get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), - Some(Slot::from(0)), - "the slot was fully read, so the floor still advances" + recorded_keys(&dbio).len(), + 1, + "the watcher records it and lets the target refuse it" ); } diff --git a/lez/sequencer/core/src/gossip/accreditation/mod.rs b/lez/sequencer/core/src/gossip/accreditation/mod.rs new file mode 100644 index 000000000..82bbf484a --- /dev/null +++ b/lez/sequencer/core/src/gossip/accreditation/mod.rs @@ -0,0 +1,87 @@ +//! Source of the channel's current accredited key set, read from L1. Retained +//! for future ChannelConfig-signature work; not currently consumed by the +//! gossip mesh. +//! +//! FIXME: `NodeKeysProvider` will be replaced by an L2 Join/Leave-derived provider in a follow-up. +//! The related PR is . + +use std::{collections::HashSet, future::Future}; + +use anyhow::{Context as _, Result}; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_key_management_system_service::keys::Ed25519PublicKey; +use logos_blockchain_zone_sdk::{ + CommonHttpClient, + adapter::{Node as _, NodeHttpClient}, +}; + +use crate::config::BedrockConfig; + +pub trait AccreditedKeysProvider: Send + 'static { + /// The channel's current accredited Ed25519 keys. + /// + /// An empty set is valid, meaning that the channel does not exist yet. + fn accredited_keys(&self) -> impl Future>> + Send; +} + +/// Reads accredited keys from the bedrock node's channel state, on its own +/// HTTP connection (no coupling to the publisher's drive task). +pub struct NodeKeysProvider { + node: NodeHttpClient, + channel_id: ChannelId, +} + +impl NodeKeysProvider { + #[must_use] + pub fn new(config: &BedrockConfig) -> Self { + let node = NodeHttpClient::new( + CommonHttpClient::new(config.auth.clone().map(Into::into)), + config.node_url.clone(), + ); + Self { + node, + channel_id: config.channel_id, + } + } +} + +impl AccreditedKeysProvider for NodeKeysProvider { + async fn accredited_keys(&self) -> Result> { + let state = self + .node + .channel_state(self.channel_id) + .await + .context("Failed to read channel state for accredited keys")?; + + Ok(state + .map(|state| { + state + .accredited_keys + .iter() + .map(Ed25519PublicKey::to_bytes) + .collect() + }) + .unwrap_or_default()) + } +} + +/// Fixed key set, for tests. +pub struct StaticKeysProvider(pub HashSet<[u8; 32]>); + +impl AccreditedKeysProvider for StaticKeysProvider { + async fn accredited_keys(&self) -> Result> { + Ok(self.0.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn static_provider_returns_its_set() { + let keys = HashSet::from([[1; 32], [2; 32]]); + let provider = StaticKeysProvider(keys.clone()); + assert_eq!(provider.accredited_keys().await.unwrap(), keys); + } +} diff --git a/lez/sequencer/core/src/gossip/mod.rs b/lez/sequencer/core/src/gossip/mod.rs new file mode 100644 index 000000000..575456660 --- /dev/null +++ b/lez/sequencer/core/src/gossip/mod.rs @@ -0,0 +1,17 @@ +//! Sequencer p2p gossip: a libp2p swarm that discovers peers via Kademlia, +//! Identify, and bootstrap (plus mDNS behind a cargo feature). +//! +//! p2p is a latency optimization, never a source of truth: gossip being +//! down degrades to L1-only behavior, and a gossip failure after startup +//! never halts the node. + +pub use libp2p::Multiaddr; +pub use network::{GossipNetwork, GossipTxPublisher}; + +pub mod accreditation; +pub mod network; +pub mod seen_cache; +pub mod validation; + +#[cfg(test)] +mod tests; diff --git a/lez/sequencer/core/src/gossip/network.rs b/lez/sequencer/core/src/gossip/network.rs new file mode 100644 index 000000000..d8e4ae698 --- /dev/null +++ b/lez/sequencer/core/src/gossip/network.rs @@ -0,0 +1,666 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + time::Duration, +}; + +use anyhow::{Context as _, Result, anyhow}; +use common::transaction::LeeTransaction; +use futures::StreamExt as _; +#[cfg(feature = "mdns")] +use libp2p::mdns; +use libp2p::{ + Multiaddr, PeerId, SwarmBuilder, gossipsub, identify, + identity::Keypair, + kad, + multiaddr::Protocol, + swarm::{NetworkBehaviour, Swarm, SwarmEvent}, +}; +use logos_blockchain_key_management_system_service::keys::Ed25519Key; +use mempool::MemPoolHandle; +use tokio::sync::{mpsc, watch}; +use tokio_util::sync::CancellationToken; + +use crate::{TransactionOrigin, config::GossipConfig, gossip::seen_cache::SeenCache}; + +/// How long to wait for the first listen address before failing startup. +const LISTEN_TIMEOUT: Duration = Duration::from_secs(5); +/// How often the watchdog warns that the driver is down and the node is L1-only. +const DRIVER_OUTAGE_WARN_INTERVAL: Duration = Duration::from_secs(300); +/// Recently-seen gossiped transaction hashes kept for dedup. +const SEEN_CACHE_CAPACITY: usize = 4096; +/// Outbound local-publish channel depth; `try_send` drops on overflow. +const TX_PUBLISH_CHANNEL_CAPACITY: usize = 1024; +/// Headroom over `max_block_size` for `GossipSub` protobuf framing (signature, +/// source, seqno, topic) so a maximum-size transaction still fits the transmit +/// limit instead of being dropped at the transport before validation. +const GOSSIP_FRAME_MARGIN: u64 = 4096; +/// How often to re-dial bootstrap peers while the node has no connected +/// peers, so a node that starts before its bootstrap peer still joins. +const BOOTSTRAP_RETRY_INTERVAL: Duration = Duration::from_secs(30); +/// Local transactions whose publish failed (e.g. `InsufficientPeers` while +/// the mesh is still forming), kept for republish once a peer subscribes. +const PENDING_PUBLISH_CAPACITY: usize = 256; + +#[derive(NetworkBehaviour)] +struct GossipBehaviour { + gossipsub: gossipsub::Behaviour, + identify: identify::Behaviour, + kademlia: kad::Behaviour, + #[cfg(feature = "mdns")] + mdns: mdns::tokio::Behaviour, +} + +/// Handle to the running gossip network. Dropping it stops the drive task. +pub struct GossipNetwork { + connected_rx: watch::Receiver>, + shutdown: CancellationToken, + listen_addrs: Vec, + local_peer_id: PeerId, + tx_tx: mpsc::Sender, +} + +/// Handle for publishing locally-submitted transactions to the gossip mesh. +/// `publish` is non-blocking: a full channel drops the transaction rather +/// than back-pressuring the caller. +#[derive(Clone)] +pub struct GossipTxPublisher(mpsc::Sender); + +impl GossipTxPublisher { + pub fn publish(&self, tx: LeeTransaction) { + if let Err(err) = self.0.try_send(tx) { + log::debug!("Dropping local tx publish: outbound gossip channel full or closed: {err}"); + } + } +} + +impl GossipNetwork { + /// Builds the swarm, binds `listen_addr`, seeds Kademlia and dials + /// bootstrap peers, and spawns the drive task. + pub async fn start( + config: GossipConfig, + channel_id: [u8; 32], + signing_key: Ed25519Key, + mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + max_block_size: u64, + ) -> Result { + // Reuse the node's L1 bedrock signing key as the libp2p identity. The + // secret stays in a `Zeroizing` buffer that both `ed25519_from_bytes` + // and drop wipe. + // + // FIXME: get rid of `unsecure` here when we introduce accredited key + // handling, and a separete `Gossip node key -> Bedrock signing key` mapping. + let mut secret = signing_key.into_unsecured().to_bytes(); + let keypair = Keypair::ed25519_from_bytes(&mut *secret) + .map_err(|err| anyhow!("Invalid bedrock signing key for libp2p identity: {err}"))?; + let local_peer_id = keypair.public().to_peer_id(); + + let listen_addr = config.listen_addr; + let bootstrap = config.bootstrap_peers; + + let message_id_fn = |msg: &gossipsub::Message| { + // Undecodable messages still need a message-id, but it must be a + // deterministic digest, not the attacker-controlled bytes + // themselves and not a process-local hash (`DefaultHasher`): + // every peer has to derive the same id from the same data. + let id = borsh::from_slice::(&msg.data).map_or_else( + |_| common::block::OwnHasher::hash(&msg.data).0.to_vec(), + |tx| tx.hash().0.to_vec(), + ); + gossipsub::MessageId::from(id) + }; + // Derived from this node's `max_block_size`, so all nodes on a channel + // must agree on it: a node configured smaller would drop a larger frame + // its peers send at the codec (an inbound-stream close, not a clean + // application-level Reject), i.e. a near-invisible partial partition. + // `max_block_size` is already effectively a channel-wide parameter + // (block validation depends on it), so this inherits that requirement. + let max_transmit_size = usize::try_from(max_block_size.saturating_add(GOSSIP_FRAME_MARGIN)) + .unwrap_or(usize::MAX); + let gossipsub_config = gossipsub::ConfigBuilder::default() + .validation_mode(gossipsub::ValidationMode::Strict) + .message_id_fn(message_id_fn) + .validate_messages() + .max_transmit_size(max_transmit_size) + .build() + .map_err(|err| anyhow!("Failed to build gossipsub config: {err}"))?; + + let gossipsub_behaviour = gossipsub::Behaviour::new( + gossipsub::MessageAuthenticity::Signed(keypair.clone()), + gossipsub_config, + ) + .map_err(|err| anyhow!("Failed to build gossipsub behaviour: {err}"))?; + let identify_behaviour = + identify::Behaviour::new(identify::Config::new("/lez/1".to_owned(), keypair.public())); + let kademlia_behaviour = { + let store = kad::store::MemoryStore::new(local_peer_id); + let mut kademlia = kad::Behaviour::new(local_peer_id, store); + kademlia.set_mode(Some(kad::Mode::Server)); + kademlia + }; + #[cfg(feature = "mdns")] + let mdns_behaviour = mdns::tokio::Behaviour::new(mdns::Config::default(), local_peer_id) + .map_err(|err| anyhow!("Failed to build mdns behaviour: {err}"))?; + + let mut swarm = SwarmBuilder::with_existing_identity(keypair) + .with_tokio() + .with_quic() + .with_behaviour(|_key| GossipBehaviour { + gossipsub: gossipsub_behaviour, + identify: identify_behaviour, + kademlia: kademlia_behaviour, + #[cfg(feature = "mdns")] + mdns: mdns_behaviour, + }) + .expect("behaviour constructor is infallible") + .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(60))) + .build(); + + // subscribe to topic for the selected channel + let topic = Self::get_topic_for_channel(channel_id); + swarm + .behaviour_mut() + .gossipsub + .subscribe(&topic) + .context("Failed to subscribe to gossip tx topic")?; + + swarm + .listen_on(listen_addr) + .context("Failed to listen on gossip address")?; + + // Fail fast on bind errors: wait for the first listen address. + let listen_addrs = wait_for_listen_addr(&mut swarm).await?; + log::info!("Gossip listening on {listen_addrs:?} as {local_peer_id}"); + + // Seed Kademlia with bootstrap peers that carry an embedded peer id; + // dial the rest directly, since Kademlia can't route to an address + // without a known peer id. + for addr in &bootstrap { + let embedded_peer_id = match addr.iter().last() { + Some(Protocol::P2p(peer_id)) => Some(peer_id), + _ => None, + }; + if let Some(peer_id) = embedded_peer_id { + swarm + .behaviour_mut() + .kademlia + .add_address(&peer_id, addr.clone()); + continue; + } + if let Err(err) = swarm.dial(addr.clone()) { + log::warn!("Failed to dial gossip bootstrap peer {addr}: {err}"); + } + } + if let Err(err) = swarm.behaviour_mut().kademlia.bootstrap() { + log::debug!("Kademlia bootstrap skipped (no known peers yet): {err}"); + } + + let (connected_tx, connected_rx) = watch::channel(Vec::new()); + let shutdown = CancellationToken::new(); + let (tx_tx, tx_rx) = mpsc::channel::(TX_PUBLISH_CHANNEL_CAPACITY); + + let driver = tokio::spawn(run_drive_task(DriveTask { + swarm, + connected: HashSet::new(), + pubkeys: HashMap::new(), + connected_tx, + shutdown: shutdown.clone(), + topic, + mempool: mempool_handle, + seen: SeenCache::new(SEEN_CACHE_CAPACITY), + max_block_size, + tx_rx, + bootstrap, + pending_publish: VecDeque::new(), + })); + spawn_driver_watchdog(driver, shutdown.clone()); + + Ok(Self { + connected_rx, + shutdown, + listen_addrs, + local_peer_id, + tx_tx, + }) + } + + #[must_use] + pub fn get_topic_for_channel(channel_id: [u8; 32]) -> gossipsub::IdentTopic { + gossipsub::IdentTopic::new(format!("/lez/{}/v1/txs", hex::encode(channel_id))) + } + + #[must_use] + pub fn listen_addrs(&self) -> Vec { + self.listen_addrs.clone() + } + + /// Listen addresses with the `/p2p/` peer id appended — the form other + /// nodes put in `bootstrap_peers`. + #[must_use] + pub fn bootstrap_addrs(&self) -> Vec { + self.listen_addrs + .iter() + .map(|addr| addr.clone().with(Protocol::P2p(self.local_peer_id))) + .collect() + } + + #[must_use] + pub const fn local_peer_id(&self) -> PeerId { + self.local_peer_id + } + + /// Handle for publishing locally-submitted transactions to the mesh. + #[must_use] + pub fn tx_publisher(&self) -> GossipTxPublisher { + GossipTxPublisher(self.tx_tx.clone()) + } + + /// Ed25519 public keys of currently connected peers. + #[must_use] + pub fn connected_peers(&self) -> Vec<[u8; 32]> { + self.connected_rx.borrow().clone() + } + + /// Cancelled when a graceful shutdown is requested (the handle is dropped). + /// Observers must NOT halt the node on it. + #[must_use] + pub fn shutdown_token(&self) -> CancellationToken { + self.shutdown.clone() + } +} + +impl Drop for GossipNetwork { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +/// Everything the drive task owns. +struct DriveTask { + swarm: Swarm, + connected: HashSet, + /// Ed25519 public keys of peers seen via Identify, keyed by `PeerId`. + pubkeys: HashMap, + connected_tx: watch::Sender>, + shutdown: CancellationToken, + topic: gossipsub::IdentTopic, + mempool: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + seen: SeenCache, + max_block_size: u64, + tx_rx: mpsc::Receiver, + /// Configured bootstrap peers, re-dialed while the node is isolated. + bootstrap: Vec, + /// Local transactions whose publish failed, retried when a peer + /// subscribes to the topic. Bounded; the oldest is dropped on overflow. + pending_publish: VecDeque, +} + +impl DriveTask { + #[expect( + clippy::wildcard_enum_match_arm, + reason = "SwarmEvent is non_exhaustive; only connection and behaviour events are handled" + )] + fn on_swarm_event(&mut self, event: SwarmEvent) { + match event { + SwarmEvent::ConnectionEstablished { peer_id, .. } => { + self.connected.insert(peer_id); + self.update_connected_watch(); + } + SwarmEvent::ConnectionClosed { + peer_id, + num_established: 0, + .. + } => { + self.connected.remove(&peer_id); + self.pubkeys.remove(&peer_id); + self.update_connected_watch(); + } + SwarmEvent::Behaviour(behaviour_event) => self.on_behaviour_event(behaviour_event), + _ => {} + } + } + + // `GossipBehaviourEvent` is generated by `#[derive(NetworkBehaviour)]`; + // clippy does not flag wildcard matches against macro-generated enums, + // so no `#[expect(clippy::wildcard_enum_match_arm)]` is needed here. + fn on_behaviour_event(&mut self, event: GossipBehaviourEvent) { + match event { + GossipBehaviourEvent::Gossipsub(gossipsub::Event::Message { + propagation_source, + message_id, + message, + }) => { + self.on_gossip_message(propagation_source, &message_id, &message.data); + } + GossipBehaviourEvent::Gossipsub(gossipsub::Event::Subscribed { topic, .. }) + if topic == self.topic.hash() => + { + self.flush_pending_publishes(); + } + GossipBehaviourEvent::Identify(identify::Event::Received { peer_id, info, .. }) => { + if let Ok(ed25519_pubkey) = info.public_key.try_into_ed25519() { + self.pubkeys.insert(peer_id, ed25519_pubkey.to_bytes()); + self.update_connected_watch(); + } + for addr in info + .listen_addrs + .into_iter() + .filter(|addr| !is_unspecified(addr)) + { + self.swarm + .behaviour_mut() + .kademlia + .add_address(&peer_id, addr); + } + } + #[cfg(feature = "mdns")] + GossipBehaviourEvent::Mdns(mdns::Event::Discovered(peers)) => { + for (peer_id, addr) in peers { + if let Err(err) = self.swarm.dial(addr) { + log::debug!("Failed to dial mdns-discovered peer {peer_id}: {err}"); + } + } + } + _ => {} + } + } + + fn update_connected_watch(&self) { + let mut peers: Vec<[u8; 32]> = self + .connected + .iter() + .filter_map(|peer_id| self.pubkeys.get(peer_id).copied()) + .collect(); + peers.sort_unstable(); + self.connected_tx.send_if_modified(|current| { + if *current == peers { + false + } else { + *current = peers; + true + } + }); + } + + /// Validates an inbound gossiped transaction and reports the mesh + /// acceptance decision, admitting it to the mempool on first sight. + fn on_gossip_message( + &mut self, + source: PeerId, + message_id: &gossipsub::MessageId, + data: &[u8], + ) { + use crate::gossip::validation::{TxEvaluation, evaluate_transaction}; + + let acceptance = match evaluate_transaction(data, self.max_block_size) { + TxEvaluation::Reject(reason) => { + log::debug!("Rejecting gossiped tx from {source}: {reason}"); + gossipsub::MessageAcceptance::Reject + } + TxEvaluation::Accept(tx) => { + let hash = tx.hash(); + if self.seen.contains(&hash) { + gossipsub::MessageAcceptance::Ignore + } else { + match self.mempool.try_push((TransactionOrigin::Gossip, tx)) { + Ok(()) => { + // mark seen only on successful pushes, so that if mempool is full we + // can later receive the same tx from gossip and try pushing it again + self.seen.insert(hash); + } + Err(_) => { + log::debug!("Mempool full; forwarding tx {hash:?} without admitting"); + } + } + + gossipsub::MessageAcceptance::Accept + } + } + }; + + _ = self + .swarm + .behaviour_mut() + .gossipsub + .report_message_validation_result(message_id, &source, acceptance); + } + + /// Publishes a locally-submitted transaction to the mesh. Marked seen + /// only once actually published; a failed publish (e.g. + /// `InsufficientPeers` while the mesh is still forming) is queued and + /// retried when a peer subscribes to the topic. + fn publish_transaction(&mut self, tx: LeeTransaction) { + let hash = tx.hash(); + let bytes = borsh::to_vec(&tx).expect("tx borsh serialization should not fail"); + match self + .swarm + .behaviour_mut() + .gossipsub + .publish(self.topic.clone(), bytes) + { + // Duplicate means the mesh already carries this message. + Ok(_) | Err(gossipsub::PublishError::Duplicate) => { + self.seen.insert(hash); + } + Err(err) => { + log::debug!("Queueing local tx publish {hash:?} for retry: {err}"); + if self.pending_publish.len() >= PENDING_PUBLISH_CAPACITY + && let Some(dropped) = self.pending_publish.pop_front() + { + log::debug!( + "Pending publish queue full; dropping oldest tx {:?}", + dropped.hash() + ); + } + self.pending_publish.push_back(tx); + } + } + } + + /// Retries queued local publishes; still-failing ones are re-queued by + /// `publish_transaction`. + fn flush_pending_publishes(&mut self) { + for tx in std::mem::take(&mut self.pending_publish) { + self.publish_transaction(tx); + } + } + + /// Re-dials bootstrap peers while the node is isolated. The startup + /// attempt runs once, so a node that starts before its bootstrap peer + /// would otherwise never join the mesh. + fn retry_bootstrap(&mut self) { + if !self.connected.is_empty() || self.bootstrap.is_empty() { + return; + } + log::debug!( + "No connected gossip peers; retrying {} bootstrap peer(s)", + self.bootstrap.len() + ); + for addr in self.bootstrap.clone() { + if let Err(err) = self.swarm.dial(addr.clone()) { + log::debug!("Failed to dial gossip bootstrap peer {addr}: {err}"); + } + } + _ = self.swarm.behaviour_mut().kademlia.bootstrap(); + } +} + +/// True if `addr` carries an unspecified (`0.0.0.0` / `::`) IP component. +/// Peers behind a default `0.0.0.0` listen address advertise these; feeding +/// them to Kademlia would pollute the routing table with unroutable entries. +#[expect( + clippy::wildcard_enum_match_arm, + reason = "Protocol is non_exhaustive; only the IP variants matter here" +)] +fn is_unspecified(addr: &Multiaddr) -> bool { + addr.iter().any(|proto| match proto { + Protocol::Ip4(ip) => ip.is_unspecified(), + Protocol::Ip6(ip) => ip.is_unspecified(), + _ => false, + }) +} + +/// Derives the libp2p `PeerId` an Ed25519 public key produces. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "unused by the mesh until a later gossip task; exercised by the identity test below" + ) +)] +pub(crate) fn peer_id_from_ed25519( + pubkey: &[u8; 32], +) -> Result { + libp2p::identity::ed25519::PublicKey::try_from_bytes(pubkey) + .map(|key| libp2p::identity::PublicKey::from(key).to_peer_id()) +} + +#[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" +)] +#[expect( + clippy::wildcard_enum_match_arm, + reason = "SwarmEvent is non_exhaustive; only startup listener events are handled here" +)] +async fn wait_for_listen_addr(swarm: &mut Swarm) -> Result> { + let deadline = tokio::time::sleep(LISTEN_TIMEOUT); + tokio::pin!(deadline); + loop { + tokio::select! { + event = swarm.select_next_some() => match event { + SwarmEvent::NewListenAddr { address, .. } => return Ok(vec![address]), + SwarmEvent::ListenerError { error, .. } => { + anyhow::bail!("Gossip listener error during startup: {error}"); + } + SwarmEvent::ListenerClosed { reason, .. } => { + anyhow::bail!("Gossip listener closed during startup: {reason:?}"); + } + _ => {} + }, + () = &mut deadline => { + anyhow::bail!("Timed out waiting for gossip listen address"); + } + } + } +} + +#[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" +)] +async fn run_drive_task(mut task: DriveTask) { + // `interval_at`: startup already dialed the bootstrap peers, so the + // first tick waits a full interval instead of firing immediately. + let mut bootstrap_retry = tokio::time::interval_at( + tokio::time::Instant::now() + .checked_add(BOOTSTRAP_RETRY_INTERVAL) + .expect("bootstrap retry deadline within Instant range"), + BOOTSTRAP_RETRY_INTERVAL, + ); + loop { + tokio::select! { + () = task.shutdown.cancelled() => break, + event = task.swarm.select_next_some() => task.on_swarm_event(event), + Some(tx) = task.tx_rx.recv() => task.publish_transaction(tx), + _ = bootstrap_retry.tick() => task.retry_bootstrap(), + } + } +} + +/// Owns the driver handle so it can detect the task ending. A graceful shutdown +/// cancels `shutdown` first, so that path stays silent; a crash leaves it +/// uncancelled, and operators are warned the node is running L1-only until the +/// handle is dropped. +#[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" +)] +fn spawn_driver_watchdog(driver: tokio::task::JoinHandle<()>, shutdown: CancellationToken) { + tokio::spawn(async move { + _ = driver.await; + if shutdown.is_cancelled() { + return; + } + loop { + log::error!( + "Sequencer gossip network is down; continuing L1-only. \ + Restart the node to restore p2p." + ); + tokio::select! { + () = shutdown.cancelled() => return, + () = tokio::time::sleep(DRIVER_OUTAGE_WARN_INTERVAL) => {} + } + } + }); +} + +#[cfg(test)] +mod tests { + use logos_blockchain_key_management_system_service::keys::Ed25519Key; + + use super::*; + use crate::config::GossipConfig; + + const TEST_MAX_BLOCK_SIZE: u64 = 1 << 20; + + fn test_config() -> GossipConfig { + GossipConfig { + listen_addr: "/ip4/127.0.0.1/udp/0/quic-v1".parse().unwrap(), + bootstrap_peers: vec![], + } + } + + fn test_mempool_handle() -> MemPoolHandle<(TransactionOrigin, LeeTransaction)> { + mempool::MemPool::new(1000).1 + } + + #[test] + fn libp2p_identity_matches_kms_public_key() { + // The PeerId derived from an Ed25519 public key must equal the + // PeerId the same secret produces as a libp2p identity. + let secret = [9; 32]; + let kms_pubkey = Ed25519Key::from_bytes(&secret).public_key().to_bytes(); + let mut secret_for_libp2p = secret; + let keypair = + libp2p::identity::Keypair::ed25519_from_bytes(&mut secret_for_libp2p).unwrap(); + assert_eq!( + peer_id_from_ed25519(&kms_pubkey).unwrap(), + keypair.public().to_peer_id() + ); + } + + #[tokio::test] + async fn start_binds_and_reports_listen_addr() { + let network = GossipNetwork::start( + test_config(), + [1; 32], + Ed25519Key::from_bytes(&[9; 32]), + test_mempool_handle(), + TEST_MAX_BLOCK_SIZE, + ) + .await + .unwrap(); + let addrs = network.listen_addrs(); + assert!(!addrs.is_empty()); + assert!(addrs[0].to_string().contains("/udp/")); + assert!(network.connected_peers().is_empty()); + } + + #[tokio::test] + async fn drop_cancels_driver() { + let network = GossipNetwork::start( + test_config(), + [1; 32], + Ed25519Key::from_bytes(&[9; 32]), + test_mempool_handle(), + TEST_MAX_BLOCK_SIZE, + ) + .await + .unwrap(); + let token = network.shutdown_token(); + drop(network); + tokio::time::timeout(std::time::Duration::from_secs(5), token.cancelled()) + .await + .expect("driver should stop when the handle is dropped"); + } +} diff --git a/lez/sequencer/core/src/gossip/seen_cache.rs b/lez/sequencer/core/src/gossip/seen_cache.rs new file mode 100644 index 000000000..3c42da33a --- /dev/null +++ b/lez/sequencer/core/src/gossip/seen_cache.rs @@ -0,0 +1,116 @@ +use std::collections::{HashSet, VecDeque}; + +use common::HashType; + +/// Bounded, FIFO-eviction membership cache over transaction hashes. +/// +/// The mempool is a plain channel with no dedup, so the gossip layer tracks +/// recently seen transactions here to avoid re-admitting duplicates that +/// arrive from multiple peers or echo back after a local publish. +/// +/// TODO: expose counters to `metrics` later. +pub struct SeenCache { + capacity: usize, + order: VecDeque, + set: HashSet, + hits: u64, + inserts: u64, + evictions: u64, +} + +impl SeenCache { + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(1), + order: VecDeque::new(), + set: HashSet::new(), + hits: 0, + inserts: 0, + evictions: 0, + } + } + + /// Records `hash` as seen. Returns `true` if it was newly inserted, + /// `false` if already present (a hit). Evicts the oldest entry when full. + pub fn insert(&mut self, hash: HashType) -> bool { + if self.set.contains(&hash) { + self.hits = self.hits.saturating_add(1); + return false; + } + if self.order.len() >= self.capacity + && let Some(oldest) = self.order.pop_front() + { + self.set.remove(&oldest); + self.evictions = self.evictions.saturating_add(1); + } + self.set.insert(hash); + self.order.push_back(hash); + self.inserts = self.inserts.saturating_add(1); + true + } + + #[must_use] + pub fn contains(&self, hash: &HashType) -> bool { + self.set.contains(hash) + } + + #[must_use] + pub fn len(&self) -> usize { + self.order.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.order.is_empty() + } + + #[must_use] + pub const fn hits(&self) -> u64 { + self.hits + } + + #[must_use] + pub const fn inserts(&self) -> u64 { + self.inserts + } + + #[must_use] + pub const fn evictions(&self) -> u64 { + self.evictions + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h(n: u8) -> HashType { + HashType([n; 32]) + } + + #[test] + fn insert_reports_novelty_and_contains() { + let mut cache = SeenCache::new(4); + assert!(cache.insert(h(1))); + assert!(!cache.insert(h(1))); + assert!(cache.contains(&h(1))); + assert!(!cache.contains(&h(2))); + assert_eq!(cache.len(), 1); + assert_eq!(cache.inserts(), 1); + assert_eq!(cache.hits(), 1); + } + + #[test] + fn evicts_oldest_past_capacity() { + let mut cache = SeenCache::new(2); + assert!(cache.insert(h(1))); + assert!(cache.insert(h(2))); + assert!(cache.insert(h(3))); // evicts h(1) + assert!(!cache.contains(&h(1))); + assert!(cache.contains(&h(2))); + assert!(cache.contains(&h(3))); + assert_eq!(cache.len(), 2); + assert_eq!(cache.evictions(), 1); + } +} diff --git a/lez/sequencer/core/src/gossip/tests.rs b/lez/sequencer/core/src/gossip/tests.rs new file mode 100644 index 000000000..a0e586b69 --- /dev/null +++ b/lez/sequencer/core/src/gossip/tests.rs @@ -0,0 +1,192 @@ +use std::time::{Duration, Instant}; + +use common::transaction::LeeTransaction; +use logos_blockchain_key_management_system_service::keys::Ed25519Key; +use mempool::MemPool; +use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; + +use crate::{TransactionOrigin, config::GossipConfig, gossip::GossipNetwork}; + +const CHANNEL: [u8; 32] = [1; 32]; +const TEST_MAX_BLOCK_SIZE: u64 = 1 << 20; + +fn pubkey(secret: [u8; 32]) -> [u8; 32] { + Ed25519Key::from_bytes(&secret).public_key().to_bytes() +} + +/// A real, validly-signed transfer, reusing the same helper the RPC-side +/// admission tests use. +fn valid_transaction() -> LeeTransaction { + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let sign_key1 = initial_pub_accounts_private_keys()[0].pub_sign_key.clone(); + common::test_utils::create_transaction_native_token_transfer(acc1, 0, acc2, 10, &sign_key1) +} + +/// Structurally well-formed but with a signature/public-key pair that does +/// not match, so it decodes but fails the stateless witness check; used to +/// exercise rejection through the real gossip pipeline rather than +/// `evaluate_transaction` directly. +fn invalidly_signed_transaction() -> LeeTransaction { + let LeeTransaction::Public(mut tx) = valid_transaction() else { + unreachable!("valid_transaction always builds a Public transaction"); + }; + let (signature, _correct_public_key) = tx.witness_set.signatures_and_public_keys()[0].clone(); + let wrong_public_key = + lee::PublicKey::new_from_private_key(&initial_pub_accounts_private_keys()[1].pub_sign_key); + tx.witness_set = + lee::public_transaction::WitnessSet::from_raw_parts(vec![(signature, wrong_public_key)]); + LeeTransaction::Public(tx) +} + +async fn start_node( + secret: [u8; 32], + bootstrap: Vec, +) -> (GossipNetwork, MemPool<(TransactionOrigin, LeeTransaction)>) { + let config = GossipConfig { + listen_addr: "/ip4/127.0.0.1/udp/0/quic-v1".parse().unwrap(), + bootstrap_peers: bootstrap, + }; + let (mempool, mempool_handle) = MemPool::new(1000); + let network = GossipNetwork::start( + config, + CHANNEL, + Ed25519Key::from_bytes(&secret), + mempool_handle, + TEST_MAX_BLOCK_SIZE, + ) + .await + .expect("node should start"); + (network, mempool) +} + +async fn wait_for(timeout: Duration, mut condition: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + .checked_add(timeout) + .expect("deadline within Instant range"); + while Instant::now() < deadline { + if condition() { + return true; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + false +} + +#[tokio::test] +async fn nodes_discover_each_other_via_bootstrap() { + let secrets = [[10; 32], [11; 32], [12; 32]]; + let (node_a, _mempool_a) = start_node(secrets[0], vec![]).await; + let a_addr = node_a.listen_addrs()[0].clone(); + // B bootstraps with a `/p2p/`-suffixed address (the Kademlia-seeded + // branch operators configure), C with a plain one (the direct-dial + // branch), covering both paths in `GossipNetwork::start`. + let a_addr_with_peer_id = a_addr + .clone() + .with(libp2p::multiaddr::Protocol::P2p(node_a.local_peer_id())); + let (node_b, _mempool_b) = start_node(secrets[1], vec![a_addr_with_peer_id]).await; + let (node_c, _mempool_c) = start_node(secrets[2], vec![a_addr]).await; + + assert!( + wait_for(Duration::from_secs(30), || { + node_a.connected_peers().contains(&pubkey(secrets[1])) + && node_a.connected_peers().contains(&pubkey(secrets[2])) + }) + .await, + "A never connected to both B and C; A sees {:?}", + node_a.connected_peers() + ); + drop((node_a, node_b, node_c)); +} + +#[tokio::test] +async fn transaction_submitted_to_one_node_reaches_others() { + let secrets = [[20; 32], [21; 32], [22; 32]]; + let (node_a, _mempool_a) = start_node(secrets[0], vec![]).await; + let a_addr = node_a.listen_addrs()[0].clone(); + let (node_b, mut mempool_b) = start_node(secrets[1], vec![a_addr.clone()]).await; + let (node_c, mut mempool_c) = start_node(secrets[2], vec![a_addr]).await; + + assert!( + wait_for(Duration::from_secs(30), || { + node_a.connected_peers().contains(&pubkey(secrets[1])) + && node_a.connected_peers().contains(&pubkey(secrets[2])) + }) + .await, + "A never connected to both B and C" + ); + + let tx = valid_transaction(); + let expected_hash = tx.hash(); + node_a.tx_publisher().publish(tx.clone()); + + assert!( + wait_for(Duration::from_secs(30), || { + mempool_b + .pop() + .is_some_and(|(_, received)| received.hash() == expected_hash) + }) + .await, + "B never received the gossiped transaction" + ); + assert!( + wait_for(Duration::from_secs(30), || { + mempool_c + .pop() + .is_some_and(|(_, received)| received.hash() == expected_hash) + }) + .await, + "C never received the gossiped transaction" + ); + drop((node_a, node_b, node_c)); +} + +#[tokio::test] +async fn invalid_transaction_is_not_propagated() { + // `TxPublisher::publish` only accepts a `LeeTransaction`, so genuinely + // undecodable bytes are not reachable through the public API; instead we + // publish a structurally well-formed transaction with an invalid + // signature, which still exercises the real gossip pipeline's rejection + // path (`evaluate_transaction`'s stateless check, then + // `MessageAcceptance::Reject`) end-to-end. + let secrets = [[30; 32], [31; 32]]; + let (node_a, _mempool_a) = start_node(secrets[0], vec![]).await; + let a_addr = node_a.listen_addrs()[0].clone(); + let (node_b, mut mempool_b) = start_node(secrets[1], vec![a_addr]).await; + + assert!( + wait_for(Duration::from_secs(30), || { + node_a.connected_peers().contains(&pubkey(secrets[1])) + }) + .await, + "A never connected to B" + ); + + // Publish a valid transaction first and wait for it to arrive: swarm + // connectivity alone does not mean the gossipsub mesh has grafted, and + // without proof the link is live the absence assertion below would pass + // vacuously. + let valid_tx = valid_transaction(); + let valid_hash = valid_tx.hash(); + node_a.tx_publisher().publish(valid_tx); + assert!( + wait_for(Duration::from_secs(30), || { + mempool_b + .pop() + .is_some_and(|(_, received)| received.hash() == valid_hash) + }) + .await, + "B never received the valid transaction; gossip link not live" + ); + + node_a + .tx_publisher() + .publish(invalidly_signed_transaction()); + + tokio::time::sleep(Duration::from_secs(2)).await; + assert!( + mempool_b.pop().is_none(), + "an invalidly-signed transaction must not reach the mempool" + ); + drop((node_a, node_b)); +} diff --git a/lez/sequencer/core/src/gossip/validation.rs b/lez/sequencer/core/src/gossip/validation.rs new file mode 100644 index 000000000..77bbe4397 --- /dev/null +++ b/lez/sequencer/core/src/gossip/validation.rs @@ -0,0 +1,90 @@ +//! Pure decision function for an inbound gossiped transaction. +//! +//! The same stateless admission the RPC performs, minus mempool/seen-cache +//! side effects (those live in the drive task). Testable without a swarm. + +use common::transaction::LeeTransaction; + +/// Reserve ~200 bytes for block header overhead, mirroring the RPC check. +const BLOCK_HEADER_OVERHEAD: u64 = 200; + +#[derive(Debug)] +pub enum TxEvaluation { + /// Structurally valid and authenticated; forward and admit. + Accept(LeeTransaction), + /// Malformed / forbidden; do not forward. `GossipSub` peer scoring is not + /// configured, so this does not currently penalize the propagating peer. + Reject(String), +} + +/// Decodes and stateless-checks a gossiped transaction the same way the RPC +/// admits a submitted one: size check, signature/witness check, then the +/// sequencer-only-program guard. +#[must_use] +pub fn evaluate_transaction(data: &[u8], max_block_size: u64) -> TxEvaluation { + let tx_size = u64::try_from(data.len()).unwrap_or(u64::MAX); + let max_tx_size = max_block_size.saturating_sub(BLOCK_HEADER_OVERHEAD); + if tx_size > max_tx_size { + return TxEvaluation::Reject(format!("transaction too large: {tx_size} > {max_tx_size}")); + } + + let tx: LeeTransaction = match borsh::from_slice(data) { + Ok(tx) => tx, + Err(err) => return TxEvaluation::Reject(format!("undecodable transaction: {err}")), + }; + + let authenticated = match tx.transaction_stateless_check() { + Ok(tx) => tx, + Err(err) => return TxEvaluation::Reject(format!("stateless check failed: {err:?}")), + }; + + if let LeeTransaction::Public(public_tx) = &authenticated + && crate::is_sequencer_only_program(public_tx.message().program_id) + { + return TxEvaluation::Reject("sequencer-only program".to_owned()); + } + + TxEvaluation::Accept(authenticated) +} + +#[cfg(test)] +mod tests { + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; + + use super::*; + + fn valid_transaction() -> LeeTransaction { + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let sign_key1 = initial_pub_accounts_private_keys()[0].pub_sign_key.clone(); + common::test_utils::create_transaction_native_token_transfer(acc1, 0, acc2, 10, &sign_key1) + } + + #[test] + fn well_formed_transaction_is_accepted() { + let tx = valid_transaction(); + let bytes = borsh::to_vec(&tx).unwrap(); + assert!(matches!( + evaluate_transaction(&bytes, 1 << 20), + TxEvaluation::Accept(_) + )); + } + + #[test] + fn garbage_bytes_are_rejected() { + assert!(matches!( + evaluate_transaction(&[0xff, 0xff, 0xff], 1 << 20), + TxEvaluation::Reject(_) + )); + } + + #[test] + fn oversize_transaction_is_rejected() { + let tx = valid_transaction(); + let bytes = borsh::to_vec(&tx).unwrap(); + assert!(matches!( + evaluate_transaction(&bytes, 1), + TxEvaluation::Reject(_) + )); + } +} diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index aeb1055d5..1199aa705 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -2,7 +2,7 @@ use std::{ collections::VecDeque, path::Path, sync::{Arc, Mutex}, - time::Instant, + time::{Duration, Instant}, }; use anyhow::{Context as _, Result, anyhow}; @@ -21,7 +21,8 @@ use futures::StreamExt as _; use itertools::Itertools as _; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::GENESIS_BLOCK_ID; -use log::{debug, error, warn}; +use log::{debug, error, info, warn}; +use logos_blockchain_core::mantle::ops::channel::Ed25519PublicKey; use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; use logos_blockchain_zone_sdk::{ Slot, ZoneMessage, @@ -42,6 +43,7 @@ use storage::sequencer::{ WithdrawalReconciliationKey, ZoneAnchorRecord, }, }; +use tokio_retry::{Retry, strategy::FixedInterval}; use crate::{ block_publisher::{BlockPublisherTrait, MsgId, NoteId, ZoneSdkPublisher}, @@ -51,8 +53,10 @@ use crate::{ pub mod block_publisher; pub mod block_store; +pub mod committee_discovery; pub mod config; pub mod cross_zone_watcher; +pub mod gossip; #[cfg(feature = "mock")] pub mod mock; @@ -74,6 +78,25 @@ const RETIRE_DISPATCH_AFTER_FAILURES: u32 = 3; /// block; nothing is dropped. const MAX_DISPATCHES_PER_BLOCK: usize = 16; +/// Fixed, public key behind a genesis-only funding account: the faucet can +/// only be called top-level, not as `Stake`'s mover, so this account is a +/// pass-through that receives faucet funds and then moves them into the real +/// stake account. Not a secret: every node derives the same account, and it +/// holds nothing once genesis has run. +// TODO: replace the faucet pass-through with a real deposit from Bedrock, +// once that path exists, instead of a fixed genesis-only key. +const GENESIS_STAKE_FUNDING_KEY: [u8; 32] = [9; 32]; + +/// A number of Bedrock slots, as opposed to a [`Slot`] position. +type SlotCount = u64; + +/// A founding sequencer's key, plus the ownership account attesting to its stake. +type FoundingStake = ( + sequencer_stake_core::SequencerKey, + lee::PublicKey, + lee::Signature, +); + /// The origin of a transaction. #[derive(Clone, Copy)] pub enum TransactionOrigin { @@ -81,6 +104,8 @@ pub enum TransactionOrigin { User, /// Transactions generated by the sequencer itself. Sequencer, + /// Transactions received via p2p gossip from a peer sequencer. + Gossip, } impl From for sequencer_core_metrics::TransactionOrigin { @@ -88,6 +113,7 @@ impl From for sequencer_core_metrics::TransactionOrigin { match origin { TransactionOrigin::User => Self::User, TransactionOrigin::Sequencer => Self::Sequencer, + TransactionOrigin::Gossip => Self::Gossip, } } } @@ -109,15 +135,26 @@ pub struct SequencerCore { /// store handle, so leaving them running would keep the `RocksDB` lock held /// and make the home directory unopenable by a restarting sequencer. watchers: TaskGroup, + /// Channel tip slot as of the last committee-config submission. + last_committee_submission_slot: Option, } impl SequencerCore { + const CHANNEL_PROBE_RETRIES: usize = 29; + const CHANNEL_PROBE_RETRY_DELAY: Duration = Duration::from_secs(2); + /// Channel slots between committee-config submissions; a margin over + /// observed Bedrock confirmation lag. + const COMMITTEE_SUBMISSION_COOLDOWN: SlotCount = 10; + /// Starts the sequencer using the provided configuration. /// If an existing database is found, the sequencer state is loaded from it and /// 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) { + fn open_or_create_store( + config: &SequencerConfig, + bootstrap_sequencer_key: Option, + ) -> (SequencerStore, lee::V03State) { let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); let db_path = config.db_path(); @@ -146,7 +183,7 @@ impl SequencerCore { db_path.display() ); - let (genesis_state, genesis_txs) = build_genesis_state(config); + let (genesis_state, genesis_txs) = build_genesis_state(config, bootstrap_sequencer_key); let hashable_data = HashableBlockData { block_id: GENESIS_BLOCK_ID, @@ -228,7 +265,36 @@ impl SequencerCore { hex::encode(bedrock_signing_key.public_key().to_bytes()) ); - let (store, state) = Self::open_or_create_store(&config); + let own_sequencer_key = + sequencer_stake_core::SequencerKey::new(bedrock_signing_key.public_key().to_bytes()) + .expect("our own Bedrock public key is a valid Ed25519 public key"); + + // Only seed our own key into genesis as the bootstrap sequencer if the + // channel doesn't exist yet. Otherwise it's someone else's channel and + // we join later, the normal self-join way. + let channel_probe_retry_strategy = + FixedInterval::new(Self::CHANNEL_PROBE_RETRY_DELAY).take(Self::CHANNEL_PROBE_RETRIES); + let channel_already_exists = Retry::start(channel_probe_retry_strategy, || async { + BP::channel_exists(&config.bedrock_config) + .await + .inspect_err(|err| warn!("Failed to probe Bedrock channel: {err:#}")) + }) + .await + .expect("Failed to probe Bedrock channel"); + if channel_already_exists { + info!("Channel already exists; joining as a non channel creator"); + } else { + info!("Channel does not exist yet; starting it as channel creator"); + } + let bootstrap_sequencer_key = (!channel_already_exists).then_some(own_sequencer_key); + + let (store, state) = Self::open_or_create_store(&config, bootstrap_sequencer_key); + + assert!( + committee_discovery::config_is_readable(&state), + "sequencer_stake config account is absent or undecodable; this chain's state is not \ + one this sequencer can operate on" + ); let chain = Arc::new(Mutex::new(Self::restore_chain_state( &config, &store, &state, @@ -307,17 +373,27 @@ impl SequencerCore { "First pending block on fresh start should be the genesis block" ); + // The channel is born holding only its creator's key, so a configured + // founding set is applied by the same tx that writes genesis; the + // committee is never observable without it. + let founding_committee = founding_committee(&config, own_sequencer_key); + let mut last_checkpoint = None; for block in &pending_blocks { - let outcome = block_publisher - .publish_block(block, vec![]) - .await - .unwrap_or_else(|err| { - panic!( - "Failed to publish block {} on fresh start: {err:#}", - block.header.block_id - ) - }); + let publish = match &founding_committee { + Some(keys) if block.header.block_id == GENESIS_BLOCK_ID => { + block_publisher + .publish_genesis_creating_channel(block, keys.clone()) + .await + } + _ => block_publisher.publish_block(block, vec![]).await, + }; + let outcome = publish.unwrap_or_else(|err| { + panic!( + "Failed to publish block {} on fresh start: {err:#}", + block.header.block_id + ) + }); last_checkpoint = Some(outcome.checkpoint); store .raise_published_high_water(block.header.block_id) @@ -341,6 +417,7 @@ impl SequencerCore { sequencer_config: config, block_publisher, watchers, + last_committee_submission_slot: None, }; sequencer_core_metrics::record_chain_height(sequencer_core.chain_height()); @@ -585,10 +662,18 @@ impl SequencerCore { }) } - /// Produces a new block from mempool transactions and publishes it via zone-sdk. - pub async fn produce_new_block(&mut self) -> Result { - let BlockWithMeta { block, withdrawals } = self - .build_block_from_mempool() + /// Runs everything this sequencer owes its turn: builds a block from + /// mempool transactions, publishes it via zone-sdk, and submits any + /// committee-config update the new state calls for. + pub async fn run_production_turn(&mut self) -> Result { + let live_accredited_keys = self.live_accredited_sequencer_keys().await; + + let BlockWithMeta { + block, + withdrawals, + committee_update, + } = self + .build_block_from_mempool(live_accredited_keys.as_deref()) .context("Failed to build block from mempool transactions")?; let block_publisher::PublishOutcome { @@ -607,6 +692,10 @@ impl SequencerCore { .raise_published_high_water(block.header.block_id) .context("Failed to persist published high water mark")?; + // Independent Mantle tx, not bundled with the block above — join/exit + // config updates don't need to be. + self.submit_committee_update(committee_update).await; + let withdrawal_reconciliation_keys: Vec<_> = released_notes .iter() .map(withdrawal_reconciliation_key) @@ -622,6 +711,78 @@ impl SequencerCore { Ok(block.header.block_id) } + /// Live committee snapshot for gating `FinalizeUnstake` inclusion and + /// committee updates. `None` if it could not be read. + async fn live_accredited_sequencer_keys( + &self, + ) -> Option> { + match self.block_publisher.accredited_keys().await { + Ok(keys) => Some( + keys.iter() + .filter_map(|key| { + sequencer_stake_core::SequencerKey::new(key.to_bytes()).or_else(|| { + warn!( + "Ignoring accredited key {}: not a valid Ed25519 public key", + hex::encode(key.to_bytes()) + ); + None + }) + }) + .collect(), + ), + Err(err) => { + warn!( + "Failed to read live committee snapshot; skipping FinalizeUnstake inclusion \ + and committee updates this round: {err:#}" + ); + None + } + } + } + + /// Whether the channel has advanced far enough past `last_submission` to + /// submit again. A missing tip counts as no advance. + fn committee_cooldown_elapsed(last_submission: Option, tip: Option) -> bool { + let Some(last_submission) = last_submission else { + return true; + }; + tip.is_some_and(|tip| { + tip.into_inner() + .saturating_sub(last_submission.into_inner()) + >= Self::COMMITTEE_SUBMISSION_COOLDOWN + }) + } + + async fn submit_committee_update( + &mut self, + committee_update: Option>, + ) { + let Some(new_keys) = committee_update else { + return; + }; + let tip_slot = match self.block_publisher.channel_tip_slot().await { + Ok(tip_slot) => tip_slot, + Err(err) => { + warn!("Failed to read channel tip slot; skipping committee update: {err:#}"); + return; + } + }; + if !Self::committee_cooldown_elapsed(self.last_committee_submission_slot, tip_slot) { + return; + } + let new_keys = new_keys + .into_iter() + .map(|key| { + Ed25519PublicKey::from_bytes(&key.to_bytes()) + .expect("sequencer key was decoded from a valid Ed25519 public key") + }) + .collect(); + self.last_committee_submission_slot = tip_slot; + if let Err(err) = self.block_publisher.submit_channel_config(new_keys).await { + warn!("Failed to submit committee channel-config update: {err:#}"); + } + } + /// 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. @@ -688,13 +849,26 @@ impl SequencerCore { ) -> bool { let tx_hash = tx.hash(); match origin { - TransactionOrigin::User => { + // Gossiped transactions arrive from untrusted peers, same as + // user-submitted ones, so they get the same full state validation. + TransactionOrigin::User | TransactionOrigin::Gossip => { let validated_diff = match tx.validate_on_state(state, block_height, timestamp) { Ok(diff) => diff, Err(err) => { - error!( - "Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it", - ); + // A gossiped tx the leader already included is + // expected to fail here (e.g. on nonce) for every + // other node on its turn; that is steady-state noise, + // not an error. User-submitted failures still warrant + // `error!`. + if matches!(origin, TransactionOrigin::Gossip) { + debug!( + "Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it", + ); + } else { + error!( + "Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it", + ); + } return false; } }; @@ -736,7 +910,10 @@ impl SequencerCore { true } - fn build_block_from_mempool(&mut self) -> Result { + fn build_block_from_mempool( + &mut self, + live_accredited_keys: Option<&[sequencer_stake_core::SequencerKey]>, + ) -> Result { let now = Instant::now(); // Decoded outside the chain lock, and read before it is taken: the usual @@ -774,7 +951,13 @@ impl SequencerCore { // The delivery records are classified in here rather than after, so the // final state can be read by reference. Cloning it cost a full state // copy on every block of every zone, cross-zone or not. - let (prev_block_hash, new_block_height, mut working_state, pending_dispatches) = { + let ( + prev_block_hash, + new_block_height, + mut working_state, + pending_dispatches, + finalize_unstake_txs, + ) = { 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| { @@ -803,7 +986,13 @@ impl SequencerCore { } } - (prev, height, chain.head_state().clone(), pending) + ( + prev, + height, + chain.head_state().clone(), + pending, + build_finalize_unstake_txs(chain.head_state()), + ) }; if !settled.is_empty() { @@ -871,6 +1060,7 @@ impl SequencerCore { // it was not submitted by a user. let mut pending_from_store = pending_deposits; pending_from_store.extend(pending_dispatches); + pending_from_store.extend(finalize_unstake_txs); while let Some((origin, tx, from_store)) = pending_from_store .pop_front() .map(|tx| (TransactionOrigin::Sequencer, tx, true)) @@ -935,6 +1125,17 @@ impl SequencerCore { break; } + // Block-validity rule: a not-yet-valid FinalizeUnstake is dropped + // outright, not applied — whether it arrived via the mempool + // (anyone may submit one, per spec) or from this sequencer's own + // discovery above. It re-appears on its own once conditions are + // met (mempool: whoever wants it finalized resubmits; + // discovery-sourced: reconstructed fresh next block), so it + // doesn't need requeuing here. + if !finalize_unstake_is_includable(&working_state, &tx, live_accredited_keys) { + continue; + } + let before_tx_apply = Instant::now(); let applied = Self::apply_mempool_transaction( &mut working_state, @@ -996,7 +1197,14 @@ impl SequencerCore { sequencer_core_metrics::record_block_creation_time(now.elapsed()); - Ok(BlockWithMeta { block, withdrawals }) + let committee_update = live_accredited_keys + .and_then(|keys| committee_discovery::committee_update(&working_state, keys)); + + Ok(BlockWithMeta { + block, + withdrawals, + committee_update, + }) } /// Reads the current head state under the lock without cloning it, so callers @@ -1215,6 +1423,7 @@ impl SequencerCore { struct BlockWithMeta { block: Block, withdrawals: Vec, + committee_update: Option>, } /// Whether `deposit_op_id`'s mint is already reflected in `state` — its receipt @@ -1492,8 +1701,9 @@ fn apply_follow_update( } /// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings, -/// the only accounts seeded outside any transaction. Cross-zone config is seeded -/// by genesis `InitConfig` transactions and reconstructed by replaying them. +/// the only accounts seeded outside any transaction. Everything else, including +/// the bootstrap sequencer's own stake, is applied as a genesis transaction in +/// [`build_genesis_state`] so followers replay it instead of guessing it. fn build_initial_state(config: &SequencerConfig) -> lee::V03State { #[cfg(not(feature = "testnet"))] let base = testnet_initial_state::initial_state(); @@ -1513,7 +1723,10 @@ fn build_initial_state(config: &SequencerConfig) -> lee::V03State { /// 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) { +fn build_genesis_state( + config: &SequencerConfig, + bootstrap_sequencer_key: Option, +) -> (lee::V03State, Vec) { let mut state = build_initial_state(config); // Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's. @@ -1523,16 +1736,24 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec (lee::V03State, Vec None, + // genesis tx. Stakes are built separately below. + GenesisAction::SupplyBridgeLockHolding { .. } | GenesisAction::StakeSequencer { .. } => { + None + } }); + // The creator falls back to staking itself, signing with the key it owns. + let mut staked = founding_stakes(config); + if staked.is_empty() { + staked.extend(bootstrap_sequencer_key.map(|key| { + let key_path = config.home.join("sequencer_stake_signing_key"); + let owner = load_or_create_stake_signing_key(&key_path) + .expect("Failed to load or create the stake signing key"); + let signature = sign_genesis_stake(0, key, &owner); + (key, lee::PublicKey::new_from_private_key(&owner), signature) + })); + } + let bootstrap_stake_txs = build_stake_genesis_transactions(&staked); + let genesis_txs = wrapped_token_config_tx + .chain(ping_sender_config_tx) + .chain(ping_receiver_config_tx) + .chain(bridge_lock_config_tx) .chain(inbox_config_tx) .chain(supply_txs) + .chain(bootstrap_stake_txs) .chain(std::iter::once(clock_invocation(0))) .inspect(|tx| { state @@ -1564,13 +1804,180 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec Vec { + config + .genesis + .iter() + .filter_map(|action| match action { + GenesisAction::StakeSequencer { + sequencer_key, + ownership_public_key, + stake_signature, + } => Some(( + *sequencer_key, + ownership_public_key.clone(), + stake_signature.clone(), + )), + GenesisAction::SupplyAccount { .. } + | GenesisAction::SupplyBridgeAccount { .. } + | GenesisAction::SupplyBridgeLockHolding { .. } => None, + }) + .collect() +} + +/// The accredited keys a newly created channel should carry, `own_key` first +/// because creation gives the turn to index 0. `None` leaves creation to the +/// plain inscription path. +fn founding_committee( + config: &SequencerConfig, + own_key: sequencer_stake_core::SequencerKey, +) -> Option> { + let mut keys: Vec<_> = founding_stakes(config) + .into_iter() + .map(|(key, ..)| key) + .collect(); + if keys.is_empty() { + return None; + } + keys.sort_unstable(); + keys.retain(|key| *key != own_key); + + Some( + std::iter::once(own_key) + .chain(keys) + .map(|key| { + block_publisher::Ed25519PublicKey::from_bytes(&key.to_bytes()) + .expect("sequencer key was decoded from a valid Ed25519 public key") + }) + .collect(), + ) +} + +fn genesis_stake_funding_account() -> AccountId { + let key = lee::PrivateKey::try_new(GENESIS_STAKE_FUNDING_KEY) + .expect("GENESIS_STAKE_FUNDING_KEY is a valid private key"); + AccountId::from(&lee::PublicKey::new_from_private_key(&key)) +} + +/// The exact `Stake` message the founding sequencer at `index` must sign. Shared +/// offchain by the genesis sequencer. +fn genesis_stake_message( + index: usize, + sequencer_key: sequencer_stake_core::SequencerKey, + ownership_id: AccountId, +) -> Message { + let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + let mover_instruction_data = lee::program::Program::serialize_instruction( + authenticated_transfer_core::Instruction::Transfer { amount }, + ) + .expect("Failed to serialize genesis mover instruction"); + // A nonce counts how many times an account has signed. The funding account + // signed the faucet tx already, so its count starts at 1 here. + let funding_nonce = u128::try_from(index) + .expect("founding sequencer count fits in u128") + .checked_add(1) + .expect("genesis funding nonce overflow"); + + Message::try_new( + programs::sequencer_stake().id(), + vec![ + genesis_stake_funding_account(), + ownership_id, + system_accounts::sequencer_stake_config_account_id(), + ], + vec![ + lee_core::account::Nonce(funding_nonce), + lee_core::account::Nonce(0), + ], + sequencer_stake_core::Instruction::Stake { + sequencer_key, + amount, + mover_program_id: programs::authenticated_transfer().id(), + mover_instruction_data, + }, + ) + .expect("Failed to build genesis Stake message") +} + +/// Signs the founding sequencer at `index`'s genesis `Stake`, for an operator +/// producing their `GenesisAction::StakeSequencer` entry. +#[must_use] +pub fn sign_genesis_stake( + index: usize, + sequencer_key: sequencer_stake_core::SequencerKey, + ownership_key: &lee::PrivateKey, +) -> lee::Signature { + let ownership_id = AccountId::from(&lee::PublicKey::new_from_private_key(ownership_key)); + let message = genesis_stake_message(index, sequencer_key, ownership_id); + lee::Signature::new(ownership_key, &message.hash()) +} + +/// The founding sequencers' `Stake`s, funded via the faucet. Real transactions, +/// not raw state, so followers replay them instead of missing them. +fn build_stake_genesis_transactions(staked: &[FoundingStake]) -> Vec { + if staked.is_empty() { + return Vec::new(); + } + + let funding_key = lee::PrivateKey::try_new(GENESIS_STAKE_FUNDING_KEY).unwrap(); + let funding_public_key = lee::PublicKey::new_from_private_key(&funding_key); + let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + let total = u128::try_from(staked.len()) + .ok() + .and_then(|count| amount.checked_mul(count)) + .expect("genesis stake total overflow"); + + let fund_message = Message::try_new( + programs::faucet().id(), + vec![ + system_accounts::faucet_account_id(), + genesis_stake_funding_account(), + ], + vec![lee_core::account::Nonce(0)], + faucet_core::Instruction::GenesisTransferDirect { amount: total }, + ) + .expect("Failed to build genesis funding message"); + // The funding account signs even though it is only receiving. It is a brand + // new account, so the transfer claims it, and a claim needs that account's + // own signature. + let fund_witness_set = + lee::public_transaction::WitnessSet::for_message(&fund_message, &[&funding_key]); + + let mut txs = vec![PublicTransaction::new(fund_message, fund_witness_set)]; + + for (index, (sequencer_key, ownership_public_key, signature)) in staked.iter().enumerate() { + let ownership_id = AccountId::from(ownership_public_key); + let stake_message = genesis_stake_message(index, *sequencer_key, ownership_id); + let stake_witness_set = lee::public_transaction::WitnessSet::from_raw_parts(vec![ + ( + lee::Signature::new(&funding_key, &stake_message.hash()), + funding_public_key.clone(), + ), + (signature.clone(), ownership_public_key.clone()), + ]); + + // Redundant with the signature check every tx gets, but names the entry. + assert!( + stake_witness_set.is_valid_for(&stake_message), + "genesis stake signature does not match founding sequencer {index} ({})", + hex::encode(sequencer_key) + ); + + txs.push(PublicTransaction::new(stake_message, stake_witness_set)); + } + + txs +} + /// Bridge-lock holder balances configured for this zone's genesis. fn bridge_lock_holdings( genesis: &[GenesisAction], ) -> impl Iterator + '_ { genesis.iter().filter_map(|action| match action { GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)), - GenesisAction::SupplyAccount { .. } | GenesisAction::SupplyBridgeAccount { .. } => None, + GenesisAction::SupplyAccount { .. } + | GenesisAction::SupplyBridgeAccount { .. } + | GenesisAction::StakeSequencer { .. } => None, }) } @@ -1670,6 +2077,84 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu ))) } +/// Block-validity gate for a `FinalizeUnstake`, applied uniformly regardless +/// of where the transaction came from. Passes through unconditionally for +/// anything that isn't a `FinalizeUnstake` call. +fn finalize_unstake_is_includable( + state: &lee::V03State, + tx: &LeeTransaction, + live_accredited_keys: Option<&[sequencer_stake_core::SequencerKey]>, +) -> bool { + let Some(ownership_id) = finalize_unstake_ownership_account(tx) else { + return true; + }; + // Without a committee snapshot there is nothing to check a FinalizeUnstake + // against, so none is includable this block. + live_accredited_keys.is_some_and(|live_accredited_keys| { + committee_discovery::finalize_unstake_is_valid(state, ownership_id, live_accredited_keys) + }) +} + +/// The ownership account a `FinalizeUnstake` call targets, or `None` if `tx` +/// isn't one. +fn finalize_unstake_ownership_account(tx: &LeeTransaction) -> Option { + let LeeTransaction::Public(tx) = tx else { + return None; + }; + + let message = tx.message(); + if message.program_id != programs::sequencer_stake().id() { + return None; + } + + match risc0_zkvm::serde::from_slice::( + &message.instruction_data, + ) { + Ok(sequencer_stake_core::Instruction::FinalizeUnstake) => { + message.account_ids.first().copied() + } + Ok(_) | Err(_) => None, + } +} + +/// A `FinalizeUnstake` for every release `state` has pending. Whether each one +/// is actually includable is decided later, uniformly, by +/// [`finalize_unstake_is_includable`]. +fn build_finalize_unstake_txs(state: &lee::V03State) -> VecDeque { + committee_discovery::finalize_unstake_candidates(state) + .into_iter() + .filter_map(|(ownership_id, pending)| { + build_finalize_unstake_tx(ownership_id, pending) + .inspect_err(|err| warn!("Failed to build FinalizeUnstake tx: {err:#}")) + .ok() + }) + .collect() +} + +// Unsigned: FinalizeUnstake needs no authorization, per the program. +fn build_finalize_unstake_tx( + ownership_id: AccountId, + pending: sequencer_stake_core::PendingUnstake, +) -> Result { + let message = Message::try_new( + programs::sequencer_stake().id(), + vec![ + ownership_id, + pending.destination, + system_accounts::sequencer_stake_config_account_id(), + ], + vec![], + sequencer_stake_core::Instruction::FinalizeUnstake, + ) + .context("Failed to build FinalizeUnstake message")?; + + let witness_set = lee::public_transaction::WitnessSet::from_raw_parts(vec![]); + Ok(LeeTransaction::Public(PublicTransaction::new( + message, + witness_set, + ))) +} + /// 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 @@ -1862,16 +2347,14 @@ fn withdrawal_reconciliation_key(note_id: &NoteId) -> WithdrawalReconciliationKe WithdrawalReconciliationKey { released_note_id } } -/// Load signing key from file or generate a new one if it doesn't exist. -pub fn load_or_create_signing_key(path: &Path) -> Result { +/// Load key bytes from file or generate a new set if it doesn't exist. +fn load_or_create_key_bytes(path: &Path) -> Result<[u8; ED25519_SECRET_KEY_SIZE]> { if path.exists() { let key_bytes = std::fs::read(path)?; - let key_array: [u8; ED25519_SECRET_KEY_SIZE] = key_bytes + key_bytes .try_into() - .map_err(|_bytes| anyhow!("Found key with incorrect length"))?; - - Ok(Ed25519Key::from_bytes(&key_array)) + .map_err(|_bytes| anyhow!("Found key with incorrect length")) } else { let mut key_bytes = [0_u8; ED25519_SECRET_KEY_SIZE]; rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut key_bytes); @@ -1880,10 +2363,24 @@ pub fn load_or_create_signing_key(path: &Path) -> Result { std::fs::create_dir_all(parent)?; } std::fs::write(path, key_bytes)?; - Ok(Ed25519Key::from_bytes(&key_bytes)) + Ok(key_bytes) } } +/// Load signing key from file or generate a new one if it doesn't exist. +pub fn load_or_create_signing_key(path: &Path) -> Result { + Ok(Ed25519Key::from_bytes(&load_or_create_key_bytes(path)?)) +} + +/// Load the key owning this sequencer's genesis stake, or generate one. +/// +/// Only read when a solo sequencer creates the channel: a configured founding +/// set carries a signature instead, so the key never reaches the node. +pub fn load_or_create_stake_signing_key(path: &Path) -> Result { + let bytes = load_or_create_key_bytes(path)?; + lee::PrivateKey::try_new(bytes).context("stake signing key file holds an invalid private key") +} + #[cfg(test)] #[cfg(feature = "mock")] mod tests; diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index a8dd490b9..8a70bd7b1 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -7,7 +7,7 @@ use logos_blockchain_core::{ header::HeaderId, mantle::{ ledger::{NoteId, Utxo}, - ops::channel::{ChannelId, MsgId}, + ops::channel::{ChannelId, Ed25519PublicKey, MsgId}, }, }; use logos_blockchain_key_management_system_service::keys::Ed25519Key; @@ -52,6 +52,11 @@ impl MockBlockPublisher { } impl BlockPublisherTrait for MockBlockPublisher { + // Tests assume this node is always the one bootstrapping the channel. + async fn channel_exists(_config: &BedrockConfig) -> Result { + Ok(false) + } + async fn new( config: &BedrockConfig, _bedrock_signing_key: Ed25519Key, @@ -85,6 +90,22 @@ impl BlockPublisherTrait for MockBlockPublisher { }) } + async fn publish_genesis_creating_channel( + &self, + block: &Block, + _keys: Vec, + ) -> Result { + self.publish_block(block, Vec::new()).await + } + + async fn accredited_keys(&self) -> Result> { + Ok(Vec::new()) + } + + async fn submit_channel_config(&self, _new_keys: Vec) -> Result<()> { + Ok(()) + } + fn channel_id(&self) -> ChannelId { self.channel_id } diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index ceef2d4ae..d3727cf05 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -20,10 +20,10 @@ use logos_blockchain_core::{ ops::channel::{ChannelId, MsgId, deposit::Metadata}, }, }; -use logos_blockchain_key_management_system_service::keys::ZkPublicKey; -use logos_blockchain_zone_sdk::sequencer::DepositInfo; +use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkPublicKey}; +use logos_blockchain_zone_sdk::{Slot, sequencer::DepositInfo}; use mempool::MemPoolHandle; -use ping_core::{ReceiverInstruction, ping_record_pda}; +use ping_core::{ReceiverInstruction, ping_record_pda, receiver_config_account_id}; use storage::sequencer::sequencer_cells::{ DispatchOrigin, PendingCrossZoneDispatchRecord, PendingDepositEventRecord, }; @@ -35,13 +35,14 @@ use crate::{ apply_follow_update, block_publisher::FollowUpdate, block_store::SequencerStore, - build_bridge_deposit_tx_from_event, build_genesis_state, classify_settled_deliveries, + build_bridge_deposit_tx_from_event, build_finalize_unstake_tx, build_genesis_state, + classify_settled_deliveries, config::{ self, BedrockConfig, CrossZoneConfig, CrossZonePeer, CrossZoneRoute, GenesisAction, SequencerConfig, }, deposit_already_minted, dispatch_already_delivered, extract_cross_zone_dispatch, - extract_cross_zone_dispatch_key, is_sequencer_only_program, + extract_cross_zone_dispatch_key, finalize_unstake_is_includable, is_sequencer_only_program, mock::{SequencerCoreWithMockClients, mock_checkpoint}, resubmittable_txs, }; @@ -57,6 +58,27 @@ struct DepositMetadataForEncoding { recipient_id: lee::AccountId, } +/// The bootstrap sequencer's key for `config`, exactly as `start_from_config` +/// would derive it: read from `config.home`'s key file if present, else +/// generated and persisted there. Callers building genesis state by hand (or +/// reopening a store `start_from_config` already created) must use this +/// rather than a fixed constant, so it always matches what's actually on +/// disk. +fn test_bootstrap_sequencer_key(config: &SequencerConfig) -> sequencer_stake_core::SequencerKey { + let bytes = crate::load_or_create_signing_key(&config.home.join("bedrock_signing_key")) + .expect("Failed to load or create bedrock signing key") + .public_key() + .to_bytes(); + sequencer_stake_core::SequencerKey::new(bytes) + .expect("a Bedrock public key is a valid Ed25519 public key") +} + +fn test_sequencer_key(seed: u8) -> sequencer_stake_core::SequencerKey { + let bytes = Ed25519Key::from_bytes(&[seed; 32]).public_key().to_bytes(); + sequencer_stake_core::SequencerKey::new(bytes) + .expect("a Bedrock public key is a valid Ed25519 public key") +} + /// A follow update carrying nothing, to fill in the fields a test does not /// exercise via `..empty_follow_update()`. fn empty_follow_update() -> FollowUpdate { @@ -70,6 +92,19 @@ fn empty_follow_update() -> FollowUpdate { } } +/// Key of the account holding a solo channel creator's genesis stake. Read +/// from the same file genesis uses, which creates it on first read. +fn bootstrap_stake_key(config: &SequencerConfig) -> PrivateKey { + crate::load_or_create_stake_signing_key(&config.home.join("sequencer_stake_signing_key")) + .expect("Failed to load or create the stake signing key") +} + +fn bootstrap_stake_account_id(config: &SequencerConfig) -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&bootstrap_stake_key( + config, + ))) +} + fn setup_sequencer_config() -> SequencerConfig { let tempdir = tempfile::tempdir().unwrap(); let home = tempdir.path().to_path_buf(); @@ -92,6 +127,7 @@ fn setup_sequencer_config() -> SequencerConfig { genesis: vec![], cross_zone: None, metrics_address: None, + gossip: None, } } @@ -106,6 +142,24 @@ fn only_the_cross_zone_inbox_is_sequencer_only() { assert!(!is_sequencer_only_program(programs::clock().id())); } +#[test] +fn committee_cooldown_needs_the_channel_to_advance() { + type Core = SequencerCoreWithMockClients; + let cooldown = Core::COMMITTEE_SUBMISSION_COOLDOWN; + let submitted_at = Slot::new(100); + + assert!(Core::committee_cooldown_elapsed(None, None)); + assert!(!Core::committee_cooldown_elapsed(Some(submitted_at), None)); + assert!(!Core::committee_cooldown_elapsed( + Some(submitted_at), + Some(Slot::new(100 + cooldown - 1)) + )); + assert!(Core::committee_cooldown_elapsed( + Some(submitted_at), + Some(Slot::new(100 + cooldown)) + )); +} + fn create_signing_key_for_account1() -> lee::PrivateKey { initial_pub_accounts_private_keys()[0].pub_sign_key.clone() } @@ -137,7 +191,7 @@ async fn common_setup_with_config( .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); (sequencer, mempool_handle) } @@ -216,7 +270,10 @@ fn dispatch_tx(src_block_id: u64, payload: Vec) -> LeeTransaction { src_program_id: programs::ping_sender().id(), }, receiver_id, - &[ping_record_pda(receiver_id).into_value()], + &[ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, )) } @@ -285,8 +342,9 @@ async fn start_from_config_opens_existing_db_if_it_exists() { let mut config = config; config.home = temp_dir.path().to_path_buf(); + let bootstrap_sequencer_key = test_bootstrap_sequencer_key(&config); let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); - let (genesis_state, genesis_txs) = build_genesis_state(&config); + let (genesis_state, genesis_txs) = build_genesis_state(&config, Some(bootstrap_sequencer_key)); let genesis_hashable_data = HashableBlockData { block_id: 1, transactions: genesis_txs, @@ -367,7 +425,7 @@ async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() { "deposit mints are drained from the store, never queued in the mempool" ); - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let block = sequencer .store .get_block_at_id(block_id) @@ -419,8 +477,8 @@ async fn a_drained_deposit_is_not_minted_twice_across_turns() { }) .unwrap(); - let first = sequencer.produce_new_block().await.unwrap(); - let second = sequencer.produce_new_block().await.unwrap(); + let first = sequencer.run_production_turn().await.unwrap(); + let second = sequencer.run_production_turn().await.unwrap(); let minted_in = |block_id: u64| { sequencer @@ -469,7 +527,7 @@ async fn an_orphaned_deposit_is_reminted_exactly_once_in_the_replacement() { .unwrap(); // Produce the block that mints the deposit; its receipt marks it minted. - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let minted_block = sequencer.store.get_block_at_id(2).unwrap().unwrap(); assert!( sequencer.with_state(|s| deposit_already_minted(s, HashType(deposit_op_id))), @@ -496,7 +554,7 @@ async fn an_orphaned_deposit_is_reminted_exactly_once_in_the_replacement() { // Next turn: the still-pending record is drained and re-minted on the new // head, exactly once. - let replacement = sequencer.produce_new_block().await.unwrap(); + let replacement = sequencer.run_production_turn().await.unwrap(); let mints = sequencer .store .get_block_at_id(replacement) @@ -599,7 +657,7 @@ async fn recorded_dispatches_are_drained_from_the_store_on_production() { "deliveries are drained from the store, never queued in the mempool" ); - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let block = sequencer .store .get_block_at_id(block_id) @@ -646,8 +704,8 @@ async fn a_delivered_dispatch_is_skipped_on_the_next_turn() { .add_pending_cross_zone_dispatches(vec![record]) .unwrap(); - let first = sequencer.produce_new_block().await.unwrap(); - let second = sequencer.produce_new_block().await.unwrap(); + let first = sequencer.run_production_turn().await.unwrap(); + let second = sequencer.run_production_turn().await.unwrap(); let delivered_in = |block_id: u64| { dispatches_in( @@ -689,7 +747,7 @@ async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() .unwrap(); for attempt in 1..RETIRE_DISPATCH_AFTER_FAILURES { - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let block = sequencer .store .get_block_at_id(block_id) @@ -712,7 +770,7 @@ async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() // pending list. Anything else leaves an entry no later block can ever // remove, which is how a peer that can make deliveries fail would grow this // list without bound. - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); assert!( pending_dispatches(&sequencer).is_empty(), "giving up on a delivery must take its record out of the pending list" @@ -748,7 +806,7 @@ async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() assert_eq!(retained, dead_letters); // And nothing re-feeds it, so it stops costing a guest execution per block. - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); assert!(dispatches_in(&block).is_empty()); assert!(pending_dispatches(&sequencer).is_empty()); @@ -772,7 +830,7 @@ async fn a_redelivered_record_is_dropped_once_its_delivery_is_irreversible() { .add_pending_cross_zone_dispatches(vec![record.clone()]) .unwrap(); - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let delivery_block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); assert_eq!(dispatches_in(&delivery_block), vec![key]); @@ -795,7 +853,7 @@ async fn a_redelivered_record_is_dropped_once_its_delivery_is_irreversible() { .unwrap(); assert_eq!(pending_dispatches(&sequencer).len(), 1); - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); assert!( dispatches_in(&block).is_empty(), @@ -824,8 +882,8 @@ async fn a_delivery_still_reversible_keeps_its_record() { .add_pending_cross_zone_dispatches(vec![record]) .unwrap(); - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); assert_eq!( pending_dispatches(&sequencer) @@ -905,7 +963,7 @@ async fn a_delivery_too_large_for_any_block_does_not_stall_production() { .unwrap(); // Production must get past it to the mempool in the very first block. - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); assert!( block.body.transactions.contains(&user_tx), @@ -915,7 +973,7 @@ async fn a_delivery_too_large_for_any_block_does_not_stall_production() { // And it is given up on rather than retried for ever. for _ in 1..RETIRE_DISPATCH_AFTER_FAILURES { - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); } assert!( pending_dispatches(&sequencer).is_empty(), @@ -947,7 +1005,7 @@ async fn a_delivery_backlog_is_spread_across_blocks() { .add_pending_cross_zone_dispatches(records) .unwrap(); - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); assert_eq!( dispatches_in(&block).len(), @@ -956,7 +1014,7 @@ async fn a_delivery_backlog_is_spread_across_blocks() { ); // Deferred, not dropped: the rest go in the next block. - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); assert_eq!(dispatches_in(&block).len(), 3); } @@ -1105,7 +1163,7 @@ async fn push_tx_into_mempool_blocks_until_mempool_is_full() { assert!(poll.is_pending()); // Empty the mempool by producing a block - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); // Resolve the pending push assert!(push_fut.await.is_ok()); @@ -1122,12 +1180,46 @@ async fn build_block_from_mempool() { .await .unwrap(); - let result = sequencer.build_block_from_mempool(); + let result = sequencer.build_block_from_mempool(Some(&[])); assert!(result.is_ok()); // Building itself does not advance the head; only apply-after-publish does. assert_eq!(sequencer.chain_height(), genesis_height); } +#[test] +fn without_a_committee_snapshot_finalize_unstake_is_not_includable() { + let state = V03State::new(); + let finalize_unstake = build_finalize_unstake_tx( + AccountId::new([1; 32]), + sequencer_stake_core::PendingUnstake { + amount: 10, + destination: AccountId::new([2; 32]), + }, + ) + .expect("FinalizeUnstake tx should build"); + + // An empty committee is a real answer ("no key is accredited"), so it lets + // a full drain through. No answer at all must not. + assert!(finalize_unstake_is_includable( + &state, + &finalize_unstake, + Some(&[]) + )); + assert!(!finalize_unstake_is_includable( + &state, + &finalize_unstake, + None + )); +} + +#[test] +fn a_missing_committee_snapshot_holds_back_nothing_else() { + let state = V03State::new(); + let ordinary_tx = common::test_utils::produce_dummy_empty_transaction(); + + assert!(finalize_unstake_is_includable(&state, &ordinary_tx, None)); +} + #[tokio::test] async fn replay_transactions_are_rejected_in_the_same_block() { let (mut sequencer, mempool_handle) = common_setup().await; @@ -1154,7 +1246,7 @@ async fn replay_transactions_are_rejected_in_the_same_block() { .unwrap(); // Create block - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block = sequencer .store .get_block_at_id(sequencer.chain_height()) @@ -1189,7 +1281,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() { .push((TransactionOrigin::User, tx.clone())) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block = sequencer .store .get_block_at_id(sequencer.chain_height()) @@ -1208,7 +1300,7 @@ async fn replay_transactions_are_rejected_in_different_blocks() { .push((TransactionOrigin::User, tx.clone())) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block = sequencer .store .get_block_at_id(sequencer.chain_height()) @@ -1250,7 +1342,7 @@ async fn restart_from_storage() { .push((TransactionOrigin::User, tx.clone())) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block = sequencer .store .get_block_at_id(sequencer.chain_height()) @@ -1288,9 +1380,9 @@ async fn get_pending_blocks() { let config = setup_sequencer_config(); let (mut sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); assert_eq!(sequencer.get_pending_blocks().unwrap().len(), 4); } @@ -1299,9 +1391,9 @@ async fn delete_blocks() { let config = setup_sequencer_config(); let (mut sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let last_finalized_block = 3; sequencer @@ -1337,7 +1429,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() { .push((TransactionOrigin::User, tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); // Get the metadata of the last block produced sequencer.store.latest_block_meta().unwrap().unwrap() @@ -1363,7 +1455,7 @@ async fn produce_block_with_correct_prev_meta_after_restart() { .unwrap(); // Step 4: Produce new block - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); // Step 5: Verify the new block has correct previous block metadata let new_block = sequencer @@ -1416,7 +1508,7 @@ async fn transactions_touching_clock_account_are_dropped_from_block() { .push((TransactionOrigin::User, crafted_clock_tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block = sequencer .store @@ -1446,7 +1538,7 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { .push((TransactionOrigin::User, deploy_tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); // Build a user transaction that invokes clock_chain_caller, which in turn chain-calls the // clock program with the clock accounts. The sequencer should detect that the resulting @@ -1471,7 +1563,7 @@ async fn user_tx_that_chain_calls_clock_is_dropped() { .push((TransactionOrigin::User, user_tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block = sequencer .store @@ -1511,7 +1603,7 @@ async fn block_production_aborts_when_clock_account_data_is_corrupted() { .unwrap(); // Block production must fail because the appended clock tx cannot execute. - let result = sequencer.produce_new_block().await; + let result = sequencer.run_production_turn().await; assert!( result.is_err(), "Block production should abort when clock account data is corrupted" @@ -2062,8 +2154,8 @@ async fn head_rewound_below_published_height_blocks_production() { let (mut sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; - let first = sequencer.produce_new_block().await.unwrap(); - let published_tip = sequencer.produce_new_block().await.unwrap(); + let first = sequencer.run_production_turn().await.unwrap(); + let published_tip = sequencer.run_production_turn().await.unwrap(); assert_eq!( sequencer.store.published_high_water().unwrap(), Some(published_tip), @@ -2220,7 +2312,7 @@ async fn follow_redelivery_of_own_block_is_deduped() { .push((TransactionOrigin::User, tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().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 @@ -2262,7 +2354,7 @@ async fn follow_orphan_reverts_head_and_requeues_user_txs() { .push((TransactionOrigin::User, tx.clone())) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); apply_follow_update( @@ -2318,7 +2410,7 @@ async fn follow_orphan_of_a_finalized_block_requeues_nothing() { .push((TransactionOrigin::User, tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); apply_follow_update( @@ -2367,7 +2459,7 @@ async fn follow_finalized_own_block_moves_final_tier_and_marks_store() { .push((TransactionOrigin::User, tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); apply_follow_update( @@ -2411,7 +2503,7 @@ async fn follow_finalized_delivery_drops_its_pending_record() { .add_pending_cross_zone_dispatches(vec![record]) .unwrap(); - let block_id = sequencer.produce_new_block().await.unwrap(); + let block_id = sequencer.run_production_turn().await.unwrap(); let delivery_block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); assert_eq!(dispatches_in(&delivery_block), vec![key]); assert_eq!( @@ -2459,7 +2551,7 @@ async fn a_parked_finalized_block_does_not_drop_a_dispatch_record() { .push((TransactionOrigin::User, tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); // A skip-ahead block carrying the same delivery: not in head and linking to // nothing we hold, so the final tier parks it instead of applying it. @@ -2538,7 +2630,7 @@ async fn parked_finalized_block_neither_sweeps_the_store_nor_drops_its_deposit_r .push((TransactionOrigin::User, tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let deposit_op_id = HashType([21; 32]); let record = PendingDepositEventRecord { @@ -2617,7 +2709,7 @@ async fn restart_restores_head_tier_and_recovers_from_orphan() { .push((TransactionOrigin::User, tx.clone())) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); (tx, sequencer.store.get_block_at_id(2).unwrap().unwrap()) }; @@ -2682,7 +2774,7 @@ async fn restart_reanchors_on_the_persisted_final_snapshot() { .push((TransactionOrigin::User, tx)) .await .unwrap(); - sequencer.produce_new_block().await.unwrap(); + sequencer.run_production_turn().await.unwrap(); let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); apply_follow_update( &sequencer.store.dbio(), @@ -2835,3 +2927,543 @@ async fn follow_update_persists_blocks_meta_and_state_atomically() { .balance; assert_eq!(stored_balance, 20010); } + +/// Diagnostic repro: exercises `sequencer_stake`'s `Stake` instruction (claim +/// on the outer call, hand off to a mover chained call, self-chained confirm) +/// directly through `V03State::transition_from_public_transaction`, with no +/// sequencer/mempool/Bedrock machinery involved, to isolate whether the LEE +/// state machine itself claims the ownership account correctly. +#[test] +fn diag_sequencer_stake_claims_ownership_account() { + let funding_key = PrivateKey::try_new([21; 32]).unwrap(); + let funding_id = AccountId::from(&PublicKey::new_from_private_key(&funding_key)); + let ownership_key = PrivateKey::try_new([22; 32]).unwrap(); + let ownership_id = AccountId::from(&PublicKey::new_from_private_key(&ownership_key)); + + let amount: u128 = 5_000_000; + let sequencer_key = test_sequencer_key(0x42); + + let config_id = system_accounts::sequencer_stake_config_account_id(); + let mut state = V03State::new() + .with_programs([ + programs::authenticated_transfer(), + programs::sequencer_stake(), + ]) + .with_public_accounts([ + ( + funding_id, + Account { + program_owner: programs::authenticated_transfer().id(), + balance: amount, + ..Account::default() + }, + ), + (config_id, system_accounts::sequencer_stake_config_account()), + ]); + + assert_eq!( + state.get_account_by_id(ownership_id), + Account::default(), + "ownership account must start out fresh/unclaimed" + ); + + let mover_instruction_data = + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount, + }) + .unwrap(); + + let message = lee::public_transaction::Message::try_new( + programs::sequencer_stake().id(), + vec![funding_id, ownership_id, config_id], + vec![Nonce(0), Nonce(0)], + sequencer_stake_core::Instruction::Stake { + sequencer_key, + amount, + mover_program_id: programs::authenticated_transfer().id(), + mover_instruction_data, + }, + ) + .unwrap(); + let witness_set = + lee::public_transaction::WitnessSet::for_message(&message, &[&funding_key, &ownership_key]); + let tx = PublicTransaction::new(message, witness_set); + + state + .transition_from_public_transaction(&tx, 1, 0) + .expect("Stake transaction should succeed"); + + let ownership_account = state.get_account_by_id(ownership_id); + assert_eq!( + ownership_account.program_owner, + programs::sequencer_stake().id(), + "ownership account should be claimed by sequencer_stake" + ); + assert_eq!(ownership_account.balance, amount); +} + +/// Builds a `Stake` moving `amount` from `funding` into `ownership` via +/// `authenticated_transfer`, taking each signer's nonce from `state`. +fn stake_transaction( + state: &V03State, + funding: (AccountId, &PrivateKey), + ownership: (AccountId, &PrivateKey), + sequencer_key: sequencer_stake_core::SequencerKey, + amount: u128, +) -> PublicTransaction { + let (funding_id, funding_key) = funding; + let (ownership_id, ownership_key) = ownership; + let mover_instruction_data = + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount, + }) + .unwrap(); + + let message = lee::public_transaction::Message::try_new( + programs::sequencer_stake().id(), + vec![ + funding_id, + ownership_id, + system_accounts::sequencer_stake_config_account_id(), + ], + vec![ + state.get_account_by_id(funding_id).nonce, + state.get_account_by_id(ownership_id).nonce, + ], + sequencer_stake_core::Instruction::Stake { + sequencer_key, + amount, + mover_program_id: programs::authenticated_transfer().id(), + mover_instruction_data, + }, + ) + .unwrap(); + let witness_set = + lee::public_transaction::WitnessSet::for_message(&message, &[funding_key, ownership_key]); + PublicTransaction::new(message, witness_set) +} + +fn stake_entry( + state: &V03State, + sequencer_key: sequencer_stake_core::SequencerKey, +) -> Option { + sequencer_stake_core::SequencerStakeConfig::from_bytes( + state + .get_account_by_id(system_accounts::sequencer_stake_config_account_id()) + .data + .as_ref(), + ) + .expect("config account should decode") + .entries + .get(&sequencer_key) + .copied() +} + +/// A state carrying the two `sequencer_stake` needs plus a funding account +/// holding `funding_balance`. +fn stake_test_state(funding_id: AccountId, funding_balance: u128) -> V03State { + V03State::new() + .with_programs([ + programs::authenticated_transfer(), + programs::sequencer_stake(), + ]) + .with_public_accounts([ + ( + funding_id, + Account { + program_owner: programs::authenticated_transfer().id(), + balance: funding_balance, + ..Account::default() + }, + ), + ( + system_accounts::sequencer_stake_config_account_id(), + system_accounts::sequencer_stake_config_account(), + ), + ]) +} + +/// Builds an `UnstakeRequest` against `ownership`, passing `config_slot` where +/// the config account belongs. +fn unstake_request_transaction( + state: &V03State, + ownership: (AccountId, &PrivateKey), + config_slot: AccountId, + amount: u128, + destination: AccountId, +) -> PublicTransaction { + let (ownership_id, ownership_key) = ownership; + let message = lee::public_transaction::Message::try_new( + programs::sequencer_stake().id(), + vec![ownership_id, config_slot], + vec![state.get_account_by_id(ownership_id).nonce], + sequencer_stake_core::Instruction::UnstakeRequest { + amount, + destination, + }, + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[ownership_key]); + PublicTransaction::new(message, witness_set) +} + +/// Anyone can credit a program-owned account, so an `UnstakeRequest` sized off +/// the balance rather than the tracked stake must be rejected. +#[test] +fn an_unstake_request_cannot_exceed_the_tracked_stake() { + let funding_key = PrivateKey::try_new([31; 32]).unwrap(); + let funding_id = AccountId::from(&PublicKey::new_from_private_key(&funding_key)); + let ownership_key = PrivateKey::try_new([32; 32]).unwrap(); + let ownership_id = AccountId::from(&PublicKey::new_from_private_key(&ownership_key)); + + let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + let donation = 1; + let sequencer_key = test_sequencer_key(0x43); + + let mut state = stake_test_state(funding_id, amount + donation); + let stake = stake_transaction( + &state, + (funding_id, &funding_key), + (ownership_id, &ownership_key), + sequencer_key, + amount, + ); + state + .transition_from_public_transaction(&stake, 1, 0) + .expect("Stake should succeed"); + + // Donate into the claimed ownership account: a balance increase needs no + // ownership of the target. + let message = lee::public_transaction::Message::try_new( + programs::authenticated_transfer().id(), + vec![funding_id, ownership_id], + vec![state.get_account_by_id(funding_id).nonce], + authenticated_transfer_core::Instruction::Transfer { amount: donation }, + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[&funding_key]); + state + .transition_from_public_transaction(&PublicTransaction::new(message, witness_set), 2, 0) + .expect("donation should succeed"); + + let balance = state.get_account_by_id(ownership_id).balance; + assert_eq!( + balance, + amount + donation, + "balance now exceeds total_staked" + ); + + let over = unstake_request_transaction( + &state, + (ownership_id, &ownership_key), + system_accounts::sequencer_stake_config_account_id(), + balance, + funding_id, + ); + state + .transition_from_public_transaction(&over, 3, 0) + .expect_err("an UnstakeRequest for the full balance must be rejected"); + + // The tracked total is still releasable. + let exact = unstake_request_transaction( + &state, + (ownership_id, &ownership_key), + system_accounts::sequencer_stake_config_account_id(), + amount, + funding_id, + ); + state + .transition_from_public_transaction(&exact, 4, 0) + .expect("an UnstakeRequest for the tracked stake should succeed"); +} + +#[test] +fn a_top_up_is_rejected_while_an_unstake_request_is_pending() { + let funding_key = PrivateKey::try_new([33; 32]).unwrap(); + let funding_id = AccountId::from(&PublicKey::new_from_private_key(&funding_key)); + let ownership_key = PrivateKey::try_new([34; 32]).unwrap(); + let ownership_id = AccountId::from(&PublicKey::new_from_private_key(&ownership_key)); + + let minimum = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + let sequencer_key = test_sequencer_key(0x44); + + let mut state = stake_test_state(funding_id, 3 * minimum); + let stake = stake_transaction( + &state, + (funding_id, &funding_key), + (ownership_id, &ownership_key), + sequencer_key, + 2 * minimum, + ); + state + .transition_from_public_transaction(&stake, 1, 0) + .expect("Stake should succeed"); + + // Partial release, leaving exactly the minimum staked. + let request = unstake_request_transaction( + &state, + (ownership_id, &ownership_key), + system_accounts::sequencer_stake_config_account_id(), + minimum, + funding_id, + ); + state + .transition_from_public_transaction(&request, 2, 0) + .expect("partial UnstakeRequest should succeed"); + + let top_up = stake_transaction( + &state, + (funding_id, &funding_key), + (ownership_id, &ownership_key), + sequencer_key, + minimum, + ); + state + .transition_from_public_transaction(&top_up, 3, 0) + .expect_err("a top up must be rejected while an unstake request is pending"); +} + +/// Ownership accounts are `sequencer_stake`-owned too, so the config account is +/// identified by its address. +#[test] +fn an_ownership_account_cannot_stand_in_for_the_config_account() { + let funding_key = PrivateKey::try_new([35; 32]).unwrap(); + let funding_id = AccountId::from(&PublicKey::new_from_private_key(&funding_key)); + let ownership_key = PrivateKey::try_new([36; 32]).unwrap(); + let ownership_id = AccountId::from(&PublicKey::new_from_private_key(&ownership_key)); + let other_ownership_key = PrivateKey::try_new([37; 32]).unwrap(); + let other_ownership_id = + AccountId::from(&PublicKey::new_from_private_key(&other_ownership_key)); + + let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + let mut state = stake_test_state(funding_id, 2 * amount); + + for (index, (id, key, sequencer_key)) in [ + (ownership_id, &ownership_key, test_sequencer_key(0x45)), + ( + other_ownership_id, + &other_ownership_key, + test_sequencer_key(0x46), + ), + ] + .into_iter() + .enumerate() + { + let stake = stake_transaction( + &state, + (funding_id, &funding_key), + (id, key), + sequencer_key, + amount, + ); + state + .transition_from_public_transaction( + &stake, + u64::try_from(index).expect("test index fits") + 1, + 0, + ) + .expect("Stake should succeed"); + } + + assert_eq!( + state.get_account_by_id(other_ownership_id).program_owner, + programs::sequencer_stake().id(), + "the stand-in is owned by sequencer_stake, so ownership alone would not catch it" + ); + + let spoofed = unstake_request_transaction( + &state, + (ownership_id, &ownership_key), + other_ownership_id, + amount, + funding_id, + ); + state + .transition_from_public_transaction(&spoofed, 3, 0) + .expect_err("an ownership account passed as the config account must be rejected"); +} + +/// `FinalizeUnstake` drops a fully drained key's config entry, and the same +/// ownership account can stake again against it. +#[test] +fn a_fully_exited_ownership_account_can_stake_again() { + let funding_key = PrivateKey::try_new([21; 32]).unwrap(); + let funding_id = AccountId::from(&PublicKey::new_from_private_key(&funding_key)); + let ownership_key = PrivateKey::try_new([22; 32]).unwrap(); + let ownership_id = AccountId::from(&PublicKey::new_from_private_key(&ownership_key)); + + let amount = system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE; + let sequencer_key = test_sequencer_key(0x42); + + let mut state = V03State::new() + .with_programs([ + programs::authenticated_transfer(), + programs::sequencer_stake(), + ]) + .with_public_accounts([ + ( + funding_id, + Account { + program_owner: programs::authenticated_transfer().id(), + balance: amount, + ..Account::default() + }, + ), + ( + system_accounts::sequencer_stake_config_account_id(), + system_accounts::sequencer_stake_config_account(), + ), + ]); + + let stake = stake_transaction( + &state, + (funding_id, &funding_key), + (ownership_id, &ownership_key), + sequencer_key, + amount, + ); + state + .transition_from_public_transaction(&stake, 1, 0) + .expect("initial Stake should succeed"); + assert_eq!( + stake_entry(&state, sequencer_key).map(|entry| entry.total_staked), + Some(amount) + ); + + // Full exit, releasing back to the (now drained) funding account. + let message = lee::public_transaction::Message::try_new( + programs::sequencer_stake().id(), + vec![ + ownership_id, + system_accounts::sequencer_stake_config_account_id(), + ], + vec![state.get_account_by_id(ownership_id).nonce], + sequencer_stake_core::Instruction::UnstakeRequest { + amount, + destination: funding_id, + }, + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[&ownership_key]); + state + .transition_from_public_transaction(&PublicTransaction::new(message, witness_set), 2, 0) + .expect("UnstakeRequest should succeed"); + + let finalize = build_finalize_unstake_tx( + ownership_id, + sequencer_stake_core::PendingUnstake { + amount, + destination: funding_id, + }, + ) + .unwrap(); + let LeeTransaction::Public(finalize) = finalize else { + panic!("FinalizeUnstake should be a public transaction"); + }; + state + .transition_from_public_transaction(&finalize, 3, 0) + .expect("FinalizeUnstake should succeed"); + + assert_eq!(stake_entry(&state, sequencer_key), None, "key fully exited"); + assert_eq!(state.get_account_by_id(ownership_id).balance, 0); + assert_eq!( + state.get_account_by_id(ownership_id).program_owner, + programs::sequencer_stake().id(), + "the ownership account stays claimed after a full exit" + ); + + // The account is still claimed, so the re-stake goes through the same + // already-owned account rather than needing a fresh one. + let restake = stake_transaction( + &state, + (funding_id, &funding_key), + (ownership_id, &ownership_key), + sequencer_key, + amount, + ); + state + .transition_from_public_transaction(&restake, 4, 0) + .expect("a fully exited account should be able to stake again"); + + let entry = stake_entry(&state, sequencer_key).expect("key is registered again"); + assert_eq!(entry.account_id, ownership_id); + assert_eq!(entry.total_staked, amount); + assert_eq!(entry.total_pending_unstake, 0); + assert_eq!(state.get_account_by_id(ownership_id).balance, amount); +} + +#[test] +fn genesis_stakes_the_bootstrap_sequencer_at_the_configured_account() { + let config = setup_sequencer_config(); + let bootstrap_sequencer_key = test_bootstrap_sequencer_key(&config); + let (state, _genesis_txs) = build_genesis_state(&config, Some(bootstrap_sequencer_key)); + + let stake_account = state.get_account_by_id(bootstrap_stake_account_id(&config)); + assert_eq!( + stake_account.program_owner, + programs::sequencer_stake().id() + ); + assert_eq!( + stake_account.balance, + system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE + ); + + let stake_config = sequencer_stake_core::SequencerStakeConfig::from_bytes( + state + .get_account_by_id(system_accounts::sequencer_stake_config_account_id()) + .data + .as_ref(), + ) + .expect("genesis config account should decode"); + assert_eq!( + stake_config.entries[&bootstrap_sequencer_key].account_id, + bootstrap_stake_account_id(&config) + ); +} + +/// The genesis stake account must be one the operator can sign for, so the +/// bootstrap sequencer can top up and exit like any self-joined staker. +#[test] +fn the_bootstrap_sequencer_can_request_an_unstake_of_its_genesis_stake() { + let config = setup_sequencer_config(); + let bootstrap_sequencer_key = test_bootstrap_sequencer_key(&config); + let (mut state, _genesis_txs) = build_genesis_state(&config, Some(bootstrap_sequencer_key)); + + let stake_id = bootstrap_stake_account_id(&config); + let destination = AccountId::from(&PublicKey::new_from_private_key( + &PrivateKey::try_new([56; 32]).unwrap(), + )); + + let message = lee::public_transaction::Message::try_new( + programs::sequencer_stake().id(), + vec![ + stake_id, + system_accounts::sequencer_stake_config_account_id(), + ], + // The genesis Stake transaction already signed once with this account. + vec![Nonce(1)], + sequencer_stake_core::Instruction::UnstakeRequest { + amount: system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE, + destination, + }, + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message( + &message, + &[&bootstrap_stake_key(&config)], + ); + let tx = PublicTransaction::new(message, witness_set); + + state + .transition_from_public_transaction(&tx, 1, 0) + .expect("the bootstrap sequencer should be able to request an unstake"); + + let record = sequencer_stake_core::StakeRecord::from_bytes( + state.get_account_by_id(stake_id).data.as_ref(), + ) + .expect("genesis stake account should hold a StakeRecord"); + assert_eq!( + record.pending_unstake.map(|pending| pending.amount), + Some(system_accounts::DEFAULT_MINIMUM_SEQUENCER_STAKE) + ); +} diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs index 6365fca84..f2802ec0b 100644 --- a/lez/sequencer/core/src/tests/reconstruction.rs +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -20,7 +20,9 @@ use crate::{ /// 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 bootstrap_sequencer_key = Some(test_bootstrap_sequencer_key(config)); + let (store, state) = + SequencerCore::::open_or_create_store(config, bootstrap_sequencer_key); let chain = Mutex::new(SequencerCore::::restore_chain_state( config, &store, &state, )); @@ -56,8 +58,8 @@ async fn reconstructs_missing_channel_blocks_into_fresh_store() { 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(); + seq_a.run_production_turn().await.unwrap(); + seq_a.run_production_turn().await.unwrap(); let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); let messages = channel_from_store(seq_a.block_store(), 10); @@ -194,8 +196,8 @@ async fn fails_when_a_below_tip_channel_block_does_not_validate() { // 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(); + seq.run_production_turn().await.unwrap(); + seq.run_production_turn().await.unwrap(); // A below-tip block re-served with a corrupted hash. Holding a different // block at that id is not itself grounds to abort — the head tier is @@ -289,7 +291,7 @@ async fn reconstruction_ignores_a_duplicate_height_the_final_tier_settled() { let config_a = setup_sequencer_config(); let (mut seq_a, _mempool_a) = SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; - seq_a.produce_new_block().await.unwrap(); + seq_a.run_production_turn().await.unwrap(); let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); let mut messages = channel_from_store(seq_a.block_store(), 10); let settled_slot = messages.last().unwrap().1; @@ -372,7 +374,7 @@ async fn reconstruction_replaces_a_conflicting_head_block_with_finalized_history let config_a = setup_sequencer_config(); let (mut seq_a, _mempool_a) = SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; - seq_a.produce_new_block().await.unwrap(); + seq_a.run_production_turn().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; @@ -506,7 +508,7 @@ fn deposit_event_record( // .push((TransactionOrigin::Sequencer, deposit_tx)) // .await // .unwrap(); -// seq_a.produce_new_block().await.unwrap(); +// seq_a.run_production_turn().await.unwrap(); // let withdraw_tx = build_public_withdraw_tx( // recipient, @@ -519,7 +521,7 @@ fn deposit_event_record( // .push((TransactionOrigin::User, withdraw_tx.clone())) // .await // .unwrap(); -// seq_a.produce_new_block().await.unwrap(); +// seq_a.run_production_turn().await.unwrap(); // let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); // let messages = channel_from_store(seq_a.block_store(), 10); @@ -569,7 +571,7 @@ fn deposit_event_record( // "reconstruction must drop the re-delivered pending deposit record" // ); -// seq_b.produce_new_block().await.unwrap(); +// seq_b.run_production_turn().await.unwrap(); // let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient); // let bridge_id = system_accounts::bridge_account_id(); @@ -641,7 +643,7 @@ fn deposit_event_record( // .push((TransactionOrigin::User, withdraw_tx.clone())) // .await // .unwrap(); -// seq_a.produce_new_block().await.unwrap(); +// seq_a.run_production_turn().await.unwrap(); // let key = produced_withdraw_key(&withdraw_tx); // // Producing the withdraw counts it as unseen, awaiting its L1 event. @@ -694,7 +696,7 @@ async fn reconstruction_reconciles_already_finished_deposit() { .push((TransactionOrigin::Sequencer, deposit_tx)) .await .unwrap(); - seq_a.produce_new_block().await.unwrap(); + seq_a.run_production_turn().await.unwrap(); let messages = channel_from_store(seq_a.block_store(), 10); let tip_slot = messages.last().unwrap().1; @@ -766,7 +768,7 @@ async fn reconstructed_delivery_settles_its_pending_record() { .dbio() .add_pending_cross_zone_dispatches(vec![record.clone()]) .unwrap(); - seq_a.produce_new_block().await.unwrap(); + seq_a.run_production_turn().await.unwrap(); let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); let messages = channel_from_store(seq_a.block_store(), 10); @@ -816,7 +818,7 @@ async fn reconstructed_delivery_settles_its_pending_record() { payload, "the reconstructed delivery must reach its target program" ); - seq_b.produce_new_block().await.unwrap(); + seq_b.run_production_turn().await.unwrap(); let produced = seq_b .block_store() .get_block_at_id(tip_b.id + 1) @@ -845,7 +847,7 @@ async fn a_verified_own_block_settles_its_delivery_records() { .add_pending_cross_zone_dispatches(vec![record]) .unwrap(); - let block_id = seq.produce_new_block().await.unwrap(); + let block_id = seq.run_production_turn().await.unwrap(); let block = seq .block_store() .get_block_at_id(block_id) @@ -896,8 +898,8 @@ async fn committed_local_against_missing_channel_fails_without_anchor() { { let (mut seq, _handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; - seq.produce_new_block().await.unwrap(); - seq.produce_new_block().await.unwrap(); + seq.run_production_turn().await.unwrap(); + seq.run_production_turn().await.unwrap(); assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1); } // drop releases the store so we can reopen it diff --git a/lez/sequencer/service/Cargo.toml b/lez/sequencer/service/Cargo.toml index cb3575c59..c8edaec26 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -12,6 +12,12 @@ workspace = true sequencer_core = { workspace = true, features = ["testnet"] } sequencer_executor_actor.workspace = true sequencer_rpc_server_actor.workspace = true +lee = { workspace = true, optional = true } +programs = { workspace = true, optional = true } +system_accounts = { workspace = true, optional = true } +wallet = { workspace = true, optional = true } +sequencer_stake_core = { workspace = true, optional = true } +authenticated_transfer_core = { workspace = true, optional = true } clap = { workspace = true, features = ["derive", "env"] } anyhow.workspace = true @@ -25,7 +31,23 @@ tokio.workspace = true tokio-util.workspace = true futures.workspace = true +[[bin]] +name = "submit_stake" +path = "src/bin/submit_stake.rs" +required-features = ["submit_stake"] + [features] default = [] # Runs the sequencer in standalone mode without depending on Bedrock and Indexer services. standalone = ["sequencer_core/mock"] +# Enable mDNS-based local peer discovery for gossip. +mdns = ["sequencer_core/mdns"] +# Needed only by the submit_stake bin. +submit_stake = [ + "dep:lee", + "programs", + "dep:system_accounts", + "dep:wallet", + "dep:sequencer_stake_core", + "dep:authenticated_transfer_core", +] diff --git a/lez/sequencer/service/configs/debug/sequencer_config.json b/lez/sequencer/service/configs/debug/sequencer_config.json index 92072e84d..f5680ab0d 100644 --- a/lez/sequencer/service/configs/debug/sequencer_config.json +++ b/lez/sequencer/service/configs/debug/sequencer_config.json @@ -14,6 +14,10 @@ "node_url": "http://localhost:18080", "funding_key": "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26" }, + "gossip": { + "listen_addr": "/ip4/127.0.0.1/udp/0/quic-v1", + "bootstrap_peers": [] + }, "genesis": [ { "supply_bridge_account": { @@ -79,4 +83,4 @@ 37, 37 ] -} \ No newline at end of file +} diff --git a/lez/sequencer/service/configs/docker/sequencer_config.json b/lez/sequencer/service/configs/docker/sequencer_config.json index 44bebf548..6683f7f32 100644 --- a/lez/sequencer/service/configs/docker/sequencer_config.json +++ b/lez/sequencer/service/configs/docker/sequencer_config.json @@ -79,4 +79,4 @@ 37, 37 ] -} \ No newline at end of file +} diff --git a/lez/sequencer/service/src/bin/submit_stake.rs b/lez/sequencer/service/src/bin/submit_stake.rs new file mode 100644 index 000000000..2037fd57a --- /dev/null +++ b/lez/sequencer/service/src/bin/submit_stake.rs @@ -0,0 +1,147 @@ +//! Submits `Stake`/`UnstakeRequest` transactions for the self-join flow, +//! signed with keys already held by a wallet at the given path. +//! +//! For `stake`, the funding account must already be owned by +//! `authenticated_transfer` and hold at least `amount`. The ownership account +//! should be fresh (or already backing the same sequencer key). Both must +//! already exist in the wallet (e.g. via `wallet account new public`). + +use anyhow::{Context as _, Result, anyhow}; +use clap::{Parser, Subcommand}; +use lee::{AccountId, program::Program}; +use wallet::{AccountIdentity, WalletCore}; + +#[derive(Debug, Parser)] +#[clap(version)] +struct Args { + /// Path to the wallet's home directory (holds `wallet_config.json`, + /// `storage.json` and `statistics.json`). + #[clap(long)] + wallet: std::path::PathBuf, + #[clap(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Locks `amount` into a stake ownership account for `sequencer_key`. + Stake { + /// Account funding the stake; must already be authenticated_transfer-owned. + #[clap(long)] + funding_account: AccountId, + /// Stake ownership account for `sequencer_key`. + #[clap(long)] + ownership_account: AccountId, + /// Bedrock sequencer key (hex) to stake for. + #[clap(long)] + sequencer_key: String, + /// Amount to stake; must be at least the current minimum. + #[clap(long)] + amount: u128, + }, + /// Records a request to release `amount` from `ownership_account` to `destination`. + /// Moves no balance yet; must leave the account at zero or at/above the minimum. + UnstakeRequest { + /// Stake ownership account to release from. + #[clap(long)] + ownership_account: AccountId, + /// Amount to release. + #[clap(long)] + amount: u128, + /// Account credited once `FinalizeUnstake` later runs. + #[clap(long)] + destination: AccountId, + }, +} + +#[tokio::main] +#[expect( + clippy::print_stdout, + reason = "the submitted tx hash on stdout is this binary's output" +)] +async fn main() -> Result<()> { + env_logger::init(); + let args = Args::parse(); + + let wallet = WalletCore::new_update_chain( + args.wallet.join("wallet_config.json"), + args.wallet.join("storage.json"), + args.wallet.join("statistics.json"), + None, + ) + .await + .context("Failed to open wallet")?; + + let config_id = system_accounts::sequencer_stake_config_account_id(); + + let tx_hash = match args.command { + Command::Stake { + funding_account, + ownership_account, + sequencer_key, + amount, + } => { + let sequencer_key = parse_sequencer_key(&sequencer_key)?; + let mover_instruction_data = Program::serialize_instruction( + authenticated_transfer_core::Instruction::Transfer { amount }, + ) + .context("Failed to serialize mover instruction")?; + let instruction_data = + Program::serialize_instruction(sequencer_stake_core::Instruction::Stake { + sequencer_key, + amount, + mover_program_id: programs::authenticated_transfer().id(), + mover_instruction_data, + }) + .context("Failed to serialize Stake instruction")?; + + wallet + .send_pub_tx( + vec![ + AccountIdentity::Public(funding_account), + AccountIdentity::Public(ownership_account), + AccountIdentity::PublicNoSign(config_id), + ], + instruction_data, + programs::sequencer_stake().id(), + ) + .await + .map_err(|err| anyhow!("Failed to submit Stake transaction: {err:?}"))? + } + Command::UnstakeRequest { + ownership_account, + amount, + destination, + } => { + let instruction_data = + Program::serialize_instruction(sequencer_stake_core::Instruction::UnstakeRequest { + amount, + destination, + }) + .context("Failed to serialize UnstakeRequest instruction")?; + + wallet + .send_pub_tx( + vec![ + AccountIdentity::Public(ownership_account), + AccountIdentity::PublicNoSign(config_id), + ], + instruction_data, + programs::sequencer_stake().id(), + ) + .await + .map_err(|err| anyhow!("Failed to submit UnstakeRequest transaction: {err:?}"))? + } + }; + + println!("Submitted transaction {tx_hash}"); + Ok(()) +} + +fn parse_sequencer_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}"))?; + sequencer_stake_core::SequencerKey::new(bytes) + .with_context(|| format!("{hex_key} is not a valid Ed25519 public key")) +} diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index 5ea04edb4..e6b819c09 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -1,11 +1,12 @@ use std::net::SocketAddr; -use anyhow::Result; +use anyhow::{Context as _, Result}; use futures::never::Never; use kameo::actor::Spawn as _; use kameo_actors::scheduler::{Scheduler, SetInterval}; use log::info; pub use sequencer_core::config::*; +use sequencer_core::load_or_create_signing_key; use sequencer_executor_actor::ExecutorActor; use sequencer_rpc_server_actor::RpcServerActor; use tokio::select; @@ -29,6 +30,9 @@ pub struct SequencerHandle { rpc_server: ActorHandle, executor: ActorHandle>, addr: SocketAddr, + /// Held for its lifetime: dropping it stops the gossip drive task. + /// `None` when gossip is unconfigured. + gossip: Option, } impl SequencerHandle { @@ -37,12 +41,14 @@ impl SequencerHandle { rpc_server: ActorHandle, executor: ActorHandle>, addr: SocketAddr, + gossip: Option, ) -> Self { Self { scheduler, rpc_server, executor, addr, + gossip, } } @@ -54,6 +60,7 @@ impl SequencerHandle { rpc_server, executor, addr: _, + gossip: _, } = self; // NOTE: Order of shutdown matters. Make sure it follows the order of fields in the struct. @@ -73,6 +80,7 @@ impl SequencerHandle { rpc_server, scheduler, addr: _, + gossip: _, } = self; select! { @@ -99,6 +107,7 @@ impl SequencerHandle { rpc_server, scheduler, addr: _, + gossip: _, } = self; executor.is_healthy() && rpc_server.is_healthy() && scheduler.is_healthy() @@ -108,17 +117,66 @@ impl SequencerHandle { pub const fn addr(&self) -> SocketAddr { self.addr } + + /// Multiaddrs (with the `/p2p/` peer id suffix) other nodes can use as + /// gossip `bootstrap_peers`. `None` when gossip is unconfigured. + #[must_use] + pub fn gossip_bootstrap_addrs(&self) -> Option> { + self.gossip + .as_ref() + .map(sequencer_core::gossip::GossipNetwork::bootstrap_addrs) + } } 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; + // Captured before `config` moves into the executor; gossip needs them after. + let gossip_config = config.gossip.clone(); + let bedrock_config = config.bedrock_config.clone(); + let sequencer_home = config.home.clone(); + let executor = ExecutorActor::new(config).await; + let mempool_handle = executor.mempool_handle(); let executor_ref = ExecutorActor::spawn(executor); info!("Executor Actor spawned"); - let rpc_server = RpcServerActor::new(executor_ref.clone(), listen_addr, max_block_size).await?; + // Gossip is constructed only when configured; a `None` config means no + // sockets and no tasks. Startup failure here is a hard error + // (misconfiguration); after startup, gossip never halts the node. + let gossip_network = match gossip_config { + None => None, + Some(gossip_config) => { + // The node's L1 bedrock signing key is deliberately reused as the + // libp2p identity; `GossipNetwork::start` derives the keypair. + let signing_key = + load_or_create_signing_key(&sequencer_home.join("bedrock_signing_key"))?; + let channel_id = *bedrock_config.channel_id.as_ref(); + let network = sequencer_core::gossip::GossipNetwork::start( + gossip_config, + channel_id, + signing_key, + mempool_handle, + max_block_size.as_u64(), + ) + .await + .context("Failed to start sequencer gossip network")?; + info!("Gossip network started as {}", network.local_peer_id()); + Some(network) + } + }; + let tx_publisher = gossip_network + .as_ref() + .map(sequencer_core::gossip::GossipNetwork::tx_publisher); + + let rpc_server = RpcServerActor::new( + executor_ref.clone(), + listen_addr, + max_block_size, + tx_publisher, + ) + .await?; let addr = rpc_server.addr(); let rpc_server_ref = RpcServerActor::spawn(rpc_server); info!("RPC Server Actor spawned"); @@ -142,5 +200,6 @@ pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result AccountId { // TODO: Use derivation from a public key? @@ -55,6 +72,28 @@ pub const fn clock_account_ids() -> [AccountId; 3] { clock_core::CLOCK_PROGRAM_ACCOUNT_IDS } +#[must_use] +pub fn sequencer_stake_config_account_id() -> AccountId { + sequencer_stake_core::sequencer_stake_config_account_id(programs::sequencer_stake().id()) +} + +/// Starts with no entries; every stake, including the bootstrap sequencer's +/// own, is added by replaying a `Stake` transaction, not seeded here. +#[must_use] +pub fn sequencer_stake_config_account() -> Account { + Account { + program_owner: programs::sequencer_stake().id(), + data: sequencer_stake_core::SequencerStakeConfig { + minimum_sequencer_stake: DEFAULT_MINIMUM_SEQUENCER_STAKE, + entries: BTreeMap::new(), + } + .to_bytes() + .try_into() + .expect("sequencer stake config data should fit"), + ..Account::default() + } +} + #[must_use] pub fn clock_account() -> Account { Account { diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index 8ea71e22b..1c9871de7 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -212,6 +212,10 @@ fn initial_public_accounts() -> HashMap { .into_iter() .map(|clock_id| (clock_id, system_accounts::clock_account())), ) + .chain([( + system_accounts::sequencer_stake_config_account_id(), + system_accounts::sequencer_stake_config_account(), + )]) .collect() } @@ -225,6 +229,7 @@ fn initial_programs() -> Vec { programs::vault(), programs::faucet(), programs::bridge(), + programs::sequencer_stake(), // Cross-zone programs are builtins: their bytecode is baked into every node, // so registering them in the base state (rather than shipping ELFs through // the genesis block, which exceeds the inscription size limit) keeps the two diff --git a/test_fixtures/Cargo.toml b/test_fixtures/Cargo.toml index 41e82bda7..4d9982123 100644 --- a/test_fixtures/Cargo.toml +++ b/test_fixtures/Cargo.toml @@ -16,6 +16,7 @@ lee.workspace = true lee_core = { workspace = true, features = ["host"] } sequencer_core = { workspace = true, features = ["default", "testnet"] } sequencer_service.workspace = true +sequencer_stake_core.workspace = true sequencer_service_rpc = { workspace = true, features = ["client"] } wallet.workspace = true programs.workspace = true diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 79ac275f1..b9d700aa4 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/test_fixtures/src/bin/regenerate_test_fixture.rs b/test_fixtures/src/bin/regenerate_test_fixture.rs index 22e529513..52e91bca6 100644 --- a/test_fixtures/src/bin/regenerate_test_fixture.rs +++ b/test_fixtures/src/bin/regenerate_test_fixture.rs @@ -52,6 +52,7 @@ async fn generate_prebuilt_fixture(dest: &Path) -> Result<()> { let (sequencer_handle, temp_sequencer_dir) = SequencerSetup::new(config::SequencerPartialConfig::default(), bedrock_addr) .with_genesis(genesis) + .with_bedrock_signing_key(config::SEQUENCER_BEDROCK_SIGNING_KEY) .setup() .await .context("Failed to setup Sequencer for fixture generation")?; diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index 1890809a3..9513dd509 100644 --- a/test_fixtures/src/config.rs +++ b/test_fixtures/src/config.rs @@ -6,9 +6,13 @@ use indexer_service::{ChannelId, ClientConfig, IndexerConfig}; use key_protocol::key_management::{KeyChain, secret_holders::SeedHolder}; use lee::{AccountId, PrivateKey, PublicKey}; use lee_core::Identifier; -use logos_blockchain_key_management_system_service::keys::ZkPublicKey; +use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkPublicKey}; use num_bigint::BigUint; -use sequencer_core::config::{BedrockConfig, CrossZoneConfig, GenesisAction, SequencerConfig}; +use sequencer_core::{ + config::{BedrockConfig, CrossZoneConfig, GenesisAction, GossipConfig, SequencerConfig}, + sign_genesis_stake, +}; +use sequencer_stake_core::SequencerKey; use url::Url; use wallet::config::{MultiSequencerClientConfig, SequencerConnectionData, WalletConfig}; @@ -18,6 +22,13 @@ pub const INITIAL_PRIVATE_BALANCES_FOR_WALLET: [u128; 2] = [10_000, 20_000]; /// Fixed sequencer signing key; exposed so the fixture generator can reopen the produced store. pub const SEQUENCER_SIGNING_KEY: [u8; 32] = [37; 32]; +/// Key of the account holding the sequencer's genesis stake. Separate from +/// [`SEQUENCER_SIGNING_KEY`]: block signing and stake control are distinct roles. +pub const SEQUENCER_STAKE_KEY: [u8; 32] = [55; 32]; + +/// Bedrock signing key used by the prebuilt dump as first accredited key. +pub const SEQUENCER_BEDROCK_SIGNING_KEY: [u8; 32] = [77; 32]; + // Fixed entropy seeds for the default accounts: deterministic so one prebuilt database is reusable, // and distinct from the `testnet_initial_state` accounts to avoid depending on / double-funding // them. @@ -106,6 +117,7 @@ pub fn sequencer_config( genesis_transactions: Vec, cross_zone: Option, signing_key: Option<[u8; 32]>, + gossip: Option, ) -> Result { let SequencerPartialConfig { max_num_tx_in_block, @@ -133,6 +145,7 @@ pub fn sequencer_config( }, cross_zone, metrics_address: Some(SequencerConfig::DEFAULT_METRICS_ADDRESS), + gossip, }) } @@ -304,6 +317,37 @@ pub fn sequencer_signing_key_from_seed(seed: u32) -> [u8; 32] { .unwrap_or_else(|_| unreachable!()) } +/// Seed of the account owning sequencer `index`'s founding stake. +fn founding_stake_owner_seed(index: usize) -> [u8; 32] { + if index == 0 { + return SEQUENCER_STAKE_KEY; + } + let mut seed = [0x70; 32]; + seed[0] = u8::try_from(index).expect("Test contexts never run enough sequencers to overflow"); + seed +} + +/// Genesis entries staking every sequencer in `sequencer_signing_keys`, so the +/// creator opens the channel already accrediting all of them. +pub fn genesis_sequencer_stakes(sequencer_signing_keys: &[[u8; 32]]) -> Result> { + sequencer_signing_keys + .iter() + .enumerate() + .map(|(index, signing_key)| { + let public_key = Ed25519Key::from_bytes(signing_key).public_key(); + let sequencer_key = SequencerKey::new(public_key.to_bytes()) + .context("Sequencer signing key is not a valid Ed25519 point")?; + let owner = PrivateKey::try_new(founding_stake_owner_seed(index)) + .context("Failed to build the founding stake ownership key")?; + Ok(GenesisAction::StakeSequencer { + sequencer_key, + ownership_public_key: PublicKey::new_from_private_key(&owner), + stake_signature: sign_genesis_stake(index, sequencer_key, &owner), + }) + }) + .collect() +} + /// Generate bedrock channel id from `u32` number via repeating le bytes 8 times. /// /// Counting from the end of `u32` to guarantee, that it is different from diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index ccbfaf93e..5cb942b78 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -10,11 +10,8 @@ use indexer_service::{ChannelId, IndexerHandle}; use lee::{AccountId, PrivacyPreservingTransaction, PrivateKey}; use lee_core::Commitment; use log::{debug, error}; -use sequencer_core::{ - block_publisher::{Ed25519Key, post_channel_config}, - config::GenesisAction, -}; -use sequencer_service::{BedrockConfig, CrossZoneConfig, SequencerHandle, default_priority_fee}; +use sequencer_core::config::GenesisAction; +use sequencer_service::{CrossZoneConfig, GossipConfig, SequencerHandle}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use serde::Serialize; use tempfile::TempDir; @@ -25,10 +22,7 @@ use wallet::{ }; use crate::{ - config::{ - InitialPrivateAccountForWallet, MultiNodeTestContextConfig, SequencerPartialConfig, - bedrock_funding_key, - }, + config::{InitialPrivateAccountForWallet, MultiNodeTestContextConfig, SequencerPartialConfig}, indexer_client::IndexerClient, setup::{ SequencerSetup, setup_bedrock_node, setup_indexer, @@ -43,10 +37,6 @@ pub mod setup; // TODO: Remove this and control time from tests pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12; -/// 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; pub(crate) const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0"; pub(crate) const BEDROCK_SERVICE_PORT: u16 = 18080; @@ -496,11 +486,17 @@ impl Drop for TestContext { } #[derive(Debug)] +#[expect( + clippy::struct_excessive_bools, + reason = "test-context builder toggles independent features; a state machine would obscure it" +)] pub struct ZoneTestContextBuilder { genesis_transactions: Option>, sequencer_partial_config: Option, + follower_sequencer_partial_config: Option, enable_indexer: bool, enable_wallet: bool, + enable_gossip: bool, wallet_config_overrides: WalletConfigOverrides, from_scratch: bool, mn_config: MultiNodeTestContextConfig, @@ -513,8 +509,10 @@ impl ZoneTestContextBuilder { Self { genesis_transactions: None, sequencer_partial_config: None, + follower_sequencer_partial_config: None, enable_indexer: true, enable_wallet: true, + enable_gossip: false, wallet_config_overrides: WalletConfigOverrides::default(), from_scratch: false, mn_config, @@ -558,6 +556,25 @@ impl ZoneTestContextBuilder { self } + /// Override the sequencer partial config for the non-leader nodes only. + /// If not set, followers use the same config as the leader. + #[must_use] + pub const fn with_follower_sequencer_partial_config( + mut self, + follower_sequencer_partial_config: config::SequencerPartialConfig, + ) -> Self { + self.follower_sequencer_partial_config = Some(follower_sequencer_partial_config); + self + } + + /// Enable p2p gossip between the sequencers: the leader listens on an + /// OS-assigned localhost port and every follower bootstraps from it. + #[must_use] + pub const fn with_gossip(mut self) -> Self { + self.enable_gossip = true; + self + } + /// Build from genesis live instead of loading the prebuilt fixture. Implied by /// [`Self::with_genesis`]. #[must_use] @@ -600,8 +617,10 @@ impl ZoneTestContextBuilder { let Self { genesis_transactions, sequencer_partial_config, + follower_sequencer_partial_config, enable_indexer, enable_wallet, + enable_gossip, wallet_config_overrides, from_scratch, mn_config, @@ -610,6 +629,24 @@ impl ZoneTestContextBuilder { debug!("Test context setup"); + let mut sequencer_keys = vec![config::SEQUENCER_SIGNING_KEY]; + sequencer_keys.extend((1..mn_config.num_nodes).map(|i| { + config::sequencer_signing_key_from_seed( + u32::try_from(i).expect("Not being able to fit is realistically impossible"), + ) + })); + + let genesis_transactions = if mn_config.num_nodes == 1 { + genesis_transactions + } else { + let mut actions = config::genesis_sequencer_stakes(&sequencer_keys) + .context("Failed to build the founding sequencer stakes")?; + actions.extend(genesis_transactions.unwrap_or_default()); + // Returning Some() forces a live build below: the prebuilt dump stakes only one + // sequencer. + Some(actions) + }; + // The fixture bakes in the default accounts + genesis, so custom genesis / from_scratch // must build live. Otherwise load the fixture (fails if it is missing). let use_prebuilt = !from_scratch && genesis_transactions.is_none(); @@ -642,17 +679,13 @@ impl ZoneTestContextBuilder { let mut sequencer_addrs = vec![]; let mut sequencer_components = vec![]; - let mut sequencer_keys = vec![]; - - sequencer_keys.push(config::SEQUENCER_SIGNING_KEY); - - sequencer_keys.extend((1..mn_config.num_nodes).map(|i| { - config::sequencer_signing_key_from_seed( - u32::try_from(i).expect("Not being able to fit is realistically impossible"), - ) - })); - // First, need to start a leader. + let leader_gossip = enable_gossip.then(|| GossipConfig { + listen_addr: "/ip4/127.0.0.1/udp/0/quic-v1" + .parse() + .expect("hardcoded gossip listen multiaddr is valid"), + bootstrap_peers: vec![], + }); let (leader_addr, leader_components) = build_sequencer_components( partial_config, bedrock_addr, @@ -664,9 +697,22 @@ impl ZoneTestContextBuilder { config::SEQUENCER_SIGNING_KEY, mn_config.bedrock_channel, cross_zone_config.clone(), + leader_gossip, ) .await?; + // The leader listened on an OS-assigned port, so followers can only + // learn its gossip address from the running handle. + let follower_gossip = leader_components + .sequencer_handle + .gossip_bootstrap_addrs() + .map(|bootstrap_peers| GossipConfig { + listen_addr: "/ip4/127.0.0.1/udp/0/quic-v1" + .parse() + .expect("hardcoded gossip listen multiaddr is valid"), + bootstrap_peers, + }); + // Wait for genesis to be published wait_until_genesis(&leader_components.sequencer_client) .await @@ -677,19 +723,10 @@ impl ZoneTestContextBuilder { sequencer_addrs.push(leader_addr); sequencer_components.push(leader_components); - // Skip posting chain config with just one node. - if mn_config.num_nodes != 1 { - post_chain_config_with_default_parameters( - mn_config.bedrock_channel, - bedrock_addr, - sequencer_keys.clone(), - ) - .await?; - } - + // Followers are already accredited by their genesis stakes. for sequencer_key in sequencer_keys.into_iter().skip(1) { let (sequencer_addr, sequencer_component) = build_sequencer_components( - partial_config, + follower_sequencer_partial_config.unwrap_or(partial_config), bedrock_addr, enable_wallet, use_prebuilt, @@ -699,6 +736,7 @@ impl ZoneTestContextBuilder { sequencer_key, mn_config.bedrock_channel, cross_zone_config.clone(), + follower_gossip.clone(), ) .await?; @@ -992,6 +1030,12 @@ pub async fn verify_commitment_is_in_state( .is_some() } +/// Initializes the global logger once, for tests that build their fixtures +/// without going through [`TestContextBuilder`]. +pub fn init_logger() { + *LOGGER; +} + fn dir_size_bytes(path: &Path) -> u64 { let mut total = 0_u64; let Ok(entries) = std::fs::read_dir(path) else { @@ -1013,47 +1057,6 @@ fn dir_size_bytes(path: &Path) -> u64 { total } -async fn post_chain_config_with_default_parameters( - channel_id: ChannelId, - bedrock_addr: SocketAddr, - sequencer_keys: Vec<[u8; 32]>, -) -> Result<()> { - log::info!( - "Sequencer committee is {:?} at {channel_id}", - sequencer_keys - .iter() - .map(|key| Ed25519Key::from_bytes(key).public_key().as_bytes().to_vec()) - .map(hex::encode) - .collect::>() - ); - - post_channel_config( - &BedrockConfig { - channel_id, - node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, - auth: None, - funding_key: bedrock_funding_key(), - priority_fee: default_priority_fee(), - }, - &Ed25519Key::from_bytes( - sequencer_keys - .first() - .expect("Must be at least one sequencer"), - ), - sequencer_keys - .clone() - .into_iter() - .map(|key| Ed25519Key::from_bytes(&key).public_key()) - .collect(), - POSTING_TIMEFRAME_SLOTS, - POSTING_TIMEOUT_SLOTS, - 1, - 1, - ) - .await - .context("Failed to configure the channel committee") -} - async fn wait_until_genesis(client: &SequencerClient) -> Result<()> { log::info!("Waiting for leader to send genesis"); @@ -1082,6 +1085,7 @@ async fn build_sequencer_components( sequencer_key: [u8; 32], bedrock_channel_id: ChannelId, cross_zone_config: Option, + gossip: Option, ) -> Result<(SocketAddr, SequencerComponents)> { let mut sequencer_setup = SequencerSetup::new(partial_config, bedrock_addr); @@ -1103,15 +1107,20 @@ async fn build_sequencer_components( genesis_transactions.unwrap_or_default() }; + // The prebuilt dump carries a genesis stake for the key the fixture generator + // ran with, so a node restoring it has to sign Bedrock with that same key. if !use_prebuilt { - sequencer_setup = sequencer_setup.with_genesis(genesis_actions); + sequencer_setup = sequencer_setup + .with_genesis(genesis_actions) + .with_bedrock_signing_key(sequencer_key); } - - sequencer_setup = sequencer_setup.with_bedrock_signing_key(sequencer_key); sequencer_setup = sequencer_setup.with_channel_id(bedrock_channel_id); if let Some(cross_zone_config) = cross_zone_config.clone() { sequencer_setup = sequencer_setup.with_cross_zone(cross_zone_config); } + if let Some(gossip) = gossip { + sequencer_setup = sequencer_setup.with_gossip(gossip); + } let (sequencer_handle, temp_sequencer_dir) = sequencer_setup .setup() diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs index 59d9cae7e..6844d4b6a 100644 --- a/test_fixtures/src/setup.rs +++ b/test_fixtures/src/setup.rs @@ -36,6 +36,7 @@ pub struct SequencerSetup { genesis_transactions: Option>, cross_zone: Option, bedrock_signing_key: Option<[u8; ED25519_SECRET_KEY_SIZE]>, + gossip: Option, } impl SequencerSetup { @@ -48,6 +49,7 @@ impl SequencerSetup { genesis_transactions: None, cross_zone: None, bedrock_signing_key: None, + gossip: None, } } @@ -75,6 +77,14 @@ impl SequencerSetup { self } + /// Build a sequencer that joins a channel another node already created, + /// replaying its genesis from the channel instead of the prebuilt dump. + #[must_use] + pub fn joining_existing_channel(mut self) -> Self { + self.genesis_transactions = Some(Vec::new()); + self + } + /// Pre-write a bedrock (Ed25519, 32-byte seed) signing key into the home /// before boot, so tests know the sequencer's public key in advance (e.g. /// to accredit a committee member that has not started yet). @@ -84,6 +94,14 @@ impl SequencerSetup { self } + /// Enable p2p gossip with the given configuration. + /// If not set, the sequencer runs without gossip. + #[must_use] + pub fn with_gossip(mut self, gossip: sequencer_core::config::GossipConfig) -> Self { + self.gossip = Some(gossip); + self + } + /// Set up the sequencer in a fresh temporary home directory, returning the /// owning [`TempDir`] alongside the handle. pub async fn setup(self) -> Result<(SequencerHandle, TempDir)> { @@ -106,14 +124,26 @@ impl SequencerSetup { genesis_transactions, cross_zone, bedrock_signing_key, + gossip, } = self; debug!("Using sequencer home at {}", home.display()); + let bedrock_signing_key = bedrock_signing_key.or_else(|| { + genesis_transactions + .is_none() + .then_some(config::SEQUENCER_BEDROCK_SIGNING_KEY) + }); if let Some(key_bytes) = bedrock_signing_key { std::fs::write(home.join("bedrock_signing_key"), key_bytes) .context("Failed to write pre-generated bedrock signing key")?; } + // Pinned like the bedrock key: the prebuilt dump stakes this account. + std::fs::write( + home.join("sequencer_stake_signing_key"), + config::SEQUENCER_STAKE_KEY, + ) + .context("Failed to write pre-generated stake signing key")?; let genesis_transactions = if let Some(genesis) = genesis_transactions { genesis @@ -142,6 +172,7 @@ impl SequencerSetup { genesis_transactions, cross_zone, bedrock_signing_key, + gossip, ) .context("Failed to create Sequencer config")?; diff --git a/tools/cross_zone_chat/Cargo.toml b/tools/cross_zone_chat/Cargo.toml index 2c74e6dfa..a7c5de31a 100644 --- a/tools/cross_zone_chat/Cargo.toml +++ b/tools/cross_zone_chat/Cargo.toml @@ -23,3 +23,4 @@ serde = { workspace = true, features = ["derive"] } axum.workspace = true log.workspace = true env_logger.workspace = true +rand.workspace = true diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index dc527abc0..83962eaf8 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -29,8 +29,8 @@ clippy::unused_async, clippy::needless_pass_by_value, clippy::infinite_loop, - reason = "Demo binary: stdout banner is the deliverable; ordinal/elapsed arithmetic is \ - bounded at chat scale; the per-zone scanners and finality poller are daemon \ + reason = "Demo binary: stdout banner is the deliverable; elapsed arithmetic is bounded at chat \ + scale; the per-zone scanners and finality poller are daemon \ loops that run for the process lifetime; axum handlers must be `async` and take \ their extractors (State/Json/Query) by value to satisfy the framework's bounds." )] @@ -57,11 +57,14 @@ use common::{block::BedrockStatus, transaction::LeeTransaction}; use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, Instruction, ZoneId}; use cross_zone_outbox_core::outbox_pda; use lee::{ - ProgramId, PublicTransaction, + Account, ProgramId, PublicTransaction, public_transaction::{Message, WitnessSet}, }; -use log::warn; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use log::{info, warn}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; use serde::{Deserialize, Serialize}; use test_fixtures::{ @@ -71,6 +74,10 @@ use test_fixtures::{ const HTTP_PORT: u16 = 8088; const POLL_INTERVAL: Duration = Duration::from_secs(1); +/// Ordinals probed for a free outbox slot before giving up. +const ORDINAL_PROBE_LIMIT: u32 = 1_024; +/// Transient RPC failures tolerated per probed slot. +const RPC_RETRY_LIMIT: u32 = 5; /// One chat message tracked through its cross-zone pipeline. Displayed in the /// receiving zone's column; the stage fields drive the on-page timeline. @@ -127,8 +134,12 @@ struct ZoneRuntime { /// The peer zone's channel id; the target of sends from this zone. other_zone: ZoneId, client: SequencerClient, - /// Monotonic outbox ordinal per (this zone -> peer); each send must use a - /// fresh value because the outbox PDA is claimed only when default. + /// Next outbox ordinal to try for (this zone -> peer). + /// + /// An outbox slot is written once and the ordinal names a slot in a space + /// every user of `ping_sender` shares, so a fixed starting point collides + /// with whatever the chain already holds. Seeded from a free slot found by + /// [`next_free_ordinal`], then incremented per send. ordinal: AtomicU32, } @@ -304,16 +315,26 @@ async fn main() -> Result<()> { .await .context("Failed to set up zone B sequencer")?; + let client_a = sequencer_client(seq_a.addr())?; + let client_b = sequencer_client(seq_b.addr())?; + let ordinal_a = next_free_ordinal(&client_a, &zone_b) + .await + .context("Failed to find a free outbox ordinal for zone A")?; + let ordinal_b = next_free_ordinal(&client_b, &zone_a) + .await + .context("Failed to find a free outbox ordinal for zone B")?; + info!("Outbox ordinals start at A={ordinal_a} B={ordinal_b}"); + let state = Arc::new(AppState { zone_a: ZoneRuntime { other_zone: zone_b, - client: sequencer_client(seq_a.addr())?, - ordinal: AtomicU32::new(0), + client: client_a, + ordinal: AtomicU32::new(ordinal_a), }, zone_b: ZoneRuntime { other_zone: zone_a, - client: sequencer_client(seq_b.addr())?, - ordinal: AtomicU32::new(0), + client: client_b, + ordinal: AtomicU32::new(ordinal_b), }, next_id: AtomicU64::new(1), messages: Mutex::new(Vec::new()), @@ -365,6 +386,55 @@ fn sequencer_client(addr: SocketAddr) -> Result { .context("Failed to build sequencer client") } +/// A free outbox slot for this zone's `ping_sender` to start counting from: the +/// first unwritten one at or after a random ordinal. +/// +/// An outbox slot is written once, so a send into an occupied one fails at block +/// production, and `send_transaction` checks a transaction only statelessly, so +/// nothing tells the sender. The predicate here is the one the guest asserts, +/// asked before submitting instead of after. +/// +/// Random rather than zero because the ordinal space is shared by every user of +/// `ping_sender` and a slot costs one unsigned transaction to occupy, so any +/// fixed starting point can be squatted. On a chain this process owns, which is +/// what `just cross-zone-chat` boots, nothing is occupied and this returns on +/// its first try; it earns its keep against a chain the tool did not create. +/// +/// It only places the first send. Later ordinals come from incrementing, so a +/// slot taken after this returns still collides, at a probability of the +/// occupied count over 2^32. +async fn next_free_ordinal(client: &SequencerClient, target_zone: &ZoneId) -> Result { + let outbox_id = programs::cross_zone_outbox().id(); + let emitter = programs::ping_sender().id(); + let start: u32 = rand::random(); + + for offset in 0..ORDINAL_PROBE_LIMIT { + let ordinal = start.wrapping_add(offset); + let slot = outbox_pda(outbox_id, emitter, target_zone, ordinal); + // Retried rather than propagated: by here the run has already paid for a + // Bedrock bring-up and two sequencer boots, and every other RPC caller + // in this tool rides out a transient error rather than ending the run. + let mut attempt = 0_u32; + let account = loop { + match client.get_account(slot).await { + Ok(account) => break account, + Err(err) if attempt < RPC_RETRY_LIMIT => { + attempt += 1; + warn!("Outbox probe failed, retrying ({attempt}/{RPC_RETRY_LIMIT}): {err}"); + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(err) => { + return Err(err).context("Failed to read an outbox slot while probing"); + } + } + }; + if account == Account::default() { + return Ok(ordinal); + } + } + anyhow::bail!("No free outbox ordinal in {ORDINAL_PROBE_LIMIT} tried from {start}") +} + /// Scans one zone's new blocks. A `ping_sender` tx marks the message's source /// block (its outbound leg); an inbox dispatch marks delivery on this zone. /// Runs forever; transient RPC errors are logged and retried. @@ -455,7 +525,9 @@ fn decode_inbox_text(instruction_data: &[u32]) -> Option { fn decode_send_ordinal(instruction_data: &[u32]) -> Option { let instruction: SenderInstruction = risc0_zkvm::serde::from_slice::(instruction_data).ok()?; - let SenderInstruction::Send { ordinal, .. } = instruction; + let SenderInstruction::Send { ordinal, .. } = instruction else { + return None; + }; Some(ordinal) } @@ -470,7 +542,9 @@ fn decode_payload(payload: &[u8]) -> Option { .collect(); let instruction: ReceiverInstruction = risc0_zkvm::serde::from_slice::(&words).ok()?; - let ReceiverInstruction::Record { payload: bytes } = instruction; + let ReceiverInstruction::Record { payload: bytes } = instruction else { + return None; + }; Some(String::from_utf8_lossy(&bytes).into_owned()) } @@ -487,18 +561,21 @@ fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone: other_zone, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_id, &other_zone, ordinal); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &other_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, )