From 1534ac6fe23f585dcc6da1e73b2478a7a9e9d066 Mon Sep 17 00:00:00 2001 From: Lucas B Date: Thu, 25 Aug 2022 17:18:46 -0500 Subject: [PATCH] Jito Patch Use cluster info functions for tpu (#345) (#347) Use git rev-parse for git sha Remove blacklisted tx from message_hash_to_transaction (Backport #374) (#376) Updates scripts for easy local setup. (#383) Backports sim_bundle improvements (#407) backports clone derivation 416 (#417) Backport #419: add upsert to AccountOverrides (#420) backports #430 v1.16: update jito-programs sha (#431) [JIT-1661] Faster Autosnapshot (#406) Fix Buildkite warnings (#437) Backport #446 to v1.16 (#447) backport 428, runtime plugin (#429) v1.16: Backport #449 (#450) --- .dockerignore | 9 + .github/workflows/client-targets.yml | 4 + .github/workflows/crate-check.yml | 1 + .github/workflows/docs.yml | 3 + .github/workflows/downstream-project-spl.yml | 6 + .../increment-cargo-version-on-release.yml | 2 + .github/workflows/release-artifacts.yml | 1 + .gitignore | 6 +- .gitmodules | 9 + Cargo.lock | 468 ++++- Cargo.toml | 12 + README.md | 141 +- SECURITY.md | 167 -- anchor | 1 + banking-bench/src/main.rs | 14 +- banks-server/Cargo.toml | 5 + banks-server/src/banks_server.rs | 5 +- bootstrap | 28 + bundle/Cargo.toml | 34 + bundle/src/bundle_execution.rs | 1186 ++++++++++++ bundle/src/lib.rs | 60 + ci/buildkite-pipeline-in-disk.sh | 4 +- ci/buildkite-pipeline.sh | 4 +- ci/channel-info.sh | 2 +- ci/check-crates.sh | 3 + core/Cargo.toml | 12 + core/benches/banking_stage.rs | 26 +- core/benches/cluster_info.rs | 1 + core/benches/consumer.rs | 24 +- core/benches/proto_to_packet.rs | 56 + core/benches/retransmit_stage.rs | 1 + core/src/accounts_hash_verifier.rs | 7 +- core/src/admin_rpc_post_init.rs | 7 +- core/src/banking_stage.rs | 66 +- core/src/banking_stage/committer.rs | 17 +- core/src/banking_stage/consume_worker.rs | 11 +- core/src/banking_stage/consumer.rs | 158 +- core/src/banking_trace.rs | 1 + core/src/broadcast_stage.rs | 49 +- .../broadcast_duplicates_run.rs | 1 + .../broadcast_fake_shreds_run.rs | 1 + core/src/broadcast_stage/broadcast_utils.rs | 60 +- .../fail_entry_verification_broadcast_run.rs | 4 +- .../broadcast_stage/standard_broadcast_run.rs | 30 +- core/src/bundle_stage.rs | 435 +++++ .../src/bundle_stage/bundle_account_locker.rs | 328 ++++ core/src/bundle_stage/bundle_consumer.rs | 1589 +++++++++++++++++ .../bundle_packet_deserializer.rs | 286 +++ .../bundle_stage/bundle_packet_receiver.rs | 848 +++++++++ .../bundle_reserved_space_manager.rs | 189 ++ .../bundle_stage_leader_metrics.rs | 502 ++++++ core/src/bundle_stage/committer.rs | 221 +++ core/src/bundle_stage/result.rs | 41 + core/src/consensus_cache_updater.rs | 52 + core/src/immutable_deserialized_bundle.rs | 483 +++++ core/src/latest_unprocessed_votes.rs | 2 +- core/src/lib.rs | 44 + core/src/packet_bundle.rs | 7 + core/src/proxy/auth.rs | 185 ++ core/src/proxy/block_engine_stage.rs | 533 ++++++ core/src/proxy/fetch_stage_manager.rs | 170 ++ core/src/proxy/mod.rs | 100 ++ core/src/proxy/relayer_stage.rs | 495 +++++ core/src/qos_service.rs | 45 +- core/src/replay_stage.rs | 4 +- core/src/retransmit_stage.rs | 16 +- core/src/snapshot_packager_service.rs | 19 +- core/src/tip_manager.rs | 583 ++++++ core/src/tpu.rs | 113 +- core/src/tpu_entry_notifier.rs | 60 +- core/src/tvu.rs | 5 +- core/src/unprocessed_transaction_storage.rs | 782 ++++---- core/src/validator.rs | 53 +- core/tests/epoch_accounts_hash.rs | 2 + core/tests/snapshots.rs | 5 + deploy_programs | 17 + dev/Dockerfile | 48 + entry/src/entry.rs | 2 +- entry/src/poh.rs | 29 +- f | 30 + fetch-spl.sh | 41 +- frozen-abi/macro/src/lib.rs | 2 +- .../src/geyser_plugin_manager.rs | 36 +- gossip/src/cluster_info.rs | 4 + jito-programs | 1 + jito-protos/Cargo.toml | 19 + jito-protos/build.rs | 38 + jito-protos/protos | 1 + jito-protos/src/lib.rs | 25 + ledger-tool/src/ledger_utils.rs | 16 +- ledger-tool/src/main.rs | 31 +- ledger-tool/src/program.rs | 1 + ledger/src/bank_forks_utils.rs | 21 +- ledger/src/blockstore_processor.rs | 5 +- ledger/src/token_balances.rs | 58 +- local-cluster/src/local_cluster.rs | 3 + .../src/local_cluster_snapshot_utils.rs | 6 +- local-cluster/src/validator_configs.rs | 5 + local-cluster/tests/local_cluster.rs | 12 +- merkle-tree/src/merkle_tree.rs | 46 +- multinode-demo/bootstrap-validator.sh | 34 + multinode-demo/validator.sh | 40 + perf/src/sigverify.rs | 2 +- poh/src/poh_recorder.rs | 138 +- poh/src/poh_service.rs | 34 +- program-runtime/src/timings.rs | 23 +- program-test/src/programs.rs | 17 + .../programs/jito_tip_distribution-0.1.3.so | Bin 0 -> 439968 bytes .../src/programs/jito_tip_payment-0.1.3.so | Bin 0 -> 433224 bytes programs/sbf/Cargo.lock | 321 +++- programs/sbf/tests/programs.rs | 4 +- rpc-client-api/Cargo.toml | 2 + rpc-client-api/src/bundles.rs | 166 ++ rpc-client-api/src/config.rs | 2 +- rpc-client-api/src/lib.rs | 1 + rpc-client-api/src/request.rs | 3 + rpc-client-api/src/response.rs | 16 + rpc-client/src/http_sender.rs | 209 ++- rpc-client/src/mock_sender.rs | 7 + rpc-client/src/nonblocking/rpc_client.rs | 131 +- rpc-client/src/rpc_client.rs | 30 + rpc-client/src/rpc_sender.rs | 4 + rpc-test/Cargo.toml | 1 + rpc-test/tests/rpc.rs | 2 + rpc/Cargo.toml | 2 + rpc/src/rpc.rs | 488 ++++- rpc/src/rpc_service.rs | 9 +- runtime-plugin/Cargo.toml | 23 + runtime-plugin/src/lib.rs | 4 + runtime-plugin/src/runtime_plugin.rs | 41 + .../src/runtime_plugin_admin_rpc_service.rs | 326 ++++ runtime-plugin/src/runtime_plugin_manager.rs | 275 +++ runtime-plugin/src/runtime_plugin_service.rs | 123 ++ runtime/src/account_overrides.rs | 6 +- runtime/src/accounts.rs | 98 +- runtime/src/bank.rs | 110 +- runtime/src/cost_tracker.rs | 8 + runtime/src/snapshot_package.rs | 8 +- runtime/src/snapshot_utils.rs | 30 +- runtime/src/stake_account.rs | 4 +- runtime/src/stakes.rs | 12 +- runtime/src/transaction_batch.rs | 24 +- rustfmt.toml | 5 + s | 15 + scripts/increment-cargo-version.sh | 2 + scripts/run.sh | 4 + sdk/Cargo.toml | 1 + sdk/src/bundle/mod.rs | 33 + sdk/src/lib.rs | 1 + send-transaction-service/Cargo.toml | 2 + .../src/send_transaction_service.rs | 47 +- start | 9 + start_multi | 30 + test-validator/src/lib.rs | 1 + tip-distributor/Cargo.toml | 54 + tip-distributor/README.md | 52 + tip-distributor/src/bin/claim-mev-tips.rs | 171 ++ .../src/bin/merkle-root-generator.rs | 34 + .../src/bin/merkle-root-uploader.rs | 54 + .../src/bin/stake-meta-generator.rs | 67 + tip-distributor/src/claim_mev_workflow.rs | 448 +++++ tip-distributor/src/lib.rs | 1083 +++++++++++ .../src/merkle_root_generator_workflow.rs | 54 + .../src/merkle_root_upload_workflow.rs | 138 ++ tip-distributor/src/reclaim_rent_workflow.rs | 247 +++ .../src/stake_meta_generator_workflow.rs | 952 ++++++++++ transaction-status/src/lib.rs | 9 +- validator/Cargo.toml | 2 + validator/src/admin_rpc_service.rs | 116 +- validator/src/bootstrap.rs | 9 +- validator/src/cli.rs | 237 +++ validator/src/dashboard.rs | 1 + validator/src/main.rs | 268 ++- version/src/lib.rs | 2 +- 174 files changed, 17093 insertions(+), 1234 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitmodules delete mode 100644 SECURITY.md create mode 160000 anchor create mode 100755 bootstrap create mode 100644 bundle/Cargo.toml create mode 100644 bundle/src/bundle_execution.rs create mode 100644 bundle/src/lib.rs create mode 100644 core/benches/proto_to_packet.rs create mode 100644 core/src/bundle_stage.rs create mode 100644 core/src/bundle_stage/bundle_account_locker.rs create mode 100644 core/src/bundle_stage/bundle_consumer.rs create mode 100644 core/src/bundle_stage/bundle_packet_deserializer.rs create mode 100644 core/src/bundle_stage/bundle_packet_receiver.rs create mode 100644 core/src/bundle_stage/bundle_reserved_space_manager.rs create mode 100644 core/src/bundle_stage/bundle_stage_leader_metrics.rs create mode 100644 core/src/bundle_stage/committer.rs create mode 100644 core/src/bundle_stage/result.rs create mode 100644 core/src/consensus_cache_updater.rs create mode 100644 core/src/immutable_deserialized_bundle.rs create mode 100644 core/src/packet_bundle.rs create mode 100644 core/src/proxy/auth.rs create mode 100644 core/src/proxy/block_engine_stage.rs create mode 100644 core/src/proxy/fetch_stage_manager.rs create mode 100644 core/src/proxy/mod.rs create mode 100644 core/src/proxy/relayer_stage.rs create mode 100644 core/src/tip_manager.rs create mode 100755 deploy_programs create mode 100644 dev/Dockerfile create mode 100755 f create mode 160000 jito-programs create mode 100644 jito-protos/Cargo.toml create mode 100644 jito-protos/build.rs create mode 160000 jito-protos/protos create mode 100644 jito-protos/src/lib.rs create mode 100644 program-test/src/programs/jito_tip_distribution-0.1.3.so create mode 100644 program-test/src/programs/jito_tip_payment-0.1.3.so create mode 100644 rpc-client-api/src/bundles.rs create mode 100644 runtime-plugin/Cargo.toml create mode 100644 runtime-plugin/src/lib.rs create mode 100644 runtime-plugin/src/runtime_plugin.rs create mode 100644 runtime-plugin/src/runtime_plugin_admin_rpc_service.rs create mode 100644 runtime-plugin/src/runtime_plugin_manager.rs create mode 100644 runtime-plugin/src/runtime_plugin_service.rs create mode 100755 s create mode 100644 sdk/src/bundle/mod.rs create mode 100755 start create mode 100755 start_multi create mode 100644 tip-distributor/Cargo.toml create mode 100644 tip-distributor/README.md create mode 100644 tip-distributor/src/bin/claim-mev-tips.rs create mode 100644 tip-distributor/src/bin/merkle-root-generator.rs create mode 100644 tip-distributor/src/bin/merkle-root-uploader.rs create mode 100644 tip-distributor/src/bin/stake-meta-generator.rs create mode 100644 tip-distributor/src/claim_mev_workflow.rs create mode 100644 tip-distributor/src/lib.rs create mode 100644 tip-distributor/src/merkle_root_generator_workflow.rs create mode 100644 tip-distributor/src/merkle_root_upload_workflow.rs create mode 100644 tip-distributor/src/reclaim_rent_workflow.rs create mode 100644 tip-distributor/src/stake_meta_generator_workflow.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..99262ca894 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.dockerignore +.git/ +.github/ +.gitignore +.idea/ +README.md +Dockerfile +f +target/ diff --git a/.github/workflows/client-targets.yml b/.github/workflows/client-targets.yml index 3b3d1779a1..fd1971e894 100644 --- a/.github/workflows/client-targets.yml +++ b/.github/workflows/client-targets.yml @@ -30,6 +30,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - run: cargo install cargo-ndk@2.12.2 @@ -54,6 +56,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - name: Setup Rust run: | diff --git a/.github/workflows/crate-check.yml b/.github/workflows/crate-check.yml index a47e7cde5f..9b57d633ad 100644 --- a/.github/workflows/crate-check.yml +++ b/.github/workflows/crate-check.yml @@ -18,6 +18,7 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 + submodules: 'recursive' - name: Get commit range (push) if: ${{ github.event_name == 'push' }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fb2096bd33..e5ac907ea1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,6 +22,7 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 + submodules: 'recursive' - name: Get commit range (push) if: ${{ github.event_name == 'push' }} @@ -77,6 +78,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 + with: + submodules: 'recursive' - name: Setup Node uses: actions/setup-node@v3 diff --git a/.github/workflows/downstream-project-spl.yml b/.github/workflows/downstream-project-spl.yml index 9934defc6d..f5070beb2d 100644 --- a/.github/workflows/downstream-project-spl.yml +++ b/.github/workflows/downstream-project-spl.yml @@ -36,6 +36,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - shell: bash run: | @@ -86,6 +88,8 @@ jobs: ] steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - shell: bash run: | @@ -139,6 +143,8 @@ jobs: steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - shell: bash run: | diff --git a/.github/workflows/increment-cargo-version-on-release.yml b/.github/workflows/increment-cargo-version-on-release.yml index 5592d76ca5..ca55af2155 100644 --- a/.github/workflows/increment-cargo-version-on-release.yml +++ b/.github/workflows/increment-cargo-version-on-release.yml @@ -11,6 +11,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v3 + with: + submodules: 'recursive' # This script confirms two assumptions: # 1) Tag should be branch. diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 3e5ab89fe3..7090907550 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -26,6 +26,7 @@ jobs: with: ref: master fetch-depth: 0 + submodules: 'recursive' - name: Setup Rust shell: bash diff --git a/.gitignore b/.gitignore index 3167a9d720..f891833b15 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ /solana-release.tar.bz2 /solana-metrics/ /solana-metrics.tar.bz2 -/target/ +**/target/ /test-ledger/ **/*.rs.bk @@ -27,7 +27,11 @@ log-*/ # fetch-spl.sh artifacts /spl-genesis-args.sh /spl_*.so +/jito_*.so .DS_Store # scripts that may be generated by cargo *-bpf commands **/cargo-*-bpf-child-script-*.sh + +.env +docker-output/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..e31fc7fccd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "anchor"] + path = anchor + url = https://github.com/jito-foundation/anchor.git +[submodule "jito-programs"] + path = jito-programs + url = https://github.com/jito-foundation/jito-programs.git +[submodule "jito-protos/protos"] + path = jito-protos/protos + url = https://github.com/jito-labs/mev-protos.git diff --git a/Cargo.lock b/Cargo.lock index 12bc0786c9..9091fb561f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,145 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "anchor-attribute-access-control" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "regex", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-account" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "bs58 0.4.0", + "proc-macro2 1.0.60", + "quote 1.0.28", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-constant" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "proc-macro2 1.0.60", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-error" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-event" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-interface" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "heck 0.3.3", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-program" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-state" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-derive-accounts" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-lang" +version = "0.24.2" +dependencies = [ + "anchor-attribute-access-control", + "anchor-attribute-account", + "anchor-attribute-constant", + "anchor-attribute-error", + "anchor-attribute-event", + "anchor-attribute-interface", + "anchor-attribute-program", + "anchor-attribute-state", + "anchor-derive-accounts", + "arrayref", + "base64 0.13.1", + "bincode", + "borsh 0.10.3", + "bytemuck", + "solana-program", + "thiserror", +] + +[[package]] +name = "anchor-syn" +version = "0.24.2" +dependencies = [ + "anyhow", + "bs58 0.3.1", + "heck 0.3.3", + "proc-macro2 1.0.60", + "proc-macro2-diagnostics", + "quote 1.0.28", + "serde", + "serde_json", + "sha2 0.9.9", + "syn 1.0.109", + "thiserror", +] + [[package]] name = "android_system_properties" version = "0.1.4" @@ -277,9 +416,9 @@ checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" [[package]] name = "arrayvec" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" [[package]] name = "ascii" @@ -446,7 +585,7 @@ checksum = "e5694b64066a2459918d8074c2ce0d5a88f409431994c2356617c8ae0c4721fc" dependencies = [ "async-trait", "axum-core", - "bitflags", + "bitflags 1.3.2", "bytes", "futures-util", "http", @@ -537,7 +676,7 @@ version = "0.65.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cexpr", "clang-sys", "lazy_static", @@ -573,6 +712,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" + [[package]] name = "bitmaps" version = "2.1.0" @@ -584,9 +729,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.3.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ae2468a89544a466886840aa467a25b766499f4f04bf7d9fcd10ecee9fccef" +checksum = "0231f06152bf547e9c2b5194f247cd97aacf6dcd8b15d8e5ec0663f64580da87" dependencies = [ "arrayref", "arrayvec", @@ -659,7 +804,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b" dependencies = [ "borsh-derive 0.10.3", - "hashbrown 0.11.2", + "hashbrown 0.13.2", ] [[package]] @@ -753,6 +898,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476e9cd489f9e121e02ffa6014a8ef220ecb15c05ed23fc34cca13925dc283fb" + [[package]] name = "bs58" version = "0.4.0" @@ -1011,7 +1162,7 @@ checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" dependencies = [ "ansi_term", "atty", - "bitflags", + "bitflags 1.3.2", "strsim 0.8.0", "textwrap 0.11.0", "unicode-width", @@ -1025,9 +1176,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71655c45cb9845d3270c9d6df84ebe72b4dad3c2ba3f7023ad47c144e4e473a5" dependencies = [ "atty", - "bitflags", - "clap_derive", - "clap_lex", + "bitflags 1.3.2", + "clap_derive 3.2.18", + "clap_lex 0.2.4", "indexmap", "once_cell", "strsim 0.10.0", @@ -1035,6 +1186,21 @@ dependencies = [ "textwrap 0.16.0", ] +[[package]] +name = "clap" +version = "4.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42dfd32784433290c51d92c438bb72ea5063797fc3cc9a21a8c4346bebbb2098" +dependencies = [ + "bitflags 2.4.0", + "clap_derive 4.1.9", + "clap_lex 0.3.3", + "is-terminal", + "once_cell", + "strsim 0.10.0", + "termcolor", +] + [[package]] name = "clap_derive" version = "3.2.18" @@ -1048,6 +1214,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "clap_derive" +version = "4.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fddf67631444a3a3e3e5ac51c36a5e01335302de677bd78759eaa90ab1f46644" +dependencies = [ + "heck 0.4.0", + "proc-macro-error", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + [[package]] name = "clap_lex" version = "0.2.4" @@ -1057,6 +1236,15 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "clap_lex" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "033f6b7a4acb1f358c742aaca805c939ee73b4c6209ae4318ec7aca81c42e646" +dependencies = [ + "os_str_bytes", +] + [[package]] name = "combine" version = "3.8.1" @@ -1140,9 +1328,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.2.5" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13418e745008f7349ec7e449155f419a61b92b58a99cc3616942b926825ec76b" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" [[package]] name = "convert_case" @@ -1394,6 +1582,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" +[[package]] +name = "default-env" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f753eb82d29277e79efc625e84aecacfd4851ee50e05a8573a4740239a77bfd3" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "syn 0.15.44", +] + [[package]] name = "der" version = "0.5.1" @@ -1970,7 +2169,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32c95766e0414f8bfc1d07055574c621b67739466d6ba516c4fef8e99d30d2e6" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cfg-if 1.0.0", "log", "managed", @@ -2160,7 +2359,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cff78e5788be1e0ab65b04d306b2ed5092c815ec97ec70f4ebd5aee158aa55d" dependencies = [ "base64 0.13.1", - "bitflags", + "bitflags 1.3.2", "bytes", "headers-core", "http", @@ -2515,6 +2714,18 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f2d64f2edebec4ce84ad108148e67e1064789bee435edc5b60ad398714a3a9" +[[package]] +name = "is-terminal" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" +dependencies = [ + "hermit-abi 0.3.1", + "io-lifetimes", + "rustix", + "windows-sys 0.48.0", +] + [[package]] name = "itertools" version = "0.10.5" @@ -2530,6 +2741,49 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" +[[package]] +name = "jito-programs-vote-state" +version = "0.1.4" +dependencies = [ + "anchor-lang", + "bincode", + "serde", + "serde_derive", + "solana-program", +] + +[[package]] +name = "jito-protos" +version = "1.16.22" +dependencies = [ + "bytes", + "prost 0.11.9", + "prost-types 0.11.9", + "protobuf-src", + "tonic 0.8.3", + "tonic-build 0.8.4", +] + +[[package]] +name = "jito-tip-distribution" +version = "0.1.4" +dependencies = [ + "anchor-lang", + "default-env", + "jito-programs-vote-state", + "solana-program", + "solana-security-txt", +] + +[[package]] +name = "jito-tip-payment" +version = "0.1.4" +dependencies = [ + "anchor-lang", + "default-env", + "solana-security-txt", +] + [[package]] name = "jobserver" version = "0.1.24" @@ -3051,7 +3305,7 @@ version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cfg-if 1.0.0", "libc", "memoffset 0.7.1", @@ -3316,7 +3570,7 @@ version = "0.10.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d" dependencies = [ - "bitflags", + "bitflags 1.3.2", "cfg-if 1.0.0", "foreign-types", "libc", @@ -3793,6 +4047,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf29726d67464d49fa6224a1d07936a8c08bb3fba727c7493f6cf1616fdaada" +dependencies = [ + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", + "version_check", + "yansi", +] + [[package]] name = "proptest" version = "1.2.0" @@ -3800,7 +4067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e35c06b98bf36aba164cc17cb25f7e232f5c4aeea73baa14b8a9f0d92dbfa65" dependencies = [ "bit-set", - "bitflags", + "bitflags 1.3.2", "byteorder", "lazy_static", "num-traits", @@ -4196,7 +4463,7 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -4205,7 +4472,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -4406,7 +4673,7 @@ version = "0.37.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bbfc1d1c7c40c01715f47d71444744a81669ca84e8b63e25a55e169b1f86433" dependencies = [ - "bitflags", + "bitflags 1.3.2", "errno", "io-lifetimes", "libc", @@ -4564,7 +4831,7 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "525bc1abfda2e1998d152c45cf13e696f76d0a4972310b22fac1658b05df7c87" dependencies = [ - "bitflags", + "bitflags 1.3.2", "core-foundation", "core-foundation-sys", "libc", @@ -4937,7 +5204,7 @@ dependencies = [ "Inflector", "base64 0.21.2", "bincode", - "bs58", + "bs58 0.4.0", "bv", "lazy_static", "serde", @@ -5086,12 +5353,15 @@ dependencies = [ "futures 0.3.28", "solana-banks-interface", "solana-client", + "solana-gossip", "solana-runtime", "solana-sdk", "solana-send-transaction-service", + "solana-streamer", "tarpc", "tokio", "tokio-serde", + "tokio-stream", ] [[package]] @@ -5210,6 +5480,26 @@ dependencies = [ "tempfile", ] +[[package]] +name = "solana-bundle" +version = "1.16.22" +dependencies = [ + "anchor-lang", + "assert_matches", + "itertools", + "log", + "serde", + "solana-ledger", + "solana-logger", + "solana-measure", + "solana-poh", + "solana-program-runtime", + "solana-runtime", + "solana-sdk", + "solana-transaction-status", + "thiserror", +] + [[package]] name = "solana-cargo-build-bpf" version = "1.16.22" @@ -5299,7 +5589,7 @@ name = "solana-cli" version = "1.16.22" dependencies = [ "bincode", - "bs58", + "bs58 0.4.0", "clap 2.33.3", "console", "const_format", @@ -5496,9 +5786,10 @@ dependencies = [ name = "solana-core" version = "1.16.22" dependencies = [ + "anchor-lang", "base64 0.21.2", "bincode", - "bs58", + "bs58 0.4.0", "chrono", "crossbeam-channel", "dashmap 4.0.2", @@ -5507,12 +5798,17 @@ dependencies = [ "fs_extra", "histogram", "itertools", + "jito-protos", + "jito-tip-distribution", + "jito-tip-payment", "lazy_static", "log", "lru", "matches", "min-max-heap", "num_enum 0.6.1", + "prost 0.11.9", + "prost-types 0.11.9", "rand 0.7.3", "rand_chacha 0.2.2", "raptorq", @@ -5525,6 +5821,7 @@ dependencies = [ "serial_test", "solana-address-lookup-table-program", "solana-bloom", + "solana-bundle", "solana-client", "solana-entry", "solana-frozen-abi", @@ -5539,10 +5836,12 @@ dependencies = [ "solana-perf", "solana-poh", "solana-program-runtime", + "solana-program-test", "solana-rayon-threadlimit", "solana-rpc", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-stake-program", @@ -5561,6 +5860,8 @@ dependencies = [ "test-case", "thiserror", "tokio", + "tonic 0.8.3", + "tonic-build 0.8.4", "trees", ] @@ -5669,7 +5970,7 @@ dependencies = [ "ahash 0.8.3", "blake3", "block-buffer 0.10.4", - "bs58", + "bs58 0.4.0", "bv", "byteorder", "cc", @@ -5753,7 +6054,7 @@ dependencies = [ name = "solana-geyser-plugin-manager" version = "1.16.22" dependencies = [ - "bs58", + "bs58 0.4.0", "crossbeam-channel", "json5", "jsonrpc-core", @@ -5861,7 +6162,7 @@ dependencies = [ name = "solana-keygen" version = "1.16.22" dependencies = [ - "bs58", + "bs58 0.4.0", "clap 3.2.23", "dirs-next", "num_cpus", @@ -5880,8 +6181,8 @@ version = "1.16.22" dependencies = [ "assert_matches", "bincode", - "bitflags", - "bs58", + "bitflags 1.3.2", + "bs58 0.4.0", "byteorder", "chrono", "chrono-humanize", @@ -5943,7 +6244,7 @@ name = "solana-ledger-tool" version = "1.16.22" dependencies = [ "assert_cmd", - "bs58", + "bs58 0.4.0", "bytecount", "chrono", "clap 2.33.3", @@ -6224,11 +6525,11 @@ dependencies = [ "assert_matches", "base64 0.21.2", "bincode", - "bitflags", + "bitflags 1.3.2", "blake3", "borsh 0.10.3", "borsh 0.9.3", - "bs58", + "bs58 0.4.0", "bv", "bytemuck", "cc", @@ -6406,7 +6707,7 @@ version = "1.16.22" dependencies = [ "base64 0.21.2", "bincode", - "bs58", + "bs58 0.4.0", "crossbeam-channel", "dashmap 4.0.2", "itertools", @@ -6426,6 +6727,7 @@ dependencies = [ "soketto", "solana-account-decoder", "solana-address-lookup-table-program", + "solana-bundle", "solana-client", "solana-entry", "solana-faucet", @@ -6436,6 +6738,7 @@ dependencies = [ "solana-net-utils", "solana-perf", "solana-poh", + "solana-program-runtime", "solana-rayon-threadlimit", "solana-rpc-client-api", "solana-runtime", @@ -6466,7 +6769,7 @@ dependencies = [ "async-trait", "base64 0.21.2", "bincode", - "bs58", + "bs58 0.4.0", "crossbeam-channel", "futures 0.3.28", "indicatif", @@ -6492,7 +6795,7 @@ name = "solana-rpc-client-api" version = "1.16.22" dependencies = [ "base64 0.21.2", - "bs58", + "bs58 0.4.0", "jsonrpc-core", "reqwest", "semver 1.0.17", @@ -6500,6 +6803,8 @@ dependencies = [ "serde_derive", "serde_json", "solana-account-decoder", + "solana-bundle", + "solana-runtime", "solana-sdk", "solana-transaction-status", "solana-version", @@ -6529,13 +6834,14 @@ name = "solana-rpc-test" version = "1.16.22" dependencies = [ "bincode", - "bs58", + "bs58 0.4.0", "crossbeam-channel", "futures-util", "log", "reqwest", "serde", "serde_json", + "serial_test", "solana-account-decoder", "solana-client", "solana-logger", @@ -6628,17 +6934,36 @@ dependencies = [ "zstd", ] +[[package]] +name = "solana-runtime-plugin" +version = "1.16.22" +dependencies = [ + "crossbeam-channel", + "json5", + "jsonrpc-core", + "jsonrpc-core-client", + "jsonrpc-derive", + "jsonrpc-ipc-server", + "jsonrpc-server-utils", + "libloading", + "log", + "solana-runtime", + "solana-sdk", + "thiserror", +] + [[package]] name = "solana-sdk" version = "1.16.22" dependencies = [ + "anchor-lang", "anyhow", "assert_matches", "base64 0.21.2", "bincode", - "bitflags", + "bitflags 1.3.2", "borsh 0.10.3", - "bs58", + "bs58 0.4.0", "bytemuck", "byteorder", "chrono", @@ -6688,13 +7013,19 @@ dependencies = [ name = "solana-sdk-macro" version = "1.16.22" dependencies = [ - "bs58", + "bs58 0.4.0", "proc-macro2 1.0.60", "quote 1.0.28", "rustversion", "syn 2.0.18", ] +[[package]] +name = "solana-security-txt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "468aa43b7edb1f9b7b7b686d5c3aeb6630dc1708e86e31343499dd5c4d775183" + [[package]] name = "solana-send-transaction-service" version = "1.16.22" @@ -6702,11 +7033,13 @@ dependencies = [ "crossbeam-channel", "log", "solana-client", + "solana-gossip", "solana-logger", "solana-measure", "solana-metrics", "solana-runtime", "solana-sdk", + "solana-streamer", "solana-tpu-client", ] @@ -6780,7 +7113,7 @@ name = "solana-storage-proto" version = "1.16.22" dependencies = [ "bincode", - "bs58", + "bs58 0.4.0", "enum-iterator", "prost 0.11.9", "protobuf-src", @@ -6892,6 +7225,41 @@ dependencies = [ "solana-sdk", ] +[[package]] +name = "solana-tip-distributor" +version = "1.16.22" +dependencies = [ + "anchor-lang", + "clap 4.1.11", + "crossbeam-channel", + "env_logger", + "futures 0.3.28", + "gethostname", + "im", + "itertools", + "jito-tip-distribution", + "jito-tip-payment", + "log", + "num-traits", + "rand 0.7.3", + "serde", + "serde_json", + "solana-client", + "solana-genesis-utils", + "solana-ledger", + "solana-measure", + "solana-merkle-tree", + "solana-metrics", + "solana-program", + "solana-program-runtime", + "solana-rpc-client-api", + "solana-runtime", + "solana-sdk", + "solana-stake-program", + "thiserror", + "tokio", +] + [[package]] name = "solana-tokens" version = "1.16.22" @@ -6983,7 +7351,7 @@ dependencies = [ "base64 0.21.2", "bincode", "borsh 0.10.3", - "bs58", + "bs58 0.4.0", "lazy_static", "log", "serde", @@ -7069,6 +7437,7 @@ dependencies = [ "solana-rpc-client", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-storage-bigtable", @@ -7081,6 +7450,7 @@ dependencies = [ "symlink", "thiserror", "tikv-jemallocator", + "tonic 0.8.3", ] [[package]] @@ -7142,7 +7512,7 @@ dependencies = [ name = "solana-zk-keygen" version = "1.16.22" dependencies = [ - "bs58", + "bs58 0.4.0", "clap 3.2.23", "dirs-next", "num_cpus", @@ -7584,7 +7954,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "225e483f02d0ad107168dc57381a8a40c3aeea6abe47f37506931f861643cfa8" dependencies = [ - "bitflags", + "bitflags 1.3.2", "byteorder", "libc", "thiserror", @@ -8043,6 +8413,7 @@ dependencies = [ "pin-project", "prost 0.11.9", "prost-derive 0.11.9", + "rustls-native-certs", "rustls-pemfile 1.0.0", "tokio", "tokio-rustls 0.23.3", @@ -8053,6 +8424,7 @@ dependencies = [ "tower-service", "tracing", "tracing-futures", + "webpki-roots", ] [[package]] @@ -8106,7 +8478,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f873044bf02dd1e8239e9c1293ea39dad76dc594ec16185d0a1bf31d8dc8d858" dependencies = [ - "bitflags", + "bitflags 1.3.2", "bytes", "futures-core", "futures-util", @@ -8838,6 +9210,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "yasna" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 7e4d1dbea3..570755aad1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "bench-tps", "bloom", "bucket_map", + "bundle", "clap-utils", "clap-v3-utils", "cli", @@ -32,6 +33,7 @@ members = [ "geyser-plugin-manager", "gossip", "install", + "jito-protos", "keygen", "ledger", "ledger-tool", @@ -76,6 +78,7 @@ members = [ "rpc-client-nonce-utils", "rpc-test", "runtime", + "runtime-plugin", "runtime/store-tool", "sdk", "sdk/cargo-build-bpf", @@ -93,6 +96,7 @@ members = [ "streamer", "test-validator", "thin-client", + "tip-distributor", "tokens", "tpu-client", "transaction-dos", @@ -107,6 +111,8 @@ members = [ ] exclude = [ + "anchor", + "jito-programs", "programs/sbf", ] @@ -125,6 +131,7 @@ edition = "2021" aes-gcm-siv = "0.10.3" ahash = "0.8.3" anyhow = "1.0.71" +anchor-lang = { path = "anchor/lang" } ark-bn254 = "0.4.0" ark-ec = "0.4.0" ark-ff = "0.4.0" @@ -210,6 +217,9 @@ indicatif = "0.17.4" Inflector = "0.11.4" itertools = "0.10.5" jemallocator = { package = "tikv-jemallocator", version = "0.4.1", features = ["unprefixed_malloc_on_supported_platforms"] } +jito-protos = { path = "jito-protos", version = "=1.16.22" } +jito-tip-distribution = { path = "jito-programs/mev-programs/programs/tip-distribution", features = ["no-entrypoint"] } +jito-tip-payment = { path = "jito-programs/mev-programs/programs/tip-payment", features = ["no-entrypoint"] } js-sys = "0.3.63" json5 = "0.4.1" jsonrpc-core = "18.0.0" @@ -298,6 +308,7 @@ solana-bench-tps = { path = "bench-tps", version = "=1.16.22" } solana-bloom = { path = "bloom", version = "=1.16.22" } solana-bpf-loader-program = { path = "programs/bpf_loader", version = "=1.16.22" } solana-bucket-map = { path = "bucket_map", version = "=1.16.22" } +solana-bundle = { path = "bundle", version = "=1.16.22" } solana-connection-cache = { path = "connection-cache", version = "=1.16.22", default-features = false } solana-clap-utils = { path = "clap-utils", version = "=1.16.22" } solana-clap-v3-utils = { path = "clap-v3-utils", version = "=1.16.22" } @@ -341,6 +352,7 @@ solana-rpc-client = { path = "rpc-client", version = "=1.16.22", default-feature solana-rpc-client-api = { path = "rpc-client-api", version = "=1.16.22" } solana-rpc-client-nonce-utils = { path = "rpc-client-nonce-utils", version = "=1.16.22" } solana-runtime = { path = "runtime", version = "=1.16.22" } +solana-runtime-plugin = { path = "runtime-plugin", version = "=1.16.22" } solana-sdk = { path = "sdk", version = "=1.16.22" } solana-sdk-macro = { path = "sdk/macro", version = "=1.16.22" } solana-send-transaction-service = { path = "send-transaction-service", version = "=1.16.22" } diff --git a/README.md b/README.md index 4fccacf2ba..750e797895 100644 --- a/README.md +++ b/README.md @@ -4,142 +4,9 @@

-[![Solana crate](https://img.shields.io/crates/v/solana-core.svg)](https://crates.io/crates/solana-core) -[![Solana documentation](https://docs.rs/solana-core/badge.svg)](https://docs.rs/solana-core) -[![Build status](https://badge.buildkite.com/8cc350de251d61483db98bdfc895b9ea0ac8ffa4a32ee850ed.svg?branch=master)](https://buildkite.com/solana-labs/solana/builds?branch=master) -[![codecov](https://codecov.io/gh/solana-labs/solana/branch/master/graph/badge.svg)](https://codecov.io/gh/solana-labs/solana) +[![Build status](https://badge.buildkite.com/3a7c88c0f777e1a0fddacc190823565271ae4c251ef78d83a8.svg)](https://buildkite.com/jito/jito-solana) -# Building +# About +This repository contains Jito's fork of the Solana validator. -## **1. Install rustc, cargo and rustfmt.** - -```bash -$ curl https://sh.rustup.rs -sSf | sh -$ source $HOME/.cargo/env -$ rustup component add rustfmt -``` - -When building the master branch, please make sure you are using the latest stable rust version by running: - -```bash -$ rustup update -``` - -When building a specific release branch, you should check the rust version in `ci/rust-version.sh` and if necessary, install that version by running: -```bash -$ rustup install VERSION -``` -Note that if this is not the latest rust version on your machine, cargo commands may require an [override](https://rust-lang.github.io/rustup/overrides.html) in order to use the correct version. - -On Linux systems you may need to install libssl-dev, pkg-config, zlib1g-dev, protobuf etc. - -On Ubuntu: -```bash -$ sudo apt-get update -$ sudo apt-get install libssl-dev libudev-dev pkg-config zlib1g-dev llvm clang cmake make libprotobuf-dev protobuf-compiler -``` - -On Fedora: -```bash -$ sudo dnf install openssl-devel systemd-devel pkg-config zlib-devel llvm clang cmake make protobuf-devel protobuf-compiler perl-core -``` - -## **2. Download the source code.** - -```bash -$ git clone https://github.com/solana-labs/solana.git -$ cd solana -``` - -## **3. Build.** - -```bash -$ ./cargo build -``` - -# Testing - -**Run the test suite:** - -```bash -$ ./cargo test -``` - -### Starting a local testnet -Start your own testnet locally, instructions are in the [online docs](https://docs.solana.com/cluster/bench-tps). - -### Accessing the remote development cluster -* `devnet` - stable public cluster for development accessible via -devnet.solana.com. Runs 24/7. Learn more about the [public clusters](https://docs.solana.com/clusters) - -# Benchmarking - -First, install the nightly build of rustc. `cargo bench` requires the use of the -unstable features only available in the nightly build. - -```bash -$ rustup install nightly -``` - -Run the benchmarks: - -```bash -$ cargo +nightly bench -``` - -# Release Process - -The release process for this project is described [here](RELEASE.md). - -# Code coverage - -To generate code coverage statistics: - -```bash -$ scripts/coverage.sh -$ open target/cov/lcov-local/index.html -``` - -Why coverage? While most see coverage as a code quality metric, we see it primarily as a developer -productivity metric. When a developer makes a change to the codebase, presumably it's a *solution* to -some problem. Our unit-test suite is how we encode the set of *problems* the codebase solves. Running -the test suite should indicate that your change didn't *infringe* on anyone else's solutions. Adding a -test *protects* your solution from future changes. Say you don't understand why a line of code exists, -try deleting it and running the unit-tests. The nearest test failure should tell you what problem -was solved by that code. If no test fails, go ahead and submit a Pull Request that asks, "what -problem is solved by this code?" On the other hand, if a test does fail and you can think of a -better way to solve the same problem, a Pull Request with your solution would most certainly be -welcome! Likewise, if rewriting a test can better communicate what code it's protecting, please -send us that patch! - -# Disclaimer - -All claims, content, designs, algorithms, estimates, roadmaps, -specifications, and performance measurements described in this project -are done with the Solana Labs, Inc. (“SL”) good faith efforts. It is up to -the reader to check and validate their accuracy and truthfulness. -Furthermore, nothing in this project constitutes a solicitation for -investment. - -Any content produced by SL or developer resources that SL provides are -for educational and inspirational purposes only. SL does not encourage, -induce or sanction the deployment, integration or use of any such -applications (including the code comprising the Solana blockchain -protocol) in violation of applicable laws or regulations and hereby -prohibits any such deployment, integration or use. This includes the use of -any such applications by the reader (a) in violation of export control -or sanctions laws of the United States or any other applicable -jurisdiction, (b) if the reader is located in or ordinarily resident in -a country or territory subject to comprehensive sanctions administered -by the U.S. Office of Foreign Assets Control (OFAC), or (c) if the -reader is or is working on behalf of a Specially Designated National -(SDN) or a person subject to similar blocking or denied party -prohibitions. - -The reader should be aware that U.S. export control and sanctions laws prohibit -U.S. persons (and other persons that are subject to such laws) from transacting -with persons in certain countries and territories or that are on the SDN list. -Accordingly, there is a risk to individuals that other persons using any of the -code contained in this repo, or a derivation thereof, may be sanctioned persons -and that transactions with such persons would be a violation of U.S. export -controls and sanctions law. +We recommend checking out our [Gitbook](https://jito-foundation.gitbook.io/mev/jito-solana/building-the-software) for more detailed instructions on building and running Jito-Solana. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 48326f1497..0000000000 --- a/SECURITY.md +++ /dev/null @@ -1,167 +0,0 @@ -# Security Policy - -1. [Reporting security problems](#reporting) -4. [Security Bug Bounties](#bounty) -2. [Incident Response Process](#process) - - -## Reporting security problems in the Solana Labs Validator Client - -**DO NOT CREATE A GITHUB ISSUE** to report a security problem. - -Instead please use this [Report a Vulnerability](https://github.com/solana-labs/solana/security/advisories/new) link. -Provide a helpful title, detailed description of the vulnerability and an exploit -proof-of-concept. Speculative submissions without proof-of-concept will be closed -with no further consideration. - -If you haven't done so already, please **enable two-factor auth** in your GitHub account. - -Expect a response as fast as possible in the advisory, typically within 72 hours. - --- - -If you do not receive a response in the advisory, send an email to -security@solanalabs.com with the full URL of the advisory you have created. DO NOT -include attachments or provide detail sufficient for exploitation regarding the -security issue in this email. **Only provide such details in the advisory**. - -If you do not receive a response from security@solanalabs.com please followup with -the team directly. You can do this in the `#core-technology` channel of the -[Solana Tech discord server](https://solana.com/discord), by pinging the `Solana Labs` -role in the channel and referencing the fact that you submitted a security problem. - - -## Incident Response Process - -In case an incident is discovered or reported, the following process will be -followed to contain, respond and remediate: - -### 1. Accept the new report -In response a newly reported security problem, a member of the -`solana-labs/admins` group will accept the report to turn it into a draft -advisory. The `solana-labs/security-incident-response` group should be added to -the draft security advisory, and create a private fork of the repository (grey -button towards the bottom of the page) if necessary. - -If the advisory is the result of an audit finding, follow the same process as above but add the auditor's github user(s) and begin the title with "[Audit]". - -If the report is out of scope, a member of the `solana-labs/admins` group will -comment as such and then close the report. - -### 2. Triage -Within the draft security advisory, discuss and determine the severity of the issue. If necessary, members of the solana-labs/security-incident-response group may add other github users to the advisory to assist. -If it is determined that this not a critical network issue then the advisory should be closed and if more follow-up is required a normal Solana public github issue should be created. - -### 3. Prepare Fixes -For the affected branches, typically all three (edge, beta and stable), prepare a fix for the issue and push them to the corresponding branch in the private repository associated with the draft security advisory. -There is no CI available in the private repository so you must build from source and manually verify fixes. -Code review from the reporter is ideal, as well as from multiple members of the core development team. - -### 4. Notify Security Group Validators -Once an ETA is available for the fix, a member of the solana-labs/security-incident-response group should notify the validators so they can prepare for an update using the "Solana Red Alert" notification system. -The teams are all over the world and it's critical to provide actionable information at the right time. Don't be the person that wakes everybody up at 2am when a fix won't be available for hours. - -### 5. Ship the patch -Once the fix is accepted, a member of the solana-labs/security-incident-response group should prepare a single patch file for each affected branch. The commit title for the patch should only contain the advisory id, and not disclose any further details about the incident. -Copy the patches to https://release.solana.com/ under a subdirectory named after the advisory id (example: https://release.solana.com/GHSA-hx59-f5g4-jghh/v1.4.patch). Contact a member of the solana-labs/admins group if you require access to release.solana.com -Using the "Solana Red Alert" channel: - a) Notify validators that there's an issue and a patch will be provided in X minutes - b) If X minutes expires and there's no patch, notify of the delay and provide a new ETA - c) Provide links to patches of https://release.solana.com/ for each affected branch -Validators can be expected to build the patch from source against the latest release for the affected branch. -Since the software version will not change after the patch is applied, request that each validator notify in the existing channel once they've updated. Manually monitor the roll out until a sufficient amount of stake has updated - typically at least 33.3% or 66.6% depending on the issue. - -### 6. Public Disclosure and Release -Once the fix has been deployed to the security group validators, the patches from the security advisory may be merged into the main source repository. A new official release for each affected branch should be shipped and all validators requested to upgrade as quickly as possible. - -### 7. Security Advisory Bounty Accounting and Cleanup -If this issue is [eligible](#eligibility) for a bounty, prefix the title of the -security advisory with one of the following, depending on the severity: -- [Bounty Category: Critical: Loss of Funds] -- [Bounty Category: Critical: Consensus / Safety Violations] -- [Bounty Category: Critical: Liveness / Loss of Availability] -- [Bounty Category: Critical: DoS Attacks] -- [Bounty Category: Supply Chain Attacks] -- [Bounty Category: RPC] - -Confirm with the reporter that they agree with the severity assessment, and discuss as required to reach a conclusion. - -We currently do not use the Github workflow to publish security advisories. Once the issue and fix have been disclosed, and a bounty category is assessed if appropriate, the GitHub security advisory is no longer needed and can be closed. - - -## Security Bug Bounties -The Solana Foundation offer bounties for critical Solana security issues. Please -see below for more details. Either a demonstration or a valid bug report is all -that's necessary to submit a bug bounty. A patch to fix the issue isn't -required. - -#### Loss of Funds: -$2,000,000 USD in locked SOL tokens (locked for 12 months) -* Theft of funds without users signature from any account -* Theft of funds without users interaction in system, token, stake, vote programs -* Theft of funds that requires users signature - creating a vote program that drains the delegated stakes. - -#### Consensus/Safety Violations: -$1,000,000 USD in locked SOL tokens (locked for 12 months) -* Consensus safety violation -* Tricking a validator to accept an optimistic confirmation or rooted slot without a double vote, etc. - -#### Liveness / Loss of Availability: -$400,000 USD in locked SOL tokens (locked for 12 months) -* Whereby consensus halts and requires human intervention -* Eclipse attacks, -* Remote attacks that partition the network, - -#### DoS Attacks: -$100,000 USD in locked SOL tokens (locked for 12 months) -* Remote resource exaustion via Non-RPC protocols - -#### Supply Chain Attacks: -$100,000 USD in locked SOL tokens (locked for 12 months) -* Non-social attacks against source code change management, automated testing, release build, release publication and release hosting infrastructure of the monorepo. - -#### RPC DoS/Crashes: -$5,000 USD in locked SOL tokens (locked for 12 months) -* RPC attacks - -### Out of Scope: -The following components are out of scope for the bounty program -* Metrics: `/metrics` in the monorepo as well as https://metrics.solana.com -* Any encrypted credentials, auth tokens, etc. checked into the repo -* Bugs in dependencies. Please take them upstream! -* Attacks that require social engineering -* Any undeveloped automated tooling (scanners, etc) results. (OK with developed PoC) -* Any asset whose source code does not exist in this repository (including, but not limited -to, any and all web properties not explicitly listed on this page) - -### Eligibility: -* Submissions _MUST_ include an exploit proof-of-concept to be considered eligible -* The participant submitting the bug report shall follow the process outlined within this document -* Valid exploits can be eligible even if they are not successfully executed on a public cluster -* Multiple submissions for the same class of exploit are still eligible for compensation, though may be compensated at a lower rate, however these will be assessed on a case-by-case basis -* Participants must complete KYC and sign the participation agreement here when the registrations are open https://solana.foundation/kyc. Security exploits will still be assessed and open for submission at all times. This needs only be done prior to distribution of tokens. - -### Duplicate Reports -Compensation for duplicative reports will be split among reporters with first to report taking priority using the following equation -``` -R: total reports -ri: report priority -bi: bounty share - -bi = 2 ^ (R - ri) / ((2^R) - 1) -``` -#### Bounty Split Examples -| total reports | priority | share | | total reports | priority | share | | total reports | priority | share | -| ------------- | -------- | -----: | - | ------------- | -------- | -----: | - | ------------- | -------- | -----: | -| 1 | 1 | 100% | | 2 | 1 | 66.67% | | 5 | 1 | 51.61% | -| | | | | 2 | 2 | 33.33% | | 5 | 2 | 25.81% | -| 4 | 1 | 53.33% | | | | | | 5 | 3 | 12.90% | -| 4 | 2 | 26.67% | | 3 | 1 | 57.14% | | 5 | 4 | 6.45% | -| 4 | 3 | 13.33% | | 3 | 2 | 28.57% | | 5 | 5 | 3.23% | -| 4 | 4 | 6.67% | | 3 | 3 | 14.29% | | | | | - -### Payment of Bug Bounties: -* Bounties are currently awarded on a rolling/weekly basis and paid out within 30 days upon receipt of an invoice. -* The SOL/USD conversion rate used for payments is the market price of SOL (denominated in USD) at the end of the day the invoice is submitted by the researcher. -* The reference for this price is the Closing Price given by Coingecko.com on that date given here: https://www.coingecko.com/en/coins/solana/historical_data/usd#panel -* Bug bounties that are paid out in SOL are paid to stake accounts with a lockup expiring 12 months from the date of delivery of SOL. diff --git a/anchor b/anchor new file mode 160000 index 0000000000..4f52f41cbe --- /dev/null +++ b/anchor @@ -0,0 +1 @@ +Subproject commit 4f52f41cbeafb77d85c7b712516dfbeb5b86dd5f diff --git a/banking-bench/src/main.rs b/banking-bench/src/main.rs index f817df75e5..5ac679bf16 100644 --- a/banking-bench/src/main.rs +++ b/banking-bench/src/main.rs @@ -9,6 +9,7 @@ use { solana_core::{ banking_stage::BankingStage, banking_trace::{BankingPacketBatch, BankingTracer, BANKING_TRACE_DIR_DEFAULT_BYTE_LIMIT}, + bundle_stage::bundle_account_locker::BundleAccountLocker, }, solana_gossip::cluster_info::{ClusterInfo, Node}, solana_ledger::{ @@ -36,6 +37,7 @@ use { solana_streamer::socket::SocketAddrSpace, solana_tpu_client::tpu_client::DEFAULT_TPU_CONNECTION_POOL_SIZE, std::{ + collections::HashSet, sync::{atomic::Ordering, Arc, RwLock}, thread::sleep, time::{Duration, Instant}, @@ -57,9 +59,15 @@ fn check_txs( let now = Instant::now(); let mut no_bank = false; loop { - if let Ok((_bank, (entry, _tick_height))) = receiver.recv_timeout(Duration::from_millis(10)) + if let Ok(WorkingBankEntry { + bank: _, + entries_ticks, + }) = receiver.recv_timeout(Duration::from_millis(10)) { - total += entry.transactions.len(); + total += entries_ticks + .iter() + .map(|e| e.0.transactions.len()) + .sum::(); } if total >= ref_tx_count { break; @@ -459,6 +467,8 @@ fn main() { Arc::new(connection_cache), bank_forks.clone(), &Arc::new(PrioritizationFeeCache::new(0u64)), + HashSet::default(), + BundleAccountLocker::default(), ); // This is so that the signal_receiver does not go out of scope after the closure. diff --git a/banks-server/Cargo.toml b/banks-server/Cargo.toml index 4c29dcd30d..ffa0ac7ceb 100644 --- a/banks-server/Cargo.toml +++ b/banks-server/Cargo.toml @@ -15,12 +15,17 @@ crossbeam-channel = { workspace = true } futures = { workspace = true } solana-banks-interface = { workspace = true } solana-client = { workspace = true } +solana-gossip = { workspace = true } solana-runtime = { workspace = true } solana-sdk = { workspace = true } solana-send-transaction-service = { workspace = true } tarpc = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] } tokio-serde = { workspace = true, features = ["bincode"] } +tokio-stream = { workspace = true } + +[dev-dependencies] +solana-streamer = { workspace = true } [lib] crate-type = ["lib"] diff --git a/banks-server/src/banks_server.rs b/banks-server/src/banks_server.rs index cee47e8108..0eb057b2fc 100644 --- a/banks-server/src/banks_server.rs +++ b/banks-server/src/banks_server.rs @@ -8,6 +8,7 @@ use { TransactionSimulationDetails, TransactionStatus, }, solana_client::connection_cache::ConnectionCache, + solana_gossip::cluster_info::ClusterInfo, solana_runtime::{ bank::{Bank, TransactionExecutionResult, TransactionSimulationResult}, bank_forks::BankForks, @@ -439,7 +440,7 @@ pub async fn start_local_server( pub async fn start_tcp_server( listen_addr: SocketAddr, - tpu_addr: SocketAddr, + cluster_info: Arc, bank_forks: Arc>, block_commitment_cache: Arc>, connection_cache: Arc, @@ -464,7 +465,7 @@ pub async fn start_tcp_server( let (sender, receiver) = unbounded(); SendTransactionService::new::( - tpu_addr, + cluster_info.clone(), &bank_forks, None, receiver, diff --git a/bootstrap b/bootstrap new file mode 100755 index 0000000000..4f8955e5ca --- /dev/null +++ b/bootstrap @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -eu + +BANK_HASH=$(cargo run --release --bin solana-ledger-tool -- -l config/bootstrap-validator bank-hash) + +# increase max file handle limit +ulimit -Hn 1000000 + +# if above fails, run: +# sudo bash -c 'echo "* hard nofile 1000000" >> /etc/security/limits.conf' + +# NOTE: make sure tip-payment and tip-distribution program are deployed using the correct pubkeys +RUST_LOG=INFO,solana_core::bundle_stage=DEBUG \ + NDEBUG=1 ./multinode-demo/bootstrap-validator.sh \ + --wait-for-supermajority 0 \ + --expected-bank-hash "$BANK_HASH" \ + --block-engine-address http://127.0.0.1:1003 \ + --block-engine-auth-service-address http://127.0.0.1:1005 \ + --relayer-auth-service-address http://127.0.0.1:11226 \ + --relayer-address http://127.0.0.1:11226 \ + --rpc-pubsub-enable-block-subscription \ + --enable-rpc-transaction-history \ + --tip-payment-program-pubkey T1pyyaTNZsKv2WcRAB8oVnk93mLJw2XzjtVYqCsaHqt \ + --tip-distribution-program-pubkey 4R3gSG8BpU4t19KYj8CfnbtRpnT8gtk4dvTHxVRwc2r7 \ + --commission-bps 0 \ + --shred-receiver-address 127.0.0.1:1002 \ + --trust-relayer-packets \ + --trust-block-engine-packets diff --git a/bundle/Cargo.toml b/bundle/Cargo.toml new file mode 100644 index 0000000000..e8bdd2cb4d --- /dev/null +++ b/bundle/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "solana-bundle" +description = "Library related to handling bundles" +documentation = "https://docs.rs/solana-bundle" +readme = "../README.md" +version = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +[dependencies] +anchor-lang = { workspace = true } +itertools = { workspace = true } +log = { workspace = true } +serde = { workspace = true } +solana-ledger = { workspace = true } +solana-logger = { workspace = true } +solana-measure = { workspace = true } +solana-poh = { workspace = true } +solana-program-runtime = { workspace = true } +solana-runtime = { workspace = true } +solana-sdk = { workspace = true } +solana-transaction-status = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +solana-logger = { workspace = true } + +[lib] +crate-type = ["lib"] +name = "solana_bundle" diff --git a/bundle/src/bundle_execution.rs b/bundle/src/bundle_execution.rs new file mode 100644 index 0000000000..48a78194c2 --- /dev/null +++ b/bundle/src/bundle_execution.rs @@ -0,0 +1,1186 @@ +use { + itertools::izip, + log::*, + solana_ledger::token_balances::collect_token_balances, + solana_measure::{measure::Measure, measure_us}, + solana_program_runtime::timings::ExecuteTimings, + solana_runtime::{ + account_overrides::AccountOverrides, + accounts::TransactionLoadResult, + bank::{ + Bank, LoadAndExecuteTransactionsOutput, TransactionBalances, TransactionExecutionResult, + }, + transaction_batch::TransactionBatch, + }, + solana_sdk::{ + account::AccountSharedData, + bundle::SanitizedBundle, + pubkey::Pubkey, + saturating_add_assign, + signature::Signature, + transaction::{SanitizedTransaction, TransactionError, VersionedTransaction}, + }, + solana_transaction_status::{token_balances::TransactionTokenBalances, PreBalanceInfo}, + std::{ + cmp::{max, min}, + time::{Duration, Instant}, + }, + thiserror::Error, +}; + +#[derive(Clone, Default)] +pub struct BundleExecutionMetrics { + pub num_retries: u64, + pub collect_balances_us: u64, + pub load_execute_us: u64, + pub collect_pre_post_accounts_us: u64, + pub cache_accounts_us: u64, + pub execute_timings: ExecuteTimings, +} + +/// Contains the results from executing each TransactionBatch with a final result associated with it +/// Note that if !result.is_ok(), bundle_transaction_results will not contain the output for every transaction. +pub struct LoadAndExecuteBundleOutput<'a> { + bundle_transaction_results: Vec>, + result: LoadAndExecuteBundleResult<()>, + metrics: BundleExecutionMetrics, +} + +impl<'a> LoadAndExecuteBundleOutput<'a> { + pub fn executed_ok(&self) -> bool { + self.result.is_ok() + } + + pub fn result(&self) -> &LoadAndExecuteBundleResult<()> { + &self.result + } + + pub fn bundle_transaction_results_mut(&mut self) -> &'a mut [BundleTransactionsOutput] { + &mut self.bundle_transaction_results + } + + pub fn bundle_transaction_results(&self) -> &'a [BundleTransactionsOutput] { + &self.bundle_transaction_results + } + + pub fn executed_transaction_batches(&self) -> Vec> { + self.bundle_transaction_results + .iter() + .map(|br| br.executed_versioned_transactions()) + .collect() + } + + pub fn metrics(&self) -> BundleExecutionMetrics { + self.metrics.clone() + } +} + +#[derive(Clone, Debug, Error)] +pub enum LoadAndExecuteBundleError { + #[error("Bundle execution timed out")] + ProcessingTimeExceeded(Duration), + + #[error( + "A transaction in the bundle encountered a lock error: [signature={:?}, transaction_error={:?}]", + signature, + transaction_error + )] + LockError { + signature: Signature, + transaction_error: TransactionError, + }, + + #[error( + "A transaction in the bundle failed to execute: [signature={:?}, execution_result={:?}", + signature, + execution_result + )] + TransactionError { + signature: Signature, + // Box reduces the size between variants in the Error + execution_result: Box, + }, + + #[error("Invalid pre or post accounts")] + InvalidPreOrPostAccounts, +} + +pub struct BundleTransactionsOutput<'a> { + transactions: &'a [SanitizedTransaction], + load_and_execute_transactions_output: LoadAndExecuteTransactionsOutput, + pre_balance_info: PreBalanceInfo, + post_balance_info: (TransactionBalances, TransactionTokenBalances), + // the length of the outer vector should be the same as transactions.len() + // for indices that didn't get executed, expect a None. + pre_tx_execution_accounts: Vec>>, + post_tx_execution_accounts: Vec>>, +} + +impl<'a> BundleTransactionsOutput<'a> { + pub fn executed_versioned_transactions(&self) -> Vec { + self.transactions + .iter() + .zip( + self.load_and_execute_transactions_output + .execution_results + .iter(), + ) + .filter_map(|(tx, exec_result)| { + exec_result + .was_executed() + .then_some(tx.to_versioned_transaction()) + }) + .collect() + } + + pub fn executed_transactions(&self) -> Vec<&'a SanitizedTransaction> { + self.transactions + .iter() + .zip( + self.load_and_execute_transactions_output + .execution_results + .iter(), + ) + .filter_map(|(tx, exec_result)| exec_result.was_executed().then_some(tx)) + .collect() + } + + pub fn load_and_execute_transactions_output(&self) -> &LoadAndExecuteTransactionsOutput { + &self.load_and_execute_transactions_output + } + + pub fn transactions(&self) -> &[SanitizedTransaction] { + self.transactions + } + + pub fn loaded_transactions_mut(&mut self) -> &mut [TransactionLoadResult] { + &mut self + .load_and_execute_transactions_output + .loaded_transactions + } + + pub fn execution_results(&self) -> &[TransactionExecutionResult] { + &self.load_and_execute_transactions_output.execution_results + } + + pub fn pre_balance_info(&mut self) -> &mut PreBalanceInfo { + &mut self.pre_balance_info + } + + pub fn post_balance_info(&self) -> &(TransactionBalances, TransactionTokenBalances) { + &self.post_balance_info + } + + pub fn pre_tx_execution_accounts(&self) -> &Vec>> { + &self.pre_tx_execution_accounts + } + + pub fn post_tx_execution_accounts(&self) -> &Vec>> { + &self.post_tx_execution_accounts + } +} + +pub type LoadAndExecuteBundleResult = Result; + +/// Return an Error if a transaction was executed and reverted +/// NOTE: `execution_results` are zipped with `sanitized_txs` so it's expected a sanitized tx at +/// position i has a corresponding execution result at position i within the `execution_results` +/// slice +pub fn check_bundle_execution_results<'a>( + execution_results: &'a [TransactionExecutionResult], + sanitized_txs: &'a [SanitizedTransaction], +) -> Result<(), (&'a SanitizedTransaction, &'a TransactionExecutionResult)> { + for (exec_results, sanitized_tx) in execution_results.iter().zip(sanitized_txs) { + match exec_results { + TransactionExecutionResult::Executed { details, .. } => { + if details.status.is_err() { + return Err((sanitized_tx, exec_results)); + } + } + TransactionExecutionResult::NotExecuted(e) => { + if !matches!(e, TransactionError::AccountInUse) { + return Err((sanitized_tx, exec_results)); + } + } + } + } + Ok(()) +} + +/// Executing a bundle is somewhat complicated compared to executing single transactions. In order to +/// avoid duplicate logic for execution and simulation, this function can be leveraged. +/// +/// Assumptions for the caller: +/// - all transactions were signed properly +/// - user has deduplicated transactions inside the bundle +/// +/// TODO (LB): +/// - given a bundle with 3 transactions that write lock the following accounts: [A, B, C], on failure of B +/// we should add in the BundleTransactionsOutput of A and C and return the error for B. +#[allow(clippy::too_many_arguments)] +pub fn load_and_execute_bundle<'a>( + bank: &Bank, + bundle: &'a SanitizedBundle, + // Max blockhash age + max_age: usize, + // Upper bound on execution time for a bundle + max_processing_time: &Duration, + // Execution data logging + enable_cpi_recording: bool, + enable_log_recording: bool, + enable_return_data_recording: bool, + enable_balance_recording: bool, + log_messages_bytes_limit: &Option, + // simulation will not use the Bank's account locks when building the TransactionBatch + // if simulating on an unfrozen bank, this is helpful to avoid stalling replay and use whatever + // state the accounts are in at the current time + is_simulation: bool, + account_overrides: Option<&mut AccountOverrides>, + // these must be the same length as the bundle's transactions + // allows one to read account state before and after execution of each transaction in the bundle + // will use AccountsOverride + Bank + pre_execution_accounts: &Vec>>, + post_execution_accounts: &Vec>>, +) -> LoadAndExecuteBundleOutput<'a> { + if pre_execution_accounts.len() != post_execution_accounts.len() + || post_execution_accounts.len() != bundle.transactions.len() + { + return LoadAndExecuteBundleOutput { + bundle_transaction_results: vec![], + result: Err(LoadAndExecuteBundleError::InvalidPreOrPostAccounts), + metrics: BundleExecutionMetrics::default(), + }; + } + let mut binding = AccountOverrides::default(); + let account_overrides = account_overrides.unwrap_or(&mut binding); + + let mut chunk_start = 0; + let start_time = Instant::now(); + + let mut bundle_transaction_results = vec![]; + let mut metrics = BundleExecutionMetrics::default(); + + while chunk_start != bundle.transactions.len() { + if start_time.elapsed() > *max_processing_time { + trace!("bundle: {} took too long to execute", bundle.bundle_id); + return LoadAndExecuteBundleOutput { + bundle_transaction_results, + metrics, + result: Err(LoadAndExecuteBundleError::ProcessingTimeExceeded( + start_time.elapsed(), + )), + }; + } + + let chunk_end = min(bundle.transactions.len(), chunk_start.saturating_add(128)); + let chunk = &bundle.transactions[chunk_start..chunk_end]; + + // Note: these batches are dropped after execution and before record/commit, which is atypical + // compared to BankingStage which holds account locks until record + commit to avoid race conditions with + // other BankingStage threads. However, the caller of this method, BundleConsumer, will use BundleAccountLocks + // to hold RW locks across all transactions in a bundle until its processed. + let batch = if is_simulation { + bank.prepare_sequential_sanitized_batch_with_results_for_simulation(chunk) + } else { + bank.prepare_sequential_sanitized_batch_with_results(chunk) + }; + + debug!( + "bundle: {} batch num locks ok: {}", + bundle.bundle_id, + batch.lock_results().iter().filter(|lr| lr.is_ok()).count() + ); + + // Ensures that bundle lock results only return either: + // Ok(()) | Err(TransactionError::AccountInUse) + // If the error isn't one of those, the + if let Some((transaction, lock_failure)) = batch.check_bundle_lock_results() { + debug!( + "bundle: {} lock error; signature: {} error: {}", + bundle.bundle_id, + transaction.signature(), + lock_failure + ); + return LoadAndExecuteBundleOutput { + bundle_transaction_results, + metrics, + result: Err(LoadAndExecuteBundleError::LockError { + signature: *transaction.signature(), + transaction_error: lock_failure.clone(), + }), + }; + } + + let mut pre_balance_info = PreBalanceInfo::default(); + let (_, collect_balances_us) = measure_us!({ + if enable_balance_recording { + pre_balance_info.native = + bank.collect_balances_with_cache(&batch, Some(account_overrides)); + pre_balance_info.token = collect_token_balances( + bank, + &batch, + &mut pre_balance_info.mint_decimals, + Some(account_overrides), + ); + } + }); + saturating_add_assign!(metrics.collect_balances_us, collect_balances_us); + + let end = min( + chunk_start.saturating_add(batch.sanitized_transactions().len()), + pre_execution_accounts.len(), + ); + + let m = Measure::start("accounts"); + let accounts_requested = &pre_execution_accounts[chunk_start..end]; + let pre_tx_execution_accounts = + get_account_transactions(bank, account_overrides, accounts_requested, &batch); + saturating_add_assign!(metrics.collect_pre_post_accounts_us, m.end_as_us()); + + let (mut load_and_execute_transactions_output, load_execute_us) = measure_us!(bank + .load_and_execute_transactions( + &batch, + max_age, + enable_cpi_recording, + enable_log_recording, + enable_return_data_recording, + &mut metrics.execute_timings, + Some(account_overrides), + *log_messages_bytes_limit, + )); + debug!( + "bundle id: {} loaded_transactions: {:?}", + bundle.bundle_id, load_and_execute_transactions_output.loaded_transactions + ); + saturating_add_assign!(metrics.load_execute_us, load_execute_us); + + // All transactions within a bundle are expected to be executable + not fail + // If there's any transactions that executed and failed or didn't execute due to + // unexpected failures (not locking related), bail out of bundle execution early. + if let Err((failing_tx, exec_result)) = check_bundle_execution_results( + load_and_execute_transactions_output + .execution_results + .as_slice(), + batch.sanitized_transactions(), + ) { + // TODO (LB): we should try to return partial results here for successful bundles in a parallel batch. + // given a bundle that write locks the following accounts [[A], [B], [C]] + // when B fails, we could return the execution results for A and C, but leave B out. + // however, if we have bundle that write locks accounts [[A_1], [A_2], [B], [C]] and B fails + // we'll get the results for A_1 but not [A_2], [B], [C] due to the way this loop executes. + debug!( + "bundle: {} execution error; signature: {} error: {:?}", + bundle.bundle_id, + failing_tx.signature(), + exec_result + ); + return LoadAndExecuteBundleOutput { + bundle_transaction_results, + metrics, + result: Err(LoadAndExecuteBundleError::TransactionError { + signature: *failing_tx.signature(), + execution_result: Box::new(exec_result.clone()), + }), + }; + } + + // If none of the transactions were executed, most likely an AccountInUse error + // need to retry to ensure that all transactions in the bundle are executed. + if !load_and_execute_transactions_output + .execution_results + .iter() + .any(|r| r.was_executed()) + { + saturating_add_assign!(metrics.num_retries, 1); + debug!( + "bundle: {} no transaction executed, retrying", + bundle.bundle_id + ); + continue; + } + + // Cache accounts so next iterations of loop can load cached state instead of using + // AccountsDB, which will contain stale account state because results aren't committed + // to the bank yet. + // NOTE: Bank::collect_accounts_to_store does not handle any state changes related to + // failed, non-nonce transactions. + let m = Measure::start("cache"); + let accounts = bank.collect_accounts_to_store( + batch.sanitized_transactions(), + &load_and_execute_transactions_output.execution_results, + &mut load_and_execute_transactions_output.loaded_transactions, + ); + for (pubkey, data) in accounts { + account_overrides.set_account(pubkey, Some(data.clone())); + } + saturating_add_assign!(metrics.cache_accounts_us, m.end_as_us()); + + let end = max( + chunk_start.saturating_add(batch.sanitized_transactions().len()), + post_execution_accounts.len(), + ); + + let m = Measure::start("accounts"); + let accounts_requested = &post_execution_accounts[chunk_start..end]; + let post_tx_execution_accounts = + get_account_transactions(bank, account_overrides, accounts_requested, &batch); + saturating_add_assign!(metrics.collect_pre_post_accounts_us, m.end_as_us()); + + let ((post_balances, post_token_balances), collect_balances_us) = + measure_us!(if enable_balance_recording { + let post_balances = + bank.collect_balances_with_cache(&batch, Some(account_overrides)); + let post_token_balances = collect_token_balances( + bank, + &batch, + &mut pre_balance_info.mint_decimals, + Some(account_overrides), + ); + (post_balances, post_token_balances) + } else { + ( + TransactionBalances::default(), + TransactionTokenBalances::default(), + ) + }); + saturating_add_assign!(metrics.collect_balances_us, collect_balances_us); + + let processing_end = batch.lock_results().iter().position(|lr| lr.is_err()); + if let Some(end) = processing_end { + chunk_start = chunk_start.saturating_add(end); + } else { + chunk_start = chunk_end; + } + + bundle_transaction_results.push(BundleTransactionsOutput { + transactions: chunk, + load_and_execute_transactions_output, + pre_balance_info, + post_balance_info: (post_balances, post_token_balances), + pre_tx_execution_accounts, + post_tx_execution_accounts, + }); + } + + LoadAndExecuteBundleOutput { + bundle_transaction_results, + metrics, + result: Ok(()), + } +} + +fn get_account_transactions( + bank: &Bank, + account_overrides: &mut AccountOverrides, + accounts: &[Option>], + batch: &TransactionBatch, +) -> Vec>> { + let iter = izip!(batch.lock_results().iter(), accounts.iter()); + + iter.map(|(lock_result, accounts_requested)| { + if lock_result.is_ok() { + accounts_requested.as_ref().map(|accounts_requested| { + accounts_requested + .iter() + .map(|a| match account_overrides.get(a) { + None => (*a, bank.get_account(a).unwrap_or_default()), + Some(data) => (*a, data.clone()), + }) + .collect() + }) + } else { + None + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use { + crate::bundle_execution::{load_and_execute_bundle, LoadAndExecuteBundleError}, + assert_matches::assert_matches, + solana_ledger::genesis_utils::create_genesis_config, + solana_runtime::{bank::Bank, genesis_utils::GenesisConfigInfo}, + solana_sdk::{ + bundle::{derive_bundle_id_from_sanitized_transactions, SanitizedBundle}, + clock::MAX_PROCESSING_AGE, + pubkey::Pubkey, + signature::{Keypair, Signer}, + system_transaction::transfer, + transaction::{SanitizedTransaction, Transaction, TransactionError}, + }, + std::{ + sync::{Arc, Barrier}, + thread::{sleep, spawn}, + time::Duration, + }, + }; + + const MAX_PROCESSING_TIME: Duration = Duration::from_secs(1); + const LOG_MESSAGE_BYTES_LIMITS: Option = Some(100_000); + const MINT_AMOUNT_LAMPORTS: u64 = 1_000_000; + + fn create_simple_test_bank(lamports: u64) -> (GenesisConfigInfo, Arc) { + let genesis_config_info = create_genesis_config(lamports); + let bank = Arc::new(Bank::new_for_tests(&genesis_config_info.genesis_config)); + (genesis_config_info, bank) + } + + fn make_bundle(txs: &[Transaction]) -> SanitizedBundle { + let transactions: Vec<_> = txs + .iter() + .map(|tx| SanitizedTransaction::try_from_legacy_transaction(tx.clone()).unwrap()) + .collect(); + + let bundle_id = derive_bundle_id_from_sanitized_transactions(&transactions); + + SanitizedBundle { + transactions, + bundle_id, + } + } + + fn find_account_index(tx: &Transaction, account: &Pubkey) -> Option { + tx.message + .account_keys + .iter() + .position(|pubkey| account == pubkey) + } + + /// A single, valid bundle shall execute successfully and return the correct BundleTransactionsOutput content + #[test] + fn test_single_transaction_bundle_success() { + const TRANSFER_AMOUNT: u64 = 1_000; + let (genesis_config_info, bank) = create_simple_test_bank(MINT_AMOUNT_LAMPORTS); + let lamports_per_signature = bank + .get_lamports_per_signature_for_blockhash(&genesis_config_info.genesis_config.hash()) + .unwrap(); + + let kp = Keypair::new(); + let transactions = vec![transfer( + &genesis_config_info.mint_keypair, + &kp.pubkey(), + TRANSFER_AMOUNT, + genesis_config_info.genesis_config.hash(), + )]; + let bundle = make_bundle(&transactions); + let default_accounts = vec![None; bundle.transactions.len()]; + + let execution_result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &MAX_PROCESSING_TIME, + true, + true, + true, + true, + &LOG_MESSAGE_BYTES_LIMITS, + false, + None, + &default_accounts, + &default_accounts, + ); + + // make sure the bundle succeeded + assert!(execution_result.result.is_ok()); + + // check to make sure there was one batch returned with one transaction that was the same that was put in + assert_eq!(execution_result.bundle_transaction_results.len(), 1); + let tx_result = execution_result.bundle_transaction_results.get(0).unwrap(); + assert_eq!(tx_result.transactions.len(), 1); + assert_eq!(tx_result.transactions[0], bundle.transactions[0]); + + // make sure the transaction executed successfully + assert_eq!( + tx_result + .load_and_execute_transactions_output + .execution_results + .len(), + 1 + ); + let execution_result = tx_result + .load_and_execute_transactions_output + .execution_results + .get(0) + .unwrap(); + assert!(execution_result.was_executed()); + assert!(execution_result.was_executed_successfully()); + + // Make sure the post-balances are correct + assert_eq!(tx_result.pre_balance_info.native.len(), 1); + let post_tx_sol_balances = tx_result.post_balance_info.0.get(0).unwrap(); + + let minter_message_index = + find_account_index(&transactions[0], &genesis_config_info.mint_keypair.pubkey()) + .unwrap(); + let receiver_message_index = find_account_index(&transactions[0], &kp.pubkey()).unwrap(); + + assert_eq!( + post_tx_sol_balances[minter_message_index], + MINT_AMOUNT_LAMPORTS - lamports_per_signature - TRANSFER_AMOUNT + ); + assert_eq!( + post_tx_sol_balances[receiver_message_index], + TRANSFER_AMOUNT + ); + } + + /// Test a simple failure + #[test] + fn test_single_transaction_bundle_fail() { + const TRANSFER_AMOUNT: u64 = 1_000; + let (genesis_config_info, bank) = create_simple_test_bank(MINT_AMOUNT_LAMPORTS); + + // kp has no funds, transfer will fail + let kp = Keypair::new(); + let transactions = vec![transfer( + &kp, + &kp.pubkey(), + TRANSFER_AMOUNT, + genesis_config_info.genesis_config.hash(), + )]; + let bundle = make_bundle(&transactions); + + let default_accounts = vec![None; bundle.transactions.len()]; + let execution_result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &MAX_PROCESSING_TIME, + true, + true, + true, + true, + &LOG_MESSAGE_BYTES_LIMITS, + false, + None, + &default_accounts, + &default_accounts, + ); + + assert_eq!(execution_result.bundle_transaction_results.len(), 0); + + assert!(execution_result.result.is_err()); + + match execution_result.result.unwrap_err() { + LoadAndExecuteBundleError::ProcessingTimeExceeded(_) + | LoadAndExecuteBundleError::LockError { .. } + | LoadAndExecuteBundleError::InvalidPreOrPostAccounts => { + unreachable!(); + } + LoadAndExecuteBundleError::TransactionError { + signature, + execution_result, + } => { + assert_eq!(signature, *bundle.transactions[0].signature()); + assert!(!execution_result.was_executed()); + } + } + } + + /// Tests a multi-tx bundle that succeeds. Checks the returned results + #[test] + fn test_multi_transaction_bundle_success() { + const TRANSFER_AMOUNT_1: u64 = 100_000; + const TRANSFER_AMOUNT_2: u64 = 50_000; + const TRANSFER_AMOUNT_3: u64 = 10_000; + let (genesis_config_info, bank) = create_simple_test_bank(MINT_AMOUNT_LAMPORTS); + let lamports_per_signature = bank + .get_lamports_per_signature_for_blockhash(&genesis_config_info.genesis_config.hash()) + .unwrap(); + + // mint transfers 100k to 1 + // 1 transfers 50k to 2 + // 2 transfers 10k to 3 + // should get executed in 3 batches [[1], [2], [3]] + let kp1 = Keypair::new(); + let kp2 = Keypair::new(); + let kp3 = Keypair::new(); + let transactions = vec![ + transfer( + &genesis_config_info.mint_keypair, + &kp1.pubkey(), + TRANSFER_AMOUNT_1, + genesis_config_info.genesis_config.hash(), + ), + transfer( + &kp1, + &kp2.pubkey(), + TRANSFER_AMOUNT_2, + genesis_config_info.genesis_config.hash(), + ), + transfer( + &kp2, + &kp3.pubkey(), + TRANSFER_AMOUNT_3, + genesis_config_info.genesis_config.hash(), + ), + ]; + let bundle = make_bundle(&transactions); + + let default_accounts = vec![None; bundle.transactions.len()]; + let execution_result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &MAX_PROCESSING_TIME, + true, + true, + true, + true, + &LOG_MESSAGE_BYTES_LIMITS, + false, + None, + &default_accounts, + &default_accounts, + ); + + assert!(execution_result.result.is_ok()); + assert_eq!(execution_result.bundle_transaction_results.len(), 3); + + // first batch contains the first tx that was executed + assert_eq!( + execution_result.bundle_transaction_results[0].transactions, + bundle.transactions + ); + assert_eq!( + execution_result.bundle_transaction_results[0] + .load_and_execute_transactions_output + .execution_results + .len(), + 3 + ); + assert!(execution_result.bundle_transaction_results[0] + .load_and_execute_transactions_output + .execution_results[0] + .was_executed_successfully()); + assert_eq!( + execution_result.bundle_transaction_results[0] + .load_and_execute_transactions_output + .execution_results[1] + .flattened_result(), + Err(TransactionError::AccountInUse) + ); + assert_eq!( + execution_result.bundle_transaction_results[0] + .load_and_execute_transactions_output + .execution_results[2] + .flattened_result(), + Err(TransactionError::AccountInUse) + ); + assert_eq!( + execution_result.bundle_transaction_results[0] + .pre_balance_info + .native + .len(), + 3 + ); + assert_eq!( + execution_result.bundle_transaction_results[0] + .post_balance_info + .0 + .len(), + 3 + ); + + let minter_index = + find_account_index(&transactions[0], &genesis_config_info.mint_keypair.pubkey()) + .unwrap(); + let kp1_index = find_account_index(&transactions[0], &kp1.pubkey()).unwrap(); + + assert_eq!( + execution_result.bundle_transaction_results[0] + .post_balance_info + .0[0][minter_index], + MINT_AMOUNT_LAMPORTS - lamports_per_signature - TRANSFER_AMOUNT_1 + ); + + assert_eq!( + execution_result.bundle_transaction_results[0] + .post_balance_info + .0[0][kp1_index], + TRANSFER_AMOUNT_1 + ); + + // in the second batch, the second transaction was executed + assert_eq!( + execution_result.bundle_transaction_results[1] + .transactions + .to_owned(), + bundle.transactions[1..] + ); + assert_eq!( + execution_result.bundle_transaction_results[1] + .load_and_execute_transactions_output + .execution_results + .len(), + 2 + ); + assert!(execution_result.bundle_transaction_results[1] + .load_and_execute_transactions_output + .execution_results[0] + .was_executed_successfully()); + assert_eq!( + execution_result.bundle_transaction_results[1] + .load_and_execute_transactions_output + .execution_results[1] + .flattened_result(), + Err(TransactionError::AccountInUse) + ); + + assert_eq!( + execution_result.bundle_transaction_results[1] + .pre_balance_info + .native + .len(), + 2 + ); + assert_eq!( + execution_result.bundle_transaction_results[1] + .post_balance_info + .0 + .len(), + 2 + ); + + let kp1_index = find_account_index(&transactions[1], &kp1.pubkey()).unwrap(); + let kp2_index = find_account_index(&transactions[1], &kp2.pubkey()).unwrap(); + + assert_eq!( + execution_result.bundle_transaction_results[1] + .post_balance_info + .0[0][kp1_index], + TRANSFER_AMOUNT_1 - lamports_per_signature - TRANSFER_AMOUNT_2 + ); + + assert_eq!( + execution_result.bundle_transaction_results[1] + .post_balance_info + .0[0][kp2_index], + TRANSFER_AMOUNT_2 + ); + + // in the third batch, the third transaction was executed + assert_eq!( + execution_result.bundle_transaction_results[2] + .transactions + .to_owned(), + bundle.transactions[2..] + ); + assert_eq!( + execution_result.bundle_transaction_results[2] + .load_and_execute_transactions_output + .execution_results + .len(), + 1 + ); + assert!(execution_result.bundle_transaction_results[2] + .load_and_execute_transactions_output + .execution_results[0] + .was_executed_successfully()); + + assert_eq!( + execution_result.bundle_transaction_results[2] + .pre_balance_info + .native + .len(), + 1 + ); + assert_eq!( + execution_result.bundle_transaction_results[2] + .post_balance_info + .0 + .len(), + 1 + ); + + let kp2_index = find_account_index(&transactions[2], &kp2.pubkey()).unwrap(); + let kp3_index = find_account_index(&transactions[2], &kp3.pubkey()).unwrap(); + + assert_eq!( + execution_result.bundle_transaction_results[2] + .post_balance_info + .0[0][kp2_index], + TRANSFER_AMOUNT_2 - lamports_per_signature - TRANSFER_AMOUNT_3 + ); + + assert_eq!( + execution_result.bundle_transaction_results[2] + .post_balance_info + .0[0][kp3_index], + TRANSFER_AMOUNT_3 + ); + } + + /// Tests a multi-tx bundle with the middle transaction failing. + #[test] + fn test_multi_transaction_bundle_fails() { + let (genesis_config_info, bank) = create_simple_test_bank(MINT_AMOUNT_LAMPORTS); + + let kp1 = Keypair::new(); + let kp2 = Keypair::new(); + let kp3 = Keypair::new(); + let transactions = vec![ + transfer( + &genesis_config_info.mint_keypair, + &kp1.pubkey(), + 100_000, + genesis_config_info.genesis_config.hash(), + ), + transfer( + &kp2, + &kp3.pubkey(), + 100_000, + genesis_config_info.genesis_config.hash(), + ), + transfer( + &kp1, + &kp2.pubkey(), + 100_000, + genesis_config_info.genesis_config.hash(), + ), + ]; + let bundle = make_bundle(&transactions); + + let default_accounts = vec![None; bundle.transactions.len()]; + let execution_result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &MAX_PROCESSING_TIME, + true, + true, + true, + true, + &LOG_MESSAGE_BYTES_LIMITS, + false, + None, + &default_accounts, + &default_accounts, + ); + match execution_result.result.as_ref().unwrap_err() { + LoadAndExecuteBundleError::ProcessingTimeExceeded(_) + | LoadAndExecuteBundleError::LockError { .. } + | LoadAndExecuteBundleError::InvalidPreOrPostAccounts => { + unreachable!(); + } + + LoadAndExecuteBundleError::TransactionError { + signature, + execution_result: tx_failure, + } => { + assert_eq!(signature, bundle.transactions[1].signature()); + assert_eq!( + tx_failure.flattened_result(), + Err(TransactionError::AccountNotFound) + ); + assert_eq!(execution_result.bundle_transaction_results().len(), 0); + } + } + } + + /// Tests that when the max processing time is exceeded, the bundle is an error + #[test] + fn test_bundle_max_processing_time_exceeded() { + let (genesis_config_info, bank) = create_simple_test_bank(MINT_AMOUNT_LAMPORTS); + + let kp = Keypair::new(); + let transactions = vec![transfer( + &genesis_config_info.mint_keypair, + &kp.pubkey(), + 1, + genesis_config_info.genesis_config.hash(), + )]; + let bundle = make_bundle(&transactions); + + let locked_transfer = vec![SanitizedTransaction::from_transaction_for_tests(transfer( + &genesis_config_info.mint_keypair, + &kp.pubkey(), + 2, + genesis_config_info.genesis_config.hash(), + ))]; + + // locks it and prevents execution bc write lock on genesis_config_info.mint_keypair + kp.pubkey() held + let _batch = bank.prepare_sanitized_batch(&locked_transfer); + + let default = vec![None; bundle.transactions.len()]; + let result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &Duration::from_millis(100), + false, + false, + false, + false, + &None, + false, + None, + &default, + &default, + ); + assert_matches!( + result.result, + Err(LoadAndExecuteBundleError::ProcessingTimeExceeded(_)) + ); + } + + #[test] + fn test_simulate_bundle_with_locked_account_works() { + let (genesis_config_info, bank) = create_simple_test_bank(MINT_AMOUNT_LAMPORTS); + + let kp = Keypair::new(); + let transactions = vec![transfer( + &genesis_config_info.mint_keypair, + &kp.pubkey(), + 1, + genesis_config_info.genesis_config.hash(), + )]; + let bundle = make_bundle(&transactions); + + let locked_transfer = vec![SanitizedTransaction::from_transaction_for_tests(transfer( + &genesis_config_info.mint_keypair, + &kp.pubkey(), + 2, + genesis_config_info.genesis_config.hash(), + ))]; + + let _batch = bank.prepare_sanitized_batch(&locked_transfer); + + // simulation ignores account locks so you can simulate bundles on unfrozen banks + let default = vec![None; bundle.transactions.len()]; + let result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &Duration::from_millis(100), + false, + false, + false, + false, + &None, + true, + None, + &default, + &default, + ); + assert!(result.result.is_ok()); + } + + /// Creates a multi-tx bundle and temporarily locks the accounts for one of the transactions in a bundle. + /// Ensures the result is what's expected + #[test] + fn test_bundle_works_with_released_account_locks() { + let (genesis_config_info, bank) = create_simple_test_bank(MINT_AMOUNT_LAMPORTS); + let barrier = Arc::new(Barrier::new(2)); + + let kp = Keypair::new(); + + let transactions = vec![transfer( + &genesis_config_info.mint_keypair, + &kp.pubkey(), + 1, + genesis_config_info.genesis_config.hash(), + )]; + let bundle = Arc::new(make_bundle(&transactions)); + + let locked_transfer = vec![SanitizedTransaction::from_transaction_for_tests(transfer( + &genesis_config_info.mint_keypair, + &kp.pubkey(), + 2, + genesis_config_info.genesis_config.hash(), + ))]; + + // background thread locks the accounts for a bit then unlocks them + let thread = { + let barrier = barrier.clone(); + let bank = bank.clone(); + spawn(move || { + let batch = bank.prepare_sanitized_batch(&locked_transfer); + barrier.wait(); + sleep(Duration::from_millis(500)); + drop(batch); + }) + }; + + let _ = barrier.wait(); + + // load_and_execute_bundle should spin for a bit then process after the 500ms sleep is over + let default = vec![None; bundle.transactions.len()]; + let result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &Duration::from_secs(2), + false, + false, + false, + false, + &None, + false, + None, + &default, + &default, + ); + assert!(result.result.is_ok()); + + thread.join().unwrap(); + } + + /// Tests that when the max processing time is exceeded, the bundle is an error + #[test] + fn test_bundle_bad_pre_post_accounts() { + let (genesis_config_info, bank) = create_simple_test_bank(MINT_AMOUNT_LAMPORTS); + + let kp = Keypair::new(); + let transactions = vec![transfer( + &genesis_config_info.mint_keypair, + &kp.pubkey(), + 1, + genesis_config_info.genesis_config.hash(), + )]; + let bundle = make_bundle(&transactions); + + let result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &Duration::from_millis(100), + false, + false, + false, + false, + &None, + false, + None, + &vec![None; 2], + &vec![None; bundle.transactions.len()], + ); + assert_matches!( + result.result, + Err(LoadAndExecuteBundleError::InvalidPreOrPostAccounts) + ); + + let result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &Duration::from_millis(100), + false, + false, + false, + false, + &None, + false, + None, + &vec![None; bundle.transactions.len()], + &vec![None; 2], + ); + assert_matches!( + result.result, + Err(LoadAndExecuteBundleError::InvalidPreOrPostAccounts) + ); + } +} diff --git a/bundle/src/lib.rs b/bundle/src/lib.rs new file mode 100644 index 0000000000..a93e0d3d17 --- /dev/null +++ b/bundle/src/lib.rs @@ -0,0 +1,60 @@ +use { + crate::bundle_execution::LoadAndExecuteBundleError, + anchor_lang::error::Error, + serde::{Deserialize, Serialize}, + solana_poh::poh_recorder::PohRecorderError, + solana_sdk::pubkey::Pubkey, + thiserror::Error, +}; + +pub mod bundle_execution; + +#[derive(Error, Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TipError { + #[error("account is missing from bank: {0}")] + AccountMissing(Pubkey), + + #[error("Anchor error: {0}")] + AnchorError(String), + + #[error("Lock error")] + LockError, + + #[error("Error executing initialize programs")] + InitializeProgramsError, + + #[error("Error cranking tip programs")] + CrankTipError, +} + +impl From for TipError { + fn from(anchor_err: Error) -> Self { + match anchor_err { + Error::AnchorError(e) => Self::AnchorError(e.error_msg), + Error::ProgramError(e) => Self::AnchorError(e.to_string()), + } + } +} + +pub type BundleExecutionResult = Result; + +#[derive(Error, Debug, Clone)] +pub enum BundleExecutionError { + #[error("The bank has hit the max allotted time for processing transactions")] + BankProcessingTimeLimitReached, + + #[error("The bundle exceeds the cost model")] + ExceedsCostModel, + + #[error("Runtime error while executing the bundle: {0}")] + TransactionFailure(#[from] LoadAndExecuteBundleError), + + #[error("Error locking bundle because a transaction is malformed")] + LockError, + + #[error("PoH record error: {0}")] + PohRecordError(#[from] PohRecorderError), + + #[error("Tip payment error {0}")] + TipError(#[from] TipError), +} diff --git a/ci/buildkite-pipeline-in-disk.sh b/ci/buildkite-pipeline-in-disk.sh index 113b009aa4..6b41beda32 100644 --- a/ci/buildkite-pipeline-in-disk.sh +++ b/ci/buildkite-pipeline-in-disk.sh @@ -292,7 +292,7 @@ if [[ -n $BUILDKITE_TAG ]]; then "https://github.com/solana-labs/solana/releases/$BUILDKITE_TAG" # Jump directly to the secondary build to publish release artifacts quickly - trigger_secondary_step +# trigger_secondary_step exit 0 fi @@ -320,5 +320,5 @@ fi start_pipeline "Push pipeline for ${BUILDKITE_BRANCH:-?unknown branch?}" pull_or_push_steps wait_step -trigger_secondary_step +#trigger_secondary_step exit 0 diff --git a/ci/buildkite-pipeline.sh b/ci/buildkite-pipeline.sh index d7467f98b7..78b92d26c1 100755 --- a/ci/buildkite-pipeline.sh +++ b/ci/buildkite-pipeline.sh @@ -309,7 +309,7 @@ if [[ -n $BUILDKITE_TAG ]]; then "https://github.com/solana-labs/solana/releases/$BUILDKITE_TAG" # Jump directly to the secondary build to publish release artifacts quickly - trigger_secondary_step +# trigger_secondary_step exit 0 fi @@ -337,5 +337,5 @@ fi start_pipeline "Push pipeline for ${BUILDKITE_BRANCH:-?unknown branch?}" pull_or_push_steps wait_step -trigger_secondary_step +#trigger_secondary_step exit 0 diff --git a/ci/channel-info.sh b/ci/channel-info.sh index c82806454d..101583307f 100755 --- a/ci/channel-info.sh +++ b/ci/channel-info.sh @@ -11,7 +11,7 @@ here="$(dirname "$0")" # shellcheck source=ci/semver_bash/semver.sh source "$here"/semver_bash/semver.sh -remote=https://github.com/solana-labs/solana.git +remote=https://github.com/jito-foundation/jito-solana.git # Fetch all vX.Y.Z tags # diff --git a/ci/check-crates.sh b/ci/check-crates.sh index 655504ea11..d6a9ad9c39 100755 --- a/ci/check-crates.sh +++ b/ci/check-crates.sh @@ -31,6 +31,9 @@ printf "%s\n" "${files[@]}" error_count=0 for file in "${files[@]}"; do read -r crate_name package_publish workspace < <(toml get "$file" . | jq -r '(.package.name | tostring)+" "+(.package.publish | tostring)+" "+(.workspace | tostring)') + if [ "$crate_name" == "solana-bundle" ]; then + continue + fi echo "=== $crate_name ($file) ===" if [[ $package_publish = 'false' ]]; then diff --git a/core/Cargo.toml b/core/Cargo.toml index 46eae11c4a..3ce235ba1a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -14,6 +14,7 @@ edition = { workspace = true } codecov = { repository = "solana-labs/solana", branch = "master", service = "github" } [dependencies] +anchor-lang = { workspace = true } base64 = { workspace = true } bincode = { workspace = true } bs58 = { workspace = true } @@ -24,11 +25,16 @@ eager = { workspace = true } etcd-client = { workspace = true, features = ["tls"] } histogram = { workspace = true } itertools = { workspace = true } +jito-protos = { workspace = true } +jito-tip-distribution = { workspace = true } +jito-tip-payment = { workspace = true } lazy_static = { workspace = true } log = { workspace = true } lru = { workspace = true } min-max-heap = { workspace = true } num_enum = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } rand = { workspace = true } rand_chacha = { workspace = true } rayon = { workspace = true } @@ -37,6 +43,7 @@ serde = { workspace = true } serde_derive = { workspace = true } solana-address-lookup-table-program = { workspace = true } solana-bloom = { workspace = true } +solana-bundle = { workspace = true } solana-client = { workspace = true } solana-entry = { workspace = true } solana-frozen-abi = { workspace = true } @@ -54,6 +61,7 @@ solana-rayon-threadlimit = { workspace = true } solana-rpc = { workspace = true } solana-rpc-client-api = { workspace = true } solana-runtime = { workspace = true } +solana-runtime-plugin = { workspace = true } solana-sdk = { workspace = true } solana-send-transaction-service = { workspace = true } solana-streamer = { workspace = true } @@ -67,6 +75,7 @@ sys-info = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { version = "~1.14.1", features = ["full"] } +tonic = { workspace = true } trees = { workspace = true } [dev-dependencies] @@ -75,8 +84,10 @@ matches = { workspace = true } raptorq = { workspace = true } serde_json = { workspace = true } serial_test = { workspace = true } +solana-bundle = { workspace = true } solana-logger = { workspace = true } solana-program-runtime = { workspace = true } +solana-program-test = { workspace = true } solana-stake-program = { workspace = true } static_assertions = { workspace = true } systemstat = { workspace = true } @@ -87,6 +98,7 @@ sysctl = { workspace = true } [build-dependencies] rustc_version = { workspace = true } +tonic-build = { workspace = true } [[bench]] name = "banking_stage" diff --git a/core/benches/banking_stage.rs b/core/benches/banking_stage.rs index a6254292a5..22932e01a4 100644 --- a/core/benches/banking_stage.rs +++ b/core/benches/banking_stage.rs @@ -14,6 +14,7 @@ use { committer::Committer, consumer::Consumer, BankingStage, BankingStageStats, }, banking_trace::{BankingPacketBatch, BankingTracer}, + bundle_stage::bundle_account_locker::BundleAccountLocker, leader_slot_banking_stage_metrics::LeaderSlotMetricsTracker, qos_service::QosService, unprocessed_packet_batches::*, @@ -50,6 +51,7 @@ use { vote_state::VoteStateUpdate, vote_transaction::new_vote_state_update_transaction, }, std::{ + collections::HashSet, iter::repeat_with, sync::{atomic::Ordering, Arc, RwLock}, time::{Duration, Instant}, @@ -61,8 +63,15 @@ fn check_txs(receiver: &Arc>, ref_tx_count: usize) { let mut total = 0; let now = Instant::now(); loop { - if let Ok((_bank, (entry, _tick_height))) = receiver.recv_timeout(Duration::new(1, 0)) { - total += entry.transactions.len(); + if let Ok(WorkingBankEntry { + bank: _, + entries_ticks, + }) = receiver.recv_timeout(Duration::new(1, 0)) + { + total += entries_ticks + .iter() + .map(|e| e.0.transactions.len()) + .sum::(); } if total >= ref_tx_count { break; @@ -105,7 +114,14 @@ fn bench_consume_buffered(bencher: &mut Bencher) { ); let (s, _r) = unbounded(); let committer = Committer::new(None, s, Arc::new(PrioritizationFeeCache::new(0u64))); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); // This tests the performance of buffering packets. // If the packet buffers are copied, performance will be poor. bencher.iter(move || { @@ -299,7 +315,9 @@ fn bench_banking(bencher: &mut Bencher, tx_type: TransactionType) { None, Arc::new(ConnectionCache::new("connection_cache_test")), bank_forks, - &Arc::new(PrioritizationFeeCache::new(0u64)), + &Arc::new(PrioritizationFeeCache::default()), + HashSet::default(), + BundleAccountLocker::default(), ); let chunk_len = verified.len() / CHUNKS; diff --git a/core/benches/cluster_info.rs b/core/benches/cluster_info.rs index 04eb85c2dd..46da7fd03b 100644 --- a/core/benches/cluster_info.rs +++ b/core/benches/cluster_info.rs @@ -79,6 +79,7 @@ fn broadcast_shreds_bench(bencher: &mut Bencher) { &cluster_info, &bank_forks, &SocketAddrSpace::Unspecified, + &None, ) .unwrap(); }); diff --git a/core/benches/consumer.rs b/core/benches/consumer.rs index d6f908c577..875297a131 100644 --- a/core/benches/consumer.rs +++ b/core/benches/consumer.rs @@ -9,15 +9,15 @@ use { }, solana_core::{ banking_stage::{committer::Committer, consumer::Consumer}, + bundle_stage::bundle_account_locker::BundleAccountLocker, qos_service::QosService, }, - solana_entry::entry::Entry, solana_ledger::{ blockstore::Blockstore, genesis_utils::{create_genesis_config, GenesisConfigInfo}, }, solana_poh::{ - poh_recorder::{create_test_recorder, PohRecorder}, + poh_recorder::{create_test_recorder, PohRecorder, WorkingBankEntry}, poh_service::PohService, }, solana_runtime::bank::Bank, @@ -26,9 +26,12 @@ use { signer::Signer, stake_history::Epoch, system_program, system_transaction, transaction::SanitizedTransaction, }, - std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, RwLock, + std::{ + collections::HashSet, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, RwLock, + }, }, tempfile::TempDir, test::Bencher, @@ -81,7 +84,14 @@ fn create_consumer(poh_recorder: &RwLock) -> Consumer { let (replay_vote_sender, _replay_vote_receiver) = unbounded(); let committer = Committer::new(None, replay_vote_sender, Arc::default()); let transaction_recorder = poh_recorder.read().unwrap().new_recorder(); - Consumer::new(committer, transaction_recorder, QosService::new(0), None) + Consumer::new( + committer, + transaction_recorder, + QosService::new(0), + None, + HashSet::default(), + BundleAccountLocker::default(), + ) } struct BenchFrame { @@ -90,7 +100,7 @@ struct BenchFrame { exit: Arc, poh_recorder: Arc>, poh_service: PohService, - signal_receiver: Receiver<(Arc, (Entry, u64))>, + signal_receiver: Receiver, } fn setup(apply_cost_tracker_during_replay: bool) -> BenchFrame { diff --git a/core/benches/proto_to_packet.rs b/core/benches/proto_to_packet.rs new file mode 100644 index 0000000000..87f85f9c7f --- /dev/null +++ b/core/benches/proto_to_packet.rs @@ -0,0 +1,56 @@ +#![feature(test)] + +extern crate test; + +use { + jito_protos::proto::packet::{ + Meta as PbMeta, Packet as PbPacket, PacketBatch, PacketFlags as PbFlags, + }, + solana_core::proto_packet_to_packet, + solana_sdk::packet::{Packet, PACKET_DATA_SIZE}, + std::iter::repeat, + test::{black_box, Bencher}, +}; + +fn get_proto_packet(i: u8) -> PbPacket { + PbPacket { + data: repeat(i).take(PACKET_DATA_SIZE).collect(), + meta: Some(PbMeta { + size: PACKET_DATA_SIZE as u64, + addr: "255.255.255.255:65535".to_string(), + port: 65535, + flags: Some(PbFlags { + discard: false, + forwarded: false, + repair: false, + simple_vote_tx: false, + tracer_packet: false, + }), + sender_stake: 0, + }), + } +} + +#[bench] +fn bench_proto_to_packet(bencher: &mut Bencher) { + bencher.iter(|| { + black_box(proto_packet_to_packet(get_proto_packet(1))); + }); +} + +#[bench] +fn bench_batch_list_to_packets(bencher: &mut Bencher) { + let packet_batch = PacketBatch { + packets: (0..128).map(get_proto_packet).collect(), + }; + + bencher.iter(|| { + black_box( + packet_batch + .packets + .iter() + .map(|p| proto_packet_to_packet(p.clone())) + .collect::>(), + ); + }); +} diff --git a/core/benches/retransmit_stage.rs b/core/benches/retransmit_stage.rs index 9af88ae880..440c5353a4 100644 --- a/core/benches/retransmit_stage.rs +++ b/core/benches/retransmit_stage.rs @@ -124,6 +124,7 @@ fn bench_retransmitter(bencher: &mut Bencher) { shreds_receiver, Arc::default(), // solana_rpc::max_slots::MaxSlots None, + Arc::new(RwLock::new(None)), ); let mut index = 0; diff --git a/core/src/accounts_hash_verifier.rs b/core/src/accounts_hash_verifier.rs index eb2e7ba9d0..32d0c0bda7 100644 --- a/core/src/accounts_hash_verifier.rs +++ b/core/src/accounts_hash_verifier.rs @@ -75,7 +75,8 @@ impl AccountsHashVerifier { )) = Self::get_next_accounts_package( &accounts_package_sender, &accounts_package_receiver, - ) else { + ) + else { std::thread::sleep(LOOP_LIMITER); continue; }; @@ -302,7 +303,9 @@ impl AccountsHashVerifier { (accounts_hash.into(), accounts_hash, None) } CalcAccountsHashFlavor::Incremental => { - let AccountsPackageType::Snapshot(SnapshotType::IncrementalSnapshot(base_slot)) = accounts_package.package_type else { + let AccountsPackageType::Snapshot(SnapshotType::IncrementalSnapshot(base_slot)) = + accounts_package.package_type + else { panic!("Calculating incremental accounts hash requires a base slot"); }; let (base_accounts_hash, base_capitalization) = accounts_package diff --git a/core/src/admin_rpc_post_init.rs b/core/src/admin_rpc_post_init.rs index 110e1f5aa4..7373ffd5b3 100644 --- a/core/src/admin_rpc_post_init.rs +++ b/core/src/admin_rpc_post_init.rs @@ -1,10 +1,12 @@ use { + crate::proxy::{block_engine_stage::BlockEngineConfig, relayer_stage::RelayerConfig}, solana_gossip::cluster_info::ClusterInfo, solana_runtime::bank_forks::BankForks, solana_sdk::pubkey::Pubkey, std::{ collections::HashSet, - sync::{Arc, RwLock}, + net::SocketAddr, + sync::{Arc, Mutex, RwLock}, }, }; @@ -14,4 +16,7 @@ pub struct AdminRpcRequestMetadataPostInit { pub bank_forks: Arc>, pub vote_account: Pubkey, pub repair_whitelist: Arc>>, + pub block_engine_config: Arc>, + pub relayer_config: Arc>, + pub shred_receiver_address: Arc>>, } diff --git a/core/src/banking_stage.rs b/core/src/banking_stage.rs index dc75d24a53..337b4e40ea 100644 --- a/core/src/banking_stage.rs +++ b/core/src/banking_stage.rs @@ -12,6 +12,7 @@ use { crate::{ banking_stage::committer::Committer, banking_trace::BankingPacketReceiver, + bundle_stage::bundle_account_locker::BundleAccountLocker, latest_unprocessed_votes::{LatestUnprocessedVotes, VoteSource}, leader_slot_banking_stage_metrics::LeaderSlotMetricsTracker, qos_service::QosService, @@ -31,9 +32,14 @@ use { bank_forks::BankForks, prioritization_fee_cache::PrioritizationFeeCache, vote_sender_types::ReplayVoteSender, }, - solana_sdk::{feature_set::allow_votes_to_directly_update_vote_state, timing::AtomicInterval}, + solana_sdk::{ + feature_set::allow_votes_to_directly_update_vote_state, pubkey::Pubkey, + timing::AtomicInterval, + }, std::{ - cmp, env, + cmp, + collections::HashSet, + env, sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, RwLock, @@ -45,7 +51,7 @@ use { pub mod committer; pub mod consumer; -mod decision_maker; +pub(crate) mod decision_maker; mod forwarder; mod packet_receiver; @@ -311,6 +317,8 @@ impl BankingStage { connection_cache: Arc, bank_forks: Arc>, prioritization_fee_cache: &Arc, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, ) -> Self { Self::new_num_threads( cluster_info, @@ -325,6 +333,8 @@ impl BankingStage { connection_cache, bank_forks, prioritization_fee_cache, + blacklisted_accounts, + bundle_account_locker, ) } @@ -342,6 +352,8 @@ impl BankingStage { connection_cache: Arc, bank_forks: Arc>, prioritization_fee_cache: &Arc, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, ) -> Self { assert!(num_threads >= MIN_TOTAL_THREADS); // Single thread to generate entries from many banks. @@ -424,6 +436,8 @@ impl BankingStage { poh_recorder.read().unwrap().new_recorder(), QosService::new(id), log_messages_bytes_limit, + blacklisted_accounts.clone(), + bundle_account_locker.clone(), ); Builder::new() @@ -584,7 +598,7 @@ mod tests { crate::banking_trace::{BankingPacketBatch, BankingTracer}, crossbeam_channel::{unbounded, Receiver}, itertools::Itertools, - solana_entry::entry::{Entry, EntrySlice}, + solana_entry::entry::EntrySlice, solana_gossip::cluster_info::Node, solana_ledger::{ blockstore::Blockstore, @@ -598,6 +612,7 @@ mod tests { solana_poh::{ poh_recorder::{ create_test_recorder, PohRecorderError, Record, RecordTransactionsSummary, + WorkingBankEntry, }, poh_service::PohService, }, @@ -673,6 +688,8 @@ mod tests { Arc::new(ConnectionCache::new("connection_cache_test")), bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), + HashSet::default(), + BundleAccountLocker::default(), ); drop(non_vote_sender); drop(tpu_vote_sender); @@ -729,6 +746,8 @@ mod tests { Arc::new(ConnectionCache::new("connection_cache_test")), bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), + HashSet::default(), + BundleAccountLocker::default(), ); trace!("sending bank"); drop(non_vote_sender); @@ -741,7 +760,12 @@ mod tests { trace!("getting entries"); let entries: Vec<_> = entry_receiver .iter() - .map(|(_bank, (entry, _tick_height))| entry) + .flat_map( + |WorkingBankEntry { + bank: _, + entries_ticks, + }| entries_ticks.into_iter().map(|(e, _)| e), + ) .collect(); trace!("done"); assert_eq!(entries.len(), genesis_config.ticks_per_slot as usize); @@ -810,6 +834,8 @@ mod tests { Arc::new(ConnectionCache::new("connection_cache_test")), bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), + HashSet::default(), + BundleAccountLocker::default(), ); // fund another account so we can send 2 good transactions in a single batch. @@ -861,9 +887,14 @@ mod tests { bank.process_transaction(&fund_tx).unwrap(); //receive entries + ticks loop { - let entries: Vec = entry_receiver + let entries: Vec<_> = entry_receiver .iter() - .map(|(_bank, (entry, _tick_height))| entry) + .flat_map( + |WorkingBankEntry { + bank: _, + entries_ticks, + }| entries_ticks.into_iter().map(|(e, _)| e), + ) .collect(); assert!(entries.verify(&blockhash)); @@ -972,6 +1003,8 @@ mod tests { Arc::new(ConnectionCache::new("connection_cache_test")), bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), + HashSet::default(), + BundleAccountLocker::default(), ); // wait for banking_stage to eat the packets @@ -990,7 +1023,12 @@ mod tests { // check that the balance is what we expect. let entries: Vec<_> = entry_receiver .iter() - .map(|(_bank, (entry, _tick_height))| entry) + .flat_map( + |WorkingBankEntry { + bank: _, + entries_ticks, + }| entries_ticks.into_iter().map(|(e, _)| e), + ) .collect(); let bank = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); @@ -1051,15 +1089,19 @@ mod tests { system_transaction::transfer(&keypair2, &pubkey2, 1, genesis_config.hash()).into(), ]; - let _ = recorder.record_transactions(bank.slot(), txs.clone()); - let (_bank, (entry, _tick_height)) = entry_receiver.recv().unwrap(); + let _ = recorder.record_transactions(bank.slot(), vec![txs.clone()]); + let WorkingBankEntry { + bank, + entries_ticks, + } = entry_receiver.recv().unwrap(); + let entry = &entries_ticks.get(0).unwrap().0; assert_eq!(entry.transactions, txs); // Once bank is set to a new bank (setting bank.slot() + 1 in record_transactions), // record_transactions should throw MaxHeightReached let next_slot = bank.slot() + 1; let RecordTransactionsSummary { result, .. } = - recorder.record_transactions(next_slot, txs); + recorder.record_transactions(next_slot, vec![txs]); assert_matches!(result, Err(PohRecorderError::MaxHeightReached)); // Should receive nothing from PohRecorder b/c record failed assert!(entry_receiver.try_recv().is_err()); @@ -1166,6 +1208,8 @@ mod tests { Arc::new(ConnectionCache::new("connection_cache_test")), bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), + HashSet::default(), + BundleAccountLocker::default(), ); let keypairs = (0..100).map(|_| Keypair::new()).collect_vec(); diff --git a/core/src/banking_stage/committer.rs b/core/src/banking_stage/committer.rs index 81ae708d65..5925f64289 100644 --- a/core/src/banking_stage/committer.rs +++ b/core/src/banking_stage/committer.rs @@ -16,11 +16,9 @@ use { transaction_batch::TransactionBatch, vote_sender_types::ReplayVoteSender, }, - solana_sdk::{pubkey::Pubkey, saturating_add_assign}, - solana_transaction_status::{ - token_balances::TransactionTokenBalancesSet, TransactionTokenBalance, - }, - std::{collections::HashMap, sync::Arc}, + solana_sdk::saturating_add_assign, + solana_transaction_status::{token_balances::TransactionTokenBalancesSet, PreBalanceInfo}, + std::sync::Arc, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -29,13 +27,6 @@ pub enum CommitTransactionDetails { NotCommitted, } -#[derive(Default)] -pub(super) struct PreBalanceInfo { - pub native: Vec>, - pub token: Vec>, - pub mint_decimals: HashMap, -} - pub struct Committer { transaction_status_sender: Option, replay_vote_sender: ReplayVoteSender, @@ -144,7 +135,7 @@ impl Committer { let txs = batch.sanitized_transactions().to_vec(); let post_balances = bank.collect_balances(batch); let post_token_balances = - collect_token_balances(bank, batch, &mut pre_balance_info.mint_decimals); + collect_token_balances(bank, batch, &mut pre_balance_info.mint_decimals, None); let mut transaction_index = starting_transaction_index.unwrap_or_default(); let batch_transaction_indexes: Vec<_> = tx_results .execution_results diff --git a/core/src/banking_stage/consume_worker.rs b/core/src/banking_stage/consume_worker.rs index 856b5ad6f2..396c755217 100644 --- a/core/src/banking_stage/consume_worker.rs +++ b/core/src/banking_stage/consume_worker.rs @@ -132,6 +132,7 @@ mod tests { scheduler_messages::{TransactionBatchId, TransactionId}, tests::{create_slow_genesis_config, sanitize_transactions, simulate_poh}, }, + bundle_stage::bundle_account_locker::BundleAccountLocker, qos_service::QosService, }, crossbeam_channel::unbounded, @@ -149,6 +150,7 @@ mod tests { signature::Keypair, system_transaction, }, std::{ + collections::HashSet, sync::{atomic::AtomicBool, RwLock}, thread::JoinHandle, }, @@ -206,7 +208,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let (consume_sender, consume_receiver) = unbounded(); let (consumed_sender, consumed_receiver) = unbounded(); diff --git a/core/src/banking_stage/consumer.rs b/core/src/banking_stage/consumer.rs index 9ae1009672..79d3c1750b 100644 --- a/core/src/banking_stage/consumer.rs +++ b/core/src/banking_stage/consumer.rs @@ -4,7 +4,7 @@ use { BankingStageStats, }, crate::{ - banking_stage::committer::PreBalanceInfo, + bundle_stage::bundle_account_locker::BundleAccountLocker, immutable_deserialized_packet::ImmutableDeserializedPacket, leader_slot_banking_stage_metrics::{LeaderSlotMetricsTracker, ProcessTransactionsSummary}, leader_slot_banking_stage_timing_metrics::LeaderExecuteAndCommitTimings, @@ -18,7 +18,6 @@ use { BankStart, PohRecorderError, RecordTransactionsSummary, RecordTransactionsTimings, TransactionRecorder, }, - solana_program_runtime::timings::ExecuteTimings, solana_runtime::{ bank::{Bank, LoadAndExecuteTransactionsOutput, TransactionCheckResult}, transaction_batch::TransactionBatch, @@ -26,11 +25,15 @@ use { }, solana_sdk::{ clock::{Slot, FORWARD_TRANSACTIONS_TO_LEADER_AT_SLOT_OFFSET, MAX_PROCESSING_AGE}, - feature_set, saturating_add_assign, + feature_set, + pubkey::Pubkey, + saturating_add_assign, timing::timestamp, transaction::{self, AddressLoader, SanitizedTransaction, TransactionError}, }, + solana_transaction_status::PreBalanceInfo, std::{ + collections::HashSet, sync::{atomic::Ordering, Arc}, time::Instant, }, @@ -70,6 +73,8 @@ pub struct Consumer { transaction_recorder: TransactionRecorder, qos_service: QosService, log_messages_bytes_limit: Option, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, } impl Consumer { @@ -78,12 +83,16 @@ impl Consumer { transaction_recorder: TransactionRecorder, qos_service: QosService, log_messages_bytes_limit: Option, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, ) -> Self { Self { committer, transaction_recorder, qos_service, log_messages_bytes_limit, + blacklisted_accounts, + bundle_account_locker, } } @@ -113,6 +122,7 @@ impl Consumer { packets_to_process, ) }, + &self.blacklisted_accounts, ); if reached_end_of_slot { @@ -443,20 +453,26 @@ impl Consumer { cost_model_us, ) = measure_us!(self.qos_service.select_and_accumulate_transaction_costs( bank, + &mut bank.write_cost_tracker().unwrap(), txs, pre_results )); // Only lock accounts for those transactions are selected for the block; // Once accounts are locked, other threads cannot encode transactions that will modify the - // same account state + // same account state. + // BundleAccountLocker is used to prevent race conditions with bundled transactions from bundle stage + let bundle_account_locks = self.bundle_account_locker.account_locks(); let (batch, lock_us) = measure_us!(bank.prepare_sanitized_batch_with_results( txs, transaction_qos_cost_results.iter().map(|r| match r { Ok(_cost) => Ok(()), Err(err) => Err(err.clone()), - }) + }), + &bundle_account_locks.read_locks(), + &bundle_account_locks.write_locks() )); + drop(bundle_account_locks); // retryable_txs includes AccountInUse, WouldExceedMaxBlockCostLimit // WouldExceedMaxAccountCostLimit, WouldExceedMaxVoteCostLimit @@ -501,8 +517,9 @@ impl Consumer { .iter_mut() .for_each(|x| *x += chunk_offset); - let (cu, us) = - Self::accumulate_execute_units_and_time(&execute_and_commit_timings.execute_timings); + let (cu, us) = execute_and_commit_timings + .execute_timings + .accumulate_execute_units_and_time(); self.qos_service.accumulate_actual_execute_cu(cu); self.qos_service.accumulate_actual_execute_time(us); @@ -539,7 +556,7 @@ impl Consumer { if transaction_status_sender_enabled { pre_balance_info.native = bank.collect_balances(batch); pre_balance_info.token = - collect_token_balances(bank, batch, &mut pre_balance_info.mint_decimals) + collect_token_balances(bank, batch, &mut pre_balance_info.mint_decimals, None) } }); execute_and_commit_timings.collect_balances_us = collect_balances_us; @@ -588,7 +605,7 @@ impl Consumer { let (record_transactions_summary, record_us) = measure_us!(self .transaction_recorder - .record_transactions(bank.slot(), executed_transactions)); + .record_transactions(bank.slot(), vec![executed_transactions])); execute_and_commit_timings.record_us = record_us; let RecordTransactionsSummary { @@ -670,18 +687,6 @@ impl Consumer { } } - fn accumulate_execute_units_and_time(execute_timings: &ExecuteTimings) -> (u64, u64) { - execute_timings.details.per_program_timings.values().fold( - (0, 0), - |(units, times), program_timings| { - ( - units.saturating_add(program_timings.accumulated_units), - times.saturating_add(program_timings.accumulated_us), - ) - }, - ) - } - /// This function filters pending packets that are still valid /// # Arguments /// * `transactions` - a batch of transactions deserialized from packets @@ -749,7 +754,7 @@ mod tests { }, solana_perf::packet::Packet, solana_poh::poh_recorder::{PohRecorder, WorkingBankEntry}, - solana_program_runtime::timings::ProgramTiming, + solana_program_runtime::timings::{ExecuteTimings, ProgramTiming}, solana_rpc::transaction_status_service::TransactionStatusService, solana_runtime::{cost_model::CostModel, prioritization_fee_cache::PrioritizationFeeCache}, solana_sdk::{ @@ -808,7 +813,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let process_transactions_summary = consumer.process_transactions(&bank, &Instant::now(), &transactions); @@ -964,7 +976,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let process_transactions_batch_output = consumer.process_and_record_transactions(&bank, &transactions, 0); @@ -989,7 +1008,13 @@ mod tests { let mut done = false; // read entries until I find mine, might be ticks... - while let Ok((_bank, (entry, _tick_height))) = entry_receiver.recv() { + while let Ok(WorkingBankEntry { + bank, + entries_ticks, + }) = entry_receiver.recv() + { + assert!(entries_ticks.len() == 1); + let entry = &entries_ticks.get(0).unwrap().0; if !entry.is_tick() { trace!("got entry"); assert_eq!(entry.transactions.len(), transactions.len()); @@ -1091,7 +1116,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let process_transactions_batch_output = consumer.process_and_record_transactions(&bank, &transactions, 0); @@ -1177,7 +1209,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let get_block_cost = || bank.read_cost_tracker().unwrap().block_cost(); let get_tx_count = || bank.read_cost_tracker().unwrap().transaction_count(); @@ -1327,7 +1366,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let process_transactions_batch_output = consumer.process_and_record_transactions(&bank, &transactions, 0); @@ -1524,7 +1570,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder.clone(), QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder.clone(), + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let process_transactions_summary = consumer.process_transactions(&bank, &Instant::now(), &transactions); @@ -1649,7 +1702,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let _ = consumer.process_and_record_transactions(&bank, &transactions, 0); @@ -1787,7 +1847,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); let _ = consumer.process_and_record_transactions(&bank, &[sanitized_tx.clone()], 0); @@ -1847,7 +1914,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); // When the working bank in poh_recorder is None, no packets should be processed (consume will not be called) assert!(!poh_recorder.read().unwrap().has_bank()); @@ -1925,7 +1999,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); // When the working bank in poh_recorder is None, no packets should be processed assert!(!poh_recorder.read().unwrap().has_bank()); @@ -1977,7 +2058,14 @@ mod tests { replay_vote_sender, Arc::new(PrioritizationFeeCache::new(0u64)), ); - let consumer = Consumer::new(committer, recorder, QosService::new(1), None); + let consumer = Consumer::new( + committer, + recorder, + QosService::new(1), + None, + HashSet::default(), + BundleAccountLocker::default(), + ); // When the working bank in poh_recorder is None, no packets should be processed (consume will not be called) assert!(!poh_recorder.read().unwrap().has_bank()); @@ -2065,7 +2153,7 @@ mod tests { expected_units += n * 1000; } - let (units, us) = Consumer::accumulate_execute_units_and_time(&execute_timings); + let (units, us) = execute_timings.accumulate_execute_units_and_time(); assert_eq!(expected_units, units); assert_eq!(expected_us, us); diff --git a/core/src/banking_trace.rs b/core/src/banking_trace.rs index b245e1fb59..fd6c7c2d90 100644 --- a/core/src/banking_trace.rs +++ b/core/src/banking_trace.rs @@ -318,6 +318,7 @@ impl BankingTracer { } } +#[derive(Clone)] pub struct TracedSender { label: ChannelLabel, sender: Sender, diff --git a/core/src/broadcast_stage.rs b/core/src/broadcast_stage.rs index 3b6e4967c3..1f5b652383 100644 --- a/core/src/broadcast_stage.rs +++ b/core/src/broadcast_stage.rs @@ -36,7 +36,7 @@ use { std::{ collections::{HashMap, HashSet}, iter::repeat_with, - net::UdpSocket, + net::{SocketAddr, UdpSocket}, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, RwLock, @@ -87,6 +87,7 @@ impl BroadcastStageType { blockstore: Arc, bank_forks: Arc>, shred_version: u16, + shred_receiver_address: Arc>>, ) -> BroadcastStage { match self { BroadcastStageType::Standard => BroadcastStage::new( @@ -98,6 +99,7 @@ impl BroadcastStageType { blockstore, bank_forks, StandardBroadcastRun::new(shred_version), + shred_receiver_address, ), BroadcastStageType::FailEntryVerification => BroadcastStage::new( @@ -109,6 +111,7 @@ impl BroadcastStageType { blockstore, bank_forks, FailEntryVerificationBroadcastRun::new(shred_version), + Arc::new(RwLock::new(None)), ), BroadcastStageType::BroadcastFakeShreds => BroadcastStage::new( @@ -120,6 +123,7 @@ impl BroadcastStageType { blockstore, bank_forks, BroadcastFakeShredsRun::new(0, shred_version), + Arc::new(RwLock::new(None)), ), BroadcastStageType::BroadcastDuplicates(config) => BroadcastStage::new( @@ -131,6 +135,7 @@ impl BroadcastStageType { blockstore, bank_forks, BroadcastDuplicatesRun::new(shred_version, config.clone()), + Arc::new(RwLock::new(None)), ), } } @@ -151,6 +156,7 @@ trait BroadcastRun { cluster_info: &ClusterInfo, sock: &UdpSocket, bank_forks: &RwLock, + shred_receiver_address: &Arc>>, ) -> Result<()>; fn record(&mut self, receiver: &Mutex, blockstore: &Blockstore) -> Result<()>; } @@ -245,6 +251,7 @@ impl BroadcastStage { blockstore: Arc, bank_forks: Arc>, broadcast_stage_run: impl BroadcastRun + Send + 'static + Clone, + shred_receiver_address: Arc>>, ) -> Self { let (socket_sender, socket_receiver) = unbounded(); let (blockstore_sender, blockstore_receiver) = unbounded(); @@ -276,8 +283,16 @@ impl BroadcastStage { let mut bs_transmit = broadcast_stage_run.clone(); let cluster_info = cluster_info.clone(); let bank_forks = bank_forks.clone(); + let shred_receiver_address = shred_receiver_address.clone(); + let run_transmit = move || loop { - let res = bs_transmit.transmit(&socket_receiver, &cluster_info, &sock, &bank_forks); + let res = bs_transmit.transmit( + &socket_receiver, + &cluster_info, + &sock, + &bank_forks, + &shred_receiver_address, + ); let res = Self::handle_error(res, "solana-broadcaster-transmit"); if let Some(res) = res { return res; @@ -397,6 +412,7 @@ pub fn broadcast_shreds( cluster_info: &ClusterInfo, bank_forks: &RwLock, socket_addr_space: &SocketAddrSpace, + shred_receiver_address: &Option, ) -> Result<()> { let mut result = Ok(()); let mut shred_select = Measure::start("shred_select"); @@ -412,13 +428,22 @@ pub fn broadcast_shreds( let cluster_nodes = cluster_nodes_cache.get(slot, &root_bank, &working_bank, cluster_info); update_peer_stats(&cluster_nodes, last_datapoint_submit); - shreds.filter_map(move |shred| { - cluster_nodes - .get_broadcast_peer(&shred.id())? - .tvu(Protocol::UDP) - .ok() - .filter(|addr| socket_addr_space.check(addr)) - .map(|addr| (shred.payload(), addr)) + shreds.flat_map(move |shred| { + let mut addrs = Vec::with_capacity(2); + if let Some(shred_receiver_address) = shred_receiver_address { + addrs.push(*shred_receiver_address); + } + + if let Some(peer) = cluster_nodes.get_broadcast_peer(&shred.id()) { + if let Ok(tvu) = peer.tvu(Protocol::UDP) { + addrs.push(tvu); + } + } + + addrs + .into_iter() + .filter(|a| socket_addr_space.check(a)) + .map(move |addr| (shred.payload(), addr)) }) }) .collect(); @@ -620,6 +645,7 @@ pub mod test { blockstore.clone(), bank_forks, StandardBroadcastRun::new(0), + Arc::new(RwLock::new(None)), ); MockBroadcastStage { @@ -659,7 +685,10 @@ pub mod test { let ticks = create_ticks(max_tick_height - start_tick_height, 0, Hash::default()); for (i, tick) in ticks.into_iter().enumerate() { entry_sender - .send((bank.clone(), (tick, i as u64 + 1))) + .send(WorkingBankEntry { + bank: bank.clone(), + entries_ticks: vec![(tick, i as u64 + 1)], + }) .expect("Expect successful send to broadcast service"); } } diff --git a/core/src/broadcast_stage/broadcast_duplicates_run.rs b/core/src/broadcast_stage/broadcast_duplicates_run.rs index 08eec89838..050e87aa66 100644 --- a/core/src/broadcast_stage/broadcast_duplicates_run.rs +++ b/core/src/broadcast_stage/broadcast_duplicates_run.rs @@ -265,6 +265,7 @@ impl BroadcastRun for BroadcastDuplicatesRun { cluster_info: &ClusterInfo, sock: &UdpSocket, bank_forks: &RwLock, + _shred_receiver_addr: &Arc>>, ) -> Result<()> { let (shreds, _) = receiver.lock().unwrap().recv()?; if shreds.is_empty() { diff --git a/core/src/broadcast_stage/broadcast_fake_shreds_run.rs b/core/src/broadcast_stage/broadcast_fake_shreds_run.rs index 01650aad32..6b23429029 100644 --- a/core/src/broadcast_stage/broadcast_fake_shreds_run.rs +++ b/core/src/broadcast_stage/broadcast_fake_shreds_run.rs @@ -132,6 +132,7 @@ impl BroadcastRun for BroadcastFakeShredsRun { cluster_info: &ClusterInfo, sock: &UdpSocket, _bank_forks: &RwLock, + _shred_receiver_addr: &Arc>>, ) -> Result<()> { for (data_shreds, batch_info) in receiver.lock().unwrap().iter() { let fake = batch_info.is_some(); diff --git a/core/src/broadcast_stage/broadcast_utils.rs b/core/src/broadcast_stage/broadcast_utils.rs index f9485d59a9..6150bf4fec 100644 --- a/core/src/broadcast_stage/broadcast_utils.rs +++ b/core/src/broadcast_stage/broadcast_utils.rs @@ -36,13 +36,22 @@ pub(super) fn recv_slot_entries(receiver: &Receiver) -> Result 32 * ShredData::capacity(/*merkle_proof_size*/ None).unwrap() as u64; let timer = Duration::new(1, 0); let recv_start = Instant::now(); - let (mut bank, (entry, mut last_tick_height)) = receiver.recv_timeout(timer)?; - let mut entries = vec![entry]; + + let WorkingBankEntry { + mut bank, + entries_ticks, + } = receiver.recv_timeout(timer)?; + let mut last_tick_height = entries_ticks.iter().last().unwrap().1; + let mut entries: Vec = entries_ticks.into_iter().map(|(e, _)| e).collect(); + assert!(last_tick_height <= bank.max_tick_height()); // Drain channel while last_tick_height != bank.max_tick_height() { - let (try_bank, (entry, tick_height)) = match receiver.try_recv() { + let WorkingBankEntry { + bank: try_bank, + entries_ticks: new_entries_ticks, + } = match receiver.try_recv() { Ok(working_bank_entry) => working_bank_entry, Err(_) => break, }; @@ -53,8 +62,8 @@ pub(super) fn recv_slot_entries(receiver: &Receiver) -> Result entries.clear(); bank = try_bank; } - last_tick_height = tick_height; - entries.push(entry); + last_tick_height = new_entries_ticks.iter().last().unwrap().1; + entries.extend(new_entries_ticks.into_iter().map(|(entry, _)| entry)); assert!(last_tick_height <= bank.max_tick_height()); } @@ -65,11 +74,13 @@ pub(super) fn recv_slot_entries(receiver: &Receiver) -> Result while last_tick_height != bank.max_tick_height() && serialized_batch_byte_count < target_serialized_batch_byte_count { - let (try_bank, (entry, tick_height)) = - match receiver.recv_deadline(coalesce_start + ENTRY_COALESCE_DURATION) { - Ok(working_bank_entry) => working_bank_entry, - Err(_) => break, - }; + let WorkingBankEntry { + bank: try_bank, + entries_ticks: new_entries_ticks, + } = match receiver.recv_deadline(coalesce_start + ENTRY_COALESCE_DURATION) { + Ok(working_bank_entry) => working_bank_entry, + Err(_) => break, + }; // If the bank changed, that implies the previous slot was interrupted and we do not have to // broadcast its entries. if try_bank.slot() != bank.slot() { @@ -79,10 +90,12 @@ pub(super) fn recv_slot_entries(receiver: &Receiver) -> Result bank = try_bank; coalesce_start = Instant::now(); } - last_tick_height = tick_height; - let entry_bytes = serialized_size(&entry)?; - serialized_batch_byte_count += entry_bytes; - entries.push(entry); + last_tick_height = new_entries_ticks.iter().last().unwrap().1; + + for (entry, _) in &new_entries_ticks { + serialized_batch_byte_count += serialized_size(entry)?; + } + entries.extend(new_entries_ticks.into_iter().map(|(entry, _)| entry)); assert!(last_tick_height <= bank.max_tick_height()); } let time_coalesced = coalesce_start.elapsed(); @@ -139,7 +152,11 @@ mod tests { .map(|i| { let entry = Entry::new(&last_hash, 1, vec![tx.clone()]); last_hash = entry.hash; - s.send((bank1.clone(), (entry.clone(), i))).unwrap(); + s.send(WorkingBankEntry { + bank: bank1.clone(), + entries_ticks: vec![(entry.clone(), i)], + }) + .unwrap(); entry }) .collect(); @@ -173,11 +190,18 @@ mod tests { last_hash = entry.hash; // Interrupt slot 1 right before the last tick if tick_height == expected_last_height { - s.send((bank2.clone(), (entry.clone(), tick_height))) - .unwrap(); + s.send(WorkingBankEntry { + bank: bank2.clone(), + entries_ticks: vec![(entry.clone(), tick_height)], + }) + .unwrap(); Some(entry) } else { - s.send((bank1.clone(), (entry, tick_height))).unwrap(); + s.send(WorkingBankEntry { + bank: bank1.clone(), + entries_ticks: vec![(entry, tick_height)], + }) + .unwrap(); None } }) diff --git a/core/src/broadcast_stage/fail_entry_verification_broadcast_run.rs b/core/src/broadcast_stage/fail_entry_verification_broadcast_run.rs index e7b899ab0f..61ea979402 100644 --- a/core/src/broadcast_stage/fail_entry_verification_broadcast_run.rs +++ b/core/src/broadcast_stage/fail_entry_verification_broadcast_run.rs @@ -3,7 +3,7 @@ use { crate::cluster_nodes::ClusterNodesCache, solana_ledger::shred::{ProcessShredsStats, ReedSolomonCache, Shredder}, solana_sdk::{hash::Hash, signature::Keypair}, - std::{thread::sleep, time::Duration}, + std::{net::SocketAddr, thread::sleep, time::Duration}, }; pub const NUM_BAD_SLOTS: u64 = 10; @@ -162,6 +162,7 @@ impl BroadcastRun for FailEntryVerificationBroadcastRun { cluster_info: &ClusterInfo, sock: &UdpSocket, bank_forks: &RwLock, + shred_receiver_address: &Arc>>, ) -> Result<()> { let (shreds, _) = receiver.lock().unwrap().recv()?; broadcast_shreds( @@ -173,6 +174,7 @@ impl BroadcastRun for FailEntryVerificationBroadcastRun { cluster_info, bank_forks, cluster_info.socket_addr_space(), + &shred_receiver_address.read().unwrap(), ) } fn record(&mut self, receiver: &Mutex, blockstore: &Blockstore) -> Result<()> { diff --git a/core/src/broadcast_stage/standard_broadcast_run.rs b/core/src/broadcast_stage/standard_broadcast_run.rs index c22224b1fa..196883d453 100644 --- a/core/src/broadcast_stage/standard_broadcast_run.rs +++ b/core/src/broadcast_stage/standard_broadcast_run.rs @@ -17,7 +17,7 @@ use { signature::Keypair, timing::{duration_as_us, AtomicInterval}, }, - std::{sync::RwLock, time::Duration}, + std::{net::SocketAddr, sync::RwLock, time::Duration}, }; #[derive(Clone)] @@ -191,10 +191,22 @@ impl StandardBroadcastRun { let brecv = Arc::new(Mutex::new(brecv)); //data - let _ = self.transmit(&srecv, cluster_info, sock, bank_forks); + let _ = self.transmit( + &srecv, + cluster_info, + sock, + bank_forks, + &Arc::new(RwLock::new(None)), + ); let _ = self.record(&brecv, blockstore); //coding - let _ = self.transmit(&srecv, cluster_info, sock, bank_forks); + let _ = self.transmit( + &srecv, + cluster_info, + sock, + bank_forks, + &Arc::new(RwLock::new(None)), + ); let _ = self.record(&brecv, blockstore); Ok(()) } @@ -387,6 +399,7 @@ impl StandardBroadcastRun { shreds: Arc>, broadcast_shred_batch_info: Option, bank_forks: &RwLock, + shred_receiver_addr: &Option, ) -> Result<()> { trace!("Broadcasting {:?} shreds", shreds.len()); let mut transmit_stats = TransmitShredsStats::default(); @@ -402,6 +415,7 @@ impl StandardBroadcastRun { cluster_info, bank_forks, cluster_info.socket_addr_space(), + shred_receiver_addr, )?; transmit_time.stop(); @@ -471,9 +485,17 @@ impl BroadcastRun for StandardBroadcastRun { cluster_info: &ClusterInfo, sock: &UdpSocket, bank_forks: &RwLock, + shred_receiver_address: &Arc>>, ) -> Result<()> { let (shreds, batch_info) = receiver.lock().unwrap().recv()?; - self.broadcast(sock, cluster_info, shreds, batch_info, bank_forks) + self.broadcast( + sock, + cluster_info, + shreds, + batch_info, + bank_forks, + &shred_receiver_address.read().unwrap(), + ) } fn record(&mut self, receiver: &Mutex, blockstore: &Blockstore) -> Result<()> { let (shreds, slot_start_ts) = receiver.lock().unwrap().recv()?; diff --git a/core/src/bundle_stage.rs b/core/src/bundle_stage.rs new file mode 100644 index 0000000000..feff5512cc --- /dev/null +++ b/core/src/bundle_stage.rs @@ -0,0 +1,435 @@ +//! The `bundle_stage` processes bundles, which are list of transactions to be executed +//! sequentially and atomically. +use { + crate::{ + banking_stage::decision_maker::{BufferedPacketsDecision, DecisionMaker}, + bundle_stage::{ + bundle_account_locker::BundleAccountLocker, bundle_consumer::BundleConsumer, + bundle_packet_receiver::BundleReceiver, + bundle_reserved_space_manager::BundleReservedSpaceManager, + bundle_stage_leader_metrics::BundleStageLeaderMetrics, committer::Committer, + }, + packet_bundle::PacketBundle, + proxy::block_engine_stage::BlockBuilderFeeInfo, + qos_service::QosService, + tip_manager::TipManager, + unprocessed_transaction_storage::UnprocessedTransactionStorage, + }, + crossbeam_channel::{Receiver, RecvTimeoutError}, + solana_gossip::cluster_info::ClusterInfo, + solana_ledger::blockstore_processor::TransactionStatusSender, + solana_measure::measure, + solana_poh::poh_recorder::PohRecorder, + solana_runtime::{ + bank_forks::BankForks, block_cost_limits::MAX_BLOCK_UNITS, + prioritization_fee_cache::PrioritizationFeeCache, vote_sender_types::ReplayVoteSender, + }, + solana_sdk::timing::AtomicInterval, + std::{ + collections::VecDeque, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, RwLock, + }, + thread::{self, Builder, JoinHandle}, + time::{Duration, Instant}, + }, +}; + +pub mod bundle_account_locker; +mod bundle_consumer; +mod bundle_packet_deserializer; +mod bundle_packet_receiver; +mod bundle_reserved_space_manager; +pub(crate) mod bundle_stage_leader_metrics; +mod committer; + +const MAX_BUNDLE_RETRY_DURATION: Duration = Duration::from_millis(10); +const SLOT_BOUNDARY_CHECK_PERIOD: Duration = Duration::from_millis(10); + +// Stats emitted periodically +#[derive(Default)] +pub struct BundleStageLoopMetrics { + last_report: AtomicInterval, + id: u32, + + // total received + num_bundles_received: AtomicU64, + num_packets_received: AtomicU64, + + // newly buffered + newly_buffered_bundles_count: AtomicU64, + + // currently buffered + current_buffered_bundles_count: AtomicU64, + current_buffered_packets_count: AtomicU64, + + // buffered due to cost model + cost_model_buffered_bundles_count: AtomicU64, + cost_model_buffered_packets_count: AtomicU64, + + // number of bundles dropped during insertion + num_bundles_dropped: AtomicU64, + + // timings + receive_and_buffer_bundles_elapsed_us: AtomicU64, + process_buffered_bundles_elapsed_us: AtomicU64, +} + +impl BundleStageLoopMetrics { + fn new(id: u32) -> Self { + BundleStageLoopMetrics { + id, + ..BundleStageLoopMetrics::default() + } + } + + pub fn increment_num_bundles_received(&mut self, count: u64) { + self.num_bundles_received + .fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_num_packets_received(&mut self, count: u64) { + self.num_packets_received + .fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_newly_buffered_bundles_count(&mut self, count: u64) { + self.newly_buffered_bundles_count + .fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_current_buffered_bundles_count(&mut self, count: u64) { + self.current_buffered_bundles_count + .fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_current_buffered_packets_count(&mut self, count: u64) { + self.current_buffered_packets_count + .fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_cost_model_buffered_bundles_count(&mut self, count: u64) { + self.cost_model_buffered_bundles_count + .fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_cost_model_buffered_packets_count(&mut self, count: u64) { + self.cost_model_buffered_packets_count + .fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_num_bundles_dropped(&mut self, count: u64) { + self.num_bundles_dropped.fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_receive_and_buffer_bundles_elapsed_us(&mut self, count: u64) { + self.receive_and_buffer_bundles_elapsed_us + .fetch_add(count, Ordering::Relaxed); + } + + pub fn increment_process_buffered_bundles_elapsed_us(&mut self, count: u64) { + self.process_buffered_bundles_elapsed_us + .fetch_add(count, Ordering::Relaxed); + } +} + +impl BundleStageLoopMetrics { + fn maybe_report(&mut self, report_interval_ms: u64) { + if self.last_report.should_update(report_interval_ms) { + datapoint_info!( + "bundle_stage-loop_stats", + ("id", self.id, i64), + ( + "num_bundles_received", + self.num_bundles_received.swap(0, Ordering::Acquire) as i64, + i64 + ), + ( + "num_packets_received", + self.num_packets_received.swap(0, Ordering::Acquire) as i64, + i64 + ), + ( + "newly_buffered_bundles_count", + self.newly_buffered_bundles_count.swap(0, Ordering::Acquire) as i64, + i64 + ), + ( + "current_buffered_bundles_count", + self.current_buffered_bundles_count + .swap(0, Ordering::Acquire) as i64, + i64 + ), + ( + "current_buffered_packets_count", + self.current_buffered_packets_count + .swap(0, Ordering::Acquire) as i64, + i64 + ), + ( + "num_bundles_dropped", + self.num_bundles_dropped.swap(0, Ordering::Acquire) as i64, + i64 + ), + ( + "receive_and_buffer_bundles_elapsed_us", + self.receive_and_buffer_bundles_elapsed_us + .swap(0, Ordering::Acquire) as i64, + i64 + ), + ( + "process_buffered_bundles_elapsed_us", + self.process_buffered_bundles_elapsed_us + .swap(0, Ordering::Acquire) as i64, + i64 + ), + ); + } + } +} + +pub struct BundleStage { + bundle_thread: JoinHandle<()>, +} + +impl BundleStage { + #[allow(clippy::new_ret_no_self)] + #[allow(clippy::too_many_arguments)] + pub fn new( + cluster_info: &Arc, + poh_recorder: &Arc>, + bundle_receiver: Receiver>, + transaction_status_sender: Option, + replay_vote_sender: ReplayVoteSender, + log_messages_bytes_limit: Option, + exit: Arc, + tip_manager: TipManager, + bundle_account_locker: BundleAccountLocker, + block_builder_fee_info: &Arc>, + preallocated_bundle_cost: u64, + bank_forks: Arc>, + prioritization_fee_cache: &Arc, + ) -> Self { + Self::start_bundle_thread( + cluster_info, + poh_recorder, + bundle_receiver, + transaction_status_sender, + replay_vote_sender, + log_messages_bytes_limit, + exit, + tip_manager, + bundle_account_locker, + MAX_BUNDLE_RETRY_DURATION, + block_builder_fee_info, + preallocated_bundle_cost, + bank_forks, + prioritization_fee_cache, + ) + } + + pub fn join(self) -> thread::Result<()> { + self.bundle_thread.join() + } + + #[allow(clippy::too_many_arguments)] + fn start_bundle_thread( + cluster_info: &Arc, + poh_recorder: &Arc>, + bundle_receiver: Receiver>, + transaction_status_sender: Option, + replay_vote_sender: ReplayVoteSender, + log_message_bytes_limit: Option, + exit: Arc, + tip_manager: TipManager, + bundle_account_locker: BundleAccountLocker, + max_bundle_retry_duration: Duration, + block_builder_fee_info: &Arc>, + preallocated_bundle_cost: u64, + bank_forks: Arc>, + prioritization_fee_cache: &Arc, + ) -> Self { + const BUNDLE_STAGE_ID: u32 = 10_000; + let poh_recorder = poh_recorder.clone(); + let cluster_info = cluster_info.clone(); + + let mut bundle_receiver = + BundleReceiver::new(BUNDLE_STAGE_ID, bundle_receiver, bank_forks, Some(5)); + + let committer = Committer::new( + transaction_status_sender, + replay_vote_sender, + prioritization_fee_cache.clone(), + ); + let decision_maker = DecisionMaker::new(cluster_info.id(), poh_recorder.clone()); + + let unprocessed_bundle_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(1_000), + VecDeque::with_capacity(1_000), + ); + + let reserved_ticks = poh_recorder + .read() + .unwrap() + .ticks_per_slot() + .saturating_mul(8) + .saturating_div(10); + + // The first 80% of the block, based on poh ticks, has `preallocated_bundle_cost` less compute units. + // The last 20% has has full compute so blockspace is maximized if BundleStage is idle. + let reserved_space = BundleReservedSpaceManager::new( + MAX_BLOCK_UNITS, + preallocated_bundle_cost, + reserved_ticks, + ); + + let consumer = BundleConsumer::new( + committer, + poh_recorder.read().unwrap().new_recorder(), + QosService::new(BUNDLE_STAGE_ID), + log_message_bytes_limit, + tip_manager, + bundle_account_locker, + block_builder_fee_info.clone(), + max_bundle_retry_duration, + cluster_info, + reserved_space, + ); + + let bundle_thread = Builder::new() + .name("solBundleStgTx".to_string()) + .spawn(move || { + Self::process_loop( + &mut bundle_receiver, + decision_maker, + consumer, + BUNDLE_STAGE_ID, + unprocessed_bundle_storage, + exit, + ); + }) + .unwrap(); + + Self { bundle_thread } + } + + #[allow(clippy::too_many_arguments)] + fn process_loop( + bundle_receiver: &mut BundleReceiver, + decision_maker: DecisionMaker, + mut consumer: BundleConsumer, + id: u32, + mut unprocessed_bundle_storage: UnprocessedTransactionStorage, + exit: Arc, + ) { + let mut last_metrics_update = Instant::now(); + + let mut bundle_stage_metrics = BundleStageLoopMetrics::new(id); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(id); + + while !exit.load(Ordering::Relaxed) { + if !unprocessed_bundle_storage.is_empty() + || last_metrics_update.elapsed() >= SLOT_BOUNDARY_CHECK_PERIOD + { + let (_, process_buffered_packets_time) = measure!( + Self::process_buffered_bundles( + &decision_maker, + &mut consumer, + &mut unprocessed_bundle_storage, + &mut bundle_stage_leader_metrics, + ), + "process_buffered_packets", + ); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_process_buffered_packets_us(process_buffered_packets_time.as_us()); + last_metrics_update = Instant::now(); + } + + match bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_bundle_storage, + &mut bundle_stage_metrics, + &mut bundle_stage_leader_metrics, + ) { + Ok(_) | Err(RecvTimeoutError::Timeout) => (), + Err(RecvTimeoutError::Disconnected) => break, + } + + let bundle_storage = unprocessed_bundle_storage.bundle_storage().unwrap(); + bundle_stage_metrics.increment_current_buffered_bundles_count( + bundle_storage.unprocessed_bundles_len() as u64, + ); + bundle_stage_metrics.increment_current_buffered_packets_count( + bundle_storage.unprocessed_packets_len() as u64, + ); + bundle_stage_metrics.increment_cost_model_buffered_bundles_count( + bundle_storage.cost_model_buffered_bundles_len() as u64, + ); + bundle_stage_metrics.increment_cost_model_buffered_packets_count( + bundle_storage.cost_model_buffered_packets_len() as u64, + ); + bundle_stage_metrics.maybe_report(1_000); + } + } + + #[allow(clippy::too_many_arguments)] + fn process_buffered_bundles( + decision_maker: &DecisionMaker, + consumer: &mut BundleConsumer, + unprocessed_bundle_storage: &mut UnprocessedTransactionStorage, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + ) { + let (decision, make_decision_time) = + measure!(decision_maker.make_consume_or_forward_decision()); + + let (metrics_action, banking_stage_metrics_action) = + bundle_stage_leader_metrics.check_leader_slot_boundary(decision.bank_start()); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_make_decision_us(make_decision_time.as_us()); + + match decision { + // BufferedPacketsDecision::Consume means this leader is scheduled to be running at the moment. + // Execute, record, and commit as many bundles possible given time, compute, and other constraints. + BufferedPacketsDecision::Consume(bank_start) => { + // Take metrics action before consume packets (potentially resetting the + // slot metrics tracker to the next slot) so that we don't count the + // packet processing metrics from the next slot towards the metrics + // of the previous slot + bundle_stage_leader_metrics + .apply_action(metrics_action, banking_stage_metrics_action); + + let (_, consume_buffered_packets_time) = measure!( + consumer.consume_buffered_bundles( + &bank_start, + unprocessed_bundle_storage, + bundle_stage_leader_metrics, + ), + "consume_buffered_bundles", + ); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_consume_buffered_packets_us(consume_buffered_packets_time.as_us()); + } + // BufferedPacketsDecision::Forward means the leader is slot is far away. + // Bundles aren't forwarded because it breaks atomicity guarantees, so just drop them. + BufferedPacketsDecision::Forward => { + let (_num_bundles_cleared, _num_cost_model_buffered_bundles) = + unprocessed_bundle_storage.bundle_storage().unwrap().reset(); + + // TODO (LB): add metrics here for how many bundles were cleared + + bundle_stage_leader_metrics + .apply_action(metrics_action, banking_stage_metrics_action); + } + // BufferedPacketsDecision::ForwardAndHold | BufferedPacketsDecision::Hold means the validator + // is approaching the leader slot, hold bundles. Also, bundles aren't forwarded because it breaks + // atomicity guarantees + BufferedPacketsDecision::ForwardAndHold | BufferedPacketsDecision::Hold => { + bundle_stage_leader_metrics + .apply_action(metrics_action, banking_stage_metrics_action); + } + } + } +} diff --git a/core/src/bundle_stage/bundle_account_locker.rs b/core/src/bundle_stage/bundle_account_locker.rs new file mode 100644 index 0000000000..7382fcb5b0 --- /dev/null +++ b/core/src/bundle_stage/bundle_account_locker.rs @@ -0,0 +1,328 @@ +//! Handles pre-locking bundle accounts so that accounts bundles touch can be reserved ahead +// of time for execution. Also, ensures that ALL accounts mentioned across a bundle are locked +// to avoid race conditions between BundleStage and BankingStage. +// +// For instance, imagine a bundle with three transactions and the set of accounts for each transaction +// is: {{A, B}, {B, C}, {C, D}}. We need to lock A, B, and C even though only one is executed at a time. +// Imagine BundleStage is in the middle of processing {C, D} and we didn't have a lock on accounts {A, B, C}. +// In this situation, there's a chance that BankingStage can process a transaction containing A or B +// and commit the results before the bundle completes. By the time the bundle commits the new account +// state for {A, B, C}, A and B would be incorrect and the entries containing the bundle would be +// replayed improperly and that leader would have produced an invalid block. +use { + solana_runtime::bank::Bank, + solana_sdk::{bundle::SanitizedBundle, pubkey::Pubkey, transaction::TransactionAccountLocks}, + std::{ + collections::{hash_map::Entry, HashMap, HashSet}, + sync::{Arc, Mutex, MutexGuard}, + }, + thiserror::Error, +}; + +#[derive(Clone, Error, Debug)] +pub enum BundleAccountLockerError { + #[error("locking error")] + LockingError, +} + +pub type BundleAccountLockerResult = Result; + +pub struct LockedBundle<'a, 'b> { + bundle_account_locker: &'a BundleAccountLocker, + sanitized_bundle: &'b SanitizedBundle, + bank: Arc, +} + +impl<'a, 'b> LockedBundle<'a, 'b> { + pub fn new( + bundle_account_locker: &'a BundleAccountLocker, + sanitized_bundle: &'b SanitizedBundle, + bank: &Arc, + ) -> Self { + Self { + bundle_account_locker, + sanitized_bundle, + bank: bank.clone(), + } + } + + pub fn sanitized_bundle(&self) -> &SanitizedBundle { + self.sanitized_bundle + } +} + +// Automatically unlock bundle accounts when destructed +impl<'a, 'b> Drop for LockedBundle<'a, 'b> { + fn drop(&mut self) { + let _ = self + .bundle_account_locker + .unlock_bundle_accounts(self.sanitized_bundle, &self.bank); + } +} + +#[derive(Default, Clone)] +pub struct BundleAccountLocks { + read_locks: HashMap, + write_locks: HashMap, +} + +impl BundleAccountLocks { + pub fn read_locks(&self) -> HashSet { + self.read_locks.keys().cloned().collect() + } + + pub fn write_locks(&self) -> HashSet { + self.write_locks.keys().cloned().collect() + } + + pub fn lock_accounts( + &mut self, + read_locks: HashMap, + write_locks: HashMap, + ) { + for (acc, count) in read_locks { + *self.read_locks.entry(acc).or_insert(0) += count; + } + for (acc, count) in write_locks { + *self.write_locks.entry(acc).or_insert(0) += count; + } + } + + pub fn unlock_accounts( + &mut self, + read_locks: HashMap, + write_locks: HashMap, + ) { + for (acc, count) in read_locks { + if let Entry::Occupied(mut entry) = self.read_locks.entry(acc) { + let val = entry.get_mut(); + *val = val.saturating_sub(count); + if entry.get() == &0 { + let _ = entry.remove(); + } + } else { + warn!("error unlocking read-locked account, account: {:?}", acc); + } + } + for (acc, count) in write_locks { + if let Entry::Occupied(mut entry) = self.write_locks.entry(acc) { + let val = entry.get_mut(); + *val = val.saturating_sub(count); + if entry.get() == &0 { + let _ = entry.remove(); + } + } else { + warn!("error unlocking write-locked account, account: {:?}", acc); + } + } + } +} + +#[derive(Clone, Default)] +pub struct BundleAccountLocker { + account_locks: Arc>, +} + +impl BundleAccountLocker { + /// used in BankingStage during TransactionBatch construction to ensure that BankingStage + /// doesn't lock anything currently locked in the BundleAccountLocker + pub fn read_locks(&self) -> HashSet { + self.account_locks.lock().unwrap().read_locks() + } + + /// used in BankingStage during TransactionBatch construction to ensure that BankingStage + /// doesn't lock anything currently locked in the BundleAccountLocker + pub fn write_locks(&self) -> HashSet { + self.account_locks.lock().unwrap().write_locks() + } + + /// used in BankingStage during TransactionBatch construction to ensure that BankingStage + /// doesn't lock anything currently locked in the BundleAccountLocker + pub fn account_locks(&self) -> MutexGuard { + self.account_locks.lock().unwrap() + } + + /// Prepares a locked bundle and returns a LockedBundle containing locked accounts. + /// When a LockedBundle is dropped, the accounts are automatically unlocked + pub fn prepare_locked_bundle<'a, 'b>( + &'a self, + sanitized_bundle: &'b SanitizedBundle, + bank: &Arc, + ) -> BundleAccountLockerResult> { + let (read_locks, write_locks) = Self::get_read_write_locks(sanitized_bundle, bank)?; + + self.account_locks + .lock() + .unwrap() + .lock_accounts(read_locks, write_locks); + Ok(LockedBundle::new(self, sanitized_bundle, bank)) + } + + /// Unlocks bundle accounts. Note that LockedBundle::drop will auto-drop the bundle account locks + fn unlock_bundle_accounts( + &self, + sanitized_bundle: &SanitizedBundle, + bank: &Bank, + ) -> BundleAccountLockerResult<()> { + let (read_locks, write_locks) = Self::get_read_write_locks(sanitized_bundle, bank)?; + + self.account_locks + .lock() + .unwrap() + .unlock_accounts(read_locks, write_locks); + Ok(()) + } + + /// Returns the read and write locks for this bundle + /// Each lock type contains a HashMap which maps Pubkey to number of locks held + fn get_read_write_locks( + bundle: &SanitizedBundle, + bank: &Bank, + ) -> BundleAccountLockerResult<(HashMap, HashMap)> { + let transaction_locks: Vec = bundle + .transactions + .iter() + .filter_map(|tx| { + tx.get_account_locks(bank.get_transaction_account_lock_limit()) + .ok() + }) + .collect(); + + if transaction_locks.len() != bundle.transactions.len() { + return Err(BundleAccountLockerError::LockingError); + } + + let bundle_read_locks = transaction_locks + .iter() + .flat_map(|tx| tx.readonly.iter().map(|a| **a)); + let bundle_read_locks = + bundle_read_locks + .into_iter() + .fold(HashMap::new(), |mut map, acc| { + *map.entry(acc).or_insert(0) += 1; + map + }); + + let bundle_write_locks = transaction_locks + .iter() + .flat_map(|tx| tx.writable.iter().map(|a| **a)); + let bundle_write_locks = + bundle_write_locks + .into_iter() + .fold(HashMap::new(), |mut map, acc| { + *map.entry(acc).or_insert(0) += 1; + map + }); + + Ok((bundle_read_locks, bundle_write_locks)) + } +} + +#[cfg(test)] +mod tests { + use { + crate::{ + bundle_stage::bundle_account_locker::BundleAccountLocker, + immutable_deserialized_bundle::ImmutableDeserializedBundle, + packet_bundle::PacketBundle, + }, + solana_ledger::genesis_utils::create_genesis_config, + solana_perf::packet::PacketBatch, + solana_runtime::{ + bank::Bank, genesis_utils::GenesisConfigInfo, + transaction_error_metrics::TransactionErrorMetrics, + }, + solana_sdk::{ + packet::Packet, signature::Signer, signer::keypair::Keypair, system_program, + system_transaction::transfer, transaction::VersionedTransaction, + }, + std::{collections::HashSet, sync::Arc}, + }; + + #[test] + fn test_simple_lock_bundles() { + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(2); + let bank = Arc::new(Bank::new_no_wallclock_throttle_for_tests(&genesis_config)); + + let bundle_account_locker = BundleAccountLocker::default(); + + let kp0 = Keypair::new(); + let kp1 = Keypair::new(); + + let tx0 = VersionedTransaction::from(transfer( + &mint_keypair, + &kp0.pubkey(), + 1, + genesis_config.hash(), + )); + let tx1 = VersionedTransaction::from(transfer( + &mint_keypair, + &kp1.pubkey(), + 1, + genesis_config.hash(), + )); + + let mut packet_bundle0 = PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data(None, &tx0).unwrap()]), + bundle_id: tx0.signatures[0].to_string(), + }; + let mut packet_bundle1 = PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data(None, &tx1).unwrap()]), + bundle_id: tx1.signatures[0].to_string(), + }; + + let mut transaction_errors = TransactionErrorMetrics::default(); + + let sanitized_bundle0 = ImmutableDeserializedBundle::new(&mut packet_bundle0, None) + .unwrap() + .build_sanitized_bundle(&bank, &HashSet::default(), &mut transaction_errors) + .expect("sanitize bundle 0"); + let sanitized_bundle1 = ImmutableDeserializedBundle::new(&mut packet_bundle1, None) + .unwrap() + .build_sanitized_bundle(&bank, &HashSet::default(), &mut transaction_errors) + .expect("sanitize bundle 1"); + + let locked_bundle0 = bundle_account_locker + .prepare_locked_bundle(&sanitized_bundle0, &bank) + .unwrap(); + + assert_eq!( + bundle_account_locker.write_locks(), + HashSet::from_iter([mint_keypair.pubkey(), kp0.pubkey()]) + ); + assert_eq!( + bundle_account_locker.read_locks(), + HashSet::from_iter([system_program::id()]) + ); + + let locked_bundle1 = bundle_account_locker + .prepare_locked_bundle(&sanitized_bundle1, &bank) + .unwrap(); + assert_eq!( + bundle_account_locker.write_locks(), + HashSet::from_iter([mint_keypair.pubkey(), kp0.pubkey(), kp1.pubkey()]) + ); + assert_eq!( + bundle_account_locker.read_locks(), + HashSet::from_iter([system_program::id()]) + ); + + drop(locked_bundle0); + assert_eq!( + bundle_account_locker.write_locks(), + HashSet::from_iter([mint_keypair.pubkey(), kp1.pubkey()]) + ); + assert_eq!( + bundle_account_locker.read_locks(), + HashSet::from_iter([system_program::id()]) + ); + + drop(locked_bundle1); + assert!(bundle_account_locker.write_locks().is_empty()); + assert!(bundle_account_locker.read_locks().is_empty()); + } +} diff --git a/core/src/bundle_stage/bundle_consumer.rs b/core/src/bundle_stage/bundle_consumer.rs new file mode 100644 index 0000000000..83f3619fd6 --- /dev/null +++ b/core/src/bundle_stage/bundle_consumer.rs @@ -0,0 +1,1589 @@ +use { + crate::{ + banking_stage::committer::CommitTransactionDetails, + bundle_stage::{ + bundle_account_locker::{BundleAccountLocker, LockedBundle}, + bundle_reserved_space_manager::BundleReservedSpaceManager, + bundle_stage_leader_metrics::BundleStageLeaderMetrics, + committer::Committer, + }, + consensus_cache_updater::ConsensusCacheUpdater, + immutable_deserialized_bundle::ImmutableDeserializedBundle, + leader_slot_banking_stage_metrics::ProcessTransactionsSummary, + leader_slot_banking_stage_timing_metrics::LeaderExecuteAndCommitTimings, + proxy::block_engine_stage::BlockBuilderFeeInfo, + qos_service::QosService, + tip_manager::TipManager, + unprocessed_transaction_storage::UnprocessedTransactionStorage, + }, + solana_bundle::{ + bundle_execution::{load_and_execute_bundle, BundleExecutionMetrics}, + BundleExecutionError, BundleExecutionResult, TipError, + }, + solana_gossip::cluster_info::ClusterInfo, + solana_measure::{measure, measure_us}, + solana_poh::poh_recorder::{BankStart, RecordTransactionsSummary, TransactionRecorder}, + solana_runtime::{ + bank::Bank, cost_model::TransactionCost, transaction_error_metrics::TransactionErrorMetrics, + }, + solana_sdk::{ + bundle::SanitizedBundle, + clock::{Slot, MAX_PROCESSING_AGE}, + feature_set, + pubkey::Pubkey, + transaction::{self}, + }, + std::{ + collections::HashSet, + sync::{Arc, Mutex}, + time::{Duration, Instant}, + }, +}; + +pub struct ExecuteRecordCommitResult { + commit_transaction_details: Vec, + result: BundleExecutionResult<()>, + execution_metrics: BundleExecutionMetrics, + execute_and_commit_timings: LeaderExecuteAndCommitTimings, + transaction_error_counter: TransactionErrorMetrics, +} + +pub struct BundleConsumer { + committer: Committer, + transaction_recorder: TransactionRecorder, + qos_service: QosService, + log_messages_bytes_limit: Option, + + consensus_cache_updater: ConsensusCacheUpdater, + + tip_manager: TipManager, + last_tip_update_slot: Slot, + + blacklisted_accounts: HashSet, + + // Manages account locks across multiple transactions within a bundle to prevent race conditions + // with BankingStage + bundle_account_locker: BundleAccountLocker, + + block_builder_fee_info: Arc>, + + max_bundle_retry_duration: Duration, + + cluster_info: Arc, + + reserved_space: BundleReservedSpaceManager, +} + +impl BundleConsumer { + #[allow(clippy::too_many_arguments)] + pub fn new( + committer: Committer, + transaction_recorder: TransactionRecorder, + qos_service: QosService, + log_messages_bytes_limit: Option, + tip_manager: TipManager, + bundle_account_locker: BundleAccountLocker, + block_builder_fee_info: Arc>, + max_bundle_retry_duration: Duration, + cluster_info: Arc, + reserved_space: BundleReservedSpaceManager, + ) -> Self { + Self { + committer, + transaction_recorder, + qos_service, + log_messages_bytes_limit, + consensus_cache_updater: ConsensusCacheUpdater::default(), + tip_manager, + // MAX because sending tips during slot 0 in tests doesn't work + last_tip_update_slot: u64::MAX, + blacklisted_accounts: HashSet::default(), + bundle_account_locker, + block_builder_fee_info, + max_bundle_retry_duration, + cluster_info, + reserved_space, + } + } + + // A bundle is a series of transactions to be executed sequentially, atomically, and all-or-nothing. + // Sequentially: + // - Transactions are executed in order + // Atomically: + // - All transactions in a bundle get recoded to PoH and committed to the bank in the same slot. Account locks + // for all accounts in all transactions in a bundle are held during the entire execution to remove POH record race conditions + // with transactions in BankingStage. + // All-or-nothing: + // - All transactions are committed or none. Modified state for the entire bundle isn't recorded to PoH and committed to the + // bank until all transactions in the bundle have executed. + // + // Some corner cases to be aware of when working with BundleStage: + // A bundle is not allowed to call the Tip Payment program in a bundle (or BankingStage). + // - This is to avoid stealing of tips by malicious parties with bundles that crank the tip + // payment program and set the tip receiver to themself. + // A bundle is not allowed to touch consensus-related accounts + // - This is to avoid stalling the voting BankingStage threads. + pub fn consume_buffered_bundles( + &mut self, + bank_start: &BankStart, + unprocessed_transaction_storage: &mut UnprocessedTransactionStorage, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + ) { + self.maybe_update_blacklist(bank_start); + self.reserved_space.tick(&bank_start.working_bank); + + let reached_end_of_slot = unprocessed_transaction_storage.process_bundles( + bank_start.working_bank.clone(), + bundle_stage_leader_metrics, + &self.blacklisted_accounts, + |bundles, bundle_stage_leader_metrics| { + Self::do_process_bundles( + &self.bundle_account_locker, + &self.tip_manager, + &mut self.last_tip_update_slot, + &self.cluster_info, + &self.block_builder_fee_info, + &self.committer, + &self.transaction_recorder, + &self.qos_service, + &self.log_messages_bytes_limit, + self.max_bundle_retry_duration, + &self.reserved_space, + bundles, + bank_start, + bundle_stage_leader_metrics, + ) + }, + ); + + if reached_end_of_slot { + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .set_end_of_slot_unprocessed_buffer_len( + unprocessed_transaction_storage.len() as u64 + ); + } + } + + /// Blacklist is updated with the tip payment program + any consensus accounts. + fn maybe_update_blacklist(&mut self, bank_start: &BankStart) { + if self + .consensus_cache_updater + .maybe_update(&bank_start.working_bank) + { + self.blacklisted_accounts = self + .consensus_cache_updater + .consensus_accounts_cache() + .union(&HashSet::from_iter([self + .tip_manager + .tip_payment_program_id()])) + .cloned() + .collect(); + + debug!( + "updated blacklist with {} accounts", + self.blacklisted_accounts.len() + ); + } + } + + #[allow(clippy::too_many_arguments)] + fn do_process_bundles( + bundle_account_locker: &BundleAccountLocker, + tip_manager: &TipManager, + last_tip_updated_slot: &mut Slot, + cluster_info: &Arc, + block_builder_fee_info: &Arc>, + committer: &Committer, + recorder: &TransactionRecorder, + qos_service: &QosService, + log_messages_bytes_limit: &Option, + max_bundle_retry_duration: Duration, + reserved_space: &BundleReservedSpaceManager, + bundles: &[(ImmutableDeserializedBundle, SanitizedBundle)], + bank_start: &BankStart, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + ) -> Vec> { + // BundleAccountLocker holds RW locks for ALL accounts in ALL transactions within a single bundle. + // By pre-locking bundles before they're ready to be processed, it will prevent BankingStage from + // grabbing those locks so BundleStage can process as fast as possible. + // A LockedBundle is similar to TransactionBatch; once its dropped the locks are released. + #[allow(clippy::needless_collect)] + let (locked_bundle_results, locked_bundles_elapsed) = measure!( + bundles + .iter() + .map(|(_, sanitized_bundle)| { + bundle_account_locker + .prepare_locked_bundle(sanitized_bundle, &bank_start.working_bank) + }) + .collect::>(), + "locked_bundles_elapsed" + ); + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_locked_bundle_elapsed_us(locked_bundles_elapsed.as_us()); + + let (execution_results, execute_locked_bundles_elapsed) = measure!(locked_bundle_results + .into_iter() + .map(|r| match r { + Ok(locked_bundle) => { + let (r, measure) = measure_us!(Self::process_bundle( + bundle_account_locker, + tip_manager, + last_tip_updated_slot, + cluster_info, + block_builder_fee_info, + committer, + recorder, + qos_service, + log_messages_bytes_limit, + max_bundle_retry_duration, + reserved_space, + &locked_bundle, + bank_start, + bundle_stage_leader_metrics, + )); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_process_packets_transactions_us(measure); + r + } + Err(_) => { + Err(BundleExecutionError::LockError) + } + }) + .collect::>()); + + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_execute_locked_bundles_elapsed_us(execute_locked_bundles_elapsed.as_us()); + execution_results.iter().for_each(|result| { + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_bundle_execution_result(result); + }); + + execution_results + } + + #[allow(clippy::too_many_arguments)] + fn process_bundle( + bundle_account_locker: &BundleAccountLocker, + tip_manager: &TipManager, + last_tip_updated_slot: &mut Slot, + cluster_info: &Arc, + block_builder_fee_info: &Arc>, + committer: &Committer, + recorder: &TransactionRecorder, + qos_service: &QosService, + log_messages_bytes_limit: &Option, + max_bundle_retry_duration: Duration, + reserved_space: &BundleReservedSpaceManager, + locked_bundle: &LockedBundle, + bank_start: &BankStart, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + ) -> Result<(), BundleExecutionError> { + if !Bank::should_bank_still_be_processing_txs( + &bank_start.bank_creation_time, + bank_start.working_bank.ns_per_slot, + ) { + return Err(BundleExecutionError::BankProcessingTimeLimitReached); + } + + if Self::bundle_touches_tip_pdas( + locked_bundle.sanitized_bundle(), + &tip_manager.get_tip_accounts(), + ) && bank_start.working_bank.slot() != *last_tip_updated_slot + { + let start = Instant::now(); + let result = Self::handle_tip_programs( + bundle_account_locker, + tip_manager, + cluster_info, + block_builder_fee_info, + committer, + recorder, + qos_service, + log_messages_bytes_limit, + max_bundle_retry_duration, + reserved_space, + bank_start, + bundle_stage_leader_metrics, + ); + + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_change_tip_receiver_elapsed_us(start.elapsed().as_micros() as u64); + + result?; + + *last_tip_updated_slot = bank_start.working_bank.slot(); + } + + Self::update_qos_and_execute_record_commit_bundle( + committer, + recorder, + qos_service, + log_messages_bytes_limit, + max_bundle_retry_duration, + reserved_space, + locked_bundle.sanitized_bundle(), + bank_start, + bundle_stage_leader_metrics, + )?; + + Ok(()) + } + + /// The validator needs to manage state on two programs related to tips + #[allow(clippy::too_many_arguments)] + fn handle_tip_programs( + bundle_account_locker: &BundleAccountLocker, + tip_manager: &TipManager, + cluster_info: &Arc, + block_builder_fee_info: &Arc>, + committer: &Committer, + recorder: &TransactionRecorder, + qos_service: &QosService, + log_messages_bytes_limit: &Option, + max_bundle_retry_duration: Duration, + reserved_space: &BundleReservedSpaceManager, + bank_start: &BankStart, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + ) -> Result<(), BundleExecutionError> { + debug!("handle_tip_programs"); + + // This will setup the tip payment and tip distribution program if they haven't been + // initialized yet, which is typically helpful for local validators. On mainnet and testnet, + // this code should never run. + let keypair = cluster_info.keypair().clone(); + let initialize_tip_programs_bundle = + tip_manager.get_initialize_tip_programs_bundle(&bank_start.working_bank, &keypair); + if let Some(bundle) = initialize_tip_programs_bundle { + debug!( + "initializing tip programs with {} transactions, bundle id: {}", + bundle.transactions.len(), + bundle.bundle_id + ); + + let locked_init_tip_programs_bundle = bundle_account_locker + .prepare_locked_bundle(&bundle, &bank_start.working_bank) + .map_err(|_| BundleExecutionError::TipError(TipError::LockError))?; + + Self::update_qos_and_execute_record_commit_bundle( + committer, + recorder, + qos_service, + log_messages_bytes_limit, + max_bundle_retry_duration, + reserved_space, + locked_init_tip_programs_bundle.sanitized_bundle(), + bank_start, + bundle_stage_leader_metrics, + ) + .map_err(|e| { + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_num_init_tip_account_errors(1); + error!( + "bundle: {} error initializing tip programs: {:?}", + locked_init_tip_programs_bundle.sanitized_bundle().bundle_id, + e + ); + BundleExecutionError::TipError(TipError::InitializeProgramsError) + })?; + + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_num_init_tip_account_ok(1); + } + + // There are two frequently run internal cranks inside the jito-solana validator that have to do with managing MEV tips. + // One is initialize the TipDistributionAccount, which is a validator's "tip piggy bank" for an epoch + // The other is ensuring the tip_receiver is configured correctly to ensure tips are routed to the correct + // address. The validator must drain the tip accounts to the previous tip receiver before setting the tip receiver to + // themselves. + + let kp = cluster_info.keypair().clone(); + let tip_crank_bundle = tip_manager.get_tip_programs_crank_bundle( + &bank_start.working_bank, + &kp, + &block_builder_fee_info.lock().unwrap(), + )?; + debug!("tip_crank_bundle is_some: {}", tip_crank_bundle.is_some()); + + if let Some(bundle) = tip_crank_bundle { + info!( + "bundle id: {} cranking tip programs with {} transactions", + bundle.bundle_id, + bundle.transactions.len() + ); + + let locked_tip_crank_bundle = bundle_account_locker + .prepare_locked_bundle(&bundle, &bank_start.working_bank) + .map_err(|_| BundleExecutionError::TipError(TipError::LockError))?; + + Self::update_qos_and_execute_record_commit_bundle( + committer, + recorder, + qos_service, + log_messages_bytes_limit, + max_bundle_retry_duration, + reserved_space, + locked_tip_crank_bundle.sanitized_bundle(), + bank_start, + bundle_stage_leader_metrics, + ) + .map_err(|e| { + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_num_change_tip_receiver_errors(1); + error!( + "bundle: {} error cranking tip programs: {:?}", + locked_tip_crank_bundle.sanitized_bundle().bundle_id, + e + ); + BundleExecutionError::TipError(TipError::CrankTipError) + })?; + + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_num_change_tip_receiver_ok(1); + } + + Ok(()) + } + + /// Reserves space for the entire bundle up-front to ensure the entire bundle can execute. + /// Rolls back the reserved space if there's not enough blockspace for all transactions in the bundle. + fn reserve_bundle_blockspace( + qos_service: &QosService, + reserved_space: &BundleReservedSpaceManager, + sanitized_bundle: &SanitizedBundle, + bank: &Arc, + ) -> BundleExecutionResult<(Vec>, usize)> { + let mut write_cost_tracker = bank.write_cost_tracker().unwrap(); + + // set the block cost limit to the original block cost limit, run the select + accumulate + // then reset back to the expected block cost limit. this allows bundle stage to potentially + // increase block_compute_limits, allocate the space, and reset the block_cost_limits to + // the reserved space without BankingStage racing to allocate this extra reserved space + write_cost_tracker.set_block_cost_limit(reserved_space.block_cost_limit()); + let (transaction_qos_cost_results, cost_model_throttled_transactions_count) = qos_service + .select_and_accumulate_transaction_costs( + bank, + &mut write_cost_tracker, + &sanitized_bundle.transactions, + std::iter::repeat(Ok(())), + ); + write_cost_tracker.set_block_cost_limit(reserved_space.expected_block_cost_limits(bank)); + drop(write_cost_tracker); + + // rollback all transaction costs if it can't fit and + if transaction_qos_cost_results.iter().any(|c| c.is_err()) { + QosService::remove_costs(transaction_qos_cost_results.iter(), None, bank); + return Err(BundleExecutionError::ExceedsCostModel); + } + + Ok(( + transaction_qos_cost_results, + cost_model_throttled_transactions_count, + )) + } + + fn update_qos_and_execute_record_commit_bundle( + committer: &Committer, + recorder: &TransactionRecorder, + qos_service: &QosService, + log_messages_bytes_limit: &Option, + max_bundle_retry_duration: Duration, + reserved_space: &BundleReservedSpaceManager, + sanitized_bundle: &SanitizedBundle, + bank_start: &BankStart, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + ) -> BundleExecutionResult<()> { + debug!( + "bundle: {} reserving blockspace for {} transactions", + sanitized_bundle.bundle_id, + sanitized_bundle.transactions.len() + ); + + let ( + (transaction_qos_cost_results, _cost_model_throttled_transactions_count), + cost_model_elapsed_us, + ) = measure_us!(Self::reserve_bundle_blockspace( + qos_service, + reserved_space, + sanitized_bundle, + &bank_start.working_bank + )?); + + debug!( + "bundle: {} executing, recording, and committing", + sanitized_bundle.bundle_id + ); + + let (result, process_transactions_us) = measure_us!(Self::execute_record_commit_bundle( + committer, + recorder, + log_messages_bytes_limit, + max_bundle_retry_duration, + sanitized_bundle, + bank_start, + )); + + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_num_execution_retries(result.execution_metrics.num_retries); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .accumulate_transaction_errors(&result.transaction_error_counter); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_process_transactions_us(process_transactions_us); + + let (cu, us) = result + .execute_and_commit_timings + .execute_timings + .accumulate_execute_units_and_time(); + qos_service.accumulate_actual_execute_cu(cu); + qos_service.accumulate_actual_execute_time(us); + + let num_committed = result + .commit_transaction_details + .iter() + .filter(|c| matches!(c, CommitTransactionDetails::Committed { .. })) + .count(); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .accumulate_process_transactions_summary(&ProcessTransactionsSummary { + reached_max_poh_height: matches!( + result.result, + Err(BundleExecutionError::BankProcessingTimeLimitReached) + | Err(BundleExecutionError::PohRecordError(_)) + ), + transactions_attempted_execution_count: sanitized_bundle.transactions.len(), + committed_transactions_count: num_committed, + // NOTE: this assumes that bundles are committed all-or-nothing + committed_transactions_with_successful_result_count: num_committed, + failed_commit_count: 0, + retryable_transaction_indexes: vec![], + cost_model_throttled_transactions_count: 0, + cost_model_us: cost_model_elapsed_us, + execute_and_commit_timings: result.execute_and_commit_timings, + error_counters: result.transaction_error_counter, + }); + + match result.result { + Ok(_) => { + // it's assumed that all transactions in the bundle executed, can update QoS + if !bank_start + .working_bank + .feature_set + .is_active(&feature_set::apply_cost_tracker_during_replay::id()) + { + QosService::update_costs( + transaction_qos_cost_results.iter(), + Some(&result.commit_transaction_details), + &bank_start.working_bank, + ); + } + + qos_service.report_metrics(bank_start.working_bank.slot()); + Ok(()) + } + Err(e) => { + // on bundle failure, none of the transactions are committed, so need to revert + // all compute reserved + QosService::remove_costs( + transaction_qos_cost_results.iter(), + None, + &bank_start.working_bank, + ); + qos_service.report_metrics(bank_start.working_bank.slot()); + + Err(e) + } + } + } + + fn execute_record_commit_bundle( + committer: &Committer, + recorder: &TransactionRecorder, + log_messages_bytes_limit: &Option, + max_bundle_retry_duration: Duration, + sanitized_bundle: &SanitizedBundle, + bank_start: &BankStart, + ) -> ExecuteRecordCommitResult { + let transaction_status_sender_enabled = committer.transaction_status_sender_enabled(); + + let mut execute_and_commit_timings = LeaderExecuteAndCommitTimings::default(); + + debug!("bundle: {} executing", sanitized_bundle.bundle_id); + let default_accounts = vec![None; sanitized_bundle.transactions.len()]; + let mut bundle_execution_results = load_and_execute_bundle( + &bank_start.working_bank, + sanitized_bundle, + MAX_PROCESSING_AGE, + &max_bundle_retry_duration, + transaction_status_sender_enabled, + transaction_status_sender_enabled, + transaction_status_sender_enabled, + transaction_status_sender_enabled, + log_messages_bytes_limit, + false, + None, + &default_accounts, + &default_accounts, + ); + + let execution_metrics = bundle_execution_results.metrics(); + + execute_and_commit_timings.collect_balances_us = execution_metrics.collect_balances_us; + execute_and_commit_timings.load_execute_us = execution_metrics.load_execute_us; + execute_and_commit_timings + .execute_timings + .accumulate(&execution_metrics.execute_timings); + + let mut transaction_error_counter = TransactionErrorMetrics::default(); + bundle_execution_results + .bundle_transaction_results() + .iter() + .for_each(|r| { + transaction_error_counter + .accumulate(&r.load_and_execute_transactions_output().error_counters); + }); + + debug!( + "bundle: {} executed, is_ok: {}", + sanitized_bundle.bundle_id, + bundle_execution_results.result().is_ok() + ); + + // don't commit bundle if failure executing any part of the bundle + if let Err(e) = bundle_execution_results.result() { + return ExecuteRecordCommitResult { + commit_transaction_details: vec![], + result: Err(e.clone().into()), + execution_metrics, + execute_and_commit_timings, + transaction_error_counter, + }; + } + + let (executed_batches, execution_results_to_transactions_us) = + measure_us!(bundle_execution_results.executed_transaction_batches()); + + debug!( + "bundle: {} recording {} batches of {:?} transactions", + sanitized_bundle.bundle_id, + executed_batches.len(), + executed_batches + .iter() + .map(|b| b.len()) + .collect::>() + ); + + let (freeze_lock, freeze_lock_us) = measure_us!(bank_start.working_bank.freeze_lock()); + execute_and_commit_timings.freeze_lock_us = freeze_lock_us; + + let ( + RecordTransactionsSummary { + result: record_transactions_result, + record_transactions_timings, + starting_transaction_index, + }, + record_us, + ) = measure_us!( + recorder.record_transactions(bank_start.working_bank.slot(), executed_batches) + ); + + execute_and_commit_timings.record_us = record_us; + execute_and_commit_timings.record_transactions_timings = record_transactions_timings; + execute_and_commit_timings + .record_transactions_timings + .execution_results_to_transactions_us = execution_results_to_transactions_us; + + debug!( + "bundle: {} record result: {}", + sanitized_bundle.bundle_id, + record_transactions_result.is_ok() + ); + + // don't commit bundle if failed to record + if let Err(e) = record_transactions_result { + return ExecuteRecordCommitResult { + commit_transaction_details: vec![], + result: Err(e.into()), + execution_metrics, + execute_and_commit_timings, + transaction_error_counter, + }; + } + + // note: execute_and_commit_timings.commit_us handled inside this function + let (commit_us, commit_bundle_details) = committer.commit_bundle( + &mut bundle_execution_results, + starting_transaction_index, + &bank_start.working_bank, + &mut execute_and_commit_timings, + ); + execute_and_commit_timings.commit_us = commit_us; + + drop(freeze_lock); + + // commit_bundle_details contains transactions that were and were not committed + // given the current implementation only executes, records, and commits bundles + // where all transactions executed, we can filter out the non-committed + // TODO (LB): does this make more sense in commit_bundle for future when failing bundles are accepted? + let commit_transaction_details = commit_bundle_details + .commit_transaction_details + .into_iter() + .flat_map(|commit_details| { + commit_details + .into_iter() + .filter(|d| matches!(d, CommitTransactionDetails::Committed { .. })) + }) + .collect(); + debug!( + "bundle: {} commit details: {:?}", + sanitized_bundle.bundle_id, commit_transaction_details + ); + + ExecuteRecordCommitResult { + commit_transaction_details, + result: Ok(()), + execution_metrics, + execute_and_commit_timings, + transaction_error_counter, + } + } + + /// Returns true if any of the transactions in a bundle mention one of the tip PDAs + fn bundle_touches_tip_pdas(bundle: &SanitizedBundle, tip_pdas: &HashSet) -> bool { + bundle.transactions.iter().any(|tx| { + tx.message() + .account_keys() + .iter() + .any(|a| tip_pdas.contains(a)) + }) + } +} + +#[cfg(test)] +mod tests { + use { + crate::{ + bundle_stage::{ + bundle_account_locker::BundleAccountLocker, bundle_consumer::BundleConsumer, + bundle_packet_deserializer::BundlePacketDeserializer, + bundle_reserved_space_manager::BundleReservedSpaceManager, + bundle_stage_leader_metrics::BundleStageLeaderMetrics, committer::Committer, + }, + packet_bundle::PacketBundle, + proxy::block_engine_stage::BlockBuilderFeeInfo, + qos_service::QosService, + tip_manager::{TipDistributionAccountConfig, TipManager, TipManagerConfig}, + unprocessed_transaction_storage::UnprocessedTransactionStorage, + }, + crossbeam_channel::{unbounded, Receiver}, + jito_tip_distribution::sdk::derive_tip_distribution_account_address, + rand::{thread_rng, RngCore}, + solana_gossip::{cluster_info::ClusterInfo, contact_info::ContactInfo}, + solana_ledger::{ + blockstore::Blockstore, genesis_utils::create_genesis_config, + get_tmp_ledger_path_auto_delete, leader_schedule_cache::LeaderScheduleCache, + }, + solana_perf::packet::PacketBatch, + solana_poh::{ + poh_recorder::{PohRecorder, Record, WorkingBankEntry}, + poh_service::PohService, + }, + solana_program_test::programs::spl_programs, + solana_runtime::{ + bank::Bank, + block_cost_limits::MAX_BLOCK_UNITS, + cost_model::CostModel, + genesis_utils::{create_genesis_config_with_leader_ex, GenesisConfigInfo}, + prioritization_fee_cache::PrioritizationFeeCache, + transaction_error_metrics::TransactionErrorMetrics, + }, + solana_sdk::{ + bundle::{derive_bundle_id, SanitizedBundle}, + clock::MAX_PROCESSING_AGE, + feature_set::delay_visibility_of_program_deployment, + fee_calculator::{FeeRateGovernor, DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE}, + genesis_config::ClusterType, + hash::Hash, + native_token::sol_to_lamports, + packet::Packet, + poh_config::PohConfig, + pubkey::Pubkey, + rent::Rent, + signature::{Keypair, Signer}, + system_transaction::transfer, + transaction::{SanitizedTransaction, TransactionError, VersionedTransaction}, + vote::state::VoteState, + }, + solana_streamer::socket::SocketAddrSpace, + std::{ + collections::{HashSet, VecDeque}, + str::FromStr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, RwLock, + }, + thread::{Builder, JoinHandle}, + time::Duration, + }, + }; + + struct TestFixture { + genesis_config_info: GenesisConfigInfo, + leader_keypair: Keypair, + bank: Arc, + exit: Arc, + poh_recorder: Arc>, + poh_simulator: JoinHandle<()>, + entry_receiver: Receiver, + } + + pub(crate) fn simulate_poh( + record_receiver: Receiver, + poh_recorder: &Arc>, + ) -> JoinHandle<()> { + let poh_recorder = poh_recorder.clone(); + let is_exited = poh_recorder.read().unwrap().is_exited.clone(); + let tick_producer = Builder::new() + .name("solana-simulate_poh".to_string()) + .spawn(move || loop { + PohService::read_record_receiver_and_process( + &poh_recorder, + &record_receiver, + Duration::from_millis(10), + ); + if is_exited.load(Ordering::Relaxed) { + break; + } + }); + tick_producer.unwrap() + } + + pub fn create_test_recorder( + bank: &Arc, + blockstore: Arc, + poh_config: Option, + leader_schedule_cache: Option>, + ) -> ( + Arc, + Arc>, + JoinHandle<()>, + Receiver, + ) { + let leader_schedule_cache = match leader_schedule_cache { + Some(provided_cache) => provided_cache, + None => Arc::new(LeaderScheduleCache::new_from_bank(bank)), + }; + let exit = Arc::new(AtomicBool::new(false)); + let poh_config = poh_config.unwrap_or_default(); + let (mut poh_recorder, entry_receiver, record_receiver) = PohRecorder::new( + bank.tick_height(), + bank.last_blockhash(), + bank.clone(), + Some((4, 4)), + bank.ticks_per_slot(), + &Pubkey::default(), + blockstore, + &leader_schedule_cache, + &poh_config, + exit.clone(), + ); + poh_recorder.set_bank(bank.clone(), false); + + let poh_recorder = Arc::new(RwLock::new(poh_recorder)); + let poh_simulator = simulate_poh(record_receiver, &poh_recorder); + + (exit, poh_recorder, poh_simulator, entry_receiver) + } + + fn create_test_fixture(mint_sol: u64) -> TestFixture { + let mint_keypair = Keypair::new(); + let leader_keypair = Keypair::new(); + let voting_keypair = Keypair::new(); + + let rent = Rent::default(); + + let mut genesis_config = create_genesis_config_with_leader_ex( + sol_to_lamports(mint_sol as f64), + &mint_keypair.pubkey(), + &leader_keypair.pubkey(), + &voting_keypair.pubkey(), + &solana_sdk::pubkey::new_rand(), + rent.minimum_balance(VoteState::size_of()) + sol_to_lamports(1_000_000.0), + sol_to_lamports(1_000_000.0), + FeeRateGovernor { + // Initialize with a non-zero fee + lamports_per_signature: DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE / 2, + ..FeeRateGovernor::default() + }, + rent, // most tests don't expect rent + ClusterType::Development, + spl_programs(&rent), + ); + genesis_config.ticks_per_slot *= 8; + + // workaround for https://github.com/solana-labs/solana/issues/30085 + // the test can deploy and use spl_programs in the genensis slot without waiting for the next one + let mut bank = Bank::new_for_tests(&genesis_config); + bank.deactivate_feature(&delay_visibility_of_program_deployment::id()); + let bank = Arc::new(bank); + + let ledger_path = get_tmp_ledger_path_auto_delete!(); + let blockstore = Arc::new( + Blockstore::open(ledger_path.path()) + .expect("Expected to be able to open database ledger"), + ); + + let (exit, poh_recorder, poh_simulator, entry_receiver) = + create_test_recorder(&bank, blockstore, Some(PohConfig::default()), None); + + let validator_pubkey = voting_keypair.pubkey(); + TestFixture { + genesis_config_info: GenesisConfigInfo { + genesis_config, + mint_keypair, + voting_keypair, + validator_pubkey, + }, + leader_keypair, + bank, + exit, + poh_recorder, + poh_simulator, + entry_receiver, + } + } + + fn make_random_overlapping_bundles( + mint_keypair: &Keypair, + num_bundles: usize, + num_packets_per_bundle: usize, + hash: Hash, + max_transfer_amount: u64, + ) -> Vec { + let mut rng = thread_rng(); + + (0..num_bundles) + .map(|_| { + let transfers: Vec<_> = (0..num_packets_per_bundle) + .map(|_| { + VersionedTransaction::from(transfer( + mint_keypair, + &mint_keypair.pubkey(), + rng.next_u64() % max_transfer_amount, + hash, + )) + }) + .collect(); + let bundle_id = derive_bundle_id(&transfers); + + PacketBundle { + batch: PacketBatch::new( + transfers + .iter() + .map(|tx| Packet::from_data(None, tx).unwrap()) + .collect(), + ), + bundle_id, + } + }) + .collect() + } + + fn get_tip_manager(vote_account: &Pubkey) -> TipManager { + TipManager::new(TipManagerConfig { + tip_payment_program_id: Pubkey::from_str("T1pyyaTNZsKv2WcRAB8oVnk93mLJw2XzjtVYqCsaHqt") + .unwrap(), + tip_distribution_program_id: Pubkey::from_str( + "4R3gSG8BpU4t19KYj8CfnbtRpnT8gtk4dvTHxVRwc2r7", + ) + .unwrap(), + tip_distribution_account_config: TipDistributionAccountConfig { + merkle_root_upload_authority: Pubkey::new_unique(), + vote_account: *vote_account, + commission_bps: 10, + }, + }) + } + + /// Happy-path bundle execution w/ no tip management + #[test] + fn test_bundle_no_tip_success() { + solana_logger::setup(); + let TestFixture { + genesis_config_info, + leader_keypair, + bank, + exit, + poh_recorder, + poh_simulator, + entry_receiver, + } = create_test_fixture(1_000_000); + let recorder = poh_recorder.read().unwrap().new_recorder(); + + let status = poh_recorder.read().unwrap().reached_leader_slot(); + info!("status: {:?}", status); + + let (replay_vote_sender, _replay_vote_receiver) = unbounded(); + let committer = Committer::new( + None, + replay_vote_sender, + Arc::new(PrioritizationFeeCache::new(0u64)), + ); + + let block_builder_pubkey = Pubkey::new_unique(); + let tip_manager = get_tip_manager(&genesis_config_info.voting_keypair.pubkey()); + let block_builder_info = Arc::new(Mutex::new(BlockBuilderFeeInfo { + block_builder: block_builder_pubkey, + block_builder_commission: 10, + })); + + let cluster_info = Arc::new(ClusterInfo::new( + ContactInfo::new(leader_keypair.pubkey(), 0, 0), + Arc::new(leader_keypair), + SocketAddrSpace::new(true), + )); + + let mut consumer = BundleConsumer::new( + committer, + recorder, + QosService::new(1), + None, + tip_manager, + BundleAccountLocker::default(), + block_builder_info, + Duration::from_secs(10), + cluster_info, + BundleReservedSpaceManager::new( + MAX_BLOCK_UNITS, + 3_000_000, + poh_recorder + .read() + .unwrap() + .ticks_per_slot() + .saturating_mul(8) + .saturating_div(10), + ), + ); + + let bank_start = poh_recorder.read().unwrap().bank_start().unwrap(); + + let mut bundle_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(1); + + let mut packet_bundles = make_random_overlapping_bundles( + &genesis_config_info.mint_keypair, + 1, + 3, + genesis_config_info.genesis_config.hash(), + 10_000, + ); + let deserialized_bundle = BundlePacketDeserializer::deserialize_bundle( + packet_bundles.get_mut(0).unwrap(), + false, + None, + ) + .unwrap(); + let mut error_metrics = TransactionErrorMetrics::default(); + let sanitized_bundle = deserialized_bundle + .build_sanitized_bundle( + &bank_start.working_bank, + &HashSet::default(), + &mut error_metrics, + ) + .unwrap(); + + let summary = bundle_storage.insert_bundles(vec![deserialized_bundle]); + assert_eq!( + summary.num_packets_inserted, + sanitized_bundle.transactions.len() + ); + assert_eq!(summary.num_bundles_dropped, 0); + assert_eq!(summary.num_bundles_inserted, 1); + + consumer.consume_buffered_bundles( + &bank_start, + &mut bundle_storage, + &mut bundle_stage_leader_metrics, + ); + + let mut transactions = Vec::new(); + while let Ok(WorkingBankEntry { + bank: wbe_bank, + entries_ticks, + }) = entry_receiver.recv() + { + assert_eq!(bank.slot(), wbe_bank.slot()); + for (entry, _) in entries_ticks { + if !entry.transactions.is_empty() { + // transactions in this test are all overlapping, so each entry will contain 1 transaction + assert_eq!(entry.transactions.len(), 1); + transactions.extend(entry.transactions); + } + } + if transactions.len() == sanitized_bundle.transactions.len() { + break; + } + } + + let bundle_versioned_transactions: Vec<_> = sanitized_bundle + .transactions + .iter() + .map(|tx| tx.to_versioned_transaction()) + .collect(); + assert_eq!(transactions, bundle_versioned_transactions); + + let check_results = bank.check_transactions( + &sanitized_bundle.transactions, + &vec![Ok(()); sanitized_bundle.transactions.len()], + MAX_PROCESSING_AGE, + &mut error_metrics, + ); + assert_eq!( + check_results, + vec![ + (Err(TransactionError::AlreadyProcessed), None); + sanitized_bundle.transactions.len() + ] + ); + + poh_recorder + .write() + .unwrap() + .is_exited + .store(true, Ordering::Relaxed); + exit.store(true, Ordering::Relaxed); + poh_simulator.join().unwrap(); + // TODO (LB): cleanup blockstore + } + + /// Happy-path bundle execution to ensure tip management works. + /// Tip management involves cranking setup bundles before executing the test bundle + #[test] + fn test_bundle_tip_program_setup_success() { + solana_logger::setup(); + let TestFixture { + genesis_config_info, + leader_keypair, + bank, + exit, + poh_recorder, + poh_simulator, + entry_receiver, + } = create_test_fixture(1_000_000); + let recorder = poh_recorder.read().unwrap().new_recorder(); + + let (replay_vote_sender, _replay_vote_receiver) = unbounded(); + let committer = Committer::new( + None, + replay_vote_sender, + Arc::new(PrioritizationFeeCache::new(0u64)), + ); + + let block_builder_pubkey = Pubkey::new_unique(); + let tip_manager = get_tip_manager(&genesis_config_info.voting_keypair.pubkey()); + let block_builder_info = Arc::new(Mutex::new(BlockBuilderFeeInfo { + block_builder: block_builder_pubkey, + block_builder_commission: 10, + })); + + let cluster_info = Arc::new(ClusterInfo::new( + ContactInfo::new(leader_keypair.pubkey(), 0, 0), + Arc::new(leader_keypair), + SocketAddrSpace::new(true), + )); + + let mut consumer = BundleConsumer::new( + committer, + recorder, + QosService::new(1), + None, + tip_manager.clone(), + BundleAccountLocker::default(), + block_builder_info, + Duration::from_secs(10), + cluster_info.clone(), + BundleReservedSpaceManager::new( + MAX_BLOCK_UNITS, + 3_000_000, + poh_recorder + .read() + .unwrap() + .ticks_per_slot() + .saturating_mul(8) + .saturating_div(10), + ), + ); + + let bank_start = poh_recorder.read().unwrap().bank_start().unwrap(); + + let mut bundle_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(1); + // MAIN LOGIC + + // a bundle that tips the tip program + let tip_accounts = tip_manager.get_tip_accounts(); + let tip_account = tip_accounts.iter().collect::>()[0]; + let mut packet_bundle = PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data( + None, + transfer( + &genesis_config_info.mint_keypair, + tip_account, + 1, + genesis_config_info.genesis_config.hash(), + ), + ) + .unwrap()]), + bundle_id: "test_transfer".to_string(), + }; + + let deserialized_bundle = + BundlePacketDeserializer::deserialize_bundle(&mut packet_bundle, false, None).unwrap(); + let mut error_metrics = TransactionErrorMetrics::default(); + let sanitized_bundle = deserialized_bundle + .build_sanitized_bundle( + &bank_start.working_bank, + &HashSet::default(), + &mut error_metrics, + ) + .unwrap(); + + let summary = bundle_storage.insert_bundles(vec![deserialized_bundle]); + assert_eq!(summary.num_bundles_inserted, 1); + assert_eq!(summary.num_packets_inserted, 1); + assert_eq!(summary.num_bundles_dropped, 0); + + consumer.consume_buffered_bundles( + &bank_start, + &mut bundle_storage, + &mut bundle_stage_leader_metrics, + ); + + // its expected there are 3 transactions. One to initialize the tip program configuration, one to change the tip receiver, + // and another with the tip + + let mut transactions = Vec::new(); + while let Ok(WorkingBankEntry { + bank: wbe_bank, + entries_ticks, + }) = entry_receiver.recv() + { + assert_eq!(bank.slot(), wbe_bank.slot()); + transactions.extend(entries_ticks.into_iter().flat_map(|(e, _)| e.transactions)); + if transactions.len() == 5 { + break; + } + } + + // tip management on the first bundle involves: + // calling initialize on the tip payment and tip distribution programs + // creating the tip distribution account for this validator's epoch (the MEV piggy bank) + // changing the tip receiver and block builder tx + // the original transfer that was sent + let keypair = cluster_info.keypair().clone(); + + assert_eq!( + transactions[0], + tip_manager + .initialize_tip_payment_program_tx(bank.last_blockhash(), &keypair) + .to_versioned_transaction() + ); + assert_eq!( + transactions[1], + tip_manager + .initialize_tip_distribution_config_tx(bank.last_blockhash(), &keypair) + .to_versioned_transaction() + ); + assert_eq!( + transactions[2], + tip_manager + .initialize_tip_distribution_account_tx( + bank.last_blockhash(), + bank.epoch(), + &keypair + ) + .to_versioned_transaction() + ); + // the first tip receiver + block builder are the initializer (keypair.pubkey()) as set by the + // TipPayment program during initialization + assert_eq!( + transactions[3], + tip_manager + .build_change_tip_receiver_and_block_builder_tx( + &keypair.pubkey(), + &derive_tip_distribution_account_address( + &tip_manager.tip_distribution_program_id(), + &genesis_config_info.validator_pubkey, + bank_start.working_bank.epoch() + ) + .0, + &bank_start.working_bank, + &keypair, + &keypair.pubkey(), + &block_builder_pubkey, + 10 + ) + .to_versioned_transaction() + ); + assert_eq!( + transactions[4], + sanitized_bundle.transactions[0].to_versioned_transaction() + ); + + poh_recorder + .write() + .unwrap() + .is_exited + .store(true, Ordering::Relaxed); + exit.store(true, Ordering::Relaxed); + poh_simulator.join().unwrap(); + } + + #[test] + fn test_handle_tip_programs() { + solana_logger::setup(); + let TestFixture { + genesis_config_info, + leader_keypair, + bank, + exit, + poh_recorder, + poh_simulator, + entry_receiver, + } = create_test_fixture(1_000_000); + let recorder = poh_recorder.read().unwrap().new_recorder(); + + let (replay_vote_sender, _replay_vote_receiver) = unbounded(); + let committer = Committer::new( + None, + replay_vote_sender, + Arc::new(PrioritizationFeeCache::new(0u64)), + ); + + let block_builder_pubkey = Pubkey::new_unique(); + let tip_manager = get_tip_manager(&genesis_config_info.voting_keypair.pubkey()); + let block_builder_info = Arc::new(Mutex::new(BlockBuilderFeeInfo { + block_builder: block_builder_pubkey, + block_builder_commission: 10, + })); + + let cluster_info = Arc::new(ClusterInfo::new( + ContactInfo::new(leader_keypair.pubkey(), 0, 0), + Arc::new(leader_keypair), + SocketAddrSpace::new(true), + )); + + let bank_start = poh_recorder.read().unwrap().bank_start().unwrap(); + + let reserved_ticks = bank.max_tick_height().saturating_mul(8).saturating_div(10); + + // The first 80% of the block, based on poh ticks, has `preallocated_bundle_cost` less compute units. + // The last 20% has has full compute so blockspace is maximized if BundleStage is idle. + let reserved_space = + BundleReservedSpaceManager::new(MAX_BLOCK_UNITS, 3_000_000, reserved_ticks); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(1); + assert_matches!( + BundleConsumer::handle_tip_programs( + &BundleAccountLocker::default(), + &tip_manager, + &cluster_info, + &block_builder_info, + &committer, + &recorder, + &QosService::new(1), + &None, + Duration::from_secs(10), + &reserved_space, + &bank_start, + &mut bundle_stage_leader_metrics + ), + Ok(()) + ); + + let mut transactions = Vec::new(); + while let Ok(WorkingBankEntry { + bank: wbe_bank, + entries_ticks, + }) = entry_receiver.recv() + { + assert_eq!(bank.slot(), wbe_bank.slot()); + transactions.extend(entries_ticks.into_iter().flat_map(|(e, _)| e.transactions)); + if transactions.len() == 4 { + break; + } + } + + let keypair = cluster_info.keypair().clone(); + // expect to see initialize tip payment program, tip distribution program, initialize tip distribution account, change tip receiver + change block builder + assert_eq!( + transactions[0], + tip_manager + .initialize_tip_payment_program_tx(bank.last_blockhash(), &keypair) + .to_versioned_transaction() + ); + assert_eq!( + transactions[1], + tip_manager + .initialize_tip_distribution_config_tx(bank.last_blockhash(), &keypair) + .to_versioned_transaction() + ); + assert_eq!( + transactions[2], + tip_manager + .initialize_tip_distribution_account_tx( + bank.last_blockhash(), + bank.epoch(), + &keypair + ) + .to_versioned_transaction() + ); + // the first tip receiver + block builder are the initializer (keypair.pubkey()) as set by the + // TipPayment program during initialization + assert_eq!( + transactions[3], + tip_manager + .build_change_tip_receiver_and_block_builder_tx( + &keypair.pubkey(), + &derive_tip_distribution_account_address( + &tip_manager.tip_distribution_program_id(), + &genesis_config_info.validator_pubkey, + bank_start.working_bank.epoch() + ) + .0, + &bank_start.working_bank, + &keypair, + &keypair.pubkey(), + &block_builder_pubkey, + 10 + ) + .to_versioned_transaction() + ); + + poh_recorder + .write() + .unwrap() + .is_exited + .store(true, Ordering::Relaxed); + exit.store(true, Ordering::Relaxed); + poh_simulator.join().unwrap(); + } + + #[test] + fn test_reserve_bundle_blockspace_success() { + let GenesisConfigInfo { genesis_config, .. } = create_genesis_config(10); + let bank = Arc::new(Bank::new_for_tests(&genesis_config)); + + let keypair1 = Keypair::new(); + let keypair2 = Keypair::new(); + let transfer_tx = SanitizedTransaction::from_transaction_for_tests(transfer( + &keypair1, + &keypair2.pubkey(), + 1, + bank.parent_hash(), + )); + let sanitized_bundle = SanitizedBundle { + transactions: vec![transfer_tx], + bundle_id: String::default(), + }; + + let transfer_cost = + CostModel::calculate_cost(&sanitized_bundle.transactions[0], &bank.feature_set); + + let qos_service = QosService::new(1); + let reserved_ticks = bank.max_tick_height().saturating_mul(8).saturating_div(10); + + // The first 80% of the block, based on poh ticks, has `preallocated_bundle_cost` less compute units. + // The last 20% has has full compute so blockspace is maximized if BundleStage is idle. + let reserved_space = + BundleReservedSpaceManager::new(MAX_BLOCK_UNITS, 3_000_000, reserved_ticks); + + assert!(BundleConsumer::reserve_bundle_blockspace( + &qos_service, + &reserved_space, + &sanitized_bundle, + &bank + ) + .is_ok()); + assert_eq!( + bank.read_cost_tracker().unwrap().block_cost(), + transfer_cost.sum() + ); + } + + #[test] + fn test_reserve_bundle_blockspace_failure() { + let GenesisConfigInfo { genesis_config, .. } = create_genesis_config(10); + let bank = Arc::new(Bank::new_for_tests(&genesis_config)); + + let keypair1 = Keypair::new(); + let keypair2 = Keypair::new(); + let transfer_tx1 = SanitizedTransaction::from_transaction_for_tests(transfer( + &keypair1, + &keypair2.pubkey(), + 1, + bank.parent_hash(), + )); + let transfer_tx2 = SanitizedTransaction::from_transaction_for_tests(transfer( + &keypair1, + &keypair2.pubkey(), + 2, + bank.parent_hash(), + )); + let sanitized_bundle = SanitizedBundle { + transactions: vec![transfer_tx1, transfer_tx2], + bundle_id: String::default(), + }; + + // set block cost limit to 1 transfer transaction, try to process 2, should return an error + // and rollback block cost added + let transfer_cost = + CostModel::calculate_cost(&sanitized_bundle.transactions[0], &bank.feature_set); + bank.write_cost_tracker() + .unwrap() + .set_block_cost_limit(transfer_cost.sum()); + + let qos_service = QosService::new(1); + let reserved_ticks = bank.max_tick_height().saturating_mul(8).saturating_div(10); + + // The first 80% of the block, based on poh ticks, has `preallocated_bundle_cost` less compute units. + // The last 20% has has full compute so blockspace is maximized if BundleStage is idle. + let reserved_space = BundleReservedSpaceManager::new( + bank.read_cost_tracker().unwrap().block_cost(), + 50, + reserved_ticks, + ); + + assert!(BundleConsumer::reserve_bundle_blockspace( + &qos_service, + &reserved_space, + &sanitized_bundle, + &bank + ) + .is_err()); + assert_eq!(bank.read_cost_tracker().unwrap().block_cost(), 0); + assert_eq!( + bank.read_cost_tracker().unwrap().block_cost_limit(), + bank.read_cost_tracker() + .unwrap() + .block_cost_limit() + .saturating_sub(50) + ); + } +} diff --git a/core/src/bundle_stage/bundle_packet_deserializer.rs b/core/src/bundle_stage/bundle_packet_deserializer.rs new file mode 100644 index 0000000000..b3af110fc3 --- /dev/null +++ b/core/src/bundle_stage/bundle_packet_deserializer.rs @@ -0,0 +1,286 @@ +//! Deserializes PacketBundles +use { + crate::{ + immutable_deserialized_bundle::{DeserializedBundleError, ImmutableDeserializedBundle}, + packet_bundle::PacketBundle, + }, + crossbeam_channel::{Receiver, RecvTimeoutError}, + solana_runtime::bank_forks::BankForks, + solana_sdk::saturating_add_assign, + std::{ + sync::{Arc, RwLock}, + time::{Duration, Instant}, + }, +}; + +/// Results from deserializing packet batches. +#[derive(Debug)] +pub struct ReceiveBundleResults { + /// Deserialized bundles from all received bundle packets + pub deserialized_bundles: Vec, + /// Number of dropped bundles + pub num_dropped_bundles: usize, + /// Number of dropped packets + pub num_dropped_packets: usize, +} + +pub struct BundlePacketDeserializer { + /// Receiver for bundle packets + bundle_packet_receiver: Receiver>, + /// Provides working bank for deserializer to check feature activation + bank_forks: Arc>, + /// Max packets per bundle + max_packets_per_bundle: Option, +} + +impl BundlePacketDeserializer { + pub fn new( + bundle_packet_receiver: Receiver>, + bank_forks: Arc>, + max_packets_per_bundle: Option, + ) -> Self { + Self { + bundle_packet_receiver, + bank_forks, + max_packets_per_bundle, + } + } + + /// Handles receiving bundles and deserializing them + pub fn receive_bundles( + &self, + recv_timeout: Duration, + capacity: usize, + ) -> Result { + let (bundle_count, _packet_count, mut bundles) = + self.receive_until(recv_timeout, capacity)?; + + // Note: this can be removed after feature `round_compute_unit_price` is activated in + // mainnet-beta + let _working_bank = self.bank_forks.read().unwrap().working_bank(); + let round_compute_unit_price_enabled = false; // TODO get from working_bank.feature_set + + Ok(Self::deserialize_and_collect_bundles( + bundle_count, + &mut bundles, + round_compute_unit_price_enabled, + self.max_packets_per_bundle, + )) + } + + /// Deserialize packet batches, aggregates tracer packet stats, and collect + /// them into ReceivePacketResults + fn deserialize_and_collect_bundles( + bundle_count: usize, + bundles: &mut [PacketBundle], + round_compute_unit_price_enabled: bool, + max_packets_per_bundle: Option, + ) -> ReceiveBundleResults { + let mut deserialized_bundles = Vec::with_capacity(bundle_count); + let mut num_dropped_bundles: usize = 0; + let mut num_dropped_packets: usize = 0; + + for bundle in bundles.iter_mut() { + match Self::deserialize_bundle( + bundle, + round_compute_unit_price_enabled, + max_packets_per_bundle, + ) { + Ok(deserialized_bundle) => { + deserialized_bundles.push(deserialized_bundle); + } + Err(_) => { + // TODO (LB): prob wanna collect stats here + saturating_add_assign!(num_dropped_bundles, 1); + saturating_add_assign!(num_dropped_packets, bundle.batch.len()); + } + } + } + + ReceiveBundleResults { + deserialized_bundles, + num_dropped_bundles, + num_dropped_packets, + } + } + + /// Receives bundle packets + fn receive_until( + &self, + recv_timeout: Duration, + bundle_count_upperbound: usize, + ) -> Result<(usize, usize, Vec), RecvTimeoutError> { + let start = Instant::now(); + + let mut bundles = self.bundle_packet_receiver.recv_timeout(recv_timeout)?; + let mut num_packets_received: usize = bundles.iter().map(|pb| pb.batch.len()).sum(); + let mut num_bundles_received: usize = bundles.len(); + + if num_bundles_received <= bundle_count_upperbound { + while let Ok(bundle_packets) = self.bundle_packet_receiver.try_recv() { + trace!("got more packet batches in bundle packet deserializer"); + + saturating_add_assign!( + num_packets_received, + bundle_packets + .iter() + .map(|pb| pb.batch.len()) + .sum::() + ); + saturating_add_assign!(num_bundles_received, bundle_packets.len()); + + bundles.extend(bundle_packets); + + if start.elapsed() >= recv_timeout + || num_bundles_received >= bundle_count_upperbound + { + break; + } + } + } + + Ok((num_bundles_received, num_packets_received, bundles)) + } + + /// Deserializes the Bundle into DeserializedBundlePackets, returning None if any packet in the + /// bundle failed to deserialize + pub fn deserialize_bundle( + bundle: &mut PacketBundle, + round_compute_unit_price_enabled: bool, + max_packets_per_bundle: Option, + ) -> Result { + bundle.batch.iter_mut().for_each(|p| { + p.meta_mut() + .set_round_compute_unit_price(round_compute_unit_price_enabled); + }); + + ImmutableDeserializedBundle::new(bundle, max_packets_per_bundle) + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crossbeam_channel::unbounded, + solana_ledger::genesis_utils::create_genesis_config, + solana_perf::packet::PacketBatch, + solana_runtime::{bank::Bank, genesis_utils::GenesisConfigInfo}, + solana_sdk::{packet::Packet, signature::Signer, system_transaction::transfer}, + }; + + #[test] + fn test_deserialize_and_collect_bundles_empty() { + let results = + BundlePacketDeserializer::deserialize_and_collect_bundles(0, &mut [], false, Some(5)); + assert_eq!(results.deserialized_bundles.len(), 0); + assert_eq!(results.num_dropped_packets, 0); + assert_eq!(results.num_dropped_bundles, 0); + } + + #[test] + fn test_receive_bundles_capacity() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let (sender, receiver) = unbounded(); + + let deserializer = BundlePacketDeserializer::new(receiver, bank_forks, Some(10)); + + let packet_bundles: Vec<_> = (0..10) + .map(|_| PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data( + None, + transfer( + &mint_keypair, + &mint_keypair.pubkey(), + 100, + genesis_config.hash(), + ), + ) + .unwrap()]), + bundle_id: String::default(), + }) + .collect(); + + sender.send(packet_bundles.clone()).unwrap(); + + let bundles = deserializer + .receive_bundles(Duration::from_millis(100), 5) + .unwrap(); + // this is confusing, but it's sent as one batch + assert_eq!(bundles.deserialized_bundles.len(), 10); + assert_eq!(bundles.num_dropped_bundles, 0); + assert_eq!(bundles.num_dropped_packets, 0); + + // make sure empty + assert_matches!( + deserializer.receive_bundles(Duration::from_millis(100), 5), + Err(RecvTimeoutError::Timeout) + ); + + // send 2x 10 size batches. capacity is 5, but will return 10 since that's the batch size + sender.send(packet_bundles.clone()).unwrap(); + sender.send(packet_bundles).unwrap(); + let bundles = deserializer + .receive_bundles(Duration::from_millis(100), 5) + .unwrap(); + assert_eq!(bundles.deserialized_bundles.len(), 10); + assert_eq!(bundles.num_dropped_bundles, 0); + assert_eq!(bundles.num_dropped_packets, 0); + + let bundles = deserializer + .receive_bundles(Duration::from_millis(100), 5) + .unwrap(); + assert_eq!(bundles.deserialized_bundles.len(), 10); + assert_eq!(bundles.num_dropped_bundles, 0); + assert_eq!(bundles.num_dropped_packets, 0); + + assert_matches!( + deserializer.receive_bundles(Duration::from_millis(100), 5), + Err(RecvTimeoutError::Timeout) + ); + } + + #[test] + fn test_receive_bundles_bad_bundles() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair: _, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let (sender, receiver) = unbounded(); + + let deserializer = BundlePacketDeserializer::new(receiver, bank_forks, Some(10)); + + let packet_bundles: Vec<_> = (0..10) + .map(|_| PacketBundle { + batch: PacketBatch::new(vec![]), + bundle_id: String::default(), + }) + .collect(); + sender.send(packet_bundles).unwrap(); + + let bundles = deserializer + .receive_bundles(Duration::from_millis(100), 5) + .unwrap(); + // this is confusing, but it's sent as one batch + assert_eq!(bundles.deserialized_bundles.len(), 0); + assert_eq!(bundles.num_dropped_bundles, 10); + assert_eq!(bundles.num_dropped_packets, 0); + } +} diff --git a/core/src/bundle_stage/bundle_packet_receiver.rs b/core/src/bundle_stage/bundle_packet_receiver.rs new file mode 100644 index 0000000000..ffdf47c7f7 --- /dev/null +++ b/core/src/bundle_stage/bundle_packet_receiver.rs @@ -0,0 +1,848 @@ +use { + super::BundleStageLoopMetrics, + crate::{ + bundle_stage::{ + bundle_packet_deserializer::{BundlePacketDeserializer, ReceiveBundleResults}, + bundle_stage_leader_metrics::BundleStageLeaderMetrics, + }, + immutable_deserialized_bundle::ImmutableDeserializedBundle, + packet_bundle::PacketBundle, + unprocessed_transaction_storage::UnprocessedTransactionStorage, + }, + crossbeam_channel::{Receiver, RecvTimeoutError}, + solana_measure::{measure::Measure, measure_us}, + solana_runtime::bank_forks::BankForks, + solana_sdk::timing::timestamp, + std::{ + sync::{Arc, RwLock}, + time::Duration, + }, +}; + +pub struct BundleReceiver { + id: u32, + bundle_packet_deserializer: BundlePacketDeserializer, +} + +impl BundleReceiver { + pub fn new( + id: u32, + bundle_packet_receiver: Receiver>, + bank_forks: Arc>, + max_packets_per_bundle: Option, + ) -> Self { + Self { + id, + bundle_packet_deserializer: BundlePacketDeserializer::new( + bundle_packet_receiver, + bank_forks, + max_packets_per_bundle, + ), + } + } + + /// Receive incoming packets, push into unprocessed buffer with packet indexes + pub fn receive_and_buffer_bundles( + &mut self, + unprocessed_bundle_storage: &mut UnprocessedTransactionStorage, + bundle_stage_metrics: &mut BundleStageLoopMetrics, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + ) -> Result<(), RecvTimeoutError> { + let (result, recv_time_us) = measure_us!({ + let recv_timeout = Self::get_receive_timeout(unprocessed_bundle_storage); + let mut recv_and_buffer_measure = Measure::start("recv_and_buffer"); + self.bundle_packet_deserializer + .receive_bundles(recv_timeout, unprocessed_bundle_storage.max_receive_size()) + // Consumes results if Ok, otherwise we keep the Err + .map(|receive_bundle_results| { + self.buffer_bundles( + receive_bundle_results, + unprocessed_bundle_storage, + bundle_stage_metrics, + // tracer_packet_stats, + bundle_stage_leader_metrics, + ); + recv_and_buffer_measure.stop(); + bundle_stage_metrics.increment_receive_and_buffer_bundles_elapsed_us( + recv_and_buffer_measure.as_us(), + ); + }) + }); + + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_receive_and_buffer_packets_us(recv_time_us); + + result + } + + fn get_receive_timeout( + unprocessed_transaction_storage: &UnprocessedTransactionStorage, + ) -> Duration { + // Gossip thread will almost always not wait because the transaction storage will most likely not be empty + if !unprocessed_transaction_storage.is_empty() { + // If there are buffered packets, run the equivalent of try_recv to try reading more + // packets. This prevents starving BankingStage::consume_buffered_packets due to + // buffered_packet_batches containing transactions that exceed the cost model for + // the current bank. + Duration::from_millis(0) + } else { + // BundleStage should pick up a working_bank as fast as possible + Duration::from_millis(100) + } + } + + fn buffer_bundles( + &self, + ReceiveBundleResults { + deserialized_bundles, + num_dropped_bundles: _, + num_dropped_packets: _, + }: ReceiveBundleResults, + unprocessed_transaction_storage: &mut UnprocessedTransactionStorage, + bundle_stage_stats: &mut BundleStageLoopMetrics, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + ) { + let bundle_count = deserialized_bundles.len(); + let packet_count: usize = deserialized_bundles.iter().map(|b| b.len()).sum(); + + bundle_stage_stats.increment_num_bundles_received(bundle_count as u64); + bundle_stage_stats.increment_num_packets_received(packet_count as u64); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_total_new_valid_packets(packet_count as u64); + + debug!( + "@{:?} bundles: {} txs: {} id: {}", + timestamp(), + bundle_count, + packet_count, + self.id + ); + + Self::push_unprocessed( + unprocessed_transaction_storage, + deserialized_bundles, + bundle_stage_leader_metrics, + bundle_stage_stats, + ); + } + + fn push_unprocessed( + unprocessed_transaction_storage: &mut UnprocessedTransactionStorage, + deserialized_bundles: Vec, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + bundle_stage_stats: &mut BundleStageLoopMetrics, + ) { + if !deserialized_bundles.is_empty() { + let insert_bundles_summary = + unprocessed_transaction_storage.insert_bundles(deserialized_bundles); + + bundle_stage_stats.increment_newly_buffered_bundles_count( + insert_bundles_summary.num_bundles_inserted as u64, + ); + bundle_stage_stats + .increment_num_bundles_dropped(insert_bundles_summary.num_bundles_dropped as u64); + + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_newly_buffered_packets_count( + insert_bundles_summary.num_packets_inserted as u64, + ); + + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .accumulate_insert_packet_batches_summary( + &insert_bundles_summary.insert_packets_summary, + ); + } + } +} + +/// This tests functionality of BundlePacketReceiver and the internals of BundleStorage because +/// they're tightly intertwined +#[cfg(test)] +mod tests { + use { + super::*, + crossbeam_channel::unbounded, + rand::{thread_rng, RngCore}, + solana_bundle::{ + bundle_execution::LoadAndExecuteBundleError, BundleExecutionError, TipError, + }, + solana_ledger::genesis_utils::create_genesis_config, + solana_perf::packet::PacketBatch, + solana_poh::poh_recorder::PohRecorderError, + solana_runtime::{bank::Bank, genesis_utils::GenesisConfigInfo}, + solana_sdk::{ + bundle::{derive_bundle_id, SanitizedBundle}, + hash::Hash, + packet::Packet, + signature::{Keypair, Signer}, + system_transaction::transfer, + transaction::VersionedTransaction, + }, + std::collections::{HashSet, VecDeque}, + }; + + /// Makes `num_bundles` random bundles with `num_packets_per_bundle` packets per bundle. + fn make_random_bundles( + mint_keypair: &Keypair, + num_bundles: usize, + num_packets_per_bundle: usize, + hash: Hash, + ) -> Vec { + let mut rng = thread_rng(); + + (0..num_bundles) + .map(|_| { + let transfers: Vec<_> = (0..num_packets_per_bundle) + .map(|_| { + VersionedTransaction::from(transfer( + mint_keypair, + &mint_keypair.pubkey(), + rng.next_u64(), + hash, + )) + }) + .collect(); + let bundle_id = derive_bundle_id(&transfers); + + PacketBundle { + batch: PacketBatch::new( + transfers + .iter() + .map(|tx| Packet::from_data(None, tx).unwrap()) + .collect(), + ), + bundle_id, + } + }) + .collect() + } + + fn assert_bundles_same( + packet_bundles: &[PacketBundle], + bundles_to_process: &[(ImmutableDeserializedBundle, SanitizedBundle)], + ) { + assert_eq!(packet_bundles.len(), bundles_to_process.len()); + packet_bundles + .iter() + .zip(bundles_to_process.iter()) + .for_each(|(packet_bundle, (_, sanitized_bundle))| { + assert_eq!(packet_bundle.bundle_id, sanitized_bundle.bundle_id); + assert_eq!( + packet_bundle.batch.len(), + sanitized_bundle.transactions.len() + ); + }); + } + + #[test] + fn test_receive_bundles() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(1_000), + VecDeque::with_capacity(1_000), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + let bundles = make_random_bundles(&mint_keypair, 10, 2, genesis_config.hash()); + sender.send(bundles.clone()).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 10); + assert_eq!(bundle_storage.unprocessed_packets_len(), 20); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_packets_len(), 0); + assert_eq!(bundle_storage.max_receive_size(), 990); + + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles, bundles_to_process); + (0..bundles_to_process.len()).map(|_| Ok(())).collect() + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + assert_eq!(bundle_storage.unprocessed_packets_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_packets_len(), 0); + assert_eq!(bundle_storage.max_receive_size(), 1000); + } + + #[test] + fn test_receive_more_bundles_than_capacity() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + let bundles = make_random_bundles(&mint_keypair, 15, 2, genesis_config.hash()); + + sender.send(bundles.clone()).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + // 15 bundles were sent, but the capacity is 10 + assert_eq!(bundle_storage.unprocessed_bundles_len(), 10); + assert_eq!(bundle_storage.unprocessed_packets_len(), 20); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_packets_len(), 0); + + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + // make sure the first 10 bundles are the ones to process + assert_bundles_same(&bundles[0..10], bundles_to_process); + (0..bundles_to_process.len()).map(|_| Ok(())).collect() + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 0); + } + + #[test] + fn test_process_bundles_poh_record_error_rebuffered() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 5 bundles across the queue + let bundles = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles.clone()).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let poh_max_height_reached_index = 3; + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + + // make sure poh end of slot reached + the correct bundles are buffered for the next time. + // bundles at index 3 + 4 are rebuffered + assert!(bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles, bundles_to_process); + + let mut results = vec![Ok(()); bundles_to_process.len()]; + + (poh_max_height_reached_index..bundles_to_process.len()).for_each(|index| { + results[index] = Err(BundleExecutionError::PohRecordError( + PohRecorderError::MaxHeightReached, + )); + }); + results + } + )); + + assert_eq!(bundle_storage.unprocessed_bundles_len(), 2); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 0); + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles[poh_max_height_reached_index..], bundles_to_process); + vec![Ok(()); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + } + + #[test] + fn test_process_bundles_bank_processing_done_rebuffered() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 5 bundles across the queue + let bundles = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles.clone()).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bank_processing_done_index = 3; + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + + // bundles at index 3 + 4 are rebuffered + assert!(bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles, bundles_to_process); + + let mut results = vec![Ok(()); bundles_to_process.len()]; + + (bank_processing_done_index..bundles_to_process.len()).for_each(|index| { + results[index] = Err(BundleExecutionError::BankProcessingTimeLimitReached); + }); + results + } + )); + + // 0, 1, 2 processed; 3, 4 buffered + assert_eq!(bundle_storage.unprocessed_bundles_len(), 2); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 0); + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles[bank_processing_done_index..], bundles_to_process); + vec![Ok(()); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + } + + #[test] + fn test_process_bundles_bank_execution_error_dropped() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 5 bundles across the queue + let bundles = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles.clone()).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles, bundles_to_process); + vec![ + Err(BundleExecutionError::TransactionFailure( + LoadAndExecuteBundleError::ProcessingTimeExceeded(Duration::from_secs(1)), + )); + bundles_to_process.len() + ] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + } + + #[test] + fn test_process_bundles_tip_error_dropped() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 5 bundles across the queue + let bundles = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles.clone()).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles, bundles_to_process); + vec![ + Err(BundleExecutionError::TipError(TipError::LockError)); + bundles_to_process.len() + ] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + } + + #[test] + fn test_process_bundles_lock_error_dropped() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 5 bundles across the queue + let bundles = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + vec![Err(BundleExecutionError::LockError); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + } + + #[test] + fn test_process_bundles_cost_model_exceeded_set_aside_and_requeued() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 5 bundles across the queue + let bundles = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles.clone()).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + + // buffered bundles are moved to cost model side deque + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles, bundles_to_process); + vec![Err(BundleExecutionError::ExceedsCostModel); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 5); + + // double check there's no bundles to process + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert!(bundles_to_process.is_empty()); + vec![Ok(()); bundles_to_process.len()] + } + )); + + // create a new bank w/ new slot number, cost model buffered packets should move back onto queue + // in the same order they were originally + let bank = &bank_forks.read().unwrap().working_bank(); + let new_bank = Arc::new(Bank::new_from_parent( + bank, + bank.collector_id(), + bank.slot() + 1, + )); + assert!(!bundle_storage.process_bundles( + new_bank, + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + // make sure same order as original + assert_bundles_same(&bundles, bundles_to_process); + vec![Ok(()); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 0); + } + + #[test] + fn test_process_bundles_cost_model_exceeded_buffer_capacity() { + solana_logger::setup(); + + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank_forks = Arc::new(RwLock::new(BankForks::new( + Bank::new_no_wallclock_throttle_for_tests(&genesis_config), + ))); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage( + VecDeque::with_capacity(10), + VecDeque::with_capacity(10), + ); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 15 bundles across the queue + let bundles0 = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles0.clone()).unwrap(); + + let mut bundle_stage_stats = BundleStageLoopMetrics::default(); + let mut bundle_stage_leader_metrics = BundleStageLeaderMetrics::new(0); + + // receive and buffer bundles to the cost model reserve to test the capacity/dropped bundles there + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + // buffered bundles are moved to cost model side deque + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles0, bundles_to_process); + vec![Err(BundleExecutionError::ExceedsCostModel); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 5); + + let bundles1 = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles1.clone()).unwrap(); + // should get 5 more bundles + cost model buffered length should be 10 + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + // buffered bundles are moved to cost model side deque + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles1, bundles_to_process); + vec![Err(BundleExecutionError::ExceedsCostModel); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 10); + + let bundles2 = make_random_bundles(&mint_keypair, 5, 2, genesis_config.hash()); + sender.send(bundles2.clone()).unwrap(); + + // this set will get dropped from cost model buffered bundles + let result = bundle_receiver.receive_and_buffer_bundles( + &mut unprocessed_storage, + &mut bundle_stage_stats, + &mut bundle_stage_leader_metrics, + ); + assert!(result.is_ok()); + + let bundle_storage = unprocessed_storage.bundle_storage().unwrap(); + // buffered bundles are moved to cost model side deque, but its at capacity so stays the same size + assert!(!bundle_storage.process_bundles( + bank_forks.read().unwrap().working_bank(), + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + assert_bundles_same(&bundles2, bundles_to_process); + vec![Err(BundleExecutionError::ExceedsCostModel); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 10); + + // create new bank then call process_bundles again, expect to see [bundles1,bundles2] + let bank = &bank_forks.read().unwrap().working_bank(); + let new_bank = Arc::new(Bank::new_from_parent( + bank, + bank.collector_id(), + bank.slot() + 1, + )); + assert!(!bundle_storage.process_bundles( + new_bank, + &mut bundle_stage_leader_metrics, + &HashSet::default(), + |bundles_to_process, _stats| { + // make sure same order as original + let expected_bundles: Vec<_> = + bundles0.iter().chain(bundles1.iter()).cloned().collect(); + assert_bundles_same(&expected_bundles, bundles_to_process); + vec![Ok(()); bundles_to_process.len()] + } + )); + assert_eq!(bundle_storage.unprocessed_bundles_len(), 0); + assert_eq!(bundle_storage.cost_model_buffered_bundles_len(), 0); + } +} diff --git a/core/src/bundle_stage/bundle_reserved_space_manager.rs b/core/src/bundle_stage/bundle_reserved_space_manager.rs new file mode 100644 index 0000000000..42cb7adeb6 --- /dev/null +++ b/core/src/bundle_stage/bundle_reserved_space_manager.rs @@ -0,0 +1,189 @@ +use {solana_runtime::bank::Bank, solana_sdk::clock::Slot, std::sync::Arc}; + +/// Manager responsible for reserving `bundle_reserved_cost` during the first `reserved_ticks` of a bank +/// and resetting the block cost limit to `block_cost_limit` after the reserved tick period is over +pub struct BundleReservedSpaceManager { + // the bank's cost limit + block_cost_limit: u64, + // bundles get this much reserved space for the first reserved_ticks + bundle_reserved_cost: u64, + // a reduced block_compute_limit is reserved for this many ticks, afterwards it goes back to full cost + reserved_ticks: u64, + last_slot_updated: Slot, +} + +impl BundleReservedSpaceManager { + pub fn new(block_cost_limit: u64, bundle_reserved_cost: u64, reserved_ticks: u64) -> Self { + Self { + block_cost_limit, + bundle_reserved_cost, + reserved_ticks, + last_slot_updated: u64::MAX, + } + } + + /// Call this on creation of new bank and periodically while bundle processing + /// to manage the block_cost_limits + pub fn tick(&mut self, bank: &Arc) { + if self.last_slot_updated == bank.slot() && !self.is_in_reserved_tick_period(bank) { + // new slot logic already ran, need to revert the block cost limit to original if + // ticks are past the reserved tick mark + debug!( + "slot: {} ticks: {}, resetting block_cost_limit to {}", + bank.slot(), + bank.tick_height(), + self.block_cost_limit + ); + bank.write_cost_tracker() + .unwrap() + .set_block_cost_limit(self.block_cost_limit); + } else if self.last_slot_updated != bank.slot() && self.is_in_reserved_tick_period(bank) { + // new slot, if in the first max_tick - tick_height slots reserve space + // otherwise can leave the current block limit as is + let new_block_cost_limit = self.reduced_block_cost_limit(); + debug!( + "slot: {} ticks: {}, reserving block_cost_limit with block_cost_limit of {}", + bank.slot(), + bank.tick_height(), + new_block_cost_limit + ); + bank.write_cost_tracker() + .unwrap() + .set_block_cost_limit(new_block_cost_limit); + self.last_slot_updated = bank.slot(); + } + } + + /// return true if the bank is still in the period where block_cost_limits is reduced + pub fn is_in_reserved_tick_period(&self, bank: &Bank) -> bool { + bank.tick_height() < self.reserved_ticks + } + + /// return the block_cost_limits as determined by the tick height of the bank + pub fn expected_block_cost_limits(&self, bank: &Bank) -> u64 { + if self.is_in_reserved_tick_period(bank) { + self.reduced_block_cost_limit() + } else { + self.block_cost_limit() + } + } + + pub fn reduced_block_cost_limit(&self) -> u64 { + self.block_cost_limit + .saturating_sub(self.bundle_reserved_cost) + } + + pub fn block_cost_limit(&self) -> u64 { + self.block_cost_limit + } +} + +#[cfg(test)] +mod tests { + use { + crate::bundle_stage::bundle_reserved_space_manager::BundleReservedSpaceManager, + solana_ledger::genesis_utils::create_genesis_config, solana_runtime::bank::Bank, + solana_sdk::hash::Hash, std::sync::Arc, + }; + + #[test] + fn test_reserve_block_cost_limits_during_reserved_ticks() { + const BUNDLE_BLOCK_COST_LIMITS_RESERVATION: u64 = 100; + + let genesis_config_info = create_genesis_config(100); + let bank = Arc::new(Bank::new_for_tests(&genesis_config_info.genesis_config)); + + let block_cost_limits = bank.read_cost_tracker().unwrap().block_cost_limit(); + + let mut reserved_space = BundleReservedSpaceManager::new( + block_cost_limits, + BUNDLE_BLOCK_COST_LIMITS_RESERVATION, + 5, + ); + reserved_space.tick(&bank); + + assert_eq!( + bank.read_cost_tracker().unwrap().block_cost_limit(), + block_cost_limits - BUNDLE_BLOCK_COST_LIMITS_RESERVATION + ); + } + + #[test] + fn test_dont_reserve_block_cost_limits_after_reserved_ticks() { + const BUNDLE_BLOCK_COST_LIMITS_RESERVATION: u64 = 100; + + let genesis_config_info = create_genesis_config(100); + let bank = Arc::new(Bank::new_for_tests(&genesis_config_info.genesis_config)); + + let block_cost_limits = bank.read_cost_tracker().unwrap().block_cost_limit(); + + for _ in 0..5 { + bank.register_tick(&Hash::default()); + } + + let mut reserved_space = BundleReservedSpaceManager::new( + block_cost_limits, + BUNDLE_BLOCK_COST_LIMITS_RESERVATION, + 5, + ); + reserved_space.tick(&bank); + + assert_eq!( + bank.read_cost_tracker().unwrap().block_cost_limit(), + block_cost_limits + ); + } + + #[test] + fn test_dont_reset_block_cost_limits_during_reserved_ticks() { + const BUNDLE_BLOCK_COST_LIMITS_RESERVATION: u64 = 100; + + let genesis_config_info = create_genesis_config(100); + let bank = Arc::new(Bank::new_for_tests(&genesis_config_info.genesis_config)); + + let block_cost_limits = bank.read_cost_tracker().unwrap().block_cost_limit(); + + let mut reserved_space = BundleReservedSpaceManager::new( + block_cost_limits, + BUNDLE_BLOCK_COST_LIMITS_RESERVATION, + 5, + ); + + reserved_space.tick(&bank); + bank.register_tick(&Hash::default()); + reserved_space.tick(&bank); + + assert_eq!( + bank.read_cost_tracker().unwrap().block_cost_limit(), + block_cost_limits - BUNDLE_BLOCK_COST_LIMITS_RESERVATION + ); + } + + #[test] + fn test_reset_block_cost_limits_after_reserved_ticks() { + const BUNDLE_BLOCK_COST_LIMITS_RESERVATION: u64 = 100; + + let genesis_config_info = create_genesis_config(100); + let bank = Arc::new(Bank::new_for_tests(&genesis_config_info.genesis_config)); + + let block_cost_limits = bank.read_cost_tracker().unwrap().block_cost_limit(); + + let mut reserved_space = BundleReservedSpaceManager::new( + block_cost_limits, + BUNDLE_BLOCK_COST_LIMITS_RESERVATION, + 5, + ); + + reserved_space.tick(&bank); + + for _ in 0..5 { + bank.register_tick(&Hash::default()); + } + reserved_space.tick(&bank); + + assert_eq!( + bank.read_cost_tracker().unwrap().block_cost_limit(), + block_cost_limits + ); + } +} diff --git a/core/src/bundle_stage/bundle_stage_leader_metrics.rs b/core/src/bundle_stage/bundle_stage_leader_metrics.rs new file mode 100644 index 0000000000..a32e874bb3 --- /dev/null +++ b/core/src/bundle_stage/bundle_stage_leader_metrics.rs @@ -0,0 +1,502 @@ +use { + crate::{ + immutable_deserialized_bundle::DeserializedBundleError, + leader_slot_banking_stage_metrics::{self, LeaderSlotMetricsTracker}, + }, + solana_bundle::{bundle_execution::LoadAndExecuteBundleError, BundleExecutionError}, + solana_poh::poh_recorder::BankStart, + solana_sdk::{bundle::SanitizedBundle, clock::Slot, saturating_add_assign}, +}; + +pub struct BundleStageLeaderMetrics { + bundle_stage_metrics_tracker: BundleStageStatsMetricsTracker, + leader_slot_metrics_tracker: LeaderSlotMetricsTracker, +} + +pub(crate) enum MetricsTrackerAction { + Noop, + ReportAndResetTracker, + NewTracker(Option), + ReportAndNewTracker(Option), +} + +impl BundleStageLeaderMetrics { + pub fn new(id: u32) -> Self { + Self { + bundle_stage_metrics_tracker: BundleStageStatsMetricsTracker::new(id), + leader_slot_metrics_tracker: LeaderSlotMetricsTracker::new(id), + } + } + + pub(crate) fn check_leader_slot_boundary( + &mut self, + bank_start: Option<&BankStart>, + ) -> ( + leader_slot_banking_stage_metrics::MetricsTrackerAction, + MetricsTrackerAction, + ) { + let banking_stage_metrics_action = self + .leader_slot_metrics_tracker + .check_leader_slot_boundary(bank_start); + let bundle_stage_metrics_action = self + .bundle_stage_metrics_tracker + .check_leader_slot_boundary(bank_start); + (banking_stage_metrics_action, bundle_stage_metrics_action) + } + + pub(crate) fn apply_action( + &mut self, + banking_stage_metrics_action: leader_slot_banking_stage_metrics::MetricsTrackerAction, + bundle_stage_metrics_action: MetricsTrackerAction, + ) -> Option { + self.leader_slot_metrics_tracker + .apply_action(banking_stage_metrics_action); + self.bundle_stage_metrics_tracker + .apply_action(bundle_stage_metrics_action) + } + + pub fn leader_slot_metrics_tracker(&mut self) -> &mut LeaderSlotMetricsTracker { + &mut self.leader_slot_metrics_tracker + } + + pub fn bundle_stage_metrics_tracker(&mut self) -> &mut BundleStageStatsMetricsTracker { + &mut self.bundle_stage_metrics_tracker + } +} + +pub struct BundleStageStatsMetricsTracker { + bundle_stage_metrics: Option, + id: u32, +} + +impl BundleStageStatsMetricsTracker { + pub fn new(id: u32) -> Self { + Self { + bundle_stage_metrics: None, + id, + } + } + + /// Similar to as LeaderSlotMetricsTracker::check_leader_slot_boundary + pub(crate) fn check_leader_slot_boundary( + &mut self, + bank_start: Option<&BankStart>, + ) -> MetricsTrackerAction { + match (self.bundle_stage_metrics.as_mut(), bank_start) { + (None, None) => MetricsTrackerAction::Noop, + (Some(_), None) => MetricsTrackerAction::ReportAndResetTracker, + // Our leader slot has begun, time to create a new slot tracker + (None, Some(bank_start)) => MetricsTrackerAction::NewTracker(Some( + BundleStageStats::new(self.id, bank_start.working_bank.slot()), + )), + (Some(bundle_stage_metrics), Some(bank_start)) => { + if bundle_stage_metrics.slot != bank_start.working_bank.slot() { + // Last slot has ended, new slot has began + MetricsTrackerAction::ReportAndNewTracker(Some(BundleStageStats::new( + self.id, + bank_start.working_bank.slot(), + ))) + } else { + MetricsTrackerAction::Noop + } + } + } + } + + /// Similar to LeaderSlotMetricsTracker::apply_action + pub(crate) fn apply_action(&mut self, action: MetricsTrackerAction) -> Option { + match action { + MetricsTrackerAction::Noop => None, + MetricsTrackerAction::ReportAndResetTracker => { + let mut reported_slot = None; + if let Some(bundle_stage_metrics) = self.bundle_stage_metrics.as_mut() { + bundle_stage_metrics.report(); + reported_slot = bundle_stage_metrics.reported_slot(); + } + self.bundle_stage_metrics = None; + reported_slot + } + MetricsTrackerAction::NewTracker(new_bundle_stage_metrics) => { + self.bundle_stage_metrics = new_bundle_stage_metrics; + self.bundle_stage_metrics.as_ref().unwrap().reported_slot() + } + MetricsTrackerAction::ReportAndNewTracker(new_bundle_stage_metrics) => { + let mut reported_slot = None; + if let Some(bundle_stage_metrics) = self.bundle_stage_metrics.as_mut() { + bundle_stage_metrics.report(); + reported_slot = bundle_stage_metrics.reported_slot(); + } + self.bundle_stage_metrics = new_bundle_stage_metrics; + reported_slot + } + } + } + + pub(crate) fn increment_sanitize_transaction_result( + &mut self, + result: &Result, + ) { + if let Some(bundle_stage_metrics) = self.bundle_stage_metrics.as_mut() { + match result { + Ok(_) => { + saturating_add_assign!(bundle_stage_metrics.sanitize_transaction_ok, 1); + } + Err(e) => match e { + DeserializedBundleError::VoteOnlyMode => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_vote_only_mode, + 1 + ); + } + DeserializedBundleError::BlacklistedAccount => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_blacklisted_account, + 1 + ); + } + DeserializedBundleError::FailedToSerializeTransaction => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_failed_to_serialize, + 1 + ); + } + DeserializedBundleError::DuplicateTransaction => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_duplicate_transaction, + 1 + ); + } + DeserializedBundleError::FailedCheckTransactions => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_failed_check, + 1 + ); + } + DeserializedBundleError::FailedToSerializePacket => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_failed_to_serialize, + 1 + ); + } + DeserializedBundleError::EmptyBatch => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_failed_empty_batch, + 1 + ); + } + DeserializedBundleError::TooManyPackets => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_failed_too_many_packets, + 1 + ); + } + DeserializedBundleError::MarkedDiscard => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_failed_marked_discard, + 1 + ); + } + DeserializedBundleError::SignatureVerificationFailure => { + saturating_add_assign!( + bundle_stage_metrics.sanitize_transaction_failed_sig_verify_failed, + 1 + ); + } + }, + } + } + } + + pub fn increment_bundle_execution_result(&mut self, result: &Result<(), BundleExecutionError>) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + match result { + Ok(_) => { + saturating_add_assign!(bundle_stage_metrics.execution_results_ok, 1); + } + Err(BundleExecutionError::PohRecordError(_)) + | Err(BundleExecutionError::BankProcessingTimeLimitReached) => { + saturating_add_assign!( + bundle_stage_metrics.execution_results_poh_max_height, + 1 + ); + } + Err(BundleExecutionError::TransactionFailure( + LoadAndExecuteBundleError::ProcessingTimeExceeded(_), + )) => { + saturating_add_assign!(bundle_stage_metrics.num_execution_timeouts, 1); + } + Err(BundleExecutionError::TransactionFailure( + LoadAndExecuteBundleError::TransactionError { .. }, + )) => { + saturating_add_assign!( + bundle_stage_metrics.execution_results_transaction_failures, + 1 + ); + } + Err(BundleExecutionError::TransactionFailure( + LoadAndExecuteBundleError::LockError { .. }, + )) + | Err(BundleExecutionError::LockError) => { + saturating_add_assign!(bundle_stage_metrics.num_lock_errors, 1); + } + Err(BundleExecutionError::ExceedsCostModel) => { + saturating_add_assign!( + bundle_stage_metrics.execution_results_exceeds_cost_model, + 1 + ); + } + Err(BundleExecutionError::TipError(_)) => { + saturating_add_assign!(bundle_stage_metrics.execution_results_tip_errors, 1); + } + Err(BundleExecutionError::TransactionFailure( + LoadAndExecuteBundleError::InvalidPreOrPostAccounts, + )) => { + saturating_add_assign!(bundle_stage_metrics.bad_argument, 1); + } + } + } + } + + pub(crate) fn increment_sanitize_bundle_elapsed_us(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!(bundle_stage_metrics.sanitize_bundle_elapsed_us, count); + } + } + + pub(crate) fn increment_locked_bundle_elapsed_us(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!(bundle_stage_metrics.locked_bundle_elapsed_us, count); + } + } + + pub(crate) fn increment_num_init_tip_account_errors(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!(bundle_stage_metrics.num_init_tip_account_errors, count); + } + } + + pub(crate) fn increment_num_init_tip_account_ok(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!(bundle_stage_metrics.num_init_tip_account_ok, count); + } + } + + pub(crate) fn increment_num_change_tip_receiver_errors(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!(bundle_stage_metrics.num_change_tip_receiver_errors, count); + } + } + + pub(crate) fn increment_num_change_tip_receiver_ok(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!(bundle_stage_metrics.num_change_tip_receiver_ok, count); + } + } + + pub(crate) fn increment_change_tip_receiver_elapsed_us(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!(bundle_stage_metrics.change_tip_receiver_elapsed_us, count); + } + } + + pub(crate) fn increment_num_execution_retries(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!(bundle_stage_metrics.num_execution_retries, count); + } + } + + pub(crate) fn increment_execute_locked_bundles_elapsed_us(&mut self, count: u64) { + if let Some(bundle_stage_metrics) = &mut self.bundle_stage_metrics { + saturating_add_assign!( + bundle_stage_metrics.execute_locked_bundles_elapsed_us, + count + ); + } + } +} + +#[derive(Default)] +pub struct BundleStageStats { + id: u32, + slot: u64, + is_reported: bool, + + sanitize_transaction_ok: u64, + sanitize_transaction_vote_only_mode: u64, + sanitize_transaction_blacklisted_account: u64, + sanitize_transaction_failed_to_serialize: u64, + sanitize_transaction_duplicate_transaction: u64, + sanitize_transaction_failed_check: u64, + sanitize_bundle_elapsed_us: u64, + sanitize_transaction_failed_empty_batch: u64, + sanitize_transaction_failed_too_many_packets: u64, + sanitize_transaction_failed_marked_discard: u64, + sanitize_transaction_failed_sig_verify_failed: u64, + + locked_bundle_elapsed_us: u64, + + num_lock_errors: u64, + + num_init_tip_account_errors: u64, + num_init_tip_account_ok: u64, + + num_change_tip_receiver_errors: u64, + num_change_tip_receiver_ok: u64, + change_tip_receiver_elapsed_us: u64, + + num_execution_timeouts: u64, + num_execution_retries: u64, + + execute_locked_bundles_elapsed_us: u64, + + execution_results_ok: u64, + execution_results_poh_max_height: u64, + execution_results_transaction_failures: u64, + execution_results_exceeds_cost_model: u64, + execution_results_tip_errors: u64, + execution_results_max_retries: u64, + + bad_argument: u64, +} + +impl BundleStageStats { + pub fn new(id: u32, slot: Slot) -> BundleStageStats { + BundleStageStats { + id, + slot, + is_reported: false, + ..BundleStageStats::default() + } + } + + /// Returns `Some(self.slot)` if the metrics have been reported, otherwise returns None + fn reported_slot(&self) -> Option { + if self.is_reported { + Some(self.slot) + } else { + None + } + } + + pub fn report(&mut self) { + self.is_reported = true; + + datapoint_info!( + "bundle_stage-stats", + ("id", self.id, i64), + ("slot", self.slot, i64), + ("num_sanitized_ok", self.sanitize_transaction_ok, i64), + ( + "sanitize_transaction_vote_only_mode", + self.sanitize_transaction_vote_only_mode, + i64 + ), + ( + "sanitize_transaction_blacklisted_account", + self.sanitize_transaction_blacklisted_account, + i64 + ), + ( + "sanitize_transaction_failed_to_serialize", + self.sanitize_transaction_failed_to_serialize, + i64 + ), + ( + "sanitize_transaction_duplicate_transaction", + self.sanitize_transaction_duplicate_transaction, + i64 + ), + ( + "sanitize_transaction_failed_check", + self.sanitize_transaction_failed_check, + i64 + ), + ( + "sanitize_bundle_elapsed_us", + self.sanitize_bundle_elapsed_us, + i64 + ), + ( + "sanitize_transaction_failed_empty_batch", + self.sanitize_transaction_failed_empty_batch, + i64 + ), + ( + "sanitize_transaction_failed_too_many_packets", + self.sanitize_transaction_failed_too_many_packets, + i64 + ), + ( + "sanitize_transaction_failed_marked_discard", + self.sanitize_transaction_failed_marked_discard, + i64 + ), + ( + "sanitize_transaction_failed_sig_verify_failed", + self.sanitize_transaction_failed_sig_verify_failed, + i64 + ), + ( + "locked_bundle_elapsed_us", + self.locked_bundle_elapsed_us, + i64 + ), + ("num_lock_errors", self.num_lock_errors, i64), + ( + "num_init_tip_account_errors", + self.num_init_tip_account_errors, + i64 + ), + ("num_init_tip_account_ok", self.num_init_tip_account_ok, i64), + ( + "num_change_tip_receiver_errors", + self.num_change_tip_receiver_errors, + i64 + ), + ( + "num_change_tip_receiver_ok", + self.num_change_tip_receiver_ok, + i64 + ), + ( + "change_tip_receiver_elapsed_us", + self.change_tip_receiver_elapsed_us, + i64 + ), + ("num_execution_timeouts", self.num_execution_timeouts, i64), + ("num_execution_retries", self.num_execution_retries, i64), + ( + "execute_locked_bundles_elapsed_us", + self.execute_locked_bundles_elapsed_us, + i64 + ), + ("execution_results_ok", self.execution_results_ok, i64), + ( + "execution_results_poh_max_height", + self.execution_results_poh_max_height, + i64 + ), + ( + "execution_results_transaction_failures", + self.execution_results_transaction_failures, + i64 + ), + ( + "execution_results_exceeds_cost_model", + self.execution_results_exceeds_cost_model, + i64 + ), + ( + "execution_results_tip_errors", + self.execution_results_tip_errors, + i64 + ), + ( + "execution_results_max_retries", + self.execution_results_max_retries, + i64 + ), + ("bad_argument", self.bad_argument, i64) + ); + } +} diff --git a/core/src/bundle_stage/committer.rs b/core/src/bundle_stage/committer.rs new file mode 100644 index 0000000000..ae87c25aaf --- /dev/null +++ b/core/src/bundle_stage/committer.rs @@ -0,0 +1,221 @@ +use { + crate::{ + banking_stage::committer::CommitTransactionDetails, + leader_slot_banking_stage_timing_metrics::LeaderExecuteAndCommitTimings, + }, + solana_bundle::bundle_execution::LoadAndExecuteBundleOutput, + solana_ledger::blockstore_processor::TransactionStatusSender, + solana_measure::measure_us, + solana_runtime::{ + bank::{ + Bank, CommitTransactionCounts, TransactionBalances, TransactionBalancesSet, + TransactionResults, + }, + bank_utils, + prioritization_fee_cache::PrioritizationFeeCache, + vote_sender_types::ReplayVoteSender, + }, + solana_sdk::{saturating_add_assign, transaction::SanitizedTransaction}, + solana_transaction_status::{ + token_balances::{TransactionTokenBalances, TransactionTokenBalancesSet}, + PreBalanceInfo, + }, + std::sync::Arc, +}; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CommitBundleDetails { + pub commit_transaction_details: Vec>, +} + +pub struct Committer { + transaction_status_sender: Option, + replay_vote_sender: ReplayVoteSender, + prioritization_fee_cache: Arc, +} + +impl Committer { + pub fn new( + transaction_status_sender: Option, + replay_vote_sender: ReplayVoteSender, + prioritization_fee_cache: Arc, + ) -> Self { + Self { + transaction_status_sender, + replay_vote_sender, + prioritization_fee_cache, + } + } + + pub(crate) fn transaction_status_sender_enabled(&self) -> bool { + self.transaction_status_sender.is_some() + } + + /// Very similar to Committer::commit_transactions, but works with bundles. + /// The main difference is there's multiple non-parallelizable transaction vectors to commit + /// and post-balances are collected after execution instead of from the bank in Self::collect_balances_and_send_status_batch. + #[allow(clippy::too_many_arguments)] + pub(crate) fn commit_bundle<'a>( + &self, + bundle_execution_output: &'a mut LoadAndExecuteBundleOutput<'a>, + mut starting_transaction_index: Option, + bank: &Arc, + execute_and_commit_timings: &mut LeaderExecuteAndCommitTimings, + ) -> (u64, CommitBundleDetails) { + let (last_blockhash, lamports_per_signature) = + bank.last_blockhash_and_lamports_per_signature(); + + let transaction_output = bundle_execution_output.bundle_transaction_results_mut(); + + let (commit_transaction_details, commit_times): (Vec<_>, Vec<_>) = transaction_output + .iter_mut() + .map(|bundle_results| { + let committed_transactions_count = bundle_results + .load_and_execute_transactions_output() + .executed_transactions_count + as u64; + + let committed_non_vote_transactions_count = bundle_results + .load_and_execute_transactions_output() + .executed_non_vote_transactions_count + as u64; + + let committed_with_failure_result_count = bundle_results + .load_and_execute_transactions_output() + .executed_transactions_count + .saturating_sub( + bundle_results + .load_and_execute_transactions_output() + .executed_with_successful_result_count, + ) as u64; + + let signature_count = bundle_results + .load_and_execute_transactions_output() + .signature_count; + + let sanitized_transactions = bundle_results.transactions().to_vec(); + let execution_results = bundle_results.execution_results().to_vec(); + + let loaded_transactions = bundle_results.loaded_transactions_mut(); + debug!("loaded_transactions: {:?}", loaded_transactions); + + let (tx_results, commit_time_us) = measure_us!(bank.commit_transactions( + &sanitized_transactions, + loaded_transactions, + execution_results, + last_blockhash, + lamports_per_signature, + CommitTransactionCounts { + committed_transactions_count, + committed_non_vote_transactions_count, + committed_with_failure_result_count, + signature_count, + }, + &mut execute_and_commit_timings.execute_timings, + )); + + let commit_transaction_statuses: Vec<_> = tx_results + .execution_results + .iter() + .map(|execution_result| match execution_result.details() { + Some(details) => CommitTransactionDetails::Committed { + compute_units: details.executed_units, + }, + None => CommitTransactionDetails::NotCommitted, + }) + .collect(); + + let ((), find_and_send_votes_us) = measure_us!({ + bank_utils::find_and_send_votes( + &sanitized_transactions, + &tx_results, + Some(&self.replay_vote_sender), + ); + + let post_balance_info = bundle_results.post_balance_info().clone(); + let pre_balance_info = bundle_results.pre_balance_info(); + + let num_committed = tx_results + .execution_results + .iter() + .filter(|r| r.was_executed()) + .count(); + + self.collect_balances_and_send_status_batch( + tx_results, + bank, + sanitized_transactions, + pre_balance_info, + post_balance_info, + starting_transaction_index, + ); + + // NOTE: we're doing batched records, so we need to increment the poh starting_transaction_index + // by number committed so the next batch will have the correct starting_transaction_index + starting_transaction_index = + starting_transaction_index.map(|starting_transaction_index| { + starting_transaction_index.saturating_add(num_committed) + }); + + self.prioritization_fee_cache + .update(bank, bundle_results.executed_transactions().into_iter()); + }); + saturating_add_assign!( + execute_and_commit_timings.find_and_send_votes_us, + find_and_send_votes_us + ); + + (commit_transaction_statuses, commit_time_us) + }) + .unzip(); + + ( + commit_times.iter().sum(), + CommitBundleDetails { + commit_transaction_details, + }, + ) + } + + fn collect_balances_and_send_status_batch( + &self, + tx_results: TransactionResults, + bank: &Arc, + sanitized_transactions: Vec, + pre_balance_info: &mut PreBalanceInfo, + (post_balances, post_token_balances): (TransactionBalances, TransactionTokenBalances), + starting_transaction_index: Option, + ) { + if let Some(transaction_status_sender) = &self.transaction_status_sender { + let mut transaction_index = starting_transaction_index.unwrap_or_default(); + let batch_transaction_indexes: Vec<_> = tx_results + .execution_results + .iter() + .map(|result| { + if result.was_executed() { + let this_transaction_index = transaction_index; + saturating_add_assign!(transaction_index, 1); + this_transaction_index + } else { + 0 + } + }) + .collect(); + transaction_status_sender.send_transaction_status_batch( + bank.clone(), + sanitized_transactions, + tx_results.execution_results, + TransactionBalancesSet::new( + std::mem::take(&mut pre_balance_info.native), + post_balances, + ), + TransactionTokenBalancesSet::new( + std::mem::take(&mut pre_balance_info.token), + post_token_balances, + ), + tx_results.rent_debits, + batch_transaction_indexes, + ); + } + } +} diff --git a/core/src/bundle_stage/result.rs b/core/src/bundle_stage/result.rs new file mode 100644 index 0000000000..3370251791 --- /dev/null +++ b/core/src/bundle_stage/result.rs @@ -0,0 +1,41 @@ +use { + crate::{ + bundle_stage::bundle_account_locker::BundleAccountLockerError, tip_manager::TipPaymentError, + }, + anchor_lang::error::Error, + solana_bundle::bundle_execution::LoadAndExecuteBundleError, + solana_poh::poh_recorder::PohRecorderError, + thiserror::Error, +}; + +pub type BundleExecutionResult = Result; + +#[derive(Error, Debug, Clone)] +pub enum BundleExecutionError { + #[error("PoH record error: {0}")] + PohRecordError(#[from] PohRecorderError), + + #[error("Bank is done processing")] + BankProcessingDone, + + #[error("Execution error: {0}")] + ExecutionError(#[from] LoadAndExecuteBundleError), + + #[error("The bundle exceeds the cost model")] + ExceedsCostModel, + + #[error("Tip error {0}")] + TipError(#[from] TipPaymentError), + + #[error("Error locking bundle")] + LockError(#[from] BundleAccountLockerError), +} + +impl From for TipPaymentError { + fn from(anchor_err: Error) -> Self { + match anchor_err { + Error::AnchorError(e) => Self::AnchorError(e.error_msg), + Error::ProgramError(e) => Self::AnchorError(e.to_string()), + } + } +} diff --git a/core/src/consensus_cache_updater.rs b/core/src/consensus_cache_updater.rs new file mode 100644 index 0000000000..1bd75554c0 --- /dev/null +++ b/core/src/consensus_cache_updater.rs @@ -0,0 +1,52 @@ +use { + solana_runtime::bank::Bank, + solana_sdk::{clock::Epoch, pubkey::Pubkey}, + std::collections::HashSet, +}; + +#[derive(Default)] +pub(crate) struct ConsensusCacheUpdater { + last_epoch_updated: Epoch, + consensus_accounts_cache: HashSet, +} + +impl ConsensusCacheUpdater { + pub(crate) fn consensus_accounts_cache(&self) -> &HashSet { + &self.consensus_accounts_cache + } + + /// Builds a HashSet of all consensus related accounts for the Bank's epoch + fn get_consensus_accounts(bank: &Bank) -> HashSet { + let mut consensus_accounts: HashSet = HashSet::new(); + if let Some(epoch_stakes) = bank.epoch_stakes(bank.epoch()) { + // votes use the following accounts: + // - vote_account pubkey: writeable + // - authorized_voter_pubkey: read-only + // - node_keypair pubkey: payer (writeable) + let node_id_vote_accounts = epoch_stakes.node_id_to_vote_accounts(); + + let vote_accounts = node_id_vote_accounts + .values() + .flat_map(|v| v.vote_accounts.clone()); + + // vote_account + consensus_accounts.extend(vote_accounts.into_iter()); + // authorized_voter_pubkey + consensus_accounts.extend(epoch_stakes.epoch_authorized_voters().keys()); + // node_keypair + consensus_accounts.extend(epoch_stakes.node_id_to_vote_accounts().keys()); + } + consensus_accounts + } + + /// Updates consensus-related accounts on epoch boundaries + pub(crate) fn maybe_update(&mut self, bank: &Bank) -> bool { + if bank.epoch() > self.last_epoch_updated { + self.consensus_accounts_cache = Self::get_consensus_accounts(bank); + self.last_epoch_updated = bank.epoch(); + true + } else { + false + } + } +} diff --git a/core/src/immutable_deserialized_bundle.rs b/core/src/immutable_deserialized_bundle.rs new file mode 100644 index 0000000000..b3d82741fd --- /dev/null +++ b/core/src/immutable_deserialized_bundle.rs @@ -0,0 +1,483 @@ +use { + crate::{ + immutable_deserialized_packet::ImmutableDeserializedPacket, packet_bundle::PacketBundle, + }, + solana_perf::sigverify::verify_packet, + solana_runtime::{bank::Bank, transaction_error_metrics::TransactionErrorMetrics}, + solana_sdk::{ + bundle::SanitizedBundle, clock::MAX_PROCESSING_AGE, pubkey::Pubkey, signature::Signature, + transaction::SanitizedTransaction, + }, + std::{ + collections::{hash_map::RandomState, HashSet}, + iter::repeat, + }, + thiserror::Error, +}; + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum DeserializedBundleError { + #[error("FailedToSerializePacket")] + FailedToSerializePacket, + + #[error("EmptyBatch")] + EmptyBatch, + + #[error("TooManyPackets")] + TooManyPackets, + + #[error("MarkedDiscard")] + MarkedDiscard, + + #[error("SignatureVerificationFailure")] + SignatureVerificationFailure, + + #[error("Bank is in vote-only mode")] + VoteOnlyMode, + + #[error("Bundle mentions blacklisted account")] + BlacklistedAccount, + + #[error("Bundle contains a transaction that failed to serialize")] + FailedToSerializeTransaction, + + #[error("Bundle contains a duplicate transaction")] + DuplicateTransaction, + + #[error("Bundle failed check_transactions")] + FailedCheckTransactions, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ImmutableDeserializedBundle { + bundle_id: String, + packets: Vec, +} + +impl ImmutableDeserializedBundle { + pub fn new( + bundle: &mut PacketBundle, + max_len: Option, + ) -> Result { + // Checks: non-zero, less than some length, marked for discard, signature verification failed, failed to sanitize to + // ImmutableDeserializedPacket + if bundle.batch.is_empty() { + return Err(DeserializedBundleError::EmptyBatch); + } + if max_len + .map(|max_len| bundle.batch.len() > max_len) + .unwrap_or(false) + { + return Err(DeserializedBundleError::TooManyPackets); + } + if bundle.batch.iter().any(|p| p.meta().discard()) { + return Err(DeserializedBundleError::MarkedDiscard); + } + if bundle.batch.iter_mut().any(|p| !verify_packet(p, false)) { + return Err(DeserializedBundleError::SignatureVerificationFailure); + } + + let immutable_packets: Vec<_> = bundle + .batch + .iter() + .filter_map(|p| ImmutableDeserializedPacket::new(p.clone()).ok()) + .collect(); + + if bundle.batch.len() != immutable_packets.len() { + return Err(DeserializedBundleError::FailedToSerializePacket); + } + + Ok(Self { + bundle_id: bundle.bundle_id.clone(), + packets: immutable_packets, + }) + } + + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + self.packets.len() + } + + pub fn bundle_id(&self) -> &str { + &self.bundle_id + } + + /// A bundle has the following requirements: + /// - all transactions must be sanitiz-able + /// - no duplicate signatures + /// - must not contain a blacklisted account + /// - can't already be processed or contain a bad blockhash + pub fn build_sanitized_bundle( + &self, + bank: &Bank, + blacklisted_accounts: &HashSet, + transaction_error_metrics: &mut TransactionErrorMetrics, + ) -> Result { + if bank.vote_only_bank() { + return Err(DeserializedBundleError::VoteOnlyMode); + } + + let transactions: Vec = self + .packets + .iter() + .filter_map(|p| { + p.build_sanitized_transaction(&bank.feature_set, bank.vote_only_bank(), bank) + }) + .collect(); + + if self.packets.len() != transactions.len() { + return Err(DeserializedBundleError::FailedToSerializeTransaction); + } + + let unique_signatures: HashSet<&Signature, RandomState> = + HashSet::from_iter(transactions.iter().map(|tx| tx.signature())); + if unique_signatures.len() != transactions.len() { + return Err(DeserializedBundleError::DuplicateTransaction); + } + + let contains_blacklisted_account = transactions.iter().any(|tx| { + tx.message() + .account_keys() + .iter() + .any(|acc| blacklisted_accounts.contains(acc)) + }); + + if contains_blacklisted_account { + return Err(DeserializedBundleError::BlacklistedAccount); + } + + // assume everything locks okay to check for already-processed transaction or expired/invalid blockhash + let lock_results: Vec<_> = repeat(Ok(())).take(transactions.len()).collect(); + let check_results = bank.check_transactions( + &transactions, + &lock_results, + MAX_PROCESSING_AGE, + transaction_error_metrics, + ); + + if check_results.iter().any(|r| r.0.is_err()) { + return Err(DeserializedBundleError::FailedCheckTransactions); + } + + Ok(SanitizedBundle { + transactions, + bundle_id: self.bundle_id.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use { + crate::{ + immutable_deserialized_bundle::{DeserializedBundleError, ImmutableDeserializedBundle}, + packet_bundle::PacketBundle, + }, + solana_client::rpc_client::SerializableTransaction, + solana_ledger::genesis_utils::create_genesis_config, + solana_perf::packet::PacketBatch, + solana_runtime::{ + bank::{Bank, NewBankOptions}, + genesis_utils::GenesisConfigInfo, + transaction_error_metrics::TransactionErrorMetrics, + }, + solana_sdk::{ + hash::Hash, + packet::Packet, + pubkey::Pubkey, + signature::{Keypair, Signer}, + system_transaction::transfer, + }, + std::{collections::HashSet, sync::Arc}, + }; + + /// Happy case + #[test] + fn test_simple_get_sanitized_bundle() { + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank = Arc::new(Bank::new_no_wallclock_throttle_for_tests(&genesis_config)); + + let kp = Keypair::new(); + + let tx0 = transfer(&mint_keypair, &kp.pubkey(), 500, genesis_config.hash()); + + let tx1 = transfer(&mint_keypair, &kp.pubkey(), 501, genesis_config.hash()); + + let bundle = ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![ + Packet::from_data(None, &tx0).unwrap(), + Packet::from_data(None, &tx1).unwrap(), + ]), + bundle_id: String::default(), + }, + None, + ) + .unwrap(); + + let mut transaction_errors = TransactionErrorMetrics::default(); + let sanitized_bundle = bundle + .build_sanitized_bundle(&bank, &HashSet::default(), &mut transaction_errors) + .unwrap(); + assert_eq!(sanitized_bundle.transactions.len(), 2); + assert_eq!( + sanitized_bundle.transactions[0].signature(), + tx0.get_signature() + ); + assert_eq!( + sanitized_bundle.transactions[1].signature(), + tx1.get_signature() + ); + } + + #[test] + fn test_empty_batch_fails_to_init() { + assert_eq!( + ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![]), + bundle_id: String::default(), + }, + None, + ), + Err(DeserializedBundleError::EmptyBatch) + ); + } + + #[test] + fn test_too_many_packets_fails_to_init() { + let kp = Keypair::new(); + + assert_eq!( + ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new( + (0..10) + .map(|i| { + Packet::from_data( + None, + transfer(&kp, &kp.pubkey(), i, Hash::default()), + ) + .unwrap() + }) + .collect() + ), + bundle_id: String::default(), + }, + Some(5), + ), + Err(DeserializedBundleError::TooManyPackets) + ); + } + + #[test] + fn test_packets_marked_discard_fails_to_init() { + let kp = Keypair::new(); + + let mut packet = + Packet::from_data(None, transfer(&kp, &kp.pubkey(), 100, Hash::default())).unwrap(); + packet.meta_mut().set_discard(true); + + assert_eq!( + ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![packet]), + bundle_id: String::default(), + }, + Some(5), + ), + Err(DeserializedBundleError::MarkedDiscard) + ); + } + + #[test] + fn test_bad_signature_fails_to_init() { + let kp0 = Keypair::new(); + let kp1 = Keypair::new(); + + let mut tx0 = transfer(&kp0, &kp0.pubkey(), 100, Hash::default()); + let tx1 = transfer(&kp1, &kp0.pubkey(), 100, Hash::default()); + tx0.signatures = tx1.signatures; + + assert_eq!( + ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data(None, tx0).unwrap()]), + bundle_id: String::default(), + }, + None + ), + Err(DeserializedBundleError::SignatureVerificationFailure) + ); + } + + #[test] + fn test_vote_only_bank_fails_to_build() { + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let parent = Arc::new(Bank::new_no_wallclock_throttle_for_tests(&genesis_config)); + let vote_only_bank = Arc::new(Bank::new_from_parent_with_options( + &parent, + &Pubkey::new_unique(), + 1, + NewBankOptions { + vote_only_bank: true, + }, + )); + + let kp = Keypair::new(); + + let tx0 = transfer(&mint_keypair, &kp.pubkey(), 500, genesis_config.hash()); + + let bundle = ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data(None, tx0).unwrap()]), + bundle_id: String::default(), + }, + None, + ) + .unwrap(); + + let mut transaction_errors = TransactionErrorMetrics::default(); + assert_matches!( + bundle.build_sanitized_bundle( + &vote_only_bank, + &HashSet::default(), + &mut transaction_errors + ), + Err(DeserializedBundleError::VoteOnlyMode) + ); + } + + #[test] + fn test_duplicate_signature_fails_to_build() { + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank = Arc::new(Bank::new_no_wallclock_throttle_for_tests(&genesis_config)); + + let kp = Keypair::new(); + + let tx0 = transfer(&mint_keypair, &kp.pubkey(), 500, genesis_config.hash()); + + let bundle = ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![ + Packet::from_data(None, &tx0).unwrap(), + Packet::from_data(None, &tx0).unwrap(), + ]), + bundle_id: String::default(), + }, + None, + ) + .unwrap(); + + let mut transaction_errors = TransactionErrorMetrics::default(); + assert_matches!( + bundle.build_sanitized_bundle(&bank, &HashSet::default(), &mut transaction_errors), + Err(DeserializedBundleError::DuplicateTransaction) + ); + } + + #[test] + fn test_blacklisted_account_fails_to_build() { + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank = Arc::new(Bank::new_no_wallclock_throttle_for_tests(&genesis_config)); + + let kp = Keypair::new(); + + let tx0 = transfer(&mint_keypair, &kp.pubkey(), 500, genesis_config.hash()); + + let bundle = ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data(None, tx0).unwrap()]), + bundle_id: String::default(), + }, + None, + ) + .unwrap(); + + let mut transaction_errors = TransactionErrorMetrics::default(); + assert_matches!( + bundle.build_sanitized_bundle( + &bank, + &HashSet::from([kp.pubkey()]), + &mut transaction_errors + ), + Err(DeserializedBundleError::BlacklistedAccount) + ); + } + + #[test] + fn test_already_processed_tx_fails_to_build() { + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank = Arc::new(Bank::new_no_wallclock_throttle_for_tests(&genesis_config)); + + let kp = Keypair::new(); + + let tx0 = transfer(&mint_keypair, &kp.pubkey(), 500, genesis_config.hash()); + + bank.process_transaction(&tx0).unwrap(); + + let bundle = ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data(None, tx0).unwrap()]), + bundle_id: String::default(), + }, + None, + ) + .unwrap(); + + let mut transaction_errors = TransactionErrorMetrics::default(); + assert_matches!( + bundle.build_sanitized_bundle(&bank, &HashSet::default(), &mut transaction_errors), + Err(DeserializedBundleError::FailedCheckTransactions) + ); + } + + #[test] + fn test_bad_blockhash_fails_to_build() { + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(10_000); + let bank = Arc::new(Bank::new_no_wallclock_throttle_for_tests(&genesis_config)); + + let kp = Keypair::new(); + + let tx0 = transfer(&mint_keypair, &kp.pubkey(), 500, Hash::default()); + + let bundle = ImmutableDeserializedBundle::new( + &mut PacketBundle { + batch: PacketBatch::new(vec![Packet::from_data(None, tx0).unwrap()]), + bundle_id: String::default(), + }, + None, + ) + .unwrap(); + + let mut transaction_errors = TransactionErrorMetrics::default(); + assert_matches!( + bundle.build_sanitized_bundle(&bank, &HashSet::default(), &mut transaction_errors), + Err(DeserializedBundleError::FailedCheckTransactions) + ); + } +} diff --git a/core/src/latest_unprocessed_votes.rs b/core/src/latest_unprocessed_votes.rs index db606a4a2b..8dde88be74 100644 --- a/core/src/latest_unprocessed_votes.rs +++ b/core/src/latest_unprocessed_votes.rs @@ -136,7 +136,7 @@ pub(crate) fn weighted_random_order_by_stake<'a>( } #[derive(Default, Debug)] -pub(crate) struct VoteBatchInsertionMetrics { +pub struct VoteBatchInsertionMetrics { pub(crate) num_dropped_gossip: usize, pub(crate) num_dropped_tpu: usize, } diff --git a/core/src/lib.rs b/core/src/lib.rs index 6747732231..988142e06a 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -14,6 +14,7 @@ pub mod ancestor_hashes_service; pub mod banking_stage; pub mod banking_trace; pub mod broadcast_stage; +pub mod bundle_stage; pub mod cache_block_meta_service; pub mod cluster_info_vote_listener; pub mod cluster_nodes; @@ -23,6 +24,7 @@ pub mod cluster_slots_service; pub mod commitment_service; pub mod completed_data_sets_service; pub mod consensus; +pub mod consensus_cache_updater; pub mod cost_update_service; pub mod drop_bank_service; pub mod duplicate_repair_status; @@ -31,6 +33,7 @@ pub mod fork_choice; pub mod forward_packet_batches_by_accounts; pub mod gen_keys; pub mod heaviest_subtree_fork_choice; +pub mod immutable_deserialized_bundle; pub mod immutable_deserialized_packet; mod latest_unprocessed_votes; pub mod latest_validator_votes_for_frozen_banks; @@ -42,11 +45,13 @@ pub mod multi_iterator_scanner; pub mod next_leader; pub mod optimistic_confirmation_verifier; pub mod outstanding_requests; +pub mod packet_bundle; pub mod packet_deserializer; pub mod packet_threshold; pub mod poh_timing_report_service; pub mod poh_timing_reporter; pub mod progress_map; +pub mod proxy; pub mod qos_service; pub mod read_write_account_set; pub mod repair_generic_traversal; @@ -70,6 +75,7 @@ pub mod snapshot_packager_service; pub mod staked_nodes_updater_service; pub mod stats_reporter_service; pub mod system_monitor_service; +pub mod tip_manager; mod tower1_14_11; mod tower1_7_14; pub mod tower_storage; @@ -107,3 +113,41 @@ extern crate solana_frozen_abi_macro; #[cfg(test)] #[macro_use] extern crate matches; + +use { + solana_sdk::packet::{Meta, Packet, PacketFlags, PACKET_DATA_SIZE}, + std::{ + cmp::min, + net::{IpAddr, Ipv4Addr}, + }, +}; + +const UNKNOWN_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)); + +// NOTE: last profiled at around 180ns +pub fn proto_packet_to_packet(p: jito_protos::proto::packet::Packet) -> Packet { + let mut data = [0; PACKET_DATA_SIZE]; + let copy_len = min(data.len(), p.data.len()); + data[..copy_len].copy_from_slice(&p.data[..copy_len]); + let mut packet = Packet::new(data, Meta::default()); + if let Some(meta) = p.meta { + packet.meta_mut().size = meta.size as usize; + packet.meta_mut().addr = meta.addr.parse().unwrap_or(UNKNOWN_IP); + packet.meta_mut().port = meta.port as u16; + if let Some(flags) = meta.flags { + if flags.simple_vote_tx { + packet.meta_mut().flags.insert(PacketFlags::SIMPLE_VOTE_TX); + } + if flags.forwarded { + packet.meta_mut().flags.insert(PacketFlags::FORWARDED); + } + if flags.tracer_packet { + packet.meta_mut().flags.insert(PacketFlags::TRACER_PACKET); + } + if flags.repair { + packet.meta_mut().flags.insert(PacketFlags::REPAIR); + } + } + } + packet +} diff --git a/core/src/packet_bundle.rs b/core/src/packet_bundle.rs new file mode 100644 index 0000000000..2158f37414 --- /dev/null +++ b/core/src/packet_bundle.rs @@ -0,0 +1,7 @@ +use solana_perf::packet::PacketBatch; + +#[derive(Clone, Debug)] +pub struct PacketBundle { + pub batch: PacketBatch, + pub bundle_id: String, +} diff --git a/core/src/proxy/auth.rs b/core/src/proxy/auth.rs new file mode 100644 index 0000000000..39821e12ef --- /dev/null +++ b/core/src/proxy/auth.rs @@ -0,0 +1,185 @@ +use { + crate::proxy::ProxyError, + chrono::Utc, + jito_protos::proto::auth::{ + auth_service_client::AuthServiceClient, GenerateAuthChallengeRequest, + GenerateAuthTokensRequest, RefreshAccessTokenRequest, Role, Token, + }, + solana_gossip::cluster_info::ClusterInfo, + solana_sdk::signature::{Keypair, Signer}, + std::{ + sync::{Arc, Mutex}, + time::Duration, + }, + tokio::time::timeout, + tonic::{service::Interceptor, transport::Channel, Code, Request, Status}, +}; + +/// Interceptor responsible for adding the access token to request headers. +pub(crate) struct AuthInterceptor { + /// The token added to each request header. + access_token: Arc>, +} + +impl AuthInterceptor { + pub(crate) fn new(access_token: Arc>) -> Self { + Self { access_token } + } +} + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + request.metadata_mut().insert( + "authorization", + format!("Bearer {}", self.access_token.lock().unwrap().value) + .parse() + .unwrap(), + ); + + Ok(request) + } +} + +/// Generates an auth challenge then generates and returns validated auth tokens. +pub async fn generate_auth_tokens( + auth_service_client: &mut AuthServiceClient, + // used to sign challenges + keypair: &Keypair, +) -> crate::proxy::Result<( + Token, /* access_token */ + Token, /* refresh_token */ +)> { + debug!("generate_auth_challenge"); + let challenge_response = auth_service_client + .generate_auth_challenge(GenerateAuthChallengeRequest { + role: Role::Validator as i32, + pubkey: keypair.pubkey().as_ref().to_vec(), + }) + .await + .map_err(|e: Status| { + if e.code() == Code::PermissionDenied { + ProxyError::AuthenticationPermissionDenied + } else { + ProxyError::AuthenticationError(e.to_string()) + } + })?; + + let formatted_challenge = format!( + "{}-{}", + keypair.pubkey(), + challenge_response.into_inner().challenge + ); + + let signed_challenge = keypair + .sign_message(formatted_challenge.as_bytes()) + .as_ref() + .to_vec(); + + debug!( + "formatted_challenge: {} signed_challenge: {:?}", + formatted_challenge, signed_challenge + ); + + debug!("generate_auth_tokens"); + let auth_tokens = auth_service_client + .generate_auth_tokens(GenerateAuthTokensRequest { + challenge: formatted_challenge, + client_pubkey: keypair.pubkey().as_ref().to_vec(), + signed_challenge, + }) + .await + .map_err(|e| ProxyError::AuthenticationError(e.to_string()))?; + + let inner = auth_tokens.into_inner(); + let access_token = get_validated_token(inner.access_token)?; + let refresh_token = get_validated_token(inner.refresh_token)?; + + Ok((access_token, refresh_token)) +} + +/// Tries to refresh the access token or run full-reauth if needed. +pub async fn maybe_refresh_auth_tokens( + auth_service_client: &mut AuthServiceClient, + access_token: &Arc>, + refresh_token: &Token, + cluster_info: &Arc, + connection_timeout: &Duration, + refresh_within_s: u64, +) -> crate::proxy::Result<( + Option, // access token + Option, // refresh token +)> { + let access_token_expiry: u64 = access_token + .lock() + .unwrap() + .expires_at_utc + .as_ref() + .map(|ts| ts.seconds as u64) + .unwrap_or_default(); + let refresh_token_expiry: u64 = refresh_token + .expires_at_utc + .as_ref() + .map(|ts| ts.seconds as u64) + .unwrap_or_default(); + + let now = Utc::now().timestamp() as u64; + + let should_refresh_access = + access_token_expiry.checked_sub(now).unwrap_or_default() <= refresh_within_s; + let should_generate_new_tokens = + refresh_token_expiry.checked_sub(now).unwrap_or_default() <= refresh_within_s; + + if should_generate_new_tokens { + let kp = cluster_info.keypair().clone(); + + let (new_access_token, new_refresh_token) = timeout( + *connection_timeout, + generate_auth_tokens(auth_service_client, kp.as_ref()), + ) + .await + .map_err(|_| ProxyError::MethodTimeout("generate_auth_tokens".to_string()))? + .map_err(|e| ProxyError::MethodError(e.to_string()))?; + + return Ok((Some(new_access_token), Some(new_refresh_token))); + } else if should_refresh_access { + let new_access_token = timeout( + *connection_timeout, + refresh_access_token(auth_service_client, refresh_token), + ) + .await + .map_err(|_| ProxyError::MethodTimeout("refresh_access_token".to_string()))? + .map_err(|e| ProxyError::MethodError(e.to_string()))?; + + return Ok((Some(new_access_token), None)); + } + + Ok((None, None)) +} + +pub async fn refresh_access_token( + auth_service_client: &mut AuthServiceClient, + refresh_token: &Token, +) -> crate::proxy::Result { + let response = auth_service_client + .refresh_access_token(RefreshAccessTokenRequest { + refresh_token: refresh_token.value.clone(), + }) + .await + .map_err(|e| ProxyError::AuthenticationError(e.to_string()))?; + get_validated_token(response.into_inner().access_token) +} + +/// An invalid token is one where any of its fields are None or the token itself is None. +/// Performs the necessary validations on the auth tokens before returning, +/// i.e. it is safe to call .unwrap() on the token fields from the call-site. +fn get_validated_token(maybe_token: Option) -> crate::proxy::Result { + let token = maybe_token + .ok_or_else(|| ProxyError::BadAuthenticationToken("received a null token".to_string()))?; + if token.expires_at_utc.is_none() { + Err(ProxyError::BadAuthenticationToken( + "expires_at_utc field is null".to_string(), + )) + } else { + Ok(token) + } +} diff --git a/core/src/proxy/block_engine_stage.rs b/core/src/proxy/block_engine_stage.rs new file mode 100644 index 0000000000..4128f5379f --- /dev/null +++ b/core/src/proxy/block_engine_stage.rs @@ -0,0 +1,533 @@ +//! Maintains a connection to the Block Engine. +//! +//! The Block Engine is responsible for the following: +//! - Acts as a system that sends high profit bundles and transactions to a validator. +//! - Sends transactions and bundles to the validator. +use { + crate::{ + banking_trace::BankingPacketSender, + packet_bundle::PacketBundle, + proto_packet_to_packet, + proxy::{ + auth::{generate_auth_tokens, maybe_refresh_auth_tokens, AuthInterceptor}, + ProxyError, + }, + }, + crossbeam_channel::Sender, + jito_protos::proto::{ + auth::{auth_service_client::AuthServiceClient, Token}, + block_engine::{ + self, block_engine_validator_client::BlockEngineValidatorClient, + BlockBuilderFeeInfoRequest, + }, + }, + solana_gossip::cluster_info::ClusterInfo, + solana_perf::packet::PacketBatch, + solana_sdk::{ + pubkey::Pubkey, saturating_add_assign, signature::Signer, signer::keypair::Keypair, + }, + std::{ + str::FromStr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread::{self, Builder, JoinHandle}, + time::Duration, + }, + tokio::time::{interval, sleep, timeout}, + tonic::{ + codegen::InterceptedService, + transport::{Channel, Endpoint}, + Status, Streaming, + }, +}; + +const CONNECTION_TIMEOUT_S: u64 = 10; +const CONNECTION_BACKOFF_S: u64 = 5; + +#[derive(Default)] +struct BlockEngineStageStats { + num_bundles: u64, + num_bundle_packets: u64, + num_packets: u64, + num_empty_packets: u64, +} + +impl BlockEngineStageStats { + pub(crate) fn report(&self) { + datapoint_info!( + "block_engine_stage-stats", + ("num_bundles", self.num_bundles, i64), + ("num_bundle_packets", self.num_bundle_packets, i64), + ("num_packets", self.num_packets, i64), + ("num_empty_packets", self.num_empty_packets, i64) + ); + } +} + +pub struct BlockBuilderFeeInfo { + pub block_builder: Pubkey, + pub block_builder_commission: u64, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BlockEngineConfig { + /// Block Engine URL + pub block_engine_url: String, + + /// If set then it will be assumed the backend verified packets so signature verification will be bypassed in the validator. + pub trust_packets: bool, +} + +pub struct BlockEngineStage { + t_hdls: Vec>, +} + +impl BlockEngineStage { + pub fn new( + block_engine_config: Arc>, + // Channel that bundles get piped through. + bundle_tx: Sender>, + // The keypair stored here is used to sign auth challenges. + cluster_info: Arc, + // Channel that non-trusted packets get piped through. + packet_tx: Sender, + // Channel that trusted packets get piped through. + banking_packet_sender: BankingPacketSender, + exit: Arc, + block_builder_fee_info: &Arc>, + ) -> Self { + let block_builder_fee_info = block_builder_fee_info.clone(); + + let thread = Builder::new() + .name("block-engine-stage".to_string()) + .spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(Self::start( + block_engine_config, + cluster_info, + bundle_tx, + packet_tx, + banking_packet_sender, + exit, + block_builder_fee_info, + )); + }) + .unwrap(); + + Self { + t_hdls: vec![thread], + } + } + + pub fn join(self) -> thread::Result<()> { + for t in self.t_hdls { + t.join()?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn start( + block_engine_config: Arc>, + cluster_info: Arc, + bundle_tx: Sender>, + packet_tx: Sender, + banking_packet_sender: BankingPacketSender, + exit: Arc, + block_builder_fee_info: Arc>, + ) { + const CONNECTION_TIMEOUT: Duration = Duration::from_secs(CONNECTION_TIMEOUT_S); + const CONNECTION_BACKOFF: Duration = Duration::from_secs(CONNECTION_BACKOFF_S); + let mut error_count: u64 = 0; + + while !exit.load(Ordering::Relaxed) { + // Wait until a valid config is supplied (either initially or by admin rpc) + // Use if!/else here to avoid extra CONNECTION_BACKOFF wait on successful termination + if !Self::is_valid_block_engine_config(&block_engine_config.lock().unwrap()) { + sleep(CONNECTION_BACKOFF).await; + } else if let Err(e) = Self::connect_auth_and_stream( + &block_engine_config, + &cluster_info, + &bundle_tx, + &packet_tx, + &banking_packet_sender, + &exit, + &block_builder_fee_info, + &CONNECTION_TIMEOUT, + ) + .await + { + match e { + // This error is frequent on hot spares, and the parsed string does not work + // with datapoints (incorrect escaping). + ProxyError::AuthenticationPermissionDenied => { + warn!("block engine permission denied. not on leader schedule. ignore if hot-spare.") + } + e => { + error_count += 1; + datapoint_warn!( + "block_engine_stage-proxy_error", + ("count", error_count, i64), + ("error", e.to_string(), String), + ); + } + } + sleep(CONNECTION_BACKOFF).await; + } + } + } + + async fn connect_auth_and_stream( + block_engine_config: &Arc>, + cluster_info: &Arc, + bundle_tx: &Sender>, + packet_tx: &Sender, + banking_packet_sender: &BankingPacketSender, + exit: &Arc, + block_builder_fee_info: &Arc>, + connection_timeout: &Duration, + ) -> crate::proxy::Result<()> { + // Get a copy of configs here in case they have changed at runtime + let keypair = cluster_info.keypair().clone(); + let local_config = block_engine_config.lock().unwrap().clone(); + + let mut backend_endpoint = Endpoint::from_shared(local_config.block_engine_url.clone()) + .map_err(|_| { + ProxyError::BlockEngineConnectionError(format!( + "invalid block engine url value: {}", + local_config.block_engine_url + )) + })? + .tcp_keepalive(Some(Duration::from_secs(60))); + if local_config.block_engine_url.starts_with("https") { + backend_endpoint = backend_endpoint + .tls_config(tonic::transport::ClientTlsConfig::new()) + .map_err(|_| { + ProxyError::BlockEngineConnectionError( + "failed to set tls_config for block engine service".to_string(), + ) + })?; + } + + debug!("connecting to auth: {}", local_config.block_engine_url); + let auth_channel = timeout(*connection_timeout, backend_endpoint.connect()) + .await + .map_err(|_| ProxyError::AuthenticationConnectionTimeout)? + .map_err(|e| ProxyError::AuthenticationConnectionError(e.to_string()))?; + + let mut auth_client = AuthServiceClient::new(auth_channel); + + debug!("generating authentication token"); + let (access_token, refresh_token) = timeout( + *connection_timeout, + generate_auth_tokens(&mut auth_client, &keypair), + ) + .await + .map_err(|_| ProxyError::AuthenticationTimeout)??; + + datapoint_info!( + "block_engine_stage-tokens_generated", + ("url", local_config.block_engine_url, String), + ("count", 1, i64), + ); + + debug!( + "connecting to block engine: {}", + local_config.block_engine_url + ); + let block_engine_channel = timeout(*connection_timeout, backend_endpoint.connect()) + .await + .map_err(|_| ProxyError::BlockEngineConnectionTimeout)? + .map_err(|e| ProxyError::BlockEngineConnectionError(e.to_string()))?; + + let access_token = Arc::new(Mutex::new(access_token)); + let block_engine_client = BlockEngineValidatorClient::with_interceptor( + block_engine_channel, + AuthInterceptor::new(access_token.clone()), + ); + + Self::start_consuming_block_engine_bundles_and_packets( + bundle_tx, + block_engine_client, + packet_tx, + &local_config, + block_engine_config, + banking_packet_sender, + exit, + block_builder_fee_info, + auth_client, + access_token, + refresh_token, + connection_timeout, + keypair, + cluster_info, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn start_consuming_block_engine_bundles_and_packets( + bundle_tx: &Sender>, + mut client: BlockEngineValidatorClient>, + packet_tx: &Sender, + local_config: &BlockEngineConfig, // local copy of config with current connections + global_config: &Arc>, // guarded reference for detecting run-time updates + banking_packet_sender: &BankingPacketSender, + exit: &Arc, + block_builder_fee_info: &Arc>, + auth_client: AuthServiceClient, + access_token: Arc>, + refresh_token: Token, + connection_timeout: &Duration, + keypair: Arc, + cluster_info: &Arc, + ) -> crate::proxy::Result<()> { + let subscribe_packets_stream = timeout( + *connection_timeout, + client.subscribe_packets(block_engine::SubscribePacketsRequest {}), + ) + .await + .map_err(|_| ProxyError::MethodTimeout("block_engine_subscribe_packets".to_string()))? + .map_err(|e| ProxyError::MethodError(e.to_string()))? + .into_inner(); + + let subscribe_bundles_stream = timeout( + *connection_timeout, + client.subscribe_bundles(block_engine::SubscribeBundlesRequest {}), + ) + .await + .map_err(|_| ProxyError::MethodTimeout("subscribe_bundles".to_string()))? + .map_err(|e| ProxyError::MethodError(e.to_string()))? + .into_inner(); + + let block_builder_info = timeout( + *connection_timeout, + client.get_block_builder_fee_info(BlockBuilderFeeInfoRequest {}), + ) + .await + .map_err(|_| ProxyError::MethodTimeout("get_block_builder_fee_info".to_string()))? + .map_err(|e| ProxyError::MethodError(e.to_string()))? + .into_inner(); + + { + let mut bb_fee = block_builder_fee_info.lock().unwrap(); + bb_fee.block_builder_commission = block_builder_info.commission; + bb_fee.block_builder = + Pubkey::from_str(&block_builder_info.pubkey).unwrap_or(bb_fee.block_builder); + } + + Self::consume_bundle_and_packet_stream( + client, + (subscribe_bundles_stream, subscribe_packets_stream), + bundle_tx, + packet_tx, + local_config, + global_config, + banking_packet_sender, + exit, + block_builder_fee_info, + auth_client, + access_token, + refresh_token, + keypair, + cluster_info, + connection_timeout, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn consume_bundle_and_packet_stream( + mut client: BlockEngineValidatorClient>, + (mut bundle_stream, mut packet_stream): ( + Streaming, + Streaming, + ), + bundle_tx: &Sender>, + packet_tx: &Sender, + local_config: &BlockEngineConfig, // local copy of config with current connections + global_config: &Arc>, // guarded reference for detecting run-time updates + banking_packet_sender: &BankingPacketSender, + exit: &Arc, + block_builder_fee_info: &Arc>, + mut auth_client: AuthServiceClient, + access_token: Arc>, + mut refresh_token: Token, + keypair: Arc, + cluster_info: &Arc, + connection_timeout: &Duration, + ) -> crate::proxy::Result<()> { + const METRICS_TICK: Duration = Duration::from_secs(1); + const MAINTENANCE_TICK: Duration = Duration::from_secs(10 * 60); + let refresh_within_s: u64 = METRICS_TICK.as_secs().saturating_mul(3).saturating_div(2); + + let mut num_full_refreshes: u64 = 1; + let mut num_refresh_access_token: u64 = 0; + let mut block_engine_stats = BlockEngineStageStats::default(); + let mut metrics_and_auth_tick = interval(METRICS_TICK); + let mut maintenance_tick = interval(MAINTENANCE_TICK); + + info!("connected to packet and bundle stream"); + + while !exit.load(Ordering::Relaxed) { + tokio::select! { + maybe_msg = packet_stream.message() => { + let resp = maybe_msg?.ok_or(ProxyError::GrpcStreamDisconnected)?; + Self::handle_block_engine_packets(resp, packet_tx, banking_packet_sender, local_config.trust_packets, &mut block_engine_stats)?; + } + maybe_bundles = bundle_stream.message() => { + Self::handle_block_engine_maybe_bundles(maybe_bundles, bundle_tx, &mut block_engine_stats)?; + } + _ = metrics_and_auth_tick.tick() => { + block_engine_stats.report(); + block_engine_stats = BlockEngineStageStats::default(); + + if cluster_info.id() != keypair.pubkey() { + return Err(ProxyError::AuthenticationConnectionError("validator identity changed".to_string())); + } + + if *global_config.lock().unwrap() != *local_config { + return Err(ProxyError::AuthenticationConnectionError("block engine config changed".to_string())); + } + + let (maybe_new_access, maybe_new_refresh) = maybe_refresh_auth_tokens(&mut auth_client, + &access_token, + &refresh_token, + cluster_info, + connection_timeout, + refresh_within_s, + ).await?; + + if let Some(new_token) = maybe_new_access { + num_refresh_access_token += 1; + datapoint_info!( + "block_engine_stage-refresh_access_token", + ("url", &local_config.block_engine_url, String), + ("count", num_refresh_access_token, i64), + ); + *access_token.lock().unwrap() = new_token; + } + if let Some(new_token) = maybe_new_refresh { + num_full_refreshes += 1; + datapoint_info!( + "block_engine_stage-tokens_generated", + ("url", &local_config.block_engine_url, String), + ("count", num_full_refreshes, i64), + ); + refresh_token = new_token; + } + } + _ = maintenance_tick.tick() => { + let block_builder_info = timeout( + *connection_timeout, + client.get_block_builder_fee_info(BlockBuilderFeeInfoRequest{}) + ) + .await + .map_err(|_| ProxyError::MethodTimeout("get_block_builder_fee_info".to_string()))? + .map_err(|e| ProxyError::MethodError(e.to_string()))? + .into_inner(); + + let mut bb_fee = block_builder_fee_info.lock().unwrap(); + bb_fee.block_builder_commission = block_builder_info.commission; + bb_fee.block_builder = Pubkey::from_str(&block_builder_info.pubkey).unwrap_or(bb_fee.block_builder); + } + } + } + Ok(()) + } + + fn handle_block_engine_maybe_bundles( + maybe_bundles_response: Result, Status>, + bundle_sender: &Sender>, + block_engine_stats: &mut BlockEngineStageStats, + ) -> crate::proxy::Result<()> { + let bundles_response = maybe_bundles_response?.ok_or(ProxyError::GrpcStreamDisconnected)?; + let bundles: Vec = bundles_response + .bundles + .into_iter() + .filter_map(|bundle| { + Some(PacketBundle { + batch: PacketBatch::new( + bundle + .bundle? + .packets + .into_iter() + .map(proto_packet_to_packet) + .collect(), + ), + bundle_id: bundle.uuid, + }) + }) + .collect(); + + saturating_add_assign!(block_engine_stats.num_bundles, bundles.len() as u64); + saturating_add_assign!( + block_engine_stats.num_bundle_packets, + bundles.iter().map(|bundle| bundle.batch.len() as u64).sum() + ); + + // NOTE: bundles are sanitized in bundle_sanitizer module + bundle_sender + .send(bundles) + .map_err(|_| ProxyError::PacketForwardError) + } + + fn handle_block_engine_packets( + resp: block_engine::SubscribePacketsResponse, + packet_tx: &Sender, + banking_packet_sender: &BankingPacketSender, + trust_packets: bool, + block_engine_stats: &mut BlockEngineStageStats, + ) -> crate::proxy::Result<()> { + if let Some(batch) = resp.batch { + if batch.packets.is_empty() { + saturating_add_assign!(block_engine_stats.num_empty_packets, 1); + return Ok(()); + } + + let packet_batch = PacketBatch::new( + batch + .packets + .into_iter() + .map(proto_packet_to_packet) + .collect(), + ); + + saturating_add_assign!(block_engine_stats.num_packets, packet_batch.len() as u64); + + if trust_packets { + banking_packet_sender + .send(Arc::new((vec![packet_batch], None))) + .map_err(|_| ProxyError::PacketForwardError)?; + } else { + packet_tx + .send(packet_batch) + .map_err(|_| ProxyError::PacketForwardError)?; + } + } else { + saturating_add_assign!(block_engine_stats.num_empty_packets, 1); + } + + Ok(()) + } + + pub fn is_valid_block_engine_config(config: &BlockEngineConfig) -> bool { + if config.block_engine_url.is_empty() { + warn!("can't connect to block_engine. missing block_engine_url."); + return false; + } + if let Err(e) = Endpoint::from_str(&config.block_engine_url) { + error!( + "can't connect to block engine. error creating block engine endpoint - {}", + e.to_string() + ); + return false; + } + true + } +} diff --git a/core/src/proxy/fetch_stage_manager.rs b/core/src/proxy/fetch_stage_manager.rs new file mode 100644 index 0000000000..38471fc512 --- /dev/null +++ b/core/src/proxy/fetch_stage_manager.rs @@ -0,0 +1,170 @@ +use { + crate::proxy::{HeartbeatEvent, ProxyError}, + crossbeam_channel::{select, tick, Receiver, Sender}, + solana_client::connection_cache::Protocol, + solana_gossip::{cluster_info::ClusterInfo, contact_info}, + solana_perf::packet::PacketBatch, + std::{ + net::SocketAddr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::{self, Builder, JoinHandle}, + time::{Duration, Instant}, + }, +}; + +const HEARTBEAT_TIMEOUT: Duration = Duration::from_millis(1500); // Empirically determined from load testing +const DISCONNECT_DELAY: Duration = Duration::from_secs(60); +const METRICS_CADENCE: Duration = Duration::from_secs(1); + +/// Manages switching between the validator's tpu ports and that of the proxy's. +/// Switch-overs are triggered by late and missed heartbeats. +pub struct FetchStageManager { + t_hdl: JoinHandle<()>, +} + +impl FetchStageManager { + pub fn new( + // ClusterInfo is used to switch between advertising the proxy's TPU ports and that of this validator's. + cluster_info: Arc, + // Channel that heartbeats are received from. Entirely responsible for triggering switch-overs. + heartbeat_rx: Receiver, + // Channel that packets from FetchStage are intercepted from. + packet_intercept_rx: Receiver, + // Intercepted packets get piped through here. + packet_tx: Sender, + exit: Arc, + ) -> Self { + let t_hdl = Self::start( + cluster_info, + heartbeat_rx, + packet_intercept_rx, + packet_tx, + exit, + ); + + Self { t_hdl } + } + + /// Disconnect fetch behaviour + /// Starts connected + /// When connected and a packet is received, forward it + /// When disconnected, packet is dropped + /// When receiving heartbeat while connected and not pending disconnect + /// Sets pending_disconnect to true and records time + /// When receiving heartbeat while connected, and pending for > DISCONNECT_DELAY_SEC + /// Sets fetch_connected to false, pending_disconnect to false + /// Advertises TPU ports sent in heartbeat + /// When tick is received without heartbeat_received + /// Sets fetch_connected to true, pending_disconnect to false + /// Advertises saved contact info + fn start( + cluster_info: Arc, + heartbeat_rx: Receiver, + packet_intercept_rx: Receiver, + packet_tx: Sender, + exit: Arc, + ) -> JoinHandle<()> { + Builder::new().name("fetch-stage-manager".into()).spawn(move || { + let my_fallback_contact_info = cluster_info.my_contact_info(); + + let mut fetch_connected = true; + let mut heartbeat_received = false; + let mut pending_disconnect = false; + + let mut pending_disconnect_ts = Instant::now(); + + let heartbeat_tick = tick(HEARTBEAT_TIMEOUT); + let metrics_tick = tick(METRICS_CADENCE); + let mut packets_forwarded = 0; + let mut heartbeats_received = 0; + loop { + select! { + recv(packet_intercept_rx) -> pkt => { + match pkt { + Ok(pkt) => { + if fetch_connected { + if packet_tx.send(pkt).is_err() { + error!("{:?}", ProxyError::PacketForwardError); + return; + } + packets_forwarded += 1; + } + } + Err(_) => { + warn!("packet intercept receiver disconnected, shutting down"); + return; + } + } + } + recv(heartbeat_tick) -> _ => { + if exit.load(Ordering::Relaxed) { + break; + } + if !heartbeat_received && (!fetch_connected || pending_disconnect) { + warn!("heartbeat late, reconnecting fetch stage"); + fetch_connected = true; + pending_disconnect = false; + + // unwrap safe here bc contact_info.tpu(Protocol::QUIC) and contact_info.tpu_forwards(Protocol::QUIC) + // are checked on startup + if let Err(e) = Self::set_tpu_addresses(&cluster_info, my_fallback_contact_info.tpu(Protocol::QUIC).unwrap(), my_fallback_contact_info.tpu_forwards(Protocol::QUIC).unwrap()) { + error!("error setting tpu or tpu_fwd to ({:?}, {:?}), error: {:?}", my_fallback_contact_info.tpu(Protocol::QUIC).unwrap(), my_fallback_contact_info.tpu_forwards(Protocol::QUIC).unwrap(), e); + } + heartbeats_received = 0; + } + heartbeat_received = false; + } + recv(heartbeat_rx) -> tpu_info => { + if let Ok((tpu_addr, tpu_forward_addr)) = tpu_info { + heartbeats_received += 1; + heartbeat_received = true; + if fetch_connected && !pending_disconnect { + info!("received heartbeat while fetch stage connected, pending disconnect after delay"); + pending_disconnect_ts = Instant::now(); + pending_disconnect = true; + } + if fetch_connected && pending_disconnect && pending_disconnect_ts.elapsed() > DISCONNECT_DELAY { + info!("disconnecting fetch stage"); + fetch_connected = false; + pending_disconnect = false; + if let Err(e) = Self::set_tpu_addresses(&cluster_info, tpu_addr, tpu_forward_addr) { + error!("error setting tpu or tpu_fwd to ({:?}, {:?}), error: {:?}", tpu_addr, tpu_forward_addr, e); + } + } + } else { + { + warn!("relayer heartbeat receiver disconnected, shutting down"); + return; + } + } + } + recv(metrics_tick) -> _ => { + datapoint_info!( + "relayer-heartbeat", + ("fetch_stage_packets_forwarded", packets_forwarded, i64), + ("heartbeats_received", heartbeats_received, i64), + ); + + } + } + } + }).unwrap() + } + + fn set_tpu_addresses( + cluster_info: &Arc, + tpu_address: SocketAddr, + tpu_forward_address: SocketAddr, + ) -> Result<(), contact_info::Error> { + cluster_info.set_tpu(tpu_address)?; + cluster_info.set_tpu_forwards(tpu_forward_address)?; + Ok(()) + } + + pub fn join(self) -> thread::Result<()> { + self.t_hdl.join() + } +} diff --git a/core/src/proxy/mod.rs b/core/src/proxy/mod.rs new file mode 100644 index 0000000000..86d48482aa --- /dev/null +++ b/core/src/proxy/mod.rs @@ -0,0 +1,100 @@ +//! This module contains logic for connecting to an external Relayer and Block Engine. +//! The Relayer acts as an external TPU and TPU Forward socket while the Block Engine +//! is tasked with streaming high value bundles to the validator. The validator can run +//! in one of 3 modes: +//! 1. Connected to Relayer and Block Engine. +//! - This is the ideal mode as it increases the probability of building the most profitable blocks. +//! 2. Connected only to Relayer. +//! - A validator may choose to run in this mode if the main concern is to offload ingress traffic deduplication and sig-verification. +//! 3. Connected only to Block Engine. +//! - Running in this mode means pending transactions are not exposed to external actors. This mode is ideal if the validator wishes +//! to accept bundles while maintaining some level of privacy for in-flight transactions. + +mod auth; +pub mod block_engine_stage; +pub mod fetch_stage_manager; +pub mod relayer_stage; + +use { + std::{ + net::{AddrParseError, SocketAddr}, + result, + }, + thiserror::Error, + tonic::Status, +}; + +type Result = result::Result; +type HeartbeatEvent = (SocketAddr, SocketAddr); + +#[derive(Error, Debug)] +pub enum ProxyError { + #[error("grpc error: {0}")] + GrpcError(#[from] Status), + + #[error("stream disconnected")] + GrpcStreamDisconnected, + + #[error("heartbeat error")] + HeartbeatChannelError, + + #[error("heartbeat expired")] + HeartbeatExpired, + + #[error("error forwarding packet to banking stage")] + PacketForwardError, + + #[error("missing tpu config: {0:?}")] + MissingTpuSocket(String), + + #[error("invalid socket address: {0:?}")] + InvalidSocketAddress(#[from] AddrParseError), + + #[error("invalid gRPC data: {0:?}")] + InvalidData(String), + + #[error("timeout: {0:?}")] + ConnectionError(#[from] tonic::transport::Error), + + #[error("AuthenticationConnectionTimeout")] + AuthenticationConnectionTimeout, + + #[error("AuthenticationTimeout")] + AuthenticationTimeout, + + #[error("AuthenticationConnectionError: {0:?}")] + AuthenticationConnectionError(String), + + #[error("BlockEngineConnectionTimeout")] + BlockEngineConnectionTimeout, + + #[error("BlockEngineTimeout")] + BlockEngineTimeout, + + #[error("BlockEngineConnectionError: {0:?}")] + BlockEngineConnectionError(String), + + #[error("RelayerConnectionTimeout")] + RelayerConnectionTimeout, + + #[error("RelayerTimeout")] + RelayerEngineTimeout, + + #[error("RelayerConnectionError: {0:?}")] + RelayerConnectionError(String), + + #[error("AuthenticationError: {0:?}")] + AuthenticationError(String), + + #[error("AuthenticationPermissionDenied")] + AuthenticationPermissionDenied, + + #[error("BadAuthenticationToken: {0:?}")] + BadAuthenticationToken(String), + + #[error("MethodTimeout: {0:?}")] + MethodTimeout(String), + + #[error("MethodError: {0:?}")] + MethodError(String), +} diff --git a/core/src/proxy/relayer_stage.rs b/core/src/proxy/relayer_stage.rs new file mode 100644 index 0000000000..3c754fb9e4 --- /dev/null +++ b/core/src/proxy/relayer_stage.rs @@ -0,0 +1,495 @@ +//! Maintains a connection to the Relayer. +//! +//! The external Relayer is responsible for the following: +//! - Acts as a TPU proxy. +//! - Sends transactions to the validator. +//! - Does not bundles to avoid DOS vector. +//! - When validator connects, it changes its TPU and TPU forward address to the relayer. +//! - Expected to send heartbeat to validator as watchdog. If watchdog times out, the validator +//! disconnects and reverts the TPU and TPU forward settings. + +use { + crate::{ + banking_trace::BankingPacketSender, + proto_packet_to_packet, + proxy::{ + auth::{generate_auth_tokens, maybe_refresh_auth_tokens, AuthInterceptor}, + HeartbeatEvent, ProxyError, + }, + }, + crossbeam_channel::Sender, + jito_protos::proto::{ + auth::{auth_service_client::AuthServiceClient, Token}, + relayer::{self, relayer_client::RelayerClient}, + }, + solana_gossip::cluster_info::ClusterInfo, + solana_perf::packet::PacketBatch, + solana_sdk::{ + saturating_add_assign, + signature::{Keypair, Signer}, + }, + std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + str::FromStr, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread::{self, Builder, JoinHandle}, + time::{Duration, Instant}, + }, + tokio::time::{interval, sleep, timeout}, + tonic::{ + codegen::InterceptedService, + transport::{Channel, Endpoint}, + Streaming, + }, +}; + +const CONNECTION_TIMEOUT_S: u64 = 10; +const CONNECTION_BACKOFF_S: u64 = 5; + +#[derive(Default)] +struct RelayerStageStats { + num_empty_messages: u64, + num_packets: u64, + num_heartbeats: u64, +} + +impl RelayerStageStats { + pub(crate) fn report(&self) { + datapoint_info!( + "relayer_stage-stats", + ("num_empty_messages", self.num_empty_messages, i64), + ("num_packets", self.num_packets, i64), + ("num_heartbeats", self.num_heartbeats, i64), + ); + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RelayerConfig { + /// Relayer URL + pub relayer_url: String, + + /// Interval at which heartbeats are expected. + pub expected_heartbeat_interval: Duration, + + /// The max tolerable age of the last heartbeat. + pub oldest_allowed_heartbeat: Duration, + + /// If set then it will be assumed the backend verified packets so signature verification will be bypassed in the validator. + pub trust_packets: bool, +} + +pub struct RelayerStage { + t_hdls: Vec>, +} + +impl RelayerStage { + pub fn new( + relayer_config: Arc>, + // The keypair stored here is used to sign auth challenges. + cluster_info: Arc, + // Channel that server-sent heartbeats are piped through. + heartbeat_tx: Sender, + // Channel that non-trusted streamed packets are piped through. + packet_tx: Sender, + // Channel that trusted streamed packets are piped through. + banking_packet_sender: BankingPacketSender, + exit: Arc, + ) -> Self { + let thread = Builder::new() + .name("relayer-stage".to_string()) + .spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(Self::start( + relayer_config, + cluster_info, + heartbeat_tx, + packet_tx, + banking_packet_sender, + exit, + )); + }) + .unwrap(); + + Self { + t_hdls: vec![thread], + } + } + + pub fn join(self) -> thread::Result<()> { + for t in self.t_hdls { + t.join()?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn start( + relayer_config: Arc>, + cluster_info: Arc, + heartbeat_tx: Sender, + packet_tx: Sender, + banking_packet_sender: BankingPacketSender, + exit: Arc, + ) { + const CONNECTION_TIMEOUT: Duration = Duration::from_secs(CONNECTION_TIMEOUT_S); + const CONNECTION_BACKOFF: Duration = Duration::from_secs(CONNECTION_BACKOFF_S); + + let mut error_count: u64 = 0; + + while !exit.load(Ordering::Relaxed) { + // Wait until a valid config is supplied (either initially or by admin rpc) + // Use if!/else here to avoid extra CONNECTION_BACKOFF wait on successful termination + if !Self::is_valid_relayer_config(&relayer_config.lock().unwrap()) { + sleep(CONNECTION_BACKOFF).await; + } else if let Err(e) = Self::connect_auth_and_stream( + &relayer_config, + &cluster_info, + &heartbeat_tx, + &packet_tx, + &banking_packet_sender, + &exit, + &CONNECTION_TIMEOUT, + ) + .await + { + match e { + // This error is frequent on hot spares, and the parsed string does not work + // with datapoints (incorrect escaping). + ProxyError::AuthenticationPermissionDenied => { + warn!("relayer permission denied. not on leader schedule. ignore if hot-spare.") + } + e => { + error_count += 1; + datapoint_warn!( + "relayer_stage-proxy_error", + ("count", error_count, i64), + ("error", e.to_string(), String), + ); + } + } + sleep(CONNECTION_BACKOFF).await; + } + } + } + + async fn connect_auth_and_stream( + relayer_config: &Arc>, + cluster_info: &Arc, + heartbeat_tx: &Sender, + packet_tx: &Sender, + banking_packet_sender: &BankingPacketSender, + exit: &Arc, + connection_timeout: &Duration, + ) -> crate::proxy::Result<()> { + // Get a copy of configs here in case they have changed at runtime + let keypair = cluster_info.keypair().clone(); + let local_config = relayer_config.lock().unwrap().clone(); + + let mut backend_endpoint = Endpoint::from_shared(local_config.relayer_url.clone()) + .map_err(|_| { + ProxyError::RelayerConnectionError(format!( + "invalid relayer url value: {}", + local_config.relayer_url + )) + })? + .tcp_keepalive(Some(Duration::from_secs(60))); + if local_config.relayer_url.starts_with("https") { + backend_endpoint = backend_endpoint + .tls_config(tonic::transport::ClientTlsConfig::new()) + .map_err(|_| { + ProxyError::RelayerConnectionError( + "failed to set tls_config for relayer service".to_string(), + ) + })?; + } + + debug!("connecting to auth: {}", local_config.relayer_url); + let auth_channel = timeout(*connection_timeout, backend_endpoint.connect()) + .await + .map_err(|_| ProxyError::AuthenticationConnectionTimeout)? + .map_err(|e| ProxyError::AuthenticationConnectionError(e.to_string()))?; + + let mut auth_client = AuthServiceClient::new(auth_channel); + + debug!("generating authentication token"); + let (access_token, refresh_token) = timeout( + *connection_timeout, + generate_auth_tokens(&mut auth_client, &keypair), + ) + .await + .map_err(|_| ProxyError::AuthenticationTimeout)??; + + datapoint_info!( + "relayer_stage-tokens_generated", + ("url", local_config.relayer_url, String), + ("count", 1, i64), + ); + + debug!("connecting to relayer: {}", local_config.relayer_url); + let relayer_channel = timeout(*connection_timeout, backend_endpoint.connect()) + .await + .map_err(|_| ProxyError::RelayerConnectionTimeout)? + .map_err(|e| ProxyError::RelayerConnectionError(e.to_string()))?; + + let access_token = Arc::new(Mutex::new(access_token)); + let relayer_client = RelayerClient::with_interceptor( + relayer_channel, + AuthInterceptor::new(access_token.clone()), + ); + + Self::start_consuming_relayer_packets( + relayer_client, + heartbeat_tx, + packet_tx, + banking_packet_sender, + &local_config, + relayer_config, + exit, + auth_client, + access_token, + refresh_token, + keypair, + cluster_info, + connection_timeout, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn start_consuming_relayer_packets( + mut client: RelayerClient>, + heartbeat_tx: &Sender, + packet_tx: &Sender, + banking_packet_sender: &BankingPacketSender, + local_config: &RelayerConfig, // local copy of config with current connections + global_config: &Arc>, // guarded reference for detecting run-time updates + exit: &Arc, + auth_client: AuthServiceClient, + access_token: Arc>, + refresh_token: Token, + keypair: Arc, + cluster_info: &Arc, + connection_timeout: &Duration, + ) -> crate::proxy::Result<()> { + let heartbeat_event: HeartbeatEvent = { + let tpu_config = timeout( + *connection_timeout, + client.get_tpu_configs(relayer::GetTpuConfigsRequest {}), + ) + .await + .map_err(|_| ProxyError::MethodTimeout("relayer_get_tpu_configs".to_string()))? + .map_err(|e| ProxyError::MethodError(e.to_string()))? + .into_inner(); + + let tpu_addr = tpu_config + .tpu + .ok_or_else(|| ProxyError::MissingTpuSocket("tpu".to_string()))?; + let tpu_forward_addr = tpu_config + .tpu_forward + .ok_or_else(|| ProxyError::MissingTpuSocket("tpu_fwd".to_string()))?; + + let tpu_ip = IpAddr::from(tpu_addr.ip.parse::()?); + let tpu_forward_ip = IpAddr::from(tpu_forward_addr.ip.parse::()?); + + let tpu_socket = SocketAddr::new(tpu_ip, tpu_addr.port as u16); + let tpu_forward_socket = SocketAddr::new(tpu_forward_ip, tpu_forward_addr.port as u16); + (tpu_socket, tpu_forward_socket) + }; + + let packet_stream = timeout( + *connection_timeout, + client.subscribe_packets(relayer::SubscribePacketsRequest {}), + ) + .await + .map_err(|_| ProxyError::MethodTimeout("relayer_subscribe_packets".to_string()))? + .map_err(|e| ProxyError::MethodError(e.to_string()))? + .into_inner(); + + Self::consume_packet_stream( + heartbeat_event, + heartbeat_tx, + packet_stream, + packet_tx, + local_config, + global_config, + banking_packet_sender, + exit, + auth_client, + access_token, + refresh_token, + keypair, + cluster_info, + connection_timeout, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn consume_packet_stream( + heartbeat_event: HeartbeatEvent, + heartbeat_tx: &Sender, + mut packet_stream: Streaming, + packet_tx: &Sender, + local_config: &RelayerConfig, // local copy of config with current connections + global_config: &Arc>, // guarded reference for detecting run-time updates + banking_packet_sender: &BankingPacketSender, + exit: &Arc, + mut auth_client: AuthServiceClient, + access_token: Arc>, + mut refresh_token: Token, + keypair: Arc, + cluster_info: &Arc, + connection_timeout: &Duration, + ) -> crate::proxy::Result<()> { + const METRICS_TICK: Duration = Duration::from_secs(1); + let refresh_within_s: u64 = METRICS_TICK.as_secs().saturating_mul(3).saturating_div(2); + + let mut relayer_stats = RelayerStageStats::default(); + let mut metrics_and_auth_tick = interval(METRICS_TICK); + + let mut num_full_refreshes: u64 = 1; + let mut num_refresh_access_token: u64 = 0; + + let mut heartbeat_check_interval = interval(local_config.expected_heartbeat_interval); + let mut last_heartbeat_ts = Instant::now(); + + info!("connected to packet stream"); + + while !exit.load(Ordering::Relaxed) { + tokio::select! { + maybe_msg = packet_stream.message() => { + let resp = maybe_msg?.ok_or(ProxyError::GrpcStreamDisconnected)?; + Self::handle_relayer_packets(resp, heartbeat_event, heartbeat_tx, &mut last_heartbeat_ts, packet_tx, local_config.trust_packets, banking_packet_sender, &mut relayer_stats)?; + } + _ = heartbeat_check_interval.tick() => { + if last_heartbeat_ts.elapsed() > local_config.oldest_allowed_heartbeat { + return Err(ProxyError::HeartbeatExpired); + } + } + _ = metrics_and_auth_tick.tick() => { + relayer_stats.report(); + relayer_stats = RelayerStageStats::default(); + + if cluster_info.id() != keypair.pubkey() { + return Err(ProxyError::AuthenticationConnectionError("validator identity changed".to_string())); + } + + if *global_config.lock().unwrap() != *local_config { + return Err(ProxyError::AuthenticationConnectionError("relayer config changed".to_string())); + } + + let (maybe_new_access, maybe_new_refresh) = maybe_refresh_auth_tokens(&mut auth_client, + &access_token, + &refresh_token, + cluster_info, + connection_timeout, + refresh_within_s, + ).await?; + + if let Some(new_token) = maybe_new_access { + num_refresh_access_token += 1; + datapoint_info!( + "relayer_stage-refresh_access_token", + ("url", &local_config.relayer_url, String), + ("count", num_refresh_access_token, i64), + ); + *access_token.lock().unwrap() = new_token; + } + if let Some(new_token) = maybe_new_refresh { + num_full_refreshes += 1; + datapoint_info!( + "relayer_stage-tokens_generated", + ("url", &local_config.relayer_url, String), + ("count", num_full_refreshes, i64), + ); + refresh_token = new_token; + } + } + } + } + Ok(()) + } + + fn handle_relayer_packets( + subscribe_packets_resp: relayer::SubscribePacketsResponse, + heartbeat_event: HeartbeatEvent, + heartbeat_tx: &Sender, + last_heartbeat_ts: &mut Instant, + packet_tx: &Sender, + trust_packets: bool, + banking_packet_sender: &BankingPacketSender, + relayer_stats: &mut RelayerStageStats, + ) -> crate::proxy::Result<()> { + match subscribe_packets_resp.msg { + None => { + saturating_add_assign!(relayer_stats.num_empty_messages, 1); + } + Some(relayer::subscribe_packets_response::Msg::Batch(proto_batch)) => { + if proto_batch.packets.is_empty() { + saturating_add_assign!(relayer_stats.num_empty_messages, 1); + return Ok(()); + } + + let packet_batch = PacketBatch::new( + proto_batch + .packets + .into_iter() + .map(proto_packet_to_packet) + .collect(), + ); + + saturating_add_assign!(relayer_stats.num_packets, packet_batch.len() as u64); + + if trust_packets { + banking_packet_sender + .send(Arc::new((vec![packet_batch], None))) + .map_err(|_| ProxyError::PacketForwardError)?; + } else { + packet_tx + .send(packet_batch) + .map_err(|_| ProxyError::PacketForwardError)?; + } + } + Some(relayer::subscribe_packets_response::Msg::Heartbeat(_)) => { + saturating_add_assign!(relayer_stats.num_heartbeats, 1); + + *last_heartbeat_ts = Instant::now(); + heartbeat_tx + .send(heartbeat_event) + .map_err(|_| ProxyError::HeartbeatChannelError)?; + } + } + Ok(()) + } + + pub fn is_valid_relayer_config(config: &RelayerConfig) -> bool { + if config.relayer_url.is_empty() { + warn!("can't connect to relayer. missing relayer_url."); + return false; + } + if config.oldest_allowed_heartbeat.is_zero() { + error!("can't connect to relayer. oldest allowed heartbeat must be greater than 0."); + return false; + } + if config.expected_heartbeat_interval.is_zero() { + error!("can't connect to relayer. expected heartbeat interval must be greater than 0."); + return false; + } + if let Err(e) = Endpoint::from_str(&config.relayer_url) { + error!( + "can't connect to relayer. error creating relayer endpoint - {}", + e.to_string() + ); + return false; + } + true + } +} diff --git a/core/src/qos_service.rs b/core/src/qos_service.rs index a27974a2b9..356aa90196 100644 --- a/core/src/qos_service.rs +++ b/core/src/qos_service.rs @@ -10,6 +10,7 @@ use { solana_runtime::{ bank::Bank, cost_model::{CostModel, TransactionCost}, + cost_tracker::CostTracker, }, solana_sdk::{ clock::Slot, @@ -91,6 +92,7 @@ impl QosService { pub fn select_and_accumulate_transaction_costs( &self, bank: &Bank, + cost_tracker: &mut CostTracker, // caller should pass in &mut bank.write_cost_tracker().unwrap() transactions: &[SanitizedTransaction], pre_results: impl Iterator>, ) -> (Vec>, usize) { @@ -99,7 +101,8 @@ impl QosService { let (transactions_qos_cost_results, num_included) = self.select_transactions_per_cost( transactions.iter(), transaction_costs.into_iter(), - bank, + bank.slot(), + cost_tracker, ); self.accumulate_estimated_transaction_costs(&Self::accumulate_batched_transaction_costs( transactions_qos_cost_results.iter(), @@ -145,10 +148,10 @@ impl QosService { &self, transactions: impl Iterator, transactions_costs: impl Iterator>, - bank: &Bank, + slot: Slot, + cost_tracker: &mut CostTracker, ) -> (Vec>, usize) { let mut cost_tracking_time = Measure::start("cost_tracking_time"); - let mut cost_tracker = bank.write_cost_tracker().unwrap(); let mut num_included = 0; let select_results = transactions.zip(transactions_costs) .map(|(tx, cost)| { @@ -156,13 +159,13 @@ impl QosService { Ok(cost) => { match cost_tracker.try_add(&cost) { Ok(current_block_cost) => { - debug!("slot {:?}, transaction {:?}, cost {:?}, fit into current block, current block cost {}", bank.slot(), tx, cost, current_block_cost); + debug!("slot {:?}, transaction {:?}, cost {:?}, fit into current block, current block cost {}", slot, tx, cost, current_block_cost); self.metrics.stats.selected_txs_count.fetch_add(1, Ordering::Relaxed); num_included += 1; Ok(cost) }, Err(e) => { - debug!("slot {:?}, transaction {:?}, cost {:?}, not fit into current block, '{:?}'", bank.slot(), tx, cost, e); + debug!("slot {:?}, transaction {:?}, cost {:?}, not fit into current block, '{:?}'", slot, tx, cost, e); Err(TransactionError::from(e)) } } @@ -751,8 +754,12 @@ mod tests { bank.write_cost_tracker() .unwrap() .set_limits(cost_limit, cost_limit, cost_limit); - let (results, num_selected) = - qos_service.select_transactions_per_cost(txs.iter(), txs_costs.into_iter(), &bank); + let (results, num_selected) = qos_service.select_transactions_per_cost( + txs.iter(), + txs_costs.into_iter(), + bank.slot(), + &mut bank.write_cost_tracker().unwrap(), + ); assert_eq!(num_selected, 2); // verify that first transfer tx and first vote are allowed @@ -793,8 +800,12 @@ mod tests { .iter() .map(|cost| cost.as_ref().unwrap().sum()) .sum(); - let (qos_cost_results, _num_included) = - qos_service.select_transactions_per_cost(txs.iter(), txs_costs.into_iter(), &bank); + let (qos_cost_results, _num_included) = qos_service.select_transactions_per_cost( + txs.iter(), + txs_costs.into_iter(), + bank.slot(), + &mut bank.write_cost_tracker().unwrap(), + ); assert_eq!( total_txs_cost, bank.read_cost_tracker().unwrap().block_cost() @@ -861,8 +872,12 @@ mod tests { .iter() .map(|cost| cost.as_ref().unwrap().sum()) .sum(); - let (qos_cost_results, _num_included) = - qos_service.select_transactions_per_cost(txs.iter(), txs_costs.into_iter(), &bank); + let (qos_cost_results, _num_included) = qos_service.select_transactions_per_cost( + txs.iter(), + txs_costs.into_iter(), + bank.slot(), + &mut bank.write_cost_tracker().unwrap(), + ); assert_eq!( total_txs_cost, bank.read_cost_tracker().unwrap().block_cost() @@ -915,8 +930,12 @@ mod tests { .iter() .map(|cost| cost.as_ref().unwrap().sum()) .sum(); - let (qos_cost_results, _num_included) = - qos_service.select_transactions_per_cost(txs.iter(), txs_costs.into_iter(), &bank); + let (qos_cost_results, _num_included) = qos_service.select_transactions_per_cost( + txs.iter(), + txs_costs.into_iter(), + bank.slot(), + &mut bank.write_cost_tracker().unwrap(), + ); assert_eq!( total_txs_cost, bank.read_cost_tracker().unwrap().block_cost() diff --git a/core/src/replay_stage.rs b/core/src/replay_stage.rs index 4e2283e04f..627821f907 100644 --- a/core/src/replay_stage.rs +++ b/core/src/replay_stage.rs @@ -2373,7 +2373,9 @@ impl ReplayStage { // If we are a non voting validator or have an incorrect setup preventing us from // generating vote txs, no need to refresh - let Some(last_vote_tx_blockhash) = tower.last_vote_tx_blockhash() else { return }; + let Some(last_vote_tx_blockhash) = tower.last_vote_tx_blockhash() else { + return; + }; if my_latest_landed_vote >= last_voted_slot || heaviest_bank_on_same_fork diff --git a/core/src/retransmit_stage.rs b/core/src/retransmit_stage.rs index 96eeffff8c..7887143f63 100644 --- a/core/src/retransmit_stage.rs +++ b/core/src/retransmit_stage.rs @@ -29,7 +29,7 @@ use { std::{ collections::HashMap, iter::repeat, - net::UdpSocket, + net::{SocketAddr, UdpSocket}, ops::AddAssign, sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, @@ -178,6 +178,7 @@ fn retransmit( shred_deduper: &mut ShredDeduper<2>, max_slots: &MaxSlots, rpc_subscriptions: Option<&RpcSubscriptions>, + shred_receiver_address: &Arc>>, ) -> Result<(), RecvTimeoutError> { const RECV_TIMEOUT: Duration = Duration::from_secs(1); let mut shreds = shreds_receiver.recv_timeout(RECV_TIMEOUT)?; @@ -260,6 +261,7 @@ fn retransmit( socket_addr_space, &sockets[index % sockets.len()], stats, + &shred_receiver_address.read().unwrap(), ) .map_err(|err| { stats.record_error(&err); @@ -284,6 +286,7 @@ fn retransmit( socket_addr_space, &sockets[index % sockets.len()], stats, + &shred_receiver_address.read().unwrap(), ) .map_err(|err| { stats.record_error(&err); @@ -312,15 +315,20 @@ fn retransmit_shred( socket_addr_space: &SocketAddrSpace, socket: &UdpSocket, stats: &RetransmitStats, + shred_receiver_addr: &Option, ) -> Result<(/*root_distance:*/ usize, /*num_nodes:*/ usize), Error> { let mut compute_turbine_peers = Measure::start("turbine_start"); let data_plane_fanout = cluster_nodes::get_data_plane_fanout(key.slot(), root_bank); let (root_distance, addrs) = cluster_nodes.get_retransmit_addrs(slot_leader, key, root_bank, data_plane_fanout)?; - let addrs: Vec<_> = addrs + let mut addrs: Vec<_> = addrs .into_iter() .filter(|addr| ContactInfo::is_valid_address(addr, socket_addr_space)) .collect(); + if let Some(addr) = shred_receiver_addr { + addrs.push(*addr); + } + compute_turbine_peers.stop(); stats .compute_turbine_peers_total @@ -366,6 +374,7 @@ pub fn retransmitter( shreds_receiver: Receiver>>, max_slots: Arc, rpc_subscriptions: Option>, + shred_receiver_addr: Arc>>, ) -> JoinHandle<()> { let cluster_nodes_cache = ClusterNodesCache::::new( CLUSTER_NODES_CACHE_NUM_EPOCH_CAP, @@ -396,6 +405,7 @@ pub fn retransmitter( &mut shred_deduper, &max_slots, rpc_subscriptions.as_deref(), + &shred_receiver_addr, ) { Ok(()) => (), Err(RecvTimeoutError::Timeout) => (), @@ -418,6 +428,7 @@ impl RetransmitStage { retransmit_receiver: Receiver>>, max_slots: Arc, rpc_subscriptions: Option>, + shred_receiver_addr: Arc>>, ) -> Self { let retransmit_thread_handle = retransmitter( retransmit_sockets, @@ -427,6 +438,7 @@ impl RetransmitStage { retransmit_receiver, max_slots, rpc_subscriptions, + shred_receiver_addr, ); Self { diff --git a/core/src/snapshot_packager_service.rs b/core/src/snapshot_packager_service.rs index 5974d076ed..0094098588 100644 --- a/core/src/snapshot_packager_service.rs +++ b/core/src/snapshot_packager_service.rs @@ -51,13 +51,13 @@ impl SnapshotPackagerService { .spawn(move || { info!("SnapshotPackagerService has started"); renice_this_thread(snapshot_config.packager_thread_niceness_adj).unwrap(); - let mut snapshot_gossip_manager = enable_gossip_push.then(|| + let mut snapshot_gossip_manager = enable_gossip_push.then(|| { SnapshotGossipManager::new( cluster_info, max_full_snapshot_hashes, starting_snapshot_hashes, ) - ); + }); loop { if exit.load(Ordering::Relaxed) { @@ -68,7 +68,11 @@ impl SnapshotPackagerService { snapshot_package, num_outstanding_snapshot_packages, num_re_enqueued_snapshot_packages, - )) = Self::get_next_snapshot_package(&snapshot_package_sender, &snapshot_package_receiver) else { + )) = Self::get_next_snapshot_package( + &snapshot_package_sender, + &snapshot_package_receiver, + ) + else { std::thread::sleep(Self::LOOP_LIMITER); continue; }; @@ -102,7 +106,8 @@ impl SnapshotPackagerService { measure_us!(snapshot_utils::purge_bank_snapshots_older_than_slot( &snapshot_config.bank_snapshots_dir, snapshot_package.slot(), - )).1 + )) + .1 }); datapoint_info!( @@ -119,7 +124,11 @@ impl SnapshotPackagerService { ), ("enqueued_time_us", enqueued_time.as_micros(), i64), ("handling_time_us", handling_time_us, i64), - ("purge_old_snapshots_time_us", purge_bank_snapshots_time_us, i64), + ( + "purge_old_snapshots_time_us", + purge_bank_snapshots_time_us, + i64 + ), ); } info!("SnapshotPackagerService has stopped"); diff --git a/core/src/tip_manager.rs b/core/src/tip_manager.rs new file mode 100644 index 0000000000..724abbbe02 --- /dev/null +++ b/core/src/tip_manager.rs @@ -0,0 +1,583 @@ +use { + crate::proxy::block_engine_stage::BlockBuilderFeeInfo, + anchor_lang::{ + solana_program::hash::Hash, AccountDeserialize, InstructionData, ToAccountMetas, + }, + jito_tip_distribution::sdk::{ + derive_config_account_address, derive_tip_distribution_account_address, + instruction::{ + initialize_ix, initialize_tip_distribution_account_ix, InitializeAccounts, + InitializeArgs, InitializeTipDistributionAccountAccounts, + InitializeTipDistributionAccountArgs, + }, + }, + jito_tip_payment::{ + Config, InitBumps, TipPaymentAccount, CONFIG_ACCOUNT_SEED, TIP_ACCOUNT_SEED_0, + TIP_ACCOUNT_SEED_1, TIP_ACCOUNT_SEED_2, TIP_ACCOUNT_SEED_3, TIP_ACCOUNT_SEED_4, + TIP_ACCOUNT_SEED_5, TIP_ACCOUNT_SEED_6, TIP_ACCOUNT_SEED_7, + }, + log::warn, + solana_bundle::TipError, + solana_runtime::bank::Bank, + solana_sdk::{ + account::ReadableAccount, + bundle::{derive_bundle_id_from_sanitized_transactions, SanitizedBundle}, + instruction::Instruction, + pubkey::Pubkey, + signature::Keypair, + signer::Signer, + stake_history::Epoch, + system_program, + transaction::{SanitizedTransaction, Transaction}, + }, + std::{collections::HashSet, sync::Arc}, +}; + +pub type Result = std::result::Result; + +#[derive(Debug, Clone)] +struct TipPaymentProgramInfo { + program_id: Pubkey, + + config_pda_bump: (Pubkey, u8), + tip_pda_0: (Pubkey, u8), + tip_pda_1: (Pubkey, u8), + tip_pda_2: (Pubkey, u8), + tip_pda_3: (Pubkey, u8), + tip_pda_4: (Pubkey, u8), + tip_pda_5: (Pubkey, u8), + tip_pda_6: (Pubkey, u8), + tip_pda_7: (Pubkey, u8), +} + +/// Contains metadata regarding the tip-distribution account. +/// The PDAs contained in this struct are presumed to be owned by the program. +#[derive(Debug, Clone)] +struct TipDistributionProgramInfo { + /// The tip-distribution program_id. + program_id: Pubkey, + + /// Singleton [Config] PDA and bump tuple. + config_pda_and_bump: (Pubkey, u8), +} + +/// This config is used on each invocation to the `initialize_tip_distribution_account` instruction. +#[derive(Debug, Clone)] +pub struct TipDistributionAccountConfig { + /// The account with authority to upload merkle-roots to this validator's [TipDistributionAccount]. + pub merkle_root_upload_authority: Pubkey, + + /// This validator's vote account. + pub vote_account: Pubkey, + + /// This validator's commission rate BPS for tips in the [TipDistributionAccount]. + pub commission_bps: u16, +} + +impl Default for TipDistributionAccountConfig { + fn default() -> Self { + Self { + merkle_root_upload_authority: Pubkey::new_unique(), + vote_account: Pubkey::new_unique(), + commission_bps: 0, + } + } +} + +#[derive(Debug, Clone)] +pub struct TipManager { + tip_payment_program_info: TipPaymentProgramInfo, + tip_distribution_program_info: TipDistributionProgramInfo, + tip_distribution_account_config: TipDistributionAccountConfig, +} + +#[derive(Clone)] +pub struct TipManagerConfig { + pub tip_payment_program_id: Pubkey, + pub tip_distribution_program_id: Pubkey, + pub tip_distribution_account_config: TipDistributionAccountConfig, +} + +impl Default for TipManagerConfig { + fn default() -> Self { + TipManagerConfig { + tip_payment_program_id: Pubkey::new_unique(), + tip_distribution_program_id: Pubkey::new_unique(), + tip_distribution_account_config: TipDistributionAccountConfig::default(), + } + } +} + +impl TipManager { + pub fn new(config: TipManagerConfig) -> TipManager { + let TipManagerConfig { + tip_payment_program_id, + tip_distribution_program_id, + tip_distribution_account_config, + } = config; + + let config_pda_bump = + Pubkey::find_program_address(&[CONFIG_ACCOUNT_SEED], &tip_payment_program_id); + + let tip_pda_0 = + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_0], &tip_payment_program_id); + let tip_pda_1 = + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_1], &tip_payment_program_id); + let tip_pda_2 = + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_2], &tip_payment_program_id); + let tip_pda_3 = + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_3], &tip_payment_program_id); + let tip_pda_4 = + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_4], &tip_payment_program_id); + let tip_pda_5 = + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_5], &tip_payment_program_id); + let tip_pda_6 = + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_6], &tip_payment_program_id); + let tip_pda_7 = + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_7], &tip_payment_program_id); + + let config_pda_and_bump = derive_config_account_address(&tip_distribution_program_id); + + TipManager { + tip_payment_program_info: TipPaymentProgramInfo { + program_id: tip_payment_program_id, + config_pda_bump, + tip_pda_0, + tip_pda_1, + tip_pda_2, + tip_pda_3, + tip_pda_4, + tip_pda_5, + tip_pda_6, + tip_pda_7, + }, + tip_distribution_program_info: TipDistributionProgramInfo { + program_id: tip_distribution_program_id, + config_pda_and_bump, + }, + tip_distribution_account_config, + } + } + + pub fn tip_payment_program_id(&self) -> Pubkey { + self.tip_payment_program_info.program_id + } + + pub fn tip_distribution_program_id(&self) -> Pubkey { + self.tip_distribution_program_info.program_id + } + + /// Returns the [Config] account owned by the tip-payment program. + pub fn tip_payment_config_pubkey(&self) -> Pubkey { + self.tip_payment_program_info.config_pda_bump.0 + } + + /// Returns the [Config] account owned by the tip-distribution program. + pub fn tip_distribution_config_pubkey(&self) -> Pubkey { + self.tip_distribution_program_info.config_pda_and_bump.0 + } + + /// Given a bank, returns the current `tip_receiver` configured with the tip-payment program. + pub fn get_configured_tip_receiver(&self, bank: &Bank) -> Result { + Ok(self.get_tip_payment_config_account(bank)?.tip_receiver) + } + + pub fn get_tip_accounts(&self) -> HashSet { + HashSet::from([ + self.tip_payment_program_info.tip_pda_0.0, + self.tip_payment_program_info.tip_pda_1.0, + self.tip_payment_program_info.tip_pda_2.0, + self.tip_payment_program_info.tip_pda_3.0, + self.tip_payment_program_info.tip_pda_4.0, + self.tip_payment_program_info.tip_pda_5.0, + self.tip_payment_program_info.tip_pda_6.0, + self.tip_payment_program_info.tip_pda_7.0, + ]) + } + + pub fn get_tip_payment_config_account(&self, bank: &Bank) -> Result { + let config_data = bank + .get_account(&self.tip_payment_program_info.config_pda_bump.0) + .ok_or(TipError::AccountMissing( + self.tip_payment_program_info.config_pda_bump.0, + ))?; + + Ok(Config::try_deserialize(&mut config_data.data())?) + } + + /// Only called once during contract creation. + pub fn initialize_tip_payment_program_tx( + &self, + recent_blockhash: Hash, + keypair: &Keypair, + ) -> SanitizedTransaction { + let init_ix = Instruction { + program_id: self.tip_payment_program_info.program_id, + data: jito_tip_payment::instruction::Initialize { + _bumps: InitBumps { + config: self.tip_payment_program_info.config_pda_bump.1, + tip_payment_account_0: self.tip_payment_program_info.tip_pda_0.1, + tip_payment_account_1: self.tip_payment_program_info.tip_pda_1.1, + tip_payment_account_2: self.tip_payment_program_info.tip_pda_2.1, + tip_payment_account_3: self.tip_payment_program_info.tip_pda_3.1, + tip_payment_account_4: self.tip_payment_program_info.tip_pda_4.1, + tip_payment_account_5: self.tip_payment_program_info.tip_pda_5.1, + tip_payment_account_6: self.tip_payment_program_info.tip_pda_6.1, + tip_payment_account_7: self.tip_payment_program_info.tip_pda_7.1, + }, + } + .data(), + accounts: jito_tip_payment::accounts::Initialize { + config: self.tip_payment_program_info.config_pda_bump.0, + tip_payment_account_0: self.tip_payment_program_info.tip_pda_0.0, + tip_payment_account_1: self.tip_payment_program_info.tip_pda_1.0, + tip_payment_account_2: self.tip_payment_program_info.tip_pda_2.0, + tip_payment_account_3: self.tip_payment_program_info.tip_pda_3.0, + tip_payment_account_4: self.tip_payment_program_info.tip_pda_4.0, + tip_payment_account_5: self.tip_payment_program_info.tip_pda_5.0, + tip_payment_account_6: self.tip_payment_program_info.tip_pda_6.0, + tip_payment_account_7: self.tip_payment_program_info.tip_pda_7.0, + system_program: system_program::id(), + payer: keypair.pubkey(), + } + .to_account_metas(None), + }; + SanitizedTransaction::try_from_legacy_transaction(Transaction::new_signed_with_payer( + &[init_ix], + Some(&keypair.pubkey()), + &[keypair], + recent_blockhash, + )) + .unwrap() + } + + /// Returns this validator's [TipDistributionAccount] PDA derived from the provided epoch. + pub fn get_my_tip_distribution_pda(&self, epoch: Epoch) -> Pubkey { + derive_tip_distribution_account_address( + &self.tip_distribution_program_info.program_id, + &self.tip_distribution_account_config.vote_account, + epoch, + ) + .0 + } + + /// Returns whether or not the tip-payment program should be initialized. + pub fn should_initialize_tip_payment_program(&self, bank: &Bank) -> bool { + match bank.get_account(&self.tip_payment_config_pubkey()) { + None => true, + Some(account) => account.owner() != &self.tip_payment_program_info.program_id, + } + } + + /// Returns whether or not the tip-distribution program's [Config] PDA should be initialized. + pub fn should_initialize_tip_distribution_config(&self, bank: &Bank) -> bool { + match bank.get_account(&self.tip_distribution_config_pubkey()) { + None => true, + Some(account) => account.owner() != &self.tip_distribution_program_info.program_id, + } + } + + /// Returns whether or not the current [TipDistributionAccount] PDA should be initialized for this epoch. + pub fn should_init_tip_distribution_account(&self, bank: &Bank) -> bool { + let pda = derive_tip_distribution_account_address( + &self.tip_distribution_program_info.program_id, + &self.tip_distribution_account_config.vote_account, + bank.epoch(), + ) + .0; + match bank.get_account(&pda) { + None => true, + // Since anyone can derive the PDA and send it lamports we must also check the owner is the program. + Some(account) => account.owner() != &self.tip_distribution_program_info.program_id, + } + } + + /// Creates an [Initialize] transaction object. + pub fn initialize_tip_distribution_config_tx( + &self, + recent_blockhash: Hash, + kp: &Keypair, + ) -> SanitizedTransaction { + let ix = initialize_ix( + self.tip_distribution_program_info.program_id, + InitializeArgs { + authority: kp.pubkey(), + expired_funds_account: kp.pubkey(), + num_epochs_valid: 10, + max_validator_commission_bps: 10_000, + bump: self.tip_distribution_program_info.config_pda_and_bump.1, + }, + InitializeAccounts { + config: self.tip_distribution_program_info.config_pda_and_bump.0, + system_program: system_program::id(), + initializer: kp.pubkey(), + }, + ); + + SanitizedTransaction::try_from_legacy_transaction(Transaction::new_signed_with_payer( + &[ix], + Some(&kp.pubkey()), + &[kp], + recent_blockhash, + )) + .unwrap() + } + + /// Creates an [InitializeTipDistributionAccount] transaction object using the provided Epoch. + pub fn initialize_tip_distribution_account_tx( + &self, + recent_blockhash: Hash, + epoch: Epoch, + keypair: &Keypair, + ) -> SanitizedTransaction { + let (tip_distribution_account, bump) = derive_tip_distribution_account_address( + &self.tip_distribution_program_info.program_id, + &self.tip_distribution_account_config.vote_account, + epoch, + ); + + let ix = initialize_tip_distribution_account_ix( + self.tip_distribution_program_info.program_id, + InitializeTipDistributionAccountArgs { + merkle_root_upload_authority: self + .tip_distribution_account_config + .merkle_root_upload_authority, + validator_commission_bps: self.tip_distribution_account_config.commission_bps, + bump, + }, + InitializeTipDistributionAccountAccounts { + config: self.tip_distribution_program_info.config_pda_and_bump.0, + tip_distribution_account, + system_program: system_program::id(), + signer: keypair.pubkey(), + validator_vote_account: self.tip_distribution_account_config.vote_account, + }, + ); + + SanitizedTransaction::try_from_legacy_transaction(Transaction::new_signed_with_payer( + &[ix], + Some(&keypair.pubkey()), + &[keypair], + recent_blockhash, + )) + .unwrap() + } + + /// Builds a transaction that changes the current tip receiver to new_tip_receiver. + /// The on-chain program will transfer tips sitting in the tip accounts to the tip receiver + /// before changing ownership. + pub fn change_tip_receiver_and_block_builder_tx( + &self, + new_tip_receiver: &Pubkey, + bank: &Bank, + keypair: &Keypair, + block_builder: &Pubkey, + block_builder_commission: u64, + ) -> Result { + let config = self.get_tip_payment_config_account(bank)?; + Ok(self.build_change_tip_receiver_and_block_builder_tx( + &config.tip_receiver, + new_tip_receiver, + bank, + keypair, + &config.block_builder, + block_builder, + block_builder_commission, + )) + } + + pub fn build_change_tip_receiver_and_block_builder_tx( + &self, + old_tip_receiver: &Pubkey, + new_tip_receiver: &Pubkey, + bank: &Bank, + keypair: &Keypair, + old_block_builder: &Pubkey, + block_builder: &Pubkey, + block_builder_commission: u64, + ) -> SanitizedTransaction { + let change_tip_ix = Instruction { + program_id: self.tip_payment_program_info.program_id, + data: jito_tip_payment::instruction::ChangeTipReceiver {}.data(), + accounts: jito_tip_payment::accounts::ChangeTipReceiver { + config: self.tip_payment_program_info.config_pda_bump.0, + old_tip_receiver: *old_tip_receiver, + new_tip_receiver: *new_tip_receiver, + block_builder: *old_block_builder, + tip_payment_account_0: self.tip_payment_program_info.tip_pda_0.0, + tip_payment_account_1: self.tip_payment_program_info.tip_pda_1.0, + tip_payment_account_2: self.tip_payment_program_info.tip_pda_2.0, + tip_payment_account_3: self.tip_payment_program_info.tip_pda_3.0, + tip_payment_account_4: self.tip_payment_program_info.tip_pda_4.0, + tip_payment_account_5: self.tip_payment_program_info.tip_pda_5.0, + tip_payment_account_6: self.tip_payment_program_info.tip_pda_6.0, + tip_payment_account_7: self.tip_payment_program_info.tip_pda_7.0, + signer: keypair.pubkey(), + } + .to_account_metas(None), + }; + let change_block_builder_ix = Instruction { + program_id: self.tip_payment_program_info.program_id, + data: jito_tip_payment::instruction::ChangeBlockBuilder { + block_builder_commission, + } + .data(), + accounts: jito_tip_payment::accounts::ChangeBlockBuilder { + config: self.tip_payment_program_info.config_pda_bump.0, + tip_receiver: *new_tip_receiver, // tip receiver will have just changed in previous ix + old_block_builder: *old_block_builder, + new_block_builder: *block_builder, + tip_payment_account_0: self.tip_payment_program_info.tip_pda_0.0, + tip_payment_account_1: self.tip_payment_program_info.tip_pda_1.0, + tip_payment_account_2: self.tip_payment_program_info.tip_pda_2.0, + tip_payment_account_3: self.tip_payment_program_info.tip_pda_3.0, + tip_payment_account_4: self.tip_payment_program_info.tip_pda_4.0, + tip_payment_account_5: self.tip_payment_program_info.tip_pda_5.0, + tip_payment_account_6: self.tip_payment_program_info.tip_pda_6.0, + tip_payment_account_7: self.tip_payment_program_info.tip_pda_7.0, + signer: keypair.pubkey(), + } + .to_account_metas(None), + }; + SanitizedTransaction::try_from_legacy_transaction(Transaction::new_signed_with_payer( + &[change_tip_ix, change_block_builder_ix], + Some(&keypair.pubkey()), + &[keypair], + bank.last_blockhash(), + )) + .unwrap() + } + + /// Returns the balance of all the MEV tip accounts + pub fn get_tip_account_balances(&self, bank: &Arc) -> Vec<(Pubkey, u64)> { + let accounts = self.get_tip_accounts(); + accounts + .into_iter() + .map(|account| { + let balance = bank.get_balance(&account); + (account, balance) + }) + .collect() + } + + /// Returns the balance of all the MEV tip accounts above the rent-exempt amount. + /// NOTE: the on-chain program has rent_exempt = force + pub fn get_tip_account_balances_above_rent_exempt( + &self, + bank: &Arc, + ) -> Vec<(Pubkey, u64)> { + let accounts = self.get_tip_accounts(); + accounts + .into_iter() + .map(|account| { + let account_data = bank.get_account(&account).unwrap_or_default(); + let balance = bank.get_balance(&account); + let rent_exempt = + bank.get_minimum_balance_for_rent_exemption(account_data.data().len()); + // NOTE: don't unwrap here in case bug in on-chain program, don't want all validators to crash + // if program gets stuck in bad state + (account, balance.checked_sub(rent_exempt).unwrap_or_else(|| { + warn!("balance is below rent exempt amount. balance: {} rent_exempt: {} acc size: {}", balance, rent_exempt, TipPaymentAccount::SIZE); + 0 + })) + }) + .collect() + } + + /// Return a bundle that is capable of calling the initialize instructions on the two tip payment programs + /// This is mainly helpful for local development and shouldn't run on testnet and mainnet, assuming the + /// correct TipManager configuration is set. + pub fn get_initialize_tip_programs_bundle( + &self, + bank: &Bank, + keypair: &Keypair, + ) -> Option { + let maybe_init_tip_payment_config_tx = if self.should_initialize_tip_payment_program(bank) { + debug!("should_initialize_tip_payment_program=true"); + Some(self.initialize_tip_payment_program_tx(bank.last_blockhash(), keypair)) + } else { + None + }; + + let maybe_init_tip_distro_config_tx = + if self.should_initialize_tip_distribution_config(bank) { + debug!("should_initialize_tip_distribution_config=true"); + Some(self.initialize_tip_distribution_config_tx(bank.last_blockhash(), keypair)) + } else { + None + }; + + let transactions = [ + maybe_init_tip_payment_config_tx, + maybe_init_tip_distro_config_tx, + ] + .into_iter() + .flatten() + .collect::>(); + + if transactions.is_empty() { + None + } else { + let bundle_id = derive_bundle_id_from_sanitized_transactions(&transactions); + Some(SanitizedBundle { + transactions, + bundle_id, + }) + } + } + + pub fn get_tip_programs_crank_bundle( + &self, + bank: &Bank, + keypair: &Keypair, + block_builder_fee_info: &BlockBuilderFeeInfo, + ) -> Result> { + let maybe_init_tip_distro_account_tx = if self.should_init_tip_distribution_account(bank) { + debug!("should_init_tip_distribution_account=true"); + Some(self.initialize_tip_distribution_account_tx( + bank.last_blockhash(), + bank.epoch(), + keypair, + )) + } else { + None + }; + + let configured_tip_receiver = self.get_configured_tip_receiver(bank)?; + let my_tip_receiver = self.get_my_tip_distribution_pda(bank.epoch()); + let maybe_change_tip_receiver_tx = if configured_tip_receiver != my_tip_receiver { + debug!("change_tip_receiver=true"); + Some(self.change_tip_receiver_and_block_builder_tx( + &my_tip_receiver, + bank, + keypair, + &block_builder_fee_info.block_builder, + block_builder_fee_info.block_builder_commission, + )?) + } else { + None + }; + debug!( + "maybe_change_tip_receiver_tx: {:?}", + maybe_change_tip_receiver_tx + ); + + let transactions = [ + maybe_init_tip_distro_account_tx, + maybe_change_tip_receiver_tx, + ] + .into_iter() + .flatten() + .collect::>(); + + if transactions.is_empty() { + Ok(None) + } else { + let bundle_id = derive_bundle_id_from_sanitized_transactions(&transactions); + Ok(Some(SanitizedBundle { + transactions, + bundle_id, + })) + } + } +} diff --git a/core/src/tpu.rs b/core/src/tpu.rs index f129b2fbed..8545a0d3be 100644 --- a/core/src/tpu.rs +++ b/core/src/tpu.rs @@ -7,14 +7,21 @@ use { banking_stage::BankingStage, banking_trace::{BankingTracer, TracerThread}, broadcast_stage::{BroadcastStage, BroadcastStageType, RetransmitSlotsReceiver}, + bundle_stage::{bundle_account_locker::BundleAccountLocker, BundleStage}, cluster_info_vote_listener::{ ClusterInfoVoteListener, GossipDuplicateConfirmedSlotsSender, GossipVerifiedVoteHashSender, VerifiedVoteSender, VoteTracker, }, fetch_stage::FetchStage, + proxy::{ + block_engine_stage::{BlockBuilderFeeInfo, BlockEngineConfig, BlockEngineStage}, + fetch_stage_manager::FetchStageManager, + relayer_stage::{RelayerConfig, RelayerStage}, + }, sigverify::TransactionSigVerifier, sigverify_stage::SigVerifyStage, staked_nodes_updater_service::StakedNodesUpdaterService, + tip_manager::{TipManager, TipManagerConfig}, tpu_entry_notifier::TpuEntryNotifier, validator::GeneratorConfig, }, @@ -35,16 +42,19 @@ use { prioritization_fee_cache::PrioritizationFeeCache, vote_sender_types::{ReplayVoteReceiver, ReplayVoteSender}, }, - solana_sdk::{pubkey::Pubkey, signature::Keypair}, + solana_sdk::{ + pubkey::Pubkey, + signature::{Keypair, Signer}, + }, solana_streamer::{ nonblocking::quic::DEFAULT_WAIT_FOR_CHUNK_TIMEOUT, quic::{spawn_server, MAX_STAKED_CONNECTIONS, MAX_UNSTAKED_CONNECTIONS}, streamer::StakedNodes, }, std::{ - collections::HashMap, - net::UdpSocket, - sync::{atomic::AtomicBool, Arc, RwLock}, + collections::{HashMap, HashSet}, + net::{SocketAddr, UdpSocket}, + sync::{atomic::AtomicBool, Arc, Mutex, RwLock}, thread, time::Duration, }, @@ -66,6 +76,9 @@ pub struct Tpu { fetch_stage: FetchStage, sigverify_stage: SigVerifyStage, vote_sigverify_stage: SigVerifyStage, + relayer_stage: RelayerStage, + block_engine_stage: BlockEngineStage, + fetch_stage_manager: FetchStageManager, banking_stage: BankingStage, cluster_info_vote_listener: ClusterInfoVoteListener, broadcast_stage: BroadcastStage, @@ -74,6 +87,7 @@ pub struct Tpu { tpu_entry_notifier: Option, staked_nodes_updater_service: StakedNodesUpdaterService, tracer_thread_hdl: TracerThread, + bundle_stage: BundleStage, } impl Tpu { @@ -104,12 +118,17 @@ impl Tpu { keypair: &Keypair, log_messages_bytes_limit: Option, staked_nodes: &Arc>, + block_engine_config: Arc>, + relayer_config: Arc>, + tip_manager_config: TipManagerConfig, + shred_receiver_address: Arc>>, shared_staked_nodes_overrides: Arc>>, banking_tracer: Arc, tracer_thread_hdl: TracerThread, tpu_enable_udp: bool, prioritization_fee_cache: &Arc, _generator_config: Option, /* vestigial code for replay invalidator */ + preallocated_bundle_cost: u64, ) -> Self { let TpuSockets { transactions: transactions_sockets, @@ -120,7 +139,10 @@ impl Tpu { transactions_forwards_quic: transactions_forwards_quic_sockets, } = sockets; - let (packet_sender, packet_receiver) = unbounded(); + // Packets from fetch stage and quic server are intercepted and sent through fetch_stage_manager + // If relayer is connected, packets are dropped. If not, packets are forwarded on to packet_sender + let (packet_intercept_sender, packet_intercept_receiver) = unbounded(); + let (vote_packet_sender, vote_packet_receiver) = unbounded(); let (forwarded_packet_sender, forwarded_packet_receiver) = unbounded(); let fetch_stage = FetchStage::new_with_sender( @@ -128,7 +150,7 @@ impl Tpu { tpu_forwards_sockets, tpu_vote_sockets, exit, - &packet_sender, + &packet_intercept_sender, &vote_packet_sender, &forwarded_packet_sender, forwarded_packet_receiver, @@ -156,7 +178,7 @@ impl Tpu { .tpu(Protocol::QUIC) .expect("Operator must spin up node with valid (QUIC) TPU address") .ip(), - packet_sender, + packet_intercept_sender, exit.clone(), MAX_QUIC_CONNECTIONS_PER_PEER, staked_nodes.clone(), @@ -187,8 +209,10 @@ impl Tpu { ) .unwrap(); + let (packet_sender, packet_receiver) = unbounded(); + let sigverify_stage = { - let verifier = TransactionSigVerifier::new(non_vote_sender); + let verifier = TransactionSigVerifier::new(non_vote_sender.clone()); SigVerifyStage::new(packet_receiver, verifier, "tpu-verifier") }; @@ -201,6 +225,41 @@ impl Tpu { let (gossip_vote_sender, gossip_vote_receiver) = banking_tracer.create_channel_gossip_vote(); + + let block_builder_fee_info = Arc::new(Mutex::new(BlockBuilderFeeInfo { + block_builder: cluster_info.keypair().pubkey(), + block_builder_commission: 0, + })); + + let (bundle_sender, bundle_receiver) = unbounded(); + let block_engine_stage = BlockEngineStage::new( + block_engine_config, + bundle_sender, + cluster_info.clone(), + packet_sender.clone(), + non_vote_sender.clone(), + exit.clone(), + &block_builder_fee_info, + ); + + let (heartbeat_tx, heartbeat_rx) = unbounded(); + let fetch_stage_manager = FetchStageManager::new( + cluster_info.clone(), + heartbeat_rx, + packet_intercept_receiver, + packet_sender.clone(), + exit.clone(), + ); + + let relayer_stage = RelayerStage::new( + relayer_config, + cluster_info.clone(), + heartbeat_tx, + packet_sender, + non_vote_sender, + exit.clone(), + ); + let cluster_info_vote_listener = ClusterInfoVoteListener::new( exit.clone(), cluster_info.clone(), @@ -217,16 +276,43 @@ impl Tpu { cluster_confirmed_slot_sender, ); + let tip_manager = TipManager::new(tip_manager_config); + + let bundle_account_locker = BundleAccountLocker::default(); + + // tip accounts can't be used in BankingStage to avoid someone from stealing tips mid-slot. + // it also helps reduce surface area for potential account contention + let mut blacklisted_accounts = HashSet::new(); + blacklisted_accounts.insert(tip_manager.tip_payment_config_pubkey()); + blacklisted_accounts.extend(tip_manager.get_tip_accounts()); let banking_stage = BankingStage::new( cluster_info, poh_recorder, non_vote_receiver, tpu_vote_receiver, gossip_vote_receiver, + transaction_status_sender.clone(), + replay_vote_sender.clone(), + log_messages_bytes_limit, + connection_cache.clone(), + bank_forks.clone(), + prioritization_fee_cache, + blacklisted_accounts, + bundle_account_locker.clone(), + ); + + let bundle_stage = BundleStage::new( + cluster_info, + poh_recorder, + bundle_receiver, transaction_status_sender, replay_vote_sender, log_messages_bytes_limit, - connection_cache.clone(), + exit.clone(), + tip_manager, + bundle_account_locker, + &block_builder_fee_info, + preallocated_bundle_cost, bank_forks.clone(), prioritization_fee_cache, ); @@ -254,12 +340,16 @@ impl Tpu { blockstore.clone(), bank_forks, shred_version, + shred_receiver_address, ); Self { fetch_stage, sigverify_stage, vote_sigverify_stage, + block_engine_stage, + relayer_stage, + fetch_stage_manager, banking_stage, cluster_info_vote_listener, broadcast_stage, @@ -268,6 +358,7 @@ impl Tpu { tpu_entry_notifier, staked_nodes_updater_service, tracer_thread_hdl, + bundle_stage, } } @@ -281,6 +372,10 @@ impl Tpu { self.staked_nodes_updater_service.join(), self.tpu_quic_t.join(), self.tpu_forwards_quic_t.join(), + self.bundle_stage.join(), + self.relayer_stage.join(), + self.block_engine_stage.join(), + self.fetch_stage_manager.join(), ]; let broadcast_result = self.broadcast_stage.join(); for result in results { diff --git a/core/src/tpu_entry_notifier.rs b/core/src/tpu_entry_notifier.rs index 730a3b14fa..bc20696f5b 100644 --- a/core/src/tpu_entry_notifier.rs +++ b/core/src/tpu_entry_notifier.rs @@ -58,40 +58,54 @@ impl TpuEntryNotifier { current_slot: &mut u64, current_index: &mut usize, ) -> Result<(), RecvTimeoutError> { - let (bank, (entry, tick_height)) = entry_receiver.recv_timeout(Duration::from_secs(1))?; + let WorkingBankEntry { + bank, + entries_ticks, + } = entry_receiver.recv_timeout(Duration::from_secs(1))?; let slot = bank.slot(); - let index = if slot != *current_slot { - *current_index = 0; - *current_slot = slot; - 0 - } else { - *current_index += 1; - *current_index - }; - let entry_summary = EntrySummary { - num_hashes: entry.num_hashes, - hash: entry.hash, - num_transactions: entry.transactions.len() as u64, - }; - if let Err(err) = entry_notification_sender.send(EntryNotification { - slot, - index, - entry: entry_summary, - }) { - warn!( + let mut indices_sent = vec![]; + + entries_ticks.iter().for_each(|(entry, _)| { + let index = if slot != *current_slot { + *current_index = 0; + *current_slot = slot; + 0 + } else { + *current_index += 1; + *current_index + }; + + let entry_summary = EntrySummary { + num_hashes: entry.num_hashes, + hash: entry.hash, + num_transactions: entry.transactions.len() as u64, + }; + if let Err(err) = entry_notification_sender.send(EntryNotification { + slot, + index, + entry: entry_summary, + }) { + warn!( "Failed to send slot {slot:?} entry {index:?} from Tpu to EntryNotifierService, error {err:?}", ); - } + } + + indices_sent.push(index); + }); - if let Err(err) = broadcast_entry_sender.send((bank, (entry, tick_height))) { + if let Err(err) = broadcast_entry_sender.send(WorkingBankEntry { + bank, + entries_ticks, + }) { warn!( - "Failed to send slot {slot:?} entry {index:?} from Tpu to BroadcastStage, error {err:?}", + "Failed to send slot {slot:?} entries {indices_sent:?} from Tpu to BroadcastStage, error {err:?}", ); // If the BroadcastStage channel is closed, the validator has halted. Try to exit // gracefully. exit.store(true, Ordering::Relaxed); } + Ok(()) } diff --git a/core/src/tvu.rs b/core/src/tvu.rs index a5a7fba451..d83f2bd573 100644 --- a/core/src/tvu.rs +++ b/core/src/tvu.rs @@ -52,7 +52,7 @@ use { solana_sdk::{clock::Slot, pubkey::Pubkey, signature::Keypair}, std::{ collections::HashSet, - net::UdpSocket, + net::{SocketAddr, UdpSocket}, sync::{atomic::AtomicBool, Arc, RwLock}, thread::{self, JoinHandle}, }, @@ -138,6 +138,7 @@ impl Tvu { connection_cache: &Arc, prioritization_fee_cache: &Arc, banking_tracer: Arc, + shred_receiver_addr: Arc>>, ) -> Result { let TvuSockets { repair: repair_socket, @@ -185,6 +186,7 @@ impl Tvu { retransmit_receiver, max_slots.clone(), Some(rpc_subscriptions.clone()), + shred_receiver_addr, ); let cluster_slots = Arc::new(ClusterSlots::default()); @@ -483,6 +485,7 @@ pub mod tests { &Arc::new(ConnectionCache::new("connection_cache_test")), &ignored_prioritization_fee_cache, BankingTracer::new_disabled(), + Arc::new(RwLock::new(None)), ) .expect("assume success"); exit.store(true, Ordering::Relaxed); diff --git a/core/src/unprocessed_transaction_storage.rs b/core/src/unprocessed_transaction_storage.rs index 53d6005a55..d0428dd8c6 100644 --- a/core/src/unprocessed_transaction_storage.rs +++ b/core/src/unprocessed_transaction_storage.rs @@ -1,7 +1,9 @@ use { crate::{ banking_stage::{BankingStageStats, FilterForwardingResults, ForwardOption}, + bundle_stage::bundle_stage_leader_metrics::BundleStageLeaderMetrics, forward_packet_batches_by_accounts::ForwardPacketBatchesByAccounts, + immutable_deserialized_bundle::ImmutableDeserializedBundle, immutable_deserialized_packet::ImmutableDeserializedPacket, latest_unprocessed_votes::{ LatestUnprocessedVotes, LatestValidatorVotePacket, VoteBatchInsertionMetrics, @@ -16,15 +18,22 @@ use { }, itertools::Itertools, min_max_heap::MinMaxHeap, + solana_bundle::BundleExecutionError, solana_measure::measure, - solana_runtime::bank::Bank, + solana_runtime::{bank::Bank, transaction_error_metrics::TransactionErrorMetrics}, solana_sdk::{ - clock::FORWARD_TRANSACTIONS_TO_LEADER_AT_SLOT_OFFSET, feature_set::FeatureSet, hash::Hash, - saturating_add_assign, transaction::SanitizedTransaction, + bundle::SanitizedBundle, + clock::{Slot, FORWARD_TRANSACTIONS_TO_LEADER_AT_SLOT_OFFSET}, + feature_set::FeatureSet, + hash::Hash, + pubkey::Pubkey, + saturating_add_assign, + transaction::SanitizedTransaction, }, std::{ - collections::HashMap, + collections::{HashMap, HashSet, VecDeque}, sync::{atomic::Ordering, Arc}, + time::Instant, }, }; @@ -39,6 +48,7 @@ const MAX_NUM_VOTES_RECEIVE: usize = 10_000; pub enum UnprocessedTransactionStorage { VoteStorage(VoteStorage), LocalTransactionStorage(ThreadLocalUnprocessedPackets), + BundleStorage(BundleStorage), } #[derive(Debug)] @@ -57,10 +67,11 @@ pub struct VoteStorage { pub enum ThreadType { Voting(VoteSource), Transactions, + Bundles, } #[derive(Debug)] -pub(crate) enum InsertPacketBatchSummary { +pub enum InsertPacketBatchSummary { VoteBatchInsertionMetrics(VoteBatchInsertionMetrics), PacketBatchInsertionMetrics(PacketBatchInsertionMetrics), } @@ -143,6 +154,7 @@ fn consume_scan_should_process_packet( banking_stage_stats: &BankingStageStats, packet: &ImmutableDeserializedPacket, payload: &mut ConsumeScannerPayload, + blacklisted_accounts: &HashSet, ) -> ProcessingDecision { // If end of the slot, return should process (quick loop after reached end of slot) if payload.reached_end_of_slot { @@ -177,6 +189,10 @@ fn consume_scan_should_process_packet( bank.get_transaction_account_lock_limit(), ) .is_err() + || message + .account_keys() + .iter() + .any(|key| blacklisted_accounts.contains(key)) { payload .message_hash_to_transaction @@ -245,10 +261,24 @@ impl UnprocessedTransactionStorage { }) } + pub fn new_bundle_storage( + unprocessed_bundle_storage: VecDeque, + cost_model_failed_bundles: VecDeque, + ) -> Self { + Self::BundleStorage(BundleStorage { + last_update_slot: Slot::default(), + unprocessed_bundle_storage, + cost_model_buffered_bundle_storage: cost_model_failed_bundles, + }) + } + pub fn is_empty(&self) -> bool { match self { Self::VoteStorage(vote_storage) => vote_storage.is_empty(), Self::LocalTransactionStorage(transaction_storage) => transaction_storage.is_empty(), + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.is_empty() + } } } @@ -256,6 +286,10 @@ impl UnprocessedTransactionStorage { match self { Self::VoteStorage(vote_storage) => vote_storage.len(), Self::LocalTransactionStorage(transaction_storage) => transaction_storage.len(), + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.unprocessed_bundles_len() + + bundle_storage.cost_model_buffered_bundles_len() + } } } @@ -266,6 +300,9 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => { transaction_storage.max_receive_size() } + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.max_receive_size() + } } } @@ -292,6 +329,9 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => { transaction_storage.forward_option() } + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.forward_option() + } } } @@ -299,6 +339,16 @@ impl UnprocessedTransactionStorage { match self { Self::LocalTransactionStorage(transaction_storage) => transaction_storage.clear(), // Since we set everything as forwarded this is the same Self::VoteStorage(vote_storage) => vote_storage.clear_forwarded_packets(), + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + let _ = bundle_storage.reset(); + } + } + } + + pub fn bundle_storage(&mut self) -> Option<&mut BundleStorage> { + match self { + UnprocessedTransactionStorage::BundleStorage(bundle_stoge) => Some(bundle_stoge), + _ => None, } } @@ -313,6 +363,11 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => InsertPacketBatchSummary::from( transaction_storage.insert_batch(deserialized_packets), ), + UnprocessedTransactionStorage::BundleStorage(_) => { + panic!( + "bundles must be inserted using UnprocessedTransactionStorage::insert_bundle" + ) + } } } @@ -332,6 +387,9 @@ impl UnprocessedTransactionStorage { bank, forward_packet_batches_by_accounts, ), + UnprocessedTransactionStorage::BundleStorage(_) => { + panic!("bundles are not forwarded between leaders") + } } } @@ -345,6 +403,7 @@ impl UnprocessedTransactionStorage { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -359,15 +418,62 @@ impl UnprocessedTransactionStorage { banking_stage_stats, slot_metrics_tracker, processing_function, + blacklisted_accounts, ), Self::VoteStorage(vote_storage) => vote_storage.process_packets( bank, banking_stage_stats, slot_metrics_tracker, processing_function, + blacklisted_accounts, + ), + UnprocessedTransactionStorage::BundleStorage(_) => panic!( + "UnprocessedTransactionStorage::BundleStorage does not support processing packets" ), } } + + #[must_use] + pub fn process_bundles( + &mut self, + bank: Arc, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + blacklisted_accounts: &HashSet, + processing_function: F, + ) -> bool + where + F: FnMut( + &[(ImmutableDeserializedBundle, SanitizedBundle)], + &mut BundleStageLeaderMetrics, + ) -> Vec>, + { + match self { + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => bundle_storage + .process_bundles( + bank, + bundle_stage_leader_metrics, + blacklisted_accounts, + processing_function, + ), + _ => panic!("class does not support processing bundles"), + } + } + + /// Inserts bundles into storage. Only supported for UnprocessedTransactionStorage::BundleStorage + pub(crate) fn insert_bundles( + &mut self, + deserialized_bundles: Vec, + ) -> InsertPacketBundlesSummary { + match self { + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.insert_unprocessed_bundles(deserialized_bundles, true) + } + UnprocessedTransactionStorage::LocalTransactionStorage(_) + | UnprocessedTransactionStorage::VoteStorage(_) => { + panic!("UnprocessedTransactionStorage::insert_bundles only works for type UnprocessedTransactionStorage::BundleStorage"); + } + } + } } impl VoteStorage { @@ -436,6 +542,7 @@ impl VoteStorage { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, mut processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -449,7 +556,13 @@ impl VoteStorage { let should_process_packet = |packet: &Arc, payload: &mut ConsumeScannerPayload| { - consume_scan_should_process_packet(&bank, banking_stage_stats, packet, payload) + consume_scan_should_process_packet( + &bank, + banking_stage_stats, + packet, + payload, + blacklisted_accounts, + ) }; // Based on the stake distribution present in the supplied bank, drain the unprocessed votes @@ -524,6 +637,7 @@ impl ThreadLocalUnprocessedPackets { ThreadType::Transactions => ForwardOption::ForwardTransaction, ThreadType::Voting(VoteSource::Tpu) => ForwardOption::ForwardTpuVote, ThreadType::Voting(VoteSource::Gossip) => ForwardOption::NotForward, + ThreadType::Bundles => panic!(), // TODO (LB) } } @@ -852,6 +966,7 @@ impl ThreadLocalUnprocessedPackets { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, mut processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -866,7 +981,13 @@ impl ThreadLocalUnprocessedPackets { let should_process_packet = |packet: &Arc, payload: &mut ConsumeScannerPayload| { - consume_scan_should_process_packet(bank, banking_stage_stats, packet, payload) + consume_scan_should_process_packet( + bank, + banking_stage_stats, + packet, + payload, + blacklisted_accounts, + ) }; let mut scanner = create_consume_multi_iterator( &all_packets_to_process, @@ -943,396 +1064,305 @@ impl ThreadLocalUnprocessedPackets { } } -#[cfg(test)] -mod tests { - use { - super::*, - solana_ledger::genesis_utils::{create_genesis_config, GenesisConfigInfo}, - solana_perf::packet::{Packet, PacketFlags}, - solana_sdk::{ - hash::Hash, - signature::{Keypair, Signer}, - system_transaction, - transaction::Transaction, - }, - solana_vote_program::{ - vote_state::VoteStateUpdate, vote_transaction::new_vote_state_update_transaction, - }, - std::error::Error, - }; +pub struct InsertPacketBundlesSummary { + pub insert_packets_summary: InsertPacketBatchSummary, + pub num_bundles_inserted: usize, + pub num_packets_inserted: usize, + pub num_bundles_dropped: usize, +} - #[test] - fn test_filter_processed_packets() { - let retryable_indexes = [0, 1, 2, 3]; - let mut non_retryable_indexes = vec![]; - let f = |start, end| { - non_retryable_indexes.push((start, end)); - }; - filter_processed_packets(retryable_indexes.iter(), f); - assert!(non_retryable_indexes.is_empty()); - - let retryable_indexes = [0, 1, 2, 3, 5]; - let mut non_retryable_indexes = vec![]; - let f = |start, end| { - non_retryable_indexes.push((start, end)); - }; - filter_processed_packets(retryable_indexes.iter(), f); - assert_eq!(non_retryable_indexes, vec![(4, 5)]); - - let retryable_indexes = [1, 2, 3]; - let mut non_retryable_indexes = vec![]; - let f = |start, end| { - non_retryable_indexes.push((start, end)); - }; - filter_processed_packets(retryable_indexes.iter(), f); - assert_eq!(non_retryable_indexes, vec![(0, 1)]); - - let retryable_indexes = [1, 2, 3, 5]; - let mut non_retryable_indexes = vec![]; - let f = |start, end| { - non_retryable_indexes.push((start, end)); - }; - filter_processed_packets(retryable_indexes.iter(), f); - assert_eq!(non_retryable_indexes, vec![(0, 1), (4, 5)]); - - let retryable_indexes = [1, 2, 3, 5, 8]; - let mut non_retryable_indexes = vec![]; - let f = |start, end| { - non_retryable_indexes.push((start, end)); - }; - filter_processed_packets(retryable_indexes.iter(), f); - assert_eq!(non_retryable_indexes, vec![(0, 1), (4, 5), (6, 8)]); - - let retryable_indexes = [1, 2, 3, 5, 8, 8]; - let mut non_retryable_indexes = vec![]; - let f = |start, end| { - non_retryable_indexes.push((start, end)); - }; - filter_processed_packets(retryable_indexes.iter(), f); - assert_eq!(non_retryable_indexes, vec![(0, 1), (4, 5), (6, 8)]); - } - - #[test] - fn test_filter_and_forward_with_account_limits() { - solana_logger::setup(); - let GenesisConfigInfo { - genesis_config, - mint_keypair, - .. - } = create_genesis_config(10); - let current_bank = Arc::new(Bank::new_for_tests(&genesis_config)); - - let simple_transactions: Vec = (0..256) - .map(|_id| { - // packets are deserialized upon receiving, failed packets will not be - // forwarded; Therefore we need to create real packets here. - let key1 = Keypair::new(); - system_transaction::transfer( - &mint_keypair, - &key1.pubkey(), - genesis_config.rent.minimum_balance(0), - genesis_config.hash(), - ) - }) - .collect_vec(); +/// Bundle storage has two deques: one for unprocessed bundles and another for ones that exceeded +/// the cost model and need to get retried next slot. +#[derive(Debug)] +pub struct BundleStorage { + last_update_slot: Slot, + unprocessed_bundle_storage: VecDeque, + // Storage for bundles that exceeded the cost model for the slot they were last attempted + // execution on + cost_model_buffered_bundle_storage: VecDeque, +} + +impl BundleStorage { + fn is_empty(&self) -> bool { + self.unprocessed_bundle_storage.is_empty() + } + + pub fn unprocessed_bundles_len(&self) -> usize { + self.unprocessed_bundle_storage.len() + } - let mut packets: Vec = simple_transactions + pub fn unprocessed_packets_len(&self) -> usize { + self.unprocessed_bundle_storage .iter() - .enumerate() - .map(|(packets_id, transaction)| { - let mut p = Packet::from_data(None, transaction).unwrap(); - p.meta_mut().port = packets_id as u16; - p.meta_mut().set_tracer(true); - DeserializedPacket::new(p).unwrap() - }) - .collect_vec(); + .map(|b| b.len()) + .sum::() + } - // all packets are forwarded - { - let buffered_packet_batches: UnprocessedPacketBatches = - UnprocessedPacketBatches::from_iter(packets.clone().into_iter(), packets.len()); - let mut transaction_storage = UnprocessedTransactionStorage::new_transaction_storage( - buffered_packet_batches, - ThreadType::Transactions, - ); - let mut forward_packet_batches_by_accounts = - ForwardPacketBatchesByAccounts::new_with_default_batch_limits(); + pub(crate) fn cost_model_buffered_bundles_len(&self) -> usize { + self.cost_model_buffered_bundle_storage.len() + } - let FilterForwardingResults { - total_forwardable_packets, - total_tracer_packets_in_buffer, - total_forwardable_tracer_packets, - .. - } = transaction_storage.filter_forwardable_packets_and_add_batches( - current_bank.clone(), - &mut forward_packet_batches_by_accounts, - ); - assert_eq!(total_forwardable_packets, 256); - assert_eq!(total_tracer_packets_in_buffer, 256); - assert_eq!(total_forwardable_tracer_packets, 256); - - // packets in a batch are forwarded in arbitrary order; verify the ports match after - // sorting - let expected_ports: Vec<_> = (0..256).collect(); - let mut forwarded_ports: Vec<_> = forward_packet_batches_by_accounts - .iter_batches() - .flat_map(|batch| batch.get_forwardable_packets().map(|p| p.meta().port)) - .collect(); - forwarded_ports.sort_unstable(); - assert_eq!(expected_ports, forwarded_ports); - } + pub(crate) fn cost_model_buffered_packets_len(&self) -> usize { + self.cost_model_buffered_bundle_storage + .iter() + .map(|b| b.len()) + .sum() + } - // some packets are forwarded - { - let num_already_forwarded = 16; - for packet in &mut packets[0..num_already_forwarded] { - packet.forwarded = true; + pub(crate) fn max_receive_size(&self) -> usize { + self.unprocessed_bundle_storage.capacity() - self.unprocessed_bundle_storage.len() + } + + fn forward_option(&self) -> ForwardOption { + ForwardOption::NotForward + } + + /// Returns the number of unprocessed bundles + cost model buffered cleared + pub fn reset(&mut self) -> (usize, usize) { + let num_unprocessed_bundles = self.unprocessed_bundle_storage.len(); + let num_cost_model_buffered_bundles = self.cost_model_buffered_bundle_storage.len(); + self.unprocessed_bundle_storage.clear(); + self.cost_model_buffered_bundle_storage.clear(); + (num_unprocessed_bundles, num_cost_model_buffered_bundles) + } + + fn insert_bundles( + deque: &mut VecDeque, + deserialized_bundles: Vec, + push_back: bool, + ) -> InsertPacketBundlesSummary { + let mut num_bundles_inserted: usize = 0; + let mut num_packets_inserted: usize = 0; + let mut num_bundles_dropped: usize = 0; + let mut num_packets_dropped: usize = 0; + + for bundle in deserialized_bundles { + if deque.capacity() == deque.len() { + saturating_add_assign!(num_bundles_dropped, 1); + saturating_add_assign!(num_packets_dropped, bundle.len()); + } else { + saturating_add_assign!(num_bundles_inserted, 1); + saturating_add_assign!(num_packets_inserted, bundle.len()); + if push_back { + deque.push_back(bundle); + } else { + deque.push_front(bundle) + } } - let buffered_packet_batches: UnprocessedPacketBatches = - UnprocessedPacketBatches::from_iter(packets.clone().into_iter(), packets.len()); - let mut transaction_storage = UnprocessedTransactionStorage::new_transaction_storage( - buffered_packet_batches, - ThreadType::Transactions, - ); - let mut forward_packet_batches_by_accounts = - ForwardPacketBatchesByAccounts::new_with_default_batch_limits(); - let FilterForwardingResults { - total_forwardable_packets, - total_tracer_packets_in_buffer, - total_forwardable_tracer_packets, - .. - } = transaction_storage.filter_forwardable_packets_and_add_batches( - current_bank.clone(), - &mut forward_packet_batches_by_accounts, - ); - assert_eq!( - total_forwardable_packets, - packets.len() - num_already_forwarded - ); - assert_eq!(total_tracer_packets_in_buffer, packets.len()); - assert_eq!( - total_forwardable_tracer_packets, - packets.len() - num_already_forwarded - ); } - // some packets are invalid (already processed) - { - let num_already_processed = 16; - for tx in &simple_transactions[0..num_already_processed] { - assert_eq!(current_bank.process_transaction(tx), Ok(())); + InsertPacketBundlesSummary { + insert_packets_summary: PacketBatchInsertionMetrics { + num_dropped_packets: num_packets_dropped, + num_dropped_tracer_packets: 0, } - let buffered_packet_batches: UnprocessedPacketBatches = - UnprocessedPacketBatches::from_iter(packets.clone().into_iter(), packets.len()); - let mut transaction_storage = UnprocessedTransactionStorage::new_transaction_storage( - buffered_packet_batches, - ThreadType::Transactions, - ); - let mut forward_packet_batches_by_accounts = - ForwardPacketBatchesByAccounts::new_with_default_batch_limits(); - let FilterForwardingResults { - total_forwardable_packets, - total_tracer_packets_in_buffer, - total_forwardable_tracer_packets, - .. - } = transaction_storage.filter_forwardable_packets_and_add_batches( - current_bank, - &mut forward_packet_batches_by_accounts, - ); - assert_eq!( - total_forwardable_packets, - packets.len() - num_already_processed - ); - assert_eq!(total_tracer_packets_in_buffer, packets.len()); - assert_eq!( - total_forwardable_tracer_packets, - packets.len() - num_already_processed - ); + .into(), + num_bundles_inserted, + num_packets_inserted, + num_bundles_dropped, } } - #[test] - fn test_unprocessed_transaction_storage_insert() -> Result<(), Box> { - let keypair = Keypair::new(); - let vote_keypair = Keypair::new(); - let pubkey = solana_sdk::pubkey::new_rand(); - - let small_transfer = Packet::from_data( - None, - system_transaction::transfer(&keypair, &pubkey, 1, Hash::new_unique()), - )?; - let mut vote = Packet::from_data( - None, - new_vote_state_update_transaction( - VoteStateUpdate::default(), - Hash::new_unique(), - &keypair, - &vote_keypair, - &vote_keypair, - None, - ), - )?; - vote.meta_mut().flags.set(PacketFlags::SIMPLE_VOTE_TX, true); - let big_transfer = Packet::from_data( - None, - system_transaction::transfer(&keypair, &pubkey, 1000000, Hash::new_unique()), - )?; - - for thread_type in [ - ThreadType::Transactions, - ThreadType::Voting(VoteSource::Gossip), - ThreadType::Voting(VoteSource::Tpu), - ] { - let mut transaction_storage = UnprocessedTransactionStorage::new_transaction_storage( - UnprocessedPacketBatches::with_capacity(100), - thread_type, + fn push_front_unprocessed_bundles( + &mut self, + deserialized_bundles: Vec, + ) -> InsertPacketBundlesSummary { + Self::insert_bundles( + &mut self.unprocessed_bundle_storage, + deserialized_bundles, + false, + ) + } + + fn push_back_cost_model_buffered_bundles( + &mut self, + deserialized_bundles: Vec, + ) -> InsertPacketBundlesSummary { + Self::insert_bundles( + &mut self.cost_model_buffered_bundle_storage, + deserialized_bundles, + true, + ) + } + + fn insert_unprocessed_bundles( + &mut self, + deserialized_bundles: Vec, + push_back: bool, + ) -> InsertPacketBundlesSummary { + Self::insert_bundles( + &mut self.unprocessed_bundle_storage, + deserialized_bundles, + push_back, + ) + } + + /// Drains bundles from the queue, sanitizes them to prepare for execution, executes them by + /// calling `processing_function`, then potentially rebuffer them. + pub fn process_bundles( + &mut self, + bank: Arc, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + blacklisted_accounts: &HashSet, + mut processing_function: F, + ) -> bool + where + F: FnMut( + &[(ImmutableDeserializedBundle, SanitizedBundle)], + &mut BundleStageLeaderMetrics, + ) -> Vec>, + { + let sanitized_bundles = self.drain_and_sanitize_bundles( + bank, + bundle_stage_leader_metrics, + blacklisted_accounts, + ); + + debug!("processing {} bundles", sanitized_bundles.len()); + let bundle_execution_results = + processing_function(&sanitized_bundles, bundle_stage_leader_metrics); + + let mut is_slot_over = false; + + let mut rebuffered_bundles = Vec::new(); + + sanitized_bundles + .into_iter() + .zip(bundle_execution_results.into_iter()) + .for_each( + |((deserialized_bundle, sanitized_bundle), result)| match result { + Ok(_) => { + debug!("bundle={} executed ok", sanitized_bundle.bundle_id); + // yippee + } + Err(BundleExecutionError::PohRecordError(e)) => { + // buffer the bundle to the front of the queue to be attempted next slot + debug!( + "bundle={} poh record error: {e:?}", + sanitized_bundle.bundle_id + ); + rebuffered_bundles.push(deserialized_bundle); + is_slot_over = true; + } + Err(BundleExecutionError::BankProcessingTimeLimitReached) => { + // buffer the bundle to the front of the queue to be attempted next slot + debug!("bundle={} bank processing done", sanitized_bundle.bundle_id); + rebuffered_bundles.push(deserialized_bundle); + is_slot_over = true; + } + Err(BundleExecutionError::TransactionFailure(e)) => { + debug!( + "bundle={} execution error: {:?}", + sanitized_bundle.bundle_id, e + ); + // do nothing + } + Err(BundleExecutionError::ExceedsCostModel) => { + // cost model buffered bundles contain most recent bundles at the front of the queue + debug!("bundle={} exceeds cost model", sanitized_bundle.bundle_id); + self.push_back_cost_model_buffered_bundles(vec![deserialized_bundle]); + } + Err(BundleExecutionError::TipError(e)) => { + debug!("bundle={} tip error: {}", sanitized_bundle.bundle_id, e); + // Tip errors are _typically_ due to misconfiguration (except for poh record error, bank processing error, exceeds cost model) + // in order to prevent buffering too many bundles, we'll just drop the bundle + } + Err(BundleExecutionError::LockError) => { + // lock errors are irrecoverable due to malformed transactions + debug!("bundle={} lock error", sanitized_bundle.bundle_id); + } + }, ); - transaction_storage.insert_batch(vec![ - ImmutableDeserializedPacket::new(small_transfer.clone())?, - ImmutableDeserializedPacket::new(vote.clone())?, - ImmutableDeserializedPacket::new(big_transfer.clone())?, - ]); - let deserialized_packets = transaction_storage - .iter() - .map(|packet| packet.immutable_section().original_packet().clone()) - .collect_vec(); - assert_eq!(3, deserialized_packets.len()); - assert!(deserialized_packets.contains(&small_transfer)); - assert!(deserialized_packets.contains(&vote)); - assert!(deserialized_packets.contains(&big_transfer)); + + // rebuffered bundles are pushed onto deque in reverse order so the first bundle is at the front + for bundle in rebuffered_bundles.into_iter().rev() { + self.push_front_unprocessed_bundles(vec![bundle]); } - for vote_source in [VoteSource::Gossip, VoteSource::Tpu] { - let mut transaction_storage = UnprocessedTransactionStorage::new_vote_storage( - Arc::new(LatestUnprocessedVotes::new()), - vote_source, + is_slot_over + } + + /// Drains the unprocessed_bundle_storage, converting bundle packets into SanitizedBundles + fn drain_and_sanitize_bundles( + &mut self, + bank: Arc, + bundle_stage_leader_metrics: &mut BundleStageLeaderMetrics, + blacklisted_accounts: &HashSet, + ) -> Vec<(ImmutableDeserializedBundle, SanitizedBundle)> { + let mut error_metrics = TransactionErrorMetrics::default(); + + let start = Instant::now(); + + let mut sanitized_bundles = Vec::new(); + + // on new slot, drain anything that was buffered from last slot + if bank.slot() != self.last_update_slot { + sanitized_bundles.extend( + self.cost_model_buffered_bundle_storage + .drain(..) + .filter_map(|packet_bundle| { + let r = packet_bundle.build_sanitized_bundle( + &bank, + blacklisted_accounts, + &mut error_metrics, + ); + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_sanitize_transaction_result(&r); + + match r { + Ok(sanitized_bundle) => Some((packet_bundle, sanitized_bundle)), + Err(e) => { + debug!( + "bundle id: {} error sanitizing: {}", + packet_bundle.bundle_id(), + e + ); + None + } + } + }), ); - transaction_storage.insert_batch(vec![ - ImmutableDeserializedPacket::new(small_transfer.clone())?, - ImmutableDeserializedPacket::new(vote.clone())?, - ImmutableDeserializedPacket::new(big_transfer.clone())?, - ]); - assert_eq!(1, transaction_storage.len()); + + self.last_update_slot = bank.slot(); } - Ok(()) - } - - #[test] - fn test_prepare_packets_to_forward() { - solana_logger::setup(); - let GenesisConfigInfo { - genesis_config, - mint_keypair, - .. - } = create_genesis_config(10); - - let simple_transactions: Vec = (0..256) - .map(|_id| { - // packets are deserialized upon receiving, failed packets will not be - // forwarded; Therefore we need to create real packets here. - let key1 = Keypair::new(); - system_transaction::transfer( - &mint_keypair, - &key1.pubkey(), - genesis_config.rent.minimum_balance(0), - genesis_config.hash(), - ) - }) - .collect_vec(); - let mut packets: Vec = simple_transactions - .iter() - .enumerate() - .map(|(packets_id, transaction)| { - let mut p = Packet::from_data(None, transaction).unwrap(); - p.meta_mut().port = packets_id as u16; - p.meta_mut().set_tracer(true); - DeserializedPacket::new(p).unwrap() - }) - .collect_vec(); - - // test preparing buffered packets for forwarding - let test_prepareing_buffered_packets_for_forwarding = - |buffered_packet_batches: UnprocessedPacketBatches| -> (usize, usize, usize) { - let mut total_tracer_packets_in_buffer: usize = 0; - let mut total_packets_to_forward: usize = 0; - let mut total_tracer_packets_to_forward: usize = 0; - - let mut unprocessed_transactions = ThreadLocalUnprocessedPackets { - unprocessed_packet_batches: buffered_packet_batches, - thread_type: ThreadType::Transactions, - }; - - let mut original_priority_queue = unprocessed_transactions.take_priority_queue(); - let _ = original_priority_queue - .drain_desc() - .chunks(128usize) - .into_iter() - .flat_map(|packets_to_process| { - let (_, packets_to_forward, is_tracer_packet) = unprocessed_transactions - .prepare_packets_to_forward( - packets_to_process, - &mut total_tracer_packets_in_buffer, - ); - total_packets_to_forward += packets_to_forward.len(); - total_tracer_packets_to_forward += is_tracer_packet.len(); - packets_to_forward - }) - .collect::>>(); - ( - total_tracer_packets_in_buffer, - total_packets_to_forward, - total_tracer_packets_to_forward, - ) - }; + sanitized_bundles.extend(self.unprocessed_bundle_storage.drain(..).filter_map( + |packet_bundle| { + let r = packet_bundle.build_sanitized_bundle( + &bank, + blacklisted_accounts, + &mut error_metrics, + ); + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_sanitize_transaction_result(&r); + match r { + Ok(sanitized_bundle) => Some((packet_bundle, sanitized_bundle)), + Err(e) => { + debug!( + "bundle id: {} error sanitizing: {}", + packet_bundle.bundle_id(), + e + ); + None + } + } + }, + )); - // all tracer packets are forwardable - { - let buffered_packet_batches: UnprocessedPacketBatches = - UnprocessedPacketBatches::from_iter(packets.clone().into_iter(), packets.len()); - let ( - total_tracer_packets_in_buffer, - total_packets_to_forward, - total_tracer_packets_to_forward, - ) = test_prepareing_buffered_packets_for_forwarding(buffered_packet_batches); - assert_eq!(total_tracer_packets_in_buffer, 256); - assert_eq!(total_packets_to_forward, 256); - assert_eq!(total_tracer_packets_to_forward, 256); - } + let elapsed = start.elapsed().as_micros(); + bundle_stage_leader_metrics + .bundle_stage_metrics_tracker() + .increment_sanitize_bundle_elapsed_us(elapsed as u64); + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .increment_transactions_from_packets_us(elapsed as u64); - // some packets are forwarded - { - let num_already_forwarded = 16; - for packet in &mut packets[0..num_already_forwarded] { - packet.forwarded = true; - } - let buffered_packet_batches: UnprocessedPacketBatches = - UnprocessedPacketBatches::from_iter(packets.clone().into_iter(), packets.len()); - let ( - total_tracer_packets_in_buffer, - total_packets_to_forward, - total_tracer_packets_to_forward, - ) = test_prepareing_buffered_packets_for_forwarding(buffered_packet_batches); - assert_eq!(total_tracer_packets_in_buffer, 256); - assert_eq!(total_packets_to_forward, 256 - num_already_forwarded); - assert_eq!(total_tracer_packets_to_forward, 256 - num_already_forwarded); - } + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .accumulate_transaction_errors(&error_metrics); - // all packets are forwarded - { - for packet in &mut packets { - packet.forwarded = true; - } - let buffered_packet_batches: UnprocessedPacketBatches = - UnprocessedPacketBatches::from_iter(packets.clone().into_iter(), packets.len()); - let ( - total_tracer_packets_in_buffer, - total_packets_to_forward, - total_tracer_packets_to_forward, - ) = test_prepareing_buffered_packets_for_forwarding(buffered_packet_batches); - assert_eq!(total_tracer_packets_in_buffer, 256); - assert_eq!(total_packets_to_forward, 0); - assert_eq!(total_tracer_packets_to_forward, 0); - } + sanitized_bundles } } diff --git a/core/src/validator.rs b/core/src/validator.rs index 246f09a89b..44ad43a7bb 100644 --- a/core/src/validator.rs +++ b/core/src/validator.rs @@ -13,6 +13,7 @@ use { consensus::{reconcile_blockstore_roots_with_external_source, ExternalRootSource, Tower}, ledger_metric_report_service::LedgerMetricReportService, poh_timing_report_service::PohTimingReportService, + proxy::{block_engine_stage::BlockEngineConfig, relayer_stage::RelayerConfig}, rewards_recorder_service::{RewardsRecorderSender, RewardsRecorderService}, sample_performance_service::SamplePerformanceService, serve_repair::ServeRepair, @@ -23,6 +24,7 @@ use { system_monitor_service::{ verify_net_stats_access, SystemMonitorService, SystemMonitorStatsReportConfig, }, + tip_manager::TipManagerConfig, tower_storage::TowerStorage, tpu::{Tpu, TpuSockets, DEFAULT_TPU_COALESCE}, tvu::{Tvu, TvuConfig, TvuSockets}, @@ -97,6 +99,10 @@ use { self, clean_orphaned_account_snapshot_dirs, move_and_async_delete_path_contents, }, }, + solana_runtime_plugin::{ + runtime_plugin_admin_rpc_service::RuntimePluginManagerRpcRequest, + runtime_plugin_service::RuntimePluginService, + }, solana_sdk::{ clock::Slot, epoch_schedule::MAX_LEADER_SCHEDULE_EPOCH_OFFSET, @@ -117,7 +123,7 @@ use { path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, RwLock, + Arc, Mutex, RwLock, }, thread::{sleep, Builder, JoinHandle}, time::{Duration, Instant}, @@ -250,6 +256,12 @@ pub struct ValidatorConfig { pub block_verification_method: BlockVerificationMethod, pub block_production_method: BlockProductionMethod, pub generator_config: Option, + pub relayer_config: Arc>, + pub block_engine_config: Arc>, + // Using Option inside RwLock is ugly, but only convenient way to allow toggle on/off + pub shred_receiver_address: Arc>>, + pub tip_manager_config: TipManagerConfig, + pub preallocated_bundle_cost: u64, } impl Default for ValidatorConfig { @@ -317,6 +329,11 @@ impl Default for ValidatorConfig { block_verification_method: BlockVerificationMethod::default(), block_production_method: BlockProductionMethod::default(), generator_config: None, + relayer_config: Arc::new(Mutex::new(RelayerConfig::default())), + block_engine_config: Arc::new(Mutex::new(BlockEngineConfig::default())), + shred_receiver_address: Arc::new(RwLock::new(None)), + tip_manager_config: TipManagerConfig::default(), + preallocated_bundle_cost: u64::default(), } } } @@ -476,6 +493,10 @@ impl Validator { tpu_connection_pool_size: usize, tpu_enable_udp: bool, admin_rpc_service_post_init: Arc>>, + runtime_plugin_configs_and_request_rx: Option<( + Vec, + Receiver, + )>, ) -> Result { let id = identity_keypair.pubkey(); assert_eq!(&id, node.info.pubkey()); @@ -489,10 +510,9 @@ impl Validator { } } - let mut bank_notification_senders = Vec::new(); - let exit = Arc::new(AtomicBool::new(false)); + let mut bank_notification_senders = Vec::new(); let geyser_plugin_service = if let Some(geyser_plugin_config_files) = &config.on_start_geyser_plugin_config_files { let (confirmed_bank_sender, confirmed_bank_receiver) = unbounded(); @@ -838,8 +858,8 @@ impl Validator { None }; - let mut block_commitment_cache = BlockCommitmentCache::default(); let bank_forks_guard = bank_forks.read().unwrap(); + let mut block_commitment_cache = BlockCommitmentCache::default(); block_commitment_cache.initialize_slots( bank_forks_guard.working_bank().slot(), bank_forks_guard.root(), @@ -862,6 +882,17 @@ impl Validator { None, )); + if let Some((runtime_plugin_configs, request_rx)) = runtime_plugin_configs_and_request_rx { + RuntimePluginService::start( + &runtime_plugin_configs, + request_rx, + bank_forks.clone(), + block_commitment_cache.clone(), + exit.clone(), + ) + .map_err(|e| format!("Failed to start runtime plugin service: {e:?}"))?; + } + let max_slots = Arc::new(MaxSlots::default()); let (completed_data_sets_sender, completed_data_sets_receiver) = bounded(MAX_COMPLETED_DATA_SETS_IN_CHANNEL); @@ -1062,6 +1093,9 @@ impl Validator { cluster_info: cluster_info.clone(), vote_account: *vote_account, repair_whitelist: config.repair_whitelist.clone(), + block_engine_config: config.block_engine_config.clone(), + relayer_config: config.relayer_config.clone(), + shred_receiver_address: config.shred_receiver_address.clone(), }); let waited_for_supermajority = match wait_for_supermajority( @@ -1180,6 +1214,7 @@ impl Validator { &connection_cache, &prioritization_fee_cache, banking_tracer.clone(), + config.shred_receiver_address.clone(), )?; let tpu = Tpu::new( @@ -1215,12 +1250,17 @@ impl Validator { &identity_keypair, config.runtime_config.log_messages_bytes_limit, &staked_nodes, + config.block_engine_config.clone(), + config.relayer_config.clone(), + config.tip_manager_config.clone(), + config.shred_receiver_address.clone(), config.staked_nodes_overrides.clone(), banking_tracer, tracer_thread, tpu_enable_udp, &prioritization_fee_cache, config.generator_config.clone(), + config.preallocated_bundle_cost, ); datapoint_info!( @@ -1667,6 +1707,7 @@ fn load_blockstore( .map(|service| service.sender()), accounts_update_notifier, exit, + true, ); // Before replay starts, set the callbacks in each of the banks in BankForks so that @@ -2353,6 +2394,7 @@ mod tests { DEFAULT_TPU_CONNECTION_POOL_SIZE, DEFAULT_TPU_ENABLE_UDP, Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); assert_eq!( @@ -2444,7 +2486,7 @@ mod tests { Arc::new(RwLock::new(vec![Arc::new(vote_account_keypair)])), vec![LegacyContactInfo::try_from(&leader_node.info).unwrap()], &config, - true, // should_check_duplicate_instance. + true, // should_check_duplicate_instance None, // rpc_to_plugin_manager_receiver Arc::new(RwLock::new(ValidatorStartProgress::default())), SocketAddrSpace::Unspecified, @@ -2452,6 +2494,7 @@ mod tests { DEFAULT_TPU_CONNECTION_POOL_SIZE, DEFAULT_TPU_ENABLE_UDP, Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start") }) diff --git a/core/tests/epoch_accounts_hash.rs b/core/tests/epoch_accounts_hash.rs index bc9638a16c..72173a859a 100755 --- a/core/tests/epoch_accounts_hash.rs +++ b/core/tests/epoch_accounts_hash.rs @@ -428,6 +428,7 @@ fn test_snapshots_have_expected_epoch_accounts_hash() { if let Some(full_snapshot_archive_info) = snapshot_utils::get_highest_full_snapshot_archive_info( &snapshot_config.full_snapshot_archives_dir, + None, ) { if full_snapshot_archive_info.slot() == bank.slot() { @@ -545,6 +546,7 @@ fn test_background_services_request_handling_for_epoch_accounts_hash() { info!("Taking full snapshot..."); while snapshot_utils::get_highest_full_snapshot_archive_slot( &snapshot_config.full_snapshot_archives_dir, + None, ) != Some(bank.slot()) { trace!("waiting for full snapshot..."); diff --git a/core/tests/snapshots.rs b/core/tests/snapshots.rs index 10755d1335..0d3fe89d21 100644 --- a/core/tests/snapshots.rs +++ b/core/tests/snapshots.rs @@ -540,6 +540,7 @@ fn test_concurrent_snapshot_packaging( // Wait until the package has been archived by SnapshotPackagerService while snapshot_utils::get_highest_full_snapshot_archive_slot( &full_snapshot_archives_dir, + None, ) .is_none() { @@ -898,6 +899,7 @@ fn restore_from_snapshots_and_check_banks_are_equal( Some(ACCOUNTS_DB_CONFIG_FOR_TESTING), None, &Arc::default(), + None, )?; deserialized_bank.wait_for_initial_accounts_hash_verification_completed_for_tests(); @@ -1063,6 +1065,7 @@ fn test_snapshots_with_background_services( &snapshot_test_config .snapshot_config .full_snapshot_archives_dir, + None, ) != Some(slot) { assert!( @@ -1081,6 +1084,7 @@ fn test_snapshots_with_background_services( .snapshot_config .incremental_snapshot_archives_dir, last_full_snapshot_slot.unwrap(), + None, ) != Some(slot) { assert!( @@ -1118,6 +1122,7 @@ fn test_snapshots_with_background_services( Some(ACCOUNTS_DB_CONFIG_FOR_TESTING), None, &exit, + None, ) .unwrap(); deserialized_bank.wait_for_initial_accounts_hash_verification_completed_for_tests(); diff --git a/deploy_programs b/deploy_programs new file mode 100755 index 0000000000..cbdf837e92 --- /dev/null +++ b/deploy_programs @@ -0,0 +1,17 @@ +#!/usr/bin/env sh +# Deploys the tip payment and tip distribution programs on local validator at predetermined address +set -eux + +WALLET_LOCATION=~/.config/solana/id.json + +# build this solana binary to ensure we're using a version compatible with the validator +cargo b --release --bin solana + +./target/release/solana airdrop -ul 1000 $WALLET_LOCATION + +(cd jito-programs/tip-payment && anchor build) + +# NOTE: make sure the declare_id! is set correctly in the programs +# Also, || true to make sure if fails the first time around, tip_payment can still be deployed +RUST_INFO=trace ./target/release/solana deploy --keypair $WALLET_LOCATION -ul ./jito-programs/tip-payment/target/deploy/tip_distribution.so ./jito-programs/tip-payment/dev/dev_tip_distribution.json || true +RUST_INFO=trace ./target/release/solana deploy --keypair $WALLET_LOCATION -ul ./jito-programs/tip-payment/target/deploy/tip_payment.so ./jito-programs/tip-payment/dev/dev_tip_payment.json diff --git a/dev/Dockerfile b/dev/Dockerfile new file mode 100644 index 0000000000..bab9a1c02f --- /dev/null +++ b/dev/Dockerfile @@ -0,0 +1,48 @@ +FROM rust:1.64-slim-bullseye as builder + +# Add Google Protocol Buffers for Libra's metrics library. +ENV PROTOC_VERSION 3.8.0 +ENV PROTOC_ZIP protoc-$PROTOC_VERSION-linux-x86_64.zip + +RUN set -x \ + && apt update \ + && apt install -y \ + clang \ + cmake \ + libudev-dev \ + make \ + unzip \ + libssl-dev \ + pkg-config \ + zlib1g-dev \ + curl \ + && rustup component add rustfmt \ + && rustup component add clippy \ + && rustc --version \ + && cargo --version \ + && curl -OL https://github.com/google/protobuf/releases/download/v$PROTOC_VERSION/$PROTOC_ZIP \ + && unzip -o $PROTOC_ZIP -d /usr/local bin/protoc \ + && unzip -o $PROTOC_ZIP -d /usr/local include/* \ + && rm -f $PROTOC_ZIP + + +WORKDIR /solana +COPY . . +RUN mkdir -p docker-output + +ARG ci_commit +# NOTE: Keep this here before build since variable is referenced during CI build step. +ENV CI_COMMIT=$ci_commit + +ARG debug + +# Uses docker buildkit to cache the image. +# /usr/local/cargo/git needed for crossbeam patch +RUN --mount=type=cache,mode=0777,target=/solana/target \ + --mount=type=cache,mode=0777,target=/usr/local/cargo/registry \ + --mount=type=cache,mode=0777,target=/usr/local/cargo/git \ + if [ "$debug" = "false" ] ; then \ + ./cargo stable build --release && cp target/release/solana* ./docker-output; \ + else \ + RUSTFLAGS='-g -C force-frame-pointers=yes' ./cargo stable build --release && cp target/release/solana* ./docker-output; \ + fi diff --git a/entry/src/entry.rs b/entry/src/entry.rs index 0551abe02a..71bdbbaca0 100644 --- a/entry/src/entry.rs +++ b/entry/src/entry.rs @@ -232,7 +232,7 @@ pub fn hash_transactions(transactions: &[VersionedTransaction]) -> Hash { .iter() .flat_map(|tx| tx.signatures.iter()) .collect(); - let merkle_tree = MerkleTree::new(&signatures); + let merkle_tree = MerkleTree::new(&signatures, false); if let Some(root_hash) = merkle_tree.get_root() { *root_hash } else { diff --git a/entry/src/poh.rs b/entry/src/poh.rs index 573b1ab606..00c21c7586 100644 --- a/entry/src/poh.rs +++ b/entry/src/poh.rs @@ -72,19 +72,30 @@ impl Poh { } pub fn record(&mut self, mixin: Hash) -> Option { - if self.remaining_hashes == 1 { + let entries = self.record_bundle(&[mixin]); + entries.unwrap_or_default().pop() + } + + pub fn record_bundle(&mut self, mixins: &[Hash]) -> Option> { + if self.remaining_hashes <= mixins.len() as u64 { return None; // Caller needs to `tick()` first } - self.hash = hashv(&[self.hash.as_ref(), mixin.as_ref()]); - let num_hashes = self.num_hashes + 1; - self.num_hashes = 0; - self.remaining_hashes -= 1; + let entries = mixins + .iter() + .map(|m| { + self.hash = hashv(&[self.hash.as_ref(), m.as_ref()]); + let num_hashes = self.num_hashes + 1; + self.num_hashes = 0; + self.remaining_hashes -= 1; + PohEntry { + num_hashes, + hash: self.hash, + } + }) + .collect(); - Some(PohEntry { - num_hashes, - hash: self.hash, - }) + Some(entries) } pub fn tick(&mut self) -> Option { diff --git a/f b/f new file mode 100755 index 0000000000..e5fe635508 --- /dev/null +++ b/f @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Builds jito-solana in a docker container. +# Useful for running on machines that might not have cargo installed but can run docker (Flatcar Linux). +# run `./f true` to compile with debug flags + +set -eux + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" + +GIT_SHA="$(git rev-parse --short HEAD)" + +echo "Git hash: $GIT_SHA" + +DEBUG_FLAGS=${1-false} + +DOCKER_BUILDKIT=1 docker build \ + --build-arg debug=$DEBUG_FLAGS \ + --build-arg ci_commit=$GIT_SHA \ + -t jitolabs/build-solana \ + -f dev/Dockerfile . \ + --progress=plain + +# Creates a temporary container, copies solana-validator built inside container there and +# removes the temporary container. +docker rm temp || true +docker container create --name temp jitolabs/build-solana +mkdir -p $SCRIPT_DIR/docker-output +# Outputs the solana-validator binary to $SOLANA/docker-output/solana-validator +docker container cp temp:/solana/docker-output $SCRIPT_DIR/ +docker rm temp diff --git a/fetch-spl.sh b/fetch-spl.sh index bb8e84ebb2..8fe111a60d 100755 --- a/fetch-spl.sh +++ b/fetch-spl.sh @@ -13,8 +13,24 @@ fetch_program() { declare version=$2 declare address=$3 declare loader=$4 + declare repo=$5 - declare so=spl_$name-$version.so + case $repo in + "jito") + so=$name-$version.so + so_name="$name.so" + url="https://github.com/jito-foundation/jito-programs/releases/download/v$version/$so_name" + ;; + "solana") + so=spl_$name-$version.so + so_name="spl_${name//-/_}.so" + url="https://github.com/solana-labs/solana-program-library/releases/download/$name-v$version/$so_name" + ;; + *) + echo "Unsupported repo: $repo" + return 1 + ;; + esac if [[ $loader == "$upgradeableLoader" ]]; then genesis_args+=(--upgradeable-program "$address" "$loader" "$so" none) @@ -30,12 +46,11 @@ fetch_program() { cp ~/.cache/solana-spl/"$so" "$so" else echo "Downloading $name $version" - so_name="spl_${name//-/_}.so" ( set -x curl -L --retry 5 --retry-delay 2 --retry-connrefused \ -o "$so" \ - "https://github.com/solana-labs/solana-program-library/releases/download/$name-v$version/$so_name" + "$url" ) mkdir -p ~/.cache/solana-spl @@ -44,19 +59,25 @@ fetch_program() { } -fetch_program token 3.5.0 TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA BPFLoader2111111111111111111111111111111111 -fetch_program token-2022 0.9.0 TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb BPFLoaderUpgradeab1e11111111111111111111111 -fetch_program memo 1.0.0 Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo BPFLoader1111111111111111111111111111111111 -fetch_program memo 3.0.0 MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr BPFLoader2111111111111111111111111111111111 -fetch_program associated-token-account 1.1.2 ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL BPFLoader2111111111111111111111111111111111 -fetch_program feature-proposal 1.0.0 Feat1YXHhH6t1juaWF74WLcfv4XoNocjXA6sPWHNgAse BPFLoader2111111111111111111111111111111111 +fetch_program token 3.5.0 TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA BPFLoader2111111111111111111111111111111111 solana +fetch_program token-2022 0.9.0 TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb BPFLoaderUpgradeab1e11111111111111111111111 solana +fetch_program memo 1.0.0 Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo BPFLoader1111111111111111111111111111111111 solana +fetch_program memo 3.0.0 MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr BPFLoader2111111111111111111111111111111111 solana +fetch_program associated-token-account 1.1.2 ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL BPFLoader2111111111111111111111111111111111 solana +fetch_program feature-proposal 1.0.0 Feat1YXHhH6t1juaWF74WLcfv4XoNocjXA6sPWHNgAse BPFLoader2111111111111111111111111111111111 solana +# jito programs +fetch_program jito_tip_payment 0.1.3 T1pyyaTNZsKv2WcRAB8oVnk93mLJw2XzjtVYqCsaHqt BPFLoaderUpgradeab1e11111111111111111111111 jito +fetch_program jito_tip_distribution 0.1.3 4R3gSG8BpU4t19KYj8CfnbtRpnT8gtk4dvTHxVRwc2r7 BPFLoaderUpgradeab1e11111111111111111111111 jito -echo "${genesis_args[@]}" > spl-genesis-args.sh +echo "${genesis_args[@]}" >spl-genesis-args.sh echo echo "Available SPL programs:" ls -l spl_*.so +echo "Available Jito programs:" +ls -l jito*.so + echo echo "solana-genesis command-line arguments (spl-genesis-args.sh):" cat spl-genesis-args.sh diff --git a/frozen-abi/macro/src/lib.rs b/frozen-abi/macro/src/lib.rs index 8a1358b391..0c37eeb149 100644 --- a/frozen-abi/macro/src/lib.rs +++ b/frozen-abi/macro/src/lib.rs @@ -425,7 +425,7 @@ pub fn frozen_abi(attrs: TokenStream, item: TokenStream) -> TokenStream { "the required \"digest\" = ... attribute is missing.", ) .to_compile_error() - .into() + .into(); }; let item = parse_macro_input!(item as Item); diff --git a/geyser-plugin-manager/src/geyser_plugin_manager.rs b/geyser-plugin-manager/src/geyser_plugin_manager.rs index 1e39d3df72..41f7e3eea8 100644 --- a/geyser-plugin-manager/src/geyser_plugin_manager.rs +++ b/geyser-plugin-manager/src/geyser_plugin_manager.rs @@ -127,15 +127,17 @@ impl GeyserPluginManager { pub(crate) fn unload_plugin(&mut self, name: &str) -> JsonRpcResult<()> { // Check if any plugin names match this one - let Some(idx) = self.plugins.iter().position(|plugin| plugin.name().eq(name)) else { + let Some(idx) = self + .plugins + .iter() + .position(|plugin| plugin.name().eq(name)) + else { // If we don't find one return an error - return Err( - jsonrpc_core::error::Error { - code: ErrorCode::InvalidRequest, - message: String::from("The plugin you requested to unload is not loaded"), - data: None, - } - ) + return Err(jsonrpc_core::error::Error { + code: ErrorCode::InvalidRequest, + message: String::from("The plugin you requested to unload is not loaded"), + data: None, + }); }; // Unload and drop plugin and lib @@ -149,15 +151,17 @@ impl GeyserPluginManager { /// Then, attempt to load a new plugin pub(crate) fn reload_plugin(&mut self, name: &str, config_file: &str) -> JsonRpcResult<()> { // Check if any plugin names match this one - let Some(idx) = self.plugins.iter().position(|plugin| plugin.name().eq(name)) else { + let Some(idx) = self + .plugins + .iter() + .position(|plugin| plugin.name().eq(name)) + else { // If we don't find one return an error - return Err( - jsonrpc_core::error::Error { - code: ErrorCode::InvalidRequest, - message: String::from("The plugin you requested to reload is not loaded"), - data: None, - } - ) + return Err(jsonrpc_core::error::Error { + code: ErrorCode::InvalidRequest, + message: String::from("The plugin you requested to reload is not loaded"), + data: None, + }); }; // Unload and drop current plugin first in case plugin requires exclusive access to resource, diff --git a/gossip/src/cluster_info.rs b/gossip/src/cluster_info.rs index 117d2092ab..443a18953a 100644 --- a/gossip/src/cluster_info.rs +++ b/gossip/src/cluster_info.rs @@ -542,6 +542,10 @@ impl ClusterInfo { *self.entrypoints.write().unwrap() = entrypoints; } + pub fn set_my_contact_info(&self, my_contact_info: ContactInfo) { + *self.my_contact_info.write().unwrap() = my_contact_info; + } + pub fn save_contact_info(&self) { let nodes = { let entrypoint_gossip_addrs = self diff --git a/jito-programs b/jito-programs new file mode 160000 index 0000000000..180be58caf --- /dev/null +++ b/jito-programs @@ -0,0 +1 @@ +Subproject commit 180be58cafcbbe3dac66bbe6428579d58d58f683 diff --git a/jito-protos/Cargo.toml b/jito-protos/Cargo.toml new file mode 100644 index 0000000000..008767e3ba --- /dev/null +++ b/jito-protos/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "jito-protos" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } + +[dependencies] +bytes = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } +tonic = { workspace = true } + +[build-dependencies] +tonic-build = { workspace = true } + +# windows users should install the protobuf compiler manually and set the PROTOC +# envar to point to the installed binary +[target."cfg(not(windows))".build-dependencies] +protobuf-src = { workspace = true } diff --git a/jito-protos/build.rs b/jito-protos/build.rs new file mode 100644 index 0000000000..37f5a261a4 --- /dev/null +++ b/jito-protos/build.rs @@ -0,0 +1,38 @@ +use tonic_build::configure; + +fn main() -> Result<(), std::io::Error> { + const PROTOC_ENVAR: &str = "PROTOC"; + if std::env::var(PROTOC_ENVAR).is_err() { + #[cfg(not(windows))] + std::env::set_var(PROTOC_ENVAR, protobuf_src::protoc()); + } + + let proto_base_path = std::path::PathBuf::from("protos"); + let proto_files = [ + "auth.proto", + "block_engine.proto", + "bundle.proto", + "packet.proto", + "relayer.proto", + "shared.proto", + ]; + let mut protos = Vec::new(); + for proto_file in &proto_files { + let proto = proto_base_path.join(proto_file); + println!("cargo::rerun-if-changed={}", proto.display()); + protos.push(proto); + } + + configure() + .build_client(true) + .build_server(false) + .type_attribute( + "TransactionErrorType", + "#[cfg_attr(test, derive(enum_iterator::Sequence))]", + ) + .type_attribute( + "InstructionErrorType", + "#[cfg_attr(test, derive(enum_iterator::Sequence))]", + ) + .compile(&protos, &[proto_base_path]) +} diff --git a/jito-protos/protos b/jito-protos/protos new file mode 160000 index 0000000000..05d210980f --- /dev/null +++ b/jito-protos/protos @@ -0,0 +1 @@ +Subproject commit 05d210980f34a7c974d7ed1a4dbcb2ce1fca00b3 diff --git a/jito-protos/src/lib.rs b/jito-protos/src/lib.rs new file mode 100644 index 0000000000..cf630c53d2 --- /dev/null +++ b/jito-protos/src/lib.rs @@ -0,0 +1,25 @@ +pub mod proto { + pub mod auth { + tonic::include_proto!("auth"); + } + + pub mod block_engine { + tonic::include_proto!("block_engine"); + } + + pub mod bundle { + tonic::include_proto!("bundle"); + } + + pub mod packet { + tonic::include_proto!("packet"); + } + + pub mod relayer { + tonic::include_proto!("relayer"); + } + + pub mod shared { + tonic::include_proto!("shared"); + } +} diff --git a/ledger-tool/src/ledger_utils.rs b/ledger-tool/src/ledger_utils.rs index 6e12f300cb..8d0c484948 100644 --- a/ledger-tool/src/ledger_utils.rs +++ b/ledger-tool/src/ledger_utils.rs @@ -74,6 +74,7 @@ pub fn load_and_process_ledger( process_options: ProcessOptions, snapshot_archive_path: Option, incremental_snapshot_archive_path: Option, + ignore_halt_at_slot_for_snapshot_loading: bool, ) -> Result<(Arc>, Option), BlockstoreProcessorError> { let bank_snapshots_dir = blockstore .ledger_path() @@ -83,6 +84,12 @@ pub fn load_and_process_ledger( "snapshot.ledger-tool" }); + let snapshot_halt_at_slot = if ignore_halt_at_slot_for_snapshot_loading { + None + } else { + process_options.halt_at_slot + }; + let mut starting_slot = 0; // default start check with genesis let snapshot_config = if arg_matches.is_present("no_snapshot") { None @@ -91,13 +98,15 @@ pub fn load_and_process_ledger( snapshot_archive_path.unwrap_or_else(|| blockstore.ledger_path().to_path_buf()); let incremental_snapshot_archives_dir = incremental_snapshot_archive_path.unwrap_or_else(|| full_snapshot_archives_dir.clone()); - if let Some(full_snapshot_slot) = - snapshot_utils::get_highest_full_snapshot_archive_slot(&full_snapshot_archives_dir) - { + if let Some(full_snapshot_slot) = snapshot_utils::get_highest_full_snapshot_archive_slot( + &full_snapshot_archives_dir, + snapshot_halt_at_slot, + ) { let incremental_snapshot_slot = snapshot_utils::get_highest_incremental_snapshot_archive_slot( &incremental_snapshot_archives_dir, full_snapshot_slot, + snapshot_halt_at_slot, ) .unwrap_or_default(); starting_slot = std::cmp::max(full_snapshot_slot, incremental_snapshot_slot); @@ -242,6 +251,7 @@ pub fn load_and_process_ledger( None, // Maybe support this later, though accounts_update_notifier, &Arc::default(), + ignore_halt_at_slot_for_snapshot_loading, ); let block_verification_method = value_t!( arg_matches, diff --git a/ledger-tool/src/main.rs b/ledger-tool/src/main.rs index 2fce8c9ac8..6bd0446eac 100644 --- a/ledger-tool/src/main.rs +++ b/ledger-tool/src/main.rs @@ -990,10 +990,11 @@ fn get_latest_optimistic_slots( let Some(latest_slot) = blockstore .get_latest_optimistic_slots(1) .expect("get_latest_optimistic_slots() failed") - .pop() else { - eprintln!("Blockstore does not contain any optimistically confirmed slots"); - return vec![]; - }; + .pop() + else { + eprintln!("Blockstore does not contain any optimistically confirmed slots"); + return vec![]; + }; let latest_slot = latest_slot.0; let slot_iter = AncestorIterator::new_inclusive(latest_slot, blockstore).map(|slot| { @@ -2296,6 +2297,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) { Ok((bank_forks, ..)) => { println!( @@ -2388,6 +2390,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) { Ok((bank_forks, ..)) => { println!("{}", &bank_forks.read().unwrap().working_bank().hash()); @@ -2627,6 +2630,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) .unwrap_or_else(|err| { eprintln!("Ledger verification failed: {err:?}"); @@ -2679,6 +2683,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) { Ok((bank_forks, ..)) => { let dot = graph_forks(&bank_forks.read().unwrap(), &graph_config); @@ -2813,6 +2818,21 @@ fn main() { exit(1); } + if let Ok(metas) = blockstore.slot_meta_iterator(0) { + let slots: Vec<_> = metas.map(|(slot, _)| slot).collect(); + if slots.is_empty() { + eprintln!("Ledger is empty, can't create snapshot"); + exit(1); + } else { + let first = slots.first().unwrap(); + let last = slots.last().unwrap_or(first); + if first > &snapshot_slot || &snapshot_slot > last { + eprintln!("Slot {} is out of bounds of ledger [{}, {}], cannot create snapshot", &snapshot_slot, first, last); + exit(1); + } + } + } + let ending_slot = if is_minimized { let ending_slot = value_t_or_exit!(arg_matches, "ending_slot", Slot); if ending_slot <= snapshot_slot { @@ -2856,6 +2876,7 @@ fn main() { }, snapshot_archive_path, incremental_snapshot_archive_path, + false, // want to load snapshots <= halt_at_slot ) { Ok((bank_forks, starting_snapshot_hashes)) => { let mut bank = bank_forks @@ -3220,6 +3241,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) .unwrap_or_else(|err| { eprintln!("Failed to load ledger: {err:?}"); @@ -3309,6 +3331,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) { Ok((bank_forks, ..)) => { let bank_forks = bank_forks.read().unwrap(); diff --git a/ledger-tool/src/program.rs b/ledger-tool/src/program.rs index 359ca60aae..6329799130 100644 --- a/ledger-tool/src/program.rs +++ b/ledger-tool/src/program.rs @@ -124,6 +124,7 @@ fn load_blockstore(ledger_path: &Path, arg_matches: &ArgMatches<'_>) -> Arc, accounts_update_notifier: Option, exit: &Arc, + ignore_halt_at_slot_for_snapshot_loading: bool, ) -> ( Arc>, LeaderScheduleCache, @@ -105,8 +107,15 @@ pub fn load_bank_forks( fs::create_dir_all(&snapshot_config.bank_snapshots_dir) .expect("Couldn't create snapshot directory"); + let halt_at_slot = if ignore_halt_at_slot_for_snapshot_loading { + None + } else { + process_options.halt_at_slot + }; + if snapshot_utils::get_highest_full_snapshot_archive_info( &snapshot_config.full_snapshot_archives_dir, + halt_at_slot, ) .is_some() { @@ -124,12 +133,19 @@ pub fn load_bank_forks( }; let (bank_forks, starting_snapshot_hashes) = if snapshot_present { + let mut process_options = process_options.clone(); + process_options.halt_at_slot = if ignore_halt_at_slot_for_snapshot_loading { + None + } else { + process_options.halt_at_slot + }; + bank_forks_from_snapshot( genesis_config, account_paths, shrink_paths, snapshot_config.as_ref().unwrap(), - process_options, + &process_options, accounts_update_notifier, exit, ) @@ -189,7 +205,7 @@ pub fn load_bank_forks( } #[allow(clippy::too_many_arguments)] -fn bank_forks_from_snapshot( +pub fn bank_forks_from_snapshot( genesis_config: &GenesisConfig, account_paths: Vec, shrink_paths: Option>, @@ -230,6 +246,7 @@ fn bank_forks_from_snapshot( process_options.accounts_db_config.clone(), accounts_update_notifier, exit, + process_options.halt_at_slot, ) .unwrap_or_else(|err| { error!("Failed to load bank from snapshot archives: {err}"); diff --git a/ledger/src/blockstore_processor.rs b/ledger/src/blockstore_processor.rs index 42588b3a9d..6875a9ae64 100644 --- a/ledger/src/blockstore_processor.rs +++ b/ledger/src/blockstore_processor.rs @@ -150,7 +150,7 @@ fn execute_batch( let mut mint_decimals: HashMap = HashMap::new(); let pre_token_balances = if record_token_balances { - collect_token_balances(bank, batch, &mut mint_decimals) + collect_token_balances(bank, batch, &mut mint_decimals, None) } else { vec![] }; @@ -188,7 +188,7 @@ fn execute_batch( if let Some(transaction_status_sender) = transaction_status_sender { let transactions = batch.sanitized_transactions().to_vec(); let post_token_balances = if record_token_balances { - collect_token_balances(bank, batch, &mut mint_decimals) + collect_token_balances(bank, batch, &mut mint_decimals, None) } else { vec![] }; @@ -684,6 +684,7 @@ pub fn test_process_blockstore( None, None, exit, + true, ); process_blockstore_from_root( diff --git a/ledger/src/token_balances.rs b/ledger/src/token_balances.rs index 5dc368b103..c2b7a04840 100644 --- a/ledger/src/token_balances.rs +++ b/ledger/src/token_balances.rs @@ -4,7 +4,9 @@ use { }, solana_measure::measure::Measure, solana_metrics::datapoint_debug, - solana_runtime::{bank::Bank, transaction_batch::TransactionBatch}, + solana_runtime::{ + account_overrides::AccountOverrides, bank::Bank, transaction_batch::TransactionBatch, + }, solana_sdk::{account::ReadableAccount, pubkey::Pubkey}, solana_transaction_status::{ token_balances::TransactionTokenBalances, TransactionTokenBalance, @@ -38,6 +40,7 @@ pub fn collect_token_balances( bank: &Bank, batch: &TransactionBatch, mint_decimals: &mut HashMap, + cached_accounts: Option<&AccountOverrides>, ) -> TransactionTokenBalances { let mut balances: TransactionTokenBalances = vec![]; let mut collect_time = Measure::start("collect_token_balances"); @@ -58,8 +61,12 @@ pub fn collect_token_balances( ui_token_amount, owner, program_id, - }) = collect_token_balance_from_account(bank, account_id, mint_decimals) - { + }) = collect_token_balance_from_account( + bank, + account_id, + mint_decimals, + cached_accounts, + ) { transaction_balances.push(TransactionTokenBalance { account_index: index as u8, mint, @@ -92,8 +99,17 @@ fn collect_token_balance_from_account( bank: &Bank, account_id: &Pubkey, mint_decimals: &mut HashMap, + account_overrides: Option<&AccountOverrides>, ) -> Option { - let account = bank.get_account(account_id)?; + let account = { + if let Some(account_override) = + account_overrides.and_then(|overrides| overrides.get(account_id)) + { + Some(account_override.clone()) + } else { + bank.get_account(account_id) + } + }?; if !is_known_spl_token_id(account.owner()) { return None; @@ -235,13 +251,13 @@ mod test { // Account is not owned by spl_token (nor does it have TokenAccount state) assert_eq!( - collect_token_balance_from_account(&bank, &account_pubkey, &mut mint_decimals), + collect_token_balance_from_account(&bank, &account_pubkey, &mut mint_decimals, None), None ); // Mint does not have TokenAccount state assert_eq!( - collect_token_balance_from_account(&bank, &mint_pubkey, &mut mint_decimals), + collect_token_balance_from_account(&bank, &mint_pubkey, &mut mint_decimals, None), None ); @@ -250,7 +266,8 @@ mod test { collect_token_balance_from_account( &bank, &spl_token_account_pubkey, - &mut mint_decimals + &mut mint_decimals, + None ), Some(TokenBalanceData { mint: mint_pubkey.to_string(), @@ -267,7 +284,12 @@ mod test { // TokenAccount is not owned by known spl-token program_id assert_eq!( - collect_token_balance_from_account(&bank, &other_account_pubkey, &mut mint_decimals), + collect_token_balance_from_account( + &bank, + &other_account_pubkey, + &mut mint_decimals, + None + ), None ); @@ -276,7 +298,8 @@ mod test { collect_token_balance_from_account( &bank, &other_mint_account_pubkey, - &mut mint_decimals + &mut mint_decimals, + None ), None ); @@ -429,13 +452,13 @@ mod test { // Account is not owned by spl_token (nor does it have TokenAccount state) assert_eq!( - collect_token_balance_from_account(&bank, &account_pubkey, &mut mint_decimals), + collect_token_balance_from_account(&bank, &account_pubkey, &mut mint_decimals, None), None ); // Mint does not have TokenAccount state assert_eq!( - collect_token_balance_from_account(&bank, &mint_pubkey, &mut mint_decimals), + collect_token_balance_from_account(&bank, &mint_pubkey, &mut mint_decimals, None), None ); @@ -444,7 +467,8 @@ mod test { collect_token_balance_from_account( &bank, &spl_token_account_pubkey, - &mut mint_decimals + &mut mint_decimals, + None ), Some(TokenBalanceData { mint: mint_pubkey.to_string(), @@ -461,7 +485,12 @@ mod test { // TokenAccount is not owned by known spl-token program_id assert_eq!( - collect_token_balance_from_account(&bank, &other_account_pubkey, &mut mint_decimals), + collect_token_balance_from_account( + &bank, + &other_account_pubkey, + &mut mint_decimals, + None + ), None ); @@ -470,7 +499,8 @@ mod test { collect_token_balance_from_account( &bank, &other_mint_account_pubkey, - &mut mint_decimals + &mut mint_decimals, + None ), None ); diff --git a/local-cluster/src/local_cluster.rs b/local-cluster/src/local_cluster.rs index ccf621c05c..08ccf08ce5 100644 --- a/local-cluster/src/local_cluster.rs +++ b/local-cluster/src/local_cluster.rs @@ -279,6 +279,7 @@ impl LocalCluster { DEFAULT_TPU_CONNECTION_POOL_SIZE, DEFAULT_TPU_ENABLE_UDP, Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); @@ -494,6 +495,7 @@ impl LocalCluster { DEFAULT_TPU_CONNECTION_POOL_SIZE, DEFAULT_TPU_ENABLE_UDP, Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); @@ -887,6 +889,7 @@ impl Cluster for LocalCluster { DEFAULT_TPU_CONNECTION_POOL_SIZE, DEFAULT_TPU_ENABLE_UDP, Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); cluster_validator_info.validator = Some(restarted_node); diff --git a/local-cluster/src/local_cluster_snapshot_utils.rs b/local-cluster/src/local_cluster_snapshot_utils.rs index 259b9e1559..2ebd90e86e 100644 --- a/local-cluster/src/local_cluster_snapshot_utils.rs +++ b/local-cluster/src/local_cluster_snapshot_utils.rs @@ -90,7 +90,10 @@ impl LocalCluster { let timer = Instant::now(); let next_snapshot = loop { if let Some(full_snapshot_archive_info) = - snapshot_utils::get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir) + snapshot_utils::get_highest_full_snapshot_archive_info( + &full_snapshot_archives_dir, + None, + ) { match next_snapshot_type { NextSnapshotType::FullSnapshot => { @@ -103,6 +106,7 @@ impl LocalCluster { snapshot_utils::get_highest_incremental_snapshot_archive_info( incremental_snapshot_archives_dir.as_ref().unwrap(), full_snapshot_archive_info.slot(), + None, ) { if incremental_snapshot_archive_info.slot() >= last_slot { diff --git a/local-cluster/src/validator_configs.rs b/local-cluster/src/validator_configs.rs index d81bcb8b6b..e840795311 100644 --- a/local-cluster/src/validator_configs.rs +++ b/local-cluster/src/validator_configs.rs @@ -68,6 +68,11 @@ pub fn safe_clone_config(config: &ValidatorConfig) -> ValidatorConfig { block_verification_method: config.block_verification_method.clone(), block_production_method: config.block_production_method.clone(), generator_config: config.generator_config.clone(), + relayer_config: config.relayer_config.clone(), + block_engine_config: config.block_engine_config.clone(), + shred_receiver_address: config.shred_receiver_address.clone(), + tip_manager_config: config.tip_manager_config.clone(), + preallocated_bundle_cost: config.preallocated_bundle_cost, } } diff --git a/local-cluster/tests/local_cluster.rs b/local-cluster/tests/local_cluster.rs index 0c1b073807..8bbbc1209f 100644 --- a/local-cluster/tests/local_cluster.rs +++ b/local-cluster/tests/local_cluster.rs @@ -855,6 +855,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st validator_snapshot_test_config .full_snapshot_archives_dir .path(), + None, ) .unwrap(); info!( @@ -894,6 +895,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st .incremental_snapshot_archives_dir .path(), full_snapshot_archive.slot(), + None, ) .unwrap(); info!( @@ -1036,6 +1038,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st validator_snapshot_test_config .full_snapshot_archives_dir .path(), + None, ) .unwrap(); @@ -1102,6 +1105,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st validator_snapshot_test_config .full_snapshot_archives_dir .path(), + None, ) .unwrap(); @@ -1138,6 +1142,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st validator_snapshot_test_config .full_snapshot_archives_dir .path(), + None, ) { if full_snapshot_slot >= validator_next_full_snapshot_slot { if let Some(incremental_snapshot_slot) = @@ -1146,6 +1151,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st .incremental_snapshot_archives_dir .path(), full_snapshot_slot, + None, ) { if incremental_snapshot_slot >= validator_next_incremental_snapshot_slot { @@ -1339,8 +1345,10 @@ fn test_snapshots_blockstore_floor() { trace!("Waiting for snapshot tar to be generated with slot",); let archive_info = loop { - let archive = - snapshot_utils::get_highest_full_snapshot_archive_info(full_snapshot_archives_dir); + let archive = snapshot_utils::get_highest_full_snapshot_archive_info( + full_snapshot_archives_dir, + None, + ); if archive.is_some() { trace!("snapshot exists"); break archive.unwrap(); diff --git a/merkle-tree/src/merkle_tree.rs b/merkle-tree/src/merkle_tree.rs index d08e111d4e..2ee984ec46 100644 --- a/merkle-tree/src/merkle_tree.rs +++ b/merkle-tree/src/merkle_tree.rs @@ -18,7 +18,7 @@ macro_rules! hash_intermediate { } } -#[derive(Debug)] +#[derive(Default, Debug, Eq, Hash, PartialEq)] pub struct MerkleTree { leaf_count: usize, nodes: Vec, @@ -36,6 +36,14 @@ impl<'a> ProofEntry<'a> { assert!(left_sibling.is_none() ^ right_sibling.is_none()); Self(target, left_sibling, right_sibling) } + + pub fn get_left_sibling(&self) -> Option<&'a Hash> { + self.1 + } + + pub fn get_right_sibling(&self) -> Option<&'a Hash> { + self.2 + } } #[derive(Debug, Default, PartialEq, Eq)] @@ -60,6 +68,10 @@ impl<'a> Proof<'a> { }); matches!(result, Some(_)) } + + pub fn get_proof_entries(self) -> Vec> { + self.0 + } } impl MerkleTree { @@ -95,7 +107,7 @@ impl MerkleTree { } } - pub fn new>(items: &[T]) -> Self { + pub fn new>(items: &[T], sorted_hashes: bool) -> Self { let cap = MerkleTree::calculate_vec_capacity(items.len()); let mut mt = MerkleTree { leaf_count: items.len(), @@ -123,8 +135,20 @@ impl MerkleTree { &mt.nodes[prev_level_start + prev_level_idx] }; - let hash = hash_intermediate!(lsib, rsib); - mt.nodes.push(hash); + // tip-distribution verification uses sorted hashing + if sorted_hashes { + if lsib <= rsib { + let hash = hash_intermediate!(lsib, rsib); + mt.nodes.push(hash); + } else { + let hash = hash_intermediate!(rsib, lsib); + mt.nodes.push(hash); + } + } else { + // hashing for solana internals + let hash = hash_intermediate!(lsib, rsib); + mt.nodes.push(hash); + } } prev_level_start = level_start; prev_level_len = level_len; @@ -189,21 +213,21 @@ mod tests { #[test] fn test_tree_from_empty() { - let mt = MerkleTree::new::<[u8; 0]>(&[]); + let mt = MerkleTree::new::<[u8; 0]>(&[], false); assert_eq!(mt.get_root(), None); } #[test] fn test_tree_from_one() { let input = b"test"; - let mt = MerkleTree::new(&[input]); + let mt = MerkleTree::new(&[input], false); let expected = hash_leaf!(input); assert_eq!(mt.get_root(), Some(&expected)); } #[test] fn test_tree_from_many() { - let mt = MerkleTree::new(TEST); + let mt = MerkleTree::new(TEST, false); // This golden hash will need to be updated whenever the contents of `TEST` change in any // way, including addition, removal and reordering or any of the tree calculation algo // changes @@ -215,7 +239,7 @@ mod tests { #[test] fn test_path_creation() { - let mt = MerkleTree::new(TEST); + let mt = MerkleTree::new(TEST, false); for (i, _s) in TEST.iter().enumerate() { let _path = mt.find_path(i).unwrap(); } @@ -223,13 +247,13 @@ mod tests { #[test] fn test_path_creation_bad_index() { - let mt = MerkleTree::new(TEST); + let mt = MerkleTree::new(TEST, false); assert_eq!(mt.find_path(TEST.len()), None); } #[test] fn test_path_verify_good() { - let mt = MerkleTree::new(TEST); + let mt = MerkleTree::new(TEST, false); for (i, s) in TEST.iter().enumerate() { let hash = hash_leaf!(s); let path = mt.find_path(i).unwrap(); @@ -239,7 +263,7 @@ mod tests { #[test] fn test_path_verify_bad() { - let mt = MerkleTree::new(TEST); + let mt = MerkleTree::new(TEST, false); for (i, s) in BAD.iter().enumerate() { let hash = hash_leaf!(s); let path = mt.find_path(i).unwrap(); diff --git a/multinode-demo/bootstrap-validator.sh b/multinode-demo/bootstrap-validator.sh index f69c05d1ed..983b5eb6be 100755 --- a/multinode-demo/bootstrap-validator.sh +++ b/multinode-demo/bootstrap-validator.sh @@ -103,9 +103,39 @@ while [[ -n $1 ]]; do elif [[ $1 == --skip-require-tower ]]; then maybeRequireTower=false shift + elif [[ $1 == --relayer-url ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --block-engine-url ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --tip-payment-program-pubkey ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --tip-distribution-program-pubkey ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --commission-bps ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --shred-receiver-address ]]; then + args+=("$1" "$2") + shift 2 elif [[ $1 = --log-messages-bytes-limit ]]; then args+=("$1" "$2") shift 2 + elif [[ $1 == --geyser-plugin-config ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --trust-relayer-packets ]]; then + args+=("$1") + shift + elif [[ $1 == --rpc-threads ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --trust-block-engine-packets ]]; then + args+=("$1") + shift else echo "Unknown argument: $1" $program --help @@ -141,6 +171,7 @@ args+=( --no-incremental-snapshots --identity "$identity" --vote-account "$vote_account" + --merkle-root-upload-authority "$identity" --rpc-faucet-address 127.0.0.1:9900 --no-poh-speed-test --no-os-network-limits-test @@ -150,6 +181,9 @@ args+=( ) default_arg --gossip-port 8001 default_arg --log - +default_arg --tip-payment-program-pubkey "DThZmRNNXh7kvTQW9hXeGoWGPKktK8pgVAyoTLjH7UrT" +default_arg --tip-distribution-program-pubkey "FjrdANjvo76aCYQ4kf9FM1R8aESUcEE6F8V7qyoVUQcM" +default_arg --commission-bps 0 pid= diff --git a/multinode-demo/validator.sh b/multinode-demo/validator.sh index 9090055b90..721008a661 100755 --- a/multinode-demo/validator.sh +++ b/multinode-demo/validator.sh @@ -85,6 +85,24 @@ while [[ -n $1 ]]; do vote_account=$2 args+=("$1" "$2") shift 2 + elif [[ $1 == --block-engine-url ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --relayer-url ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 = --merkle-root-upload-authority ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --tip-payment-program-pubkey ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --tip-distribution-program-pubkey ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --commission-bps ]]; then + args+=("$1" "$2") + shift 2 elif [[ $1 = --init-complete-file ]]; then args+=("$1" "$2") shift 2 @@ -182,6 +200,24 @@ while [[ -n $1 ]]; do elif [[ $1 == --skip-require-tower ]]; then maybeRequireTower=false shift + elif [[ $1 == --rpc-pubsub-enable-block-subscription ]]; then + args+=("$1") + shift + elif [[ $1 == --geyser-plugin-config ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --trust-relayer-packets ]]; then + args+=("$1") + shift + elif [[ $1 == --rpc-threads ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --shred-receiver-address ]]; then + args+=("$1" "$2") + shift 2 + elif [[ $1 == --trust-block-engine-packets ]]; then + args+=("$1") + shift elif [[ $1 = -h ]]; then usage "$@" else @@ -256,6 +292,10 @@ fi default_arg --identity "$identity" default_arg --vote-account "$vote_account" +default_arg --merkle-root-upload-authority "$identity" +default_arg --tip-payment-program-pubkey "DThZmRNNXh7kvTQW9hXeGoWGPKktK8pgVAyoTLjH7UrT" +default_arg --tip-distribution-program-pubkey "FjrdANjvo76aCYQ4kf9FM1R8aESUcEE6F8V7qyoVUQcM" +default_arg --commission-bps 0 default_arg --ledger "$ledger_dir" default_arg --log - default_arg --full-rpc-api diff --git a/perf/src/sigverify.rs b/perf/src/sigverify.rs index 47302b06c7..7cfacd33d3 100644 --- a/perf/src/sigverify.rs +++ b/perf/src/sigverify.rs @@ -110,7 +110,7 @@ pub fn init() { /// Returns true if the signatrue on the packet verifies. /// Caller must do packet.set_discard(true) if this returns false. #[must_use] -fn verify_packet(packet: &mut Packet, reject_non_vote: bool) -> bool { +pub fn verify_packet(packet: &mut Packet, reject_non_vote: bool) -> bool { // If this packet was already marked as discard, drop it if packet.meta().discard() { return false; diff --git a/poh/src/poh_recorder.rs b/poh/src/poh_recorder.rs index 342d3c8f2c..b8878c837f 100644 --- a/poh/src/poh_recorder.rs +++ b/poh/src/poh_recorder.rs @@ -60,9 +60,14 @@ pub enum PohRecorderError { SendError(#[from] SendError), } -type Result = std::result::Result; +pub type Result = std::result::Result; -pub type WorkingBankEntry = (Arc, (Entry, u64)); +#[derive(Clone, Debug)] +pub struct WorkingBankEntry { + pub bank: Arc, + // normal entries have len == 1, bundles have len > 1 + pub entries_ticks: Vec<(Entry, u64)>, +} #[derive(Debug, Clone)] pub struct BankStart { @@ -92,21 +97,19 @@ impl BankStart { type RecordResultSender = Sender>>; pub struct Record { - pub mixin: Hash, - pub transactions: Vec, + // non-bundles shall have mixins_txs.len() == 1, bundles shall have mixins_txs.len() > 1 + pub mixins_txs: Vec<(Hash, Vec)>, pub slot: Slot, pub sender: RecordResultSender, } impl Record { pub fn new( - mixin: Hash, - transactions: Vec, + mixins_txs: Vec<(Hash, Vec)>, slot: Slot, sender: RecordResultSender, ) -> Self { Self { - mixin, - transactions, + mixins_txs, slot, sender, } @@ -160,16 +163,21 @@ impl TransactionRecorder { pub fn record_transactions( &self, bank_slot: Slot, - transactions: Vec, + batches: Vec>, ) -> RecordTransactionsSummary { let mut record_transactions_timings = RecordTransactionsTimings::default(); let mut starting_transaction_index = None; - if !transactions.is_empty() { - let (hash, hash_us) = measure_us!(hash_transactions(&transactions)); + if !batches.is_empty() && !batches.iter().any(|b| b.is_empty()) { + let (hashes, hash_us) = measure_us!(batches + .iter() + .map(|b| hash_transactions(b)) + .collect::>()); record_transactions_timings.hash_us = hash_us; - let (res, poh_record_us) = measure_us!(self.record(bank_slot, hash, transactions)); + let hashes_transactions: Vec<_> = hashes.into_iter().zip(batches.into_iter()).collect(); + + let (res, poh_record_us) = measure_us!(self.record(bank_slot, hashes_transactions)); record_transactions_timings.poh_record_us = poh_record_us; match res { @@ -198,14 +206,13 @@ impl TransactionRecorder { pub fn record( &self, bank_slot: Slot, - mixin: Hash, - transactions: Vec, + mixins_txs: Vec<(Hash, Vec)>, ) -> Result> { // create a new channel so that there is only 1 sender and when it goes out of scope, the receiver fails let (result_sender, result_receiver) = unbounded(); - let res = - self.record_sender - .send(Record::new(mixin, transactions, bank_slot, result_sender)); + let res = self + .record_sender + .send(Record::new(mixins_txs, bank_slot, result_sender)); if res.is_err() { // If the channel is dropped, then the validator is shutting down so return that we are hitting // the max tick height to stop transaction processing and flush any transactions in the pipeline. @@ -658,7 +665,10 @@ impl PohRecorder { for tick in &self.tick_cache[..entry_count] { working_bank.bank.register_tick(&tick.0.hash); - send_result = self.sender.send((working_bank.bank.clone(), tick.clone())); + send_result = self.sender.send(WorkingBankEntry { + bank: working_bank.bank.clone(), + entries_ticks: vec![tick.clone()], + }); if send_result.is_err() { break; } @@ -838,16 +848,23 @@ impl PohRecorder { pub fn record( &mut self, bank_slot: Slot, - mixin: Hash, - transactions: Vec, + mixins_txs: &[(Hash, Vec)], ) -> Result> { // Entries without transactions are used to track real-time passing in the ledger and // cannot be generated by `record()` - assert!(!transactions.is_empty(), "No transactions provided"); + assert!(!mixins_txs.is_empty(), "No transactions provided"); + assert!( + !mixins_txs.iter().any(|(_, txs)| txs.is_empty()), + "One of mixins is missing txs" + ); let ((), report_metrics_time) = measure!(self.report_metrics(bank_slot), "report_metrics"); self.report_metrics_us += report_metrics_time.as_us(); + let mixins: Vec = mixins_txs.iter().map(|(m, _)| *m).collect(); + let transactions: Vec> = + mixins_txs.iter().map(|(_, tx)| tx.clone()).collect(); + loop { let (flush_cache_res, flush_cache_time) = measure!(self.flush_cache(false), "flush_cache"); @@ -865,23 +882,36 @@ impl PohRecorder { let (mut poh_lock, poh_lock_time) = measure!(self.poh.lock().unwrap(), "poh_lock"); self.record_lock_contention_us += poh_lock_time.as_us(); - let (record_mixin_res, record_mixin_time) = - measure!(poh_lock.record(mixin), "record_mixin"); + let (maybe_entries, record_mixin_time) = + measure!(poh_lock.record_bundle(&mixins), "record_mixin"); self.record_us += record_mixin_time.as_us(); drop(poh_lock); - if let Some(poh_entry) = record_mixin_res { - let num_transactions = transactions.len(); + if let Some(entries) = maybe_entries { + assert_eq!(entries.len(), transactions.len()); + let num_transactions = transactions.iter().map(|txs| txs.len()).sum(); let (send_entry_res, send_entry_time) = measure!( { - let entry = Entry { - num_hashes: poh_entry.num_hashes, - hash: poh_entry.hash, - transactions, - }; + let entries_tick_heights: Vec<(Entry, u64)> = entries + .into_iter() + .zip(transactions.into_iter()) + .map(|(poh_entry, transactions)| { + ( + Entry { + num_hashes: poh_entry.num_hashes, + hash: poh_entry.hash, + transactions, + }, + self.tick_height, + ) + }) + .collect(); let bank_clone = working_bank.bank.clone(); - self.sender.send((bank_clone, (entry, self.tick_height))) + self.sender.send(WorkingBankEntry { + bank: bank_clone, + entries_ticks: entries_tick_heights, + }) }, "send_poh_entry", ); @@ -1258,13 +1288,17 @@ mod tests { assert_eq!(poh_recorder.tick_height, tick_height_before + 1); assert_eq!(poh_recorder.tick_cache.len(), 0); let mut num_entries = 0; - while let Ok((wbank, (_entry, _tick_height))) = entry_receiver.try_recv() { + while let Ok(WorkingBankEntry { + bank: wbank, + entries_ticks, + }) = entry_receiver.try_recv() + { assert_eq!(wbank.slot(), bank1.slot()); - num_entries += 1; + num_entries += entries_ticks.len(); } // All the cached ticks, plus the new tick above should have been flushed - assert_eq!(num_entries, num_new_ticks + 1); + assert_eq!(num_entries as u64, num_new_ticks + 1); } Blockstore::destroy(&ledger_path).unwrap(); } @@ -1353,7 +1387,7 @@ mod tests { // We haven't yet reached the minimum tick height for the working bank, // so record should fail assert_matches!( - poh_recorder.record(bank1.slot(), h1, vec![tx.into()]), + poh_recorder.record(bank1.slot(), &[(h1, vec![tx.into()])]), Err(PohRecorderError::MinHeightNotReached) ); assert!(entry_receiver.try_recv().is_err()); @@ -1396,7 +1430,7 @@ mod tests { // However we hand over a bad slot so record fails let bad_slot = bank.slot() + 1; assert_matches!( - poh_recorder.record(bad_slot, h1, vec![tx.into()]), + poh_recorder.record(bad_slot, &[(h1, vec![tx.into()])]), Err(PohRecorderError::MaxHeightReached) ); } @@ -1443,17 +1477,27 @@ mod tests { let tx = test_tx(); let h1 = hash(b"hello world!"); assert!(poh_recorder - .record(bank1.slot(), h1, vec![tx.into()]) + .record(bank1.slot(), &[(h1, vec![tx.into()])]) .is_ok()); assert_eq!(poh_recorder.tick_cache.len(), 0); //tick in the cache + entry for _ in 0..min_tick_height { - let (_bank, (e, _tick_height)) = entry_receiver.recv().unwrap(); + let WorkingBankEntry { + bank: _, + entries_ticks, + } = entry_receiver.recv().unwrap(); + assert_eq!(entries_ticks.len(), 1); + let e = entries_ticks.get(0).unwrap().0.clone(); assert!(e.is_tick()); } - let (_bank, (e, _tick_height)) = entry_receiver.recv().unwrap(); + let WorkingBankEntry { + bank: _, + entries_ticks, + } = entry_receiver.recv().unwrap(); + assert_eq!(entries_ticks.len(), 1); + let e = entries_ticks.get(0).unwrap().0.clone(); assert!(!e.is_tick()); } Blockstore::destroy(&ledger_path).unwrap(); @@ -1489,10 +1533,16 @@ mod tests { let tx = test_tx(); let h1 = hash(b"hello world!"); assert!(poh_recorder - .record(bank.slot(), h1, vec![tx.into()]) + .record(bank.slot(), &[(h1, vec![tx.into()])]) .is_err()); + for _ in 0..num_ticks_to_max { - let (_bank, (entry, _tick_height)) = entry_receiver.recv().unwrap(); + let WorkingBankEntry { + bank: _, + entries_ticks, + } = entry_receiver.recv().unwrap(); + assert_eq!(entries_ticks.len(), 1); + let entry = entries_ticks.get(0).unwrap().0.clone(); assert!(entry.is_tick()); } } @@ -1537,7 +1587,7 @@ mod tests { let tx1 = test_tx(); let h1 = hash(b"hello world!"); let record_result = poh_recorder - .record(bank.slot(), h1, vec![tx0.into(), tx1.into()]) + .record(bank.slot(), &[(h1, vec![tx0.into(), tx1.into()])]) .unwrap() .unwrap(); assert_eq!(record_result, 0); @@ -1554,7 +1604,7 @@ mod tests { let tx = test_tx(); let h2 = hash(b"foobar"); let record_result = poh_recorder - .record(bank.slot(), h2, vec![tx.into()]) + .record(bank.slot(), &[(h2, vec![tx.into()])]) .unwrap() .unwrap(); assert_eq!(record_result, 2); @@ -1821,7 +1871,7 @@ mod tests { let tx = test_tx(); let h1 = hash(b"hello world!"); assert!(poh_recorder - .record(bank.slot(), h1, vec![tx.into()]) + .record(bank.slot(), &[(h1, vec![tx.into()])]) .is_err()); assert!(poh_recorder.working_bank.is_none()); diff --git a/poh/src/poh_service.rs b/poh/src/poh_service.rs index 4fcc918e5b..b86495e7cd 100644 --- a/poh/src/poh_service.rs +++ b/poh/src/poh_service.rs @@ -193,11 +193,12 @@ impl PohService { if let Ok(record) = record { if record .sender - .send(poh_recorder.write().unwrap().record( - record.slot, - record.mixin, - record.transactions, - )) + .send( + poh_recorder + .write() + .unwrap() + .record(record.slot, &record.mixins_txs), + ) .is_err() { panic!("Error returning mixin hash"); @@ -256,11 +257,7 @@ impl PohService { timing.total_lock_time_ns += lock_time.as_ns(); let mut record_time = Measure::start("record"); loop { - let res = poh_recorder_l.record( - record.slot, - record.mixin, - std::mem::take(&mut record.transactions), - ); + let res = poh_recorder_l.record(record.slot, &record.mixins_txs); // what do we do on failure here? Ignore for now. let (_send_res, send_record_result_time) = measure!(record.sender.send(res), "send_record_result"); @@ -382,6 +379,7 @@ impl PohService { mod tests { use { super::*, + crate::poh_recorder::WorkingBankEntry, rand::{thread_rng, Rng}, solana_ledger::{ blockstore::Blockstore, @@ -461,11 +459,10 @@ mod tests { loop { // send some data let mut time = Measure::start("record"); - let _ = poh_recorder.write().unwrap().record( - bank_slot, - h1, - vec![tx.clone()], - ); + let _ = poh_recorder + .write() + .unwrap() + .record(bank_slot, &[(h1, vec![tx.clone()])]); time.stop(); total_us += time.as_us(); total_times += 1; @@ -510,7 +507,12 @@ mod tests { let time = Instant::now(); while run_time != 0 || need_tick || need_entry || need_partial { - let (_bank, (entry, _tick_height)) = entry_receiver.recv().unwrap(); + let WorkingBankEntry { + bank: _, + entries_ticks, + } = entry_receiver.recv().unwrap(); + assert_eq!(entries_ticks.len(), 0); + let entry = entries_ticks.get(0).unwrap().0.clone(); if entry.is_tick() { num_ticks += 1; diff --git a/program-runtime/src/timings.rs b/program-runtime/src/timings.rs index 0e2e4956a5..b63b20669f 100644 --- a/program-runtime/src/timings.rs +++ b/program-runtime/src/timings.rs @@ -8,7 +8,7 @@ use { }, }; -#[derive(Default, Debug, PartialEq, Eq)] +#[derive(Clone, Default, Debug, PartialEq, Eq)] pub struct ProgramTiming { pub accumulated_us: u64, pub accumulated_units: u64, @@ -53,6 +53,7 @@ pub enum ExecuteTimingType { UpdateTransactionStatuses, } +#[derive(Clone)] pub struct Metrics([u64; ExecuteTimingType::CARDINALITY]); impl Index for Metrics { @@ -316,7 +317,7 @@ impl ThreadExecuteTimings { } } -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct ExecuteTimings { pub metrics: Metrics, pub details: ExecuteDetailsTimings, @@ -340,9 +341,21 @@ impl ExecuteTimings { None => debug_assert!(idx < ExecuteTimingType::CARDINALITY, "Index out of bounds"), } } + + pub fn accumulate_execute_units_and_time(&self) -> (u64, u64) { + self.details + .per_program_timings + .values() + .fold((0, 0), |(units, times), program_timings| { + ( + units.saturating_add(program_timings.accumulated_units), + times.saturating_add(program_timings.accumulated_us), + ) + }) + } } -#[derive(Default, Debug)] +#[derive(Clone, Default, Debug)] pub struct ExecuteProcessInstructionTimings { pub total_us: u64, pub verify_caller_us: u64, @@ -362,7 +375,7 @@ impl ExecuteProcessInstructionTimings { } } -#[derive(Default, Debug)] +#[derive(Clone, Default, Debug)] pub struct ExecuteAccessoryTimings { pub feature_set_clone_us: u64, pub compute_budget_process_transaction_us: u64, @@ -387,7 +400,7 @@ impl ExecuteAccessoryTimings { } } -#[derive(Default, Debug, PartialEq, Eq)] +#[derive(Clone, Default, Debug, PartialEq, Eq)] pub struct ExecuteDetailsTimings { pub serialize_us: u64, pub create_vm_us: u64, diff --git a/program-test/src/programs.rs b/program-test/src/programs.rs index ed96be7644..c01b5f4a82 100644 --- a/program-test/src/programs.rs +++ b/program-test/src/programs.rs @@ -21,6 +21,13 @@ mod spl_associated_token_account { solana_sdk::declare_id!("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"); } +mod jito_tip_payment { + solana_sdk::declare_id!("T1pyyaTNZsKv2WcRAB8oVnk93mLJw2XzjtVYqCsaHqt"); +} +mod jito_tip_distribution { + solana_sdk::declare_id!("4R3gSG8BpU4t19KYj8CfnbtRpnT8gtk4dvTHxVRwc2r7"); +} + static SPL_PROGRAMS: &[(Pubkey, Pubkey, &[u8])] = &[ ( spl_token::ID, @@ -47,6 +54,16 @@ static SPL_PROGRAMS: &[(Pubkey, Pubkey, &[u8])] = &[ solana_sdk::bpf_loader::ID, include_bytes!("programs/spl_associated_token_account-1.1.1.so"), ), + ( + jito_tip_distribution::ID, + solana_sdk::bpf_loader::ID, + include_bytes!("programs/jito_tip_distribution-0.1.3.so"), + ), + ( + jito_tip_payment::ID, + solana_sdk::bpf_loader::ID, + include_bytes!("programs/jito_tip_payment-0.1.3.so"), + ), ]; pub fn spl_programs(rent: &Rent) -> Vec<(Pubkey, AccountSharedData)> { diff --git a/program-test/src/programs/jito_tip_distribution-0.1.3.so b/program-test/src/programs/jito_tip_distribution-0.1.3.so new file mode 100644 index 0000000000000000000000000000000000000000..094ad26f310bb20cad7985fb8416c8004593e2aa GIT binary patch literal 439968 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 z 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%0D5v0^dkyD|)n#z;e(2d+bL%q?N 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 H)A;`%XKV4{ literal 0 HcmV?d00001 diff --git a/program-test/src/programs/jito_tip_payment-0.1.3.so b/program-test/src/programs/jito_tip_payment-0.1.3.so new file mode 100644 index 0000000000000000000000000000000000000000..4b747aa3cc6e120680200e9dcabc6ad56d6e4279 GIT binary patch literal 433224 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

{7f3+7Eaa99?NYkIO4^RJoBp{$=gJa%+Ndrk_t6 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 WsOP>rXmun1Y<0%|<~OU`-2Z=OoC{X~ literal 0 HcmV?d00001 diff --git a/programs/sbf/Cargo.lock b/programs/sbf/Cargo.lock index 3b18d7505f..cb05c5d1f4 100644 --- a/programs/sbf/Cargo.lock +++ b/programs/sbf/Cargo.lock @@ -116,6 +116,145 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "anchor-attribute-access-control" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "regex", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-account" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "bs58 0.4.0", + "proc-macro2 1.0.60", + "quote 1.0.28", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-constant" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "proc-macro2 1.0.60", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-error" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-event" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-interface" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "heck 0.3.3", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-program" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-state" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-derive-accounts" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", +] + +[[package]] +name = "anchor-lang" +version = "0.24.2" +dependencies = [ + "anchor-attribute-access-control", + "anchor-attribute-account", + "anchor-attribute-constant", + "anchor-attribute-error", + "anchor-attribute-event", + "anchor-attribute-interface", + "anchor-attribute-program", + "anchor-attribute-state", + "anchor-derive-accounts", + "arrayref", + "base64 0.13.1", + "bincode", + "borsh 0.10.3", + "bytemuck", + "solana-program", + "thiserror", +] + +[[package]] +name = "anchor-syn" +version = "0.24.2" +dependencies = [ + "anyhow", + "bs58 0.3.1", + "heck 0.3.3", + "proc-macro2 1.0.60", + "proc-macro2-diagnostics", + "quote 1.0.28", + "serde", + "serde_json", + "sha2 0.9.9", + "syn 1.0.109", + "thiserror", +] + [[package]] name = "android_system_properties" version = "0.1.4" @@ -277,9 +416,9 @@ checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545" [[package]] name = "arrayvec" -version = "0.7.1" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4dc07131ffa69b8072d35f5007352af944213cde02545e2103680baed38fcd" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" [[package]] name = "ascii" @@ -555,9 +694,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.3.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ae2468a89544a466886840aa467a25b766499f4f04bf7d9fcd10ecee9fccef" +checksum = "0231f06152bf547e9c2b5194f247cd97aacf6dcd8b15d8e5ec0663f64580da87" dependencies = [ "arrayref", "arrayvec", @@ -630,7 +769,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b" dependencies = [ "borsh-derive 0.10.3", - "hashbrown 0.11.2", + "hashbrown 0.13.2", ] [[package]] @@ -724,6 +863,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476e9cd489f9e121e02ffa6014a8ef220ecb15c05ed23fc34cca13925dc283fb" + [[package]] name = "bs58" version = "0.4.0" @@ -999,9 +1144,9 @@ checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3" [[package]] name = "constant_time_eq" -version = "0.2.5" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13418e745008f7349ec7e449155f419a61b92b58a99cc3616942b926825ec76b" +checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" [[package]] name = "convert_case" @@ -1199,6 +1344,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ee2393c4a91429dffb4bedf19f4d6abf27d8a732c8ce4980305d782e5426d57" +[[package]] +name = "default-env" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f753eb82d29277e79efc625e84aecacfd4851ee50e05a8573a4740239a77bfd3" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "syn 0.15.44", +] + [[package]] name = "der" version = "0.5.1" @@ -2248,6 +2404,49 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" +[[package]] +name = "jito-programs-vote-state" +version = "0.1.4" +dependencies = [ + "anchor-lang", + "bincode", + "serde", + "serde_derive", + "solana-program", +] + +[[package]] +name = "jito-protos" +version = "1.16.22" +dependencies = [ + "bytes", + "prost 0.11.9", + "prost-types 0.11.9", + "protobuf-src", + "tonic 0.8.3", + "tonic-build 0.8.4", +] + +[[package]] +name = "jito-tip-distribution" +version = "0.1.4" +dependencies = [ + "anchor-lang", + "default-env", + "jito-programs-vote-state", + "solana-program", + "solana-security-txt", +] + +[[package]] +name = "jito-tip-payment" +version = "0.1.4" +dependencies = [ + "anchor-lang", + "default-env", + "solana-security-txt", +] + [[package]] name = "jobserver" version = "0.1.21" @@ -3478,6 +3677,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf29726d67464d49fa6224a1d07936a8c08bb3fba727c7493f6cf1616fdaada" +dependencies = [ + "proc-macro2 1.0.60", + "quote 1.0.28", + "syn 1.0.109", + "version_check", + "yansi", +] + [[package]] name = "prost" version = "0.9.0" @@ -4446,7 +4658,7 @@ dependencies = [ "Inflector", "base64 0.21.2", "bincode", - "bs58", + "bs58 0.4.0", "bv", "lazy_static", "serde", @@ -4514,12 +4726,14 @@ dependencies = [ "futures 0.3.28", "solana-banks-interface", "solana-client", + "solana-gossip", "solana-runtime", "solana-sdk", "solana-send-transaction-service", "tarpc", "tokio", "tokio-serde", + "tokio-stream", ] [[package]] @@ -4581,6 +4795,25 @@ dependencies = [ "tempfile", ] +[[package]] +name = "solana-bundle" +version = "1.16.22" +dependencies = [ + "anchor-lang", + "itertools", + "log", + "serde", + "solana-ledger", + "solana-logger", + "solana-measure", + "solana-poh", + "solana-program-runtime", + "solana-runtime", + "solana-sdk", + "solana-transaction-status", + "thiserror", +] + [[package]] name = "solana-clap-utils" version = "1.16.22" @@ -4710,9 +4943,10 @@ dependencies = [ name = "solana-core" version = "1.16.22" dependencies = [ + "anchor-lang", "base64 0.21.2", "bincode", - "bs58", + "bs58 0.4.0", "chrono", "crossbeam-channel", "dashmap", @@ -4720,11 +4954,16 @@ dependencies = [ "etcd-client", "histogram", "itertools", + "jito-protos", + "jito-tip-distribution", + "jito-tip-payment", "lazy_static", "log", "lru", "min-max-heap", "num_enum 0.6.1", + "prost 0.11.9", + "prost-types 0.11.9", "rand 0.7.3", "rand_chacha 0.2.2", "rayon", @@ -4734,6 +4973,7 @@ dependencies = [ "serde_derive", "solana-address-lookup-table-program", "solana-bloom", + "solana-bundle", "solana-client", "solana-entry", "solana-frozen-abi", @@ -4751,6 +4991,7 @@ dependencies = [ "solana-rpc", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-streamer", @@ -4765,6 +5006,8 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tonic 0.8.3", + "tonic-build 0.8.4", "trees", ] @@ -4830,7 +5073,7 @@ dependencies = [ "ahash 0.8.3", "blake3", "block-buffer 0.10.4", - "bs58", + "bs58 0.4.0", "bv", "byteorder 1.4.3", "cc", @@ -4889,7 +5132,7 @@ dependencies = [ name = "solana-geyser-plugin-manager" version = "1.16.22" dependencies = [ - "bs58", + "bs58 0.4.0", "crossbeam-channel", "json5", "jsonrpc-core", @@ -5140,7 +5383,7 @@ dependencies = [ "blake3", "borsh 0.10.3", "borsh 0.9.3", - "bs58", + "bs58 0.4.0", "bv", "bytemuck", "cc", @@ -5309,7 +5552,7 @@ version = "1.16.22" dependencies = [ "base64 0.21.2", "bincode", - "bs58", + "bs58 0.4.0", "crossbeam-channel", "dashmap", "itertools", @@ -5327,6 +5570,7 @@ dependencies = [ "serde_json", "soketto", "solana-account-decoder", + "solana-bundle", "solana-client", "solana-entry", "solana-faucet", @@ -5336,6 +5580,7 @@ dependencies = [ "solana-metrics", "solana-perf", "solana-poh", + "solana-program-runtime", "solana-rayon-threadlimit", "solana-rpc-client-api", "solana-runtime", @@ -5363,7 +5608,7 @@ dependencies = [ "async-trait", "base64 0.21.2", "bincode", - "bs58", + "bs58 0.4.0", "indicatif", "log", "reqwest", @@ -5385,7 +5630,7 @@ name = "solana-rpc-client-api" version = "1.16.22" dependencies = [ "base64 0.21.2", - "bs58", + "bs58 0.4.0", "jsonrpc-core", "reqwest", "semver", @@ -5393,6 +5638,8 @@ dependencies = [ "serde_derive", "serde_json", "solana-account-decoder", + "solana-bundle", + "solana-runtime", "solana-sdk", "solana-transaction-status", "solana-version", @@ -5481,6 +5728,24 @@ dependencies = [ "zstd", ] +[[package]] +name = "solana-runtime-plugin" +version = "1.16.22" +dependencies = [ + "crossbeam-channel", + "json5", + "jsonrpc-core", + "jsonrpc-core-client", + "jsonrpc-derive", + "jsonrpc-ipc-server", + "jsonrpc-server-utils", + "libloading", + "log", + "solana-runtime", + "solana-sdk", + "thiserror", +] + [[package]] name = "solana-sbf-programs" version = "1.16.22" @@ -5879,12 +6144,13 @@ dependencies = [ name = "solana-sdk" version = "1.16.22" dependencies = [ + "anchor-lang", "assert_matches", "base64 0.21.2", "bincode", "bitflags", "borsh 0.10.3", - "bs58", + "bs58 0.4.0", "bytemuck", "byteorder 1.4.3", "chrono", @@ -5930,13 +6196,19 @@ dependencies = [ name = "solana-sdk-macro" version = "1.16.22" dependencies = [ - "bs58", + "bs58 0.4.0", "proc-macro2 1.0.60", "quote 1.0.28", "rustversion", "syn 2.0.18", ] +[[package]] +name = "solana-security-txt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "468aa43b7edb1f9b7b7b686d5c3aeb6630dc1708e86e31343499dd5c4d775183" + [[package]] name = "solana-send-transaction-service" version = "1.16.22" @@ -5944,6 +6216,7 @@ dependencies = [ "crossbeam-channel", "log", "solana-client", + "solana-gossip", "solana-measure", "solana-metrics", "solana-runtime", @@ -6001,7 +6274,7 @@ name = "solana-storage-proto" version = "1.16.22" dependencies = [ "bincode", - "bs58", + "bs58 0.4.0", "prost 0.11.9", "protobuf-src", "serde", @@ -6127,7 +6400,7 @@ dependencies = [ "base64 0.21.2", "bincode", "borsh 0.10.3", - "bs58", + "bs58 0.4.0", "lazy_static", "log", "serde", @@ -6204,6 +6477,7 @@ dependencies = [ "solana-rpc-client", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-storage-bigtable", @@ -6215,6 +6489,7 @@ dependencies = [ "symlink", "thiserror", "tikv-jemallocator", + "tonic 0.8.3", ] [[package]] @@ -7065,6 +7340,7 @@ dependencies = [ "pin-project", "prost 0.11.9", "prost-derive 0.11.9", + "rustls-native-certs", "rustls-pemfile 1.0.0", "tokio", "tokio-rustls 0.23.2", @@ -7075,6 +7351,7 @@ dependencies = [ "tower-service", "tracing", "tracing-futures", + "webpki-roots", ] [[package]] @@ -7840,6 +8117,12 @@ dependencies = [ "libc", ] +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "yasna" version = "0.5.0" diff --git a/programs/sbf/tests/programs.rs b/programs/sbf/tests/programs.rs index 9a3910f4f7..4f09daaf88 100644 --- a/programs/sbf/tests/programs.rs +++ b/programs/sbf/tests/programs.rs @@ -135,7 +135,7 @@ fn execute_transactions( let batch = bank.prepare_batch_for_tests(txs.clone()); let mut timings = ExecuteTimings::default(); let mut mint_decimals = HashMap::new(); - let tx_pre_token_balances = collect_token_balances(&bank, &batch, &mut mint_decimals); + let tx_pre_token_balances = collect_token_balances(&bank, &batch, &mut mint_decimals, None); let ( TransactionResults { execution_results, .. @@ -155,7 +155,7 @@ fn execute_transactions( &mut timings, None, ); - let tx_post_token_balances = collect_token_balances(&bank, &batch, &mut mint_decimals); + let tx_post_token_balances = collect_token_balances(&bank, &batch, &mut mint_decimals, None); izip!( txs.iter(), diff --git a/rpc-client-api/Cargo.toml b/rpc-client-api/Cargo.toml index 92bc9d4958..a7d3bae207 100644 --- a/rpc-client-api/Cargo.toml +++ b/rpc-client-api/Cargo.toml @@ -19,6 +19,8 @@ serde = { workspace = true } serde_derive = { workspace = true } serde_json = { workspace = true } solana-account-decoder = { workspace = true } +solana-bundle = { workspace = true } +solana-runtime = { workspace = true } solana-sdk = { workspace = true } solana-transaction-status = { workspace = true } solana-version = { workspace = true } diff --git a/rpc-client-api/src/bundles.rs b/rpc-client-api/src/bundles.rs new file mode 100644 index 0000000000..f0a6d99933 --- /dev/null +++ b/rpc-client-api/src/bundles.rs @@ -0,0 +1,166 @@ +//! Use a separate file for Jito related code to minimize upstream merge conflicts. + +use { + crate::config::RpcSimulateTransactionAccountsConfig, + solana_account_decoder::UiAccount, + solana_bundle::{bundle_execution::LoadAndExecuteBundleError, BundleExecutionError}, + solana_runtime::bank::TransactionExecutionResult, + solana_sdk::{ + clock::Slot, + commitment_config::{CommitmentConfig, CommitmentLevel}, + signature::Signature, + transaction::TransactionError, + }, + solana_transaction_status::{UiTransactionEncoding, UiTransactionReturnData}, + thiserror::Error, +}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub enum RpcBundleSimulationSummary { + /// error and offending transaction signature if applicable + Failed { + error: RpcBundleExecutionError, + tx_signature: Option, + }, + Succeeded, +} + +#[derive(Error, Debug, Clone, Serialize, Deserialize)] +pub enum RpcBundleExecutionError { + #[error("The bank has hit the max allotted time for processing transactions")] + BankProcessingTimeLimitReached, + + #[error("Error locking bundle because a transaction is malformed")] + BundleLockError, + + #[error("Bundle execution timed out")] + BundleExecutionTimeout, + + #[error("The bundle exceeds the cost model")] + ExceedsCostModel, + + #[error("Invalid pre or post accounts")] + InvalidPreOrPostAccounts, + + #[error("PoH record error: {0}")] + PohRecordError(String), + + #[error("Tip payment error: {0}")] + TipError(String), + + #[error("A transaction in the bundle failed to execute: [signature={0}, error={1}]")] + TransactionFailure(Signature, String), +} + +impl From for RpcBundleExecutionError { + fn from(bundle_execution_error: BundleExecutionError) -> Self { + match bundle_execution_error { + BundleExecutionError::BankProcessingTimeLimitReached => { + Self::BankProcessingTimeLimitReached + } + BundleExecutionError::ExceedsCostModel => Self::ExceedsCostModel, + BundleExecutionError::TransactionFailure(load_and_execute_bundle_error) => { + match load_and_execute_bundle_error { + LoadAndExecuteBundleError::ProcessingTimeExceeded(_) => { + Self::BundleExecutionTimeout + } + LoadAndExecuteBundleError::LockError { + signature, + transaction_error, + } => Self::TransactionFailure(signature, transaction_error.to_string()), + LoadAndExecuteBundleError::TransactionError { + signature, + execution_result, + } => match *execution_result { + TransactionExecutionResult::Executed { details, .. } => { + let err_msg = if let Err(e) = details.status { + e.to_string() + } else { + "Unknown error".to_string() + }; + Self::TransactionFailure(signature, err_msg) + } + TransactionExecutionResult::NotExecuted(e) => { + Self::TransactionFailure(signature, e.to_string()) + } + }, + LoadAndExecuteBundleError::InvalidPreOrPostAccounts => { + Self::InvalidPreOrPostAccounts + } + } + } + BundleExecutionError::LockError => Self::BundleLockError, + BundleExecutionError::PohRecordError(e) => Self::PohRecordError(e.to_string()), + BundleExecutionError::TipError(e) => Self::TipError(e.to_string()), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RpcSimulateBundleResult { + pub summary: RpcBundleSimulationSummary, + pub transaction_results: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RpcSimulateBundleTransactionResult { + pub err: Option, + pub logs: Option>, + pub pre_execution_accounts: Option>, + pub post_execution_accounts: Option>, + pub units_consumed: Option, + pub return_data: Option, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcSimulateBundleConfig { + /// Gives the state of accounts pre/post transaction execution. + /// The length of each of these must be equal to the number transactions. + pub pre_execution_accounts_configs: Vec>, + pub post_execution_accounts_configs: Vec>, + + /// Specifies the encoding scheme of the contained transactions. + pub transaction_encoding: Option, + + /// Specifies the bank to run simulation against. + pub simulation_bank: Option, + + /// Opt to skip sig-verify for faster performance. + #[serde(default)] + pub skip_sig_verify: bool, + + /// Replace recent blockhash to simulate old transactions without resigning. + #[serde(default)] + pub replace_recent_blockhash: bool, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase")] +pub enum SimulationSlotConfig { + /// Simulate on top of bank with the provided commitment. + Commitment(CommitmentConfig), + + /// Simulate on the provided slot's bank. + Slot(Slot), + + /// Simulates on top of the RPC's highest slot's bank i.e. the working bank. + Tip, +} + +impl Default for SimulationSlotConfig { + fn default() -> Self { + Self::Commitment(CommitmentConfig { + commitment: CommitmentLevel::Confirmed, + }) + } +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RpcBundleRequest { + pub encoded_transactions: Vec, +} diff --git a/rpc-client-api/src/config.rs b/rpc-client-api/src/config.rs index 9ecff334ca..d16df2d165 100644 --- a/rpc-client-api/src/config.rs +++ b/rpc-client-api/src/config.rs @@ -46,7 +46,7 @@ pub struct RpcSimulateTransactionConfig { pub min_context_slot: Option, } -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RpcRequestAirdropConfig { pub recent_blockhash: Option, // base-58 encoded blockhash diff --git a/rpc-client-api/src/lib.rs b/rpc-client-api/src/lib.rs index 9be15cbab4..1d8b1e2dd1 100644 --- a/rpc-client-api/src/lib.rs +++ b/rpc-client-api/src/lib.rs @@ -1,5 +1,6 @@ #![allow(clippy::integer_arithmetic)] +pub mod bundles; pub mod client_error; pub mod config; pub mod custom_error; diff --git a/rpc-client-api/src/request.rs b/rpc-client-api/src/request.rs index e9c6ef9b38..e1e5cfd756 100644 --- a/rpc-client-api/src/request.rs +++ b/rpc-client-api/src/request.rs @@ -113,6 +113,7 @@ pub enum RpcRequest { RequestAirdrop, SendTransaction, SimulateTransaction, + SimulateBundle, SignVote, } @@ -189,6 +190,7 @@ impl fmt::Display for RpcRequest { RpcRequest::RequestAirdrop => "requestAirdrop", RpcRequest::SendTransaction => "sendTransaction", RpcRequest::SimulateTransaction => "simulateTransaction", + RpcRequest::SimulateBundle => "simulateBundle", RpcRequest::SignVote => "signVote", }; @@ -258,6 +260,7 @@ pub enum RpcError { RpcRequestError(String), #[error("RPC response error {code}: {message} {data}")] RpcResponseError { + request_id: u64, code: i64, message: String, data: RpcResponseErrorData, diff --git a/rpc-client-api/src/response.rs b/rpc-client-api/src/response.rs index 7591c58c03..e20b92d1fc 100644 --- a/rpc-client-api/src/response.rs +++ b/rpc-client-api/src/response.rs @@ -36,6 +36,7 @@ impl OptionalContext { } } +pub type BatchRpcResult = client_error::Result>>; pub type RpcResult = client_error::Result>; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -46,6 +47,15 @@ pub struct RpcResponseContext { pub api_version: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchRpcResponseContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub slot: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_version: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RpcApiVersion(semver::Version); @@ -92,6 +102,12 @@ impl RpcResponseContext { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct BatchResponse { + pub id: u64, + pub result: Response, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Response { pub context: RpcResponseContext, diff --git a/rpc-client/src/http_sender.rs b/rpc-client/src/http_sender.rs index 902f86ce63..dc278db38d 100644 --- a/rpc-client/src/http_sender.rs +++ b/rpc-client/src/http_sender.rs @@ -11,7 +11,7 @@ use { }, solana_rpc_client_api::{ client_error::Result, - custom_error, + custom_error::{self}, error_object::RpcErrorObject, request::{RpcError, RpcRequest, RpcResponseErrorData}, response::RpcSimulateTransactionResult, @@ -72,62 +72,74 @@ impl HttpSender { stats: RwLock::new(RpcTransportStats::default()), } } -} -struct StatsUpdater<'a> { - stats: &'a RwLock, - request_start_time: Instant, - rate_limited_time: Duration, -} + fn check_response(json: &serde_json::Value) -> Result<()> { + if json["error"].is_object() { + return match serde_json::from_value::(json["error"].clone()) { + Ok(rpc_error_object) => { + let data = match rpc_error_object.code { + custom_error::JSON_RPC_SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE => { + match serde_json::from_value::( + json["error"]["data"].clone(), + ) { + Ok(data) => { + RpcResponseErrorData::SendTransactionPreflightFailure(data) + } + Err(err) => { + debug!( + "Failed to deserialize RpcSimulateTransactionResult: {:?}", + err + ); + RpcResponseErrorData::Empty + } + } + } + custom_error::JSON_RPC_SERVER_ERROR_NODE_UNHEALTHY => { + match serde_json::from_value::( + json["error"]["data"].clone(), + ) { + Ok(custom_error::NodeUnhealthyErrorData { num_slots_behind }) => { + RpcResponseErrorData::NodeUnhealthy { num_slots_behind } + } + Err(_err) => RpcResponseErrorData::Empty, + } + } + _ => RpcResponseErrorData::Empty, + }; -impl<'a> StatsUpdater<'a> { - fn new(stats: &'a RwLock) -> Self { - Self { - stats, - request_start_time: Instant::now(), - rate_limited_time: Duration::default(), + Err(RpcError::RpcResponseError { + request_id: json["id"].as_u64().unwrap(), + code: rpc_error_object.code, + message: rpc_error_object.message, + data, + } + .into()) + } + Err(err) => Err(RpcError::RpcRequestError(format!( + "Failed to deserialize RPC error response: {} [{}]", + serde_json::to_string(&json["error"]).unwrap(), + err + )) + .into()), + }; } + Ok(()) } - fn add_rate_limited_time(&mut self, duration: Duration) { - self.rate_limited_time += duration; - } -} - -impl<'a> Drop for StatsUpdater<'a> { - fn drop(&mut self) { - let mut stats = self.stats.write().unwrap(); - stats.request_count += 1; - stats.elapsed_time += Instant::now().duration_since(self.request_start_time); - stats.rate_limited_time += self.rate_limited_time; - } -} - -#[async_trait] -impl RpcSender for HttpSender { - fn get_transport_stats(&self) -> RpcTransportStats { - self.stats.read().unwrap().clone() - } - - async fn send( + async fn do_send_with_retry( &self, - request: RpcRequest, - params: serde_json::Value, - ) -> Result { + request: serde_json::Value, + ) -> reqwest::Result { let mut stats_updater = StatsUpdater::new(&self.stats); - - let request_id = self.request_id.fetch_add(1, Ordering::Relaxed); - let request_json = request.build_request_json(request_id, params).to_string(); - let mut too_many_requests_retries = 5; loop { let response = { let client = self.client.clone(); - let request_json = request_json.clone(); + let request = request.to_string(); client .post(&self.url) .header(CONTENT_TYPE, "application/json") - .body(request_json) + .body(request) .send() .await }?; @@ -155,54 +167,81 @@ impl RpcSender for HttpSender { sleep(duration).await; stats_updater.add_rate_limited_time(duration); + continue; } - return Err(response.error_for_status().unwrap_err().into()); - } - let mut json = response.json::().await?; - if json["error"].is_object() { - return match serde_json::from_value::(json["error"].clone()) { - Ok(rpc_error_object) => { - let data = match rpc_error_object.code { - custom_error::JSON_RPC_SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE => { - match serde_json::from_value::(json["error"]["data"].clone()) { - Ok(data) => RpcResponseErrorData::SendTransactionPreflightFailure(data), - Err(err) => { - debug!("Failed to deserialize RpcSimulateTransactionResult: {:?}", err); - RpcResponseErrorData::Empty - } - } - }, - custom_error::JSON_RPC_SERVER_ERROR_NODE_UNHEALTHY => { - match serde_json::from_value::(json["error"]["data"].clone()) { - Ok(custom_error::NodeUnhealthyErrorData {num_slots_behind}) => RpcResponseErrorData::NodeUnhealthy {num_slots_behind}, - Err(_err) => { - RpcResponseErrorData::Empty - } - } - }, - _ => RpcResponseErrorData::Empty - }; - - Err(RpcError::RpcResponseError { - code: rpc_error_object.code, - message: rpc_error_object.message, - data, - } - .into()) - } - Err(err) => Err(RpcError::RpcRequestError(format!( - "Failed to deserialize RPC error response: {} [{}]", - serde_json::to_string(&json["error"]).unwrap(), - err - )) - .into()), - }; + return Err(response.error_for_status().unwrap_err()); } - return Ok(json["result"].take()); + + return response.json::().await; } } +} + +struct StatsUpdater<'a> { + stats: &'a RwLock, + request_start_time: Instant, + rate_limited_time: Duration, +} + +impl<'a> StatsUpdater<'a> { + fn new(stats: &'a RwLock) -> Self { + Self { + stats, + request_start_time: Instant::now(), + rate_limited_time: Duration::default(), + } + } + + fn add_rate_limited_time(&mut self, duration: Duration) { + self.rate_limited_time += duration; + } +} + +impl<'a> Drop for StatsUpdater<'a> { + fn drop(&mut self) { + let mut stats = self.stats.write().unwrap(); + stats.request_count += 1; + stats.elapsed_time += Instant::now().duration_since(self.request_start_time); + stats.rate_limited_time += self.rate_limited_time; + } +} + +#[async_trait] +impl RpcSender for HttpSender { + async fn send( + &self, + request: RpcRequest, + params: serde_json::Value, + ) -> Result { + let request_id = self.request_id.fetch_add(1, Ordering::Relaxed); + let request = request.build_request_json(request_id, params); + let mut resp = self.do_send_with_retry(request).await?; + Self::check_response(&resp)?; + + Ok(resp["result"].take()) + } + + async fn send_batch( + &self, + requests_and_params: Vec<(RpcRequest, serde_json::Value)>, + ) -> Result { + let mut batch_request = vec![]; + for (request_id, req) in requests_and_params.into_iter().enumerate() { + batch_request.push(req.0.build_request_json(request_id as u64, req.1)); + } + + let resp = self + .do_send_with_retry(serde_json::Value::Array(batch_request)) + .await?; + + Ok(resp) + } + + fn get_transport_stats(&self) -> RpcTransportStats { + self.stats.read().unwrap().clone() + } fn url(&self) -> String { self.url.clone() diff --git a/rpc-client/src/mock_sender.rs b/rpc-client/src/mock_sender.rs index 654f45d029..d710735dbc 100644 --- a/rpc-client/src/mock_sender.rs +++ b/rpc-client/src/mock_sender.rs @@ -489,4 +489,11 @@ impl RpcSender for MockSender { fn url(&self) -> String { format!("MockSender: {}", self.url) } + + async fn send_batch( + &self, + _requests_and_params: Vec<(RpcRequest, serde_json::Value)>, + ) -> Result { + todo!() + } } diff --git a/rpc-client/src/nonblocking/rpc_client.rs b/rpc-client/src/nonblocking/rpc_client.rs index 21350938a7..145c3417e0 100644 --- a/rpc-client/src/nonblocking/rpc_client.rs +++ b/rpc-client/src/nonblocking/rpc_client.rs @@ -33,6 +33,10 @@ use { UiAccount, UiAccountData, UiAccountEncoding, }, solana_rpc_client_api::{ + bundles::{ + RpcBundleRequest, RpcSimulateBundleConfig, RpcSimulateBundleResult, + SimulationSlotConfig, + }, client_error::{ Error as ClientError, ErrorKind as ClientErrorKind, Result as ClientResult, }, @@ -43,6 +47,7 @@ use { }, solana_sdk::{ account::Account, + bundle::VersionedBundle, clock::{Epoch, Slot, UnixTimestamp, DEFAULT_MS_PER_SLOT}, commitment_config::{CommitmentConfig, CommitmentLevel}, epoch_info::EpochInfo, @@ -51,7 +56,7 @@ use { hash::Hash, pubkey::Pubkey, signature::Signature, - transaction, + transaction::{self, VersionedTransaction}, }, solana_transaction_status::{ EncodedConfirmedBlock, EncodedConfirmedTransactionWithStatusMeta, TransactionStatus, @@ -970,6 +975,7 @@ impl RpcClient { code, message, data, + .. }) = &err.kind { debug!("{} {}", code, message); @@ -1412,6 +1418,113 @@ impl RpcClient { .await } + pub async fn batch_simulate_bundle( + &self, + bundles: &[VersionedBundle], + ) -> BatchRpcResult { + let configs = bundles + .iter() + .map(|b| RpcSimulateBundleConfig { + simulation_bank: Some(SimulationSlotConfig::Commitment(self.commitment())), + pre_execution_accounts_configs: vec![None; b.transactions.len()], + post_execution_accounts_configs: vec![None; b.transactions.len()], + ..RpcSimulateBundleConfig::default() + }) + .collect::>(); + + self.batch_simulate_bundle_with_config(bundles.iter().zip(configs).collect()) + .await + } + + pub async fn batch_simulate_bundle_with_config( + &self, + bundles_and_configs: Vec<(&VersionedBundle, RpcSimulateBundleConfig)>, + ) -> BatchRpcResult { + let mut params = vec![]; + for (bundle, config) in bundles_and_configs { + let transaction_encoding = if let Some(encoding) = config.transaction_encoding { + encoding + } else { + self.default_cluster_transaction_encoding().await? + }; + + let simulation_bank = config.simulation_bank.unwrap_or_default(); + + let config = RpcSimulateBundleConfig { + transaction_encoding: Some(transaction_encoding), + simulation_bank: Some(simulation_bank), + ..config + }; + + let encoded_transactions = bundle + .transactions + .iter() + .map(|tx| serialize_and_encode::(tx, transaction_encoding)) + .collect::, ClientError>>()?; + let rpc_bundle_request = RpcBundleRequest { + encoded_transactions, + }; + + params.push(json!([rpc_bundle_request, config])); + } + + let requests_and_params = vec![RpcRequest::SimulateBundle; params.len()] + .into_iter() + .zip(params) + .collect(); + self.send_batch(requests_and_params).await + } + + pub async fn simulate_bundle( + &self, + bundle: &VersionedBundle, + ) -> RpcResult { + self.simulate_bundle_with_config( + bundle, + RpcSimulateBundleConfig { + simulation_bank: Some(SimulationSlotConfig::Commitment(self.commitment())), + pre_execution_accounts_configs: vec![None; bundle.transactions.len()], + post_execution_accounts_configs: vec![None; bundle.transactions.len()], + ..RpcSimulateBundleConfig::default() + }, + ) + .await + } + + pub async fn simulate_bundle_with_config( + &self, + bundle: &VersionedBundle, + config: RpcSimulateBundleConfig, + ) -> RpcResult { + let transaction_encoding = if let Some(enc) = config.transaction_encoding { + enc + } else { + self.default_cluster_transaction_encoding().await? + }; + let simulation_bank = Some(config.simulation_bank.unwrap_or_default()); + + let encoded_transactions = bundle + .transactions + .iter() + .map(|tx| serialize_and_encode::(tx, transaction_encoding)) + .collect::>>()?; + let rpc_bundle_request = RpcBundleRequest { + encoded_transactions, + }; + + let config = RpcSimulateBundleConfig { + transaction_encoding: Some(transaction_encoding), + simulation_bank, + ..config + }; + + self.send( + RpcRequest::SimulateBundle, + json!([rpc_bundle_request, config]), + ) + .await + } + /// Returns the highest slot information that the node has snapshots for. /// /// This will find the highest full snapshot slot, and the highest incremental snapshot slot @@ -5375,6 +5488,22 @@ impl RpcClient { .map_err(|err| ClientError::new_with_request(err.into(), request)) } + pub async fn send_batch( + &self, + requests_and_params: Vec<(RpcRequest, Value)>, + ) -> ClientResult + where + T: serde::de::DeserializeOwned, + { + let response = self.sender.send_batch(requests_and_params).await?; + debug!("response: {:?}", response); + + serde_json::from_value(response).map_err(|err| ClientError { + request: None, + kind: err.into(), + }) + } + pub fn get_transport_stats(&self) -> RpcTransportStats { self.sender.get_transport_stats() } diff --git a/rpc-client/src/rpc_client.rs b/rpc-client/src/rpc_client.rs index afccd7af00..33301ea73c 100644 --- a/rpc-client/src/rpc_client.rs +++ b/rpc-client/src/rpc_client.rs @@ -28,6 +28,7 @@ use { UiAccount, UiAccountEncoding, }, solana_rpc_client_api::{ + bundles::{RpcSimulateBundleConfig, RpcSimulateBundleResult}, client_error::{Error as ClientError, ErrorKind, Result as ClientResult}, config::{RpcAccountInfoConfig, *}, request::{RpcRequest, TokenAccountsFilter}, @@ -35,6 +36,7 @@ use { }, solana_sdk::{ account::{Account, ReadableAccount}, + bundle::VersionedBundle, clock::{Epoch, Slot, UnixTimestamp}, commitment_config::CommitmentConfig, epoch_info::EpochInfo, @@ -1151,6 +1153,34 @@ impl RpcClient { ) } + pub fn batch_simulate_bundle( + &self, + bundles: &[VersionedBundle], + ) -> BatchRpcResult { + self.invoke(self.rpc_client.batch_simulate_bundle(bundles)) + } + + pub fn batch_simulate_bundle_with_config( + &self, + bundles_and_configs: Vec<(&VersionedBundle, RpcSimulateBundleConfig)>, + ) -> BatchRpcResult { + self.invoke( + (self.rpc_client.as_ref()).batch_simulate_bundle_with_config(bundles_and_configs), + ) + } + + pub fn simulate_bundle(&self, bundle: &VersionedBundle) -> RpcResult { + self.invoke((self.rpc_client.as_ref()).simulate_bundle(bundle)) + } + + pub fn simulate_bundle_with_config( + &self, + bundle: &VersionedBundle, + config: RpcSimulateBundleConfig, + ) -> RpcResult { + self.invoke((self.rpc_client.as_ref()).simulate_bundle_with_config(bundle, config)) + } + /// Returns the highest slot information that the node has snapshots for. /// /// This will find the highest full snapshot slot, and the highest incremental snapshot slot diff --git a/rpc-client/src/rpc_sender.rs b/rpc-client/src/rpc_sender.rs index 948ac45a46..6a357b4e6b 100644 --- a/rpc-client/src/rpc_sender.rs +++ b/rpc-client/src/rpc_sender.rs @@ -31,6 +31,10 @@ pub trait RpcSender { request: RpcRequest, params: serde_json::Value, ) -> Result; + async fn send_batch( + &self, + requests_and_params: Vec<(RpcRequest, serde_json::Value)>, + ) -> Result; fn get_transport_stats(&self) -> RpcTransportStats; fn url(&self) -> String; } diff --git a/rpc-test/Cargo.toml b/rpc-test/Cargo.toml index 578757a571..41724e1197 100644 --- a/rpc-test/Cargo.toml +++ b/rpc-test/Cargo.toml @@ -33,6 +33,7 @@ solana-transaction-status = { workspace = true } tokio = { version = "~1.14.1", features = ["full"] } [dev-dependencies] +serial_test = { workspace = true } solana-logger = { workspace = true } [package.metadata.docs.rs] diff --git a/rpc-test/tests/rpc.rs b/rpc-test/tests/rpc.rs index f1c2d4acb9..e7f85e2285 100644 --- a/rpc-test/tests/rpc.rs +++ b/rpc-test/tests/rpc.rs @@ -5,6 +5,7 @@ use { log::*, reqwest::{self, header::CONTENT_TYPE}, serde_json::{json, Value}, + serial_test::serial, solana_account_decoder::UiAccount, solana_client::{ connection_cache::ConnectionCache, @@ -241,6 +242,7 @@ fn test_rpc_slot_updates() { } #[test] +#[serial] // helps test pass fn test_rpc_subscriptions() { solana_logger::setup(); diff --git a/rpc/Cargo.toml b/rpc/Cargo.toml index 00c8c4a043..4f26d72e20 100644 --- a/rpc/Cargo.toml +++ b/rpc/Cargo.toml @@ -30,6 +30,7 @@ serde_derive = { workspace = true } serde_json = { workspace = true } soketto = { workspace = true } solana-account-decoder = { workspace = true } +solana-bundle = { workspace = true } solana-client = { workspace = true } solana-entry = { workspace = true } solana-faucet = { workspace = true } @@ -39,6 +40,7 @@ solana-measure = { workspace = true } solana-metrics = { workspace = true } solana-perf = { workspace = true } solana-poh = { workspace = true } +solana-program-runtime = { workspace = true } solana-rayon-threadlimit = { workspace = true } solana-rpc-client-api = { workspace = true } solana-runtime = { workspace = true } diff --git a/rpc/src/rpc.rs b/rpc/src/rpc.rs index 287aeac0c6..554850e122 100644 --- a/rpc/src/rpc.rs +++ b/rpc/src/rpc.rs @@ -225,6 +225,13 @@ impl JsonRpcRequestProcessor { Ok(bank) } + fn bank_from_slot(&self, slot: Slot) -> Option> { + debug!("Slot: {:?}", slot); + + let r_bank_forks = self.bank_forks.read().unwrap(); + r_bank_forks.get(slot) + } + #[allow(deprecated)] fn bank(&self, commitment: Option) -> Arc { debug!("RPC commitment_config: {:?}", commitment); @@ -359,13 +366,10 @@ impl JsonRpcRequestProcessor { ); ClusterInfo::new(contact_info, keypair, socket_addr_space) }); - let tpu_address = cluster_info - .my_contact_info() - .tpu(connection_cache.protocol()) - .unwrap(); + let (sender, receiver) = unbounded(); SendTransactionService::new::( - tpu_address, + cluster_info.clone(), &bank_forks, None, receiver, @@ -2654,13 +2658,16 @@ pub mod rpc_minimal { }) .unwrap(); - let full_snapshot_slot = - snapshot_utils::get_highest_full_snapshot_archive_slot(full_snapshot_archives_dir) - .ok_or(RpcCustomError::NoSnapshot)?; + let full_snapshot_slot = snapshot_utils::get_highest_full_snapshot_archive_slot( + full_snapshot_archives_dir, + None, + ) + .ok_or(RpcCustomError::NoSnapshot)?; let incremental_snapshot_slot = snapshot_utils::get_highest_incremental_snapshot_archive_slot( incremental_snapshot_archives_dir, full_snapshot_slot, + None, ); Ok(RpcSnapshotSlotInfo { @@ -3266,13 +3273,168 @@ pub mod rpc_accounts_scan { } } +pub mod utils { + use { + crate::rpc::encode_account, + jsonrpc_core::Error, + solana_account_decoder::{UiAccount, UiAccountEncoding}, + solana_bundle::{ + bundle_execution::{LoadAndExecuteBundleError, LoadAndExecuteBundleOutput}, + BundleExecutionError, + }, + solana_rpc_client_api::{ + bundles::{ + RpcBundleExecutionError, RpcBundleSimulationSummary, RpcSimulateBundleConfig, + RpcSimulateBundleResult, RpcSimulateBundleTransactionResult, + }, + config::RpcSimulateTransactionAccountsConfig, + }, + solana_sdk::{account::AccountSharedData, pubkey::Pubkey}, + std::str::FromStr, + }; + + /// Encodes the accounts, returns an error if any of the accounts failed to encode + /// The outer error can be set by error parsing, Ok(None) means there wasn't any accounts in the parameter + fn try_encode_accounts( + accounts: &Option>, + encoding: UiAccountEncoding, + ) -> Result>, Error> { + if let Some(accounts) = accounts { + Ok(Some( + accounts + .iter() + .map(|(pubkey, account)| encode_account(account, pubkey, encoding, None)) + .collect::, Error>>()?, + )) + } else { + Ok(None) + } + } + + pub fn rpc_bundle_result_from_bank_result( + bundle_execution_result: LoadAndExecuteBundleOutput, + rpc_config: RpcSimulateBundleConfig, + ) -> Result { + let summary = match bundle_execution_result.result() { + Ok(_) => RpcBundleSimulationSummary::Succeeded, + Err(e) => { + let tx_signature = match e { + LoadAndExecuteBundleError::TransactionError { signature, .. } + | LoadAndExecuteBundleError::LockError { signature, .. } => { + Some(signature.to_string()) + } + _ => None, + }; + RpcBundleSimulationSummary::Failed { + error: RpcBundleExecutionError::from(BundleExecutionError::TransactionFailure( + e.clone(), + )), + tx_signature, + } + } + }; + + let mut transaction_results = Vec::new(); + for bundle_output in bundle_execution_result.bundle_transaction_results() { + for (index, execution_result) in bundle_output + .execution_results() + .iter() + .enumerate() + .filter(|(_, result)| result.was_executed()) + { + // things are filtered by was_executed, so safe to unwrap here + let result = execution_result.flattened_result(); + let details = execution_result.details().unwrap(); + + let account_config = rpc_config + .pre_execution_accounts_configs + .get(transaction_results.len()) + .ok_or_else(|| Error::invalid_params("the length of pre_execution_accounts_configs must match the number of transactions"))?; + let account_encoding = account_config + .as_ref() + .and_then(|config| config.encoding) + .unwrap_or(UiAccountEncoding::Base64); + + let pre_execution_accounts = if let Some(pre_tx_accounts) = + bundle_output.pre_tx_execution_accounts().get(index) + { + try_encode_accounts(pre_tx_accounts, account_encoding)? + } else { + None + }; + + let post_execution_accounts = if let Some(post_tx_accounts) = + bundle_output.post_tx_execution_accounts().get(index) + { + try_encode_accounts(post_tx_accounts, account_encoding)? + } else { + None + }; + + transaction_results.push(RpcSimulateBundleTransactionResult { + err: match result { + Ok(_) => None, + Err(e) => Some(e), + }, + logs: details.log_messages.clone(), + pre_execution_accounts, + post_execution_accounts, + units_consumed: Some(details.executed_units), + return_data: details.return_data.clone().map(|data| data.into()), + }); + } + } + + Ok(RpcSimulateBundleResult { + summary, + transaction_results, + }) + } + + pub fn account_configs_to_accounts( + accounts_config: &[Option], + ) -> Result>>, Error> { + let mut execution_accounts = Vec::new(); + for account_config in accounts_config { + let accounts = match account_config { + None => None, + Some(account_config) => Some( + account_config + .addresses + .iter() + .map(|a| { + Pubkey::from_str(a).map_err(|_| { + Error::invalid_params(format!("invalid pubkey provided: {}", a)) + }) + }) + .collect::, Error>>()?, + ), + }; + execution_accounts.push(accounts); + } + Ok(execution_accounts) + } +} + // Full RPC interface that an API node is expected to provide // (rpc_minimal should also be provided by an API node) pub mod rpc_full { use { super::*, - solana_sdk::message::{SanitizedVersionedMessage, VersionedMessage}, + crate::rpc::utils::{account_configs_to_accounts, rpc_bundle_result_from_bank_result}, + jsonrpc_core::ErrorCode, + solana_bundle::bundle_execution::{load_and_execute_bundle, LoadAndExecuteBundleError}, + solana_rpc_client_api::bundles::{ + RpcBundleRequest, RpcSimulateBundleConfig, RpcSimulateBundleResult, + SimulationSlotConfig, + }, + solana_sdk::{ + bundle::{derive_bundle_id, SanitizedBundle}, + clock::MAX_PROCESSING_AGE, + message::{SanitizedVersionedMessage, VersionedMessage}, + }, }; + #[rpc] pub trait Full { type Metadata; @@ -3334,6 +3496,14 @@ pub mod rpc_full { config: Option, ) -> Result>; + #[rpc(meta, name = "simulateBundle")] + fn simulate_bundle( + &self, + meta: Self::Metadata, + rpc_bundle_request: RpcBundleRequest, + config: Option, + ) -> Result>; + #[rpc(meta, name = "minimumLedgerSlot")] fn minimum_ledger_slot(&self, meta: Self::Metadata) -> Result; @@ -3822,6 +3992,146 @@ pub mod rpc_full { )) } + // TODO (LB): probably want to add a max transaction size and max account return size and max + // allowable simulation time + fn simulate_bundle( + &self, + meta: Self::Metadata, + rpc_bundle_request: RpcBundleRequest, + config: Option, + ) -> Result> { + const MAX_BUNDLE_SIMULATION_TIME: Duration = Duration::from_millis(500); + + debug!("simulate_bundle rpc request received"); + + let config = config.unwrap_or_else(|| RpcSimulateBundleConfig { + pre_execution_accounts_configs: vec![ + None; + rpc_bundle_request.encoded_transactions.len() + ], + post_execution_accounts_configs: vec![ + None; + rpc_bundle_request.encoded_transactions.len() + ], + ..RpcSimulateBundleConfig::default() + }); + + // Run some request validations + if !(config.pre_execution_accounts_configs.len() + == rpc_bundle_request.encoded_transactions.len() + && config.post_execution_accounts_configs.len() + == rpc_bundle_request.encoded_transactions.len()) + { + return Err(Error::invalid_params( + "pre/post_execution_accounts_configs must be equal in length to the number of transactions", + )); + } + + let bank = match config.simulation_bank.unwrap_or_default() { + SimulationSlotConfig::Commitment(commitment) => Ok(meta.bank(Some(commitment))), + SimulationSlotConfig::Slot(slot) => meta.bank_from_slot(slot).ok_or_else(|| { + Error::invalid_params(format!("bank not found for the provided slot: {}", slot)) + }), + SimulationSlotConfig::Tip => Ok(meta.bank_forks.read().unwrap().working_bank()), + }?; + + let tx_encoding = config + .transaction_encoding + .unwrap_or(UiTransactionEncoding::Base64); + let binary_encoding = tx_encoding.into_binary_encoding().ok_or_else(|| { + Error::invalid_params(format!( + "Unsupported encoding: {}. Supported encodings are: base58 & base64", + tx_encoding + )) + })?; + let mut decoded_transactions = rpc_bundle_request + .encoded_transactions + .into_iter() + .map(|encoded_tx| { + decode_and_deserialize::(encoded_tx, binary_encoding) + .map(|de| de.1) + }) + .collect::>>()?; + + if config.replace_recent_blockhash { + if !config.skip_sig_verify { + return Err(Error::invalid_params( + "sigVerify may not be used with replaceRecentBlockhash", + )); + } + decoded_transactions.iter_mut().for_each(|tx| { + tx.message.set_recent_blockhash(bank.last_blockhash()); + }); + } + + let bundle_id = derive_bundle_id(&decoded_transactions); + let sanitized_bundle = SanitizedBundle { + transactions: decoded_transactions + .into_iter() + .map(|tx| sanitize_transaction(tx, bank.as_ref())) + .collect::>>()?, + bundle_id, + }; + + if !config.skip_sig_verify { + for tx in &sanitized_bundle.transactions { + verify_transaction(tx, &bank.feature_set)?; + } + } + + let pre_execution_accounts = + account_configs_to_accounts(&config.pre_execution_accounts_configs)?; + let post_execution_accounts = + account_configs_to_accounts(&config.post_execution_accounts_configs)?; + + let bundle_execution_result = load_and_execute_bundle( + &bank, + &sanitized_bundle, + MAX_PROCESSING_AGE, + &MAX_BUNDLE_SIMULATION_TIME, + true, + true, + true, + true, + &None, + true, + None, + &pre_execution_accounts, + &post_execution_accounts, + ); + + // only return error if irrecoverable (timeout or tx malformed) + // bundle execution failures w/ context are returned to client + match bundle_execution_result.result() { + Ok(()) | Err(LoadAndExecuteBundleError::TransactionError { .. }) => {} + Err(LoadAndExecuteBundleError::ProcessingTimeExceeded(elapsed)) => { + let mut error = Error::new(ErrorCode::ServerError(10_000)); + error.message = format!( + "simulation time exceeded max allowed time: {:?}ms", + elapsed.as_millis() + ); + return Err(error); + } + Err(LoadAndExecuteBundleError::InvalidPreOrPostAccounts) => { + return Err(Error::invalid_params("invalid pre or post account data")); + } + Err(LoadAndExecuteBundleError::LockError { + signature, + transaction_error, + }) => { + return Err(Error::invalid_params(format!( + "error locking transaction with signature: {}, error: {:?}", + signature, transaction_error + ))); + } + } + + let rpc_bundle_result = + rpc_bundle_result_from_bank_result(bundle_execution_result, config)?; + + Ok(new_response(&bank, rpc_bundle_result)) + } + fn minimum_ledger_slot(&self, meta: Self::Metadata) -> Result { debug!("minimum_ledger_slot rpc request received"); meta.minimum_ledger_slot() @@ -4152,6 +4462,7 @@ pub mod rpc_deprecated_v1_9 { .and_then(|snapshot_config| { snapshot_utils::get_highest_full_snapshot_archive_slot( snapshot_config.full_snapshot_archives_dir, + None, ) }) .ok_or_else(|| RpcCustomError::NoSnapshot.into()) @@ -4643,6 +4954,7 @@ pub mod tests { }, rpc_subscriptions::RpcSubscriptions, }, + base64::engine::general_purpose, bincode::deserialize, jsonrpc_core::{futures, ErrorCode, MetaIoHandler, Output, Response, Value}, jsonrpc_core_client::transports::local, @@ -5855,6 +6167,146 @@ pub mod tests { assert_eq!(result.len(), 0); } + #[test] + fn test_rpc_simulate_bundle_happy_path() { + // 1. setup + let rpc = RpcHandler::start(); + let bank = rpc.working_bank(); + + let recent_blockhash = bank.confirmed_last_blockhash(); + let RpcHandler { + ref meta, ref io, .. + } = rpc; + + let data_len = 100; + let lamports = bank.get_minimum_balance_for_rent_exemption(data_len); + let leader_pubkey = solana_sdk::pubkey::new_rand(); + let leader_account_data = AccountSharedData::new(lamports, data_len, &system_program::id()); + bank.store_account(&leader_pubkey, &leader_account_data); + bank.freeze(); + + // 2. build bundle + + // let's pretend the RPC keypair is a searcher + let searcher_keypair = rpc.mint_keypair; + + // create tip tx + let tip_amount = 10000; + let tip_tx = VersionedTransaction::from(system_transaction::transfer( + &searcher_keypair, + &leader_pubkey, + tip_amount, + recent_blockhash, + )); + + // some random mev tx + let mev_amount = 20000; + let goku_pubkey = solana_sdk::pubkey::new_rand(); + let mev_tx = VersionedTransaction::from(system_transaction::transfer( + &searcher_keypair, + &goku_pubkey, + mev_amount, + recent_blockhash, + )); + + let encoded_mev_tx = general_purpose::STANDARD.encode(serialize(&mev_tx).unwrap()); + let encoded_tip_tx = general_purpose::STANDARD.encode(serialize(&tip_tx).unwrap()); + let b64_data = general_purpose::STANDARD.encode(leader_account_data.data()); + + // 3. test and assert + let skip_sig_verify = true; + let replace_recent_blockhash = false; + let expected_response = json!({ + "jsonrpc": "2.0", + "result": { + "context": {"slot": bank.slot(), "apiVersion": RpcApiVersion::default()}, + "value":{ + "summary": "succeeded", + "transactionResults": [ + { + "err": null, + "logs": ["Program 11111111111111111111111111111111 invoke [1]", "Program 11111111111111111111111111111111 success"], + "returnData": null, + "unitsConsumed": 150, + "postExecutionAccounts": [], + "preExecutionAccounts": [ + { + "data": [b64_data, "base64"], + "executable": false, + "lamports": leader_account_data.lamports(), + "owner": "11111111111111111111111111111111", + "rentEpoch": 0, + "space": 100 + } + ], + }, + { + "err": null, + "logs": ["Program 11111111111111111111111111111111 invoke [1]", "Program 11111111111111111111111111111111 success"], + "returnData": null, + "unitsConsumed": 150, + "preExecutionAccounts": [], + "postExecutionAccounts": [ + { + "data": [b64_data, "base64"], + "executable": false, + "lamports": leader_account_data.lamports() + tip_amount, + "owner": "11111111111111111111111111111111", + "rentEpoch": u64::MAX, + "space": 100 + } + ], + }, + ], + } + }, + "id": 1, + }); + + let request = format!( + r#"{{"jsonrpc":"2.0", + "id":1, + "method":"simulateBundle", + "params":[ + {{ + "encodedTransactions": ["{}", "{}"] + }}, + {{ + "skipSigVerify": {}, + "replaceRecentBlockhash": {}, + "slot": {}, + "preExecutionAccountsConfigs": [ + {{ "encoding": "base64", "addresses": ["{}"] }}, + {{ "encoding": "base64", "addresses": [] }} + ], + "postExecutionAccountsConfigs": [ + {{ "encoding": "base64", "addresses": [] }}, + {{ "encoding": "base64", "addresses": ["{}"] }} + ] + }} + ] + }}"#, + encoded_mev_tx, + encoded_tip_tx, + skip_sig_verify, + replace_recent_blockhash, + bank.slot(), + leader_pubkey, + leader_pubkey, + ); + + let actual_response = io + .handle_request_sync(&request, meta.clone()) + .expect("response"); + + let expected_response = serde_json::from_value::(expected_response) + .expect("expected_response deserialization"); + let actual_response = serde_json::from_str::(&actual_response) + .expect("actual_response deserialization"); + + assert_eq!(expected_response, actual_response); + } + #[test] fn test_rpc_simulate_transaction() { let rpc = RpcHandler::start(); @@ -6439,10 +6891,7 @@ pub mod tests { ClusterInfo::new(contact_info, keypair, SocketAddrSpace::Unspecified) }); let connection_cache = Arc::new(ConnectionCache::new("connection_cache_test")); - let tpu_address = cluster_info - .my_contact_info() - .tpu(connection_cache.protocol()) - .unwrap(); + let (meta, receiver) = JsonRpcRequestProcessor::new( JsonRpcConfig::default(), None, @@ -6451,7 +6900,7 @@ pub mod tests { blockstore, validator_exit, health.clone(), - cluster_info, + cluster_info.clone(), Hash::default(), None, optimistically_confirmed_bank, @@ -6463,7 +6912,7 @@ pub mod tests { Arc::new(PrioritizationFeeCache::default()), ); SendTransactionService::new::( - tpu_address, + cluster_info, &bank_forks, None, receiver, @@ -6711,12 +7160,9 @@ pub mod tests { let cluster_info = Arc::new(new_test_cluster_info()); let connection_cache = Arc::new(ConnectionCache::new("connection_cache_test")); - let tpu_address = cluster_info - .my_contact_info() - .tpu(connection_cache.protocol()) - .unwrap(); let optimistically_confirmed_bank = OptimisticallyConfirmedBank::locked_from_bank_forks_root(&bank_forks); + let (request_processor, receiver) = JsonRpcRequestProcessor::new( JsonRpcConfig::default(), None, @@ -6725,7 +7171,7 @@ pub mod tests { blockstore.clone(), validator_exit, RpcHealth::stub(optimistically_confirmed_bank.clone(), blockstore), - cluster_info, + cluster_info.clone(), Hash::default(), None, optimistically_confirmed_bank, @@ -6737,7 +7183,7 @@ pub mod tests { Arc::new(PrioritizationFeeCache::default()), ); SendTransactionService::new::( - tpu_address, + cluster_info, &bank_forks, None, receiver, diff --git a/rpc/src/rpc_service.rs b/rpc/src/rpc_service.rs index 0e84d4ca48..3020f899c8 100644 --- a/rpc/src/rpc_service.rs +++ b/rpc/src/rpc_service.rs @@ -255,6 +255,7 @@ impl RequestMiddleware for RpcRequestMiddleware { let full_snapshot_archive_info = snapshot_utils::get_highest_full_snapshot_archive_info( &snapshot_config.full_snapshot_archives_dir, + None, ); let snapshot_archive_info = if let Some(full_snapshot_archive_info) = full_snapshot_archive_info { @@ -264,6 +265,7 @@ impl RequestMiddleware for RpcRequestMiddleware { snapshot_utils::get_highest_incremental_snapshot_archive_info( &snapshot_config.incremental_snapshot_archives_dir, full_snapshot_archive_info.slot(), + None, ) .map(|incremental_snapshot_archive_info| { incremental_snapshot_archive_info @@ -377,11 +379,6 @@ impl JsonRpcService { LARGEST_ACCOUNTS_CACHE_DURATION, ))); - let tpu_address = cluster_info - .my_contact_info() - .tpu(connection_cache.protocol()) - .map_err(|err| format!("{err}"))?; - // sadly, some parts of our current rpc implemention block the jsonrpc's // _socket-listening_ event loop for too long, due to (blocking) long IO or intesive CPU, // causing no further processing of incoming requests and ultimatily innocent clients timing-out. @@ -478,7 +475,7 @@ impl JsonRpcService { let leader_info = poh_recorder.map(|recorder| ClusterTpuInfo::new(cluster_info.clone(), recorder)); let _send_transaction_service = Arc::new(SendTransactionService::new_with_config( - tpu_address, + cluster_info, &bank_forks, leader_info, receiver, diff --git a/runtime-plugin/Cargo.toml b/runtime-plugin/Cargo.toml new file mode 100644 index 0000000000..531bcf69fe --- /dev/null +++ b/runtime-plugin/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "solana-runtime-plugin" +description = "Solana runtime plugin" +version = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +[dependencies] +crossbeam-channel = { workspace = true } +json5 = { workspace = true } +jsonrpc-core = { workspace = true } +jsonrpc-core-client = { workspace = true, features = ["ipc"] } +jsonrpc-derive = { workspace = true } +jsonrpc-ipc-server = { workspace = true } +jsonrpc-server-utils = { workspace = true } +libloading = { workspace = true } +log = { workspace = true } +solana-runtime = { workspace = true } +solana-sdk = { workspace = true } +thiserror = { workspace = true } diff --git a/runtime-plugin/src/lib.rs b/runtime-plugin/src/lib.rs new file mode 100644 index 0000000000..477af43c9b --- /dev/null +++ b/runtime-plugin/src/lib.rs @@ -0,0 +1,4 @@ +pub mod runtime_plugin; +pub mod runtime_plugin_admin_rpc_service; +pub mod runtime_plugin_manager; +pub mod runtime_plugin_service; diff --git a/runtime-plugin/src/runtime_plugin.rs b/runtime-plugin/src/runtime_plugin.rs new file mode 100644 index 0000000000..7dc0b95fa4 --- /dev/null +++ b/runtime-plugin/src/runtime_plugin.rs @@ -0,0 +1,41 @@ +use { + solana_runtime::{bank_forks::BankForks, commitment::BlockCommitmentCache}, + std::{ + any::Any, + error, + fmt::Debug, + io, + sync::{atomic::AtomicBool, Arc, RwLock}, + }, + thiserror::Error, +}; + +pub type Result = std::result::Result; + +/// Errors returned by plugin calls +#[derive(Error, Debug)] +pub enum RuntimePluginError { + /// Error opening the configuration file; for example, when the file + /// is not found or when the validator process has no permission to read it. + #[error("Error opening config file. Error detail: ({0}).")] + ConfigFileOpenError(#[from] io::Error), + + /// Any custom error defined by the plugin. + #[error("Plugin-defined custom error. Error message: ({0})")] + Custom(Box), + + #[error("Failed to load a runtime plugin")] + FailedToLoadPlugin(#[from] Box), +} + +pub struct PluginDependencies { + pub bank_forks: Arc>, + pub block_commitment_cache: Arc>, + pub exit: Arc, +} + +pub trait RuntimePlugin: Any + Debug + Send + Sync { + fn name(&self) -> &'static str; + fn on_load(&mut self, config_file: &str, dependencies: PluginDependencies) -> Result<()>; + fn on_unload(&mut self); +} diff --git a/runtime-plugin/src/runtime_plugin_admin_rpc_service.rs b/runtime-plugin/src/runtime_plugin_admin_rpc_service.rs new file mode 100644 index 0000000000..fdc33b06c5 --- /dev/null +++ b/runtime-plugin/src/runtime_plugin_admin_rpc_service.rs @@ -0,0 +1,326 @@ +//! RPC interface to dynamically make changes to runtime plugins. + +use { + crossbeam_channel::Sender, + jsonrpc_core::{BoxFuture, ErrorCode, MetaIoHandler, Metadata, Result as JsonRpcResult}, + jsonrpc_core_client::{transports::ipc, RpcError}, + jsonrpc_derive::rpc, + jsonrpc_ipc_server::{ + tokio::{self, sync::oneshot::channel as oneshot_channel}, + RequestContext, ServerBuilder, + }, + jsonrpc_server_utils::tokio::sync::oneshot::Sender as OneShotSender, + log::*, + solana_sdk::exit::Exit, + std::{ + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, RwLock, + }, + }, +}; + +#[derive(Debug)] +pub enum RuntimePluginManagerRpcRequest { + ReloadPlugin { + name: String, + config_file: String, + response_sender: OneShotSender>, + }, + UnloadPlugin { + name: String, + response_sender: OneShotSender>, + }, + LoadPlugin { + config_file: String, + response_sender: OneShotSender>, + }, + ListPlugins { + response_sender: OneShotSender>>, + }, +} + +#[rpc] +pub trait RuntimePluginAdminRpc { + type Metadata; + + #[rpc(meta, name = "reloadPlugin")] + fn reload_plugin( + &self, + meta: Self::Metadata, + name: String, + config_file: String, + ) -> BoxFuture>; + + #[rpc(meta, name = "unloadPlugin")] + fn unload_plugin(&self, meta: Self::Metadata, name: String) -> BoxFuture>; + + #[rpc(meta, name = "loadPlugin")] + fn load_plugin( + &self, + meta: Self::Metadata, + config_file: String, + ) -> BoxFuture>; + + #[rpc(meta, name = "listPlugins")] + fn list_plugins(&self, meta: Self::Metadata) -> BoxFuture>>; +} + +#[derive(Clone)] +pub struct RuntimePluginAdminRpcRequestMetadata { + pub rpc_request_sender: Sender, + pub validator_exit: Arc>, +} + +impl Metadata for RuntimePluginAdminRpcRequestMetadata {} + +fn rpc_path(ledger_path: &Path) -> PathBuf { + #[cfg(target_family = "windows")] + { + // More information about the wackiness of pipe names over at + // https://docs.microsoft.com/en-us/windows/win32/ipc/pipe-names + if let Some(ledger_filename) = ledger_path.file_name() { + PathBuf::from(format!( + "\\\\.\\pipe\\{}-runtime_plugin_admin.rpc", + ledger_filename.to_string_lossy() + )) + } else { + PathBuf::from("\\\\.\\pipe\\runtime_plugin_admin.rpc") + } + } + #[cfg(not(target_family = "windows"))] + { + ledger_path.join("runtime_plugin_admin.rpc") + } +} + +/// Start the Runtime Plugin Admin RPC interface. +pub fn run( + ledger_path: &Path, + metadata: RuntimePluginAdminRpcRequestMetadata, + plugin_exit: Arc, +) { + let rpc_path = rpc_path(ledger_path); + + let event_loop = tokio::runtime::Builder::new_multi_thread() + .thread_name("solRuntimePluginAdminRpc") + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + + std::thread::Builder::new() + .name("solAdminRpc".to_string()) + .spawn(move || { + let mut io = MetaIoHandler::default(); + io.extend_with(RuntimePluginAdminRpcImpl.to_delegate()); + + let validator_exit = metadata.validator_exit.clone(); + + match ServerBuilder::with_meta_extractor(io, move |_req: &RequestContext| { + metadata.clone() + }) + .event_loop_executor(event_loop.handle().clone()) + .start(&format!("{}", rpc_path.display())) + { + Err(e) => { + error!("Unable to start runtime plugin admin rpc service: {e:?}, exiting"); + validator_exit.write().unwrap().exit(); + } + Ok(server) => { + info!("started runtime plugin admin rpc service!"); + let close_handle = server.close_handle(); + let c_plugin_exit = plugin_exit.clone(); + validator_exit + .write() + .unwrap() + .register_exit(Box::new(move || { + close_handle.close(); + c_plugin_exit.store(true, Ordering::Relaxed); + })); + + server.wait(); + plugin_exit.store(true, Ordering::Relaxed); + } + } + }) + .unwrap(); +} + +pub struct RuntimePluginAdminRpcImpl; +impl RuntimePluginAdminRpc for RuntimePluginAdminRpcImpl { + type Metadata = RuntimePluginAdminRpcRequestMetadata; + + fn reload_plugin( + &self, + meta: Self::Metadata, + name: String, + config_file: String, + ) -> BoxFuture> { + Box::pin(async move { + let (response_sender, response_receiver) = oneshot_channel(); + + if meta + .rpc_request_sender + .send(RuntimePluginManagerRpcRequest::ReloadPlugin { + name, + config_file, + response_sender, + }) + .is_err() + { + error!("rpc_request_sender channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + + return Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while sending the request".to_string(), + data: None, + }); + } + + match response_receiver.await { + Err(_) => { + error!("response_receiver channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while awaiting the response" + .to_string(), + data: None, + }) + } + Ok(resp) => resp, + } + }) + } + + fn unload_plugin(&self, meta: Self::Metadata, name: String) -> BoxFuture> { + Box::pin(async move { + let (response_sender, response_receiver) = oneshot_channel(); + + if meta + .rpc_request_sender + .send(RuntimePluginManagerRpcRequest::UnloadPlugin { + name, + response_sender, + }) + .is_err() + { + error!("rpc_request_sender channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + + return Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while sending the request".to_string(), + data: None, + }); + } + + match response_receiver.await { + Err(_) => { + error!("response_receiver channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while awaiting the response" + .to_string(), + data: None, + }) + } + Ok(resp) => resp, + } + }) + } + + fn load_plugin( + &self, + meta: Self::Metadata, + config_file: String, + ) -> BoxFuture> { + Box::pin(async move { + let (response_sender, response_receiver) = oneshot_channel(); + + if meta + .rpc_request_sender + .send(RuntimePluginManagerRpcRequest::LoadPlugin { + config_file, + response_sender, + }) + .is_err() + { + error!("rpc_request_sender channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + + return Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while sending the request".to_string(), + data: None, + }); + } + + match response_receiver.await { + Err(_) => { + error!("response_receiver channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while awaiting the response" + .to_string(), + data: None, + }) + } + Ok(resp) => resp, + } + }) + } + + fn list_plugins(&self, meta: Self::Metadata) -> BoxFuture>> { + Box::pin(async move { + let (response_sender, response_receiver) = oneshot_channel(); + + if meta + .rpc_request_sender + .send(RuntimePluginManagerRpcRequest::ListPlugins { response_sender }) + .is_err() + { + error!("rpc_request_sender channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + + return Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while sending the request".to_string(), + data: None, + }); + } + + match response_receiver.await { + Err(_) => { + error!("response_receiver channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while awaiting the response" + .to_string(), + data: None, + }) + } + Ok(resp) => resp, + } + }) + } +} + +// Connect to the Runtime Plugin RPC interface +pub async fn connect(ledger_path: &Path) -> Result { + let rpc_path = rpc_path(ledger_path); + if !rpc_path.exists() { + Err(RpcError::Client(format!( + "{} does not exist", + rpc_path.display() + ))) + } else { + ipc::connect::<_, gen_client::Client>(&format!("{}", rpc_path.display())).await + } +} diff --git a/runtime-plugin/src/runtime_plugin_manager.rs b/runtime-plugin/src/runtime_plugin_manager.rs new file mode 100644 index 0000000000..af1dcf2cde --- /dev/null +++ b/runtime-plugin/src/runtime_plugin_manager.rs @@ -0,0 +1,275 @@ +use { + crate::runtime_plugin::{PluginDependencies, RuntimePlugin}, + jsonrpc_core::{serde_json, ErrorCode, Result as JsonRpcResult}, + libloading::Library, + log::*, + solana_runtime::{bank_forks::BankForks, commitment::BlockCommitmentCache}, + std::{ + fs::File, + io::Read, + path::{Path, PathBuf}, + sync::{atomic::AtomicBool, Arc, RwLock}, + }, +}; + +#[derive(thiserror::Error, Debug)] +pub enum RuntimePluginManagerError { + #[error("Cannot open the the plugin config file")] + CannotOpenConfigFile(String), + + #[error("Cannot read the the plugin config file")] + CannotReadConfigFile(String), + + #[error("The config file is not in a valid Json format")] + InvalidConfigFileFormat(String), + + #[error("Plugin library path is not specified in the config file")] + LibPathNotSet, + + #[error("Invalid plugin path")] + InvalidPluginPath, + + #[error("Cannot load plugin shared library")] + PluginLoadError(String), + + #[error("The runtime plugin {0} is already loaded shared library")] + PluginAlreadyLoaded(String), + + #[error("The RuntimePlugin on_load method failed")] + PluginStartError(String), +} + +pub struct RuntimePluginManager { + plugins: Vec>, + libs: Vec, + bank_forks: Arc>, + block_commitment_cache: Arc>, + exit: Arc, +} + +impl RuntimePluginManager { + pub fn new( + bank_forks: Arc>, + block_commitment_cache: Arc>, + exit: Arc, + ) -> Self { + Self { + plugins: vec![], + libs: vec![], + bank_forks, + block_commitment_cache, + exit, + } + } + + /// This method allows dynamic loading of a runtime plugin. + /// Adds to the existing list of loaded plugins. + pub(crate) fn load_plugin( + &mut self, + plugin_config_path: impl AsRef, + ) -> JsonRpcResult { + // First load plugin + let (mut new_plugin, new_lib, config_file) = + load_plugin_from_config(plugin_config_path.as_ref()).map_err(|e| { + jsonrpc_core::Error { + code: ErrorCode::InvalidRequest, + message: format!("Failed to load plugin: {e}"), + data: None, + } + })?; + + // Then see if a plugin with this name already exists, if so return Err. + let name = new_plugin.name(); + if self.plugins.iter().any(|plugin| name.eq(plugin.name())) { + return Err(jsonrpc_core::Error { + code: ErrorCode::InvalidRequest, + message: format!( + "There already exists a plugin named {} loaded. Did not load requested plugin", + name, + ), + data: None, + }); + } + + new_plugin + .on_load( + config_file, + PluginDependencies { + bank_forks: self.bank_forks.clone(), + block_commitment_cache: self.block_commitment_cache.clone(), + exit: self.exit.clone(), + }, + ) + .map_err(|on_load_err| jsonrpc_core::Error { + code: ErrorCode::InvalidRequest, + message: format!( + "on_load method of plugin {} failed: {on_load_err}", + new_plugin.name() + ), + data: None, + })?; + + self.plugins.push(new_plugin); + self.libs.push(new_lib); + + Ok(name.to_string()) + } + + /// Unloads the plugins and loaded plugin libraries, making sure to fire + /// their `on_plugin_unload()` methods so they can do any necessary cleanup. + pub(crate) fn unload_all_plugins(&mut self) { + (0..self.plugins.len()).for_each(|idx| { + self.try_drop_plugin(idx); + }); + } + + pub(crate) fn unload_plugin(&mut self, name: &str) -> JsonRpcResult<()> { + // Check if any plugin names match this one + let Some(idx) = self + .plugins + .iter() + .position(|plugin| plugin.name().eq(name)) + else { + // If we don't find one return an error + return Err(jsonrpc_core::error::Error { + code: ErrorCode::InvalidRequest, + message: String::from("The plugin you requested to unload is not loaded"), + data: None, + }); + }; + + // Unload and drop plugin and lib + self.try_drop_plugin(idx); + + Ok(()) + } + + /// Reloads an existing plugin. + pub(crate) fn reload_plugin(&mut self, name: &str, config_file: &str) -> JsonRpcResult<()> { + // Check if any plugin names match this one + let Some(idx) = self + .plugins + .iter() + .position(|plugin| plugin.name().eq(name)) + else { + // If we don't find one return an error + return Err(jsonrpc_core::error::Error { + code: ErrorCode::InvalidRequest, + message: String::from("The plugin you requested to reload is not loaded"), + data: None, + }); + }; + + self.try_drop_plugin(idx); + + // Try to load plugin, library + // SAFETY: It is up to the validator to ensure this is a valid plugin library. + let (mut new_plugin, new_lib, new_parsed_config_file) = + load_plugin_from_config(config_file.as_ref()).map_err(|err| jsonrpc_core::Error { + code: ErrorCode::InvalidRequest, + message: err.to_string(), + data: None, + })?; + + // Attempt to on_load with new plugin + match new_plugin.on_load( + new_parsed_config_file, + PluginDependencies { + bank_forks: self.bank_forks.clone(), + block_commitment_cache: self.block_commitment_cache.clone(), + exit: self.exit.clone(), + }, + ) { + // On success, push plugin and library + Ok(()) => { + self.plugins.push(new_plugin); + self.libs.push(new_lib); + Ok(()) + } + // On failure, return error + Err(err) => Err(jsonrpc_core::error::Error { + code: ErrorCode::InvalidRequest, + message: format!( + "Failed to start new plugin (previous plugin was dropped!): {err}" + ), + data: None, + }), + } + } + + pub(crate) fn list_plugins(&self) -> JsonRpcResult> { + Ok(self.plugins.iter().map(|p| p.name().to_owned()).collect()) + } + + fn try_drop_plugin(&mut self, idx: usize) { + if idx < self.plugins.len() { + let mut plugin = self.plugins.remove(idx); + let lib = self.libs.remove(idx); + drop(lib); + plugin.on_unload(); + } else { + error!("failed to drop plugin: index {idx} out of bounds"); + } + } +} + +fn load_plugin_from_config( + plugin_config_path: &Path, +) -> Result<(Box, Library, &str), RuntimePluginManagerError> { + type PluginConstructor = unsafe fn() -> *mut dyn RuntimePlugin; + use libloading::Symbol; + + let mut file = match File::open(plugin_config_path) { + Ok(file) => file, + Err(err) => { + return Err(RuntimePluginManagerError::CannotOpenConfigFile(format!( + "Failed to open the plugin config file {plugin_config_path:?}, error: {err:?}" + ))); + } + }; + + let mut contents = String::new(); + if let Err(err) = file.read_to_string(&mut contents) { + return Err(RuntimePluginManagerError::CannotReadConfigFile(format!( + "Failed to read the plugin config file {plugin_config_path:?}, error: {err:?}" + ))); + } + + let result: serde_json::Value = match json5::from_str(&contents) { + Ok(value) => value, + Err(err) => { + return Err(RuntimePluginManagerError::InvalidConfigFileFormat(format!( + "The config file {plugin_config_path:?} is not in a valid Json5 format, error: {err:?}" + ))); + } + }; + + let libpath = result["libpath"] + .as_str() + .ok_or(RuntimePluginManagerError::LibPathNotSet)?; + let mut libpath = PathBuf::from(libpath); + if libpath.is_relative() { + let config_dir = plugin_config_path.parent().ok_or_else(|| { + RuntimePluginManagerError::CannotOpenConfigFile(format!( + "Failed to resolve parent of {plugin_config_path:?}", + )) + })?; + libpath = config_dir.join(libpath); + } + + let config_file = plugin_config_path + .as_os_str() + .to_str() + .ok_or(RuntimePluginManagerError::InvalidPluginPath)?; + + let (plugin, lib) = unsafe { + let lib = Library::new(libpath) + .map_err(|e| RuntimePluginManagerError::PluginLoadError(e.to_string()))?; + let constructor: Symbol = lib + .get(b"_create_plugin") + .map_err(|e| RuntimePluginManagerError::PluginLoadError(e.to_string()))?; + (Box::from_raw(constructor()), lib) + }; + + Ok((plugin, lib, config_file)) +} diff --git a/runtime-plugin/src/runtime_plugin_service.rs b/runtime-plugin/src/runtime_plugin_service.rs new file mode 100644 index 0000000000..5fcb625a26 --- /dev/null +++ b/runtime-plugin/src/runtime_plugin_service.rs @@ -0,0 +1,123 @@ +use { + crate::{ + runtime_plugin::RuntimePluginError, + runtime_plugin_admin_rpc_service::RuntimePluginManagerRpcRequest, + runtime_plugin_manager::RuntimePluginManager, + }, + crossbeam_channel::Receiver, + log::{error, info}, + solana_runtime::{bank_forks::BankForks, commitment::BlockCommitmentCache}, + std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, RwLock, + }, + thread::{self, JoinHandle}, + time::Duration, + }, +}; + +pub struct RuntimePluginService { + plugin_manager: Arc>, + rpc_thread: JoinHandle<()>, +} + +impl RuntimePluginService { + pub fn start( + plugin_config_files: &[PathBuf], + rpc_receiver: Receiver, + bank_forks: Arc>, + block_commitment_cache: Arc>, + exit: Arc, + ) -> Result { + let mut plugin_manager = + RuntimePluginManager::new(bank_forks, block_commitment_cache, exit.clone()); + + for config in plugin_config_files { + let name = plugin_manager + .load_plugin(config) + .map_err(|e| RuntimePluginError::FailedToLoadPlugin(e.into()))?; + info!("Loaded Runtime Plugin: {name}"); + } + + let plugin_manager = Arc::new(RwLock::new(plugin_manager)); + let rpc_thread = + Self::start_rpc_request_handler(rpc_receiver, plugin_manager.clone(), exit); + + Ok(Self { + plugin_manager, + rpc_thread, + }) + } + + pub fn join(self) { + if let Err(e) = self.rpc_thread.join() { + error!("error joining rpc thread: {e:?}"); + } + self.plugin_manager.write().unwrap().unload_all_plugins(); + } + + fn start_rpc_request_handler( + rpc_receiver: Receiver, + plugin_manager: Arc>, + exit: Arc, + ) -> JoinHandle<()> { + thread::Builder::new() + .name("solRuntimePluginRpc".to_string()) + .spawn(move || { + const TIMEOUT: Duration = Duration::from_secs(3); + while !exit.load(Ordering::Relaxed) { + if let Ok(request) = rpc_receiver.recv_timeout(TIMEOUT) { + match request { + RuntimePluginManagerRpcRequest::ListPlugins { response_sender } => { + let plugin_list = plugin_manager.read().unwrap().list_plugins(); + if response_sender.send(plugin_list).is_err() { + error!("response_sender channel disconnected"); + return; + } + } + RuntimePluginManagerRpcRequest::ReloadPlugin { + ref name, + ref config_file, + response_sender, + } => { + let reload_result = plugin_manager + .write() + .unwrap() + .reload_plugin(name, config_file); + if response_sender.send(reload_result).is_err() { + error!("response_sender channel disconnected"); + return; + } + } + RuntimePluginManagerRpcRequest::LoadPlugin { + ref config_file, + response_sender, + } => { + let load_result = + plugin_manager.write().unwrap().load_plugin(config_file); + if response_sender.send(load_result).is_err() { + error!("response_sender channel disconnected"); + return; + } + } + RuntimePluginManagerRpcRequest::UnloadPlugin { + ref name, + response_sender, + } => { + let unload_result = + plugin_manager.write().unwrap().unload_plugin(name); + if response_sender.send(unload_result).is_err() { + error!("response_sender channel disconnected"); + return; + } + } + } + } + } + plugin_manager.write().unwrap().unload_all_plugins(); + }) + .unwrap() + } +} diff --git a/runtime/src/account_overrides.rs b/runtime/src/account_overrides.rs index ee8e7ec9e2..d5d3286426 100644 --- a/runtime/src/account_overrides.rs +++ b/runtime/src/account_overrides.rs @@ -4,12 +4,16 @@ use { }; /// Encapsulates overridden accounts, typically used for transaction simulations -#[derive(Default)] +#[derive(Clone, Default)] pub struct AccountOverrides { accounts: HashMap, } impl AccountOverrides { + pub fn upsert_account_overrides(&mut self, other: AccountOverrides) { + self.accounts.extend(other.accounts); + } + pub fn set_account(&mut self, pubkey: &Pubkey, account: Option) { match account { Some(account) => self.accounts.insert(*pubkey, account), diff --git a/runtime/src/accounts.rs b/runtime/src/accounts.rs index 7a7bb7212e..06e2f986cd 100644 --- a/runtime/src/accounts.rs +++ b/runtime/src/accounts.rs @@ -1168,19 +1168,24 @@ impl Accounts { } fn lock_account( - &self, account_locks: &mut AccountLocks, writable_keys: Vec<&Pubkey>, readonly_keys: Vec<&Pubkey>, + additional_read_locks: &HashSet, + additional_write_locks: &HashSet, ) -> Result<()> { for k in writable_keys.iter() { - if account_locks.is_locked_write(k) || account_locks.is_locked_readonly(k) { + if account_locks.is_locked_write(k) + || account_locks.is_locked_readonly(k) + || additional_write_locks.contains(k) + || additional_read_locks.contains(k) + { debug!("Writable account in use: {:?}", k); return Err(TransactionError::AccountInUse); } } for k in readonly_keys.iter() { - if account_locks.is_locked_write(k) { + if account_locks.is_locked_write(k) || additional_write_locks.contains(k) { debug!("Read-only account in use: {:?}", k); return Err(TransactionError::AccountInUse); } @@ -1225,7 +1230,22 @@ impl Accounts { let tx_account_locks_results: Vec> = txs .map(|tx| tx.get_account_locks(tx_account_lock_limit)) .collect(); - self.lock_accounts_inner(tx_account_locks_results) + self.lock_accounts_inner( + tx_account_locks_results, + &HashSet::default(), + &HashSet::default(), + ) + } + + pub fn lock_accounts_sequential_with_results<'a>( + &self, + txs: impl Iterator, + tx_account_lock_limit: usize, + ) -> Vec> { + let tx_account_locks_results: Vec> = txs + .map(|tx| tx.get_account_locks(tx_account_lock_limit)) + .collect(); + self.lock_accounts_sequential_inner(tx_account_locks_results) } #[must_use] @@ -1235,6 +1255,8 @@ impl Accounts { txs: impl Iterator, results: impl Iterator>, tx_account_lock_limit: usize, + additional_read_locks: &HashSet, + additional_write_locks: &HashSet, ) -> Vec> { let tx_account_locks_results: Vec> = txs .zip(results) @@ -1243,28 +1265,74 @@ impl Accounts { Err(err) => Err(err), }) .collect(); - self.lock_accounts_inner(tx_account_locks_results) + self.lock_accounts_inner( + tx_account_locks_results, + additional_read_locks, + additional_write_locks, + ) } #[must_use] fn lock_accounts_inner( &self, tx_account_locks_results: Vec>, + additional_read_locks: &HashSet, + additional_write_locks: &HashSet, ) -> Vec> { let account_locks = &mut self.account_locks.lock().unwrap(); tx_account_locks_results .into_iter() .map(|tx_account_locks_result| match tx_account_locks_result { - Ok(tx_account_locks) => self.lock_account( + Ok(tx_account_locks) => Self::lock_account( account_locks, tx_account_locks.writable, tx_account_locks.readonly, + additional_read_locks, + additional_write_locks, ), Err(err) => Err(err), }) .collect() } + #[must_use] + fn lock_accounts_sequential_inner( + &self, + tx_account_locks_results: Vec>, + ) -> Vec> { + let mut l_account_locks = self.account_locks.lock().unwrap(); + Self::lock_accounts_sequential(&mut l_account_locks, tx_account_locks_results) + } + + pub fn lock_accounts_sequential( + account_locks: &mut AccountLocks, + tx_account_locks_results: Vec>, + ) -> Vec> { + let mut account_in_use_set = false; + tx_account_locks_results + .into_iter() + .map(|tx_account_locks_result| match tx_account_locks_result { + Ok(tx_account_locks) => match account_in_use_set { + true => Err(TransactionError::AccountInUse), + false => { + let locked = Self::lock_account( + account_locks, + tx_account_locks.writable, + tx_account_locks.readonly, + &HashSet::default(), + &HashSet::default(), + ); + if matches!(locked, Err(TransactionError::AccountInUse)) { + account_in_use_set = true; + } + locked + } + }, + Err(err) => Err(err), + }) + .collect() + } + /// Once accounts are unlocked, new transactions that modify that state can enter the pipeline #[allow(clippy::needless_collect)] pub fn unlock_accounts<'a>( @@ -1308,7 +1376,7 @@ impl Accounts { lamports_per_signature: u64, include_slot_in_hash: IncludeSlotInHash, ) { - let (accounts_to_store, transactions) = self.collect_accounts_to_store( + let (accounts_to_store, transactions) = Self::collect_accounts_to_store( txs, res, loaded, @@ -1335,8 +1403,7 @@ impl Accounts { } #[allow(clippy::too_many_arguments)] - fn collect_accounts_to_store<'a>( - &self, + pub fn collect_accounts_to_store<'a>( txs: &'a [SanitizedTransaction], execution_results: &'a [TransactionExecutionResult], load_results: &'a mut [TransactionLoadResult], @@ -1493,6 +1560,7 @@ mod tests { sync::atomic::{AtomicBool, AtomicU64, Ordering}, thread, time, }, + Accounts, }; fn new_sanitized_tx( @@ -3164,6 +3232,8 @@ mod tests { txs.iter(), qos_results.into_iter(), MAX_TX_ACCOUNT_LOCKS, + &HashSet::default(), + &HashSet::default(), ); assert!(results[0].is_ok()); // Read-only account (keypair0) can be referenced multiple times @@ -3285,7 +3355,7 @@ mod tests { } let txs = vec![tx0.clone(), tx1.clone()]; let execution_results = vec![new_execution_result(Ok(()), None); 2]; - let (collected_accounts, transactions) = accounts.collect_accounts_to_store( + let (collected_accounts, transactions) = Accounts::collect_accounts_to_store( &txs, &execution_results, loaded.as_mut_slice(), @@ -3749,7 +3819,7 @@ mod tests { let mut loaded = vec![loaded]; let durable_nonce = DurableNonce::from_blockhash(&Hash::new_unique()); - let accounts = Accounts::new_with_config_for_tests( + let _accounts = Accounts::new_with_config_for_tests( Vec::new(), &ClusterType::Development, AccountSecondaryIndexes::default(), @@ -3763,7 +3833,7 @@ mod tests { )), nonce.as_ref(), )]; - let (collected_accounts, _) = accounts.collect_accounts_to_store( + let (collected_accounts, _) = Accounts::collect_accounts_to_store( &txs, &execution_results, loaded.as_mut_slice(), @@ -3862,7 +3932,7 @@ mod tests { let mut loaded = vec![loaded]; let durable_nonce = DurableNonce::from_blockhash(&Hash::new_unique()); - let accounts = Accounts::new_with_config_for_tests( + let _accounts = Accounts::new_with_config_for_tests( Vec::new(), &ClusterType::Development, AccountSecondaryIndexes::default(), @@ -3876,7 +3946,7 @@ mod tests { )), nonce.as_ref(), )]; - let (collected_accounts, _) = accounts.collect_accounts_to_store( + let (collected_accounts, _) = Accounts::collect_accounts_to_store( &txs, &execution_results, loaded.as_mut_slice(), diff --git a/runtime/src/bank.rs b/runtime/src/bank.rs index d237113474..29656dfc52 100644 --- a/runtime/src/bank.rs +++ b/runtime/src/bank.rs @@ -260,6 +260,7 @@ pub struct BankRc { pub(crate) bank_id_generator: Arc, } +use crate::accounts::AccountLocks; #[cfg(RUSTC_WITH_SPECIALIZATION)] use solana_frozen_abi::abi_example::AbiExample; @@ -359,6 +360,7 @@ impl TransactionExecutionResult { } } +#[derive(Debug)] pub struct LoadAndExecuteTransactionsOutput { pub loaded_transactions: Vec, // Vector of results indicating whether a transaction was executed or could not @@ -400,6 +402,22 @@ impl DurableNonceFee { } } +#[derive(Clone, Debug, PartialEq)] +pub struct AccountData { + pub pubkey: Pubkey, + pub data: AccountSharedData, +} + +#[derive(Clone)] +pub struct BundleTransactionSimulationResult { + pub result: Result<()>, + pub logs: TransactionLogMessages, + pub pre_execution_accounts: Option>, + pub post_execution_accounts: Option>, + pub return_data: Option, + pub units_consumed: u64, +} + pub struct TransactionSimulationResult { pub result: Result<()>, pub logs: TransactionLogMessages, @@ -407,6 +425,7 @@ pub struct TransactionSimulationResult { pub units_consumed: u64, pub return_data: Option, } + pub struct TransactionBalancesSet { pub pre_balances: TransactionBalances, pub post_balances: TransactionBalances, @@ -1005,7 +1024,7 @@ pub struct Bank { inflation: Arc>, /// cache of vote_account and stake_account state for this fork - stakes_cache: StakesCache, + pub stakes_cache: StakesCache, /// staked nodes on epoch boundaries, saved off when a bank.slot() is at /// a leader schedule calculation boundary @@ -3631,17 +3650,61 @@ impl Bank { &'a self, transactions: &'b [SanitizedTransaction], transaction_results: impl Iterator>, + additional_read_locks: &HashSet, + additional_write_locks: &HashSet, ) -> TransactionBatch<'a, 'b> { - // this lock_results could be: Ok, AccountInUse, WouldExceedBlockMaxLimit or WouldExceedAccountMaxLimit let tx_account_lock_limit = self.get_transaction_account_lock_limit(); let lock_results = self.rc.accounts.lock_accounts_with_results( transactions.iter(), transaction_results, tx_account_lock_limit, + additional_read_locks, + additional_write_locks, ); TransactionBatch::new(lock_results, self, Cow::Borrowed(transactions)) } + /// Prepare a locked transaction batch from a list of sanitized transactions, and their cost + /// limited packing status, where transactions will be locked sequentially until the first failure + pub fn prepare_sequential_sanitized_batch_with_results<'a, 'b>( + &'a self, + transactions: &'b [SanitizedTransaction], + ) -> TransactionBatch<'a, 'b> { + // this lock_results could be: Ok, AccountInUse, AccountLoadedTwice, or TooManyAccountLocks + let tx_account_lock_limit = self.get_transaction_account_lock_limit(); + let lock_results = self + .rc + .accounts + .lock_accounts_sequential_with_results(transactions.iter(), tx_account_lock_limit); + TransactionBatch::new(lock_results, self, Cow::Borrowed(transactions)) + } + + /// Prepare a locked transaction batch from a list of sanitized transactions for simulation. + /// This grabs as many sequential account locks that it can without a RW conflict. However, + /// it uses a temporary version of AccountLocks and not the Bank's account locks, so one can + /// use this during simulation on an unfrozen Bank without worrying about impacting the RW + /// lock usage in replay + pub fn prepare_sequential_sanitized_batch_with_results_for_simulation<'a, 'b>( + &'a self, + transactions: &'b [SanitizedTransaction], + ) -> TransactionBatch<'a, 'b> { + let tx_account_lock_limit = self.get_transaction_account_lock_limit(); + let tx_account_locks_results: Vec> = transactions + .iter() + .map(|tx| tx.get_account_locks(tx_account_lock_limit)) + .collect(); + + let mut account_locks = AccountLocks::default(); + let lock_results = + Accounts::lock_accounts_sequential(&mut account_locks, tx_account_locks_results); + let mut batch = TransactionBatch::new(lock_results, self, Cow::Borrowed(transactions)); + // this is required to ensure that accounts aren't unlocked accidentally, which can be problematic during replay. + // more specifically, during process_entries, if the lock counts are accidentally decremented, + // one might end up replaying a block incorrectly + batch.set_needs_unlock(false); + batch + } + /// Prepare a transaction batch without locking accounts for transaction simulation. pub(crate) fn prepare_simulation_batch( &self, @@ -3954,6 +4017,29 @@ impl Bank { } } + pub fn collect_balances_with_cache( + &self, + batch: &TransactionBatch, + account_overrides: Option<&AccountOverrides>, + ) -> TransactionBalances { + let mut balances: TransactionBalances = vec![]; + for transaction in batch.sanitized_transactions() { + let mut transaction_balances: Vec = vec![]; + for account_key in transaction.message().account_keys().iter() { + let balance = match account_overrides { + None => self.get_balance(account_key), + Some(overrides) => match overrides.get(account_key) { + None => self.get_balance(account_key), + Some(account_data) => account_data.lamports(), + }, + }; + transaction_balances.push(balance); + } + balances.push(transaction_balances); + } + balances + } + pub fn load_program(&self, pubkey: &Pubkey) -> Arc { let program = if let Some(program) = self.get_account_with_fixed_root(pubkey) { program @@ -5063,6 +5149,26 @@ impl Bank { } } + pub fn collect_accounts_to_store<'a>( + &self, + txs: &'a [SanitizedTransaction], + res: &'a [TransactionExecutionResult], + loaded: &'a mut [TransactionLoadResult], + ) -> Vec<(&'a Pubkey, &'a AccountSharedData)> { + let (last_blockhash, lamports_per_signature) = + self.last_blockhash_and_lamports_per_signature(); + let durable_nonce = DurableNonce::from_blockhash(&last_blockhash); + Accounts::collect_accounts_to_store( + txs, + res, + loaded, + &self.rent_collector, + &durable_nonce, + lamports_per_signature, + ) + .0 + } + // Distribute collected rent fees for this slot to staked validators (excluding stakers) // according to stake. // diff --git a/runtime/src/cost_tracker.rs b/runtime/src/cost_tracker.rs index 08991a854c..d360d9445a 100644 --- a/runtime/src/cost_tracker.rs +++ b/runtime/src/cost_tracker.rs @@ -107,6 +107,10 @@ impl CostTracker { self.vote_cost_limit = vote_cost_limit; } + pub fn set_block_cost_limit(&mut self, new_limit: u64) { + self.block_cost_limit = new_limit; + } + pub fn try_add(&mut self, tx_cost: &TransactionCost) -> Result { self.would_fit(tx_cost)?; self.add_transaction_cost(tx_cost); @@ -144,6 +148,10 @@ impl CostTracker { self.block_cost } + pub fn block_cost_limit(&self) -> u64 { + self.block_cost_limit + } + pub fn transaction_count(&self) -> u64 { self.transaction_count } diff --git a/runtime/src/snapshot_package.rs b/runtime/src/snapshot_package.rs index 7044ce8496..6abcd4efd3 100644 --- a/runtime/src/snapshot_package.rs +++ b/runtime/src/snapshot_package.rs @@ -230,10 +230,14 @@ pub struct SnapshotPackage { impl SnapshotPackage { pub fn new(accounts_package: AccountsPackage, accounts_hash: AccountsHashEnum) -> Self { let AccountsPackageType::Snapshot(snapshot_type) = accounts_package.package_type else { - panic!("The AccountsPackage must be of type Snapshot in order to make a SnapshotPackage!"); + panic!( + "The AccountsPackage must be of type Snapshot in order to make a SnapshotPackage!" + ); }; let Some(snapshot_info) = accounts_package.snapshot_info else { - panic!("The AccountsPackage must have snapshot info in order to make a SnapshotPackage!"); + panic!( + "The AccountsPackage must have snapshot info in order to make a SnapshotPackage!" + ); }; let snapshot_hash = SnapshotHash::new(&accounts_hash, snapshot_info.epoch_accounts_hash.as_ref()); diff --git a/runtime/src/snapshot_utils.rs b/runtime/src/snapshot_utils.rs index e76de3c636..dd6f69cd04 100644 --- a/runtime/src/snapshot_utils.rs +++ b/runtime/src/snapshot_utils.rs @@ -1487,12 +1487,13 @@ pub fn bank_fields_from_snapshot_archives( incremental_snapshot_archives_dir: impl AsRef, ) -> Result { let full_snapshot_archive_info = - get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir) + get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir, None) .ok_or(SnapshotError::NoSnapshotArchives)?; let incremental_snapshot_archive_info = get_highest_incremental_snapshot_archive_info( &incremental_snapshot_archives_dir, full_snapshot_archive_info.slot(), + None, ); let temp_unpack_dir = TempDir::new()?; @@ -1661,18 +1662,20 @@ pub fn bank_from_latest_snapshot_archives( accounts_db_config: Option, accounts_update_notifier: Option, exit: &Arc, + halt_at_slot: Option, ) -> Result<( Bank, FullSnapshotArchiveInfo, Option, )> { let full_snapshot_archive_info = - get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir) + get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir, halt_at_slot) .ok_or(SnapshotError::NoSnapshotArchives)?; let incremental_snapshot_archive_info = get_highest_incremental_snapshot_archive_info( &incremental_snapshot_archives_dir, full_snapshot_archive_info.slot(), + halt_at_slot, ); info!( @@ -2366,8 +2369,9 @@ pub fn get_incremental_snapshot_archives( /// Get the highest slot of the full snapshot archives in a directory pub fn get_highest_full_snapshot_archive_slot( full_snapshot_archives_dir: impl AsRef, + halt_at_slot: Option, ) -> Option { - get_highest_full_snapshot_archive_info(full_snapshot_archives_dir) + get_highest_full_snapshot_archive_info(full_snapshot_archives_dir, halt_at_slot) .map(|full_snapshot_archive_info| full_snapshot_archive_info.slot()) } @@ -2376,10 +2380,12 @@ pub fn get_highest_full_snapshot_archive_slot( pub fn get_highest_incremental_snapshot_archive_slot( incremental_snapshot_archives_dir: impl AsRef, full_snapshot_slot: Slot, + halt_at_slot: Option, ) -> Option { get_highest_incremental_snapshot_archive_info( incremental_snapshot_archives_dir, full_snapshot_slot, + halt_at_slot, ) .map(|incremental_snapshot_archive_info| incremental_snapshot_archive_info.slot()) } @@ -2387,8 +2393,13 @@ pub fn get_highest_incremental_snapshot_archive_slot( /// Get the path (and metadata) for the full snapshot archive with the highest slot in a directory pub fn get_highest_full_snapshot_archive_info( full_snapshot_archives_dir: impl AsRef, + halt_at_slot: Option, ) -> Option { let mut full_snapshot_archives = get_full_snapshot_archives(full_snapshot_archives_dir); + if let Some(halt_at_slot) = halt_at_slot { + full_snapshot_archives + .retain(|archive| archive.snapshot_archive_info().slot <= halt_at_slot); + } full_snapshot_archives.sort_unstable(); full_snapshot_archives.into_iter().rev().next() } @@ -2398,6 +2409,7 @@ pub fn get_highest_full_snapshot_archive_info( pub fn get_highest_incremental_snapshot_archive_info( incremental_snapshot_archives_dir: impl AsRef, full_snapshot_slot: Slot, + halt_at_slot: Option, ) -> Option { // Since we want to filter down to only the incremental snapshot archives that have the same // full snapshot slot as the value passed in, perform the filtering before sorting to avoid @@ -2409,6 +2421,9 @@ pub fn get_highest_incremental_snapshot_archive_info( incremental_snapshot_archive_info.base_slot() == full_snapshot_slot }) .collect::>(); + if let Some(halt_at_slot) = halt_at_slot { + incremental_snapshot_archives.retain(|archive| archive.slot() <= halt_at_slot); + } incremental_snapshot_archives.sort_unstable(); incremental_snapshot_archives.into_iter().rev().next() } @@ -4031,7 +4046,7 @@ mod tests { ); assert_eq!( - get_highest_full_snapshot_archive_slot(full_snapshot_archives_dir.path()), + get_highest_full_snapshot_archive_slot(full_snapshot_archives_dir.path(), None), Some(max_slot - 1) ); } @@ -4057,7 +4072,8 @@ mod tests { assert_eq!( get_highest_incremental_snapshot_archive_slot( incremental_snapshot_archives_dir.path(), - full_snapshot_slot + full_snapshot_slot, + None, ), Some(max_incremental_snapshot_slot - 1) ); @@ -4066,7 +4082,8 @@ mod tests { assert_eq!( get_highest_incremental_snapshot_archive_slot( incremental_snapshot_archives_dir.path(), - max_full_snapshot_slot + max_full_snapshot_slot, + None, ), None ); @@ -4774,6 +4791,7 @@ mod tests { Some(ACCOUNTS_DB_CONFIG_FOR_TESTING), None, &Arc::default(), + None, ) .unwrap(); deserialized_bank.wait_for_initial_accounts_hash_verification_completed_for_tests(); diff --git a/runtime/src/stake_account.rs b/runtime/src/stake_account.rs index e4cd79e48c..7dc5bf7fd0 100644 --- a/runtime/src/stake_account.rs +++ b/runtime/src/stake_account.rs @@ -41,14 +41,14 @@ impl StakeAccount { } #[inline] - pub(crate) fn stake_state(&self) -> &StakeState { + pub fn stake_state(&self) -> &StakeState { &self.stake_state } } impl StakeAccount { #[inline] - pub(crate) fn delegation(&self) -> Delegation { + pub fn delegation(&self) -> Delegation { // Safe to unwrap here because StakeAccount will always // only wrap a stake-state which is a delegation. self.stake_state.delegation().unwrap() diff --git a/runtime/src/stakes.rs b/runtime/src/stakes.rs index 5836073b95..7d943da5fb 100644 --- a/runtime/src/stakes.rs +++ b/runtime/src/stakes.rs @@ -50,17 +50,17 @@ pub enum InvalidCacheEntryReason { WrongOwner, } -type StakeAccount = stake_account::StakeAccount; +pub type StakeAccount = stake_account::StakeAccount; #[derive(Default, Debug, AbiExample)] -pub(crate) struct StakesCache(RwLock>); +pub struct StakesCache(RwLock>); impl StakesCache { pub(crate) fn new(stakes: Stakes) -> Self { Self(RwLock::new(stakes)) } - pub(crate) fn stakes(&self) -> RwLockReadGuard> { + pub fn stakes(&self) -> RwLockReadGuard> { self.0.read().unwrap() } @@ -174,7 +174,7 @@ pub struct Stakes { vote_accounts: VoteAccounts, /// stake_delegations - stake_delegations: ImHashMap, + pub stake_delegations: ImHashMap, /// unused unused: u64, @@ -214,7 +214,7 @@ impl Stakes { /// full account state for respective stake pubkeys. get_account function /// should return the account at the respective slot where stakes where /// cached. - pub(crate) fn new(stakes: &Stakes, get_account: F) -> Result + pub fn new(stakes: &Stakes, get_account: F) -> Result where F: Fn(&Pubkey) -> Option, { @@ -445,7 +445,7 @@ impl Stakes { } } - pub(crate) fn stake_delegations(&self) -> &ImHashMap { + pub fn stake_delegations(&self) -> &ImHashMap { &self.stake_delegations } diff --git a/runtime/src/transaction_batch.rs b/runtime/src/transaction_batch.rs index 5d55acb2ec..d41a5763c2 100644 --- a/runtime/src/transaction_batch.rs +++ b/runtime/src/transaction_batch.rs @@ -1,6 +1,6 @@ use { crate::bank::Bank, - solana_sdk::transaction::{Result, SanitizedTransaction}, + solana_sdk::transaction::{Result, SanitizedTransaction, TransactionError}, std::borrow::Cow, }; @@ -46,6 +46,28 @@ impl<'a, 'b> TransactionBatch<'a, 'b> { pub fn needs_unlock(&self) -> bool { self.needs_unlock } + + /// Bundle locking failed if lock result returns something other than ok or AccountInUse + pub fn check_bundle_lock_results(&self) -> Option<(&SanitizedTransaction, &TransactionError)> { + self.sanitized_transactions() + .iter() + .zip(self.lock_results.iter()) + .find(|(_, lock_result)| { + !matches!(lock_result, Ok(()) | Err(TransactionError::AccountInUse)) + }) + .map(|(transaction, lock_result)| { + ( + transaction, + match lock_result { + Ok(_) => { + // safe here bc the above find will never return Ok + unreachable!() + } + Err(lock_error) => lock_error, + }, + ) + }) + } } // Unlock all locked accounts in destructor. diff --git a/rustfmt.toml b/rustfmt.toml index e26d07f0d8..c7ccd48750 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,2 +1,7 @@ imports_granularity = "One" group_imports = "One" + +ignore = [ + "jito-programs", + "anchor" +] \ No newline at end of file diff --git a/s b/s new file mode 100755 index 0000000000..308133d227 --- /dev/null +++ b/s @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" + +if [ -f .env ]; then + export $(cat .env | grep -v '#' | awk '/=/ {print $1}') +else + echo "Missing .env file" + exit 0 +fi + +echo "Syncing to host: $HOST" + +# sync to build server, ignoring local builds and local/remote dev ledger +rsync -avh --delete --exclude target --exclude docker-output "$SCRIPT_DIR" "$HOST":~/ diff --git a/scripts/increment-cargo-version.sh b/scripts/increment-cargo-version.sh index 1cadfc4bdd..c383f244dd 100755 --- a/scripts/increment-cargo-version.sh +++ b/scripts/increment-cargo-version.sh @@ -23,6 +23,8 @@ ignores=( .cargo target node_modules + jito-programs + anchor ) not_paths=() diff --git a/scripts/run.sh b/scripts/run.sh index a890aa10c1..5bdf46a2d1 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -102,6 +102,10 @@ args=( --identity "$validator_identity" --vote-account "$validator_vote_account" --ledger "$ledgerDir" + --tip-payment-program-pubkey "T1pyyaTNZsKv2WcRAB8oVnk93mLJw2XzjtVYqCsaHqt" + --tip-distribution-program-pubkey "4R3gSG8BpU4t19KYj8CfnbtRpnT8gtk4dvTHxVRwc2r7" + --merkle-root-upload-authority "$validator_identity" + --commission-bps 0 --gossip-port 8001 --full-rpc-api --rpc-port 8899 diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 7e8368d82a..59c88e6531 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -37,6 +37,7 @@ full = [ ] [dependencies] +anchor-lang = { workspace = true } assert_matches = { workspace = true, optional = true } base64 = { workspace = true } bincode = { workspace = true } diff --git a/sdk/src/bundle/mod.rs b/sdk/src/bundle/mod.rs new file mode 100644 index 0000000000..3c02a59f9f --- /dev/null +++ b/sdk/src/bundle/mod.rs @@ -0,0 +1,33 @@ +#![cfg(feature = "full")] + +use { + crate::transaction::{SanitizedTransaction, VersionedTransaction}, + digest::Digest, + itertools::Itertools, + sha2::Sha256, +}; + +#[derive(Debug, PartialEq, Default, Eq, Clone, Serialize, Deserialize)] +pub struct VersionedBundle { + pub transactions: Vec, +} + +#[derive(Clone, Debug)] +pub struct SanitizedBundle { + pub transactions: Vec, + pub bundle_id: String, +} + +pub fn derive_bundle_id(transactions: &[VersionedTransaction]) -> String { + let mut hasher = Sha256::new(); + hasher.update(transactions.iter().map(|tx| tx.signatures[0]).join(",")); + format!("{:x}", hasher.finalize()) +} + +pub fn derive_bundle_id_from_sanitized_transactions( + transactions: &[SanitizedTransaction], +) -> String { + let mut hasher = Sha256::new(); + hasher.update(transactions.iter().map(|tx| tx.signature()).join(",")); + format!("{:x}", hasher.finalize()) +} diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index ef73fe83c2..9f8efdb2cd 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -57,6 +57,7 @@ pub use solana_program::{ pub mod account; pub mod account_utils; +pub mod bundle; pub mod client; pub mod commitment_config; pub mod compute_budget; diff --git a/send-transaction-service/Cargo.toml b/send-transaction-service/Cargo.toml index 71431037f5..247fd4d950 100644 --- a/send-transaction-service/Cargo.toml +++ b/send-transaction-service/Cargo.toml @@ -13,6 +13,7 @@ edition = { workspace = true } crossbeam-channel = { workspace = true } log = { workspace = true } solana-client = { workspace = true } +solana-gossip = { workspace = true } solana-measure = { workspace = true } solana-metrics = { workspace = true } solana-runtime = { workspace = true } @@ -21,6 +22,7 @@ solana-tpu-client = { workspace = true } [dev-dependencies] solana-logger = { workspace = true } +solana-streamer = { workspace = true } [package.metadata.docs.rs] targets = ["x86_64-unknown-linux-gnu"] diff --git a/send-transaction-service/src/send_transaction_service.rs b/send-transaction-service/src/send_transaction_service.rs index 6d5faef296..e312ed0739 100644 --- a/send-transaction-service/src/send_transaction_service.rs +++ b/send-transaction-service/src/send_transaction_service.rs @@ -6,6 +6,7 @@ use { connection_cache::{ConnectionCache, Protocol}, tpu_connection::TpuConnection, }, + solana_gossip::cluster_info::ClusterInfo, solana_measure::measure::Measure, solana_metrics::datapoint_warn, solana_runtime::{bank::Bank, bank_forks::BankForks}, @@ -328,7 +329,7 @@ const SEND_TRANSACTION_METRICS_REPORT_RATE_MS: u64 = 5000; impl SendTransactionService { pub fn new( - tpu_address: SocketAddr, + cluster_info: Arc, bank_forks: &Arc>, leader_info: Option, receiver: Receiver, @@ -343,7 +344,7 @@ impl SendTransactionService { ..Config::default() }; Self::new_with_config( - tpu_address, + cluster_info, bank_forks, leader_info, receiver, @@ -354,7 +355,7 @@ impl SendTransactionService { } pub fn new_with_config( - tpu_address: SocketAddr, + cluster_info: Arc, bank_forks: &Arc>, leader_info: Option, receiver: Receiver, @@ -369,7 +370,7 @@ impl SendTransactionService { let leader_info_provider = Arc::new(Mutex::new(CurrentLeaderInfo::new(leader_info))); let receive_txn_thread = Self::receive_txn_thread( - tpu_address, + cluster_info.clone(), receiver, leader_info_provider.clone(), connection_cache.clone(), @@ -380,7 +381,7 @@ impl SendTransactionService { ); let retry_thread = Self::retry_thread( - tpu_address, + cluster_info, bank_forks.clone(), leader_info_provider, connection_cache.clone(), @@ -398,7 +399,7 @@ impl SendTransactionService { /// Thread responsible for receiving transactions from RPC clients. fn receive_txn_thread( - tpu_address: SocketAddr, + cluster_info: Arc, receiver: Receiver, leader_info_provider: Arc>>, connection_cache: Arc, @@ -459,6 +460,10 @@ impl SendTransactionService { stats .sent_transactions .fetch_add(transactions.len() as u64, Ordering::Relaxed); + let tpu_address = cluster_info + .my_contact_info() + .tpu(connection_cache.protocol()) + .unwrap(); Self::send_transactions_in_batch( &tpu_address, &mut transactions, @@ -505,7 +510,7 @@ impl SendTransactionService { /// Thread responsible for retrying transactions fn retry_thread( - tpu_address: SocketAddr, + cluster_info: Arc, bank_forks: Arc>, leader_info_provider: Arc>>, connection_cache: Arc, @@ -538,7 +543,10 @@ impl SendTransactionService { let bank_forks = bank_forks.read().unwrap(); (bank_forks.root_bank(), bank_forks.working_bank()) }; - + let tpu_address = cluster_info + .my_contact_info() + .tpu(connection_cache.protocol()) + .unwrap(); let _result = Self::process_transactions( &working_bank, &root_bank, @@ -790,27 +798,40 @@ mod test { super::*, crate::tpu_info::NullTpuInfo, crossbeam_channel::{bounded, unbounded}, + solana_gossip::contact_info::ContactInfo, solana_sdk::{ account::AccountSharedData, genesis_config::create_genesis_config, nonce::{self, state::DurableNonce}, pubkey::Pubkey, - signature::Signer, + signature::{Keypair, Signer}, system_program, system_transaction, + timing::timestamp, }, + solana_streamer::socket::SocketAddrSpace, std::ops::Sub, }; + fn new_test_cluster_info() -> Arc { + let keypair = Arc::new(Keypair::new()); + let contact_info = ContactInfo::new_localhost(&keypair.pubkey(), timestamp()); + Arc::new(ClusterInfo::new( + contact_info, + keypair, + SocketAddrSpace::Unspecified, + )) + } + #[test] fn service_exit() { - let tpu_address = "127.0.0.1:0".parse().unwrap(); let bank = Bank::default_for_tests(); let bank_forks = Arc::new(RwLock::new(BankForks::new(bank))); let (sender, receiver) = unbounded(); let connection_cache = Arc::new(ConnectionCache::new("connection_cache_test")); + let cluster_info = new_test_cluster_info(); let send_transaction_service = SendTransactionService::new::( - tpu_address, + cluster_info, &bank_forks, None, receiver, @@ -826,7 +847,7 @@ mod test { #[test] fn validator_exit() { - let tpu_address = "127.0.0.1:0".parse().unwrap(); + let cluster_info = new_test_cluster_info(); let bank = Bank::default_for_tests(); let bank_forks = Arc::new(RwLock::new(BankForks::new(bank))); let (sender, receiver) = bounded(0); @@ -844,7 +865,7 @@ mod test { let exit = Arc::new(AtomicBool::new(false)); let connection_cache = Arc::new(ConnectionCache::new("connection_cache_test")); let _send_transaction_service = SendTransactionService::new::( - tpu_address, + cluster_info, &bank_forks, None, receiver, diff --git a/start b/start new file mode 100755 index 0000000000..c2f35e272a --- /dev/null +++ b/start @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -eu + +SOLANA_CONFIG_DIR=./config + +mkdir -p $SOLANA_CONFIG_DIR +NDEBUG=1 ./multinode-demo/setup.sh +cargo run --release --bin solana-ledger-tool -- -l config/bootstrap-validator/ create-snapshot 0 +NDEBUG=1 ./multinode-demo/faucet.sh diff --git a/start_multi b/start_multi new file mode 100755 index 0000000000..66de0032dc --- /dev/null +++ b/start_multi @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -eu + +SOLANA_KEYGEN="cargo run --release --bin solana-keygen --" +SOLANA_CONFIG_DIR=./config + +if [[ ! -d $SOLANA_CONFIG_DIR ]]; then + echo "New Config! Generating Identities" + mkdir $SOLANA_CONFIG_DIR + $SOLANA_KEYGEN new --no-passphrase -so "$SOLANA_CONFIG_DIR"/a/identity.json + $SOLANA_KEYGEN new --no-passphrase -so "$SOLANA_CONFIG_DIR"/a/stake-account.json + $SOLANA_KEYGEN new --no-passphrase -so "$SOLANA_CONFIG_DIR"/a/vote-account.json + + $SOLANA_KEYGEN new --no-passphrase -so "$SOLANA_CONFIG_DIR"/b/identity.json + $SOLANA_KEYGEN new --no-passphrase -so "$SOLANA_CONFIG_DIR"/b/stake-account.json + $SOLANA_KEYGEN new --no-passphrase -so "$SOLANA_CONFIG_DIR"/b/vote-account.json +fi + +NDEBUG=1 ./multinode-demo/setup.sh \ + --bootstrap-validator \ + "$SOLANA_CONFIG_DIR"/a/identity.json \ + "$SOLANA_CONFIG_DIR"/a/vote-account.json \ + "$SOLANA_CONFIG_DIR"/a/stake-account.json \ + --bootstrap-validator \ + "$SOLANA_CONFIG_DIR"/b/identity.json \ + "$SOLANA_CONFIG_DIR"/b/vote-account.json \ + "$SOLANA_CONFIG_DIR"/b/stake-account.json + +cargo run --bin solana-ledger-tool -- -l config/bootstrap-validator/ create-snapshot 0 +NDEBUG=1 ./multinode-demo/faucet.sh diff --git a/test-validator/src/lib.rs b/test-validator/src/lib.rs index ef3faca4e9..1e6a3ac9f3 100644 --- a/test-validator/src/lib.rs +++ b/test-validator/src/lib.rs @@ -976,6 +976,7 @@ impl TestValidator { DEFAULT_TPU_CONNECTION_POOL_SIZE, config.tpu_enable_udp, config.admin_rpc_service_post_init.clone(), + None, )?); // Needed to avoid panics in `solana-responder-gossip` in tests that create a number of diff --git a/tip-distributor/Cargo.toml b/tip-distributor/Cargo.toml new file mode 100644 index 0000000000..46f90e61ea --- /dev/null +++ b/tip-distributor/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "solana-tip-distributor" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Collection of binaries used to distribute MEV rewards to delegators and validators." +publish = false + +[dependencies] +anchor-lang = { workspace = true } +clap = { version = "4.1.11", features = ["derive", "env"] } +crossbeam-channel = { workspace = true } +env_logger = { workspace = true } +futures = { workspace = true } +gethostname = { workspace = true } +im = { workspace = true } +itertools = { workspace = true } +jito-tip-distribution = { workspace = true } +jito-tip-payment = { workspace = true } +log = { workspace = true } +num-traits = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +solana-client = { workspace = true } +solana-genesis-utils = { workspace = true } +solana-ledger = { workspace = true } +solana-measure = { workspace = true } +solana-merkle-tree = { workspace = true } +solana-metrics = { workspace = true } +solana-program = { workspace = true } +solana-program-runtime = { workspace = true } +solana-rpc-client-api = { workspace = true } +solana-runtime = { workspace = true } +solana-sdk = { workspace = true } +solana-stake-program = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + +[[bin]] +name = "solana-stake-meta-generator" +path = "src/bin/stake-meta-generator.rs" + +[[bin]] +name = "solana-merkle-root-generator" +path = "src/bin/merkle-root-generator.rs" + +[[bin]] +name = "solana-merkle-root-uploader" +path = "src/bin/merkle-root-uploader.rs" + +[[bin]] +name = "solana-claim-mev-tips" +path = "src/bin/claim-mev-tips.rs" diff --git a/tip-distributor/README.md b/tip-distributor/README.md new file mode 100644 index 0000000000..fec682879a --- /dev/null +++ b/tip-distributor/README.md @@ -0,0 +1,52 @@ +# Tip Distributor +This library and collection of binaries are responsible for generating and uploading merkle roots to the on-chain +tip-distribution program found [here](https://github.com/jito-foundation/jito-programs/blob/submodule/tip-payment/programs/tip-distribution/src/lib.rs). + +## Background +Each individual validator is assigned a new PDA per epoch where their share of tips, in lamports, will be stored. +At the end of the epoch it's expected that validators take a commission and then distribute the rest of the funds +to their delegators such that delegators receive rewards proportional to their respective delegations. The distribution +mechanism is via merkle proofs similar to how airdrops work. + +The merkle roots are calculated off-chain and uploaded to the validator's **TipDistributionAccount** PDA. Validators may +elect an account to upload the merkle roots on their behalf. Once uploaded, users can invoke the **claim** instruction +and receive the rewards they're entitled to. Once all funds are claimed by users the validator can close the account and +refunded the rent. + +## Scripts + +### stake-meta-generator + +This script generates a JSON file identifying individual stake delegations to a validator, along with amount of lamports +in each validator's **TipDistributionAccount**. All validators will be contained in the JSON list, regardless of whether +the validator is a participant in the system; participant being indicative of running the jito-solana client to accept tips +having initialized a **TipDistributionAccount** PDA account for the epoch. + +One edge case that we've taken into account is the last validator in an epoch N receives tips but those tips don't get transferred +out into the PDA until some slot in epoch N + 1. Due to this we cannot rely on the bank's state at epoch N for lamports amount +in the PDAs. We use the bank solely to take a snapshot of delegations, but an RPC node to fetch the PDA lamports for more up-to-date data. + +### merkle-root-generator +This script accepts a path to the above JSON file as one of its arguments, and generates a merkle-root into a JSON file. + +### merkle-root-uploader +Uploads the root on-chain. + +### claim-mev-tips +This reads the file outputted by `merkle-root-generator` and finds all eligible accounts to receive mev tips. Transactions +are created and sent to the RPC server. + + +## How it works? +In order to use this library as the merkle root creator one must follow the following steps: +1. Download a ledger snapshot containing the slot of interest, i.e. the last slot in an epoch. The Solana foundation has snapshots that can be found [here](https://console.cloud.google.com/storage/browser/mainnet-beta-ledger-us-ny5). +2. Download the snapshot onto your worker machine (where this script will run). +3. Run `solana-ledger-tool -l ${PATH_TO_LEDGER} create-snapshot ${YOUR_SLOT} ${WHERE_TO_CREATE_SNAPSHOT}` + 1. The snapshot created at `${WHERE_TO_CREATE_SNAPSHOT}` will have the highest slot of `${YOUR_SLOT}`, assuming you downloaded the correct snapshot. +4. Run `stake-meta-generator --ledger-path ${WHERE_TO_CREATE_SNAPSHOT} --tip-distribution-program-id ${PUBKEY} --out-path ${JSON_OUT_PATH} --snapshot-slot ${SLOT} --rpc-url ${URL}` + 1. Note: `${WHERE_TO_CREATE_SNAPSHOT}` must be the same in steps 3 & 4. +5. Run `merkle-root-generator --stake-meta-coll-path ${STAKE_META_COLLECTION_JSON} --rpc-url ${URL} --out-path ${MERKLE_ROOT_PATH}` +6. Run `merkle-root-uploader --out-path ${MERKLE_ROOT_PATH} --keypair-path ${KEYPAIR_PATH} --rpc-url ${URL} --tip-distribution-program-id ${PROGRAM_ID}` +7. Run `solana-claim-mev-tips --merkle-trees-path /solana/ledger/autosnapshot/merkle-tree-221615999.json --rpc-url ${URL} --tip-distribution-program-id ${PROGRAM_ID} --keypair-path ${KEYPAIR_PATH}` + +Voila! diff --git a/tip-distributor/src/bin/claim-mev-tips.rs b/tip-distributor/src/bin/claim-mev-tips.rs new file mode 100644 index 0000000000..e517377c7d --- /dev/null +++ b/tip-distributor/src/bin/claim-mev-tips.rs @@ -0,0 +1,171 @@ +//! This binary claims MEV tips. +use { + clap::Parser, + gethostname::gethostname, + log::*, + solana_metrics::{datapoint_error, datapoint_info, set_host_id}, + solana_sdk::{pubkey::Pubkey, signature::read_keypair_file}, + solana_tip_distributor::{ + claim_mev_workflow::{claim_mev_tips, ClaimMevError}, + read_json_from_file, + reclaim_rent_workflow::reclaim_rent, + GeneratedMerkleTreeCollection, + }, + std::{ + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, + }, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Path to JSON file containing the [GeneratedMerkleTreeCollection] object. + #[arg(long, env)] + merkle_trees_path: PathBuf, + + /// RPC to send transactions through + #[arg(long, env, default_value = "http://localhost:8899")] + rpc_url: String, + + /// Tip distribution program ID + #[arg(long, env)] + tip_distribution_program_id: Pubkey, + + /// Path to keypair + #[arg(long, env)] + keypair_path: PathBuf, + + /// Number of unique connections to the RPC server for sending txns + #[arg(long, env, default_value_t = 128)] + rpc_send_connection_count: u64, + + /// Rate-limits the maximum number of GET requests per RPC connection + #[arg(long, env, default_value_t = 256)] + max_concurrent_rpc_get_reqs: usize, + + /// Number of retries for main claim send loop. Loop is time bounded. + #[arg(long, env, default_value_t = 5)] + max_loop_retries: u64, + + /// Limits how long before send loop runs before stopping. Defaults to 10 mins + #[arg(long, env, default_value_t = 10 * 60)] + max_loop_duration_secs: u64, + + /// Specifies whether to reclaim any rent. + #[arg(long, env, default_value_t = true)] + should_reclaim_rent: bool, + + /// Specifies whether to reclaim rent on behalf of validators from respective TDAs. + #[arg(long, env)] + should_reclaim_tdas: bool, +} + +#[tokio::main] +async fn main() -> Result<(), ClaimMevError> { + env_logger::init(); + gethostname() + .into_string() + .map(set_host_id) + .expect("set hostname"); + let args: Args = Args::parse(); + let keypair = Arc::new(read_keypair_file(&args.keypair_path).expect("read keypair file")); + let merkle_trees: GeneratedMerkleTreeCollection = + read_json_from_file(&args.merkle_trees_path).expect("read GeneratedMerkleTreeCollection"); + let max_loop_duration = Duration::from_secs(args.max_loop_duration_secs); + + info!( + "Starting to claim mev tips for epoch: {}", + merkle_trees.epoch + ); + let start = Instant::now(); + + match claim_mev_tips( + merkle_trees.clone(), + args.rpc_url.clone(), + args.rpc_send_connection_count, + args.max_concurrent_rpc_get_reqs, + &args.tip_distribution_program_id, + keypair.clone(), + args.max_loop_retries, + max_loop_duration, + ) + .await + { + Err(e) => { + datapoint_error!( + "claim_mev_workflow-claim_error", + ("epoch", merkle_trees.epoch, i64), + ("error", 1, i64), + ("err_str", e.to_string(), String), + ( + "merkle_trees_path", + args.merkle_trees_path.to_string_lossy(), + String + ), + ("elapsed_us", start.elapsed().as_micros(), i64), + ); + Err(e) + } + Ok(()) => { + datapoint_info!( + "claim_mev_workflow-claim_completion", + ("epoch", merkle_trees.epoch, i64), + ( + "merkle_trees_path", + args.merkle_trees_path.to_string_lossy(), + String + ), + ("elapsed_us", start.elapsed().as_micros(), i64), + ); + Ok(()) + } + }?; + + if args.should_reclaim_rent { + let start = Instant::now(); + match reclaim_rent( + args.rpc_url, + args.rpc_send_connection_count, + args.tip_distribution_program_id, + keypair, + args.max_loop_retries, + max_loop_duration, + args.should_reclaim_tdas, + ) + .await + { + Err(e) => { + datapoint_error!( + "claim_mev_workflow-reclaim_rent_error", + ("epoch", merkle_trees.epoch, i64), + ("error", 1, i64), + ("err_str", e.to_string(), String), + ( + "merkle_trees_path", + args.merkle_trees_path.to_string_lossy(), + String + ), + ("elapsed_us", start.elapsed().as_micros(), i64), + ); + Err(e) + } + Ok(()) => { + datapoint_info!( + "claim_mev_workflow-reclaim_rent_completion", + ("epoch", merkle_trees.epoch, i64), + ( + "merkle_trees_path", + args.merkle_trees_path.to_string_lossy(), + String + ), + ("elapsed_us", start.elapsed().as_micros(), i64), + ); + Ok(()) + } + }?; + } + solana_metrics::flush(); // sometimes last datapoint doesn't get emitted. this increases likelihood. + Ok(()) +} diff --git a/tip-distributor/src/bin/merkle-root-generator.rs b/tip-distributor/src/bin/merkle-root-generator.rs new file mode 100644 index 0000000000..9f2d0f9a4e --- /dev/null +++ b/tip-distributor/src/bin/merkle-root-generator.rs @@ -0,0 +1,34 @@ +//! This binary generates a merkle tree for each [TipDistributionAccount]; they are derived +//! using a user provided [StakeMetaCollection] JSON file. + +use { + clap::Parser, log::*, + solana_tip_distributor::merkle_root_generator_workflow::generate_merkle_root, + std::path::PathBuf, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Path to JSON file containing the [StakeMetaCollection] object. + #[arg(long, env)] + stake_meta_coll_path: PathBuf, + + /// RPC to send transactions through. Used to validate what's being claimed is equal to TDA balance minus rent. + #[arg(long, env)] + rpc_url: String, + + /// Path to JSON file to get populated with tree node data. + #[arg(long, env)] + out_path: PathBuf, +} + +fn main() { + env_logger::init(); + info!("Starting merkle-root-generator workflow..."); + + let args: Args = Args::parse(); + generate_merkle_root(&args.stake_meta_coll_path, &args.out_path, &args.rpc_url) + .expect("merkle tree produced"); + info!("saved merkle roots to {:?}", args.stake_meta_coll_path); +} diff --git a/tip-distributor/src/bin/merkle-root-uploader.rs b/tip-distributor/src/bin/merkle-root-uploader.rs new file mode 100644 index 0000000000..9000ce66d0 --- /dev/null +++ b/tip-distributor/src/bin/merkle-root-uploader.rs @@ -0,0 +1,54 @@ +use { + clap::Parser, log::info, solana_sdk::pubkey::Pubkey, + solana_tip_distributor::merkle_root_upload_workflow::upload_merkle_root, std::path::PathBuf, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Path to JSON file containing the [StakeMetaCollection] object. + #[arg(long, env)] + merkle_root_path: PathBuf, + + /// The path to the keypair used to sign and pay for the `upload_merkle_root` transactions. + #[arg(long, env)] + keypair_path: PathBuf, + + /// The RPC to send transactions to. + #[arg(long, env)] + rpc_url: String, + + /// Tip distribution program ID + #[arg(long, env)] + tip_distribution_program_id: Pubkey, + + /// Rate-limits the maximum number of requests per RPC connection + #[arg(long, env, default_value_t = 100)] + max_concurrent_rpc_get_reqs: usize, + + /// Number of transactions to send to RPC at a time. + #[arg(long, env, default_value_t = 64)] + txn_send_batch_size: usize, +} + +fn main() { + env_logger::init(); + + let args: Args = Args::parse(); + + info!("starting merkle root uploader..."); + if let Err(e) = upload_merkle_root( + &args.merkle_root_path, + &args.keypair_path, + &args.rpc_url, + &args.tip_distribution_program_id, + args.max_concurrent_rpc_get_reqs, + args.txn_send_batch_size, + ) { + panic!("failed to upload merkle roots: {:?}", e); + } + info!( + "uploaded merkle roots from file {:?}", + args.merkle_root_path + ); +} diff --git a/tip-distributor/src/bin/stake-meta-generator.rs b/tip-distributor/src/bin/stake-meta-generator.rs new file mode 100644 index 0000000000..be7993be02 --- /dev/null +++ b/tip-distributor/src/bin/stake-meta-generator.rs @@ -0,0 +1,67 @@ +//! This binary is responsible for generating a JSON file that contains meta-data about stake +//! & delegations given a ledger snapshot directory. The JSON file is structured as an array +//! of [StakeMeta] objects. + +use { + clap::Parser, + log::*, + solana_sdk::{clock::Slot, pubkey::Pubkey}, + solana_tip_distributor::{self, stake_meta_generator_workflow::generate_stake_meta}, + std::{ + fs::{self}, + path::PathBuf, + process::exit, + }, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Ledger path, where you created the snapshot. + #[arg(long, env, value_parser = Args::ledger_path_parser)] + ledger_path: PathBuf, + + /// The tip-distribution program id. + #[arg(long, env)] + tip_distribution_program_id: Pubkey, + + /// The tip-payment program id. + #[arg(long, env)] + tip_payment_program_id: Pubkey, + + /// Path to JSON file populated with the [StakeMetaCollection] object. + #[arg(long, env)] + out_path: String, + + /// The expected snapshot slot. + #[arg(long, env)] + snapshot_slot: Slot, +} + +impl Args { + fn ledger_path_parser(ledger_path: &str) -> Result { + Ok(fs::canonicalize(ledger_path).unwrap_or_else(|err| { + error!("Unable to access ledger path '{}': {}", ledger_path, err); + exit(1); + })) + } +} + +fn main() { + env_logger::init(); + info!("Starting stake-meta-generator..."); + + let args: Args = Args::parse(); + + if let Err(e) = generate_stake_meta( + &args.ledger_path, + &args.snapshot_slot, + &args.tip_distribution_program_id, + &args.out_path, + &args.tip_payment_program_id, + ) { + error!("error producing stake-meta: {:?}", e); + } else { + info!("produced stake meta"); + } +} diff --git a/tip-distributor/src/claim_mev_workflow.rs b/tip-distributor/src/claim_mev_workflow.rs new file mode 100644 index 0000000000..82cdba3827 --- /dev/null +++ b/tip-distributor/src/claim_mev_workflow.rs @@ -0,0 +1,448 @@ +use { + crate::{ + claim_mev_workflow::ClaimMevError::{ClaimantNotFound, InsufficientBalance, TDANotFound}, + minimum_balance, sign_and_send_transactions_with_retries_multi_rpc, + GeneratedMerkleTreeCollection, TreeNode, + }, + anchor_lang::{AccountDeserialize, InstructionData, ToAccountMetas}, + itertools::Itertools, + jito_tip_distribution::state::{ClaimStatus, Config, TipDistributionAccount}, + log::{debug, error, info}, + solana_client::nonblocking::rpc_client::RpcClient, + solana_metrics::{datapoint_info, datapoint_warn}, + solana_program::{ + fee_calculator::DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE, native_token::LAMPORTS_PER_SOL, + system_program, + }, + solana_sdk::{ + account::Account, + commitment_config::CommitmentConfig, + instruction::Instruction, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, + }, + std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, + }, + thiserror::Error, +}; + +#[derive(Error, Debug)] +pub enum ClaimMevError { + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + JsonError(#[from] serde_json::Error), + + #[error(transparent)] + AnchorError(anchor_lang::error::Error), + + #[error("TDA not found for pubkey: {0:?}")] + TDANotFound(Pubkey), + + #[error("Claim Status not found for pubkey: {0:?}")] + ClaimStatusNotFound(Pubkey), + + #[error("Claimant not found for pubkey: {0:?}")] + ClaimantNotFound(Pubkey), + + #[error(transparent)] + MaxFetchRetriesExceeded(#[from] solana_rpc_client_api::client_error::Error), + + #[error("Failed after {attempts} retries. {remaining_transaction_count} remaining mev claim transactions, {failed_transaction_count} failed requests.",)] + MaxSendTransactionRetriesExceeded { + attempts: u64, + remaining_transaction_count: usize, + failed_transaction_count: usize, + }, + + #[error("Expected to have at least {desired_balance} lamports in {payer:?}. Current balance is {start_balance} lamports. Deposit {sol_to_deposit} SOL to continue.")] + InsufficientBalance { + desired_balance: u64, + payer: Pubkey, + start_balance: u64, + sol_to_deposit: u64, + }, +} + +pub async fn claim_mev_tips( + merkle_trees: GeneratedMerkleTreeCollection, + rpc_url: String, + rpc_send_connection_count: u64, + max_concurrent_rpc_get_reqs: usize, + tip_distribution_program_id: &Pubkey, + keypair: Arc, + max_loop_retries: u64, + max_loop_duration: Duration, +) -> Result<(), ClaimMevError> { + let payer_pubkey = keypair.pubkey(); + let blockhash_rpc_client = Arc::new(RpcClient::new_with_commitment( + rpc_url.clone(), + CommitmentConfig::finalized(), + )); + let rpc_clients = Arc::new( + (0..rpc_send_connection_count) + .map(|_| { + Arc::new(RpcClient::new_with_commitment( + rpc_url.clone(), + CommitmentConfig::confirmed(), + )) + }) + .collect_vec(), + ); + + let tree_nodes = merkle_trees + .generated_merkle_trees + .iter() + .flat_map(|tree| &tree.tree_nodes) + .collect_vec(); + + // fetch all accounts up front + info!( + "Starting to fetch accounts for epoch {}", + merkle_trees.epoch + ); + let tdas = crate::get_batched_accounts( + &blockhash_rpc_client, + max_concurrent_rpc_get_reqs, + merkle_trees + .generated_merkle_trees + .iter() + .map(|tree| tree.tip_distribution_account) + .collect_vec(), + ) + .await + .map_err(ClaimMevError::MaxFetchRetriesExceeded)? + .into_iter() + .filter_map(|(pubkey, maybe_account)| { + let Some(account) = maybe_account else { + datapoint_warn!( + "claim_mev_workflow-account_error", + ("epoch", merkle_trees.epoch, i64), + ("pubkey", pubkey.to_string(), String), + ("account_type", "tip_distribution_account", String), + ("error", 1, i64), + ("err_type", "fetch", String), + ("err_str", "Failed to fetch TipDistributionAccount", String) + ); + return None; + }; + + let account = match TipDistributionAccount::try_deserialize(&mut account.data.as_slice()) { + Ok(a) => a, + Err(e) => { + datapoint_warn!( + "claim_mev_workflow-account_error", + ("epoch", merkle_trees.epoch, i64), + ("pubkey", pubkey.to_string(), String), + ("account_type", "tip_distribution_account", String), + ("error", 1, i64), + ("err_type", "deserialize_tip_distribution_account", String), + ("err_str", e.to_string(), String) + ); + return None; + } + }; + Some((pubkey, account)) + }) + .collect::>(); + + // track balances and account len to make sure account is rent-exempt after transfer + let claimants = crate::get_batched_accounts( + &blockhash_rpc_client, + max_concurrent_rpc_get_reqs, + tree_nodes + .iter() + .map(|tree_node| tree_node.claimant) + .collect_vec(), + ) + .await + .map_err(ClaimMevError::MaxFetchRetriesExceeded)? + .into_iter() + .map(|(pubkey, maybe_account)| { + ( + pubkey, + maybe_account + .map(|account| (account.lamports, account.data.len())) + .unwrap_or_default(), + ) + }) + .collect::>(); + + // Refresh claimants + Try sending txns to RPC + let mut retries = 0; + let mut failed_transaction_count = 0usize; + loop { + let start = Instant::now(); + let claim_statuses = crate::get_batched_accounts( + &blockhash_rpc_client, + max_concurrent_rpc_get_reqs, + tree_nodes + .iter() + .map(|tree_node| tree_node.claim_status_pubkey) + .collect_vec(), + ) + .await + .map_err(ClaimMevError::MaxFetchRetriesExceeded)?; + let account_fetch_elapsed = start.elapsed(); + + let ( + skipped_merkle_root_count, + zero_lamports_count, + already_claimed_count, + below_min_rent_count, + transactions, + ) = build_transactions( + tip_distribution_program_id, + &merkle_trees, + &payer_pubkey, + &tree_nodes, + &tdas, + &claimants, + &claim_statuses, + )?; + datapoint_info!( + "claim_mev_workflow-prepare_transactions", + ("epoch", merkle_trees.epoch, i64), + ("attempt", retries, i64), + ("tree_node_count", tree_nodes.len(), i64), + ("tda_count", tdas.len(), i64), + ("claimant_count", claimants.len(), i64), + ("claim_status_count", claim_statuses.len(), i64), + ("skipped_merkle_root_count", skipped_merkle_root_count, i64), + ("zero_lamports_count", zero_lamports_count, i64), + ("already_claimed_count", already_claimed_count, i64), + ("below_min_rent_count", below_min_rent_count, i64), + ("transaction_count", transactions.len(), i64), + ( + "account_fetch_latency_us", + account_fetch_elapsed.as_micros(), + i64 + ), + ( + "transaction_prepare_latency_us", + start.elapsed().as_micros(), + i64 + ), + ); + + if transactions.is_empty() { + info!("Finished claiming tips after {retries} retries, {failed_transaction_count} failed requests."); + return Ok(()); + } + + if let Some((start_balance, desired_balance, sol_to_deposit)) = is_sufficient_balance( + &payer_pubkey, + &blockhash_rpc_client, + transactions.len() as u64, + ) + .await + { + return Err(InsufficientBalance { + desired_balance, + payer: payer_pubkey, + start_balance, + sol_to_deposit, + }); + } + let transactions_len = transactions.len(); + + info!("Sending {} tip claim transactions. {zero_lamports_count} would transfer zero lamports, {below_min_rent_count} would be below minimum rent", transactions.len()); + let send_start = Instant::now(); + let (remaining_transaction_count, new_failed_transaction_count) = + sign_and_send_transactions_with_retries_multi_rpc( + &keypair, + &blockhash_rpc_client, + &rpc_clients, + transactions, + max_loop_duration, + ) + .await; + failed_transaction_count = + failed_transaction_count.saturating_add(new_failed_transaction_count); + + datapoint_info!( + "claim_mev_workflow-send_transactions", + ("epoch", merkle_trees.epoch, i64), + ("attempt", retries, i64), + ("transaction_count", transactions_len, i64), + ( + "successful_transaction_count", + transactions_len.saturating_sub(remaining_transaction_count), + i64 + ), + ( + "remaining_transaction_count", + remaining_transaction_count, + i64 + ), + ( + "failed_transaction_count", + new_failed_transaction_count, + i64 + ), + ("send_latency_us", send_start.elapsed().as_micros(), i64), + ); + + if retries >= max_loop_retries { + return Err(ClaimMevError::MaxSendTransactionRetriesExceeded { + attempts: max_loop_retries, + remaining_transaction_count, + failed_transaction_count, + }); + } + retries = retries.saturating_add(1); + } +} + +#[allow(clippy::result_large_err)] +fn build_transactions( + tip_distribution_program_id: &Pubkey, + merkle_trees: &GeneratedMerkleTreeCollection, + payer_pubkey: &Pubkey, + tree_nodes: &[&TreeNode], + tdas: &HashMap, + claimants: &HashMap, + claim_statuses: &HashMap>, +) -> Result< + ( + usize, /* skipped_merkle_root_count */ + usize, /* zero_lamports_count */ + usize, /* already_claimed_count */ + usize, /* below_min_rent_count */ + Vec, + ), + ClaimMevError, +> { + let tip_distribution_config = + Pubkey::find_program_address(&[Config::SEED], tip_distribution_program_id).0; + let mut skipped_merkle_root_count: usize = 0; + let mut zero_lamports_count: usize = 0; + let mut already_claimed_count: usize = 0; + let mut below_min_rent_count: usize = 0; + let mut instructions = + Vec::with_capacity(tree_nodes.iter().filter(|node| node.amount > 0).count()); + + // prepare instructions to transfer to all claimants + for tree in &merkle_trees.generated_merkle_trees { + let Some(fetched_tip_distribution_account) = tdas.get(&tree.tip_distribution_account) + else { + return Err(TDANotFound(tree.tip_distribution_account)); + }; + // only claim for ones that have merkle root on-chain + if fetched_tip_distribution_account.merkle_root.is_none() { + info!( + "Merkle root has not uploaded yet. Skipped {} claimants for TDA: {:?}", + tree.tree_nodes.len(), + tree.tip_distribution_account + ); + skipped_merkle_root_count = skipped_merkle_root_count.checked_add(1).unwrap(); + continue; + } + for node in &tree.tree_nodes { + if node.amount == 0 { + zero_lamports_count = zero_lamports_count.checked_add(1).unwrap(); + continue; + } + + // make sure not previously claimed + match claim_statuses.get(&node.claim_status_pubkey) { + Some(None) => {} // expected to not find ClaimStatus account, don't skip + Some(Some(_account)) => { + debug!( + "Claim status account already exists (already paid out). Skipping pubkey: {:?}.", node.claim_status_pubkey, + ); + already_claimed_count = already_claimed_count.checked_add(1).unwrap(); + continue; + } + None => return Err(ClaimantNotFound(node.claim_status_pubkey)), + }; + let Some((current_balance, allocated_bytes)) = claimants.get(&node.claimant) else { + return Err(ClaimantNotFound(node.claimant)); + }; + + // some older accounts can be rent-paying + // any new transfers will need to make the account rent-exempt (runtime enforced) + let new_balance = current_balance.checked_add(node.amount).unwrap(); + let minimum_rent = minimum_balance(*allocated_bytes); + if new_balance < minimum_rent { + debug!("Current balance + claim amount of {new_balance} is less than required rent-exempt of {minimum_rent} for pubkey: {}. Skipping.", node.claimant); + below_min_rent_count = below_min_rent_count.checked_add(1).unwrap(); + continue; + } + instructions.push(Instruction { + program_id: *tip_distribution_program_id, + data: jito_tip_distribution::instruction::Claim { + proof: node.proof.clone().unwrap(), + amount: node.amount, + bump: node.claim_status_bump, + } + .data(), + accounts: jito_tip_distribution::accounts::Claim { + config: tip_distribution_config, + tip_distribution_account: tree.tip_distribution_account, + claimant: node.claimant, + claim_status: node.claim_status_pubkey, + payer: *payer_pubkey, + system_program: system_program::id(), + } + .to_account_metas(None), + }); + } + } + + let transactions = instructions + .into_iter() + .map(|ix| Transaction::new_with_payer(&[ix], Some(payer_pubkey))) + .collect::>(); + Ok(( + skipped_merkle_root_count, + zero_lamports_count, + already_claimed_count, + below_min_rent_count, + transactions, + )) +} + +/// heuristic to make sure we have enough funds to cover the rent costs if epoch has many validators +/// If insufficient funds, returns start balance, desired balance, and amount of sol to deposit +async fn is_sufficient_balance( + payer: &Pubkey, + rpc_client: &RpcClient, + instruction_count: u64, +) -> Option<(u64, u64, u64)> { + let start_balance = rpc_client + .get_balance(payer) + .await + .expect("Failed to get starting balance"); + // most amounts are for 0 lamports. had 1736 non-zero claims out of 164742 + let min_rent_per_claim = rpc_client + .get_minimum_balance_for_rent_exemption(ClaimStatus::SIZE) + .await + .expect("Failed to calculate min rent"); + let desired_balance = instruction_count + .checked_mul( + min_rent_per_claim + .checked_add(DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE) + .unwrap(), + ) + .unwrap(); + if start_balance < desired_balance { + let sol_to_deposit = desired_balance + .checked_sub(start_balance) + .unwrap() + .checked_add(LAMPORTS_PER_SOL) + .unwrap() + .checked_sub(1) + .unwrap() + .checked_div(LAMPORTS_PER_SOL) + .unwrap(); // rounds up to nearest sol + Some((start_balance, desired_balance, sol_to_deposit)) + } else { + None + } +} diff --git a/tip-distributor/src/lib.rs b/tip-distributor/src/lib.rs new file mode 100644 index 0000000000..c914adb376 --- /dev/null +++ b/tip-distributor/src/lib.rs @@ -0,0 +1,1083 @@ +pub mod claim_mev_workflow; +pub mod merkle_root_generator_workflow; +pub mod merkle_root_upload_workflow; +pub mod reclaim_rent_workflow; +pub mod stake_meta_generator_workflow; + +use { + crate::{ + merkle_root_generator_workflow::MerkleRootGeneratorError, + stake_meta_generator_workflow::StakeMetaGeneratorError::CheckedMathError, + }, + anchor_lang::Id, + itertools::Itertools, + jito_tip_distribution::{ + program::JitoTipDistribution, + state::{ClaimStatus, TipDistributionAccount}, + }, + jito_tip_payment::{ + Config, CONFIG_ACCOUNT_SEED, TIP_ACCOUNT_SEED_0, TIP_ACCOUNT_SEED_1, TIP_ACCOUNT_SEED_2, + TIP_ACCOUNT_SEED_3, TIP_ACCOUNT_SEED_4, TIP_ACCOUNT_SEED_5, TIP_ACCOUNT_SEED_6, + TIP_ACCOUNT_SEED_7, + }, + log::*, + rand::prelude::SliceRandom, + serde::{de::DeserializeOwned, Deserialize, Serialize}, + solana_client::{nonblocking::rpc_client::RpcClient, rpc_client::RpcClient as SyncRpcClient}, + solana_merkle_tree::MerkleTree, + solana_metrics::{datapoint_error, datapoint_warn}, + solana_program::{ + instruction::InstructionError, + rent::{ + ACCOUNT_STORAGE_OVERHEAD, DEFAULT_EXEMPTION_THRESHOLD, DEFAULT_LAMPORTS_PER_BYTE_YEAR, + }, + }, + solana_rpc_client_api::{ + client_error::{Error, ErrorKind}, + request::{RpcError, RpcResponseErrorData, MAX_MULTIPLE_ACCOUNTS}, + response::RpcSimulateTransactionResult, + }, + solana_sdk::{ + account::{Account, AccountSharedData, ReadableAccount}, + clock::Slot, + hash::{Hash, Hasher}, + pubkey::Pubkey, + signature::{Keypair, Signature}, + stake_history::Epoch, + transaction::{ + Transaction, + TransactionError::{self}, + }, + }, + std::{ + collections::HashMap, + fs::File, + io::BufReader, + path::PathBuf, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::{Duration, Instant}, + }, + tokio::sync::{RwLock, Semaphore}, +}; + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct GeneratedMerkleTreeCollection { + pub generated_merkle_trees: Vec, + pub bank_hash: String, + pub epoch: Epoch, + pub slot: Slot, +} + +#[derive(Clone, Eq, Debug, Hash, PartialEq, Deserialize, Serialize)] +pub struct GeneratedMerkleTree { + #[serde(with = "pubkey_string_conversion")] + pub tip_distribution_account: Pubkey, + #[serde(with = "pubkey_string_conversion")] + pub merkle_root_upload_authority: Pubkey, + pub merkle_root: Hash, + pub tree_nodes: Vec, + pub max_total_claim: u64, + pub max_num_nodes: u64, +} + +pub struct TipPaymentPubkeys { + config_pda: Pubkey, + tip_pdas: Vec, +} + +fn emit_inconsistent_tree_node_amount_dp( + tree_nodes: &[TreeNode], + tip_distribution_account: &Pubkey, + rpc_client: &SyncRpcClient, +) { + let actual_claims: u64 = tree_nodes.iter().map(|t| t.amount).sum(); + let tda = rpc_client.get_account(tip_distribution_account).unwrap(); + let min_rent = rpc_client + .get_minimum_balance_for_rent_exemption(tda.data.len()) + .unwrap(); + + let expected_claims = tda.lamports.checked_sub(min_rent).unwrap(); + if actual_claims == expected_claims { + return; + } + + if actual_claims > expected_claims { + datapoint_error!( + "tip-distributor", + ( + "actual_claims_exceeded", + format!("tip_distribution_account={tip_distribution_account},actual_claims={actual_claims}, expected_claims={expected_claims}"), + String + ), + ); + } else { + datapoint_warn!( + "tip-distributor", + ( + "actual_claims_below", + format!("tip_distribution_account={tip_distribution_account},actual_claims={actual_claims}, expected_claims={expected_claims}"), + String + ), + ); + } +} + +impl GeneratedMerkleTreeCollection { + pub fn new_from_stake_meta_collection( + stake_meta_coll: StakeMetaCollection, + maybe_rpc_client: Option, + ) -> Result { + let generated_merkle_trees = stake_meta_coll + .stake_metas + .into_iter() + .filter(|stake_meta| stake_meta.maybe_tip_distribution_meta.is_some()) + .filter_map(|stake_meta| { + let mut tree_nodes = match TreeNode::vec_from_stake_meta(&stake_meta) { + Err(e) => return Some(Err(e)), + Ok(maybe_tree_nodes) => maybe_tree_nodes, + }?; + + if let Some(rpc_client) = &maybe_rpc_client { + if let Some(tda) = stake_meta.maybe_tip_distribution_meta.as_ref() { + emit_inconsistent_tree_node_amount_dp( + &tree_nodes[..], + &tda.tip_distribution_pubkey, + rpc_client, + ); + } + } + + let hashed_nodes: Vec<[u8; 32]> = + tree_nodes.iter().map(|n| n.hash().to_bytes()).collect(); + + let tip_distribution_meta = stake_meta.maybe_tip_distribution_meta.unwrap(); + + let merkle_tree = MerkleTree::new(&hashed_nodes[..], true); + let max_num_nodes = tree_nodes.len() as u64; + + for (i, tree_node) in tree_nodes.iter_mut().enumerate() { + tree_node.proof = Some(get_proof(&merkle_tree, i)); + } + + Some(Ok(GeneratedMerkleTree { + max_num_nodes, + tip_distribution_account: tip_distribution_meta.tip_distribution_pubkey, + merkle_root_upload_authority: tip_distribution_meta + .merkle_root_upload_authority, + merkle_root: *merkle_tree.get_root().unwrap(), + tree_nodes, + max_total_claim: tip_distribution_meta.total_tips, + })) + }) + .collect::, MerkleRootGeneratorError>>()?; + + Ok(GeneratedMerkleTreeCollection { + generated_merkle_trees, + bank_hash: stake_meta_coll.bank_hash, + epoch: stake_meta_coll.epoch, + slot: stake_meta_coll.slot, + }) + } +} + +pub fn get_proof(merkle_tree: &MerkleTree, i: usize) -> Vec<[u8; 32]> { + let mut proof = Vec::new(); + let path = merkle_tree.find_path(i).expect("path to index"); + for branch in path.get_proof_entries() { + if let Some(hash) = branch.get_left_sibling() { + proof.push(hash.to_bytes()); + } else if let Some(hash) = branch.get_right_sibling() { + proof.push(hash.to_bytes()); + } else { + panic!("expected some hash at each level of the tree"); + } + } + proof +} + +fn derive_tip_payment_pubkeys(program_id: &Pubkey) -> TipPaymentPubkeys { + let config_pda = Pubkey::find_program_address(&[CONFIG_ACCOUNT_SEED], program_id).0; + let tip_pda_0 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_0], program_id).0; + let tip_pda_1 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_1], program_id).0; + let tip_pda_2 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_2], program_id).0; + let tip_pda_3 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_3], program_id).0; + let tip_pda_4 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_4], program_id).0; + let tip_pda_5 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_5], program_id).0; + let tip_pda_6 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_6], program_id).0; + let tip_pda_7 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_7], program_id).0; + + TipPaymentPubkeys { + config_pda, + tip_pdas: vec![ + tip_pda_0, tip_pda_1, tip_pda_2, tip_pda_3, tip_pda_4, tip_pda_5, tip_pda_6, tip_pda_7, + ], + } +} + +#[derive(Clone, Eq, Debug, Hash, PartialEq, Deserialize, Serialize)] +pub struct TreeNode { + /// The stake account entitled to redeem. + #[serde(with = "pubkey_string_conversion")] + pub claimant: Pubkey, + + /// Pubkey of the ClaimStatus PDA account, this account should be closed to reclaim rent. + #[serde(with = "pubkey_string_conversion")] + pub claim_status_pubkey: Pubkey, + + /// Bump of the ClaimStatus PDA account + pub claim_status_bump: u8, + + #[serde(with = "pubkey_string_conversion")] + pub staker_pubkey: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub withdrawer_pubkey: Pubkey, + + /// The amount this account is entitled to. + pub amount: u64, + + /// The proof associated with this TreeNode + pub proof: Option>, +} + +impl TreeNode { + fn vec_from_stake_meta( + stake_meta: &StakeMeta, + ) -> Result>, MerkleRootGeneratorError> { + if let Some(tip_distribution_meta) = stake_meta.maybe_tip_distribution_meta.as_ref() { + let validator_amount = (tip_distribution_meta.total_tips as u128) + .checked_mul(tip_distribution_meta.validator_fee_bps as u128) + .unwrap() + .checked_div(10_000) + .unwrap() as u64; + let (claim_status_pubkey, claim_status_bump) = Pubkey::find_program_address( + &[ + ClaimStatus::SEED, + &stake_meta.validator_vote_account.to_bytes(), + &tip_distribution_meta.tip_distribution_pubkey.to_bytes(), + ], + &JitoTipDistribution::id(), + ); + let mut tree_nodes = vec![TreeNode { + claimant: stake_meta.validator_vote_account, + claim_status_pubkey, + claim_status_bump, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: validator_amount, + proof: None, + }]; + + let remaining_total_rewards = tip_distribution_meta + .total_tips + .checked_sub(validator_amount) + .unwrap() as u128; + + let total_delegated = stake_meta.total_delegated as u128; + tree_nodes.extend( + stake_meta + .delegations + .iter() + .map(|delegation| { + let amount_delegated = delegation.lamports_delegated as u128; + let reward_amount = (amount_delegated.checked_mul(remaining_total_rewards)) + .unwrap() + .checked_div(total_delegated) + .unwrap(); + let (claim_status_pubkey, claim_status_bump) = Pubkey::find_program_address( + &[ + ClaimStatus::SEED, + &delegation.stake_account_pubkey.to_bytes(), + &tip_distribution_meta.tip_distribution_pubkey.to_bytes(), + ], + &JitoTipDistribution::id(), + ); + Ok(TreeNode { + claimant: delegation.stake_account_pubkey, + claim_status_pubkey, + claim_status_bump, + staker_pubkey: delegation.staker_pubkey, + withdrawer_pubkey: delegation.withdrawer_pubkey, + amount: reward_amount as u64, + proof: None, + }) + }) + .collect::, MerkleRootGeneratorError>>()?, + ); + + Ok(Some(tree_nodes)) + } else { + Ok(None) + } + } + + fn hash(&self) -> Hash { + let mut hasher = Hasher::default(); + hasher.hash(self.claimant.as_ref()); + hasher.hash(self.amount.to_le_bytes().as_ref()); + hasher.result() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct StakeMetaCollection { + /// List of [StakeMeta]. + pub stake_metas: Vec, + + /// base58 encoded tip-distribution program id. + #[serde(with = "pubkey_string_conversion")] + pub tip_distribution_program_id: Pubkey, + + /// Base58 encoded bank hash this object was generated at. + pub bank_hash: String, + + /// Epoch for which this object was generated for. + pub epoch: Epoch, + + /// Slot at which this object was generated. + pub slot: Slot, +} + +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)] +pub struct StakeMeta { + #[serde(with = "pubkey_string_conversion")] + pub validator_vote_account: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub validator_node_pubkey: Pubkey, + + /// The validator's tip-distribution meta if it exists. + pub maybe_tip_distribution_meta: Option, + + /// Delegations to this validator. + pub delegations: Vec, + + /// The total amount of delegations to the validator. + pub total_delegated: u64, + + /// The validator's delegation commission rate as a percentage between 0-100. + pub commission: u8, +} + +impl Ord for StakeMeta { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.validator_vote_account + .cmp(&other.validator_vote_account) + } +} + +impl PartialOrd for StakeMeta { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)] +pub struct TipDistributionMeta { + #[serde(with = "pubkey_string_conversion")] + pub merkle_root_upload_authority: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub tip_distribution_pubkey: Pubkey, + + /// The validator's total tips in the [TipDistributionAccount]. + pub total_tips: u64, + + /// The validator's cut of tips from [TipDistributionAccount], calculated from the on-chain + /// commission fee bps. + pub validator_fee_bps: u16, +} + +impl TipDistributionMeta { + fn from_tda_wrapper( + tda_wrapper: TipDistributionAccountWrapper, + // The amount that will be left remaining in the tda to maintain rent exemption status. + rent_exempt_amount: u64, + ) -> Result { + Ok(TipDistributionMeta { + tip_distribution_pubkey: tda_wrapper.tip_distribution_pubkey, + total_tips: tda_wrapper + .account_data + .lamports() + .checked_sub(rent_exempt_amount) + .ok_or(CheckedMathError)?, + validator_fee_bps: tda_wrapper + .tip_distribution_account + .validator_commission_bps, + merkle_root_upload_authority: tda_wrapper + .tip_distribution_account + .merkle_root_upload_authority, + }) + } +} + +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)] +pub struct Delegation { + #[serde(with = "pubkey_string_conversion")] + pub stake_account_pubkey: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub staker_pubkey: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub withdrawer_pubkey: Pubkey, + + /// Lamports delegated by the stake account + pub lamports_delegated: u64, +} + +impl Ord for Delegation { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + ( + self.stake_account_pubkey, + self.withdrawer_pubkey, + self.staker_pubkey, + self.lamports_delegated, + ) + .cmp(&( + other.stake_account_pubkey, + other.withdrawer_pubkey, + other.staker_pubkey, + other.lamports_delegated, + )) + } +} + +impl PartialOrd for Delegation { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Convenience wrapper around [TipDistributionAccount] +pub struct TipDistributionAccountWrapper { + pub tip_distribution_account: TipDistributionAccount, + pub account_data: AccountSharedData, + pub tip_distribution_pubkey: Pubkey, +} + +// TODO: move to program's sdk +pub fn derive_tip_distribution_account_address( + tip_distribution_program_id: &Pubkey, + vote_pubkey: &Pubkey, + epoch: Epoch, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + TipDistributionAccount::SEED, + vote_pubkey.to_bytes().as_ref(), + epoch.to_le_bytes().as_ref(), + ], + tip_distribution_program_id, + ) +} + +pub const MAX_RETRIES: usize = 5; +pub const FAIL_DELAY: Duration = Duration::from_millis(100); + +/// Returns unprocessed transactions, along with fail count +pub async fn sign_and_send_transactions_with_retries_multi_rpc( + signer: &Arc, + blockhash_rpc_client: &Arc, + rpc_clients: &Arc>>, + mut transactions: Vec, + max_loop_duration: Duration, +) -> ( + usize, /* remaining txn count */ + usize, /* failed txn count */ +) { + let error_count = Arc::new(AtomicUsize::default()); + let blockhash = Arc::new(RwLock::new( + blockhash_rpc_client + .get_latest_blockhash() + .await + .expect("fetch latest blockhash"), + )); + let transactions_receiver = { + let (transactions_sender, transactions_receiver) = crossbeam_channel::unbounded(); + let mut rng = rand::thread_rng(); + transactions.shuffle(&mut rng); // shuffle to avoid racing for the same order of txns as other claim-tip processes + transactions + .into_iter() + .for_each(|txn| transactions_sender.send(txn).unwrap()); + transactions_receiver + }; + let blockhash_refresh_handle = { + let blockhash_rpc_client = blockhash_rpc_client.clone(); + let blockhash = blockhash.clone(); + let transactions_receiver = transactions_receiver.clone(); + tokio::spawn(async move { + let start = Instant::now(); + let mut last_blockhash_update = Instant::now(); + while start.elapsed() < max_loop_duration && !transactions_receiver.is_empty() { + // ensure we always have a recent blockhash + if last_blockhash_update.elapsed() > Duration::from_secs(2) { + let hash = blockhash_rpc_client + .get_latest_blockhash() + .await + .expect("fetch latest blockhash"); + info!( + "Got blockhash {hash:?}. Sending {} transactions to claim mev tips.", + transactions_receiver.len() + ); + *blockhash.write().await = hash; + last_blockhash_update = Instant::now(); + } + } + + info!( + "Exited blockhash refresh thread. {} transactions remain.", + transactions_receiver.len() + ); + transactions_receiver.len() + }) + }; + let send_handles = rpc_clients + .iter() + .map(|rpc_client| { + let signer = signer.clone(); + let transactions_receiver = transactions_receiver.clone(); + let rpc_client = rpc_client.clone(); + let error_count = error_count.clone(); + let blockhash = blockhash.clone(); + tokio::spawn(async move { + let mut iterations = 0usize; + while let Ok(txn) = transactions_receiver.recv() { + let mut retries = 0usize; + while retries < MAX_RETRIES { + iterations = iterations.saturating_add(1); + let (_signed_txn, res) = + signed_send(&signer, &rpc_client, *blockhash.read().await, txn.clone()) + .await; + match res { + Ok(_) => break, + Err(_) => { + retries = retries.saturating_add(1); + error_count.fetch_add(1, Ordering::Relaxed); + tokio::time::sleep(FAIL_DELAY).await; + } + } + } + } + + info!("Exited send thread. Ran {iterations} times."); + }) + }) + .collect_vec(); + + for handle in send_handles { + if let Err(e) = handle.await { + warn!("Error joining handle: {e:?}") + } + } + let remaining_transaction_count = blockhash_refresh_handle.await.unwrap(); + ( + remaining_transaction_count, + error_count.load(Ordering::Relaxed), + ) +} + +pub async fn sign_and_send_transactions_with_retries( + signer: &Keypair, + rpc_client: &RpcClient, + max_concurrent_rpc_get_reqs: usize, + transactions: Vec, + txn_send_batch_size: usize, + max_loop_duration: Duration, +) -> (Vec, HashMap) { + let semaphore = Arc::new(Semaphore::new(max_concurrent_rpc_get_reqs)); + let mut errors = HashMap::default(); + let mut blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("fetch latest blockhash"); + // track unsigned txns + let mut transactions_to_process = transactions + .into_iter() + .map(|txn| (txn.message_data(), txn)) + .collect::, Transaction>>(); + + let start = Instant::now(); + while start.elapsed() < max_loop_duration && !transactions_to_process.is_empty() { + // ensure we always have a recent blockhash + // blockhashes last max 150 blocks + // finalized commitment is ~32 slots behind tip + // assuming 0% skip rate (every slot has a block), we’d have roughly 120 slots + // or (120*0.4s) = 48s to land a tx before it expires + // if we’re refreshing every 30s, then any txs sent immediately before the refresh would likely expire + if start.elapsed() > Duration::from_secs(1) { + blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("fetch latest blockhash"); + } + info!( + "Sending {txn_send_batch_size} of {} transactions to claim mev tips", + transactions_to_process.len() + ); + let send_futs = transactions_to_process + .iter() + .take(txn_send_batch_size) + .map(|(hash, txn)| { + let semaphore = semaphore.clone(); + async move { + let _permit = semaphore.acquire_owned().await.unwrap(); // wait until our turn + let (txn, res) = signed_send(signer, rpc_client, blockhash, txn.clone()).await; + (hash.clone(), txn, res) + } + }); + + let send_res = futures::future::join_all(send_futs).await; + let new_errors = send_res + .into_iter() + .filter_map(|(hash, txn, result)| match result { + Err(e) => Some((txn.signatures[0], e)), + Ok(..) => { + let _ = transactions_to_process.remove(&hash); + None + } + }) + .collect::>(); + + errors.extend(new_errors); + } + + (transactions_to_process.values().cloned().collect(), errors) +} + +/// Just in time sign and send transaction to RPC +async fn signed_send( + signer: &Keypair, + rpc_client: &RpcClient, + blockhash: Hash, + mut txn: Transaction, +) -> (Transaction, solana_rpc_client_api::client_error::Result<()>) { + txn.sign(&[signer], blockhash); // just in time signing + let res = match rpc_client.send_and_confirm_transaction(&txn).await { + Ok(_) => Ok(()), + Err(e) => { + match e.kind { + // Already claimed, skip. + ErrorKind::TransactionError(TransactionError::AlreadyProcessed) + | ErrorKind::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(0), + )) + | ErrorKind::RpcError(RpcError::RpcResponseError { + data: + RpcResponseErrorData::SendTransactionPreflightFailure( + RpcSimulateTransactionResult { + err: + Some(TransactionError::InstructionError( + 0, + InstructionError::Custom(0), + )), + .. + }, + ), + .. + }) => Ok(()), + + // transaction got held up too long and blockhash expired. retry txn + ErrorKind::TransactionError(TransactionError::BlockhashNotFound) => Err(e), + + // unexpected error, warn and retry + _ => { + error!( + "Error sending transaction. Signature: {}, Error: {e:?}", + txn.signatures[0] + ); + Err(e) + } + } + } + }; + + (txn, res) +} + +/// Fetch accounts in parallel batches with retries. +async fn get_batched_accounts( + rpc_client: &RpcClient, + max_concurrent_rpc_get_reqs: usize, + pubkeys: Vec, +) -> solana_rpc_client_api::client_error::Result>> { + let semaphore = Arc::new(Semaphore::new(max_concurrent_rpc_get_reqs)); + let futs = pubkeys.chunks(MAX_MULTIPLE_ACCOUNTS).map(|pubkeys| { + let semaphore = semaphore.clone(); + + async move { + let _permit = semaphore.acquire_owned().await.unwrap(); // wait until our turn + let mut retries = 0usize; + loop { + match rpc_client.get_multiple_accounts(pubkeys).await { + Ok(accts) => return Ok(accts), + Err(e) => { + retries = retries.saturating_add(1); + if retries == MAX_RETRIES { + datapoint_error!( + "claim_mev_workflow-get_batched_accounts_error", + ("pubkeys", format!("{pubkeys:?}"), String), + ("error", 1, i64), + ("err_type", "fetch_account", String), + ("err_str", e.to_string(), String) + ); + return Err(e); + } + tokio::time::sleep(FAIL_DELAY).await; + } + } + } + } + }); + + let claimant_accounts = futures::future::join_all(futs) + .await + .into_iter() + .collect::>>>>()? // fail on single error + .into_iter() + .flatten() + .collect_vec(); + + Ok(pubkeys.into_iter().zip(claimant_accounts).collect()) +} + +/// Calculates the minimum balance needed to be rent-exempt +/// taken from: https://github.com/jito-foundation/jito-solana/blob/d1ba42180d0093dd59480a77132477323a8e3f88/sdk/program/src/rent.rs#L78 +pub fn minimum_balance(data_len: usize) -> u64 { + ((((ACCOUNT_STORAGE_OVERHEAD + .checked_add(data_len as u64) + .unwrap()) + .checked_mul(DEFAULT_LAMPORTS_PER_BYTE_YEAR)) + .unwrap() as f64) + * DEFAULT_EXEMPTION_THRESHOLD) as u64 +} + +mod pubkey_string_conversion { + use { + serde::{self, Deserialize, Deserializer, Serializer}, + solana_sdk::pubkey::Pubkey, + std::str::FromStr, + }; + + pub(crate) fn serialize(pubkey: &Pubkey, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&pubkey.to_string()) + } + + pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Pubkey::from_str(&s).map_err(serde::de::Error::custom) + } +} + +pub fn read_json_from_file(path: &PathBuf) -> serde_json::Result +where + T: DeserializeOwned, +{ + let file = File::open(path).unwrap(); + let reader = BufReader::new(file); + serde_json::from_reader(reader) +} + +#[cfg(test)] +mod tests { + use {super::*, jito_tip_distribution::merkle_proof}; + + #[test] + fn test_merkle_tree_verify() { + // Create the merkle tree and proofs + let tda = Pubkey::new_unique(); + let (acct_0, acct_1) = (Pubkey::new_unique(), Pubkey::new_unique()); + let claim_statuses = &[(acct_0, tda), (acct_1, tda)] + .iter() + .map(|(claimant, tda)| { + Pubkey::find_program_address( + &[ClaimStatus::SEED, &claimant.to_bytes(), &tda.to_bytes()], + &JitoTipDistribution::id(), + ) + }) + .collect::>(); + let tree_nodes = vec![ + TreeNode { + claimant: acct_0, + claim_status_pubkey: claim_statuses[0].0, + claim_status_bump: claim_statuses[0].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 151_507, + proof: None, + }, + TreeNode { + claimant: acct_1, + claim_status_pubkey: claim_statuses[1].0, + claim_status_bump: claim_statuses[1].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 176_624, + proof: None, + }, + ]; + + // First the nodes are hashed and merkle tree constructed + let hashed_nodes: Vec<[u8; 32]> = tree_nodes.iter().map(|n| n.hash().to_bytes()).collect(); + let mk = MerkleTree::new(&hashed_nodes[..], true); + let root = mk.get_root().expect("to have valid root").to_bytes(); + + // verify first node + let node = solana_program::hash::hashv(&[&[0u8], &hashed_nodes[0]]); + let proof = get_proof(&mk, 0); + assert!(merkle_proof::verify(proof, root, node.to_bytes())); + + // verify second node + let node = solana_program::hash::hashv(&[&[0u8], &hashed_nodes[1]]); + let proof = get_proof(&mk, 1); + assert!(merkle_proof::verify(proof, root, node.to_bytes())); + } + + #[test] + fn test_new_from_stake_meta_collection_happy_path() { + let merkle_root_upload_authority = Pubkey::new_unique(); + + let (tda_0, tda_1) = (Pubkey::new_unique(), Pubkey::new_unique()); + + let stake_account_0 = Pubkey::new_unique(); + let stake_account_1 = Pubkey::new_unique(); + let stake_account_2 = Pubkey::new_unique(); + let stake_account_3 = Pubkey::new_unique(); + + let staker_account_0 = Pubkey::new_unique(); + let staker_account_1 = Pubkey::new_unique(); + let staker_account_2 = Pubkey::new_unique(); + let staker_account_3 = Pubkey::new_unique(); + + let validator_vote_account_0 = Pubkey::new_unique(); + let validator_vote_account_1 = Pubkey::new_unique(); + + let validator_id_0 = Pubkey::new_unique(); + let validator_id_1 = Pubkey::new_unique(); + + let stake_meta_collection = StakeMetaCollection { + stake_metas: vec![ + StakeMeta { + validator_vote_account: validator_vote_account_0, + validator_node_pubkey: validator_id_0, + maybe_tip_distribution_meta: Some(TipDistributionMeta { + merkle_root_upload_authority, + tip_distribution_pubkey: tda_0, + total_tips: 1_900_122_111_000, + validator_fee_bps: 100, + }), + delegations: vec![ + Delegation { + stake_account_pubkey: stake_account_0, + staker_pubkey: staker_account_0, + withdrawer_pubkey: staker_account_0, + lamports_delegated: 123_999_123_555, + }, + Delegation { + stake_account_pubkey: stake_account_1, + staker_pubkey: staker_account_1, + withdrawer_pubkey: staker_account_1, + lamports_delegated: 144_555_444_556, + }, + ], + total_delegated: 1_555_123_000_333_454_000, + commission: 100, + }, + StakeMeta { + validator_vote_account: validator_vote_account_1, + validator_node_pubkey: validator_id_1, + maybe_tip_distribution_meta: Some(TipDistributionMeta { + merkle_root_upload_authority, + tip_distribution_pubkey: tda_1, + total_tips: 1_900_122_111_333, + validator_fee_bps: 200, + }), + delegations: vec![ + Delegation { + stake_account_pubkey: stake_account_2, + staker_pubkey: staker_account_2, + withdrawer_pubkey: staker_account_2, + lamports_delegated: 224_555_444, + }, + Delegation { + stake_account_pubkey: stake_account_3, + staker_pubkey: staker_account_3, + withdrawer_pubkey: staker_account_3, + lamports_delegated: 700_888_944_555, + }, + ], + total_delegated: 2_565_318_909_444_123, + commission: 10, + }, + ], + tip_distribution_program_id: Pubkey::new_unique(), + bank_hash: Hash::new_unique().to_string(), + epoch: 100, + slot: 2_000_000, + }; + + let merkle_tree_collection = GeneratedMerkleTreeCollection::new_from_stake_meta_collection( + stake_meta_collection.clone(), + None, + ) + .unwrap(); + + assert_eq!(stake_meta_collection.epoch, merkle_tree_collection.epoch); + assert_eq!( + stake_meta_collection.bank_hash, + merkle_tree_collection.bank_hash + ); + assert_eq!(stake_meta_collection.slot, merkle_tree_collection.slot); + assert_eq!( + stake_meta_collection.stake_metas.len(), + merkle_tree_collection.generated_merkle_trees.len() + ); + let claim_statuses = &[ + (validator_vote_account_0, tda_0), + (stake_account_0, tda_0), + (stake_account_1, tda_0), + (validator_vote_account_1, tda_1), + (stake_account_2, tda_1), + (stake_account_3, tda_1), + ] + .iter() + .map(|(claimant, tda)| { + Pubkey::find_program_address( + &[ClaimStatus::SEED, &claimant.to_bytes(), &tda.to_bytes()], + &JitoTipDistribution::id(), + ) + }) + .collect::>(); + let tree_nodes = vec![ + TreeNode { + claimant: validator_vote_account_0, + claim_status_pubkey: claim_statuses[0].0, + claim_status_bump: claim_statuses[0].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 19_001_221_110, + proof: None, + }, + TreeNode { + claimant: stake_account_0, + claim_status_pubkey: claim_statuses[1].0, + claim_status_bump: claim_statuses[1].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 149_992, + proof: None, + }, + TreeNode { + claimant: stake_account_1, + claim_status_pubkey: claim_statuses[2].0, + claim_status_bump: claim_statuses[2].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 174_858, + proof: None, + }, + ]; + let hashed_nodes: Vec<[u8; 32]> = tree_nodes.iter().map(|n| n.hash().to_bytes()).collect(); + let merkle_tree = MerkleTree::new(&hashed_nodes[..], true); + let gmt_0 = GeneratedMerkleTree { + tip_distribution_account: tda_0, + merkle_root_upload_authority, + merkle_root: *merkle_tree.get_root().unwrap(), + tree_nodes, + max_total_claim: stake_meta_collection.stake_metas[0] + .clone() + .maybe_tip_distribution_meta + .unwrap() + .total_tips, + max_num_nodes: 3, + }; + + let tree_nodes = vec![ + TreeNode { + claimant: validator_vote_account_1, + claim_status_pubkey: claim_statuses[3].0, + claim_status_bump: claim_statuses[3].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 38_002_442_226, + proof: None, + }, + TreeNode { + claimant: stake_account_2, + claim_status_pubkey: claim_statuses[4].0, + claim_status_bump: claim_statuses[4].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 163_000, + proof: None, + }, + TreeNode { + claimant: stake_account_3, + claim_status_pubkey: claim_statuses[5].0, + claim_status_bump: claim_statuses[5].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 508_762_900, + proof: None, + }, + ]; + let hashed_nodes: Vec<[u8; 32]> = tree_nodes.iter().map(|n| n.hash().to_bytes()).collect(); + let merkle_tree = MerkleTree::new(&hashed_nodes[..], true); + let gmt_1 = GeneratedMerkleTree { + tip_distribution_account: tda_1, + merkle_root_upload_authority, + merkle_root: *merkle_tree.get_root().unwrap(), + tree_nodes, + max_total_claim: stake_meta_collection.stake_metas[1] + .clone() + .maybe_tip_distribution_meta + .unwrap() + .total_tips, + max_num_nodes: 3, + }; + + let expected_generated_merkle_trees = vec![gmt_0, gmt_1]; + let actual_generated_merkle_trees = merkle_tree_collection.generated_merkle_trees; + + expected_generated_merkle_trees + .iter() + .for_each(|expected_gmt| { + let actual_gmt = actual_generated_merkle_trees + .iter() + .find(|gmt| { + gmt.tip_distribution_account == expected_gmt.tip_distribution_account + }) + .unwrap(); + + assert_eq!(expected_gmt.max_num_nodes, actual_gmt.max_num_nodes); + assert_eq!(expected_gmt.max_total_claim, actual_gmt.max_total_claim); + assert_eq!( + expected_gmt.tip_distribution_account, + actual_gmt.tip_distribution_account + ); + assert_eq!(expected_gmt.tree_nodes.len(), actual_gmt.tree_nodes.len()); + expected_gmt + .tree_nodes + .iter() + .for_each(|expected_tree_node| { + let actual_tree_node = actual_gmt + .tree_nodes + .iter() + .find(|tree_node| tree_node.claimant == expected_tree_node.claimant) + .unwrap(); + assert_eq!(expected_tree_node.amount, actual_tree_node.amount); + }); + assert_eq!(expected_gmt.merkle_root, actual_gmt.merkle_root); + }); + } +} diff --git a/tip-distributor/src/merkle_root_generator_workflow.rs b/tip-distributor/src/merkle_root_generator_workflow.rs new file mode 100644 index 0000000000..bee3da016b --- /dev/null +++ b/tip-distributor/src/merkle_root_generator_workflow.rs @@ -0,0 +1,54 @@ +use { + crate::{read_json_from_file, GeneratedMerkleTreeCollection, StakeMetaCollection}, + log::*, + solana_client::rpc_client::RpcClient, + std::{ + fmt::Debug, + fs::File, + io::{BufWriter, Write}, + path::PathBuf, + }, + thiserror::Error, +}; + +#[derive(Error, Debug)] +pub enum MerkleRootGeneratorError { + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + RpcError(#[from] Box), + + #[error(transparent)] + SerdeJsonError(#[from] serde_json::Error), +} + +pub fn generate_merkle_root( + stake_meta_coll_path: &PathBuf, + out_path: &PathBuf, + rpc_url: &str, +) -> Result<(), MerkleRootGeneratorError> { + let stake_meta_coll: StakeMetaCollection = read_json_from_file(stake_meta_coll_path)?; + + let rpc_client = RpcClient::new(rpc_url); + let merkle_tree_coll = GeneratedMerkleTreeCollection::new_from_stake_meta_collection( + stake_meta_coll, + Some(rpc_client), + )?; + + write_to_json_file(&merkle_tree_coll, out_path)?; + Ok(()) +} + +fn write_to_json_file( + merkle_tree_coll: &GeneratedMerkleTreeCollection, + file_path: &PathBuf, +) -> Result<(), MerkleRootGeneratorError> { + let file = File::create(file_path)?; + let mut writer = BufWriter::new(file); + let json = serde_json::to_string_pretty(&merkle_tree_coll).unwrap(); + writer.write_all(json.as_bytes())?; + writer.flush()?; + + Ok(()) +} diff --git a/tip-distributor/src/merkle_root_upload_workflow.rs b/tip-distributor/src/merkle_root_upload_workflow.rs new file mode 100644 index 0000000000..e40465581f --- /dev/null +++ b/tip-distributor/src/merkle_root_upload_workflow.rs @@ -0,0 +1,138 @@ +use { + crate::{ + read_json_from_file, sign_and_send_transactions_with_retries, GeneratedMerkleTree, + GeneratedMerkleTreeCollection, + }, + anchor_lang::AccountDeserialize, + jito_tip_distribution::{ + sdk::instruction::{upload_merkle_root_ix, UploadMerkleRootAccounts, UploadMerkleRootArgs}, + state::{Config, TipDistributionAccount}, + }, + log::{error, info}, + solana_client::nonblocking::rpc_client::RpcClient, + solana_program::{ + fee_calculator::DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE, native_token::LAMPORTS_PER_SOL, + }, + solana_sdk::{ + commitment_config::CommitmentConfig, + pubkey::Pubkey, + signature::{read_keypair_file, Signer}, + transaction::Transaction, + }, + std::{path::PathBuf, time::Duration}, + thiserror::Error, + tokio::runtime::Builder, +}; + +#[derive(Error, Debug)] +pub enum MerkleRootUploadError { + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + JsonError(#[from] serde_json::Error), +} + +pub fn upload_merkle_root( + merkle_root_path: &PathBuf, + keypair_path: &PathBuf, + rpc_url: &str, + tip_distribution_program_id: &Pubkey, + max_concurrent_rpc_get_reqs: usize, + txn_send_batch_size: usize, +) -> Result<(), MerkleRootUploadError> { + const MAX_RETRY_DURATION: Duration = Duration::from_secs(600); + + let merkle_tree: GeneratedMerkleTreeCollection = + read_json_from_file(merkle_root_path).expect("read GeneratedMerkleTreeCollection"); + let keypair = read_keypair_file(keypair_path).expect("read keypair file"); + + let tip_distribution_config = + Pubkey::find_program_address(&[Config::SEED], tip_distribution_program_id).0; + + let runtime = Builder::new_multi_thread() + .worker_threads(16) + .enable_all() + .build() + .expect("build runtime"); + + runtime.block_on(async move { + let rpc_client = + RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::confirmed()); + let trees: Vec = merkle_tree + .generated_merkle_trees + .into_iter() + .filter(|tree| tree.merkle_root_upload_authority == keypair.pubkey()) + .collect(); + + info!("num trees to upload: {:?}", trees.len()); + + // heuristic to make sure we have enough funds to cover execution, assumes all trees need updating + { + let initial_balance = rpc_client.get_balance(&keypair.pubkey()).await.expect("failed to get balance"); + let desired_balance = (trees.len() as u64).checked_mul(DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE).unwrap(); + if initial_balance < desired_balance { + let sol_to_deposit = desired_balance.checked_sub(initial_balance).unwrap().checked_add(LAMPORTS_PER_SOL).unwrap().checked_sub(1).unwrap().checked_div(LAMPORTS_PER_SOL).unwrap(); // rounds up to nearest sol + panic!("Expected to have at least {} lamports in {}, current balance is {} lamports, deposit {} SOL to continue.", + desired_balance, &keypair.pubkey(), initial_balance, sol_to_deposit) + } + } + let mut trees_needing_update: Vec = vec![]; + for tree in trees { + let account = rpc_client + .get_account(&tree.tip_distribution_account) + .await + .expect("fetch expect"); + + let mut data = account.data.as_slice(); + let fetched_tip_distribution_account = + TipDistributionAccount::try_deserialize(&mut data) + .expect("failed to deserialize tip_distribution_account state"); + + let needs_upload = match fetched_tip_distribution_account.merkle_root { + Some(merkle_root) => { + merkle_root.total_funds_claimed == 0 + && merkle_root.root != tree.merkle_root.to_bytes() + } + None => true, + }; + + if needs_upload { + trees_needing_update.push(tree); + } + } + + info!("num trees need uploading: {:?}", trees_needing_update.len()); + + let transactions: Vec = trees_needing_update + .iter() + .map(|tree| { + let ix = upload_merkle_root_ix( + *tip_distribution_program_id, + UploadMerkleRootArgs { + root: tree.merkle_root.to_bytes(), + max_total_claim: tree.max_total_claim, + max_num_nodes: tree.max_num_nodes, + }, + UploadMerkleRootAccounts { + config: tip_distribution_config, + merkle_root_upload_authority: keypair.pubkey(), + tip_distribution_account: tree.tip_distribution_account, + }, + ); + Transaction::new_with_payer( + &[ix], + Some(&keypair.pubkey()), + ) + }) + .collect(); + + let (to_process, failed_transactions) = sign_and_send_transactions_with_retries( + &keypair, &rpc_client, max_concurrent_rpc_get_reqs, transactions, txn_send_batch_size, MAX_RETRY_DURATION).await; + if !to_process.is_empty() { + panic!("{} remaining mev claim transactions, {} failed requests.", to_process.len(), failed_transactions.len()); + } + }); + + Ok(()) +} diff --git a/tip-distributor/src/reclaim_rent_workflow.rs b/tip-distributor/src/reclaim_rent_workflow.rs new file mode 100644 index 0000000000..8d923e3980 --- /dev/null +++ b/tip-distributor/src/reclaim_rent_workflow.rs @@ -0,0 +1,247 @@ +use { + crate::{ + claim_mev_workflow::ClaimMevError, reclaim_rent_workflow::ClaimMevError::AnchorError, + sign_and_send_transactions_with_retries_multi_rpc, + }, + anchor_lang::AccountDeserialize, + itertools::Itertools, + jito_tip_distribution::{ + sdk::{ + derive_config_account_address, + instruction::{ + close_claim_status_ix, close_tip_distribution_account_ix, CloseClaimStatusAccounts, + CloseClaimStatusArgs, CloseTipDistributionAccountArgs, + CloseTipDistributionAccounts, + }, + }, + state::{ClaimStatus, Config, TipDistributionAccount}, + }, + log::info, + solana_client::nonblocking::rpc_client::RpcClient, + solana_measure::measure, + solana_metrics::datapoint_info, + solana_program::pubkey::Pubkey, + solana_sdk::{ + commitment_config::CommitmentConfig, + signature::{Keypair, Signer}, + transaction::Transaction, + }, + std::{ + sync::Arc, + time::{Duration, Instant}, + }, +}; + +/// Clear old ClaimStatus accounts +pub async fn reclaim_rent( + rpc_url: String, + rpc_send_connection_count: u64, + tip_distribution_program_id: Pubkey, + signer: Arc, + max_loop_retries: u64, + max_loop_duration: Duration, + // Optionally reclaim TipDistributionAccount rents on behalf of validators. + should_reclaim_tdas: bool, +) -> Result<(), ClaimMevError> { + let blockhash_rpc_client = Arc::new(RpcClient::new_with_timeout_and_commitment( + rpc_url.clone(), + Duration::from_secs(180), // 3 mins + CommitmentConfig::finalized(), + )); + let rpc_clients = Arc::new( + (0..rpc_send_connection_count) + .map(|_| { + Arc::new(RpcClient::new_with_commitment( + rpc_url.clone(), + CommitmentConfig::confirmed(), + )) + }) + .collect_vec(), + ); + let mut retries = 0; + let mut failed_transaction_count = 0usize; + let signer_pubkey = signer.pubkey(); + loop { + let (transactions, get_pa_elapsed, transaction_prepare_elaspsed) = build_transactions( + blockhash_rpc_client.clone(), + &tip_distribution_program_id, + &signer_pubkey, + should_reclaim_tdas, + ) + .await?; + datapoint_info!( + "claim_mev_workflow-prepare_rent_reclaim_transactions", + ("attempt", retries, i64), + ("transaction_count", transactions.len(), i64), + ("account_fetch_latency_us", get_pa_elapsed.as_micros(), i64), + ( + "transaction_prepare_latency_us", + transaction_prepare_elaspsed.as_micros(), + i64 + ), + ); + let transactions_len = transactions.len(); + if transactions.is_empty() { + info!("Finished reclaim rent after {retries} retries, {failed_transaction_count} failed requests."); + return Ok(()); + } + + info!("Sending {} rent reclaim transactions", transactions.len()); + let send_start = Instant::now(); + let (remaining_transaction_count, new_failed_transaction_count) = + sign_and_send_transactions_with_retries_multi_rpc( + &signer, + &blockhash_rpc_client, + &rpc_clients, + transactions, + max_loop_duration, + ) + .await; + failed_transaction_count = + failed_transaction_count.saturating_add(new_failed_transaction_count); + + datapoint_info!( + "claim_mev_workflow-send_reclaim_rent_transactions", + ("attempt", retries, i64), + ("transaction_count", transactions_len, i64), + ( + "successful_transaction_count", + transactions_len.saturating_sub(remaining_transaction_count), + i64 + ), + ( + "remaining_transaction_count", + remaining_transaction_count, + i64 + ), + ( + "failed_transaction_count", + new_failed_transaction_count, + i64 + ), + ("send_latency_us", send_start.elapsed().as_micros(), i64), + ); + + if retries >= max_loop_retries { + return Err(ClaimMevError::MaxSendTransactionRetriesExceeded { + attempts: max_loop_retries, + remaining_transaction_count, + failed_transaction_count, + }); + } + retries = retries.saturating_add(1); + } +} + +async fn build_transactions( + rpc_client: Arc, + tip_distribution_program_id: &Pubkey, + signer_pubkey: &Pubkey, + should_reclaim_tdas: bool, +) -> Result<(Vec, Duration, Duration), ClaimMevError> { + info!("Fetching program accounts"); + let (accounts, get_pa_elapsed) = measure!( + rpc_client + .get_program_accounts(tip_distribution_program_id) + .await? + ); + info!( + "Fetch get_program_accounts took {:?} and fetched {} accounts", + get_pa_elapsed.as_duration(), + accounts.len() + ); + + info!("Fetching current_epoch"); + let current_epoch = rpc_client.get_epoch_info().await?.epoch; + info!("Fetch current_epoch: {current_epoch}"); + + info!("Fetching Config account"); + let config_pubkey = derive_config_account_address(tip_distribution_program_id).0; + let (config_account, elapsed) = measure!(rpc_client.get_account(&config_pubkey).await?); + info!("Fetch Config account took {:?}", elapsed.as_duration()); + let config_account: Config = + Config::try_deserialize(&mut config_account.data.as_slice()).map_err(AnchorError)?; + + info!("Filtering for ClaimStatus accounts"); + let claim_status_accounts: Vec<(Pubkey, ClaimStatus)> = accounts + .iter() + .filter_map(|(pubkey, account)| { + let claim_status = ClaimStatus::try_deserialize(&mut account.data.as_slice()).ok()?; + Some((*pubkey, claim_status)) + }) + .filter(|(_, claim_status): &(Pubkey, ClaimStatus)| { + // Only return claim statuses that we've paid for and ones that are expired to avoid transaction failures. + claim_status.claim_status_payer.eq(signer_pubkey) + && current_epoch > claim_status.expires_at + }) + .collect::>(); + info!( + "{} ClaimStatus accounts eligible for rent reclaim", + claim_status_accounts.len() + ); + + info!("Creating CloseClaimStatusAccounts transactions"); + let transaction_now = Instant::now(); + let mut transactions = claim_status_accounts + .into_iter() + .map(|(claim_status_pubkey, claim_status)| { + close_claim_status_ix( + *tip_distribution_program_id, + CloseClaimStatusArgs, + CloseClaimStatusAccounts { + config: config_pubkey, + claim_status: claim_status_pubkey, + claim_status_payer: claim_status.claim_status_payer, + }, + ) + }) + .collect::>() + .chunks(4) + .map(|instructions| Transaction::new_with_payer(instructions, Some(signer_pubkey))) + .collect::>(); + + info!( + "Create CloseClaimStatusAccounts transactions took {:?}", + transaction_now.elapsed() + ); + + if should_reclaim_tdas { + info!("Creating CloseTipDistributionAccounts transactions"); + let now = Instant::now(); + let close_tda_txs = accounts + .into_iter() + .filter_map(|(pubkey, account)| { + let tda = + TipDistributionAccount::try_deserialize(&mut account.data.as_slice()).ok()?; + Some((pubkey, tda)) + }) + .filter(|(_, tda): &(Pubkey, TipDistributionAccount)| current_epoch > tda.expires_at) + .map(|(tip_distribution_account, tda)| { + close_tip_distribution_account_ix( + *tip_distribution_program_id, + CloseTipDistributionAccountArgs { + _epoch: tda.epoch_created_at, + }, + CloseTipDistributionAccounts { + config: config_pubkey, + tip_distribution_account, + validator_vote_account: tda.validator_vote_account, + expired_funds_account: config_account.expired_funds_account, + signer: *signer_pubkey, + }, + ) + }) + .collect::>() + .chunks(4) + .map(|instructions| Transaction::new_with_payer(instructions, Some(signer_pubkey))) + .collect::>(); + info!("Create CloseTipDistributionAccounts transactions took {:?}, closing {} tip distribution accounts", now.elapsed(), close_tda_txs.len()); + + transactions.extend(close_tda_txs); + } + Ok(( + transactions, + get_pa_elapsed.as_duration(), + transaction_now.elapsed(), + )) +} diff --git a/tip-distributor/src/stake_meta_generator_workflow.rs b/tip-distributor/src/stake_meta_generator_workflow.rs new file mode 100644 index 0000000000..b35e7929f7 --- /dev/null +++ b/tip-distributor/src/stake_meta_generator_workflow.rs @@ -0,0 +1,952 @@ +use { + crate::{ + derive_tip_distribution_account_address, derive_tip_payment_pubkeys, Config, StakeMeta, + StakeMetaCollection, TipDistributionAccount, TipDistributionAccountWrapper, + TipDistributionMeta, + }, + anchor_lang::AccountDeserialize, + itertools::Itertools, + log::*, + solana_client::client_error::ClientError, + solana_ledger::{ + bank_forks_utils, + blockstore::BlockstoreError, + blockstore_processor::{BlockstoreProcessorError, ProcessOptions}, + }, + solana_runtime::{ + bank::Bank, + hardened_unpack::{open_genesis_config, MAX_GENESIS_ARCHIVE_UNPACKED_SIZE}, + snapshot_config::SnapshotConfig, + stakes::StakeAccount, + vote_account::VoteAccount, + }, + solana_sdk::{ + account::{ReadableAccount, WritableAccount}, + clock::Slot, + pubkey::Pubkey, + }, + std::{ + collections::HashMap, + fmt::{Debug, Display, Formatter}, + fs::File, + io::{BufWriter, Write}, + mem::size_of, + path::{Path, PathBuf}, + sync::{atomic::AtomicBool, Arc}, + }, + thiserror::Error, +}; + +#[derive(Error, Debug)] +pub enum StakeMetaGeneratorError { + #[error(transparent)] + AnchorError(#[from] Box), + + #[error(transparent)] + BlockstoreError(#[from] BlockstoreError), + + #[error(transparent)] + BlockstoreProcessorError(#[from] BlockstoreProcessorError), + + #[error(transparent)] + IoError(#[from] std::io::Error), + + CheckedMathError, + + #[error(transparent)] + RpcError(#[from] ClientError), + + #[error(transparent)] + SerdeJsonError(#[from] serde_json::Error), + + SnapshotSlotNotFound, +} + +impl Display for StakeMetaGeneratorError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Debug::fmt(&self, f) + } +} + +/// Runs the entire workflow of creating a bank from a snapshot to writing stake meta-data +/// to a JSON file. +pub fn generate_stake_meta( + ledger_path: &Path, + snapshot_slot: &Slot, + tip_distribution_program_id: &Pubkey, + out_path: &str, + tip_payment_program_id: &Pubkey, +) -> Result<(), StakeMetaGeneratorError> { + info!("Creating bank from ledger path..."); + let bank = create_bank_from_snapshot(ledger_path, snapshot_slot)?; + + info!("Generating stake_meta_collection object..."); + let stake_meta_coll = + generate_stake_meta_collection(&bank, tip_distribution_program_id, tip_payment_program_id)?; + + info!("Writing stake_meta_collection to JSON {}...", out_path); + write_to_json_file(&stake_meta_coll, out_path)?; + + Ok(()) +} + +fn create_bank_from_snapshot( + ledger_path: &Path, + snapshot_slot: &Slot, +) -> Result, StakeMetaGeneratorError> { + let genesis_config = open_genesis_config(ledger_path, MAX_GENESIS_ARCHIVE_UNPACKED_SIZE); + let snapshot_config = SnapshotConfig { + full_snapshot_archive_interval_slots: Slot::MAX, + incremental_snapshot_archive_interval_slots: Slot::MAX, + full_snapshot_archives_dir: PathBuf::from(ledger_path), + incremental_snapshot_archives_dir: PathBuf::from(ledger_path), + bank_snapshots_dir: PathBuf::from(ledger_path), + ..SnapshotConfig::default() + }; + let (bank_forks, _snapshot_hashes) = bank_forks_utils::bank_forks_from_snapshot( + &genesis_config, + vec![PathBuf::from(ledger_path).join(Path::new("stake-meta.accounts"))], + None, + &snapshot_config, + &ProcessOptions::default(), + None, + &Arc::new(AtomicBool::new(false)), + ); + + let working_bank = bank_forks.read().unwrap().working_bank(); + assert_eq!( + working_bank.slot(), + *snapshot_slot, + "expected working bank slot {}, found {}", + snapshot_slot, + working_bank.slot() + ); + + Ok(working_bank) +} + +fn write_to_json_file( + stake_meta_coll: &StakeMetaCollection, + out_path: &str, +) -> Result<(), StakeMetaGeneratorError> { + let file = File::create(out_path)?; + let mut writer = BufWriter::new(file); + let json = serde_json::to_string_pretty(&stake_meta_coll).unwrap(); + writer.write_all(json.as_bytes())?; + writer.flush()?; + + Ok(()) +} + +/// Creates a collection of [StakeMeta]'s from the given bank. +pub fn generate_stake_meta_collection( + bank: &Arc, + tip_distribution_program_id: &Pubkey, + tip_payment_program_id: &Pubkey, +) -> Result { + assert!(bank.is_frozen()); + + let epoch_vote_accounts = bank.epoch_vote_accounts(bank.epoch()).unwrap_or_else(|| { + panic!( + "No epoch_vote_accounts found for slot {} at epoch {}", + bank.slot(), + bank.epoch() + ) + }); + + let l_stakes = bank.stakes_cache.stakes(); + let delegations = l_stakes.stake_delegations(); + + let voter_pubkey_to_delegations = group_delegations_by_voter_pubkey(delegations, bank); + + // the last leader in an epoch may not crank the tip program before the epoch is over, which + // would result in MEV rewards for epoch N not being cranked until epoch N + 1. This means that + // the account balance in the snapshot could be incorrect. + // We assume that the rewards sitting in the tip program PDAs are cranked out by the time all of + // the rewards are claimed. + let tip_accounts = derive_tip_payment_pubkeys(tip_payment_program_id); + let account = bank + .get_account(&tip_accounts.config_pda) + .expect("config pda exists"); + + let config = Config::try_deserialize(&mut account.data()).expect("deserializes configuration"); + + let bb_commission_pct: u64 = config.block_builder_commission_pct; + let tip_receiver: Pubkey = config.tip_receiver; + + // includes the block builder fee + let excess_tip_balances: u64 = tip_accounts + .tip_pdas + .iter() + .map(|pubkey| { + let tip_account = bank.get_account(pubkey).expect("tip account exists"); + tip_account + .lamports() + .checked_sub(bank.get_minimum_balance_for_rent_exemption(tip_account.data().len())) + .expect("tip balance underflow") + }) + .sum(); + // matches math in tip payment program + let block_builder_tips = excess_tip_balances + .checked_mul(bb_commission_pct) + .expect("block_builder_tips overflow") + .checked_div(100) + .expect("block_builder_tips division error"); + let tip_receiver_fee = excess_tip_balances + .checked_sub(block_builder_tips) + .expect("tip_receiver_fee doesnt underflow"); + + let vote_pk_and_maybe_tdas: Vec<( + (Pubkey, &VoteAccount), + Option, + )> = epoch_vote_accounts + .iter() + .map(|(vote_pubkey, (_total_stake, vote_account))| { + let tip_distribution_pubkey = derive_tip_distribution_account_address( + tip_distribution_program_id, + vote_pubkey, + bank.epoch(), + ) + .0; + let tda = if let Some(mut account_data) = bank.get_account(&tip_distribution_pubkey) { + // TDAs may be funded with lamports and therefore exist in the bank, but would fail the deserialization step + // if the buffer is yet to be allocated thru the init call to the program. + if let Ok(tip_distribution_account) = + TipDistributionAccount::try_deserialize(&mut account_data.data()) + { + // this snapshot might have tips that weren't claimed by the time the epoch is over + // assume that it will eventually be cranked and credit the excess to this account + if tip_distribution_pubkey == tip_receiver { + account_data.set_lamports( + account_data + .lamports() + .checked_add(tip_receiver_fee) + .expect("tip overflow"), + ); + } + Some(TipDistributionAccountWrapper { + tip_distribution_account, + account_data, + tip_distribution_pubkey, + }) + } else { + None + } + } else { + None + }; + Ok(((*vote_pubkey, vote_account), tda)) + }) + .collect::>()?; + + let mut stake_metas = vec![]; + for ((vote_pubkey, vote_account), maybe_tda) in vote_pk_and_maybe_tdas { + if let Some(mut delegations) = voter_pubkey_to_delegations.get(&vote_pubkey).cloned() { + let total_delegated = delegations.iter().fold(0u64, |sum, delegation| { + sum.checked_add(delegation.lamports_delegated).unwrap() + }); + + let maybe_tip_distribution_meta = if let Some(tda) = maybe_tda { + let actual_len = tda.account_data.data().len(); + let expected_len = 8_usize.saturating_add(size_of::()); + if actual_len != expected_len { + warn!("len mismatch actual={actual_len}, expected={expected_len}"); + } + let rent_exempt_amount = + bank.get_minimum_balance_for_rent_exemption(tda.account_data.data().len()); + + Some(TipDistributionMeta::from_tda_wrapper( + tda, + rent_exempt_amount, + )?) + } else { + None + }; + + let vote_state = vote_account.vote_state().unwrap(); + delegations.sort(); + stake_metas.push(StakeMeta { + maybe_tip_distribution_meta, + validator_node_pubkey: vote_state.node_pubkey, + validator_vote_account: vote_pubkey, + delegations, + total_delegated, + commission: vote_state.commission, + }); + } else { + warn!( + "voter_pubkey not found in voter_pubkey_to_delegations map [validator_vote_pubkey={}]", + vote_pubkey + ); + } + } + stake_metas.sort(); + + Ok(StakeMetaCollection { + stake_metas, + tip_distribution_program_id: *tip_distribution_program_id, + bank_hash: bank.hash().to_string(), + epoch: bank.epoch(), + slot: bank.slot(), + }) +} + +/// Given an [EpochStakes] object, return delegations grouped by voter_pubkey (validator delegated to). +fn group_delegations_by_voter_pubkey( + delegations: &im::HashMap, + bank: &Bank, +) -> HashMap> { + delegations + .into_iter() + .filter(|(_stake_pubkey, stake_account)| { + stake_account.delegation().stake(bank.epoch(), None, None) > 0 + }) + .into_group_map_by(|(_stake_pubkey, stake_account)| stake_account.delegation().voter_pubkey) + .into_iter() + .map(|(voter_pubkey, group)| { + ( + voter_pubkey, + group + .into_iter() + .map(|(stake_pubkey, stake_account)| crate::Delegation { + stake_account_pubkey: *stake_pubkey, + staker_pubkey: stake_account + .stake_state() + .authorized() + .map(|a| a.staker) + .unwrap_or_default(), + withdrawer_pubkey: stake_account + .stake_state() + .authorized() + .map(|a| a.withdrawer) + .unwrap_or_default(), + lamports_delegated: stake_account.delegation().stake, + }) + .collect::>(), + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::derive_tip_distribution_account_address, + anchor_lang::AccountSerialize, + jito_tip_distribution::state::TipDistributionAccount, + jito_tip_payment::{ + InitBumps, TipPaymentAccount, CONFIG_ACCOUNT_SEED, TIP_ACCOUNT_SEED_0, + TIP_ACCOUNT_SEED_1, TIP_ACCOUNT_SEED_2, TIP_ACCOUNT_SEED_3, TIP_ACCOUNT_SEED_4, + TIP_ACCOUNT_SEED_5, TIP_ACCOUNT_SEED_6, TIP_ACCOUNT_SEED_7, + }, + solana_runtime::genesis_utils::{ + create_genesis_config_with_vote_accounts, GenesisConfigInfo, ValidatorVoteKeypairs, + }, + solana_sdk::{ + self, + account::{from_account, AccountSharedData}, + message::Message, + signature::{Keypair, Signer}, + stake::{ + self, + state::{Authorized, Lockup}, + }, + stake_history::StakeHistory, + sysvar, + transaction::Transaction, + }, + solana_stake_program::stake_state, + }; + + #[test] + fn test_generate_stake_meta_collection_happy_path() { + /* 1. Create a Bank seeded with some validator stake accounts */ + let validator_keypairs_0 = ValidatorVoteKeypairs::new_rand(); + let validator_keypairs_1 = ValidatorVoteKeypairs::new_rand(); + let validator_keypairs_2 = ValidatorVoteKeypairs::new_rand(); + let validator_keypairs = vec![ + &validator_keypairs_0, + &validator_keypairs_1, + &validator_keypairs_2, + ]; + const INITIAL_VALIDATOR_STAKES: u64 = 10_000; + let GenesisConfigInfo { genesis_config, .. } = create_genesis_config_with_vote_accounts( + 1_000_000_000, + &validator_keypairs, + vec![INITIAL_VALIDATOR_STAKES; 3], + ); + + let bank = Bank::new_for_tests(&genesis_config); + + /* 2. Seed the Bank with [TipDistributionAccount]'s */ + let merkle_root_upload_authority = Pubkey::new_unique(); + let tip_distribution_program_id = Pubkey::new_unique(); + let tip_payment_program_id = Pubkey::new_unique(); + + let delegator_0 = Keypair::new(); + let delegator_1 = Keypair::new(); + let delegator_2 = Keypair::new(); + let delegator_3 = Keypair::new(); + let delegator_4 = Keypair::new(); + + let delegator_0_pk = delegator_0.pubkey(); + let delegator_1_pk = delegator_1.pubkey(); + let delegator_2_pk = delegator_2.pubkey(); + let delegator_3_pk = delegator_3.pubkey(); + let delegator_4_pk = delegator_4.pubkey(); + + let d_0_data = AccountSharedData::new( + 300_000_000_000_000 * 10, + 0, + &solana_sdk::system_program::id(), + ); + let d_1_data = AccountSharedData::new( + 100_000_203_000_000 * 10, + 0, + &solana_sdk::system_program::id(), + ); + let d_2_data = AccountSharedData::new( + 100_000_235_899_000 * 10, + 0, + &solana_sdk::system_program::id(), + ); + let d_3_data = AccountSharedData::new( + 200_000_000_000_000 * 10, + 0, + &solana_sdk::system_program::id(), + ); + let d_4_data = AccountSharedData::new( + 100_000_000_777_000 * 10, + 0, + &solana_sdk::system_program::id(), + ); + + bank.store_account(&delegator_0_pk, &d_0_data); + bank.store_account(&delegator_1_pk, &d_1_data); + bank.store_account(&delegator_2_pk, &d_2_data); + bank.store_account(&delegator_3_pk, &d_3_data); + bank.store_account(&delegator_4_pk, &d_4_data); + + /* 3. Delegate some stake to the initial set of validators */ + let mut validator_0_delegations = vec![crate::Delegation { + stake_account_pubkey: validator_keypairs_0.stake_keypair.pubkey(), + staker_pubkey: validator_keypairs_0.stake_keypair.pubkey(), + withdrawer_pubkey: validator_keypairs_0.stake_keypair.pubkey(), + lamports_delegated: INITIAL_VALIDATOR_STAKES, + }]; + let stake_account = delegate_stake_helper( + &bank, + &delegator_0, + &validator_keypairs_0.vote_keypair.pubkey(), + 30_000_000_000, + ); + validator_0_delegations.push(crate::Delegation { + stake_account_pubkey: stake_account, + staker_pubkey: delegator_0.pubkey(), + withdrawer_pubkey: delegator_0.pubkey(), + lamports_delegated: 30_000_000_000, + }); + let stake_account = delegate_stake_helper( + &bank, + &delegator_1, + &validator_keypairs_0.vote_keypair.pubkey(), + 3_000_000_000, + ); + validator_0_delegations.push(crate::Delegation { + stake_account_pubkey: stake_account, + staker_pubkey: delegator_1.pubkey(), + withdrawer_pubkey: delegator_1.pubkey(), + lamports_delegated: 3_000_000_000, + }); + let stake_account = delegate_stake_helper( + &bank, + &delegator_2, + &validator_keypairs_0.vote_keypair.pubkey(), + 33_000_000_000, + ); + validator_0_delegations.push(crate::Delegation { + stake_account_pubkey: stake_account, + staker_pubkey: delegator_2.pubkey(), + withdrawer_pubkey: delegator_2.pubkey(), + lamports_delegated: 33_000_000_000, + }); + + let mut validator_1_delegations = vec![crate::Delegation { + stake_account_pubkey: validator_keypairs_1.stake_keypair.pubkey(), + staker_pubkey: validator_keypairs_1.stake_keypair.pubkey(), + withdrawer_pubkey: validator_keypairs_1.stake_keypair.pubkey(), + lamports_delegated: INITIAL_VALIDATOR_STAKES, + }]; + let stake_account = delegate_stake_helper( + &bank, + &delegator_3, + &validator_keypairs_1.vote_keypair.pubkey(), + 4_222_364_000, + ); + validator_1_delegations.push(crate::Delegation { + stake_account_pubkey: stake_account, + staker_pubkey: delegator_3.pubkey(), + withdrawer_pubkey: delegator_3.pubkey(), + lamports_delegated: 4_222_364_000, + }); + let stake_account = delegate_stake_helper( + &bank, + &delegator_4, + &validator_keypairs_1.vote_keypair.pubkey(), + 6_000_000_527, + ); + validator_1_delegations.push(crate::Delegation { + stake_account_pubkey: stake_account, + staker_pubkey: delegator_4.pubkey(), + withdrawer_pubkey: delegator_4.pubkey(), + lamports_delegated: 6_000_000_527, + }); + + let mut validator_2_delegations = vec![crate::Delegation { + stake_account_pubkey: validator_keypairs_2.stake_keypair.pubkey(), + staker_pubkey: validator_keypairs_2.stake_keypair.pubkey(), + withdrawer_pubkey: validator_keypairs_2.stake_keypair.pubkey(), + lamports_delegated: INITIAL_VALIDATOR_STAKES, + }]; + let stake_account = delegate_stake_helper( + &bank, + &delegator_0, + &validator_keypairs_2.vote_keypair.pubkey(), + 1_300_123_156, + ); + validator_2_delegations.push(crate::Delegation { + stake_account_pubkey: stake_account, + staker_pubkey: delegator_0.pubkey(), + withdrawer_pubkey: delegator_0.pubkey(), + lamports_delegated: 1_300_123_156, + }); + let stake_account = delegate_stake_helper( + &bank, + &delegator_4, + &validator_keypairs_2.vote_keypair.pubkey(), + 1_610_565_420, + ); + validator_2_delegations.push(crate::Delegation { + stake_account_pubkey: stake_account, + staker_pubkey: delegator_4.pubkey(), + withdrawer_pubkey: delegator_4.pubkey(), + lamports_delegated: 1_610_565_420, + }); + + /* 4. Run assertions */ + fn warmed_up(bank: &Bank, stake_pubkeys: &[Pubkey]) -> bool { + for stake_pubkey in stake_pubkeys { + let stake = + stake_state::stake_from(&bank.get_account(stake_pubkey).unwrap()).unwrap(); + + if stake.delegation.stake + != stake.stake( + bank.epoch(), + Some( + &from_account::( + &bank.get_account(&sysvar::stake_history::id()).unwrap(), + ) + .unwrap(), + ), + None, + ) + { + return false; + } + } + + true + } + fn next_epoch(bank: &Arc) -> Arc { + bank.squash(); + + Arc::new(Bank::new_from_parent( + bank, + &Pubkey::default(), + bank.get_slots_in_epoch(bank.epoch()) + bank.slot(), + )) + } + + let mut bank = Arc::new(bank); + let mut stake_pubkeys = validator_0_delegations + .iter() + .map(|v| v.stake_account_pubkey) + .collect::>(); + stake_pubkeys.extend( + validator_1_delegations + .iter() + .map(|v| v.stake_account_pubkey), + ); + stake_pubkeys.extend( + validator_2_delegations + .iter() + .map(|v| v.stake_account_pubkey), + ); + loop { + if warmed_up(&bank, &stake_pubkeys[..]) { + break; + } + + // Cycle thru banks until we're fully warmed up + bank = next_epoch(&bank); + } + + let tip_distribution_account_0 = derive_tip_distribution_account_address( + &tip_distribution_program_id, + &validator_keypairs_0.vote_keypair.pubkey(), + bank.epoch(), + ); + let tip_distribution_account_1 = derive_tip_distribution_account_address( + &tip_distribution_program_id, + &validator_keypairs_1.vote_keypair.pubkey(), + bank.epoch(), + ); + let tip_distribution_account_2 = derive_tip_distribution_account_address( + &tip_distribution_program_id, + &validator_keypairs_2.vote_keypair.pubkey(), + bank.epoch(), + ); + + let expires_at = bank.epoch() + 3; + + let tda_0 = TipDistributionAccount { + validator_vote_account: validator_keypairs_0.vote_keypair.pubkey(), + merkle_root_upload_authority, + merkle_root: None, + epoch_created_at: bank.epoch(), + validator_commission_bps: 50, + expires_at, + bump: tip_distribution_account_0.1, + }; + let tda_1 = TipDistributionAccount { + validator_vote_account: validator_keypairs_1.vote_keypair.pubkey(), + merkle_root_upload_authority, + merkle_root: None, + epoch_created_at: bank.epoch(), + validator_commission_bps: 500, + expires_at: 0, + bump: tip_distribution_account_1.1, + }; + let tda_2 = TipDistributionAccount { + validator_vote_account: validator_keypairs_2.vote_keypair.pubkey(), + merkle_root_upload_authority, + merkle_root: None, + epoch_created_at: bank.epoch(), + validator_commission_bps: 75, + expires_at: 0, + bump: tip_distribution_account_2.1, + }; + + let tip_distro_0_tips = 1_000_000 * 10; + let tip_distro_1_tips = 69_000_420 * 10; + let tip_distro_2_tips = 789_000_111 * 10; + + let tda_0_fields = (tip_distribution_account_0.0, tda_0.validator_commission_bps); + let data_0 = + tda_to_account_shared_data(&tip_distribution_program_id, tip_distro_0_tips, tda_0); + let tda_1_fields = (tip_distribution_account_1.0, tda_1.validator_commission_bps); + let data_1 = + tda_to_account_shared_data(&tip_distribution_program_id, tip_distro_1_tips, tda_1); + let tda_2_fields = (tip_distribution_account_2.0, tda_2.validator_commission_bps); + let data_2 = + tda_to_account_shared_data(&tip_distribution_program_id, tip_distro_2_tips, tda_2); + + let accounts_data = create_config_account_data(&tip_payment_program_id, &bank); + for (pubkey, data) in accounts_data { + bank.store_account(&pubkey, &data); + } + + bank.store_account(&tip_distribution_account_0.0, &data_0); + bank.store_account(&tip_distribution_account_1.0, &data_1); + bank.store_account(&tip_distribution_account_2.0, &data_2); + + bank.freeze(); + let stake_meta_collection = generate_stake_meta_collection( + &bank, + &tip_distribution_program_id, + &tip_payment_program_id, + ) + .unwrap(); + assert_eq!( + stake_meta_collection.tip_distribution_program_id, + tip_distribution_program_id + ); + assert_eq!(stake_meta_collection.slot, bank.slot()); + assert_eq!(stake_meta_collection.epoch, bank.epoch()); + + let mut expected_stake_metas = HashMap::new(); + expected_stake_metas.insert( + validator_keypairs_0.vote_keypair.pubkey(), + StakeMeta { + validator_vote_account: validator_keypairs_0.vote_keypair.pubkey(), + delegations: validator_0_delegations.clone(), + total_delegated: validator_0_delegations + .iter() + .fold(0u64, |sum, delegation| { + sum.checked_add(delegation.lamports_delegated).unwrap() + }), + maybe_tip_distribution_meta: Some(TipDistributionMeta { + merkle_root_upload_authority, + tip_distribution_pubkey: tda_0_fields.0, + total_tips: tip_distro_0_tips + .checked_sub( + bank.get_minimum_balance_for_rent_exemption( + TipDistributionAccount::SIZE, + ), + ) + .unwrap(), + validator_fee_bps: tda_0_fields.1, + }), + commission: 0, + validator_node_pubkey: validator_keypairs_0.node_keypair.pubkey(), + }, + ); + expected_stake_metas.insert( + validator_keypairs_1.vote_keypair.pubkey(), + StakeMeta { + validator_vote_account: validator_keypairs_1.vote_keypair.pubkey(), + delegations: validator_1_delegations.clone(), + total_delegated: validator_1_delegations + .iter() + .fold(0u64, |sum, delegation| { + sum.checked_add(delegation.lamports_delegated).unwrap() + }), + maybe_tip_distribution_meta: Some(TipDistributionMeta { + merkle_root_upload_authority, + tip_distribution_pubkey: tda_1_fields.0, + total_tips: tip_distro_1_tips + .checked_sub( + bank.get_minimum_balance_for_rent_exemption( + TipDistributionAccount::SIZE, + ), + ) + .unwrap(), + validator_fee_bps: tda_1_fields.1, + }), + commission: 0, + validator_node_pubkey: validator_keypairs_1.node_keypair.pubkey(), + }, + ); + expected_stake_metas.insert( + validator_keypairs_2.vote_keypair.pubkey(), + StakeMeta { + validator_vote_account: validator_keypairs_2.vote_keypair.pubkey(), + delegations: validator_2_delegations.clone(), + total_delegated: validator_2_delegations + .iter() + .fold(0u64, |sum, delegation| { + sum.checked_add(delegation.lamports_delegated).unwrap() + }), + maybe_tip_distribution_meta: Some(TipDistributionMeta { + merkle_root_upload_authority, + tip_distribution_pubkey: tda_2_fields.0, + total_tips: tip_distro_2_tips + .checked_sub( + bank.get_minimum_balance_for_rent_exemption( + TipDistributionAccount::SIZE, + ), + ) + .unwrap(), + validator_fee_bps: tda_2_fields.1, + }), + commission: 0, + validator_node_pubkey: validator_keypairs_2.node_keypair.pubkey(), + }, + ); + + println!( + "validator_0 [vote_account={}, stake_account={}]", + validator_keypairs_0.vote_keypair.pubkey(), + validator_keypairs_0.stake_keypair.pubkey() + ); + println!( + "validator_1 [vote_account={}, stake_account={}]", + validator_keypairs_1.vote_keypair.pubkey(), + validator_keypairs_1.stake_keypair.pubkey() + ); + println!( + "validator_2 [vote_account={}, stake_account={}]", + validator_keypairs_2.vote_keypair.pubkey(), + validator_keypairs_2.stake_keypair.pubkey(), + ); + + assert_eq!( + expected_stake_metas.len(), + stake_meta_collection.stake_metas.len() + ); + + for actual_stake_meta in stake_meta_collection.stake_metas { + let expected_stake_meta = expected_stake_metas + .get(&actual_stake_meta.validator_vote_account) + .unwrap(); + assert_eq!( + expected_stake_meta.maybe_tip_distribution_meta, + actual_stake_meta.maybe_tip_distribution_meta + ); + assert_eq!( + expected_stake_meta.total_delegated, + actual_stake_meta.total_delegated + ); + assert_eq!(expected_stake_meta.commission, actual_stake_meta.commission); + assert_eq!( + expected_stake_meta.validator_vote_account, + actual_stake_meta.validator_vote_account + ); + + assert_eq!( + expected_stake_meta.delegations.len(), + actual_stake_meta.delegations.len() + ); + + for expected_delegation in &expected_stake_meta.delegations { + let actual_delegation = actual_stake_meta + .delegations + .iter() + .find(|d| d.stake_account_pubkey == expected_delegation.stake_account_pubkey) + .unwrap(); + + assert_eq!(expected_delegation, actual_delegation); + } + } + } + + /// Helper function that sends a delegate stake instruction to the bank. + /// Returns the created stake account pubkey. + fn delegate_stake_helper( + bank: &Bank, + from_keypair: &Keypair, + vote_account: &Pubkey, + delegation_amount: u64, + ) -> Pubkey { + let minimum_delegation = solana_stake_program::get_minimum_delegation(&bank.feature_set); + assert!( + delegation_amount >= minimum_delegation, + "{}", + format!( + "received delegation_amount {}, must be at least {}", + delegation_amount, minimum_delegation + ) + ); + if let Some(from_account) = bank.get_account(&from_keypair.pubkey()) { + assert_eq!(from_account.owner(), &solana_sdk::system_program::id()); + } else { + panic!("from_account DNE"); + } + assert!(bank.get_account(vote_account).is_some()); + + let stake_keypair = Keypair::new(); + let instructions = stake::instruction::create_account_and_delegate_stake( + &from_keypair.pubkey(), + &stake_keypair.pubkey(), + vote_account, + &Authorized::auto(&from_keypair.pubkey()), + &Lockup::default(), + delegation_amount, + ); + + let message = Message::new(&instructions[..], Some(&from_keypair.pubkey())); + let transaction = Transaction::new( + &[from_keypair, &stake_keypair], + message, + bank.last_blockhash(), + ); + + bank.process_transaction(&transaction) + .map_err(|e| { + eprintln!("Error delegating stake [error={}]", e); + e + }) + .unwrap(); + + stake_keypair.pubkey() + } + + fn tda_to_account_shared_data( + tip_distribution_program_id: &Pubkey, + lamports: u64, + tda: TipDistributionAccount, + ) -> AccountSharedData { + let mut account_data = AccountSharedData::new( + lamports, + TipDistributionAccount::SIZE, + tip_distribution_program_id, + ); + + let mut data: [u8; TipDistributionAccount::SIZE] = [0u8; TipDistributionAccount::SIZE]; + let mut cursor = std::io::Cursor::new(&mut data[..]); + tda.try_serialize(&mut cursor).unwrap(); + + account_data.set_data(data.to_vec()); + account_data + } + + fn create_config_account_data( + tip_payment_program_id: &Pubkey, + bank: &Bank, + ) -> Vec<(Pubkey, AccountSharedData)> { + let mut account_datas = vec![]; + + let config_pda = + Pubkey::find_program_address(&[CONFIG_ACCOUNT_SEED], tip_payment_program_id); + + let tip_accounts = [ + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_0], tip_payment_program_id), + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_1], tip_payment_program_id), + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_2], tip_payment_program_id), + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_3], tip_payment_program_id), + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_4], tip_payment_program_id), + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_5], tip_payment_program_id), + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_6], tip_payment_program_id), + Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_7], tip_payment_program_id), + ]; + + let config = Config { + tip_receiver: Pubkey::new_unique(), + block_builder: Pubkey::new_unique(), + block_builder_commission_pct: 10, + bumps: InitBumps { + config: config_pda.1, + tip_payment_account_0: tip_accounts[0].1, + tip_payment_account_1: tip_accounts[1].1, + tip_payment_account_2: tip_accounts[2].1, + tip_payment_account_3: tip_accounts[3].1, + tip_payment_account_4: tip_accounts[4].1, + tip_payment_account_5: tip_accounts[5].1, + tip_payment_account_6: tip_accounts[6].1, + tip_payment_account_7: tip_accounts[7].1, + }, + }; + + let mut config_account_data = AccountSharedData::new( + bank.get_minimum_balance_for_rent_exemption(Config::SIZE), + Config::SIZE, + tip_payment_program_id, + ); + + let mut config_data: [u8; Config::SIZE] = [0u8; Config::SIZE]; + let mut config_cursor = std::io::Cursor::new(&mut config_data[..]); + config.try_serialize(&mut config_cursor).unwrap(); + config_account_data.set_data(config_data.to_vec()); + account_datas.push((config_pda.0, config_account_data)); + + account_datas.extend(tip_accounts.into_iter().map(|(pubkey, _)| { + let mut tip_account_data = AccountSharedData::new( + bank.get_minimum_balance_for_rent_exemption(TipPaymentAccount::SIZE), + TipPaymentAccount::SIZE, + tip_payment_program_id, + ); + + let mut data: [u8; TipPaymentAccount::SIZE] = [0u8; TipPaymentAccount::SIZE]; + let mut cursor = std::io::Cursor::new(&mut data[..]); + TipPaymentAccount::default() + .try_serialize(&mut cursor) + .unwrap(); + tip_account_data.set_data(data.to_vec()); + + (pubkey, tip_account_data) + })); + + account_datas + } +} diff --git a/transaction-status/src/lib.rs b/transaction-status/src/lib.rs index 7c9877a26b..7a585d3c9d 100644 --- a/transaction-status/src/lib.rs +++ b/transaction-status/src/lib.rs @@ -25,7 +25,7 @@ use { }, transaction_context::TransactionReturnData, }, - std::fmt, + std::{collections::HashMap, fmt}, thiserror::Error, }; @@ -278,6 +278,13 @@ impl From for UiInnerInstructions { } } +#[derive(Default)] +pub struct PreBalanceInfo { + pub native: Vec>, + pub token: Vec>, + pub mint_decimals: HashMap, +} + #[derive(Clone, Debug, PartialEq)] pub struct TransactionTokenBalance { pub account_index: u8, diff --git a/validator/Cargo.toml b/validator/Cargo.toml index 03d14f17d4..865724114c 100644 --- a/validator/Cargo.toml +++ b/validator/Cargo.toml @@ -53,6 +53,7 @@ solana-rpc = { workspace = true } solana-rpc-client = { workspace = true } solana-rpc-client-api = { workspace = true } solana-runtime = { workspace = true } +solana-runtime-plugin = { workspace = true } solana-sdk = { workspace = true } solana-send-transaction-service = { workspace = true } solana-storage-bigtable = { workspace = true } @@ -63,6 +64,7 @@ solana-version = { workspace = true } solana-vote-program = { workspace = true } symlink = { workspace = true } thiserror = { workspace = true } +tonic = { workspace = true, features = ["tls", "tls-roots", "tls-webpki-roots"] } [dev-dependencies] solana-account-decoder = { workspace = true } diff --git a/validator/src/admin_rpc_service.rs b/validator/src/admin_rpc_service.rs index b377c219ed..2be51d513e 100644 --- a/validator/src/admin_rpc_service.rs +++ b/validator/src/admin_rpc_service.rs @@ -10,8 +10,14 @@ use { log::*, serde::{de::Deserializer, Deserialize, Serialize}, solana_core::{ - admin_rpc_post_init::AdminRpcRequestMetadataPostInit, consensus::Tower, - tower_storage::TowerStorage, validator::ValidatorStartProgress, + admin_rpc_post_init::AdminRpcRequestMetadataPostInit, + consensus::Tower, + proxy::{ + block_engine_stage::{BlockEngineConfig, BlockEngineStage}, + relayer_stage::{RelayerConfig, RelayerStage}, + }, + tower_storage::TowerStorage, + validator::ValidatorStartProgress, }, solana_geyser_plugin_manager::GeyserPluginManagerRequest, solana_gossip::contact_info::{ContactInfo, Protocol, SOCKET_ADDR_UNSPECIFIED}, @@ -29,6 +35,7 @@ use { fmt::{self, Display}, net::SocketAddr, path::{Path, PathBuf}, + str::FromStr, sync::{Arc, RwLock}, thread::{self, Builder}, time::{Duration, SystemTime}, @@ -233,6 +240,27 @@ pub trait AdminRpc { meta: Self::Metadata, public_tpu_forwards_addr: SocketAddr, ) -> Result<()>; + + #[rpc(meta, name = "setBlockEngineConfig")] + fn set_block_engine_config( + &self, + meta: Self::Metadata, + block_engine_url: String, + trust_packets: bool, + ) -> Result<()>; + + #[rpc(meta, name = "setRelayerConfig")] + fn set_relayer_config( + &self, + meta: Self::Metadata, + relayer_url: String, + trust_packets: bool, + expected_heartbeat_interval_ms: u64, + max_failed_heartbeats: u64, + ) -> Result<()>; + + #[rpc(meta, name = "setShredReceiverAddress")] + fn set_shred_receiver_address(&self, meta: Self::Metadata, addr: String) -> Result<()>; } pub struct AdminRpcImpl; @@ -431,6 +459,30 @@ impl AdminRpc for AdminRpcImpl { Ok(()) } + fn set_block_engine_config( + &self, + meta: Self::Metadata, + block_engine_url: String, + trust_packets: bool, + ) -> Result<()> { + debug!("set_block_engine_config request received"); + let config = BlockEngineConfig { + block_engine_url, + trust_packets, + }; + // Detailed log messages are printed inside validate function + if BlockEngineStage::is_valid_block_engine_config(&config) { + meta.with_post_init(|post_init| { + *post_init.block_engine_config.lock().unwrap() = config; + Ok(()) + }) + } else { + Err(jsonrpc_core::error::Error::invalid_params( + "failed to set block engine config. see logs for details.", + )) + } + } + fn set_identity( &self, meta: Self::Metadata, @@ -465,6 +517,55 @@ impl AdminRpc for AdminRpcImpl { AdminRpcImpl::set_identity_keypair(meta, identity_keypair, require_tower) } + fn set_relayer_config( + &self, + meta: Self::Metadata, + relayer_url: String, + trust_packets: bool, + expected_heartbeat_interval_ms: u64, + max_failed_heartbeats: u64, + ) -> Result<()> { + debug!("set_relayer_config request received"); + let expected_heartbeat_interval = Duration::from_millis(expected_heartbeat_interval_ms); + let oldest_allowed_heartbeat = + Duration::from_millis(max_failed_heartbeats * expected_heartbeat_interval_ms); + let config = RelayerConfig { + relayer_url, + expected_heartbeat_interval, + oldest_allowed_heartbeat, + trust_packets, + }; + // Detailed log messages are printed inside validate function + if RelayerStage::is_valid_relayer_config(&config) { + meta.with_post_init(|post_init| { + *post_init.relayer_config.lock().unwrap() = config; + Ok(()) + }) + } else { + Err(jsonrpc_core::error::Error::invalid_params( + "failed to set relayer config. see logs for details.", + )) + } + } + + fn set_shred_receiver_address(&self, meta: Self::Metadata, addr: String) -> Result<()> { + let shred_receiver_address = if addr.is_empty() { + None + } else { + Some(SocketAddr::from_str(&addr).map_err(|_| { + jsonrpc_core::error::Error::invalid_params(format!( + "invalid shred receiver address: {}", + addr + )) + })?) + }; + + meta.with_post_init(|post_init| { + *post_init.shred_receiver_address.write().unwrap() = shred_receiver_address; + Ok(()) + }) + } + fn set_staked_nodes_overrides(&self, meta: Self::Metadata, path: String) -> Result<()> { let loaded_config = load_staked_nodes_overrides(&path) .map_err(|err| { @@ -839,7 +940,10 @@ mod tests { solana_program::{program_option::COption, program_pack::Pack}, state::{Account as TokenAccount, AccountState as TokenAccountState, Mint}, }, - std::{collections::HashSet, sync::atomic::AtomicBool}, + std::{ + collections::HashSet, + sync::{atomic::AtomicBool, Mutex}, + }, }; #[derive(Default)] @@ -877,6 +981,9 @@ mod tests { let vote_account = vote_keypair.pubkey(); let start_progress = Arc::new(RwLock::new(ValidatorStartProgress::default())); let repair_whitelist = Arc::new(RwLock::new(HashSet::new())); + let block_engine_config = Arc::new(Mutex::new(BlockEngineConfig::default())); + let relayer_config = Arc::new(Mutex::new(RelayerConfig::default())); + let shred_receiver_address = Arc::new(RwLock::new(None)); let meta = AdminRpcRequestMetadata { rpc_addr: None, start_time: SystemTime::now(), @@ -889,6 +996,9 @@ mod tests { bank_forks: bank_forks.clone(), vote_account, repair_whitelist, + block_engine_config, + relayer_config, + shred_receiver_address, }))), staked_nodes_overrides: Arc::new(RwLock::new(HashMap::new())), rpc_to_plugin_manager_sender: None, diff --git a/validator/src/bootstrap.rs b/validator/src/bootstrap.rs index c2777189db..20705c1d03 100644 --- a/validator/src/bootstrap.rs +++ b/validator/src/bootstrap.rs @@ -817,12 +817,13 @@ fn get_highest_local_snapshot_hash( incremental_snapshot_archives_dir: impl AsRef, incremental_snapshot_fetch: bool, ) -> Option<(Slot, Hash)> { - snapshot_utils::get_highest_full_snapshot_archive_info(full_snapshot_archives_dir) + snapshot_utils::get_highest_full_snapshot_archive_info(full_snapshot_archives_dir, None) .and_then(|full_snapshot_info| { if incremental_snapshot_fetch { snapshot_utils::get_highest_incremental_snapshot_archive_info( incremental_snapshot_archives_dir, full_snapshot_info.slot(), + None, ) .map(|incremental_snapshot_info| { ( @@ -970,7 +971,11 @@ fn build_known_snapshot_hashes<'a>( } 'to_next_node: for node in nodes { - let Some(SnapshotHash {full: full_snapshot_hash, incr: incremental_snapshot_hash}) = get_snapshot_hashes_for_node(node) else { + let Some(SnapshotHash { + full: full_snapshot_hash, + incr: incremental_snapshot_hash, + }) = get_snapshot_hashes_for_node(node) + else { continue 'to_next_node; }; diff --git a/validator/src/cli.rs b/validator/src/cli.rs index 7eb2aea11c..b028f8beff 100644 --- a/validator/src/cli.rs +++ b/validator/src/cli.rs @@ -56,6 +56,10 @@ const MAX_SNAPSHOT_DOWNLOAD_ABORT: u32 = 5; // with less than 2 ticks per slot. const MINIMUM_TICKS_PER_SLOT: u64 = 2; +const DEFAULT_PREALLOCATED_BUNDLE_COST: &str = "3000000"; +const DEFAULT_RELAYER_EXPECTED_HEARTBEAT_INTERVAL_MS: &str = "500"; +const DEFAULT_RELAYER_MAX_FAILED_HEARTBEATS: &str = "3"; + pub fn app<'a>(version: &'a str, default_args: &'a DefaultArgs) -> App<'a, 'a> { return App::new(crate_name!()).about(crate_description!()) .version(version) @@ -66,6 +70,87 @@ pub fn app<'a>(version: &'a str, default_args: &'a DefaultArgs) -> App<'a, 'a> { .long(SKIP_SEED_PHRASE_VALIDATION_ARG.long) .help(SKIP_SEED_PHRASE_VALIDATION_ARG.help), ) + .arg( + Arg::with_name("block_engine_url") + .long("block-engine-url") + .help("Block engine url. Set to empty string to disable block engine connection.") + .takes_value(true) + ) + .arg( + Arg::with_name("relayer_url") + .long("relayer-url") + .help("Relayer url. Set to empty string to disable relayer connection.") + .takes_value(true) + ) + .arg( + Arg::with_name("trust_relayer_packets") + .long("trust-relayer-packets") + .takes_value(false) + .help("Skip signature verification on relayer packets. Not recommended unless the relayer is trusted.") + ) + .arg( + Arg::with_name("relayer_expected_heartbeat_interval_ms") + .long("relayer-expected-heartbeat-interval-ms") + .takes_value(true) + .help("Interval at which the Relayer is expected to send heartbeat messages.") + .default_value(DEFAULT_RELAYER_EXPECTED_HEARTBEAT_INTERVAL_MS) + ) + .arg( + Arg::with_name("relayer_max_failed_heartbeats") + .long("relayer-max-failed-heartbeats") + .takes_value(true) + .help("Maximum number of heartbeats the Relayer can miss before falling back to the normal TPU pipeline.") + .default_value(DEFAULT_RELAYER_MAX_FAILED_HEARTBEATS) + ) + .arg( + Arg::with_name("trust_block_engine_packets") + .long("trust-block-engine-packets") + .takes_value(false) + .help("Skip signature verification on block engine packets. Not recommended unless the block engine is trusted.") + ) + .arg( + Arg::with_name("tip_payment_program_pubkey") + .long("tip-payment-program-pubkey") + .value_name("TIP_PAYMENT_PROGRAM_PUBKEY") + .takes_value(true) + .help("The public key of the tip-payment program") + ) + .arg( + Arg::with_name("tip_distribution_program_pubkey") + .long("tip-distribution-program-pubkey") + .value_name("TIP_DISTRIBUTION_PROGRAM_PUBKEY") + .takes_value(true) + .help("The public key of the tip-distribution program.") + ) + .arg( + Arg::with_name("merkle_root_upload_authority") + .long("merkle-root-upload-authority") + .value_name("MERKLE_ROOT_UPLOAD_AUTHORITY") + .takes_value(true) + .help("The public key of the authorized merkle-root uploader.") + ) + .arg( + Arg::with_name("commission_bps") + .long("commission-bps") + .value_name("COMMISSION_BPS") + .takes_value(true) + .help("The commission validator takes from tips expressed in basis points.") + ) + .arg( + Arg::with_name("preallocated_bundle_cost") + .long("preallocated-bundle-cost") + .value_name("PREALLOCATED_BUNDLE_COST") + .takes_value(true) + .default_value(DEFAULT_PREALLOCATED_BUNDLE_COST) + .help("Number of CUs to allocate for bundles at beginning of slot.") + ) + .arg( + Arg::with_name("shred_receiver_address") + .long("shred-receiver-address") + .value_name("SHRED_RECEIVER_ADDRESS") + .takes_value(true) + .help("Validator will forward all shreds to this address in addition to normal turbine operation. Set to empty string to disable.") + ) .arg( Arg::with_name("identity") .short("i") @@ -1097,6 +1182,14 @@ pub fn app<'a>(version: &'a str, default_args: &'a DefaultArgs) -> App<'a, 'a> { .multiple(true) .help("Specify the configuration file for the Geyser plugin."), ) + .arg( + Arg::with_name("runtime_plugin_config") + .long("runtime-plugin-config") + .value_name("FILE") + .takes_value(true) + .multiple(true) + .help("Specify the configuration file for a Runtime plugin."), + ) .arg( Arg::with_name("snapshot_archive_format") .long("snapshot-archive-format") @@ -1378,6 +1471,68 @@ pub fn app<'a>(version: &'a str, default_args: &'a DefaultArgs) -> App<'a, 'a> { ) .args(&get_deprecated_arguments()) .after_help("The default subcommand is run") + .subcommand( + SubCommand::with_name("set-block-engine-config") + .about("Set configuration for connection to a block engine") + .arg( + Arg::with_name("block_engine_url") + .long("block-engine-url") + .help("Block engine url. Set to empty string to disable block engine connection.") + .takes_value(true) + .required(true) + ) + .arg( + Arg::with_name("trust_block_engine_packets") + .long("trust-block-engine-packets") + .takes_value(false) + .help("Skip signature verification on block engine packets. Not recommended unless the block engine is trusted.") + ) + ) + .subcommand( + SubCommand::with_name("set-relayer-config") + .about("Set configuration for connection to a relayer") + .arg( + Arg::with_name("relayer_url") + .long("relayer-url") + .help("Relayer url. Set to empty string to disable relayer connection.") + .takes_value(true) + .required(true) + ) + .arg( + Arg::with_name("trust_relayer_packets") + .long("trust-relayer-packets") + .takes_value(false) + .help("Skip signature verification on relayer packets. Not recommended unless the relayer is trusted.") + ) + .arg( + Arg::with_name("relayer_expected_heartbeat_interval_ms") + .long("relayer-expected-heartbeat-interval-ms") + .takes_value(true) + .help("Interval at which the Relayer is expected to send heartbeat messages.") + .required(false) + .default_value(DEFAULT_RELAYER_EXPECTED_HEARTBEAT_INTERVAL_MS) + ) + .arg( + Arg::with_name("relayer_max_failed_heartbeats") + .long("relayer-max-failed-heartbeats") + .takes_value(true) + .help("Maximum number of heartbeats the Relayer can miss before falling back to the normal TPU pipeline.") + .required(false) + .default_value(DEFAULT_RELAYER_MAX_FAILED_HEARTBEATS) + ) + ) + .subcommand( + SubCommand::with_name("set-shred-receiver-address") + .about("Changes shred receiver address") + .arg( + Arg::with_name("shred_receiver_address") + .long("shred-receiver-address") + .value_name("SHRED_RECEIVER_ADDRESS") + .takes_value(true) + .help("Validator will forward all shreds to this address in addition to normal turbine operation. Set to empty string to disable.") + .required(true) + ) + ) .subcommand( SubCommand::with_name("exit") .about("Send an exit request to the validator") @@ -1511,6 +1666,48 @@ pub fn app<'a>(version: &'a str, default_args: &'a DefaultArgs) -> App<'a, 'a> { SubCommand::with_name("run") .about("Run the validator") ) + .subcommand( + SubCommand::with_name("runtime-plugin") + .about("Manage and view runtime plugins") + .setting(AppSettings::SubcommandRequiredElseHelp) + .setting(AppSettings::InferSubcommands) + .subcommand( + SubCommand::with_name("list") + .about("List all current running runtime plugins") + ) + .subcommand( + SubCommand::with_name("unload") + .about("Unload a particular runtime plugin. You must specify the runtime plugin name") + .arg( + Arg::with_name("name") + .required(true) + .takes_value(true) + ) + ) + .subcommand( + SubCommand::with_name("reload") + .about("Reload a particular runtime plugin. You must specify the runtime plugin name and the new config path") + .arg( + Arg::with_name("name") + .required(true) + .takes_value(true) + ) + .arg( + Arg::with_name("config") + .required(true) + .takes_value(true) + ) + ) + .subcommand( + SubCommand::with_name("load") + .about("Load a new gesyer plugin. You must specify the config path. Fails if overwriting (use reload)") + .arg( + Arg::with_name("config") + .required(true) + .takes_value(true) + ) + ) + ) .subcommand( SubCommand::with_name("plugin") .about("Manage and view geyser plugins") @@ -1725,6 +1922,22 @@ fn deprecated_arguments() -> Vec { .help("Enables faster starting of validators by skipping startup clean and shrink."), usage_warning: "Enabled by default", ); + add_arg!( + Arg::with_name("block_engine_address") + .long("block-engine-address") + .value_name("block_engine_address") + .takes_value(true) + .help("Deprecated: Please use block_engine_url.") + .conflicts_with("block_engine_url"), + replaced_by: "block-engine-url"); + add_arg!( + Arg::with_name("block_engine_auth_service_address") + .long("block-engine-auth-service-address") + .value_name("block_engine_auth_service_address") + .takes_value(true) + .help("Deprecated: Please use block_engine_url.") + .conflicts_with("block_engine_url"), + replaced_by: "block-engine-url"); add_arg!(Arg::with_name("disable_accounts_disk_index") .long("disable-accounts-disk-index") .help("Disable the disk-based accounts index if it is enabled by default.") @@ -1796,6 +2009,22 @@ fn deprecated_arguments() -> Vec { .long("no-rocksdb-compaction") .takes_value(false) .help("Disable manual compaction of the ledger database")); + add_arg!( + Arg::with_name("relayer_address") + .long("relayer-address") + .value_name("relayer_address") + .takes_value(true) + .help("Deprecated: Please use relayer_url.") + .conflicts_with("relayer_url"), + replaced_by: "relayer-url"); + add_arg!( + Arg::with_name("relayer_auth_service_address") + .long("relayer-auth-service-address") + .value_name("relayer_auth_service_address") + .takes_value(true) + .help("Deprecated: Please use relayer_url.") + .conflicts_with("relayer_url"), + replaced_by: "relayer-url"); add_arg!(Arg::with_name("rocksdb_compaction_interval") .long("rocksdb-compaction-interval-slots") .value_name("ROCKSDB_COMPACTION_INTERVAL_SLOTS") @@ -2444,6 +2673,14 @@ pub fn test_app<'a>(version: &'a str, default_args: &'a DefaultTestArgs) -> App< .multiple(true) .help("Specify the configuration file for the Geyser plugin."), ) + .arg( + Arg::with_name("runtime_plugin_config") + .long("runtime-plugin-config") + .value_name("FILE") + .takes_value(true) + .multiple(true) + .help("Specify the configuration file for a Runtime plugin."), + ) .arg( Arg::with_name("deactivate_feature") .long("deactivate-feature") diff --git a/validator/src/dashboard.rs b/validator/src/dashboard.rs index a904172cda..d458e21530 100644 --- a/validator/src/dashboard.rs +++ b/validator/src/dashboard.rs @@ -274,6 +274,7 @@ fn get_validator_stats( Ok(()) => "ok".to_string(), Err(err) => { if let client_error::ErrorKind::RpcError(request::RpcError::RpcResponseError { + request_id: _, code: _, message: _, data: diff --git a/validator/src/main.rs b/validator/src/main.rs index f888adae28..2bddf47dd2 100644 --- a/validator/src/main.rs +++ b/validator/src/main.rs @@ -1,17 +1,21 @@ #![allow(clippy::integer_arithmetic)] + #[cfg(not(target_env = "msvc"))] use jemallocator::Jemalloc; use { clap::{crate_name, value_t, value_t_or_exit, values_t, values_t_or_exit, ArgMatches}, console::style, crossbeam_channel::unbounded, + jsonrpc_server_utils::tokio::runtime::Runtime, log::*, rand::{seq::SliceRandom, thread_rng}, solana_clap_utils::input_parsers::{keypair_of, keypairs_of, pubkey_of, value_of}, solana_core::{ banking_trace::DISABLED_BAKING_TRACE_DIR, ledger_cleanup_service::{DEFAULT_MAX_LEDGER_SHREDS, DEFAULT_MIN_MAX_LEDGER_SHREDS}, + proxy::{block_engine_stage::BlockEngineConfig, relayer_stage::RelayerConfig}, system_monitor_service::SystemMonitorService, + tip_manager::{TipDistributionAccountConfig, TipManagerConfig}, tower_storage, tpu::DEFAULT_TPU_COALESCE, validator::{ @@ -48,6 +52,10 @@ use { ArchiveFormat, SnapshotVersion, }, }, + solana_runtime_plugin::{ + runtime_plugin_admin_rpc_service, + runtime_plugin_admin_rpc_service::RuntimePluginAdminRpcRequestMetadata, + }, solana_sdk::{ clock::{Slot, DEFAULT_S_PER_SLOT}, commitment_config::CommitmentConfig, @@ -76,7 +84,7 @@ use { path::{Path, PathBuf}, process::exit, str::FromStr, - sync::{Arc, RwLock}, + sync::{atomic::AtomicBool, Arc, Mutex, RwLock}, time::{Duration, SystemTime}, }, }; @@ -465,6 +473,60 @@ pub fn main() { let operation = match matches.subcommand() { ("", _) | ("run", _) => Operation::Run, + ("set-block-engine-config", Some(subcommand_matches)) => { + let block_engine_url = value_t_or_exit!(subcommand_matches, "block_engine_url", String); + let trust_packets = subcommand_matches.is_present("trust_block_engine_packets"); + let admin_client = admin_rpc_service::connect(&ledger_path); + admin_rpc_service::runtime() + .block_on(async move { + admin_client + .await? + .set_block_engine_config(block_engine_url, trust_packets) + .await + }) + .unwrap_or_else(|err| { + println!("set block engine config failed: {}", err); + exit(1); + }); + return; + } + ("set-relayer-config", Some(subcommand_matches)) => { + let relayer_url = value_t_or_exit!(subcommand_matches, "relayer_url", String); + let trust_packets = subcommand_matches.is_present("trust_relayer_packets"); + let expected_heartbeat_interval_ms: u64 = + value_of(subcommand_matches, "relayer_expected_heartbeat_interval_ms").unwrap(); + let max_failed_heartbeats: u64 = + value_of(subcommand_matches, "relayer_max_failed_heartbeats").unwrap(); + let admin_client = admin_rpc_service::connect(&ledger_path); + admin_rpc_service::runtime() + .block_on(async move { + admin_client + .await? + .set_relayer_config( + relayer_url, + trust_packets, + expected_heartbeat_interval_ms, + max_failed_heartbeats, + ) + .await + }) + .unwrap_or_else(|err| { + println!("set relayer config failed: {}", err); + exit(1); + }); + return; + } + ("set-shred-receiver-address", Some(subcommand_matches)) => { + let addr = value_t_or_exit!(subcommand_matches, "shred_receiver_address", String); + let admin_client = admin_rpc_service::connect(&ledger_path); + admin_rpc_service::runtime() + .block_on(async move { admin_client.await?.set_shred_receiver_address(addr).await }) + .unwrap_or_else(|err| { + println!("set shred receiver address failed: {}", err); + exit(1); + }); + return; + } ("authorized-voter", Some(authorized_voter_subcommand_matches)) => { match authorized_voter_subcommand_matches.subcommand() { ("add", Some(subcommand_matches)) => { @@ -616,6 +678,92 @@ pub fn main() { _ => unreachable!(), } } + ("runtime-plugin", Some(plugin_subcommand_matches)) => { + let runtime_plugin_rpc_client = runtime_plugin_admin_rpc_service::connect(&ledger_path); + let runtime = Runtime::new().unwrap(); + match plugin_subcommand_matches.subcommand() { + ("list", _) => { + let plugins = runtime + .block_on( + async move { runtime_plugin_rpc_client.await?.list_plugins().await }, + ) + .unwrap_or_else(|err| { + println!("Failed to list plugins: {err}"); + exit(1); + }); + if !plugins.is_empty() { + println!("Currently the following plugins are loaded:"); + for (plugin, i) in plugins.into_iter().zip(1..) { + println!(" {i}) {plugin}"); + } + } else { + println!("There are currently no plugins loaded"); + } + return; + } + ("unload", Some(subcommand_matches)) => { + if let Ok(name) = value_t!(subcommand_matches, "name", String) { + runtime + .block_on(async { + runtime_plugin_rpc_client + .await? + .unload_plugin(name.clone()) + .await + }) + .unwrap_or_else(|err| { + println!("Failed to unload plugin {name}: {err:?}"); + exit(1); + }); + println!("Successfully unloaded plugin: {name}"); + } + return; + } + ("load", Some(subcommand_matches)) => { + if let Ok(config) = value_t!(subcommand_matches, "config", String) { + let name = runtime + .block_on(async { + runtime_plugin_rpc_client + .await? + .load_plugin(config.clone()) + .await + }) + .unwrap_or_else(|err| { + println!("Failed to load plugin {config}: {err:?}"); + exit(1); + }); + println!("Successfully loaded plugin: {name}"); + } + return; + } + ("reload", Some(subcommand_matches)) => { + if let Ok(name) = value_t!(subcommand_matches, "name", String) { + if let Ok(config) = value_t!(subcommand_matches, "config", String) { + println!( + "This command does not work as intended on some systems.\ + To correctly reload an existing plugin make sure to:\ + 1. Rename the new plugin binary file.\ + 2. Unload the previous version.\ + 3. Load the new, renamed binary using the 'Load' command." + ); + runtime + .block_on(async { + runtime_plugin_rpc_client + .await? + .reload_plugin(name.clone(), config.clone()) + .await + }) + .unwrap_or_else(|err| { + println!("Failed to reload plugin {name}: {err:?}"); + exit(1); + }); + println!("Successfully reloaded plugin: {name}"); + } + } + return; + } + _ => unreachable!(), + } + } ("contact-info", Some(subcommand_matches)) => { let output_mode = subcommand_matches.value_of("output"); let admin_client = admin_rpc_service::connect(&ledger_path); @@ -1255,6 +1403,44 @@ pub fn main() { } let full_api = matches.is_present("full_rpc_api"); + let voting_disabled = matches.is_present("no_voting") || restricted_repair_only_mode; + let tip_manager_config = tip_manager_config_from_matches(&matches, voting_disabled); + + let block_engine_config = BlockEngineConfig { + block_engine_url: if matches.is_present("block_engine_url") { + value_of(&matches, "block_engine_url").expect("couldn't parse block_engine_url") + } else { + "".to_string() + }, + trust_packets: matches.is_present("trust_block_engine_packets"), + }; + + // Defaults are set in cli definition, safe to use unwrap() here + let expected_heartbeat_interval_ms: u64 = + value_of(&matches, "relayer_expected_heartbeat_interval_ms").unwrap(); + assert!( + expected_heartbeat_interval_ms > 0, + "relayer-max-failed-heartbeats must be greater than zero" + ); + let max_failed_heartbeats: u64 = value_of(&matches, "relayer_max_failed_heartbeats").unwrap(); + assert!( + max_failed_heartbeats > 0, + "relayer-max-failed-heartbeats must be greater than zero" + ); + + let relayer_config = RelayerConfig { + relayer_url: if matches.is_present("relayer_url") { + value_of(&matches, "relayer_url").expect("couldn't parse relayer_url") + } else { + "".to_string() + }, + expected_heartbeat_interval: Duration::from_millis(expected_heartbeat_interval_ms), + oldest_allowed_heartbeat: Duration::from_millis( + max_failed_heartbeats * expected_heartbeat_interval_ms, + ), + trust_packets: matches.is_present("trust_relayer_packets"), + }; + let mut validator_config = ValidatorConfig { require_tower: matches.is_present("require_tower"), tower_storage, @@ -1387,8 +1573,18 @@ pub fn main() { log_messages_bytes_limit: value_of(&matches, "log_messages_bytes_limit"), ..RuntimeConfig::default() }, + relayer_config: Arc::new(Mutex::new(relayer_config)), + block_engine_config: Arc::new(Mutex::new(block_engine_config)), + tip_manager_config, + shred_receiver_address: Arc::new(RwLock::new( + matches + .value_of("shred_receiver_address") + .map(|addr| SocketAddr::from_str(addr).expect("shred_receiver_address invalid")), + )), staked_nodes_overrides: staked_nodes_overrides.clone(), replay_slots_concurrently: matches.is_present("replay_slots_concurrently"), + preallocated_bundle_cost: value_of(&matches, "preallocated_bundle_cost") + .expect("preallocated_bundle_cost set as default"), ..ValidatorConfig::default() }; @@ -1699,6 +1895,31 @@ pub fn main() { }, ); + let runtime_plugin_config_and_rpc_rx = { + let plugin_exit = Arc::new(AtomicBool::new(false)); + let (rpc_request_sender, rpc_request_receiver) = unbounded(); + runtime_plugin_admin_rpc_service::run( + &ledger_path, + RuntimePluginAdminRpcRequestMetadata { + rpc_request_sender, + validator_exit: validator_config.validator_exit.clone(), + }, + plugin_exit, + ); + + if matches.is_present("runtime_plugin_config") { + ( + values_t_or_exit!(matches, "runtime_plugin_config", String) + .into_iter() + .map(PathBuf::from) + .collect(), + rpc_request_receiver, + ) + } else { + (vec![], rpc_request_receiver) + } + }; + let gossip_host: IpAddr = matches .value_of("gossip_host") .map(|gossip_host| { @@ -1872,6 +2093,7 @@ pub fn main() { tpu_connection_pool_size, tpu_enable_udp, admin_service_post_init, + Some(runtime_plugin_config_and_rpc_rx), ) .unwrap_or_else(|e| { error!("Failed to start validator: {:?}", e); @@ -1937,3 +2159,47 @@ fn process_account_indexes(matches: &ArgMatches) -> AccountSecondaryIndexes { indexes: account_indexes, } } + +fn tip_manager_config_from_matches( + matches: &ArgMatches, + voting_disabled: bool, +) -> TipManagerConfig { + TipManagerConfig { + tip_payment_program_id: pubkey_of(matches, "tip_payment_program_pubkey").unwrap_or_else( + || { + if !voting_disabled { + panic!("--tip-payment-program-pubkey argument required when validator is voting"); + } + Pubkey::new_unique() + }, + ), + tip_distribution_program_id: pubkey_of(matches, "tip_distribution_program_pubkey") + .unwrap_or_else(|| { + if !voting_disabled { + panic!("--tip-distribution-program-pubkey argument required when validator is voting"); + } + Pubkey::new_unique() + }), + tip_distribution_account_config: TipDistributionAccountConfig { + merkle_root_upload_authority: pubkey_of(matches, "merkle_root_upload_authority") + .unwrap_or_else(|| { + if !voting_disabled { + panic!("--merkle-root-upload-authority argument required when validator is voting"); + } + Pubkey::new_unique() + }), + vote_account: pubkey_of(matches, "vote_account").unwrap_or_else(|| { + if !voting_disabled { + panic!("--vote-account argument required when validator is voting"); + } + Pubkey::new_unique() + }), + commission_bps: value_t!(matches, "commission_bps", u16).unwrap_or_else(|_| { + if !voting_disabled { + panic!("--commission-bps argument required when validator is voting"); + } + 0 + }), + }, + } +} diff --git a/version/src/lib.rs b/version/src/lib.rs index edeca08c96..68ce039318 100644 --- a/version/src/lib.rs +++ b/version/src/lib.rs @@ -63,7 +63,7 @@ impl Default for Version { commit: compute_commit(option_env!("CI_COMMIT")).unwrap_or_default(), feature_set, // Other client implementations need to modify this line. - client: u16::try_from(ClientId::SolanaLabs).unwrap(), + client: u16::try_from(ClientId::JitoLabs).unwrap(), } } }