diff --git a/.github/scripts/with_retry.sh b/.github/scripts/with_retry.sh new file mode 100644 index 000000000..fb03036c6 --- /dev/null +++ b/.github/scripts/with_retry.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -uo pipefail + +with_retry() { + local command="$1" + local max_attempts="${2:-3}" + local attempt=1 + + while (( attempt <= max_attempts )); do + if eval "$command"; then + return 0 + fi + + if (( attempt < max_attempts )); then + echo "::warning:: Attempt $attempt failed, cleaning up and retrying..." >&2 + rm -rf target/debug/deps/*.o target/debug/incremental 2>/dev/null || true + cargo clean -p integration_tests 2>/dev/null || true + sleep 5 + fi + + (( attempt++ )) + done + + echo "::error:: Command failed after $max_attempts attempts: $command" >&2 + return 1 +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b6250be8..ed66e91f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,13 +215,8 @@ jobs: image: ${{ needs.ci-image.outputs.image }} env: RISC0_DEV_MODE=1 run: | - for i in 1 2 3; do - cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager && break - echo "::warning:: Attempt $i failed, cleaning up and retrying..." >&2 - rm -rf target/debug/deps/*.o target/debug/incremental 2>/dev/null || true - cargo clean -p integration_tests 2>/dev/null || true - sleep 5 - done + source .github/scripts/with_retry.sh + with_retry 'cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager' 5 - name: Upload integration test archive uses: actions/upload-artifact@v4 @@ -288,7 +283,9 @@ jobs: env: | RISC0_DEV_MODE=1 RUST_LOG=info - run: cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})" + run: | + source .github/scripts/with_retry.sh + with_retry 'cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})"' 5 valid-proof-test: needs: ci-image @@ -318,7 +315,9 @@ jobs: with: image: ${{ needs.ci-image.outputs.image }} env: RUST_LOG=info - run: cargo test -p integration_tests -- --exact private::private_transfer_to_owned_account + run: | + source .github/scripts/with_retry.sh + with_retry 'cargo test -p integration_tests -- --exact private::private_transfer_to_owned_account' 5 # `just build-artifacts` drives the host's Docker daemon via `cargo risczero # build`, so it goes through the image rather than `container:`. diff --git a/Cargo.lock b/Cargo.lock index 8bae205e9..26f1a2787 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", @@ -1979,8 +1957,10 @@ name = "cross_zone" version = "0.1.0" dependencies = [ "bridge_lock_core", + "common", "cross_zone_inbox_core", "cross_zone_marker_core", + "hex", "lee", "lee_core", "ping_core", @@ -2310,7 +2290,7 @@ dependencies = [ "clap", "json-pretty-compact", "sequencer_core_metrics", - "sequencer_service_metrics", + "sequencer_rpc_server_actor_metrics", "serde", "serde_json", ] @@ -2524,7 +2504,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2584,6 +2564,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "downloader" version = "0.2.8" @@ -2886,7 +2872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2981,6 +2967,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" @@ -3433,7 +3431,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils", - "http 1.4.1", + "http", "js-sys", "pin-project", "serde", @@ -3499,16 +3497,16 @@ checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.1", + "http", "indexmap 2.14.0", "slab", "tokio", @@ -3564,7 +3562,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -3662,11 +3659,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", @@ -3678,6 +3674,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.9.4", + "ring", "socket2 0.5.10", "thiserror 2.0.18", "tinyvec", @@ -3688,9 +3685,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", @@ -3778,17 +3775,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" @@ -3806,7 +3792,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http", ] [[package]] @@ -3817,7 +3803,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http", "http-body", "pin-project-lite", ] @@ -3895,7 +3881,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.4.1", + "http", "http-body", "httparse", "httpdate", @@ -3927,7 +3913,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", @@ -3962,14 +3948,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.1", + "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -4162,27 +4148,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" @@ -4190,10 +4155,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", @@ -4403,11 +4368,13 @@ dependencies = [ "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", @@ -4656,7 +4623,7 @@ dependencies = [ "futures-channel", "futures-util", "gloo-net", - "http 1.4.1", + "http", "jsonrpsee-core", "pin-project", "rustls", @@ -4681,7 +4648,7 @@ dependencies = [ "bytes", "futures-timer", "futures-util", - "http 1.4.1", + "http", "http-body", "http-body-util", "jsonrpsee-types", @@ -4742,7 +4709,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", @@ -4768,7 +4735,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", @@ -4792,7 +4759,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", @@ -4830,6 +4797,46 @@ dependencies = [ "wnaf", ] +[[package]] +name = "kameo" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbab7323ed30490812f43ef6416a9e21bb150e9633e451ed124ca276b1ca82a" +dependencies = [ + "downcast-rs 2.0.2", + "dyn-clone", + "futures", + "kameo_macros", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "kameo_actors" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069ef0ae25f4da6f817ce7d81f7990b3d12c3ae398b59e6e16e37b5fcc92a443" +dependencies = [ + "futures", + "glob", + "kameo", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "kameo_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7566055976eb86ee8e8fbafa0fbdad985c5d7c3f4eed04fc11bb495f71e3856" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "keccak" version = "0.1.6" @@ -5300,9 +5307,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", @@ -5332,9 +5339,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", @@ -5343,9 +5350,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", @@ -5368,9 +5375,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", @@ -5404,9 +5411,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", @@ -5420,9 +5427,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", @@ -5439,7 +5446,6 @@ dependencies = [ "libp2p-core", "libp2p-identity", "libp2p-swarm", - "prometheus-client", "quick-protobuf", "quick-protobuf-codec", "rand 0.8.6", @@ -5452,9 +5458,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", @@ -5494,9 +5500,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", @@ -5522,9 +5528,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", @@ -5541,9 +5547,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", @@ -5559,9 +5565,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", @@ -5570,6 +5576,7 @@ dependencies = [ "libp2p-identity", "libp2p-tls", "quinn", + "quinn-proto", "rand 0.8.6", "ring", "rustls", @@ -5581,9 +5588,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", @@ -5598,9 +5605,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", @@ -5612,20 +5619,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", @@ -5635,28 +5641,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", ] @@ -5682,13 +5687,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", @@ -5767,20 +5772,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", @@ -5794,7 +5789,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", @@ -5819,7 +5814,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", @@ -5840,7 +5835,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", @@ -5854,7 +5849,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", @@ -5884,8 +5879,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", @@ -5896,16 +5891,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", @@ -5914,8 +5909,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", @@ -5924,8 +5919,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", @@ -5935,15 +5930,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", @@ -5952,8 +5947,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", @@ -5962,7 +5957,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", @@ -5974,7 +5969,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", @@ -5985,7 +5980,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", @@ -6008,7 +6003,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", @@ -6016,7 +6011,6 @@ dependencies = [ "bytes", "const-hex", "hex", - "logos-blockchain-blake2btree", "logos-blockchain-blend-proofs", "logos-blockchain-codec", "logos-blockchain-cryptarchia-engine", @@ -6031,6 +6025,7 @@ dependencies = [ "logos-blockchain-utxotree", "multiaddr", "num-bigint 0.4.6", + "rpds", "serde", "strum 0.27.2", "thiserror 2.0.18", @@ -6041,7 +6036,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", @@ -6058,7 +6053,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", @@ -6077,7 +6072,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", @@ -6086,7 +6081,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", @@ -6105,7 +6100,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", @@ -6126,7 +6121,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", @@ -6154,7 +6149,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", @@ -6164,7 +6159,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", @@ -6182,7 +6177,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", @@ -6199,7 +6194,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", @@ -6225,7 +6220,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", @@ -6233,7 +6228,7 @@ dependencies = [ "either", "futures", "hex", - "igd-next 0.16.2", + "igd-next", "libp2p", "logos-blockchain-cryptarchia-sync", "logos-blockchain-log-targets", @@ -6254,7 +6249,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", ] @@ -6262,7 +6257,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", @@ -6272,7 +6267,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", @@ -6283,7 +6278,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", @@ -6297,7 +6292,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", @@ -6319,7 +6314,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", @@ -6336,7 +6331,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", @@ -6356,7 +6351,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", @@ -6375,7 +6370,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", @@ -6386,7 +6381,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", @@ -6397,7 +6392,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", @@ -6413,7 +6408,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", @@ -6434,7 +6429,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", @@ -6457,7 +6452,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", @@ -6483,7 +6478,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", @@ -6508,7 +6503,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", @@ -6521,7 +6516,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", @@ -6541,7 +6536,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", @@ -6584,15 +6579,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" @@ -6937,7 +6923,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.1", + "http", "httparse", "memchr", "mime", @@ -7170,7 +7156,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7365,6 +7351,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" @@ -7423,7 +7413,7 @@ checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http", "opentelemetry", "reqwest", ] @@ -7434,7 +7424,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", @@ -8048,6 +8038,7 @@ dependencies = [ "lee_core", "ping_core", "risc0-zkvm", + "sequencer_stake_core", "token_core", "token_program", "vault_core", @@ -8056,9 +8047,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", @@ -8220,7 +8211,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -8234,6 +8225,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", @@ -8257,7 +8249,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -8597,7 +8589,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.1", + "http", "http-body", "http-body-util", "hyper", @@ -9055,7 +9047,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4382d3af3a4ebdae7f64ba6edd9114fff92c89808004c4943b393377a25d001" dependencies = [ - "downcast-rs", + "downcast-rs 1.2.1", "paste", ] @@ -9215,7 +9207,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9498,6 +9490,7 @@ name = "sequencer_core" version = "0.1.0" dependencies = [ "anyhow", + "authenticated_transfer_core", "borsh", "bridge_core", "bytesize", @@ -9514,6 +9507,7 @@ dependencies = [ "key_protocol", "lee", "lee_core", + "libp2p", "log", "logos-blockchain-core", "logos-blockchain-http-api-common", @@ -9526,6 +9520,7 @@ dependencies = [ "rand 0.8.6", "risc0-zkvm", "sequencer_core_metrics", + "sequencer_stake_core", "serde", "serde_json", "storage", @@ -9535,6 +9530,7 @@ dependencies = [ "testnet_initial_state", "token_core", "tokio", + "tokio-retry", "tokio-util", "url", "vault_core", @@ -9550,38 +9546,83 @@ dependencies = [ ] [[package]] -name = "sequencer_service" +name = "sequencer_executor_actor" version = "0.1.0" dependencies = [ "anyhow", - "borsh", "bytesize", - "clap", "common", "env_logger", - "futures", "hex", - "jsonrpsee", + "kameo", "lee", + "lee_core", "log", "mempool", - "metrics-exporter-prometheus", - "programs", + "num-bigint 0.4.6", "sequencer_core", - "sequencer_service_metrics", - "sequencer_service_protocol", - "sequencer_service_rpc", + "storage", + "tempfile", + "test_programs", + "thiserror 2.0.18", "tokio", "tokio-util", ] [[package]] -name = "sequencer_service_metrics" +name = "sequencer_rpc_server_actor" +version = "0.1.0" +dependencies = [ + "borsh", + "bytesize", + "common", + "jsonrpsee", + "kameo", + "lee", + "log", + "programs", + "sequencer_core", + "sequencer_executor_actor", + "sequencer_rpc_server_actor_metrics", + "sequencer_service_protocol", + "sequencer_service_rpc", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "sequencer_rpc_server_actor_metrics" version = "0.1.0" dependencies = [ "metrics", ] +[[package]] +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]] name = "sequencer_service_protocol" version = "0.1.0" @@ -9602,6 +9643,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" @@ -9825,7 +9884,7 @@ dependencies = [ "const_format", "futures", "gloo-net", - "http 1.4.1", + "http", "http-body-util", "hyper", "inventory", @@ -9998,6 +10057,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" @@ -10070,7 +10135,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures", - "http 1.4.1", + "http", "httparse", "log", "rand 0.8.6", @@ -10346,6 +10411,7 @@ dependencies = [ "faucet_core", "lee_core", "programs", + "sequencer_stake_core", ] [[package]] @@ -10413,7 +10479,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -10472,6 +10538,7 @@ dependencies = [ "sequencer_core", "sequencer_service", "sequencer_service_rpc", + "sequencer_stake_core", "serde", "serde_json", "tempfile", @@ -10534,7 +10601,7 @@ dependencies = [ "etcetera", "ferroid", "futures", - "http 1.4.1", + "http", "itertools 0.14.0", "log", "memchr", @@ -10727,6 +10794,7 @@ dependencies = [ "signal-hook-registry", "socket2 0.6.4", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -10741,6 +10809,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" @@ -10914,7 +10993,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http 1.4.1", + "http", "http-body", "http-body-util", "hyper", @@ -10972,7 +11051,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.1", + "http", "http-body", "http-body-util", "http-range-header", @@ -11179,7 +11258,7 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.1", + "http", "httparse", "log", "rand 0.9.4", @@ -11380,7 +11459,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", ] @@ -11548,7 +11627,6 @@ dependencies = [ "associated_token_account_core", "async-stream", "authenticated_transfer_core", - "base58", "bincode", "bip39", "bridge_core", @@ -11592,6 +11670,7 @@ version = "0.1.0" dependencies = [ "bip39", "cbindgen", + "common", "key_protocol", "lee", "lee_core", @@ -11858,7 +11937,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 180feb84c..1c294d202 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,9 @@ members = [ "lez/sequencer/service", "lez/sequencer/service/protocol", "lez/sequencer/service/rpc", - "lez/sequencer/service/metrics", + "lez/sequencer/actors/executor", + "lez/sequencer/actors/rpc_server", + "lez/sequencer/actors/rpc_server/metrics", "lez/indexer/core", "lez/indexer/service", "lez/indexer/service/protocol", @@ -53,6 +55,7 @@ members = [ "lez/programs/wrapped_token", "lez/programs/ping_sender", "lez/programs/ping_receiver", + "lez/programs/sequencer_stake", "lez/cross_zone", "test_programs", @@ -85,7 +88,9 @@ sequencer_core = { path = "lez/sequencer/core" } sequencer_core_metrics = { path = "lez/sequencer/core/metrics" } sequencer_service_protocol = { path = "lez/sequencer/service/protocol" } sequencer_service_rpc = { path = "lez/sequencer/service/rpc" } -sequencer_service_metrics = { path = "lez/sequencer/service/metrics" } +sequencer_executor_actor = { path = "lez/sequencer/actors/executor" } +sequencer_rpc_server_actor = { path = "lez/sequencer/actors/rpc_server" } +sequencer_rpc_server_actor_metrics = { path = "lez/sequencer/actors/rpc_server/metrics" } sequencer_service = { path = "lez/sequencer/service" } indexer_core = { path = "lez/indexer/core" } indexer_service = { path = "lez/indexer/service" } @@ -116,6 +121,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" } @@ -132,6 +138,8 @@ tokio = { version = "1.50", features = [ tokio-util = "0.7.18" risc0-zkvm = { version = "3.0.5", default-features = false, features = ['std'] } risc0-build = "3.0.5" +kameo = "0.22.2" +kameo_actors = "0.8.1" anyhow = "1.0.98" derive_more = "2.1.1" num_cpus = "1.13.1" @@ -154,12 +162,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" @@ -184,14 +203,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" } @@ -345,6 +364,8 @@ clippy.let-underscore-untyped = "allow" # Reason: this lint is actually bad as it forces to use wildcard `..` instead of # field-by-field `_` which may lead to subtle bugs when new fields are added to the struct. clippy.unneeded-field-pattern = "allow" +# Reason: this lint makes no sense for us. +clippy.error_impl_error = "allow" # Nursery clippy.nursery = { level = "deny", priority = -1 } diff --git a/Justfile b/Justfile index 4741f6c84..607b92817 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,7 @@ cross-zone-chat: clean: @echo "๐Ÿงน Cleaning run artifacts" rm -rf lez/sequencer/service/bedrock_signing_key - rm -rf lez/sequencer/service/rocksdb + rm -rf lez/sequencer/service/rocksdb* rm -rf lez/indexer/service/rocksdb* rm -rf lez/wallet/configs/debug/storage.json rm -rf lez/wallet/configs/debug/statistics.json diff --git a/README.md b/README.md index 401fff157..79a624ba9 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,9 @@ The sequencer and logos blockchain node can be run locally: After stopping services above you need to remove 3 folders to start cleanly: 1. In the `logos-blockchain/logos-blockchain` folder `state` (not needed in case of docker setup) - 2. In the `logos-execution-zone` folder `lez/sequencer/service/rocksdb` + 2. In the `logos-execution-zone` folder `lez/sequencer/service/rocksdb-` 3. In the `logos-execution-zone` file `lez/sequencer/service/bedrock_signing_key` - 4. In the `logos-execution-zone` folder `lez/indexer/service/rocksdb` + 4. In the `logos-execution-zone` folder `lez/indexer/service/rocksdb-` ### Normal mode (`just` commands) We provide a `Justfile` for developer and user needs, you can run the whole setup with it. The only difference will be that logos-blockchain (bedrock) will be started from docker. diff --git a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin index 639da90ac..bdb7c684b 100644 Binary files a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin and b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin differ diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index e4c1ffa77..5908e7e35 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 4c68da349..27ce81da7 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 68239adc0..b203abed2 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 9859cb42c..8740f78c9 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 3de6c5e53..6639f16b3 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 4789a393a..59d14a880 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 008d58a10..c47e42458 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 efdcb2ae5..95e647aac 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 1959d1b4b..cbe6c3100 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 70bd15fe5..6850d7532 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 fa7a1814b..62d1fe279 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 3ac15abcc..60b71e942 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 a8aba28fe..6a75873ac 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..314e927b0 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 1d6d4c960..d5205c4c0 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 64c8f0bbd..a1bd960f5 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 5d4932bab..4bad6b150 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/build_utils/src/lib.rs b/build_utils/src/lib.rs index 1323d830e..6753e845d 100644 --- a/build_utils/src/lib.rs +++ b/build_utils/src/lib.rs @@ -17,11 +17,19 @@ use anyhow::{Context as _, Result, bail}; /// } /// ``` pub fn include_artifacts(artifacts_sub_dir: &str) -> Result<()> { - let manifest_dir = PathBuf::from(std::env!("CARGO_MANIFEST_DIR")); + // Resolved at build-script runtime from the invoking crate, not at compile + // time: `env!` would bake in the path of whichever checkout compiled this + // rlib first, and with a shared cargo target dir every other worktree then + // embeds that checkout's artifacts instead of its own. + let invoking_manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let workspace_root = invoking_manifest_dir + .ancestors() + .find(|dir| dir.join("artifacts").is_dir()) + .context("no artifacts/ directory above the invoking crate")?; let out_dir = PathBuf::from(env::var("OUT_DIR")?); let mod_dir = out_dir.join(artifacts_sub_dir); let mod_file = mod_dir.join("mod.rs"); - let artifacts_dir = manifest_dir.join(format!("../artifacts/{artifacts_sub_dir}/")); + let artifacts_dir = workspace_root.join(format!("artifacts/{artifacts_sub_dir}/")); println!("cargo:rerun-if-changed={}", artifacts_dir.display()); diff --git a/examples/program_deployment/README.md b/examples/program_deployment/README.md index 240079a52..35a1cd5bf 100644 --- a/examples/program_deployment/README.md +++ b/examples/program_deployment/README.md @@ -348,9 +348,9 @@ Check the `run_hello_world_private.rs` file to see how it is used. # 8. Account authorization mechanism The Hello world example does not enforce any authorization on the input account. This means any user can execute it on any account, regardless of ownership. -LEE provides a mechanism for programs to enforce proper authorization before an execution can succeed. The meaning of authorization differs between public and private accounts: -- Public accounts: authorization requires that the transaction is signed with the accountโ€™s signing key. -- Private accounts: authorization requires that the circuit verifies knowledge of the accountโ€™s nullifier secret key. +LEE provides a mechanism for programs to enforce proper authorization before an execution can succeed. For both private and public accounts, the authorization is checked against knowledge of a secret key, yet the check is different: +- Public accounts: the transaction is signed with the accountโ€™s signing key. +- Private accounts: the circuit verifies knowledge of the accountโ€™s authorization secret key (`ask`), the key from which the accountโ€™s nullifier secret key is derived. From the program development perspective it is very simple: input accounts come with a flag indicating whether they has been properly authorized. And so, the only difference between the program `hello_world.rs` and `hello_world_with_authorization.rs` is in the lines diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 92f87b005..816bac69d 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -33,11 +33,13 @@ 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 diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index fe07aee76..1a9783ee8 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -8,7 +8,6 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use lee::AccountId; -use log::info; use sequencer_service_rpc::RpcClient as _; pub use test_fixtures::*; use wallet::{ @@ -93,7 +92,7 @@ pub async fn send_claiming_new_account( amount, ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; Ok(()) } @@ -113,7 +112,7 @@ pub async fn create_token( total_supply, }; wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; Ok(()) } @@ -135,7 +134,7 @@ pub async fn token_send( amount, }; wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; Ok(()) } @@ -155,7 +154,7 @@ pub async fn token_send_claiming_new_account( amount, ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; Ok(()) } @@ -198,6 +197,7 @@ pub async fn sync_private(ctx: &mut TestContext) -> Result<()> { } /// Look up a restored private account for `account_id`, panicking with `label` if absent. +#[must_use] pub fn restored_private_account<'ctx>( ctx: &'ctx TestContext, account_id: AccountId, @@ -240,7 +240,7 @@ pub async fn wait_for_indexer_to_catch_up(ctx: &TestContext) -> Result { let last_seq = sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()) .await?; - info!( + log::info!( "Indexer caught up. Indexer last block id: {ind}. Current sequencer last block id: {last_seq}" ); return Ok(ind); diff --git a/integration_tests/tests/account.rs b/integration_tests/tests/account.rs index 2b69f7e0e..edfdda01f 100644 --- a/integration_tests/tests/account.rs +++ b/integration_tests/tests/account.rs @@ -8,7 +8,6 @@ use integration_tests::{TestContext, get_account, new_account, private_mention}; use key_protocol::key_management::KeyChain; use lee::Data; use lee_core::account::Nonce; -use log::info; use tokio::test; use wallet::{ account::{AccountIdWithPrivacy, HumanReadableAccount, Label}, @@ -27,13 +26,13 @@ async fn get_existing_account() -> Result<()> { assert_eq!( account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); assert_eq!(account.balance, 10000); assert!(account.data.is_empty()); assert_eq!(account.nonce.0, 1); - info!("Successfully retrieved account with correct details"); + log::info!("Successfully retrieved account with correct details"); Ok(()) } @@ -60,7 +59,7 @@ async fn new_public_account_with_label() -> Result<()> { assert_eq!(resolved, Some(AccountIdWithPrivacy::Public(account_id))); - info!("Successfully created public account with label"); + log::info!("Successfully created public account with label"); Ok(()) } @@ -82,7 +81,7 @@ async fn add_label_to_existing_account() -> Result<()> { assert_eq!(resolved, Some(AccountIdWithPrivacy::Private(account_id))); - info!("Successfully set label on existing private account"); + log::info!("Successfully set label on existing private account"); Ok(()) } @@ -103,7 +102,7 @@ async fn new_public_account_without_label() -> Result<()> { "No label should be stored when not provided" ); - info!("Successfully created public account without label"); + log::info!("Successfully created public account without label"); Ok(()) } @@ -147,7 +146,7 @@ async fn import_private_account() -> Result<()> { 0, )); let account = lee::Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), balance: 777, data: Data::default(), nonce: Nonce::default(), @@ -211,7 +210,7 @@ async fn import_private_account_second_time_overrides_account_data() -> Result<( serde_json::to_string(&key_chain).context("Failed to serialize key chain")?; let initial_account = lee::Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), balance: 100, data: Data::default(), nonce: Nonce::default(), @@ -230,7 +229,7 @@ async fn import_private_account_second_time_overrides_account_data() -> Result<( .await?; let updated_account = lee::Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), balance: 999, data: Data::default(), nonce: Nonce::default(), diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index b62fe8920..3fa15e3d6 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -17,7 +17,6 @@ use lee_core::{ account::{Account, AccountWithMetadata}, encryption::ViewingPublicKey, }; -use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ @@ -38,13 +37,13 @@ async fn private_transfer_to_owned_account() -> Result<()> { send(&mut ctx, private_mention(from), private_mention(to), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; assert_private_commitment_in_state(&ctx, from, "sender").await?; assert_private_commitment_in_state(&ctx, to, "receiver").await?; - info!("Successfully transferred privately to owned account"); + log::info!("Successfully transferred privately to owned account"); Ok(()) } @@ -73,7 +72,7 @@ async fn private_transfer_to_foreign_account() -> Result<()> { anyhow::bail!("Expected TransactionExecuted return value"); }; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let new_commitment1 = ctx @@ -88,7 +87,7 @@ async fn private_transfer_to_foreign_account() -> Result<()> { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } - info!("Successfully transferred privately to foreign account"); + log::info!("Successfully transferred privately to foreign account"); Ok(()) } @@ -109,7 +108,7 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { send(&mut ctx, private_mention(from), public_mention(to), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let from_acc = ctx @@ -123,7 +122,7 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { assert_eq!(from_acc.balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Successfully deshielded transfer to public account"); + log::info!("Successfully deshielded transfer to public account"); Ok(()) } @@ -154,7 +153,7 @@ async fn deshielded_transfer_does_not_sign_with_recipient_key() -> Result<()> { anyhow::bail!("Expected TransactionExecuted return value"); }; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; @@ -164,7 +163,7 @@ async fn deshielded_transfer_does_not_sign_with_recipient_key() -> Result<()> { "deshielded transfer must not carry any signature, in particular not the recipient's" ); - info!("Deshielded transfer correctly did not sign with the recipient's key"); + log::info!("Deshielded transfer correctly did not sign with the recipient's key"); Ok(()) } @@ -223,7 +222,7 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { .context("Failed to get recipient's private account")?; assert_eq!(to_res_acc.balance, 100); - info!("Successfully transferred using claiming path"); + log::info!("Successfully transferred using claiming path"); Ok(()) } @@ -237,7 +236,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> { send(&mut ctx, public_mention(from), private_mention(to), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let acc_to = ctx @@ -251,7 +250,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> { assert_eq!(acc_from_balance, 9900); assert_eq!(acc_to.balance, 20100); - info!("Successfully shielded transfer to owned private account"); + log::info!("Successfully shielded transfer to owned private account"); Ok(()) } @@ -280,7 +279,7 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { anyhow::bail!("Expected TransactionExecuted return value"); }; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; @@ -293,7 +292,7 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { assert_eq!(acc_1_balance, 9900); - info!("Successfully shielded transfer to foreign account"); + log::info!("Successfully shielded transfer to foreign account"); Ok(()) } @@ -338,7 +337,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; - info!("Waiting for next blocks to check if continuous run fetches account"); + log::info!("Waiting for next blocks to check if continuous run fetches account"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; @@ -371,7 +370,7 @@ async fn initialize_private_account() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Syncing private accounts"); + log::info!("Syncing private accounts"); sync_private(&mut ctx).await?; assert_private_commitment_in_state(&ctx, account_id, "account").await?; @@ -383,12 +382,12 @@ async fn initialize_private_account() -> Result<()> { assert_eq!( account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); assert_eq!(account.balance, 0); assert!(account.data.is_empty()); - info!("Successfully initialized private account"); + log::info!("Successfully initialized private account"); Ok(()) } @@ -417,13 +416,13 @@ async fn private_transfer_using_from_label() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; assert_private_commitment_in_state(&ctx, from, "sender").await?; assert_private_commitment_in_state(&ctx, to, "receiver").await?; - info!("Successfully transferred privately using from_label"); + log::info!("Successfully transferred privately using from_label"); Ok(()) } @@ -462,10 +461,10 @@ async fn initialize_private_account_using_label() -> Result<()> { assert_eq!( account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); - info!("Successfully initialized private account using label"); + log::info!("Successfully initialized private account using label"); Ok(()) } @@ -526,7 +525,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; sync_private(&mut ctx).await?; @@ -569,7 +568,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { "both accounts must resolve to the key node created at the start of the test" ); - info!("Successfully transferred to two distinct identifiers under the same NPK"); + log::info!("Successfully transferred to two distinct identifiers under the same NPK"); Ok(()) } @@ -584,7 +583,7 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { )); ctx.sequencer_client().send_transaction(deploy_tx).await?; - info!("Waiting for deploy block creation"); + log::info!("Waiting for deploy block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let faucet_account_id = system_accounts::faucet_account_id(); @@ -592,7 +591,8 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { let faucet_program_id = programs::faucet().id(); let vault_program_id = programs::vault().id(); let auth_transfer_program_id = programs::authenticated_transfer().id(); - let nsk: lee_core::NullifierSecretKey = [3; 32]; + let ask = lee_core::AuthorizationSecretKey([3; 32]); + let nsk = lee_core::NullifierSecretKey::from(&ask); let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); let attacker_vault_id = { @@ -661,7 +661,8 @@ async fn prove_init_with_commitment_root( sender_id, ); - let nsk: lee_core::NullifierSecretKey = [7; 32]; + let ask = lee_core::AuthorizationSecretKey([7; 32]); + let nsk = lee_core::NullifierSecretKey::from(&ask); let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); let recipient_account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); @@ -678,7 +679,7 @@ async fn prove_init_with_commitment_root( vpk, random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { ask: Some(ask) }, nullifier: NullifierWitness::Init { npk, commitment_root, @@ -697,7 +698,8 @@ async fn init_with_dummy_commitment_root_produces_valid_root() -> Result<()> { let (_, expected_digest) = ctx.sequencer_client().get_proofs_and_root(vec![]).await?; - let nsk: lee_core::NullifierSecretKey = [7; 32]; + let ask = lee_core::AuthorizationSecretKey([7; 32]); + let nsk = lee_core::NullifierSecretKey::from(&ask); let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); let recipient_account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index ea0838efd..1a48b4a0e 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -7,7 +7,6 @@ use integration_tests::{ public_mention, send, send_claiming_new_account, }; use lee::{PublicKey, public_transaction}; -use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ @@ -39,15 +38,15 @@ async fn successful_transfer_to_existing_account() -> Result<()> { anyhow::bail!("Expected TransactionExecuted return value"); }; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -94,12 +93,12 @@ pub async fn successful_transfer_to_new_account() -> Result<()> { // requires it, so bypass the CLI for this one send. send_claiming_new_account(&mut ctx, sender, new_persistent_account_id, 100).await?; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, new_persistent_account_id).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 100); @@ -124,15 +123,15 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> { let failed_send = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await; assert!(failed_send.is_err()); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking balances unchanged"); + log::info!("Checking balances unchanged"); let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; let acc_2_balance = account_balance(&ctx, ctx.existing_public_accounts()[1]).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 10000); assert_eq!(acc_2_balance, 20000); @@ -156,20 +155,20 @@ async fn two_consecutive_successful_transfers() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move after first transfer"); + log::info!("Checking correct balance move after first transfer"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("First TX Success!"); + log::info!("First TX Success!"); // Second transfer send( @@ -180,20 +179,20 @@ async fn two_consecutive_successful_transfers() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move after second transfer"); + log::info!("Checking correct balance move after second transfer"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9800); assert_eq!(acc_2_balance, 20200); - info!("Second TX Success!"); + log::info!("Second TX Success!"); Ok(()) } @@ -209,18 +208,18 @@ async fn initialize_public_account() -> Result<()> { }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - info!("Checking correct execution"); + log::info!("Checking correct execution"); let account = get_account(&ctx, account_id).await?; assert_eq!( account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); assert_eq!(account.balance, 0); assert_eq!(account.nonce.0, 1); assert!(account.data.is_empty()); - info!("Successfully initialized public account"); + log::info!("Successfully initialized public account"); Ok(()) } @@ -248,17 +247,17 @@ async fn successful_transfer_using_from_label() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Successfully transferred using from_label"); + log::info!("Successfully transferred using from_label"); Ok(()) } @@ -286,17 +285,17 @@ async fn successful_transfer_using_to_label() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Successfully transferred using to_label"); + log::info!("Successfully transferred using to_label"); Ok(()) } @@ -326,7 +325,7 @@ async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> { .send_transaction(LeeTransaction::Public(tx)) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let recipient_balance_after = account_balance(&ctx, recipient).await?; @@ -372,7 +371,7 @@ async fn cannot_execute_faucet_program() -> Result<()> { .send_transaction(LeeTransaction::Public(tx)) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let recipient_balance_after = account_balance(&ctx, recipient).await?; @@ -396,7 +395,7 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { )); ctx.sequencer_client().send_transaction(deploy_tx).await?; - info!("Waiting for deploy block creation"); + log::info!("Waiting for deploy block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let faucet_account_id = system_accounts::faucet_account_id(); @@ -422,7 +421,7 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let faucet_balance_after = account_balance(&ctx, faucet_account_id).await?; diff --git a/integration_tests/tests/block_size_limit.rs b/integration_tests/tests/block_size_limit.rs index d97b695d8..237a5e279 100644 --- a/integration_tests/tests/block_size_limit.rs +++ b/integration_tests/tests/block_size_limit.rs @@ -9,22 +9,26 @@ use std::time::Duration; use anyhow::Result; use bytesize::ByteSize; use common::transaction::LeeTransaction; -use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, config::SequencerPartialConfig, -}; +use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, config::SequencerPartialConfig}; use lee::program::Program; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; #[test] async fn reject_oversized_transaction() -> Result<()> { - let ctx = TestContext::builder() - .with_sequencer_partial_config(SequencerPartialConfig { - max_num_tx_in_block: 100, - max_block_size: ByteSize::mib(1), - mempool_max_size: 1000, - block_create_timeout: Duration::from_secs(10), - }) + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(SequencerPartialConfig { + max_num_tx_in_block: 100, + max_block_size: ByteSize::mib(1), + mempool_max_size: 1000, + block_create_timeout: Duration::from_secs(10), + }), + ) .build() .await?; @@ -61,13 +65,16 @@ async fn reject_oversized_transaction() -> Result<()> { #[test] async fn accept_transaction_within_limit() -> Result<()> { - let ctx = TestContext::builder() - .with_sequencer_partial_config(SequencerPartialConfig { - max_num_tx_in_block: 100, - max_block_size: ByteSize::mib(1), - mempool_max_size: 1000, - block_create_timeout: Duration::from_secs(10), - }) + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(SequencerPartialConfig { + max_num_tx_in_block: 100, + max_block_size: ByteSize::mib(1), + mempool_max_size: 1000, + block_create_timeout: Duration::from_secs(10), + }), + ) .build() .await?; @@ -102,13 +109,16 @@ async fn transaction_deferred_to_next_block_when_current_full() -> Result<()> { let max_program_size = claimer.elf().len().max(chain_caller.elf().len()); let block_size = ByteSize::b((max_program_size + 10 * 1024) as u64); - let ctx = TestContext::builder() - .with_sequencer_partial_config(SequencerPartialConfig { - max_num_tx_in_block: 100, - max_block_size: block_size, - mempool_max_size: 1000, - block_create_timeout: Duration::from_secs(10), - }) + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(SequencerPartialConfig { + max_num_tx_in_block: 100, + max_block_size: block_size, + mempool_max_size: 1000, + block_create_timeout: Duration::from_secs(10), + }), + ) .build() .await?; diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 7e59d6c6f..84c86daa4 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -249,7 +249,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // let mut balance = bedrock_wallet_balance(bedrock_addr, bedrock_account_pk).await?; -// info!( +// log::info!( // "Queried Bedrock balance for key {bedrock_account_pk}: {:?}", // balance.balance // ); @@ -291,7 +291,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // .await // .context("Failed to decode Bedrock transfer-funds response")?; -// info!( +// log::info!( // "Submitted transfer-funds to create exact deposit note, tx hash {:?}", // transfer.hash // ); @@ -343,7 +343,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // .text() // .await // .unwrap_or_else(|_| "".to_owned()); -// info!( +// log::info!( // "Successfully submitted Bedrock deposit request for recipient {recipient_id} and amount // {amount}, response body: {body_text}", ); @@ -585,7 +585,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // let mut stream = std::pin::pin!(stream); // while let Some(message) = stream.next().await { -// info!("Observed zone message {message:?}"); +// log::info!("Observed zone message {message:?}"); // if let ZoneMessage::Withdraw(withdraw) = message { // released_notes.extend(withdraw.inputs.iter().copied()); diff --git a/integration_tests/tests/config.rs b/integration_tests/tests/config.rs index 091058330..5f0d27a18 100644 --- a/integration_tests/tests/config.rs +++ b/integration_tests/tests/config.rs @@ -6,9 +6,12 @@ use anyhow::Result; use integration_tests::TestContext; -use log::info; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, + config::{MultiNodeTestContextConfig, bedrock_channel_id}, +}; use tokio::test; -use wallet::cli::{Command, config::ConfigSubcommand}; +use wallet::cli::{Command, config::ConfigSubcommand, statistics::StatisticsSubcommand}; #[test] async fn modify_config_field() -> Result<()> { @@ -33,7 +36,66 @@ async fn modify_config_field() -> Result<()> { }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - info!("Successfully modified and restored config field"); + log::info!("Successfully modified and restored config field"); + + Ok(()) +} + +#[test] +async fn modify_config_field_multiseq() -> Result<()> { + let mut ctx = MultiZoneTestContextBuilder::default() + .with_zone(ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 2, + bedrock_channel: bedrock_channel_id(), + })) + .build() + .await?; + + // Default config have callibration limit and distribution limit as 1 + // Modifying them + let wallet_mut = ctx.wallet_mut(); + + let command = Command::Config(ConfigSubcommand::Set { + key: "distribution_limit".to_owned(), + value: "2".to_owned(), + }); + wallet::cli::execute_subcommand(wallet_mut, command).await?; + + let command = Command::Config(ConfigSubcommand::Set { + key: "calibration_limit".to_owned(), + value: "10".to_owned(), + }); + wallet::cli::execute_subcommand(wallet_mut, command).await?; + + // Check config correctness + assert_eq!( + wallet_mut + .config() + .multi_sequencer_client_config + .calibration_limit, + 10 + ); + assert_eq!( + wallet_mut + .config() + .multi_sequencer_client_config + .distribution_limit, + 2 + ); + + // Rotate clients to callibrate the other one + let command = Command::Statistics(StatisticsSubcommand::ExecuteRotation); + wallet::cli::execute_subcommand(wallet_mut, command).await?; + + // After that, there must be two leaders + let leaders = wallet_mut.leaders(); + assert_eq!(leaders.len(), 2); + + // And both of them must have similar statistics + let first_stat = wallet_mut.get_statistics(&leaders[0].1).unwrap(); + let second_stat = wallet_mut.get_statistics(&leaders[1].1).unwrap(); + + assert_eq!(first_stat.latest_block_id, second_stat.latest_block_id); Ok(()) } diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 8e3f703c8..ed91f2f3d 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -22,7 +22,6 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; use lee::{ AccountId, PrivateKey, PublicKey, PublicTransaction, @@ -30,6 +29,9 @@ use lee::{ }; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, GenesisAction}; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); @@ -39,11 +41,6 @@ const RECIPIENT: [u8; 32] = [9; 32]; #[test] async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { - // Declared first so it outlives both zones (drops run in reverse order). - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up shared Bedrock node")?; - let partial = SequencerPartialConfig::default(); let channel_a = config::bedrock_channel_id(); let channel_b = config::bedrock_channel_id_b(); @@ -72,37 +69,48 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { holder: holder_id, amount: INITIAL_BALANCE, }]; - let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_a) - .with_genesis(genesis_a) - .setup() - .await - .context("Failed to set up zone A sequencer")?; - let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_b) - .with_genesis(vec![]) - .with_cross_zone(cross_zone.clone()) - .setup() - .await - .context("Failed to set up zone B sequencer")?; - let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) - .await - .context("Failed to set up zone B indexer")?; + + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_a, + }) + .disable_wallet() + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_genesis(genesis_a), + ) + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_b, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]) + .with_cross_zone(Some(cross_zone)), + ) + .build() + .await?; + + let seq_client_a = &ctx + .zone_default_sequencer_component(channel_a) + .sequencer_client; + + let ind_client_b = ctx.indexer_client_zone(channel_b).unwrap(); // Lock LOCK_AMOUNT on zone A, addressed to the recipient on zone B. let lock = build_lock_tx(&holder_key, holder_id, zone_b); - sequencer_client(seq_a.addr())? + seq_client_a .send_transaction(lock) .await .context("Failed to submit lock on zone A")?; // Wait until zone B's indexer reflects the verified mint. let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); - let indexer = indexer_client(idx_b.addr()) - .await - .context("Failed to build indexer client")?; - let minted = wait_for_mint(&indexer, holding_id).await?; + let minted = wait_for_mint(ind_client_b, holding_id).await?; assert_eq!( minted, LOCK_AMOUNT, "zone B must mint exactly the locked amount" @@ -111,14 +119,13 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { // Conservation: the mint on B must be backed by an equal lock on A. The lock // has already landed (it preceded delivery), so zone A reflects the debit and // escrow now. - let seq_a_client = sequencer_client(seq_a.addr())?; let escrow_id = bridge_lock_core::escrow_account_id(programs::bridge_lock().id()); - let escrowed = seq_a_client.get_account(escrow_id).await?.balance; + let escrowed = seq_client_a.get_account(escrow_id).await?.balance; assert_eq!( escrowed, LOCK_AMOUNT, "zone A escrow must hold the locked amount" ); - let remaining = seq_a_client.get_account(holder_id).await?.balance; + let remaining = seq_client_a.get_account(holder_id).await?.balance; assert_eq!( remaining, INITIAL_BALANCE - LOCK_AMOUNT, diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs index 1da931379..af8b4cbf9 100644 --- a/integration_tests/tests/cross_zone_ingress_guard.rs +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -9,35 +9,39 @@ //! inbox guest's caller-is-none assertion passes for a top-level user tx, so the //! sequencer ingress guard is the only thing that stops this. -use anyhow::{Context as _, Result}; +use anyhow::Result; use common::transaction::LeeTransaction; use cross_zone_inbox_core::{ CrossZoneMessage, Instruction, inbox_config_account_id, inbox_seen_shard_account_id, }; -use integration_tests::{ - config::{self, SequencerPartialConfig}, - setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, -}; +use integration_tests::config::{self, SequencerPartialConfig}; use lee::{ PublicTransaction, public_transaction::{Message, WitnessSet}, }; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; #[test] async fn user_origin_inbox_call_rejected() -> Result<()> { - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up Bedrock node")?; let partial = SequencerPartialConfig::default(); let channel = config::bedrock_channel_id(); - let (seq, _seq_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up sequencer")?; + + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel, + }) + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .build() + .await?; // A user hand-builds a top-level inbox Dispatch and submits it via RPC. let inbox_id = programs::cross_zone_inbox().id(); @@ -64,7 +68,11 @@ async fn user_origin_inbox_call_rejected() -> Result<()> { WitnessSet::from_raw_parts(vec![]), )); - let result = sequencer_client(seq.addr())?.send_transaction(tx).await; + let result = ctx + .default_sequencer_component() + .sequencer_client + .send_transaction(tx) + .await; let err = result.expect_err("the sequencer must reject a user-origin inbox call"); assert!( err.to_string().contains("sequencer-only"), diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index 842558022..9265e33bb 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -16,10 +16,7 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; use cross_zone_outbox_core::outbox_pda; -use integration_tests::{ - config::{self, SequencerPartialConfig}, - setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, -}; +use integration_tests::config::{self, SequencerPartialConfig}; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ @@ -28,6 +25,9 @@ use ping_core::{ }; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480); @@ -35,11 +35,6 @@ const PING_PAYLOAD: &[u8] = b"hello-cross-zone"; #[test] async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { - // Declared first so it outlives both zones (drops run in reverse order). - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up shared Bedrock node")?; - let partial = SequencerPartialConfig::default(); let channel_a = config::bedrock_channel_id(); let channel_b = config::bedrock_channel_id_b(); @@ -62,30 +57,50 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { source_governance: None, }; - let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_a) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up zone A sequencer")?; - let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_b) - .with_genesis(vec![]) - .with_cross_zone(cross_zone) - .setup() - .await - .context("Failed to set up zone B sequencer")?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_a, + }) + .disable_wallet() + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_b, + }) + .disable_wallet() + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]) + .with_cross_zone(Some(cross_zone)), + ) + .build() + .await?; // Submit the ping on zone A, addressed to ping_receiver on zone B. let ping = build_ping_tx(zone_b, receiver_id); - sequencer_client(seq_a.addr())? + + let seq_client_a = &ctx + .zone_default_sequencer_component(channel_a) + .sequencer_client; + + let seq_client_b = &ctx + .zone_default_sequencer_component(channel_b) + .sequencer_client; + + seq_client_a .send_transaction(ping) .await .context("Failed to submit ping on zone A")?; // Wait until zone B's sequencer records the delivered payload. let record_id = ping_record_pda(receiver_id); - let delivered = wait_for_delivery(sequencer_client(seq_b.addr())?, record_id).await?; + let delivered = wait_for_delivery(seq_client_b.clone(), record_id).await?; assert_eq!( delivered, PING_PAYLOAD, diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index 7cb98a3ec..30725c680 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -60,7 +60,7 @@ fn seed_inbox_config(state: &mut V03State, self_zone: [u8; 32]) { *state = std::mem::replace(state, V03State::new()).with_public_accounts([( inbox_config_account_id(inbox_id), Account { - program_owner: inbox_id, + program_owner: inbox_id.into(), balance: 0, data: config .to_bytes() @@ -98,7 +98,7 @@ fn seed_wrapped_config_with_governance( *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, + program_owner: wrapped_token_id.into(), data: config .to_bytes() .try_into() @@ -135,7 +135,7 @@ fn seed_receiver_config_with_governance( *state = std::mem::replace(state, V03State::new()).with_public_accounts([( receiver_config_account_id(receiver_id), Account { - program_owner: receiver_id, + program_owner: receiver_id.into(), data: config .to_bytes() .try_into() @@ -152,7 +152,7 @@ fn seed_ping_sender_config(state: &mut V03State) { *state = std::mem::replace(state, V03State::new()).with_public_accounts([( sender_config_account_id(sender_id), Account { - program_owner: sender_id, + program_owner: sender_id.into(), data: outbox_bytes(programs::cross_zone_outbox().id()) .to_vec() .try_into() @@ -169,7 +169,7 @@ fn seed_bridge_lock_config(state: &mut V03State) { *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, + program_owner: bridge_lock_id.into(), data: bridge_lock_core::config_bytes( programs::cross_zone_outbox().id(), programs::wrapped_token().id(), @@ -457,7 +457,7 @@ fn lock_escrows_balance_and_emits_to_outbox() { state = state.with_public_accounts([( holder_id, Account { - program_owner: bridge_lock_id, + program_owner: bridge_lock_id.into(), balance: INITIAL_BALANCE, ..Default::default() }, @@ -580,7 +580,7 @@ fn a_second_emit_at_the_same_slot_is_rejected() { let mut state = base_state().with_public_accounts([( holder_id, Account { - program_owner: programs::bridge_lock().id(), + program_owner: programs::bridge_lock().id().into(), balance: INITIAL_BALANCE, ..Default::default() }, @@ -628,7 +628,7 @@ fn two_emitters_share_an_ordinal_without_colliding() { let mut state = base_state().with_public_accounts([( holder_id, Account { - program_owner: bridge_lock_id, + program_owner: bridge_lock_id.into(), balance: INITIAL_BALANCE, ..Default::default() }, @@ -718,7 +718,7 @@ fn a_lock_naming_another_target_program_is_rejected() { let mut state = base_state().with_public_accounts([( holder_id, Account { - program_owner: bridge_lock_id, + program_owner: bridge_lock_id.into(), balance: INITIAL_BALANCE, ..Default::default() }, @@ -764,7 +764,7 @@ fn a_lock_naming_other_mint_accounts_is_rejected() { let mut state = base_state().with_public_accounts([( holder_id, Account { - program_owner: bridge_lock_id, + program_owner: bridge_lock_id.into(), balance: INITIAL_BALANCE, ..Default::default() }, @@ -823,7 +823,7 @@ fn a_lock_with_a_substituted_config_account_is_rejected() { ( holder_id, Account { - program_owner: bridge_lock_id, + program_owner: bridge_lock_id.into(), balance: INITIAL_BALANCE, ..Default::default() }, @@ -831,7 +831,7 @@ fn a_lock_with_a_substituted_config_account_is_rejected() { ( decoy_id, Account { - program_owner: bridge_lock_id, + program_owner: bridge_lock_id.into(), data: bridge_lock_core::config_bytes([3; 8], [4; 8]) .to_vec() .try_into() @@ -888,7 +888,7 @@ fn a_lock_before_the_pins_are_set_is_rejected() { let state = base_state().with_public_accounts([( holder_id, Account { - program_owner: bridge_lock_id, + program_owner: bridge_lock_id.into(), balance: INITIAL_BALANCE, ..Default::default() }, @@ -1188,7 +1188,7 @@ fn the_token_authority_path_holds() { assert_eq!(cfg.sources, bridge_source, "the new source is authorized"); assert_eq!( state.get_account_by_id(authority).program_owner, - wrapped_token_id, + wrapped_token_id.into(), "the first use claims the authority account for the target" ); @@ -1564,7 +1564,7 @@ fn the_governance_path_holds() { assert_eq!(cfg.sources, vec![(src_zone, programs::bridge_lock().id())]); assert_eq!( state.get_account_by_id(authority).program_owner, - wrapped_token_id, + wrapped_token_id.into(), "the first use claims the delegated PDA for the target" ); @@ -1731,7 +1731,7 @@ fn the_receiver_governance_path_holds() { assert_eq!(cfg.sources, vec![(src_zone, programs::ping_sender().id())]); assert_eq!( state.get_account_by_id(authority).program_owner, - receiver_id, + receiver_id.into(), "the first use claims the delegated PDA for the receiver" ); } @@ -1770,7 +1770,7 @@ fn a_shared_authority_survives_the_first_claim() { state.apply_state_diff(first); assert_eq!( state.get_account_by_id(authority).program_owner, - wrapped_token_id, + wrapped_token_id.into(), "the first target to be used owns the account" ); @@ -1800,7 +1800,7 @@ fn a_shared_authority_survives_the_first_claim() { ); assert_eq!( state.get_account_by_id(authority).program_owner, - wrapped_token_id, + wrapped_token_id.into(), "the receiver never takes the account over" ); @@ -2205,7 +2205,7 @@ fn mint_replay_rejected() { state = state.with_public_accounts([( seen_id, Account { - program_owner: inbox_id, + program_owner: inbox_id.into(), balance: 0, data: shard .to_bytes() @@ -2282,7 +2282,7 @@ fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { state = state.with_public_accounts([( seen_id, Account { - program_owner: inbox_id, + program_owner: inbox_id.into(), balance: 0, data: shard .to_bytes() diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index 6e84694dd..0a9c5d97e 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -17,7 +17,6 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; @@ -27,6 +26,9 @@ use ping_core::{ }; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); @@ -34,11 +36,6 @@ const PING_PAYLOAD: &[u8] = b"hello-verified-zone"; #[test] async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { - // Declared first so it outlives both zones (drops run in reverse order). - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up shared Bedrock node")?; - let partial = SequencerPartialConfig::default(); let channel_a = config::bedrock_channel_id(); let channel_b = config::bedrock_channel_id_b(); @@ -59,31 +56,40 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { source_governance: None, }; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_a, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_b, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]) + .with_cross_zone(Some(cross_zone)), + ) + .build() + .await?; + // Zone A: source. Zone B: destination, with the watcher on its sequencer and // the verifier on its indexer. - let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_a) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up zone A sequencer")?; - let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) - .await - .context("Failed to set up zone A indexer")?; - let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_b) - .with_genesis(vec![]) - .with_cross_zone(cross_zone.clone()) - .setup() - .await - .context("Failed to set up zone B sequencer")?; - let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) - .await - .context("Failed to set up zone B indexer")?; + let ind_client_b = ctx.indexer_client_zone(channel_b).unwrap(); + + let seq_client_a = &ctx + .zone_default_sequencer_component(channel_a) + .sequencer_client; // Submit the ping on zone A, addressed to ping_receiver on zone B. let ping = build_ping_tx(zone_b, receiver_id); - sequencer_client(seq_a.addr())? + seq_client_a .send_transaction(ping) .await .context("Failed to submit ping on zone A")?; @@ -91,11 +97,8 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { // Wait until zone B's indexer records the delivered payload. The indexer only // applies the dispatch after re-deriving and verifying it. let record_id = ping_record_pda(receiver_id); - let indexer = indexer_client(idx_b.addr()) - .await - .context("Failed to build indexer client")?; - let delivered = wait_for_indexer_delivery(&indexer, record_id).await?; + let delivered = wait_for_indexer_delivery(ind_client_b, record_id).await?; assert_eq!( delivered, PING_PAYLOAD, "Zone B's indexer must record the verified cross-zone payload" 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/indexer_block_batching.rs b/integration_tests/tests/indexer_block_batching.rs index d5999f105..89619f3c8 100644 --- a/integration_tests/tests/indexer_block_batching.rs +++ b/integration_tests/tests/indexer_block_batching.rs @@ -6,16 +6,15 @@ use anyhow::Result; use indexer_service_rpc::RpcClient as _; use integration_tests::{TestContext, wait_for_indexer_to_catch_up}; -use log::info; #[tokio::test] async fn indexer_block_batching() -> Result<()> { let ctx = TestContext::new().await?; - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); let last_block_indexer = wait_for_indexer_to_catch_up(&ctx).await?; - info!("Last block on ind now is {last_block_indexer}"); + log::info!("Last block on ind now is {last_block_indexer}"); assert!(last_block_indexer > 0); @@ -31,7 +30,7 @@ async fn indexer_block_batching() -> Result<()> { for block in &block_batch[1..] { assert_eq!(block.header.prev_block_hash, prev_block_hash); - info!("Block {} chain-consistent", block.header.block_id); + log::info!("Block {} chain-consistent", block.header.block_id); prev_block_hash = block.header.hash; } diff --git a/integration_tests/tests/indexer_ffi_block_batching.rs b/integration_tests/tests/indexer_ffi_block_batching.rs index c244fbb0d..38b637220 100644 --- a/integration_tests/tests/indexer_ffi_block_batching.rs +++ b/integration_tests/tests/indexer_ffi_block_batching.rs @@ -6,7 +6,6 @@ use anyhow::Result; use indexer_ffi::api::types::FfiOption; -use log::info; #[path = "indexer_ffi_helpers/mod.rs"] mod indexer_ffi_helpers; @@ -20,10 +19,10 @@ fn indexer_ffi_block_batching() -> Result<()> { // WAIT: poll until the indexer has finalized at least two blocks (so the // chain-consistency check below verifies at least one block link), returning // early instead of sleeping for the full timeout. - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); let last_block_indexer = indexer_ffi_helpers::wait_for_indexer_ffi_block(&indexer_ffi, 2)?; - info!("Last block on indexer FFI now is {last_block_indexer}"); + log::info!("Last block on indexer FFI now is {last_block_indexer}"); assert!(last_block_indexer > 0); @@ -44,7 +43,7 @@ fn indexer_ffi_block_batching() -> Result<()> { assert_eq!(last_block_prev_hash, block.header.hash.data); - info!("Block {} chain-consistent", block.header.block_id); + log::info!("Block {} chain-consistent", block.header.block_id); last_block_prev_hash = block.header.prev_block_hash.data; } diff --git a/integration_tests/tests/indexer_ffi_helpers/mod.rs b/integration_tests/tests/indexer_ffi_helpers/mod.rs index 09e0a9271..bd8d8e57e 100644 --- a/integration_tests/tests/indexer_ffi_helpers/mod.rs +++ b/integration_tests/tests/indexer_ffi_helpers/mod.rs @@ -17,8 +17,11 @@ use indexer_ffi::{ types::{FfiAccountId, FfiOption, FfiVec, account::FfiAccount, block::FfiBlock}, }, }; -use integration_tests::{BlockingTestContext, TestContext}; +use integration_tests::BlockingTestContext; use tempfile::TempDir; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; unsafe extern "C" { pub unsafe fn query_last_block(indexer: *const IndexerServiceFFI) -> LastBlockIdResult; @@ -83,7 +86,12 @@ pub fn setup_indexer_ffi(bedrock_addr: SocketAddr) -> Result<(IndexerServiceFFI, } pub fn setup() -> Result<(BlockingTestContext, IndexerServiceFFI, TempDir)> { - let ctx = TestContext::builder().disable_indexer().build_blocking()?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()).disable_indexer(), + ) + .build_blocking()?; + // Don't borrow `ctx.runtime()`: `ctx` (and its by-value tokio runtime) is // moved into the returned tuple, which would leave any pointer into it // dangling. Pass a null runtime so the FFI owns its own โ€” the same path the diff --git a/integration_tests/tests/indexer_ffi_state_consistency.rs b/integration_tests/tests/indexer_ffi_state_consistency.rs index 0a41c68c0..593b1ea76 100644 --- a/integration_tests/tests/indexer_ffi_state_consistency.rs +++ b/integration_tests/tests/indexer_ffi_state_consistency.rs @@ -14,7 +14,6 @@ use integration_tests::{ verify_commitment_is_in_state, }; use lee::AccountId; -use log::info; use wallet::cli::{Command, programs::native_token_transfer::AuthTransferSubcommand}; #[path = "indexer_ffi_helpers/mod.rs"] @@ -36,10 +35,10 @@ fn indexer_ffi_state_consistency() -> Result<()> { ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = ctx.block_on(|ctx| { sequencer_service_rpc::RpcClient::get_account_balance( ctx.sequencer_client(), @@ -53,8 +52,8 @@ fn indexer_ffi_state_consistency() -> Result<()> { ) })?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -74,7 +73,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let new_commitment1 = ctx @@ -95,10 +94,10 @@ fn indexer_ffi_state_consistency() -> Result<()> { ctx.block_on(|ctx| verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client())); assert!(commitment_check2); - info!("Successfully transferred privately to owned account"); + log::info!("Successfully transferred privately to owned account"); // WAIT - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); std::thread::sleep(L2_TO_L1_TIMEOUT); let acc1_ind_state_ffi = unsafe { @@ -125,7 +124,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { let acc2_ind_state_pre = unsafe { &*acc2_ind_state_ffi.value }; let acc2_ind_state: Account = acc2_ind_state_pre.into(); - info!("Checking correct state transition"); + log::info!("Checking correct state transition"); let acc1_seq_state = ctx.block_on(|ctx| { sequencer_service_rpc::RpcClient::get_account( ctx.sequencer_client(), diff --git a/integration_tests/tests/indexer_ffi_state_consistency_with_labels.rs b/integration_tests/tests/indexer_ffi_state_consistency_with_labels.rs index fbc0b422d..f19ad9a4c 100644 --- a/integration_tests/tests/indexer_ffi_state_consistency_with_labels.rs +++ b/integration_tests/tests/indexer_ffi_state_consistency_with_labels.rs @@ -10,7 +10,6 @@ use std::time::Duration; use anyhow::Result; use indexer_service_protocol::Account; use integration_tests::{L2_TO_L1_TIMEOUT, TIME_TO_WAIT_FOR_BLOCK_SECONDS, public_mention}; -use log::info; use wallet::{ account::Label, cli::{Command, programs::native_token_transfer::AuthTransferSubcommand}, @@ -52,7 +51,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let acc_1_balance = ctx.block_on(|ctx| { @@ -71,7 +70,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); std::thread::sleep(L2_TO_L1_TIMEOUT); let acc1_ind_state_ffi = unsafe { @@ -95,7 +94,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { assert_eq!(acc1_ind_state, acc1_seq_state.into()); - info!("Indexer state is consistent after label-based transfer"); + log::info!("Indexer state is consistent after label-based transfer"); Ok(()) } diff --git a/integration_tests/tests/indexer_stall.rs b/integration_tests/tests/indexer_stall.rs index ae1b8b6a3..3b2f95e44 100644 --- a/integration_tests/tests/indexer_stall.rs +++ b/integration_tests/tests/indexer_stall.rs @@ -9,7 +9,6 @@ use anyhow::{Context as _, Result}; use indexer_service_protocol::IndexerSyncState; use indexer_service_rpc::RpcClient as _; use integration_tests::{TestContext, wait_for_indexer_to_catch_up}; -use log::info; const CAUGHT_UP_STATUS_TIMEOUT: Duration = Duration::from_secs(60); @@ -32,7 +31,7 @@ async fn indexer_status_rpc_reports_caught_up_with_no_stall() -> Result<()> { if status.state == IndexerSyncState::CaughtUp { return anyhow::Ok(status); } - info!("Waiting for caught-up indexer status, got {status:?}"); + log::info!("Waiting for caught-up indexer status, got {status:?}"); tokio::time::sleep(Duration::from_millis(500)).await; } }) diff --git a/integration_tests/tests/indexer_state_consistency.rs b/integration_tests/tests/indexer_state_consistency.rs index 4ed2fd260..bdd3e816c 100644 --- a/integration_tests/tests/indexer_state_consistency.rs +++ b/integration_tests/tests/indexer_state_consistency.rs @@ -13,7 +13,6 @@ use integration_tests::{ wait_for_indexer_to_catch_up, }; use lee::AccountId; -use log::info; #[tokio::test] async fn indexer_state_consistency() -> Result<()> { @@ -25,15 +24,15 @@ async fn indexer_state_consistency() -> Result<()> { ); send(&mut ctx, public_mention(acc0), public_mention(acc1), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; let acc_2_balance = account_balance(&ctx, ctx.existing_public_accounts()[1]).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -43,15 +42,15 @@ async fn indexer_state_consistency() -> Result<()> { send(&mut ctx, private_mention(from), private_mention(to), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; assert_private_commitment_in_state(&ctx, from, "sender").await?; assert_private_commitment_in_state(&ctx, to, "receiver").await?; - info!("Successfully transferred privately to owned account"); + log::info!("Successfully transferred privately to owned account"); - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); wait_for_indexer_to_catch_up(&ctx).await?; let acc1_ind_state = ctx @@ -65,7 +64,7 @@ async fn indexer_state_consistency() -> Result<()> { .await .unwrap(); - info!("Checking correct state transition"); + log::info!("Checking correct state transition"); let acc1_seq_state = get_account(&ctx, ctx.existing_public_accounts()[0]).await?; let acc2_seq_state = get_account(&ctx, ctx.existing_public_accounts()[1]).await?; diff --git a/integration_tests/tests/indexer_state_consistency_with_labels.rs b/integration_tests/tests/indexer_state_consistency_with_labels.rs index 219c3ebfe..d8af09342 100644 --- a/integration_tests/tests/indexer_state_consistency_with_labels.rs +++ b/integration_tests/tests/indexer_state_consistency_with_labels.rs @@ -12,7 +12,6 @@ use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, public_mention, send, wait_for_indexer_to_catch_up, }; -use log::info; use wallet::{ account::Label, cli::{CliAccountMention, Command}, @@ -47,7 +46,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; @@ -56,7 +55,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); wait_for_indexer_to_catch_up(&ctx).await?; let acc1_ind_state = ctx @@ -68,7 +67,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { assert_eq!(acc1_ind_state, acc1_seq_state.into()); - info!("Indexer state is consistent after label-based transfer"); + log::info!("Indexer state is consistent after label-based transfer"); Ok(()) } diff --git a/integration_tests/tests/indexer_test_run.rs b/integration_tests/tests/indexer_test_run.rs index b54cdf825..2c4213e6e 100644 --- a/integration_tests/tests/indexer_test_run.rs +++ b/integration_tests/tests/indexer_test_run.rs @@ -5,7 +5,6 @@ use anyhow::Result; use integration_tests::{TestContext, wait_for_indexer_to_catch_up}; -use log::info; #[tokio::test] async fn indexer_test_run() -> Result<()> { @@ -16,8 +15,8 @@ async fn indexer_test_run() -> Result<()> { let last_block_seq = sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()).await?; - info!("Last block on seq now is {last_block_seq}"); - info!("Last block on ind now is {last_block_indexer}"); + log::info!("Last block on seq now is {last_block_seq}"); + log::info!("Last block on ind now is {last_block_indexer}"); assert!(last_block_indexer > 0); diff --git a/integration_tests/tests/indexer_test_run_ffi.rs b/integration_tests/tests/indexer_test_run_ffi.rs index e37b619ee..594bf7a04 100644 --- a/integration_tests/tests/indexer_test_run_ffi.rs +++ b/integration_tests/tests/indexer_test_run_ffi.rs @@ -4,7 +4,6 @@ )] use anyhow::Result; -use log::info; #[path = "indexer_ffi_helpers/mod.rs"] mod indexer_ffi_helpers; @@ -19,7 +18,7 @@ fn indexer_test_run_ffi() -> Result<()> { // returning early instead of sleeping for the full timeout. let last_block_indexer_ffi = indexer_ffi_helpers::wait_for_indexer_ffi_block(&indexer_ffi, 1)?; - info!("Last block on indexer FFI now is {last_block_indexer_ffi}"); + log::info!("Last block on indexer FFI now is {last_block_indexer_ffi}"); assert!(last_block_indexer_ffi > 0); diff --git a/integration_tests/tests/keys.rs b/integration_tests/tests/keys.rs index c7e5d3c27..10b6845c1 100644 --- a/integration_tests/tests/keys.rs +++ b/integration_tests/tests/keys.rs @@ -14,7 +14,6 @@ use integration_tests::{ }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use lee::AccountId; -use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::cli::{ @@ -83,7 +82,7 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { .context("Failed to get recipient's private account")?; assert_eq!(to_res_acc.balance, 100); - info!("Successfully transferred using claiming path"); + log::info!("Successfully transferred using claiming path"); Ok(()) } @@ -125,7 +124,7 @@ async fn restore_keys_from_seed() -> Result<()> { send_claiming_new_account(&mut ctx, from, to_account_id3, 102).await?; send_claiming_new_account(&mut ctx, from, to_account_id4, 103).await?; - info!("Preparation complete, performing keys restoration"); + log::info!("Preparation complete, performing keys restoration"); // Restore keys from seed wallet::cli::execute_keys_restoration(ctx.wallet_mut(), 10).await?; @@ -140,17 +139,17 @@ async fn restore_keys_from_seed() -> Result<()> { assert_eq!( acc1.account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); assert_eq!( acc2.account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); assert_eq!(acc1.account.balance, 100); assert_eq!(acc2.account.balance, 101); - info!("Tree checks passed, testing restored accounts can transact"); + log::info!("Tree checks passed, testing restored accounts can transact"); // Test that restored accounts can send transactions send( @@ -196,7 +195,7 @@ async fn restore_keys_from_seed() -> Result<()> { assert_eq!(acc3, 91); // 102 - 11 assert_eq!(acc4, 114); // 103 + 11 - info!("Successfully restored keys and verified transactions"); + log::info!("Successfully restored keys and verified transactions"); Ok(()) } diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs index 34eae1e5a..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,109 +13,97 @@ use anyhow::{Context as _, Result, ensure}; use indexer_service_rpc::RpcClient as _; use integration_tests::{ config::{self, SequencerPartialConfig}, - indexer_client::IndexerClient, - setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, + init_logger, +}; +use logos_blockchain_key_management_system_service::keys::Ed25519Key; +use sequencer_core::{ + block_publisher::{Ed25519PublicKey, read_channel_state}, + config::BedrockConfig, }; -use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; -use sequencer_core::{block_publisher::post_channel_config, config::BedrockConfig}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; use tokio::test; -/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled -/// turn after ~30 s (bounds the stall while B is accredited but not started). -const POSTING_TIMEFRAME_SLOTS: u32 = 20; -const POSTING_TIMEOUT_SLOTS: u32 = 30; const PHASE_TIMEOUT: Duration = Duration::from_secs(360); const POLL_INTERVAL: Duration = Duration::from_secs(2); const TRANSFER_AMOUNT: u128 = 10; -/// โ‰ˆ4 turn windows past B's join (5 s blocks, ~20 s turns โ†’ ~4 blocks/window). +/// โ‰ˆ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, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up Bedrock node")?; - - // Fixed seeds so A can accredit B's public key before B exists. - let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE]; - let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE]; - let pub_a = Ed25519Key::from_bytes(&key_a).public_key(); - let pub_b = Ed25519Key::from_bytes(&key_b).public_key(); + init_logger(); + let channel = config::bedrock_channel_id(); let partial = SequencerPartialConfig { block_create_timeout: Duration::from_secs(5), ..SequencerPartialConfig::default() }; - // Phase 1: A solo (its first inscription creates the channel), plus an indexer. - let (seq_a, _a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_genesis(vec![]) - .with_bedrock_signing_key(key_a) - .setup() + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 2, + bedrock_channel: channel, + }) + .disable_wallet() + .with_sequencer_partial_config(partial), + ) + .build() .await - .context("Failed to set up sequencer A")?; - let a = sequencer_client(seq_a.addr())?; - let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None) - .await - .context("Failed to set up indexer")?; - let indexer = indexer_client(idx.addr()).await?; + .context("Failed to build the two-sequencer test context")?; - wait_for_height(&a, 2, "sequencer A to produce past genesis").await?; + 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(); - // Phase 2: live roster change to [A, B] with rotation enabled, posted - // straight to bedrock with A's admin key (the operator one-shot path). - post_channel_config( - &BedrockConfig { - channel_id: config::bedrock_channel_id(), - node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, - funding_key: config::bedrock_funding_key(), - auth: None, - priority_fee: sequencer_core::config::default_priority_fee(), - }, - &Ed25519Key::from_bytes(&key_a), - vec![pub_a, pub_b], - POSTING_TIMEFRAME_SLOTS, - POSTING_TIMEOUT_SLOTS, - 1, - 1, - ) - .await - .context("Failed to configure the channel committee")?; + let 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(); - let height_at_config = a.get_last_block_id().await?; - wait_for_height( - &a, - height_at_config + 1, - "A to produce after the roster change", - ) + 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(), + }; + + // 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?; - // Phase 3: B joins live and syncs the existing chain. - let (seq_b, _b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_genesis(vec![]) - .with_bedrock_signing_key(key_b) - .setup() - .await - .context("Failed to set up sequencer B")?; - let b = sequencer_client(seq_b.addr())?; + // 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 = a.get_last_block_id().await?; - wait_for_height(&b, join_height, "B to sync to A's height at join").await?; - - // Phase 4: rotation + convergence over โ‰ˆ4 turn windows. + // 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( - &a, + a, rotation_target, "the chain to advance across turn windows", ) .await?; - wait_for_height(&b, rotation_target, "B to follow across turn windows").await?; - assert_same_chain(&a, &b).await?; + 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(a, b).await?; - // 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; @@ -134,10 +122,17 @@ async fn multi_sequencer_committee_converges() -> Result<()> { .await .context("Failed to submit the transfer to B")?; - wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?; + 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 6: the indexer finalizes the same chain, with no stall. - wait_for_finalized(&indexer, join_height).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 @@ -163,53 +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<()> { - 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<()> { - 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<()> { - 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/private_pda.rs b/integration_tests/tests/private_pda.rs index b73e943a2..8f5bbf474 100644 --- a/integration_tests/tests/private_pda.rs +++ b/integration_tests/tests/private_pda.rs @@ -27,7 +27,6 @@ use lee_core::{ encryption::ViewingPublicKey, program::PdaSeed, }; -use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{AccountIdentity, WalletCore}; @@ -181,7 +180,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { // โ”€โ”€ Receive โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - info!("Sending to alice_pda_0 (identifier=0)"); + log::info!("Sending to alice_pda_0 (identifier=0)"); fund_private_pda( ctx.wallet_mut(), sender_0, @@ -195,7 +194,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { ) .await?; - info!("Sending to alice_pda_1 (identifier=1)"); + log::info!("Sending to alice_pda_1 (identifier=1)"); fund_private_pda( ctx.wallet_mut(), sender_1, @@ -209,7 +208,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { ) .await?; - info!("Waiting for block"); + log::info!("Waiting for block"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Sync so alice's wallet discovers and stores both PDAs. @@ -263,7 +262,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { let amount_spend_0: u128 = 13; let amount_spend_1: u128 = 37; - info!("Alice spending from alice_pda_0"); + log::info!("Alice spending from alice_pda_0"); spend_private_pda( ctx.wallet_mut(), alice_pda_0_id, @@ -276,7 +275,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { ) .await?; - info!("Alice spending from alice_pda_1"); + log::info!("Alice spending from alice_pda_1"); spend_private_pda( ctx.wallet_mut(), alice_pda_1_id, @@ -289,7 +288,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { ) .await?; - info!("Waiting for block"); + log::info!("Waiting for block"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; sync_private(&mut ctx).await?; @@ -326,6 +325,6 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { "alice_pda_1 post-spend commitment not in state" ); - info!("Private PDA family member receive-and-spend test passed"); + log::info!("Private PDA family member receive-and-spend test passed"); Ok(()) } diff --git a/integration_tests/tests/program_deployment.rs b/integration_tests/tests/program_deployment.rs index 3c620168e..c501752f0 100644 --- a/integration_tests/tests/program_deployment.rs +++ b/integration_tests/tests/program_deployment.rs @@ -8,8 +8,10 @@ use std::{io::Write as _, time::Duration}; use anyhow::Result; use common::transaction::LeeTransaction; use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account, new_account}; -use log::info; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; use wallet::{cli::Command, config::WalletConfigOverrides}; @@ -45,7 +47,7 @@ async fn deploy_and_execute_program() -> Result<()> { .send_transaction(LeeTransaction::Public(transaction)) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); // Waiting for long time as it may take some time for such a big transaction to be included in a // block tokio::time::sleep(Duration::from_secs(2 * TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; @@ -53,12 +55,12 @@ async fn deploy_and_execute_program() -> Result<()> { let post_state_account = get_account(&ctx, account_id).await?; let expected_data: &[u8] = &[]; - assert_eq!(post_state_account.program_owner, claimer.id()); + assert_eq!(post_state_account.program_owner, claimer.id().into()); assert_eq!(post_state_account.balance, 0); assert_eq!(post_state_account.data.as_ref(), expected_data); assert_eq!(post_state_account.nonce.0, 1); - info!("Successfully deployed and executed program"); + log::info!("Successfully deployed and executed program"); Ok(()) } @@ -68,13 +70,17 @@ async fn deploy_invalid_program_fails() -> Result<()> { // An invalid program bytecode is rejected by the sequencer during block production, so the // deployment transaction is never included in a block. Shrink the wallet's polling window so // the command gives up quickly instead of waiting for the full default timeout. - let mut ctx = TestContext::builder() - .with_wallet_config_overrides(WalletConfigOverrides { - seq_poll_timeout: Some(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)), - seq_tx_poll_max_blocks: Some(5), - seq_poll_max_retries: Some(2), - ..WalletConfigOverrides::default() - }) + + let mut ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_wallet_config_overrides(WalletConfigOverrides { + seq_poll_timeout: Some(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)), + seq_tx_poll_max_blocks: Some(5), + seq_poll_max_retries: Some(2), + ..WalletConfigOverrides::default() + }), + ) .build() .await?; @@ -92,7 +98,7 @@ async fn deploy_invalid_program_fails() -> Result<()> { "Deploying an invalid program should fail, but got: {result:?}" ); - info!("Deploying an invalid program failed as expected"); + log::info!("Deploying an invalid program failed as expected"); Ok(()) } diff --git a/integration_tests/tests/sequencer_bootstrap.rs b/integration_tests/tests/sequencer_bootstrap.rs index c4468c1b5..dc6d72739 100644 --- a/integration_tests/tests/sequencer_bootstrap.rs +++ b/integration_tests/tests/sequencer_bootstrap.rs @@ -13,7 +13,6 @@ use std::{path::Path, time::Duration}; use anyhow::{Context as _, Result, bail}; use indexer_service_rpc::RpcClient as _; use lee::{AccountId, PrivateKey, PublicKey}; -use logos_blockchain_core::mantle::ops::channel::ChannelId; use sequencer_core::config::GenesisAction; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use test_fixtures::{ @@ -237,8 +236,11 @@ async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> { // lost its local DB. drop(handle_a); tokio::time::sleep(Duration::from_secs(2)).await; - std::fs::remove_dir_all(home_a.path().join("rocksdb")) - .context("Failed to wipe sequencer L2 store")?; + std::fs::remove_dir_all(home_a.path().join(format!( + "rocksdb-{}", + test_fixtures::config::bedrock_channel_id() + ))) + .context("Failed to wipe sequencer L2 store")?; // Sequencer B restarts on the same home from that empty store and reconstructs. let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr) @@ -274,11 +276,13 @@ async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> { /// Case 3: local store is not empty, but the Bedrock channel is empty. /// /// A sequencer produces blocks (committing to a channel), is stopped, and is -/// restarted against a fresh/empty channel โ€” i.e. the channel it committed to -/// was wiped or the node points at a different chain. Startup must fail rather -/// than silently resume onto a foreign channel. Crucially this must hold even -/// though the sequencer only ever *produced* (so it never recorded a per-block -/// anchor): the committed-but-missing-channel invariant catches it. +/// restarted with the same channel id against a Bedrock node where that channel +/// is empty โ€” i.e. the channel it committed to was wiped. Startup must fail +/// rather than silently resume onto a foreign channel. Crucially this must hold +/// even though the sequencer only ever *produced* (so it never recorded a +/// per-block anchor): the committed-but-missing-channel invariant catches it. +/// A *different* channel id no longer exercises this, because the db path is +/// per-channel and a new id simply fresh-starts beside the old store. #[test] async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { const PRODUCED_TARGET: u64 = 3; @@ -310,9 +314,12 @@ async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { drop(handle_a); tokio::time::sleep(Duration::from_secs(2)).await; - // Restart on the SAME home (A's committed store: blocks + checkpoint) but - // pointed at a fresh, never-used channel โ€” the channel it committed to is gone. - let empty_channel = ChannelId::from([0x5a_u8; 32]); + // Restart on the SAME home (A's committed store: blocks + checkpoint) and the + // SAME channel id, but against a fresh Bedrock node where that channel does + // not exist โ€” the channel it committed to is gone. + let (_bedrock_b, bedrock_addr_b) = setup_bedrock_node() + .await + .context("Failed to setup second Bedrock")?; // Startup aborts on the missing-channel invariant (a panic in // `start_from_config`). Run it on a dedicated OS thread with its own runtime @@ -325,8 +332,7 @@ async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { runtime.block_on(async { tokio::time::timeout( Duration::from_secs(90), - SequencerSetup::new(slow_blocks(), bedrock_addr) - .with_channel_id(empty_channel) + SequencerSetup::new(slow_blocks(), bedrock_addr_b) .with_genesis(genesis) .setup_at(&home_a_path), ) @@ -473,7 +479,10 @@ async fn local_behind_channel_reconstructs_forward() -> Result<()> { ]; let home = tempfile::tempdir().context("Failed to create sequencer home")?; - let rocksdb = home.path().join("rocksdb"); + let rocksdb = home.path().join(format!( + "rocksdb-{}", + test_fixtures::config::bedrock_channel_id() + )); // Bring the sequencer up to an early tip, then stop it so its store is at rest. { diff --git a/integration_tests/tests/sequencer_stake_demo.rs b/integration_tests/tests/sequencer_stake_demo.rs new file mode 100644 index 000000000..39249b5ac --- /dev/null +++ b/integration_tests/tests/sequencer_stake_demo.rs @@ -0,0 +1,392 @@ +//! 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().into()) + }) + .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().into(), + "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/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index cc6e6e1d3..83615540d 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -21,7 +21,6 @@ use anyhow::{Context as _, Result}; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, sync_private, }; -use log::info; use tokio::test; use wallet::{ account::Label, @@ -81,7 +80,7 @@ async fn group_create_and_shared_account_registration() -> Result<()> { assert_eq!(entry.group_label, Label::new("test-group")); assert!(entry.pda_seed.is_none()); - info!("Shared account registered: {shared_account_id}"); + log::info!("Shared account registered: {shared_account_id}"); Ok(()) } @@ -156,7 +155,7 @@ async fn group_invite_join_key_agreement() -> Result<()> { "Key agreement: same GMS produces same keys" ); - info!("Key agreement verified via invite/join"); + log::info!("Key agreement verified via invite/join"); Ok(()) } @@ -225,7 +224,7 @@ async fn fund_shared_account_from_public() -> Result<()> { .shared_private_account(shared_id) .context("Shared account not found after sync")?; - info!( + log::info!( "Shared account balance after funding: {}", entry.account.balance ); diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index 5977dfc19..ef33425e6 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result}; use bytesize::ByteSize; use common::transaction::LeeTransaction; -use integration_tests::{TestContext, config::SequencerPartialConfig}; +use integration_tests::config::SequencerPartialConfig; use lee::{ Account, AccountId, PrivacyPreservingTransaction, PrivateKey, PublicKey, PublicTransaction, privacy_preserving_transaction::{self as pptx, circuit}, @@ -22,14 +22,16 @@ use lee::{ public_transaction as putx, }; use lee_core::{ - DUMMY_COMMITMENT_HASH, InputAccountIdentity, MembershipProof, NullifierPublicKey, - NullifierWitness, PrivateWitness, WitnessKind, + AuthorizationSecretKey, DUMMY_COMMITMENT_HASH, InputAccountIdentity, MembershipProof, + NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateWitness, WitnessKind, account::{AccountWithMetadata, Nonce, data::Data}, encryption::ViewingPublicKey, }; -use log::info; use sequencer_core::config::GenesisAction; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; pub(crate) struct TpsTestManager { @@ -178,9 +180,13 @@ pub async fn tps_test() -> Result<()> { let target_tps = 8; let tps_test = TpsTestManager::new(target_tps, num_transactions); - let ctx = TestContext::builder() - .with_sequencer_partial_config(TpsTestManager::generate_sequencer_partial_config()) - .with_genesis(tps_test.generate_genesis()) + + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(TpsTestManager::generate_sequencer_partial_config()) + .with_genesis(tps_test.generate_genesis()), + ) .build() .await?; @@ -191,7 +197,7 @@ pub async fn tps_test() -> Result<()> { .context("Failed to claim vault funds for TPS accounts")?; let target_time = tps_test.target_time(); - info!( + log::info!( "TPS test begin. Target time is {target_time:?} for {num_transactions} transactions ({target_tps} TPS)" ); @@ -205,7 +211,7 @@ pub async fn tps_test() -> Result<()> { .send_transaction(LeeTransaction::Public(tx)) .await .unwrap(); - info!("Sent tx {i}"); + log::info!("Sent tx {i}"); tx_hashes.push(tx_hash); } @@ -225,7 +231,7 @@ pub async fn tps_test() -> Result<()> { }); if tx_obj.is_ok_and(|opt| opt.is_some()) { - info!("Found tx {i} with hash {tx_hash}"); + log::info!("Found tx {i} with hash {tx_hash}"); break; } } @@ -234,7 +240,7 @@ pub async fn tps_test() -> Result<()> { let tx_processed = tx_hashes.len(); let actual_tps = tx_processed as u64 / time_elapsed; - info!("Processed {tx_processed} transactions in {time_elapsed:?} ({actual_tps} TPS)",); + log::info!("Processed {tx_processed} transactions in {time_elapsed:?} ({actual_tps} TPS)",); assert_eq!(tx_processed, num_transactions); @@ -243,7 +249,7 @@ pub async fn tps_test() -> Result<()> { "Elapsed time {time_elapsed:?} exceeded target time {target_time:?}" ); - info!("TPS test finished successfully"); + log::info!("TPS test finished successfully"); Ok(()) } @@ -255,20 +261,22 @@ pub async fn tps_test() -> Result<()> { #[expect(dead_code, reason = "No idea if we need this, should we remove it?")] fn build_privacy_transaction() -> PrivacyPreservingTransaction { let program = programs::authenticated_transfer(); - let sender_nsk = [1; 32]; + let sender_ask = AuthorizationSecretKey([1; 32]); + let sender_nsk = NullifierSecretKey::from(&sender_ask); let sender_vpk = ViewingPublicKey::from_seed(&[99_u8; 32], &[100_u8; 32]); let sender_npk = NullifierPublicKey::from(&sender_nsk); let sender_pre = AccountWithMetadata::new( Account { balance: 100, nonce: Nonce(0xdead_beef), - program_owner: program.id(), + program_owner: program.id().into(), data: Data::default(), }, true, AccountId::for_regular_private_account(&sender_npk, &sender_vpk, 0), ); - let recipient_nsk = [2; 32]; + let recipient_ask = AuthorizationSecretKey([2; 32]); + let recipient_nsk = NullifierSecretKey::from(&recipient_ask); let recipient_vpk = ViewingPublicKey::from_seed(&[101_u8; 32], &[102_u8; 32]); let recipient_npk = NullifierPublicKey::from(&recipient_nsk); let recipient_pre = AccountWithMetadata::new( @@ -296,7 +304,9 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { vpk: sender_vpk, random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, nsk: sender_nsk, @@ -307,7 +317,9 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { vpk: recipient_vpk, random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_ask), + }, nullifier: NullifierWitness::Init { npk: recipient_npk, commitment_root: DUMMY_COMMITMENT_HASH, diff --git a/integration_tests/tests/two_zone.rs b/integration_tests/tests/two_zone.rs index 8f4b26971..d03f4221e 100644 --- a/integration_tests/tests/two_zone.rs +++ b/integration_tests/tests/two_zone.rs @@ -6,16 +6,18 @@ //! Two zones (sequencer + indexer each, on separate channels) sharing one //! Bedrock node, each producing and finalizing blocks independently. -use std::{net::SocketAddr, time::Duration}; +use std::time::Duration; use anyhow::{Context as _, Result}; use indexer_service_rpc::RpcClient as _; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, setup_bedrock_node, setup_indexer}, }; -use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; const ZONE_LIVE_TIMEOUT: Duration = Duration::from_secs(360); @@ -25,38 +27,45 @@ const MIN_BLOCK_ID: u64 = 2; #[test] async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> { - // Declared first so it outlives both zones (drops run in reverse order). - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up shared Bedrock node")?; - - let partial = SequencerPartialConfig::default(); let channel_a = config::bedrock_channel_id(); let channel_b = config::bedrock_channel_id_b(); + let partial = SequencerPartialConfig::default(); - // Empty genesis is enough: the clock transaction drives block production. - let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_a) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up zone A sequencer")?; - let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) - .await - .context("Failed to set up zone A indexer")?; - let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_b) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up zone B sequencer")?; - let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None) - .await - .context("Failed to set up zone B indexer")?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_a, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_b, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .build() + .await?; + + let ind_client_a = ctx.indexer_client_zone(channel_a).unwrap(); + let ind_client_b = ctx.indexer_client_zone(channel_b).unwrap(); + + let seq_client_a = &ctx + .zone_default_sequencer_component(channel_a) + .sequencer_client; + let seq_client_b = &ctx + .zone_default_sequencer_component(channel_b) + .sequencer_client; let (height_a, height_b) = tokio::try_join!( - wait_until_zone_live("A", seq_a.addr(), idx_a.addr()), - wait_until_zone_live("B", seq_b.addr(), idx_b.addr()), + wait_until_zone_live("A", seq_client_a, ind_client_a), + wait_until_zone_live("B", seq_client_b, ind_client_b), )?; assert!( @@ -75,31 +84,22 @@ async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> { /// to it. Returns the indexer's finalized block id. async fn wait_until_zone_live( label: &str, - sequencer_addr: SocketAddr, - indexer_addr: SocketAddr, + sequencer_client: &SequencerClient, + indexer_client: &IndexerClient, ) -> Result { - let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_addr) - .context("Failed to build sequencer URL")?; - let sequencer = SequencerClientBuilder::default() - .build(sequencer_url) - .context("Failed to build sequencer client")?; - - let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_addr) - .context("Failed to build indexer URL")?; - let indexer = IndexerClient::new(&indexer_url) - .await - .context("Failed to build indexer client")?; - let wait = async { loop { - if sequencer.get_last_block_id().await? >= MIN_BLOCK_ID { + if sequencer_client.get_last_block_id().await? >= MIN_BLOCK_ID { break; } tokio::time::sleep(Duration::from_secs(2)).await; } - let target = sequencer.get_last_block_id().await?; + let target = sequencer_client.get_last_block_id().await?; loop { - let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); + let finalized = indexer_client + .get_last_finalized_block_id() + .await? + .unwrap_or(0); if finalized >= target { log::info!( "Zone {label} live: sequencer at {target}, indexer finalized {finalized}" diff --git a/integration_tests/tests/wallet_ffi.rs b/integration_tests/tests/wallet_ffi.rs index a19bc3550..f2bb19f47 100644 --- a/integration_tests/tests/wallet_ffi.rs +++ b/integration_tests/tests/wallet_ffi.rs @@ -26,8 +26,7 @@ use lee::{ Account, AccountId, PrivateKey, PublicKey, privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program, }; -use lee_core::program::DEFAULT_PROGRAM_ID; -use log::info; +use lee_core::program::DEFAULT_PROGRAM_OWNER; use wallet::{account::HumanReadableAccount, program_facades::vault::Vault}; use wallet_ffi::{ FfiAccount, FfiAccountIdWithPrivacy, FfiAccountIdentity, FfiAccountList, FfiBytes32, @@ -284,6 +283,12 @@ unsafe extern "C" { ) -> LabelList; fn wallet_ffi_free_label_list(label_list: *mut LabelList) -> error::WalletFfiError; + + fn wallet_ffi_poll_transaction_status( + handle: *mut WalletHandle, + tx_hash: FfiBytes32, + transaction_status: *mut bool, + ) -> error::WalletFfiError; } fn new_wallet_ffi_with_test_context_config( @@ -389,7 +394,7 @@ fn load_existing_ffi_wallet(home: &Path) -> Result<*mut WalletHandle> { #[test] fn wallet_ffi_create_public_accounts() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let n_accounts = 10; // Create `n_accounts` public accounts with wallet FFI @@ -430,7 +435,7 @@ fn wallet_ffi_create_public_accounts() -> Result<()> { #[test] fn wallet_ffi_create_private_accounts() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let n_accounts = 10; // Create `n_accounts` receiving keys with wallet FFI let new_npks_ffi = unsafe { @@ -465,7 +470,7 @@ fn wallet_ffi_create_private_accounts() -> Result<()> { #[test] fn wallet_ffi_save_and_load_persistent_storage() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; // Create a receiving key and save let first_npk = unsafe { @@ -505,7 +510,7 @@ fn wallet_ffi_save_and_load_persistent_storage() -> Result<()> { #[test] fn test_wallet_ffi_list_accounts() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; // Create the wallet FFI and track which account IDs were created as public/private let (wallet_ffi_handle, created_public_ids) = unsafe { let home = tempfile::tempdir()?; @@ -574,7 +579,7 @@ fn test_wallet_ffi_list_accounts() -> Result<()> { #[test] fn test_wallet_ffi_get_balance_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_public_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -596,7 +601,7 @@ fn test_wallet_ffi_get_balance_public() -> Result<()> { }; assert_eq!(balance, 10000); - info!("Successfully retrieved account balance"); + log::info!("Successfully retrieved account balance"); unsafe { wallet_ffi_destroy(wallet_ffi_handle); @@ -607,7 +612,7 @@ fn test_wallet_ffi_get_balance_public() -> Result<()> { #[test] fn test_wallet_ffi_get_account_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_public_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -629,7 +634,7 @@ fn test_wallet_ffi_get_account_public() -> Result<()> { assert_eq!( account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); assert_eq!(account.balance, 10000); assert!(account.data.is_empty()); @@ -640,14 +645,14 @@ fn test_wallet_ffi_get_account_public() -> Result<()> { wallet_ffi_destroy(wallet_ffi_handle); } - info!("Successfully retrieved account with correct details"); + log::info!("Successfully retrieved account with correct details"); Ok(()) } #[test] fn test_wallet_ffi_get_account_private() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_private_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -669,7 +674,7 @@ fn test_wallet_ffi_get_account_private() -> Result<()> { assert_eq!( account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); assert_eq!(account.balance, 10000); assert!(account.data.is_empty()); @@ -679,14 +684,14 @@ fn test_wallet_ffi_get_account_private() -> Result<()> { wallet_ffi_destroy(wallet_ffi_handle); } - info!("Successfully retrieved account with correct details"); + log::info!("Successfully retrieved account with correct details"); Ok(()) } #[test] fn test_wallet_ffi_get_public_account_keys() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_public_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -717,7 +722,7 @@ fn test_wallet_ffi_get_public_account_keys() -> Result<()> { assert_eq!(key, expected_key); - info!("Successfully retrieved account key"); + log::info!("Successfully retrieved account key"); unsafe { wallet_ffi_destroy(wallet_ffi_handle); @@ -728,7 +733,7 @@ fn test_wallet_ffi_get_public_account_keys() -> Result<()> { #[test] fn test_wallet_ffi_get_private_account_keys() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_private_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -767,7 +772,7 @@ fn test_wallet_ffi_get_private_account_keys() -> Result<()> { wallet_ffi_destroy(wallet_ffi_handle); } - info!("Successfully retrieved account keys"); + log::info!("Successfully retrieved account keys"); Ok(()) } @@ -814,7 +819,7 @@ fn wallet_ffi_base58_to_account_id() -> Result<()> { #[test] fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -838,7 +843,7 @@ fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { .unwrap(); (&out_account).try_into().unwrap() }; - assert_eq!(account.program_owner, DEFAULT_PROGRAM_ID); + assert_eq!(account.program_owner, DEFAULT_PROGRAM_OWNER); // Call the init funciton let mut transfer_result = FfiTransferResult::default(); @@ -851,7 +856,7 @@ fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Check that the program owner is now the authenticated transfer program @@ -867,7 +872,7 @@ fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { }; assert_eq!( account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); unsafe { @@ -880,7 +885,7 @@ fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { #[test] fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -904,7 +909,7 @@ fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -927,7 +932,7 @@ fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { }; assert_eq!( account.program_owner, - programs::authenticated_transfer().id() + programs::authenticated_transfer().id().into() ); unsafe { @@ -940,7 +945,7 @@ fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { #[test] fn test_wallet_ffi_transfer_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -962,7 +967,7 @@ fn test_wallet_ffi_transfer_public() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let from_balance = unsafe { @@ -987,8 +992,18 @@ fn test_wallet_ffi_transfer_public() -> Result<()> { assert_eq!(from_balance, 9900); assert_eq!(to_balance, 20100); + // Also check for transaction inclusion + let hash_bytes = unsafe { transfer_result.tx_hash_bytes() }; + let mut is_included = false; + + unsafe { + wallet_ffi_poll_transaction_status(wallet_ffi_handle, hash_bytes, &raw mut is_included) + .unwrap(); + } + + assert!(is_included); + unsafe { - wallet_ffi_free_transfer_result(&raw mut transfer_result); wallet_ffi_destroy(wallet_ffi_handle); } @@ -997,7 +1012,7 @@ fn test_wallet_ffi_transfer_public() -> Result<()> { #[test] fn test_wallet_ffi_transfer_shielded() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1034,7 +1049,7 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1080,7 +1095,7 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { #[test] fn test_wallet_ffi_transfer_deshielded() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1102,7 +1117,7 @@ fn test_wallet_ffi_transfer_deshielded() -> Result<()> { } .unwrap(); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1143,7 +1158,7 @@ fn test_wallet_ffi_transfer_deshielded() -> Result<()> { #[test] fn test_wallet_ffi_transfer_private() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1181,7 +1196,7 @@ fn test_wallet_ffi_transfer_private() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1226,7 +1241,7 @@ fn test_wallet_ffi_transfer_private() -> Result<()> { #[test] fn restore_keys_from_seed_ffi() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1271,9 +1286,9 @@ fn restore_keys_from_seed_ffi() -> Result<()> { wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut public_account_id_2).unwrap(); } - info!("Accounts created"); + log::info!("Accounts created"); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1305,7 +1320,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1333,7 +1348,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1357,7 +1372,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1381,7 +1396,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1398,11 +1413,11 @@ fn restore_keys_from_seed_ffi() -> Result<()> { wallet_ffi_free_transfer_result(&raw mut transfer_result_4); } - info!("Preparation complete, performing keys restoration"); + log::info!("Preparation complete, performing keys restoration"); let password = CString::new(ctx.ctx().wallet_password())?; - info!("Checking balance correctness before restoration"); + log::info!("Checking balance correctness before restoration"); let private_account_id_1_balance = unsafe { let mut out_balance: [u8; 16] = [0; 16]; @@ -1465,7 +1480,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { wallet_ffi_sync_to_block(wallet_ffi_handle, current_height).unwrap(); }; - info!("Checking balance correctness after restoration"); + log::info!("Checking balance correctness after restoration"); let private_account_id_1_balance = unsafe { let mut out_balance: [u8; 16] = [0; 16]; @@ -1516,7 +1531,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { assert_eq!(public_account_id_1_balance, 102); assert_eq!(public_account_id_2_balance, 103); - info!("Accounts restored"); + log::info!("Accounts restored"); Ok(()) } @@ -1546,7 +1561,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { // .unwrap(); // } -// info!("Waiting for next block creation"); +// log::info!("Waiting for next block creation"); // std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // let from_balance = unsafe { @@ -1586,7 +1601,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { #[test] fn test_wallet_ffi_transfer_generic_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1637,7 +1652,7 @@ fn test_wallet_ffi_transfer_generic_public() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let from_balance = unsafe { @@ -1680,7 +1695,7 @@ fn test_wallet_ffi_transfer_generic_public() -> Result<()> { #[test] fn test_wallet_ffi_transfer_generic_private() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1736,7 +1751,7 @@ fn test_wallet_ffi_transfer_generic_private() -> Result<()> { assert_eq!(transaction_result.secrets_size, 2); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1789,7 +1804,7 @@ fn test_wallet_ffi_transfer_generic_private() -> Result<()> { #[test] fn test_wallet_ffi_vault_balance_and_claim_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1809,7 +1824,7 @@ fn test_wallet_ffi_vault_balance_and_claim_public() -> Result<()> { }) .unwrap(); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let vault_balance = unsafe { @@ -1836,7 +1851,7 @@ fn test_wallet_ffi_vault_balance_and_claim_public() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let vault_balance_after_claim = unsafe { @@ -1874,7 +1889,7 @@ fn test_wallet_ffi_vault_balance_and_claim_public() -> Result<()> { #[test] fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1895,7 +1910,7 @@ fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { }) .unwrap(); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let vault_balance = unsafe { @@ -1922,7 +1937,7 @@ fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1966,7 +1981,7 @@ fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { #[test] fn test_wallet_ffi_single_label() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1978,7 +1993,7 @@ fn test_wallet_ffi_single_label() -> Result<()> { wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id_1).unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let lab_1 = CString::from_str("LABEL1").unwrap().into_raw(); @@ -2015,7 +2030,7 @@ fn test_wallet_ffi_single_label() -> Result<()> { #[test] fn test_wallet_ffi_more_labels() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -2027,7 +2042,7 @@ fn test_wallet_ffi_more_labels() -> Result<()> { wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id_1).unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let lab_1 = CString::from_str("LABEL1").unwrap().into_raw(); diff --git a/lee/key_protocol/src/key_management/group_key_holder.rs b/lee/key_protocol/src/key_management/group_key_holder.rs index 1aef6c916..e2a7cce84 100644 --- a/lee/key_protocol/src/key_management/group_key_holder.rs +++ b/lee/key_protocol/src/key_management/group_key_holder.rs @@ -339,7 +339,7 @@ mod tests { } /// Pins the end-to-end derivation for a fixed (GMS, `ProgramId`, `PdaSeed`). Any change - /// to `secret_spending_key_for_pda`, the `PrivateKeyHolder` nsk/npk chain, or the + /// to `secret_spending_key_for_pda`, the `PrivateKeyHolder` ask/nsk/npk chain, or the /// `AccountId::for_private_pda` formula breaks this test. Mirrors the pinned-value /// pattern from `for_private_pda_matches_pinned_value` in `lee_core`. #[test] @@ -357,8 +357,8 @@ mod tests { let account_id = AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, u128::MAX); let expected_npk = NullifierPublicKey([ - 136, 176, 234, 71, 208, 8, 143, 142, 126, 155, 132, 18, 71, 27, 88, 56, 100, 90, 79, - 215, 76, 92, 60, 166, 104, 35, 51, 91, 16, 114, 188, 112, + 59, 136, 7, 185, 56, 46, 38, 4, 195, 155, 85, 32, 161, 24, 119, 14, 148, 100, 26, 152, + 239, 255, 145, 142, 122, 166, 219, 75, 200, 9, 168, 7, ]); // AccountId is derived from (program_id, seed, npk), so it changes when npk changes. // We verify npk is pinned, and AccountId is deterministically derived from it. diff --git a/lee/key_protocol/src/key_management/key_tree/keys_private.rs b/lee/key_protocol/src/key_management/key_tree/keys_private.rs index 8165e808e..de8e8410c 100644 --- a/lee/key_protocol/src/key_management/key_tree/keys_private.rs +++ b/lee/key_protocol/src/key_management/key_tree/keys_private.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; -use lee_core::{NullifierPublicKey, PrivateAccountKind, encryption::ViewingPublicKey}; +use lee_core::{ + NullifierPublicKey, NullifierSecretKey, PrivateAccountKind, encryption::ViewingPublicKey, +}; use serde::{Deserialize, Serialize}; use sha2::Digest as _; @@ -22,7 +24,7 @@ pub struct ChildKeysPrivate { impl ChildKeysPrivate { #[must_use] pub fn root(seed: [u8; 64]) -> Self { - let hash_value = hmac_sha512::HMAC::mac(seed, b"LEE_master_priv"); + let hash_value = hmac_sha512::HMAC::mac(seed, b"/LEE-Keys/v1/Master/Private"); let (first, ccc) = split_hash(&hash_value); let ssk = SecretSpendingKey(first); @@ -32,11 +34,13 @@ impl ChildKeysPrivate { #[must_use] pub fn nth_child(&self, cci: u32) -> Self { + const DOMAIN: &[u8; 27] = b"/LEE-Keys/v1/Parent/Private"; + // `parent_hash`` is used to incorporate entropy based on the parent node's keys // to generate the `ssk` and `ccc` values. let mut parent_hash = sha2::Sha256::new(); - parent_hash.update(b"LEE/keys"); - parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key); + parent_hash.update(DOMAIN); + parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key()); parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.d); parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.z); let parent_pt = parent_hash.finalize(); @@ -44,7 +48,7 @@ impl ChildKeysPrivate { // Each child (of the same parent node) share the same `parent_pt`. // To ensure that each child generates unique keys, we include the child index. let mut input = vec![]; - input.extend_from_slice(b"LEE_seed_priv"); + input.extend_from_slice(b"/LEE-Keys/v1/Seed/Private"); input.extend_from_slice(&parent_pt); #[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")] input.extend_from_slice(&cci.to_be_bytes()); @@ -58,10 +62,10 @@ impl ChildKeysPrivate { } fn from_ssk_and_ccc(ssk: SecretSpendingKey, ccc: [u8; 32], cci: Option) -> Self { - let nsk = ssk.generate_nullifier_secret_key(cci); + let ask = ssk.generate_authorization_secret_key(cci); let vsk = ssk.generate_viewing_secret_seed_key(cci); - let npk = NullifierPublicKey::from(&nsk); + let npk = NullifierPublicKey::from(&NullifierSecretKey::from(&ask)); let vpk = ViewingPublicKey::from(&vsk); Self { @@ -71,7 +75,7 @@ impl ChildKeysPrivate { nullifier_public_key: npk, viewing_public_key: vpk, private_key_holder: PrivateKeyHolder { - nullifier_secret_key: nsk, + authorization_secret_key: ask, viewing_secret_key: vsk, }, }, @@ -121,104 +125,112 @@ mod tests { let keys = ChildKeysPrivate::root(SEED); let expected_ssk = key_management::secret_holders::SecretSpendingKey([ - 246, 79, 26, 124, 135, 95, 52, 51, 201, 27, 48, 194, 2, 144, 51, 219, 245, 128, 139, - 222, 42, 195, 105, 33, 115, 97, 186, 0, 97, 14, 218, 191, + 89, 83, 219, 205, 176, 131, 82, 74, 181, 52, 131, 240, 254, 155, 208, 126, 153, 157, + 15, 191, 143, 255, 97, 11, 215, 173, 222, 16, 183, 7, 202, 121, ]); let expected_ccc = [ - 56, 114, 70, 249, 67, 169, 206, 9, 192, 11, 180, 168, 149, 129, 42, 95, 43, 157, 130, - 111, 13, 5, 195, 75, 20, 255, 162, 85, 40, 251, 8, 168, + 186, 27, 116, 2, 28, 54, 110, 48, 7, 169, 72, 98, 9, 24, 12, 67, 109, 226, 251, 203, + 24, 253, 128, 147, 135, 32, 56, 255, 46, 230, 70, 10, ]; + let expected_ask = lee_core::AuthorizationSecretKey([ + 101, 181, 188, 132, 20, 141, 124, 205, 63, 12, 23, 238, 2, 74, 87, 224, 188, 155, 99, + 63, 14, 60, 27, 126, 47, 106, 97, 114, 103, 158, 103, 60, + ]); + let expected_nsk: NullifierSecretKey = [ - 154, 102, 103, 5, 34, 235, 227, 13, 22, 182, 226, 11, 7, 67, 110, 162, 99, 193, 174, - 34, 234, 19, 222, 2, 22, 12, 163, 252, 88, 11, 0, 163, + 46, 160, 51, 114, 77, 129, 41, 210, 140, 244, 36, 67, 95, 17, 96, 147, 113, 144, 67, + 94, 198, 153, 220, 206, 59, 61, 18, 52, 63, 0, 51, 102, ]; let expected_npk = lee_core::NullifierPublicKey([ - 7, 123, 125, 191, 233, 183, 201, 4, 20, 214, 155, 210, 45, 234, 27, 240, 194, 111, 97, - 247, 155, 113, 122, 246, 192, 0, 70, 61, 76, 71, 70, 2, + 31, 162, 180, 178, 183, 165, 42, 194, 236, 199, 174, 146, 241, 104, 255, 171, 249, 138, + 18, 132, 96, 10, 57, 210, 159, 169, 66, 238, 104, 132, 226, 13, ]); let expected_vsk = ViewingSecretKey::new( [ - 187, 143, 146, 12, 68, 148, 25, 203, 21, 92, 131, 2, 221, 81, 117, 62, 98, 194, - 159, 177, 102, 254, 236, 182, 76, 242, 116, 219, 17, 166, 99, 36, + 239, 105, 105, 155, 13, 89, 203, 100, 162, 108, 87, 128, 65, 88, 101, 217, 214, + 126, 200, 51, 35, 146, 251, 207, 76, 44, 218, 2, 51, 113, 171, 167, ], [ - 80, 97, 83, 209, 145, 99, 168, 99, 89, 29, 153, 236, 82, 99, 134, 114, 168, 19, - 223, 69, 34, 47, 76, 76, 15, 97, 245, 184, 25, 103, 251, 82, + 107, 52, 23, 158, 102, 144, 107, 47, 137, 52, 56, 143, 52, 225, 107, 228, 152, 23, + 24, 172, 164, 21, 92, 89, 87, 175, 51, 190, 34, 219, 52, 47, ], ); - // Length matches MlKem768EncapsulationKey::LEN. + // Length matches MlKem768EncapsulationKey::LEN. Oracle-sourced from the ML-KEM-768 + // implementation, unlike every other vector here; its trailing 32 bytes are + // rho = SHA3-512(d || 3)[..32] per FIPS-203, checked against the independent `d`. let expected_vpk: [u8; 1184] = [ - 127, 229, 162, 212, 104, 117, 4, 150, 192, 103, 122, 195, 14, 35, 12, 60, 52, 23, 220, - 150, 100, 203, 34, 34, 127, 232, 156, 43, 218, 109, 6, 160, 67, 35, 210, 194, 25, 181, - 118, 237, 25, 129, 51, 160, 189, 51, 99, 184, 57, 28, 121, 240, 236, 2, 170, 198, 26, - 91, 172, 110, 52, 32, 186, 35, 179, 202, 234, 249, 15, 242, 100, 198, 168, 163, 120, - 205, 118, 85, 195, 210, 187, 95, 150, 154, 8, 68, 165, 237, 87, 166, 101, 57, 4, 18, - 11, 122, 235, 180, 199, 154, 165, 158, 55, 136, 30, 237, 43, 167, 215, 68, 80, 102, 0, - 71, 90, 130, 206, 240, 215, 69, 199, 83, 7, 60, 184, 128, 230, 184, 61, 93, 201, 204, - 165, 104, 9, 127, 220, 52, 246, 217, 131, 251, 2, 170, 133, 6, 51, 40, 224, 101, 61, - 16, 135, 32, 182, 201, 68, 58, 171, 54, 161, 184, 243, 38, 106, 200, 251, 17, 172, 8, - 24, 73, 230, 55, 85, 20, 147, 222, 165, 200, 116, 135, 47, 20, 227, 56, 220, 64, 120, - 215, 245, 58, 86, 102, 149, 252, 193, 163, 160, 59, 82, 138, 249, 171, 1, 54, 199, 193, - 171, 85, 38, 64, 56, 121, 106, 84, 57, 252, 94, 147, 16, 191, 196, 104, 47, 129, 84, - 21, 252, 160, 81, 207, 184, 199, 3, 177, 74, 117, 115, 175, 138, 108, 36, 198, 5, 32, - 15, 218, 3, 20, 19, 15, 251, 209, 86, 128, 139, 148, 78, 10, 34, 144, 149, 74, 102, 48, - 59, 70, 124, 47, 193, 100, 26, 9, 104, 178, 102, 156, 199, 242, 101, 147, 161, 87, 27, - 234, 192, 204, 41, 36, 43, 83, 219, 15, 211, 66, 91, 76, 73, 13, 113, 155, 203, 193, - 160, 130, 84, 103, 47, 70, 100, 147, 169, 65, 119, 84, 121, 122, 161, 76, 203, 144, - 248, 145, 22, 8, 46, 121, 44, 77, 20, 149, 66, 179, 56, 149, 231, 98, 184, 9, 64, 14, - 67, 196, 34, 8, 123, 21, 80, 169, 168, 223, 230, 133, 0, 66, 159, 230, 69, 201, 205, - 169, 105, 196, 21, 71, 84, 70, 58, 165, 165, 134, 186, 232, 60, 70, 51, 57, 239, 74, - 174, 116, 234, 36, 178, 49, 42, 168, 250, 104, 141, 106, 0, 109, 52, 86, 104, 243, 62, - 214, 137, 48, 107, 2, 152, 206, 227, 175, 147, 236, 19, 113, 27, 191, 231, 235, 167, - 114, 104, 23, 126, 203, 94, 242, 149, 171, 115, 170, 89, 244, 58, 29, 176, 73, 203, 44, - 8, 32, 9, 226, 32, 78, 246, 38, 235, 149, 133, 25, 243, 47, 124, 180, 200, 211, 165, - 137, 56, 169, 117, 31, 244, 65, 91, 135, 146, 158, 20, 75, 102, 32, 65, 250, 103, 199, - 36, 48, 31, 155, 164, 191, 222, 85, 37, 66, 243, 17, 120, 104, 0, 228, 83, 200, 116, 6, - 199, 106, 236, 139, 246, 216, 152, 241, 211, 85, 106, 200, 44, 231, 240, 66, 3, 193, - 147, 16, 145, 65, 49, 33, 53, 247, 69, 47, 44, 113, 86, 117, 6, 20, 193, 183, 128, 178, - 181, 21, 251, 99, 39, 149, 210, 146, 106, 181, 186, 7, 36, 63, 186, 234, 191, 164, 193, - 162, 127, 250, 122, 189, 219, 21, 92, 48, 86, 209, 184, 99, 160, 201, 162, 145, 20, - 138, 154, 18, 37, 180, 209, 165, 165, 51, 187, 78, 193, 175, 135, 6, 55, 216, 178, 10, - 40, 246, 98, 128, 80, 14, 38, 69, 113, 123, 54, 94, 43, 50, 106, 167, 17, 77, 163, 148, - 117, 225, 9, 7, 253, 240, 157, 96, 103, 33, 100, 37, 37, 20, 53, 138, 234, 55, 45, 232, - 154, 9, 150, 192, 116, 36, 119, 106, 95, 119, 34, 220, 84, 174, 19, 227, 33, 209, 96, - 197, 148, 230, 197, 59, 117, 130, 7, 116, 11, 0, 197, 16, 249, 151, 31, 4, 64, 29, 165, - 247, 110, 176, 166, 4, 112, 136, 101, 208, 7, 179, 38, 183, 134, 58, 107, 207, 160, 38, - 159, 67, 112, 20, 225, 199, 179, 133, 117, 144, 54, 199, 15, 204, 80, 154, 116, 84, 88, - 109, 113, 5, 207, 226, 21, 62, 247, 122, 14, 156, 9, 8, 76, 26, 148, 67, 196, 128, 176, - 78, 51, 161, 151, 75, 248, 154, 31, 168, 9, 4, 3, 107, 222, 245, 178, 21, 84, 7, 25, - 155, 118, 97, 135, 63, 89, 233, 11, 207, 148, 155, 38, 106, 104, 102, 140, 104, 67, - 149, 20, 30, 196, 44, 197, 128, 34, 182, 80, 30, 32, 137, 34, 212, 164, 177, 164, 12, - 115, 41, 156, 111, 71, 230, 120, 111, 218, 25, 117, 218, 75, 167, 32, 37, 57, 50, 99, - 181, 203, 40, 105, 248, 150, 114, 121, 73, 127, 198, 191, 161, 44, 56, 213, 243, 71, 2, - 56, 192, 243, 107, 179, 27, 96, 21, 116, 169, 64, 15, 97, 166, 151, 200, 11, 40, 204, - 71, 168, 220, 9, 55, 43, 146, 244, 212, 166, 192, 180, 189, 237, 162, 42, 29, 33, 52, - 193, 4, 178, 157, 244, 28, 209, 44, 26, 36, 147, 126, 94, 164, 37, 47, 115, 38, 23, - 165, 96, 106, 140, 42, 69, 146, 194, 93, 71, 175, 49, 147, 32, 246, 97, 94, 41, 116, - 127, 174, 18, 16, 14, 163, 17, 180, 213, 203, 166, 33, 139, 214, 18, 170, 27, 41, 59, - 175, 200, 101, 14, 128, 45, 179, 167, 136, 232, 138, 56, 124, 145, 75, 233, 132, 161, - 196, 164, 72, 80, 60, 187, 38, 90, 90, 17, 66, 134, 59, 2, 165, 29, 76, 24, 38, 211, - 177, 83, 119, 20, 239, 59, 77, 34, 3, 42, 47, 60, 89, 46, 103, 168, 120, 17, 199, 50, - 17, 103, 107, 48, 8, 53, 220, 159, 212, 65, 198, 80, 8, 11, 235, 97, 203, 196, 240, 44, - 56, 121, 77, 91, 196, 160, 129, 242, 149, 226, 57, 106, 180, 76, 161, 203, 18, 37, 166, - 153, 44, 40, 28, 74, 8, 11, 6, 166, 54, 10, 103, 247, 23, 35, 7, 47, 173, 133, 71, 85, - 3, 168, 250, 120, 126, 174, 37, 80, 128, 107, 7, 161, 130, 155, 136, 92, 48, 215, 119, - 196, 124, 85, 157, 234, 2, 166, 137, 65, 121, 222, 112, 47, 17, 43, 23, 111, 88, 5, - 195, 41, 8, 191, 227, 21, 173, 35, 199, 196, 188, 162, 191, 195, 204, 137, 54, 16, 73, - 178, 150, 249, 234, 22, 216, 123, 157, 144, 218, 118, 53, 193, 67, 65, 84, 162, 244, - 165, 24, 110, 246, 146, 228, 212, 180, 150, 116, 201, 37, 128, 76, 41, 188, 42, 79, - 148, 52, 196, 176, 178, 224, 48, 168, 13, 129, 193, 131, 185, 131, 93, 40, 145, 56, - 180, 29, 153, 83, 39, 69, 232, 96, 238, 137, 104, 150, 2, 202, 239, 149, 248, 154, 115, - 115, 127, 3, 8, 32, 61, 96, 66, 25, 181, 14, 72, 73, 97, 186, 134, 140, 33, 69, 33, 74, + 9, 112, 59, 246, 41, 55, 116, 67, 166, 192, 83, 50, 213, 177, 26, 20, 243, 125, 9, 35, + 156, 82, 73, 55, 138, 66, 88, 141, 179, 140, 141, 50, 149, 79, 181, 43, 174, 200, 31, + 111, 9, 154, 216, 90, 4, 89, 136, 130, 6, 139, 36, 142, 27, 168, 149, 210, 2, 105, 25, + 39, 94, 244, 7, 242, 108, 19, 64, 72, 25, 237, 105, 3, 215, 75, 21, 211, 196, 58, 39, + 116, 34, 24, 215, 25, 152, 56, 62, 70, 136, 98, 188, 21, 138, 18, 214, 75, 140, 185, + 77, 4, 192, 71, 202, 76, 158, 212, 161, 37, 48, 171, 156, 254, 192, 68, 19, 89, 83, 83, + 131, 194, 135, 250, 59, 239, 40, 22, 15, 211, 140, 112, 7, 154, 36, 196, 152, 144, 72, + 64, 117, 249, 134, 46, 21, 36, 101, 242, 103, 182, 226, 93, 207, 243, 0, 214, 3, 117, + 129, 65, 45, 181, 1, 44, 82, 71, 145, 188, 128, 21, 205, 56, 125, 31, 64, 16, 245, 247, + 55, 10, 32, 112, 67, 164, 57, 63, 124, 10, 111, 251, 15, 21, 23, 132, 206, 122, 29, 16, + 137, 206, 109, 193, 192, 132, 98, 140, 125, 41, 22, 41, 7, 208, 33, 160, 121, 143, 0, + 24, 224, 149, 107, 141, 196, 129, 187, 181, 23, 21, 86, 62, 160, 185, 134, 185, 151, + 165, 11, 72, 118, 163, 49, 182, 67, 0, 142, 101, 42, 2, 99, 102, 200, 92, 105, 53, 53, + 144, 9, 84, 32, 119, 14, 12, 129, 26, 247, 173, 118, 193, 24, 252, 131, 99, 22, 97, + 207, 213, 75, 199, 238, 197, 143, 153, 60, 13, 156, 236, 11, 218, 171, 106, 58, 52, 99, + 236, 149, 153, 249, 147, 49, 179, 57, 20, 100, 38, 78, 62, 235, 110, 113, 72, 77, 177, + 225, 51, 114, 172, 28, 150, 246, 190, 164, 43, 60, 3, 225, 85, 160, 38, 205, 189, 163, + 72, 231, 56, 111, 2, 163, 74, 114, 218, 10, 45, 181, 35, 59, 119, 124, 4, 92, 45, 44, + 53, 154, 122, 161, 65, 244, 130, 78, 83, 9, 198, 204, 134, 11, 214, 75, 156, 178, 151, + 19, 212, 10, 140, 133, 128, 45, 93, 178, 121, 169, 108, 170, 134, 200, 0, 215, 172, 43, + 111, 105, 182, 82, 235, 132, 236, 7, 37, 239, 235, 142, 173, 246, 150, 61, 8, 140, 159, + 41, 166, 23, 218, 153, 65, 151, 91, 137, 132, 180, 141, 163, 113, 16, 209, 201, 63, 65, + 121, 236, 66, 47, 74, 177, 74, 80, 117, 34, 44, 27, 22, 238, 154, 140, 90, 12, 50, 47, + 5, 64, 121, 227, 36, 156, 233, 19, 85, 89, 1, 96, 137, 77, 187, 235, 124, 226, 3, 6, + 219, 70, 126, 89, 247, 70, 85, 208, 200, 99, 37, 89, 18, 249, 128, 233, 245, 123, 18, + 49, 32, 227, 163, 20, 184, 131, 203, 153, 4, 60, 188, 243, 157, 172, 120, 176, 72, 210, + 146, 177, 54, 4, 125, 33, 82, 99, 184, 93, 186, 106, 54, 57, 156, 27, 96, 177, 89, 81, + 12, 11, 83, 161, 153, 253, 160, 132, 28, 192, 34, 221, 184, 46, 51, 171, 203, 36, 121, + 83, 229, 80, 39, 168, 115, 36, 126, 86, 153, 228, 115, 137, 127, 162, 189, 222, 200, + 67, 231, 38, 3, 186, 66, 62, 69, 26, 166, 9, 99, 149, 134, 178, 0, 87, 120, 150, 11, + 216, 124, 191, 136, 129, 130, 115, 101, 143, 195, 134, 79, 118, 197, 177, 165, 179, + 117, 19, 50, 215, 51, 171, 123, 236, 193, 172, 24, 9, 5, 74, 28, 237, 43, 187, 114, 90, + 187, 41, 188, 204, 64, 19, 147, 114, 0, 23, 16, 53, 89, 32, 36, 184, 145, 165, 6, 57, + 134, 80, 102, 66, 59, 16, 60, 43, 132, 101, 73, 84, 161, 11, 192, 229, 163, 160, 117, + 26, 11, 113, 50, 51, 98, 34, 220, 98, 143, 102, 105, 97, 156, 241, 34, 138, 136, 160, + 129, 216, 89, 171, 42, 92, 178, 90, 5, 168, 84, 189, 223, 52, 33, 218, 203, 27, 89, + 188, 184, 138, 172, 34, 166, 171, 156, 180, 161, 130, 94, 252, 166, 148, 114, 39, 138, + 156, 66, 222, 146, 171, 40, 118, 29, 14, 5, 147, 53, 226, 24, 85, 1, 68, 28, 98, 99, + 236, 65, 91, 131, 145, 152, 159, 198, 202, 144, 6, 125, 93, 247, 26, 149, 51, 159, 164, + 100, 142, 239, 66, 105, 106, 155, 177, 96, 216, 160, 20, 198, 10, 186, 113, 83, 85, 51, + 7, 246, 227, 69, 80, 22, 38, 129, 198, 7, 66, 130, 95, 32, 224, 110, 62, 231, 70, 123, + 55, 175, 115, 219, 199, 234, 43, 73, 93, 198, 188, 196, 231, 76, 72, 193, 179, 47, 153, + 182, 203, 193, 116, 70, 37, 57, 198, 67, 82, 24, 119, 60, 68, 16, 94, 206, 178, 173, + 135, 89, 90, 238, 245, 30, 112, 59, 76, 110, 217, 188, 135, 68, 155, 184, 16, 27, 168, + 203, 44, 101, 96, 164, 184, 48, 24, 84, 56, 169, 44, 179, 19, 6, 66, 143, 3, 71, 128, + 181, 244, 43, 70, 67, 168, 17, 58, 184, 74, 35, 167, 50, 198, 180, 205, 85, 126, 11, + 151, 116, 238, 21, 83, 146, 34, 69, 233, 32, 87, 117, 107, 28, 116, 102, 62, 74, 26, + 80, 38, 201, 44, 145, 248, 129, 136, 243, 177, 191, 215, 107, 93, 56, 99, 35, 252, 183, + 220, 23, 47, 173, 7, 14, 238, 5, 159, 88, 242, 101, 223, 68, 112, 158, 40, 148, 198, + 26, 27, 128, 231, 177, 17, 101, 191, 12, 103, 91, 62, 216, 112, 75, 188, 64, 65, 218, + 29, 122, 177, 204, 50, 66, 123, 84, 91, 15, 238, 220, 41, 24, 17, 36, 169, 231, 152, + 245, 59, 25, 35, 49, 141, 205, 67, 5, 102, 162, 146, 41, 89, 49, 215, 58, 35, 106, 7, + 110, 209, 231, 123, 2, 27, 170, 25, 1, 163, 200, 120, 142, 73, 202, 16, 156, 162, 133, + 182, 186, 171, 153, 181, 179, 230, 225, 202, 184, 149, 73, 210, 51, 201, 134, 227, 80, + 51, 108, 103, 171, 201, 107, 237, 18, 197, 4, 167, 121, 210, 53, 156, 245, 120, 117, + 174, 167, 83, 220, 73, 125, 3, 179, 87, 70, 25, 59, 182, 103, 164, 134, 193, 51, 16, + 137, 23, 27, 194, 18, 98, 112, 93, 18, 220, 137, 37, 97, 123, 161, 232, 157, 231, 44, + 147, 11, 179, 89, 88, 4, 45, 192, 36, 124, 12, 203, 84, 109, 164, 19, 151, 183, 145, + 108, 233, 122, 115, 227, 104, 186, 128, 38, 185, 87, 89, 220, 198, 174, 160, 165, 152, + 40, 236, 0, 167, 106, 175, 244, 4, 55, 213, 18, 133, 255, 153, 9, 188, 247, 38, 225, + 140, 52, 45, 186, 134, 255, 10, 138, 132, 237, 79, 72, 133, 108, 147, 85, 247, 55, 61, + 252, 187, 135, 75, 170, 63, 63, 239, 196, 99, 147, 72, 117, 111, 126, ]; assert!(expected_ssk == keys.value.0.secret_spending_key); assert!(expected_ccc == keys.ccc); - assert!(expected_nsk == keys.value.0.private_key_holder.nullifier_secret_key); + assert!(expected_ask == keys.value.0.private_key_holder.authorization_secret_key); + assert!(expected_nsk == keys.value.0.private_key_holder.nullifier_secret_key()); assert!(expected_npk == keys.value.0.nullifier_public_key); assert!(expected_vsk == keys.value.0.private_key_holder.viewing_secret_key); assert!(expected_vpk == keys.value.0.viewing_public_key.to_bytes()); @@ -230,105 +242,119 @@ mod tests { let child_node = ChildKeysPrivate::nth_child(&root_node, 42_u32); let expected_ssk = key_management::secret_holders::SecretSpendingKey([ - 151, 183, 113, 151, 215, 187, 207, 64, 197, 182, 207, 32, 5, 49, 180, 98, 119, 14, 248, - 175, 39, 100, 47, 109, 148, 173, 217, 253, 159, 234, 209, 113, + 255, 91, 0, 255, 146, 9, 219, 224, 232, 0, 145, 114, 101, 34, 207, 151, 203, 96, 120, + 175, 228, 160, 182, 56, 113, 198, 151, 158, 223, 183, 121, 111, ]); let expected_ccc = [ - 138, 243, 142, 163, 62, 107, 63, 131, 230, 158, 185, 60, 204, 50, 243, 222, 13, 123, - 98, 116, 131, 194, 7, 25, 129, 209, 163, 72, 178, 143, 192, 240, + 149, 233, 214, 67, 128, 214, 160, 223, 4, 243, 147, 157, 59, 9, 140, 26, 30, 60, 246, + 175, 98, 220, 192, 241, 47, 25, 197, 150, 25, 192, 2, 229, ]; + let expected_ask = lee_core::AuthorizationSecretKey([ + 181, 178, 105, 190, 28, 96, 174, 62, 65, 157, 130, 133, 57, 64, 9, 248, 208, 87, 211, + 185, 124, 194, 181, 148, 243, 25, 63, 35, 38, 65, 48, 74, + ]); + let expected_nsk: NullifierSecretKey = [ - 196, 33, 11, 39, 220, 84, 119, 182, 187, 194, 135, 20, 124, 33, 244, 205, 96, 58, 102, - 52, 74, 67, 110, 213, 24, 16, 160, 64, 247, 3, 107, 235, + 9, 95, 3, 185, 154, 73, 180, 31, 241, 193, 141, 13, 247, 72, 210, 198, 29, 39, 33, 149, + 238, 157, 50, 138, 162, 227, 70, 164, 93, 169, 83, 231, ]; let expected_npk = lee_core::NullifierPublicKey([ - 247, 253, 217, 86, 157, 208, 39, 172, 59, 190, 88, 165, 7, 173, 183, 106, 172, 211, 4, - 180, 51, 107, 177, 107, 51, 117, 231, 176, 200, 103, 1, 121, + 220, 138, 42, 117, 37, 161, 212, 202, 29, 17, 97, 186, 118, 172, 21, 159, 163, 147, 39, + 65, 114, 231, 255, 39, 207, 253, 76, 171, 57, 141, 34, 11, ]); let expected_vsk = ViewingSecretKey::new( [ - 185, 209, 179, 92, 7, 131, 98, 121, 215, 46, 154, 56, 238, 106, 162, 225, 83, 82, - 134, 3, 80, 186, 35, 178, 161, 204, 205, 163, 28, 19, 149, 18, + 122, 167, 237, 237, 86, 162, 1, 146, 79, 10, 160, 65, 61, 77, 8, 65, 241, 247, 144, + 153, 109, 134, 20, 7, 229, 52, 249, 30, 32, 109, 23, 112, ], [ - 174, 24, 72, 205, 129, 123, 131, 9, 146, 152, 224, 151, 10, 184, 224, 109, 94, 149, - 117, 60, 26, 10, 212, 125, 113, 147, 87, 67, 73, 26, 101, 193, + 112, 168, 129, 117, 127, 160, 73, 110, 21, 164, 82, 213, 253, 135, 11, 26, 213, 53, + 120, 199, 254, 143, 28, 169, 170, 187, 92, 12, 43, 217, 129, 58, ], ); - // Length matches MlKem768EncapsulationKey::LEN. + // Length matches MlKem768EncapsulationKey::LEN. Oracle-sourced from the ML-KEM-768 + // implementation, unlike every other vector here; its trailing 32 bytes are + // rho = SHA3-512(d || 3)[..32] per FIPS-203, checked against the independent `d`. let expected_vpk: [u8; 1184] = [ - 215, 229, 207, 120, 148, 177, 148, 197, 72, 222, 134, 3, 231, 146, 123, 226, 36, 84, - 232, 179, 205, 16, 241, 142, 9, 81, 58, 54, 12, 115, 148, 182, 19, 245, 22, 203, 57, - 71, 11, 204, 156, 130, 30, 170, 199, 201, 25, 2, 21, 34, 155, 136, 124, 145, 223, 128, - 177, 207, 92, 38, 252, 165, 118, 61, 128, 71, 154, 242, 105, 165, 52, 7, 6, 244, 120, - 227, 134, 191, 25, 169, 150, 123, 246, 138, 25, 196, 126, 156, 144, 33, 123, 120, 44, - 142, 89, 201, 49, 219, 205, 87, 236, 110, 64, 129, 102, 100, 155, 26, 101, 121, 42, - 236, 82, 111, 141, 117, 75, 71, 194, 73, 123, 170, 110, 69, 149, 107, 96, 195, 55, 122, - 140, 131, 106, 140, 156, 147, 75, 28, 128, 138, 113, 86, 37, 63, 173, 214, 200, 2, 214, - 84, 234, 176, 120, 252, 184, 99, 192, 65, 112, 150, 99, 26, 174, 187, 183, 187, 64, 90, - 248, 100, 66, 63, 195, 3, 44, 43, 128, 59, 149, 107, 66, 180, 67, 200, 183, 200, 36, - 91, 7, 65, 228, 159, 79, 44, 89, 35, 163, 145, 92, 227, 104, 2, 72, 5, 7, 193, 21, 51, - 116, 198, 184, 6, 192, 188, 68, 183, 163, 193, 142, 244, 217, 155, 197, 187, 189, 174, - 225, 45, 126, 112, 93, 194, 156, 102, 150, 1, 188, 222, 76, 108, 73, 149, 44, 28, 219, - 66, 95, 215, 204, 148, 217, 16, 36, 121, 112, 2, 51, 10, 195, 137, 12, 93, 203, 146, - 138, 211, 15, 201, 42, 72, 146, 186, 160, 222, 235, 127, 83, 48, 182, 49, 248, 29, 138, - 16, 32, 232, 179, 163, 187, 161, 174, 152, 187, 93, 76, 166, 48, 230, 219, 111, 123, - 181, 103, 130, 28, 109, 235, 115, 45, 57, 193, 206, 160, 17, 52, 92, 194, 25, 3, 80, - 97, 142, 249, 151, 94, 250, 95, 12, 57, 11, 165, 92, 47, 85, 182, 48, 22, 60, 97, 244, - 59, 194, 135, 180, 133, 106, 227, 56, 192, 60, 91, 15, 241, 146, 89, 240, 130, 219, - 202, 187, 43, 85, 98, 50, 104, 64, 114, 113, 80, 54, 69, 69, 5, 43, 90, 19, 0, 0, 188, - 251, 184, 70, 160, 18, 117, 76, 53, 209, 166, 96, 34, 224, 137, 115, 183, 168, 243, 19, - 1, 255, 4, 97, 162, 199, 104, 72, 213, 111, 62, 54, 172, 82, 184, 82, 143, 71, 99, 25, - 104, 74, 120, 70, 84, 235, 32, 22, 20, 218, 163, 77, 194, 125, 75, 22, 72, 236, 192, - 200, 107, 91, 156, 201, 10, 178, 87, 19, 181, 211, 91, 17, 145, 200, 17, 179, 65, 75, - 200, 186, 89, 144, 91, 184, 116, 214, 51, 91, 42, 162, 243, 202, 92, 18, 54, 0, 213, - 67, 149, 151, 51, 29, 220, 196, 160, 201, 68, 113, 210, 164, 175, 152, 121, 168, 231, - 161, 91, 132, 218, 1, 171, 176, 84, 100, 57, 1, 3, 2, 196, 194, 76, 181, 79, 171, 157, - 35, 162, 155, 192, 210, 149, 142, 120, 189, 127, 151, 96, 202, 225, 73, 242, 81, 112, - 237, 224, 155, 130, 130, 34, 196, 153, 131, 161, 113, 163, 172, 114, 48, 207, 32, 151, - 172, 83, 145, 79, 210, 100, 161, 92, 82, 216, 90, 104, 238, 212, 38, 50, 107, 17, 228, - 195, 190, 6, 151, 165, 148, 245, 102, 51, 8, 185, 8, 85, 59, 247, 219, 95, 219, 170, - 155, 233, 123, 27, 64, 251, 56, 24, 200, 16, 181, 212, 146, 61, 116, 106, 215, 214, 62, - 118, 27, 68, 233, 148, 73, 135, 199, 74, 184, 89, 159, 217, 139, 24, 208, 250, 30, 224, - 97, 185, 237, 193, 8, 216, 23, 186, 5, 50, 41, 161, 203, 22, 217, 23, 194, 191, 148, - 124, 10, 212, 171, 209, 210, 145, 184, 171, 74, 35, 220, 43, 145, 241, 23, 43, 92, 171, - 216, 43, 114, 77, 155, 147, 156, 86, 56, 170, 27, 1, 54, 182, 169, 96, 22, 201, 51, - 145, 94, 143, 133, 106, 47, 176, 112, 197, 197, 96, 80, 73, 164, 207, 179, 22, 229, - 171, 201, 223, 219, 13, 219, 1, 91, 224, 252, 171, 199, 217, 25, 60, 128, 135, 9, 71, - 105, 231, 86, 34, 21, 155, 50, 0, 105, 72, 117, 108, 175, 140, 9, 181, 249, 139, 97, 3, - 161, 66, 248, 42, 67, 113, 132, 8, 119, 232, 6, 169, 18, 157, 222, 53, 176, 56, 137, - 120, 18, 115, 199, 187, 112, 48, 223, 211, 206, 152, 252, 108, 179, 129, 20, 227, 248, - 183, 234, 87, 202, 49, 17, 69, 215, 118, 89, 188, 180, 33, 238, 245, 206, 40, 179, 129, - 242, 59, 73, 254, 117, 114, 250, 179, 103, 109, 250, 202, 99, 152, 2, 167, 130, 169, - 35, 71, 89, 211, 140, 71, 103, 154, 121, 108, 147, 191, 186, 73, 10, 73, 203, 23, 55, - 106, 144, 98, 227, 157, 25, 27, 81, 67, 11, 57, 88, 227, 116, 61, 100, 94, 23, 166, - 146, 57, 226, 72, 124, 33, 65, 226, 35, 167, 206, 156, 202, 213, 213, 158, 89, 249, - 181, 19, 113, 109, 217, 71, 168, 142, 180, 122, 30, 5, 54, 170, 155, 73, 56, 170, 124, - 139, 4, 165, 103, 82, 32, 183, 84, 7, 239, 117, 135, 239, 48, 24, 28, 210, 49, 137, 6, - 158, 65, 211, 113, 205, 135, 146, 83, 10, 46, 90, 27, 97, 135, 135, 185, 173, 69, 58, - 34, 247, 141, 150, 6, 158, 117, 23, 198, 139, 65, 81, 179, 187, 194, 247, 203, 127, - 106, 232, 119, 122, 215, 197, 110, 69, 203, 174, 227, 63, 185, 106, 14, 184, 104, 113, - 233, 83, 92, 104, 38, 188, 9, 135, 107, 108, 121, 193, 33, 209, 89, 39, 137, 17, 208, - 26, 21, 238, 169, 86, 181, 193, 153, 82, 8, 151, 53, 39, 88, 91, 252, 3, 33, 75, 127, - 9, 168, 53, 34, 1, 173, 202, 123, 157, 174, 170, 199, 254, 187, 196, 144, 37, 29, 48, - 112, 173, 107, 147, 155, 69, 134, 137, 156, 247, 123, 242, 72, 5, 43, 106, 89, 179, - 204, 41, 15, 60, 48, 78, 214, 180, 26, 170, 67, 71, 66, 146, 113, 220, 159, 153, 201, - 176, 116, 154, 21, 186, 33, 180, 72, 39, 187, 240, 80, 112, 132, 144, 173, 210, 12, 76, - 184, 146, 89, 178, 178, 82, 109, 71, 201, 241, 160, 207, 219, 124, 77, 2, 105, 124, - 178, 71, 3, 38, 64, 41, 83, 170, 137, 82, 242, 144, 76, 102, 82, 7, 25, 149, 141, 169, - 46, 4, 68, 40, 244, 146, 131, 107, 148, 18, 111, 85, 104, 243, 28, 75, 176, 249, 88, - 82, 123, 89, 29, 104, 135, 230, 117, 67, 26, 249, 108, 145, 76, 38, 175, 89, 185, 94, - 106, 128, 201, 150, 151, 194, 133, 21, 81, 213, 231, 15, 117, 44, 61, 86, 223, 162, 56, - 190, 166, 177, 157, 137, 60, 208, 155, 234, 158, 252, 30, + 65, 58, 16, 29, 18, 146, 252, 162, 117, 98, 133, 1, 86, 200, 127, 2, 35, 59, 88, 227, + 203, 23, 97, 155, 214, 138, 119, 145, 232, 190, 40, 8, 202, 43, 194, 147, 48, 246, 95, + 103, 232, 203, 34, 203, 50, 46, 4, 65, 209, 74, 112, 3, 183, 4, 102, 71, 80, 232, 148, + 53, 102, 71, 87, 246, 99, 161, 208, 197, 127, 244, 115, 136, 37, 201, 60, 45, 25, 24, + 181, 88, 141, 151, 149, 135, 121, 242, 184, 195, 138, 23, 247, 195, 56, 183, 51, 166, + 176, 119, 101, 130, 177, 15, 126, 85, 163, 226, 98, 17, 154, 134, 159, 188, 231, 49, + 64, 25, 183, 21, 59, 119, 203, 22, 143, 113, 136, 89, 202, 9, 8, 233, 24, 125, 11, 87, + 18, 178, 240, 33, 13, 106, 55, 148, 2, 145, 164, 105, 15, 233, 43, 202, 169, 10, 151, + 68, 92, 100, 137, 22, 155, 153, 91, 14, 96, 2, 43, 183, 97, 133, 77, 6, 99, 238, 26, + 163, 123, 53, 41, 221, 83, 19, 46, 243, 52, 252, 168, 187, 195, 241, 148, 57, 149, 118, + 92, 188, 47, 177, 234, 57, 78, 229, 188, 194, 108, 55, 254, 24, 75, 25, 19, 143, 28, + 84, 105, 245, 50, 41, 255, 176, 84, 41, 58, 87, 171, 33, 117, 14, 112, 109, 136, 168, + 28, 53, 90, 35, 135, 145, 200, 209, 136, 123, 58, 28, 138, 241, 193, 64, 118, 187, 29, + 228, 50, 11, 179, 134, 161, 251, 245, 100, 148, 224, 103, 97, 103, 132, 222, 72, 176, + 26, 39, 186, 246, 44, 19, 125, 131, 156, 243, 16, 121, 77, 33, 60, 72, 17, 43, 229, 70, + 69, 74, 41, 166, 109, 88, 195, 9, 154, 193, 25, 107, 41, 12, 82, 126, 68, 251, 131, 84, + 69, 31, 60, 226, 26, 236, 185, 11, 238, 147, 23, 72, 80, 170, 124, 201, 96, 173, 242, + 24, 186, 114, 44, 129, 72, 35, 170, 115, 18, 30, 218, 137, 50, 21, 62, 145, 227, 76, + 107, 146, 196, 72, 249, 122, 137, 91, 97, 127, 120, 19, 199, 21, 10, 20, 187, 182, 109, + 160, 103, 224, 155, 182, 38, 148, 48, 203, 166, 188, 243, 181, 153, 34, 88, 108, 75, + 114, 178, 25, 5, 84, 168, 10, 162, 249, 49, 197, 100, 57, 94, 211, 12, 178, 174, 229, + 192, 146, 210, 166, 37, 209, 124, 207, 98, 69, 146, 244, 164, 102, 182, 69, 156, 201, + 184, 70, 117, 11, 20, 64, 151, 125, 235, 80, 144, 165, 58, 4, 32, 162, 46, 82, 92, 244, + 180, 153, 58, 225, 39, 53, 34, 194, 181, 149, 148, 204, 117, 167, 245, 24, 108, 229, + 119, 114, 149, 171, 122, 54, 182, 171, 36, 117, 117, 181, 44, 152, 228, 85, 19, 183, + 172, 157, 201, 236, 28, 232, 216, 143, 203, 34, 131, 38, 91, 108, 104, 170, 147, 54, + 66, 115, 254, 246, 165, 50, 97, 24, 167, 49, 206, 118, 41, 74, 143, 28, 75, 160, 211, + 183, 93, 181, 142, 209, 40, 70, 218, 151, 170, 138, 69, 37, 33, 118, 9, 143, 148, 92, + 149, 114, 84, 161, 8, 107, 34, 7, 142, 78, 169, 157, 245, 20, 32, 189, 245, 2, 254, 92, + 43, 161, 155, 69, 23, 60, 33, 208, 70, 149, 44, 70, 131, 241, 119, 134, 95, 7, 10, 188, + 89, 44, 62, 44, 40, 60, 178, 34, 227, 187, 152, 63, 169, 145, 197, 217, 6, 227, 55, 59, + 62, 145, 5, 235, 107, 35, 147, 7, 106, 240, 144, 24, 45, 161, 118, 112, 16, 169, 45, + 150, 36, 207, 184, 161, 51, 64, 93, 198, 226, 97, 167, 163, 120, 211, 165, 198, 151, + 130, 44, 133, 249, 201, 198, 52, 190, 156, 83, 158, 104, 131, 0, 57, 132, 69, 53, 33, + 86, 83, 70, 173, 121, 108, 80, 164, 234, 169, 74, 155, 158, 242, 187, 139, 31, 213, 65, + 188, 135, 49, 86, 105, 18, 48, 91, 142, 123, 181, 50, 26, 26, 199, 176, 128, 102, 28, + 210, 100, 237, 186, 112, 22, 147, 10, 175, 196, 154, 73, 151, 194, 230, 90, 56, 213, + 34, 44, 250, 231, 120, 232, 89, 193, 116, 244, 39, 82, 149, 135, 248, 0, 181, 98, 68, + 185, 83, 194, 8, 200, 166, 74, 187, 200, 75, 177, 194, 67, 248, 91, 63, 83, 33, 36, 14, + 39, 99, 17, 194, 47, 209, 51, 138, 113, 227, 23, 171, 236, 26, 217, 229, 199, 27, 3, + 14, 22, 6, 93, 150, 136, 182, 162, 146, 103, 24, 71, 31, 26, 131, 201, 177, 71, 25, 53, + 7, 70, 126, 99, 22, 240, 240, 43, 124, 234, 83, 8, 67, 166, 220, 7, 128, 49, 165, 165, + 19, 145, 78, 121, 215, 177, 73, 58, 150, 211, 50, 74, 243, 150, 107, 41, 196, 146, 238, + 180, 67, 28, 58, 78, 237, 229, 164, 220, 60, 90, 93, 169, 51, 76, 166, 195, 59, 250, + 22, 199, 68, 57, 149, 74, 102, 68, 164, 207, 161, 213, 132, 156, 228, 144, 163, 165, + 68, 37, 227, 62, 115, 41, 112, 141, 162, 71, 169, 92, 22, 84, 22, 132, 90, 132, 141, + 196, 182, 25, 252, 240, 117, 142, 172, 144, 243, 209, 5, 167, 247, 142, 58, 49, 139, + 55, 73, 103, 112, 149, 185, 134, 103, 73, 243, 194, 137, 70, 144, 178, 47, 182, 10, 78, + 144, 60, 83, 26, 108, 205, 165, 60, 73, 91, 203, 73, 36, 111, 157, 241, 180, 203, 145, + 3, 233, 131, 87, 54, 34, 201, 178, 232, 195, 214, 80, 98, 234, 19, 22, 170, 195, 123, + 169, 56, 170, 40, 112, 112, 91, 139, 25, 222, 99, 98, 228, 50, 97, 115, 2, 44, 175, 55, + 147, 130, 12, 158, 167, 161, 63, 45, 48, 81, 63, 48, 68, 61, 88, 92, 225, 26, 104, 129, + 74, 21, 89, 212, 147, 229, 216, 11, 90, 51, 5, 45, 22, 158, 243, 8, 69, 67, 133, 4, 31, + 138, 58, 77, 101, 200, 125, 52, 181, 56, 36, 73, 40, 178, 187, 254, 71, 138, 174, 187, + 76, 172, 6, 164, 8, 84, 184, 72, 11, 112, 63, 138, 123, 44, 241, 90, 108, 71, 113, 108, + 83, 87, 67, 168, 9, 236, 210, 23, 130, 162, 108, 108, 103, 112, 136, 248, 38, 94, 156, + 64, 15, 219, 108, 193, 199, 131, 81, 188, 53, 236, 57, 181, 253, 6, 164, 70, 202, 121, + 214, 149, 161, 252, 132, 74, 106, 82, 160, 17, 83, 196, 103, 70, 159, 176, 55, 19, 224, + 91, 153, 155, 53, 48, 240, 133, 28, 180, 44, 121, 102, 82, 70, 28, 17, 130, 155, 91, + 31, 51, 33, 34, 149, 33, 121, 74, 39, 16, 157, 86, 90, 81, 74, 152, 98, 59, 204, 153, + 220, 136, 219, 231, 161, 198, 232, 60, 243, 227, 220, 48, 189, 105, 166, 191, 118, 202, + 76, 193, 226, 21, 183, 54, 157, 96, 84, 1, 235, 248, 25, 18, 152, 102, 177, 171, 246, ]; assert!(expected_ssk == child_node.value.0.secret_spending_key); assert!(expected_ccc == child_node.ccc); - assert!(expected_nsk == child_node.value.0.private_key_holder.nullifier_secret_key); + assert!( + expected_ask + == child_node + .value + .0 + .private_key_holder + .authorization_secret_key + ); + assert!(expected_nsk == child_node.value.0.private_key_holder.nullifier_secret_key()); assert!(expected_npk == child_node.value.0.nullifier_public_key); assert!(expected_vsk == child_node.value.0.private_key_holder.viewing_secret_key); assert!(expected_vpk == child_node.value.0.viewing_public_key.to_bytes()); diff --git a/lee/key_protocol/src/key_management/key_tree/keys_public.rs b/lee/key_protocol/src/key_management/key_tree/keys_public.rs index 4caad0e74..06e7d415d 100644 --- a/lee/key_protocol/src/key_management/key_tree/keys_public.rs +++ b/lee/key_protocol/src/key_management/key_tree/keys_public.rs @@ -20,7 +20,7 @@ pub struct ChildKeysPublic { impl ChildKeysPublic { #[must_use] pub fn root(seed: [u8; 64]) -> Self { - let hash_value = hmac_sha512::HMAC::mac(seed, "LEE_master_pub"); + let hash_value = hmac_sha512::HMAC::mac(seed, "/LEE-Keys/v1/Master/Public"); let (first, cc) = split_hash(&hash_value); let sk = lee::PrivateKey::try_new(first).expect("Expect a valid Private Key"); @@ -120,25 +120,25 @@ mod tests { let keys = ChildKeysPublic::root(SEED); let expected_cc = [ - 238, 94, 84, 154, 56, 224, 80, 218, 133, 249, 179, 222, 9, 24, 17, 252, 120, 127, 222, - 13, 146, 126, 232, 239, 113, 9, 194, 219, 190, 48, 187, 155, + 184, 94, 197, 114, 84, 79, 170, 62, 107, 107, 141, 196, 11, 255, 15, 165, 7, 40, 93, + 211, 244, 153, 12, 70, 10, 174, 141, 69, 117, 167, 165, 81, ]; let expected_sk: PrivateKey = PrivateKey::try_new([ - 40, 35, 239, 19, 53, 178, 250, 55, 115, 12, 34, 3, 153, 153, 72, 170, 190, 36, 172, 36, - 202, 148, 181, 228, 35, 222, 58, 84, 156, 24, 146, 86, + 142, 140, 44, 81, 255, 159, 131, 163, 210, 67, 198, 176, 43, 243, 163, 35, 242, 200, + 232, 99, 69, 240, 63, 16, 33, 104, 8, 152, 243, 153, 180, 169, ]) .unwrap(); let expected_ssk: PrivateKey = PrivateKey::try_new([ - 207, 4, 246, 223, 104, 72, 19, 85, 14, 122, 194, 82, 32, 163, 60, 57, 8, 25, 209, 91, - 254, 107, 76, 238, 31, 68, 236, 192, 154, 78, 105, 118, + 241, 47, 167, 208, 182, 77, 106, 158, 182, 41, 17, 3, 91, 229, 165, 35, 90, 33, 145, + 202, 246, 65, 127, 65, 124, 240, 165, 152, 127, 50, 60, 198, ]) .unwrap(); let expected_pk: PublicKey = PublicKey::try_new([ - 188, 163, 203, 45, 151, 154, 230, 254, 123, 114, 158, 130, 19, 182, 164, 143, 150, 131, - 176, 7, 27, 58, 204, 116, 5, 247, 0, 255, 111, 160, 52, 201, + 43, 138, 92, 79, 223, 49, 90, 162, 205, 76, 143, 151, 96, 77, 10, 85, 179, 208, 244, + 71, 251, 191, 237, 226, 120, 247, 194, 57, 117, 180, 96, 65, ]) .unwrap(); @@ -155,25 +155,25 @@ mod tests { let child_keys = ChildKeysPublic::nth_child(&root_keys, cci); let expected_cc = [ - 149, 226, 13, 4, 194, 12, 69, 29, 9, 234, 209, 119, 98, 4, 128, 91, 37, 103, 192, 31, - 130, 126, 123, 20, 90, 34, 173, 209, 101, 248, 155, 36, + 184, 162, 65, 125, 129, 202, 96, 126, 157, 15, 189, 122, 22, 152, 31, 107, 244, 188, + 215, 30, 70, 205, 164, 142, 6, 152, 106, 147, 160, 1, 168, 168, ]; let expected_sk: PrivateKey = PrivateKey::try_new([ - 9, 65, 33, 228, 25, 82, 219, 117, 91, 217, 11, 223, 144, 85, 246, 26, 123, 216, 107, - 213, 33, 52, 188, 22, 198, 246, 71, 46, 245, 174, 16, 47, + 222, 78, 224, 138, 167, 32, 235, 208, 192, 129, 121, 150, 204, 149, 151, 33, 82, 109, + 238, 245, 20, 106, 70, 126, 120, 66, 165, 169, 241, 242, 224, 10, ]) .unwrap(); let expected_ssk: PrivateKey = PrivateKey::try_new([ - 100, 37, 212, 81, 40, 233, 72, 156, 177, 139, 50, 114, 136, 157, 202, 132, 203, 246, - 252, 242, 13, 81, 42, 100, 159, 240, 187, 252, 202, 108, 25, 105, + 103, 101, 18, 63, 86, 198, 110, 120, 163, 160, 181, 249, 184, 163, 7, 38, 132, 223, 72, + 208, 74, 223, 16, 110, 60, 227, 167, 192, 89, 28, 14, 222, ]) .unwrap(); let expected_pk: PublicKey = PublicKey::try_new([ - 210, 59, 119, 137, 21, 153, 82, 22, 195, 82, 12, 16, 80, 156, 125, 199, 19, 173, 46, - 224, 213, 144, 165, 126, 70, 129, 171, 141, 77, 212, 108, 233, + 107, 153, 105, 58, 6, 157, 131, 253, 141, 130, 168, 182, 82, 2, 99, 26, 211, 22, 55, + 203, 23, 34, 236, 147, 86, 156, 194, 114, 89, 77, 219, 173, ]) .unwrap(); diff --git a/lee/key_protocol/src/key_management/key_tree/mod.rs b/lee/key_protocol/src/key_management/key_tree/mod.rs index 463c757a1..7c27f9930 100644 --- a/lee/key_protocol/src/key_management/key_tree/mod.rs +++ b/lee/key_protocol/src/key_management/key_tree/mod.rs @@ -361,8 +361,8 @@ mod tests { assert!(tree.key_map.contains_key(&ChainIndex::root())); assert!(tree.account_id_map.contains_key(&AccountId::new([ - 10, 231, 159, 65, 236, 46, 205, 5, 172, 89, 250, 29, 123, 195, 212, 137, 155, 111, 40, - 120, 53, 28, 124, 54, 224, 170, 119, 208, 2, 72, 75, 50 + 215, 164, 47, 51, 250, 90, 227, 248, 132, 109, 120, 59, 116, 142, 34, 79, 242, 112, 89, + 142, 210, 12, 183, 217, 160, 19, 169, 147, 203, 173, 172, 105 ]))); } diff --git a/lee/key_protocol/src/key_management/secret_holders.rs b/lee/key_protocol/src/key_management/secret_holders.rs index b8225a4b6..961704731 100644 --- a/lee/key_protocol/src/key_management/secret_holders.rs +++ b/lee/key_protocol/src/key_management/secret_holders.rs @@ -1,6 +1,8 @@ use bip39::Mnemonic; use common::HashType; -use lee_core::{NullifierPublicKey, NullifierSecretKey, encryption::ViewingPublicKey}; +use lee_core::{ + AuthorizationSecretKey, NullifierPublicKey, NullifierSecretKey, encryption::ViewingPublicKey, +}; use ml_kem; use rand::{RngCore as _, rngs::OsRng}; use serde::{Deserialize, Serialize}; @@ -36,7 +38,7 @@ impl ViewingSecretKey { /// for recepient. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct PrivateKeyHolder { - pub nullifier_secret_key: NullifierSecretKey, + pub authorization_secret_key: AuthorizationSecretKey, pub viewing_secret_key: ViewingSecretKey, } @@ -87,43 +89,37 @@ impl SeedHolder { impl SecretSpendingKey { #[must_use] #[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")] - pub fn generate_nullifier_secret_key(&self, index: Option) -> NullifierSecretKey { - const PREFIX: &[u8; 8] = b"LEE/keys"; - const SUFFIX_1: &[u8; 1] = &[1]; - const SUFFIX_2: &[u8; 19] = &[0; 19]; + pub fn generate_authorization_secret_key(&self, index: Option) -> AuthorizationSecretKey { + const DOMAIN: &[u8; 33] = b"/LEE-Keys/v1/Authorization/Secret"; let index = index.unwrap_or(0); let mut hasher = sha2::Sha256::new(); - hasher.update(PREFIX); + hasher.update(DOMAIN); hasher.update(self.0); - hasher.update(SUFFIX_1); hasher.update(index.to_be_bytes()); - hasher.update(SUFFIX_2); - ::from(hasher.finalize_fixed()) + AuthorizationSecretKey(hasher.finalize_fixed().into()) + } + + #[must_use] + pub fn generate_nullifier_secret_key(&self, index: Option) -> NullifierSecretKey { + ::from(&self.generate_authorization_secret_key(index)) } #[must_use] #[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")] pub fn generate_viewing_secret_seed_key(&self, index: Option) -> ViewingSecretKey { - const PREFIX: &[u8; 8] = b"LEE/keys"; - const SUFFIX_1: &[u8; 1] = &[2]; - const SUFFIX_2: &[u8; 19] = &[0; 19]; + const DOMAIN: &[u8; 27] = b"/LEE-Keys/v1/Viewing/Secret"; let index = index.unwrap_or(0); - let mut bytes: Vec = Vec::with_capacity(64); - bytes.extend_from_slice(PREFIX); - bytes.extend_from_slice(&self.0); - bytes.extend_from_slice(SUFFIX_1); - bytes.extend_from_slice(&index.to_be_bytes()); - bytes.extend_from_slice(SUFFIX_2); - let bytes: [u8; 64] = bytes - .try_into() - .expect("`generate_viewing_secret_seed_key`: bytes must be exactly 64"); + let mut bytes = [0_u8; 27 + 32 + 4]; + bytes[..27].copy_from_slice(DOMAIN); + bytes[27..59].copy_from_slice(&self.0); + bytes[59..].copy_from_slice(&index.to_be_bytes()); - let full_seed = hmac_sha512::HMAC::mac(bytes, b"LEE_viewing_seed"); + let full_seed = hmac_sha512::HMAC::mac(bytes, b"/LEE-Keys/v1/Viewing/Seed"); Self::generate_viewing_secret_key(full_seed) } @@ -139,7 +135,7 @@ impl SecretSpendingKey { #[must_use] pub fn produce_private_key_holder(&self, index: Option) -> PrivateKeyHolder { PrivateKeyHolder { - nullifier_secret_key: self.generate_nullifier_secret_key(index), + authorization_secret_key: self.generate_authorization_secret_key(index), viewing_secret_key: self.generate_viewing_secret_seed_key(index), } } @@ -158,9 +154,14 @@ impl From<&ViewingSecretKey> for ViewingPublicKey { } impl PrivateKeyHolder { + #[must_use] + pub fn nullifier_secret_key(&self) -> NullifierSecretKey { + (&self.authorization_secret_key).into() + } + #[must_use] pub fn generate_nullifier_public_key(&self) -> NullifierPublicKey { - (&self.nullifier_secret_key).into() + NullifierPublicKey::from(&self.nullifier_secret_key()) } #[must_use] diff --git a/lee/privacy_preserving_circuit/src/execution_state.rs b/lee/privacy_preserving_circuit/src/execution_state.rs index 334cad7c2..d3eac1db5 100644 --- a/lee/privacy_preserving_circuit/src/execution_state.rs +++ b/lee/privacy_preserving_circuit/src/execution_state.rs @@ -8,9 +8,9 @@ use lee_core::{ account::{Account, AccountId, AccountWithMetadata}, encryption::ViewingPublicKey, program::{ - AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID, - MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow, - validate_execution, + AccountPostState, BlockValidityWindow, CallerData, ChainedCall, Claim, + DEFAULT_PROGRAM_OWNER, MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, + TimestampValidityWindow, validate_execution, }, }; use risc0_zkvm::{guest::env, serde::to_vec}; @@ -51,7 +51,9 @@ pub struct ExecutionState { /// `AccountId::for_private_pda(program_id, seed, npk, vpk, identifier) == /// pre_state.account_id`. private_pda_by_position: HashMap, - authorized_accounts: HashSet, + /// The set containing non-PDA accounts authorized at their first sight, anywhere in the + /// call tree, remaining authorized throughout all calls. + globally_authorized: HashSet, } impl ExecutionState { @@ -112,7 +114,7 @@ impl ExecutionState { private_pda_bound_positions: HashMap::new(), pda_family_binding: HashMap::new(), private_pda_by_position, - authorized_accounts: HashSet::new(), + globally_authorized: HashSet::new(), }; let Some(first_output) = program_outputs.first() else { @@ -125,12 +127,17 @@ impl ExecutionState { pre_states: first_output.pre_states.clone(), pda_seeds: Vec::new(), }; - let mut chained_calls = VecDeque::from_iter([(initial_call, None)]); + let initial_caller_data = CallerData { + program_id: None, + authorized_accounts: HashSet::new(), + }; + let mut chained_calls = + VecDeque::<(ChainedCall, CallerData)>::from_iter([(initial_call, initial_caller_data)]); let mut program_outputs_iter = program_outputs.into_iter(); let mut chain_calls_counter = 0; - while let Some((chained_call, caller_program_id)) = chained_calls.pop_front() { + while let Some((chained_call, caller_data)) = chained_calls.pop_front() { assert!( chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS, "Max chained calls depth is exceeded" @@ -166,7 +173,7 @@ impl ExecutionState { // by spoofing caller_program_id (e.g. passing caller_program_id = self_program_id // to bypass access control checks). assert_eq!( - program_output.caller_program_id, caller_program_id, + program_output.caller_program_id, caller_data.program_id, "Program output caller_program_id does not match actual caller" ); @@ -184,18 +191,25 @@ impl ExecutionState { ); } - for next_call in program_output.chained_calls.iter().rev() { - chained_calls.push_front((next_call.clone(), Some(chained_call.program_id))); - } - - execution_state.validate_and_sync_states( + let authorized_accounts = execution_state.validate_and_sync_states( account_identities, chained_call.program_id, - caller_program_id, + caller_data, &chained_call.pda_seeds, program_output.pre_states, program_output.post_states, ); + + for next_call in program_output.chained_calls.into_iter().rev() { + // Push the call with newly-authorized account set. + chained_calls.push_front(( + next_call, + CallerData { + program_id: Some(chained_call.program_id), + authorized_accounts: authorized_accounts.clone(), + }, + )); + } chain_calls_counter = chain_calls_counter.checked_add(1).expect( "Chain calls counter should not overflow as it checked before incrementing", ); @@ -225,7 +239,7 @@ impl ExecutionState { for (account_id, post) in execution_state .pre_states .iter() - .filter(|a| a.account.program_owner == DEFAULT_PROGRAM_ID) + .filter(|a| a.account.program_owner == DEFAULT_PROGRAM_OWNER) .map(|a| { let post = execution_state .post_states @@ -237,7 +251,7 @@ impl ExecutionState { .map(|(pre, post)| (pre.account_id, post)) { assert_ne!( - post.program_owner, DEFAULT_PROGRAM_ID, + post.program_owner, DEFAULT_PROGRAM_OWNER, "Account {account_id} was modified but not claimed" ); } @@ -246,16 +260,20 @@ impl ExecutionState { } /// Validate program pre and post states and populate the execution state. + /// + /// Return the set of authorized accounts as the result of the processed + /// call. fn validate_and_sync_states( &mut self, account_identities: &[InputAccountIdentity], program_id: ProgramId, - caller_program_id: Option, + caller: CallerData, caller_pda_seeds: &[PdaSeed], output_pre_states: Vec, output_post_states: Vec, - ) { - for (pre, mut post) in output_pre_states.into_iter().zip(output_post_states) { + ) -> HashSet { + let mut authorized_output_accounts = Vec::new(); + for (mut pre, mut post) in output_pre_states.into_iter().zip(output_post_states) { let pre_account_id = pre.account_id; let pre_is_authorized = pre.is_authorized; let post_states_entry = self.post_states.entry(pre.account_id); @@ -278,33 +296,26 @@ impl ExecutionState { "Inconsistent pre state for account {pre_account_id}", ); - let (previous_is_authorized, pre_state_position) = self + let pre_state_position = self .pre_states .iter() - .enumerate() - .find(|(_, acc)| acc.account_id == pre_account_id) - .map_or_else( - || panic!( + .position(|acc| acc.account_id == pre_account_id) + .unwrap_or_else(|| { + panic!( "Pre state must exist in execution state for account {pre_account_id}", - ), - |(pos, acc)| (acc.is_authorized, pos) - ); + ) + }); - let is_authorized = resolve_authorization_and_record_bindings( + assert_authorization_and_record_bindings( &mut self.pda_family_binding, &mut self.private_pda_bound_positions, &self.private_pda_by_position, - &mut self.authorized_accounts, + &self.globally_authorized, + &caller, + caller_pda_seeds, pre_account_id, pre_state_position, - caller_program_id, - caller_pda_seeds, - previous_is_authorized, - ); - - assert_eq!( - pre_is_authorized, is_authorized, - "Inconsistent authorization for account {pre_account_id}", + pre_is_authorized, ); } Entry::Vacant(_) => { @@ -340,10 +351,6 @@ impl ExecutionState { // Subsequent calls need no re-check because the entry is already recorded on // private_pda_bound_positions. if let Some((authority_program_id, seed)) = external_seed { - assert!( - !pre.is_authorized, - "Private PDA with externally-provided seed must not be authorized at position {pre_state_position}" - ); bind_private_pda_position( &mut self.private_pda_bound_positions, pre_state_position, @@ -357,15 +364,55 @@ impl ExecutionState { pre_account_id, ); } + let has_private_pda_witness = self + .private_pda_by_position + .contains_key(&pre_state_position); + if has_private_pda_witness { + assert_authorization_and_record_bindings( + &mut self.pda_family_binding, + &mut self.private_pda_bound_positions, + &self.private_pda_by_position, + &self.globally_authorized, + &caller, + caller_pda_seeds, + pre_account_id, + pre_state_position, + pre_is_authorized, + ); + } + if !has_private_pda_witness + && authorize_first_sight_without_pda_witness( + &mut self.pda_family_binding, + &mut self.globally_authorized, + &caller, + caller_pda_seeds, + pre_account_id, + pre_is_authorized, + ) + { + // authorize_first_sight_without_pda_witness is only true for PDAs + // which will be recorded in output journal. + // + // Since we are in a privacy circuit, the verifier cannot + // replay the transaction to see which public PDAs were + // actually authorized. We mark them false as the + // verifier checks regular account signatures as well. + pre.is_authorized = false; + } self.pre_states.push(pre); } } + // If an account it authorized, push it to the autorized set. + if pre_is_authorized { + authorized_output_accounts.push(pre_account_id); + } + if let Some(claim) = post.required_claim() { // The invoked program can only claim accounts with default program id. assert_eq!( post.account().program_owner, - DEFAULT_PROGRAM_ID, + DEFAULT_PROGRAM_OWNER, "Cannot claim an initialized account {pre_account_id}" ); @@ -439,11 +486,15 @@ impl ExecutionState { } } - post.account_mut().program_owner = program_id; + post.account_mut().program_owner = AccountId::from(program_id); } post_states_entry.insert_entry(post.into_account()); } + + let mut authorized_accounts = caller.authorized_accounts; + authorized_accounts.extend(authorized_output_accounts); + authorized_accounts } /// Consume self and yield the validity windows, the per-position PDA seed/program map @@ -527,66 +578,127 @@ fn bind_private_pda_position( } } -/// Resolve the authorization state of a `pre_state` seen again in a chained call and record -/// any resulting bindings. Returns `true` if the `pre_state` is authorized through either a -/// previously-seen authorization or a matching caller seed (under the public or private -/// derivation). When a caller seed matches, also records the `(caller, seed) โ†’ account_id` -/// family binding and, for the private form, marks the position in -/// `private_pda_bound_positions`. Only reachable when `caller_program_id.is_some()`, -/// top-level flows have no caller-emitted seeds, so binding at top level must come from the -/// claim path. Free function so callers can pass individual `&mut self.*` field borrows -/// without holding a borrow on the surrounding struct's other fields. +/// Match `account_id` against the caller's seeds under the public-PDA derivation. `None` +/// if no appropriate authorization given. +fn match_caller_seed_as_public_pda( + caller: &CallerData, + caller_pda_seeds: &[PdaSeed], + account_id: AccountId, +) -> Option<(PdaSeed, ProgramId)> { + let caller_program_id = caller.program_id?; + // Costy for calls with multiple seeds in one call. + caller_pda_seeds.iter().find_map(|seed| { + if AccountId::for_public_pda(&caller_program_id, seed) == account_id { + return Some((*seed, caller_program_id)); + } + None + }) +} + +/// Match `account_id` against the caller's seeds interpreted as private-PDA derivations, using the +/// (npk, vpk, identifier) supplied for this position. `None` when the position carries no +/// private-PDA witness. +fn match_caller_seed_as_private_pda( + private_pda_by_position: &HashMap, + caller: &CallerData, + caller_pda_seeds: &[PdaSeed], + account_id: AccountId, + pre_state_position: usize, +) -> Option<(PdaSeed, ProgramId)> { + let (npk, vpk, identifier) = private_pda_by_position.get(&pre_state_position)?; + let caller_program_id = caller.program_id?; + // Costy for calls with multiple seeds in one call. + caller_pda_seeds.iter().find_map(|seed| { + if AccountId::for_private_pda(&caller_program_id, seed, npk, vpk, *identifier) == account_id + { + return Some((*seed, caller_program_id)); + } + None + }) +} + +/// Judge a non-private-PDA `pre_state` at its first sighting and resolve its journal mask. +/// +/// Either the account is a public PDA in which case the public mask should be changed, or +/// it is a regular account. For PDAs, we assert the family bindings. For regular accounts, +/// add to global authorization set. +fn authorize_first_sight_without_pda_witness( + pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>, + globally_authorized: &mut HashSet, + caller: &CallerData, + caller_pda_seeds: &[PdaSeed], + pre_account_id: AccountId, + pre_is_authorized: bool, +) -> bool { + if let Some((seed, caller_program_id)) = + match_caller_seed_as_public_pda(caller, caller_pda_seeds, pre_account_id) + { + assert!( + pre_is_authorized, + "Caller-seeded public PDA must be declared authorized at first sight: {pre_account_id}" + ); + assert_family_binding(pda_family_binding, caller_program_id, seed, pre_account_id); + true + } else { + // If an authorized account is a non-PDA one, it is globally authorized. + if pre_is_authorized { + globally_authorized.insert(pre_account_id); + } + false + } +} + +/// When a caller seed matches, also records the `(caller, seed) โ†’ account_id` family binding +/// and, for the private form, marks the position in `private_pda_bound_positions`. Free +/// function so callers can pass individual `&mut self.*` field borrows without holding a borrow +/// on the surrounding struct's other fields. #[expect( clippy::too_many_arguments, reason = "breaking out a context struct does not buy us anything here" )] -fn resolve_authorization_and_record_bindings( +fn assert_authorization_and_record_bindings( pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>, private_pda_bound_positions: &mut HashMap, private_pda_by_position: &HashMap, - authorized_accounts: &mut HashSet, + globally_authorized: &HashSet, + caller: &CallerData, + caller_pda_seeds: &[PdaSeed], pre_account_id: AccountId, pre_state_position: usize, - caller_program_id: Option, - caller_pda_seeds: &[PdaSeed], - previous_is_authorized: bool, -) -> bool { + pre_is_authorized: bool, +) { let matched_caller_seed: Option<(PdaSeed, bool, ProgramId)> = - caller_program_id.and_then(|caller| { - caller_pda_seeds.iter().find_map(|seed| { - if AccountId::for_public_pda(&caller, seed) == pre_account_id { - return Some((*seed, false, caller)); - } - if let Some((npk, vpk, identifier)) = - private_pda_by_position.get(&pre_state_position) - && AccountId::for_private_pda(&caller, seed, npk, vpk, *identifier) - == pre_account_id - { - return Some((*seed, true, caller)); - } - None - }) - }); + match_caller_seed_as_public_pda(caller, caller_pda_seeds, pre_account_id) + .map(|(seed, caller_program_id)| (seed, false, caller_program_id)) + .or_else(|| { + match_caller_seed_as_private_pda( + private_pda_by_position, + caller, + caller_pda_seeds, + pre_account_id, + pre_state_position, + ) + .map(|(seed, caller_program_id)| (seed, true, caller_program_id)) + }); - if let Some((seed, is_private_form, caller)) = matched_caller_seed { - assert_family_binding(pda_family_binding, caller, seed, pre_account_id); + if let Some((seed, is_private_form, caller_program_id)) = matched_caller_seed { + assert_family_binding(pda_family_binding, caller_program_id, seed, pre_account_id); if is_private_form { bind_private_pda_position( private_pda_bound_positions, pre_state_position, - caller, + caller_program_id, seed, ); } } - if authorized_accounts.contains(&pre_account_id) { - return true; - } + let is_authorized = matched_caller_seed.is_some() + || globally_authorized.contains(&pre_account_id) + || caller.authorized_accounts.contains(&pre_account_id); - let authorized = previous_is_authorized || matched_caller_seed.is_some(); - if authorized { - authorized_accounts.insert(pre_account_id); - } - authorized + assert_eq!( + pre_is_authorized, is_authorized, + "Inconsistent authorization for account {pre_account_id}", + ); } diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index 3ff6d9cb4..aa6e4dae5 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -1,8 +1,8 @@ use lee_core::{ Commitment, CommitmentSetDigest, DummyInput, EncryptedAccountData, EncryptionScheme, - EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierSecretKey, - NullifierWitness, PrivacyPreservingCircuitOutput, PrivateAccountKind, PrivateAction, - PrivateWitness, PublicAction, SharedSecretKey, WitnessKind, + EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, + NullifierSecretKey, NullifierWitness, PrivacyPreservingCircuitOutput, PrivateAccountKind, + PrivateAction, PrivateWitness, PublicAction, SharedSecretKey, WitnessKind, account::{Account, AccountId, Nonce}, compute_digest_for_path, encryption::{ViewTag, ViewingPublicKey}, @@ -48,7 +48,7 @@ pub fn compute_circuit_output( nullifier, }) => { let account_id = match kind { - WitnessKind::Regular => { + WitnessKind::Regular { .. } => { let derived = AccountId::for_regular_private_account( &nullifier.npk(), vpk, @@ -66,27 +66,28 @@ pub fn compute_circuit_output( WitnessKind::Pda { .. } => pre_state.account_id, }; - match (kind, nullifier) { - ( - WitnessKind::Regular, - NullifierWitness::Init { .. } | NullifierWitness::Update { .. }, - ) => assert!( + if let WitnessKind::Regular { ask } = kind { + if let Some(ask) = ask { + let derived = NullifierSecretKey::from(ask); + match nullifier { + // Check that the authorization key is actually bound to the + // account Id. + NullifierWitness::Update { nsk, .. } => assert_eq!( + derived, *nsk, + "Authorization secret key does not derive this account's nullifier secret key" + ), + NullifierWitness::Init { npk, .. } => assert_eq!( + NullifierPublicKey::from(&derived), + *npk, + "Authorization secret key does not derive this account's nullifier public key" + ), + } + } + assert_eq!( pre_state.is_authorized, - "Regular private account pre-state must be authorized" - ), - (WitnessKind::Pda { .. }, NullifierWitness::Init { .. }) => assert!( - !pre_state.is_authorized, - "Private PDA init requires unauthorized pre_state" - ), - // With an external seed the binding comes from the circuit input and the - // pre_state is intentionally unauthorized; without one the binding comes from - // a Claim or caller pda_seeds, so the pre_state must already be authorized. - // When `binding` is `Some`, execution_state already asserted - // `!pre_state.is_authorized`. - (WitnessKind::Pda { binding }, NullifierWitness::Update { .. }) => assert!( - pre_state.is_authorized ^ binding.is_some(), - "Private PDA update requires authorized pre_state or external seed" - ), + ask.is_some(), + "Regular private account authorization must match the supplied credential" + ); } let (new_nullifier, new_nonce, view_tag) = match nullifier { @@ -126,7 +127,7 @@ pub fn compute_circuit_output( }; let account_kind = match kind { - WitnessKind::Regular => PrivateAccountKind::Regular(*identifier), + WitnessKind::Regular { .. } => PrivateAccountKind::Regular(*identifier), WitnessKind::Pda { .. } => { let (authority_program_id, seed) = pda_seed_by_position .get(&pos) diff --git a/lee/state_machine/core/src/account.rs b/lee/state_machine/core/src/account.rs index dc8a49a98..0a7f9f145 100644 --- a/lee/state_machine/core/src/account.rs +++ b/lee/state_machine/core/src/account.rs @@ -1,7 +1,4 @@ -use std::{ - fmt::{Display, Write as _}, - str::FromStr, -}; +use std::{fmt::Display, str::FromStr}; use base58::{FromBase58 as _, ToBase58 as _}; use borsh::{BorshDeserialize, BorshSerialize}; @@ -10,7 +7,7 @@ use risc0_zkvm::sha::{Impl, Sha256 as _}; use serde::{Deserialize, Serialize}; use serde_with::{DeserializeFromStr, SerializeDisplay}; -use crate::{NullifierSecretKey, program::ProgramId}; +use crate::NullifierSecretKey; pub mod data; @@ -93,34 +90,15 @@ pub type Balance = u128; /// Account to be used both in public and private contexts. #[derive( - Default, Clone, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, + Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, )] pub struct Account { - pub program_owner: ProgramId, + pub program_owner: AccountId, pub balance: Balance, pub data: Data, pub nonce: Nonce, } -impl std::fmt::Debug for Account { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let program_owner_hex = self - .program_owner - .iter() - .flat_map(|n| n.to_le_bytes()) - .fold(String::new(), |mut acc, bytes| { - write!(acc, "{bytes:02x}").expect("writing to string should not fail"); - acc - }); - f.debug_struct("Account") - .field("program_owner", &program_owner_hex) - .field("balance", &self.balance) - .field("data", &self.data) - .field("nonce", &self.nonce) - .finish() - } -} - #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct AccountWithMetadata { pub account: Account, @@ -243,14 +221,14 @@ mod tests { fn default_program_owner_account_data_creation() { let new_acc = Account::default(); - assert_eq!(new_acc.program_owner, DEFAULT_PROGRAM_ID); + assert_eq!(new_acc.program_owner, DEFAULT_PROGRAM_ID.into()); } #[cfg(feature = "host")] #[test] fn account_with_metadata_constructor() { let account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), balance: 1337, data: b"testing_account_with_metadata_constructor" .to_vec() diff --git a/lee/state_machine/core/src/account/data.rs b/lee/state_machine/core/src/account/data.rs index 272f01915..867c82746 100644 --- a/lee/state_machine/core/src/account/data.rs +++ b/lee/state_machine/core/src/account/data.rs @@ -4,7 +4,15 @@ use borsh::{BorshDeserialize, BorshSerialize}; use bytesize::ByteSize; use serde::{Deserialize, Serialize}; -pub const DATA_MAX_LENGTH: ByteSize = ByteSize::kib(100); +/// Raised from the original 100 KiB to accommodate program elfs stored directly in +/// `Account.data` under the Program-as-Account migration. +/// +/// Observed elfs currently run 375 KB-520 KB, plus 631 KB for the fixed +/// privacy-preserving circuit itself. This value is a rough placeholder, not a considered +/// protocol constant yet โ€” it still needs to be refined against real transaction/block-size +/// budgets (e.g. `SequencerConfig::max_block_size`, currently 1 MiB) before this is something +/// production traffic should rely on. +pub const DATA_MAX_LENGTH: ByteSize = ByteSize::kib(700); #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, BorshSerialize)] pub struct Data(Vec); diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index 1e3b35152..8edacf4f6 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -2,8 +2,8 @@ use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; use crate::{ - Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey, - NullifierSecretKey, + AuthorizationSecretKey, Commitment, CommitmentSetDigest, Identifier, MembershipProof, + Nullifier, NullifierPublicKey, NullifierSecretKey, account::{Account, AccountWithMetadata}, encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey}, program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow}, @@ -48,20 +48,18 @@ pub struct PrivateWitness { pub enum WitnessKind { /// Standalone private account. The `account_id` is derived as /// `AccountId::for_regular_private_account(&npk, vpk, identifier)` and matched against - /// `pre_state.account_id`. - Regular, + /// `pre_state.account_id`. An honest authorized account's `npk` for Id computation gets + /// derived from the supplied `ask`. + Regular { ask: Option }, /// Private PDA. The npk-to-account_id binding is proven upstream via `Claim::Pda(seed)` or a /// caller's `pda_seeds` match. The identifier diversifies the PDA within the /// `(program_id, seed, npk)` family: `AccountId::for_private_pda` uses it as the 4th input. - /// An init is unauthorized; on an update, authorization may be established upstream by a - /// caller `pda_seeds` match or a previously-seen authorization in a chained call. Pda { /// When `Some((authority_program_id, seed))`, the circuit binds this position via the /// external derivation check /// `AccountId::for_private_pda(authority_program_id, seed, npk, vpk, identifier) == /// pre_state.account_id` rather than requiring a `Claim::Pda` or caller - /// `pda_seeds` to establish the binding. The `pre_state` must have `is_authorized - /// == false`. + /// `pda_seeds` to establish the binding. binding: Option<(ProgramId, PdaSeed)>, }, } @@ -220,7 +218,7 @@ mod tests { PublicAction { pre: AccountWithMetadata::new( Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), balance: 12_345_678_901_234_567_890, data: b"test data".to_vec().try_into().unwrap(), nonce: Nonce(0xFFFF_FFFF_FFFF_FFFE), @@ -229,7 +227,7 @@ mod tests { AccountId::new([0; 32]), ), post: Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), balance: 100, data: b"post state data".to_vec().try_into().unwrap(), nonce: Nonce(0xFFFF_FFFF_FFFF_FFFF), @@ -238,7 +236,7 @@ mod tests { PublicAction { pre: AccountWithMetadata::new( Account { - program_owner: [9, 9, 9, 8, 8, 8, 7, 7], + program_owner: [9, 9, 9, 8, 8, 8, 7, 7].into(), balance: 123_123_123_456_456_567_112, data: b"test data".to_vec().try_into().unwrap(), nonce: Nonce(9_999_999_999_999_999_999_999), @@ -247,7 +245,7 @@ mod tests { AccountId::new([1; 32]), ), post: Account { - program_owner: [2, 3, 4, 5, 6, 7, 8, 9], + program_owner: [2, 3, 4, 5, 6, 7, 8, 9].into(), balance: 200, data: b"post state data 2".to_vec().try_into().unwrap(), nonce: Nonce(0xFFFF_FFFF_FFFF_FFFD), diff --git a/lee/state_machine/core/src/commitment.rs b/lee/state_machine/core/src/commitment.rs index bee311f21..bfe737501 100644 --- a/lee/state_machine/core/src/commitment.rs +++ b/lee/state_machine/core/src/commitment.rs @@ -66,9 +66,7 @@ impl Commitment { bytes.extend_from_slice(account_id.value()); let account_bytes_with_hashed_data = { let mut this = Vec::new(); - for word in &account.program_owner { - this.extend_from_slice(&word.to_le_bytes()); - } + this.extend_from_slice(account.program_owner.as_ref()); this.extend_from_slice(&account.balance.to_le_bytes()); this.extend_from_slice(&account.nonce.0.to_le_bytes()); let hashed_data: [u8; 32] = Impl::hash_bytes(&account.data) diff --git a/lee/state_machine/core/src/encoding.rs b/lee/state_machine/core/src/encoding.rs index e9b4a8454..f30b319eb 100644 --- a/lee/state_machine/core/src/encoding.rs +++ b/lee/state_machine/core/src/encoding.rs @@ -21,9 +21,7 @@ impl Account { #[must_use] pub fn to_bytes(&self) -> Vec { let mut bytes = Vec::new(); - for word in &self.program_owner { - bytes.extend_from_slice(&word.to_le_bytes()); - } + bytes.extend_from_slice(self.program_owner.as_ref()); bytes.extend_from_slice(&self.balance.to_le_bytes()); bytes.extend_from_slice(&self.nonce.0.to_le_bytes()); let data_length: u32 = u32::try_from(self.data.len()).expect("Invalid u32"); @@ -37,15 +35,12 @@ impl Account { pub fn from_cursor(cursor: &mut Cursor<&[u8]>) -> Result { use crate::account::{Nonce, data::Data}; - let mut u32_bytes = [0_u8; 4]; let mut u128_bytes = [0_u8; 16]; // program owner - let mut program_owner = [0_u32; 8]; - for word in &mut program_owner { - cursor.read_exact(&mut u32_bytes)?; - *word = u32::from_le_bytes(u32_bytes); - } + let mut program_owner_bytes = [0_u8; 32]; + cursor.read_exact(&mut program_owner_bytes)?; + let program_owner = AccountId::new(program_owner_bytes); // balance cursor.read_exact(&mut u128_bytes)?; @@ -183,7 +178,7 @@ mod tests { #[test] fn enconding() { let account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), balance: 123_456_789_012_345_678_901_234_567_890_123_456, nonce: 42_u128.into(), data: b"hola mundo".to_vec().try_into().unwrap(), @@ -244,7 +239,7 @@ mod tests { #[test] fn account_to_bytes_roundtrip() { let account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), balance: 123_456_789_012_345_678_901_234_567_890_123_456, nonce: 42_u128.into(), data: b"hola mundo".to_vec().try_into().unwrap(), diff --git a/lee/state_machine/core/src/encryption/mod.rs b/lee/state_machine/core/src/encryption/mod.rs index 639404e1f..598dfe57c 100644 --- a/lee/state_machine/core/src/encryption/mod.rs +++ b/lee/state_machine/core/src/encryption/mod.rs @@ -234,7 +234,7 @@ mod tests { let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap(); let account = Account { - program_owner: [12_u32; 8], + program_owner: [12_u32; 8].into(), balance: 999, ..Account::default() }; diff --git a/lee/state_machine/core/src/lib.rs b/lee/state_machine/core/src/lib.rs index f6944ec86..ab7b40f36 100644 --- a/lee/state_machine/core/src/lib.rs +++ b/lee/state_machine/core/src/lib.rs @@ -15,7 +15,9 @@ pub use encryption::{ EncryptedAccountData, EncryptionScheme, EphemeralPublicKey, EphemeralSecretKey, ML_KEM_768_CIPHERTEXT_LEN, SharedSecretKey, ViewTag, }; -pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey}; +pub use nullifier::{ + AuthorizationSecretKey, Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey, +}; pub use program::PrivateAccountKind; pub mod account; diff --git a/lee/state_machine/core/src/nullifier.rs b/lee/state_machine/core/src/nullifier.rs index 755a2adce..f15220673 100644 --- a/lee/state_machine/core/src/nullifier.rs +++ b/lee/state_machine/core/src/nullifier.rs @@ -48,16 +48,29 @@ impl AsRef<[u8]> for NullifierPublicKey { } } +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(any(feature = "host", test), derive(Hash))] +pub struct AuthorizationSecretKey(pub [u8; 32]); + +impl From<&AuthorizationSecretKey> for NullifierSecretKey { + fn from(value: &AuthorizationSecretKey) -> Self { + const DOMAIN: &[u8; 29] = b"/LEE-Keys/v1/Nullifier/Secret"; + let mut bytes = [0_u8; 29 + 32]; + bytes[..29].copy_from_slice(DOMAIN); + bytes[29..].copy_from_slice(&value.0); + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("hash should be exactly 32 bytes long") + } +} + impl From<&NullifierSecretKey> for NullifierPublicKey { fn from(value: &NullifierSecretKey) -> Self { - const PREFIX: &[u8; 8] = b"LEE/keys"; - const SUFFIX_1: &[u8; 1] = &[7]; - const SUFFIX_2: &[u8; 23] = &[0; 23]; - let mut bytes = Vec::new(); - bytes.extend_from_slice(PREFIX); - bytes.extend_from_slice(value); - bytes.extend_from_slice(SUFFIX_1); - bytes.extend_from_slice(SUFFIX_2); + const DOMAIN: &[u8; 29] = b"/LEE-Keys/v1/Nullifier/Public"; + let mut bytes = [0_u8; 29 + 32]; + bytes[..29].copy_from_slice(DOMAIN); + bytes[29..].copy_from_slice(value); Self( Impl::hash_bytes(&bytes) .as_bytes() @@ -154,6 +167,17 @@ mod tests { assert_eq!(nullifier, expected_nullifier); } + #[test] + fn from_authorization_key() { + let ask = AuthorizationSecretKey([0; 32]); + let expected_nsk: NullifierSecretKey = [ + 135, 144, 25, 255, 27, 190, 82, 191, 49, 83, 55, 248, 251, 98, 149, 55, 143, 129, 2, + 201, 237, 77, 248, 237, 15, 11, 188, 41, 219, 213, 10, 74, + ]; + let nsk = NullifierSecretKey::from(&ask); + assert_eq!(nsk, expected_nsk); + } + #[test] fn from_secret_key() { let nsk = [ @@ -161,8 +185,8 @@ mod tests { 196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28, ]; let expected_npk = NullifierPublicKey([ - 78, 20, 20, 5, 177, 198, 233, 100, 175, 134, 174, 200, 24, 205, 68, 215, 130, 74, 35, - 54, 154, 184, 219, 42, 168, 106, 126, 147, 133, 244, 18, 218, + 44, 121, 113, 131, 34, 101, 53, 97, 87, 111, 83, 78, 157, 34, 59, 248, 105, 103, 194, + 137, 127, 221, 25, 17, 105, 84, 114, 129, 183, 83, 168, 193, ]); let npk = NullifierPublicKey::from(&nsk); assert_eq!(npk, expected_npk); @@ -177,8 +201,8 @@ mod tests { let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 242, 239, 57, 244, 89, 109, 65, 201, 223, 100, 43, 87, 205, 83, 148, 161, 176, 22, 208, - 220, 68, 135, 10, 171, 182, 80, 54, 74, 228, 244, 236, 7, + 6, 35, 121, 102, 237, 184, 156, 247, 28, 185, 212, 214, 51, 229, 66, 170, 10, 75, 126, + 12, 93, 139, 88, 61, 65, 246, 230, 184, 223, 232, 252, 124, ]); let account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); @@ -195,8 +219,8 @@ mod tests { let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 149, 125, 157, 109, 119, 81, 9, 163, 231, 181, 214, 43, 57, 113, 221, 72, 180, 149, - 189, 170, 32, 181, 255, 231, 19, 92, 235, 59, 153, 185, 172, 206, + 56, 217, 214, 244, 51, 212, 184, 73, 217, 85, 4, 126, 54, 35, 135, 225, 75, 253, 183, + 19, 96, 182, 189, 138, 62, 101, 131, 30, 2, 236, 157, 235, ]); let account_id = AccountId::for_regular_private_account(&npk, &vpk, 1); @@ -214,8 +238,8 @@ mod tests { let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 30, 232, 222, 201, 233, 125, 124, 194, 58, 39, 121, 96, 185, 84, 168, 109, 80, 111, - 159, 112, 84, 100, 133, 244, 16, 34, 221, 35, 128, 131, 98, 159, + 14, 231, 97, 140, 18, 163, 250, 222, 102, 223, 118, 160, 65, 228, 201, 232, 182, 198, + 230, 213, 216, 143, 78, 95, 163, 95, 32, 1, 20, 240, 97, 95, ]); let account_id = AccountId::for_regular_private_account(&npk, &vpk, identifier); diff --git a/lee/state_machine/core/src/program/mod.rs b/lee/state_machine/core/src/program/mod.rs index 770bcf2db..0a63b129d 100644 --- a/lee/state_machine/core/src/program/mod.rs +++ b/lee/state_machine/core/src/program/mod.rs @@ -11,9 +11,47 @@ use crate::{ }; pub const DEFAULT_PROGRAM_ID: ProgramId = [0; 8]; + +/// TODO: Placeholder `program_owner` for uninitialized `Account`. +pub const DEFAULT_PROGRAM_OWNER: AccountId = AccountId::new([0; 32]); + +/// TODO: Temporary placeholder for program deployment program id; this serves as +/// `program_owner` for program `Account`s. +pub const PROGRAM_STORAGE_OWNER: AccountId = AccountId::new([0xFF; 32]); + pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; pub type ProgramId = [u32; 8]; + +/// Derives the `AccountId` under which a program's data is stored, directly from its +/// `ProgramId`, by reinterpreting the 8 little-endian `u32` words as 32 raw bytes. +/// +/// A 1:1, information-preserving mapping (both types are exactly 32 bytes) rather than a +/// hash โ€” `ProgramId` is already content-derived (RISC0's `image_id`), so no extra domain +/// separation is needed just to use it as a `HashMap` key. +impl From for AccountId { + fn from(program_id: ProgramId) -> Self { + let bytes: Vec = program_id + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect(); + Self::new(bytes.try_into().expect("8 u32 words are exactly 32 bytes")) + } +} + +impl From for ProgramId { + fn from(account_id: AccountId) -> Self { + let mut program_id = [0_u32; 8]; + for (word, chunk) in program_id + .iter_mut() + .zip(account_id.value().chunks_exact(4)) + { + *word = u32::from_le_bytes(chunk.try_into().expect("chunk is exactly 4 bytes")); + } + program_id + } +} + pub type InstructionData = Vec; pub struct ProgramInput { pub self_program_id: ProgramId, @@ -198,6 +236,12 @@ impl AccountId { } } +#[derive(Debug)] +pub struct CallerData { + pub program_id: Option, + pub authorized_accounts: HashSet, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct ChainedCall { /// The program ID of the program to execute. @@ -289,7 +333,7 @@ impl AccountPostState { /// if the account's program owner is the default program ID. #[must_use] pub fn new_claimed_if_default(account: Account, claim: Claim) -> Self { - let is_default_owner = account.program_owner == DEFAULT_PROGRAM_ID; + let is_default_owner = account.program_owner == DEFAULT_PROGRAM_OWNER; Self { account, claim: is_default_owner.then_some(claim), @@ -589,11 +633,11 @@ pub enum ExecutionValidationError { ModifiedProgramOwner { account_id: AccountId }, #[error( - "Trying to decrease balance of account {account_id} owned by {owner_program_id:?} in a program {executing_program_id:?} which is not the owner" + "Trying to decrease balance of account {account_id} owned by {owner_account_id:?} in a program {executing_program_id:?} which is not the owner" )] UnauthorizedBalanceDecrease { account_id: AccountId, - owner_program_id: ProgramId, + owner_account_id: AccountId, executing_program_id: ProgramId, }, @@ -672,6 +716,10 @@ pub fn validate_execution( post_states: &[AccountPostState], executing_program_id: ProgramId, ) -> Result<(), ExecutionValidationError> { + // `program_owner` is `AccountId`-typed; convert once up front rather than at each + // comparison below (see `From for AccountId`'s doc comment). + let executing_account_id = AccountId::from(executing_program_id); + // 1. Check account ids are all different if !validate_uniqueness_of_account_ids(pre_states) { return Err(ExecutionValidationError::PreStateAccountIdsNotUnique); @@ -706,11 +754,11 @@ pub fn validate_execution( // 5. Decreasing balance only allowed if owned by executing program if post.account.balance < pre.account.balance - && account_program_owner != executing_program_id + && account_program_owner != executing_account_id { return Err(ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: pre.account_id, - owner_program_id: account_program_owner, + owner_account_id: account_program_owner, executing_program_id, }); } @@ -719,7 +767,7 @@ pub fn validate_execution( // default values if pre.account.data != post.account.data && pre.account != Account::default() - && account_program_owner != executing_program_id + && account_program_owner != executing_account_id { return Err(ExecutionValidationError::UnauthorizedDataModification { account_id: pre.account_id, @@ -729,7 +777,8 @@ pub fn validate_execution( // 7. If a post state has default program owner, the pre state must have been a default // account - if post.account.program_owner == DEFAULT_PROGRAM_ID && pre.account != Account::default() { + if post.account.program_owner == DEFAULT_PROGRAM_OWNER && pre.account != Account::default() + { return Err( ExecutionValidationError::NonDefaultAccountWithDefaultOwner { account_id: pre.account_id, diff --git a/lee/state_machine/core/src/program/tests.rs b/lee/state_machine/core/src/program/tests.rs index 19a259a81..138545d3f 100644 --- a/lee/state_machine/core/src/program/tests.rs +++ b/lee/state_machine/core/src/program/tests.rs @@ -132,7 +132,7 @@ fn program_output_try_with_block_validity_window_empty_range_fails() { #[test] fn post_state_new_with_claim_constructor() { let account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), balance: 1337, data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), nonce: 10_u128.into(), @@ -147,7 +147,7 @@ fn post_state_new_with_claim_constructor() { #[test] fn post_state_new_without_claim_constructor() { let account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), balance: 1337, data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), nonce: 10_u128.into(), @@ -162,7 +162,7 @@ fn post_state_new_without_claim_constructor() { #[test] fn post_state_account_getter() { let mut account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), balance: 1337, data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), nonce: 10_u128.into(), @@ -339,3 +339,61 @@ fn compute_public_authorized_pdas_no_caller_returns_empty() { let result = compute_public_authorized_pdas(None, &[seed]); assert!(result.is_empty()); } + +#[test] +fn account_id_from_program_id_reinterprets_words_as_le_bytes() { + let program_id: ProgramId = [ + 0x0403_0201, + 0x0807_0605, + 0x0c0b_0a09, + 0x100f_0e0d, + 0x1413_1211, + 0x1817_1615, + 0x1c1b_1a19, + 0x201f_1e1d, + ]; + let expected: [u8; 32] = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31, 32, + ]; + assert_eq!(AccountId::from(program_id).value(), &expected); +} + +#[test] +fn account_id_from_default_program_id_is_default_program_owner() { + assert_eq!(AccountId::from(DEFAULT_PROGRAM_ID), DEFAULT_PROGRAM_OWNER); +} + +#[test] +fn program_id_from_account_id_reinterprets_le_bytes_as_words() { + let account_id = AccountId::new([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, + 26, 27, 28, 29, 30, 31, 32, + ]); + let expected: ProgramId = [ + 0x0403_0201, + 0x0807_0605, + 0x0c0b_0a09, + 0x100f_0e0d, + 0x1413_1211, + 0x1817_1615, + 0x1c1b_1a19, + 0x201f_1e1d, + ]; + assert_eq!(ProgramId::from(account_id), expected); +} + +#[test] +fn program_id_account_id_conversion_round_trips() { + let program_id: ProgramId = [ + 0x1122_3344, + 0x5566_7788, + 0x99aa_bbcc, + 0xddee_ff00, + 0xcafe_babe, + 0xdead_beef, + 0x0bad_f00d, + 0xfeed_face, + ]; + assert_eq!(ProgramId::from(AccountId::from(program_id)), program_id); +} diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index 9886127b1..eceb8f28e 100644 --- a/lee/state_machine/src/lib.rs +++ b/lee/state_machine/src/lib.rs @@ -135,6 +135,30 @@ mod test_methods { ) } + #[must_use] + pub const fn selective_pda_delegator() -> Program { + Program::new_unchecked( + test_methods::SELECTIVE_PDA_DELEGATOR_ID, + Cow::Borrowed(test_methods::SELECTIVE_PDA_DELEGATOR_ELF), + ) + } + + #[must_use] + pub const fn undeclaring_pda_delegator() -> Program { + Program::new_unchecked( + test_methods::UNDECLARING_PDA_DELEGATOR_ID, + Cow::Borrowed(test_methods::UNDECLARING_PDA_DELEGATOR_ELF), + ) + } + + #[must_use] + pub const fn non_delegating_forwarder() -> Program { + Program::new_unchecked( + test_methods::NON_DELEGATING_FORWARDER_ID, + Cow::Borrowed(test_methods::NON_DELEGATING_FORWARDER_ELF), + ) + } + #[must_use] pub const fn pda_claimer() -> Program { Program::new_unchecked( diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs index 9a0bb94ff..80a415e9c 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -15,7 +15,7 @@ use crate::{ program::Program, state::{ CommitmentSet, - tests::{test_private_account_keys_1, test_private_account_keys_2}, + tests::{init_pda_witness, test_private_account_keys_1, test_private_account_keys_2}, }, }; @@ -50,7 +50,7 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() let program = crate::test_methods::simple_balance_transfer(); let sender = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -65,14 +65,14 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() let balance_to_move: u128 = 37; let expected_sender_post = Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100 - balance_to_move, nonce: Nonce::default(), data: Data::default(), }; let expected_recipient_post = Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: balance_to_move, nonce: Nonce::private_account_nonce_init(&recipient_account_id), data: Data::default(), @@ -93,7 +93,9 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -132,7 +134,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { Account { balance: 100, nonce: sender_nonce, - program_owner: program.id(), + program_owner: program.id().into(), data: Data::default(), }, true, @@ -151,7 +153,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { commitment_set.extend(std::slice::from_ref(&commitment_sender)); let expected_new_nullifiers = vec![ ( - Nullifier::for_account_update(&commitment_sender, &sender_keys.nsk), + Nullifier::for_account_update(&commitment_sender, &sender_keys.nsk()), commitment_set.digest(), ), ( @@ -163,13 +165,13 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { let program = crate::test_methods::simple_balance_transfer(); let expected_private_account_1 = Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100 - balance_to_move, - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk()), ..Default::default() }; let expected_private_account_2 = Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: balance_to_move, nonce: Nonce::private_account_nonce_init(&recipient_account_id), ..Default::default() @@ -182,7 +184,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { let esk_1 = EphemeralSecretKey::new( &sender_account_id, &[0; 32], - &sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + &sender_nonce.private_account_nonce_increment(&sender_keys.nsk()), ); let shared_secret_1 = SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &esk_1).0; @@ -199,10 +201,12 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: commitment_set .get_proof_for(&commitment_sender) .expect("sender's commitment must be in the set"), @@ -212,7 +216,9 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -284,7 +290,9 @@ fn init_note_view_tag_is_derived_from_account_keys() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Init { npk: keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -309,7 +317,7 @@ fn update_note_view_tag_is_the_supplied_value() { let identifier: u128 = 99; let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); let account = Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 1, ..Account::default() }; @@ -329,10 +337,12 @@ fn update_note_view_tag_is_the_supplied_value() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: fed_tag, - nsk: keys.nsk, + nsk: keys.nsk(), membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), }, })], @@ -381,7 +391,9 @@ fn circuit_fails_when_chained_validity_windows_have_empty_intersection() { vpk: account_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(account_keys.ask), + }, nullifier: NullifierWitness::Init { npk: account_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -461,16 +473,7 @@ fn private_pda_init() { let result = execute_and_prove( vec![pda_pre], instruction, - vec![InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: 0, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - })], + vec![init_pda_witness(&keys, 0, None)], &program_with_deps, ); @@ -496,7 +499,7 @@ fn private_pda_withdraw() { let recipient_id = AccountId::new([88; 32]); let recipient_pre = AccountWithMetadata::new( Account { - program_owner: simple_transfer.id(), + program_owner: simple_transfer.id().into(), balance: 10000, ..Account::default() }, @@ -515,16 +518,7 @@ fn private_pda_withdraw() { vec![pda_pre, recipient_pre], instruction, vec![ - InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: 0, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - }), + init_pda_witness(&keys, 0, None), InputAccountIdentity::Public, ], &program_with_deps, @@ -550,7 +544,7 @@ fn shared_account_receives_via_simple_transfer() { let sender_id = AccountId::new([99; 32]); let sender = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 1000, ..Account::default() }, @@ -574,7 +568,9 @@ fn shared_account_receives_via_simple_transfer() { vpk: shared_keys.vpk(), random_seed: [0; 32], identifier: shared_identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(shared_keys.ask), + }, nullifier: NullifierWitness::Init { npk: shared_npk, commitment_root: DUMMY_COMMITMENT_HASH, @@ -613,9 +609,11 @@ fn private_authorized_init_encrypts_regular_kind_with_identifier() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Init { - npk: NullifierPublicKey::from(&keys.nsk), + npk: NullifierPublicKey::from(&keys.nsk()), commitment_root: DUMMY_COMMITMENT_HASH, }, })], @@ -653,7 +651,9 @@ fn private_foreign_init_encrypts_regular_kind_with_identifier() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Init { npk: keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -680,11 +680,11 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() { let esk = EphemeralSecretKey::new( &account_id, &[0; 32], - &Nonce::default().private_account_nonce_increment(&keys.nsk), + &Nonce::default().private_account_nonce_increment(&keys.nsk()), ); let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; let account = Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 1, ..Account::default() }; @@ -701,10 +701,12 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: keys.nsk, + nsk: keys.nsk(), membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), }, })], @@ -718,26 +720,236 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() { ); } -/// A private-PDA update with a non-default identifier produces a ciphertext that decrypts -/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`. +/// Builds an on-chain regular private account owned by `program`, returning its id, pre-state +/// and a membership proof for its commitment. +fn seeded_regular_account( + keys: &crate::state::tests::TestPrivateKeys, + program: &Program, + identifier: u128, +) -> (AccountId, AccountWithMetadata, lee_core::MembershipProof) { + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let account = Account { + program_owner: program.id().into(), + balance: 1, + ..Account::default() + }; + let commitment = Commitment::new(&account_id, &account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&commitment)); + let proof = commitment_set.get_proof_for(&commitment).unwrap(); + ( + account_id, + AccountWithMetadata::new(account, false, account_id), + proof, + ) +} + +/// Spending without consenting. The witness carries no `ask`, so the pre-state is unauthorized, +/// and the nullifier is still produced from the `nsk`. #[test] -fn private_pda_update_encrypts_pda_kind_with_identifier() { +fn private_regular_update_without_ask_is_spendable() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let (_, pre, membership_proof) = seeded_regular_account(&keys, &program, 0); + assert!(!pre.is_authorized); + + execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Update { + view_tag: 0, + nsk: keys.nsk(), + membership_proof, + }, + })], + &program.into(), + ) + .unwrap(); +} + +/// Claiming authorization without supplying an `ask` is rejected. +#[test] +fn private_regular_witness_without_ask_cannot_assert_authorization() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let (account_id, pre, membership_proof) = seeded_regular_account(&keys, &program, 0); + let pre = AccountWithMetadata::new(pre.account, true, account_id); + + let result = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Update { + view_tag: 0, + nsk: keys.nsk(), + membership_proof, + }, + })], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// An `ask` that does not derive this account's `nsk` is not a credential for it. +#[test] +fn regular_update_with_wrong_ask_nsk_is_rejected() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let foreign = test_private_account_keys_2(); + let (account_id, pre, membership_proof) = seeded_regular_account(&keys, &program, 0); + let pre = AccountWithMetadata::new(pre.account, true, account_id); + + let result = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { + ask: Some(foreign.ask), + }, + nullifier: NullifierWitness::Update { + view_tag: 0, + nsk: keys.nsk(), + membership_proof, + }, + })], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// An `ask` that does not derive this account's `npk` is not a credential for it. +#[test] +fn regular_init_with_non_chaining_ask_npk_is_rejected() { + let program = crate::test_methods::claimer(); + let keys = test_private_account_keys_1(); + let foreign = test_private_account_keys_2(); + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0); + let pre = AccountWithMetadata::new(Account::default(), true, account_id); + + let result = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { + ask: Some(foreign.ask), + }, + nullifier: NullifierWitness::Init { + npk: keys.npk(), + commitment_root: DUMMY_COMMITMENT_HASH, + }, + })], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn unauthorized_private_init_can_be_claimed() { + let program = crate::test_methods::claimer(); + let program_id = program.id(); + let keys = test_private_account_keys_1(); + let recipient_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0); + let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id); + let esk = EphemeralSecretKey::new( + &recipient_id, + &[0; 32], + &Nonce::private_account_nonce_init(&recipient_id), + ); + let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; + + let (output, _) = execute_and_prove( + vec![recipient], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Init { + npk: keys.npk(), + commitment_root: DUMMY_COMMITMENT_HASH, + }, + })], + &program.into(), + ) + .unwrap(); + + let (_, claimed) = EncryptionScheme::decrypt( + &output.private_actions[0].encrypted_post_state.ciphertext, + &ssk, + &output.private_actions[0].nullifier, + ) + .unwrap(); + assert_eq!(claimed.program_owner, program_id.into()); +} + +/// A program that asserts authorization over its pre-states rejects a regular private account +/// whose witness supplied no `ask`. +#[test] +fn auth_asserting_program_rejects_unauthorized_regular_private_account() { + let program = crate::test_methods::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let (_, pre, membership_proof) = seeded_regular_account(&keys, &program, 0); + + let result = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Update { + view_tag: 0, + nsk: keys.nsk(), + membership_proof, + }, + })], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::ProgramProveFailed(_)))); +} + +/// Root-call private-PDA update attempt: `pda_spend_proxy` spends a PDA it owns via +/// `simple_balance_transfer`. +fn pda_update_attempt( + declare_authorized: bool, + derivation_identifier: u128, + witness_identifier: u128, +) -> Result { let program = crate::test_methods::pda_spend_proxy(); let simple_transfer = crate::test_methods::simple_balance_transfer(); let keys = test_private_account_keys_1(); - let npk = keys.npk(); let seed = PdaSeed::new([42; 32]); - let identifier: u128 = 99; let simple_transfer_id = simple_transfer.id(); - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier); - let esk = EphemeralSecretKey::new( - &pda_id, - &[0; 32], - &Nonce::default().private_account_nonce_increment(&keys.nsk), + let pda_id = AccountId::for_private_pda( + &program.id(), + &seed, + &keys.npk(), + &keys.vpk(), + derivation_identifier, ); - let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; let pda_account = Account { - program_owner: simple_transfer_id, + program_owner: simple_transfer_id.into(), balance: 1, ..Account::default() }; @@ -745,26 +957,24 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() { let mut commitment_set = CommitmentSet::with_capacity(1); commitment_set.extend(std::slice::from_ref(&pda_commitment)); - let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id); + let pda_pre = AccountWithMetadata::new(pda_account, declare_authorized, pda_id); let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); - let program_with_deps = ProgramWithDependencies::new( - program.clone(), - [(simple_transfer_id, simple_transfer)].into(), - ); + let program_with_deps = + ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into()); - let (output, _) = execute_and_prove( + execute_and_prove( vec![pda_pre, recipient_pre], - Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(), + Program::serialize_instruction((seed, 1_u128, simple_transfer_id)).unwrap(), vec![ InputAccountIdentity::Private(PrivateWitness { vpk: keys.vpk(), random_seed: [0; 32], - identifier, + identifier: witness_identifier, kind: WitnessKind::Pda { binding: None }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: keys.nsk, + nsk: keys.nsk(), membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), }, }), @@ -772,18 +982,46 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() { ], &program_with_deps, ) - .unwrap(); + .map(|(output, _proof)| output) +} +/// A private-PDA update with a non-default identifier produces a ciphertext that decrypts +/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`. +#[test] +fn private_pda_update_encrypts_pda_kind_with_identifier() { + let program_id = crate::test_methods::pda_spend_proxy().id(); + let keys = test_private_account_keys_1(); + let seed = PdaSeed::new([42; 32]); + let identifier: u128 = 99; + + let output = pda_update_attempt(false, identifier, identifier) + .expect("a well-formed private PDA update must prove"); + + let pda_id = + AccountId::for_private_pda(&program_id, &seed, &keys.npk(), &keys.vpk(), identifier); + let esk = EphemeralSecretKey::new( + &pda_id, + &[0; 32], + &Nonce::default().private_account_nonce_increment(&keys.nsk()), + ); + let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; assert_eq!( decrypt_kind(&output, &ssk, 0), PrivateAccountKind::Pda { - program_id: program.id(), + program_id, seed, identifier }, ); } +#[test] +fn private_pda_update_at_root_call_may_not_declare_authorization() { + let result = pda_update_attempt(true, 99, 99); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + #[test] fn private_pda_init_identifier_mismatch_fails() { let program = crate::test_methods::pda_claimer(); @@ -791,6 +1029,27 @@ fn private_pda_init_identifier_mismatch_fails() { let npk = keys.npk(); let seed = PdaSeed::new([42; 32]); let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![init_pda_witness(&keys, 99, None)], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn private_pda_init_at_root_call_may_not_declare_authorization() { + let program = crate::test_methods::pda_claimer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let identifier: u128 = 5; + let account_id = + AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier); let pre_state = AccountWithMetadata::new(Account::default(), true, account_id); let result = execute_and_prove( @@ -799,7 +1058,7 @@ fn private_pda_init_identifier_mismatch_fails() { vec![InputAccountIdentity::Private(PrivateWitness { vpk: keys.vpk(), random_seed: [0; 32], - identifier: 99, + identifier, kind: WitnessKind::Pda { binding: None }, nullifier: NullifierWitness::Init { npk, @@ -814,47 +1073,7 @@ fn private_pda_init_identifier_mismatch_fails() { #[test] fn private_pda_update_identifier_mismatch_fails() { - let program = crate::test_methods::pda_spend_proxy(); - let simple_transfer = crate::test_methods::simple_balance_transfer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let simple_transfer_id = simple_transfer.id(); - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5); - let pda_account = Account { - program_owner: simple_transfer_id, - balance: 1, - ..Account::default() - }; - let pda_commitment = Commitment::new(&pda_id, &pda_account); - let mut commitment_set = CommitmentSet::with_capacity(1); - commitment_set.extend(std::slice::from_ref(&pda_commitment)); - - let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id); - let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); - - let program_with_deps = - ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into()); - - let result = execute_and_prove( - vec![pda_pre, recipient_pre], - Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(), - vec![ - InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: 99, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Update { - view_tag: 0, - nsk: keys.nsk, - membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), - }, - }), - InputAccountIdentity::Public, - ], - &program_with_deps, - ); + let result = pda_update_attempt(false, 5, 99); assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); } 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/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs index 8b91b6983..79e7d2c0c 100644 --- a/lee/state_machine/src/state/mod.rs +++ b/lee/state_machine/src/state/mod.rs @@ -4,8 +4,8 @@ use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier, Timestamp, - account::{Account, AccountId}, - program::ProgramId, + account::{Account, AccountId, Data}, + program::{PROGRAM_STORAGE_OWNER, ProgramId}, }; use crate::{ @@ -114,7 +114,6 @@ impl BorshDeserialize for NullifierSet { pub struct V03State { public_state: HashMap, private_state: (CommitmentSet, NullifierSet), - programs: HashMap, } impl Default for V03State { @@ -127,7 +126,6 @@ impl Default for V03State { Self { public_state: HashMap::default(), private_state, - programs: HashMap::default(), } } } @@ -190,13 +188,20 @@ impl V03State { #[must_use] pub fn with_programs(mut self, programs: impl IntoIterator) -> Self { for program in programs { - self.insert_program(program); + self.insert_program(&program); } self } - pub(crate) fn insert_program(&mut self, program: Program) { - self.programs.insert(program.id(), program); + pub(crate) fn insert_program(&mut self, program: &Program) { + let account_id = AccountId::from(program.id()); + let account = Account { + program_owner: PROGRAM_STORAGE_OWNER, + data: Data::try_from(program.elf().to_vec()) + .expect("elf must fit under DATA_MAX_LENGTH"), + ..Account::default() + }; + self.public_state.insert(account_id, account); } pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) { @@ -222,7 +227,7 @@ impl V03State { self.private_state.0.extend(&new_commitments); self.private_state.1.extend(&new_nullifiers); if let Some(program) = program { - self.insert_program(program); + self.insert_program(&program); } } @@ -276,22 +281,30 @@ impl V03State { self.public_state.get(&account_id) } + /// Looks up a deployed program's storage account by its `ProgramId`, verifying it is + /// actually owned by [`PROGRAM_STORAGE_OWNER`]. + /// + /// An account at `AccountId::from(program_id)` that lacks this ownership isn't a deployed + /// program, whatever its contents โ€” this is the single place that distinction is enforced, + /// so callers never have to remember to re-check it themselves. + #[must_use] + pub fn get_program(&self, program_id: ProgramId) -> Option<&Account> { + let account = self.get_account_by_id_ref(AccountId::from(program_id))?; + (account.program_owner == PROGRAM_STORAGE_OWNER).then_some(account) + } + #[must_use] pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option { self.private_state.0.get_proof_for(commitment) } - pub(crate) const fn programs(&self) -> &HashMap { - &self.programs - } - #[must_use] pub fn commitment_set_digest(&self) -> CommitmentSetDigest { self.private_state.0.digest() } - /// Order-independent fingerprint of the genesis-relevant state: the public - /// account set, the deployed program set, and the commitment-set digest. + /// Order-independent fingerprint of the genesis-relevant state: the public account set + /// (which includes deployed programs' storage accounts) and the commitment-set digest. /// /// The sequencer and the indexer build the directly-seeded part of genesis /// (base builtins plus any directly-seeded accounts) separately from their own @@ -308,17 +321,11 @@ impl V03State { let Self { public_state, private_state, - programs, } = self; let mut accounts: Vec<(&AccountId, &Account)> = public_state.iter().collect(); accounts.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref())); - - let mut program_ids: Vec = programs.keys().copied().collect(); - program_ids.sort_unstable(); - let account_count = u64::try_from(accounts.len()).expect("account count fits in u64"); - let program_count = u64::try_from(program_ids.len()).expect("program count fits in u64"); let mut hasher = Sha256::new(); hasher.update(account_count.to_le_bytes()); @@ -329,12 +336,6 @@ impl V03State { hasher.update(len.to_le_bytes()); hasher.update(&bytes); } - hasher.update(program_count.to_le_bytes()); - for id in program_ids { - for word in id { - hasher.update(word.to_le_bytes()); - } - } hasher.update(private_state.0.digest()); let mut out = [0_u8; 32]; diff --git a/lee/state_machine/src/state/tests/authenticated_transfer.rs b/lee/state_machine/src/state/tests/authenticated_transfer.rs index 8d227fc30..28e048e7c 100644 --- a/lee/state_machine/src/state/tests/authenticated_transfer.rs +++ b/lee/state_machine/src/state/tests/authenticated_transfer.rs @@ -7,7 +7,7 @@ fn transition_from_authenticated_transfer_program_invocation_default_account_des let initial_data = [( account_id, Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, ..Account::default() }, @@ -64,7 +64,7 @@ fn transition_from_authenticated_transfer_program_invocation_non_default_account ( account_id1, Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, ..Account::default() }, @@ -72,7 +72,7 @@ fn transition_from_authenticated_transfer_program_invocation_non_default_account ( account_id2, Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 200, ..Account::default() }, @@ -106,7 +106,7 @@ fn transition_from_sequence_of_authenticated_transfer_program_invocations() { let initial_data = [( account_id1, Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, ..Account::default() }, diff --git a/lee/state_machine/src/state/tests/changer_claimer.rs b/lee/state_machine/src/state/tests/changer_claimer.rs index 16b3872ee..b422d1199 100644 --- a/lee/state_machine/src/state/tests/changer_claimer.rs +++ b/lee/state_machine/src/state/tests/changer_claimer.rs @@ -75,10 +75,12 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, })], @@ -109,10 +111,12 @@ fn private_changer_claimer_data_change_no_claim_fails() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, })], diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs index f71f9e097..7ba63f7b3 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -5,7 +5,7 @@ fn circuit_fails_if_visibility_masks_have_incorrect_lenght() { let program = crate::test_methods::simple_balance_transfer(); let public_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -14,7 +14,7 @@ fn circuit_fails_if_visibility_masks_have_incorrect_lenght() { ); let public_account_2 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -40,7 +40,7 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() { let recipient_keys = test_private_account_keys_2(); let private_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -65,10 +65,12 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: recipient_keys.nsk, + nsk: recipient_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -76,7 +78,9 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() { vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -96,7 +100,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid let recipient_keys = test_private_account_keys_2(); let private_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -121,10 +125,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -132,7 +138,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -152,7 +160,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ let recipient_keys = test_private_account_keys_2(); let private_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -162,7 +170,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ let private_account_2 = AccountWithMetadata::new( Account { // Non default program_owner - program_owner: [0, 1, 2, 3, 4, 5, 6, 7], + program_owner: [0, 1, 2, 3, 4, 5, 6, 7].into(), ..Account::default() }, true, @@ -177,10 +185,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -188,7 +198,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -208,7 +220,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided( let recipient_keys = test_private_account_keys_2(); let private_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -233,10 +245,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided( vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -244,7 +258,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided( vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -264,7 +280,7 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided let recipient_keys = test_private_account_keys_2(); let private_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -289,10 +305,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -300,7 +318,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -321,7 +341,7 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu let recipient_keys = test_private_account_keys_2(); let private_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -343,10 +363,12 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -354,7 +376,9 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -375,10 +399,9 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu fn private_pda_without_binding_fails() { let program = crate::test_methods::simple_balance_transfer(); let keys = test_private_account_keys_1(); - let npk = keys.npk(); let public_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -393,16 +416,7 @@ fn private_pda_without_binding_fails() { Program::serialize_instruction(10_u128).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: u128::MAX, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - }), + init_pda_witness(&keys, u128::MAX, None), ], &program.into(), ); @@ -428,16 +442,7 @@ fn private_pda_claim_succeeds() { let result = execute_and_prove( vec![pre_state], Program::serialize_instruction(seed).unwrap(), - vec![InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: u128::MAX, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - })], + vec![init_pda_witness(&keys, u128::MAX, None)], &program.into(), ); @@ -456,7 +461,6 @@ fn private_pda_npk_mismatch_fails() { let keys_a = test_private_account_keys_1(); let keys_b = test_private_account_keys_2(); let npk_a = keys_a.npk(); - let npk_b = keys_b.npk(); let seed = PdaSeed::new([42; 32]); // `account_id` is derived from `npk_a`, but `npk_b` is supplied for this pre_state. @@ -469,16 +473,7 @@ fn private_pda_npk_mismatch_fails() { let result = execute_and_prove( vec![pre_state], Program::serialize_instruction(seed).unwrap(), - vec![InputAccountIdentity::Private(PrivateWitness { - vpk: keys_b.vpk(), - random_seed: [0; 32], - identifier: u128::MAX, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk: npk_b, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - })], + vec![init_pda_witness(&keys_b, u128::MAX, None)], &program.into(), ); @@ -508,16 +503,7 @@ fn caller_pda_seeds_authorize_private_pda_for_callee() { let result = execute_and_prove( vec![pre_state], Program::serialize_instruction((seed, seed, callee_id)).unwrap(), - vec![InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: u128::MAX, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - })], + vec![init_pda_witness(&keys, u128::MAX, None)], &program_with_deps, ); @@ -549,22 +535,411 @@ fn caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee() { let result = execute_and_prove( vec![pre_state], Program::serialize_instruction((claim_seed, wrong_delegated_seed, callee_id)).unwrap(), - vec![InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: u128::MAX, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - })], + vec![init_pda_witness(&keys, u128::MAX, None)], &program_with_deps, ); assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); } +fn sibling_declaring_delegated_pda(pda_is_authorized: bool) -> Result<(), LeeError> { + let delegator = crate::test_methods::selective_pda_delegator(); + let callee = crate::test_methods::auth_asserting_noop(); + let sibling = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([77; 32]); + + let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, &keys.vpk(), 0); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let callee_id = callee.id(); + let sibling_id = sibling.id(); + let program_with_deps = ProgramWithDependencies::new( + delegator, + [(callee_id, callee), (sibling_id, sibling)].into(), + ); + + execute_and_prove( + vec![pre_state], + Program::serialize_instruction(( + seed, + seed, + callee_id, + Program::serialize_instruction(()).unwrap(), + Some((sibling_id, Some(pda_is_authorized))), + )) + .unwrap(), + vec![init_pda_witness(&keys, 0, None)], + &program_with_deps, + ) + .map(|_| ()) +} + +#[test] +fn delegated_pda_is_not_authorized_in_sibling_call() { + let result = sibling_declaring_delegated_pda(true); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn sibling_call_may_declare_delegated_pda_unauthorized() { + sibling_declaring_delegated_pda(false) + .expect("a sibling declaring the delegated PDA unauthorized must be accepted"); +} + +#[test] +fn delegated_pda_stays_authorized_in_delegated_subtree() { + let delegator = crate::test_methods::selective_pda_delegator(); + let forwarder = crate::test_methods::non_delegating_forwarder(); + let callee = crate::test_methods::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([77; 32]); + + let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, &keys.vpk(), 0); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let forwarder_id = forwarder.id(); + let callee_id = callee.id(); + let program_with_deps = ProgramWithDependencies::new( + delegator, + [(forwarder_id, forwarder), (callee_id, callee)].into(), + ); + let no_sibling: Option<(ProgramId, Option)> = None; + + execute_and_prove( + vec![pre_state], + Program::serialize_instruction(( + seed, + seed, + forwarder_id, + Program::serialize_instruction(( + callee_id, + Program::serialize_instruction(()).unwrap(), + true, + )) + .unwrap(), + no_sibling, + )) + .unwrap(), + vec![init_pda_witness(&keys, 0, None)], + &program_with_deps, + ) + .expect("a callee that forwards without re-delegating must keep the PDA authorized"); +} + +#[test] +fn holder_authorization_survives_across_sibling_calls() { + let delegator = crate::test_methods::selective_pda_delegator(); + let callee = crate::test_methods::auth_asserting_noop(); + let sibling = crate::test_methods::noop(); + let pda_keys = test_private_account_keys_1(); + let holder_keys = test_private_account_keys_2(); + let npk = pda_keys.npk(); + let holder_npk = holder_keys.npk(); + let seed = PdaSeed::new([77; 32]); + + let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, &pda_keys.vpk(), 0); + let holder_id = AccountId::for_regular_private_account(&holder_npk, &holder_keys.vpk(), 0); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + let holder_pre_state = AccountWithMetadata::new(Account::default(), true, holder_id); + + let callee_id = callee.id(); + let sibling_id = sibling.id(); + let program_with_deps = ProgramWithDependencies::new( + delegator, + [(callee_id, callee), (sibling_id, sibling)].into(), + ); + + execute_and_prove( + vec![pre_state, holder_pre_state], + Program::serialize_instruction(( + seed, + seed, + callee_id, + Program::serialize_instruction(()).unwrap(), + Some((sibling_id, None::)), + )) + .unwrap(), + vec![ + init_pda_witness(&pda_keys, 0, None), + InputAccountIdentity::Private(PrivateWitness { + vpk: holder_keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { + ask: Some(holder_keys.ask), + }, + nullifier: NullifierWitness::Init { + npk: holder_npk, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + }), + ], + &program_with_deps, + ) + .expect("an account authorized by its own credential stays authorized in a sibling call"); +} + +#[test] +fn inherited_scope_passes_through_intermediate_calls() { + let delegator = crate::test_methods::selective_pda_delegator(); + let forwarder = crate::test_methods::non_delegating_forwarder(); + let callee = crate::test_methods::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([77; 32]); + + let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, &keys.vpk(), 0); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let forwarder_id = forwarder.id(); + let callee_id = callee.id(); + let program_with_deps = ProgramWithDependencies::new( + delegator, + [(forwarder_id, forwarder), (callee_id, callee)].into(), + ); + let no_sibling: Option<(ProgramId, Option)> = None; + let forward_through_undeclaring_call = Program::serialize_instruction(( + forwarder_id, + Program::serialize_instruction(( + callee_id, + Program::serialize_instruction(()).unwrap(), + false, + )) + .unwrap(), + true, + )) + .unwrap(); + + execute_and_prove( + vec![pre_state], + Program::serialize_instruction(( + seed, + seed, + forwarder_id, + forward_through_undeclaring_call, + no_sibling, + )) + .unwrap(), + vec![init_pda_witness(&keys, 0, None)], + &program_with_deps, + ) + .expect( + "an account authorized in an ancestor's output stays authorized below a call that never mentions it", + ); +} + +fn undeclaring_private_delegation( + delegated: bool, + external_binding: bool, + declare_authorized: bool, + callee: Program, +) -> Result<(), LeeError> { + let delegator = crate::test_methods::undeclaring_pda_delegator(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([77; 32]); + + let delegator_id = delegator.id(); + let account_id = AccountId::for_private_pda(&delegator_id, &seed, &npk, &keys.vpk(), 0); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let callee_id = callee.id(); + let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); + + execute_and_prove( + vec![pre_state], + Program::serialize_instruction(( + delegated.then_some(seed), + declare_authorized, + callee_id, + Program::serialize_instruction(()).unwrap(), + None::, + )) + .unwrap(), + vec![init_pda_witness( + &keys, + 0, + external_binding.then_some((delegator_id, seed)), + )], + &program_with_deps, + ) + .map(|_| ()) +} + +#[test] +fn delegated_private_pda_first_seen_in_callee_is_authorized() { + undeclaring_private_delegation(true, true, true, crate::test_methods::auth_asserting_noop()) + .expect("a caller's pda_seeds must authorize a private PDA it delegates at first sight"); +} + +#[test] +fn caller_seeds_bind_a_private_pda_first_seen_in_the_callee() { + undeclaring_private_delegation( + true, + false, + true, + crate::test_methods::auth_asserting_noop(), + ) + .expect("a caller's pda_seeds must bind a private PDA it delegates at first sight"); +} + +#[test] +fn undelegated_private_pda_in_a_callee_may_not_declare_authorization() { + let result = undeclaring_private_delegation( + false, + true, + true, + crate::test_methods::auth_asserting_noop(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn granted_private_pda_may_not_be_declared_unauthorized_at_first_sight() { + // `noop` tolerates unauthorized pre_states during host-side execution, so the only + // rejector left is the first-sight consistency assert on the granted edge. + let result = undeclaring_private_delegation(true, true, false, crate::test_methods::noop()); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +fn undeclaring_public_delegation( + account_id: AccountId, + delegated_seed: Option, + declare_authorized: bool, + callee: Program, + with_sibling: bool, +) -> Result { + let delegator = crate::test_methods::undeclaring_pda_delegator(); + let sibling = crate::test_methods::noop(); + + let pre_state = AccountWithMetadata::new( + Account { + program_owner: delegator.id().into(), + ..Account::default() + }, + false, + account_id, + ); + + let callee_id = callee.id(); + let sibling_id = sibling.id(); + let program_with_deps = ProgramWithDependencies::new( + delegator, + [(callee_id, callee), (sibling_id, sibling)].into(), + ); + + execute_and_prove( + vec![pre_state], + Program::serialize_instruction(( + delegated_seed, + declare_authorized, + callee_id, + Program::serialize_instruction(()).unwrap(), + with_sibling.then_some(sibling_id), + )) + .unwrap(), + vec![InputAccountIdentity::Public], + &program_with_deps, + ) + .map(|(output, _proof)| output) +} + +#[test] +fn delegated_public_pda_first_seen_in_callee_is_authorized() { + let seed = PdaSeed::new([77; 32]); + let delegator_id = crate::test_methods::undeclaring_pda_delegator().id(); + let pda = AccountId::for_public_pda(&delegator_id, &seed); + + let output = undeclaring_public_delegation( + pda, + Some(seed), + true, + crate::test_methods::auth_asserting_noop(), + false, + ) + .expect("a caller's pda_seeds must authorize a public PDA it delegates at first sight"); + + // The callee ran with the PDA authorized (auth_asserting_noop did not panic), while + // the journal exports the credential view: a seed grant is not a signer-backed claim. + assert_eq!(output.public_actions.len(), 1); + assert_eq!(output.public_actions[0].pre.account_id, pda); + assert!(!output.public_actions[0].pre.is_authorized); +} + +#[test] +fn granted_public_pda_may_not_be_declared_unauthorized_at_first_sight() { + let seed = PdaSeed::new([77; 32]); + let delegator_id = crate::test_methods::undeclaring_pda_delegator().id(); + let pda = AccountId::for_public_pda(&delegator_id, &seed); + + // `noop` tolerates unauthorized pre_states, so the only rejector left is the + // first-sight consistency assert on the granted edge. + let result = + undeclaring_public_delegation(pda, Some(seed), false, crate::test_methods::noop(), false); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn public_pda_first_sight_grant_does_not_extend_to_sibling_calls() { + let seed = PdaSeed::new([77; 32]); + let delegator_id = crate::test_methods::undeclaring_pda_delegator().id(); + let pda = AccountId::for_public_pda(&delegator_id, &seed); + + let result = undeclaring_public_delegation( + pda, + Some(seed), + true, + crate::test_methods::auth_asserting_noop(), + true, + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn public_account_first_sight_authorization_is_exported_to_the_journal() { + let seed = PdaSeed::new([77; 32]); + + let output = undeclaring_public_delegation( + AccountId::new([9; 32]), + Some(seed), + true, + crate::test_methods::auth_asserting_noop(), + false, + ) + .expect("a first-sight authorization claim on a plain public account must prove"); + + assert!(output.public_actions[0].pre.is_authorized); +} + +#[test] +fn wrong_seed_public_pda_first_sight_is_exported_as_credential_claim() { + let seed = PdaSeed::new([77; 32]); + let wrong_seed = PdaSeed::new([88; 32]); + let delegator_id = crate::test_methods::undeclaring_pda_delegator().id(); + let pda = AccountId::for_public_pda(&delegator_id, &seed); + + let output = undeclaring_public_delegation( + pda, + Some(wrong_seed), + true, + crate::test_methods::auth_asserting_noop(), + false, + ) + .expect("an unmatched seed must fall back to the credential-claim path"); + + // In-circuit this is indistinguishable from a signer's claim; the exported `true` + // is what the verifier audits (and rejects โ€” the id is not a signer). + assert!(output.public_actions[0].pre.is_authorized); +} + /// Exploit-scenario pin. A single `(program_id, seed)` pair can derive a family of /// `AccountId`s, one public PDA and one private PDA per distinct npk. Without the tx-wide /// family-binding check, a program could claim `PDA_alice` (`alice_npk`) and @@ -602,26 +977,8 @@ fn two_private_pda_claims_under_same_seed_are_rejected() { vec![pre_a, pre_b], Program::serialize_instruction(seed).unwrap(), vec![ - InputAccountIdentity::Private(PrivateWitness { - vpk: keys_a.vpk(), - random_seed: [0; 32], - identifier: u128::MAX, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk: keys_a.npk(), - commitment_root: DUMMY_COMMITMENT_HASH, - }, - }), - InputAccountIdentity::Private(PrivateWitness { - vpk: keys_b.vpk(), - random_seed: [0; 32], - identifier: u128::MAX, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk: keys_b.npk(), - commitment_root: DUMMY_COMMITMENT_HASH, - }, - }), + init_pda_witness(&keys_a, u128::MAX, None), + init_pda_witness(&keys_b, u128::MAX, None), ], &program.into(), ); @@ -641,31 +998,20 @@ fn private_pda_top_level_reuse_rejected_by_binding_check() { let npk = keys.npk(); let seed = PdaSeed::new([99; 32]); - // Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized = - // true, account_id derived via the private formula. let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), u128::MAX); let owned_pre_state = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), ..Account::default() }, - true, + false, account_id, ); let result = execute_and_prove( vec![owned_pre_state], Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: u128::MAX, - kind: WitnessKind::Pda { binding: None }, - nullifier: NullifierWitness::Init { - npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - })], + vec![init_pda_witness(&keys, u128::MAX, None)], &program.into(), ); @@ -678,7 +1024,7 @@ fn private_accounts_can_only_be_initialized_once() { let sender_nonce = Nonce(0xdead_beef); let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, nonce: sender_nonce, data: Data::default(), @@ -703,7 +1049,7 @@ fn private_accounts_can_only_be_initialized_once() { .unwrap(); let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, nonce: sender_nonce, data: Data::default(), @@ -733,7 +1079,7 @@ fn circuit_should_fail_if_there_are_repeated_ids() { let sender_keys = test_private_account_keys_1(); let private_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -749,10 +1095,12 @@ fn circuit_should_fail_if_there_are_repeated_ids() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (1, vec![]), }, }), @@ -760,10 +1108,12 @@ fn circuit_should_fail_if_there_are_repeated_ids() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (1, vec![]), }, }), @@ -802,9 +1152,11 @@ fn private_authorized_uninitialized_account() { vpk: private_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(private_keys.ask), + }, nullifier: NullifierWitness::Init { - npk: NullifierPublicKey::from(&private_keys.nsk), + npk: NullifierPublicKey::from(&private_keys.nsk()), commitment_root: DUMMY_COMMITMENT_HASH, }, })], @@ -851,7 +1203,9 @@ fn private_unauthorized_uninitialized_account_can_still_be_claimed() { vpk: private_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(private_keys.ask), + }, nullifier: NullifierWitness::Init { npk: private_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -904,9 +1258,11 @@ fn private_account_claimed_then_used_without_init_flag_should_fail() { vpk: private_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(private_keys.ask), + }, nullifier: NullifierWitness::Init { - npk: NullifierPublicKey::from(&private_keys.nsk), + npk: NullifierPublicKey::from(&private_keys.nsk()), commitment_root: DUMMY_COMMITMENT_HASH, }, })], @@ -935,7 +1291,7 @@ fn private_account_claimed_then_used_without_init_flag_should_fail() { // Prepare new state of account let account_metadata = { let mut acc = authorized_account; - acc.account.program_owner = crate::test_methods::claimer().id(); + acc.account.program_owner = crate::test_methods::claimer().id().into(); acc }; @@ -949,9 +1305,11 @@ fn private_account_claimed_then_used_without_init_flag_should_fail() { vpk: private_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(private_keys.ask), + }, nullifier: NullifierWitness::Init { - npk: NullifierPublicKey::from(&private_keys.nsk), + npk: NullifierPublicKey::from(&private_keys.nsk()), commitment_root: DUMMY_COMMITMENT_HASH, }, })], @@ -991,13 +1349,13 @@ fn two_private_pda_family_members_receive_and_spend() { V03State::new().with_public_accounts(public_state_from_balances(&[(funder_id, 500)])); let alice_pda_0_account = Account { - program_owner: simple_transfer_id, + program_owner: simple_transfer_id.into(), balance: amount, nonce: Nonce::private_account_nonce_init(&alice_pda_0_id), ..Account::default() }; let alice_pda_1_account = Account { - program_owner: simple_transfer_id, + program_owner: simple_transfer_id.into(), balance: amount, nonce: Nonce::private_account_nonce_init(&alice_pda_1_id), ..Account::default() @@ -1015,18 +1373,7 @@ fn two_private_pda_family_members_receive_and_spend() { Program::serialize_instruction(amount).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::Private(PrivateWitness { - vpk: alice_keys.vpk(), - random_seed: [0; 32], - identifier: 0, - kind: WitnessKind::Pda { - binding: Some((proxy_id, seed)), - }, - nullifier: NullifierWitness::Init { - npk: alice_npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - }), + init_pda_witness(&alice_keys, 0, Some((proxy_id, seed))), ], &simple_transfer.clone().into(), ) @@ -1054,18 +1401,7 @@ fn two_private_pda_family_members_receive_and_spend() { Program::serialize_instruction(amount).unwrap(), vec![ InputAccountIdentity::Public, - InputAccountIdentity::Private(PrivateWitness { - vpk: alice_keys.vpk(), - random_seed: [0; 32], - identifier: 1, - kind: WitnessKind::Pda { - binding: Some((proxy_id, seed)), - }, - nullifier: NullifierWitness::Init { - npk: alice_npk, - commitment_root: DUMMY_COMMITMENT_HASH, - }, - }), + init_pda_witness(&alice_keys, 1, Some((proxy_id, seed))), ], &simple_transfer.into(), ) @@ -1092,7 +1428,7 @@ fn two_private_pda_family_members_receive_and_spend() { let recipient_account = state.get_account_by_id(recipient_id); let (output, proof) = execute_and_prove( vec![ - AccountWithMetadata::new(alice_pda_0_account, true, alice_pda_0_id), + AccountWithMetadata::new(alice_pda_0_account, false, alice_pda_0_id), AccountWithMetadata::new(recipient_account, true, recipient_id), ], Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(), @@ -1104,7 +1440,7 @@ fn two_private_pda_family_members_receive_and_spend() { kind: WitnessKind::Pda { binding: None }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: alice_keys.nsk, + nsk: alice_keys.nsk(), membership_proof: state .get_proof_for_commitment(&commitment_pda_0) .expect("pda_0 must be in state"), @@ -1131,7 +1467,7 @@ fn two_private_pda_family_members_receive_and_spend() { let recipient_account = state.get_account_by_id(recipient_id); let (output, proof) = execute_and_prove( vec![ - AccountWithMetadata::new(alice_pda_1_account.clone(), true, alice_pda_1_id), + AccountWithMetadata::new(alice_pda_1_account.clone(), false, alice_pda_1_id), AccountWithMetadata::new(recipient_account, false, recipient_id), ], Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(), @@ -1143,7 +1479,7 @@ fn two_private_pda_family_members_receive_and_spend() { kind: WitnessKind::Pda { binding: None }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: alice_keys.nsk, + nsk: alice_keys.nsk(), membership_proof: state .get_proof_for_commitment(&commitment_pda_1) .expect("pda_1 must be in state"), @@ -1170,11 +1506,11 @@ fn two_private_pda_family_members_receive_and_spend() { // Re-fund alice_pda_1 top-level via simple_transfer using a private-PDA update with an // external seed. let alice_pda_1_account_after_spend = Account { - program_owner: simple_transfer_id, + program_owner: simple_transfer_id.into(), balance: 0, nonce: alice_pda_1_account .nonce - .private_account_nonce_increment(&alice_keys.nsk), + .private_account_nonce_increment(&alice_keys.nsk()), ..Account::default() }; let commitment_pda_1_after_spend = @@ -1199,7 +1535,7 @@ fn two_private_pda_family_members_receive_and_spend() { }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: alice_keys.nsk, + nsk: alice_keys.nsk(), membership_proof: state .get_proof_for_commitment(&commitment_pda_1_after_spend) .expect("pda_1 after spend must be in state"), diff --git a/lee/state_machine/src/state/tests/claiming.rs b/lee/state_machine/src/state/tests/claiming.rs index 68c3cf5e4..cb6e0cc47 100644 --- a/lee/state_machine/src/state/tests/claiming.rs +++ b/lee/state_machine/src/state/tests/claiming.rs @@ -18,7 +18,7 @@ fn claiming_mechanism() { assert_eq!(state.get_account_by_id(to), Account::default()); let expected_recipient_post = Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: amount, nonce: Nonce(1), ..Account::default() @@ -86,7 +86,7 @@ fn authorized_public_account_claiming_succeeds() { assert_eq!( state.get_account_by_id(account_id), Account { - program_owner: program.id(), + program_owner: program.id().into(), nonce: Nonce(1), ..Account::default() } @@ -114,7 +114,7 @@ fn public_chained_call() { ); let expected_to_post = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: amount * 2, // The `chain_caller` chains the program twice ..Account::default() }; @@ -197,7 +197,7 @@ fn execution_that_requires_authentication_of_a_program_derived_account_id_succee ); let expected_to_post = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: amount, // The `chain_caller` chains the program twice ..Account::default() }; @@ -244,7 +244,7 @@ fn claiming_mechanism_within_chain_call() { let expected_to_post = Account { // The expected program owner is the authenticated transfer program - program_owner: simple_transfer.id(), + program_owner: simple_transfer.id().into(), balance: amount, nonce: Nonce(1), ..Account::default() @@ -299,7 +299,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() { let program_id = program.id(); let sender_keys = test_private_account_keys_1(); let sender_private_account = Account { - program_owner: program_id, + program_owner: program_id.into(), balance: 100, ..Account::default() }; @@ -329,10 +329,12 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: state .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in state"), @@ -353,13 +355,13 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() { .transition_from_privacy_preserving_transaction(&tx, 1, 0) .unwrap(); - let nullifier = Nullifier::for_account_update(&sender_commitment, &sender_keys.nsk); + let nullifier = Nullifier::for_account_update(&sender_commitment, &sender_keys.nsk()); assert!(state.private_state.1.contains(&nullifier)); assert_eq!( state.get_account_by_id(recipient_account_id), Account { - program_owner: program_id, + program_owner: program_id.into(), balance, nonce: Nonce(1), ..Account::default() @@ -378,7 +380,7 @@ fn private_chained_call(number_of_calls: u32) { let initial_balance = 100; let from_account = AccountWithMetadata::new( Account { - program_owner: simple_transfers.id(), + program_owner: simple_transfers.id().into(), balance: initial_balance, ..Account::default() }, @@ -387,7 +389,7 @@ fn private_chained_call(number_of_calls: u32) { ); let to_account = AccountWithMetadata::new( Account { - program_owner: simple_transfers.id(), + program_owner: simple_transfers.id().into(), ..Account::default() }, true, @@ -420,8 +422,8 @@ fn private_chained_call(number_of_calls: u32) { dependencies.insert(simple_transfers.id(), simple_transfers); let program_with_deps = ProgramWithDependencies::new(chain_caller, dependencies); - let from_new_nonce = Nonce::default().private_account_nonce_increment(&from_keys.nsk); - let to_new_nonce = Nonce::default().private_account_nonce_increment(&to_keys.nsk); + let from_new_nonce = Nonce::default().private_account_nonce_increment(&from_keys.nsk()); + let to_new_nonce = Nonce::default().private_account_nonce_increment(&to_keys.nsk()); let from_expected_post = Account { balance: initial_balance - u128::from(number_of_calls) * amount, @@ -446,10 +448,12 @@ fn private_chained_call(number_of_calls: u32) { vpk: from_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(from_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: from_keys.nsk, + nsk: from_keys.nsk(), membership_proof: state .get_proof_for_commitment(&from_commitment) .expect("from's commitment must be in state"), @@ -459,10 +463,12 @@ fn private_chained_call(number_of_calls: u32) { vpk: to_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(to_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: to_keys.nsk, + nsk: to_keys.nsk(), membership_proof: state .get_proof_for_commitment(&to_commitment) .expect("to's commitment must be in state"), @@ -504,7 +510,7 @@ fn claiming_mechanism_cannot_claim_initialied_accounts() { state.force_insert_account( account_id, Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), ..Account::default() }, ); @@ -543,7 +549,7 @@ fn malicious_program_cannot_break_balance_validation_if_not_in_genesis() { ( sender_id, Account { - program_owner: modified_transfer_id, + program_owner: modified_transfer_id.into(), balance: sender_init_balance, ..Account::default() }, @@ -551,7 +557,7 @@ fn malicious_program_cannot_break_balance_validation_if_not_in_genesis() { ( recipient_id, Account { - program_owner: modified_transfer_id, + program_owner: modified_transfer_id.into(), balance: recipient_init_balance, ..Account::default() }, diff --git a/lee/state_machine/src/state/tests/flash_swap.rs b/lee/state_machine/src/state/tests/flash_swap.rs index be8f1c106..54765a488 100644 --- a/lee/state_machine/src/state/tests/flash_swap.rs +++ b/lee/state_machine/src/state/tests/flash_swap.rs @@ -13,12 +13,12 @@ fn flash_swap_successful() { let amount_out: u128 = 100; let vault_account = Account { - program_owner: token.id(), + program_owner: token.id().into(), balance: initial_balance, ..Account::default() }; let receiver_account = Account { - program_owner: token.id(), + program_owner: token.id().into(), balance: 0, ..Account::default() }; @@ -64,12 +64,12 @@ fn flash_swap_callback_keeps_funds_rollback() { let amount_out: u128 = 100; let vault_account = Account { - program_owner: token.id(), + program_owner: token.id().into(), balance: initial_balance, ..Account::default() }; let receiver_account = Account { - program_owner: token.id(), + program_owner: token.id().into(), balance: 0, ..Account::default() }; @@ -121,12 +121,12 @@ fn flash_swap_self_call_targets_correct_program() { let initial_balance: u128 = 1000; let vault_account = Account { - program_owner: token.id(), + program_owner: token.id().into(), balance: initial_balance, ..Account::default() }; let receiver_account = Account { - program_owner: token.id(), + program_owner: token.id().into(), balance: 0, ..Account::default() }; @@ -167,7 +167,7 @@ fn flash_swap_standalone_invariant_check_rejected() { let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); let vault_account = Account { - program_owner: token.id(), + program_owner: token.id().into(), balance: 1000, ..Account::default() }; diff --git a/lee/state_machine/src/state/tests/genesis.rs b/lee/state_machine/src/state/tests/genesis.rs index f67628eee..07b2e22c4 100644 --- a/lee/state_machine/src/state/tests/genesis.rs +++ b/lee/state_machine/src/state/tests/genesis.rs @@ -24,13 +24,10 @@ fn new_works() { ); this }; - let expected_builtin_programs = HashMap::new(); - let state = V03State::new().with_public_account_balances([(addr1, 100_u128), (addr2, 151_u128)]); assert_eq!(state.public_state, expected_public_state); - assert_eq!(state.programs, expected_builtin_programs); } #[test] @@ -67,11 +64,12 @@ fn insert_program() { let mut state = V03State::new(); let program_to_insert = crate::test_methods::simple_balance_transfer(); let program_id = program_to_insert.id(); - assert!(!state.programs.contains_key(&program_id)); + let account_id = lee_core::account::AccountId::from(program_id); + assert!(!state.public_state.contains_key(&account_id)); - state.insert_program(program_to_insert); + state.insert_program(&program_to_insert); - assert!(state.programs.contains_key(&program_id)); + assert!(state.public_state.contains_key(&account_id)); } #[test] @@ -81,7 +79,7 @@ fn get_account_by_account_id_non_default_account() { let initial_data = [( account_id, Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, ..Account::default() }, @@ -105,15 +103,6 @@ fn get_account_by_account_id_default_account() { assert_eq!(account, expected_account); } -#[test] -fn builtin_programs_getter() { - let state = V03State::new(); - - let builtin_programs = state.programs(); - - assert_eq!(builtin_programs, &state.programs); -} - #[test] fn state_serialization_roundtrip() { let account_id_1 = AccountId::new([1; 32]); diff --git a/lee/state_machine/src/state/tests/mod.rs b/lee/state_machine/src/state/tests/mod.rs index 878f722c6..1f657c14b 100644 --- a/lee/state_machine/src/state/tests/mod.rs +++ b/lee/state_machine/src/state/tests/mod.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use lee_core::{ - BlockId, Commitment, DUMMY_COMMITMENT_HASH, InputAccountIdentity, Nullifier, - NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateWitness, Timestamp, - WitnessKind, + AuthorizationSecretKey, BlockId, Commitment, DUMMY_COMMITMENT_HASH, Identifier, + InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, NullifierWitness, + PrivateWitness, Timestamp, WitnessKind, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, encryption::ViewingPublicKey, program::{ @@ -45,36 +45,36 @@ impl V03State { /// Include test programs in the builtin programs map. #[must_use] pub fn with_test_programs(mut self) -> Self { - self.insert_program(crate::test_methods::simple_balance_transfer()); - self.insert_program(crate::test_methods::nonce_changer()); - self.insert_program(crate::test_methods::extra_output()); - self.insert_program(crate::test_methods::missing_output()); - self.insert_program(crate::test_methods::dropped_account()); - self.insert_program(crate::test_methods::program_owner_changer()); - self.insert_program(crate::test_methods::data_changer()); - self.insert_program(crate::test_methods::minter()); - self.insert_program(crate::test_methods::burner()); - self.insert_program(crate::test_methods::auth_asserting_noop()); - self.insert_program(crate::test_methods::private_pda_delegator()); - self.insert_program(crate::test_methods::pda_claimer()); - self.insert_program(crate::test_methods::two_pda_claimer()); - self.insert_program(crate::test_methods::noop()); - self.insert_program(crate::test_methods::chain_caller()); - self.insert_program(crate::test_methods::modified_transfer_program()); - self.insert_program(crate::test_methods::malicious_authorization_changer()); - self.insert_program(crate::test_methods::validity_window()); - self.insert_program(crate::test_methods::flash_swap_initiator()); - self.insert_program(crate::test_methods::flash_swap_callback()); - self.insert_program(crate::test_methods::malicious_self_program_id()); - self.insert_program(crate::test_methods::malicious_caller_program_id()); - self.insert_program(crate::test_methods::pda_spend_proxy()); - self.insert_program(crate::test_methods::claimer()); - self.insert_program(crate::test_methods::changer_claimer()); - self.insert_program(crate::test_methods::validity_window_chain_caller()); - self.insert_program(crate::test_methods::simple_transfer_proxy()); - self.insert_program(crate::test_methods::malicious_injector()); - self.insert_program(crate::test_methods::malicious_launderer()); - self.insert_program(crate::test_methods::modified_transfer_program()); + self.insert_program(&crate::test_methods::simple_balance_transfer()); + self.insert_program(&crate::test_methods::nonce_changer()); + self.insert_program(&crate::test_methods::extra_output()); + self.insert_program(&crate::test_methods::missing_output()); + self.insert_program(&crate::test_methods::dropped_account()); + self.insert_program(&crate::test_methods::program_owner_changer()); + self.insert_program(&crate::test_methods::data_changer()); + self.insert_program(&crate::test_methods::minter()); + self.insert_program(&crate::test_methods::burner()); + self.insert_program(&crate::test_methods::auth_asserting_noop()); + self.insert_program(&crate::test_methods::private_pda_delegator()); + self.insert_program(&crate::test_methods::pda_claimer()); + self.insert_program(&crate::test_methods::two_pda_claimer()); + self.insert_program(&crate::test_methods::noop()); + self.insert_program(&crate::test_methods::chain_caller()); + self.insert_program(&crate::test_methods::modified_transfer_program()); + self.insert_program(&crate::test_methods::malicious_authorization_changer()); + self.insert_program(&crate::test_methods::validity_window()); + self.insert_program(&crate::test_methods::flash_swap_initiator()); + self.insert_program(&crate::test_methods::flash_swap_callback()); + self.insert_program(&crate::test_methods::malicious_self_program_id()); + self.insert_program(&crate::test_methods::malicious_caller_program_id()); + self.insert_program(&crate::test_methods::pda_spend_proxy()); + self.insert_program(&crate::test_methods::claimer()); + self.insert_program(&crate::test_methods::changer_claimer()); + self.insert_program(&crate::test_methods::validity_window_chain_caller()); + self.insert_program(&crate::test_methods::simple_transfer_proxy()); + self.insert_program(&crate::test_methods::malicious_injector()); + self.insert_program(&crate::test_methods::malicious_launderer()); + self.insert_program(&crate::test_methods::modified_transfer_program()); self } @@ -110,7 +110,7 @@ impl V03State { #[must_use] pub fn with_account_owned_by_burner_program(mut self) -> Self { let account = Account { - program_owner: crate::test_methods::burner().id(), + program_owner: crate::test_methods::burner().id().into(), balance: 100, ..Default::default() }; @@ -138,14 +138,18 @@ impl TestPublicKeys { } pub struct TestPrivateKeys { - pub nsk: NullifierSecretKey, + pub ask: AuthorizationSecretKey, pub d: [u8; 32], pub z: [u8; 32], } impl TestPrivateKeys { + pub fn nsk(&self) -> NullifierSecretKey { + (&self.ask).into() + } + pub fn npk(&self) -> NullifierPublicKey { - NullifierPublicKey::from(&self.nsk) + NullifierPublicKey::from(&self.nsk()) } pub fn vpk(&self) -> ViewingPublicKey { @@ -183,7 +187,7 @@ fn public_state_from_balances(initial_data: &[(AccountId, u128)]) -> HashMap TestPublicKeys { pub fn test_private_account_keys_1() -> TestPrivateKeys { TestPrivateKeys { - nsk: [13; 32], + ask: AuthorizationSecretKey([13; 32]), d: [31; 32], z: [32; 32], } @@ -249,12 +253,30 @@ pub fn test_private_account_keys_1() -> TestPrivateKeys { pub fn test_private_account_keys_2() -> TestPrivateKeys { TestPrivateKeys { - nsk: [38; 32], + ask: AuthorizationSecretKey([38; 32]), d: [83; 32], z: [84; 32], } } +/// Init-lifecycle private-PDA witness for `keys`, the shape every PDA circuit test starts from. +pub fn init_pda_witness( + keys: &TestPrivateKeys, + identifier: Identifier, + binding: Option<(ProgramId, PdaSeed)>, +) -> InputAccountIdentity { + InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier, + kind: WitnessKind::Pda { binding }, + nullifier: NullifierWitness::Init { + npk: keys.npk(), + commitment_root: DUMMY_COMMITMENT_HASH, + }, + }) +} + fn shielded_balance_transfer_for_tests( sender_keys: &TestPublicKeys, recipient_keys: &TestPrivateKeys, @@ -284,7 +306,9 @@ fn shielded_balance_transfer_for_tests( vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -331,10 +355,12 @@ fn private_balance_transfer_for_tests( vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: state .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in state"), @@ -344,7 +370,9 @@ fn private_balance_transfer_for_tests( vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -392,10 +420,12 @@ fn deshielded_balance_transfer_for_tests( vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: state .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in state"), @@ -417,7 +447,7 @@ fn deshielded_balance_transfer_for_tests( fn valid_private_transfer_tx_and_state() -> (V03State, PrivacyPreservingTransaction) { let sender_keys = test_private_account_keys_1(); let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, nonce: Nonce(0xdead_beef), ..Account::default() diff --git a/lee/state_machine/src/state/tests/privacy_preserving.rs b/lee/state_machine/src/state/tests/privacy_preserving.rs index afc9d88ae..6a9579a8a 100644 --- a/lee/state_machine/src/state/tests/privacy_preserving.rs +++ b/lee/state_machine/src/state/tests/privacy_preserving.rs @@ -8,7 +8,7 @@ fn transition_from_privacy_preserving_transaction_shielded() { let mut state = V03State::new().with_public_accounts([( sender_keys.account_id(), Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 200, ..Account::default() }, @@ -49,7 +49,7 @@ fn transition_from_privacy_preserving_transaction_private() { let sender_nonce = Nonce(0xdead_beef); let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, nonce: sender_nonce, data: Data::default(), @@ -75,8 +75,8 @@ fn transition_from_privacy_preserving_transaction_private() { let expected_new_commitment_1 = Commitment::new( &sender_account_id, &Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk()), balance: sender_private_account.balance - balance_to_move, data: Data::default(), }, @@ -84,12 +84,12 @@ fn transition_from_privacy_preserving_transaction_private() { let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account); let expected_new_nullifier = - Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk); + Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk()); let expected_new_commitment_2 = Commitment::new( &recipient_account_id, &Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), nonce: Nonce::private_account_nonce_init(&recipient_account_id), balance: balance_to_move, ..Account::default() @@ -171,7 +171,7 @@ fn transition_from_privacy_preserving_transaction_deshielded() { let sender_nonce = Nonce(0xdead_beef); let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: 100, nonce: sender_nonce, data: Data::default(), @@ -182,7 +182,7 @@ fn transition_from_privacy_preserving_transaction_deshielded() { .with_public_accounts([( recipient_keys.account_id(), Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), balance: recipient_initial_balance, ..Account::default() }, @@ -210,8 +210,8 @@ fn transition_from_privacy_preserving_transaction_deshielded() { let expected_new_commitment = Commitment::new( &sender_account_id, &Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk()), balance: sender_private_account.balance - balance_to_move, data: Data::default(), }, @@ -219,7 +219,7 @@ fn transition_from_privacy_preserving_transaction_deshielded() { let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account); let expected_new_nullifier = - Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk); + Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk()); assert!(state.private_state.0.contains(&sender_pre_commitment)); assert!(!state.private_state.0.contains(&expected_new_commitment)); @@ -245,7 +245,7 @@ fn burner_program_should_fail_in_privacy_preserving_circuit() { let program = crate::test_methods::burner(); let public_account = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 100, ..Account::default() }, @@ -268,7 +268,7 @@ fn minter_program_should_fail_in_privacy_preserving_circuit() { let program = crate::test_methods::minter(); let public_account = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -291,7 +291,7 @@ fn nonce_changer_program_should_fail_in_privacy_preserving_circuit() { let program = crate::test_methods::nonce_changer(); let public_account = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -314,7 +314,7 @@ fn data_changer_program_should_fail_for_non_owned_account_in_privacy_preserving_ let program = crate::test_methods::data_changer(); let public_account = AccountWithMetadata::new( Account { - program_owner: [0, 1, 2, 3, 4, 5, 6, 7], + program_owner: [0, 1, 2, 3, 4, 5, 6, 7].into(), balance: 0, ..Account::default() }, @@ -337,7 +337,7 @@ fn data_changer_program_should_fail_for_too_large_data_in_privacy_preserving_cir let program = crate::test_methods::data_changer(); let public_account = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -368,7 +368,7 @@ fn extra_output_program_should_fail_in_privacy_preserving_circuit() { let program = crate::test_methods::extra_output(); let public_account = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -391,7 +391,7 @@ fn missing_output_program_should_fail_in_privacy_preserving_circuit() { let program = crate::test_methods::missing_output(); let public_account_1 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -400,7 +400,7 @@ fn missing_output_program_should_fail_in_privacy_preserving_circuit() { ); let public_account_2 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -423,7 +423,7 @@ fn program_owner_changer_should_fail_in_privacy_preserving_circuit() { let program = crate::test_methods::program_owner_changer(); let public_account = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -446,7 +446,7 @@ fn transfer_from_non_owned_account_should_fail_in_privacy_preserving_circuit() { let program = crate::test_methods::simple_balance_transfer(); let public_account_1 = AccountWithMetadata::new( Account { - program_owner: [0, 1, 2, 3, 4, 5, 6, 7], + program_owner: [0, 1, 2, 3, 4, 5, 6, 7].into(), balance: 100, ..Account::default() }, @@ -455,7 +455,7 @@ fn transfer_from_non_owned_account_should_fail_in_privacy_preserving_circuit() { ); let public_account_2 = AccountWithMetadata::new( Account { - program_owner: program.id(), + program_owner: program.id().into(), balance: 0, ..Account::default() }, @@ -483,7 +483,7 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() { let sender_account = AccountWithMetadata::new( Account { - program_owner: simple_transfers.id(), + program_owner: simple_transfers.id().into(), balance: 100, ..Default::default() }, @@ -525,10 +525,12 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() { vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: recipient_keys.nsk, + nsk: recipient_keys.nsk(), membership_proof: state .get_proof_for_commitment(&recipient_commitment) .expect("recipient's commitment must be in state"), diff --git a/lee/state_machine/src/state/tests/public_program_rules.rs b/lee/state_machine/src/state/tests/public_program_rules.rs index 236bddcff..405013357 100644 --- a/lee/state_machine/src/state/tests/public_program_rules.rs +++ b/lee/state_machine/src/state/tests/public_program_rules.rs @@ -96,7 +96,7 @@ fn program_should_fail_if_it_drops_a_declared_account() { ( AccountId::new([1; 32]), Account { - program_owner: crate::test_methods::dropped_account().id(), + program_owner: crate::test_methods::dropped_account().id().into(), balance: 100, ..Account::default() }, @@ -104,7 +104,7 @@ fn program_should_fail_if_it_drops_a_declared_account() { ( AccountId::new([2; 32]), Account { - program_owner: crate::test_methods::dropped_account().id(), + program_owner: crate::test_methods::dropped_account().id().into(), balance: 0, ..Account::default() }, @@ -136,7 +136,7 @@ fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_o let initial_data = [( AccountId::new([1; 32]), Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), + program_owner: crate::test_methods::simple_balance_transfer().id().into(), ..Account::default() }, )]; @@ -268,7 +268,7 @@ fn program_should_fail_if_transfers_balance_from_non_owned_account() { let program_id = crate::test_methods::simple_balance_transfer().id(); assert_ne!( state.get_account_by_id(sender_account_id).program_owner, - program_id + program_id.into() ); let message = public_transaction::Message::try_new( program_id, @@ -285,8 +285,8 @@ fn program_should_fail_if_transfers_balance_from_non_owned_account() { assert!(matches!( result, Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_program_id, executing_program_id } - ))) if err_account_id == sender_account_id && owner_program_id != program_id && executing_program_id == program_id + ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_account_id, executing_program_id } + ))) if err_account_id == sender_account_id && owner_account_id != program_id.into() && executing_program_id == program_id )); } @@ -303,7 +303,7 @@ fn program_should_fail_if_modifies_data_of_non_owned_account() { assert_ne!(state.get_account_by_id(account_id), Account::default()); assert_ne!( state.get_account_by_id(account_id).program_owner, - program_id + program_id.into() ); let message = public_transaction::Message::try_new(program_id, vec![account_id], vec![], vec![0]) @@ -356,7 +356,7 @@ fn program_should_fail_if_does_not_preserve_total_balance_by_burning() { let account_id = AccountId::new([252; 32]); assert_eq!( state.get_account_by_id(account_id).program_owner, - program_id + program_id.into() ); let balance_to_burn: u128 = 1; assert!(state.get_account_by_id(account_id).balance > balance_to_burn); diff --git a/lee/state_machine/src/state/tests/validity_window.rs b/lee/state_machine/src/state/tests/validity_window.rs index c39571ea4..7953c671a 100644 --- a/lee/state_machine/src/state/tests/validity_window.rs +++ b/lee/state_machine/src/state/tests/validity_window.rs @@ -142,7 +142,9 @@ fn validity_window_works_in_privacy_preserving_transactions( vpk: account_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(account_keys.ask), + }, nullifier: NullifierWitness::Init { npk: account_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -210,7 +212,9 @@ fn timestamp_validity_window_works_in_privacy_preserving_transactions( vpk: account_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(account_keys.ask), + }, nullifier: NullifierWitness::Init { npk: account_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs index 738cc5153..6feb89cb4 100644 --- a/lee/state_machine/src/validated_state_diff/mod.rs +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, collections::{HashMap, HashSet, VecDeque}, hash::Hash, }; @@ -7,7 +8,7 @@ use lee_core::{ BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, PublicAction, Timestamp, account::{Account, AccountId, AccountWithMetadata}, program::{ - ChainedCall, Claim, DEFAULT_PROGRAM_ID, ProgramId, compute_public_authorized_pdas, + CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, compute_public_authorized_pdas, validate_execution, }, }; @@ -111,10 +112,13 @@ impl ValidatedStateDiff { LeeError::MaxChainedCallsDepthExceeded ); - // Check that the `program_id` corresponds to a deployed program - let Some(program) = state.programs().get(&chained_call.program_id) else { + let Some(program_account) = state.get_program(chained_call.program_id) else { return Err(LeeError::InvalidInput("Unknown program".into())); }; + let program = Program::new_unchecked( + chained_call.program_id, + Cow::Owned(program_account.data.to_vec()), + ); debug!( "Program {:?} pre_states: {:?}, instruction_data: {:?}", @@ -217,7 +221,7 @@ impl ValidatedStateDiff { // The invoked program can only claim accounts with default program id. ensure!( - post.account().program_owner == DEFAULT_PROGRAM_ID, + post.account().program_owner == DEFAULT_PROGRAM_OWNER, InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id } ); @@ -244,7 +248,7 @@ impl ValidatedStateDiff { } } - post.account_mut().program_owner = chained_call.program_id; + post.account_mut().program_owner = AccountId::from(chained_call.program_id); } // Update the state diff @@ -264,17 +268,14 @@ impl ValidatedStateDiff { // Union with the caller's authorized set so that authorization is monotonically // growing: once an account is authorized at any point in the chain it remains // authorized for all subsequent calls. - let authorized_accounts: HashSet<_> = caller_data - .authorized_accounts - .into_iter() - .chain( - program_output - .pre_states - .iter() - .filter(|pre| pre.is_authorized) - .map(|pre| pre.account_id), - ) - .collect(); + let mut authorized_accounts = caller_data.authorized_accounts; + authorized_accounts.extend( + program_output + .pre_states + .iter() + .filter(|pre| pre.is_authorized) + .map(|pre| pre.account_id), + ); for new_call in program_output.chained_calls.into_iter().rev() { chained_calls.push_front(( new_call, @@ -293,7 +294,7 @@ impl ValidatedStateDiff { // Check that all modified uninitialized accounts where claimed for (account_id, post) in state_diff.iter().filter_map(|(account_id, post)| { let pre = state.get_account_by_id(*account_id); - if pre.program_owner != DEFAULT_PROGRAM_ID { + if pre.program_owner != DEFAULT_PROGRAM_OWNER { return None; } if pre == *post { @@ -302,7 +303,7 @@ impl ValidatedStateDiff { Some((*account_id, post)) }) { ensure!( - post.program_owner != DEFAULT_PROGRAM_ID, + post.program_owner != DEFAULT_PROGRAM_OWNER, InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { account_id } ); } @@ -444,7 +445,7 @@ impl ValidatedStateDiff { ) -> Result { // TODO: remove clone let program = Program::new(tx.message.bytecode.clone().into())?; - if state.programs().contains_key(&program.id()) { + if state.get_program(program.id()).is_some() { return Err(LeeError::ProgramAlreadyExists); } Ok(Self(StateDiff { @@ -470,12 +471,6 @@ impl ValidatedStateDiff { } } -#[derive(Debug)] -struct CallerData { - program_id: Option, - authorized_accounts: HashSet, -} - fn authenticate_public_transaction_signers( tx: &PublicTransaction, state: &V03State, diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs index b3db107a6..0f274aaaf 100644 --- a/lee/state_machine/src/validated_state_diff/tests.rs +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -18,7 +18,7 @@ fn public_state_from_balances(initial_data: &[(AccountId, u128)]) -> HashMap(); + + let (output_pre_states, output_post_states) = if declare_pre_states { + let post_states = pre_states + .iter() + .map(|account| AccountPostState::new(account.account.clone())) + .collect(); + (pre_states.clone(), post_states) + } else { + (Vec::new(), Vec::new()) + }; + + // Make exactly one chained call based on the input instruction with no + // pda seeds, ensuring the target PDAs are never authorized. + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + output_pre_states, + output_post_states, + ) + .with_chained_calls(vec![ChainedCall { + program_id: callee_program_id, + instruction_data: callee_instruction, + pre_states, + pda_seeds: vec![], + }]) + .write(); +} diff --git a/lee/state_machine/test_methods/guest/src/bin/pda_spend_proxy.rs b/lee/state_machine/test_methods/guest/src/bin/pda_spend_proxy.rs index d8b9bb5ce..86ef73b4a 100644 --- a/lee/state_machine/test_methods/guest/src/bin/pda_spend_proxy.rs +++ b/lee/state_machine/test_methods/guest/src/bin/pda_spend_proxy.rs @@ -5,7 +5,7 @@ use risc0_zkvm::serde::to_vec; /// Proxy for spending from a private PDA via `simple_transfer`. /// -/// `pre_states = [pda (authorized), recipient]`. Debits the PDA and credits the recipient. +/// `pre_states = [pda, recipient]`. Debits the PDA and credits the recipient. /// The PDA-to-npk binding is established via `pda_seeds` in the chained call to `simple_transfer`. type Instruction = (PdaSeed, u128, ProgramId); @@ -24,15 +24,16 @@ fn main() { return; }; - assert!(first.is_authorized, "first pre_state must be authorized"); - let first_post = AccountPostState::new(first.account.clone()); let second_post = AccountPostState::new(second.account.clone()); + let mut first_for_callee = first.clone(); + first_for_callee.is_authorized = true; + let chained_call = ChainedCall { program_id: simple_transfer_id, instruction_data: to_vec(&amount).unwrap(), - pre_states: vec![first.clone(), second.clone()], + pre_states: vec![first_for_callee, second.clone()], pda_seeds: vec![seed], }; diff --git a/lee/state_machine/test_methods/guest/src/bin/private_pda_delegator.rs b/lee/state_machine/test_methods/guest/src/bin/private_pda_delegator.rs index 766465e3e..3b891e9af 100644 --- a/lee/state_machine/test_methods/guest/src/bin/private_pda_delegator.rs +++ b/lee/state_machine/test_methods/guest/src/bin/private_pda_delegator.rs @@ -30,7 +30,7 @@ fn main() { let mut pre_for_callee = pre.clone(); pre_for_callee.is_authorized = true; - pre_for_callee.account.program_owner = self_program_id; + pre_for_callee.account.program_owner = self_program_id.into(); let chained_call = ChainedCall { program_id: callee_program_id, diff --git a/lee/state_machine/test_methods/guest/src/bin/program_owner_changer.rs b/lee/state_machine/test_methods/guest/src/bin/program_owner_changer.rs index ef2c59905..cc3b99369 100644 --- a/lee/state_machine/test_methods/guest/src/bin/program_owner_changer.rs +++ b/lee/state_machine/test_methods/guest/src/bin/program_owner_changer.rs @@ -19,7 +19,7 @@ fn main() { let account_pre = &pre.account; let mut account_post = account_pre.clone(); - account_post.program_owner = [0, 1, 2, 3, 4, 5, 6, 7]; + account_post.program_owner = [0, 1, 2, 3, 4, 5, 6, 7].into(); ProgramOutput::new( self_program_id, diff --git a/lee/state_machine/test_methods/guest/src/bin/selective_pda_delegator.rs b/lee/state_machine/test_methods/guest/src/bin/selective_pda_delegator.rs new file mode 100644 index 000000000..e06f76974 --- /dev/null +++ b/lee/state_machine/test_methods/guest/src/bin/selective_pda_delegator.rs @@ -0,0 +1,82 @@ +use lee_core::program::{ + AccountPostState, ChainedCall, Claim, InstructionData, PdaSeed, ProgramId, ProgramInput, + ProgramOutput, read_lee_inputs, +}; +use risc0_zkvm::serde::to_vec; + +type Instruction = ( + PdaSeed, + PdaSeed, + ProgramId, + InstructionData, + Option<(ProgramId, Option)>, +); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: + (claim_seed, delegated_seed, callee_program_id, callee_instruction, sibling), + }, + instruction_words, + ) = read_lee_inputs::(); + + let Some((pda, rest)) = pre_states.split_first() else { + return; + }; + + let pda_for_callee = |is_authorized| { + let mut for_callee = pda.clone(); + for_callee.is_authorized = is_authorized; + for_callee.account.program_owner = self_program_id.into(); + for_callee + }; + + // Send a call to the specified program with the same pre-states + // but authorized first PDA supplied. + // Push all the delegated seeds. + let mut chained_calls = vec![ChainedCall { + program_id: callee_program_id, + instruction_data: callee_instruction, + pre_states: std::iter::once(pda_for_callee(true)) + .chain(rest.iter().cloned()) + .collect(), + pda_seeds: vec![delegated_seed], + }]; + + // If sibling is present in instruction, send out a call + // with no seeds so that PDAs stay unauthorized in parallel + // branches. + if let Some((sibling_program_id, sibling_pda)) = sibling { + chained_calls.push(ChainedCall { + program_id: sibling_program_id, + instruction_data: to_vec(&()).unwrap(), + pre_states: sibling_pda.map_or_else( + || rest.to_vec(), + |is_authorized| { + std::iter::once(pda_for_callee(is_authorized)) + .chain(rest.iter().cloned()) + .collect() + }, + ), + pda_seeds: vec![], + }); + } + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pda.clone()], + // Claim first PDA supplied + vec![AccountPostState::new_claimed( + pda.account.clone(), + Claim::Pda(claim_seed), + )], + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/lee/state_machine/test_methods/guest/src/bin/simple_transfer_proxy.rs b/lee/state_machine/test_methods/guest/src/bin/simple_transfer_proxy.rs index ce7f1d8ee..c6789161e 100644 --- a/lee/state_machine/test_methods/guest/src/bin/simple_transfer_proxy.rs +++ b/lee/state_machine/test_methods/guest/src/bin/simple_transfer_proxy.rs @@ -51,7 +51,7 @@ fn main() { let recipient_post = AccountPostState::new(recipient_pre.account.clone()); // Chain to simple_transfer with pda_seeds to authorize the PDA. - // The circuit's resolve_authorization_and_record_bindings establishes the + // The circuit's assert_authorization_and_record_bindings establishes the // private PDA (seed, npk) binding when pda_seeds match the private PDA derivation. let mut auth_pda_pre = pda_pre; auth_pda_pre.is_authorized = true; diff --git a/lee/state_machine/test_methods/guest/src/bin/undeclaring_pda_delegator.rs b/lee/state_machine/test_methods/guest/src/bin/undeclaring_pda_delegator.rs new file mode 100644 index 000000000..2589ccdc9 --- /dev/null +++ b/lee/state_machine/test_methods/guest/src/bin/undeclaring_pda_delegator.rs @@ -0,0 +1,59 @@ +use lee_core::program::{ + ChainedCall, InstructionData, PdaSeed, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs, +}; +use risc0_zkvm::serde::to_vec; + +type Instruction = ( + Option, + bool, + ProgramId, + InstructionData, + Option, +); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + mut pre_states, + instruction: (seed, declare_authorized, callee_program_id, callee_instruction, sibling), + }, + instruction_words, + ) = read_lee_inputs::(); + + let Some(first) = pre_states.first_mut() else { + return; + }; + first.is_authorized = declare_authorized; + + let sibling_call = sibling.map(|sibling_program_id| { + let mut sibling_pre = pre_states[0].clone(); + sibling_pre.is_authorized = true; + ChainedCall { + program_id: sibling_program_id, + instruction_data: to_vec(&()).unwrap(), + pre_states: vec![sibling_pre], + pda_seeds: vec![], + } + }); + + let mut chained_calls = vec![ChainedCall { + program_id: callee_program_id, + instruction_data: callee_instruction, + pre_states, + pda_seeds: seed.into_iter().collect(), + }]; + chained_calls.extend(sibling_call); + + // Emit an output with only chained calls and no pre or post-states. + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + Vec::new(), + Vec::new(), + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/lez/common/src/block.rs b/lez/common/src/block.rs index 53a2f0337..ab22725e7 100644 --- a/lez/common/src/block.rs +++ b/lez/common/src/block.rs @@ -22,13 +22,26 @@ impl From<&Block> for BlockMeta { } } +/// The last peer block accepted onto a cross-zone peer chain, and the link the +/// next one has to carry. +/// +/// `block_hash` is the recomputed hash, not `header.hash` as read: the +/// signature does not cover that field, so a signed block may carry a bogus one +/// and break the link against the peer's next honest block. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct PeerChainTip { + pub block_id: u64, + pub block_hash: HashType, +} + #[derive(Debug, Clone)] /// Our own hasher. /// Currently it is SHA256 hasher wrapper. May change in a future. 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/Cargo.toml b/lez/cross_zone/Cargo.toml index 3294f4840..be80ce13e 100644 --- a/lez/cross_zone/Cargo.toml +++ b/lez/cross_zone/Cargo.toml @@ -7,10 +7,15 @@ license = { workspace = true } [lints] workspace = true +[features] +# Chain-builder helpers shared by the watcher's and verifier's tests. +test-utils = [] + [dependencies] lee.workspace = true lee_core.workspace = true programs.workspace = true +common.workspace = true cross_zone_inbox_core.workspace = true cross_zone_marker_core.workspace = true bridge_lock_core.workspace = true @@ -18,3 +23,4 @@ ping_core.workspace = true wrapped_token_core.workspace = true serde.workspace = true risc0-zkvm.workspace = true +hex.workspace = true diff --git a/lez/cross_zone/src/acceptance.rs b/lez/cross_zone/src/acceptance.rs new file mode 100644 index 000000000..eaa177aae --- /dev/null +++ b/lez/cross_zone/src/acceptance.rs @@ -0,0 +1,472 @@ +//! The one peer-block acceptance policy. +//! +//! The sequencer's watcher and the indexer's verifier both admit peer blocks +//! through here, so they cannot disagree about which block holds an id: a +//! disagreement makes the verifier re-derive a delivery against a block the +//! watcher never delivered from, and ingestion halts. + +use std::fmt::{self, Display, Formatter}; + +use common::{ + HashType, + block::{Block, PeerChainTip}, +}; +use cross_zone_inbox_core::ZoneId; +use lee::{GENESIS_BLOCK_ID, PublicKey}; + +/// Consecutive passes a reader spends stuck on one slot before it says so as +/// something more than the per-pass failure. It never reads past the slot. +/// +/// The cadence bounds log volume only: at one pass per poll interval, 5 passes +/// is roughly seconds to tens of seconds depending on each side's interval. +pub const STUCK_SLOT_ALERT_PASSES: u32 = 5; + +/// Where a screened peer block sits relative to the chain pinned by `tip`. +#[derive(Debug, PartialEq, Eq)] +pub enum Link { + /// The next block on the peer's chain, carrying its recomputed hash. + Next(HashType), + /// At or below the tip, so already accepted from. The ordinary shape of a + /// re-read slot. `equivocates` is set when the block claims the tip's own + /// id under a different hash, so callers can say so; below the tip there is + /// no held hash to compare against and it is never set. + AlreadySeen { equivocates: bool }, + /// Not on the chain the tip pins, so not acceptable. Callers read on: the + /// peer's own next block still links to the tip, and treating this as + /// terminal would hand the peer a way to stop its deliveries permanently. + OffChain(OffChain), +} + +/// Why a block is not on the chain the tip pins. +#[derive(Debug, PartialEq, Eq)] +pub enum OffChain { + /// The next id off the tip, but linking to some other predecessor. + DoesNotLink { + block_id: u64, + tip_id: u64, + links_to: HashType, + expected: HashType, + }, + /// Read with no stored tip, and not the peer's genesis. Anchoring on + /// whatever arrived first would let the peer pick the id, and burn every + /// replay key below it with one block. + NotTheGenesis { block_id: u64 }, + /// An id above the next one on the chain. + SkipsAhead { block_id: u64, next: u64 }, +} + +impl Display for OffChain { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match *self { + Self::DoesNotLink { + block_id, + tip_id, + links_to, + expected, + } => write!( + f, + "block {block_id} does not follow block {tip_id}: it links to {links_to} rather than {expected}" + ), + Self::NotTheGenesis { block_id } => write!( + f, + "block {block_id} is the first one read, but with no stored chain tip acceptance has to start at the peer's genesis block {GENESIS_BLOCK_ID}" + ), + Self::SkipsAhead { block_id, next } => write!( + f, + "block {block_id} skips past {next}, which is either a hole in what this node read or an id claimed ahead of the peer's chain" + ), + } + } +} + +/// Why a peer block was refused before any chain placement. +/// +/// The channel authorizes who may write, not what they may claim. The hash +/// check is unconditional: the signature does not cover `header.hash`, and the +/// chain link compares hashes, so an unchecked one lets a peer assert links it +/// never built. The pinned key, checked only when one is configured, is what +/// says the peer's own sequencer produced the block; it subsumes nothing here, +/// since a correctly signed block may still carry a bogus hash. +#[derive(Debug, PartialEq, Eq)] +pub enum ScreenRefusal { + /// `header.hash` is not the hash of the block's contents. + HashMismatch { + block_id: u64, + declared: HashType, + recomputed: HashType, + }, + /// The block is not signed by the pinned block-signing key. + KeyMismatch { block_id: u64 }, +} + +impl Display for ScreenRefusal { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match *self { + Self::HashMismatch { + block_id, + declared, + recomputed, + } => write!( + f, + "block {block_id} carries header hash {declared} but its contents hash to {recomputed}" + ), + Self::KeyMismatch { block_id } => write!( + f, + "block {block_id} is not signed by the pinned block-signing key" + ), + } + } +} + +/// The pass-to-pass stall of one peer reader: the slot it is stuck on and how +/// many consecutive passes it has spent there. Keyed by slot so a failure at a +/// new slot does not inherit an older slot's count. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StallState { + stalled: Option<(S, u32)>, +} + +impl Default for StallState { + fn default() -> Self { + Self { stalled: None } + } +} + +impl StallState { + /// Folds one pass in: `stuck_on` is the slot the pass ended inside, `None` + /// for a pass that ended cleanly. Returns the slot the reader is stuck on + /// and how long it has been stuck, so the caller can say so on the + /// [`alerts_at`] cadence. + /// + /// `read_to` is the read position after the pass, and is what tells a + /// stream that truncated early apart from one that genuinely drained: the + /// zone-sdk ends a stream on a fetch failure exactly as it does on catching + /// up, so without it a flaky peer endpoint resets the count for ever and a + /// reader stuck for hours never says so. + pub fn after_pass(&mut self, stuck_on: Option, read_to: Option) -> Option<(S, u32)> { + let Some(slot) = stuck_on else { + if self.passed_the_stall(read_to) { + self.stalled = None; + } + return None; + }; + let attempts = match self.stalled { + Some((held, attempts)) if held == slot => attempts.saturating_add(1), + _ => 1, + }; + self.stalled = Some((slot, attempts)); + self.stalled + } + + /// Whether the read position is now past whatever the reader was stuck on. + /// Vacuously true when it was not stuck. + fn passed_the_stall(self, read_to: Option) -> bool { + self.stalled + .is_none_or(|(stuck_on, _)| read_to.is_some_and(|slot| slot >= stuck_on)) + } +} + +/// Whether a reader stuck for `attempts` passes should say so on this one. +/// +/// Every [`STUCK_SLOT_ALERT_PASSES`], not on the crossing alone: a stall that +/// never clears would otherwise be reported once and then look resolved for as +/// long as it lasts. Not every pass, since that is one line per block time. +#[must_use] +pub const fn alerts_at(attempts: u32) -> bool { + attempts > 0 && attempts.is_multiple_of(STUCK_SLOT_ALERT_PASSES) +} + +/// The one report both sides log for a differing block at an id the accepted +/// chain already holds. +#[must_use] +pub fn equivocation_report( + peer_zone: &ZoneId, + block_id: u64, + holding: HashType, + refusing: HashType, +) -> String { + format!( + "Peer zone {} equivocated at block {block_id}: holding {holding}, refusing {refusing}. Nothing at or above block {block_id} can be delivered from until that peer inscribes a block continuing the run verified from its genesis.", + hex::encode(peer_zone) + ) +} + +/// Whether `block` continues the peer chain pinned by `tip`. +/// +/// `recomputed` is the hash [`screen_peer_block`] returned for it, so a tip +/// stored off [`Link::Next`] pins contents rather than a declared field. +/// +/// This is what closes the id suppression. A delivered message's replay key +/// covers `(src_zone, src_block_id, src_tx_index)` and nothing else, so a peer +/// that can get a block accepted under an id of its choosing burns the key an +/// honest block would later use, and the inbox then no-ops the real message as +/// a replay. Off a hash link ids are only claimable in order, so the only id +/// within reach is the one the peer is about to publish anyway. +#[must_use] +pub fn link_to_tip(tip: Option<&PeerChainTip>, block: &Block, recomputed: HashType) -> Link { + let block_id = block.header.block_id; + let Some(tip) = tip else { + return if block_id == GENESIS_BLOCK_ID { + Link::Next(recomputed) + } else { + Link::OffChain(OffChain::NotTheGenesis { block_id }) + }; + }; + + if block_id <= tip.block_id { + return Link::AlreadySeen { + equivocates: block_id == tip.block_id && recomputed != tip.block_hash, + }; + } + let next = tip.block_id.saturating_add(1); + if block_id > next { + return Link::OffChain(OffChain::SkipsAhead { block_id, next }); + } + if block.header.prev_block_hash != tip.block_hash { + return Link::OffChain(OffChain::DoesNotLink { + block_id, + tip_id: tip.block_id, + links_to: block.header.prev_block_hash, + expected: tip.block_hash, + }); + } + Link::Next(recomputed) +} + +/// Whether a block read off a peer's channel may be considered for the chain at +/// all, returning the recomputed hash every later placement has to use. +pub fn screen_peer_block( + block: &Block, + expected_pubkey: Option<&PublicKey>, +) -> Result { + let recomputed = block.recompute_hash(); + if recomputed != block.header.hash { + return Err(ScreenRefusal::HashMismatch { + block_id: block.header.block_id, + declared: block.header.hash, + recomputed, + }); + } + if expected_pubkey.is_some_and(|key| !block.is_signed_by(key)) { + return Err(ScreenRefusal::KeyMismatch { + block_id: block.header.block_id, + }); + } + Ok(recomputed) +} + +#[cfg(test)] +mod tests { + use common::test_utils::produce_dummy_block; + + use super::*; + use crate::test_utils::linked_chain_to; + + /// The peer's block at `block_id`, on its one honest chain. + fn chain_block(block_id: u64) -> Block { + linked_chain_to(block_id, |_| vec![]) + .pop() + .expect("chain reaches block_id") + } + + /// The hash the block after `block_id` has to link to. + fn chain_hash(block_id: u64) -> HashType { + chain_block(block_id).header.hash + } + + /// The tip a reader holds after accepting up to `block_id`. + fn tip_at(block_id: u64) -> PeerChainTip { + PeerChainTip { + block_id, + block_hash: chain_hash(block_id), + } + } + + fn screened(block: &Block) -> HashType { + screen_peer_block(block, None).expect("honest block passes screening") + } + + /// Runs the stall machine over `(stuck_on, read_to)` pass results. + fn run_stalls(passes: &[(Option, Option)]) -> StallState { + let mut state = StallState::default(); + for (stuck_on, read_to) in passes { + state.after_pass(*stuck_on, *read_to); + } + state + } + + #[test] + fn only_the_next_block_off_the_tip_links() { + let tip = tip_at(2); + + let next = chain_block(3); + assert_eq!( + link_to_tip(Some(&tip), &next, screened(&next)), + Link::Next(chain_hash(3)), + "the block that continues the chain is the one accepted" + ); + + // The #677 suppression. The peer's chain is public, so the version that + // matters is the block linking correctly and lying only about the id: + // one with no link at all is caught by the same check and proves + // nothing about this one. + for ahead in [ + produce_dummy_block(5, Some(chain_hash(2)), vec![]), + produce_dummy_block(5, None, vec![]), + ] { + assert_eq!( + link_to_tip(Some(&tip), &ahead, screened(&ahead)), + Link::OffChain(OffChain::SkipsAhead { + block_id: 5, + next: 3 + }) + ); + } + + // Right id, wrong ancestry: the peer forked at our tip, or reset it. + let forked = produce_dummy_block(3, Some(HashType([9; 32])), vec![]); + assert!(matches!( + link_to_tip(Some(&tip), &forked, screened(&forked)), + Link::OffChain(OffChain::DoesNotLink { .. }) + )); + } + + #[test] + fn a_reader_with_no_tip_starts_at_the_peers_genesis() { + let genesis = chain_block(GENESIS_BLOCK_ID); + assert_eq!( + link_to_tip(None, &genesis, screened(&genesis)), + Link::Next(chain_hash(GENESIS_BLOCK_ID)) + ); + // Anchoring on whatever arrived first is the whole attack: the peer + // would pick the id, and every key below it with one block. + let mid_chain = chain_block(GENESIS_BLOCK_ID + 1); + assert_eq!( + link_to_tip(None, &mid_chain, screened(&mid_chain)), + Link::OffChain(OffChain::NotTheGenesis { + block_id: GENESIS_BLOCK_ID + 1 + }) + ); + } + + #[test] + fn a_differing_block_at_the_tip_id_reports_equivocation() { + // Two blocks claiming one id collapse to one replay key on chain, so + // accepting both delivers one message twice. The re-read of the block + // the tip holds is ordinary; only a differing hash is worth a report. + let tip = tip_at(2); + + let held = chain_block(2); + assert_eq!( + link_to_tip(Some(&tip), &held, screened(&held)), + Link::AlreadySeen { equivocates: false } + ); + + let differing = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert_eq!( + link_to_tip(Some(&tip), &differing, screened(&differing)), + Link::AlreadySeen { equivocates: true } + ); + + // Below the tip there is no held hash to compare against. + let below = produce_dummy_block(1, Some(HashType([9; 32])), vec![]); + assert_eq!( + link_to_tip(Some(&tip), &below, screened(&below)), + Link::AlreadySeen { equivocates: false } + ); + } + + #[test] + fn a_block_whose_header_hash_is_not_its_contents_is_refused() { + // A correctly signed block can still carry any value in `header.hash`. + let mut tampered = chain_block(3); + tampered.header.hash = HashType([9; 32]); + assert!(matches!( + screen_peer_block(&tampered, None), + Err(ScreenRefusal::HashMismatch { block_id: 3, .. }) + )); + + // And the hash verdict comes first, whatever else is wrong. + let other = lee::PublicKey::try_new([42; 32]).expect("test key"); + assert!(matches!( + screen_peer_block(&tampered, Some(&other)), + Err(ScreenRefusal::HashMismatch { .. }) + )); + } + + #[test] + fn a_block_not_signed_by_the_pinned_key_is_refused() { + let signer = lee::PublicKey::new_from_private_key( + &lee::PrivateKey::try_new([37; 32]).expect("test key"), + ); + let block = chain_block(GENESIS_BLOCK_ID); + assert_eq!( + screen_peer_block(&block, Some(&signer)), + Ok(chain_hash(GENESIS_BLOCK_ID)), + "produce_dummy_block signs with this key, so the pin must accept it" + ); + + let other = lee::PublicKey::try_new([42; 32]).expect("test key"); + assert!(matches!( + screen_peer_block(&block, Some(&other)), + Err(ScreenRefusal::KeyMismatch { + block_id: GENESIS_BLOCK_ID + }) + )); + } + + #[test] + fn a_stuck_slot_is_counted_but_never_read_past() { + // Counting is only how loud to be about a slot a reader is stuck on; + // nothing here ever moves a cursor. + let passes = vec![(Some(4), Some(3)); 3]; + assert_eq!(run_stalls(&passes).stalled, Some((4, 3))); + + let long = vec![ + (Some(4), Some(3)); + usize::try_from(STUCK_SLOT_ALERT_PASSES).expect("alert threshold fits") * 2 + ]; + assert_eq!( + run_stalls(&long).stalled, + Some((4, STUCK_SLOT_ALERT_PASSES.saturating_mul(2))), + "a slot is retried for as long as it stays stuck" + ); + } + + #[test] + fn a_stream_that_ended_before_the_stalled_slot_does_not_reset_the_count() { + // The zone-sdk ends a stream on a fetch failure exactly as it does on + // catching up. Treating that as a clean pass would reset the count for + // ever, and a reader stuck for hours would never say so. + let mut passes = vec![(Some(4), Some(3)); 5]; + passes.push((None, Some(3))); + assert_eq!( + run_stalls(&passes).stalled, + Some((4, 5)), + "the count survives a pass that never reached the stalled slot" + ); + + // Getting past it is what actually clears the stall. + let mut read_past = vec![(Some(4), Some(3)); 5]; + read_past.push((None, Some(7))); + assert_eq!(run_stalls(&read_past).stalled, None); + } + + #[test] + fn a_stall_at_a_new_slot_starts_its_own_count() { + let passes = [(Some(4), Some(3)), (Some(4), Some(3)), (Some(9), Some(8))]; + assert_eq!(run_stalls(&passes).stalled, Some((9, 1))); + } + + #[test] + fn a_stall_says_so_on_a_cadence_rather_than_once() { + // Reporting only on the crossing leaves a reader that never recovers + // looking resolved. + assert!(!alerts_at(0)); + assert!(!alerts_at(1)); + assert!(!alerts_at(STUCK_SLOT_ALERT_PASSES - 1)); + assert!(alerts_at(STUCK_SLOT_ALERT_PASSES)); + assert!(!alerts_at(STUCK_SLOT_ALERT_PASSES + 1)); + assert!(alerts_at(STUCK_SLOT_ALERT_PASSES * 3)); + } +} diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index dcd5dc006..508895f8b 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -9,6 +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. +pub use acceptance::{ + Link, OffChain, STUCK_SLOT_ALERT_PASSES, ScreenRefusal, StallState, alerts_at, + equivocation_report, link_to_tip, screen_peer_block, +}; pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; use cross_zone_inbox_core::{ CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id, @@ -21,6 +25,10 @@ use lee_core::{ }; use serde::Serialize; +pub mod acceptance; +#[cfg(any(test, feature = "test-utils"))] +pub mod test_utils; + /// The cross-zone emission fields a watcher or verifier reads off a source /// transaction, common to every emitter program. pub struct Emission { @@ -194,7 +202,7 @@ pub fn build_inbox_init_config_tx(self_zone: ZoneId) -> lee::PublicTransaction { #[must_use] pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, Account) { let account = Account { - program_owner: programs::bridge_lock().id(), + program_owner: programs::bridge_lock().id().into(), balance: amount, ..Default::default() }; diff --git a/lez/cross_zone/src/test_utils.rs b/lez/cross_zone/src/test_utils.rs new file mode 100644 index 000000000..7789be4eb --- /dev/null +++ b/lez/cross_zone/src/test_utils.rs @@ -0,0 +1,51 @@ +//! Chain-builder helpers shared by the watcher's and verifier's tests, so both +//! sides exercise the acceptance policy against identically built peer chains. + +use common::{block::Block, test_utils::produce_dummy_block, transaction::LeeTransaction}; +use cross_zone_inbox_core::ZoneId; +use lee::{ + GENESIS_BLOCK_ID, PublicTransaction, + public_transaction::{Message, WitnessSet}, +}; +use lee_core::program::ProgramId; +use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; + +/// The peer's hash-linked chain from its genesis up to and including `last`, +/// each block carrying the transactions `txs_at(block_id)` returns. Empty when +/// `last` is below [`GENESIS_BLOCK_ID`]. +pub fn linked_chain_to(last: u64, txs_at: impl Fn(u64) -> Vec) -> Vec { + let mut blocks: Vec = Vec::new(); + for block_id in GENESIS_BLOCK_ID..=last { + let prev = blocks.last().map(|block| block.header.hash); + blocks.push(produce_dummy_block(block_id, prev, txs_at(block_id))); + } + blocks +} + +/// A `ping_sender` emission aimed at `target_zone` and `target_program_id`, +/// carrying `payload`. The sender lets its caller name any target, which is why +/// routes pin the pair rather than the target alone. +#[must_use] +pub fn ping_emission( + target_zone: ZoneId, + target_program_id: ProgramId, + payload: &[u8], +) -> LeeTransaction { + let receiver_id = programs::ping_receiver().id(); + let send = SenderInstruction::Send { + target_zone, + target_program_id, + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], + payload: payload.to_vec(), + ordinal: 0, + }; + let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send) + .expect("emission serializes"); + LeeTransaction::Public(PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )) +} diff --git a/lez/explorer_service/src/api.rs b/lez/explorer_service/src/api.rs index 5984a6360..9614e8435 100644 --- a/lez/explorer_service/src/api.rs +++ b/lez/explorer_service/src/api.rs @@ -151,9 +151,8 @@ pub async fn get_transactions_by_account( #[cfg(feature = "ssr")] pub fn create_indexer_rpc_client(url: &url::Url) -> Result { use jsonrpsee::http_client::HttpClientBuilder; - use log::info; - info!("Connecting to Indexer RPC on URL: {url}"); + log::info!("Connecting to Indexer RPC on URL: {url}"); HttpClientBuilder::default() .build(url.as_str()) diff --git a/lez/indexer/core/Cargo.toml b/lez/indexer/core/Cargo.toml index c8c8590f8..a52e128b8 100644 --- a/lez/indexer/core/Cargo.toml +++ b/lez/indexer/core/Cargo.toml @@ -40,5 +40,6 @@ hex.workspace = true thiserror.workspace = true [dev-dependencies] +cross_zone = { workspace = true, features = ["test-utils"] } tempfile.workspace = true ping_core.workspace = true diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 0aef81c77..1f847d205 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -5,14 +5,20 @@ use std::{ }; use anyhow::anyhow; -use common::{block::Block, transaction::LeeTransaction}; -use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission}; +use common::{ + block::{Block, PeerChainTip}, + transaction::LeeTransaction, +}; +use cross_zone::{ + EmissionSource, Link, OffChain, StallState, alerts_at, build_dispatch_from_emission, + equivocation_report, extract_emission, link_to_tip, screen_peer_block, +}; use cross_zone_inbox_core::{ CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key, }; use futures::{Stream, StreamExt as _}; use lee::{GENESIS_BLOCK_ID, PublicKey}; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, @@ -36,10 +42,6 @@ const PEER_BLOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(300); /// sleep can overshoot, understates it. const PEER_BLOCK_POLL_INTERVAL: Duration = Duration::from_secs(1); -/// Consecutive passes a peer reader spends stuck on one slot before it says so -/// as something more than the per-pass failure. It never reads past the slot. -const STUCK_SLOT_ALERT_PASSES: u32 = 3; - /// Why a cross-zone dispatch could not be verified. /// /// A forgery is terminal and must stop the block applying; an unavailable peer @@ -73,45 +75,57 @@ type SeenKey = (MessageKey, [u8; 32]); #[derive(Default)] struct PeerChain { blocks: HashMap, - /// Highest id such that every block from [`GENESIS_BLOCK_ID`] up to it has - /// been read and each links to its predecessor. `None` until genesis is read. + /// The head of the run: the highest id such that every block from + /// [`GENESIS_BLOCK_ID`] up to it has been read and each links to its + /// predecessor, plus that block's hash, pinned when [`Self::extend_prefix`] + /// walked it. `None` until genesis is read. /// - /// This, not `max(blocks.keys())`, is what the forgery test gates on: a peer - /// picks its own `block_id`s, and an id that does not continue the run + /// The id, not `max(blocks.keys())`, is what the forgery test gates on: a + /// peer picks its own `block_id`s, and an id that does not continue the run /// cannot advance the run. /// + /// The hash is stored rather than re-read from `blocks`, so the tip + /// survives the tip block leaving the map; re-deriving it there would make + /// any future cache bounding misclassify the next honest block as + /// [`OffChain::NotTheGenesis`] and freeze the run. + /// /// The link it walks means something only because [`accept_peer_block`] /// recomputes `header.hash` and checks the pinned key before anything is /// cached. Without that it compared two fields the peer wrote. - verified_prefix: Option, + verified_prefix: Option, } impl PeerChain { /// The id that would extend the verified run. const fn next_expected(&self) -> u64 { match self.verified_prefix { - Some(prefix) => prefix.saturating_add(1), + Some(tip) => tip.block_id.saturating_add(1), None => GENESIS_BLOCK_ID, } } - /// Extends the verified run as far as the cached blocks allow. + /// Extends the verified run as far as the cached blocks allow, off the same + /// [`link_to_tip`] the watcher follows. The tip is pinned off [`Link::Next`], + /// whose hash [`accept_peer_block`] proved is the recomputed one. fn extend_prefix(&mut self) { while let Some(next) = self.blocks.get(&self.next_expected()) { - let links = match self.verified_prefix { - Some(prefix) => self - .blocks - .get(&prefix) - .is_some_and(|prev| prev.header.hash == next.header.prev_block_hash), - // Genesis has no predecessor to link to. - None => true, - }; - if !links { - return; + match link_to_tip(self.tip().as_ref(), next, next.header.hash) { + Link::Next(block_hash) => { + self.verified_prefix = Some(PeerChainTip { + block_id: next.header.block_id, + block_hash, + }); + } + Link::AlreadySeen { .. } | Link::OffChain(_) => return, } - self.verified_prefix = Some(next.header.block_id); } } + + /// The tip pinning the verified run. `None` before the peer's genesis is + /// held. + const fn tip(&self) -> Option { + self.verified_prefix + } } /// What one consistent look at the peer cache says about a referenced block. @@ -160,58 +174,84 @@ impl PeerBlocks { async fn insert(&self, zone: ZoneId, block: Block) -> bool { let mut chains = self.chains.write().await; let chain = chains.entry(zone).or_default(); - let next = chain.next_expected(); - // Below the peer's genesis as well as ahead of the run: an id under - // GENESIS_BLOCK_ID is on no chain the run can ever walk, and cached it - // would resolve as the peer's own block for ever after. - if block.header.block_id > next || block.header.block_id < GENESIS_BLOCK_ID { - debug!( - "Peer reader for {} not caching block {}: only block {next} continues the run verified from that peer's genesis.", - hex::encode(zone), - block.header.block_id - ); - return false; - } - - if let Some(held) = chain.blocks.get(&block.header.block_id) { - if held.header.hash == block.header.hash { - return false; - } - if block.header.block_id != next || !Self::extends_the_run(chain, &block) { - error!( - "Peer zone {} equivocated at block {}: holding {}, refusing {}. Nothing at or above block {} can be delivered from until that peer inscribes a block continuing the run verified from its genesis.", + // `block` was screened on the way in, so `header.hash` is its + // recomputed hash and is what the synthesized tip pins. + let link = link_to_tip(chain.tip().as_ref(), &block, block.header.hash); + let continues_the_run = matches!(link, Link::Next(_)); + match link { + // Ahead of the run, or a first read that is not the peer's genesis. + Link::OffChain(OffChain::NotTheGenesis { .. } | OffChain::SkipsAhead { .. }) => { + debug!( + "Peer reader for {} not caching block {}: only block {} continues the run verified from that peer's genesis.", hex::encode(zone), block.header.block_id, - held.header.hash, - block.header.hash, - block.header.block_id + chain.next_expected() ); - return false; + false + } + Link::AlreadySeen { .. } => { + // Every id the run has walked is held; below the peer's genesis + // nothing is, and an id on no chain the run can ever walk must + // not be cached, or it would resolve as the peer's own block + // for ever after. + match chain.blocks.get(&block.header.block_id) { + Some(held) if held.header.hash == block.header.hash => false, + Some(held) => { + error!( + "{}", + equivocation_report( + &zone, + block.header.block_id, + held.header.hash, + block.header.hash + ) + ); + false + } + None => { + debug!( + "Peer reader for {} not caching block {}: below the peer's genesis, on no chain the run can walk.", + hex::encode(zone), + block.header.block_id + ); + false + } + } + } + // The one id that would extend the run. A block linking to the tip + // continues it; one that does not is cached only while the id is + // free, first write wins. + Link::Next(_) | Link::OffChain(OffChain::DoesNotLink { .. }) => { + if let Some(held) = chain.blocks.get(&block.header.block_id) { + if held.header.hash == block.header.hash { + return false; + } + if !continues_the_run { + error!( + "{}", + equivocation_report( + &zone, + block.header.block_id, + held.header.hash, + block.header.hash + ) + ); + return false; + } + log::info!( + "Peer zone {} block {}: replacing held block {} with {}, which continues the verified run where the held one never could.", + hex::encode(zone), + block.header.block_id, + held.header.hash, + block.header.hash + ); + } + chain.blocks.insert(block.header.block_id, block); + chain.extend_prefix(); + true } - info!( - "Peer zone {} block {}: replacing held block {} with {}, which continues the verified run where the held one never could.", - hex::encode(zone), - block.header.block_id, - held.header.hash, - block.header.hash - ); } - - chain.blocks.insert(block.header.block_id, block); - chain.extend_prefix(); - true - } - - /// Whether `block` links to the block at the head of the verified run. - /// - /// False before the peer's genesis has been read, so the first block at that - /// id wins and is never displaced, which is how the watcher anchors too. - fn extends_the_run(chain: &PeerChain, block: &Block) -> bool { - chain - .verified_prefix - .and_then(|prefix| chain.blocks.get(&prefix)) - .is_some_and(|tip| block.header.prev_block_hash == tip.header.hash) } /// Resolves `block_id` under a single read lock. @@ -235,7 +275,10 @@ impl PeerBlocks { let Some(chain) = chains.get(&zone) else { return PeerLookup::Behind; }; - if chain.verified_prefix.is_none_or(|prefix| prefix < block_id) { + if chain + .verified_prefix + .is_none_or(|tip| tip.block_id < block_id) + { return PeerLookup::Behind; } chain @@ -264,6 +307,7 @@ impl PeerBlocks { .await .get(&zone) .and_then(|chain| chain.verified_prefix) + .map(|tip| tip.block_id) } } @@ -363,7 +407,7 @@ impl CrossZoneVerifier { ))); } - info!( + log::info!( "Verified cross-zone dispatch from zone {} block {} tx {}", hex::encode(msg.src_zone), msg.src_block_id, @@ -513,7 +557,7 @@ impl CrossZoneVerifier { }); } if !waited.is_zero() && waited.as_secs().is_multiple_of(LAG_LOG_INTERVAL.as_secs()) { - info!( + log::info!( "Waiting for peer zone {} to finalize block {} ({}s); reader is behind", hex::encode(zone), block_id, @@ -542,41 +586,26 @@ fn seen_key(msg: &CrossZoneMessage) -> SeenKey { ) } -/// Whether a block read off a peer's channel may enter the cache. The channel -/// authorizes who may write, not what they may claim. +/// Whether a block read off a peer's channel may enter the cache, screened by +/// the same [`screen_peer_block`] policy the watcher applies. [`ScreenRefusal`] +/// says why each check exists. /// -/// The hash check is unconditional: `header.hash` is a field the peer wrote and -/// the prefix walk compares it against the next block's `prev_block_hash`, so -/// without recomputing it a peer can assert links it never built. The key check -/// applies only when one is pinned, mirroring the watcher; it subsumes the hash -/// check, but a peer with no pinned key still gets that one. +/// [`ScreenRefusal`]: cross_zone::ScreenRefusal fn accept_peer_block( block: &Block, peer_zone: ZoneId, expected_pubkey: Option<&PublicKey>, ) -> bool { - if block.recompute_hash() != block.header.hash { - warn!( - "Peer reader dropping block {} from {}: header hash {} does not match its contents", - block.header.block_id, - hex::encode(peer_zone), - block.header.hash - ); - return false; + match screen_peer_block(block, expected_pubkey) { + Ok(_) => true, + Err(refusal) => { + warn!( + "Peer reader dropping block from {}: {refusal}", + hex::encode(peer_zone) + ); + false + } } - - if let Some(expected) = expected_pubkey - && !block.is_signed_by(expected) - { - warn!( - "Peer reader dropping block {} from {}: not signed by the pinned block-signing key", - block.header.block_id, - hex::encode(peer_zone) - ); - return false; - } - - true } /// Reads a peer zone's finalized blocks from Bedrock into the shared cache. @@ -591,16 +620,15 @@ async fn read_peer( peers: PeerBlocks, poll_interval: Duration, ) { - info!( + log::info!( "Cross-zone peer reader started for {}", hex::encode(peer_zone) ); let mut cursor = None; - // The slot the reader is stuck on and how many passes it has spent there. - // Keyed by slot so a failure at a new slot does not inherit an older slot's - // count, and used only to say so once rather than every pass. - let mut stalled: Option<(Slot, u32)> = None; + // In memory only: it says how loud to be about a slot this reader is stuck + // on. + let mut stall = StallState::default(); loop { match zone_indexer.next_messages(cursor).await { Ok(stream) => { @@ -613,23 +641,13 @@ async fn read_peer( ) .await; cursor = pass.cursor; - if let Some(slot) = pass.stalled_at { - let attempts = match stalled { - Some((prev, attempts)) if prev == slot => attempts.saturating_add(1), - _ => 1, - }; - stalled = Some((slot, attempts)); - // Every threshold rather than on the crossing alone: a stall - // that never clears would otherwise be reported once and - // then look resolved for as long as it lasts. - if attempts > 0 && attempts.is_multiple_of(STUCK_SLOT_ALERT_PASSES) { - error!( - "Peer reader for {} has been stuck at slot {slot:?} for {attempts} passes. The run verified from that peer's genesis stops below it, so every dispatch naming a later block stalls until this slot can be read.", - hex::encode(peer_zone) - ); - } - } else { - stalled = None; + if let Some((slot, attempts)) = stall.after_pass(pass.stalled_at, pass.cursor) + && alerts_at(attempts) + { + error!( + "Peer reader for {} has been stuck at slot {slot:?} for {attempts} passes. The run verified from that peer's genesis stops below it, so every dispatch naming a later block stalls until this slot can be read.", + hex::encode(peer_zone) + ); } } Err(err) => error!( @@ -711,14 +729,12 @@ where #[cfg(test)] mod tests { use common::{HashType, test_utils::produce_dummy_block}; + use cross_zone::test_utils::{linked_chain_to, ping_emission}; use futures::stream; - use lee::{ - PrivateKey, PublicKey, PublicTransaction, - public_transaction::{Message, WitnessSet}, - }; + use lee::{PrivateKey, PublicKey}; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; - use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; + use ping_core::{ping_record_pda, receiver_config_account_id}; use super::*; @@ -744,23 +760,7 @@ mod tests { /// A `ping_sender` emission addressed to `SELF_ZONE` carrying `payload`. fn emission(payload: &[u8]) -> LeeTransaction { - let receiver_id = programs::ping_receiver().id(); - let send = SenderInstruction::Send { - target_zone: SELF_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: payload.to_vec(), - ordinal: 0, - }; - let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send) - .expect("emission serializes"); - LeeTransaction::Public(PublicTransaction::new( - message, - WitnessSet::from_raw_parts(vec![]), - )) + ping_emission(SELF_ZONE, programs::ping_receiver().id(), payload) } /// A peer-stream item inscribing `data` at `slot`. @@ -777,18 +777,10 @@ mod tests { /// A hash-linked chain of `len` blocks from genesis, each carrying a `b"hi"` /// emission. Only a chain built this way advances the verified prefix. fn linked_chain(len: u64) -> Vec { - let mut prev = None; - let mut blocks = Vec::new(); - for offset in 0..len { - let block = produce_dummy_block( - GENESIS_BLOCK_ID.saturating_add(offset), - prev, - vec![emission(b"hi")], - ); - prev = Some(block.header.hash); - blocks.push(block); - } - blocks + linked_chain_to( + GENESIS_BLOCK_ID.saturating_add(len).saturating_sub(1), + |_| vec![emission(b"hi")], + ) } /// A peer-stream item carrying `block`. @@ -800,14 +792,13 @@ mod tests { /// `PEER_BLOCK_ID`, carries a `payload` emission. The run is what makes that /// block deliverable. fn peer_chain(payload: &[u8]) -> Vec { - let mut chain = linked_chain(PEER_BLOCK_ID.saturating_sub(GENESIS_BLOCK_ID)); - let prev = chain.last().map(|block| block.header.hash); - chain.push(produce_dummy_block( - PEER_BLOCK_ID, - prev, - vec![emission(payload)], - )); - chain + linked_chain_to(PEER_BLOCK_ID, |block_id| { + vec![if block_id == PEER_BLOCK_ID { + emission(payload) + } else { + emission(b"hi") + }] + }) } /// Caches a run so its last block sits inside the verified prefix. @@ -1075,6 +1066,42 @@ mod tests { )); } + #[tokio::test] + async fn the_tip_survives_the_tip_block_leaving_the_cache() { + // The tip is pinned at walk time, not re-derived from the blocks map: + // re-derived, evicting the tip block (any future cache bounding) would + // read as no tip at all, and the next honest block would misclassify as + // NotTheGenesis and freeze the run for good. + let peers = PeerBlocks::default(); + let chain = linked_chain(3); + for block in chain.iter().take(2).cloned() { + peers.insert(PEER_ZONE, block).await; + } + peers + .chains + .write() + .await + .get_mut(&PEER_ZONE) + .expect("chain exists") + .blocks + .remove(&2); + + assert!( + peers.insert(PEER_ZONE, chain[2].clone()).await, + "the next block still extends the run off the pinned tip" + ); + assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(3)); + // The evicted id keeps its classification: inside the run and absent. + assert!(matches!( + peers.resolve(PEER_ZONE, 2).await, + PeerLookup::InsideRun + )); + assert!(matches!( + peers.resolve(PEER_ZONE, 4).await, + PeerLookup::Behind + )); + } + #[tokio::test] async fn peer_reader_holds_its_cursor_on_an_undecodable_block() { let peers = PeerBlocks::default(); diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index ccf8dbe7b..994203219 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -7,7 +7,7 @@ use chain_state::{Anchor, ChainConsistency}; use common::block::Block; // TODO: Remove after testnet use futures::StreamExt as _; -use log::{error, info, warn}; +use log::{error, warn}; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, }; @@ -270,9 +270,9 @@ impl IndexerCore { let mut retry_gate = ApplyRetryGate::new(); if let Some(slot) = &cursor { - info!("Resuming indexer from cursor {slot:?}"); + log::info!("Resuming indexer from cursor {slot:?}"); } else { - info!("Starting indexer from beginning of channel"); + log::info!("Starting indexer from beginning of channel"); } loop { @@ -372,12 +372,12 @@ impl IndexerCore { verifier.record_seen(verified_keys).await; } retry_gate.reset(); - info!("Indexed L2 block {}", block.header.block_id); + log::info!("Indexed L2 block {} at channel {}", block.header.block_id, self.config.channel_id); self.set_status(IndexerSyncStatus::syncing()); yield Ok(block); } Ok(AcceptOutcome::AlreadyApplied) => { - info!( + log::info!( "Skipping already-applied block {}", block.header.block_id ); diff --git a/lez/indexer/ffi/indexer_ffi.h b/lez/indexer/ffi/indexer_ffi.h index 16e5a17c3..583670beb 100644 --- a/lez/indexer/ffi/indexer_ffi.h +++ b/lez/indexer/ffi/indexer_ffi.h @@ -202,7 +202,7 @@ typedef struct FfiPublicTransactionBody { * byte arrays since C doesn't have native u128 support. */ typedef struct FfiAccount { - struct FfiProgramId program_owner; + struct FfiBytes32 program_owner; /** * Balance as little-endian [u8; 16]. */ diff --git a/lez/indexer/ffi/src/api/types/account.rs b/lez/indexer/ffi/src/api/types/account.rs index f2eb8e589..e68f4a979 100644 --- a/lez/indexer/ffi/src/api/types/account.rs +++ b/lez/indexer/ffi/src/api/types/account.rs @@ -1,6 +1,4 @@ -use indexer_service_protocol::ProgramId; - -use crate::api::types::{FfiBytes32, FfiProgramId, FfiU128}; +use crate::api::types::{FfiBytes32, FfiU128}; /// Account data structure - C-compatible version of lee Account. /// @@ -8,7 +6,7 @@ use crate::api::types::{FfiBytes32, FfiProgramId, FfiU128}; /// byte arrays since C doesn't have native u128 support. #[repr(C)] pub struct FfiAccount { - pub program_owner: FfiProgramId, + pub program_owner: FfiBytes32, /// Balance as little-endian [u8; 16]. pub balance: FfiU128, /// Pointer to account data bytes. @@ -40,11 +38,8 @@ impl From for FfiAccount { let (data, data_len, data_cap) = data.into_inner().into_raw_parts(); - let program_owner = FfiProgramId { - data: program_owner, - }; Self { - program_owner, + program_owner: FfiBytes32::from_account_id(&program_owner), balance: balance.into(), data, data_len, @@ -66,7 +61,9 @@ impl From for indexer_service_protocol::Account { } = value; Self { - program_owner: ProgramId(program_owner.data), + program_owner: indexer_service_protocol::AccountId { + value: program_owner.data, + }, balance: balance.into(), data: indexer_service_protocol::Data(unsafe { Vec::from_raw_parts(data, data_len, data_cap) @@ -88,7 +85,9 @@ impl From<&FfiAccount> for indexer_service_protocol::Account { } = value; Self { - program_owner: ProgramId(program_owner.data), + program_owner: indexer_service_protocol::AccountId { + value: program_owner.data, + }, balance: balance.into(), data: indexer_service_protocol::Data(unsafe { Vec::from_raw_parts(data, data_len, data_cap) diff --git a/lez/indexer/service/protocol/src/lib.rs b/lez/indexer/service/protocol/src/lib.rs index fe1fa525a..e70d2a4f0 100644 --- a/lez/indexer/service/protocol/src/lib.rs +++ b/lez/indexer/service/protocol/src/lib.rs @@ -131,7 +131,7 @@ impl FromStr for AccountId { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct Account { - pub program_owner: ProgramId, + pub program_owner: AccountId, pub balance: u128, pub data: Data, pub nonce: Nonce, diff --git a/lez/indexer/service/src/lib.rs b/lez/indexer/service/src/lib.rs index aa142b386..24c056464 100644 --- a/lez/indexer/service/src/lib.rs +++ b/lez/indexer/service/src/lib.rs @@ -4,7 +4,7 @@ use anyhow::{Context as _, Result}; pub use indexer_core::config::*; use indexer_service_rpc::RpcServer as _; use jsonrpsee::server::{Server, ServerHandle}; -use log::{error, info}; +use log::error; use tokio_util::sync::CancellationToken; pub mod service; @@ -84,7 +84,7 @@ pub async fn run_server( .local_addr() .context("Failed to get local address of RPC server")?; - info!("Starting Indexer Service RPC server on {addr}"); + log::info!("Starting Indexer Service RPC server on {addr}"); #[cfg(not(feature = "mock-responses"))] let handle = { diff --git a/lez/indexer/service/src/main.rs b/lez/indexer/service/src/main.rs index 52f195e99..e1734e3b6 100644 --- a/lez/indexer/service/src/main.rs +++ b/lez/indexer/service/src/main.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use anyhow::Result; use clap::Parser; -use log::{error, info}; +use log::error; use tokio_util::sync::CancellationToken; #[derive(Debug, Parser)] @@ -40,14 +40,14 @@ async fn main() -> Result<()> { tokio::select! { () = cancellation_token.cancelled() => { - info!("Shutting down server..."); + log::info!("Shutting down server..."); } () = indexer_handle.stopped() => { error!("Server stopped unexpectedly"); } } - info!("Server shutdown complete"); + log::info!("Server shutdown complete"); Ok(()) } @@ -61,7 +61,7 @@ fn listen_for_shutdown_signal() -> CancellationToken { error!("Failed to listen for Ctrl-C signal: {err}"); return; } - info!("Received Ctrl-C signal"); + log::info!("Received Ctrl-C signal"); cancellation_token_clone.cancel(); }); diff --git a/lez/indexer/service/src/mock_service.rs b/lez/indexer/service/src/mock_service.rs index c9a1912a1..168d06b7a 100644 --- a/lez/indexer/service/src/mock_service.rs +++ b/lez/indexer/service/src/mock_service.rs @@ -105,7 +105,9 @@ impl MockIndexerService { accounts.insert( *account_id, Account { - program_owner: ProgramId([i as u32; 8]), + program_owner: AccountId { + value: [i as u8; 32], + }, balance: 1000 * (i as u128 + 1), data: Data(vec![0xaa, 0xbb, 0xcc]), nonce: i as u128, @@ -386,7 +388,7 @@ fn mock_privacy_preserving_tx( public_actions: vec![PublicActionWithID { account_id: account_ids[tx_idx as usize % account_ids.len()], post_state: Account { - program_owner: ProgramId([1_u32; 8]), + program_owner: AccountId { value: [1_u8; 32] }, balance: 500, data: Data(vec![0xdd, 0xee]), nonce: block_id as u128, diff --git a/lez/indexer/service/src/service.rs b/lez/indexer/service/src/service.rs index 097593624..78dcea153 100644 --- a/lez/indexer/service/src/service.rs +++ b/lez/indexer/service/src/service.rs @@ -12,7 +12,7 @@ use jsonrpsee::{ core::{Serialize, SubscriptionResult, async_trait}, types::{ErrorCode, ErrorObject, ErrorObjectOwned}, }; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use tokio::sync::mpsc::UnboundedSender; use tokio_util::sync::CancellationToken; @@ -44,7 +44,7 @@ impl indexer_service_rpc::RpcServer for IndexerService { subscription_sink: jsonrpsee::PendingSubscriptionSink, ) -> SubscriptionResult { let sink = subscription_sink.accept().await?; - info!( + log::info!( "Accepted new subscription to finalized blocks with ID {:?}", sink.subscription_id() ); @@ -250,14 +250,14 @@ impl SubscriptionService { loop { tokio::select! { () = shutdown.cancelled() => { - info!("Shutdown requested; stopping block ingestion"); + log::info!("Shutdown requested; stopping block ingestion"); return Ok(()); } sub = sub_receiver.recv() => { let Some(subscription) = sub else { bail!("Subscription receiver closed unexpectedly"); }; - info!("Added new subscription with ID {:?}", subscription.sink.subscription_id()); + log::info!("Added new subscription with ID {:?}", subscription.sink.subscription_id()); subscribers.push(subscription); } block_opt = block_stream.next() => { @@ -332,7 +332,7 @@ impl Subscription { impl Drop for Subscription { fn drop(&mut self) { - info!( + log::info!( "Subscription with ID {:?} is being dropped", self.sink.subscription_id() ); diff --git a/lez/programs/Cargo.toml b/lez/programs/Cargo.toml index 30df01e51..609e7d7ff 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"] @@ -114,6 +119,7 @@ programs = [ "dep:bridge_lock_core", "dep:wrapped_token_core", "dep:ping_core", + "dep:sequencer_stake_core", ] [dependencies] @@ -134,6 +140,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/amm/src/add.rs b/lez/programs/amm/src/add.rs index 807f04d09..f3f76f8d6 100644 --- a/lez/programs/amm/src/add.rs +++ b/lez/programs/amm/src/add.rs @@ -133,7 +133,8 @@ pub fn add_liquidity( }; pool_post.data = Data::from(&pool_post_definition); - let token_program_id = user_holding_a.account.program_owner; + let token_program_id: lee_core::program::ProgramId = + user_holding_a.account.program_owner.into(); // Chain call for Token A (UserHoldingA -> Vault_A) let call_token_a = ChainedCall::new( diff --git a/lez/programs/amm/src/new_definition.rs b/lez/programs/amm/src/new_definition.rs index a6111967e..37099adbe 100644 --- a/lez/programs/amm/src/new_definition.rs +++ b/lez/programs/amm/src/new_definition.rs @@ -111,7 +111,8 @@ pub fn new_definition( let pool_pda_seed = compute_pool_pda_seed(definition_token_a_id, definition_token_b_id); let pool_post = AccountPostState::new_claimed_if_default(pool_post, Claim::Pda(pool_pda_seed)); - let token_program_id = user_holding_a.account.program_owner; + let token_program_id: lee_core::program::ProgramId = + user_holding_a.account.program_owner.into(); // Chain call for Token A (user_holding_a -> Vault_A) let vault_a_seed = compute_vault_pda_seed(pool.account_id, definition_token_a_id); diff --git a/lez/programs/amm/src/remove.rs b/lez/programs/amm/src/remove.rs index 5c492509a..18d60d140 100644 --- a/lez/programs/amm/src/remove.rs +++ b/lez/programs/amm/src/remove.rs @@ -113,7 +113,8 @@ pub fn remove_liquidity( pool_post.data = Data::from(&pool_post_definition); - let token_program_id = user_holding_a.account.program_owner; + let token_program_id: lee_core::program::ProgramId = + user_holding_a.account.program_owner.into(); // Chaincall for Token A withdraw let call_token_a = ChainedCall::new( diff --git a/lez/programs/amm/src/swap.rs b/lez/programs/amm/src/swap.rs index a76d5bcfe..42bc451ae 100644 --- a/lez/programs/amm/src/swap.rs +++ b/lez/programs/amm/src/swap.rs @@ -182,7 +182,7 @@ fn swap_logic( ); assert!(withdraw_amount != 0, "Withdraw amount should be nonzero"); - let token_program_id = user_deposit.account.program_owner; + let token_program_id: lee_core::program::ProgramId = user_deposit.account.program_owner.into(); let mut chained_calls = Vec::new(); chained_calls.push(ChainedCall::new( @@ -314,7 +314,7 @@ fn exact_output_swap_logic( "Required input exceeds maximum amount in" ); - let token_program_id = user_deposit.account.program_owner; + let token_program_id: lee_core::program::ProgramId = user_deposit.account.program_owner.into(); let mut chained_calls = Vec::new(); chained_calls.push(ChainedCall::new( diff --git a/lez/programs/amm/src/tests.rs b/lez/programs/amm/src/tests.rs index e98c33a09..7f0b98b1f 100644 --- a/lez/programs/amm/src/tests.rs +++ b/lez/programs/amm/src/tests.rs @@ -511,7 +511,7 @@ impl AccountWithMetadataForTests { fn user_holding_a() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_a_definition_id(), @@ -527,7 +527,7 @@ impl AccountWithMetadataForTests { fn user_holding_b() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_b_definition_id(), @@ -543,7 +543,7 @@ impl AccountWithMetadataForTests { fn vault_a_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_a_definition_id(), @@ -559,7 +559,7 @@ impl AccountWithMetadataForTests { fn vault_b_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_b_definition_id(), @@ -575,7 +575,7 @@ impl AccountWithMetadataForTests { fn vault_a_init_high() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_a_definition_id(), @@ -591,7 +591,7 @@ impl AccountWithMetadataForTests { fn vault_b_init_high() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_b_definition_id(), @@ -607,7 +607,7 @@ impl AccountWithMetadataForTests { fn vault_a_init_low() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_a_definition_id(), @@ -623,7 +623,7 @@ impl AccountWithMetadataForTests { fn vault_b_init_low() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_b_definition_id(), @@ -639,7 +639,7 @@ impl AccountWithMetadataForTests { fn vault_a_init_zero() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_a_definition_id(), @@ -655,7 +655,7 @@ impl AccountWithMetadataForTests { fn vault_b_init_zero() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_b_definition_id(), @@ -671,7 +671,7 @@ impl AccountWithMetadataForTests { fn pool_lp_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -688,7 +688,7 @@ impl AccountWithMetadataForTests { fn pool_lp_with_wrong_id() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -705,7 +705,7 @@ impl AccountWithMetadataForTests { fn user_holding_lp_uninit() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_lp_definition_id(), @@ -721,7 +721,7 @@ impl AccountWithMetadataForTests { fn user_holding_lp_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_lp_definition_id(), @@ -737,7 +737,7 @@ impl AccountWithMetadataForTests { fn pool_definition_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -761,7 +761,7 @@ impl AccountWithMetadataForTests { fn pool_definition_init_reserve_a_zero() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -785,7 +785,7 @@ impl AccountWithMetadataForTests { fn pool_definition_init_reserve_b_zero() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -809,7 +809,7 @@ impl AccountWithMetadataForTests { fn pool_definition_init_reserve_a_low() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -833,7 +833,7 @@ impl AccountWithMetadataForTests { fn pool_definition_init_reserve_b_low() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -857,7 +857,7 @@ impl AccountWithMetadataForTests { fn pool_definition_swap_test_1() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -881,7 +881,7 @@ impl AccountWithMetadataForTests { fn pool_definition_swap_test_2() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -905,7 +905,7 @@ impl AccountWithMetadataForTests { fn pool_definition_swap_exact_output_test_1() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -929,7 +929,7 @@ impl AccountWithMetadataForTests { fn pool_definition_swap_exact_output_test_2() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -953,7 +953,7 @@ impl AccountWithMetadataForTests { fn pool_definition_add_zero_lp() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -977,7 +977,7 @@ impl AccountWithMetadataForTests { fn pool_definition_add_successful() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -1001,7 +1001,7 @@ impl AccountWithMetadataForTests { fn pool_definition_remove_successful() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -1025,7 +1025,7 @@ impl AccountWithMetadataForTests { fn pool_definition_inactive() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -1049,7 +1049,7 @@ impl AccountWithMetadataForTests { fn pool_definition_with_wrong_id() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -1073,7 +1073,7 @@ impl AccountWithMetadataForTests { fn vault_a_with_wrong_id() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_a_definition_id(), @@ -1089,7 +1089,7 @@ impl AccountWithMetadataForTests { fn vault_b_with_wrong_id() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_b_definition_id(), @@ -1105,7 +1105,7 @@ impl AccountWithMetadataForTests { fn pool_definition_active() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -1349,7 +1349,7 @@ impl IdForExeTests { impl AccountsForExeTests { fn user_token_a_holding() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1361,7 +1361,7 @@ impl AccountsForExeTests { fn user_token_b_holding() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1373,7 +1373,7 @@ impl AccountsForExeTests { fn pool_definition_init() -> Account { Account { - program_owner: programs::amm().id(), + program_owner: programs::amm().id().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForExeTests::token_a_definition_id(), @@ -1393,7 +1393,7 @@ impl AccountsForExeTests { fn token_a_definition_account() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -1406,7 +1406,7 @@ impl AccountsForExeTests { fn token_b_definition_acc() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -1419,7 +1419,7 @@ impl AccountsForExeTests { fn token_lp_definition_acc() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("LP Token"), @@ -1432,7 +1432,7 @@ impl AccountsForExeTests { fn vault_a_init() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1444,7 +1444,7 @@ impl AccountsForExeTests { fn vault_b_init() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1456,7 +1456,7 @@ impl AccountsForExeTests { fn user_token_lp_holding() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_lp_definition_id(), @@ -1468,7 +1468,7 @@ impl AccountsForExeTests { fn vault_a_swap_1() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1480,7 +1480,7 @@ impl AccountsForExeTests { fn vault_b_swap_1() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1492,7 +1492,7 @@ impl AccountsForExeTests { fn pool_definition_swap_1() -> Account { Account { - program_owner: programs::amm().id(), + program_owner: programs::amm().id().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForExeTests::token_a_definition_id(), @@ -1512,7 +1512,7 @@ impl AccountsForExeTests { fn user_token_a_holding_swap_1() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1524,7 +1524,7 @@ impl AccountsForExeTests { fn user_token_b_holding_swap_1() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1536,7 +1536,7 @@ impl AccountsForExeTests { fn vault_a_swap_2() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1548,7 +1548,7 @@ impl AccountsForExeTests { fn vault_b_swap_2() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1560,7 +1560,7 @@ impl AccountsForExeTests { fn pool_definition_swap_2() -> Account { Account { - program_owner: programs::amm().id(), + program_owner: programs::amm().id().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForExeTests::token_a_definition_id(), @@ -1580,7 +1580,7 @@ impl AccountsForExeTests { fn user_token_a_holding_swap_2() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1592,7 +1592,7 @@ impl AccountsForExeTests { fn user_token_b_holding_swap_2() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1604,7 +1604,7 @@ impl AccountsForExeTests { fn vault_a_add() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1616,7 +1616,7 @@ impl AccountsForExeTests { fn vault_b_add() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1628,7 +1628,7 @@ impl AccountsForExeTests { fn pool_definition_add() -> Account { Account { - program_owner: programs::amm().id(), + program_owner: programs::amm().id().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForExeTests::token_a_definition_id(), @@ -1648,7 +1648,7 @@ impl AccountsForExeTests { fn user_token_a_holding_add() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1660,7 +1660,7 @@ impl AccountsForExeTests { fn user_token_b_holding_add() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1672,7 +1672,7 @@ impl AccountsForExeTests { fn user_token_lp_holding_add() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_lp_definition_id(), @@ -1684,7 +1684,7 @@ impl AccountsForExeTests { fn token_lp_definition_add() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("LP Token"), @@ -1697,7 +1697,7 @@ impl AccountsForExeTests { fn vault_a_remove() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1709,7 +1709,7 @@ impl AccountsForExeTests { fn vault_b_remove() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1721,7 +1721,7 @@ impl AccountsForExeTests { fn pool_definition_remove() -> Account { Account { - program_owner: programs::amm().id(), + program_owner: programs::amm().id().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForExeTests::token_a_definition_id(), @@ -1741,7 +1741,7 @@ impl AccountsForExeTests { fn user_token_a_holding_remove() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1753,7 +1753,7 @@ impl AccountsForExeTests { fn user_token_b_holding_remove() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1765,7 +1765,7 @@ impl AccountsForExeTests { fn user_token_lp_holding_remove() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_lp_definition_id(), @@ -1777,7 +1777,7 @@ impl AccountsForExeTests { fn token_lp_definition_remove() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("LP Token"), @@ -1790,7 +1790,7 @@ impl AccountsForExeTests { fn token_lp_definition_init_inactive() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("LP Token"), @@ -1803,7 +1803,7 @@ impl AccountsForExeTests { fn vault_a_init_inactive() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1815,7 +1815,7 @@ impl AccountsForExeTests { fn vault_b_init_inactive() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1827,7 +1827,7 @@ impl AccountsForExeTests { fn pool_definition_inactive() -> Account { Account { - program_owner: programs::amm().id(), + program_owner: programs::amm().id().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForExeTests::token_a_definition_id(), @@ -1847,7 +1847,7 @@ impl AccountsForExeTests { fn user_token_a_holding_new_init() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_a_definition_id(), @@ -1859,7 +1859,7 @@ impl AccountsForExeTests { fn user_token_b_holding_new_init() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_b_definition_id(), @@ -1871,7 +1871,7 @@ impl AccountsForExeTests { fn user_token_lp_holding_new_init() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_lp_definition_id(), @@ -1883,7 +1883,7 @@ impl AccountsForExeTests { fn token_lp_definition_new_init() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("LP Token"), @@ -1896,7 +1896,7 @@ impl AccountsForExeTests { fn pool_definition_new_init() -> Account { Account { - program_owner: programs::amm().id(), + program_owner: programs::amm().id().into(), balance: 0_u128, data: Data::from(&PoolDefinition { definition_token_a_id: IdForExeTests::token_a_definition_id(), @@ -1916,7 +1916,7 @@ impl AccountsForExeTests { fn user_token_lp_holding_init_zero() -> Account { Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForExeTests::token_lp_definition_id(), @@ -2901,7 +2901,7 @@ fn swap_exact_output_overflow_protection() { let pool = AccountWithMetadata { account: Account { - program_owner: ProgramId::default(), + program_owner: ProgramId::default().into(), balance: 0, data: Data::from(&PoolDefinition { definition_token_a_id: IdForTests::token_a_definition_id(), @@ -2923,7 +2923,7 @@ fn swap_exact_output_overflow_protection() { let vault_a = AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_a_definition_id(), @@ -2937,7 +2937,7 @@ fn swap_exact_output_overflow_protection() { let vault_b = AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::token_b_definition_id(), diff --git a/lez/programs/associated_token_account/src/burn.rs b/lez/programs/associated_token_account/src/burn.rs index 09d1645a1..3102e29f9 100644 --- a/lez/programs/associated_token_account/src/burn.rs +++ b/lez/programs/associated_token_account/src/burn.rs @@ -11,7 +11,7 @@ pub fn burn_from_associated_token_account( ata_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { - let token_program_id = holder_ata.account.program_owner; + let token_program_id: lee_core::program::ProgramId = holder_ata.account.program_owner.into(); assert!(owner.is_authorized, "Owner authorization is missing"); let definition_id = TokenHolding::try_from(&holder_ata.account.data) .expect("Holder ATA must hold a valid token") diff --git a/lez/programs/associated_token_account/src/create.rs b/lez/programs/associated_token_account/src/create.rs index 4e1b2074d..e19fc3198 100644 --- a/lez/programs/associated_token_account/src/create.rs +++ b/lez/programs/associated_token_account/src/create.rs @@ -10,7 +10,8 @@ pub fn create_associated_token_account( ata_program_id: ProgramId, ) -> (Vec, Vec) { // No authorization check needed: create is idempotent, so anyone can call it safely. - let token_program_id = token_definition.account.program_owner; + let token_program_id: lee_core::program::ProgramId = + token_definition.account.program_owner.into(); let ata_seed = associated_token_account_core::verify_ata_and_get_seed( &ata_account, &owner, diff --git a/lez/programs/associated_token_account/src/tests.rs b/lez/programs/associated_token_account/src/tests.rs index f244f6cd3..46e1afa80 100644 --- a/lez/programs/associated_token_account/src/tests.rs +++ b/lez/programs/associated_token_account/src/tests.rs @@ -33,7 +33,7 @@ fn owner_account() -> AccountWithMetadata { fn definition_account() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0, data: Data::from(&TokenDefinition::Fungible { name: "TEST".to_string(), @@ -58,7 +58,7 @@ fn uninitialized_ata_account() -> AccountWithMetadata { fn initialized_ata_account() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: TOKEN_PROGRAM_ID, + program_owner: TOKEN_PROGRAM_ID.into(), balance: 0, data: Data::from(&TokenHolding::Fungible { definition_id: definition_id(), diff --git a/lez/programs/associated_token_account/src/transfer.rs b/lez/programs/associated_token_account/src/transfer.rs index dbe388038..ecdfeae93 100644 --- a/lez/programs/associated_token_account/src/transfer.rs +++ b/lez/programs/associated_token_account/src/transfer.rs @@ -11,7 +11,7 @@ pub fn transfer_from_associated_token_account( ata_program_id: ProgramId, amount: u128, ) -> (Vec, Vec) { - let token_program_id = sender_ata.account.program_owner; + let token_program_id: lee_core::program::ProgramId = sender_ata.account.program_owner.into(); assert!(owner.is_authorized, "Owner authorization is missing"); let definition_id = TokenHolding::try_from(&sender_ata.account.data) .expect("Sender ATA must hold a valid token") diff --git a/lez/programs/authenticated_transfer/src/main.rs b/lez/programs/authenticated_transfer/src/main.rs index cb5350390..746ad5928 100644 --- a/lez/programs/authenticated_transfer/src/main.rs +++ b/lez/programs/authenticated_transfer/src/main.rs @@ -2,7 +2,8 @@ use authenticated_transfer_core::Instruction; use lee_core::{ account::{Account, AccountWithMetadata}, program::{ - AccountPostState, Claim, DEFAULT_PROGRAM_ID, ProgramInput, ProgramOutput, read_lee_inputs, + AccountPostState, Claim, DEFAULT_PROGRAM_OWNER, ProgramInput, ProgramOutput, + read_lee_inputs, }, }; @@ -48,7 +49,7 @@ fn transfer( .expect("Recipient balance overflow"); // Claim recipient account if it has default program owner - if recipient_post_account.program_owner == DEFAULT_PROGRAM_ID { + if recipient_post_account.program_owner == DEFAULT_PROGRAM_OWNER { AccountPostState::new_claimed(recipient_post_account, Claim::Authorized) } else { AccountPostState::new(recipient_post_account) diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index 8b2f174ec..5e0e9e24c 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -131,7 +131,8 @@ fn lock( // genuine holding: a caller cannot substitute an account owned by some other // program to emit the mint without an actual lock. assert_eq!( - holder.account.program_owner, self_program_id, + holder.account.program_owner, + self_program_id.into(), "holder account must be a bridge_lock holding" ); assert_eq!( @@ -217,7 +218,8 @@ fn init_config( // `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, + config.account.program_owner, + self_program_id.into(), "bridge-lock config PDA is owned by another program" ); assert_eq!( diff --git a/lez/programs/clock/src/main.rs b/lez/programs/clock/src/main.rs index 9249a1111..7bb2c9aa3 100644 --- a/lez/programs/clock/src/main.rs +++ b/lez/programs/clock/src/main.rs @@ -60,9 +60,10 @@ fn main() { } // Verify all clock accounts are owned by this program (assigned at genesis). - if pre_01.account.program_owner != self_program_id - || pre_10.account.program_owner != self_program_id - || pre_50.account.program_owner != self_program_id + let self_account_id: lee_core::account::AccountId = self_program_id.into(); + if pre_01.account.program_owner != self_account_id + || pre_10.account.program_owner != self_account_id + || pre_50.account.program_owner != self_account_id { panic!("Clock accounts must be owned by the clock program"); } diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index 74ab168b4..35127c7ab 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -2,7 +2,7 @@ use std::collections::BTreeSet; use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ - account::AccountId, + account::{AccountId, data::DATA_MAX_LENGTH}, program::{PdaSeed, ProgramId}, }; use serde::{Deserialize, Serialize}; @@ -158,13 +158,25 @@ impl SeenShard { /// Deliveries one shard can hold before it exceeds `DATA_MAX_LENGTH`. /// /// Borsh is 32 bytes of hash, a 4-byte count, then 4 bytes per index, so - /// this is exactly the 100 KiB an account may carry. + /// this is exactly the `DATA_MAX_LENGTH` an account may carry. /// /// Out of reach only because of the L1 inscription cap: a block inscribes as /// one op near 1.75 MiB and a minimal emitting transaction is about 257 /// bytes, capping a peer block near 7,100 deliveries. Raising that L1 cap /// past roughly 6.3 MiB puts this back in reach. - pub const MAX_DELIVERIES: usize = 25_591; + pub const MAX_DELIVERIES: usize = { + let remaining_bytes = DATA_MAX_LENGTH.as_u64() - 36; + let count = remaining_bytes + .checked_div(4) + .expect("division is well-defined"); + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "usize::try_from is not yet const-stable; the value is tiny and always fits" + )] + let count = count as usize; + count + }; /// Decodes a shard from account data; empty data is an unclaimed shard. pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { @@ -284,8 +296,6 @@ pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { #[cfg(test)] mod tests { - use lee_core::account::data::DATA_MAX_LENGTH; - use super::*; fn zone(b: u8) -> ZoneId { @@ -363,6 +373,7 @@ mod tests { #[test] fn a_full_shard_fits_in_account_data() { + // Exact only because `DATA_MAX_LENGTH` is whole KiB, hence a multiple of 4. let mut shard = SeenShard::default(); for index in 0..SeenShard::MAX_DELIVERIES { shard.insert([5; 32], u32::try_from(index).expect("index fits")); diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index 42372b65a..638ad75f3 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -203,7 +203,8 @@ fn init_config( // rewriting its own config data on a later call. if config_meta.account != Account::default() { assert_eq!( - config_meta.account.program_owner, self_program_id, + config_meta.account.program_owner, + self_program_id.into(), "inbox config PDA is owned by another program" ); assert_eq!( diff --git a/lez/programs/faucet/src/main.rs b/lez/programs/faucet/src/main.rs index 2a148000e..3646382d1 100644 --- a/lez/programs/faucet/src/main.rs +++ b/lez/programs/faucet/src/main.rs @@ -78,7 +78,7 @@ fn main() { vec![ ChainedCall::new( - faucet_for_transfer.account.program_owner, + faucet_for_transfer.account.program_owner.into(), vec![faucet_for_transfer, recipient], &authenticated_transfer_core::Instruction::Transfer { amount }, ) diff --git a/lez/programs/pinata_token/src/main.rs b/lez/programs/pinata_token/src/main.rs index 784112cb8..7e7fd97d6 100644 --- a/lez/programs/pinata_token/src/main.rs +++ b/lez/programs/pinata_token/src/main.rs @@ -87,7 +87,7 @@ fn main() { pinata_token_holding_for_chain_call.is_authorized = true; let chained_call = ChainedCall::new( - pinata_token_holding_post.program_owner, + pinata_token_holding_post.program_owner.into(), vec![ pinata_token_holding_for_chain_call, winner_token_holding.clone(), diff --git a/lez/programs/ping_receiver/src/main.rs b/lez/programs/ping_receiver/src/main.rs index 9a2ad5fe2..b9e90b6c4 100644 --- a/lez/programs/ping_receiver/src/main.rs +++ b/lez/programs/ping_receiver/src/main.rs @@ -2,7 +2,7 @@ use cross_zone_marker_core::inbox_source_marker_account_id; use lee_core::{ account::{Account, AccountWithMetadata}, program::{ - AccountPostState, Claim, DEFAULT_PROGRAM_ID, ProgramId, ProgramInput, ProgramOutput, + AccountPostState, Claim, DEFAULT_PROGRAM_OWNER, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs, }, }; @@ -151,7 +151,7 @@ fn renounce_authority( // account untouched; an unowned account with history is refused for good. assert!( authority.account == Account::default() - || authority.account.program_owner != DEFAULT_PROGRAM_ID, + || authority.account.program_owner != DEFAULT_PROGRAM_OWNER, "the authority account must be untouched before its first use as one" ); assert!( @@ -223,7 +223,7 @@ fn update_sources( // account untouched; an unowned account with history is refused for good. assert!( authority.account == Account::default() - || authority.account.program_owner != DEFAULT_PROGRAM_ID, + || authority.account.program_owner != DEFAULT_PROGRAM_OWNER, "the authority account must be untouched before its first use as one" ); assert!( @@ -281,7 +281,8 @@ fn init_config( // `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, + config.account.program_owner, + self_program_id.into(), "receiver config PDA is owned by another program" ); assert_eq!( diff --git a/lez/programs/ping_sender/src/main.rs b/lez/programs/ping_sender/src/main.rs index dcbf44cd1..d2b1baedb 100644 --- a/lez/programs/ping_sender/src/main.rs +++ b/lez/programs/ping_sender/src/main.rs @@ -131,7 +131,8 @@ fn init_config( // `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, + config.account.program_owner, + self_program_id.into(), "ping-sender config PDA is owned by another program" ); assert_eq!( 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..4a53bd000 --- /dev/null +++ b/lez/programs/sequencer_stake/src/main.rs @@ -0,0 +1,407 @@ +use std::collections::btree_map::Entry; + +use lee_core::{ + account::{AccountId, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, 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.into(), + "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_OWNER; + if is_claimed { + assert_eq!( + ownership_account.account.program_owner, + self_program_id.into(), + "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.into(); + + 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.into(), + "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.into(), + "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/token/src/tests.rs b/lez/programs/token/src/tests.rs index 8510300e9..1b96eb9d7 100644 --- a/lez/programs/token/src/tests.rs +++ b/lez/programs/token/src/tests.rs @@ -33,7 +33,7 @@ impl AccountForTests { fn definition_account_auth() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -50,7 +50,7 @@ impl AccountForTests { fn definition_account_without_auth() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -67,7 +67,7 @@ impl AccountForTests { fn holding_different_definition() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id_diff(), @@ -83,7 +83,7 @@ impl AccountForTests { fn holding_same_definition_with_authorization() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -99,7 +99,7 @@ impl AccountForTests { fn holding_same_definition_without_authorization() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -115,7 +115,7 @@ impl AccountForTests { fn holding_same_definition_without_authorization_overflow() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -131,7 +131,7 @@ impl AccountForTests { fn definition_account_post_burn() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -148,7 +148,7 @@ impl AccountForTests { fn holding_account_post_burn() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -172,7 +172,7 @@ impl AccountForTests { fn init_mint() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [0_u32; 8], + program_owner: [0_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -188,7 +188,7 @@ impl AccountForTests { fn holding_account_same_definition_mint() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -204,7 +204,7 @@ impl AccountForTests { fn definition_account_mint() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -221,7 +221,7 @@ impl AccountForTests { fn holding_same_definition_with_authorization_and_large_balance() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -237,7 +237,7 @@ impl AccountForTests { fn definition_account_with_authorization_nonfungible() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenDefinition::NonFungible { name: String::from("test"), @@ -262,7 +262,7 @@ impl AccountForTests { fn holding_account_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -278,7 +278,7 @@ impl AccountForTests { fn definition_account_unclaimed() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [0_u32; 8], + program_owner: [0_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -295,7 +295,7 @@ impl AccountForTests { fn holding_account_unclaimed() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [0_u32; 8], + program_owner: [0_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -311,7 +311,7 @@ impl AccountForTests { fn holding_account2_init() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -327,7 +327,7 @@ impl AccountForTests { fn holding_account2_init_post_transfer() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -343,7 +343,7 @@ impl AccountForTests { fn holding_account_init_post_transfer() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::Fungible { definition_id: IdForTests::pool_definition_id(), @@ -359,7 +359,7 @@ impl AccountForTests { fn holding_account_master_nft() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::NftMaster { definition_id: IdForTests::pool_definition_id(), @@ -375,7 +375,7 @@ impl AccountForTests { fn holding_account_master_nft_insufficient_balance() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::NftMaster { definition_id: IdForTests::pool_definition_id(), @@ -391,7 +391,7 @@ impl AccountForTests { fn holding_account_master_nft_after_print() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::NftMaster { definition_id: IdForTests::pool_definition_id(), @@ -407,7 +407,7 @@ impl AccountForTests { fn holding_account_printed_nft() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [0_u32; 8], + program_owner: [0_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::NftPrintedCopy { definition_id: IdForTests::pool_definition_id(), @@ -423,7 +423,7 @@ impl AccountForTests { fn holding_account_with_master_nft_transferred_to() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [0_u32; 8], + program_owner: [0_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::NftMaster { definition_id: IdForTests::pool_definition_id(), @@ -439,7 +439,7 @@ impl AccountForTests { fn holding_account_master_nft_post_transfer() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5_u32; 8], + program_owner: [5_u32; 8].into(), balance: 0_u128, data: Data::from(&TokenHolding::NftMaster { definition_id: IdForTests::pool_definition_id(), @@ -534,7 +534,7 @@ impl IdForTests { fn new_definition_non_default_first_account_should_fail() { let definition_account = AccountWithMetadata { account: Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), ..Account::default() }, is_authorized: true, @@ -563,7 +563,7 @@ fn new_definition_non_default_second_account_should_fail() { }; let holding_account = AccountWithMetadata { account: Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), ..Account::default() }, is_authorized: true, diff --git a/lez/programs/vault/src/main.rs b/lez/programs/vault/src/main.rs index 929248665..89bde3638 100644 --- a/lez/programs/vault/src/main.rs +++ b/lez/programs/vault/src/main.rs @@ -49,7 +49,7 @@ fn main() { vec![ ChainedCall::new( - sender.account.program_owner, + sender.account.program_owner.into(), vec![sender, recipient_vault_for_callee], &AuthTransferInstruction::Transfer { amount }, ) @@ -73,7 +73,7 @@ fn main() { vec![ ChainedCall::new( - owner_vault_for_callee.account.program_owner, + owner_vault_for_callee.account.program_owner.into(), vec![owner_vault_for_callee, owner], &AuthTransferInstruction::Transfer { amount }, ) diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index ae70694c7..311ef2614 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -2,7 +2,8 @@ use cross_zone_marker_core::inbox_source_marker_account_id; use lee_core::{ account::{Account, AccountWithMetadata}, program::{ - AccountPostState, Claim, DEFAULT_PROGRAM_ID, ProgramInput, ProgramOutput, read_lee_inputs, + AccountPostState, Claim, DEFAULT_PROGRAM_OWNER, ProgramInput, ProgramOutput, + read_lee_inputs, }, }; use wrapped_token_core::{ @@ -170,7 +171,7 @@ fn renounce_authority( // account untouched; an unowned account with history is refused for good. assert!( authority.account == Account::default() - || authority.account.program_owner != DEFAULT_PROGRAM_ID, + || authority.account.program_owner != DEFAULT_PROGRAM_OWNER, "the authority account must be untouched before its first use as one" ); assert!( @@ -242,7 +243,7 @@ fn update_sources( // account untouched; an unowned account with history is refused for good. assert!( authority.account == Account::default() - || authority.account.program_owner != DEFAULT_PROGRAM_ID, + || authority.account.program_owner != DEFAULT_PROGRAM_OWNER, "the authority account must be untouched before its first use as one" ); assert!( @@ -302,7 +303,8 @@ fn init_config( // rewriting its own config data on a later call. if config.account != Account::default() { assert_eq!( - config.account.program_owner, self_program_id, + config.account.program_owner, + self_program_id.into(), "wrapped-token config PDA is owned by another program" ); assert_eq!( diff --git a/lez/sequencer/actors/executor/Cargo.toml b/lez/sequencer/actors/executor/Cargo.toml new file mode 100644 index 000000000..969cefb99 --- /dev/null +++ b/lez/sequencer/actors/executor/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "sequencer_executor_actor" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +sequencer_core.workspace = true +common.workspace = true +lee_core.workspace = true +mempool.workspace = true +storage.workspace = true + +kameo.workspace = true +tokio.workspace = true +tokio-util.workspace = true +log.workspace = true +anyhow.workspace = true +thiserror.workspace = true +hex.workspace = true + +[dev-dependencies] +lee.workspace = true +sequencer_core = { workspace = true, features = ["mock"] } +test_programs.workspace = true + +env_logger.workspace = true +tempfile.workspace = true +bytesize.workspace = true +num-bigint.workspace = true diff --git a/lez/sequencer/actors/executor/src/actor.rs b/lez/sequencer/actors/executor/src/actor.rs new file mode 100644 index 000000000..96dc9f039 --- /dev/null +++ b/lez/sequencer/actors/executor/src/actor.rs @@ -0,0 +1,336 @@ +use common::{block::Block, transaction::LeeTransaction}; +use kameo::{ + Actor, + actor::{ActorRef, WeakActorRef}, + error::ActorStopReason, + mailbox::{MailboxReceiver, Signal}, + message::{Context, Message}, +}; +use lee_core::{ + BlockId, + account::{Balance, Nonce}, +}; +use log::{info, warn}; +use mempool::MemPoolHandle; +use sequencer_core::{ + SequencerCore, TransactionOrigin, + block_publisher::{BlockPublisherTrait, Ed25519Key}, + config::SequencerConfig, + task_group::TaskGroup, +}; +use tokio::select; +use tokio_util::sync::CancellationToken; + +use crate::{ + Result, + error::Error, + protocol::{ + GetAccount, GetAccountBalance, GetAccountNonces, GetAccountReply, GetBlock, GetBlockRange, + GetChannelId, GetChannelIdReply, GetCrossZoneDeadLetters, GetCrossZoneDeadLettersReply, + GetLastBlockId, GetProofsAndRoot, GetTransaction, ProduceBlock, Transaction, + }, +}; + +// TODO: Remove `BP` once this part is moved to a separate actor +pub struct ExecutorActor { + sequencer: SequencerCore, + mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + + // --- TODO: Remove these fields below --- + /// Cancelled when the publisher's drive task terminates (e.g. a panicked + /// persist sink); no channel events are processed past that point. + driver_cancellation: CancellationToken, + /// The core's background tasks, taken before the core was shared. This + /// handle owns no reference to the core itself, so without these there is + /// nothing to wait on: aborting the main loop only starts the teardown. + background_tasks: Vec, +} + +impl ExecutorActor { + pub async fn new(config: SequencerConfig) -> Self { + let (sequencer, mempool_handle) = SequencerCore::::start_from_config(config).await; + + let driver_cancellation = sequencer.block_publisher().driver_cancellation(); + let background_tasks = sequencer.background_tasks(); + + Self { + sequencer, + mempool_handle, + driver_cancellation, + 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 { + type Args = Self; + type Error = Error; + + async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { + Ok(args) + } + + #[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" + )] + async fn next( + &mut self, + _actor_ref: WeakActorRef, + mailbox_rx: &mut MailboxReceiver, + ) -> Result>> { + // TODO: Remove this please + for task in &self.background_tasks { + if task.any_finished() { + return Err(Error::BackgroundTaskFinishedUnexpectedly); + } + } + + select! { + signal = mailbox_rx.recv() => { + Ok(signal) + } + () = self.driver_cancellation.cancelled() => { + Err(Error::BlockPublisherFinishedUnexpectedly) + } + } + } + + async fn on_stop( + &mut self, + _actor_ref: WeakActorRef, + _reason: ActorStopReason, + ) -> Result<()> { + for tasks in &self.background_tasks { + tasks.shutdown().await; + } + + Ok(()) + } +} + +impl Message for ExecutorActor { + type Reply = Result<()>; + + async fn handle( + &mut self, + ProduceBlock: ProduceBlock, + _ctx: &mut Context, + ) -> Self::Reply { + // Only produce on our turn. + if !self.sequencer.is_our_turn() { + info!("Not our turn to produce a block, skipping"); + return Ok(()); + } + + // Never inscribe a second block at a height we already published: the + // channel would carry two chains from there and nothing resolves that. + // The head rewinds under us when the sdk orphans our own unfinalized + // blocks, and recovers once they finalize, so this is a wait. + if let Some(high_water) = self.sequencer.rewound_below_published() { + warn!( + "Skipping turn: head rewound to {} but block {high_water} is already inscribed; \ + waiting for the channel to restore it", + self.sequencer.next_block_height().saturating_sub(1), + ); + return Ok(()); + } + + info!("Our turn: producing a block and any committee update"); + let id = self + .sequencer + .run_production_turn() + .await + .map_err(Error::BlockProductionFailed)?; + + let author_identity = hex::encode( + Ed25519Key::from_bytes(&self.sequencer.sequencer_config().signing_key) + .public_key() + .as_bytes(), + ); + log::info!("Block with id {id} created by {author_identity:?}"); + + Ok(()) + } +} + +impl Message for ExecutorActor { + type Reply = Result<()>; + + async fn handle( + &mut self, + Transaction { transaction }: Transaction, + _ctx: &mut Context, + ) -> Self::Reply { + self.mempool_handle + .try_push((TransactionOrigin::User, transaction)) + .map_err(|_err| Error::MempoolIsFull) + } +} + +impl Message for ExecutorActor { + type Reply = Result>; + + async fn handle( + &mut self, + GetBlock { block_id }: GetBlock, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer + .block_store() + .get_block_at_id(block_id) + .map_err(Into::into) + } +} + +impl Message for ExecutorActor { + type Reply = Result>; + + async fn handle( + &mut self, + GetBlockRange { range }: GetBlockRange, + _ctx: &mut Context, + ) -> Self::Reply { + range + .map_while(|block_id| { + self.sequencer + .block_store() + .get_block_at_id(block_id) + .map_err(Into::into) + .transpose() + }) + .collect::>>() + } +} + +impl Message for ExecutorActor { + type Reply = Result; + + async fn handle( + &mut self, + GetLastBlockId: GetLastBlockId, + _ctx: &mut Context, + ) -> Self::Reply { + Ok(self.sequencer.chain_height()) + } +} + +impl Message for ExecutorActor { + type Reply = Balance; + + async fn handle( + &mut self, + GetAccountBalance { account_id }: GetAccountBalance, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer + .with_state(|state| state.get_account_by_id(account_id).balance) + } +} + +impl Message for ExecutorActor { + type Reply = Option<(LeeTransaction, BlockId)>; + + async fn handle( + &mut self, + GetTransaction { tx_hash }: GetTransaction, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer + .block_store() + .get_transaction_by_hash(tx_hash) + } +} + +impl Message for ExecutorActor { + type Reply = Vec; + + async fn handle( + &mut self, + GetAccountNonces { account_ids }: GetAccountNonces, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer.with_state(|state| { + account_ids + .into_iter() + .map(|account_id| state.get_account_by_id(account_id).nonce) + .collect() + }) + } +} + +impl Message for ExecutorActor { + type Reply = ( + Vec>, + lee_core::CommitmentSetDigest, + ); + + async fn handle( + &mut self, + GetProofsAndRoot { commitments }: GetProofsAndRoot, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer.with_state(|state| { + let proofs = commitments + .iter() + .map(|commitment| state.get_proof_for_commitment(commitment)) + .collect(); + (proofs, state.commitment_root()) + }) + } +} + +impl Message for ExecutorActor { + type Reply = GetAccountReply; + + async fn handle( + &mut self, + GetAccount { account_id }: GetAccount, + _ctx: &mut Context, + ) -> Self::Reply { + GetAccountReply { + account: self + .sequencer + .with_state(|state| state.get_account_by_id(account_id)), + } + } +} + +impl Message for ExecutorActor { + type Reply = GetChannelIdReply; + + async fn handle( + &mut self, + GetChannelId: GetChannelId, + _ctx: &mut Context, + ) -> Self::Reply { + GetChannelIdReply { + channel_id: *self.sequencer.block_publisher().channel_id().as_ref(), + } + } +} + +impl Message + for ExecutorActor +{ + type Reply = Result; + + async fn handle( + &mut self, + GetCrossZoneDeadLetters: GetCrossZoneDeadLetters, + _ctx: &mut Context, + ) -> Self::Reply { + let (total_retired, retained) = self.sequencer.cross_zone_dead_letters()?; + Ok(GetCrossZoneDeadLettersReply { + total_retired, + retained, + }) + } +} diff --git a/lez/sequencer/actors/executor/src/error.rs b/lez/sequencer/actors/executor/src/error.rs new file mode 100644 index 000000000..e35bda128 --- /dev/null +++ b/lez/sequencer/actors/executor/src/error.rs @@ -0,0 +1,17 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("One of the sequencer's background tasks has finished unexpectedly")] + BackgroundTaskFinishedUnexpectedly, + + #[error("The sequencer's block publisher has finished unexpectedly")] + BlockPublisherFinishedUnexpectedly, + + #[error("The mempool is full")] + MempoolIsFull, + + #[error("Storage error")] + StorageError(#[from] storage::error::DbError), + + #[error(transparent)] + BlockProductionFailed(anyhow::Error), +} diff --git a/lez/sequencer/actors/executor/src/lib.rs b/lez/sequencer/actors/executor/src/lib.rs new file mode 100644 index 000000000..c72fb5ff7 --- /dev/null +++ b/lez/sequencer/actors/executor/src/lib.rs @@ -0,0 +1,11 @@ +//! Executor Actor performs the main logic of the Sequencer. + +pub use actor::ExecutorActor; + +pub mod actor; +pub mod error; +pub mod protocol; +#[cfg(test)] +mod tests; + +pub type Result = std::result::Result; diff --git a/lez/sequencer/actors/executor/src/protocol.rs b/lez/sequencer/actors/executor/src/protocol.rs new file mode 100644 index 000000000..e4dc73ee5 --- /dev/null +++ b/lez/sequencer/actors/executor/src/protocol.rs @@ -0,0 +1,66 @@ +use std::ops::RangeInclusive; + +use common::{HashType, transaction::LeeTransaction}; +use kameo::Reply; +use lee_core::{ + BlockId, Commitment, + account::{Account, AccountId}, +}; +use sequencer_core::DeadLetterDispatchRecord; + +#[derive(Copy, Clone)] +pub struct ProduceBlock; + +pub struct Transaction { + pub transaction: LeeTransaction, +} + +pub struct GetBlock { + pub block_id: BlockId, +} + +pub struct GetBlockRange { + pub range: RangeInclusive, +} + +pub struct GetLastBlockId; + +pub struct GetAccountBalance { + pub account_id: AccountId, +} + +pub struct GetTransaction { + pub tx_hash: HashType, +} + +pub struct GetAccountNonces { + pub account_ids: Vec, +} + +pub struct GetProofsAndRoot { + pub commitments: Vec, +} + +pub struct GetAccount { + pub account_id: AccountId, +} + +#[derive(Reply)] +pub struct GetAccountReply { + pub account: Account, +} + +pub struct GetChannelId; + +#[derive(Reply)] +pub struct GetChannelIdReply { + pub channel_id: [u8; 32], +} + +pub struct GetCrossZoneDeadLetters; + +#[derive(Reply)] +pub struct GetCrossZoneDeadLettersReply { + pub total_retired: u64, + pub retained: Vec, +} diff --git a/lez/sequencer/actors/executor/src/tests.rs b/lez/sequencer/actors/executor/src/tests.rs new file mode 100644 index 000000000..6995f3053 --- /dev/null +++ b/lez/sequencer/actors/executor/src/tests.rs @@ -0,0 +1,92 @@ +use anyhow::Result; +use bytesize::ByteSize; +use common::transaction::LeeTransaction; +use kameo::{actor::Spawn as _, error::SendError}; +use lee::{ + AccountId, PrivateKey, PublicKey, PublicTransaction, + public_transaction::{Message, WitnessSet}, +}; +use num_bigint::BigUint; +use sequencer_core::{ + config::{BedrockConfig, SequencerConfig}, + mock::MockBlockPublisher, +}; +use tokio::test; + +use crate::{ExecutorActor, protocol}; + +fn sequencer_config() -> (SequencerConfig, tempfile::TempDir) { + let home = tempfile::tempdir().expect("Failed to create tmp home dir"); + + let config = SequencerConfig { + home: home.path().to_path_buf(), + max_num_tx_in_block: 10, + max_block_size: ByteSize::kib(1024), + mempool_max_size: 10, + block_create_timeout: std::time::Duration::from_secs(5), + retry_pending_blocks_timeout: std::time::Duration::from_secs(5), + signing_key: [37; 32], + bedrock_config: BedrockConfig { + channel_id: [0; 32].into(), + node_url: "http://not-used".parse().expect("Failed to parse URL"), + auth: None, + funding_key: BigUint::default().into(), + priority_fee: sequencer_core::config::default_priority_fee(), + }, + genesis: Vec::new(), + cross_zone: None, + metrics_address: None, + gossip: None, + }; + + (config, home) +} + +fn test_transaction() -> LeeTransaction { + let key1 = PrivateKey::new_os_random(); + let key2 = PrivateKey::new_os_random(); + let acc1 = AccountId::from(&PublicKey::new_from_private_key(&key1)); + let acc2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); + + let nonces = vec![0_u128.into(), 0_u128.into()]; + let instruction = 1337; + let message = Message::try_new( + test_programs::simple_balance_transfer().id(), + vec![acc1, acc2], + nonces, + instruction, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); + PublicTransaction::new(message, witness_set).into() +} + +#[test] +async fn handle_transaction_fails_on_full_mempool() -> Result<()> { + let _res = env_logger::try_init(); + + let (config, _home) = sequencer_config(); + let mempool_max_size = config.mempool_max_size; + let executor = ExecutorActor::spawn(ExecutorActor::::new(config).await); + + // Fill mempool + for _ in 0..mempool_max_size { + let tx = test_transaction(); + executor + .ask(protocol::Transaction { transaction: tx }) + .await?; + } + + // Now the mempool is full, the next transaction should fail + let tx = test_transaction(); + assert!(matches!( + executor + .ask(protocol::Transaction { transaction: tx }) + .await + .map_err(SendError::err), + Err(Some(crate::error::Error::MempoolIsFull)) + )); + + Ok(()) +} diff --git a/lez/sequencer/actors/rpc_server/Cargo.toml b/lez/sequencer/actors/rpc_server/Cargo.toml new file mode 100644 index 000000000..02bf9dfb3 --- /dev/null +++ b/lez/sequencer/actors/rpc_server/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sequencer_rpc_server_actor" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee.workspace = true +common.workspace = true +programs.workspace = true +sequencer_core.workspace = true +sequencer_service_protocol.workspace = true +sequencer_service_rpc = { workspace = true, features = ["server"] } +sequencer_rpc_server_actor_metrics = { workspace = true, features = ["record"] } +sequencer_executor_actor.workspace = true + +kameo.workspace = true +tokio.workspace = true +log.workspace = true +thiserror.workspace = true +jsonrpsee.workspace = true +borsh.workspace = true +bytesize.workspace = true diff --git a/lez/sequencer/service/metrics/Cargo.toml b/lez/sequencer/actors/rpc_server/metrics/Cargo.toml similarity index 84% rename from lez/sequencer/service/metrics/Cargo.toml rename to lez/sequencer/actors/rpc_server/metrics/Cargo.toml index 46dd2d5ac..ada599472 100644 --- a/lez/sequencer/service/metrics/Cargo.toml +++ b/lez/sequencer/actors/rpc_server/metrics/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "sequencer_service_metrics" +name = "sequencer_rpc_server_actor_metrics" version = "0.1.0" edition = "2024" license = { workspace = true } diff --git a/lez/sequencer/service/metrics/src/lib.rs b/lez/sequencer/actors/rpc_server/metrics/src/lib.rs similarity index 58% rename from lez/sequencer/service/metrics/src/lib.rs rename to lez/sequencer/actors/rpc_server/metrics/src/lib.rs index f375ff1b5..35cd7cb3b 100644 --- a/lez/sequencer/service/metrics/src/lib.rs +++ b/lez/sequencer/actors/rpc_server/metrics/src/lib.rs @@ -1,4 +1,4 @@ -//! This crate provides all metrics exposed by the sequencer service crate. +//! This crate provides all metrics exposed by RPC Server Actor. #[cfg(feature = "record")] pub use record::*; diff --git a/lez/sequencer/service/metrics/src/names.rs b/lez/sequencer/actors/rpc_server/metrics/src/names.rs similarity index 100% rename from lez/sequencer/service/metrics/src/names.rs rename to lez/sequencer/actors/rpc_server/metrics/src/names.rs diff --git a/lez/sequencer/service/metrics/src/record.rs b/lez/sequencer/actors/rpc_server/metrics/src/record.rs similarity index 100% rename from lez/sequencer/service/metrics/src/record.rs rename to lez/sequencer/actors/rpc_server/metrics/src/record.rs diff --git a/lez/sequencer/actors/rpc_server/src/actor.rs b/lez/sequencer/actors/rpc_server/src/actor.rs new file mode 100644 index 000000000..dfc8a551d --- /dev/null +++ b/lez/sequencer/actors/rpc_server/src/actor.rs @@ -0,0 +1,106 @@ +use std::net::SocketAddr; + +use bytesize::ByteSize; +use jsonrpsee::server::ServerHandle; +use kameo::{Actor, actor::ActorRef, mailbox::Signal}; +use log::info; +use sequencer_core::{block_publisher::BlockPublisherTrait, gossip::GossipTxPublisher}; +use sequencer_service_rpc::RpcServer as _; +use tokio::select; + +use crate::{Result, error::Error}; + +mod service; + +const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10); + +pub struct RpcServerActor { + server_handle: Option, + addr: SocketAddr, +} + +impl RpcServerActor { + pub async fn new( + 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() + .max_request_body_size( + u32::try_from(REQUEST_BODY_MAX_SIZE.as_u64()) + .expect("REQUEST_BODY_MAX_SIZE should be less than u32::MAX"), + ) + .build(), + ) + .build(listen_addr) + .await + .map_err(Error::RpcServerSetupFailed)?; + + let addr = server + .local_addr() + .map_err(Error::LocalAddrRetrievingFailed)?; + + info!("Starting RPC Server on {addr}"); + + let service = service::Service::new(executor_ref, max_block_size, gossip_tx_publisher); + let server_handle = server.start(service.into_rpc()); + + Ok(Self { + server_handle: Some(server_handle), + addr, + }) + } + + #[must_use] + pub const fn addr(&self) -> SocketAddr { + self.addr + } +} + +impl Actor for RpcServerActor { + type Args = Self; + type Error = Error; + + async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { + Ok(args) + } + + #[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" + )] + async fn next( + &mut self, + _actor_ref: kameo::prelude::WeakActorRef, + mailbox_rx: &mut kameo::prelude::MailboxReceiver, + ) -> Result>> { + let handle = self + .server_handle + .clone() + .expect("Server handle should be present while actor is running"); + + select! { + signal = mailbox_rx.recv() => { + Ok(signal) + } + () = handle.stopped() => { + Err(Error::RpcServerStoppedUnexpectedly) + } + } + } + + async fn on_stop( + &mut self, + _actor_ref: kameo::prelude::WeakActorRef, + _reason: kameo::prelude::ActorStopReason, + ) -> Result<()> { + if let Some(server_handle) = self.server_handle.take() { + server_handle.stop()?; + server_handle.stopped().await; + } + + Ok(()) + } +} diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/actors/rpc_server/src/actor/service.rs similarity index 60% rename from lez/sequencer/service/src/service.rs rename to lez/sequencer/actors/rpc_server/src/actor/service.rs index 6afe696be..f087c5523 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/actors/rpc_server/src/actor/service.rs @@ -1,50 +1,45 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::collections::BTreeMap; +use bytesize::ByteSize; use common::transaction::LeeTransaction; use jsonrpsee::{ core::async_trait, types::{ErrorCode, ErrorObjectOwned}, }; -use lee; +use kameo::actor::ActorRef; use log::{error, warn}; -use mempool::MemPoolHandle; -use sequencer_core::{ - DbError, SequencerCore, TransactionOrigin, 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, }; -use tokio::sync::Mutex; -const NOT_FOUND_ERROR_CODE: i32 = -31999; - -pub struct SequencerService { - sequencer: Arc>>, - mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - max_block_size: u64, +pub struct Service { + executor_ref: ActorRef>, + max_block_size: ByteSize, + gossip_tx_publisher: Option, } -impl SequencerService { - pub const fn new( - sequencer: Arc>>, - mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - max_block_size: u64, +impl Service { + pub fn new( + executor_ref: ActorRef>, + max_block_size: ByteSize, + gossip_tx_publisher: Option, ) -> Self { + sequencer_rpc_server_actor_metrics::init(); + Self { - sequencer, - mempool_handle, + executor_ref, max_block_size, + gossip_tx_publisher, } } } #[async_trait] -impl sequencer_service_rpc::RpcServer - for SequencerService -{ +impl sequencer_service_rpc::RpcServer for Service { async fn send_transaction(&self, tx: LeeTransaction) -> Result { - sequencer_service_metrics::increment_submitted_transactions_total(); + sequencer_rpc_server_actor_metrics::increment_submitted_transactions_total(); let tx_hash = tx.hash(); @@ -57,7 +52,10 @@ impl sequencer_service_rpc::Rpc let tx_size = u64::try_from(encoded_tx.len()).expect("Transaction size should fit in u64"); - let max_tx_size = self.max_block_size.saturating_sub(BLOCK_HEADER_OVERHEAD); + let max_tx_size = self + .max_block_size + .as_u64() + .saturating_sub(BLOCK_HEADER_OVERHEAD); if tx_size > max_tx_size { return Err(ErrorObjectOwned::owned( @@ -97,14 +95,23 @@ impl sequencer_service_rpc::Rpc }; let authenticated_tx = res.await.inspect_err(|err| { - sequencer_service_metrics::increment_before_mempool_failed_transactions_total(); + sequencer_rpc_server_actor_metrics::increment_before_mempool_failed_transactions_total( + ); error!("Transaction failed before reaching mempool: {err:#?}"); })?; - self.mempool_handle - .push((TransactionOrigin::User, authenticated_tx)) + // 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, + }) .await - .expect("Mempool is closed, this is a bug"); + .map_err(internal_error)?; Ok(tx_hash) } @@ -114,11 +121,10 @@ impl sequencer_service_rpc::Rpc } async fn get_block(&self, block_id: BlockId) -> Result, ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - sequencer - .block_store() - .get_block_at_id(block_id) - .map_err(|err| internal_error(&err)) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetBlock { block_id }) + .await + .map_err(internal_error) } async fn get_block_range( @@ -126,74 +132,64 @@ impl sequencer_service_rpc::Rpc start_block_id: BlockId, end_block_id: BlockId, ) -> Result, ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - (start_block_id..=end_block_id) - .map(|block_id| { - let block = sequencer - .block_store() - .get_block_at_id(block_id) - .map_err(|err| internal_error(&err))?; - block.ok_or_else(|| { - ErrorObjectOwned::owned( - NOT_FOUND_ERROR_CODE, - format!("Block with id {block_id} not found"), - None::<()>, - ) - }) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetBlockRange { + range: (start_block_id..=end_block_id), }) - .collect::, _>>() + .await + .map_err(internal_error) } async fn get_last_block_id(&self) -> Result { - let sequencer = self.sequencer.lock().await; - Ok(sequencer.chain_height()) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetLastBlockId) + .await + .map_err(internal_error) } async fn get_account_balance(&self, account_id: AccountId) -> Result { - let sequencer = self.sequencer.lock().await; - let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance); - Ok(balance) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetAccountBalance { account_id }) + .await + .map_err(internal_error) } async fn get_transaction( &self, tx_hash: HashType, ) -> Result, ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - Ok(sequencer.block_store().get_transaction_by_hash(tx_hash)) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetTransaction { tx_hash }) + .await + .map_err(internal_error) } async fn get_accounts_nonces( &self, account_ids: Vec, ) -> Result, ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - let nonces = sequencer.with_state(|state| { - account_ids - .into_iter() - .map(|account_id| state.get_account_by_id(account_id).nonce) - .collect() - }); - Ok(nonces) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetAccountNonces { account_ids }) + .await + .map_err(internal_error) } async fn get_proofs_and_root( &self, commitments: Vec, ) -> Result<(Vec>, CommitmentSetDigest), ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - Ok(sequencer.with_state(|state| { - let proofs = commitments - .iter() - .map(|commitment| state.get_proof_for_commitment(commitment)) - .collect(); - (proofs, state.commitment_root()) - })) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetProofsAndRoot { commitments }) + .await + .map_err(internal_error) } async fn get_account(&self, account_id: AccountId) -> Result { - let sequencer = self.sequencer.lock().await; - Ok(sequencer.with_state(|state| state.get_account_by_id(account_id))) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetAccount { account_id }) + .await + .map(|reply| reply.account) + .map_err(internal_error) } async fn get_program_ids(&self) -> Result, ErrorObjectOwned> { @@ -214,23 +210,28 @@ impl sequencer_service_rpc::Rpc } async fn get_channel_id(&self) -> Result { - let channel_id = self.sequencer.lock().await.block_publisher().channel_id(); - Ok(ChannelId(*channel_id.as_ref())) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetChannelId) + .await + .map(|reply| ChannelId(reply.channel_id)) + .map_err(internal_error) } async fn get_cross_zone_dead_letters( &self, ) -> Result { - let (total_retired, records) = self - .sequencer - .lock() + let sequencer_executor_actor::protocol::GetCrossZoneDeadLettersReply { + total_retired, + retained, + } = self + .executor_ref + .ask(sequencer_executor_actor::protocol::GetCrossZoneDeadLetters) .await - .cross_zone_dead_letters() - .map_err(|err| internal_error(&err))?; + .map_err(internal_error)?; Ok(CrossZoneDeadLetterReport { total_retired, - retained: records + retained: retained .into_iter() .map(|record| CrossZoneDeadLetter { message_key: HashType(record.message_key), @@ -245,6 +246,6 @@ impl sequencer_service_rpc::Rpc } } -fn internal_error(err: &DbError) -> ErrorObjectOwned { +fn internal_error(err: impl std::fmt::Display) -> ErrorObjectOwned { ErrorObjectOwned::owned(ErrorCode::InternalError.code(), err.to_string(), None::<()>) } diff --git a/lez/sequencer/actors/rpc_server/src/error.rs b/lez/sequencer/actors/rpc_server/src/error.rs new file mode 100644 index 000000000..ceb483b89 --- /dev/null +++ b/lez/sequencer/actors/rpc_server/src/error.rs @@ -0,0 +1,14 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Failed to setup RPC server")] + RpcServerSetupFailed(#[source] std::io::Error), + + #[error("Failed to retrieve local address")] + LocalAddrRetrievingFailed(#[source] std::io::Error), + + #[error("RPC server has stopped unexpectedly")] + RpcServerStoppedUnexpectedly, + + #[error("RPC server has already been stopped")] + RpcServerAlreadyStopped(#[from] jsonrpsee::server::AlreadyStoppedError), +} diff --git a/lez/sequencer/actors/rpc_server/src/lib.rs b/lez/sequencer/actors/rpc_server/src/lib.rs new file mode 100644 index 000000000..651f66c02 --- /dev/null +++ b/lez/sequencer/actors/rpc_server/src/lib.rs @@ -0,0 +1,8 @@ +//! RPC Server Actor serves RPC queries and forwards them to Executor. + +pub use actor::RpcServerActor; + +pub mod actor; +pub mod error; + +pub type Result = std::result::Result; diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index a2d8a21c3..4854197d4 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,14 +49,18 @@ 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] +cross_zone = { workspace = true, features = ["test-utils"] } futures.workspace = true test_programs.workspace = true lee = { workspace = true, features = ["test-utils"] } 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 ace601608..3462103b0 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -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,17 +133,43 @@ 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. /// /// The checkpoint must be persisted with the block โ€” restoring an older one /// drops the inscription from the pending set, and it is never resubmitted. - async fn publish_block( + fn publish_block<'blk, 'pbl: 'blk>( + &'pbl self, + block: &'blk Block, + 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, - withdrawals: Vec, + 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, @@ -251,10 +316,10 @@ impl BlockPublisherTrait for ZoneSdkPublisher { }); match &msg_result { Ok(_) if withdraw_count == 0 => { - info!("Published block with the size of {data_byte_size} bytes"); + log::info!("Published block with the size of {data_byte_size} bytes"); } Ok(_) => { - info!( + log::info!( "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", ); } @@ -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 { @@ -317,7 +432,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { } Event::Ready => {} Event::TurnNotification { notification } => { - info!( + log::info!( "Turn update: our_turn={}, starting_slot={:?}, ends_at_slot={:?}", notification.our_turn_to_write, notification.starting_slot, @@ -347,12 +462,15 @@ 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, }) } - async fn publish_block( - &self, - block: &Block, + async fn publish_block<'blk, 'pbl: 'blk>( + &'pbl self, + block: &'blk Block, withdrawals: Vec, ) -> Result { let data = borsh::to_vec(block).context("Failed to serialize block")?; @@ -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/block_store.rs b/lez/sequencer/core/src/block_store.rs index b11ed3e46..76e3fcfe0 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -8,7 +8,6 @@ use common::{ }; use lee::V03State; use lee_core::BlockId; -use log::info; use logos_blockchain_zone_sdk::{Slot, sequencer::SequencerCheckpoint}; use storage::sequencer::{ RocksDBIO, @@ -75,7 +74,7 @@ impl SequencerStore { let mut tx_hash_to_block_map = HashMap::new(); if let Some(last_id) = last_id { - info!("Preparing block cache"); + log::info!("Preparing block cache"); for i in genesis_id..=last_id { let block = dbio .get_block(i)? @@ -83,7 +82,7 @@ impl SequencerStore { tx_hash_to_block_map.extend(block_to_transactions_map(&block)); } - info!( + log::info!( "Block cache prepared. Total blocks in cache: {}", tx_hash_to_block_map.len() ); diff --git a/lez/sequencer/core/src/committee_discovery.rs b/lez/sequencer/core/src/committee_discovery.rs new file mode 100644 index 000000000..93364c095 --- /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().into(), + 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().into(), + 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 bce4a2624..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(Clone, Serialize, Deserialize)] +#[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,9 +86,12 @@ 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(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BedrockConfig { /// Bedrock channel ID. pub channel_id: ChannelId, @@ -93,18 +115,36 @@ impl SequencerConfig { Ok(serde_json::from_reader(reader)?) } + + /// Where this sequencer's database lives, suffixed with the channel id like + /// the indexer's, so several sequencers can share a home directory. Only the + /// database is per-channel; `bedrock_signing_key` stays unsuffixed, so + /// sequencers sharing a home share one Bedrock identity. + #[must_use] + pub fn db_path(&self) -> PathBuf { + self.home + .join(format!("rocksdb-{}", self.bedrock_config.channel_id)) + } } 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 038b7e891..07bbc1410 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -2,12 +2,13 @@ use std::{sync::Arc, time::Duration}; use common::{HashType, block::Block, transaction::LeeTransaction}; use cross_zone::{ - EmissionSource, build_dispatch_from_emission, extract_emission, is_sequencer_only_program, + EmissionSource, Link, StallState, alerts_at, build_dispatch_from_emission, equivocation_report, + extract_emission, is_sequencer_only_program, link_to_tip, screen_peer_block, }; use cross_zone_inbox_core::message_key; use futures::{Stream, StreamExt as _}; -use lee::{GENESIS_BLOCK_ID, PublicKey}; -use log::{debug, error, info, warn}; +use lee::PublicKey; +use log::{debug, error, warn}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, @@ -25,15 +26,6 @@ use crate::{ task_group::TaskGroup, }; -/// Consecutive passes a watcher spends stuck on one slot before it says so as -/// something more than the per-pass failure. -/// -/// One pass per poll interval, which is the block time, so this is minutes of -/// retrying rather than seconds. A transient failure (a truncated read, a peer -/// mid-upgrade) heals well inside that; anything still stuck after it wants -/// someone to look. -const STUCK_SLOT_ALERT_PASSES: u32 = 20; - /// The per-peer settings one watcher pass needs. struct PeerContext { peer_zone: [u8; 32], @@ -46,7 +38,7 @@ struct PeerContext { /// All of them hold the delivery floor at the last slot the watcher consumed /// whole, bar [`PassOutcome::Drained`] and [`PassOutcome::Stranded`], so the /// next pass re-reads from there. Only a block that will not deserialize ends a -/// pass; [`link_against`] says why one that decodes never does. +/// pass; [`link_to_tip`] says why one that decodes never does. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum PassOutcome { /// The stream drained, having delivered from at least one block or found @@ -71,10 +63,7 @@ enum PassOutcome { /// The pass-to-pass state of one watcher. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] struct WatcherState { - /// The slot it is stuck on and how many consecutive passes it has spent - /// there. Keyed by slot so a failure at a new slot does not inherit an - /// older slot's count. - stalled: Option<(Slot, u32)>, + stall: StallState, /// Consecutive passes that placed nothing while skipping blocks. Not keyed /// by slot: the peer keeps producing, so every such pass ends at a new slot /// and a slot-keyed count would reset to one for ever. @@ -82,44 +71,19 @@ struct WatcherState { } impl WatcherState { - /// Folds one pass's outcome in, returning the slot the watcher is stuck on - /// and how long it has been stuck, so the caller can say so. - /// - /// `cursor` is the read position after the pass, and is what tells a stream - /// that truncated early apart from one that genuinely drained: the zone-sdk - /// ends a stream on a fetch failure exactly as it does on catching up, so - /// without it a flaky peer endpoint resets the count for ever and a watcher - /// stuck for hours never says so. + /// Folds one pass's outcome into the stall and stranded counts, returning + /// the slot the watcher is stuck on and how long it has been stuck. fn after_pass(&mut self, outcome: PassOutcome, cursor: Option) -> Option<(Slot, u32)> { - let slot = match outcome { - PassOutcome::Drained | PassOutcome::Stranded => { - if self.passed_the_stall(cursor) { - self.stalled = None; - } - self.stranded = match outcome { - PassOutcome::Stranded => self.stranded.saturating_add(1), - PassOutcome::Drained - | PassOutcome::Undecodable(_) - | PassOutcome::Undelivered(_) => 0, - }; - return None; - } - PassOutcome::Undecodable(slot) | PassOutcome::Undelivered(slot) => slot, + match outcome { + PassOutcome::Stranded => self.stranded = self.stranded.saturating_add(1), + PassOutcome::Drained => self.stranded = 0, + PassOutcome::Undecodable(_) | PassOutcome::Undelivered(_) => {} + } + let stuck_on = match outcome { + PassOutcome::Undecodable(slot) | PassOutcome::Undelivered(slot) => Some(slot), + PassOutcome::Drained | PassOutcome::Stranded => None, }; - - let attempts = match self.stalled { - Some((stuck_on, attempts)) if stuck_on == slot => attempts.saturating_add(1), - _ => 1, - }; - self.stalled = Some((slot, attempts)); - self.stalled - } - - /// Whether the read position is now past whatever the watcher was stuck on. - /// Vacuously true when it was not stuck. - fn passed_the_stall(self, cursor: Option) -> bool { - self.stalled - .is_none_or(|(stuck_on, _)| cursor.is_some_and(|read_to| read_to >= stuck_on)) + self.stall.after_pass(stuck_on, cursor) } } @@ -132,96 +96,6 @@ struct Resume { clear_floor: bool, } -/// Where a peer block sits relative to the chain this watcher has delivered -/// from. -#[derive(Debug, PartialEq, Eq)] -enum Link { - /// The next block on the peer's chain, carrying its recomputed hash. - Next(HashType), - /// At or below the tip, so already delivered from. The ordinary shape of a - /// re-read slot, and how an equivocating second block at one id is refused. - AlreadySeen, - /// Not on the chain this watcher is following, so not deliverable. Read on: - /// the peer's own next block still links to the tip, and treating this as - /// terminal would hand the peer a way to stop its deliveries permanently. - OffChain(String), -} - -/// Whether `block` continues the peer chain pinned by `tip`. -/// -/// This is what closes the suppression. A delivered message's replay key covers -/// `(src_zone, src_block_id, src_tx_index)` and nothing else, so a peer that can -/// get a block delivered under an id of its choosing burns the key an honest -/// block would later use, and the inbox then no-ops the real message as a -/// replay. Off a hash link ids are only claimable in order, so the only id -/// within reach is the one the peer is about to publish anyway. -/// -/// Nothing but [`Link::Next`] is ever delivered from, and nothing but a block -/// that will not decode stops the pass. A peer can inscribe anything it likes on -/// its own channel, so a block this watcher cannot place is read past rather -/// than treated as the end of the chain: the peer's own next honest block still -/// links to the tip. -fn link_against( - tip: Option, - block: &Block, - expected_pubkey: Option<&PublicKey>, -) -> Link { - // The channel authorizes who may write, not what they may claim, so the - // pinned key is what says this node's own sequencer produced the block. - if expected_pubkey.is_some_and(|key| !block.is_signed_by(key)) { - return Link::OffChain("block-signing key does not match the pinned key".to_owned()); - } - - let recomputed = block.recompute_hash(); - if recomputed != block.header.hash { - // The signature does not cover this field, so a correctly signed block - // may still carry a bogus one, and the peer's own next block links - // against the recomputed value rather than this one. - return Link::OffChain(format!( - "block {} carries header hash {} but its contents hash to {recomputed}", - block.header.block_id, block.header.hash - )); - } - - let Some(tip) = tip else { - return if block.header.block_id == GENESIS_BLOCK_ID { - Link::Next(recomputed) - } else { - Link::OffChain(format!( - "block {} is the first one read, but a watcher with no stored chain tip has to start at the peer's genesis block {GENESIS_BLOCK_ID}", - block.header.block_id - )) - }; - }; - - if block.header.block_id <= tip.block_id { - return Link::AlreadySeen; - } - if block.header.block_id > tip.block_id.saturating_add(1) { - return Link::OffChain(format!( - "block {} skips past {}, which is either a hole in what this node read or an id claimed ahead of the peer's chain", - block.header.block_id, - tip.block_id.saturating_add(1) - )); - } - if block.header.prev_block_hash != tip.block_hash { - return Link::OffChain(format!( - "block {} does not follow block {} we delivered from: it links to {} rather than {}", - block.header.block_id, tip.block_id, block.header.prev_block_hash, tip.block_hash - )); - } - Link::Next(recomputed) -} - -/// Whether a watcher stuck for `attempts` passes should say so on this one. -/// -/// Every [`STUCK_SLOT_ALERT_PASSES`], not on the crossing alone: a stall that -/// never clears would otherwise be reported once and then look resolved for as -/// long as it lasts. Not every pass, since that is one line per block time. -const fn alerts_at(attempts: u32) -> bool { - attempts > 0 && attempts.is_multiple_of(STUCK_SLOT_ALERT_PASSES) -} - /// Where a starting watcher resumes reading a peer's channel. /// /// A store holding a floor but no tip predates chain pinning, and its next block @@ -302,7 +176,7 @@ async fn watch_peer( dbio: Arc, ) { let peer_zone = peer.peer_zone; - info!( + log::info!( "Cross-zone watcher started for peer {}", hex::encode(peer_zone) ); @@ -356,7 +230,7 @@ async fn watch_peer( } let mut cursor = resume.cursor; if let Some(slot) = cursor { - info!( + log::info!( "Resuming watcher for peer {} from slot {slot:?}", hex::encode(peer_zone) ); @@ -452,13 +326,42 @@ where hex::encode(peer.peer_zone), block.header.block_id ); - match link_against(*tip, &block, peer.expected_pubkey.as_ref()) { - Link::AlreadySeen => { - debug!( - "Watcher ignoring peer {} block {}: at or below the block it has already delivered from", - hex::encode(peer.peer_zone), - block.header.block_id + // Nothing but [`Link::Next`] is ever delivered from, and + // nothing but a block that will not decode stops the pass. A + // peer can inscribe anything it likes on its own channel, so a + // block this watcher cannot place is read past rather than + // treated as the end of the chain: the peer's own next honest + // block still links to the tip. + let link = match screen_peer_block(&block, peer.expected_pubkey.as_ref()) { + Ok(recomputed) => link_to_tip(tip.as_ref(), &block, recomputed), + Err(refusal) => { + skipped = skipped.saturating_add(1); + warn!( + "Watcher not delivering from peer {} block at slot {slot:?}: {refusal}. Reading on; the peer's next block that continues the chain still delivers.", + hex::encode(peer.peer_zone) ); + continue; + } + }; + match link { + Link::AlreadySeen { equivocates } => { + if equivocates && let Some(held) = *tip { + error!( + "{}", + equivocation_report( + &peer.peer_zone, + block.header.block_id, + held.block_hash, + block.header.hash + ) + ); + } else { + debug!( + "Watcher ignoring peer {} block {}: at or below the block it has already delivered from", + hex::encode(peer.peer_zone), + block.header.block_id + ); + } } Link::OffChain(reason) => { skipped = skipped.saturating_add(1); @@ -546,8 +449,8 @@ fn advance_cursor(dbio: &RocksDBIO, peer_zone: [u8; 32], cursor: &mut Option bool { let peer_zone = peer.peer_zone; let self_zone = peer.self_zone; - // 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 - // lock block production needs. + // Collected and written once, so recording a block is all-or-nothing; see + // RocksDBIO::add_pending_cross_zone_dispatches. let mut deliveries = Vec::new(); for (index, tx) in block.body.transactions.iter().enumerate() { let LeeTransaction::Public(public_tx) = tx else { @@ -627,7 +528,7 @@ fn record_block_deliveries( // the slot stays stuck. Ok(accepted) => { if accepted > 0 { - info!( + log::info!( "Watcher recorded {accepted} of {offered} cross-zone deliveries from peer {} block {}", hex::encode(peer_zone), block.header.block_id @@ -658,15 +559,11 @@ fn record_block_deliveries( #[cfg(test)] mod tests { use common::test_utils::produce_dummy_block; + use cross_zone::test_utils::{linked_chain_to, ping_emission}; use futures::stream; - use lee::{ - PublicTransaction, - public_transaction::{Message, WitnessSet}, - }; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; - use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; - use storage::sequencer::{DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, RocksDBIO}; + use storage::sequencer::{DB_META_PENDING_CROSS_ZONE_DISPATCH_COUNT_KEY, RocksDBIO}; use tempfile::TempDir; use super::*; @@ -695,27 +592,9 @@ mod tests { emission_to(programs::ping_receiver().id()) } - /// A `ping_sender` emission aimed at `target_program_id`. The sender lets its - /// caller name any target, which is exactly why the route has to pin the - /// pair rather than the target alone. + /// A `ping_sender` emission aimed at `target_program_id`. fn emission_to(target_program_id: lee_core::program::ProgramId) -> LeeTransaction { - let receiver_id = programs::ping_receiver().id(); - let send = SenderInstruction::Send { - target_zone: SELF_ZONE, - target_program_id, - 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, - }; - let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send) - .expect("emission serializes"); - LeeTransaction::Public(PublicTransaction::new( - message, - WitnessSet::from_raw_parts(vec![]), - )) + ping_emission(SELF_ZONE, target_program_id, b"hi") } fn peer_msg(data: Vec, slot: u64) -> (ZoneMessage, Slot) { @@ -730,14 +609,9 @@ mod tests { /// The peer's chain from its genesis up to and including `block_id`, each /// block linked to the one before it and carrying one emission for this - /// zone. Empty below [`GENESIS_BLOCK_ID`]. + /// zone. fn chain_to(block_id: u64) -> Vec { - let mut blocks: Vec = Vec::new(); - for id in GENESIS_BLOCK_ID..=block_id { - let prev = blocks.last().map(|block| block.header.hash); - blocks.push(produce_dummy_block(id, prev, vec![emission()])); - } - blocks + linked_chain_to(block_id, |_| vec![emission()]) } /// The peer's block at `block_id`. @@ -792,27 +666,31 @@ mod tests { peer_msg(b"not a block".to_vec(), slot) } - /// The message keys recorded so far, in insertion order. + /// The message keys recorded so far, sorted: the store keys each record by + /// its message key, so no insertion order survives. fn recorded_keys(dbio: &RocksDBIO) -> Vec<[u8; 32]> { - dbio.get_pending_cross_zone_dispatches() + let mut keys: Vec<[u8; 32]> = dbio + .get_pending_cross_zone_dispatches() .expect("pending dispatches readable") .into_iter() .map(|record| record.message_key) - .collect() + .collect(); + keys.sort_unstable(); + keys } - /// Makes every later pending-dispatch read fail, standing in for any store - /// failure between reading a peer block and the delivery being durable. - /// Recording reads the list before it writes it, so a value that will not - /// decode is enough. + /// Makes every later pending-dispatch write fail, standing in for any + /// store failure before a delivery is durable: recording reads the count + /// first, so a count that will not decode is enough. fn break_the_dispatch_store(dbio: &RocksDBIO) { let cf = dbio .db .cf_handle(storage::CF_META_NAME) .expect("meta column family"); - let key = borsh::to_vec(&DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY).expect("key encodes"); + let key = + borsh::to_vec(&DB_META_PENDING_CROSS_ZONE_DISPATCH_COUNT_KEY).expect("key encodes"); dbio.db - .put_cf(&cf, key, b"not a pending dispatch list") + .put_cf(&cf, key, b"not a pending dispatch count") .expect("write"); } @@ -826,60 +704,6 @@ mod tests { state } - fn stall(slot: u64, cursor: Option) -> (PassOutcome, Option) { - (PassOutcome::Undecodable(Slot::from(slot)), cursor) - } - - #[test] - fn a_stuck_slot_is_counted_but_never_read_past() { - // The watcher used to give up on a slot and read past it. Counting is - // now only how loud to be about one it is stuck on. - let passes = vec![stall(4, Some(3)); 3]; - assert_eq!(run_passes(&passes).stalled, Some((Slot::from(4), 3))); - - let long = vec![ - stall(4, Some(3)); - usize::try_from(STUCK_SLOT_ALERT_PASSES).expect("alert threshold fits") * 2 - ]; - assert_eq!( - run_passes(&long).stalled, - Some((Slot::from(4), STUCK_SLOT_ALERT_PASSES.saturating_mul(2))), - "a slot is retried for as long as it stays stuck" - ); - } - - #[test] - fn a_stream_that_ended_before_the_stalled_slot_does_not_reset_the_count() { - // The zone-sdk ends a stream on a fetch failure exactly as it does on - // catching up. Treating that as a clean pass would reset the count for - // ever, and a watcher stuck for hours would never say so. - let mut passes = vec![stall(4, Some(3)); 5]; - passes.push((PassOutcome::Drained, Some(3))); - let state = run_passes(&passes); - assert_eq!( - state.stalled, - Some((Slot::from(4), 5)), - "the count survives a pass that never reached the stalled slot" - ); - - // Getting past it is what actually clears the stall. - let mut read_past = vec![stall(4, Some(3)); 5]; - read_past.push((PassOutcome::Drained, Some(7))); - assert_eq!(run_passes(&read_past).stalled, None); - } - - #[test] - fn a_stall_says_so_on_a_cadence_rather_than_once() { - // Reporting only on the crossing leaves a watcher that never recovers - // looking resolved, which is the failure this whole commit is about. - assert!(!alerts_at(0)); - assert!(!alerts_at(1)); - assert!(!alerts_at(STUCK_SLOT_ALERT_PASSES - 1)); - assert!(alerts_at(STUCK_SLOT_ALERT_PASSES)); - assert!(!alerts_at(STUCK_SLOT_ALERT_PASSES + 1)); - assert!(alerts_at(STUCK_SLOT_ALERT_PASSES * 3)); - } - #[test] fn passes_that_place_nothing_while_skipping_blocks_are_counted() { // A tip that stops tracking the peer is silent by construction: every @@ -903,12 +727,6 @@ mod tests { assert_eq!(run_passes(&[(PassOutcome::Drained, Some(4))]).stranded, 0); } - #[test] - fn a_stall_at_a_new_slot_starts_its_own_count() { - let passes = vec![stall(4, Some(3)), stall(4, Some(3)), stall(9, Some(8))]; - assert_eq!(run_passes(&passes).stalled, Some((Slot::from(9), 1))); - } - #[test] fn every_way_of_ending_early_keeps_the_slot_coming_back() { // Undecodable and undelivered differ in whose problem they are, not in @@ -918,106 +736,14 @@ mod tests { PassOutcome::Undecodable(Slot::from(4)), PassOutcome::Undelivered(Slot::from(4)), ] { + let mut state = WatcherState::default(); assert_eq!( - run_passes(&[(outcome, Some(3))]).stalled, + state.after_pass(outcome, Some(Slot::from(3))), Some((Slot::from(4), 1)) ); } } - #[test] - fn only_the_next_block_off_the_tip_links() { - let tip = Some(tip_at(2)); - - assert_eq!( - link_against(tip, &chain_block(3), None), - Link::Next(chain_hash(3)), - "the block that continues the chain is the one delivered from" - ); - - // The #677 suppression. The peer's chain is public, so the version that - // matters is the block linking correctly and lying only about the id: - // one with no link at all is caught by the check below and proves - // nothing about this one. - assert!(matches!( - link_against( - tip, - &produce_dummy_block(5, Some(chain_hash(2)), vec![emission()]), - None - ), - Link::OffChain(_) - )); - assert!(matches!( - link_against(tip, &produce_dummy_block(5, None, vec![emission()]), None), - Link::OffChain(_) - )); - - // Two blocks claiming one id collapse to one key on chain, so - // delivering from both delivers one message twice. - assert_eq!(link_against(tip, &chain_block(2), None), Link::AlreadySeen); - assert_eq!( - link_against( - tip, - &produce_dummy_block(2, Some(HashType([9; 32])), vec![emission()]), - None - ), - Link::AlreadySeen - ); - - // Right id, wrong ancestry: the peer forked at our tip, or reset it. - assert!(matches!( - link_against( - tip, - &produce_dummy_block(3, Some(HashType([9; 32])), vec![emission()]), - None - ), - Link::OffChain(_) - )); - } - - #[test] - fn a_watcher_with_no_tip_starts_at_the_peers_genesis() { - assert_eq!( - link_against(None, &chain_block(GENESIS_BLOCK_ID), None), - Link::Next(chain_hash(GENESIS_BLOCK_ID)) - ); - // Anchoring on whatever arrived first is the whole attack: the peer - // would pick the id, and every key below it with one block. - assert!(matches!( - link_against(None, &chain_block(GENESIS_BLOCK_ID + 1), None), - Link::OffChain(_) - )); - } - - #[test] - fn a_block_whose_header_hash_is_not_its_contents_is_off_chain() { - // A correctly signed block can still carry any value in `header.hash`. - let mut tampered = chain_block(3); - tampered.header.hash = HashType([9; 32]); - assert!(matches!( - link_against(Some(tip_at(2)), &tampered, None), - Link::OffChain(_) - )); - } - - #[test] - fn a_block_not_signed_by_the_pinned_key_is_not_delivered_from() { - let signer = lee::PublicKey::new_from_private_key( - &lee::PrivateKey::try_new([37; 32]).expect("test key"), - ); - assert_eq!( - link_against(None, &chain_block(GENESIS_BLOCK_ID), Some(&signer)), - Link::Next(chain_hash(GENESIS_BLOCK_ID)), - "produce_dummy_block signs with this key, so the pin must accept it" - ); - - let other = lee::PublicKey::try_new([42; 32]).expect("test key"); - assert!(matches!( - link_against(None, &chain_block(GENESIS_BLOCK_ID), Some(&other)), - Link::OffChain(_) - )); - } - #[test] fn a_floor_without_a_tip_resumes_from_the_peers_genesis() { // A store written before chain pinning. The floor is cleared rather @@ -1069,10 +795,9 @@ mod tests { Some(Slot::from(1)), "the cursor must be durable, not just in memory" ); - assert_eq!( - recorded_keys(&dbio), - vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)] - ); + let mut expected = vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)]; + expected.sort_unstable(); + assert_eq!(recorded_keys(&dbio), expected); } #[tokio::test] @@ -1287,13 +1012,15 @@ mod tests { ) .await; + let mut expected = vec![ + message_key(&PEER_ZONE, 1, 0), + message_key(&PEER_ZONE, 2, 0), + message_key(&PEER_ZONE, 3, 0), + ]; + expected.sort_unstable(); assert_eq!( recorded_keys(&dbio), - vec![ - message_key(&PEER_ZONE, 1, 0), - message_key(&PEER_ZONE, 2, 0), - message_key(&PEER_ZONE, 3, 0) - ], + expected, "only the unread block is recorded on the second pass" ); assert_eq!( @@ -1427,13 +1154,15 @@ mod tests { .await; assert_eq!(outcome, PassOutcome::Drained); + let mut expected = vec![ + message_key(&PEER_ZONE, 1, 0), + message_key(&PEER_ZONE, 2, 0), + message_key(&PEER_ZONE, 3, 0), + ]; + expected.sort_unstable(); assert_eq!( recorded_keys(&dbio), - vec![ - message_key(&PEER_ZONE, 1, 0), - message_key(&PEER_ZONE, 2, 0), - message_key(&PEER_ZONE, 3, 0) - ], + expected, "the key the peer aimed to burn is never recorded, and nothing else is held up" ); assert_eq!(tip, Some(tip_at(3))); @@ -1466,9 +1195,11 @@ mod tests { PassOutcome::Drained, "a peer equivocating about its own chain is not this node's failure" ); + let mut expected = vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)]; + expected.sort_unstable(); assert_eq!( recorded_keys(&dbio), - vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)], + expected, "one delivery per id, whatever the peer publishes under it" ); assert_eq!(tip, Some(tip_at(2))); @@ -1497,9 +1228,11 @@ mod tests { .await; assert_eq!(outcome, PassOutcome::Drained); + let mut expected = vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)]; + expected.sort_unstable(); assert_eq!( recorded_keys(&dbio), - vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)], + expected, "the fork is passed over and the peer's own chain continues" ); assert_eq!(tip, Some(tip_at(2))); @@ -1556,10 +1289,9 @@ mod tests { .await; assert_eq!(outcome, PassOutcome::Drained); - assert_eq!( - recorded_keys(&dbio), - vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)] - ); + let mut expected = vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)]; + expected.sort_unstable(); + assert_eq!(recorded_keys(&dbio), expected); assert_eq!(tip, Some(tip_at(2))); } 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 0eec95d22..21a5a4ad0 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}; @@ -22,6 +22,7 @@ use itertools::Itertools as _; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::GENESIS_BLOCK_ID; 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,17 +43,20 @@ use storage::sequencer::{ WithdrawalReconciliationKey, ZoneAnchorRecord, }, }; +use tokio_retry::{Retry, strategy::FixedInterval}; use crate::{ block_publisher::{BlockPublisherTrait, MsgId, NoteId, ZoneSdkPublisher}, block_store::SequencerStore, - task_group::{StoreRelease, TaskGroup}, + task_group::TaskGroup, }; 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,17 +135,28 @@ 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.home.join("rocksdb"); + let db_path = config.db_path(); if db_path.exists() { let store = SequencerStore::open_db(&db_path, signing_key).unwrap_or_else(|err| { @@ -133,12 +170,20 @@ impl SequencerCore { .expect("Failed to read state from store"); (store, state) } else { + let legacy = config.home.join("rocksdb"); + if legacy.exists() { + warn!( + "Ignoring pre-channel-suffix database at {}; rename it to {} to resume it", + legacy.display(), + db_path.display() + ); + } warn!( "Database not found at {}, starting from genesis", 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, @@ -215,12 +260,41 @@ impl SequencerCore { let bedrock_signing_key = load_or_create_signing_key(&config.home.join("bedrock_signing_key")) .expect("Failed to load or create bedrock signing key"); - info!( + log::info!( "Bedrock signing public key: {}", 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, @@ -299,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) @@ -333,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()); @@ -577,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 { @@ -599,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) @@ -614,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. @@ -680,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; } }; @@ -724,11 +906,14 @@ impl SequencerCore { } } - info!("Validated transaction with hash {tx_hash}, including it in block"); + log::info!("Validated transaction with hash {tx_hash}, including it in block"); 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 @@ -766,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| { @@ -795,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() { @@ -863,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)) @@ -927,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, @@ -988,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 @@ -1025,7 +1241,7 @@ impl SequencerCore { // TODO: Delete blocks instead of marking them as finalized. Current // approach is used because we still have `GetBlockDataRequest`. pub fn clean_finalized_blocks_from_db(&self, last_finalized_block_id: u64) -> Result<()> { - info!("Clearing pending blocks up to id: {last_finalized_block_id}"); + log::info!("Clearing pending blocks up to id: {last_finalized_block_id}"); self.store .dbio() .clean_pending_blocks_up_to(last_finalized_block_id)?; @@ -1145,13 +1361,6 @@ impl SequencerCore { Ok((total, retained)) } - /// A weak reference to this sequencer's store, for a shutdown path that - /// needs to observe the database actually closing rather than infer it. - #[must_use] - pub fn store_release(&self) -> StoreRelease { - StoreRelease::new(&self.store.dbio()) - } - /// Every background task that holds this sequencer's store handle. /// /// Taken before the core is shared, so a shutdown path can wait for them @@ -1214,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 @@ -1460,7 +1670,7 @@ fn apply_follow_update( record_dead_letter_gauge(dbio); if outcome.accepted_deposits > 0 { - info!( + log::info!( "Recorded {} Bedrock Deposit event(s); their mints are drained from the store on our next turn", outcome.accepted_deposits ); @@ -1491,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(); @@ -1512,11 +1723,14 @@ 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. - info!( + log::info!( "Genesis fingerprint: {}", hex::encode(state.genesis_fingerprint()) ); @@ -1552,16 +1766,32 @@ fn build_genesis_state(config: &SequencerConfig) -> (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 @@ -1574,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, }) } @@ -1680,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 @@ -1872,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); @@ -1890,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 b35e3be39..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, @@ -70,9 +75,9 @@ impl BlockPublisherTrait for MockBlockPublisher { }) } - async fn publish_block( - &self, - block: &Block, + async fn publish_block<'blk, 'pbl: 'blk>( + &'pbl self, + block: &'blk Block, withdrawals: Vec, ) -> Result { // Deterministic per-block id so head dedup behaves in tests. @@ -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/task_group.rs b/lez/sequencer/core/src/task_group.rs index 8572a62fb..578b6f1b7 100644 --- a/lez/sequencer/core/src/task_group.rs +++ b/lez/sequencer/core/src/task_group.rs @@ -1,9 +1,8 @@ //! A set of background tasks that can be stopped and waited on. -use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use log::warn; -use storage::sequencer::RocksDBIO; use tokio::task::JoinHandle; /// Background tasks owned by one component, stoppable on demand and stopped @@ -25,27 +24,6 @@ pub struct TaskGroup(Arc); #[derive(Default)] struct TaskGroupInner(Mutex>>); -/// A weak handle to the store, for observing when it is finally closed. -/// -/// Every strong reference lives inside a task or a server that shutdown stops, -/// but the last drop runs on whichever thread owned it, not on the one awaiting -/// shutdown. Watching the count is the difference between knowing the database -/// file is closed and assuming it from another crate's drop order. -pub struct StoreRelease(Weak); - -impl StoreRelease { - #[must_use] - pub fn new(store: &Arc) -> Self { - Self(Arc::downgrade(store)) - } - - /// How many holders are left. Zero means the store is closed. - #[must_use] - pub fn holders(&self) -> usize { - self.0.strong_count() - } -} - impl Drop for TaskGroupInner { fn drop(&mut self) { for task in Self::take(&self.0) { diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 40e5368d1..567aea519 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -20,8 +20,8 @@ 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, receiver_config_account_id}; use storage::sequencer::sequencer_cells::{ @@ -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) } @@ -290,8 +344,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, @@ -301,7 +356,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() { let genesis_block = genesis_hashable_data.into_pending_block(&signing_key); SequencerStore::create_db_with_genesis( - &config.home.join("rocksdb"), + &config.db_path(), &genesis_block, &genesis_state, signing_key, @@ -321,7 +376,7 @@ async fn start_from_config_panics_when_db_open_returns_non_not_found_error() { let temp_dir = tempdir().unwrap(); config.home = temp_dir.path().to_path_buf(); - let db_path = config.home.join("rocksdb"); + let db_path = config.db_path(); std::fs::create_dir_all(&config.home).unwrap(); // Force RocksDB open to fail with an IO error by placing a file at DB path. @@ -353,7 +408,7 @@ async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() { { let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); - let store = SequencerStore::open_db(&config.home.join("rocksdb"), signing_key).unwrap(); + let store = SequencerStore::open_db(&config.db_path(), signing_key).unwrap(); let inserted = store .dbio() @@ -372,7 +427,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) @@ -424,8 +479,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 @@ -474,7 +529,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))), @@ -501,7 +556,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) @@ -604,7 +659,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) @@ -651,8 +706,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( @@ -694,7 +749,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) @@ -717,7 +772,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" @@ -753,7 +808,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()); @@ -777,7 +832,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]); @@ -800,7 +855,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(), @@ -829,8 +884,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) @@ -910,7 +965,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), @@ -920,7 +975,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(), @@ -952,7 +1007,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(), @@ -961,7 +1016,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); } @@ -1110,7 +1165,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()); @@ -1127,12 +1182,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; @@ -1159,7 +1248,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()) @@ -1194,7 +1283,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()) @@ -1213,7 +1302,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()) @@ -1255,7 +1344,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()) @@ -1293,9 +1382,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); } @@ -1304,9 +1393,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 @@ -1342,7 +1431,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() @@ -1368,7 +1457,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 @@ -1421,7 +1510,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 @@ -1451,7 +1540,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 @@ -1476,7 +1565,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 @@ -1516,7 +1605,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" @@ -1532,7 +1621,7 @@ async fn block_production_aborts_when_clock_account_data_is_corrupted() { // 0, // ); // let sender_private_account = Account { -// program_owner: programs::authenticated_transfer().id(), +// program_owner: programs::authenticated_transfer().id().into(), // balance: 100, // nonce: Nonce(0xdead_beef), // data: Data::default(), @@ -1659,7 +1748,7 @@ fn time_locked_transfer_succeeds_when_deadline_has_passed() { state.force_insert_account( recipient_id, Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), ..Account::default() }, ); @@ -1669,7 +1758,7 @@ fn time_locked_transfer_succeeds_when_deadline_has_passed() { state.force_insert_account( sender_id, Account { - program_owner: test_programs::time_locked_transfer().id(), + program_owner: test_programs::time_locked_transfer().id().into(), balance: 100, ..Account::default() }, @@ -1708,7 +1797,7 @@ fn time_locked_transfer_fails_when_deadline_is_in_the_future() { state.force_insert_account( recipient_id, Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), ..Account::default() }, ); @@ -1718,7 +1807,7 @@ fn time_locked_transfer_fails_when_deadline_is_in_the_future() { state.force_insert_account( sender_id, Account { - program_owner: test_programs::time_locked_transfer().id(), + program_owner: test_programs::time_locked_transfer().id().into(), balance: 100, ..Account::default() }, @@ -1793,14 +1882,14 @@ fn pinata_cooldown_claim_succeeds_after_cooldown() { state.force_insert_account( winner_id, Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), ..Account::default() }, ); state.force_insert_account( pinata_id, Account { - program_owner: test_programs::pinata_cooldown().id(), + program_owner: test_programs::pinata_cooldown().id().into(), balance: 1000, data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp) .try_into() @@ -1840,14 +1929,14 @@ fn pinata_cooldown_claim_fails_during_cooldown() { state.force_insert_account( winner_id, Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), ..Account::default() }, ); state.force_insert_account( pinata_id, Account { - program_owner: test_programs::pinata_cooldown().id(), + program_owner: test_programs::pinata_cooldown().id().into(), balance: 1000, data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp) .try_into() @@ -1886,7 +1975,7 @@ fn pda_mechanism_with_pinata_token_program() { balance: 150, }; let expected_winner_token_holding_post = Account { - program_owner: token.id(), + program_owner: token.id().into(), data: Data::from(&expected_winner_account_holding), ..Account::default() }; @@ -1897,7 +1986,7 @@ fn pda_mechanism_with_pinata_token_program() { state.force_insert_account( pinata_definition_id, Account { - program_owner: pinata_token.id(), + program_owner: pinata_token.id().into(), // Difficulty: 3 data: vec![3; 33].try_into().unwrap(), ..Account::default() @@ -1924,7 +2013,7 @@ fn pda_mechanism_with_pinata_token_program() { state.force_insert_account( pinata_token_definition_id, Account { - program_owner: token.id(), + program_owner: token.id().into(), data: Data::from(&token_definition), ..Account::default() }, @@ -1932,7 +2021,7 @@ fn pda_mechanism_with_pinata_token_program() { state.force_insert_account( pinata_token_holding_id, Account { - program_owner: token.id(), + program_owner: token.id().into(), data: Data::from(&token_holding), ..Account::default() }, @@ -1940,7 +2029,7 @@ fn pda_mechanism_with_pinata_token_program() { state.force_insert_account( winner_token_holding_id, Account { - program_owner: token.id(), + program_owner: token.id().into(), data: Data::from(&winner_holding), ..Account::default() }, @@ -2067,8 +2156,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), @@ -2225,7 +2314,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 @@ -2267,7 +2356,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( @@ -2323,7 +2412,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( @@ -2372,7 +2461,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( @@ -2416,7 +2505,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!( @@ -2464,7 +2553,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. @@ -2543,7 +2632,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 { @@ -2622,7 +2711,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()) }; @@ -2687,7 +2776,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(), @@ -2840,3 +2929,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().into(), + 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().into(), + "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().into(), + 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().into(), + "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().into(), + 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().into(), + "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().into() + ); + 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 396fbe6b0..c8edaec26 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -9,29 +9,45 @@ license = { workspace = true } workspace = true [dependencies] -common.workspace = true -lee.workspace = true -mempool.workspace = true sequencer_core = { workspace = true, features = ["testnet"] } -sequencer_service_protocol.workspace = true -sequencer_service_rpc = { workspace = true, features = ["server"] } -sequencer_service_metrics = { workspace = true, features = ["record"] } -programs.workspace = true +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 env_logger.workspace = true +kameo.workspace = true +kameo_actors.workspace = true hex.workspace = true log.workspace = true metrics-exporter-prometheus.workspace = true tokio.workspace = true tokio-util.workspace = true -jsonrpsee.workspace = true futures.workspace = true -bytesize.workspace = true -borsh.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/actor_handle.rs b/lez/sequencer/service/src/actor_handle.rs new file mode 100644 index 000000000..92433e35c --- /dev/null +++ b/lez/sequencer/service/src/actor_handle.rs @@ -0,0 +1,80 @@ +use anyhow::{Result, anyhow}; +use futures::never::Never; +use kameo::actor::ActorRef; +use log::{error, info}; + +/// A handle to an actor encapsulating some common operations like graceful shutdown and health +/// check. +pub struct ActorHandle { + actor_ref: ActorRef, + full_name: String, +} + +impl ActorHandle { + pub fn new(actor_ref: ActorRef) -> Self { + Self { + full_name: format!("{}{}", T::name(), actor_ref.id()), + actor_ref, + } + } + + pub fn full_name(&self) -> &str { + &self.full_name + } + + pub async fn shutdown(self) { + let full_name = self.full_name(); + info!("Stopping {full_name} actor..."); + + if let Err(err) = self.actor_ref.stop_gracefully().await { + error!("Failed to gracefully stop actor {full_name}: {err}",); + } + self.actor_ref.wait_for_shutdown_with_result(|_| ()).await; + + info!("{full_name} actor stopped"); + } + + pub async fn failed(&self) -> Result + where + T::Error: std::fmt::Display, + { + let err = self + .actor_ref + .wait_for_shutdown_with_result(|res| self.stop_res_into_anyhow(res)) + .await; + Err(err) + } + + pub fn is_healthy(&self) -> bool { + self.actor_ref.is_alive() + } + + fn stop_res_into_anyhow( + &self, + res: Result<&kameo::error::ActorStopReason, kameo::error::HookError<&E>>, + ) -> anyhow::Error { + let full_name = self.full_name(); + + match res { + Ok(reason) => anyhow!("{full_name} actor has been stopped: {reason}"), + Err(kameo::error::HookError::Panicked(err)) => { + anyhow!(err).context(format!("{full_name} actor has been stopped due to panic")) + } + Err(kameo::error::HookError::Error(err)) => { + // Can't use `anyhow!(err)` here because `err` is a reference to the error, not + // the error itself. Also can't require `E: Clone` as a lot + // of error types don't implement `Clone` + // (e.g. `std::io::Error` and `anyhow::Error`). + anyhow!(format!("{err:#}")) + .context(format!("{full_name} actor has been stopped due to error")) + } + } + } +} + +impl Drop for ActorHandle { + fn drop(&mut self) { + info!("Killing {} actor", self.full_name()); + self.actor_ref.kill(); + } +} 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 683b8ad4c..e6b819c09 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -1,103 +1,72 @@ -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::net::SocketAddr; -use anyhow::{Context as _, Result, anyhow}; -use bytesize::ByteSize; -use common::transaction::LeeTransaction; +use anyhow::{Context as _, Result}; use futures::never::Never; -use jsonrpsee::server::ServerHandle; -use log::{error, info, warn}; -use mempool::MemPoolHandle; -#[cfg(not(feature = "standalone"))] -use sequencer_core::SequencerCore; -#[cfg(feature = "standalone")] -use sequencer_core::SequencerCoreWithMockClients as SequencerCore; +use kameo::actor::Spawn as _; +use kameo_actors::scheduler::{Scheduler, SetInterval}; +use log::info; pub use sequencer_core::config::*; -use sequencer_core::{ - TransactionOrigin, - block_publisher::BlockPublisherTrait as _, - task_group::{StoreRelease, TaskGroup}, -}; -use sequencer_service_rpc::RpcServer as _; -use tokio::{sync::Mutex, task::JoinHandle}; -use tokio_util::sync::CancellationToken; +use sequencer_core::load_or_create_signing_key; +use sequencer_executor_actor::ExecutorActor; +use sequencer_rpc_server_actor::RpcServerActor; +use tokio::select; -pub mod service; +use crate::actor_handle::ActorHandle; -const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10); +mod actor_handle; + +#[cfg(not(feature = "standalone"))] +type BlockPublisher = sequencer_core::block_publisher::ZoneSdkPublisher; + +#[cfg(feature = "standalone")] +type BlockPublisher = sequencer_core::mock::MockBlockPublisher; /// Handle to manage the sequencer and its tasks. /// -/// Implements `Drop` to ensure all tasks are aborted and the RPC server is stopped when dropped. +/// Implements `Drop` to ensure all actors are killed when dropped. pub struct SequencerHandle { + // NOTE: Order of fields matters as it affects drop order. + scheduler: ActorHandle, + rpc_server: ActorHandle, + executor: ActorHandle>, addr: SocketAddr, - server_handle: ServerHandle, - main_loop_handle: JoinHandle>, - /// Cancelled when the publisher's drive task terminates (e.g. a panicked - /// persist sink); no channel events are processed past that point. - driver_cancellation: CancellationToken, - /// The core's background tasks, taken before the core was shared. This - /// handle owns no reference to the core itself, so without these there is - /// nothing to wait on: aborting the main loop only starts the teardown. - background_tasks: Vec, - /// The store, weakly. Every strong reference lives inside something this - /// handle stops, so watching the count go to zero is how shutdown knows the - /// database file is actually closed rather than assuming it from drop order. - store: StoreRelease, + /// Held for its lifetime: dropping it stops the gossip drive task. + /// `None` when gossip is unconfigured. + gossip: Option, } impl SequencerHandle { const fn new( + scheduler: ActorHandle, + rpc_server: ActorHandle, + executor: ActorHandle>, addr: SocketAddr, - server_handle: ServerHandle, - main_loop_handle: JoinHandle>, - driver_cancellation: CancellationToken, - background_tasks: Vec, - store: StoreRelease, + gossip: Option, ) -> Self { Self { + scheduler, + rpc_server, + executor, addr, - server_handle, - main_loop_handle, - driver_cancellation, - background_tasks, - store, + gossip, } } /// Stops the sequencer and waits for every part of it to be gone. - /// - /// `Drop` alone cannot do this: it aborts the main loop without awaiting it, - /// and the core lives behind `Arc`s held by that task and the RPC server, so - /// after a plain drop the store is still open for an unbounded stretch. That - /// is why restarting a sequencer on the same home directory used to need a - /// sleep, and why an in-process restart could fail outright with a `RocksDB` - /// lock error. - /// - /// Order matters: the main loop stops first so nothing new is produced while - /// the publisher is torn down, then the background tasks that hold the store, - /// then the server. Consuming `self` drops the last references, so the store - /// is closed by the time this returns. - pub async fn shutdown(mut self) { - self.main_loop_handle.abort(); - if let Err(err) = (&mut self.main_loop_handle).await - && err.is_panic() - { - error!("Sequencer main loop panicked before shutdown: {err}"); - } + /// executor itself. + pub async fn shutdown(self) { + let Self { + scheduler, + rpc_server, + executor, + addr: _, + gossip: _, + } = self; - for tasks in &self.background_tasks { - tasks.shutdown().await; - } - - if let Err(err) = self.server_handle.stop() { - error!("An error occurred while stopping Sequencer RPC server: {err}"); - } - self.server_handle.clone().stopped().await; - - // Nothing this handle owns holds the store, so waiting here rather than - // after the drop is the same thing, and it keeps the guarantee inside - // the call the caller awaits. - wait_for_store_release(&self.store).await; + // NOTE: Order of shutdown matters. Make sure it follows the order of fields in the struct. + scheduler.shutdown().await; + rpc_server.shutdown().await; + executor.shutdown().await; } /// Wait for any of the sequencer tasks to fail and return the error. @@ -105,30 +74,24 @@ impl SequencerHandle { clippy::integer_division_remainder_used, reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" )] - pub async fn failed(&mut self) -> Result { + pub async fn failed(&self) -> Result { let Self { + executor, + rpc_server, + scheduler, addr: _, - server_handle, - main_loop_handle, - driver_cancellation, - background_tasks: _, - store: _, + gossip: _, } = self; - // Cloned rather than taken: `stopped()` consumes a handle, and taking - // this one would leave `shutdown` with no way to stop the server. - let server_handle = server_handle.clone(); - tokio::select! { - () = server_handle.stopped() => { - Err(anyhow!("RPC Server stopped")) + select! { + Err(err) = executor.failed() => { + Err(err) } - res = main_loop_handle => { - res - .context("Main loop task panicked")? - .context("Main loop exited unexpectedly") + Err(err) = rpc_server.failed() => { + Err(err) } - () = driver_cancellation.cancelled() => { - Err(anyhow!("Publisher drive task terminated")) + Err(err) = scheduler.failed() => { + Err(err) } } } @@ -140,175 +103,103 @@ impl SequencerHandle { #[must_use] pub fn is_healthy(&self) -> bool { let Self { + executor, + rpc_server, + scheduler, addr: _, - server_handle, - main_loop_handle, - driver_cancellation, - background_tasks, - store: _, + gossip: _, } = self; - let stopped = server_handle.is_stopped() - || main_loop_handle.is_finished() - || driver_cancellation.is_cancelled() - // A watcher only ends by panicking, and a peer whose deliveries have - // silently stopped is exactly what this predicate exists to catch. - || background_tasks.iter().any(TaskGroup::any_finished); - !stopped + executor.is_healthy() && rpc_server.is_healthy() && scheduler.is_healthy() } #[must_use] pub const fn addr(&self) -> SocketAddr { self.addr } -} -impl Drop for SequencerHandle { - fn drop(&mut self) { - let Self { - addr: _, - server_handle, - main_loop_handle, - driver_cancellation: _, - background_tasks: _, - store: _, - } = self; - - main_loop_handle.abort(); - - if let Err(err) = server_handle.stop() { - error!("An error occurred while stopping Sequencer RPC server: {err}"); - } - } -} - -/// Waits until nothing holds the store any more. -/// -/// Everything that holds one lives inside a task or a server this handle has -/// already stopped, but the last drop happens on whichever thread ran them, not -/// on this one. Without this the caller can reopen the database a moment too -/// early and hit a `RocksDB` lock error, which is the kind of failure that shows -/// up as an occasional flake rather than a bug. -async fn wait_for_store_release(store: &StoreRelease) { - /// Long enough for a drop that is already in flight, short enough that a - /// leak is reported rather than hung on. - const RELEASE_TIMEOUT: Duration = Duration::from_secs(10); - const POLL: Duration = Duration::from_millis(10); - - let released = tokio::time::timeout(RELEASE_TIMEOUT, async { - while store.holders() > 0 { - tokio::time::sleep(POLL).await; - } - }) - .await; - - if released.is_err() { - error!( - "Sequencer store still held by {} reference(s) after shutdown; something outlived the tasks it should have died with", - store.holders() - ); + /// 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 { - sequencer_service_metrics::init(); - let block_timeout = config.block_create_timeout; let max_block_size = config.max_block_size; - let (sequencer_core, mempool_handle): (SequencerCore, _) = - SequencerCore::start_from_config(config).await; + // 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(); - info!("Sequencer core set up"); + let executor = ExecutorActor::new(config).await; + let mempool_handle = executor.mempool_handle(); + let executor_ref = ExecutorActor::spawn(executor); + info!("Executor Actor spawned"); - let driver_cancellation = sequencer_core.block_publisher().driver_cancellation(); - // Taken while the core is still owned here: once it is behind the `Arc` - // below, the only owners are the RPC server and the main loop task, and - // neither hands it back. - let background_tasks = sequencer_core.background_tasks(); - let store = sequencer_core.store_release(); - let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core)); - let mempool_handle_for_server = mempool_handle.clone(); + // 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 (server_handle, addr) = run_server( - Arc::clone(&seq_core_wrapped), - mempool_handle_for_server, + let rpc_server = RpcServerActor::new( + executor_ref.clone(), listen_addr, - max_block_size.as_u64(), + max_block_size, + tx_publisher, ) .await?; - info!("RPC server started"); + let addr = rpc_server.addr(); + let rpc_server_ref = RpcServerActor::spawn(rpc_server); + info!("RPC Server Actor spawned"); - info!("Starting main sequencer loop"); - let main_loop_handle = tokio::spawn(main_loop(seq_core_wrapped, block_timeout)); - - let _ = mempool_handle; + let scheduler_ref = Scheduler::spawn(Scheduler::new()); + scheduler_ref + .tell( + SetInterval::new( + executor_ref.downgrade(), + block_timeout, + sequencer_executor_actor::protocol::ProduceBlock, + ) + .start_delay(block_timeout) + .set_missed_tick_behaviour(tokio::time::MissedTickBehavior::Delay), + ) + .await?; + info!("Block production scheduler started"); Ok(SequencerHandle::new( + ActorHandle::new(scheduler_ref), + ActorHandle::new(rpc_server_ref), + ActorHandle::new(executor_ref), addr, - server_handle, - main_loop_handle, - driver_cancellation, - background_tasks, - store, + gossip_network, )) } - -async fn run_server( - sequencer: Arc>, - mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - listen_addr: SocketAddr, - max_block_size: u64, -) -> Result<(ServerHandle, SocketAddr)> { - let server = jsonrpsee::server::ServerBuilder::with_config( - jsonrpsee::server::ServerConfigBuilder::new() - .max_request_body_size( - u32::try_from(REQUEST_BODY_MAX_SIZE.as_u64()) - .expect("REQUEST_BODY_MAX_SIZE should be less than u32::MAX"), - ) - .build(), - ) - .build(listen_addr) - .await - .context("Failed to build RPC server")?; - - let addr = server - .local_addr() - .context("Failed to get local address of RPC server")?; - - info!("Starting Sequencer Service RPC server on {addr}"); - - let service = service::SequencerService::new(sequencer, mempool_handle, max_block_size); - let handle = server.start(service.into_rpc()); - Ok((handle, addr)) -} - -async fn main_loop(seq_core: Arc>, block_timeout: Duration) -> Result { - loop { - tokio::time::sleep(block_timeout).await; - - let mut state = seq_core.lock().await; - - // Only produce on our turn. - if !state.is_our_turn() { - continue; - } - - // Never inscribe a second block at a height we already published: the - // channel would carry two chains from there and nothing resolves that. - // The head rewinds under us when the sdk orphans our own unfinalized - // blocks, and recovers once they finalize, so this is a wait. - if let Some(high_water) = state.rewound_below_published() { - warn!( - "Skipping turn: head rewound to {} but block {high_water} is already inscribed; \ - waiting for the channel to restore it", - state.next_block_height().saturating_sub(1), - ); - continue; - } - - info!("Our turn: collecting transactions from mempool, creating block"); - let id = state.produce_new_block().await?; - info!("Block with id {id} created"); - } -} diff --git a/lez/sequencer/service/src/main.rs b/lez/sequencer/service/src/main.rs index b3d5bf719..9945ecd61 100644 --- a/lez/sequencer/service/src/main.rs +++ b/lez/sequencer/service/src/main.rs @@ -5,7 +5,7 @@ use std::{ use anyhow::{Context as _, Result}; use clap::Parser; -use log::{error, info}; +use log::error; use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; use tokio::signal::unix::{SignalKind, signal}; use tokio_util::sync::CancellationToken; @@ -49,12 +49,12 @@ async fn main() -> Result<()> { if let Some(metrics_address) = config.metrics_address { install_prometheus_recorder(metrics_address)?; } - let mut sequencer_handle = + let sequencer_handle = sequencer_service::run(config, SocketAddr::new(args.listen_address, args.port)).await?; tokio::select! { () = cancellation_token.cancelled() => { - info!("Shutting down sequencer..."); + log::info!("Shutting down sequencer..."); } Err(err) = sequencer_handle.failed() => { error!("Sequencer failed unexpectedly: {err}"); @@ -68,7 +68,7 @@ async fn main() -> Result<()> { // delivery and handing it over. sequencer_handle.shutdown().await; - info!("Sequencer shutdown complete"); + log::info!("Sequencer shutdown complete"); Ok(()) } @@ -135,13 +135,13 @@ fn listen_for_shutdown_signal() -> CancellationToken { tokio::select! { result = tokio::signal::ctrl_c() => match result { - Ok(()) => info!("Received Ctrl-C signal"), + Ok(()) => log::info!("Received Ctrl-C signal"), Err(err) => { error!("Failed to listen for Ctrl-C signal: {err}"); return; } }, - _ = terminate.recv() => info!("Received SIGTERM"), + _ = terminate.recv() => log::info!("Received SIGTERM"), } cancellation_token_clone.cancel(); diff --git a/lez/storage/src/indexer/tests.rs b/lez/storage/src/indexer/tests.rs index d87aaf1ca..148454efe 100644 --- a/lez/storage/src/indexer/tests.rs +++ b/lez/storage/src/indexer/tests.rs @@ -31,7 +31,7 @@ fn initial_state() -> lee::V03State { ( id, Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), balance, ..Account::default() }, diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 90f074145..c8c2b4626 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -10,9 +10,10 @@ use common::{ block::{BedrockStatus, Block, BlockMeta}, }; use lee::V03State; +use log::info; use rocksdb::{ - BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, IteratorMode, MultiThreaded, - Options, WriteBatch, + BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, Direction, IteratorMode, + MultiThreaded, Options, WriteBatch, }; use crate::{ @@ -24,12 +25,14 @@ use crate::{ DeadLetterCrossZoneDispatchesCellRef, DeadLetterDispatchRecord, DispatchOrigin, FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned, FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, - LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PeerChainTip, PeerFloorCellOwned, - PeerFloorCellRef, PeerTipCell, PeerZoneKey, PendingCrossZoneDispatchRecord, - PendingCrossZoneDispatchesCellOwned, PendingCrossZoneDispatchesCellRef, - PendingDepositEventRecord, PendingDepositEventsCellOwned, PendingDepositEventsCellRef, - PublishedHighWaterCell, UnseenWithdrawCountCell, WithdrawalReconciliationKey, - ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef, + LatestBlockMetaCellOwned, LatestBlockMetaCellRef, + LegacyPendingCrossZoneDispatchesCellOwned, PeerChainTip, PeerFloorCellOwned, + PeerFloorCellRef, PeerTipCell, PeerZoneKey, PendingCrossZoneDispatchCellOwned, + PendingCrossZoneDispatchCellRef, PendingCrossZoneDispatchCountCell, + PendingCrossZoneDispatchRecord, PendingDepositEventRecord, PendingDepositEventsCellOwned, + PendingDepositEventsCellRef, PublishedHighWaterCell, UnseenWithdrawCountCell, + WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, + ZoneSdkCheckpointCellRef, }, }; @@ -54,8 +57,13 @@ pub const DB_META_CROSS_ZONE_PEER_FLOOR_KEY: &str = "cross_zone_peer_floor"; /// Key base for storing the last peer block a cross-zone watcher delivered /// from, as an id and hash pair. Keyed per peer zone. pub const DB_META_CROSS_ZONE_PEER_TIP_KEY: &str = "cross_zone_peer_tip"; -/// Key base for storing cross-zone deliveries the watcher has recorded but -/// which are not yet known to be irreversibly delivered. +/// Key base for storing one cross-zone delivery the watcher has recorded but +/// which is not yet known to be irreversibly delivered. Keyed per message. +pub const DB_META_PENDING_CROSS_ZONE_DISPATCH_KEY: &str = "pending_cross_zone_dispatch"; +/// Key base for counting the pending cross-zone dispatch records. +pub const DB_META_PENDING_CROSS_ZONE_DISPATCH_COUNT_KEY: &str = "pending_cross_zone_dispatch_count"; +/// Key base under which older stores held the whole pending set as one borsh +/// blob; kept only for migration on open. pub const DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY: &str = "pending_cross_zone_dispatches"; /// Key base for storing cross-zone deliveries this node has given up on. pub const DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY: &str = "dead_letter_cross_zone_dispatches"; @@ -74,11 +82,10 @@ pub const DB_META_PUBLISHED_HIGH_WATER_KEY: &str = "published_high_water"; /// How many cross-zone deliveries may be pending at once. /// -/// The whole list is a single value, read on every block and rewritten on every -/// change, and what fills it is chosen by peer zones rather than by us. Refusing -/// to record past this bound turns "a peer decides how large our store gets" -/// into "a peer's messages wait", since a watcher that cannot record holds its -/// delivery floor and reads the slot again later. +/// What fills the pending set is chosen by peer zones rather than by us. +/// Refusing to record past this bound turns "a peer decides how large our +/// store gets" into "a peer's messages wait", since a watcher that cannot +/// record holds its delivery floor and reads the slot again later. pub const MAX_PENDING_CROSS_ZONE_DISPATCHES: usize = 4096; /// How many given-up-on cross-zone deliveries are kept for inspection. @@ -238,13 +245,11 @@ pub struct StoreUpdateOutcome { pub struct RocksDBIO { pub db: DBWithThreadMode, /// Serializes the read-modify-write cycles over the pending cross-zone - /// dispatch list. + /// dispatch records and their count cell. /// - /// The list is a single value holding the whole `Vec`, and three tasks - /// rewrite it: the watcher recording a delivery, the production loop - /// counting a failed attempt, and the publisher's drive task settling - /// finalized deliveries. Rocksdb makes the write atomic, not the cycle, so - /// without this the writer that read first silently drops the others. + /// Three tasks mutate them (watcher, production loop, publisher drive); + /// rocksdb makes each staged batch atomic, not the cycle, so without this + /// two interleaved writers drift the count away from the entries. pending_records: Mutex<()>, } @@ -258,9 +263,8 @@ impl RocksDBIO { /// Held across a pending-record read-modify-write. See /// [`RocksDBIO::pending_records`]. /// - /// A poisoned lock is recovered rather than propagated: the records behind - /// it are a plain `Vec` that a panicking writer cannot leave half-written, - /// since the write is a single rocksdb put. + /// Poison is recovered: every mutation is one rocksdb write, so a panic + /// tears nothing. fn lock_pending_records(&self) -> MutexGuard<'_, ()> { self.pending_records .lock() @@ -357,6 +361,7 @@ impl RocksDBIO { Some("Failed to write dump restore batch".to_owned()), ) })?; + dbio.migrate_legacy_pending_dispatches()?; Ok(dbio) } @@ -384,9 +389,77 @@ impl RocksDBIO { db, pending_records: Mutex::new(()), }; + dbio.migrate_legacy_pending_dispatches()?; Ok(dbio) } + /// Rewrites a legacy whole-vector pending-dispatch blob into per-message + /// entries plus the count cell, then drops the blob, in one batch. + /// + /// Runs on every open, and again after a dump restore, since a restored + /// dump lands after the open-time pass. Without the legacy key it is a + /// no-op read. + fn migrate_legacy_pending_dispatches(&self) -> DbResult<()> { + let legacy = self + .get_opt::(()) + .map_err(|err| { + DbError::db_interaction_error(format!( + "Legacy pending-dispatch blob at key {DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY:?} does not decode; delete that key to start without it: {err}" + )) + })?; + let Some(legacy) = legacy else { + return Ok(()); + }; + let records = legacy.0; + + let mut batch = WriteBatch::default(); + self.del_batch::((), &mut batch)?; + // Folded additively: a restored blob may land on a store that already + // migrated and drained, so writing the blob's own length (or zero, for + // an empty blob) would clobber the live count and the cap would stop + // bounding what the store holds. + let mut inserted: u64 = 0; + if !records.is_empty() { + for record in &records { + if self + .get_opt::(record.message_key)? + .is_some() + { + continue; + } + self.put_batch( + &PendingCrossZoneDispatchCellRef(record), + record.message_key, + &mut batch, + )?; + inserted = inserted.saturating_add(1); + } + if inserted > 0 { + let existing = self + .get_opt::(())? + .map_or(0, |cell| cell.0); + self.put_batch( + &PendingCrossZoneDispatchCountCell(existing.saturating_add(inserted)), + (), + &mut batch, + )?; + } + } + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to migrate legacy pending cross-zone dispatches".to_owned()), + ) + })?; + + if inserted > 0 { + info!( + "Migrated {inserted} pending cross-zone dispatch record(s) into per-message entries" + ); + } + Ok(()) + } + pub fn destroy(path: &Path) -> DbResult<()> { let mut cf_opts = Options::default(); cf_opts.set_max_write_buffer_number(16); @@ -703,27 +776,62 @@ impl RocksDBIO { self.del::(peer_zone) } + /// Every pending cross-zone dispatch record, in message-key byte order: + /// no insertion order survives. Lock-free, so a read racing a mutation + /// sees either side. pub fn get_pending_cross_zone_dispatches( &self, ) -> DbResult> { + let prefix = Self::pending_dispatch_key_prefix()?; + let cf_meta = self.meta_column(); + + let mut records = Vec::new(); + for item in self + .db + .iterator_cf(&cf_meta, IteratorMode::From(&prefix, Direction::Forward)) + { + let (key, value) = item.map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to scan pending cross-zone dispatches".to_owned()), + ) + })?; + // Keys sharing the prefix are one contiguous range, so the first + // stranger ends the scan. + if !key.starts_with(&prefix) { + break; + } + records.push( + borsh::from_slice::(&value).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to deserialize pending cross-zone dispatch".to_owned()), + ) + })?, + ); + } + Ok(records) + } + + /// The byte prefix every per-message pending-dispatch key starts with: a + /// borsh `(name, message_key)` tuple key opens with the length-prefixed + /// name alone, which no other meta cell's key shares (asserted in the cell + /// tests). + fn pending_dispatch_key_prefix() -> DbResult> { + borsh::to_vec(&DB_META_PENDING_CROSS_ZONE_DISPATCH_KEY).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize pending cross-zone dispatch key prefix".to_owned()), + ) + }) + } + + /// The persisted pending-record count; see + /// [`PendingCrossZoneDispatchCountCell`]. + fn get_pending_cross_zone_dispatch_count(&self) -> DbResult { Ok(self - .get_opt::(())? - .map_or_else(Vec::new, |cell| cell.0)) - } - - fn put_pending_cross_zone_dispatches( - &self, - records: &[PendingCrossZoneDispatchRecord], - ) -> DbResult<()> { - self.put(&PendingCrossZoneDispatchesCellRef(records), ()) - } - - fn put_pending_cross_zone_dispatches_batch( - &self, - records: &[PendingCrossZoneDispatchRecord], - batch: &mut WriteBatch, - ) -> DbResult<()> { - self.put_batch(&PendingCrossZoneDispatchesCellRef(records), (), batch) + .get_opt::(())? + .map_or(0, |cell| cell.0)) } /// Records every delivery one peer block carries, in a single write. @@ -731,14 +839,13 @@ impl RocksDBIO { /// Returns how many were new. Ones already recorded are skipped, so a slot /// the watcher re-reads is not double-tracked. /// - /// Batched rather than one call per delivery because the whole list is one - /// value: recording a block's messages one at a time rewrites the list once - /// per message, which is quadratic in a block that carries many. + /// All-or-nothing per peer block: recording is what lets the caller move + /// its delivery floor past the block, so either every delivery becomes + /// durable or none does and the floor holds. /// - /// Fails without writing anything if the list would exceed - /// [`MAX_PENDING_CROSS_ZONE_DISPATCHES`]. The caller's floor then stays put - /// and the slot is read again later, which is the difference between - /// backpressure and an unbounded list a peer controls the size of. + /// Fails without writing anything if the pending set would exceed + /// [`MAX_PENDING_CROSS_ZONE_DISPATCHES`]; see the cap for why refusal is + /// backpressure. pub fn add_pending_cross_zone_dispatches( &self, dispatches: Vec, @@ -748,30 +855,52 @@ impl RocksDBIO { } let _pending = self.lock_pending_records(); - let mut records = self.get_pending_cross_zone_dispatches()?; - let before = records.len(); + // Deduped against the store by point-get, never a scan, and against the + // offer itself, which may repeat a key. + let mut offered_keys = std::collections::HashSet::<[u8; 32]>::new(); + let mut new_records: Vec = Vec::new(); for dispatch in dispatches { - if records - .iter() - .any(|record| record.message_key == dispatch.message_key) + if !offered_keys.insert(dispatch.message_key) { + continue; + } + if self + .get_opt::(dispatch.message_key)? + .is_some() { continue; } - records.push(dispatch); + new_records.push(dispatch); } - let accepted = records.len().saturating_sub(before); + let accepted = new_records.len(); if accepted == 0 { return Ok(0); } - if records.len() > MAX_PENDING_CROSS_ZONE_DISPATCHES { + + let before = self.get_pending_cross_zone_dispatch_count()?; + let after = before.saturating_add(u64::try_from(accepted).expect("accepted fits u64")); + if after > u64::try_from(MAX_PENDING_CROSS_ZONE_DISPATCHES).expect("cap fits u64") { return Err(DbError::db_interaction_error(format!( "Refusing to hold more than {MAX_PENDING_CROSS_ZONE_DISPATCHES} pending cross-zone deliveries; {before} already pending" ))); } - self.put_pending_cross_zone_dispatches(&records)?; + let mut batch = WriteBatch::default(); + for record in &new_records { + self.put_batch( + &PendingCrossZoneDispatchCellRef(record), + record.message_key, + &mut batch, + )?; + } + self.put_batch(&PendingCrossZoneDispatchCountCell(after), (), &mut batch)?; + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to record pending cross-zone dispatches".to_owned()), + ) + })?; Ok(accepted) } @@ -793,30 +922,23 @@ impl RocksDBIO { origin: DispatchOrigin, ) -> DbResult { let _pending = self.lock_pending_records(); - let mut records = self.get_pending_cross_zone_dispatches()?; - let Some(position) = records - .iter() - .position(|record| record.message_key == message_key) - else { + let Some(held) = self.get_opt::(message_key)? else { return Ok(DispatchFailure::Absent); }; - let failed_attempts = { - let record = &mut records[position]; - record.failed_attempts = record.failed_attempts.saturating_add(1); - record.failed_attempts - }; + let mut pending = held.0; + pending.failed_attempts = pending.failed_attempts.saturating_add(1); + let failed_attempts = pending.failed_attempts; if failed_attempts < retire_at { - self.put_pending_cross_zone_dispatches(&records)?; + self.put(&PendingCrossZoneDispatchCellRef(&pending), message_key)?; return Ok(DispatchFailure::Retried { failed_attempts }); } - let retired = records.remove(position); let dead_letter = DeadLetterDispatchRecord { message_key, origin, failed_attempts, - transaction_bytes: u32::try_from(retired.transaction.len()).unwrap_or(u32::MAX), + transaction_bytes: u32::try_from(pending.transaction.len()).unwrap_or(u32::MAX), }; // One entry per delivery, not per retirement. A watcher rebuilding a @@ -838,12 +960,20 @@ impl RocksDBIO { let count = self .get_dead_letter_cross_zone_dispatch_count()? .saturating_add(1); + let pending_count = self + .get_pending_cross_zone_dispatch_count()? + .saturating_sub(1); // One batch: a crash between the two halves either loses the message // silently or leaves the drain retrying a delivery already recorded as // given up on. let mut batch = WriteBatch::default(); - self.put_pending_cross_zone_dispatches_batch(&records, &mut batch)?; + self.del_batch::(message_key, &mut batch)?; + self.put_batch( + &PendingCrossZoneDispatchCountCell(pending_count), + (), + &mut batch, + )?; self.put_batch( &DeadLetterCrossZoneDispatchesCellRef(&dead_letters), (), @@ -888,19 +1018,8 @@ impl RocksDBIO { } let _pending = self.lock_pending_records(); - let to_remove: std::collections::HashSet<&[u8; 32]> = message_keys.iter().collect(); - let mut records = self.get_pending_cross_zone_dispatches()?; - let before = records.len(); - records.retain(|record| !to_remove.contains(&record.message_key)); - let removed = before.saturating_sub(records.len()); - - // Both lists in one batch, as in `record_dispatch_failure`: nothing - // recomputes these keys on a later pass to fix a torn write. let mut batch = WriteBatch::default(); - if removed > 0 { - self.put_pending_cross_zone_dispatches_batch(&records, &mut batch)?; - } - self.stage_reconciled_dead_letters(&to_remove, &mut batch)?; + let removed = self.stage_removed_dispatches(message_keys, &mut batch)?; if !batch.is_empty() { self.db.write(batch).map_err(|rerr| { DbError::rocksdb_cast_message( @@ -942,10 +1061,11 @@ impl RocksDBIO { /// Drops the pending records of deliveries that just became irreversible, /// staged into `batch` so they go with the update that made them so. + /// Callers hold the pending-record lock; the count cell is staged here. /// - /// Removal only, unlike [`Self::stage_pending_deposit_events`]: a delivery is - /// recorded by the watcher through - /// [`Self::add_pending_cross_zone_dispatch`], on its own task and outside + /// Removal only, unlike [`Self::stage_pending_deposit_events`]: a delivery + /// is recorded by the watcher through + /// [`Self::add_pending_cross_zone_dispatches`], on its own task and outside /// any store update, so nothing ever adds one here. fn stage_removed_dispatches( &self, @@ -956,18 +1076,34 @@ impl RocksDBIO { return Ok(0); } - let to_remove: std::collections::HashSet<&[u8; 32]> = remove_keys.iter().collect(); - let mut records = self.get_pending_cross_zone_dispatches()?; - let before = records.len(); - records.retain(|record| !to_remove.contains(&record.message_key)); - let removed = before.saturating_sub(records.len()); + // Point-gets before the deletes: only a key that is actually held may + // decrement the count, and a repeated key may do so only once. + let mut staged = std::collections::HashSet::<&[u8; 32]>::new(); + for key in remove_keys { + if staged.contains(&key) { + continue; + } + if self + .get_opt::(*key)? + .is_none() + { + continue; + } + self.del_batch::(*key, batch)?; + staged.insert(key); + } + let removed = staged.len(); if removed > 0 { - self.put_pending_cross_zone_dispatches_batch(&records, batch)?; + let count = self + .get_pending_cross_zone_dispatch_count()? + .saturating_sub(u64::try_from(removed).expect("removed fits u64")); + self.put_batch(&PendingCrossZoneDispatchCountCell(count), (), batch)?; } // The ordinary case: another sequencer carried a delivery this node gave // up on into a block that just became irreversible. + let to_remove: std::collections::HashSet<&[u8; 32]> = remove_keys.iter().collect(); self.stage_reconciled_dead_letters(&to_remove, batch)?; Ok(removed) } diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 8cbfe012d..927bdde3f 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -1,4 +1,5 @@ use borsh::{BorshDeserialize, BorshSerialize}; +pub use common::block::PeerChainTip; use common::{HashType, block::BlockMeta}; use lee::V03State; @@ -11,7 +12,8 @@ use crate::{ DB_META_CROSS_ZONE_PEER_FLOOR_KEY, DB_META_CROSS_ZONE_PEER_TIP_KEY, DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCH_COUNT_KEY, DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY, DB_META_LAST_FINALIZED_BLOCK_ID, - DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, + DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCH_COUNT_KEY, + DB_META_PENDING_CROSS_ZONE_DISPATCH_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_PUBLISHED_HIGH_WATER_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, @@ -314,41 +316,107 @@ impl PendingCrossZoneDispatchRecord { } } +/// One pending delivery, held under its own message key so a mutation touches +/// one entry rather than rewriting the whole set. #[derive(BorshDeserialize)] -pub struct PendingCrossZoneDispatchesCellOwned(pub Vec); +pub struct PendingCrossZoneDispatchCellOwned(pub PendingCrossZoneDispatchRecord); -impl SimpleStorableCell for PendingCrossZoneDispatchesCellOwned { - type KeyParams = (); +impl SimpleStorableCell for PendingCrossZoneDispatchCellOwned { + type KeyParams = [u8; 32]; - const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY; + const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCH_KEY; const CF_NAME: &'static str = CF_META_NAME; -} -impl SimpleReadableCell for PendingCrossZoneDispatchesCellOwned {} - -#[derive(BorshSerialize)] -pub struct PendingCrossZoneDispatchesCellRef<'records>( - pub &'records [PendingCrossZoneDispatchRecord], -); - -impl SimpleStorableCell for PendingCrossZoneDispatchesCellRef<'_> { - type KeyParams = (); - - const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY; - const CF_NAME: &'static str = CF_META_NAME; -} - -impl SimpleWritableCell for PendingCrossZoneDispatchesCellRef<'_> { - fn value_constructor(&self) -> DbResult> { - borsh::to_vec(&self).map_err(|err| { + /// Folds the message key into the db key so each delivery is its own entry. + fn key_constructor(message_key: Self::KeyParams) -> DbResult> { + borsh::to_vec(&(Self::CELL_NAME, message_key)).map_err(|err| { DbError::borsh_cast_message( err, - Some("Failed to serialize pending cross-zone dispatches cell".to_owned()), + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), ) }) } } +impl SimpleReadableCell for PendingCrossZoneDispatchCellOwned {} + +#[derive(BorshSerialize)] +pub struct PendingCrossZoneDispatchCellRef<'record>(pub &'record PendingCrossZoneDispatchRecord); + +impl SimpleStorableCell for PendingCrossZoneDispatchCellRef<'_> { + type KeyParams = [u8; 32]; + + const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCH_KEY; + const CF_NAME: &'static str = CF_META_NAME; + + fn key_constructor(message_key: Self::KeyParams) -> DbResult> { + borsh::to_vec(&(Self::CELL_NAME, message_key)).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleWritableCell for PendingCrossZoneDispatchCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize pending cross-zone dispatch cell".to_owned()), + ) + }) + } +} + +/// How many pending dispatch records the store holds, written in the same +/// batch as every record mutation so the cap check reads one value instead of +/// scanning the set it bounds. +#[derive(BorshSerialize, BorshDeserialize)] +pub struct PendingCrossZoneDispatchCountCell(pub u64); + +impl SimpleStorableCell for PendingCrossZoneDispatchCountCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCH_COUNT_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for PendingCrossZoneDispatchCountCell {} + +impl SimpleWritableCell for PendingCrossZoneDispatchCountCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize pending cross-zone dispatch count".to_owned()), + ) + }) + } +} + +/// The whole pending set as one borsh blob, the layout stores held before the +/// per-message entries. Read-only: opening such a store migrates the blob and +/// deletes its key, and nothing writes it again. +#[derive(BorshDeserialize)] +pub struct LegacyPendingCrossZoneDispatchesCellOwned(pub Vec); + +impl SimpleStorableCell for LegacyPendingCrossZoneDispatchesCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for LegacyPendingCrossZoneDispatchesCellOwned {} + /// Which peer message a delivery carried, kept so a lost one can be traced back /// to the peer block it was in. #[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] @@ -542,21 +610,8 @@ impl SimpleWritableCell for PeerFloorCellRef<'_> { } } -/// The last peer block a cross-zone watcher delivered from, and the link the -/// next one has to carry. -/// -/// `block_hash` is the recomputed hash, not `header.hash` as read: the -/// signature does not cover that field, so a signed block may carry a bogus one -/// and break the link against the peer's next honest block. -/// -/// Durable, not in-memory: a watcher that re-anchored on restart would accept a -/// block claiming any id. -#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -pub struct PeerChainTip { - pub block_id: u64, - pub block_hash: HashType, -} - +/// The watcher's [`PeerChainTip`], durable rather than in-memory: a watcher +/// that re-anchored on restart would accept a block claiming any id. #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct PeerTipCell(pub PeerChainTip); @@ -644,6 +699,8 @@ mod uniform_tests { cells::SimpleStorableCell as _, sequencer::sequencer_cells::{ LEEStateCellOwned, LEEStateCellRef, LatestBlockMetaCellOwned, LatestBlockMetaCellRef, + LegacyPendingCrossZoneDispatchesCellOwned, PendingCrossZoneDispatchCellOwned, + PendingCrossZoneDispatchCellRef, PendingCrossZoneDispatchCountCell, PendingDepositEventsCellOwned, PendingDepositEventsCellRef, }, }; @@ -674,6 +731,46 @@ mod uniform_tests { ); } + #[test] + fn pending_dispatch_ref_and_owned_is_aligned() { + assert_eq!( + PendingCrossZoneDispatchCellRef::CELL_NAME, + PendingCrossZoneDispatchCellOwned::CELL_NAME + ); + assert_eq!( + PendingCrossZoneDispatchCellRef::CF_NAME, + PendingCrossZoneDispatchCellOwned::CF_NAME + ); + assert_eq!( + PendingCrossZoneDispatchCellRef::key_constructor([7; 32]).unwrap(), + PendingCrossZoneDispatchCellOwned::key_constructor([7; 32]).unwrap() + ); + } + + #[test] + fn pending_dispatch_scan_prefix_covers_only_the_per_message_cells() { + // A stray meta cell keyed into this range would decode as a dispatch + // record and fail the lock-free scan. + let prefix = borsh::to_vec(&PendingCrossZoneDispatchCellOwned::CELL_NAME).unwrap(); + assert!( + PendingCrossZoneDispatchCellOwned::key_constructor([0; 32]) + .unwrap() + .starts_with(&prefix) + ); + assert!( + !PendingCrossZoneDispatchCountCell::key_constructor(()) + .unwrap() + .starts_with(&prefix), + "the count cell must stay out of the record scan" + ); + assert!( + !LegacyPendingCrossZoneDispatchesCellOwned::key_constructor(()) + .unwrap() + .starts_with(&prefix), + "the legacy blob must stay out of the record scan" + ); + } + #[test] fn pending_deposit_events_ref_and_owned_is_aligned() { assert_eq!( diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs index 2d4d2ed34..392ffff93 100644 --- a/lez/storage/src/sequencer/tests.rs +++ b/lez/storage/src/sequencer/tests.rs @@ -57,6 +57,14 @@ fn key_from_index(index: usize) -> [u8; 32] { key } +/// `records` in message-key order, the order the store reports them in. +fn sorted_dispatches( + mut records: Vec, +) -> Vec { + records.sort_by_key(|record| record.message_key); + records +} + fn stored_balance(dbio: &RocksDBIO) -> u128 { dbio.get_lee_state() .unwrap() @@ -510,10 +518,12 @@ fn dispatch_records_round_trip_and_dedupe_by_message_key() { "only the delivery not already held is newly recorded" ); + // Set equality, not order: no insertion order survives the store. assert_eq!( - dbio.get_pending_cross_zone_dispatches().unwrap(), - vec![record, dispatch_record(2)] + sorted_dispatches(dbio.get_pending_cross_zone_dispatches().unwrap()), + sorted_dispatches(vec![record, dispatch_record(2)]) ); + assert_eq!(dbio.get_pending_cross_zone_dispatch_count().unwrap(), 2); } #[test] @@ -544,7 +554,12 @@ fn recording_past_the_cap_writes_nothing() { assert_eq!( dbio.get_pending_cross_zone_dispatches().unwrap().len(), MAX_PENDING_CROSS_ZONE_DISPATCHES, - "a refused write must leave the list untouched" + "a refused write must leave the records untouched" + ); + assert_eq!( + dbio.get_pending_cross_zone_dispatch_count().unwrap(), + u64::try_from(MAX_PENDING_CROSS_ZONE_DISPATCHES).unwrap(), + "and the count cell with them" ); // Re-offering only what is already held is not growth, so it still succeeds. @@ -558,6 +573,186 @@ fn recording_past_the_cap_writes_nothing() { ); } +#[test] +fn dispatch_records_survive_a_reopen() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let first = dispatch_record(1); + let second = dispatch_record(2); + dbio.add_pending_cross_zone_dispatches(vec![first.clone(), second.clone()]) + .unwrap(); + + // On disk, not in memory: the records are what stand between the watcher's + // durable read floor and a lost delivery across a restart. + drop(dbio); + let reopened = RocksDBIO::open(temp_dir.path()).unwrap(); + assert_eq!( + sorted_dispatches(reopened.get_pending_cross_zone_dispatches().unwrap()), + sorted_dispatches(vec![first, second]) + ); + assert_eq!(reopened.get_pending_cross_zone_dispatch_count().unwrap(), 2); +} + +#[test] +fn a_legacy_dispatch_blob_is_migrated_into_per_message_entries_on_open() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + // A store written before the per-message layout: the whole set as one borsh + // blob under a single fixed key. + let records = vec![dispatch_record(1), dispatch_record(2), dispatch_record(3)]; + let legacy_key = borsh::to_vec(&DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY).unwrap(); + dbio.db + .put_cf( + &dbio.meta_column(), + &legacy_key, + borsh::to_vec(&records).unwrap(), + ) + .unwrap(); + drop(dbio); + + let migrated = RocksDBIO::open(temp_dir.path()).unwrap(); + assert_eq!( + sorted_dispatches(migrated.get_pending_cross_zone_dispatches().unwrap()), + sorted_dispatches(records.clone()), + "every record must come through the migration unchanged" + ); + for record in &records { + assert_eq!( + migrated + .get_opt::(record.message_key) + .unwrap() + .map(|cell| cell.0), + Some(record.clone()), + "each record must be readable under its own message key" + ); + } + assert_eq!(migrated.get_pending_cross_zone_dispatch_count().unwrap(), 3); + assert!( + migrated + .db + .get_cf(&migrated.meta_column(), &legacy_key) + .unwrap() + .is_none(), + "the blob must not survive the migration" + ); + + // An empty blob is deleted without touching the migrated entries or count. + let empty: Vec = Vec::new(); + migrated + .db + .put_cf( + &migrated.meta_column(), + &legacy_key, + borsh::to_vec(&empty).unwrap(), + ) + .unwrap(); + drop(migrated); + + let cleaned = RocksDBIO::open(temp_dir.path()).unwrap(); + assert!( + cleaned + .db + .get_cf(&cleaned.meta_column(), &legacy_key) + .unwrap() + .is_none() + ); + assert_eq!( + cleaned.get_pending_cross_zone_dispatches().unwrap().len(), + 3 + ); + assert_eq!(cleaned.get_pending_cross_zone_dispatch_count().unwrap(), 3); +} + +/// A blob restored over live per-message entries folds additively into the +/// count. +#[test] +fn a_legacy_blob_over_live_entries_folds_additively() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + dbio.add_pending_cross_zone_dispatches(vec![dispatch_record(1), dispatch_record(2)]) + .unwrap(); + + // The blob shares record 2 with the live entries and brings record 3. + let blob = vec![dispatch_record(2), dispatch_record(3)]; + let legacy_key = borsh::to_vec(&DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY).unwrap(); + dbio.db + .put_cf( + &dbio.meta_column(), + &legacy_key, + borsh::to_vec(&blob).unwrap(), + ) + .unwrap(); + drop(dbio); + + let merged = RocksDBIO::open(temp_dir.path()).unwrap(); + assert_eq!( + sorted_dispatches(merged.get_pending_cross_zone_dispatches().unwrap()), + sorted_dispatches(vec![ + dispatch_record(1), + dispatch_record(2), + dispatch_record(3) + ]), + "the migration must keep the union of blob and live entries" + ); + assert_eq!( + merged.get_pending_cross_zone_dispatch_count().unwrap(), + 3, + "the count must be the union's size, not the blob's length" + ); +} + +#[test] +fn the_dispatch_count_cell_tracks_the_stored_entries() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let count_and_entries_agree = |expected: u64| { + assert_eq!( + dbio.get_pending_cross_zone_dispatch_count().unwrap(), + expected + ); + assert_eq!( + u64::try_from(dbio.get_pending_cross_zone_dispatches().unwrap().len()).unwrap(), + expected, + "the count cell and the scanned entries must never disagree" + ); + }; + + dbio.add_pending_cross_zone_dispatches(vec![ + dispatch_record(1), + dispatch_record(2), + dispatch_record(3), + ]) + .unwrap(); + count_and_entries_agree(3); + + // A counted retry keeps the record, so the count stands still. + dbio.record_dispatch_failure([1; 32], 2, dispatch_origin(1)) + .unwrap(); + count_and_entries_agree(3); + + // A retirement into the dead letter takes its record out. + dbio.record_dispatch_failure([1; 32], 2, dispatch_origin(1)) + .unwrap(); + count_and_entries_agree(2); + + // As does a standalone settled drop, even repeated on a key already gone. + dbio.drop_settled_cross_zone_dispatches(&[[2; 32], [2; 32]]) + .unwrap(); + count_and_entries_agree(1); + + // And the settlement path inside a store update. + dbio.store_update(&StoreUpdate { + remove_dispatch_records: &[[3; 32]], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + count_and_entries_agree(0); +} + #[test] fn settled_dispatch_records_are_dropped_outside_an_update() { let temp_dir = tempdir().unwrap(); diff --git a/lez/system_accounts/Cargo.toml b/lez/system_accounts/Cargo.toml index 093e64553..0c6a9fd40 100644 --- a/lez/system_accounts/Cargo.toml +++ b/lez/system_accounts/Cargo.toml @@ -12,4 +12,5 @@ lee_core.workspace = true faucet_core.workspace = true bridge_core.workspace = true clock_core.workspace = true +sequencer_stake_core.workspace = true programs.workspace = true diff --git a/lez/system_accounts/src/lib.rs b/lez/system_accounts/src/lib.rs index 3b6dd5af0..38997cdc7 100644 --- a/lez/system_accounts/src/lib.rs +++ b/lez/system_accounts/src/lib.rs @@ -1,10 +1,27 @@ //! This crate provides system accounts used by LEZ. -use std::str::FromStr as _; +use std::{collections::BTreeMap, str::FromStr as _}; use clock_core::ClockAccountData; use lee_core::account::{Account, AccountId, Nonce}; +// TODO: Replace with a real minimum value for testnet +/// Minimum summed stake for a Bedrock sequencer key to be a committee candidate. +pub const DEFAULT_MINIMUM_SEQUENCER_STAKE: u128 = 149; + +/// Channel administration defaults. +/// +/// Slots, not seconds (1 slot = 1s on the current devnet): 20-slot turns, +/// reclaimed after 10 idle slots if a sequencer stops posting โ€” non-zero so +/// round robin can move on when a committee has more than one accredited key. +/// A lone-signature threshold still suffices for config changes. +pub const DEFAULT_SEQUENCER_POSTING_TIMEFRAME: Slots = 20; +pub const DEFAULT_SEQUENCER_POSTING_TIMEOUT: Slots = 10; +pub const DEFAULT_SEQUENCER_CONFIGURATION_THRESHOLD: u16 = 1; +pub const DEFAULT_SEQUENCER_WITHDRAW_THRESHOLD: u16 = 1; + +pub type Slots = u32; + #[must_use] pub fn pinata_account_id() -> AccountId { // TODO: Use derivation from a public key? @@ -15,7 +32,7 @@ pub fn pinata_account_id() -> AccountId { #[must_use] pub fn pinata_account() -> Account { Account { - program_owner: programs::pinata().id(), + program_owner: programs::pinata().id().into(), balance: 1_500_000, // Difficulty: 3 data: vec![3; 33].try_into().expect("Should fit"), @@ -31,7 +48,7 @@ pub fn faucet_account_id() -> AccountId { #[must_use] pub fn faucet_account() -> Account { Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), balance: u128::MAX, ..Account::default() } @@ -45,7 +62,7 @@ pub fn bridge_account_id() -> AccountId { #[must_use] pub fn bridge_account() -> Account { Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), ..Account::default() } } @@ -55,10 +72,32 @@ 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().into(), + 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 { - program_owner: programs::clock().id(), + program_owner: programs::clock().id().into(), data: ClockAccountData { block_id: 0, timestamp: 0, diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index f77a083f3..3e3a18a5d 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -1,12 +1,10 @@ use std::collections::HashMap; use key_protocol::key_management::{ - KeyChain, - key_tree::chain_index::ChainIndex, - secret_holders::{PrivateKeyHolder, SecretSpendingKey, ViewingSecretKey}, + KeyChain, key_tree::chain_index::ChainIndex, secret_holders::SecretSpendingKey, }; use lee::{Account, AccountId, Data, PrivateKey, PublicKey, V03State, program::Program}; -use lee_core::{NullifierPublicKey, encryption::ViewingPublicKey}; +use lee_core::program::DEFAULT_PROGRAM_OWNER; use serde::{Deserialize, Serialize}; const PRIVATE_KEY_PUB_ACC_A: [u8; 32] = [ @@ -29,48 +27,6 @@ const SSK_PRIV_ACC_B: [u8; 32] = [ 180, 43, 120, 55, 151, 50, 21, 113, 22, 254, 83, 148, 56, ]; -const NSK_PRIV_ACC_A: [u8; 32] = [ - 25, 21, 186, 59, 180, 224, 101, 64, 163, 208, 228, 43, 13, 185, 100, 123, 156, 47, 80, 179, 72, - 51, 115, 11, 180, 99, 21, 201, 48, 194, 118, 144, -]; - -const NSK_PRIV_ACC_B: [u8; 32] = [ - 99, 82, 190, 140, 234, 10, 61, 163, 15, 211, 179, 54, 70, 166, 87, 5, 182, 68, 117, 244, 217, - 23, 99, 9, 4, 177, 230, 125, 109, 91, 160, 30, -]; - -const VSK_D_PRIV_ACC_A: [u8; 32] = [ - 255, 250, 140, 26, 222, 223, 174, 95, 132, 108, 124, 88, 30, 247, 82, 72, 52, 70, 84, 139, 241, - 187, 41, 163, 19, 231, 232, 122, 225, 55, 134, 184, -]; - -const VSK_Z_PRIV_ACC_A: [u8; 32] = [ - 225, 24, 98, 78, 31, 203, 175, 248, 213, 17, 133, 207, 10, 135, 132, 151, 59, 184, 5, 81, 28, - 238, 137, 62, 233, 227, 99, 17, 236, 159, 244, 63, -]; - -const VSK_D_PRIV_ACC_B: [u8; 32] = [ - 128, 85, 85, 103, 226, 218, 119, 56, 60, 252, 31, 113, 232, 215, 156, 2, 159, 247, 156, 192, - 12, 178, 229, 236, 255, 120, 146, 211, 169, 117, 153, 180, -]; - -const VSK_Z_PRIV_ACC_B: [u8; 32] = [ - 165, 80, 169, 87, 248, 88, 167, 154, 27, 67, 131, 122, 50, 130, 111, 40, 164, 180, 204, 75, - 188, 140, 110, 132, 113, 133, 222, 8, 49, 123, 187, 18, -]; - -const NPK_PRIV_ACC_A: [u8; 32] = [ - 167, 108, 50, 153, 74, 47, 151, 188, 140, 79, 195, 31, 181, 9, 40, 167, 201, 32, 175, 129, 45, - 245, 223, 193, 210, 170, 247, 128, 167, 140, 155, 129, -]; - -const NPK_PRIV_ACC_B: [u8; 32] = [ - 32, 67, 72, 164, 106, 53, 66, 239, 141, 15, 52, 230, 136, 177, 2, 236, 207, 243, 134, 135, 210, - 143, 87, 232, 215, 128, 194, 120, 113, 224, 4, 165, -]; - -const DEFAULT_PROGRAM_OWNER: [u32; 8] = [0, 0, 0, 0, 0, 0, 0, 0]; - const PUB_ACC_A_INITIAL_BALANCE: u128 = 10000; const PUB_ACC_B_INITIAL_BALANCE: u128 = 20000; @@ -133,26 +89,23 @@ pub fn initial_pub_accounts_private_keys() -> Vec Vec { - let key_chain_1 = KeyChain { - secret_spending_key: SecretSpendingKey(SSK_PRIV_ACC_A), - private_key_holder: PrivateKeyHolder { - nullifier_secret_key: NSK_PRIV_ACC_A, - viewing_secret_key: ViewingSecretKey::new(VSK_D_PRIV_ACC_A, VSK_Z_PRIV_ACC_A), - }, - nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_A), - viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_A, &VSK_Z_PRIV_ACC_A), - }; +fn key_chain_from_ssk(ssk: [u8; 32]) -> KeyChain { + let secret_spending_key = SecretSpendingKey(ssk); + let private_key_holder = secret_spending_key.produce_private_key_holder(None); + let nullifier_public_key = private_key_holder.generate_nullifier_public_key(); + let viewing_public_key = private_key_holder.generate_viewing_public_key(); - let key_chain_2 = KeyChain { - secret_spending_key: SecretSpendingKey(SSK_PRIV_ACC_B), - private_key_holder: PrivateKeyHolder { - nullifier_secret_key: NSK_PRIV_ACC_B, - viewing_secret_key: ViewingSecretKey::new(VSK_D_PRIV_ACC_B, VSK_Z_PRIV_ACC_B), - }, - nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_B), - viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_B, &VSK_Z_PRIV_ACC_B), - }; + KeyChain { + secret_spending_key, + private_key_holder, + nullifier_public_key, + viewing_public_key, + } +} + +fn initial_priv_accounts_private_keys() -> Vec { + let key_chain_1 = key_chain_from_ssk(SSK_PRIV_ACC_A); + let key_chain_2 = key_chain_from_ssk(SSK_PRIV_ACC_B); vec![ PrivateAccountPrivateInitialData { @@ -201,7 +154,7 @@ fn initial_private_accounts() -> Vec<(lee_core::Commitment, lee_core::Nullifier) let mut acc = init_comm_data.account.clone(); - acc.program_owner = programs::authenticated_transfer().id(); + acc.program_owner = programs::authenticated_transfer().id().into(); ( lee_core::Commitment::new(&account_id, &acc), @@ -237,7 +190,7 @@ fn initial_public_accounts() -> HashMap { ( acc_data.account_id, Account { - program_owner: programs::authenticated_transfer().id(), + program_owner: programs::authenticated_transfer().id().into(), balance: acc_data.balance, ..Default::default() }, @@ -258,6 +211,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() } @@ -271,6 +228,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 @@ -313,13 +271,35 @@ pub fn initial_state_testnet() -> V03State { mod tests { use std::str::FromStr as _; + use key_protocol::key_management::secret_holders::ViewingSecretKey; + use super::*; + const VSK_D_PRIV_ACC_A: [u8; 32] = [ + 37, 79, 203, 133, 143, 28, 149, 228, 53, 195, 241, 240, 40, 28, 11, 81, 126, 209, 253, 79, + 167, 213, 4, 162, 9, 183, 132, 78, 248, 92, 134, 198, + ]; + + const VSK_Z_PRIV_ACC_A: [u8; 32] = [ + 197, 94, 192, 175, 68, 106, 201, 229, 125, 33, 51, 144, 81, 154, 230, 37, 209, 230, 150, + 29, 73, 203, 166, 56, 65, 178, 205, 15, 101, 81, 111, 150, + ]; + + const VSK_D_PRIV_ACC_B: [u8; 32] = [ + 221, 28, 168, 185, 246, 234, 210, 245, 219, 3, 116, 190, 178, 31, 49, 79, 246, 147, 101, + 161, 120, 32, 218, 191, 23, 209, 8, 38, 184, 92, 104, 177, + ]; + + const VSK_Z_PRIV_ACC_B: [u8; 32] = [ + 167, 68, 2, 131, 197, 10, 239, 237, 52, 80, 87, 51, 21, 153, 205, 222, 117, 159, 204, 16, + 66, 136, 209, 158, 243, 254, 168, 14, 19, 222, 8, 97, + ]; + const PUB_ACC_A_TEXT_ADDR: &str = "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV"; const PUB_ACC_B_TEXT_ADDR: &str = "7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"; - const PRIV_ACC_A_TEXT_ADDR: &str = "EVesBKsYRVtkjnTcsbk8tWHkBn2xZmzAXzwgrP3ZaVoZ"; - const PRIV_ACC_B_TEXT_ADDR: &str = "94MXhZnueurjX6v37CYDKVEKYBiyhYArvtEdceq2XDQP"; + const PRIV_ACC_A_TEXT_ADDR: &str = "As5oeEYgbwFwHCB8xCnRJA5uQV1eYCcU86Pfir3D29fX"; + const PRIV_ACC_B_TEXT_ADDR: &str = "GhB15jD2Yig2h2SnDXqxsZii1B3EhnmSucvwodfXKhAa"; #[test] fn pub_state_consistency() { @@ -358,78 +338,24 @@ mod tests { let init_private_accs_keys = initial_priv_accounts_private_keys(); let init_comms = initial_commitments(); + // `nsk`/`npk` carry no constants of their own: the key chains derive from `SSK_*`, and the + // two address canaries below pin H(PREFIX || npk || vpk || identifier), so drift anywhere + // in ask -> nsk -> npk or in vsk -> vpk moves one of them. Nothing is left unpinned. + // `VSK_*` stays pinned separately because it is the last value on the vsk -> vpk leg that + // a test can compare directly. assert_eq!( - init_private_accs_keys[0] - .key_chain - .secret_spending_key - .produce_private_key_holder(None) - .nullifier_secret_key, init_private_accs_keys[0] .key_chain .private_key_holder - .nullifier_secret_key - ); - assert_eq!( - init_private_accs_keys[0] - .key_chain - .secret_spending_key - .produce_private_key_holder(None) .viewing_secret_key, - init_private_accs_keys[0] - .key_chain - .private_key_holder - .viewing_secret_key - ); - assert_eq!( - init_private_accs_keys[0] - .key_chain - .private_key_holder - .generate_nullifier_public_key(), - init_private_accs_keys[0].key_chain.nullifier_public_key - ); - assert_eq!( - init_private_accs_keys[0] - .key_chain - .private_key_holder - .generate_viewing_public_key(), - init_private_accs_keys[0].key_chain.viewing_public_key - ); - - assert_eq!( - init_private_accs_keys[1] - .key_chain - .secret_spending_key - .produce_private_key_holder(None) - .nullifier_secret_key, - init_private_accs_keys[1] - .key_chain - .private_key_holder - .nullifier_secret_key + ViewingSecretKey::new(VSK_D_PRIV_ACC_A, VSK_Z_PRIV_ACC_A) ); assert_eq!( init_private_accs_keys[1] .key_chain - .secret_spending_key - .produce_private_key_holder(None) + .private_key_holder .viewing_secret_key, - init_private_accs_keys[1] - .key_chain - .private_key_holder - .viewing_secret_key - ); - assert_eq!( - init_private_accs_keys[1] - .key_chain - .private_key_holder - .generate_nullifier_public_key(), - init_private_accs_keys[1].key_chain.nullifier_public_key - ); - assert_eq!( - init_private_accs_keys[1] - .key_chain - .private_key_holder - .generate_viewing_public_key(), - init_private_accs_keys[1].key_chain.viewing_public_key + ViewingSecretKey::new(VSK_D_PRIV_ACC_B, VSK_Z_PRIV_ACC_B) ); assert_eq!( @@ -453,7 +379,7 @@ mod tests { assert_eq!( init_comms[0], PrivateAccountPublicInitialData { - npk: NullifierPublicKey(NPK_PRIV_ACC_A), + npk: init_private_accs_keys[0].key_chain.nullifier_public_key, vpk: init_private_accs_keys[0] .key_chain .viewing_public_key @@ -470,7 +396,7 @@ mod tests { assert_eq!( init_comms[1], PrivateAccountPublicInitialData { - npk: NullifierPublicKey(NPK_PRIV_ACC_B), + npk: init_private_accs_keys[1].key_chain.nullifier_public_key, vpk: init_private_accs_keys[1] .key_chain .viewing_public_key diff --git a/lez/wallet-ffi/Cargo.toml b/lez/wallet-ffi/Cargo.toml index 5440bee2b..25d5f2a50 100644 --- a/lez/wallet-ffi/Cargo.toml +++ b/lez/wallet-ffi/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["rlib", "cdylib", "staticlib"] wallet.workspace = true lee.workspace = true lee_core.workspace = true +common.workspace = true programs.workspace = true tokio.workspace = true diff --git a/lez/wallet-ffi/src/generic_transaction.rs b/lez/wallet-ffi/src/generic_transaction.rs index 7be6ddafc..6420e5e81 100644 --- a/lez/wallet-ffi/src/generic_transaction.rs +++ b/lez/wallet-ffi/src/generic_transaction.rs @@ -3,6 +3,7 @@ use std::{ ffi::{c_char, CString}, }; +use common::HashType; use lee::{privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program}; use crate::{ @@ -390,6 +391,43 @@ pub unsafe extern "C" fn wallet_ffi_send_generic_private_transaction( } } +/// Poll transaction for its status. +/// +/// # Parameters +/// - `handle`: Valid pointer to wallet handle. +/// - `tx_hash`: Bytes of a transaction hash, +/// - `transaction_status`: Valid pointer into `bool`. +/// +/// # Returns +/// - `true` if seen included, `false` othervise. +/// +/// # Safety +/// - `handle` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_poll_transaction_status( + handle: *mut WalletHandle, + tx_hash: FfiBytes32, + // ToDo: Replace with status enum. + transaction_status: *mut bool, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return WalletFfiError::InternalError; + } + }; + + *transaction_status = block_on(wallet.poll_transaction(HashType(tx_hash.data))).is_ok(); + + WalletFfiError::Success +} + /// Free a transaction result returned by `wallet_ffi_send_generic_public_transaction` or /// `wallet_ffi_send_generic_private_transaction`. /// diff --git a/lez/wallet-ffi/src/keys.rs b/lez/wallet-ffi/src/keys.rs index 6a2c4d0bb..b3f52d22d 100644 --- a/lez/wallet-ffi/src/keys.rs +++ b/lez/wallet-ffi/src/keys.rs @@ -361,6 +361,7 @@ pub unsafe extern "C" fn wallet_ffi_free_account_identity( kind: _, account_id: _, key_path, + authorization_secret_key: _, nullifier_secret_key: _, nullifier_public_key: _, viewing_public_key, diff --git a/lez/wallet-ffi/src/types.rs b/lez/wallet-ffi/src/types.rs index 3779ba018..aefa30e05 100644 --- a/lez/wallet-ffi/src/types.rs +++ b/lez/wallet-ffi/src/types.rs @@ -7,8 +7,12 @@ use std::{ str::FromStr as _, }; +use common::HashType; use lee::{Data, ProgramId, SharedSecretKey}; -use lee_core::{encryption::MlKem768EncapsulationKey, program::PdaSeed, NullifierPublicKey}; +use lee_core::{ + encryption::MlKem768EncapsulationKey, program::PdaSeed, AuthorizationSecretKey, + NullifierPublicKey, NullifierSecretKey, +}; use wallet::{account::AccountIdWithPrivacy, AccountIdentity}; use crate::error::WalletFfiError; @@ -79,7 +83,7 @@ pub struct FfiU128 { /// byte arrays since C doesn't have native u128 support. #[repr(C)] pub struct FfiAccount { - pub program_owner: FfiProgramId, + pub program_owner: FfiBytes32, /// Balance as little-endian [u8; 16]. pub balance: FfiU128, /// Pointer to account data bytes. @@ -93,7 +97,7 @@ pub struct FfiAccount { impl Default for FfiAccount { fn default() -> Self { Self { - program_owner: FfiProgramId::default(), + program_owner: FfiBytes32::default(), balance: FfiU128::default(), data: std::ptr::null(), data_len: 0, @@ -156,6 +160,7 @@ impl Default for FfiAccountList { /// Result of a transfer operation. #[repr(C)] +#[derive(Debug)] pub struct FfiTransferResult { // TODO: Replace with HashType FFI representation /// Transaction hash (null-terminated string, or null on failure). @@ -173,6 +178,22 @@ impl Default for FfiTransferResult { } } +impl FfiTransferResult { + #[must_use] + /// Casting valid results hash into bytes. Effectively frees `FfiTransferResult`. + /// + /// # Safety + /// Field `tx_hash` must be a valid pointer into transaction hash. + pub unsafe fn tx_hash_bytes(self) -> FfiBytes32 { + let cstring = unsafe { CString::from_raw(self.tx_hash) }; + let rstring = cstring.into_string().expect("Must be a valid Rust string"); + + let hash_val = HashType::from_str(&rstring).expect("Must be a valid hex string"); + + FfiBytes32 { data: hash_val.0 } + } +} + // Helper functions to convert between Rust and FFI types impl FfiBytes32 { @@ -238,6 +259,7 @@ pub struct FfiAccountIdentity { pub account_id: FfiBytes32, /// C-compatible string. pub key_path: *mut c_char, + pub authorization_secret_key: FfiBytes32, pub nullifier_secret_key: FfiBytes32, pub nullifier_public_key: FfiBytes32, pub viewing_public_key: *const u8, @@ -251,6 +273,7 @@ impl Default for FfiAccountIdentity { kind: FfiAccountIdentityKind::Public, account_id: FfiBytes32::default(), key_path: std::ptr::null_mut(), + authorization_secret_key: FfiBytes32::default(), nullifier_secret_key: FfiBytes32::default(), nullifier_public_key: FfiBytes32::default(), viewing_public_key: std::ptr::null(), @@ -308,11 +331,8 @@ impl From for FfiAccount { ptr::null() }; - let program_owner = FfiProgramId { - data: value.program_owner, - }; Self { - program_owner, + program_owner: value.program_owner.into(), balance: value.balance.into(), data, data_len, @@ -335,7 +355,7 @@ impl TryFrom<&FfiAccount> for lee::Account { Data::default() }; Ok(Self { - program_owner: value.program_owner.data, + program_owner: value.program_owner.into(), balance: value.balance.into(), data, nonce: lee_core::account::Nonce(value.nonce.into()), @@ -444,8 +464,7 @@ impl From for FfiAccountIdentity { } } AccountIdentity::PrivateShared { - nsk, - npk, + ask, vpk, identifier, } => { @@ -458,10 +477,13 @@ impl From for FfiAccountIdentity { ptr::null() }; + let nsk = NullifierSecretKey::from(&ask); + Self { kind: FfiAccountIdentityKind::PrivateShared, + authorization_secret_key: ask.0.into(), nullifier_secret_key: nsk.into(), - nullifier_public_key: npk.0.into(), + nullifier_public_key: NullifierPublicKey::from(&nsk).0.into(), viewing_public_key: vpk_data, viewing_public_key_len: vpk_len, identifier: identifier.into(), @@ -471,7 +493,6 @@ impl From for FfiAccountIdentity { AccountIdentity::PrivatePdaShared { account_id, nsk, - npk, vpk, identifier, } => { @@ -488,7 +509,7 @@ impl From for FfiAccountIdentity { kind: FfiAccountIdentityKind::PrivatePdaShared, account_id: account_id.into(), nullifier_secret_key: nsk.into(), - nullifier_public_key: npk.0.into(), + nullifier_public_key: NullifierPublicKey::from(&nsk).0.into(), viewing_public_key: vpk_data, viewing_public_key_len: vpk_len, identifier: identifier.into(), @@ -578,9 +599,16 @@ impl TryFrom<&FfiAccountIdentity> for AccountIdentity { Err(WalletFfiError::InvalidKeyValue) }?; + let ask = AuthorizationSecretKey(value.authorization_secret_key.data); + let nsk = NullifierSecretKey::from(&ask); + if value.nullifier_secret_key.data != nsk + || value.nullifier_public_key.data != NullifierPublicKey::from(&nsk).0 + { + return Err(WalletFfiError::InvalidKeyValue); + } + Ok(Self::PrivateShared { - nsk: value.nullifier_secret_key.data, - npk: NullifierPublicKey(value.nullifier_public_key.data), + ask, vpk, identifier: value.identifier.into(), }) @@ -599,10 +627,14 @@ impl TryFrom<&FfiAccountIdentity> for AccountIdentity { Err(WalletFfiError::InvalidKeyValue) }?; + let nsk = value.nullifier_secret_key.data; + if value.nullifier_public_key.data != NullifierPublicKey::from(&nsk).0 { + return Err(WalletFfiError::InvalidKeyValue); + } + Ok(Self::PrivatePdaShared { account_id: value.account_id.into(), - nsk: value.nullifier_secret_key.data, - npk: NullifierPublicKey(value.nullifier_public_key.data), + nsk, vpk, identifier: value.identifier.into(), }) @@ -658,10 +690,13 @@ impl From for AccountIdWithPrivacy { #[cfg(test)] mod tests { use lee::{AccountId, PrivateKey, PublicKey}; - use lee_core::{encryption::ViewingPublicKey, program::PdaSeed, PrivateAccountKind}; + use lee_core::{ + encryption::ViewingPublicKey, program::PdaSeed, AuthorizationSecretKey, NullifierSecretKey, + PrivateAccountKind, + }; use wallet::AccountIdentity; - use crate::{FfiAccountIdentity, FfiAccountIdentityKind}; + use crate::{error::WalletFfiError, FfiAccountIdentity, FfiAccountIdentityKind, FfiBytes32}; #[test] fn account_identity_roundtrip() { @@ -669,7 +704,8 @@ mod tests { let public_key = PublicKey::new_from_private_key(&private_key); let pub_acc_id = (&public_key).into(); - let nsk = [43; 32]; + let ask = AuthorizationSecretKey([43; 32]); + let nsk = NullifierSecretKey::from(&ask); let vpk = ViewingPublicKey::from_seed(&[44; 32], &[54; 32]); let npk = (&nsk).into(); let identifier = u128::from_le_bytes([45; 16]); @@ -708,15 +744,13 @@ mod tests { identifier, }; let acc_identity_7 = AccountIdentity::PrivateShared { - nsk, - npk, + ask, vpk: vpk.clone(), identifier, }; let acc_identity_8 = AccountIdentity::PrivatePdaShared { account_id: private_pda_acc_id, nsk, - npk, vpk, identifier, }; @@ -765,6 +799,10 @@ mod tests { FfiAccountIdentityKind::PrivatePdaShared ); + assert_eq!(ffi_acc_identity_7.nullifier_secret_key.data, nsk); + assert_eq!(ffi_acc_identity_7.nullifier_public_key.data, npk.0); + assert_eq!(ffi_acc_identity_8.nullifier_public_key.data, npk.0); + let acc_identity_res_1: AccountIdentity = (&ffi_acc_identity_1).try_into().unwrap(); let acc_identity_res_2: AccountIdentity = (&ffi_acc_identity_2).try_into().unwrap(); let acc_identity_res_2_5: AccountIdentity = (&ffi_acc_identity_2_5).try_into().unwrap(); @@ -785,4 +823,49 @@ mod tests { assert_eq!(acc_identity_res_7, acc_identity_7); assert_eq!(acc_identity_res_8, acc_identity_8); } + + #[test] + fn inconsistent_derived_keys_are_rejected() { + let ask = AuthorizationSecretKey([43; 32]); + let nsk = NullifierSecretKey::from(&ask); + let vpk = ViewingPublicKey::from_seed(&[44; 32], &[54; 32]); + let identifier = u128::from_le_bytes([45; 16]); + + let shared = AccountIdentity::PrivateShared { + ask, + vpk: vpk.clone(), + identifier, + }; + let pda_shared = AccountIdentity::PrivatePdaShared { + account_id: AccountId::new([46; 32]), + nsk, + vpk, + identifier, + }; + + let mut tampered_nsk: FfiAccountIdentity = shared.clone().into(); + tampered_nsk.nullifier_secret_key.data[0] ^= 1; + let mut tampered_npk: FfiAccountIdentity = shared.clone().into(); + tampered_npk.nullifier_public_key.data[0] ^= 1; + let mut zeroed: FfiAccountIdentity = shared.into(); + zeroed.nullifier_secret_key = FfiBytes32::default(); + zeroed.nullifier_public_key = FfiBytes32::default(); + let mut tampered_pda_npk: FfiAccountIdentity = pda_shared.clone().into(); + tampered_pda_npk.nullifier_public_key.data[0] ^= 1; + let mut zeroed_pda: FfiAccountIdentity = pda_shared.into(); + zeroed_pda.nullifier_public_key = FfiBytes32::default(); + + for inconsistent in [ + &tampered_nsk, + &tampered_npk, + &zeroed, + &tampered_pda_npk, + &zeroed_pda, + ] { + assert_eq!( + AccountIdentity::try_from(inconsistent).unwrap_err(), + WalletFfiError::InvalidKeyValue + ); + } + } } diff --git a/lez/wallet-ffi/wallet_ffi.h b/lez/wallet-ffi/wallet_ffi.h index bbd7da1fa..0245b4144 100644 --- a/lez/wallet-ffi/wallet_ffi.h +++ b/lez/wallet-ffi/wallet_ffi.h @@ -179,13 +179,6 @@ typedef struct FfiAccountList { uintptr_t count; } FfiAccountList; -/** - * Program ID - 8 u32 values (32 bytes total). - */ -typedef struct FfiProgramId { - uint32_t data[8]; -} FfiProgramId; - /** * U128 - 16 bytes little endian. */ @@ -200,7 +193,7 @@ typedef struct FfiU128 { * byte arrays since C doesn't have native u128 support. */ typedef struct FfiAccount { - struct FfiProgramId program_owner; + struct FfiBytes32 program_owner; /** * Balance as little-endian [u8; 16]. */ @@ -249,6 +242,7 @@ typedef struct FfiAccountIdentity { * C-compatible string. */ char *key_path; + struct FfiBytes32 authorization_secret_key; struct FfiBytes32 nullifier_secret_key; struct FfiBytes32 nullifier_public_key; const uint8_t *viewing_public_key; @@ -256,6 +250,13 @@ typedef struct FfiAccountIdentity { struct FfiU128 identifier; } FfiAccountIdentity; +/** + * Program ID - 8 u32 values (32 bytes total). + */ +typedef struct FfiProgramId { + uint32_t data[8]; +} FfiProgramId; + /** * Result of a generic transaction operation. */ @@ -668,6 +669,24 @@ enum WalletFfiError wallet_ffi_send_generic_private_transaction(struct WalletHan const struct FfiProgramWithDependencies *program_with_dependencies, struct FfiTransactionResult *out_result); +/** + * Poll transaction for its status. + * + * # Parameters + * - `handle`: Valid pointer to wallet handle. + * - `tx_hash`: Bytes of a transaction hash, + * - `transaction_status`: Valid pointer into `bool`. + * + * # Returns + * - `true` if seen included, `false` othervise. + * + * # Safety + * - `handle` must be a valid pointer. + */ +enum WalletFfiError wallet_ffi_poll_transaction_status(struct WalletHandle *handle, + struct FfiBytes32 tx_hash, + bool *transaction_status); + /** * Free a transaction result returned by `wallet_ffi_send_generic_public_transaction` or * `wallet_ffi_send_generic_private_transaction`. diff --git a/lez/wallet/Cargo.toml b/lez/wallet/Cargo.toml index c03c1505d..a2da75640 100644 --- a/lez/wallet/Cargo.toml +++ b/lez/wallet/Cargo.toml @@ -38,7 +38,6 @@ humantime-serde.workspace = true humantime.workspace = true tokio = { workspace = true, features = ["macros"] } clap.workspace = true -base58.workspace = true hex.workspace = true rand.workspace = true itertools.workspace = true diff --git a/lez/wallet/src/account.rs b/lez/wallet/src/account.rs index 8caa73663..533126458 100644 --- a/lez/wallet/src/account.rs +++ b/lez/wallet/src/account.rs @@ -1,6 +1,5 @@ use std::str::FromStr; -use base58::{FromBase58 as _, ToBase58 as _}; use derive_more::Display; use lee::AccountId; use serde::{Deserialize, Serialize}; @@ -103,12 +102,7 @@ impl std::fmt::Display for HumanReadableAccount { impl From for HumanReadableAccount { fn from(account: lee::Account) -> Self { - let program_owner = account - .program_owner - .iter() - .flat_map(|n| n.to_le_bytes()) - .collect::>() - .to_base58(); + let program_owner = account.program_owner.to_string(); let data = hex::encode(account.data); Self { balance: account.balance, @@ -121,24 +115,10 @@ impl From for HumanReadableAccount { impl From for lee::Account { fn from(account: HumanReadableAccount) -> Self { - let mut program_owner_bytes = [0_u8; 32]; - let decoded_program_owner = account + let program_owner: lee::AccountId = account .program_owner - .from_base58() + .parse() .expect("Invalid base58 in HumanReadableAccount.program_owner"); - assert!( - decoded_program_owner.len() == 32, - "HumanReadableAccount.program_owner must decode to exactly 32 bytes" - ); - program_owner_bytes.copy_from_slice(&decoded_program_owner); - - let mut program_owner = [0_u32; 8]; - for (index, chunk) in program_owner_bytes.chunks_exact(4).enumerate() { - let chunk: [u8; 4] = chunk - .try_into() - .expect("chunk length is guaranteed to be 4"); - program_owner[index] = u32::from_le_bytes(chunk); - } let data = hex::decode(&account.data).expect("Invalid hex in HumanReadableAccount.data"); let data = data diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index bad162128..d38c0897e 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -4,9 +4,9 @@ use anyhow::Result; use keycard_wallet::KeycardWallet; use lee::{AccountId, PrivateKey, PublicKey, Signature}; use lee_core::{ - Commitment, CommitmentSetDigest, DummyInput, Identifier, InputAccountIdentity, MembershipProof, - NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateAccountKind, PrivateWitness, - SharedSecretKey, WitnessKind, + AuthorizationSecretKey, Commitment, CommitmentSetDigest, DummyInput, Identifier, + InputAccountIdentity, MembershipProof, NullifierPublicKey, NullifierSecretKey, + NullifierWitness, PrivateAccountKind, PrivateWitness, SharedSecretKey, WitnessKind, account::{Account, AccountWithMetadata, Nonce}, compute_digest_for_path, encryption::{ @@ -45,20 +45,20 @@ pub enum AccountIdentity { identifier: Identifier, }, /// A shared regular private account with externally-provided keys (e.g. from GMS). - /// Uses standard `AccountId = from((&npk, identifier))` with authorized/unauthorized private - /// paths. Works with `authenticated_transfer` and all existing programs out of the box. + /// Carries the authorization secret key: the `nsk` and `npk` behind + /// `AccountId = from((&npk, &vpk, identifier))` are derived from it. + /// Works with `authenticated_transfer` and all existing programs out of the box. PrivateShared { - nsk: NullifierSecretKey, - npk: NullifierPublicKey, + ask: AuthorizationSecretKey, vpk: ViewingPublicKey, identifier: Identifier, }, /// A shared private PDA with externally-provided keys (e.g. from GMS). - /// `account_id` was derived via [`AccountId::for_private_pda`]. + /// `account_id` was derived via [`AccountId::for_private_pda`]; its `npk` is derived from + /// the `nsk` at use. PrivatePdaShared { account_id: AccountId, nsk: NullifierSecretKey, - npk: NullifierPublicKey, vpk: ViewingPublicKey, identifier: Identifier, }, @@ -102,20 +102,15 @@ impl fmt::Debug for AccountIdentity { .field("identifier", identifier) .finish(), Self::PrivateShared { - npk, - vpk, - identifier, - .. + vpk, identifier, .. } => f .debug_struct("PrivateShared") - .field("nsk", &"") - .field("npk", npk) + .field("ask", &"") .field("vpk", vpk) .field("identifier", identifier) .finish(), Self::PrivatePdaShared { account_id, - npk, vpk, identifier, .. @@ -123,7 +118,6 @@ impl fmt::Debug for AccountIdentity { .debug_struct("PrivatePdaShared") .field("account_id", account_id) .field("nsk", &"") - .field("npk", npk) .field("vpk", vpk) .field("identifier", identifier) .finish(), @@ -266,21 +260,10 @@ impl AccountManager { vpk, identifier, } => { - let acc = lee_core::account::Account::default(); - let auth_acc = AccountWithMetadata::new(acc, true, (&npk, &vpk, identifier)); - let random_seed = random_bytes(); - let pre = AccountPreparedData { - nsk: None, - npk, - identifier, - vpk, - pre_state: auth_acc, - proof: None, - random_seed, - is_pda: false, - }; - - State::Private(pre) + let account_id = lee::AccountId::from((&npk, &vpk, identifier)); + State::Private(private_foreign_acc_preparation( + account_id, npk, vpk, identifier, false, + )) } AccountIdentity::PrivatePdaOwned(account_id) => { let pre = private_key_tree_acc_preparation(wallet, account_id, true)?; @@ -291,31 +274,25 @@ impl AccountManager { npk, vpk, identifier, - } => { - let acc = lee_core::account::Account::default(); - let auth_acc = AccountWithMetadata::new(acc, false, account_id); - let random_seed = random_bytes(); - let pre = AccountPreparedData { - nsk: None, - npk, - identifier, - vpk, - pre_state: auth_acc, - proof: None, - random_seed, - is_pda: true, - }; - State::Private(pre) - } + } => State::Private(private_foreign_acc_preparation( + account_id, npk, vpk, identifier, true, + )), AccountIdentity::PrivateShared { - nsk, - npk, + ask, vpk, identifier, } => { + let nsk = NullifierSecretKey::from(&ask); + let npk = NullifierPublicKey::from(&nsk); let account_id = lee::AccountId::from((&npk, &vpk, identifier)); let pre = private_shared_acc_preparation( - wallet, account_id, nsk, npk, vpk, identifier, false, + wallet, + account_id, + nsk, + vpk, + identifier, + Some(ask), + false, ); State::Private(pre) @@ -323,12 +300,11 @@ impl AccountManager { AccountIdentity::PrivatePdaShared { account_id, nsk, - npk, vpk, identifier, } => { let pre = private_shared_acc_preparation( - wallet, account_id, nsk, npk, vpk, identifier, true, + wallet, account_id, nsk, vpk, identifier, None, true, ); State::Private(pre) @@ -448,7 +424,7 @@ impl AccountManager { kind: if pre.is_pda { WitnessKind::Pda { binding: None } } else { - WitnessKind::Regular + WitnessKind::Regular { ask: pre.ask } }, nullifier: match (pre.nsk, pre.proof.clone()) { (Some(nsk), Some(membership_proof)) => NullifierWitness::Update { @@ -527,6 +503,7 @@ impl AccountManager { } struct AccountPreparedData { + ask: Option, nsk: Option, npk: NullifierPublicKey, identifier: Identifier, @@ -550,17 +527,20 @@ fn private_key_tree_acc_preparation( let from_identifier = from_acc.kind.identifier(); let from_keys = &from_acc.key_chain; - let nsk = from_keys.private_key_holder.nullifier_secret_key; + // A PDA is program-authorized and carries no credential of its own. + let ask = (!is_pda).then_some(from_keys.private_key_holder.authorization_secret_key); + let nsk = from_keys.private_key_holder.nullifier_secret_key(); let from_npk = from_keys.nullifier_public_key; let from_vpk = from_keys.viewing_public_key.clone(); // TODO: Technically we could allow unauthorized owned accounts, but currently we don't have // support from that in the wallet. - let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), true, account_id); + let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), ask.is_some(), account_id); let random_seed = random_bytes(); Ok(AccountPreparedData { + ask, nsk: Some(nsk), npk: from_npk, identifier: from_identifier, @@ -572,15 +552,40 @@ fn private_key_tree_acc_preparation( }) } -fn private_shared_acc_preparation( - wallet: &WalletCore, +/// Prepare a private account with no secret key knowledge, i.e. for inits. +fn private_foreign_acc_preparation( account_id: AccountId, - nsk: NullifierSecretKey, npk: NullifierPublicKey, vpk: ViewingPublicKey, identifier: Identifier, is_pda: bool, ) -> AccountPreparedData { + AccountPreparedData { + // The wallet holds no key for a recipient, so it can neither spend the account nor + // consent on its behalf. The program still claims it: a private claim never requires + // authorization. + ask: None, + nsk: None, + npk, + identifier, + vpk, + pre_state: AccountWithMetadata::new(Account::default(), false, account_id), + proof: None, + random_seed: random_bytes(), + is_pda, + } +} + +fn private_shared_acc_preparation( + wallet: &WalletCore, + account_id: AccountId, + nsk: NullifierSecretKey, + vpk: ViewingPublicKey, + identifier: Identifier, + ask: Option, + is_pda: bool, +) -> AccountPreparedData { + let npk = NullifierPublicKey::from(&nsk); let acc = wallet .storage() .key_chain() @@ -588,11 +593,12 @@ fn private_shared_acc_preparation( .map(|e| e.account.clone()) .unwrap_or_default(); - let pre_state = AccountWithMetadata::new(acc, true, account_id); + let pre_state = AccountWithMetadata::new(acc, ask.is_some(), account_id); let random_seed = random_bytes(); AccountPreparedData { + ask, nsk: Some(nsk), npk, identifier, @@ -701,8 +707,7 @@ mod tests { #[test] fn private_shared_is_private() { let acc = AccountIdentity::PrivateShared { - nsk: [0; 32], - npk: NullifierPublicKey([1; 32]), + ask: AuthorizationSecretKey([0; 32]), vpk: ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]), identifier: 42, }; @@ -715,6 +720,7 @@ mod tests { let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]); let pre_state = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0)); State::Private(AccountPreparedData { + ask: None, nsk: None, npk, identifier: 0, @@ -741,6 +747,23 @@ mod tests { } } + #[test] + fn foreign_private_init_is_unauthorized() { + let npk = NullifierPublicKey([7; 32]); + let vpk = ViewingPublicKey::from_seed(&[8; 32], &[9; 32]); + let account_id = lee::AccountId::from((&npk, &vpk, 0)); + let pre = private_foreign_acc_preparation(account_id, npk, vpk, 0, false); + + assert!(pre.ask.is_none()); + assert!(!pre.pre_state.is_authorized); + + let identities = manager(vec![State::Private(pre)]).account_identities(); + let InputAccountIdentity::Private(witness) = &identities[0] else { + panic!("expected a private witness"); + }; + assert!(matches!(witness.kind, WitnessKind::Regular { ask: None })); + } + #[test] fn dummy_inputs_default_pads_private_count_to_max() { let max = AccountManager::MAX_PRIVATE_ACCOUNTS; diff --git a/lez/wallet/src/cli/account.rs b/lez/wallet/src/cli/account.rs index c165deee8..92863892b 100644 --- a/lez/wallet/src/cli/account.rs +++ b/lez/wallet/src/cli/account.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use clap::Subcommand; use itertools::Itertools as _; use key_protocol::key_management::{KeyChain, key_tree::chain_index::ChainIndex}; -use lee::{Account, PublicKey}; +use lee::{Account, AccountId, PublicKey}; use lee_core::Identifier; use token_core::{TokenDefinition, TokenHolding}; @@ -643,8 +643,8 @@ impl WalletSubcommand for ImportSubcommand { /// Formats account details for display, returning (description, `json_view`). fn format_account_details(account: &Account) -> (String, String) { - let auth_tr_prog_id = programs::authenticated_transfer().id(); - let token_prog_id = programs::token().id(); + let auth_tr_prog_id: AccountId = programs::authenticated_transfer().id().into(); + let token_prog_id: AccountId = programs::token().id().into(); match &account.program_owner { o if *o == auth_tr_prog_id => { diff --git a/lez/wallet/src/cli/config.rs b/lez/wallet/src/cli/config.rs index 8c8c4b5c7..8b7a192b5 100644 --- a/lez/wallet/src/cli/config.rs +++ b/lez/wallet/src/cli/config.rs @@ -1,9 +1,11 @@ use anyhow::Result; use clap::Subcommand; +use common::config::BasicAuth; use crate::{ WalletCore, cli::{SubcommandReturnValue, WalletSubcommand}, + config::SequencerConnectionData, }; /// Represents generic config CLI subcommand. @@ -21,6 +23,14 @@ pub enum ConfigSubcommand { Set { key: String, value: String }, /// Prints description of corresponding field. Description { key: String }, + /// Adds a new sequencer to the list. + AddSequencer { + addr: String, + user: Option, + password: Option, + }, + /// Remove sequencer from a list. + RemoveSequencer { addr: String }, } impl ConfigSubcommand { @@ -51,6 +61,15 @@ impl ConfigSubcommand { "seq_block_poll_max_amount" => { println!("{}", config.seq_block_poll_max_amount); } + "distribution_limit" => { + println!( + "{}", + config.multi_sequencer_client_config.distribution_limit + ); + } + "calibration_limit" => { + println!("{}", config.multi_sequencer_client_config.calibration_limit); + } _ => { println!("Unknown field"); } @@ -69,6 +88,9 @@ impl ConfigSubcommand { ) -> Result { let mut config = wallet_core.config().clone(); match key.as_str() { + "sequencers" => { + anyhow::bail!("Not settable via this method, use add-sequencer subcommand"); + } "seq_poll_timeout" => { config.seq_poll_timeout = humantime::parse_duration(&value) .map_err(|e| anyhow::anyhow!("Invalid duration: {e}"))?; @@ -82,8 +104,11 @@ impl ConfigSubcommand { "seq_block_poll_max_amount" => { config.seq_block_poll_max_amount = value.parse()?; } - "initial_accounts" => { - anyhow::bail!("Setting this field from wallet is not supported"); + "distribution_limit" => { + config.multi_sequencer_client_config.distribution_limit = value.parse()?; + } + "calibration_limit" => { + config.multi_sequencer_client_config.calibration_limit = value.parse()?; } _ => { anyhow::bail!("Unknown field"); @@ -101,8 +126,8 @@ impl ConfigSubcommand { "override_rust_log" => { println!("Value of variable RUST_LOG to override, affects logging"); } - "sequencer_addr" => { - println!("HTTP V4 account_id of sequencer"); + "sequencer" => { + println!("A list of HTTP V4 addresses of sequencer, with authorization"); } "seq_poll_timeout" => { println!( @@ -124,11 +149,15 @@ impl ConfigSubcommand { "Sequencer client polling variable: max number of blocks to request in one polling call" ); } - "initial_accounts" => { - println!("List of initial accounts' keys(both public and private)"); + "distribution_limit" => { + println!( + "Sequencer multi node variable: max number of nodes to distribute transaction(can not be zero)" + ); } - "basic_auth" => { - println!("Basic authentication credentials for sequencer HTTP requests"); + "calibration_limit" => { + println!( + "Sequencer multi node variable: max number of callibration runs before the end of handshake(can not be zero)" + ); } _ => { println!("Unknown field"); @@ -148,6 +177,50 @@ impl WalletSubcommand for ConfigSubcommand { Self::Get { all, key } => Self::handle_get(all, key, wallet_core), Self::Set { key, value } => Self::handle_set(key, value, wallet_core).await, Self::Description { key } => Ok(Self::handle_description(&key, wallet_core)), + Self::AddSequencer { + addr, + user, + password, + } => { + let url_addr = addr.parse()?; + + let basic_auth = user.map(|user| { + let mut basic_auth = BasicAuth { + username: user, + password: None, + }; + + if password.is_some() { + basic_auth.password = password; + } + + basic_auth + }); + + let seq_connection_data = SequencerConnectionData { + sequencer_addr: url_addr, + basic_auth, + }; + + wallet_core.config.sequencers.push(seq_connection_data); + + Ok(SubcommandReturnValue::Empty) + } + Self::RemoveSequencer { addr } => { + let url_addr = addr.parse()?; + + let (idx, _) = wallet_core + .config + .sequencers + .iter() + .enumerate() + .find(|(_, conn_data)| conn_data.sequencer_addr == url_addr) + .ok_or_else(|| anyhow::anyhow!("Sequencer with this addr is not found"))?; + + wallet_core.config.sequencers.remove(idx); + + Ok(SubcommandReturnValue::Empty) + } } } } diff --git a/lez/wallet/src/cli/mod.rs b/lez/wallet/src/cli/mod.rs index 26653b61b..191db9001 100644 --- a/lez/wallet/src/cli/mod.rs +++ b/lez/wallet/src/cli/mod.rs @@ -25,6 +25,7 @@ use crate::{ native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand, vault::VaultSubcommand, }, + statistics::StatisticsSubcommand, }, config::SequencerConnectionData, storage::Storage, @@ -37,6 +38,7 @@ pub mod group; pub mod keycard; pub mod network; pub mod programs; +pub mod statistics; pub(crate) trait WalletSubcommand { async fn handle_subcommand(self, wallet_core: &mut WalletCore) @@ -101,6 +103,9 @@ pub enum Command { /// Keycard hardware wallet management. #[command(subcommand)] Keycard(KeycardSubcommand), + /// Metrics management. + #[command(subcommand)] + Statistics(StatisticsSubcommand), } /// To execute commands, env var `LEE_WALLET_HOME_DIR` must be set into directory with config. @@ -320,6 +325,9 @@ pub async fn execute_subcommand( .await .context("Transaction finalization error")? } + Command::Statistics(statistics_subcommand) => { + statistics_subcommand.handle_subcommand(wallet_core).await? + } }; // Kind of a sledgehammer solution, but it is not clear if there is the case to not store diff --git a/lez/wallet/src/cli/statistics.rs b/lez/wallet/src/cli/statistics.rs new file mode 100644 index 000000000..02f1ccecd --- /dev/null +++ b/lez/wallet/src/cli/statistics.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use clap::Subcommand; + +use crate::{ + WalletCore, + cli::{SubcommandReturnValue, WalletSubcommand}, + config::SequencerConnectionData, + multi_client::{calibrate_client, make_subclient}, +}; + +/// Represents generic config CLI subcommand. +#[derive(Subcommand, Debug, Clone)] +pub enum StatisticsSubcommand { + /// Show the list of the current leaders. + ShowLeaders, + /// Execute client list rotation, applies all statistics, the re-chooses the leaders. + ExecuteRotation, + /// (Re)callibrate the client. + Callibrate { addr: String }, + /// Shpw the statistics of the client. + ShowStatistics { addr: String }, +} + +impl WalletSubcommand for StatisticsSubcommand { + async fn handle_subcommand( + self, + wallet_core: &mut WalletCore, + ) -> Result { + match self { + Self::ShowLeaders => { + let leader_urls = wallet_core + .leaders() + .iter() + .map(|(_, url)| url) + .collect::>(); + + println!("Leader URLs is {leader_urls:?}"); + + Ok(SubcommandReturnValue::Empty) + } + Self::ExecuteRotation => { + wallet_core.client_rotation().await?; + + Ok(SubcommandReturnValue::Empty) + } + Self::Callibrate { addr } => { + let url_addr = addr.parse()?; + let calibration_limit = wallet_core + .config() + .multi_sequencer_client_config + .calibration_limit; + let SequencerConnectionData { + sequencer_addr, + basic_auth, + } = wallet_core + .config() + .sequencers + .iter() + .find(|conn_data| conn_data.sequencer_addr == url_addr) + .ok_or_else(|| { + anyhow::anyhow!("Sequencer with this addr was not found in config") + })?; + let client = make_subclient(sequencer_addr, basic_auth)?; + + let statistics = calibrate_client(client, calibration_limit) + .await + .ok_or_else(|| anyhow::anyhow!("Failed to callibrate the sequencer"))?; + + wallet_core.statistics.insert(url_addr, statistics); + + Ok(SubcommandReturnValue::Empty) + } + Self::ShowStatistics { addr } => { + let url_addr = addr.parse()?; + + println!( + "Statistics of a {url_addr:?} is {:?}", + wallet_core.get_statistics(&url_addr) + ); + + Ok(SubcommandReturnValue::Empty) + } + } + } +} diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index d6d7d316c..96c25a1d8 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -29,7 +29,7 @@ use lee_core::{ BlockId, Commitment, CommitmentSetDigest, MembershipProof, SharedSecretKey, account::Nonce, program::InstructionData, }; -use log::{info, warn}; +use log::warn; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use storage::Storage; use tokio::io::AsyncWriteExt as _; @@ -305,7 +305,7 @@ impl WalletCore { // Ensure data is flushed to disk before returning to prevent race conditions config_file.sync_all().await?; - info!("Stored data at {}", self.config_path.display()); + log::info!("Stored data at {}", self.config_path.display()); Ok(()) } @@ -373,23 +373,19 @@ impl WalletCore { .key_chain() .shared_private_account(account_id)?; let keys = self.storage.key_chain().derive_shared_account_keys(entry)?; - let nsk = keys.nullifier_secret_key; - let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); let identifier = entry.identifier; if entry.pda_seed.is_some() { Some(AccountIdentity::PrivatePdaShared { account_id, - nsk, - npk, + nsk: keys.nullifier_secret_key(), vpk, identifier, }) } else { Some(AccountIdentity::PrivateShared { - nsk, - npk, + ask: keys.authorization_secret_key, vpk, identifier, }) @@ -444,7 +440,7 @@ impl WalletCore { return Ok(()); } - info!("Scanning shared account {account_id:#?} from genesis to block {cursor}"); + log::info!("Scanning shared account {account_id:#?} from genesis to block {cursor}"); let mut index = NullifierIndex::default(); index.track_initialization(account_id); @@ -989,7 +985,7 @@ impl WalletCore { &key_chain.viewing_public_key, &kind, ); - let nsk = key_chain.private_key_holder.nullifier_secret_key; + let nsk = key_chain.private_key_holder.nullifier_secret_key(); (account_id, kind, res_acc, nsk) }) }) @@ -998,7 +994,7 @@ impl WalletCore { .collect::>(); for (affected_account_id, kind, new_acc, nsk) in affected_accounts { - info!( + log::info!( "Received new account for account_id {affected_account_id:#?} with account object {new_acc:#?}" ); // Await the account's next update by its nullifier, so later updates @@ -1028,7 +1024,7 @@ impl WalletCore { let keys = self.storage.key_chain().derive_shared_account_keys(entry)?; let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); - let nsk = keys.nullifier_secret_key; + let nsk = keys.nullifier_secret_key(); let vsk = keys.viewing_secret_key; Some((account_id, npk, vpk, vsk, nsk)) }) @@ -1049,7 +1045,7 @@ impl WalletCore { continue; }; if let Some((_kind, new_acc)) = decrypt_note_at(message, ciph_id, &shared_secret) { - info!("Synced shared account {account_id:#?} with new state {new_acc:#?}"); + log::info!("Synced shared account {account_id:#?} with new state {new_acc:#?}"); index.track(account_id, &new_acc, &nsk); self.storage .key_chain_mut() diff --git a/lez/wallet/src/multi_client.rs b/lez/wallet/src/multi_client.rs index 49c6bbda1..ad0cb0930 100644 --- a/lez/wallet/src/multi_client.rs +++ b/lez/wallet/src/multi_client.rs @@ -10,7 +10,7 @@ use std::{collections::HashMap, path::Path, sync::Arc}; use anyhow::{Context as _, Result}; -use common::{HashType, transaction::LeeTransaction}; +use common::{HashType, config::BasicAuth, transaction::LeeTransaction}; use itertools::Itertools as _; use lee_core::BlockId; use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; @@ -115,23 +115,7 @@ impl MultiSequencerClient { basic_auth, } in conn_data { - let sequencer_client = { - let mut builder = SequencerClientBuilder::default(); - if let Some(basic_auth) = &basic_auth { - builder = builder.set_headers( - std::iter::once(( - "Authorization".parse().expect("Header name is valid"), - format!("Basic {basic_auth}") - .parse() - .context("Invalid basic auth format")?, - )) - .collect(), - ); - } - builder - .build(sequencer_addr) - .context("Failed to create sequencer client")? - }; + let sequencer_client = make_subclient(sequencer_addr, basic_auth)?; if statistics.contains_key(sequencer_addr) { actualization_list.push((sequencer_addr.clone(), sequencer_client.clone())); @@ -472,9 +456,33 @@ async fn measure_request_duration(client: &SequencerClient) -> (u128, Option, +) -> Result { + let mut builder = SequencerClientBuilder::default(); + if let Some(basic_auth) = &basic_auth { + builder = builder.set_headers( + std::iter::once(( + "Authorization".parse().expect("Header name is valid"), + format!("Basic {basic_auth}") + .parse() + .context("Invalid basic auth format")?, + )) + .collect(), + ); + } + builder + .build(sequencer_addr) + .context("Failed to create sequencer client") +} + /// Calibrate statistics for one client. Takes `client` by value deliberately, cloning /// `SequencerClient` is cheap. -async fn calibrate_client(client: SequencerClient, calibration_limit: usize) -> Option { +pub async fn calibrate_client( + client: SequencerClient, + calibration_limit: usize, +) -> Option { let mut latencies = vec![]; let mut latest_block_id = 0; let mut errors: u64 = 0; diff --git a/lez/wallet/src/poller.rs b/lez/wallet/src/poller.rs index 80f2a0c59..f20205f59 100644 --- a/lez/wallet/src/poller.rs +++ b/lez/wallet/src/poller.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::Result; use common::{HashType, block::Block, transaction::LeeTransaction}; use lee_core::BlockId; -use log::{info, warn}; +use log::warn; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::task::JoinSet; @@ -35,9 +35,9 @@ impl TxPoller { pub async fn poll_tx(&self, tx_hash: HashType) -> Result<(LeeTransaction, BlockId)> { let max_blocks_to_query = self.polling_max_blocks_to_query; - info!("Starting poll for transaction {tx_hash}"); + log::info!("Starting poll for transaction {tx_hash}"); for poll_id in 1..max_blocks_to_query { - info!("Poll {poll_id}"); + log::info!("Poll {poll_id}"); let mut try_error_counter = 0_u64; diff --git a/lez/wallet/src/storage/key_chain.rs b/lez/wallet/src/storage/key_chain.rs index 3f5eba05a..3acd57ed0 100644 --- a/lez/wallet/src/storage/key_chain.rs +++ b/lez/wallet/src/storage/key_chain.rs @@ -365,7 +365,7 @@ impl UserKeyChain { &found.key_chain.viewing_public_key, found.kind, ); - let nsk = found.key_chain.private_key_holder.nullifier_secret_key; + let nsk = found.key_chain.private_key_holder.nullifier_secret_key(); index.track(account_id, found.account, &nsk); } @@ -374,7 +374,7 @@ impl UserKeyChain { let Some(keys) = self.derive_shared_account_keys(entry) else { continue; }; - let nsk = keys.nullifier_secret_key; + let nsk = keys.nullifier_secret_key(); index.track(account_id, &entry.account, &nsk); } @@ -426,14 +426,14 @@ impl UserKeyChain { &keys.viewing_secret_key.d, &keys.viewing_secret_key.z, )?; - (keys.nullifier_secret_key, secret, true) + (keys.nullifier_secret_key(), secret, true) } else { let found = self.private_account(account_id)?; let secret = found .key_chain .calculate_shared_secret_receiver(&encrypted.epk)?; ( - found.key_chain.private_key_holder.nullifier_secret_key, + found.key_chain.private_key_holder.nullifier_secret_key(), secret, false, ) @@ -459,14 +459,14 @@ impl UserKeyChain { return Some(NullifierIndex::next_update_nullifier( account_id, &entry.account, - &keys.nullifier_secret_key, + &keys.nullifier_secret_key(), )); } let acc = self.private_account(account_id)?; Some(NullifierIndex::next_update_nullifier( account_id, acc.account, - &acc.key_chain.private_key_holder.nullifier_secret_key, + &acc.key_chain.private_key_holder.nullifier_secret_key(), )) } @@ -898,7 +898,7 @@ mod tests { let mut kc = UserKeyChain::default(); let key_chain = KeyChain::new_os_random(); - let nsk = key_chain.private_key_holder.nullifier_secret_key; + let nsk = key_chain.private_key_holder.nullifier_secret_key(); let identifier = 0; let account_id = AccountId::for_private_account( &key_chain.nullifier_public_key, @@ -966,7 +966,7 @@ mod tests { let keys = holder.derive_regular_shared_account_keys_from_identifier(identifier); let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); - let nsk = keys.nullifier_secret_key; + let nsk = keys.nullifier_secret_key(); let account_id = AccountId::from((&npk, &vpk, identifier)); kc.insert_group_key_holder(label.clone(), holder); @@ -1036,7 +1036,7 @@ mod tests { let keys = holder.derive_regular_shared_account_keys_from_identifier(identifier); let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); - let nsk = keys.nullifier_secret_key; + let nsk = keys.nullifier_secret_key(); let account_id = AccountId::from((&npk, &vpk, identifier)); kc.insert_group_key_holder(label.clone(), holder); 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 0f442aa2f..a29c488d4 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 038d4b0cd..52e91bca6 100644 --- a/test_fixtures/src/bin/regenerate_test_fixture.rs +++ b/test_fixtures/src/bin/regenerate_test_fixture.rs @@ -52,12 +52,13 @@ 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")?; let (mut wallet, _temp_wallet_dir, _wallet_password) = setup_wallet( - sequencer_handle.addr(), + &[sequencer_handle.addr()], &initial_public_accounts, &initial_private_accounts, WalletConfigOverrides::default(), @@ -76,7 +77,9 @@ async fn generate_prebuilt_fixture(dest: &Path) -> Result<()> { drop(wallet); drop(sequencer_handle); - let db_path = temp_sequencer_dir.path().join("rocksdb"); + let db_path = temp_sequencer_dir + .path() + .join(format!("rocksdb-{}", config::bedrock_channel_id())); let store = open_store_with_retry(&db_path) .await .context("Failed to reopen sequencer store after shutdown")?; diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index b28353488..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. @@ -77,6 +88,26 @@ impl std::fmt::Display for UrlProtocol { } } +#[derive(Debug, Clone, Copy)] +/// Config for test context in multi-node case. +pub struct MultiNodeTestContextConfig { + pub num_nodes: usize, + pub bedrock_channel: ChannelId, +} + +impl Default for MultiNodeTestContextConfig { + fn default() -> Self { + Self { + num_nodes: 1, + bedrock_channel: bedrock_channel_id(), + } + } +} + +#[expect( + clippy::too_many_arguments, + reason = "All fields are necessary and better to keep separate" +)] pub fn sequencer_config( partial: SequencerPartialConfig, home: PathBuf, @@ -85,6 +116,8 @@ pub fn sequencer_config( funding_key: ZkPublicKey, genesis_transactions: Vec, cross_zone: Option, + signing_key: Option<[u8; 32]>, + gossip: Option, ) -> Result { let SequencerPartialConfig { max_num_tx_in_block, @@ -101,7 +134,7 @@ pub fn sequencer_config( block_create_timeout, retry_pending_blocks_timeout: Duration::from_secs(5), genesis: genesis_transactions, - signing_key: SEQUENCER_SIGNING_KEY, + signing_key: signing_key.unwrap_or(SEQUENCER_SIGNING_KEY), bedrock_config: BedrockConfig { channel_id, node_url: addr_to_url(UrlProtocol::Http, bedrock_addr) @@ -112,6 +145,7 @@ pub fn sequencer_config( }, cross_zone, metrics_address: Some(SequencerConfig::DEFAULT_METRICS_ADDRESS), + gossip, }) } @@ -198,13 +232,19 @@ pub fn genesis_from_accounts( .collect() } -pub fn wallet_config(sequencer_addr: SocketAddr) -> Result { - Ok(WalletConfig { - sequencers: vec![SequencerConnectionData { - sequencer_addr: addr_to_url(UrlProtocol::Http, sequencer_addr) +pub fn wallet_config(sequencer_addrs: &[SocketAddr]) -> Result { + let mut sequencers = vec![]; + + for addr in sequencer_addrs { + sequencers.push(SequencerConnectionData { + sequencer_addr: addr_to_url(UrlProtocol::Http, *addr) .context("Failed to convert sequencer addr to URL")?, basic_auth: None, - }], + }); + } + + Ok(WalletConfig { + sequencers, seq_poll_timeout: Duration::from_secs(30), seq_tx_poll_max_blocks: 15, seq_poll_max_retries: 10, @@ -268,6 +308,62 @@ pub fn bedrock_channel_id_b() -> ChannelId { ChannelId::from(channel_id) } +/// Generate sequencer signing key from `u32` number via repeating le bytes 8 times. +#[must_use] +pub fn sequencer_signing_key_from_seed(seed: u32) -> [u8; 32] { + seed.to_le_bytes() + .repeat(8) + .try_into() + .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 +/// `sequencer_signing_key_from_seed`. +#[must_use] +pub fn bedrock_channel_id_from_seed(seed: u32) -> ChannelId { + let channel_id: [u8; 32] = + // Useless in this case, but will make clippy happy + u32::MAX.saturating_sub(seed) + .to_le_bytes() + .repeat(8) + .try_into() + .unwrap_or_else(|_| unreachable!()); + ChannelId::from(channel_id) +} + /// Funding key of the Bedrock test node, matching `funding_pk` in `bedrock/node-config.yaml`. #[must_use] pub fn bedrock_funding_key() -> ZkPublicKey { diff --git a/test_fixtures/src/indexer_client.rs b/test_fixtures/src/indexer_client.rs index 5641d8243..ea7a9e9e4 100644 --- a/test_fixtures/src/indexer_client.rs +++ b/test_fixtures/src/indexer_client.rs @@ -9,14 +9,13 @@ use std::ops::Deref; use anyhow::{Context as _, Result}; use jsonrpsee::ws_client::{WsClient, WsClientBuilder}; -use log::info; use url::Url; pub struct IndexerClient(WsClient); impl IndexerClient { pub async fn new(indexer_url: &Url) -> Result { - info!("Connecting to Indexer at {indexer_url}"); + log::info!("Connecting to Indexer at {indexer_url}"); let client = WsClientBuilder::default() .build(indexer_url) .await diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index 9077feaf5..5cb942b78 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -1,17 +1,17 @@ //! Shared test/bench fixtures: spins up bedrock + sequencer + indexer + wallet //! end-to-end against docker-compose, exposes a `TestContext` callers can drive. -use std::{net::SocketAddr, path::Path, sync::LazyLock}; +use std::{collections::HashMap, net::SocketAddr, path::Path, sync::LazyLock}; use anyhow::{Context as _, Result}; use common::{HashType, transaction::LeeTransaction}; use futures::FutureExt as _; -use indexer_service::IndexerHandle; -use lee::{AccountId, PrivacyPreservingTransaction}; +use indexer_service::{ChannelId, IndexerHandle}; +use lee::{AccountId, PrivacyPreservingTransaction, PrivateKey}; use lee_core::Commitment; use log::{debug, error}; use sequencer_core::config::GenesisAction; -use sequencer_service::SequencerHandle; +use sequencer_service::{CrossZoneConfig, GossipConfig, SequencerHandle}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use serde::Serialize; use tempfile::TempDir; @@ -22,6 +22,7 @@ use wallet::{ }; use crate::{ + config::{InitialPrivateAccountForWallet, MultiNodeTestContextConfig, SequencerPartialConfig}, indexer_client::IndexerClient, setup::{ SequencerSetup, setup_bedrock_node, setup_indexer, @@ -70,56 +71,184 @@ pub struct DiskSizes { pub wallet_bytes: u64, } +pub struct SequencerComponents { + pub sequencer_handle: SequencerHandle, + pub temp_sequencer_dir: TempDir, + pub sequencer_client: SequencerClient, +} + +pub struct WalletComponents { + wallet: WalletCore, + wallet_password: String, + temp_wallet_dir: TempDir, +} + +pub struct TestContextZone { + wallet: Option, + /// Order of sequencers matter, as first one starts a channel, other ones connect in order. + sequencers: Vec, + indexer: Option, +} + /// Test context which sets up a sequencer and a wallet for integration tests. /// /// It's memory and logically safe to create multiple instances of this struct in parallel tests, /// as each instance uses its own temporary directories for sequencer and wallet data. -// NOTE: Order of fields is important for proper drop order. pub struct TestContext { - sequencer_client: SequencerClient, - wallet: WalletCore, - wallet_password: String, - /// Optional to move out value in Drop. - sequencer_handle: Option, - indexer_components: Option, + zones: HashMap, bedrock_compose: DockerCompose, bedrock_addr: SocketAddr, - temp_sequencer_dir: TempDir, - temp_wallet_dir: TempDir, } impl TestContext { - /// Create new test context. + /// Create new test context with singular config(1 zone, 1 sequencer). pub async fn new() -> Result { - Self::builder().build().await + MultiZoneTestContextBuilder::default() + .with_zone(ZoneTestContextBuilder::new( + MultiNodeTestContextConfig::default(), + )) + .build() + .await } - /// Get a builder for the test context to customize its configuration. + /// Reference for the default zone(in case if only one present). + /// + /// Panics in case if there is more than one zone. #[must_use] - pub fn builder() -> TestContextBuilder { - TestContextBuilder::new() + pub fn default_zone(&self) -> &TestContextZone { + assert!(self.zones.len() == 1); + + self.zones + .values() + .next() + .expect("Must be at least one zone") } - /// Get reference to the wallet. + /// Reference for the default sequencer component(in case, if only one zone exists and only + /// one sequencer exists). + /// + /// Panics in case if there is more than one zone. #[must_use] - pub const fn wallet(&self) -> &WalletCore { - &self.wallet + pub fn default_sequencer_component(&self) -> &SequencerComponents { + self.default_zone() + .sequencers + .first() + .expect("Must be at least one sequencer component") } + /// Iterator over all zones in random order. + pub fn zones_iter(&self) -> impl Iterator { + self.zones.iter() + } + + /// Iterator over all sequencer components in zone in order. + #[must_use] + pub fn sequencer_components_iter( + &self, + channel_id: ChannelId, + ) -> Option> { + self.zones + .get(&channel_id) + .map(|zone| zone.sequencers.iter()) + } + + /// Reference for the default sequencer component for a zone (in case, if only one sequencer + /// exists). + #[must_use] + pub fn zone_default_sequencer_component(&self, channel_id: ChannelId) -> &SequencerComponents { + self.sequencer_components_iter(channel_id) + .unwrap() + .next() + .unwrap() + } + + /// Mutable reference for the default zone(in case if only one present). + /// + /// Panics in case if there is more than one zone. + pub fn default_zone_mut(&mut self) -> &mut TestContextZone { + assert!(self.zones.len() == 1); + + self.zones + .values_mut() + .next() + .expect("Must be at least one zone") + } + + /// Mutable reference for the default sequencer component(in case, if only one zone exists and + /// only one sequencer exists). + /// + /// Panics in case if there is more than one zone. + pub fn default_sequencer_component_mut(&mut self) -> &mut SequencerComponents { + self.default_zone_mut() + .sequencers + .iter_mut() + .next() + .expect("Must be at least one integration component") + } + + /// Get reference to the default wallet. + /// + /// Panics in case if there is more than one zone. + #[must_use] + pub fn wallet(&self) -> &WalletCore { + &self.default_zone().wallet.as_ref().unwrap().wallet + } + + /// Get password of the default wallet password. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn wallet_password(&self) -> &str { - &self.wallet_password + &self.default_zone().wallet.as_ref().unwrap().wallet_password } - /// Get mutable reference to the wallet. - pub const fn wallet_mut(&mut self) -> &mut WalletCore { - &mut self.wallet + /// Get mutable reference to default the wallet. + /// + /// Panics in case if there is more than one zone. + pub fn wallet_mut(&mut self) -> &mut WalletCore { + &mut self.default_zone_mut().wallet.as_mut().unwrap().wallet } - /// Get reference to the sequencer client. + /// Get reference to the zone wallet. #[must_use] - pub const fn sequencer_client(&self) -> &SequencerClient { - &self.sequencer_client + pub fn wallet_zone(&self, channel_id: ChannelId) -> Option<&WalletCore> { + self.zones + .get(&channel_id) + .map(|val| &val.wallet.as_ref().unwrap().wallet) + } + + /// Get password of the zone wallet. + #[must_use] + pub fn wallet_password_zone(&self, channel_id: ChannelId) -> Option<&str> { + self.zones + .get(&channel_id) + .map(|val| val.wallet.as_ref().unwrap().wallet_password.as_str()) + } + + /// Get mutable reference to the zone wallet. + pub fn wallet_mut_zone(&mut self, channel_id: ChannelId) -> Option<&mut WalletCore> { + self.zones + .get_mut(&channel_id) + .map(|val| &mut val.wallet.as_mut().unwrap().wallet) + } + + /// Get reference to the sequencer client in default case (1 zone, 1 sequencer). + /// + /// Panics in case if there is more than one zone. + #[must_use] + pub fn sequencer_client(&self) -> &SequencerClient { + &self.default_sequencer_component().sequencer_client + } + + /// Get reference to the sequencer client by node zone `channel_id` and its `id`. + #[must_use] + pub fn sequencer_client_by_node_ids( + &self, + channel_id: ChannelId, + id: usize, + ) -> Option<&SequencerClient> { + let val = self.zones.get(&channel_id)?; + val.sequencers.get(id).map(|vall| &vall.sequencer_client) } /// Get the Bedrock Node address. @@ -128,62 +257,127 @@ impl TestContext { self.bedrock_addr } - /// Get reference to the indexer. + /// Get reference to the default indexer(1 zone). /// /// # Panics /// /// Panics if the indexer is not enabled in the test context. See - /// [`TestContextBuilder::disable_indexer()`]. + /// [`ZoneTestContextBuilder::disable_indexer()`]. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn indexer(&self) -> &IndexerHandle { - self.indexer_components + &self + .default_zone() + .indexer .as_ref() - .map(|components| &components.indexer_handle) .expect("Called `TestContext::indexer()` on context with disabled indexer") + .indexer_handle } - /// Get the indexer's bound socket address. + /// Get the default indexer's(1 zone) bound socket address. /// /// # Panics /// /// Panics if the indexer is not enabled in the test context. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn indexer_addr(&self) -> SocketAddr { self.indexer().addr() } - /// Get reference to the indexer client. + /// Get reference to the default indexer(1 zone) client. /// /// # Panics /// /// Panics if the indexer is not enabled in the test context. See - /// [`TestContextBuilder::disable_indexer()`]. + /// [`ZoneTestContextBuilder::disable_indexer()`]. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn indexer_client(&self) -> &IndexerClient { - self.indexer_components + &self + .default_zone() + .indexer .as_ref() - .map(|components| &components.indexer_client) - .expect("Called `TestContext::indexer_client()` on context with disabled indexer") + .expect("Called `TestContext::indexer()` on context with disabled indexer") + .indexer_client + } + + /// Get reference to the indexer for corresponding zone. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. See + /// [`ZoneTestContextBuilder::disable_indexer()`]. + #[must_use] + pub fn indexer_zone(&self, channel_id: ChannelId) -> Option<&IndexerHandle> { + let val = self.zones.get(&channel_id)?; + val.indexer.as_ref().map(|val| &val.indexer_handle) + } + + /// Get the default indexer's bound socket address for corresponding zone. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. + #[must_use] + pub fn indexer_addr_zone(&self, channel_id: ChannelId) -> Option { + self.indexer_zone(channel_id) + .map(indexer_service::IndexerHandle::addr) + } + + /// Get reference to the indexer client for corresponding zone. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. See + /// [`ZoneTestContextBuilder::disable_indexer()`]. + #[must_use] + pub fn indexer_client_zone(&self, channel_id: ChannelId) -> Option<&IndexerClient> { + let val = self.zones.get(&channel_id)?; + val.indexer.as_ref().map(|val| &val.indexer_client) } /// Recursively-sized bytes on disk for sequencer + indexer + wallet tempdirs. /// Indexer bytes are zero if the indexer is disabled. + /// Wallet bytes are zero if the wallet is disabled. #[must_use] pub fn disk_sizes(&self) -> DiskSizes { DiskSizes { - sequencer_bytes: dir_size_bytes(self.temp_sequencer_dir.path()), - indexer_bytes: self - .indexer_components - .as_ref() - .map_or(0, |c| dir_size_bytes(c.temp_dir.path())), - wallet_bytes: dir_size_bytes(self.temp_wallet_dir.path()), + sequencer_bytes: self.zones.values().fold(0, |acc, zone| { + acc.saturating_add(zone.sequencers.iter().fold(0, |accc, component| { + accc.saturating_add(dir_size_bytes(component.temp_sequencer_dir.path())) + })) + }), + indexer_bytes: self.zones.values().fold(0, |acc, zone| { + acc.saturating_add( + zone.indexer + .as_ref() + .map_or(0, |val| dir_size_bytes(val.temp_dir.path())), + ) + }), + wallet_bytes: self.zones.values().fold(0, |acc, zone| { + acc.saturating_add( + zone.wallet + .as_ref() + .map_or(0, |val| dir_size_bytes(val.temp_wallet_dir.path())), + ) + }), } } - /// Get existing public account IDs in the wallet. + /// Get default(1 zone) existing public account IDs in the wallet. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn existing_public_accounts(&self) -> Vec { - self.wallet + self.default_zone() + .wallet + .as_ref() + .unwrap() + .wallet .storage() .key_chain() .public_account_ids() @@ -191,43 +385,84 @@ impl TestContext { .collect() } - /// Get existing private account IDs in the wallet. + /// Get default (1 zone) existing private account IDs in the wallet. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn existing_private_accounts(&self) -> Vec { - self.wallet + self.default_zone() + .wallet + .as_ref() + .unwrap() + .wallet .storage() .key_chain() .private_account_ids() .map(|(account_id, _idx)| account_id) .collect() } + + /// Get existing public account IDs in the wallet. + #[must_use] + pub fn existing_public_accounts_zone(&self, channel_id: ChannelId) -> Option> { + self.wallet_zone(channel_id).map(|wallet_ref| { + wallet_ref + .storage() + .key_chain() + .public_account_ids() + .map(|(account_id, _idx)| account_id) + .collect() + }) + } + + /// Get existing private account IDs in the wallet. + #[must_use] + pub fn existing_private_accounts_zone(&self, channel_id: ChannelId) -> Option> { + self.wallet_zone(channel_id).map(|wallet_ref| { + wallet_ref + .storage() + .key_chain() + .private_account_ids() + .map(|(account_id, _idx)| account_id) + .collect() + }) + } } impl Drop for TestContext { fn drop(&mut self) { let Self { - sequencer_handle, + zones, bedrock_compose, bedrock_addr: _, - indexer_components: _, - sequencer_client: _, - wallet: _, - wallet_password: _, - temp_sequencer_dir: _, - temp_wallet_dir: _, } = self; - let mut sequencer_handle = sequencer_handle - .take() - .expect("Sequencer handle should be present in TestContext drop"); - if !sequencer_handle.is_healthy() { - let Err(err) = sequencer_handle - .failed() - .now_or_never() - .expect("Sequencer handle should not be running"); - error!( - "Sequencer handle has unexpectedly stopped before TestContext drop with error: {err:#}" - ); + #[expect( + clippy::iter_over_hash_type, + reason = "Zones can be stopped in any order" + )] + for TestContextZone { + wallet: _, + sequencers, + indexer: _, + } in zones.values_mut() + { + for SequencerComponents { + sequencer_handle, + temp_sequencer_dir: _, + sequencer_client: _, + } in sequencers.iter_mut() + { + if !sequencer_handle.is_healthy() { + let Err(err) = sequencer_handle + .failed() + .now_or_never() + .expect("Sequencer handle should not be running"); + error!( + "Sequencer handle has unexpectedly stopped before TestContext drop with error: {err:#}" + ); + } + } } let container = bedrock_compose @@ -250,25 +485,48 @@ impl Drop for TestContext { } } -pub struct TestContextBuilder { +#[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, + cross_zone_config: Option, } -impl TestContextBuilder { - fn new() -> Self { +impl ZoneTestContextBuilder { + #[must_use] + pub fn new(mn_config: MultiNodeTestContextConfig) -> Self { 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, + // There is no point providing cross zone config here, it is easier to provide it from + // builder pattern. + cross_zone_config: None, } } + #[must_use] + pub const fn bedrock_channel(&self) -> ChannelId { + self.mn_config.bedrock_channel + } + /// Override wallet config fields (e.g. polling timeouts) for the wallet built by this context. #[must_use] pub fn with_wallet_config_overrides( @@ -279,12 +537,16 @@ impl TestContextBuilder { self } + /// Set the genesis transactions to apply when initializing the sequencer. + /// If not set, the sequencer will be initialized from a prebuilt database dump. #[must_use] pub fn with_genesis(mut self, genesis_transactions: Vec) -> Self { self.genesis_transactions = Some(genesis_transactions); self } + /// Set the sequencer partial config to apply when initializing the sequencer. + /// If not set, the sequencer will be initialized with default one. #[must_use] pub const fn with_sequencer_partial_config( mut self, @@ -294,6 +556,25 @@ impl TestContextBuilder { 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] @@ -313,33 +594,71 @@ impl TestContextBuilder { self } - pub async fn build(self) -> Result { + /// Exclude wallet from test context. + /// Wallet is enabled by default. + /// + /// Methods like [`TestContext::wallet()`] will panic if + /// called when wallet is disabled. + #[must_use] + pub const fn disable_wallet(mut self) -> Self { + self.enable_wallet = false; + self + } + + /// Set the cross zone config to apply when initializing the zone. + /// If not set, the zone will be initialized with default one. + #[must_use] + pub fn with_cross_zone(mut self, cross_zone_config: Option) -> Self { + self.cross_zone_config = cross_zone_config; + self + } + + pub async fn build(self, bedrock_addr: SocketAddr) -> Result { let Self { genesis_transactions, sequencer_partial_config, + follower_sequencer_partial_config, enable_indexer, + enable_wallet, + enable_gossip, wallet_config_overrides, from_scratch, + mn_config, + cross_zone_config, } = self; - // Ensure logger is initialized only once - *LOGGER; - 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(); - let (bedrock_compose, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to setup Bedrock node")?; - let indexer_components = if enable_indexer { - let (indexer_handle, temp_indexer_dir) = - setup_indexer(bedrock_addr, config::bedrock_channel_id(), None) - .await - .context("Failed to setup Indexer")?; + let (indexer_handle, temp_indexer_dir) = setup_indexer( + bedrock_addr, + mn_config.bedrock_channel, + cross_zone_config.clone(), + ) + .await + .context("Failed to setup Indexer")?; let indexer_client = setup::indexer_client(indexer_handle.addr()) .await .context("Failed to create indexer client")?; @@ -357,67 +676,186 @@ impl TestContextBuilder { let partial_config = sequencer_partial_config.unwrap_or_default(); - let mut sequencer_setup = SequencerSetup::new(partial_config, bedrock_addr); - if !use_prebuilt { - // Wallet genesis must always be present so that - // setup_public/private_accounts_with_initial_supply can claim from the vault PDAs. - // When a test supplies custom genesis, merge rather than replace. - let wallet_genesis = - config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts); - let genesis = match genesis_transactions { - Some(mut custom) => { - custom.extend(wallet_genesis); - custom - } - None => wallet_genesis, - }; - sequencer_setup = sequencer_setup.with_genesis(genesis); - } - let (sequencer_handle, temp_sequencer_dir) = sequencer_setup - .setup() - .await - .context("Failed to setup Sequencer")?; + let mut sequencer_addrs = vec![]; + let mut sequencer_components = vec![]; - let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet( - sequencer_handle.addr(), + // 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, + enable_wallet, + use_prebuilt, &initial_public_accounts, &initial_private_accounts, - wallet_config_overrides, + genesis_transactions.clone(), + config::SEQUENCER_SIGNING_KEY, + mn_config.bedrock_channel, + cross_zone_config.clone(), + leader_gossip, ) - .await - .context("Failed to setup wallet")?; + .await?; - if use_prebuilt { - // Funds already exist on-chain in the prebuilt blocks; sync instead of claiming live. - sync_wallet_from_prebuilt(&mut wallet) - .await - .context("Failed to sync wallet from prebuilt database")?; - } else { - setup_public_accounts_with_initial_supply(&mut wallet, &initial_public_accounts) - .await - .context("Failed to initialize public accounts in wallet")?; + // 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, + }); - setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts) - .await - .context("Failed to initialize private accounts in wallet")?; + // Wait for genesis to be published + wait_until_genesis(&leader_components.sequencer_client) + .await + .context("Encountered an error while waiting for genesis to be published")?; + + log::info!("Passed wait untill genesis"); + + sequencer_addrs.push(leader_addr); + sequencer_components.push(leader_components); + + // 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( + follower_sequencer_partial_config.unwrap_or(partial_config), + bedrock_addr, + enable_wallet, + use_prebuilt, + &initial_public_accounts, + &initial_private_accounts, + genesis_transactions.clone(), + sequencer_key, + mn_config.bedrock_channel, + cross_zone_config.clone(), + follower_gossip.clone(), + ) + .await?; + + sequencer_addrs.push(sequencer_addr); + sequencer_components.push(sequencer_component); } - let sequencer_client = setup::sequencer_client(sequencer_handle.addr()) - .context("Failed to create sequencer client")?; + let wallet_components = if enable_wallet { + let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet( + &sequencer_addrs, + &initial_public_accounts, + &initial_private_accounts, + wallet_config_overrides, + ) + .await + .context("Failed to setup wallet")?; + + if use_prebuilt { + // Funds already exist on-chain in the prebuilt blocks; sync instead of + // claiming live. + sync_wallet_from_prebuilt(&mut wallet) + .await + .context("Failed to sync wallet from prebuilt database")?; + } else { + setup_public_accounts_with_initial_supply(&mut wallet, &initial_public_accounts) + .await + .context("Failed to initialize public accounts in wallet")?; + + setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts) + .await + .context("Failed to initialize private accounts in wallet")?; + } + + Some(WalletComponents { + wallet, + wallet_password, + temp_wallet_dir, + }) + } else { + None + }; + + Ok(TestContextZone { + wallet: wallet_components, + sequencers: sequencer_components, + indexer: indexer_components, + }) + } + + pub fn build_blocking(self, bedrock_addr: SocketAddr) -> Result { + let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; + + let ctx = runtime.block_on(self.build(bedrock_addr))?; + + Ok(BlockingTestContextZone { + ctx: Some(ctx), + runtime, + }) + } +} + +#[derive(Default)] +pub struct MultiZoneTestContextBuilder { + zone_builders: HashMap, +} + +impl MultiZoneTestContextBuilder { + pub async fn build(self) -> Result { + // Ensure logger is initialized only once + *LOGGER; + + let (bedrock_compose, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock node")?; + + let mut zones = HashMap::new(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Zones can be started in any order" + )] + for (channel_id, zone_builder) in self.zone_builders { + let zone_ctx = zone_builder.build(bedrock_addr).await?; + + log::info!("Built context for {channel_id}"); + + zones.insert(channel_id, zone_ctx); + } Ok(TestContext { - sequencer_client, - wallet, - wallet_password, + zones, bedrock_compose, bedrock_addr, - sequencer_handle: Some(sequencer_handle), - indexer_components, - temp_sequencer_dir, - temp_wallet_dir, }) } + #[must_use] + pub fn with_zone(mut self, zone_builder: ZoneTestContextBuilder) -> Self { + assert!( + !self + .zone_builders + .contains_key(&zone_builder.bedrock_channel()) + ); + + self.zone_builders + .insert(zone_builder.bedrock_channel(), zone_builder); + + self + } + + #[must_use] + pub fn default_channel_id(&self) -> ChannelId { + *self + .zone_builders + .keys() + .next() + .expect("Must be at least one channel") + } + pub fn build_blocking(self) -> Result { let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; @@ -430,6 +868,63 @@ impl TestContextBuilder { } } +/// A test context to be used in normal #[test] tests. +pub struct BlockingTestContextZone { + ctx: Option, + runtime: tokio::runtime::Runtime, +} + +impl BlockingTestContextZone { + pub fn new(config: MultiNodeTestContextConfig, bedrock_addr: SocketAddr) -> Result { + ZoneTestContextBuilder::new(config).build_blocking(bedrock_addr) + } + + pub const fn ctx(&self) -> &TestContextZone { + self.ctx.as_ref().expect("TestContext is set") + } + + pub const fn ctx_mut(&mut self) -> &mut TestContextZone { + self.ctx.as_mut().expect("TestContext is set") + } + + pub const fn runtime(&self) -> &tokio::runtime::Runtime { + &self.runtime + } + + pub fn block_on<'ctx, F>(&'ctx self, f: impl FnOnce(&'ctx TestContextZone) -> F) -> F::Output + where + F: std::future::Future + 'ctx, + { + let future = f(self.ctx()); + self.runtime.block_on(future) + } + + pub fn block_on_mut<'ctx, F>( + &'ctx mut self, + f: impl FnOnce(&'ctx mut TestContextZone) -> F, + ) -> F::Output + where + F: std::future::Future + 'ctx, + { + let ctx_mut = self.ctx.as_mut().expect("TestContext is set"); + let future = f(ctx_mut); + self.runtime.block_on(future) + } +} + +impl Drop for BlockingTestContextZone { + fn drop(&mut self) { + let Self { ctx, runtime } = self; + + // Ensure async cleanup of TestContext by blocking on its drop in the runtime. + runtime.block_on(async { + if let Some(ctx) = ctx.take() { + drop(ctx); + } + }); + } +} + /// A test context to be used in normal #[test] tests. pub struct BlockingTestContext { ctx: Option, @@ -437,8 +932,16 @@ pub struct BlockingTestContext { } impl BlockingTestContext { - pub fn new() -> Result { - TestContext::builder().build_blocking() + /// For now, only one zone and one sequencer is supported for blocking operations. + pub fn new_default() -> Result { + let mut zone_builders = HashMap::new(); + + zone_builders.insert( + config::bedrock_channel_id(), + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()), + ); + + MultiZoneTestContextBuilder { zone_builders }.build_blocking() } pub const fn ctx(&self) -> &TestContext { @@ -527,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 { @@ -547,3 +1056,86 @@ fn dir_size_bytes(path: &Path) -> u64 { } total } + +async fn wait_until_genesis(client: &SequencerClient) -> Result<()> { + log::info!("Waiting for leader to send genesis"); + + let wait = async { + loop { + if client.get_last_block_id().await? >= 1 { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(360), wait) + .await + .with_context(|| "Timed out waiting for genesis")? +} + +#[expect(clippy::too_many_arguments, reason = "No need to repackage fields")] +async fn build_sequencer_components( + partial_config: SequencerPartialConfig, + bedrock_addr: SocketAddr, + enable_wallet: bool, + use_prebuilt: bool, + initial_public_accounts: &[(PrivateKey, u128)], + initial_private_accounts: &[InitialPrivateAccountForWallet], + genesis_transactions: Option>, + 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); + + let genesis_actions = if enable_wallet { + // Wallet genesis must always be present so that + // setup_public/private_accounts_with_initial_supply can claim from the vault + // PDAs. When a test supplies custom genesis, merge rather + // than replace. + let wallet_genesis = + config::genesis_from_accounts(initial_public_accounts, initial_private_accounts); + match genesis_transactions { + Some(mut custom) => { + custom.extend(wallet_genesis); + custom + } + None => wallet_genesis, + } + } else { + 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) + .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() + .await + .context("Failed to setup Sequencer")?; + + let sequencer_client = setup::sequencer_client(sequencer_handle.addr()) + .context("Failed to create sequencer client")?; + + Ok(( + sequencer_handle.addr(), + SequencerComponents { + sequencer_handle, + temp_sequencer_dir, + sequencer_client, + }, + )) +} diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs index 5d185a2d7..6844d4b6a 100644 --- a/test_fixtures/src/setup.rs +++ b/test_fixtures/src/setup.rs @@ -28,6 +28,7 @@ use crate::{ private_mention, public_mention, }; +#[derive(Debug)] pub struct SequencerSetup { partial: config::SequencerPartialConfig, bedrock_addr: SocketAddr, @@ -35,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 { @@ -47,6 +49,7 @@ impl SequencerSetup { genesis_transactions: None, cross_zone: None, bedrock_signing_key: None, + gossip: None, } } @@ -74,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). @@ -83,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)> { @@ -105,21 +124,34 @@ 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 } else { let dump = load_prebuilt_dump()?; - // `SequencerCore::open_or_create_store` looks for `/rocksdb`. - let dst = home.join("rocksdb"); + // `SequencerCore::open_or_create_store` looks for the channel-suffixed + // db under its home, so the restore has to land on the same name. + let dst = home.join(format!("rocksdb-{channel_id}")); let _store = SequencerStore::restore_db_from_dump( &dst, &dump, @@ -139,6 +171,8 @@ impl SequencerSetup { config::bedrock_funding_key(), genesis_transactions, cross_zone, + bedrock_signing_key, + gossip, ) .context("Failed to create Sequencer config")?; @@ -278,12 +312,13 @@ pub async fn setup_indexer( } pub async fn setup_wallet( - sequencer_addr: SocketAddr, + sequencer_addrs: &[SocketAddr], initial_public_accounts: &[(PrivateKey, u128)], initial_private_accounts: &[InitialPrivateAccountForWallet], config_overrides: WalletConfigOverrides, ) -> Result<(WalletCore, TempDir, String)> { - let config = config::wallet_config(sequencer_addr).context("Failed to create Wallet config")?; + let config = + config::wallet_config(sequencer_addrs).context("Failed to create Wallet config")?; let config_serialized = serde_json::to_string_pretty(&config).context("Failed to serialize Wallet config")?; diff --git a/test_fixtures/tests/prebuilt_fixture.rs b/test_fixtures/tests/prebuilt_fixture.rs index 4960af812..2487e43fe 100644 --- a/test_fixtures/tests/prebuilt_fixture.rs +++ b/test_fixtures/tests/prebuilt_fixture.rs @@ -6,15 +6,24 @@ use anyhow::{Context as _, Result}; use lee::{AccountId, PublicKey}; use sequencer_service_rpc::RpcClient as _; use test_fixtures::{ - TestContext, - config::{default_private_accounts_for_wallet, default_public_accounts_for_wallet}, + MultiZoneTestContextBuilder, TestContext, ZoneTestContextBuilder, + config::{ + MultiNodeTestContextConfig, default_private_accounts_for_wallet, + default_public_accounts_for_wallet, + }, verify_commitment_is_in_state, }; /// Builds from genesis (no prebuilt database) and checks the on-chain state follows the config. #[tokio::test] async fn genesis_from_scratch_follows_config() -> Result<()> { - let ctx = TestContext::builder().from_scratch().build().await?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()).from_scratch(), + ) + .build() + .await?; + assert_context_follows_config(&ctx).await } diff --git a/test_programs/guest/src/bin/pda_spend_proxy.rs b/test_programs/guest/src/bin/pda_spend_proxy.rs index 5b00004c3..0b4c89145 100644 --- a/test_programs/guest/src/bin/pda_spend_proxy.rs +++ b/test_programs/guest/src/bin/pda_spend_proxy.rs @@ -5,7 +5,7 @@ use risc0_zkvm::serde::to_vec; /// Proxy for spending from a private PDA via `auth_transfer`. /// -/// `pre_states = [pda (authorized), recipient]`. Debits the PDA and credits the recipient. +/// `pre_states = [pda, recipient]`. Debits the PDA and credits the recipient. /// The PDA-to-npk binding is established via `pda_seeds` in the chained call to `auth_transfer`. type Instruction = (PdaSeed, u128, ProgramId); @@ -24,16 +24,17 @@ fn main() { return; }; - assert!(first.is_authorized, "first pre_state must be authorized"); - let first_post = AccountPostState::new(first.account.clone()); let second_post = AccountPostState::new(second.account.clone()); + let mut first_for_callee = first.clone(); + first_for_callee.is_authorized = true; + let chained_call = ChainedCall { program_id: auth_transfer_id, instruction_data: to_vec(&authenticated_transfer_core::Instruction::Transfer { amount }) .unwrap(), - pre_states: vec![first.clone(), second.clone()], + pre_states: vec![first_for_callee, second.clone()], pda_seeds: vec![seed], }; diff --git a/test_programs/guest/src/bin/simple_balance_transfer.rs b/test_programs/guest/src/bin/simple_balance_transfer.rs new file mode 100644 index 000000000..addc4a191 --- /dev/null +++ b/test_programs/guest/src/bin/simple_balance_transfer.rs @@ -0,0 +1,57 @@ +use lee_core::program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}; + +type Instruction = u128; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: balance, + }, + instruction_words, + ) = read_lee_inputs::(); + + if let Ok([account_pre]) = <[_; 1]>::try_from(pre_states.clone()) { + let account_post = + AccountPostState::new_claimed_if_default(account_pre.account, Claim::Authorized); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![account_post], + ) + .write(); + return; + } + + let Ok([sender_pre, receiver_pre]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + let mut sender_post = sender_pre.account.clone(); + let mut receiver_post = receiver_pre.account.clone(); + sender_post.balance = sender_post + .balance + .checked_sub(balance) + .expect("Not enough balance to transfer"); + receiver_post.balance = receiver_post + .balance + .checked_add(balance) + .expect("Overflow when adding balance"); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![sender_pre, receiver_pre], + vec![ + AccountPostState::new_claimed_if_default(sender_post, Claim::Authorized), + AccountPostState::new_claimed_if_default(receiver_post, Claim::Authorized), + ], + ) + .write(); +} diff --git a/test_programs/src/lib.rs b/test_programs/src/lib.rs index 07147fc5e..31d198732 100644 --- a/test_programs/src/lib.rs +++ b/test_programs/src/lib.rs @@ -11,6 +11,21 @@ mod guests { include!(concat!(env!("OUT_DIR"), "/methods.rs")); } +#[must_use] +#[inline] +pub const fn simple_balance_transfer() -> Program { + use guests::{ + SIMPLE_BALANCE_TRANSFER_ELF, SIMPLE_BALANCE_TRANSFER_ID, SIMPLE_BALANCE_TRANSFER_PATH, + }; + + let _unused = SIMPLE_BALANCE_TRANSFER_PATH; + + Program::new_unchecked( + SIMPLE_BALANCE_TRANSFER_ID, + Cow::Borrowed(SIMPLE_BALANCE_TRANSFER_ELF), + ) +} + #[must_use] #[inline] pub const fn chain_caller() -> Program { diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index 23e2d03cf..abafdbd5f 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -184,7 +184,7 @@ impl AppState { created: Instant::now(), delivered_secs: None, }); - info!("[stage] msg {id} submitted {source_label}->{dest_label}"); + log::info!("[stage] msg {id} submitted {source_label}->{dest_label}"); id } @@ -195,7 +195,7 @@ impl AppState { m.source_label == source_label && m.ordinal == ordinal && m.source_block.is_none() }) { message.source_block = Some(block_id); - info!( + log::info!( "[stage] msg {} in source block {source_label}#{block_id} (+{}s)", message.id, message.created.elapsed().as_secs() @@ -219,7 +219,7 @@ impl AppState { message.delivered_block = Some(block_id); let secs = message.created.elapsed().as_secs(); message.delivered_secs = Some(secs); - info!( + log::info!( "[stage] msg {} delivered {dest_label}#{block_id} (+{secs}s total)", message.id ); @@ -250,7 +250,7 @@ impl AppState { && !message.finalized { message.finalized = true; - info!( + log::info!( "[stage] msg {} source block {source_label}#{block_id} finalized on Bedrock (+{}s)", message.id, message.created.elapsed().as_secs() diff --git a/tools/crypto_primitives_bench/README.md b/tools/crypto_primitives_bench/README.md index eb2da1491..8834d1769 100644 --- a/tools/crypto_primitives_bench/README.md +++ b/tools/crypto_primitives_bench/README.md @@ -12,7 +12,7 @@ cargo bench -p crypto_primitives_bench --bench primitives Criterion's per-operation report (point estimate, 95% CI, outlier counts) for: -- `keychain/new_os_random`: full mnemonic โ†’ SSK โ†’ NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates). +- `keychain/new_os_random`: full mnemonic โ†’ SSK โ†’ ASK โ†’ NSK, plus SSK โ†’ VSK, and public-key derivation (HMAC-SHA512 PBKDF dominates). - `keychain/new_mnemonic`: same pipeline, mnemonic exposed. - `shared_secret_key/sender_dh`: secp256k1 ECDH per recipient (includes ephemeral key gen). - `encryption/encrypt` / `decrypt`: ChaCha20 over an Account note. diff --git a/tools/cycle_bench/src/main.rs b/tools/cycle_bench/src/main.rs index 5be5f367b..97e0efdc7 100644 --- a/tools/cycle_bench/src/main.rs +++ b/tools/cycle_bench/src/main.rs @@ -314,7 +314,7 @@ fn token_holding( ) -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0, data: Data::from(&TokenHolding::Fungible { definition_id, @@ -334,7 +334,7 @@ fn token_definition( ) -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: programs::token().id(), + program_owner: programs::token().id().into(), balance: 0, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -372,7 +372,7 @@ fn token_burn_pre_states() -> Vec { fn clock_account(account_id: AccountId, block_id: u64) -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: programs::clock().id(), + program_owner: programs::clock().id().into(), balance: 0, data: ClockAccountData { block_id, @@ -426,7 +426,7 @@ fn amm_pool_account() -> AccountWithMetadata { let lp_supply = (reserve_a * reserve_b).isqrt(); AccountWithMetadata { account: Account { - program_owner: programs::amm().id(), + program_owner: programs::amm().id().into(), balance: 0, data: Data::from(&PoolDefinition { definition_token_a_id: amm_token_a_def_id(), diff --git a/tools/cycle_bench/src/ppe/ppe_impl.rs b/tools/cycle_bench/src/ppe/ppe_impl.rs index e85c95f23..8023e21bd 100644 --- a/tools/cycle_bench/src/ppe/ppe_impl.rs +++ b/tools/cycle_bench/src/ppe/ppe_impl.rs @@ -51,7 +51,7 @@ pub fn prove_auth_transfer_in_ppe() -> anyhow::Result<(PrivacyPreservingCircuitO // Recipient stays default-owned so the first call can claim it. let sender = AccountWithMetadata { account: Account { - program_owner: auth_transfer_id, + program_owner: auth_transfer_id.into(), balance: 1_000_000, ..Account::default() }, @@ -117,7 +117,7 @@ fn prove_chain_caller( // would cause a state mismatch on subsequent chained calls. let recipient_pre = AccountWithMetadata { account: Account { - program_owner: auth_transfer_id, + program_owner: auth_transfer_id.into(), ..Account::default() }, is_authorized: true, @@ -125,7 +125,7 @@ fn prove_chain_caller( }; let sender_pre = AccountWithMetadata { account: Account { - program_owner: auth_transfer_id, + program_owner: auth_transfer_id.into(), balance: 1_000_000, ..Account::default() }, diff --git a/tools/dashboard_gen/Cargo.toml b/tools/dashboard_gen/Cargo.toml index 4bc73e869..1823d14cb 100644 --- a/tools/dashboard_gen/Cargo.toml +++ b/tools/dashboard_gen/Cargo.toml @@ -9,7 +9,7 @@ workspace = true [dependencies] sequencer_core_metrics.workspace = true -sequencer_service_metrics.workspace = true +sequencer_rpc_server_actor_metrics.workspace = true clap = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive", "alloc"] } diff --git a/tools/dashboard_gen/src/dashboards/sequencer.rs b/tools/dashboard_gen/src/dashboards/sequencer.rs index b75f120d3..aed7f8dc1 100644 --- a/tools/dashboard_gen/src/dashboards/sequencer.rs +++ b/tools/dashboard_gen/src/dashboards/sequencer.rs @@ -156,9 +156,9 @@ pub fn dashboard() -> Dashboard { // `clamp_min` keeps an idle window (nothing submitted) // reading as 0% instead of a division by zero. "100 * (increase({before_mempool}[$__range]) + increase({in_mempool}[$__range])) / clamp_min(increase({submitted}[$__range]), 1)", - before_mempool = sequencer_service_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, + before_mempool = sequencer_rpc_server_actor_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, in_mempool = sequencer_core_metrics::names::MEMPOOL_FAILED_TRANSACTIONS_TOTAL, - submitted = sequencer_service_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, + submitted = sequencer_rpc_server_actor_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, )) .legend("failed"), ), @@ -169,11 +169,11 @@ pub fn dashboard() -> Dashboard { .fill_opacity(35) .gradient_mode(GradientMode::Opacity) .target(rate_per_min( - sequencer_service_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, + sequencer_rpc_server_actor_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, "submitted", )) .target(rate_per_min( - sequencer_service_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, + sequencer_rpc_server_actor_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, "failed ยท before mempool", )) .target(rate_per_min(