From 1ee03bc8d123eeed1b64cd89daebdea7158f1a4a Mon Sep 17 00:00:00 2001 From: Lucas B Date: Thu, 25 Aug 2022 17:18:46 -0500 Subject: [PATCH 1/9] jito patch only reroute if relayer connected (#123) feat: add client tls config (#121) remove extra val (#129) fix clippy (#130) copy all binaries to docker-output (#131) Ledger tool halts at slot passed to create-snapshot (#118) update program submodule (#133) quick fix for tips and clearing old bundles (#135) update submodule to new program (#136) Improve stake-meta-generator usability (#134) pinning submodule head (#140) Use BundleAccountLocker when handling tip txs (#147) Add metrics for relayer + block engine proxy (#149) Build claim-mev in docker (#141) Rework bundle receiving and add metrics (#152) (#154) update submodule + dev files (#158) Deterministically find tip amounts, add meta to stake info, and cleanup pubkey/strings in MEV tips (#159) update jito-programs submodule (#160) Separate MEV tip related workflow (#161) Add block builder fee protos (#162) fix jito programs (#163) update submodule so autosnapshot exits out of ledger tool early (#164) Pipe through block builder fee (#167) pull in new snapshot code (#171) block builder bug (#172) Pull in new slack autosnapshot submodule (#174) sort stake meta json and use int math (#176) add accountsdb conn submod (#169) Update tip distribution parameters (#177) new submodules (#180) Add buildkite link for jito CI (#183) Fixed broken links to repositories (#184) Changed from ssh to https transfer for clone Seg/update submods (#187) fix tests (#190) rm geyser submod (#192) rm dangling geyser references (#193) fix syntax err (#195) use deterministic req ids in batch calls (#199) update jito-programs revert cargo update Cargo lock update with path fix fix cargo update autosnapshot with block lookback (#201) [JIT-460] When claiming mev tips, skip accounts that won't have min rent exempt amount after claiming (#203) Add logging for sol balance desired (#205) * add logging * add logging * update msg * tweak vars update submodule (#204) use efficient data structures when calling batch_simulate_bundles (#206) [JIT-504] Add low balance check in uploading merkle roots (#209) add config to simulate on top of working bank (#211) rm frozen bank check simulate_bundle rpc bugfixes (#214) rm frozen bank check in simulate_bundle rpc method [JIT-519] Store ClaimStatus address in merkle-root-json (#210) * add files * switch to include bump update submodule (#217) add amount filter (#218) update autosnapshot (#222) Print TX error in Bundles (#223) add new args to support single relayer and block-engine endpoints (#224) point to new jito-programs submod and invoke updated init tda instruction (#228) fix clippy errors (#230) fix validator start scripts (#232) Point README to gitbook (#237) use packaged cargo bin to build (#239) Add validator identity pubkey to StakeMeta (#226) The vote account associated with a validator is not a permanent link, so log the validator identity as well. bugfix: conditionally compile with debug flags (#240) Seg/tip distributor master (#242) * validate tree nodes * fix unit tests * pr feedback * bump jito-programs submod Simplify bootstrapping (#241) * startup without precompile * update spacing * use release mode * spacing fix validation rm validation skip Account for block builder fee when generating excess tip balance (#247) Improve docker caching delay constructing claim mev txs (#253) fix stake meta tests from bb fee (#254) fix tests Buffer bundles that exceed cost model (#225) * buffer bundles that exceed cost model clear qos failed bundles buffer if not leader soon (#260) update Cargo.lock to correct solana versions in jito-programs submodule (#265) fix simulate_bundle client and better error handling (#267) update submod (#272) Preallocate Bundle Cost (#238) fix Dockerfile (#278) Fix Tests (#279) Fix Tests (#281) * fix tests update jito-programs submod (#282) add reclaim rent workflow (#283) update jito-programs submod fix clippy errs rm wrong assertion and swap out file write fn call (#292) Remove security.md (#293) demote frequent relayer_stage-stream_error to warn (#275) account for case where TDA exists but not allocated (#295) implement better retries for tip-distributor workflows (#297) limit number of concurrent rpc calls (#298) Discard Empty Packet Batches (#299) Identity Hotswap (#290) small fixes (#305) Set backend config from admin rpc (#304) Admin Shred Receiver Change (#306) Seg/rm bundle UUID (#309) Fix github workflow to recursively clone (#327) Add recursive checkout for downstream-project-spl.yaml (#341) Use cluster info functions for tpu (#345) Use git rev-parse for git sha Remove blacklisted tx from message_hash_to_transaction (#374) Updates bootstrap and start scripts needed for local dev. (#384) Remove Deprecated Cli Args (#387) Master Rebase improve simulate_bundle errors and response (#404) derive Clone on accountoverrides (#416) Add upsert to AccountOverrides (#419) update jito-programs (#430) --- .dockerignore | 9 + .github/dependabot.yml | 23 +- .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 | 749 ++++++-- Cargo.toml | 10 + README.md | 141 +- SECURITY.md | 167 -- accounts-db/src/account_overrides.rs | 6 +- accounts-db/src/accounts.rs | 98 +- anchor | 1 + banking-bench/src/main.rs | 14 +- banks-server/Cargo.toml | 1 + banks-server/src/banks_server.rs | 5 +- bootstrap | 26 + bundle/Cargo.toml | 35 + bundle/src/bundle_execution.rs | 1186 ++++++++++++ bundle/src/lib.rs | 60 + ci/buildkite-pipeline-in-disk.sh | 4 +- ci/buildkite-pipeline.sh | 4 +- ci/check-crates.sh | 3 + core/Cargo.toml | 12 + core/benches/banking_stage.rs | 24 +- core/benches/consumer.rs | 28 +- core/benches/proto_to_packet.rs | 56 + core/src/admin_rpc_post_init.rs | 7 +- core/src/banking_stage.rs | 75 +- core/src/banking_stage/committer.rs | 17 +- core/src/banking_stage/consume_worker.rs | 23 +- core/src/banking_stage/consumer.rs | 168 +- .../banking_stage/latest_unprocessed_votes.rs | 2 +- core/src/banking_stage/qos_service.rs | 48 +- .../unprocessed_transaction_storage.rs | 439 ++++- core/src/banking_trace.rs | 1 + core/src/bundle_stage.rs | 436 +++++ .../src/bundle_stage/bundle_account_locker.rs | 326 ++++ core/src/bundle_stage/bundle_consumer.rs | 1587 +++++++++++++++++ .../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 | 219 +++ core/src/bundle_stage/result.rs | 41 + core/src/consensus_cache_updater.rs | 52 + core/src/immutable_deserialized_bundle.rs | 485 +++++ 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/tip_manager.rs | 583 ++++++ core/src/tpu.rs | 108 +- core/src/tpu_entry_notifier.rs | 60 +- core/src/tvu.rs | 3 + core/src/validator.rs | 25 +- core/tests/epoch_accounts_hash.rs | 2 + core/tests/snapshots.rs | 3 + cost-model/src/cost_tracker.rs | 8 + deploy_programs | 17 + dev/Dockerfile | 48 + entry/src/entry.rs | 2 +- entry/src/poh.rs | 29 +- f | 30 + fetch-spl.sh | 41 +- 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 | 22 + ledger-tool/src/program.rs | 1 + ledger/src/bank_forks_utils.rs | 22 +- ledger/src/blockstore_processor.rs | 5 +- ledger/src/token_balances.rs | 55 +- .../src/local_cluster_snapshot_utils.rs | 6 +- local-cluster/src/validator_configs.rs | 5 + local-cluster/tests/local_cluster.rs | 16 +- 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.4.so | Bin 0 -> 423080 bytes .../src/programs/jito_tip_payment-0.1.4.so | Bin 0 -> 430592 bytes programs/sbf/Cargo.lock | 608 +++++-- 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 | 489 ++++- rpc/src/rpc_service.rs | 9 +- runtime/src/bank.rs | 171 +- runtime/src/snapshot_bank_utils.rs | 6 +- runtime/src/snapshot_utils.rs | 22 +- 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 + tip-distributor/Cargo.toml | 57 + tip-distributor/README.md | 43 + tip-distributor/src/bin/claim-mev-tips.rs | 52 + .../src/bin/merkle-root-generator.rs | 34 + .../src/bin/merkle-root-uploader.rs | 50 + tip-distributor/src/bin/reclaim-rent.rs | 62 + .../src/bin/stake-meta-generator.rs | 67 + tip-distributor/src/claim_mev_workflow.rs | 152 ++ tip-distributor/src/lib.rs | 887 +++++++++ .../src/merkle_root_generator_workflow.rs | 54 + .../src/merkle_root_upload_workflow.rs | 134 ++ tip-distributor/src/reclaim_rent_workflow.rs | 167 ++ .../src/stake_meta_generator_workflow.rs | 966 ++++++++++ transaction-status/src/lib.rs | 9 +- turbine/benches/cluster_info.rs | 1 + turbine/benches/retransmit_stage.rs | 1 + turbine/src/broadcast_stage.rs | 51 +- .../broadcast_duplicates_run.rs | 1 + .../broadcast_fake_shreds_run.rs | 1 + .../src/broadcast_stage/broadcast_utils.rs | 55 +- .../fail_entry_verification_broadcast_run.rs | 4 +- .../broadcast_stage/standard_broadcast_run.rs | 24 +- turbine/src/retransmit_stage.rs | 15 +- validator/Cargo.toml | 1 + validator/src/admin_rpc_service.rs | 110 +- validator/src/bootstrap.rs | 3 +- validator/src/cli.rs | 147 ++ validator/src/dashboard.rs | 1 + validator/src/main.rs | 150 +- version/src/lib.rs | 2 +- 162 files changed, 15779 insertions(+), 1104 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.4.so create mode 100644 program-test/src/programs/jito_tip_payment-0.1.4.so create mode 100644 rpc-client-api/src/bundles.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/reclaim-rent.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/dependabot.yml b/.github/dependabot.yml index 95e3fb3444..91cf374c79 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,14 +3,15 @@ # Please see the documentation for all configuration options: # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates -version: 2 -updates: -- package-ecosystem: cargo - directory: "/" - schedule: - interval: daily - time: "01:00" - timezone: America/Los_Angeles - #labels: - # - "automerge" - open-pull-requests-limit: 6 +# NOTE: Jito-Solana ignores this as we pull in upstream dependabot merges +#version: 2 +#updates: +#- package-ecosystem: cargo +# directory: "/" +# schedule: +# interval: daily +# time: "01:00" +# timezone: America/Los_Angeles +# #labels: +# # - "automerge" +# open-pull-requests-limit: 6 diff --git a/.github/workflows/client-targets.yml b/.github/workflows/client-targets.yml index 97118918ef..aacb52629d 100644 --- a/.github/workflows/client-targets.yml +++ b/.github/workflows/client-targets.yml @@ -32,6 +32,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - run: cargo install cargo-ndk@2.12.2 @@ -56,6 +58,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 f0ecfb20ac..9a876d5d09 100644 --- a/.github/workflows/downstream-project-spl.yml +++ b/.github/workflows/downstream-project-spl.yml @@ -37,6 +37,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - shell: bash run: | @@ -84,6 +86,8 @@ jobs: ] steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - shell: bash run: | @@ -133,6 +137,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 8afcd1f638..cdc6eae453 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,6 +125,145 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "anchor-attribute-access-control" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.69", + "quote 1.0.33", + "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.69", + "quote 1.0.33", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-constant" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "proc-macro2 1.0.69", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-error" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "proc-macro2 1.0.69", + "quote 1.0.33", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-event" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.69", + "quote 1.0.33", + "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.69", + "quote 1.0.33", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-program" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.69", + "quote 1.0.33", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-state" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.69", + "quote 1.0.33", + "syn 1.0.109", +] + +[[package]] +name = "anchor-derive-accounts" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.69", + "quote 1.0.33", + "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.69", + "proc-macro2-diagnostics", + "quote 1.0.33", + "serde", + "serde_json", + "sha2 0.9.9", + "syn 1.0.109", + "thiserror", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -155,12 +294,55 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "anstream" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is-terminal", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd" +[[package]] +name = "anstyle-parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "anstyle-wincon" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c677ab05e09154296dd37acecd46420c17b9713e8366facafa8fc0885167cf4c" +dependencies = [ + "anstyle", + "windows-sys 0.48.0", +] + [[package]] name = "anyhow" version = "1.0.75" @@ -227,7 +409,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" dependencies = [ - "quote", + "quote 1.0.33", "syn 1.0.109", ] @@ -239,8 +421,8 @@ checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ "num-bigint 0.4.4", "num-traits", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -275,8 +457,8 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -336,8 +518,8 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "726535892e8eae7e70657b4c8ea93d26b8553afb1ce617caee529ef96d7dee6c" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", "synstructure", ] @@ -348,8 +530,8 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -423,8 +605,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "648ed8c8d2ce5409ccd57453d9d1b214b342a0d69376a6feda1fd6cae3299308" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -434,8 +616,8 @@ version = "0.1.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -585,8 +767,8 @@ dependencies = [ "lazycell", "peeking_take_while", "prettyplease 0.2.4", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "regex", "rustc-hash", "shlex", @@ -721,7 +903,7 @@ dependencies = [ "borsh-derive-internal 0.9.3", "borsh-schema-derive-internal 0.9.3", "proc-macro-crate 0.1.5", - "proc-macro2", + "proc-macro2 1.0.69", "syn 1.0.109", ] @@ -734,7 +916,7 @@ dependencies = [ "borsh-derive-internal 0.10.3", "borsh-schema-derive-internal 0.10.3", "proc-macro-crate 0.1.5", - "proc-macro2", + "proc-macro2 1.0.69", "syn 1.0.109", ] @@ -744,8 +926,8 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5449c28a7b352f2d1e592a8a28bf139bc71afb0764a14f3c02500935d8c44065" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -755,8 +937,8 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afb438156919598d2c7bad7e1c0adf3d26ed3840dbc010db1a882a65583ca2fb" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -766,8 +948,8 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdbd5696d8bfa21d53d9fe39a714a18538bad11492a42d066dbbc395fb1951c0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -777,8 +959,8 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "634205cc43f74a1b9046ef87c4540ebda95696ec0f315024860cad7c5b0f5ccd" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -803,6 +985,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" @@ -883,8 +1071,8 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aca418a974d83d40a0c1f0c5cba6ff4bc28d8df099109ca459a2118d40b6322" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -1109,7 +1297,7 @@ checksum = "71655c45cb9845d3270c9d6df84ebe72b4dad3c2ba3f7023ad47c144e4e473a5" dependencies = [ "atty", "bitflags 1.3.2", - "clap_derive", + "clap_derive 3.2.18", "clap_lex 0.2.4", "indexmap 1.9.3", "once_cell", @@ -1125,6 +1313,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c27cdf28c0f604ba3f512b0c9a409f8de8513e4816705deb0498b627e7c3a3fd" dependencies = [ "clap_builder", + "clap_derive 4.3.12", + "once_cell", ] [[package]] @@ -1133,8 +1323,10 @@ version = "4.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a9f1ab5e9f01a9b81f202e8562eb9a10de70abf9eaeac1be465c28b75aa4aa" dependencies = [ + "anstream", "anstyle", "clap_lex 0.5.0", + "strsim 0.10.0", ] [[package]] @@ -1143,13 +1335,25 @@ version = "3.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65" dependencies = [ - "heck", + "heck 0.4.0", "proc-macro-error", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] +[[package]] +name = "clap_derive" +version = "4.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050" +dependencies = [ + "heck 0.4.0", + "proc-macro2 1.0.69", + "quote 1.0.33", + "syn 2.0.38", +] + [[package]] name = "clap_lex" version = "0.2.4" @@ -1165,6 +1369,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + [[package]] name = "combine" version = "3.8.1" @@ -1241,9 +1451,9 @@ version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e026b6ce194a874cb9cf32cd5772d1ef9767cc8fcb5765948d74f37a9d8b2bf6" dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", + "proc-macro2 1.0.69", + "quote 1.0.33", + "unicode-xid 0.2.2", ] [[package]] @@ -1493,8 +1703,8 @@ checksum = "ab8bfa2e259f8ee1ce5e97824a3c55ec4404a0d772ca7fa96bf19f0752a046eb" dependencies = [ "fnv", "ident_case", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "strsim 0.10.0", "syn 2.0.38", ] @@ -1506,7 +1716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29a358ff9f12ec09c3e61fef9b5a9902623a695a46a917b07f269bff1445611a" dependencies = [ "darling_core", - "quote", + "quote 1.0.33", "syn 2.0.38", ] @@ -1538,6 +1748,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" @@ -1573,8 +1794,8 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -1585,8 +1806,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40eebddd2156ce1bb37b20bbe5151340a31828b1f2d22ba4141f3531710e38df" dependencies = [ "convert_case", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "rustc_version 0.3.3", "syn 1.0.109", ] @@ -1674,8 +1895,8 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bf95dc3f046b9da4f2d51833c0d3547d8564ef6910f5c1ed130306a75b92886" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -1697,8 +1918,8 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cbae11b3de8fce2a456e8ea3dada226b35fe791f0dc1d360c0941f0bb681f3" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -1756,8 +1977,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86b50932a01e7ec5c06160492ab660fb19b6bb2a7878030dd6cd68d21df9d4d" dependencies = [ "enum-ordinalize", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -1797,8 +2018,8 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eecf8589574ce9b895052fa12d69af7a233f99e6107f5cb8dd1044f2a17bfdcb" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -1810,8 +2031,8 @@ checksum = "0b166c9e378360dd5a6666a9604bb4f54ae0cac39023ffbac425e917a2a04fef" dependencies = [ "num-bigint 0.4.4", "num-traits", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -2072,8 +2293,8 @@ version = "0.3.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -2365,6 +2586,15 @@ dependencies = [ "http", ] +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.4.0" @@ -2729,6 +2959,49 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +[[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.18.0" +dependencies = [ + "bytes", + "prost", + "prost-types", + "protobuf-src", + "tonic", + "tonic-build", +] + +[[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" @@ -2809,8 +3082,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b939a78fa820cdfcb7ee7484466746a7377760970f6f9c6fe19f9edcc8a38d2" dependencies = [ "proc-macro-crate 0.1.5", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -3240,8 +3513,8 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a7d5f7076603ebc68de2dc6a650ec331a062a13abaa346975be747bbfa4b789" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -3373,8 +3646,8 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -3384,8 +3657,8 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e6a0fd4f737c707bd9086cc16c925f294943eb62eb71499e9fd4cf71f8b9f4e" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -3476,8 +3749,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" dependencies = [ "proc-macro-crate 1.1.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -3488,8 +3761,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6" dependencies = [ "proc-macro-crate 1.1.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -3500,8 +3773,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ea360eafe1022f7cc56cd7b869ed57330fb2453d0c7831d99b74c65d2f5597" dependencies = [ "proc-macro-crate 1.1.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -3583,8 +3856,8 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -3659,8 +3932,8 @@ checksum = "5f7d21ccd03305a674437ee1248f3ab5d4b1db095cf1caf49f1713ddf61956b7" dependencies = [ "Inflector", "proc-macro-error", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -3813,8 +4086,8 @@ checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" dependencies = [ "pest", "pest_meta", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -3864,8 +4137,8 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -3998,7 +4271,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b83ec2d0af5c5c556257ff52c9f98934e243b9fd39604bfb2a9b75ec2e97f18" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.69", "syn 1.0.109", ] @@ -4008,7 +4281,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ceca8aaf45b5c46ec7ed39fff75f57290368c1846d33d24a122ca81416ab058" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.69", "syn 2.0.38", ] @@ -4038,8 +4311,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ "proc-macro-error-attr", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", "version_check", ] @@ -4050,11 +4323,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "version_check", ] +[[package]] +name = "proc-macro2" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" +dependencies = [ + "unicode-xid 0.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.69" @@ -4064,6 +4346,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.69", + "quote 1.0.33", + "syn 1.0.109", + "version_check", + "yansi", +] + [[package]] name = "proptest" version = "1.2.0" @@ -4101,7 +4396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" dependencies = [ "bytes", - "heck", + "heck 0.4.0", "itertools", "lazy_static", "log", @@ -4124,8 +4419,8 @@ checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" dependencies = [ "anyhow", "itertools", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -4170,8 +4465,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e2e25ee72f5b24d773cae88422baddefff7714f97aab68d96fe2b6fc4a28fb2" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -4229,13 +4524,22 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "quote" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" +dependencies = [ + "proc-macro2 0.4.30", +] + [[package]] name = "quote" version = "1.0.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.69", ] [[package]] @@ -4650,7 +4954,7 @@ checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.4", "sct", ] @@ -4684,6 +4988,16 @@ dependencies = [ "base64 0.13.1", ] +[[package]] +name = "rustls-webpki" +version = "0.100.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6a5fc258f1c1276dfe3016516945546e2d5383911efc0fc4f1cdc5df3a4ae3" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.101.4" @@ -4758,8 +5072,8 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdbda6ac5cd1321e724fa9cee216f3a61885889b896f073b8f82322789c5250e" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -4847,8 +5161,8 @@ version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -4901,8 +5215,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f" dependencies = [ "darling", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -4951,8 +5265,8 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91d129178576168c589c9ec973feedf7d3126c01ac2bf08795109aa35b69fb8f" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -5188,7 +5502,7 @@ dependencies = [ "assert_matches", "base64 0.21.4", "bincode", - "bs58", + "bs58 0.4.0", "bv", "lazy_static", "serde", @@ -5403,6 +5717,7 @@ dependencies = [ "solana-accounts-db", "solana-banks-interface", "solana-client", + "solana-gossip", "solana-runtime", "solana-sdk", "solana-send-transaction-service", @@ -5530,6 +5845,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "solana-bundle" +version = "1.18.0" +dependencies = [ + "anchor-lang", + "assert_matches", + "itertools", + "log", + "serde", + "solana-accounts-db", + "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.18.0" @@ -5644,7 +5980,7 @@ version = "1.18.0" dependencies = [ "assert_matches", "bincode", - "bs58", + "bs58 0.4.0", "clap 2.33.3", "console", "const_format", @@ -5843,10 +6179,11 @@ dependencies = [ name = "solana-core" version = "1.18.0" dependencies = [ + "anchor-lang", "assert_matches", "base64 0.21.4", "bincode", - "bs58", + "bs58 0.4.0", "bytes", "chrono", "crossbeam-channel", @@ -5857,11 +6194,16 @@ dependencies = [ "futures 0.3.28", "histogram", "itertools", + "jito-protos", + "jito-tip-distribution", + "jito-tip-payment", "lazy_static", "log", "lru", "min-max-heap", "num_enum 0.7.0", + "prost", + "prost-types", "quinn", "rand 0.8.5", "rand_chacha 0.3.1", @@ -5878,6 +6220,7 @@ dependencies = [ "serial_test", "solana-accounts-db", "solana-bloom", + "solana-bundle", "solana-client", "solana-core", "solana-cost-model", @@ -5894,6 +6237,7 @@ dependencies = [ "solana-perf", "solana-poh", "solana-program-runtime", + "solana-program-test", "solana-quic-client", "solana-rayon-threadlimit", "solana-rpc", @@ -5920,6 +6264,8 @@ dependencies = [ "test-case", "thiserror", "tokio", + "tonic", + "tonic-build", "trees", ] @@ -6051,7 +6397,7 @@ version = "1.18.0" dependencies = [ "bitflags 2.3.3", "block-buffer 0.10.4", - "bs58", + "bs58 0.4.0", "bv", "either", "generic-array 0.14.7", @@ -6074,8 +6420,8 @@ dependencies = [ name = "solana-frozen-abi-macro" version = "1.18.0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "rustc_version 0.4.0", "syn 2.0.38", ] @@ -6130,7 +6476,7 @@ dependencies = [ name = "solana-geyser-plugin-manager" version = "1.18.0" dependencies = [ - "bs58", + "bs58 0.4.0", "crossbeam-channel", "json5", "jsonrpc-core", @@ -6241,7 +6587,7 @@ dependencies = [ name = "solana-keygen" version = "1.18.0" dependencies = [ - "bs58", + "bs58 0.4.0", "clap 3.2.23", "dirs-next", "num_cpus", @@ -6261,7 +6607,7 @@ dependencies = [ "assert_matches", "bincode", "bitflags 2.3.3", - "bs58", + "bs58 0.4.0", "byteorder", "chrono", "chrono-humanize", @@ -6327,7 +6673,7 @@ name = "solana-ledger-tool" version = "1.18.0" dependencies = [ "assert_cmd", - "bs58", + "bs58 0.4.0", "bytecount", "chrono", "clap 2.33.3", @@ -6624,7 +6970,7 @@ dependencies = [ "blake3", "borsh 0.10.3", "borsh 0.9.3", - "bs58", + "bs58 0.4.0", "bv", "bytemuck", "cc", @@ -6806,7 +7152,7 @@ version = "1.18.0" dependencies = [ "base64 0.21.4", "bincode", - "bs58", + "bs58 0.4.0", "crossbeam-channel", "dashmap 4.0.2", "itertools", @@ -6826,6 +7172,7 @@ dependencies = [ "soketto", "solana-account-decoder", "solana-accounts-db", + "solana-bundle", "solana-client", "solana-entry", "solana-faucet", @@ -6836,6 +7183,7 @@ dependencies = [ "solana-net-utils", "solana-perf", "solana-poh", + "solana-program-runtime", "solana-rayon-threadlimit", "solana-rpc-client-api", "solana-runtime", @@ -6867,7 +7215,7 @@ dependencies = [ "async-trait", "base64 0.21.4", "bincode", - "bs58", + "bs58 0.4.0", "crossbeam-channel", "futures 0.3.28", "indicatif", @@ -6893,7 +7241,7 @@ name = "solana-rpc-client-api" version = "1.18.0" dependencies = [ "base64 0.21.4", - "bs58", + "bs58 0.4.0", "jsonrpc-core", "reqwest", "semver 1.0.19", @@ -6901,6 +7249,8 @@ dependencies = [ "serde_derive", "serde_json", "solana-account-decoder", + "solana-accounts-db", + "solana-bundle", "solana-sdk", "solana-transaction-status", "solana-version", @@ -6930,13 +7280,14 @@ name = "solana-rpc-test" version = "1.18.0" 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", @@ -7039,13 +7390,14 @@ dependencies = [ name = "solana-sdk" version = "1.18.0" dependencies = [ + "anchor-lang", "anyhow", "assert_matches", "base64 0.21.4", "bincode", "bitflags 2.3.3", "borsh 0.10.3", - "bs58", + "bs58 0.4.0", "bytemuck", "byteorder", "chrono", @@ -7097,13 +7449,19 @@ dependencies = [ name = "solana-sdk-macro" version = "1.18.0" dependencies = [ - "bs58", - "proc-macro2", - "quote", + "bs58 0.4.0", + "proc-macro2 1.0.69", + "quote 1.0.33", "rustversion", "syn 2.0.38", ] +[[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.18.0" @@ -7111,11 +7469,13 @@ dependencies = [ "crossbeam-channel", "log", "solana-client", + "solana-gossip", "solana-logger", "solana-measure", "solana-metrics", "solana-runtime", "solana-sdk", + "solana-streamer", "solana-tpu-client", ] @@ -7189,7 +7549,7 @@ name = "solana-storage-proto" version = "1.18.0" dependencies = [ "bincode", - "bs58", + "bs58 0.4.0", "enum-iterator", "prost", "protobuf-src", @@ -7302,6 +7662,38 @@ dependencies = [ "solana-sdk", ] +[[package]] +name = "solana-tip-distributor" +version = "1.18.0" +dependencies = [ + "anchor-lang", + "clap 4.3.21", + "env_logger", + "futures 0.3.28", + "im", + "itertools", + "jito-tip-distribution", + "jito-tip-payment", + "log", + "num-traits", + "serde", + "serde_json", + "solana-accounts-db", + "solana-client", + "solana-genesis-utils", + "solana-ledger", + "solana-merkle-tree", + "solana-metrics", + "solana-program", + "solana-rpc-client-api", + "solana-runtime", + "solana-sdk", + "solana-stake-program", + "solana-vote", + "thiserror", + "tokio", +] + [[package]] name = "solana-tokens" version = "1.18.0" @@ -7392,7 +7784,7 @@ dependencies = [ "base64 0.21.4", "bincode", "borsh 0.10.3", - "bs58", + "bs58 0.4.0", "lazy_static", "log", "serde", @@ -7527,6 +7919,7 @@ dependencies = [ "symlink", "thiserror", "tikv-jemallocator", + "tonic", ] [[package]] @@ -7630,7 +8023,7 @@ dependencies = [ name = "solana-zk-keygen" version = "1.18.0" dependencies = [ - "bs58", + "bs58 0.4.0", "clap 3.2.23", "dirs-next", "num_cpus", @@ -7774,7 +8167,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fadbefec4f3c678215ca72bd71862697bb06b41fd77c0088902dd3203354387b" dependencies = [ - "quote", + "quote 1.0.33", "spl-discriminator-syn", "syn 2.0.38", ] @@ -7785,8 +8178,8 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e5f2044ca42c8938d54d1255ce599c79a1ffd86b677dfab695caa20f9ffc3f2" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "sha2 0.10.8", "syn 2.0.38", "thiserror", @@ -7843,8 +8236,8 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab5269c8e868da17b6552ef35a51355a017bd8e0eae269c201fef830d35fa52c" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "sha2 0.10.8", "syn 2.0.38", ] @@ -7987,9 +8380,9 @@ version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" dependencies = [ - "heck", - "proc-macro2", - "quote", + "heck 0.4.0", + "proc-macro2 1.0.69", + "quote 1.0.33", "rustversion", "syn 1.0.109", ] @@ -8006,14 +8399,25 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +[[package]] +name = "syn" +version = "0.15.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "unicode-xid 0.1.0", +] + [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "unicode-ident", ] @@ -8023,8 +8427,8 @@ version = "2.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e96b79aaa137db8f61e26363a0c9b47d8b4ec75da28b7d1d614c2303e232408b" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "unicode-ident", ] @@ -8040,10 +8444,10 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", - "unicode-xid", + "unicode-xid 0.2.2", ] [[package]] @@ -8145,8 +8549,8 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee42b4e559f17bce0385ebf511a7beb67d5cc33c12c96b7f4e9789919d9c10f" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -8195,8 +8599,8 @@ checksum = "54c25e2cb8f5fcd7318157634e8838aa6f7e4715c96637f969fabaccd1ef5462" dependencies = [ "cfg-if 1.0.0", "proc-macro-error", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -8207,8 +8611,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37cfd7bbc88a0104e304229fba519bdc45501a30b760fb72240342f1289ad257" dependencies = [ "proc-macro-error", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", "test-case-core", ] @@ -8243,8 +8647,8 @@ version = "1.0.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -8382,8 +8786,8 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] @@ -8543,6 +8947,7 @@ dependencies = [ "percent-encoding 2.3.0", "pin-project", "prost", + "rustls-native-certs", "rustls-pemfile 1.0.0", "tokio", "tokio-rustls", @@ -8551,6 +8956,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "webpki-roots 0.23.1", ] [[package]] @@ -8560,9 +8966,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6fdaae4c2c638bb70fe42803a26fbd6fc6ac8c72f5c59f67ecc2a2dcabf4b07" dependencies = [ "prettyplease 0.1.9", - "proc-macro2", + "proc-macro2 1.0.69", "prost-build", - "quote", + "quote 1.0.33", "syn 1.0.109", ] @@ -8617,8 +9023,8 @@ version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4f480b8f81512e825f337ad51e94c1eb5d3bbdf2b363dcd01e2b19a9ffe3f8e" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 1.0.109", ] @@ -8735,12 +9141,24 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" + [[package]] name = "unicode-width" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +[[package]] +name = "unicode-xid" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" + [[package]] name = "unicode-xid" version = "0.2.2" @@ -8822,6 +9240,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cf7d77f457ef8dfa11e4cd5933c5ddb5dc52a94664071951219a97710f0a32b" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "vcpkg" version = "0.2.15" @@ -8907,8 +9331,8 @@ dependencies = [ "bumpalo", "log", "once_cell", - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", "wasm-bindgen-shared", ] @@ -8931,7 +9355,7 @@ version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d" dependencies = [ - "quote", + "quote 1.0.33", "wasm-bindgen-macro-support", ] @@ -8941,8 +9365,8 @@ version = "0.2.87" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", "wasm-bindgen-backend", "wasm-bindgen-shared", @@ -8964,13 +9388,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" +dependencies = [ + "rustls-webpki 0.100.3", +] + [[package]] name = "webpki-roots" version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b291546d5d9d1eab74f069c77749f2cb8504a12caa20f0f2de93ddbf6f411888" dependencies = [ - "rustls-webpki", + "rustls-webpki 0.101.4", ] [[package]] @@ -9263,6 +9696,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" @@ -9287,8 +9726,8 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.69", + "quote 1.0.33", "syn 2.0.38", ] diff --git a/Cargo.toml b/Cargo.toml index 509ffb6047..58db6628d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "bench-tps", "bloom", "bucket_map", + "bundle", "cargo-registry", "clap-utils", "clap-v3-utils", @@ -40,6 +41,7 @@ members = [ "geyser-plugin-manager", "gossip", "install", + "jito-protos", "keygen", "ledger", "ledger-tool", @@ -101,6 +103,7 @@ members = [ "streamer", "test-validator", "thin-client", + "tip-distributor", "tokens", "tpu-client", "transaction-dos", @@ -118,6 +121,8 @@ members = [ ] exclude = [ + "anchor", + "jito-programs", "programs/sbf", ] @@ -136,6 +141,7 @@ edition = "2021" aes-gcm-siv = "0.10.3" ahash = "0.8.3" anyhow = "1.0.75" +anchor-lang = { path = "anchor/lang" } ark-bn254 = "0.4.0" ark-ec = "0.4.0" ark-ff = "0.4.0" @@ -225,6 +231,9 @@ Inflector = "0.11.4" itertools = "0.10.5" jemallocator = { package = "tikv-jemallocator", version = "0.4.1", features = ["unprefixed_malloc_on_supported_platforms"] } js-sys = "0.3.64" +jito-protos = { path = "jito-protos", version = "=1.18.0" } +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"] } json5 = "0.4.1" jsonrpc-core = "18.0.0" jsonrpc-core-client = "18.0.0" @@ -315,6 +324,7 @@ solana-bloom = { path = "bloom", version = "=1.18.0" } solana-bpf-loader-program = { path = "programs/bpf_loader", version = "=1.18.0" } solana-bucket-map = { path = "bucket_map", version = "=1.18.0" } solana-cargo-registry = { path = "cargo-registry", version = "=1.18.0" } +solana-bundle = { path = "bundle", version = "=1.18.0" } solana-connection-cache = { path = "connection-cache", version = "=1.18.0", default-features = false } solana-clap-utils = { path = "clap-utils", version = "=1.18.0" } solana-clap-v3-utils = { path = "clap-v3-utils", version = "=1.18.0" } 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 905316c2dc..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 -At its sole discretion, the Solana Foundation may offer a bounty for -[valid reports](#reporting) of critical Solana vulnerabilities. Please see below -for more details. The submitter is not required to provide a -mitigation to qualify. - -#### 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/accounts-db/src/account_overrides.rs b/accounts-db/src/account_overrides.rs index ee8e7ec9e2..d5d3286426 100644 --- a/accounts-db/src/account_overrides.rs +++ b/accounts-db/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/accounts-db/src/accounts.rs b/accounts-db/src/accounts.rs index 47b372d981..3f8a1d677a 100644 --- a/accounts-db/src/accounts.rs +++ b/accounts-db/src/accounts.rs @@ -1173,19 +1173,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); } @@ -1230,7 +1235,11 @@ 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(), + ) } #[must_use] @@ -1240,6 +1249,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) @@ -1248,22 +1259,30 @@ 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), }) @@ -1313,7 +1332,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, @@ -1340,8 +1359,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], @@ -1410,6 +1428,55 @@ impl Accounts { } (accounts, transactions) } + + #[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() + } + + 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) + } } fn prepare_if_nonce_account( @@ -1498,6 +1565,7 @@ mod tests { sync::atomic::{AtomicBool, AtomicU64, Ordering}, thread, time, }, + Accounts, }; fn new_sanitized_tx( @@ -3171,6 +3239,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 @@ -3292,7 +3362,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(), @@ -3756,7 +3826,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(), @@ -3770,7 +3840,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(), @@ -3869,7 +3939,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(), @@ -3883,7 +3953,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/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 bb5149f47c..19b68d515f 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; @@ -463,6 +471,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 1404d88b5c..94f2531cec 100644 --- a/banks-server/Cargo.toml +++ b/banks-server/Cargo.toml @@ -16,6 +16,7 @@ futures = { workspace = true } solana-accounts-db = { 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 } diff --git a/banks-server/src/banks_server.rs b/banks-server/src/banks_server.rs index a04d542108..e9e09ee6a4 100644 --- a/banks-server/src/banks_server.rs +++ b/banks-server/src/banks_server.rs @@ -9,6 +9,7 @@ use { TransactionSimulationDetails, TransactionStatus, }, solana_client::connection_cache::ConnectionCache, + solana_gossip::cluster_info::ClusterInfo, solana_runtime::{ bank::{Bank, TransactionSimulationResult}, bank_forks::BankForks, @@ -438,7 +439,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, @@ -463,7 +464,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..d9b1eed6f4 --- /dev/null +++ b/bootstrap @@ -0,0 +1,26 @@ +#!/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-url http://127.0.0.1 \ + --relayer-url 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..babb13bcd7 --- /dev/null +++ b/bundle/Cargo.toml @@ -0,0 +1,35 @@ +[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-accounts-db = { 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..e69096f8ed --- /dev/null +++ b/bundle/src/bundle_execution.rs @@ -0,0 +1,1186 @@ +use { + itertools::izip, + log::*, + solana_accounts_db::{ + account_overrides::AccountOverrides, accounts::TransactionLoadResult, + transaction_results::TransactionExecutionResult, + }, + solana_ledger::token_balances::collect_token_balances, + solana_measure::{measure::Measure, measure_us}, + solana_program_runtime::timings::ExecuteTimings, + solana_runtime::{ + bank::{Bank, LoadAndExecuteTransactionsOutput, TransactionBalances}, + 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, then error out + 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: &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 100755 --- 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 8535905bfe..e0bc9d1289 100755 --- a/ci/buildkite-pipeline.sh +++ b/ci/buildkite-pipeline.sh @@ -316,7 +316,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 @@ -344,5 +344,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/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 c3923613b7..e3377370e6 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 } @@ -26,11 +27,16 @@ etcd-client = { workspace = true, features = ["tls"] } futures = { workspace = true } 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 } quinn = { workspace = true } rand = { workspace = true } rand_chacha = { workspace = true } @@ -43,6 +49,7 @@ serde_bytes = { workspace = true } serde_derive = { workspace = true } solana-accounts-db = { workspace = true } solana-bloom = { workspace = true } +solana-bundle = { workspace = true } solana-client = { workspace = true } solana-cost-model = { workspace = true } solana-entry = { workspace = true } @@ -78,6 +85,7 @@ sys-info = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } +tonic = { workspace = true } trees = { workspace = true } [dev-dependencies] @@ -86,10 +94,13 @@ fs_extra = { workspace = true } raptorq = { workspace = true } serde_json = { workspace = true } serial_test = { workspace = true } +solana-accounts-db = { workspace = true } # See order-crates-for-publishing.py for using this unusual `path = "."` +solana-bundle = { workspace = true } solana-core = { path = ".", features = ["dev-context-only-utils"] } solana-logger = { workspace = true } solana-program-runtime = { workspace = true } +solana-program-test = { workspace = true } solana-runtime = { workspace = true, features = ["dev-context-only-utils"] } solana-sdk = { workspace = true, features = ["dev-context-only-utils"] } solana-stake-program = { workspace = true } @@ -102,6 +113,7 @@ sysctl = { workspace = true } [build-dependencies] rustc_version = { workspace = true } +tonic-build = { workspace = true } [features] dev-context-only-utils = [] diff --git a/core/benches/banking_stage.rs b/core/benches/banking_stage.rs index 2526c2a636..ac0ede9310 100644 --- a/core/benches/banking_stage.rs +++ b/core/benches/banking_stage.rs @@ -22,6 +22,7 @@ use { BankingStage, BankingStageStats, }, banking_trace::{BankingPacketBatch, BankingTracer}, + bundle_stage::bundle_account_locker::BundleAccountLocker, }, solana_entry::entry::{next_hash, Entry}, solana_gossip::cluster_info::{ClusterInfo, Node}, @@ -54,6 +55,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}, @@ -65,8 +67,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; @@ -109,7 +118,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 || { @@ -305,6 +321,8 @@ fn bench_banking(bencher: &mut Bencher, tx_type: TransactionType) { Arc::new(ConnectionCache::new("connection_cache_test")), bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), + HashSet::default(), + BundleAccountLocker::default(), ); let chunk_len = verified.len() / CHUNKS; diff --git a/core/benches/consumer.rs b/core/benches/consumer.rs index 928758deb7..0781f9bd3f 100644 --- a/core/benches/consumer.rs +++ b/core/benches/consumer.rs @@ -7,16 +7,16 @@ use { iter::IndexedParallelIterator, prelude::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator}, }, - solana_core::banking_stage::{ - committer::Committer, consumer::Consumer, qos_service::QosService, + solana_core::{ + banking_stage::{committer::Committer, consumer::Consumer, qos_service::QosService}, + bundle_stage::bundle_account_locker::BundleAccountLocker, }, - 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, @@ -25,9 +25,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, @@ -80,7 +83,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 { @@ -89,7 +99,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/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 e8b61de94d..f777a54876 100644 --- a/core/src/banking_stage.rs +++ b/core/src/banking_stage.rs @@ -16,8 +16,9 @@ use { unprocessed_transaction_storage::{ThreadType, UnprocessedTransactionStorage}, }, crate::{ - banking_trace::BankingPacketReceiver, tracer_packet_stats::TracerPacketStats, - validator::BlockProductionMethod, + banking_trace::BankingPacketReceiver, + bundle_stage::bundle_account_locker::BundleAccountLocker, + tracer_packet_stats::TracerPacketStats, validator::BlockProductionMethod, }, crossbeam_channel::RecvTimeoutError, histogram::Histogram, @@ -28,10 +29,12 @@ use { solana_perf::{data_budget::DataBudget, packet::PACKETS_PER_BATCH}, solana_poh::poh_recorder::PohRecorder, solana_runtime::{bank_forks::BankForks, prioritization_fee_cache::PrioritizationFeeCache}, - solana_sdk::timing::AtomicInterval, + solana_sdk::{pubkey::Pubkey, timing::AtomicInterval}, solana_vote::vote_sender_types::ReplayVoteSender, std::{ - cmp, env, + cmp, + collections::HashSet, + env, sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, RwLock, @@ -50,13 +53,13 @@ pub mod unprocessed_packet_batches; pub mod unprocessed_transaction_storage; mod consume_worker; -mod decision_maker; +pub(crate) mod decision_maker; mod forward_packet_batches_by_accounts; mod forward_worker; mod forwarder; -mod immutable_deserialized_packet; +pub(crate) mod immutable_deserialized_packet; mod latest_unprocessed_votes; -mod leader_slot_timing_metrics; +pub(crate) mod leader_slot_timing_metrics; mod multi_iterator_scanner; mod packet_deserializer; mod packet_receiver; @@ -327,6 +330,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( block_production_method, @@ -342,6 +347,8 @@ impl BankingStage { connection_cache, bank_forks, prioritization_fee_cache, + blacklisted_accounts, + bundle_account_locker, ) } @@ -360,6 +367,8 @@ impl BankingStage { connection_cache: Arc, bank_forks: Arc>, prioritization_fee_cache: &Arc, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, ) -> Self { match block_production_method { BlockProductionMethod::ThreadLocalMultiIterator => { @@ -376,6 +385,8 @@ impl BankingStage { connection_cache, bank_forks, prioritization_fee_cache, + blacklisted_accounts, + bundle_account_locker, ) } } @@ -395,6 +406,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. @@ -454,6 +467,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() @@ -614,7 +629,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, @@ -628,6 +643,7 @@ mod tests { solana_poh::{ poh_recorder::{ create_test_recorder, PohRecorderError, Record, RecordTransactionsSummary, + WorkingBankEntry, }, poh_service::PohService, }, @@ -702,6 +718,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); @@ -759,6 +777,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); @@ -771,7 +791,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); @@ -841,6 +866,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. @@ -892,9 +919,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)); @@ -1003,6 +1035,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 @@ -1021,7 +1055,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); @@ -1082,15 +1121,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()); @@ -1194,6 +1237,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 a5e42cbc75..bce1426b47 100644 --- a/core/src/banking_stage/committer.rs +++ b/core/src/banking_stage/committer.rs @@ -15,12 +15,10 @@ use { prioritization_fee_cache::PrioritizationFeeCache, transaction_batch::TransactionBatch, }, - solana_sdk::{pubkey::Pubkey, saturating_add_assign}, - solana_transaction_status::{ - token_balances::TransactionTokenBalancesSet, TransactionTokenBalance, - }, + solana_sdk::saturating_add_assign, + solana_transaction_status::{token_balances::TransactionTokenBalancesSet, PreBalanceInfo}, solana_vote::vote_sender_types::ReplayVoteSender, - std::{collections::HashMap, sync::Arc}, + 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 1795db9743..e8142bf953 100644 --- a/core/src/banking_stage/consume_worker.rs +++ b/core/src/banking_stage/consume_worker.rs @@ -126,11 +126,14 @@ fn try_drain_iter(work: T, receiver: &Receiver) -> impl Iterator mod tests { use { super::*, - crate::banking_stage::{ - committer::Committer, - qos_service::QosService, - scheduler_messages::{TransactionBatchId, TransactionId}, - tests::{create_slow_genesis_config, sanitize_transactions, simulate_poh}, + crate::{ + banking_stage::{ + committer::Committer, + qos_service::QosService, + scheduler_messages::{TransactionBatchId, TransactionId}, + tests::{create_slow_genesis_config, sanitize_transactions, simulate_poh}, + }, + bundle_stage::bundle_account_locker::BundleAccountLocker, }, crossbeam_channel::unbounded, solana_ledger::{ @@ -145,6 +148,7 @@ mod tests { }, solana_vote::vote_sender_types::ReplayVoteReceiver, std::{ + collections::HashSet, sync::{atomic::AtomicBool, RwLock}, thread::JoinHandle, }, @@ -202,7 +206,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 af7b5b93e4..0a2010d11b 100644 --- a/core/src/banking_stage/consumer.rs +++ b/core/src/banking_stage/consumer.rs @@ -1,6 +1,6 @@ use { super::{ - committer::{CommitTransactionDetails, Committer, PreBalanceInfo}, + committer::{CommitTransactionDetails, Committer}, immutable_deserialized_packet::ImmutableDeserializedPacket, leader_slot_metrics::{LeaderSlotMetricsTracker, ProcessTransactionsSummary}, leader_slot_timing_metrics::LeaderExecuteAndCommitTimings, @@ -8,6 +8,7 @@ use { unprocessed_transaction_storage::{ConsumeScannerPayload, UnprocessedTransactionStorage}, BankingStageStats, }, + crate::bundle_stage::bundle_account_locker::BundleAccountLocker, itertools::Itertools, solana_accounts_db::{ transaction_error_metrics::TransactionErrorMetrics, @@ -19,18 +20,21 @@ use { BankStart, PohRecorderError, RecordTransactionsSummary, RecordTransactionsTimings, TransactionRecorder, }, - solana_program_runtime::timings::ExecuteTimings, solana_runtime::{ bank::{Bank, LoadAndExecuteTransactionsOutput}, transaction_batch::TransactionBatch, }, 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, }, @@ -71,6 +75,8 @@ pub struct Consumer { transaction_recorder: TransactionRecorder, qos_service: QosService, log_messages_bytes_limit: Option, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, } impl Consumer { @@ -79,12 +85,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, } } @@ -114,6 +124,7 @@ impl Consumer { packets_to_process, ) }, + &self.blacklisted_accounts, ); if reached_end_of_slot { @@ -444,20 +455,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 @@ -502,8 +519,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); @@ -540,7 +558,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; @@ -589,7 +607,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 { @@ -671,18 +689,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 @@ -748,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::prioritization_fee_cache::PrioritizationFeeCache, solana_sdk::{ @@ -815,7 +821,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); @@ -973,7 +986,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); @@ -998,7 +1018,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()); @@ -1100,7 +1126,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); @@ -1186,7 +1219,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(); @@ -1338,7 +1378,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); @@ -1535,7 +1582,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); @@ -1660,7 +1714,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); @@ -1797,7 +1858,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); @@ -1857,7 +1925,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()); @@ -1935,7 +2010,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()); @@ -1987,7 +2069,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()); @@ -2109,7 +2198,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()); @@ -2178,7 +2274,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_stage/latest_unprocessed_votes.rs b/core/src/banking_stage/latest_unprocessed_votes.rs index 10772b74de..f81a3ca7d8 100644 --- a/core/src/banking_stage/latest_unprocessed_votes.rs +++ b/core/src/banking_stage/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/banking_stage/qos_service.rs b/core/src/banking_stage/qos_service.rs index abac9c70f8..70e1ca1699 100644 --- a/core/src/banking_stage/qos_service.rs +++ b/core/src/banking_stage/qos_service.rs @@ -5,7 +5,9 @@ use { super::{committer::CommitTransactionDetails, BatchedTransactionDetails}, - solana_cost_model::{cost_model::CostModel, transaction_cost::TransactionCost}, + solana_cost_model::{ + cost_model::CostModel, cost_tracker::CostTracker, transaction_cost::TransactionCost, + }, solana_measure::measure::Measure, solana_runtime::bank::Bank, solana_sdk::{ @@ -40,6 +42,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) { @@ -48,7 +51,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(), @@ -94,10 +98,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)| { @@ -105,13 +109,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)) } } @@ -683,8 +687,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 @@ -725,8 +733,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() @@ -793,8 +805,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() @@ -847,8 +863,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/banking_stage/unprocessed_transaction_storage.rs b/core/src/banking_stage/unprocessed_transaction_storage.rs index 03b3e58332..63f898a593 100644 --- a/core/src/banking_stage/unprocessed_transaction_storage.rs +++ b/core/src/banking_stage/unprocessed_transaction_storage.rs @@ -14,17 +14,29 @@ use { }, BankingStageStats, FilterForwardingResults, ForwardOption, }, + crate::{ + bundle_stage::bundle_stage_leader_metrics::BundleStageLeaderMetrics, + immutable_deserialized_bundle::ImmutableDeserializedBundle, + }, itertools::Itertools, min_max_heap::MinMaxHeap, + solana_accounts_db::transaction_error_metrics::TransactionErrorMetrics, + solana_bundle::BundleExecutionError, solana_measure::{measure, measure_us}, solana_runtime::bank::Bank, 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 +51,7 @@ const MAX_NUM_VOTES_RECEIVE: usize = 10_000; pub enum UnprocessedTransactionStorage { VoteStorage(VoteStorage), LocalTransactionStorage(ThreadLocalUnprocessedPackets), + BundleStorage(BundleStorage), } #[derive(Debug)] @@ -57,10 +70,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 +157,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 { @@ -170,6 +185,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 @@ -243,10 +262,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() + } } } @@ -254,6 +287,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() + } } } @@ -264,6 +301,9 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => { transaction_storage.max_receive_size() } + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.max_receive_size() + } } } @@ -290,6 +330,9 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => { transaction_storage.forward_option() } + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.forward_option() + } } } @@ -297,6 +340,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, } } @@ -311,6 +364,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" + ) + } } } @@ -330,6 +388,9 @@ impl UnprocessedTransactionStorage { bank, forward_packet_batches_by_accounts, ), + UnprocessedTransactionStorage::BundleStorage(_) => { + panic!("bundles are not forwarded between leaders") + } } } @@ -343,6 +404,7 @@ impl UnprocessedTransactionStorage { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -357,15 +419,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 { @@ -434,6 +543,7 @@ impl VoteStorage { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, mut processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -447,7 +557,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 @@ -522,6 +638,7 @@ impl ThreadLocalUnprocessedPackets { ThreadType::Transactions => ForwardOption::ForwardTransaction, ThreadType::Voting(VoteSource::Tpu) => ForwardOption::ForwardTpuVote, ThreadType::Voting(VoteSource::Gossip) => ForwardOption::NotForward, + ThreadType::Bundles => panic!(), // TODO (LB) } } @@ -846,6 +963,7 @@ impl ThreadLocalUnprocessedPackets { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, mut processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -860,7 +978,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, @@ -937,6 +1061,309 @@ impl ThreadLocalUnprocessedPackets { } } +pub struct InsertPacketBundlesSummary { + pub insert_packets_summary: InsertPacketBatchSummary, + pub num_bundles_inserted: usize, + pub num_packets_inserted: usize, + pub num_bundles_dropped: usize, +} + +/// 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() + } + + pub fn unprocessed_packets_len(&self) -> usize { + self.unprocessed_bundle_storage + .iter() + .map(|b| b.len()) + .sum::() + } + + pub(crate) fn cost_model_buffered_bundles_len(&self) -> usize { + self.cost_model_buffered_bundle_storage.len() + } + + pub(crate) fn cost_model_buffered_packets_len(&self) -> usize { + self.cost_model_buffered_bundle_storage + .iter() + .map(|b| b.len()) + .sum() + } + + 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) + } + } + } + + InsertPacketBundlesSummary { + insert_packets_summary: PacketBatchInsertionMetrics { + num_dropped_packets: num_packets_dropped, + num_dropped_tracer_packets: 0, + } + .into(), + num_bundles_inserted, + num_packets_inserted, + num_bundles_dropped, + } + } + + 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) + .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); + } + }, + ); + + // 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]); + } + + 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 + } + } + }), + ); + + self.last_update_slot = bank.slot(); + } + + 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 + } + } + }, + )); + + 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); + + bundle_stage_leader_metrics + .leader_slot_metrics_tracker() + .accumulate_transaction_errors(&error_metrics); + + sanitized_bundles + } +} + #[cfg(test)] mod tests { use { diff --git a/core/src/banking_trace.rs b/core/src/banking_trace.rs index ba76b794ba..bedfe117dc 100644 --- a/core/src/banking_trace.rs +++ b/core/src/banking_trace.rs @@ -315,6 +315,7 @@ impl BankingTracer { } } +#[derive(Clone)] pub struct TracedSender { label: ChannelLabel, sender: Sender, diff --git a/core/src/bundle_stage.rs b/core/src/bundle_stage.rs new file mode 100644 index 0000000000..3a4103831b --- /dev/null +++ b/core/src/bundle_stage.rs @@ -0,0 +1,436 @@ +//! The `bundle_stage` processes bundles, which are list of transactions to be executed +//! sequentially and atomically. +use { + crate::{ + banking_stage::{ + decision_maker::{BufferedPacketsDecision, DecisionMaker}, + qos_service::QosService, + unprocessed_transaction_storage::UnprocessedTransactionStorage, + }, + 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, + tip_manager::TipManager, + }, + crossbeam_channel::{Receiver, RecvTimeoutError}, + solana_cost_model::block_cost_limits::MAX_BLOCK_UNITS, + solana_gossip::cluster_info::ClusterInfo, + solana_ledger::blockstore_processor::TransactionStatusSender, + solana_measure::measure, + solana_poh::poh_recorder::PohRecorder, + solana_runtime::{bank_forks::BankForks, prioritization_fee_cache::PrioritizationFeeCache}, + solana_sdk::timing::AtomicInterval, + solana_vote::vote_sender_types::ReplayVoteSender, + 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..5ea8b5396d --- /dev/null +++ b/core/src/bundle_stage/bundle_account_locker.rs @@ -0,0 +1,326 @@ +//! 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_accounts_db::transaction_error_metrics::TransactionErrorMetrics, + solana_ledger::genesis_utils::create_genesis_config, + solana_perf::packet::PacketBatch, + solana_runtime::{bank::Bank, genesis_utils::GenesisConfigInfo}, + 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..bbabc77d9c --- /dev/null +++ b/core/src/bundle_stage/bundle_consumer.rs @@ -0,0 +1,1587 @@ +use { + crate::{ + banking_stage::{ + committer::CommitTransactionDetails, leader_slot_metrics::ProcessTransactionsSummary, + leader_slot_timing_metrics::LeaderExecuteAndCommitTimings, qos_service::QosService, + unprocessed_transaction_storage::UnprocessedTransactionStorage, + }, + 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, + proxy::block_engine_stage::BlockBuilderFeeInfo, + tip_manager::TipManager, + }, + solana_accounts_db::transaction_error_metrics::TransactionErrorMetrics, + solana_bundle::{ + bundle_execution::{load_and_execute_bundle, BundleExecutionMetrics}, + BundleExecutionError, BundleExecutionResult, TipError, + }, + solana_cost_model::transaction_cost::TransactionCost, + solana_gossip::cluster_info::ClusterInfo, + solana_measure::{measure, measure_us}, + solana_poh::poh_recorder::{BankStart, RecordTransactionsSummary, TransactionRecorder}, + solana_runtime::bank::Bank, + 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, + QosService, UnprocessedTransactionStorage, + }, + packet_bundle::PacketBundle, + proxy::block_engine_stage::BlockBuilderFeeInfo, + tip_manager::{TipDistributionAccountConfig, TipManager, TipManagerConfig}, + }, + crossbeam_channel::{unbounded, Receiver}, + jito_tip_distribution::sdk::derive_tip_distribution_account_address, + rand::{thread_rng, RngCore}, + solana_accounts_db::transaction_error_metrics::TransactionErrorMetrics, + solana_cost_model::{block_cost_limits::MAX_BLOCK_UNITS, cost_model::CostModel}, + 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, + genesis_utils::{create_genesis_config_with_leader_ex, GenesisConfigInfo}, + prioritization_fee_cache::PrioritizationFeeCache, + }, + 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..f67f8f946d --- /dev/null +++ b/core/src/bundle_stage/bundle_packet_receiver.rs @@ -0,0 +1,848 @@ +use { + super::BundleStageLoopMetrics, + crate::{ + banking_stage::unprocessed_transaction_storage::UnprocessedTransactionStorage, + bundle_stage::{ + bundle_packet_deserializer::{BundlePacketDeserializer, ReceiveBundleResults}, + bundle_stage_leader_metrics::BundleStageLeaderMetrics, + }, + immutable_deserialized_bundle::ImmutableDeserializedBundle, + packet_bundle::PacketBundle, + }, + 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.clone(), + 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.clone(), + 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..52c1aa0714 --- /dev/null +++ b/core/src/bundle_stage/bundle_stage_leader_metrics.rs @@ -0,0 +1,502 @@ +use { + crate::{ + banking_stage::{leader_slot_metrics, leader_slot_metrics::LeaderSlotMetricsTracker}, + immutable_deserialized_bundle::DeserializedBundleError, + }, + 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_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_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..ec0577be4e --- /dev/null +++ b/core/src/bundle_stage/committer.rs @@ -0,0 +1,219 @@ +use { + crate::banking_stage::{ + committer::CommitTransactionDetails, + leader_slot_timing_metrics::LeaderExecuteAndCommitTimings, + }, + solana_accounts_db::transaction_results::TransactionResults, + solana_bundle::bundle_execution::LoadAndExecuteBundleOutput, + solana_ledger::blockstore_processor::TransactionStatusSender, + solana_measure::measure_us, + solana_runtime::{ + bank::{Bank, CommitTransactionCounts, TransactionBalances, TransactionBalancesSet}, + bank_utils, + prioritization_fee_cache::PrioritizationFeeCache, + }, + solana_sdk::{saturating_add_assign, transaction::SanitizedTransaction}, + solana_transaction_status::{ + token_balances::{TransactionTokenBalances, TransactionTokenBalancesSet}, + PreBalanceInfo, + }, + solana_vote::vote_sender_types::ReplayVoteSender, + 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..e1dc137ba0 --- /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); + // 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..e4f9f8abb3 --- /dev/null +++ b/core/src/immutable_deserialized_bundle.rs @@ -0,0 +1,485 @@ +use { + crate::{ + banking_stage::immutable_deserialized_packet::ImmutableDeserializedPacket, + packet_bundle::PacketBundle, + }, + solana_accounts_db::transaction_error_metrics::TransactionErrorMetrics, + solana_perf::sigverify::verify_packet, + solana_runtime::bank::Bank, + 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_accounts_db::transaction_error_metrics::TransactionErrorMetrics, + solana_client::rpc_client::SerializableTransaction, + solana_ledger::genesis_utils::create_genesis_config, + solana_perf::packet::PacketBatch, + solana_runtime::{ + bank::{Bank, NewBankOptions}, + genesis_utils::GenesisConfigInfo, + }, + 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/lib.rs b/core/src/lib.rs index 99ac98b5d4..79d039d3f1 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -12,22 +12,27 @@ pub mod accounts_hash_verifier; pub mod admin_rpc_post_init; pub mod banking_stage; pub mod banking_trace; +pub mod bundle_stage; pub mod cache_block_meta_service; pub mod cluster_info_vote_listener; 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 fetch_stage; pub mod gen_keys; +pub mod immutable_deserialized_bundle; pub mod ledger_cleanup_service; pub mod ledger_metric_report_service; pub mod next_leader; pub mod optimistic_confirmation_verifier; +pub mod packet_bundle; pub mod poh_timing_report_service; pub mod poh_timing_reporter; +pub mod proxy; pub mod repair; pub mod replay_stage; mod result; @@ -40,6 +45,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; pub mod tpu; mod tpu_entry_notifier; pub mod tracer_packet_stats; @@ -70,3 +76,41 @@ extern crate solana_frozen_abi_macro; #[cfg(test)] #[macro_use] extern crate assert_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/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 028a88f416..74dd1da529 100644 --- a/core/src/tpu.rs +++ b/core/src/tpu.rs @@ -6,14 +6,21 @@ use { crate::{ banking_stage::BankingStage, banking_trace::{BankingTracer, TracerThread}, + 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::{BlockProductionMethod, GeneratorConfig}, }, @@ -31,7 +38,7 @@ use { rpc_subscriptions::RpcSubscriptions, }, solana_runtime::{bank_forks::BankForks, prioritization_fee_cache::PrioritizationFeeCache}, - solana_sdk::{clock::Slot, pubkey::Pubkey, signature::Keypair}, + solana_sdk::{clock::Slot, pubkey::Pubkey, signature::Keypair, signer::Signer}, solana_streamer::{ nonblocking::quic::DEFAULT_WAIT_FOR_CHUNK_TIMEOUT, quic::{spawn_server, MAX_STAKED_CONNECTIONS, MAX_UNSTAKED_CONNECTIONS}, @@ -40,9 +47,9 @@ use { solana_turbine::broadcast_stage::{BroadcastStage, BroadcastStageType}, solana_vote::vote_sender_types::{ReplayVoteReceiver, ReplayVoteSender}, std::{ - collections::HashMap, + collections::{HashMap, HashSet}, net::{SocketAddr, UdpSocket}, - sync::{atomic::AtomicBool, Arc, RwLock}, + sync::{atomic::AtomicBool, Arc, Mutex, RwLock}, thread, time::Duration, }, @@ -73,6 +80,10 @@ pub struct Tpu { tpu_entry_notifier: Option, staked_nodes_updater_service: StakedNodesUpdaterService, tracer_thread_hdl: TracerThread, + relayer_stage: RelayerStage, + block_engine_stage: BlockEngineStage, + fetch_stage_manager: FetchStageManager, + bundle_stage: BundleStage, } impl Tpu { @@ -111,6 +122,11 @@ impl Tpu { prioritization_fee_cache: &Arc, block_production_method: BlockProductionMethod, _generator_config: Option, /* vestigial code for replay invalidator */ + block_engine_config: Arc>, + relayer_config: Arc>, + tip_manager_config: TipManagerConfig, + shred_receiver_address: Arc>>, + preallocated_bundle_cost: u64, ) -> Self { let TpuSockets { transactions: transactions_sockets, @@ -121,7 +137,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( @@ -129,7 +148,7 @@ impl Tpu { tpu_forwards_sockets, tpu_vote_sockets, exit.clone(), - &packet_sender, + &packet_intercept_sender, &vote_packet_sender, &forwarded_packet_sender, forwarded_packet_receiver, @@ -157,7 +176,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(), @@ -188,8 +207,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") }; @@ -202,6 +223,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(), @@ -218,6 +274,15 @@ 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( block_production_method, cluster_info, @@ -225,10 +290,28 @@ impl Tpu { 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, ); @@ -257,6 +340,7 @@ impl Tpu { bank_forks, shred_version, turbine_quic_endpoint_sender, + shred_receiver_address, ); Self { @@ -271,6 +355,10 @@ impl Tpu { tpu_entry_notifier, staked_nodes_updater_service, tracer_thread_hdl, + block_engine_stage, + relayer_stage, + fetch_stage_manager, + bundle_stage, } } @@ -284,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 ec444ae440..2d749cc582 100644 --- a/core/src/tvu.rs +++ b/core/src/tvu.rs @@ -138,6 +138,7 @@ impl Tvu { turbine_quic_endpoint_sender: AsyncSender<(SocketAddr, Bytes)>, turbine_quic_endpoint_receiver: Receiver<(Pubkey, SocketAddr, Bytes)>, repair_quic_endpoint_sender: AsyncSender, + shred_receiver_addr: Arc>>, ) -> Result { let TvuSockets { repair: repair_socket, @@ -186,6 +187,7 @@ impl Tvu { retransmit_receiver, max_slots.clone(), Some(rpc_subscriptions.clone()), + shred_receiver_addr, ); let cluster_slots = Arc::new(ClusterSlots::default()); @@ -492,6 +494,7 @@ pub mod tests { turbine_quic_endpoint_sender, turbine_quic_endpoint_receiver, repair_quic_endpoint_sender, + Arc::new(RwLock::new(None)), ) .expect("assume success"); exit.store(true, Ordering::Relaxed); diff --git a/core/src/validator.rs b/core/src/validator.rs index e5eb3544ab..0c90fe09ca 100644 --- a/core/src/validator.rs +++ b/core/src/validator.rs @@ -16,6 +16,7 @@ use { }, ledger_metric_report_service::LedgerMetricReportService, poh_timing_report_service::PohTimingReportService, + proxy::{block_engine_stage::BlockEngineConfig, relayer_stage::RelayerConfig}, repair::{self, serve_repair::ServeRepair, serve_repair_service::ServeRepairService}, rewards_recorder_service::{RewardsRecorderSender, RewardsRecorderService}, sample_performance_service::SamplePerformanceService, @@ -25,6 +26,7 @@ use { system_monitor_service::{ verify_net_stats_access, SystemMonitorService, SystemMonitorStatsReportConfig, }, + tip_manager::TipManagerConfig, tpu::{Tpu, TpuSockets, DEFAULT_TPU_COALESCE}, tvu::{Tvu, TvuConfig, TvuSockets}, }, @@ -126,7 +128,7 @@ use { path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, RwLock, + Arc, Mutex, RwLock, }, thread::{sleep, Builder, JoinHandle}, time::{Duration, Instant}, @@ -261,6 +263,12 @@ pub struct ValidatorConfig { pub generator_config: Option, pub use_snapshot_archives_at_startup: UseSnapshotArchivesAtStartup, pub wen_restart_proto_path: 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 { @@ -329,6 +337,11 @@ impl Default for ValidatorConfig { generator_config: None, use_snapshot_archives_at_startup: UseSnapshotArchivesAtStartup::default(), wen_restart_proto_path: 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(), } } } @@ -1082,6 +1095,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 = wait_for_supermajority( @@ -1274,6 +1290,7 @@ impl Validator { turbine_quic_endpoint_sender.clone(), turbine_quic_endpoint_receiver, repair_quic_endpoint_sender, + config.shred_receiver_address.clone(), )?; if in_wen_restart { @@ -1332,6 +1349,11 @@ impl Validator { &prioritization_fee_cache, config.block_production_method.clone(), config.generator_config.clone(), + config.block_engine_config.clone(), + config.relayer_config.clone(), + config.tip_manager_config.clone(), + config.shred_receiver_address.clone(), + config.preallocated_bundle_cost, ); let cluster_type = bank_forks.read().unwrap().root_bank().cluster_type(); @@ -1798,6 +1820,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 diff --git a/core/tests/epoch_accounts_hash.rs b/core/tests/epoch_accounts_hash.rs index 718e62688b..fccd6cd72c 100755 --- a/core/tests/epoch_accounts_hash.rs +++ b/core/tests/epoch_accounts_hash.rs @@ -437,6 +437,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() { @@ -554,6 +555,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 3b689a8423..043e4d0d63 100644 --- a/core/tests/snapshots.rs +++ b/core/tests/snapshots.rs @@ -538,6 +538,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() { @@ -1062,6 +1063,7 @@ fn test_snapshots_with_background_services( &snapshot_test_config .snapshot_config .full_snapshot_archives_dir, + None, ) != Some(slot) { assert!( @@ -1080,6 +1082,7 @@ fn test_snapshots_with_background_services( .snapshot_config .incremental_snapshot_archives_dir, last_full_snapshot_slot.unwrap(), + None, ) != Some(slot) { assert!( diff --git a/cost-model/src/cost_tracker.rs b/cost-model/src/cost_tracker.rs index e4f1b917d7..b662017e05 100644 --- a/cost-model/src/cost_tracker.rs +++ b/cost-model/src/cost_tracker.rs @@ -108,6 +108,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); @@ -145,6 +149,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/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 af3fdca951..aa23d02b75 100644 --- a/entry/src/entry.rs +++ b/entry/src/entry.rs @@ -231,7 +231,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 31dd1abbb6..d54a81222c 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..35bcefa2f8 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.6.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.4 T1pyyaTNZsKv2WcRAB8oVnk93mLJw2XzjtVYqCsaHqt BPFLoaderUpgradeab1e11111111111111111111111 jito +fetch_program jito_tip_distribution 0.1.4 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/gossip/src/cluster_info.rs b/gossip/src/cluster_info.rs index 8bfe628da8..9d3d3ea068 100644 --- a/gossip/src/cluster_info.rs +++ b/gossip/src/cluster_info.rs @@ -537,6 +537,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..f9f0b5baa3 --- /dev/null +++ b/jito-protos/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "jito-protos" +version = { workspace = true } +edition = { workspace = true } +publish = false + +[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..30ece1620a --- /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 6514312bc5..6932e89024 100644 --- a/ledger-tool/src/ledger_utils.rs +++ b/ledger-tool/src/ledger_utils.rs @@ -71,6 +71,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 = if blockstore.is_primary_access() { blockstore.ledger_path().join("snapshot") @@ -81,6 +82,12 @@ pub fn load_and_process_ledger( .join("snapshot") }; + 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 @@ -89,13 +96,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); @@ -245,6 +254,7 @@ pub fn load_and_process_ledger( None, // Maybe support this later, though accounts_update_notifier, exit.clone(), + 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 697199981b..91f9fbd3ab 100644 --- a/ledger-tool/src/main.rs +++ b/ledger-tool/src/main.rs @@ -2344,6 +2344,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) { Ok((bank_forks, ..)) => { println!( @@ -2430,6 +2431,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) { Ok((bank_forks, ..)) => { println!("{}", &bank_forks.read().unwrap().working_bank().hash()); @@ -2682,6 +2684,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) .unwrap_or_else(|err| { eprintln!("Ledger verification failed: {err:?}"); @@ -2736,6 +2739,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); @@ -2884,6 +2888,21 @@ fn main() { } process_options.halt_at_slot = Some(snapshot_slot); + 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 { @@ -2920,6 +2939,7 @@ fn main() { process_options, 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 @@ -3297,6 +3317,7 @@ fn main() { process_options, snapshot_archive_path, incremental_snapshot_archive_path, + true, ) .unwrap_or_else(|err| { eprintln!("Failed to load ledger: {err:?}"); @@ -3391,6 +3412,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 4acad73816..19d7fbdcea 100644 --- a/ledger-tool/src/program.rs +++ b/ledger-tool/src/program.rs @@ -127,6 +127,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, @@ -102,6 +104,8 @@ pub fn load_bank_forks( ) { fn get_snapshots_to_load( snapshot_config: Option<&SnapshotConfig>, + halt_at_slot: Option, + ignore_halt_at_slot_for_snapshot_loading: bool, ) -> Option<( FullSnapshotArchiveInfo, Option, @@ -111,9 +115,16 @@ pub fn load_bank_forks( return None; }; + let halt_at_slot = if ignore_halt_at_slot_for_snapshot_loading { + None + } else { + halt_at_slot + }; + let Some(full_snapshot_archive_info) = snapshot_utils::get_highest_full_snapshot_archive_info( &snapshot_config.full_snapshot_archives_dir, + halt_at_slot, ) else { warn!( @@ -127,6 +138,7 @@ pub fn load_bank_forks( snapshot_utils::get_highest_incremental_snapshot_archive_info( &snapshot_config.incremental_snapshot_archives_dir, full_snapshot_archive_info.slot(), + None, ); Some(( @@ -137,7 +149,11 @@ pub fn load_bank_forks( let (bank_forks, starting_snapshot_hashes) = if let Some((full_snapshot_archive_info, incremental_snapshot_archive_info)) = - get_snapshots_to_load(snapshot_config) + get_snapshots_to_load( + snapshot_config, + process_options.halt_at_slot, + ignore_halt_at_slot_for_snapshot_loading, + ) { // SAFETY: Having snapshots to load ensures a snapshot config let snapshot_config = snapshot_config.unwrap(); @@ -206,7 +222,7 @@ pub fn load_bank_forks( } #[allow(clippy::too_many_arguments)] -fn bank_forks_from_snapshot( +pub fn bank_forks_from_snapshot( full_snapshot_archive_info: FullSnapshotArchiveInfo, incremental_snapshot_archive_info: Option, genesis_config: &GenesisConfig, diff --git a/ledger/src/blockstore_processor.rs b/ledger/src/blockstore_processor.rs index 219fa4c62e..4e473f4944 100644 --- a/ledger/src/blockstore_processor.rs +++ b/ledger/src/blockstore_processor.rs @@ -151,7 +151,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![] }; @@ -189,7 +189,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![] }; @@ -670,6 +670,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 204bd43359..8926fb9a04 100644 --- a/ledger/src/token_balances.rs +++ b/ledger/src/token_balances.rs @@ -2,6 +2,7 @@ use { solana_account_decoder::parse_token::{ is_known_spl_token_id, token_amount_to_ui_amount, UiTokenAmount, }, + solana_accounts_db::account_overrides::AccountOverrides, solana_measure::measure::Measure, solana_metrics::datapoint_debug, solana_runtime::{bank::Bank, transaction_batch::TransactionBatch}, @@ -38,6 +39,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 +60,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 +98,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 +250,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 +265,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 +283,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 +297,8 @@ mod test { collect_token_balance_from_account( &bank, &other_mint_account_pubkey, - &mut mint_decimals + &mut mint_decimals, + None ), None ); @@ -429,13 +451,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 +466,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 +484,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 +498,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_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 d480dc2653..c33879daf8 100644 --- a/local-cluster/src/validator_configs.rs +++ b/local-cluster/src/validator_configs.rs @@ -69,6 +69,11 @@ pub fn safe_clone_config(config: &ValidatorConfig) -> ValidatorConfig { generator_config: config.generator_config.clone(), use_snapshot_archives_at_startup: config.use_snapshot_archives_at_startup, wen_restart_proto_path: config.wen_restart_proto_path.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 658fdf0de3..a424787421 100644 --- a/local-cluster/tests/local_cluster.rs +++ b/local-cluster/tests/local_cluster.rs @@ -870,6 +870,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!( @@ -909,6 +910,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!( @@ -1051,6 +1053,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st validator_snapshot_test_config .full_snapshot_archives_dir .path(), + None, ) .unwrap(); @@ -1117,6 +1120,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st validator_snapshot_test_config .full_snapshot_archives_dir .path(), + None, ) .unwrap(); @@ -1145,6 +1149,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) = @@ -1153,6 +1158,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 { @@ -1346,8 +1352,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(); @@ -4895,11 +4903,13 @@ fn test_boot_from_local_state() { let bank_snapshot = loop { if let Some(full_snapshot_slot) = snapshot_utils::get_highest_full_snapshot_archive_slot( &validator2_config.full_snapshot_archives_dir, + None, ) { if let Some(incremental_snapshot_slot) = snapshot_utils::get_highest_incremental_snapshot_archive_slot( &validator2_config.incremental_snapshot_archives_dir, full_snapshot_slot, + None, ) { if let Some(bank_snapshot) = snapshot_utils::get_highest_bank_snapshot_post( @@ -4999,12 +5009,14 @@ fn test_boot_from_local_state() { if let Some(other_full_snapshot_slot) = snapshot_utils::get_highest_full_snapshot_archive_slot( &other_validator_config.full_snapshot_archives_dir, + None, ) { let other_incremental_snapshot_slot = snapshot_utils::get_highest_incremental_snapshot_archive_slot( &other_validator_config.incremental_snapshot_archives_dir, other_full_snapshot_slot, + None, ); if other_full_snapshot_slot >= full_snapshot_archive.slot() && other_incremental_snapshot_slot >= Some(incremental_snapshot_archive.slot()) diff --git a/merkle-tree/src/merkle_tree.rs b/merkle-tree/src/merkle_tree.rs index 09285a41e7..57bceea1ec 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> { }); result.is_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 6078961d42..cbad41c510 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 8fb10807af..f3a2c4e5ce 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).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 { @@ -205,14 +213,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. @@ -678,7 +685,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; } @@ -858,16 +868,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"); @@ -885,23 +902,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", ); @@ -1279,13 +1309,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(); } @@ -1374,7 +1408,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()); @@ -1417,7 +1451,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) ); } @@ -1464,17 +1498,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(); @@ -1510,10 +1554,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()); } } @@ -1558,7 +1608,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); @@ -1575,7 +1625,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); @@ -1842,7 +1892,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 caa2c2a7c8..b4ce97e129 100644 --- a/poh/src/poh_service.rs +++ b/poh/src/poh_service.rs @@ -192,11 +192,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"); @@ -255,11 +256,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"); @@ -381,6 +378,7 @@ impl PohService { mod tests { use { super::*, + crate::poh_recorder::WorkingBankEntry, rand::{thread_rng, Rng}, solana_ledger::{ blockstore::Blockstore, @@ -460,11 +458,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; @@ -509,7 +506,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..b119c53c58 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.4.so"), + ), + ( + jito_tip_payment::ID, + solana_sdk::bpf_loader::ID, + include_bytes!("programs/jito_tip_payment-0.1.4.so"), + ), ]; pub fn spl_programs(rent: &Rent) -> Vec<(Pubkey, AccountSharedData)> { diff --git a/program-test/src/programs/jito_tip_distribution-0.1.4.so b/program-test/src/programs/jito_tip_distribution-0.1.4.so new file mode 100644 index 0000000000000000000000000000000000000000..29fc1a0eb5b8c419b67d124872c814948cd4460b GIT binary patch literal 423080 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%0A`v2wywet^z9pV4WDaUE! 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

5}r~7gP=3L+dd- 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 literal 0 HcmV?d00001 diff --git a/program-test/src/programs/jito_tip_payment-0.1.4.so b/program-test/src/programs/jito_tip_payment-0.1.4.so new file mode 100644 index 0000000000000000000000000000000000000000..49e574ea8cf71011124b4b139bb5f055ed85ecb4 GIT binary patch literal 430592 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

+PdLPw{?*zvOY_vaAMAAkL)U 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¬#mAqw5&VH_E5TO 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 jjvHF6, + }, + 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 6386a433f7..a8c01769a4 100644 --- a/rpc-client-api/src/lib.rs +++ b/rpc-client-api/src/lib.rs @@ -1,5 +1,6 @@ #![allow(clippy::arithmetic_side_effects)] +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 eb69e4c95d..ea8d766641 100644 --- a/rpc-test/Cargo.toml +++ b/rpc-test/Cargo.toml @@ -33,6 +33,7 @@ solana-transaction-status = { workspace = true } tokio = { workspace = true, 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 3edd4b3800..292cef2365 100644 --- a/rpc/Cargo.toml +++ b/rpc/Cargo.toml @@ -31,6 +31,7 @@ serde_json = { workspace = true } soketto = { workspace = true } solana-account-decoder = { workspace = true } solana-accounts-db = { workspace = true } +solana-bundle = { workspace = true } solana-client = { workspace = true } solana-entry = { workspace = true } solana-faucet = { workspace = true } @@ -40,6 +41,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 7ff2ffa42b..3b048e384b 100644 --- a/rpc/src/rpc.rs +++ b/rpc/src/rpc.rs @@ -223,6 +223,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); @@ -357,13 +364,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, @@ -2644,13 +2648,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 { @@ -3256,13 +3263,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; @@ -3324,6 +3486,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; @@ -3625,7 +3795,6 @@ pub mod rpc_full { commitment: preflight_commitment, min_context_slot, })?; - let transaction = sanitize_transaction(unsanitized_tx, preflight_bank)?; let signature = *transaction.signature(); @@ -3812,6 +3981,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() @@ -4142,6 +4451,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()) @@ -4625,6 +4935,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, @@ -5834,6 +6145,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(); @@ -6414,10 +6865,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, @@ -6426,7 +6874,7 @@ pub mod tests { blockstore, validator_exit, health.clone(), - cluster_info, + cluster_info.clone(), Hash::default(), None, OptimisticallyConfirmedBank::locked_from_bank_forks_root(&bank_forks), @@ -6438,7 +6886,7 @@ pub mod tests { Arc::new(PrioritizationFeeCache::default()), ); SendTransactionService::new::( - tpu_address, + cluster_info, &bank_forks, None, receiver, @@ -6686,10 +7134,7 @@ 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 (request_processor, receiver) = JsonRpcRequestProcessor::new( JsonRpcConfig::default(), None, @@ -6698,7 +7143,7 @@ pub mod tests { blockstore, validator_exit, RpcHealth::stub(), - cluster_info, + cluster_info.clone(), Hash::default(), None, OptimisticallyConfirmedBank::locked_from_bank_forks_root(&bank_forks), @@ -6710,7 +7155,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 c38e3b7444..6b6e288b7e 100644 --- a/rpc/src/rpc_service.rs +++ b/rpc/src/rpc_service.rs @@ -256,6 +256,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 { @@ -265,6 +266,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 @@ -379,11 +381,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. @@ -480,7 +477,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/src/bank.rs b/runtime/src/bank.rs index 76102732c7..1d841e6a0a 100644 --- a/runtime/src/bank.rs +++ b/runtime/src/bank.rs @@ -70,8 +70,8 @@ use { account_overrides::AccountOverrides, account_rent_state::RentState, accounts::{ - AccountAddressFilter, Accounts, LoadedTransaction, PubkeyAccountSlot, RewardInterval, - TransactionLoadResult, + AccountAddressFilter, AccountLocks, Accounts, LoadedTransaction, PubkeyAccountSlot, + RewardInterval, TransactionLoadResult, }, accounts_db::{ AccountShrinkThreshold, AccountStorageEntry, AccountsDbConfig, @@ -313,6 +313,7 @@ enum ProgramAccountLoadResult { ProgramOfLoaderV4(AccountSharedData, Slot), } +#[derive(Debug)] pub struct LoadAndExecuteTransactionsOutput { pub loaded_transactions: Vec, // Vector of results indicating whether a transaction was executed or could not @@ -330,6 +331,22 @@ pub struct LoadAndExecuteTransactionsOutput { pub error_counters: TransactionErrorMetrics, } +#[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, @@ -761,7 +778,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 @@ -4300,17 +4317,56 @@ 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); + TransactionBatch::new(lock_results, self, Cow::Borrowed(transactions)) + } + /// Prepare a transaction batch from a single transaction without locking accounts pub(crate) fn prepare_unlocked_batch_from_single_tx<'a>( &'a self, @@ -4346,14 +4402,13 @@ impl Bank { transaction: SanitizedTransaction, ) -> TransactionSimulationResult { let account_keys = transaction.message().account_keys(); - let number_of_accounts = account_keys.len(); let account_overrides = self.get_account_overrides_for_simulation(&account_keys); let batch = self.prepare_unlocked_batch_from_single_tx(&transaction); let mut timings = ExecuteTimings::default(); let LoadAndExecuteTransactionsOutput { loaded_transactions, - mut execution_results, + execution_results, .. } = self.load_and_execute_transactions( &batch, @@ -4369,43 +4424,42 @@ impl Bank { None, ); - let post_simulation_accounts = loaded_transactions - .into_iter() - .next() - .unwrap() - .0 - .ok() - .map(|loaded_transaction| { - loaded_transaction - .accounts - .into_iter() - .take(number_of_accounts) - .collect::>() - }) - .unwrap_or_default(); - - let units_consumed = timings - .details - .per_program_timings - .iter() - .fold(0, |acc: u64, (_, program_timing)| { - acc.saturating_add(program_timing.accumulated_units) - }); - - debug!("simulate_transaction: {:?}", timings); + Self::build_transaction_simulation_result(&loaded_transactions[0], &execution_results[0]) + } - let execution_result = execution_results.pop().unwrap(); - let flattened_result = execution_result.flattened_result(); - let (logs, return_data) = match execution_result { + fn build_transaction_simulation_result( + loaded_transaction_result: &TransactionLoadResult, + execution_result: &TransactionExecutionResult, + ) -> TransactionSimulationResult { + let (logs, return_data, units_consumed, result) = match execution_result { TransactionExecutionResult::Executed { details, .. } => { - (details.log_messages, details.return_data) + let log_messages = if let Some(ref log_messages) = details.log_messages { + log_messages.clone() + } else { + vec![] + }; + + ( + log_messages, + details.return_data.as_ref().cloned(), + details.executed_units, + execution_result.flattened_result(), + ) + } + TransactionExecutionResult::NotExecuted(_) => { + (vec![], None, 0, execution_result.flattened_result()) } - TransactionExecutionResult::NotExecuted(_) => (None, None), }; - let logs = logs.unwrap_or_default(); + + let post_simulation_accounts = loaded_transaction_result + .0 + .as_ref() + .ok() + .map(|tx| tx.accounts.clone()) + .unwrap_or_default(); TransactionSimulationResult { - result: flattened_result, + result, logs, post_simulation_accounts, units_consumed, @@ -4626,6 +4680,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 + } + fn load_program_accounts(&self, pubkey: &Pubkey) -> ProgramAccountLoadResult { let program_account = match self.get_account_with_fixed_root(pubkey) { None => return ProgramAccountLoadResult::AccountNotFound, @@ -5682,6 +5759,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/snapshot_bank_utils.rs b/runtime/src/snapshot_bank_utils.rs index e538b07677..316606407c 100644 --- a/runtime/src/snapshot_bank_utils.rs +++ b/runtime/src/snapshot_bank_utils.rs @@ -223,12 +223,13 @@ pub fn bank_fields_from_snapshot_archives( incremental_snapshot_archives_dir: impl AsRef, ) -> snapshot_utils::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()?; @@ -437,12 +438,13 @@ pub fn bank_from_latest_snapshot_archives( 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, 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 (bank, _) = bank_from_snapshot_archives( diff --git a/runtime/src/snapshot_utils.rs b/runtime/src/snapshot_utils.rs index 0cf1aab09d..098c38966a 100644 --- a/runtime/src/snapshot_utils.rs +++ b/runtime/src/snapshot_utils.rs @@ -1732,8 +1732,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()) } @@ -1742,10 +1743,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()) } @@ -1753,8 +1756,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().next_back() } @@ -1764,6 +1772,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 @@ -1775,6 +1784,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().next_back() } @@ -2756,7 +2768,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) ); } @@ -2782,7 +2794,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) ); @@ -2791,7 +2804,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 ); diff --git a/runtime/src/stake_account.rs b/runtime/src/stake_account.rs index 7ee3c96c44..2dacfd6b6b 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) -> &StakeStateV2 { + pub fn stake_state(&self) -> &StakeStateV2 { &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 977c25b180..662dd5d614 100644 --- a/runtime/src/stakes.rs +++ b/runtime/src/stakes.rs @@ -48,17 +48,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() } @@ -185,7 +185,7 @@ pub struct Stakes { vote_accounts: VoteAccounts, /// stake_delegations - stake_delegations: ImHashMap, + pub stake_delegations: ImHashMap, /// unused unused: u64, @@ -225,7 +225,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, { @@ -458,7 +458,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 66711fd5a1..f74158c731 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 699bfce3e2..3eedad3585 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 061b16cb53..50460102ae 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -38,6 +38,7 @@ full = [ dev-context-only-utils = [] [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 825c09e2c3..fda85fda5f 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -59,6 +59,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 137160844d..b95ea8e77a 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, &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, @@ -811,27 +819,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, @@ -847,7 +868,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); @@ -865,7 +886,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/tip-distributor/Cargo.toml b/tip-distributor/Cargo.toml new file mode 100644 index 0000000000..64bc345dab --- /dev/null +++ b/tip-distributor/Cargo.toml @@ -0,0 +1,57 @@ +[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." + +[dependencies] +anchor-lang = { workspace = true } +clap = { version = "4.1.11", features = ["derive", "env"] } +env_logger = "0.9.0" +futures = "0.3.21" +im = "15.1.0" +itertools = "0.10.3" +jito-tip-distribution = { workspace = true } +jito-tip-payment = { workspace = true } +log = "0.4.17" +num-traits = "0.2.15" +serde = "1.0.137" +serde_json = "1.0.81" +solana-accounts-db = { workspace = true } +solana-client = { workspace = true } +solana-genesis-utils = { workspace = true } +solana-ledger = { workspace = true } +solana-merkle-tree = { workspace = true } +solana-metrics = { workspace = true } +solana-program = { workspace = true } +solana-rpc-client-api = { workspace = true } +solana-runtime = { workspace = true } +solana-sdk = { workspace = true } +solana-stake-program = { workspace = true } +solana-vote = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "full"] } + +[dev-dependencies] +solana-sdk = { workspace = true, features = ["dev-context-only-utils"] } + +[[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" + +[[bin]] +name = "solana-reclaim-rent" +path = "src/bin/reclaim-rent.rs" diff --git a/tip-distributor/README.md b/tip-distributor/README.md new file mode 100644 index 0000000000..c100843a41 --- /dev/null +++ b/tip-distributor/README.md @@ -0,0 +1,43 @@ +# 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. It'll optionally upload the root +on-chain if specified. Additionally, it'll spit the generated merkle trees out into a JSON file. + +## 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 --path-to-my-keypair ${KEYPAIR_PATH} --stake-meta-coll-path ${STAKE_META_COLLECTION_JSON} --rpc-url ${URL} --upload-roots ${BOOL} --force-upload-root ${BOOL}` + +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..4a9a789509 --- /dev/null +++ b/tip-distributor/src/bin/claim-mev-tips.rs @@ -0,0 +1,52 @@ +//! This binary claims MEV tips. + +use { + clap::Parser, + log::*, + solana_sdk::pubkey::Pubkey, + solana_tip_distributor::claim_mev_workflow::claim_mev_tips, + std::{path::PathBuf, str::FromStr}, +}; + +#[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)] + rpc_url: String, + + /// Tip distribution program ID + #[arg(long, env)] + tip_distribution_program_id: String, + + /// Path to keypair + #[arg(long, env)] + keypair_path: PathBuf, +} + +fn main() { + env_logger::init(); + info!("Starting to claim mev tips..."); + + let args: Args = Args::parse(); + + let tip_distribution_program_id = Pubkey::from_str(&args.tip_distribution_program_id) + .expect("valid tip_distribution_program_id"); + + if let Err(e) = claim_mev_tips( + &args.merkle_trees_path, + &args.rpc_url, + &tip_distribution_program_id, + &args.keypair_path, + ) { + panic!("error claiming mev tips: {:?}", e); + } + info!( + "done claiming mev tips from file {:?}", + args.merkle_trees_path + ); +} 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..9fcd7b8ed7 --- /dev/null +++ b/tip-distributor/src/bin/merkle-root-uploader.rs @@ -0,0 +1,50 @@ +use { + clap::Parser, + log::info, + solana_sdk::pubkey::Pubkey, + solana_tip_distributor::merkle_root_upload_workflow::upload_merkle_root, + std::{path::PathBuf, str::FromStr}, +}; + +#[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: String, +} + +fn main() { + env_logger::init(); + + let args: Args = Args::parse(); + + let tip_distribution_program_id = Pubkey::from_str(&args.tip_distribution_program_id) + .expect("valid tip_distribution_program_id"); + + info!("starting merkle root uploader..."); + if let Err(e) = upload_merkle_root( + &args.merkle_root_path, + &args.keypair_path, + &args.rpc_url, + &tip_distribution_program_id, + ) { + panic!("failed to upload merkle roots: {:?}", e); + } + info!( + "uploaded merkle roots from file {:?}", + args.merkle_root_path + ); +} diff --git a/tip-distributor/src/bin/reclaim-rent.rs b/tip-distributor/src/bin/reclaim-rent.rs new file mode 100644 index 0000000000..5aa372a27a --- /dev/null +++ b/tip-distributor/src/bin/reclaim-rent.rs @@ -0,0 +1,62 @@ +//! Reclaims rent from TDAs and Claim Status accounts. + +use { + clap::Parser, + log::*, + solana_client::nonblocking::rpc_client::RpcClient, + solana_sdk::{ + commitment_config::CommitmentConfig, pubkey::Pubkey, signature::read_keypair_file, + }, + solana_tip_distributor::reclaim_rent_workflow::reclaim_rent, + std::{path::PathBuf, str::FromStr, time::Duration}, + tokio::runtime::Runtime, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// RPC to send transactions through. + /// NOTE: This script uses getProgramAccounts, make sure you have added an account index + /// for the tip_distribution_program_id on the RPC node. + #[arg(long, env)] + rpc_url: String, + + /// Tip distribution program ID. + #[arg(long, env, value_parser = Pubkey::from_str)] + tip_distribution_program_id: Pubkey, + + /// The keypair signing and paying for transactions. + #[arg(long, env)] + keypair_path: PathBuf, + + /// High timeout b/c of get_program_accounts call + #[arg(long, env, default_value_t = 180)] + rpc_timeout_secs: u64, + + /// Specifies whether to reclaim rent on behalf of validators from respective TDAs. + #[arg(long, env)] + should_reclaim_tdas: bool, +} + +fn main() { + env_logger::init(); + + info!("Starting to claim mev tips..."); + let args: Args = Args::parse(); + + let runtime = Runtime::new().unwrap(); + if let Err(e) = runtime.block_on(reclaim_rent( + RpcClient::new_with_timeout_and_commitment( + args.rpc_url, + Duration::from_secs(args.rpc_timeout_secs), + CommitmentConfig::confirmed(), + ), + args.tip_distribution_program_id, + read_keypair_file(&args.keypair_path).expect("read keypair file"), + args.should_reclaim_tdas, + )) { + panic!("error reclaiming rent: {e:?}"); + } + + info!("done reclaiming all rent",); +} 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..379677c10c --- /dev/null +++ b/tip-distributor/src/claim_mev_workflow.rs @@ -0,0 +1,152 @@ +use { + crate::{ + read_json_from_file, sign_and_send_transactions_with_retries, GeneratedMerkleTreeCollection, + }, + anchor_lang::{AccountDeserialize, InstructionData, ToAccountMetas}, + jito_tip_distribution::state::*, + log::{debug, info, warn}, + solana_client::{nonblocking::rpc_client::RpcClient, rpc_request::RpcError}, + solana_program::{ + fee_calculator::DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE, native_token::LAMPORTS_PER_SOL, + stake::state::StakeStateV2, system_program, + }, + solana_rpc_client_api::client_error, + solana_sdk::{ + commitment_config::CommitmentConfig, + instruction::Instruction, + pubkey::Pubkey, + signature::{read_keypair_file, Signer}, + transaction::Transaction, + }, + std::{path::PathBuf, time::Duration}, + thiserror::Error, + tokio::runtime::Builder, +}; + +#[derive(Error, Debug)] +pub enum ClaimMevError { + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + JsonError(#[from] serde_json::Error), +} + +pub fn claim_mev_tips( + merkle_root_path: &PathBuf, + rpc_url: &str, + tip_distribution_program_id: &Pubkey, + keypair_path: &PathBuf, +) -> Result<(), ClaimMevError> { + const MAX_RETRY_DURATION: Duration = Duration::from_secs(600); + + let merkle_trees: 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 rpc_client = + RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::finalized()); + + let runtime = Builder::new_multi_thread() + .worker_threads(16) + .enable_all() + .build() + .unwrap(); + + let mut instructions = Vec::new(); + + runtime.block_on(async move { + let start_balance = rpc_client.get_balance(&keypair.pubkey()).await.expect("failed to get balance"); + // heuristic to make sure we have enough funds to cover the rent costs if epoch has many validators + { + // most amounts are for 0 lamports. had 1736 non-zero claims out of 164742 + let node_count = merkle_trees.generated_merkle_trees.iter().flat_map(|tree| &tree.tree_nodes).filter(|node| node.amount > 0).count(); + 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 = (node_count as u64).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 + panic!("Expected to have at least {} lamports in {}, current balance is {} lamports, deposit {} SOL to continue.", + desired_balance, &keypair.pubkey(), start_balance, sol_to_deposit) + } + } + let stake_acct_min_rent = rpc_client.get_minimum_balance_for_rent_exemption(StakeStateV2::size_of()).await.expect("Failed to calculate min rent"); + let mut below_min_rent_count: usize = 0; + let mut zero_lamports_count: usize = 0; + for tree in merkle_trees.generated_merkle_trees { + // only claim for ones that have merkle root on-chain + let account = rpc_client.get_account(&tree.tip_distribution_account).await.expect("expected to fetch tip distribution account"); + let fetched_tip_distribution_account = TipDistributionAccount::try_deserialize(&mut account.data.as_slice()).expect("failed to deserialize tip_distribution_account state"); + if fetched_tip_distribution_account.merkle_root.is_none() { + info!( + "not claiming because merkle root isn't uploaded yet. skipped {} claimants for tda: {:?}", + tree.tree_nodes.len(), + tree.tip_distribution_account + ); + 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 rpc_client.get_account(&node.claim_status_pubkey).await { + Ok(_) => { + debug!("claim status account already exists, skipping pubkey {:?}.", node.claim_status_pubkey); + continue; + } + // expected to not find ClaimStatus account, don't skip + Err(client_error::Error { kind: client_error::ErrorKind::RpcError(RpcError::ForUser(err)), .. }) if err.starts_with("AccountNotFound") => {} + Err(err) => panic!("Unexpected RPC Error: {}", err), + } + + let current_balance = rpc_client.get_balance(&node.claimant).await.expect("Failed to get balance"); + // some older accounts can be rent-paying + // any new transfers will need to make the account rent-exempt (runtime enforced) + if current_balance.checked_add(node.amount).unwrap() < stake_acct_min_rent { + warn!("Current balance + tip claim amount of {} is less than required rent-exempt of {} for pubkey: {}. Skipping.", + current_balance.checked_add(node.amount).unwrap(), stake_acct_min_rent, 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.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: keypair.pubkey(), + system_program: system_program::id(), + }.to_account_metas(None), + }); + } + } + + let transactions = instructions.into_iter().map(|ix|{ + Transaction::new_with_payer( + &[ix], + Some(&keypair.pubkey()), + ) + }).collect::>(); + + info!("Sending {} tip claim transactions. {} tried sending zero lamports, {} would be below minimum rent", + &transactions.len(), zero_lamports_count, below_min_rent_count); + + let failed_transactions = sign_and_send_transactions_with_retries(&keypair, &rpc_client, transactions, MAX_RETRY_DURATION).await; + if !failed_transactions.is_empty() { + panic!("failed to send {} transactions", failed_transactions.len()); + } + }); + + Ok(()) +} diff --git a/tip-distributor/src/lib.rs b/tip-distributor/src/lib.rs new file mode 100644 index 0000000000..bd8de90230 --- /dev/null +++ b/tip-distributor/src/lib.rs @@ -0,0 +1,887 @@ +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, + 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::*, + 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_rpc_client_api::{ + client_error::{Error, ErrorKind}, + request::RpcRequest, + }, + solana_sdk::{ + account::{AccountSharedData, ReadableAccount}, + clock::Slot, + hash::{Hash, Hasher}, + pubkey::Pubkey, + signature::{Keypair, Signature}, + stake_history::Epoch, + transaction::{Transaction, TransactionError::AlreadyProcessed}, + }, + std::{ + collections::HashMap, + fs::File, + io::BufReader, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, + }, + tokio::time::sleep, +}; + +#[derive(Deserialize, Serialize, Debug)] +pub struct GeneratedMerkleTreeCollection { + pub generated_merkle_trees: Vec, + pub bank_hash: String, + pub epoch: Epoch, + pub slot: Slot, +} + +#[derive(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 async fn sign_and_send_transactions_with_retries( + signer: &Keypair, + rpc_client: &RpcClient, + transactions: Vec, + max_retry_duration: Duration, +) -> HashMap { + use tokio::sync::Semaphore; + const MAX_CONCURRENT_RPC_CALLS: usize = 50; + let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_RPC_CALLS)); + + let mut errors = HashMap::default(); + let mut blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("fetch latest blockhash"); + + let mut signatures_to_transactions = transactions + .into_iter() + .map(|mut tx| { + tx.sign(&[signer], blockhash); + (tx.signatures[0], tx) + }) + .collect::>(); + + let start = Instant::now(); + while start.elapsed() < max_retry_duration && !signatures_to_transactions.is_empty() { + if start.elapsed() > Duration::from_secs(60) { + blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("fetch latest blockhash"); + signatures_to_transactions + .iter_mut() + .for_each(|(_sig, tx)| { + *tx = Transaction::new_unsigned(tx.message.clone()); + tx.sign(&[signer], blockhash); + }); + } + + let futs = signatures_to_transactions.iter().map(|(sig, tx)| { + let semaphore = semaphore.clone(); + async move { + let permit = semaphore.clone().acquire_owned().await.unwrap(); + let res = match rpc_client.send_transaction(tx).await { + Ok(sig) => { + info!("sent transaction: {sig:?}"); + drop(permit); + sleep(Duration::from_secs(10)).await; + + let _permit = semaphore.acquire_owned().await.unwrap(); + match rpc_client.confirm_transaction(&sig).await { + Ok(true) => Ok(()), + Ok(false) => Err(Error::new_with_request( + ErrorKind::Custom("transaction failed to confirm".to_string()), + RpcRequest::SendTransaction, + )), + Err(e) => Err(e), + } + } + Err(e) => Err(e), + }; + + let res = res + .err() + .map(|e| { + if let ErrorKind::TransactionError(AlreadyProcessed) = e.kind { + Ok(()) + } else { + error!("error sending transaction {sig:?} error: {e:?}"); + Err(e) + } + }) + .unwrap_or(Ok(())); + + (*sig, res) + } + }); + + errors = futures::future::join_all(futs) + .await + .into_iter() + .filter(|(sig, result)| { + if result.is_err() { + true + } else { + let _ = signatures_to_transactions.remove(sig); + false + } + }) + .map(|(sig, result)| { + let e = result.err().unwrap(); + warn!("error sending transaction: [error={e}, signature={sig}]"); + (sig, e) + }) + .collect::>(); + } + + errors +} + +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(crate) 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..cc75797f05 --- /dev/null +++ b/tip-distributor/src/merkle_root_upload_workflow.rs @@ -0,0 +1,134 @@ +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, +) -> 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 failed_transactions = sign_and_send_transactions_with_retries(&keypair, &rpc_client, transactions, MAX_RETRY_DURATION).await; + if !failed_transactions.is_empty() { + panic!("failed to send {} transactions", 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..da8d6c6362 --- /dev/null +++ b/tip-distributor/src/reclaim_rent_workflow.rs @@ -0,0 +1,167 @@ +use { + crate::sign_and_send_transactions_with_retries, + anchor_lang::AccountDeserialize, + 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_program::pubkey::Pubkey, + solana_sdk::{ + signature::{Keypair, Signer}, + transaction::Transaction, + }, + std::{ + error::Error, + time::{Duration, Instant}, + }, +}; + +pub async fn reclaim_rent( + rpc_client: RpcClient, + tip_distribution_program_id: Pubkey, + signer: Keypair, + // Optionally reclaim TipDistributionAccount rents on behalf of validators. + should_reclaim_tdas: bool, +) -> Result<(), Box> { + info!("fetching program accounts..."); + let now = Instant::now(); + let accounts = rpc_client + .get_program_accounts(&tip_distribution_program_id) + .await?; + info!( + "get_program_accounts took {}ms and fetched {} accounts", + now.elapsed().as_millis(), + accounts.len() + ); + + info!("fetching current_epoch..."); + let current_epoch = rpc_client.get_epoch_info().await?.epoch; + info!("current_epoch: {current_epoch}"); + + info!("fetching config_account..."); + let now = Instant::now(); + let config_pubkey = derive_config_account_address(&tip_distribution_program_id).0; + let config_account = rpc_client.get_account(&config_pubkey).await?; + let config_account: Config = Config::try_deserialize(&mut config_account.data.as_slice())?; + info!("fetch config_account took {}ms", now.elapsed().as_millis()); + + info!("filtering for claim_status 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 == signer.pubkey() + && current_epoch > claim_status.expires_at + }) + .collect::>(); + info!( + "{} claim_status accounts eligible for rent reclaim", + claim_status_accounts.len() + ); + + info!("fetching recent_blockhash"); + let now = Instant::now(); + let recent_blockhash = rpc_client.get_latest_blockhash().await?; + info!( + "fetch recent_blockhash took {}ms, hash={recent_blockhash:?}", + now.elapsed().as_millis() + ); + + info!("creating close_claim_status_account transactions"); + let 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_signed_with_payer( + instructions, + Some(&signer.pubkey()), + &[&signer], + recent_blockhash, + ) + }) + .collect::>(); + + info!( + "create close_claim_status_account transactions took {}us", + now.elapsed().as_micros() + ); + + if should_reclaim_tdas { + let tip_distribution_accounts = 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); + + info!("creating close_tip_distribution_account transactions"); + let now = Instant::now(); + let close_tda_txs = tip_distribution_accounts + .map( + |(tip_distribution_account, tda): (Pubkey, TipDistributionAccount)| { + 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 close_tip_distribution_account transactions took {}us, closing {} tip distribution accounts", now.elapsed().as_micros(), close_tda_txs.len()); + + transactions.extend(close_tda_txs); + } + + info!("sending {} transactions", transactions.len()); + let failed_txs = sign_and_send_transactions_with_retries( + &signer, + &rpc_client, + transactions, + Duration::from_secs(300), + ) + .await; + if !failed_txs.is_empty() { + panic!("failed to send {} transactions", failed_txs.len()); + } + + Ok(()) +} 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..bbbdfbd067 --- /dev/null +++ b/tip-distributor/src/stake_meta_generator_workflow.rs @@ -0,0 +1,966 @@ +use { + crate::{ + derive_tip_distribution_account_address, derive_tip_payment_pubkeys, Config, StakeMeta, + StakeMetaCollection, TipDistributionAccount, TipDistributionAccountWrapper, + TipDistributionMeta, + }, + anchor_lang::AccountDeserialize, + itertools::Itertools, + log::*, + solana_accounts_db::hardened_unpack::{open_genesis_config, MAX_GENESIS_ARCHIVE_UNPACKED_SIZE}, + solana_client::client_error::ClientError, + solana_ledger::{ + bank_forks_utils, + blockstore::{Blockstore, BlockstoreError}, + blockstore_options::{AccessType, BlockstoreOptions, LedgerColumnOptions}, + blockstore_processor::{BlockstoreProcessorError, ProcessOptions}, + }, + solana_runtime::{bank::Bank, snapshot_config::SnapshotConfig, stakes::StakeAccount}, + solana_sdk::{ + account::{ReadableAccount, WritableAccount}, + clock::Slot, + pubkey::Pubkey, + }, + solana_vote::vote_account::VoteAccount, + 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 blockstore = Blockstore::open_with_options( + ledger_path, + BlockstoreOptions { + access_type: AccessType::PrimaryForMaintenance, + recovery_mode: None, + enforce_ulimit_nofile: false, + column_options: LedgerColumnOptions::default(), + }, + )?; + let (bank_forks, _, _) = bank_forks_utils::load_bank_forks( + &genesis_config, + &blockstore, + vec![PathBuf::from(ledger_path).join(Path::new("stake-meta.accounts"))], + None, + Some(&snapshot_config), + &ProcessOptions::default(), + None, + None, + None, + Arc::new(AtomicBool::new(false)), + 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, + bank.new_warmup_cooldown_rate_epoch(), + ) > 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(), + ), + bank.new_warmup_cooldown_rate_epoch(), + ) + { + return false; + } + } + + true + } + fn next_epoch(bank: &Arc) -> Arc { + bank.squash(); + + Arc::new(Bank::new_from_parent( + bank.clone(), + &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 84654a564c..4715afb3c7 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/turbine/benches/cluster_info.rs b/turbine/benches/cluster_info.rs index 954e32903c..2c8b9eba0c 100644 --- a/turbine/benches/cluster_info.rs +++ b/turbine/benches/cluster_info.rs @@ -81,6 +81,7 @@ fn broadcast_shreds_bench(bencher: &mut Bencher) { &bank_forks, &SocketAddrSpace::Unspecified, &quic_endpoint_sender, + &None, ) .unwrap(); }); diff --git a/turbine/benches/retransmit_stage.rs b/turbine/benches/retransmit_stage.rs index b0dd67db82..601d7b7a45 100644 --- a/turbine/benches/retransmit_stage.rs +++ b/turbine/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/turbine/src/broadcast_stage.rs b/turbine/src/broadcast_stage.rs index 07be0d0bfd..7bff7aab24 100644 --- a/turbine/src/broadcast_stage.rs +++ b/turbine/src/broadcast_stage.rs @@ -108,6 +108,7 @@ impl BroadcastStageType { bank_forks: Arc>, shred_version: u16, quic_endpoint_sender: AsyncSender<(SocketAddr, Bytes)>, + shred_receiver_address: Arc>>, ) -> BroadcastStage { match self { BroadcastStageType::Standard => BroadcastStage::new( @@ -120,6 +121,7 @@ impl BroadcastStageType { bank_forks, quic_endpoint_sender, StandardBroadcastRun::new(shred_version), + shred_receiver_address, ), BroadcastStageType::FailEntryVerification => BroadcastStage::new( @@ -132,6 +134,7 @@ impl BroadcastStageType { bank_forks, quic_endpoint_sender, FailEntryVerificationBroadcastRun::new(shred_version), + Arc::new(RwLock::new(None)), ), BroadcastStageType::BroadcastFakeShreds => BroadcastStage::new( @@ -144,6 +147,7 @@ impl BroadcastStageType { bank_forks, quic_endpoint_sender, BroadcastFakeShredsRun::new(0, shred_version), + Arc::new(RwLock::new(None)), ), BroadcastStageType::BroadcastDuplicates(config) => BroadcastStage::new( @@ -156,6 +160,7 @@ impl BroadcastStageType { bank_forks, quic_endpoint_sender, BroadcastDuplicatesRun::new(shred_version, config.clone()), + Arc::new(RwLock::new(None)), ), } } @@ -177,6 +182,7 @@ trait BroadcastRun { sock: &UdpSocket, bank_forks: &RwLock, quic_endpoint_sender: &AsyncSender<(SocketAddr, Bytes)>, + shred_receiver_address: &Arc>>, ) -> Result<()>; fn record(&mut self, receiver: &RecordReceiver, blockstore: &Blockstore) -> Result<()>; } @@ -272,6 +278,7 @@ impl BroadcastStage { bank_forks: Arc>, quic_endpoint_sender: AsyncSender<(SocketAddr, Bytes)>, 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(); @@ -303,6 +310,8 @@ impl BroadcastStage { let cluster_info = cluster_info.clone(); let bank_forks = bank_forks.clone(); let quic_endpoint_sender = quic_endpoint_sender.clone(); + let shred_receiver_address = shred_receiver_address.clone(); + let run_transmit = move || loop { let res = bs_transmit.transmit( &socket_receiver, @@ -310,6 +319,7 @@ impl BroadcastStage { &sock, &bank_forks, &quic_endpoint_sender, + &shred_receiver_address, ); let res = Self::handle_error(res, "solana-broadcaster-transmit"); if let Some(res) = res { @@ -420,6 +430,7 @@ fn update_peer_stats( /// Broadcasts shreds from the leader (i.e. this node) to the root of the /// turbine retransmit tree for each shred. +#[allow(clippy::too_many_arguments)] pub fn broadcast_shreds( s: &UdpSocket, shreds: &[Shred], @@ -430,6 +441,7 @@ pub fn broadcast_shreds( bank_forks: &RwLock, socket_addr_space: &SocketAddrSpace, quic_endpoint_sender: &AsyncSender<(SocketAddr, Bytes)>, + shred_receiver_address: &Option, ) -> Result<()> { let mut result = Ok(()); let mut shred_select = Measure::start("shred_select"); @@ -445,15 +457,34 @@ 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| { + shreds.flat_map(move |shred| { let key = shred.id(); let protocol = cluster_nodes::get_broadcast_protocol(&key); - cluster_nodes - .get_broadcast_peer(&key)? - .tvu(protocol) - .ok() - .filter(|addr| socket_addr_space.check(addr)) - .map(|addr| { + + let mut addrs = Vec::with_capacity(2); + if let Some(shred_receiver_address) = shred_receiver_address { + // Assuming always over UDP for shred_receiver_address + addrs.push((Protocol::UDP, *shred_receiver_address)); + } + if let Some(peer) = cluster_nodes.get_broadcast_peer(&key) { + match protocol { + Protocol::QUIC => { + if let Ok(tvu) = peer.tvu(Protocol::QUIC) { + addrs.push((Protocol::QUIC, tvu)); + } + } + Protocol::UDP => { + if let Ok(tvu) = peer.tvu(Protocol::UDP) { + addrs.push((Protocol::UDP, tvu)); + } + } + } + } + + addrs + .into_iter() + .filter(|(_, a)| socket_addr_space.check(a)) + .map(move |(protocol, addr)| { (match protocol { Protocol::QUIC => Either::Right, Protocol::UDP => Either::Left, @@ -682,6 +713,7 @@ pub mod test { bank_forks, quic_endpoint_sender, StandardBroadcastRun::new(0), + Arc::new(RwLock::new(None)), ); MockBroadcastStage { @@ -721,7 +753,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/turbine/src/broadcast_stage/broadcast_duplicates_run.rs b/turbine/src/broadcast_stage/broadcast_duplicates_run.rs index 0db4003a07..6dfbe6f77d 100644 --- a/turbine/src/broadcast_stage/broadcast_duplicates_run.rs +++ b/turbine/src/broadcast_stage/broadcast_duplicates_run.rs @@ -280,6 +280,7 @@ impl BroadcastRun for BroadcastDuplicatesRun { sock: &UdpSocket, bank_forks: &RwLock, _quic_endpoint_sender: &AsyncSender<(SocketAddr, Bytes)>, + _shred_receiver_addr: &Arc>>, ) -> Result<()> { let (shreds, _) = receiver.recv()?; if shreds.is_empty() { diff --git a/turbine/src/broadcast_stage/broadcast_fake_shreds_run.rs b/turbine/src/broadcast_stage/broadcast_fake_shreds_run.rs index 1464d46493..60795a85bf 100644 --- a/turbine/src/broadcast_stage/broadcast_fake_shreds_run.rs +++ b/turbine/src/broadcast_stage/broadcast_fake_shreds_run.rs @@ -133,6 +133,7 @@ impl BroadcastRun for BroadcastFakeShredsRun { sock: &UdpSocket, _bank_forks: &RwLock, _quic_endpoint_sender: &AsyncSender<(SocketAddr, Bytes)>, + _shred_receiver_addr: &Arc>>, ) -> Result<()> { for (data_shreds, batch_info) in receiver { let fake = batch_info.is_some(); diff --git a/turbine/src/broadcast_stage/broadcast_utils.rs b/turbine/src/broadcast_stage/broadcast_utils.rs index fe99077091..283104e826 100644 --- a/turbine/src/broadcast_stage/broadcast_utils.rs +++ b/turbine/src/broadcast_stage/broadcast_utils.rs @@ -36,13 +36,23 @@ 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 Ok((try_bank, (entry, tick_height))) = receiver.try_recv() else { + let Ok(WorkingBankEntry { + bank: try_bank, + entries_ticks: new_entries_ticks, + }) = receiver.try_recv() + else { break; }; // If the bank changed, that implies the previous slot was interrupted and we do not have to @@ -52,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()); } @@ -64,8 +74,10 @@ 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 Ok((try_bank, (entry, tick_height))) = - receiver.recv_deadline(coalesce_start + ENTRY_COALESCE_DURATION) + let Ok(WorkingBankEntry { + bank: try_bank, + entries_ticks: new_entries_ticks, + }) = receiver.recv_deadline(coalesce_start + ENTRY_COALESCE_DURATION) else { break; }; @@ -78,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(); @@ -138,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(); @@ -172,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/turbine/src/broadcast_stage/fail_entry_verification_broadcast_run.rs b/turbine/src/broadcast_stage/fail_entry_verification_broadcast_run.rs index 1dda981e69..215e619a5a 100644 --- a/turbine/src/broadcast_stage/fail_entry_verification_broadcast_run.rs +++ b/turbine/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}, tokio::sync::mpsc::Sender as AsyncSender, }; @@ -164,6 +164,7 @@ impl BroadcastRun for FailEntryVerificationBroadcastRun { sock: &UdpSocket, bank_forks: &RwLock, quic_endpoint_sender: &AsyncSender<(SocketAddr, Bytes)>, + shred_receiver_address: &Arc>>, ) -> Result<()> { let (shreds, _) = receiver.recv()?; broadcast_shreds( @@ -176,6 +177,7 @@ impl BroadcastRun for FailEntryVerificationBroadcastRun { bank_forks, cluster_info.socket_addr_space(), quic_endpoint_sender, + &shred_receiver_address.read().unwrap(), ) } fn record(&mut self, receiver: &RecordReceiver, blockstore: &Blockstore) -> Result<()> { diff --git a/turbine/src/broadcast_stage/standard_broadcast_run.rs b/turbine/src/broadcast_stage/standard_broadcast_run.rs index 031e720123..46db179e41 100644 --- a/turbine/src/broadcast_stage/standard_broadcast_run.rs +++ b/turbine/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}, tokio::sync::mpsc::Sender as AsyncSender, }; @@ -190,10 +190,24 @@ impl StandardBroadcastRun { let (ssend, srecv) = unbounded(); self.process_receive_results(keypair, blockstore, &ssend, &bsend, receive_results)?; //data - let _ = self.transmit(&srecv, cluster_info, sock, bank_forks, quic_endpoint_sender); + let _ = self.transmit( + &srecv, + cluster_info, + sock, + bank_forks, + quic_endpoint_sender, + &Arc::new(RwLock::new(None)), + ); let _ = self.record(&brecv, blockstore); //coding - let _ = self.transmit(&srecv, cluster_info, sock, bank_forks, quic_endpoint_sender); + let _ = self.transmit( + &srecv, + cluster_info, + sock, + bank_forks, + quic_endpoint_sender, + &Arc::new(RwLock::new(None)), + ); let _ = self.record(&brecv, blockstore); Ok(()) } @@ -391,6 +405,7 @@ impl StandardBroadcastRun { broadcast_shred_batch_info: Option, bank_forks: &RwLock, quic_endpoint_sender: &AsyncSender<(SocketAddr, Bytes)>, + shred_receiver_addr: &Option, ) -> Result<()> { trace!("Broadcasting {:?} shreds", shreds.len()); let mut transmit_stats = TransmitShredsStats::default(); @@ -407,6 +422,7 @@ impl StandardBroadcastRun { bank_forks, cluster_info.socket_addr_space(), quic_endpoint_sender, + shred_receiver_addr, )?; transmit_time.stop(); @@ -477,6 +493,7 @@ impl BroadcastRun for StandardBroadcastRun { sock: &UdpSocket, bank_forks: &RwLock, quic_endpoint_sender: &AsyncSender<(SocketAddr, Bytes)>, + shred_receiver_address: &Arc>>, ) -> Result<()> { let (shreds, batch_info) = receiver.recv()?; self.broadcast( @@ -486,6 +503,7 @@ impl BroadcastRun for StandardBroadcastRun { batch_info, bank_forks, quic_endpoint_sender, + &shred_receiver_address.read().unwrap(), ) } fn record(&mut self, receiver: &RecordReceiver, blockstore: &Blockstore) -> Result<()> { diff --git a/turbine/src/retransmit_stage.rs b/turbine/src/retransmit_stage.rs index c4c7a751ab..78c5a9ce86 100644 --- a/turbine/src/retransmit_stage.rs +++ b/turbine/src/retransmit_stage.rs @@ -179,6 +179,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)?; @@ -259,6 +260,7 @@ fn retransmit( &sockets[index % sockets.len()], quic_endpoint_sender, stats, + &shred_receiver_address.read().unwrap(), ) .map_err(|err| { stats.record_error(&err); @@ -284,6 +286,7 @@ fn retransmit( &sockets[index % sockets.len()], quic_endpoint_sender, stats, + &shred_receiver_address.read().unwrap(), ) .map_err(|err| { stats.record_error(&err); @@ -303,6 +306,7 @@ fn retransmit( Ok(()) } +#[allow(clippy::too_many_arguments)] fn retransmit_shred( key: &ShredId, shred: &[u8], @@ -313,15 +317,20 @@ fn retransmit_shred( socket: &UdpSocket, quic_endpoint_sender: &AsyncSender<(SocketAddr, Bytes)>, 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, data_plane_fanout)?; - let addrs: Vec<_> = addrs + let mut addrs: Vec<_> = addrs .into_iter() .filter(|addr| socket_addr_space.check(addr)) .collect(); + if let Some(addr) = shred_receiver_addr { + addrs.push(*addr); + } + compute_turbine_peers.stop(); stats .compute_turbine_peers_total @@ -378,6 +387,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, @@ -409,6 +419,7 @@ pub fn retransmitter( &mut shred_deduper, &max_slots, rpc_subscriptions.as_deref(), + &shred_receiver_addr, ) { Ok(()) => (), Err(RecvTimeoutError::Timeout) => (), @@ -432,6 +443,7 @@ impl RetransmitStage { retransmit_receiver: Receiver>>, max_slots: Arc, rpc_subscriptions: Option>, + shred_receiver_addr: Arc>>, ) -> Self { let retransmit_thread_handle = retransmitter( retransmit_sockets, @@ -442,6 +454,7 @@ impl RetransmitStage { retransmit_receiver, max_slots, rpc_subscriptions, + shred_receiver_addr, ); Self { diff --git a/validator/Cargo.toml b/validator/Cargo.toml index 845bdda7ee..69adeea93b 100644 --- a/validator/Cargo.toml +++ b/validator/Cargo.toml @@ -64,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 e10ab05ea0..5733c89567 100644 --- a/validator/src/admin_rpc_service.rs +++ b/validator/src/admin_rpc_service.rs @@ -13,6 +13,10 @@ use { solana_core::{ admin_rpc_post_init::AdminRpcRequestMetadataPostInit, consensus::{tower_storage::TowerStorage, Tower}, + proxy::{ + block_engine_stage::{BlockEngineConfig, BlockEngineStage}, + relayer_stage::{RelayerConfig, RelayerStage}, + }, validator::ValidatorStartProgress, }, solana_geyser_plugin_manager::GeyserPluginManagerRequest, @@ -30,6 +34,7 @@ use { fmt::{self, Display}, net::SocketAddr, path::{Path, PathBuf}, + str::FromStr, sync::{Arc, RwLock}, thread::{self, Builder}, time::{Duration, SystemTime}, @@ -233,6 +238,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 +457,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 +515,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| { @@ -838,7 +937,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)] @@ -876,6 +978,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(), @@ -888,6 +993,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 88a45fdad5..26c2999c7f 100644 --- a/validator/src/bootstrap.rs +++ b/validator/src/bootstrap.rs @@ -814,12 +814,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| { ( diff --git a/validator/src/cli.rs b/validator/src/cli.rs index aba402b425..0da2e655c0 100644 --- a/validator/src/cli.rs +++ b/validator/src/cli.rs @@ -60,6 +60,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) @@ -70,6 +74,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") @@ -1413,6 +1498,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") diff --git a/validator/src/dashboard.rs b/validator/src/dashboard.rs index f6df5693c0..d9c42c2690 100644 --- a/validator/src/dashboard.rs +++ b/validator/src/dashboard.rs @@ -273,6 +273,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 0c998b91c3..d2c666da27 100644 --- a/validator/src/main.rs +++ b/validator/src/main.rs @@ -23,7 +23,9 @@ use { banking_trace::DISABLED_BAKING_TRACE_DIR, consensus::tower_storage, 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}, tpu::DEFAULT_TPU_COALESCE, validator::{ is_snapshot_config_valid, BlockProductionMethod, BlockVerificationMethod, Validator, @@ -83,7 +85,7 @@ use { path::{Path, PathBuf}, process::exit, str::FromStr, - sync::{Arc, RwLock}, + sync::{Arc, Mutex, RwLock}, time::{Duration, SystemTime}, }, }; @@ -473,6 +475,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)) => { @@ -1267,6 +1323,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, @@ -1397,6 +1491,14 @@ 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"), use_snapshot_archives_at_startup: value_t_or_exit!( @@ -1404,6 +1506,8 @@ pub fn main() { use_snapshot_archives_at_startup::cli::NAME, UseSnapshotArchivesAtStartup ), + preallocated_bundle_cost: value_of(&matches, "preallocated_bundle_cost") + .expect("preallocated_bundle_cost set as default"), ..ValidatorConfig::default() }; @@ -1947,3 +2051,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(), } } } From ff739bd69b42b7d331d68f4d9d7af20bb8c1cece Mon Sep 17 00:00:00 2001 From: Eric Semeniuc <3838856+esemeniuc@users.noreply.github.com> Date: Fri, 10 Nov 2023 18:54:45 +0100 Subject: [PATCH 2/9] [JIT-1661] Faster Autosnapshot (#436) --- Cargo.lock | 5 + tip-distributor/Cargo.toml | 28 +- tip-distributor/README.md | 15 +- tip-distributor/src/bin/claim-mev-tips.rs | 163 +++++- .../src/bin/merkle-root-uploader.rs | 24 +- tip-distributor/src/bin/reclaim-rent.rs | 62 --- tip-distributor/src/claim_mev_workflow.rs | 508 ++++++++++++++---- tip-distributor/src/lib.rs | 360 ++++++++++--- .../src/merkle_root_upload_workflow.rs | 10 +- tip-distributor/src/reclaim_rent_workflow.rs | 242 ++++++--- 10 files changed, 1035 insertions(+), 382 deletions(-) delete mode 100644 tip-distributor/src/bin/reclaim-rent.rs diff --git a/Cargo.lock b/Cargo.lock index cdc6eae453..52cc840cb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7668,23 +7668,28 @@ version = "1.18.0" dependencies = [ "anchor-lang", "clap 4.3.21", + "crossbeam-channel", "env_logger", "futures 0.3.28", + "gethostname", "im", "itertools", "jito-tip-distribution", "jito-tip-payment", "log", "num-traits", + "rand 0.8.5", "serde", "serde_json", "solana-accounts-db", "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", diff --git a/tip-distributor/Cargo.toml b/tip-distributor/Cargo.toml index 64bc345dab..6d6e9b7f78 100644 --- a/tip-distributor/Cargo.toml +++ b/tip-distributor/Cargo.toml @@ -4,34 +4,40 @@ 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"] } -env_logger = "0.9.0" -futures = "0.3.21" -im = "15.1.0" -itertools = "0.10.3" +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 = "0.4.17" -num-traits = "0.2.15" -serde = "1.0.137" -serde_json = "1.0.81" +log = { workspace = true } +num-traits = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } solana-accounts-db = { 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 } solana-vote = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "full"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } [dev-dependencies] solana-sdk = { workspace = true, features = ["dev-context-only-utils"] } @@ -51,7 +57,3 @@ path = "src/bin/merkle-root-uploader.rs" [[bin]] name = "solana-claim-mev-tips" path = "src/bin/claim-mev-tips.rs" - -[[bin]] -name = "solana-reclaim-rent" -path = "src/bin/reclaim-rent.rs" diff --git a/tip-distributor/README.md b/tip-distributor/README.md index c100843a41..fec682879a 100644 --- a/tip-distributor/README.md +++ b/tip-distributor/README.md @@ -27,8 +27,15 @@ out into the PDA until some slot in epoch N + 1. Due to this we cannot rely on t 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. It'll optionally upload the root -on-chain if specified. Additionally, it'll spit the generated merkle trees out into a JSON file. +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: @@ -38,6 +45,8 @@ In order to use this library as the merkle root creator one must follow the foll 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 --path-to-my-keypair ${KEYPAIR_PATH} --stake-meta-coll-path ${STAKE_META_COLLECTION_JSON} --rpc-url ${URL} --upload-roots ${BOOL} --force-upload-root ${BOOL}` +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 index 4a9a789509..e517377c7d 100644 --- a/tip-distributor/src/bin/claim-mev-tips.rs +++ b/tip-distributor/src/bin/claim-mev-tips.rs @@ -1,11 +1,21 @@ //! This binary claims MEV tips. - use { clap::Parser, + gethostname::gethostname, log::*, - solana_sdk::pubkey::Pubkey, - solana_tip_distributor::claim_mev_workflow::claim_mev_tips, - std::{path::PathBuf, str::FromStr}, + 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)] @@ -16,37 +26,146 @@ struct Args { merkle_trees_path: PathBuf, /// RPC to send transactions through - #[arg(long, env)] + #[arg(long, env, default_value = "http://localhost:8899")] rpc_url: String, /// Tip distribution program ID #[arg(long, env)] - tip_distribution_program_id: String, + 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, } -fn main() { +#[tokio::main] +async fn main() -> Result<(), ClaimMevError> { env_logger::init(); - info!("Starting to claim mev tips..."); - + 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); - let tip_distribution_program_id = Pubkey::from_str(&args.tip_distribution_program_id) - .expect("valid tip_distribution_program_id"); - - if let Err(e) = claim_mev_tips( - &args.merkle_trees_path, - &args.rpc_url, - &tip_distribution_program_id, - &args.keypair_path, - ) { - panic!("error claiming mev tips: {:?}", e); - } info!( - "done claiming mev tips from file {:?}", - args.merkle_trees_path + "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-uploader.rs b/tip-distributor/src/bin/merkle-root-uploader.rs index 9fcd7b8ed7..9000ce66d0 100644 --- a/tip-distributor/src/bin/merkle-root-uploader.rs +++ b/tip-distributor/src/bin/merkle-root-uploader.rs @@ -1,9 +1,6 @@ use { - clap::Parser, - log::info, - solana_sdk::pubkey::Pubkey, - solana_tip_distributor::merkle_root_upload_workflow::upload_merkle_root, - std::{path::PathBuf, str::FromStr}, + clap::Parser, log::info, solana_sdk::pubkey::Pubkey, + solana_tip_distributor::merkle_root_upload_workflow::upload_merkle_root, std::path::PathBuf, }; #[derive(Parser, Debug)] @@ -23,7 +20,15 @@ struct Args { /// Tip distribution program ID #[arg(long, env)] - tip_distribution_program_id: String, + 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() { @@ -31,15 +36,14 @@ fn main() { let args: Args = Args::parse(); - let tip_distribution_program_id = Pubkey::from_str(&args.tip_distribution_program_id) - .expect("valid tip_distribution_program_id"); - info!("starting merkle root uploader..."); if let Err(e) = upload_merkle_root( &args.merkle_root_path, &args.keypair_path, &args.rpc_url, - &tip_distribution_program_id, + &args.tip_distribution_program_id, + args.max_concurrent_rpc_get_reqs, + args.txn_send_batch_size, ) { panic!("failed to upload merkle roots: {:?}", e); } diff --git a/tip-distributor/src/bin/reclaim-rent.rs b/tip-distributor/src/bin/reclaim-rent.rs deleted file mode 100644 index 5aa372a27a..0000000000 --- a/tip-distributor/src/bin/reclaim-rent.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Reclaims rent from TDAs and Claim Status accounts. - -use { - clap::Parser, - log::*, - solana_client::nonblocking::rpc_client::RpcClient, - solana_sdk::{ - commitment_config::CommitmentConfig, pubkey::Pubkey, signature::read_keypair_file, - }, - solana_tip_distributor::reclaim_rent_workflow::reclaim_rent, - std::{path::PathBuf, str::FromStr, time::Duration}, - tokio::runtime::Runtime, -}; - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - /// RPC to send transactions through. - /// NOTE: This script uses getProgramAccounts, make sure you have added an account index - /// for the tip_distribution_program_id on the RPC node. - #[arg(long, env)] - rpc_url: String, - - /// Tip distribution program ID. - #[arg(long, env, value_parser = Pubkey::from_str)] - tip_distribution_program_id: Pubkey, - - /// The keypair signing and paying for transactions. - #[arg(long, env)] - keypair_path: PathBuf, - - /// High timeout b/c of get_program_accounts call - #[arg(long, env, default_value_t = 180)] - rpc_timeout_secs: u64, - - /// Specifies whether to reclaim rent on behalf of validators from respective TDAs. - #[arg(long, env)] - should_reclaim_tdas: bool, -} - -fn main() { - env_logger::init(); - - info!("Starting to claim mev tips..."); - let args: Args = Args::parse(); - - let runtime = Runtime::new().unwrap(); - if let Err(e) = runtime.block_on(reclaim_rent( - RpcClient::new_with_timeout_and_commitment( - args.rpc_url, - Duration::from_secs(args.rpc_timeout_secs), - CommitmentConfig::confirmed(), - ), - args.tip_distribution_program_id, - read_keypair_file(&args.keypair_path).expect("read keypair file"), - args.should_reclaim_tdas, - )) { - panic!("error reclaiming rent: {e:?}"); - } - - info!("done reclaiming all rent",); -} diff --git a/tip-distributor/src/claim_mev_workflow.rs b/tip-distributor/src/claim_mev_workflow.rs index 379677c10c..82cdba3827 100644 --- a/tip-distributor/src/claim_mev_workflow.rs +++ b/tip-distributor/src/claim_mev_workflow.rs @@ -1,26 +1,33 @@ use { crate::{ - read_json_from_file, sign_and_send_transactions_with_retries, GeneratedMerkleTreeCollection, + claim_mev_workflow::ClaimMevError::{ClaimantNotFound, InsufficientBalance, TDANotFound}, + minimum_balance, sign_and_send_transactions_with_retries_multi_rpc, + GeneratedMerkleTreeCollection, TreeNode, }, anchor_lang::{AccountDeserialize, InstructionData, ToAccountMetas}, - jito_tip_distribution::state::*, - log::{debug, info, warn}, - solana_client::{nonblocking::rpc_client::RpcClient, rpc_request::RpcError}, + 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, - stake::state::StakeStateV2, system_program, + system_program, }, - solana_rpc_client_api::client_error, solana_sdk::{ + account::Account, commitment_config::CommitmentConfig, instruction::Instruction, pubkey::Pubkey, - signature::{read_keypair_file, Signer}, + signature::{Keypair, Signer}, transaction::Transaction, }, - std::{path::PathBuf, time::Duration}, + std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, + }, thiserror::Error, - tokio::runtime::Builder, }; #[derive(Error, Debug)] @@ -30,123 +37,412 @@ pub enum ClaimMevError { #[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 fn claim_mev_tips( - merkle_root_path: &PathBuf, - rpc_url: &str, +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_path: &PathBuf, + keypair: Arc, + max_loop_retries: u64, + max_loop_duration: Duration, ) -> Result<(), ClaimMevError> { - const MAX_RETRY_DURATION: Duration = Duration::from_secs(600); + 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 merkle_trees: GeneratedMerkleTreeCollection = - read_json_from_file(merkle_root_path).expect("read GeneratedMerkleTreeCollection"); - let keypair = read_keypair_file(keypair_path).expect("read keypair file"); + let tree_nodes = merkle_trees + .generated_merkle_trees + .iter() + .flat_map(|tree| &tree.tree_nodes) + .collect_vec(); - let tip_distribution_config = - Pubkey::find_program_address(&[Config::SEED], tip_distribution_program_id).0; + // 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::>(); - let rpc_client = - RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::finalized()); + // 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::>(); - let runtime = Builder::new_multi_thread() - .worker_threads(16) - .enable_all() - .build() - .unwrap(); + // 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 mut instructions = Vec::new(); + 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 + ), + ); - runtime.block_on(async move { - let start_balance = rpc_client.get_balance(&keypair.pubkey()).await.expect("failed to get balance"); - // heuristic to make sure we have enough funds to cover the rent costs if epoch has many validators + 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 { - // most amounts are for 0 lamports. had 1736 non-zero claims out of 164742 - let node_count = merkle_trees.generated_merkle_trees.iter().flat_map(|tree| &tree.tree_nodes).filter(|node| node.amount > 0).count(); - 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 = (node_count as u64).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 - panic!("Expected to have at least {} lamports in {}, current balance is {} lamports, deposit {} SOL to continue.", - desired_balance, &keypair.pubkey(), start_balance, sol_to_deposit) - } + return Err(InsufficientBalance { + desired_balance, + payer: payer_pubkey, + start_balance, + sol_to_deposit, + }); } - let stake_acct_min_rent = rpc_client.get_minimum_balance_for_rent_exemption(StakeStateV2::size_of()).await.expect("Failed to calculate min rent"); - let mut below_min_rent_count: usize = 0; - let mut zero_lamports_count: usize = 0; - for tree in merkle_trees.generated_merkle_trees { - // only claim for ones that have merkle root on-chain - let account = rpc_client.get_account(&tree.tip_distribution_account).await.expect("expected to fetch tip distribution account"); - let fetched_tip_distribution_account = TipDistributionAccount::try_deserialize(&mut account.data.as_slice()).expect("failed to deserialize tip_distribution_account state"); - if fetched_tip_distribution_account.merkle_root.is_none() { - info!( - "not claiming because merkle root isn't uploaded yet. skipped {} claimants for tda: {:?}", - tree.tree_nodes.len(), - tree.tip_distribution_account - ); + 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; } - for node in tree.tree_nodes { - if node.amount == 0 { - zero_lamports_count = zero_lamports_count.checked_add(1).unwrap(); + + // 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)); + }; - // make sure not previously claimed - match rpc_client.get_account(&node.claim_status_pubkey).await { - Ok(_) => { - debug!("claim status account already exists, skipping pubkey {:?}.", node.claim_status_pubkey); - continue; - } - // expected to not find ClaimStatus account, don't skip - Err(client_error::Error { kind: client_error::ErrorKind::RpcError(RpcError::ForUser(err)), .. }) if err.starts_with("AccountNotFound") => {} - Err(err) => panic!("Unexpected RPC Error: {}", err), + // 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, } - - let current_balance = rpc_client.get_balance(&node.claimant).await.expect("Failed to get balance"); - // some older accounts can be rent-paying - // any new transfers will need to make the account rent-exempt (runtime enforced) - if current_balance.checked_add(node.amount).unwrap() < stake_acct_min_rent { - warn!("Current balance + tip claim amount of {} is less than required rent-exempt of {} for pubkey: {}. Skipping.", - current_balance.checked_add(node.amount).unwrap(), stake_acct_min_rent, node.claimant); - below_min_rent_count = below_min_rent_count.checked_add(1).unwrap(); - continue; + .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(), } - instructions.push(Instruction { - program_id: *tip_distribution_program_id, - data: jito_tip_distribution::instruction::Claim { - proof: node.proof.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: keypair.pubkey(), - system_program: system_program::id(), - }.to_account_metas(None), - }); - } + .to_account_metas(None), + }); } + } - let transactions = instructions.into_iter().map(|ix|{ - Transaction::new_with_payer( - &[ix], - Some(&keypair.pubkey()), - ) - }).collect::>(); - - info!("Sending {} tip claim transactions. {} tried sending zero lamports, {} would be below minimum rent", - &transactions.len(), zero_lamports_count, below_min_rent_count); - - let failed_transactions = sign_and_send_transactions_with_retries(&keypair, &rpc_client, transactions, MAX_RETRY_DURATION).await; - if !failed_transactions.is_empty() { - panic!("failed to send {} transactions", failed_transactions.len()); - } - }); + 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, + )) +} - Ok(()) +/// 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 index bd8de90230..c914adb376 100644 --- a/tip-distributor/src/lib.rs +++ b/tip-distributor/src/lib.rs @@ -10,6 +10,7 @@ use { stake_meta_generator_workflow::StakeMetaGeneratorError::CheckedMathError, }, anchor_lang::Id, + itertools::Itertools, jito_tip_distribution::{ program::JitoTipDistribution, state::{ClaimStatus, TipDistributionAccount}, @@ -20,35 +21,49 @@ use { 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::RpcRequest, + request::{RpcError, RpcResponseErrorData, MAX_MULTIPLE_ACCOUNTS}, + response::RpcSimulateTransactionResult, }, solana_sdk::{ - account::{AccountSharedData, ReadableAccount}, + account::{Account, AccountSharedData, ReadableAccount}, clock::Slot, hash::{Hash, Hasher}, pubkey::Pubkey, signature::{Keypair, Signature}, stake_history::Epoch, - transaction::{Transaction, TransactionError::AlreadyProcessed}, + transaction::{ + Transaction, + TransactionError::{self}, + }, }, std::{ collections::HashMap, fs::File, io::BufReader, path::PathBuf, - sync::Arc, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, time::{Duration, Instant}, }, - tokio::time::sleep, + tokio::sync::{RwLock, Semaphore}, }; -#[derive(Deserialize, Serialize, Debug)] +#[derive(Clone, Deserialize, Serialize, Debug)] pub struct GeneratedMerkleTreeCollection { pub generated_merkle_trees: Vec, pub bank_hash: String, @@ -56,7 +71,7 @@ pub struct GeneratedMerkleTreeCollection { pub slot: Slot, } -#[derive(Eq, Debug, Hash, PartialEq, Deserialize, Serialize)] +#[derive(Clone, Eq, Debug, Hash, PartialEq, Deserialize, Serialize)] pub struct GeneratedMerkleTree { #[serde(with = "pubkey_string_conversion")] pub tip_distribution_account: Pubkey, @@ -460,104 +475,285 @@ pub fn derive_tip_distribution_account_address( ) } +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, - max_retry_duration: Duration, -) -> HashMap { - use tokio::sync::Semaphore; - const MAX_CONCURRENT_RPC_CALLS: usize = 50; - let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_RPC_CALLS)); - + 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"); - - let mut signatures_to_transactions = transactions + // track unsigned txns + let mut transactions_to_process = transactions .into_iter() - .map(|mut tx| { - tx.sign(&[signer], blockhash); - (tx.signatures[0], tx) - }) - .collect::>(); + .map(|txn| (txn.message_data(), txn)) + .collect::, Transaction>>(); let start = Instant::now(); - while start.elapsed() < max_retry_duration && !signatures_to_transactions.is_empty() { - if start.elapsed() > Duration::from_secs(60) { + 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"); - signatures_to_transactions - .iter_mut() - .for_each(|(_sig, tx)| { - *tx = Transaction::new_unsigned(tx.message.clone()); - tx.sign(&[signer], 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 futs = signatures_to_transactions.iter().map(|(sig, tx)| { - let semaphore = semaphore.clone(); - async move { - let permit = semaphore.clone().acquire_owned().await.unwrap(); - let res = match rpc_client.send_transaction(tx).await { - Ok(sig) => { - info!("sent transaction: {sig:?}"); - drop(permit); - sleep(Duration::from_secs(10)).await; - - let _permit = semaphore.acquire_owned().await.unwrap(); - match rpc_client.confirm_transaction(&sig).await { - Ok(true) => Ok(()), - Ok(false) => Err(Error::new_with_request( - ErrorKind::Custom("transaction failed to confirm".to_string()), - RpcRequest::SendTransaction, - )), - Err(e) => Err(e), - } - } - Err(e) => Err(e), - }; - - let res = res - .err() - .map(|e| { - if let ErrorKind::TransactionError(AlreadyProcessed) = e.kind { - Ok(()) - } else { - error!("error sending transaction {sig:?} error: {e:?}"); - Err(e) - } - }) - .unwrap_or(Ok(())); - - (*sig, res) - } - }); - - errors = futures::future::join_all(futs) - .await + let send_res = futures::future::join_all(send_futs).await; + let new_errors = send_res .into_iter() - .filter(|(sig, result)| { - if result.is_err() { - true - } else { - let _ = signatures_to_transactions.remove(sig); - false + .filter_map(|(hash, txn, result)| match result { + Err(e) => Some((txn.signatures[0], e)), + Ok(..) => { + let _ = transactions_to_process.remove(&hash); + None } }) - .map(|(sig, result)| { - let e = result.err().unwrap(); - warn!("error sending transaction: [error={e}, signature={sig}]"); - (sig, e) - }) .collect::>(); + + errors.extend(new_errors); } - 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 { @@ -583,7 +779,7 @@ mod pubkey_string_conversion { } } -pub(crate) fn read_json_from_file(path: &PathBuf) -> serde_json::Result +pub fn read_json_from_file(path: &PathBuf) -> serde_json::Result where T: DeserializeOwned, { diff --git a/tip-distributor/src/merkle_root_upload_workflow.rs b/tip-distributor/src/merkle_root_upload_workflow.rs index cc75797f05..e40465581f 100644 --- a/tip-distributor/src/merkle_root_upload_workflow.rs +++ b/tip-distributor/src/merkle_root_upload_workflow.rs @@ -38,6 +38,8 @@ pub fn upload_merkle_root( 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); @@ -124,9 +126,11 @@ pub fn upload_merkle_root( ) }) .collect(); - let failed_transactions = sign_and_send_transactions_with_retries(&keypair, &rpc_client, transactions, MAX_RETRY_DURATION).await; - if !failed_transactions.is_empty() { - panic!("failed to send {} transactions", failed_transactions.len()); + + 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()); } }); diff --git a/tip-distributor/src/reclaim_rent_workflow.rs b/tip-distributor/src/reclaim_rent_workflow.rs index da8d6c6362..8d923e3980 100644 --- a/tip-distributor/src/reclaim_rent_workflow.rs +++ b/tip-distributor/src/reclaim_rent_workflow.rs @@ -1,6 +1,10 @@ use { - crate::sign_and_send_transactions_with_retries, + 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, @@ -14,47 +18,151 @@ use { }, 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::{ - error::Error, + sync::Arc, time::{Duration, Instant}, }, }; +/// Clear old ClaimStatus accounts pub async fn reclaim_rent( - rpc_client: RpcClient, + rpc_url: String, + rpc_send_connection_count: u64, tip_distribution_program_id: Pubkey, - signer: Keypair, + signer: Arc, + max_loop_retries: u64, + max_loop_duration: Duration, // Optionally reclaim TipDistributionAccount rents on behalf of validators. should_reclaim_tdas: bool, -) -> Result<(), Box> { - info!("fetching program accounts..."); - let now = Instant::now(); - let accounts = rpc_client - .get_program_accounts(&tip_distribution_program_id) +) -> 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!( - "get_program_accounts took {}ms and fetched {} accounts", - now.elapsed().as_millis(), + "Fetch get_program_accounts took {:?} and fetched {} accounts", + get_pa_elapsed.as_duration(), accounts.len() ); - info!("fetching current_epoch..."); + info!("Fetching current_epoch"); let current_epoch = rpc_client.get_epoch_info().await?.epoch; - info!("current_epoch: {current_epoch}"); + info!("Fetch current_epoch: {current_epoch}"); - info!("fetching config_account..."); - let now = Instant::now(); - let config_pubkey = derive_config_account_address(&tip_distribution_program_id).0; - let config_account = rpc_client.get_account(&config_pubkey).await?; - let config_account: Config = Config::try_deserialize(&mut config_account.data.as_slice())?; - info!("fetch config_account took {}ms", now.elapsed().as_millis()); + 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 claim_status accounts"); + info!("Filtering for ClaimStatus accounts"); let claim_status_accounts: Vec<(Pubkey, ClaimStatus)> = accounts .iter() .filter_map(|(pubkey, account)| { @@ -63,30 +171,22 @@ pub async fn reclaim_rent( }) .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 == signer.pubkey() + claim_status.claim_status_payer.eq(signer_pubkey) && current_epoch > claim_status.expires_at }) .collect::>(); info!( - "{} claim_status accounts eligible for rent reclaim", + "{} ClaimStatus accounts eligible for rent reclaim", claim_status_accounts.len() ); - info!("fetching recent_blockhash"); - let now = Instant::now(); - let recent_blockhash = rpc_client.get_latest_blockhash().await?; - info!( - "fetch recent_blockhash took {}ms, hash={recent_blockhash:?}", - now.elapsed().as_millis() - ); - - info!("creating close_claim_status_account transactions"); - let now = Instant::now(); + 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, + *tip_distribution_program_id, CloseClaimStatusArgs, CloseClaimStatusAccounts { config: config_pubkey, @@ -97,71 +197,51 @@ pub async fn reclaim_rent( }) .collect::>() .chunks(4) - .map(|instructions| { - Transaction::new_signed_with_payer( - instructions, - Some(&signer.pubkey()), - &[&signer], - recent_blockhash, - ) - }) + .map(|instructions| Transaction::new_with_payer(instructions, Some(signer_pubkey))) .collect::>(); info!( - "create close_claim_status_account transactions took {}us", - now.elapsed().as_micros() + "Create CloseClaimStatusAccounts transactions took {:?}", + transaction_now.elapsed() ); if should_reclaim_tdas { - let tip_distribution_accounts = accounts + 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); - - info!("creating close_tip_distribution_account transactions"); - let now = Instant::now(); - let close_tda_txs = tip_distribution_accounts - .map( - |(tip_distribution_account, tda): (Pubkey, TipDistributionAccount)| { - 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(), - }, - ) - }, - ) + .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()))) + .map(|instructions| Transaction::new_with_payer(instructions, Some(signer_pubkey))) .collect::>(); - info!("create close_tip_distribution_account transactions took {}us, closing {} tip distribution accounts", now.elapsed().as_micros(), close_tda_txs.len()); + info!("Create CloseTipDistributionAccounts transactions took {:?}, closing {} tip distribution accounts", now.elapsed(), close_tda_txs.len()); transactions.extend(close_tda_txs); } - - info!("sending {} transactions", transactions.len()); - let failed_txs = sign_and_send_transactions_with_retries( - &signer, - &rpc_client, + Ok(( transactions, - Duration::from_secs(300), - ) - .await; - if !failed_txs.is_empty() { - panic!("failed to send {} transactions", failed_txs.len()); - } - - Ok(()) + get_pa_elapsed.as_duration(), + transaction_now.elapsed(), + )) } From 1dda669816c8eab4fe9413e125c078013b62ed88 Mon Sep 17 00:00:00 2001 From: buffalu <85544055+buffalu@users.noreply.github.com> Date: Sun, 12 Nov 2023 12:48:35 -0600 Subject: [PATCH 3/9] Reverts simulate_transaction result calls to upstream (#446) --- rpc/src/rpc.rs | 1 + runtime/src/bank.rs | 64 +++++++++++++++++++++++---------------------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/rpc/src/rpc.rs b/rpc/src/rpc.rs index 3b048e384b..fd837a7188 100644 --- a/rpc/src/rpc.rs +++ b/rpc/src/rpc.rs @@ -3795,6 +3795,7 @@ pub mod rpc_full { commitment: preflight_commitment, min_context_slot, })?; + let transaction = sanitize_transaction(unsanitized_tx, preflight_bank)?; let signature = *transaction.signature(); diff --git a/runtime/src/bank.rs b/runtime/src/bank.rs index 1d841e6a0a..7c85444c1a 100644 --- a/runtime/src/bank.rs +++ b/runtime/src/bank.rs @@ -4402,13 +4402,14 @@ impl Bank { transaction: SanitizedTransaction, ) -> TransactionSimulationResult { let account_keys = transaction.message().account_keys(); + let number_of_accounts = account_keys.len(); let account_overrides = self.get_account_overrides_for_simulation(&account_keys); let batch = self.prepare_unlocked_batch_from_single_tx(&transaction); let mut timings = ExecuteTimings::default(); let LoadAndExecuteTransactionsOutput { loaded_transactions, - execution_results, + mut execution_results, .. } = self.load_and_execute_transactions( &batch, @@ -4424,42 +4425,43 @@ impl Bank { None, ); - Self::build_transaction_simulation_result(&loaded_transactions[0], &execution_results[0]) - } + let post_simulation_accounts = loaded_transactions + .into_iter() + .next() + .unwrap() + .0 + .ok() + .map(|loaded_transaction| { + loaded_transaction + .accounts + .into_iter() + .take(number_of_accounts) + .collect::>() + }) + .unwrap_or_default(); - fn build_transaction_simulation_result( - loaded_transaction_result: &TransactionLoadResult, - execution_result: &TransactionExecutionResult, - ) -> TransactionSimulationResult { - let (logs, return_data, units_consumed, result) = match execution_result { - TransactionExecutionResult::Executed { details, .. } => { - let log_messages = if let Some(ref log_messages) = details.log_messages { - log_messages.clone() - } else { - vec![] - }; + let units_consumed = timings + .details + .per_program_timings + .iter() + .fold(0, |acc: u64, (_, program_timing)| { + acc.saturating_add(program_timing.accumulated_units) + }); - ( - log_messages, - details.return_data.as_ref().cloned(), - details.executed_units, - execution_result.flattened_result(), - ) - } - TransactionExecutionResult::NotExecuted(_) => { - (vec![], None, 0, execution_result.flattened_result()) + debug!("simulate_transaction: {:?}", timings); + + let execution_result = execution_results.pop().unwrap(); + let flattened_result = execution_result.flattened_result(); + let (logs, return_data) = match execution_result { + TransactionExecutionResult::Executed { details, .. } => { + (details.log_messages, details.return_data) } + TransactionExecutionResult::NotExecuted(_) => (None, None), }; - - let post_simulation_accounts = loaded_transaction_result - .0 - .as_ref() - .ok() - .map(|tx| tx.accounts.clone()) - .unwrap_or_default(); + let logs = logs.unwrap_or_default(); TransactionSimulationResult { - result, + result: flattened_result, logs, post_simulation_accounts, units_consumed, From a414e129c65e6239afca3bd2d68c3c580bc91eb7 Mon Sep 17 00:00:00 2001 From: buffalu <85544055+buffalu@users.noreply.github.com> Date: Sun, 12 Nov 2023 21:04:32 -0600 Subject: [PATCH 4/9] Don't unlock accounts in TransactionBatches used during simulation (#449) --- runtime/src/bank.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/runtime/src/bank.rs b/runtime/src/bank.rs index 7c85444c1a..49921f7fe8 100644 --- a/runtime/src/bank.rs +++ b/runtime/src/bank.rs @@ -4364,7 +4364,12 @@ impl Bank { let mut account_locks = AccountLocks::default(); let lock_results = Accounts::lock_accounts_sequential(&mut account_locks, tx_account_locks_results); - TransactionBatch::new(lock_results, self, Cow::Borrowed(transactions)) + 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 from a single transaction without locking accounts From 9d0fa4326b6375a5e9d9d0880a8a42515d3774ee Mon Sep 17 00:00:00 2001 From: segfaultdoctor <17258903+segfaultdoc@users.noreply.github.com> Date: Mon, 13 Nov 2023 06:26:23 -0500 Subject: [PATCH 5/9] first pass at wiring up jito-plugin (#428) --- Cargo.lock | 20 ++ Cargo.toml | 2 + core/Cargo.toml | 1 + core/src/validator.rs | 23 +- local-cluster/src/local_cluster.rs | 3 + programs/sbf/Cargo.lock | 20 ++ runtime-plugin/Cargo.toml | 19 + 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 +++++++ test-validator/src/lib.rs | 1 + validator/Cargo.toml | 1 + validator/src/cli.rs | 58 ++++ validator/src/main.rs | 120 ++++++- 16 files changed, 1035 insertions(+), 2 deletions(-) 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 diff --git a/Cargo.lock b/Cargo.lock index 52cc840cb5..f50d34e96f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6243,6 +6243,7 @@ dependencies = [ "solana-rpc", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-stake-program", @@ -7386,6 +7387,24 @@ dependencies = [ "zstd", ] +[[package]] +name = "solana-runtime-plugin" +version = "1.18.0" +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.18.0" @@ -7912,6 +7931,7 @@ dependencies = [ "solana-rpc-client", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-storage-bigtable", diff --git a/Cargo.toml b/Cargo.toml index 58db6628d8..8f8d7d736c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ members = [ "rpc-client-nonce-utils", "rpc-test", "runtime", + "runtime-plugin", "runtime/store-tool", "sdk", "sdk/cargo-build-bpf", @@ -370,6 +371,7 @@ solana-rpc-client = { path = "rpc-client", version = "=1.18.0", default-features solana-rpc-client-api = { path = "rpc-client-api", version = "=1.18.0" } solana-rpc-client-nonce-utils = { path = "rpc-client-nonce-utils", version = "=1.18.0" } solana-runtime = { path = "runtime", version = "=1.18.0" } +solana-runtime-plugin = { path = "runtime-plugin", version = "=1.18.0" } solana-sdk = { path = "sdk", version = "=1.18.0" } solana-sdk-macro = { path = "sdk/macro", version = "=1.18.0" } solana-send-transaction-service = { path = "send-transaction-service", version = "=1.18.0" } diff --git a/core/Cargo.toml b/core/Cargo.toml index e3377370e6..32da4c3ba1 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -69,6 +69,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 } diff --git a/core/src/validator.rs b/core/src/validator.rs index 0c90fe09ca..083a9b7975 100644 --- a/core/src/validator.rs +++ b/core/src/validator.rs @@ -106,6 +106,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, @@ -505,6 +509,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()); @@ -889,6 +897,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); @@ -2512,6 +2531,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!( @@ -2589,7 +2609,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, @@ -2597,6 +2617,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/local-cluster/src/local_cluster.rs b/local-cluster/src/local_cluster.rs index d180a4abaf..71d3a9d00c 100644 --- a/local-cluster/src/local_cluster.rs +++ b/local-cluster/src/local_cluster.rs @@ -295,6 +295,7 @@ impl LocalCluster { DEFAULT_TPU_CONNECTION_POOL_SIZE, DEFAULT_TPU_ENABLE_UDP, Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); @@ -510,6 +511,7 @@ impl LocalCluster { DEFAULT_TPU_CONNECTION_POOL_SIZE, DEFAULT_TPU_ENABLE_UDP, Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); @@ -907,6 +909,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/programs/sbf/Cargo.lock b/programs/sbf/Cargo.lock index 57b84c7c4f..e86824feea 100644 --- a/programs/sbf/Cargo.lock +++ b/programs/sbf/Cargo.lock @@ -5099,6 +5099,7 @@ dependencies = [ "solana-rpc", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-streamer", @@ -5873,6 +5874,24 @@ dependencies = [ "zstd", ] +[[package]] +name = "solana-runtime-plugin" +version = "1.18.0" +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.18.0" @@ -6667,6 +6686,7 @@ dependencies = [ "solana-rpc-client", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-storage-bigtable", diff --git a/runtime-plugin/Cargo.toml b/runtime-plugin/Cargo.toml new file mode 100644 index 0000000000..6be0da19e8 --- /dev/null +++ b/runtime-plugin/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "solana-runtime-plugin" +version = "1.18.0" +edition = "2021" +publish = false + +[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/test-validator/src/lib.rs b/test-validator/src/lib.rs index e807f80c96..19b02a4016 100644 --- a/test-validator/src/lib.rs +++ b/test-validator/src/lib.rs @@ -986,6 +986,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/validator/Cargo.toml b/validator/Cargo.toml index 69adeea93b..5d1a3270a3 100644 --- a/validator/Cargo.toml +++ b/validator/Cargo.toml @@ -54,6 +54,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 } diff --git a/validator/src/cli.rs b/validator/src/cli.rs index 0da2e655c0..de2bf41b74 100644 --- a/validator/src/cli.rs +++ b/validator/src/cli.rs @@ -1183,6 +1183,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") @@ -1698,6 +1706,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") @@ -2645,6 +2695,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/main.rs b/validator/src/main.rs index d2c666da27..16f9287d0f 100644 --- a/validator/src/main.rs +++ b/validator/src/main.rs @@ -1,10 +1,12 @@ #![allow(clippy::arithmetic_side_effects)] + #[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_accounts_db::{ @@ -57,6 +59,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, @@ -85,7 +91,7 @@ use { path::{Path, PathBuf}, process::exit, str::FromStr, - sync::{Arc, Mutex, RwLock}, + sync::{atomic::AtomicBool, Arc, Mutex, RwLock}, time::{Duration, SystemTime}, }, }; @@ -680,6 +686,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); @@ -1814,6 +1906,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(); + solana_runtime_plugin::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| { @@ -1986,6 +2103,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); From 353d2d9c046c4b2c534c75cd96464cf66ce56250 Mon Sep 17 00:00:00 2001 From: Eric Semeniuc <3838856+esemeniuc@users.noreply.github.com> Date: Thu, 30 Nov 2023 18:56:20 +0100 Subject: [PATCH 6/9] [JIT-1713] Fix bundle's blockspace preallocation (#489) --- .../bundle_reserved_space_manager.rs | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/core/src/bundle_stage/bundle_reserved_space_manager.rs b/core/src/bundle_stage/bundle_reserved_space_manager.rs index 42cb7adeb6..a6b858d3b2 100644 --- a/core/src/bundle_stage/bundle_reserved_space_manager.rs +++ b/core/src/bundle_stage/bundle_reserved_space_manager.rs @@ -56,7 +56,7 @@ impl BundleReservedSpaceManager { /// 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 + bank.tick_height() % bank.ticks_per_slot() < self.reserved_ticks } /// return the block_cost_limits as determined by the tick height of the bank @@ -82,8 +82,10 @@ impl BundleReservedSpaceManager { 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, + solana_ledger::genesis_utils::create_genesis_config, + solana_runtime::bank::Bank, + solana_sdk::{hash::Hash, pubkey::Pubkey}, + std::sync::Arc, }; #[test] @@ -186,4 +188,52 @@ mod tests { block_cost_limits ); } + + #[test] + fn test_block_limits_after_first_slot() { + const BUNDLE_BLOCK_COST_LIMITS_RESERVATION: u64 = 100; + const RESERVED_TICKS: u64 = 5; + let genesis_config_info = create_genesis_config(100); + let bank = Arc::new(Bank::new_for_tests(&genesis_config_info.genesis_config)); + + for _ in 0..genesis_config_info.genesis_config.ticks_per_slot { + bank.register_tick(&Hash::default()); + } + assert!(bank.is_complete()); + bank.freeze(); + assert_eq!( + bank.read_cost_tracker().unwrap().block_cost_limit(), + solana_cost_model::block_cost_limits::MAX_BLOCK_UNITS, + ); + + let bank1 = Arc::new(Bank::new_from_parent(bank.clone(), &Pubkey::default(), 1)); + assert_eq!(bank1.slot(), 1); + assert_eq!(bank1.tick_height(), 64); + assert_eq!(bank1.max_tick_height(), 128); + + // reserve space + let block_cost_limits = bank1.read_cost_tracker().unwrap().block_cost_limit(); + let mut reserved_space = BundleReservedSpaceManager::new( + block_cost_limits, + BUNDLE_BLOCK_COST_LIMITS_RESERVATION, + RESERVED_TICKS, + ); + reserved_space.tick(&bank1); + + // wait for reservation to be over + (0..RESERVED_TICKS).for_each(|_| { + bank1.register_tick(&Hash::default()); + assert_eq!( + bank1.read_cost_tracker().unwrap().block_cost_limit(), + block_cost_limits - BUNDLE_BLOCK_COST_LIMITS_RESERVATION + ); + }); + reserved_space.tick(&bank1); + + // after reservation, revert back to normal limit + assert_eq!( + bank1.read_cost_tracker().unwrap().block_cost_limit(), + solana_cost_model::block_cost_limits::MAX_BLOCK_UNITS, + ); + } } From 8642978fdd39bb1658a775a6e9d5b94166737f9e Mon Sep 17 00:00:00 2001 From: Eric Semeniuc <3838856+esemeniuc@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:08:09 +0100 Subject: [PATCH 7/9] [JIT-1708] Fix TOC TOU condition for relayer and block engine config (#491) --- core/src/proxy/block_engine_stage.rs | 45 +++++++++++++++++----------- core/src/proxy/relayer_stage.rs | 27 ++++++++++------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/core/src/proxy/block_engine_stage.rs b/core/src/proxy/block_engine_stage.rs index 4128f5379f..5dd8510bad 100644 --- a/core/src/proxy/block_engine_stage.rs +++ b/core/src/proxy/block_engine_stage.rs @@ -148,9 +148,11 @@ impl BlockEngineStage { 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()) { + let local_block_engine_config = block_engine_config.lock().unwrap().clone(); + if !Self::is_valid_block_engine_config(&local_block_engine_config) { sleep(CONNECTION_BACKOFF).await; } else if let Err(e) = Self::connect_auth_and_stream( + &local_block_engine_config, &block_engine_config, &cluster_info, &bundle_tx, @@ -183,7 +185,8 @@ impl BlockEngineStage { } async fn connect_auth_and_stream( - block_engine_config: &Arc>, + local_block_engine_config: &BlockEngineConfig, + global_block_engine_config: &Arc>, cluster_info: &Arc, bundle_tx: &Sender>, packet_tx: &Sender, @@ -194,17 +197,20 @@ impl BlockEngineStage { ) -> 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") { + + let mut backend_endpoint = + Endpoint::from_shared(local_block_engine_config.block_engine_url.clone()) + .map_err(|_| { + ProxyError::BlockEngineConnectionError(format!( + "invalid block engine url value: {}", + local_block_engine_config.block_engine_url + )) + })? + .tcp_keepalive(Some(Duration::from_secs(60))); + if local_block_engine_config + .block_engine_url + .starts_with("https") + { backend_endpoint = backend_endpoint .tls_config(tonic::transport::ClientTlsConfig::new()) .map_err(|_| { @@ -214,7 +220,10 @@ impl BlockEngineStage { })?; } - debug!("connecting to auth: {}", local_config.block_engine_url); + debug!( + "connecting to auth: {}", + local_block_engine_config.block_engine_url + ); let auth_channel = timeout(*connection_timeout, backend_endpoint.connect()) .await .map_err(|_| ProxyError::AuthenticationConnectionTimeout)? @@ -232,13 +241,13 @@ impl BlockEngineStage { datapoint_info!( "block_engine_stage-tokens_generated", - ("url", local_config.block_engine_url, String), + ("url", local_block_engine_config.block_engine_url, String), ("count", 1, i64), ); debug!( "connecting to block engine: {}", - local_config.block_engine_url + local_block_engine_config.block_engine_url ); let block_engine_channel = timeout(*connection_timeout, backend_endpoint.connect()) .await @@ -255,8 +264,8 @@ impl BlockEngineStage { bundle_tx, block_engine_client, packet_tx, - &local_config, - block_engine_config, + local_block_engine_config, + global_block_engine_config, banking_packet_sender, exit, block_builder_fee_info, diff --git a/core/src/proxy/relayer_stage.rs b/core/src/proxy/relayer_stage.rs index 3c754fb9e4..e640bd7554 100644 --- a/core/src/proxy/relayer_stage.rs +++ b/core/src/proxy/relayer_stage.rs @@ -147,9 +147,11 @@ impl RelayerStage { 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()) { + let local_relayer_config = relayer_config.lock().unwrap().clone(); + if !Self::is_valid_relayer_config(&local_relayer_config) { sleep(CONNECTION_BACKOFF).await; } else if let Err(e) = Self::connect_auth_and_stream( + &local_relayer_config, &relayer_config, &cluster_info, &heartbeat_tx, @@ -181,7 +183,8 @@ impl RelayerStage { } async fn connect_auth_and_stream( - relayer_config: &Arc>, + local_relayer_config: &RelayerConfig, + global_relayer_config: &Arc>, cluster_info: &Arc, heartbeat_tx: &Sender, packet_tx: &Sender, @@ -191,17 +194,16 @@ impl RelayerStage { ) -> 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()) + let mut backend_endpoint = Endpoint::from_shared(local_relayer_config.relayer_url.clone()) .map_err(|_| { ProxyError::RelayerConnectionError(format!( "invalid relayer url value: {}", - local_config.relayer_url + local_relayer_config.relayer_url )) })? .tcp_keepalive(Some(Duration::from_secs(60))); - if local_config.relayer_url.starts_with("https") { + if local_relayer_config.relayer_url.starts_with("https") { backend_endpoint = backend_endpoint .tls_config(tonic::transport::ClientTlsConfig::new()) .map_err(|_| { @@ -211,7 +213,7 @@ impl RelayerStage { })?; } - debug!("connecting to auth: {}", local_config.relayer_url); + debug!("connecting to auth: {}", local_relayer_config.relayer_url); let auth_channel = timeout(*connection_timeout, backend_endpoint.connect()) .await .map_err(|_| ProxyError::AuthenticationConnectionTimeout)? @@ -229,11 +231,14 @@ impl RelayerStage { datapoint_info!( "relayer_stage-tokens_generated", - ("url", local_config.relayer_url, String), + ("url", local_relayer_config.relayer_url, String), ("count", 1, i64), ); - debug!("connecting to relayer: {}", local_config.relayer_url); + debug!( + "connecting to relayer: {}", + local_relayer_config.relayer_url + ); let relayer_channel = timeout(*connection_timeout, backend_endpoint.connect()) .await .map_err(|_| ProxyError::RelayerConnectionTimeout)? @@ -250,8 +255,8 @@ impl RelayerStage { heartbeat_tx, packet_tx, banking_packet_sender, - &local_config, - relayer_config, + local_relayer_config, + global_relayer_config, exit, auth_client, access_token, From ef61a583946661b1cdc0fde4e870b1fad547e93a Mon Sep 17 00:00:00 2001 From: Eric Semeniuc <3838856+esemeniuc@users.noreply.github.com> Date: Sat, 2 Dec 2023 17:16:55 +0100 Subject: [PATCH 8/9] [JIT-1710] - Optimize Bundle Consumer Checks (#490) --- core/src/bundle_stage/bundle_consumer.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/src/bundle_stage/bundle_consumer.rs b/core/src/bundle_stage/bundle_consumer.rs index bbabc77d9c..c0ec120f71 100644 --- a/core/src/bundle_stage/bundle_consumer.rs +++ b/core/src/bundle_stage/bundle_consumer.rs @@ -290,10 +290,11 @@ impl BundleConsumer { 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 + if bank_start.working_bank.slot() != *last_tip_updated_slot + && Self::bundle_touches_tip_pdas( + locked_bundle.sanitized_bundle(), + &tip_manager.get_tip_accounts(), + ) { let start = Instant::now(); let result = Self::handle_tip_programs( From 07b0fb80a41ff0b21844ccd2210131d5a01e05c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Dec 2023 10:03:36 +0000 Subject: [PATCH 9/9] build(deps): bump openssl from 0.10.57 to 0.10.61 Bumps [openssl](https://github.com/sfackler/rust-openssl) from 0.10.57 to 0.10.61. - [Release notes](https://github.com/sfackler/rust-openssl/releases) - [Commits](https://github.com/sfackler/rust-openssl/compare/openssl-v0.10.57...openssl-v0.10.61) --- updated-dependencies: - dependency-name: openssl dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f50d34e96f..8770697050 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3837,9 +3837,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "openssl" -version = "0.10.57" +version = "0.10.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c" +checksum = "6b8419dc8cc6d866deb801274bba2e6f8f6108c1bb7fcc10ee5ab864931dbb45" dependencies = [ "bitflags 2.3.3", "cfg-if 1.0.0", @@ -3869,18 +3869,18 @@ checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" [[package]] name = "openssl-src" -version = "111.25.0+1.1.1t" +version = "300.1.6+3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3173cd3626c43e3854b1b727422a276e568d9ec5fe8cec197822cf52cfb743d6" +checksum = "439fac53e092cd7442a3660c85dde4643ab3b5bd39040912388dcdabf6b88085" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.92" +version = "0.9.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7e971c2c2bba161b2d2fdf37080177eff520b3bc044787c7f1f5f9e78d869b" +checksum = "c3eaad34cdd97d81de97964fc7f29e2d104f483840d906ef56daa1912338460b" dependencies = [ "cc", "libc",