From 3ab34fe9208e791d4ab72707063d35583549678c Mon Sep 17 00:00:00 2001 From: Lucas B Date: Thu, 25 Aug 2022 17:18:46 -0500 Subject: [PATCH] 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) [JIT-1661] Faster Autosnapshot (#436) Reverts simulate_transaction result calls to upstream (#446) Don't unlock accounts in TransactionBatches used during simulation (#449) first pass at wiring up jito-plugin (#428) [JIT-1713] Fix bundle's blockspace preallocation (#489) [JIT-1708] Fix TOC TOU condition for relayer and block engine config (#491) [JIT-1710] - Optimize Bundle Consumer Checks (#490) Add Blockhash Metrics to Bundle Committer (#500) add priority fee ix to mev-claim (#520) Update Autosnapshot (#548) Run MEV claims + reclaiming rent-exempt amounts in parallel. (#582) Update CI (#584) - Add recursive submodule checkouts. - Re-add solana-secondary step Add more release fixes (#585) Fix more release urls (#588) [JIT-1812] Fix blocking mutexs (#495) [JIT-1711] Compare the unprocessed transaction storage BundleStorage against a constant instead of VecDeque::capacity() (#587) Automatically rebase Jito-Solana on a periodic basis. Send message on slack during any failures or success. Fix periodic rebase #594 Fixes the following bugs in the periodic rebase: Sends multiple messages on failure instead of one Cancels entire job if one branch fails Ignore buildkite curl errors for rebasing and try to keep curling until job times out (#597) Sleep longer waiting for buildkite to start (#598) correctly initialize account overrides (#595) Fix: Ensure set contact info to UDP port instead of QUIC (#603) Add fast replay branch to daily rebase (#607) take a snapshot of all bundle accounts before sim (#13) (#615) update jito-programs submodule --- .dockerignore | 9 + .github/workflows/cargo.yml | 2 + .github/workflows/changelog-label.yml | 1 + .github/workflows/client-targets.yml | 4 + .github/workflows/crate-check.yml | 1 + .github/workflows/docs.yml | 3 + .../workflows/downstream-project-anchor.yml | 4 +- .github/workflows/downstream-project-spl.yml | 42 +- .github/workflows/rebase.yaml | 181 ++ .github/workflows/release-artifacts.yml | 1 + .gitignore | 5 + .gitmodules | 9 + Cargo.lock | 778 ++++++-- Cargo.toml | 18 +- README.md | 33 +- RELEASE.md | 90 +- accounts-db/src/accounts.rs | 86 +- 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 | 38 + bundle/src/bundle_execution.rs | 1216 +++++++++++++ bundle/src/lib.rs | 60 + ci/buildkite-pipeline-in-disk.sh | 4 +- ci/buildkite-pipeline.sh | 4 +- ci/buildkite-secondary.yml | 62 +- ci/buildkite-solana-private.sh | 4 +- ci/channel-info.sh | 2 +- ci/check-crates.sh | 3 + ci/publish-installer.sh | 11 +- ci/publish-tarball.sh | 4 +- ci/test-coverage.sh | 2 +- ci/upload-github-release-asset.sh | 2 +- core/Cargo.toml | 13 + 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 | 8 +- core/src/banking_stage.rs | 85 +- core/src/banking_stage/committer.rs | 17 +- core/src/banking_stage/consume_worker.rs | 23 +- core/src/banking_stage/consumer.rs | 195 +- .../banking_stage/latest_unprocessed_votes.rs | 2 +- core/src/banking_stage/qos_service.rs | 48 +- .../unprocessed_transaction_storage.rs | 451 ++++- core/src/banking_trace.rs | 1 + core/src/bundle_stage.rs | 434 +++++ .../src/bundle_stage/bundle_account_locker.rs | 326 ++++ core/src/bundle_stage/bundle_consumer.rs | 1584 +++++++++++++++++ .../bundle_packet_deserializer.rs | 271 +++ .../bundle_stage/bundle_packet_receiver.rs | 825 +++++++++ .../bundle_reserved_space_manager.rs | 237 +++ .../bundle_stage_leader_metrics.rs | 506 ++++++ core/src/bundle_stage/committer.rs | 227 +++ core/src/bundle_stage/result.rs | 41 + core/src/consensus_cache_updater.rs | 52 + core/src/immutable_deserialized_bundle.rs | 488 +++++ core/src/lib.rs | 44 + core/src/packet_bundle.rs | 7 + core/src/proxy/auth.rs | 185 ++ core/src/proxy/block_engine_stage.rs | 571 ++++++ core/src/proxy/fetch_stage_manager.rs | 170 ++ core/src/proxy/mod.rs | 100 ++ core/src/proxy/relayer_stage.rs | 515 ++++++ core/src/tip_manager.rs | 588 ++++++ core/src/tpu.rs | 115 +- core/src/tpu_entry_notifier.rs | 66 +- core/src/tvu.rs | 3 + core/src/validator.rs | 103 +- core/tests/epoch_accounts_hash.rs | 2 + core/tests/snapshots.rs | 2 + cost-model/src/cost_tracker.rs | 8 + deploy_programs | 17 + dev/Dockerfile | 48 + docs/src/cli/install.md | 37 +- docs/src/clusters/benchmark.md | 2 +- docs/src/implemented-proposals/installer.md | 35 +- entry/src/entry.rs | 2 +- entry/src/poh.rs | 29 +- f | 30 + fetch-spl.sh | 41 +- gossip/src/cluster_info.rs | 4 + install/agave-install-init.sh | 4 +- install/src/command.rs | 8 +- 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/bigtable.rs | 1 + ledger-tool/src/ledger_utils.rs | 18 +- ledger-tool/src/main.rs | 8 +- 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 +- local-cluster/src/local_cluster.rs | 3 + .../src/local_cluster_snapshot_utils.rs | 6 +- local-cluster/src/validator_configs.rs | 5 + local-cluster/tests/local_cluster.rs | 15 +- 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 | 136 +- poh/src/poh_service.rs | 34 +- program-test/src/programs.rs | 18 + .../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 | 613 +++++-- programs/sbf/tests/programs.rs | 4 +- rpc-client-api/Cargo.toml | 2 + rpc-client-api/src/bundles.rs | 166 ++ rpc-client-api/src/lib.rs | 1 + rpc-client-api/src/request.rs | 2 + rpc-client/src/nonblocking/rpc_client.rs | 56 +- rpc-client/src/rpc_client.rs | 14 + 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-plugin/Cargo.toml | 22 + runtime-plugin/src/lib.rs | 4 + runtime-plugin/src/runtime_plugin.rs | 41 + .../src/runtime_plugin_admin_rpc_service.rs | 326 ++++ runtime-plugin/src/runtime_plugin_manager.rs | 275 +++ runtime-plugin/src/runtime_plugin_service.rs | 123 ++ runtime/src/bank.rs | 97 +- runtime/src/snapshot_bank_utils.rs | 16 +- runtime/src/snapshot_utils.rs | 24 +- runtime/src/stake_account.rs | 4 +- runtime/src/stakes.rs | 12 +- runtime/src/transaction_batch.rs | 24 +- rustfmt.toml | 5 + s | 15 + scripts/agave-install-deploy.sh | 4 +- scripts/increment-cargo-version.sh | 2 + scripts/run.sh | 4 + sdk/Cargo.toml | 5 +- 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 + svm/src/account_loader.rs | 5 + svm/src/account_overrides.rs | 6 +- svm/src/transaction_processor.rs | 27 +- test-validator/src/lib.rs | 1 + timings/src/lib.rs | 23 +- tip-distributor/Cargo.toml | 61 + tip-distributor/README.md | 52 + tip-distributor/src/bin/claim-mev-tips.rs | 190 ++ .../src/bin/merkle-root-generator.rs | 34 + .../src/bin/merkle-root-uploader.rs | 54 + .../src/bin/stake-meta-generator.rs | 67 + tip-distributor/src/claim_mev_workflow.rs | 398 +++++ tip-distributor/src/lib.rs | 1062 +++++++++++ .../src/merkle_root_generator_workflow.rs | 54 + .../src/merkle_root_upload_workflow.rs | 138 ++ tip-distributor/src/reclaim_rent_workflow.rs | 310 ++++ .../src/stake_meta_generator_workflow.rs | 973 ++++++++++ transaction-status/src/lib.rs | 9 +- turbine/benches/cluster_info.rs | 1 + turbine/benches/retransmit_stage.rs | 3 +- 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 | 2 + validator/src/admin_rpc_service.rs | 110 +- validator/src/bootstrap.rs | 3 +- validator/src/cli.rs | 206 ++- validator/src/main.rs | 268 ++- version/src/lib.rs | 2 +- wen-restart/src/wen_restart.rs | 4 +- 182 files changed, 17783 insertions(+), 854 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/rebase.yaml create mode 100644 .gitmodules 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 100644 runtime-plugin/Cargo.toml create mode 100644 runtime-plugin/src/lib.rs create mode 100644 runtime-plugin/src/runtime_plugin.rs create mode 100644 runtime-plugin/src/runtime_plugin_admin_rpc_service.rs create mode 100644 runtime-plugin/src/runtime_plugin_manager.rs create mode 100644 runtime-plugin/src/runtime_plugin_service.rs create mode 100755 s create mode 100644 sdk/src/bundle/mod.rs create mode 100755 start create mode 100755 start_multi create mode 100644 tip-distributor/Cargo.toml create mode 100644 tip-distributor/README.md create mode 100644 tip-distributor/src/bin/claim-mev-tips.rs create mode 100644 tip-distributor/src/bin/merkle-root-generator.rs create mode 100644 tip-distributor/src/bin/merkle-root-uploader.rs create mode 100644 tip-distributor/src/bin/stake-meta-generator.rs create mode 100644 tip-distributor/src/claim_mev_workflow.rs create mode 100644 tip-distributor/src/lib.rs create mode 100644 tip-distributor/src/merkle_root_generator_workflow.rs create mode 100644 tip-distributor/src/merkle_root_upload_workflow.rs create mode 100644 tip-distributor/src/reclaim_rent_workflow.rs create mode 100644 tip-distributor/src/stake_meta_generator_workflow.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..99262ca894 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.dockerignore +.git/ +.github/ +.gitignore +.idea/ +README.md +Dockerfile +f +target/ diff --git a/.github/workflows/cargo.yml b/.github/workflows/cargo.yml index e64852e428..a9ba8d26a9 100644 --- a/.github/workflows/cargo.yml +++ b/.github/workflows/cargo.yml @@ -35,6 +35,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + submodules: 'recursive' - uses: mozilla-actions/sccache-action@v0.0.4 with: diff --git a/.github/workflows/changelog-label.yml b/.github/workflows/changelog-label.yml index ffd8ec2103..3da79c6e91 100644 --- a/.github/workflows/changelog-label.yml +++ b/.github/workflows/changelog-label.yml @@ -13,6 +13,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: 'recursive' - name: Check if changes to CHANGELOG.md shell: bash env: diff --git a/.github/workflows/client-targets.yml b/.github/workflows/client-targets.yml index 1a33d2ae59..4d32555954 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@v4 + with: + submodules: 'recursive' - run: cargo install cargo-ndk@2.12.2 @@ -56,6 +58,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + submodules: 'recursive' - name: Setup Rust run: | diff --git a/.github/workflows/crate-check.yml b/.github/workflows/crate-check.yml index 6f130853ac..b92b182baa 100644 --- a/.github/workflows/crate-check.yml +++ b/.github/workflows/crate-check.yml @@ -19,6 +19,7 @@ jobs: - uses: actions/checkout@v4 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 c348d69acb..bb402372c6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,6 +22,7 @@ jobs: uses: actions/checkout@v4 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@v4 + with: + submodules: 'recursive' - name: Setup Node uses: actions/setup-node@v4 diff --git a/.github/workflows/downstream-project-anchor.yml b/.github/workflows/downstream-project-anchor.yml index 33ecc632f0..6356428200 100644 --- a/.github/workflows/downstream-project-anchor.yml +++ b/.github/workflows/downstream-project-anchor.yml @@ -41,9 +41,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - version: ["v0.29.0", "v0.30.0"] + version: [ "v0.29.0", "v0.30.0" ] steps: - uses: actions/checkout@v4 + with: + submodules: 'recursive' - shell: bash run: | diff --git a/.github/workflows/downstream-project-spl.yml b/.github/workflows/downstream-project-spl.yml index d2065f178f..421b2a49bd 100644 --- a/.github/workflows/downstream-project-spl.yml +++ b/.github/workflows/downstream-project-spl.yml @@ -40,6 +40,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + submodules: 'recursive' - shell: bash run: | @@ -66,7 +68,7 @@ jobs: arrays: [ { - test_paths: ["token/cli"], + test_paths: [ "token/cli" ], required_programs: [ "token/program", @@ -76,14 +78,14 @@ jobs: ], }, { - test_paths: ["single-pool/cli"], + test_paths: [ "single-pool/cli" ], required_programs: [ "single-pool/program", ], }, { - test_paths: ["token-upgrade/cli"], + test_paths: [ "token-upgrade/cli" ], required_programs: [ "token-upgrade/program", @@ -92,6 +94,8 @@ jobs: ] steps: - uses: actions/checkout@v4 + with: + submodules: 'recursive' - shell: bash run: | @@ -126,26 +130,28 @@ jobs: strategy: matrix: programs: - - [token/program] + - [ token/program ] - [ - instruction-padding/program, - token/program-2022, - token/program-2022-test, - ] + instruction-padding/program, + token/program-2022, + token/program-2022-test, + ] - [ - associated-token-account/program, - associated-token-account/program-test, - ] - - [token-upgrade/program] - - [feature-proposal/program] - - [governance/addin-mock/program, governance/program] - - [memo/program] - - [name-service/program] - - [stake-pool/program] - - [single-pool/program] + associated-token-account/program, + associated-token-account/program-test, + ] + - [ token-upgrade/program ] + - [ feature-proposal/program ] + - [ governance/addin-mock/program, governance/program ] + - [ memo/program ] + - [ name-service/program ] + - [ stake-pool/program ] + - [ single-pool/program ] steps: - uses: actions/checkout@v4 + with: + submodules: 'recursive' - shell: bash run: | diff --git a/.github/workflows/rebase.yaml b/.github/workflows/rebase.yaml new file mode 100644 index 0000000000..5fcc61fff4 --- /dev/null +++ b/.github/workflows/rebase.yaml @@ -0,0 +1,181 @@ +# This workflow runs a periodic rebase process, pulling in updates from an upstream repository +# The workflow for rebasing a jito-solana branch to a solana labs branch locally is typically: +# $ git checkout v1.17 +# $ git pull --rebase # --rebase needed locally +# $ git branch -D lb/v1.17_rebase # deletes branch from last v1.17 rebase +# $ git checkout -b lb/v1.17_rebase +# $ git fetch upstream +# $ git rebase upstream/v1.17 # rebase + fix merge conflicts +# $ git rebase --continue +# $ git push origin +lb/v1.17_rebase # force needed to overwrite remote. wait for CI, fix if any issues +# $ git checkout v1.17 +# $ git reset --hard lb/v1.17_rebase +# $ git push origin +v1.17 +# +# This workflow automates this process, with periodic status updates over slack. +# It will also run CI and wait for it to pass before performing the force push to v1.17. +# In the event there's a failure in the process, it's reported to slack and the job stops. + +name: "Rebase jito-solana from upstream anza-xyz/agave" + +on: + # push: + schedule: + - cron: "30 18 * * 1-5" + +jobs: + rebase: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - branch: master + rebase: upstream/master + - branch: v1.18 + rebase: upstream/v1.18 + - branch: v1.17 + rebase: upstream/v1.17 + # note: this will always be a day behind because we're rebasing from the previous day's rebase + # and NOT upstream + - branch: v1.17-fast-replay + rebase: origin/v1.17 + fail-fast: false + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ matrix.branch }} + submodules: recursive + fetch-depth: 0 + token: ${{ secrets.JITO_SOLANA_RELEASE_TOKEN }} + - name: Add upstream + run: git remote add upstream https://github.com/anza-xyz/agave.git + - name: Fetch upstream + run: git fetch upstream + - name: Fetch origin + run: git fetch origin + - name: Set REBASE_BRANCH + run: echo "REBASE_BRANCH=ci/nightly/${{ matrix.branch }}/$(date +'%Y-%m-%d-%H-%M')" >> $GITHUB_ENV + - name: echo $REBASE_BRANCH + run: echo $REBASE_BRANCH + - name: Create rebase branch + run: git checkout -b $REBASE_BRANCH + - name: Setup email + run: | + git config --global user.email "infra@jito.wtf" + git config --global user.name "Jito Infrastructure" + - name: Rebase + id: rebase + run: git rebase ${{ matrix.rebase }} + - name: Send warning for rebase error + if: failure() && steps.rebase.outcome == 'failure' + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "text": "Nightly rebase on branch ${{ matrix.branch }}\nStatus: Rebase failed to apply cleanly" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + - name: Check if rebase applied + id: check_rebase_applied + run: | + PRE_REBASE_SHA=$(git rev-parse ${{ matrix.branch }}) + POST_REBASE_SHA=$(git rev-parse HEAD) + if [ "$PRE_REBASE_SHA" = "$POST_REBASE_SHA" ]; then + echo "No rebase was applied, exiting..." + exit 1 + else + echo "Rebase applied successfully." + fi + - name: Send warning for rebase error + if: failure() && steps.check_rebase_applied.outcome == 'failure' + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "text": "Nightly rebase on branch ${{ matrix.branch }}\nStatus: Rebase not needed" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + - name: Set REBASE_SHA + run: echo "REBASE_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV + - name: Push changes + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + branch: ${{ env.REBASE_BRANCH }} + - name: Wait for buildkite to start build + run: sleep 300 + - name: Wait for buildkite to finish + id: wait_for_buildkite + timeout-minutes: 300 + run: | + while true; do + response=$(curl -s -f -H "Authorization: Bearer ${{ secrets.BUILDKITE_TOKEN }}" "https://api.buildkite.com/v2/organizations/jito/pipelines/jito-solana/builds?commit=${{ env.REBASE_SHA }}") + if [ $? -ne 0 ]; then + echo "Curl request failed." + exit 1 + fi + + state=$(echo $response | jq --exit-status -r '.[0].state') + echo "Current build state: $state" + + # Check if the state is one of the finished states + case $state in + "passed"|"finished") + echo "Build finished successfully." + exit 0 + ;; + "canceled"|"canceling"|"not_run") + # ignoring "failing"|"failed" because flaky CI, can restart and hope it finishes or times out + echo "Build failed or was cancelled." + exit 2 + ;; + esac + + sleep 30 + done + - name: Send failure update + uses: slackapi/slack-github-action@v1.25.0 + if: failure() && steps.wait_for_buildkite.outcome == 'failure' + with: + payload: | + { + "text": "Nightly rebase on branch ${{ matrix.branch }}\nStatus: CI failed\nBranch: ${{ env.REBASE_BRANCH}}\nBuild: https://buildkite.com/jito/jito-solana/builds?commit=${{ env.REBASE_SHA }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + # check to see if different branch since CI build can take awhile and these steps are not atomic + - name: Fetch the latest remote changes + run: git fetch origin ${{ matrix.branch }} + - name: Check if origin HEAD has changed from the beginning of the workflow + run: | + LOCAL_SHA=$(git rev-parse ${{ matrix.branch }}) + ORIGIN_SHA=$(git rev-parse origin/${{ matrix.branch }}) + if [ "$ORIGIN_SHA" != "$LOCAL_SHA" ]; then + echo "The remote HEAD of ${{ matrix.branch }} does not match the local HEAD of ${{ matrix.branch }} at the beginning of CI." + echo "origin sha: $ORIGIN_SHA" + echo "local sha: $LOCAL_SHA" + exit 1 + else + echo "The remote HEAD matches the local REBASE_SHA at the beginning of CI. Proceeding." + fi + - name: Reset ${{ matrix.branch }} to ${{ env.REBASE_BRANCH }} + run: | + git checkout ${{ matrix.branch }} + git reset --hard ${{ env.REBASE_BRANCH }} + - name: Push rebased %{{ matrix.branch }} + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.JITO_SOLANA_RELEASE_TOKEN }} + branch: ${{ matrix.branch }} + force: true + - name: Send success update + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "text": "Nightly rebase on branch ${{ matrix.branch }}\nStatus: CI success, rebased, and pushed\nBranch: ${{ env.REBASE_BRANCH}}\nBuild: https://buildkite.com/jito/jito-solana/builds?commit=${{ env.REBASE_SHA }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} \ No newline at end of file diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index de32cee71d..0543c964de 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -22,6 +22,7 @@ jobs: with: ref: master fetch-depth: 0 + submodules: 'recursive' - name: Setup Rust shell: bash diff --git a/.gitignore b/.gitignore index 3127645a25..c775b49cc5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ target/ /solana-release.tar.bz2 /solana-metrics/ /solana-metrics.tar.bz2 +**/target/ /test-ledger/ **/*.rs.bk @@ -28,7 +29,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 33082d2170..dcdb6dc869 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,7 +156,7 @@ name = "agave-ledger-tool" version = "2.1.0" dependencies = [ "assert_cmd", - "bs58", + "bs58 0.5.1", "bytecount", "chrono", "clap 2.33.3", @@ -271,6 +271,7 @@ dependencies = [ "solana-rpc-client", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sdk", "solana-send-transaction-service", "solana-storage-bigtable", @@ -286,6 +287,7 @@ dependencies = [ "thiserror", "tikv-jemallocator", "tokio", + "tonic", ] [[package]] @@ -370,6 +372,145 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" +[[package]] +name = "anchor-attribute-access-control" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.86", + "quote 1.0.36", + "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.86", + "quote 1.0.36", + "rustversion", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-constant" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "proc-macro2 1.0.86", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-error" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "proc-macro2 1.0.86", + "quote 1.0.36", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-event" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.86", + "quote 1.0.36", + "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.86", + "quote 1.0.36", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-program" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.86", + "quote 1.0.36", + "syn 1.0.109", +] + +[[package]] +name = "anchor-attribute-state" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.86", + "quote 1.0.36", + "syn 1.0.109", +] + +[[package]] +name = "anchor-derive-accounts" +version = "0.24.2" +dependencies = [ + "anchor-syn", + "anyhow", + "proc-macro2 1.0.86", + "quote 1.0.36", + "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.86", + "proc-macro2-diagnostics", + "quote 1.0.36", + "serde", + "serde_json", + "sha2 0.9.9", + "syn 1.0.109", + "thiserror", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -400,12 +541,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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +dependencies = [ + "windows-sys 0.52.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.86" @@ -421,8 +605,8 @@ dependencies = [ "include_dir", "itertools 0.10.5", "proc-macro-error", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -495,7 +679,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" dependencies = [ - "quote", + "quote 1.0.36", "syn 1.0.109", ] @@ -507,8 +691,8 @@ checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ "num-bigint 0.4.6", "num-traits", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -543,8 +727,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -604,8 +788,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.86", + "quote 1.0.36", "syn 1.0.109", "synstructure", ] @@ -616,8 +800,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -697,8 +881,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -708,8 +892,8 @@ version = "0.1.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -858,8 +1042,8 @@ dependencies = [ "itertools 0.12.1", "lazy_static", "lazycell", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "regex", "rustc-hash", "shlex", @@ -1006,7 +1190,7 @@ dependencies = [ "borsh-derive-internal", "borsh-schema-derive-internal", "proc-macro-crate 0.1.5", - "proc-macro2", + "proc-macro2 1.0.86", "syn 1.0.109", ] @@ -1018,8 +1202,8 @@ checksum = "c3ef8005764f53cd4dca619f5bf64cafd4664dada50ece25e4d81de54c80cc0b" dependencies = [ "once_cell", "proc-macro-crate 3.1.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", "syn_derive", ] @@ -1030,8 +1214,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -1041,8 +1225,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -1067,6 +1251,18 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "771fe0050b883fcc3ea2359b1a96bcfbc090b7116eae7c3c512c7a083fdf23d3" + [[package]] name = "bs58" version = "0.5.1" @@ -1150,8 +1346,8 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ee891b04274a59bd38b412188e24b849617b2e45a0fd8d057deb63e7403761b" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -1389,7 +1585,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", @@ -1405,6 +1601,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c27cdf28c0f604ba3f512b0c9a409f8de8513e4816705deb0498b627e7c3a3fd" dependencies = [ "clap_builder", + "clap_derive 4.3.12", + "once_cell", ] [[package]] @@ -1413,8 +1611,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]] @@ -1423,13 +1623,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.86", + "quote 1.0.36", "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.86", + "quote 1.0.36", + "syn 2.0.71", +] + [[package]] name = "clap_lex" version = "0.2.4" @@ -1445,6 +1657,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + [[package]] name = "combine" version = "3.8.1" @@ -1515,9 +1733,9 @@ version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f6ff08fd20f4f299298a28e2dfa8a8ba1036e6cd2460ac1de7b425d76f2500" dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", + "proc-macro2 1.0.86", + "quote 1.0.36", + "unicode-xid 0.2.2", ] [[package]] @@ -1766,8 +1984,8 @@ checksum = "ab8bfa2e259f8ee1ce5e97824a3c55ec4404a0d772ca7fa96bf19f0752a046eb" dependencies = [ "fnv", "ident_case", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "strsim 0.10.0", "syn 2.0.71", ] @@ -1779,7 +1997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29a358ff9f12ec09c3e61fef9b5a9902623a695a46a917b07f269bff1445611a" dependencies = [ "darling_core", - "quote", + "quote 1.0.36", "syn 2.0.71", ] @@ -1803,6 +2021,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-parser" version = "8.1.0" @@ -1829,8 +2058,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -1840,8 +2069,8 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -1852,8 +2081,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40eebddd2156ce1bb37b20bbe5151340a31828b1f2d22ba4141f3531710e38df" dependencies = [ "convert_case", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "rustc_version 0.3.3", "syn 1.0.109", ] @@ -1941,8 +2170,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -1964,8 +2193,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.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -2029,8 +2258,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86b50932a01e7ec5c06160492ab660fb19b6bb2a7878030dd6cd68d21df9d4d" dependencies = [ "enum-ordinalize", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -2070,8 +2299,8 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03cdc46ec28bd728e67540c528013c6a10eb69a02eb31078a1bda695438cbfb8" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -2083,8 +2312,8 @@ checksum = "0b166c9e378360dd5a6666a9604bb4f54ae0cac39023ffbac425e917a2a04fef" dependencies = [ "num-bigint 0.4.6", "num-traits", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -2340,8 +2569,8 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -2616,6 +2845,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" @@ -2890,8 +3128,8 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b139284b5cf57ecfa712bcc66950bb635b31aff41c188e8a4cfc758eca374a3f" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", ] [[package]] @@ -2993,6 +3231,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.5" +dependencies = [ + "anchor-lang", + "bincode", + "serde", + "serde_derive", + "solana-program", +] + +[[package]] +name = "jito-protos" +version = "2.1.0" +dependencies = [ + "bytes", + "prost", + "prost-types", + "protobuf-src", + "tonic", + "tonic-build", +] + +[[package]] +name = "jito-tip-distribution" +version = "0.1.5" +dependencies = [ + "anchor-lang", + "default-env", + "jito-programs-vote-state", + "solana-program", + "solana-security-txt", +] + +[[package]] +name = "jito-tip-payment" +version = "0.1.5" +dependencies = [ + "anchor-lang", + "default-env", + "solana-security-txt", +] + [[package]] name = "jobserver" version = "0.1.24" @@ -3073,8 +3354,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -3486,8 +3767,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" dependencies = [ "cfg-if 1.0.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -3507,8 +3788,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -3628,8 +3909,8 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -3701,8 +3982,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "681030a937600a36906c185595136d26abfebb4aa9c65701cefcaf8578bb982b" dependencies = [ "proc-macro-crate 3.1.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -3784,8 +4065,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -3991,8 +4272,8 @@ checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" dependencies = [ "pest", "pest_meta", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -4042,8 +4323,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -4159,7 +4440,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b83ec2d0af5c5c556257ff52c9f98934e243b9fd39604bfb2a9b75ec2e97f18" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.86", "syn 1.0.109", ] @@ -4194,8 +4475,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.86", + "quote 1.0.36", "syn 1.0.109", "version_check", ] @@ -4206,11 +4487,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.86", + "quote 1.0.36", "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.86" @@ -4220,6 +4510,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.86", + "quote 1.0.36", + "syn 1.0.109", + "version_check", + "yansi", +] + [[package]] name = "proptest" version = "1.5.0" @@ -4257,7 +4560,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "119533552c9a7ffacc21e099c24a0ac8bb19c2a2a3f363de84cd9b844feab270" dependencies = [ "bytes", - "heck", + "heck 0.4.0", "itertools 0.10.5", "lazy_static", "log", @@ -4280,8 +4583,8 @@ checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" dependencies = [ "anyhow", "itertools 0.10.5", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -4326,8 +4629,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.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -4385,13 +4688,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.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.86", ] [[package]] @@ -4828,7 +5140,7 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring 0.17.3", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] @@ -4862,6 +5174,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 0.16.20", + "untrusted 0.7.1", +] + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -5026,8 +5348,8 @@ version = "1.0.204" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -5080,8 +5402,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f" dependencies = [ "darling", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -5130,8 +5452,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.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -5386,7 +5708,7 @@ dependencies = [ "assert_matches", "base64 0.22.1", "bincode", - "bs58", + "bs58 0.5.1", "bv", "lazy_static", "serde", @@ -5604,6 +5926,7 @@ dependencies = [ "futures 0.3.30", "solana-banks-interface", "solana-client", + "solana-gossip", "solana-runtime", "solana-sdk", "solana-send-transaction-service", @@ -5764,6 +6087,29 @@ dependencies = [ "tempfile", ] +[[package]] +name = "solana-bundle" +version = "2.1.0" +dependencies = [ + "anchor-lang", + "assert_matches", + "itertools 0.12.1", + "log", + "serde", + "solana-accounts-db", + "solana-ledger", + "solana-logger", + "solana-measure", + "solana-poh", + "solana-program-runtime", + "solana-runtime", + "solana-sdk", + "solana-svm", + "solana-timings", + "solana-transaction-status", + "thiserror", +] + [[package]] name = "solana-cargo-build-sbf" version = "2.1.0" @@ -5838,7 +6184,7 @@ version = "2.1.0" dependencies = [ "assert_matches", "bincode", - "bs58", + "bs58 0.5.1", "clap 2.33.3", "console", "const_format", @@ -6056,11 +6402,12 @@ name = "solana-core" version = "2.1.0" dependencies = [ "ahash 0.8.10", + "anchor-lang", "arrayvec", "assert_matches", "base64 0.22.1", "bincode", - "bs58", + "bs58 0.5.1", "bytes", "chrono", "crossbeam-channel", @@ -6070,12 +6417,17 @@ dependencies = [ "futures 0.3.30", "histogram", "itertools 0.12.1", + "jito-protos", + "jito-tip-distribution", + "jito-tip-payment", "lazy_static", "log", "lru", "min-max-heap", "num_enum", "prio-graph", + "prost", + "prost-types", "qualifier_attr", "quinn", "rand 0.8.5", @@ -6091,6 +6443,7 @@ dependencies = [ "serial_test", "solana-accounts-db", "solana-bloom", + "solana-bundle", "solana-client", "solana-compute-budget", "solana-connection-cache", @@ -6109,11 +6462,13 @@ dependencies = [ "solana-perf", "solana-poh", "solana-program-runtime", + "solana-program-test", "solana-quic-client", "solana-rayon-threadlimit", "solana-rpc", "solana-rpc-client-api", "solana-runtime", + "solana-runtime-plugin", "solana-sanitize", "solana-sdk", "solana-send-transaction-service", @@ -6140,6 +6495,8 @@ dependencies = [ "test-case", "thiserror", "tokio", + "tonic", + "tonic-build", "trees", ] @@ -6298,7 +6655,7 @@ name = "solana-frozen-abi" version = "2.1.0" dependencies = [ "bitflags 2.6.0", - "bs58", + "bs58 0.5.1", "bv", "generic-array 0.14.7", "im", @@ -6318,8 +6675,8 @@ dependencies = [ name = "solana-frozen-abi-macro" version = "2.1.0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "rustc_version 0.4.0", "syn 2.0.71", ] @@ -6365,7 +6722,7 @@ name = "solana-geyser-plugin-manager" version = "2.1.0" dependencies = [ "agave-geyser-plugin-interface", - "bs58", + "bs58 0.5.1", "crossbeam-channel", "json5", "jsonrpc-core", @@ -6450,7 +6807,7 @@ dependencies = [ name = "solana-keygen" version = "2.1.0" dependencies = [ - "bs58", + "bs58 0.5.1", "clap 3.2.23", "dirs-next", "num_cpus", @@ -6482,7 +6839,7 @@ dependencies = [ "assert_matches", "bincode", "bitflags 2.6.0", - "bs58", + "bs58 0.5.1", "byteorder", "chrono", "chrono-humanize", @@ -6735,8 +7092,8 @@ dependencies = [ name = "solana-package-metadata-macro" version = "2.1.0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", "toml 0.8.12", ] @@ -6835,7 +7192,7 @@ dependencies = [ "blake3", "borsh 0.10.3", "borsh 1.5.1", - "bs58", + "bs58 0.5.1", "bv", "bytemuck", "bytemuck_derive", @@ -7029,7 +7386,7 @@ version = "2.1.0" dependencies = [ "base64 0.22.1", "bincode", - "bs58", + "bs58 0.5.1", "crossbeam-channel", "dashmap", "itertools 0.12.1", @@ -7049,6 +7406,7 @@ dependencies = [ "soketto", "solana-account-decoder", "solana-accounts-db", + "solana-bundle", "solana-client", "solana-entry", "solana-faucet", @@ -7060,6 +7418,7 @@ dependencies = [ "solana-net-utils", "solana-perf", "solana-poh", + "solana-program-runtime", "solana-rayon-threadlimit", "solana-rpc-client-api", "solana-runtime", @@ -7092,7 +7451,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "bincode", - "bs58", + "bs58 0.5.1", "crossbeam-channel", "futures 0.3.30", "indicatif", @@ -7120,7 +7479,7 @@ version = "2.1.0" dependencies = [ "anyhow", "base64 0.22.1", - "bs58", + "bs58 0.5.1", "const_format", "jsonrpc-core", "reqwest", @@ -7130,8 +7489,10 @@ dependencies = [ "serde_derive", "serde_json", "solana-account-decoder", + "solana-bundle", "solana-inline-spl", "solana-sdk", + "solana-svm", "solana-transaction-status", "solana-version", "thiserror", @@ -7159,13 +7520,14 @@ name = "solana-rpc-test" version = "2.1.0" dependencies = [ "bincode", - "bs58", + "bs58 0.5.1", "crossbeam-channel", "futures-util", "log", "reqwest", "serde", "serde_json", + "serial_test", "solana-account-decoder", "solana-client", "solana-connection-cache", @@ -7271,6 +7633,24 @@ dependencies = [ "zstd", ] +[[package]] +name = "solana-runtime-plugin" +version = "2.1.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-runtime-transaction" version = "2.1.0" @@ -7296,12 +7676,14 @@ dependencies = [ name = "solana-sdk" version = "2.1.0" dependencies = [ + "anchor-lang", "anyhow", "assert_matches", + "base64 0.22.1", "bincode", "bitflags 2.6.0", "borsh 1.5.1", - "bs58", + "bs58 0.5.1", "bytemuck", "bytemuck_derive", "byteorder", @@ -7358,9 +7740,9 @@ dependencies = [ name = "solana-sdk-macro" version = "2.1.0" dependencies = [ - "bs58", - "proc-macro2", - "quote", + "bs58 0.5.1", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -7392,11 +7774,13 @@ dependencies = [ "log", "solana-client", "solana-connection-cache", + "solana-gossip", "solana-logger", "solana-measure", "solana-metrics", "solana-runtime", "solana-sdk", + "solana-streamer", "solana-tpu-client", ] @@ -7499,7 +7883,7 @@ name = "solana-storage-proto" version = "2.1.0" dependencies = [ "bincode", - "bs58", + "bs58 0.5.1", "enum-iterator", "prost", "protobuf-src", @@ -7667,6 +8051,44 @@ dependencies = [ "solana-sdk", ] +[[package]] +name = "solana-tip-distributor" +version = "2.1.0" +dependencies = [ + "anchor-lang", + "clap 4.3.21", + "crossbeam-channel", + "env_logger", + "futures 0.3.30", + "gethostname", + "im", + "itertools 0.12.1", + "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", + "solana-stake-program", + "solana-transaction-status", + "solana-vote", + "thiserror", + "tokio", +] + [[package]] name = "solana-tokens" version = "2.1.0" @@ -7793,7 +8215,7 @@ dependencies = [ "base64 0.22.1", "bincode", "borsh 1.5.1", - "bs58", + "bs58 0.5.1", "lazy_static", "log", "serde", @@ -8010,7 +8432,7 @@ dependencies = [ name = "solana-zk-keygen" version = "2.1.0" dependencies = [ - "bs58", + "bs58 0.5.1", "clap 3.2.23", "dirs-next", "solana-clap-v3-utils", @@ -8172,7 +8594,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9e8418ea6269dcfb01c712f0444d2c75542c04448b480e87de59d2865edc750" dependencies = [ - "quote", + "quote 1.0.36", "spl-discriminator-syn", "syn 2.0.71", ] @@ -8183,8 +8605,8 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c1f05593b7ca9eac7caca309720f2eafb96355e037e6d373b909a80fe7b69b9" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "sha2 0.10.8", "syn 2.0.71", "thiserror", @@ -8242,8 +8664,8 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d375dd76c517836353e093c2dbb490938ff72821ab568b545fd30ab3256b3e" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "sha2 0.10.8", "syn 2.0.71", ] @@ -8401,9 +8823,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.86", + "quote 1.0.36", "rustversion", "syn 1.0.109", ] @@ -8420,14 +8842,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.86", + "quote 1.0.36", "unicode-ident", ] @@ -8437,8 +8870,8 @@ version = "2.0.71" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b146dcf730474b4bcd16c311627b31ede9ab149045db4d6088b3becaea046462" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "unicode-ident", ] @@ -8449,8 +8882,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" dependencies = [ "proc-macro-error", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -8466,10 +8899,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.86", + "quote 1.0.36", "syn 1.0.109", - "unicode-xid", + "unicode-xid 0.2.2", ] [[package]] @@ -8577,8 +9010,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.86", + "quote 1.0.36", "syn 1.0.109", ] @@ -8635,8 +9068,8 @@ checksum = "54c25e2cb8f5fcd7318157634e8838aa6f7e4715c96637f969fabaccd1ef5462" dependencies = [ "cfg-if 1.0.0", "proc-macro-error", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -8647,8 +9080,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37cfd7bbc88a0104e304229fba519bdc45501a30b760fb72240342f1289ad257" dependencies = [ "proc-macro-error", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", "test-case-core", ] @@ -8683,8 +9116,8 @@ version = "1.0.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -8820,8 +9253,8 @@ name = "tokio-macros" version = "2.1.0" source = "git+https://github.com/anza-xyz/solana-tokio.git?rev=7cf47705faacf7bf0e43e4131a5377b3291fce21#7cf47705faacf7bf0e43e4131a5377b3291fce21" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -8991,6 +9424,7 @@ dependencies = [ "percent-encoding 2.3.1", "pin-project", "prost", + "rustls-native-certs", "rustls-pemfile 1.0.0", "tokio", "tokio-rustls", @@ -8999,6 +9433,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "webpki-roots 0.23.1", ] [[package]] @@ -9008,9 +9443,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6fdaae4c2c638bb70fe42803a26fbd6fc6ac8c72f5c59f67ecc2a2dcabf4b07" dependencies = [ "prettyplease", - "proc-macro2", + "proc-macro2 1.0.86", "prost-build", - "quote", + "quote 1.0.36", "syn 1.0.109", ] @@ -9064,8 +9499,8 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -9183,12 +9618,24 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[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" @@ -9276,6 +9723,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cf7d77f457ef8dfa11e4cd5933c5ddb5dc52a94664071951219a97710f0a32b" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.0" @@ -9373,8 +9826,8 @@ dependencies = [ "bumpalo", "log", "once_cell", - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", "wasm-bindgen-shared", ] @@ -9397,7 +9850,7 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" dependencies = [ - "quote", + "quote 1.0.36", "wasm-bindgen-macro-support", ] @@ -9407,8 +9860,8 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", "wasm-bindgen-backend", "wasm-bindgen-shared", @@ -9430,13 +9883,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.7", ] [[package]] @@ -9742,6 +10204,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 = "zerocopy" version = "0.7.31" @@ -9757,8 +10225,8 @@ version = "0.7.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3c129550b3e6de3fd0ba67ba5c81818f9805e58b8d7fee80a3a59d2c9fc601a" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.86", + "quote 1.0.36", "syn 2.0.71", ] @@ -9777,8 +10245,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.86", + "quote 1.0.36", "syn 2.0.71", ] diff --git a/Cargo.toml b/Cargo.toml index 1d7f2d3549..2355403e42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "bench-tps", "bloom", "bucket_map", + "bundle", "cargo-registry", "clap-utils", "clap-v3-utils", @@ -46,6 +47,7 @@ members = [ "gossip", "inline-spl", "install", + "jito-protos", "keygen", "lattice-hash", "ledger", @@ -95,6 +97,7 @@ members = [ "rpc-client-nonce-utils", "rpc-test", "runtime", + "runtime-plugin", "runtime-transaction", "sanitize", "sdk", @@ -119,6 +122,7 @@ members = [ "test-validator", "thin-client", "timings", + "tip-distributor", "tokens", "tps-client", "tpu-client", @@ -141,7 +145,12 @@ members = [ "zk-token-sdk", ] -exclude = ["programs/sbf", "svm/tests/example-programs"] +exclude = [ + "anchor", + "jito-programs", + "programs/sbf", + "svm/tests/example-programs", +] resolver = "2" @@ -158,6 +167,7 @@ Inflector = "0.11.4" aquamarine = "0.3.3" aes-gcm-siv = "0.11.1" ahash = "0.8.10" +anchor-lang = { path = "anchor/lang" } anyhow = "1.0.82" arbitrary = "1.3.2" ark-bn254 = "0.4.0" @@ -247,6 +257,9 @@ jemallocator = { package = "tikv-jemallocator", version = "0.4.1", features = [ "unprefixed_malloc_on_supported_platforms", ] } js-sys = "0.3.69" +jito-protos = { path = "jito-protos", version = "=2.1.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" @@ -254,6 +267,7 @@ jsonrpc-derive = "18.0.0" jsonrpc-http-server = "18.0.0" jsonrpc-ipc-server = "18.0.0" jsonrpc-pubsub = "18.0.0" +jsonrpc-server-utils = "18.0.0" lazy-lru = "0.1.2" lazy_static = "1.5.0" libc = "0.2.155" @@ -340,6 +354,7 @@ solana-bloom = { path = "bloom", version = "=2.1.0" } solana-bn254 = { path = "curves/bn254", version = "=2.1.0" } solana-bpf-loader-program = { path = "programs/bpf_loader", version = "=2.1.0" } solana-bucket-map = { path = "bucket_map", version = "=2.1.0" } +solana-bundle = { path = "bundle", version = "=2.1.0" } agave-cargo-registry = { path = "cargo-registry", version = "=2.1.0" } solana-clap-utils = { path = "clap-utils", version = "=2.1.0" } solana-clap-v3-utils = { path = "clap-v3-utils", version = "=2.1.0" } @@ -399,6 +414,7 @@ solana-rpc-client = { path = "rpc-client", version = "=2.1.0", default-features solana-rpc-client-api = { path = "rpc-client-api", version = "=2.1.0" } solana-rpc-client-nonce-utils = { path = "rpc-client-nonce-utils", version = "=2.1.0" } solana-runtime = { path = "runtime", version = "=2.1.0" } +solana-runtime-plugin = { path = "runtime-plugin", version = "=2.1.0" } solana-runtime-transaction = { path = "runtime-transaction", version = "=2.1.0" } solana-sdk = { path = "sdk", version = "=2.1.0" } solana-sdk-macro = { path = "sdk/macro", version = "=2.1.0" } diff --git a/README.md b/README.md index 0d855e8cc8..4369c4bcb4 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,16 @@

-[![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. + +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. + +--- ## **1. Install rustc, cargo and rustfmt.** @@ -25,21 +29,27 @@ When building the master branch, please make sure you are using the latest stabl $ 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: +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. + +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 ``` @@ -47,8 +57,8 @@ $ sudo dnf install openssl-devel systemd-devel pkg-config zlib-devel llvm clang ## **2. Download the source code.** ```bash -$ git clone https://github.com/anza-xyz/agave.git -$ cd agave +$ git clone https://github.com/jito-foundation/jito-solana.git +$ cd jito-solana ``` ## **3. Build.** @@ -72,7 +82,7 @@ Start your own testnet locally, instructions are in the [online docs](https://do ### 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.solanalabs.com/clusters) + devnet.solana.com. Runs 24/7. Learn more about the [public clusters](https://docs.solanalabs.com/clusters) # Benchmarking @@ -104,7 +114,7 @@ $ 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 +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 @@ -113,3 +123,4 @@ problem is solved by this code?" On the other hand, if a test does fail and you 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! + diff --git a/RELEASE.md b/RELEASE.md index a862a0e410..9439ca3aa3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -17,9 +17,10 @@ ``` ### master branch + All new development occurs on the `master` branch. -Bug fixes that affect a `vX.Y` branch are first made on `master`. This is to +Bug fixes that affect a `vX.Y` branch are first made on `master`. This is to allow a fix some soak time on `master` before it is applied to one or more stabilization branches. @@ -29,7 +30,7 @@ release blocker in a branch causes you to forget to propagate back to `master`!)" Once the bug fix lands on `master` it is cherry-picked into the `vX.Y` branch -and potentially the `vX.Y-1` branch. The exception to this rule is when a bug +and potentially the `vX.Y-1` branch. The exception to this rule is when a bug fix for `vX.Y` doesn't apply to `master` or `vX.Y-1`. Immediately after a new stabilization branch is forged, the `Cargo.toml` minor @@ -38,10 +39,12 @@ Incrementing the major version of the `master` branch is outside the scope of this document. ### v*X.Y* stabilization branches + These are stabilization branches. They are created from the `master` branch approximately every 13 weeks. ### v*X.Y.Z* release tag + The release tags are created as desired by the owner of the given stabilization branch, and cause that *X.Y.Z* release to be shipped to https://crates.io @@ -50,11 +53,13 @@ patch version number (*Z*) of the stabilization branch is incremented by the release engineer. ## Channels + Channels are used by end-users (humans and bots) to consume the branches described in the previous section, so they may automatically update to the most recent version matching their desired stability. There are three release channels that map to branches as follows: + * edge - tracks the `master` branch, least stable. * beta - tracks the largest (and latest) `vX.Y` stabilization branch, more stable. * stable - tracks the second largest `vX.Y` stabilization branch, most stable. @@ -62,18 +67,20 @@ There are three release channels that map to branches as follows: ## Steps to Create a Branch ### Major release branch + 1. If the new branch will be the first branch of a new major release check that -all eligible deprecated symbols have been removed. Our policy is to deprecate -for at least one full minor version before removal. + all eligible deprecated symbols have been removed. Our policy is to deprecate + for at least one full minor version before removal. ### Create the new branch + 1. Check out the latest commit on `master` branch: ``` git fetch --all git checkout upstream/master ``` -1. Determine the new branch name. The name should be "v" + the first 2 version fields - from Cargo.toml. For example, a Cargo.toml with version = "0.9.0" implies +1. Determine the new branch name. The name should be "v" + the first 2 version fields + from Cargo.toml. For example, a Cargo.toml with version = "0.9.0" implies the next branch name is "v0.9". 1. Create the new branch and push this branch to the `agave` repository: ``` @@ -85,7 +92,8 @@ Alternatively use the Github UI. ### Update master branch to the next release minor version -1. After the new branch has been created and pushed, update the Cargo.toml files on **master** to the next semantic version (e.g. 0.9.0 -> 0.10.0) with: +1. After the new branch has been created and pushed, update the Cargo.toml files on **master** to the next semantic + version (e.g. 0.9.0 -> 0.10.0) with: ``` $ scripts/increment-cargo-version.sh minor ``` @@ -96,60 +104,82 @@ Alternatively use the Github UI. git commit -m 'Bump version to X.Y+1.0' git push -u origin version_update ``` -1. Confirm that your freshly cut release branch is shown as `BETA_CHANNEL` and the previous release branch as `STABLE_CHANNEL`: +1. Confirm that your freshly cut release branch is shown as `BETA_CHANNEL` and the previous release branch + as `STABLE_CHANNEL`: ``` ci/channel-info.sh ``` ### Miscellaneous Clean up -1. Pin the spl-token-cli version in the newly promoted stable branch by setting `splTokenCliVersion` in scripts/spl-token-cli-version.sh to the latest release that depends on the stable branch (usually this will be the latest spl-token-cli release). -1. Update [mergify.yml](https://github.com/anza-xyz/agave/blob/master/.mergify.yml) to add backport actions for the new branch and remove actions for the obsolete branch. -1. Adjust the [Github backport labels](https://github.com/anza-xyz/agave/labels) to add the new branch label and remove the label for the obsolete branch. +1. Pin the spl-token-cli version in the newly promoted stable branch by setting `splTokenCliVersion` in + scripts/spl-token-cli-version.sh to the latest release that depends on the stable branch (usually this will be the + latest spl-token-cli release). +1. Update [mergify.yml](https://github.com/jito-foundation/jito-solana/blob/master/.mergify.yml) to add backport actions + for the new branch and remove actions for the obsolete branch. +1. Adjust the [Github backport labels](https://github.com/jito-foundation/jito-solana/labels) to add the new branch + label and remove the label for the obsolete branch. 1. Announce on Discord #development that the release branch exists so people know to use the new backport labels. ## Steps to Create a Release ### Create the Release Tag on GitHub -1. Go to [GitHub Releases](https://github.com/anza-xyz/agave/releases) for tagging a release. -1. Click "Draft new release". The release tag must exactly match the `version` +1. Go to [GitHub Releases](https://github.com/jito-foundation/jito-solana/releases) for tagging a release. +1. Click "Draft new release". The release tag must exactly match the `version` field in `/Cargo.toml` prefixed by `v`. - 1. If the Cargo.toml version field is **0.12.3**, then the release tag must be **v0.12.3** + 1. If the Cargo.toml version field is **0.12.3**, then the release tag must be **v0.12.3** 1. Make sure the Target Branch field matches the branch you want to make a release on. - 1. If you want to release v0.12.0, the target branch must be v0.12 + 1. If you want to release v0.12.0, the target branch must be v0.12 1. Fill the release notes. - 1. If this is the first release on the branch (e.g. v0.13.**0**), paste in [this - template](https://raw.githubusercontent.com/anza-xyz/agave/master/.github/RELEASE_TEMPLATE.md). Engineering Lead can provide summary contents for release notes if needed. - 1. If this is a patch release, review all the commits since the previous release on this branch and add details as needed. + 1. If this is the first release on the branch (e.g. v0.13.**0**), paste in [this + template](https://raw.githubusercontent.com/jito-foundation/jito-solana/master/.github/RELEASE_TEMPLATE.md). + Engineering Lead can provide summary contents for release notes if needed. + 1. If this is a patch release, review all the commits since the previous release on this branch and add details as + needed. 1. Click "Save Draft", then confirm the release notes look good and the tag name and branch are correct. 1. Ensure all desired commits (usually backports) are landed on the branch by now. -1. Ensure the release is marked **"This is a pre-release"**. This flag will need to be removed manually after confirming the Linux binary artifacts appear at a later step. +1. Ensure the release is marked **"This is a pre-release"**. This flag will need to be removed manually after confirming + the Linux binary artifacts appear at a later step. 1. Go back into edit the release and click "Publish release" while being marked as a pre-release. 1. Confirm there is new git tag with intended version number at the intended revision after running `git fetch` locally. - ### Update release branch with the next patch version -[This action](https://github.com/anza-xyz/agave/blob/master/.github/workflows/increment-cargo-version-on-release.yml) ensures that publishing a release will trigger the creation of a PR to update the Cargo.toml files on **release branch** to the next semantic version (e.g. 0.9.0 -> 0.9.1). Ensure that the created PR makes it through CI and gets submitted. +[This action](https://github.com/jito-foundation/jito-solana/blob/master/.github/workflows/increment-cargo-version-on-release.yml) +ensures that publishing a release will trigger the creation of a PR to update the Cargo.toml files on **release branch** +to the next semantic version (e.g. 0.9.0 -> 0.9.1). Ensure that the created PR makes it through CI and gets submitted. -Note: As of 2024-03-26 the above action is failing so version bumps are done manually. The version bump script is incorrectly updating hashbrown and proc-macro2 versions which should be reverted. +Note: As of 2024-03-26 the above action is failing so version bumps are done manually. The version bump script is +incorrectly updating hashbrown and proc-macro2 versions which should be reverted. ### Prepare for the next release -1. Go to [GitHub Releases](https://github.com/anza-xyz/agave/releases) and create a new draft release for `X.Y.Z+1` with empty release notes. This allows people to incrementally add new release notes until it's time for the next release + +1. Go to [GitHub Releases](https://github.com/jito-foundation/jito-solana/releases) and create a new draft release + for `X.Y.Z+1` with empty release notes. This allows people to incrementally add new release notes until it's time for + the next release 1. Also, point the branch field to the same branch and mark the release as **"This is a pre-release"**. ### Verify release automation success -Go to [Agave Releases](https://github.com/anza-xyz/agave/releases) and click on the latest release that you just published. -Verify that all of the build artifacts are present (15 assets), then uncheck **"This is a pre-release"** for the release. + +Go to [Agave Releases](https://github.com/jito-foundation/jito-solana/releases) and click on the latest release that you +just published. +Verify that all of the build artifacts are present (15 assets), then uncheck **"This is a pre-release"** for the +release. Build artifacts can take up to 60 minutes after creating the tag before -appearing. To check for progress: -* The `agave-secondary` Buildkite pipeline handles creating the Linux and macOS release artifacts and updated crates. Look for a job under the tag name of the release: https://buildkite.com/anza-xyz/agave-secondary. -* The Windows release artifacts are produced by GitHub Actions. Look for a job under the tag name of the release: https://github.com/anza-xyz/agave/actions. +appearing. To check for progress: + +* The `agave-secondary` Buildkite pipeline handles creating the Linux and macOS release artifacts and updated crates. + Look for a job under the tag name of the release: https://buildkite.com/jito-foundation/jito-solana-secondary. +* The Windows release artifacts are produced by GitHub Actions. Look for a job under the tag name of the + release: https://github.com/jito-foundation/jito-solana/actions. -[Crates.io agave-validator](https://crates.io/crates/agave-validator) should have an updated agave-validator version. This can take 2-3 hours, and sometimes fails in the `agave-secondary` job. +[Crates.io agave-validator](https://crates.io/crates/agave-validator) should have an updated agave-validator version. +This can take 2-3 hours, and sometimes fails in the `agave-secondary` job. If this happens and the error is non-fatal, click "Retry" on the "publish crate" job ### Update software on testnet.solana.com -See the documentation at https://github.com/solana-labs/cluster-ops/. devnet.solana.com and mainnet-beta.solana.com run stable releases that have been tested on testnet. Do not update devnet or mainnet-beta with a beta release. + +See the documentation at https://github.com/solana-labs/cluster-ops/. devnet.solana.com and mainnet-beta.solana.com run +stable releases that have been tested on testnet. Do not update devnet or mainnet-beta with a beta release. diff --git a/accounts-db/src/accounts.rs b/accounts-db/src/accounts.rs index b79e8da16c..62e90ef61c 100644 --- a/accounts-db/src/accounts.rs +++ b/accounts-db/src/accounts.rs @@ -540,19 +540,32 @@ impl Accounts { } fn lock_account( - &self, account_locks: &mut AccountLocks, writable_keys: Vec<&Pubkey>, readonly_keys: Vec<&Pubkey>, + additional_read_locks: Option<&HashSet>, + additional_write_locks: Option<&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 + .map(|additional_write_locks| additional_write_locks.contains(k)) + .unwrap_or(false) + || additional_read_locks + .map(|additional_read_locks| additional_read_locks.contains(k)) + .unwrap_or(false) + { 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 + .map(|additional_write_locks| additional_write_locks.contains(k)) + .unwrap_or(false) + { debug!("Read-only account in use: {:?}", k); return Err(TransactionError::AccountInUse); } @@ -596,7 +609,7 @@ 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, None, None) } #[must_use] @@ -605,6 +618,8 @@ impl Accounts { txs: impl Iterator, results: impl Iterator>, tx_account_lock_limit: usize, + additional_read_locks: Option<&HashSet>, + additional_write_locks: Option<&HashSet>, ) -> Vec> { let tx_account_locks_results: Vec> = txs .zip(results) @@ -613,22 +628,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: Option<&HashSet>, + additional_write_locks: Option<&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), }) @@ -673,6 +696,55 @@ impl Accounts { pub fn add_root(&self, slot: Slot) -> AccountsAddRootTiming { self.accounts_db.add_root(slot) } + + pub fn lock_accounts_sequential_with_results<'a>( + &self, + txs: impl Iterator, + tx_account_lock_limit: usize, + ) -> Vec> { + let tx_account_locks_results: Vec> = txs + .map(|tx| tx.get_account_locks(tx_account_lock_limit)) + .collect(); + self.lock_accounts_sequential_inner(tx_account_locks_results) + } + + #[must_use] + 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, + None, + None, + ); + if matches!(locked, Err(TransactionError::AccountInUse)) { + account_in_use_set = true; + } + locked + } + }, + Err(err) => Err(err), + }) + .collect() + } } #[cfg(test)] @@ -1308,6 +1380,8 @@ mod tests { txs.iter(), qos_results.into_iter(), MAX_TX_ACCOUNT_LOCKS, + None, + None, ); assert_eq!( 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 7d194d044a..6e544039d3 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, validator::BlockProductionMethod, }, solana_gossip::cluster_info::{ClusterInfo, Node}, @@ -37,6 +38,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}, @@ -58,9 +60,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; @@ -475,6 +483,8 @@ fn main() { bank_forks.clone(), &Arc::new(PrioritizationFeeCache::new(0u64)), false, + 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 6cf5f77f92..63f6d12b87 100644 --- a/banks-server/Cargo.toml +++ b/banks-server/Cargo.toml @@ -15,6 +15,7 @@ crossbeam-channel = { workspace = true } futures = { workspace = true } solana-banks-interface = { workspace = true } solana-client = { workspace = true } +solana-gossip = { workspace = true } solana-runtime = { workspace = true } solana-sdk = { workspace = true } solana-send-transaction-service = { workspace = true } diff --git a/banks-server/src/banks_server.rs b/banks-server/src/banks_server.rs index c08a41c5d9..3836db9337 100644 --- a/banks-server/src/banks_server.rs +++ b/banks-server/src/banks_server.rs @@ -8,6 +8,7 @@ use { TransactionSimulationDetails, TransactionStatus, }, solana_client::connection_cache::ConnectionCache, + solana_gossip::cluster_info::ClusterInfo, solana_runtime::{ bank::{Bank, TransactionSimulationResult}, bank_forks::BankForks, @@ -425,7 +426,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, @@ -450,7 +451,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..7b2860e44b --- /dev/null +++ b/bundle/Cargo.toml @@ -0,0 +1,38 @@ +[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-svm = { workspace = true } +solana-timings = { workspace = true } +solana-transaction-status = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +assert_matches = { workspace = true } +solana-logger = { workspace = true } +solana-runtime = { workspace = true, features = ["dev-context-only-utils"] } + +[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..aa613c39ba --- /dev/null +++ b/bundle/src/bundle_execution.rs @@ -0,0 +1,1216 @@ +use { + itertools::izip, + log::*, + solana_ledger::token_balances::collect_token_balances, + solana_measure::{measure::Measure, measure_us}, + solana_runtime::{ + bank::{Bank, LoadAndExecuteTransactionsOutput, TransactionBalances}, + transaction_batch::TransactionBatch, + }, + solana_sdk::{ + account::AccountSharedData, + bundle::SanitizedBundle, + nonce::state::DurableNonce, + pubkey::Pubkey, + saturating_add_assign, + signature::Signature, + transaction::{SanitizedTransaction, TransactionError, VersionedTransaction}, + }, + solana_svm::{ + account_loader::TransactionLoadResult, + account_overrides::AccountOverrides, + account_saver::collect_accounts_to_store, + transaction_processing_callback::TransactionProcessingCallback, + transaction_processor::{ExecutionRecordingConfig, TransactionProcessingConfig}, + transaction_results::TransactionExecutionResult, + }, + solana_timings::ExecuteTimings, + 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, + transaction_status_sender_enabled: 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: &[Option>], + post_execution_accounts: &[Option>], +) -> 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); + if is_simulation { + bundle + .transactions + .iter() + .map(|tx| tx.message().account_keys()) + .for_each(|account_keys| { + account_overrides.upsert_account_overrides( + bank.get_account_overrides_for_simulation(&account_keys), + ); + + // An unfrozen bank's state is always changing. + // By taking a snapshot of the accounts we're mocking out grabbing their locks. + // **Note** this does not prevent race conditions, just mocks preventing them. + if !bank.is_frozen() { + for pk in account_keys.iter() { + // Save on a disk read. + if account_overrides.get(pk).is_none() { + account_overrides.set_account(pk, bank.get_account_shared_data(pk)); + } + } + } + }); + } + + 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 transaction_status_sender_enabled { + 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, + &mut metrics.execute_timings, + TransactionProcessingConfig { + account_overrides: Some(account_overrides), + check_program_modification_slot: bank.check_program_modification_slot(), + compute_budget: bank.compute_budget(), + log_messages_bytes_limit: *log_messages_bytes_limit, + limit_to_load_programs: true, + recording_config: ExecutionRecordingConfig::new_single_setting( + transaction_status_sender_enabled + ), + transaction_account_lock_limit: Some(bank.get_transaction_account_lock_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: collect_accounts_to_store does not handle any state changes related to + // failed, non-nonce transactions. + let m = Measure::start("cache"); + + let ((last_blockhash, lamports_per_signature), _last_blockhash_us) = + measure_us!(bank.last_blockhash_and_lamports_per_signature()); + let durable_nonce = DurableNonce::from_blockhash(&last_blockhash); + + let accounts = collect_accounts_to_store( + batch.sanitized_transactions(), + &load_and_execute_transactions_output.execution_results, + &mut load_and_execute_transactions_output.loaded_transactions, + &durable_nonce, + lamports_per_signature, + ) + .0; + 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 transaction_status_sender_enabled { + 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, bank_forks::BankForks, 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, RwLock}, + 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, Arc>) { + let genesis_config_info = create_genesis_config(lamports); + let (bank, bank_forks) = + Bank::new_with_bank_forks_for_tests(&genesis_config_info.genesis_config); + (genesis_config_info, bank, bank_forks) + } + + fn make_bundle(txs: &[Transaction], bank: &Bank) -> SanitizedBundle { + let transactions: Vec<_> = txs + .iter() + .map(|tx| { + SanitizedTransaction::try_from_legacy_transaction( + tx.clone(), + bank.get_reserved_account_keys(), + ) + .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, _bank_forks) = + 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, &bank); + let default_accounts = vec![None; bundle.transactions.len()]; + + let execution_result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &MAX_PROCESSING_TIME, + 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.first().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 + .first() + .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.first().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, _bank_forks) = + 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, &bank); + + let default_accounts = vec![None; bundle.transactions.len()]; + let execution_result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &MAX_PROCESSING_TIME, + 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, _bank_forks) = + 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, &bank); + + let default_accounts = vec![None; bundle.transactions.len()]; + let execution_result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &MAX_PROCESSING_TIME, + 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, _bank_forks) = + 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, &bank); + + let default_accounts = vec![None; bundle.transactions.len()]; + let execution_result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &MAX_PROCESSING_TIME, + 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, _bank_forks) = + 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, &bank); + + 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, + &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, _bank_forks) = + 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, &bank); + + 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, + &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, _bank_forks) = + 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 = make_bundle(&transactions, &bank); + + 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, + &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() { + const PRE_EXECUTION_ACCOUNTS: [Option>; 2] = [None, None]; + let (genesis_config_info, bank, _bank_forks) = + 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, &bank); + + let result = load_and_execute_bundle( + &bank, + &bundle, + MAX_PROCESSING_AGE, + &Duration::from_millis(100), + false, + &None, + false, + None, + &PRE_EXECUTION_ACCOUNTS, + &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, + &None, + false, + None, + &vec![None; bundle.transactions.len()], + &PRE_EXECUTION_ACCOUNTS, + ); + 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 81b084ea75..c5877ee6f5 100755 --- a/ci/buildkite-pipeline-in-disk.sh +++ b/ci/buildkite-pipeline-in-disk.sh @@ -289,7 +289,7 @@ if [[ -n $BUILDKITE_TAG ]]; then start_pipeline "Tag pipeline for $BUILDKITE_TAG" annotate --style info --context release-tag \ - "https://github.com/anza-xyz/agave/releases/$BUILDKITE_TAG" + "https://github.com/jito-foundation/jito-solana/releases/$BUILDKITE_TAG" # Jump directly to the secondary build to publish release artifacts quickly trigger_secondary_step @@ -307,7 +307,7 @@ if [[ $BUILDKITE_BRANCH =~ ^pull ]]; then # Add helpful link back to the corresponding Github Pull Request annotate --style info --context pr-backlink \ - "Github Pull Request: https://github.com/anza-xyz/agave/$BUILDKITE_BRANCH" + "Github Pull Request: https://github.com/jito-foundation/jito-solana/$BUILDKITE_BRANCH" pull_or_push_steps exit 0 diff --git a/ci/buildkite-pipeline.sh b/ci/buildkite-pipeline.sh index 4d1559b606..8e2d235cb1 100755 --- a/ci/buildkite-pipeline.sh +++ b/ci/buildkite-pipeline.sh @@ -353,7 +353,7 @@ if [[ -n $BUILDKITE_TAG ]]; then start_pipeline "Tag pipeline for $BUILDKITE_TAG" annotate --style info --context release-tag \ - "https://github.com/anza-xyz/agave/releases/$BUILDKITE_TAG" + "https://github.com/jito-foundation/jito-solana/releases/$BUILDKITE_TAG" # Jump directly to the secondary build to publish release artifacts quickly trigger_secondary_step @@ -371,7 +371,7 @@ if [[ $BUILDKITE_BRANCH =~ ^pull ]]; then # Add helpful link back to the corresponding Github Pull Request annotate --style info --context pr-backlink \ - "Github Pull Request: https://github.com/anza-xyz/agave/$BUILDKITE_BRANCH" + "Github Pull Request: https://github.com/jito-foundation/jito-solana/$BUILDKITE_BRANCH" pull_or_push_steps exit 0 diff --git a/ci/buildkite-secondary.yml b/ci/buildkite-secondary.yml index c43c7ee449..627a73b2c2 100644 --- a/ci/buildkite-secondary.yml +++ b/ci/buildkite-secondary.yml @@ -18,34 +18,34 @@ steps: agents: queue: "release-build" timeout_in_minutes: 5 - - wait - - name: "publish docker" - command: "sdk/docker-solana/build.sh" - agents: - queue: "release-build" - timeout_in_minutes: 60 - - name: "publish crate" - command: "ci/publish-crate.sh" - agents: - queue: "release-build" - retry: - manual: - permit_on_passed: true - timeout_in_minutes: 240 - branches: "!master" - - name: "publish tarball (aarch64-apple-darwin)" - command: "ci/publish-tarball.sh" - agents: - queue: "release-build-aarch64-apple-darwin" - retry: - manual: - permit_on_passed: true - timeout_in_minutes: 60 - - name: "publish tarball (x86_64-apple-darwin)" - command: "ci/publish-tarball.sh" - agents: - queue: "release-build-x86_64-apple-darwin" - retry: - manual: - permit_on_passed: true - timeout_in_minutes: 60 +# - wait +# - name: "publish docker" +# command: "sdk/docker-solana/build.sh" +# agents: +# queue: "release-build" +# timeout_in_minutes: 60 +# - name: "publish crate" +# command: "ci/publish-crate.sh" +# agents: +# queue: "release-build" +# retry: +# manual: +# permit_on_passed: true +# timeout_in_minutes: 240 +# branches: "!master" +# - name: "publish tarball (aarch64-apple-darwin)" +# command: "ci/publish-tarball.sh" +# agents: +# queue: "release-build-aarch64-apple-darwin" +# retry: +# manual: +# permit_on_passed: true +# timeout_in_minutes: 60 +# - name: "publish tarball (x86_64-apple-darwin)" +# command: "ci/publish-tarball.sh" +# agents: +# queue: "release-build-x86_64-apple-darwin" +# retry: +# manual: +# permit_on_passed: true +# timeout_in_minutes: 60 diff --git a/ci/buildkite-solana-private.sh b/ci/buildkite-solana-private.sh index d514ac0ad2..5bbf5ca034 100755 --- a/ci/buildkite-solana-private.sh +++ b/ci/buildkite-solana-private.sh @@ -269,7 +269,7 @@ pull_or_push_steps() { # start_pipeline "Tag pipeline for $BUILDKITE_TAG" # annotate --style info --context release-tag \ -# "https://github.com/solana-labs/solana/releases/$BUILDKITE_TAG" +# "https://github.com/jito-foundation/jito-solana/releases/$BUILDKITE_TAG" # # Jump directly to the secondary build to publish release artifacts quickly # trigger_secondary_step @@ -287,7 +287,7 @@ if [[ $BUILDKITE_BRANCH =~ ^pull ]]; then # Add helpful link back to the corresponding Github Pull Request annotate --style info --context pr-backlink \ - "Github Pull Request: https://github.com/anza-xyz/agave/$BUILDKITE_BRANCH" + "Github Pull Request: https://github.com/jito-foundation/jito-solana/$BUILDKITE_BRANCH" pull_or_push_steps exit 0 diff --git a/ci/channel-info.sh b/ci/channel-info.sh index 2bb8083656..101583307f 100755 --- a/ci/channel-info.sh +++ b/ci/channel-info.sh @@ -11,7 +11,7 @@ here="$(dirname "$0")" # shellcheck source=ci/semver_bash/semver.sh source "$here"/semver_bash/semver.sh -remote=https://github.com/anza-xyz/agave.git +remote=https://github.com/jito-foundation/jito-solana.git # Fetch all vX.Y.Z tags # diff --git a/ci/check-crates.sh b/ci/check-crates.sh index 4f407824a3..802fe7b63c 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/ci/publish-installer.sh b/ci/publish-installer.sh index f7d98ffd5d..b702e70285 100755 --- a/ci/publish-installer.sh +++ b/ci/publish-installer.sh @@ -26,14 +26,15 @@ fi # upload install script source ci/upload-ci-artifact.sh -cat >release.anza.xyz-install <release.jito.wtf-install <>release.anza.xyz-install +cat install/agave-install-init.sh >>release.jito.wtf-install echo --- GCS: "install" -upload-gcs-artifact "/solana/release.anza.xyz-install" "gs://anza-release/$CHANNEL_OR_TAG/install" +upload-gcs-artifact "/solana/release.jito.wtf-install" "gs://jito-release/$CHANNEL_OR_TAG/install" echo Published to: -ci/format-url.sh https://release.anza.xyz/"$CHANNEL_OR_TAG"/install +ci/format-url.sh https://release.jito.wtf/"$CHANNEL_OR_TAG"/install + diff --git a/ci/publish-tarball.sh b/ci/publish-tarball.sh index 4746e9fb2f..9401f74915 100755 --- a/ci/publish-tarball.sh +++ b/ci/publish-tarball.sh @@ -119,10 +119,10 @@ for file in "${TARBALL_BASENAME}"-$TARGET.tar.bz2 "${TARBALL_BASENAME}"-$TARGET. if [[ -n $BUILDKITE ]]; then echo --- GCS Store: "$file" - upload-gcs-artifact "/solana/$file" gs://anza-release/"$CHANNEL_OR_TAG"/"$file" + upload-gcs-artifact "/solana/$file" gs://jito-release/"$CHANNEL_OR_TAG"/"$file" echo Published to: - $DRYRUN ci/format-url.sh https://release.anza.xyz/"$CHANNEL_OR_TAG"/"$file" + $DRYRUN ci/format-url.sh https://release.jito.wtf/"$CHANNEL_OR_TAG"/"$file" if [[ -n $TAG ]]; then ci/upload-github-release-asset.sh "$file" diff --git a/ci/test-coverage.sh b/ci/test-coverage.sh index f4288285a4..323241b294 100755 --- a/ci/test-coverage.sh +++ b/ci/test-coverage.sh @@ -40,5 +40,5 @@ else codecov -t "${CODECOV_TOKEN}" --dir "$here/../target/cov/${SHORT_CI_COMMIT}" annotate --style success --context codecov.io \ - "CodeCov report: https://codecov.io/github/anza-xyz/agave/commit/$CI_COMMIT" + "CodeCov report: https://codecov.io/github/jito-foundation/jito-solana/commit/$CI_COMMIT" fi diff --git a/ci/upload-github-release-asset.sh b/ci/upload-github-release-asset.sh index 229fb8993e..fb4de1af9e 100755 --- a/ci/upload-github-release-asset.sh +++ b/ci/upload-github-release-asset.sh @@ -26,7 +26,7 @@ fi # Force CI_REPO_SLUG since sometimes # BUILDKITE_TRIGGERED_FROM_BUILD_PIPELINE_SLUG is not set correctly, causing the # artifact upload to fail -CI_REPO_SLUG=anza-xyz/agave +CI_REPO_SLUG=jito-foundation/jito-solana #if [[ -z $CI_REPO_SLUG ]]; then # echo Error: CI_REPO_SLUG not defined # exit 1 diff --git a/core/Cargo.toml b/core/Cargo.toml index f0a2d17871..251a75d0e8 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -15,6 +15,7 @@ codecov = { repository = "solana-labs/solana", branch = "master", service = "git [dependencies] ahash = { workspace = true } +anchor-lang = { workspace = true } arrayvec = { workspace = true } base64 = { workspace = true } bincode = { workspace = true } @@ -27,12 +28,17 @@ 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 } prio-graph = { workspace = true } +prost = { workspace = true } +prost-types = { workspace = true } qualifier_attr = { workspace = true } quinn = { workspace = true } rand = { workspace = true } @@ -45,6 +51,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-compute-budget = { workspace = true } solana-connection-cache = { workspace = true } @@ -65,6 +72,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-sanitize = { workspace = true } solana-sdk = { workspace = true } solana-send-transaction-service = { workspace = true } @@ -86,6 +94,7 @@ sys-info = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } +tonic = { workspace = true } trees = { workspace = true } [dev-dependencies] @@ -93,12 +102,15 @@ assert_matches = { workspace = true } fs_extra = { 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-ledger = { workspace = true, features = ["dev-context-only-utils"] } solana-logger = { workspace = true } solana-poh = { workspace = true, features = ["dev-context-only-utils"] } 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 } @@ -114,6 +126,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 d0efbfafdd..95d72167da 100644 --- a/core/benches/banking_stage.rs +++ b/core/benches/banking_stage.rs @@ -25,6 +25,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 { }, solana_streamer::socket::SocketAddrSpace, std::{ + collections::HashSet, iter::repeat_with, sync::{atomic::Ordering, Arc}, 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; @@ -110,7 +119,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 || { @@ -304,6 +320,8 @@ fn bench_banking(bencher: &mut Bencher, tx_type: TransactionType) { bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), false, + HashSet::default(), + BundleAccountLocker::default(), ); let chunk_len = verified.len() / CHUNKS; diff --git a/core/benches/consumer.rs b/core/benches/consumer.rs index d736b93ef9..d0486219b0 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, bank_forks::BankForks}, @@ -28,9 +28,12 @@ use { 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, @@ -84,7 +87,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 { @@ -94,7 +104,7 @@ struct BenchFrame { exit: Arc, poh_recorder: Arc>, poh_service: PohService, - signal_receiver: Receiver<(Arc, (Entry, u64))>, + signal_receiver: Receiver, } fn setup() -> 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 364509a63b..425a4375c1 100644 --- a/core/src/admin_rpc_post_init.rs +++ b/core/src/admin_rpc_post_init.rs @@ -1,6 +1,7 @@ use { crate::{ cluster_slots_service::cluster_slots::ClusterSlots, + proxy::{block_engine_stage::BlockEngineConfig, relayer_stage::RelayerConfig}, repair::{outstanding_requests::OutstandingRequests, serve_repair::ShredRepairType}, }, solana_gossip::cluster_info::ClusterInfo, @@ -8,8 +9,8 @@ use { solana_sdk::{pubkey::Pubkey, quic::NotifyKeyUpdate}, std::{ collections::HashSet, - net::UdpSocket, - sync::{Arc, RwLock}, + net::{SocketAddr, UdpSocket}, + sync::{Arc, Mutex, RwLock}, }, }; @@ -23,4 +24,7 @@ pub struct AdminRpcRequestMetadataPostInit { pub repair_socket: Arc, pub outstanding_repair_requests: Arc>>, pub cluster_slots: 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 1bdd7defee..607b7ee10d 100644 --- a/core/src/banking_stage.rs +++ b/core/src/banking_stage.rs @@ -25,6 +25,7 @@ use { }, }, banking_trace::BankingPacketReceiver, + bundle_stage::bundle_account_locker::BundleAccountLocker, tracer_packet_stats::TracerPacketStats, validator::BlockProductionMethod, }, @@ -40,9 +41,11 @@ use { bank_forks::BankForks, prioritization_fee_cache::PrioritizationFeeCache, vote_sender_types::ReplayVoteSender, }, - solana_sdk::timing::AtomicInterval, + solana_sdk::{pubkey::Pubkey, timing::AtomicInterval}, std::{ - cmp, env, + cmp, + collections::HashSet, + env, sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, RwLock, @@ -62,12 +65,12 @@ 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 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_filter; @@ -340,6 +343,8 @@ impl BankingStage { bank_forks: Arc>, prioritization_fee_cache: &Arc, enable_forwarding: bool, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, ) -> Self { Self::new_num_threads( block_production_method, @@ -356,6 +361,8 @@ impl BankingStage { bank_forks, prioritization_fee_cache, enable_forwarding, + blacklisted_accounts, + bundle_account_locker, ) } @@ -375,6 +382,8 @@ impl BankingStage { bank_forks: Arc>, prioritization_fee_cache: &Arc, enable_forwarding: bool, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, ) -> Self { match block_production_method { BlockProductionMethod::ThreadLocalMultiIterator => { @@ -391,6 +400,8 @@ impl BankingStage { connection_cache, bank_forks, prioritization_fee_cache, + blacklisted_accounts, + bundle_account_locker, ) } BlockProductionMethod::CentralScheduler => Self::new_central_scheduler( @@ -407,6 +418,8 @@ impl BankingStage { bank_forks, prioritization_fee_cache, enable_forwarding, + blacklisted_accounts, + bundle_account_locker, ), } } @@ -425,6 +438,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. @@ -489,6 +504,8 @@ impl BankingStage { log_messages_bytes_limit, forwarder, unprocessed_transaction_storage, + blacklisted_accounts.clone(), + bundle_account_locker.clone(), ) }) .collect(); @@ -510,6 +527,8 @@ impl BankingStage { bank_forks: Arc>, prioritization_fee_cache: &Arc, enable_forwarding: bool, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, ) -> Self { assert!(num_threads >= MIN_TOTAL_THREADS); // Single thread to generate entries from many banks. @@ -554,6 +573,8 @@ impl BankingStage { latest_unprocessed_votes.clone(), vote_source, ), + blacklisted_accounts.clone(), + bundle_account_locker.clone(), )); } @@ -575,6 +596,8 @@ impl BankingStage { poh_recorder.read().unwrap().new_recorder(), QosService::new(id), log_messages_bytes_limit, + blacklisted_accounts.clone(), + bundle_account_locker.clone(), ), finished_work_sender.clone(), poh_recorder.read().unwrap().new_leader_bank_notifier(), @@ -629,6 +652,7 @@ impl BankingStage { Self { bank_thread_hdls } } + #[allow(clippy::too_many_arguments)] fn spawn_thread_local_multi_iterator_thread( id: u32, packet_receiver: BankingPacketReceiver, @@ -639,13 +663,18 @@ impl BankingStage { log_messages_bytes_limit: Option, mut forwarder: Forwarder, unprocessed_transaction_storage: UnprocessedTransactionStorage, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, ) -> JoinHandle<()> { let mut packet_receiver = PacketReceiver::new(id, packet_receiver, bank_forks); + let consumer = Consumer::new( committer, transaction_recorder, QosService::new(id), log_messages_bytes_limit, + blacklisted_accounts.clone(), + bundle_account_locker.clone(), ); Builder::new() @@ -806,7 +835,7 @@ mod tests { crate::banking_trace::{BankingPacketBatch, BankingTracer}, crossbeam_channel::{unbounded, Receiver}, itertools::Itertools, - solana_entry::entry::{self, Entry, EntrySlice}, + solana_entry::entry::{self, EntrySlice}, solana_gossip::cluster_info::Node, solana_ledger::{ blockstore::Blockstore, @@ -820,6 +849,7 @@ mod tests { solana_poh::{ poh_recorder::{ create_test_recorder, PohRecorderError, Record, RecordTransactionsSummary, + WorkingBankEntry, }, poh_service::PohService, }, @@ -891,6 +921,8 @@ mod tests { bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), false, + HashSet::default(), + BundleAccountLocker::default(), ); drop(non_vote_sender); drop(tpu_vote_sender); @@ -947,6 +979,8 @@ mod tests { bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), false, + HashSet::default(), + BundleAccountLocker::default(), ); trace!("sending bank"); drop(non_vote_sender); @@ -959,7 +993,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); @@ -1027,6 +1066,8 @@ mod tests { bank_forks.clone(), // keep a local-copy of bank-forks so worker threads do not lose weak access to bank-forks &Arc::new(PrioritizationFeeCache::new(0u64)), false, + HashSet::default(), + BundleAccountLocker::default(), ); // fund another account so we can send 2 good transactions in a single batch. @@ -1078,9 +1119,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, &entry::thread_pool_for_tests())); @@ -1197,6 +1243,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 @@ -1215,7 +1263,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_forks) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); @@ -1278,15 +1331,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.first().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()); @@ -1389,6 +1446,8 @@ mod tests { bank_forks, &Arc::new(PrioritizationFeeCache::new(0u64)), false, + 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 0ca1304a45..63f6921024 100644 --- a/core/src/banking_stage/committer.rs +++ b/core/src/banking_stage/committer.rs @@ -12,15 +12,13 @@ use { transaction_batch::TransactionBatch, vote_sender_types::ReplayVoteSender, }, - solana_sdk::{hash::Hash, pubkey::Pubkey, saturating_add_assign}, + solana_sdk::{hash::Hash, saturating_add_assign}, solana_svm::{ account_loader::TransactionLoadResult, transaction_results::{TransactionExecutionResult, TransactionResults}, }, - solana_transaction_status::{ - token_balances::TransactionTokenBalancesSet, TransactionTokenBalance, - }, - std::{collections::HashMap, sync::Arc}, + solana_transaction_status::{token_balances::TransactionTokenBalancesSet, PreBalanceInfo}, + std::sync::Arc, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -32,13 +30,6 @@ pub enum CommitTransactionDetails { NotCommitted, } -#[derive(Default)] -pub(super) struct PreBalanceInfo { - pub native: Vec>, - pub token: Vec>, - pub mint_decimals: HashMap, -} - #[derive(Clone)] pub struct Committer { transaction_status_sender: Option, @@ -156,7 +147,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 57a4778d32..ad91be1cd8 100644 --- a/core/src/banking_stage/consume_worker.rs +++ b/core/src/banking_stage/consume_worker.rs @@ -697,11 +697,14 @@ impl ConsumeWorkerTransactionErrorMetrics { 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::{ @@ -718,6 +721,7 @@ mod tests { signature::Keypair, system_transaction, }, std::{ + collections::HashSet, sync::{atomic::AtomicBool, RwLock}, thread::JoinHandle, }, @@ -773,7 +777,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 9a0108a385..3a9e602e87 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_compute_budget::compute_budget_processor::process_compute_budget_instructions, solana_ledger::token_balances::collect_token_balances, @@ -25,6 +26,7 @@ use { clock::{Slot, FORWARD_TRANSACTIONS_TO_LEADER_AT_SLOT_OFFSET, MAX_PROCESSING_AGE}, feature_set, message::SanitizedMessage, + pubkey::Pubkey, saturating_add_assign, timing::timestamp, transaction::{self, AddressLoader, SanitizedTransaction, TransactionError}, @@ -34,8 +36,9 @@ use { transaction_error_metrics::TransactionErrorMetrics, transaction_processor::{ExecutionRecordingConfig, TransactionProcessingConfig}, }, - solana_timings::ExecuteTimings, + solana_transaction_status::PreBalanceInfo, std::{ + collections::HashSet, sync::{atomic::Ordering, Arc}, time::Instant, }, @@ -78,6 +81,8 @@ pub struct Consumer { transaction_recorder: TransactionRecorder, qos_service: QosService, log_messages_bytes_limit: Option, + blacklisted_accounts: HashSet, + bundle_account_locker: BundleAccountLocker, } impl Consumer { @@ -86,12 +91,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, } } @@ -121,6 +130,7 @@ impl Consumer { packets_to_process, ) }, + &self.blacklisted_accounts, ); if reached_end_of_slot { @@ -485,20 +495,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()), - }) + }), + Some(&bundle_account_locks.read_locks()), + Some(&bundle_account_locks.write_locks()) )); + drop(bundle_account_locks); // retryable_txs includes AccountInUse, WouldExceedMaxBlockCostLimit // WouldExceedMaxAccountCostLimit, WouldExceedMaxVoteCostLimit @@ -530,8 +546,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); @@ -568,7 +585,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; @@ -647,7 +664,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 { @@ -770,20 +787,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) - .saturating_add(program_timings.total_errored_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 @@ -877,7 +880,7 @@ mod tests { transaction::{MessageHash, Transaction, VersionedTransaction}, }, solana_svm::account_loader::CheckedTransactionDetails, - solana_timings::ProgramTiming, + solana_timings::{ExecuteTimings, ProgramTiming}, solana_transaction_status::{TransactionStatusMeta, VersionedTransactionWithStatusMeta}, std::{ borrow::Cow, @@ -926,7 +929,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); @@ -1101,7 +1111,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); @@ -1126,7 +1143,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.first().unwrap().0; if !entry.is_tick() { trace!("got entry"); assert_eq!(entry.transactions.len(), transactions.len()); @@ -1244,11 +1267,10 @@ mod tests { let timeout = Duration::from_millis(10); let record = record_receiver.recv_timeout(timeout); if let Ok(record) = record { - let record_response = poh_recorder.write().unwrap().record( - record.slot, - record.mixin, - record.transactions, - ); + let record_response = poh_recorder + .write() + .unwrap() + .record(record.slot, &record.mixins_txs); poh_recorder.write().unwrap().tick(); if record.sender.send(record_response).is_err() { panic!("Error returning mixin hash"); @@ -1285,7 +1307,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); @@ -1310,9 +1339,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() { - if !entry.is_tick() { - assert_eq!(entry.transactions.len(), transactions.len()); + while let Ok(WorkingBankEntry { + bank: _, + entries_ticks, + }) = entry_receiver.recv() + { + if !entries_ticks[0].0.is_tick() { + assert_eq!(entries_ticks[0].0.transactions.len(), transactions.len()); done = true; break; } @@ -1386,7 +1419,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); @@ -1462,7 +1502,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(); @@ -1625,7 +1672,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); @@ -1821,7 +1875,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); @@ -1948,7 +2009,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); @@ -2093,7 +2161,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); @@ -2153,7 +2228,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()); @@ -2238,7 +2320,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()); @@ -2290,7 +2379,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()); @@ -2422,7 +2518,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()); @@ -2491,7 +2594,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 084e4125b8..ed0f1e50ba 100644 --- a/core/src/banking_stage/latest_unprocessed_votes.rs +++ b/core/src/banking_stage/latest_unprocessed_votes.rs @@ -139,7 +139,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 bf8b7df963..ecc1d8c4b6 100644 --- a/core/src/banking_stage/qos_service.rs +++ b/core/src/banking_stage/qos_service.rs @@ -6,7 +6,9 @@ use { super::{committer::CommitTransactionDetails, BatchedTransactionDetails}, solana_cost_model::{ - cost_model::CostModel, cost_tracker::UpdatedCosts, transaction_cost::TransactionCost, + cost_model::CostModel, + cost_tracker::{CostTracker, UpdatedCosts}, + transaction_cost::TransactionCost, }, solana_measure::measure::Measure, solana_runtime::bank::Bank, @@ -42,6 +44,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) { @@ -50,7 +53,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(), @@ -96,10 +100,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)| { @@ -107,13 +111,13 @@ impl QosService { Ok(cost) => { match cost_tracker.try_add(&cost) { Ok(UpdatedCosts{updated_block_cost, updated_costliest_account_cost}) => { - debug!("slot {:?}, transaction {:?}, cost {:?}, fit into current block, current block cost {}, updated costliest account cost {}", bank.slot(), tx, cost, updated_block_cost, updated_costliest_account_cost); + debug!("slot {:?}, transaction {:?}, cost {:?}, fit into current block, current block cost {}, updated costliest account cost {}", slot, tx, cost, updated_block_cost, updated_costliest_account_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)) } } @@ -685,8 +689,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 @@ -739,8 +747,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() @@ -804,8 +816,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() @@ -859,8 +875,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 e790993dfc..ae487952c8 100644 --- a/core/src/banking_stage/unprocessed_transaction_storage.rs +++ b/core/src/banking_stage/unprocessed_transaction_storage.rs @@ -15,18 +15,29 @@ use { }, BankingStageStats, FilterForwardingResults, ForwardOption, }, + crate::{ + bundle_stage::bundle_stage_leader_metrics::BundleStageLeaderMetrics, + immutable_deserialized_bundle::ImmutableDeserializedBundle, + }, itertools::Itertools, min_max_heap::MinMaxHeap, + 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, }, solana_svm::transaction_error_metrics::TransactionErrorMetrics, std::{ - collections::HashMap, + collections::{HashMap, HashSet, VecDeque}, sync::{atomic::Ordering, Arc}, + time::Instant, }, }; @@ -41,6 +52,7 @@ const MAX_NUM_VOTES_RECEIVE: usize = 10_000; pub enum UnprocessedTransactionStorage { VoteStorage(VoteStorage), LocalTransactionStorage(ThreadLocalUnprocessedPackets), + BundleStorage(BundleStorage), } #[derive(Debug)] @@ -59,10 +71,11 @@ pub struct VoteStorage { pub enum ThreadType { Voting(VoteSource), Transactions, + Bundles, } #[derive(Debug)] -pub(crate) enum InsertPacketBatchSummary { +pub enum InsertPacketBatchSummary { VoteBatchInsertionMetrics(VoteBatchInsertionMetrics), PacketBatchInsertionMetrics(PacketBatchInsertionMetrics), } @@ -146,6 +159,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 { @@ -176,6 +190,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 @@ -271,10 +289,25 @@ impl UnprocessedTransactionStorage { }) } + pub fn new_bundle_storage() -> Self { + Self::BundleStorage(BundleStorage { + last_update_slot: Slot::default(), + unprocessed_bundle_storage: VecDeque::with_capacity( + BundleStorage::BUNDLE_STORAGE_CAPACITY, + ), + cost_model_buffered_bundle_storage: VecDeque::with_capacity( + BundleStorage::BUNDLE_STORAGE_CAPACITY, + ), + }) + } + 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() + } } } @@ -282,6 +315,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() + } } } @@ -291,6 +328,7 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => { transaction_storage.get_min_compute_unit_price() } + UnprocessedTransactionStorage::BundleStorage(_) => None, } } @@ -300,6 +338,7 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => { transaction_storage.get_max_compute_unit_price() } + UnprocessedTransactionStorage::BundleStorage(_) => None, } } @@ -310,6 +349,9 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => { transaction_storage.max_receive_size() } + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.max_receive_size() + } } } @@ -336,6 +378,9 @@ impl UnprocessedTransactionStorage { Self::LocalTransactionStorage(transaction_storage) => { transaction_storage.forward_option() } + UnprocessedTransactionStorage::BundleStorage(bundle_storage) => { + bundle_storage.forward_option() + } } } @@ -343,6 +388,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, } } @@ -357,6 +412,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" + ) + } } } @@ -376,6 +436,9 @@ impl UnprocessedTransactionStorage { bank, forward_packet_batches_by_accounts, ), + UnprocessedTransactionStorage::BundleStorage(_) => { + panic!("bundles are not forwarded between leaders") + } } } @@ -389,6 +452,7 @@ impl UnprocessedTransactionStorage { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -403,15 +467,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 { @@ -480,6 +591,7 @@ impl VoteStorage { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, mut processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -493,7 +605,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 @@ -576,6 +694,7 @@ impl ThreadLocalUnprocessedPackets { ThreadType::Transactions => ForwardOption::ForwardTransaction, ThreadType::Voting(VoteSource::Tpu) => ForwardOption::ForwardTpuVote, ThreadType::Voting(VoteSource::Gossip) => ForwardOption::NotForward, + ThreadType::Bundles => ForwardOption::NotForward, } } @@ -901,6 +1020,7 @@ impl ThreadLocalUnprocessedPackets { banking_stage_stats: &BankingStageStats, slot_metrics_tracker: &mut LeaderSlotMetricsTracker, mut processing_function: F, + blacklisted_accounts: &HashSet, ) -> bool where F: FnMut( @@ -915,7 +1035,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, @@ -992,6 +1118,319 @@ 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 { + pub const BUNDLE_STORAGE_CAPACITY: usize = 1000; + 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 { + // deque should be initialized with size [Self::BUNDLE_STORAGE_CAPACITY] + let deque_free_space = Self::BUNDLE_STORAGE_CAPACITY + .checked_sub(deque.len()) + .unwrap(); + let bundles_to_insert_count = std::cmp::min(deque_free_space, deserialized_bundles.len()); + let num_bundles_dropped = deserialized_bundles + .len() + .checked_sub(bundles_to_insert_count) + .unwrap(); + let num_packets_inserted = deserialized_bundles + .iter() + .take(bundles_to_insert_count) + .map(|b| b.len()) + .sum::(); + let num_packets_dropped = deserialized_bundles + .iter() + .skip(bundles_to_insert_count) + .map(|b| b.len()) + .sum::(); + + let to_insert = deserialized_bundles + .into_iter() + .take(bundles_to_insert_count); + if push_back { + deque.extend(to_insert) + } else { + to_insert.for_each(|b| deque.push_front(b)); + } + + InsertPacketBundlesSummary { + insert_packets_summary: PacketBatchInsertionMetrics { + num_dropped_packets: num_packets_dropped, + num_dropped_tracer_packets: 0, + } + .into(), + num_bundles_inserted: bundles_to_insert_count, + 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 c2b3c38695..96358b245f 100644 --- a/core/src/banking_trace.rs +++ b/core/src/banking_trace.rs @@ -321,6 +321,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..e935529df5 --- /dev/null +++ b/core/src/bundle_stage.rs @@ -0,0 +1,434 @@ +//! 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, + vote_sender_types::ReplayVoteSender, + }, + solana_sdk::timing::AtomicInterval, + std::{ + 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(); + + 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(), Some(unprocessed_bundle_storage)); + 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..82070e3d3a --- /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_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, + }, + solana_svm::transaction_error_metrics::TransactionErrorMetrics, + std::collections::HashSet, + }; + + #[test] + fn test_simple_lock_bundles() { + let GenesisConfigInfo { + genesis_config, + mint_keypair, + .. + } = create_genesis_config(2); + let (bank, _) = 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..b8d71edaf6 --- /dev/null +++ b/core/src/bundle_stage/bundle_consumer.rs @@ -0,0 +1,1584 @@ +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_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}, + pubkey::Pubkey, + transaction::{self}, + }, + solana_svm::transaction_error_metrics::TransactionErrorMetrics, + 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 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( + 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_or_update_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, + min_prioritization_fees: 0, // TODO (LB) + max_prioritization_fees: 0, // TODO (LB) + }); + + match result.result { + Ok(_) => { + QosService::remove_or_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_or_update_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, + 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 (last_blockhash, lamports_per_signature) = bank_start + .working_bank + .last_blockhash_and_lamports_per_signature(); + + 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, + last_blockhash, + lamports_per_signature, + 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_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, + bank_forks::BankForks, + genesis_utils::{create_genesis_config_with_leader_ex, GenesisConfigInfo}, + installed_scheduler_pool::BankWithScheduler, + prioritization_fee_cache::PrioritizationFeeCache, + }, + solana_sdk::{ + bundle::{derive_bundle_id, SanitizedBundle}, + clock::MAX_PROCESSING_AGE, + 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, + solana_svm::{ + account_loader::TransactionCheckResult, + transaction_error_metrics::TransactionErrorMetrics, + }, + std::{ + collections::HashSet, + 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, + bank_forks: Arc>, + } + + 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(), + blockstore, + &leader_schedule_cache, + &poh_config, + exit.clone(), + ); + poh_recorder.set_bank( + BankWithScheduler::new_without_scheduler(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.clone(), // 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 (bank, bank_forks) = Bank::new_with_bank_forks_for_tests(&genesis_config); + + let bank = Arc::new(Bank::new_from_parent(bank, &Pubkey::default(), 1)); + + 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, + bank_forks, + 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, + bank_forks: _bank_forks, + } = create_test_fixture(1_000_000); + let recorder = poh_recorder.read().unwrap().new_recorder(); + + let status = poh_recorder + .read() + .unwrap() + .reached_leader_slot(&leader_keypair.pubkey()); + 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(); + 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, + ); + + let expected_result: Vec = + vec![Err(TransactionError::AlreadyProcessed); sanitized_bundle.transactions.len()]; + + assert_eq!(check_results, expected_result); + + 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, + bank_forks: _bank_forks, + } = 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(); + 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, &keypair) + .to_versioned_transaction() + ); + assert_eq!( + transactions[1], + tip_manager + .initialize_tip_distribution_config_tx(&bank, &keypair) + .to_versioned_transaction() + ); + assert_eq!( + transactions[2], + tip_manager + .initialize_tip_distribution_account_tx(&bank, &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, + bank_forks: _bank_forks, + } = 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, &keypair) + .to_versioned_transaction() + ); + assert_eq!( + transactions[1], + tip_manager + .initialize_tip_distribution_config_tx(&bank, &keypair) + .to_versioned_transaction() + ); + assert_eq!( + transactions[2], + tip_manager + .initialize_tip_distribution_account_tx(&bank, &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..563902722e --- /dev/null +++ b/core/src/bundle_stage/bundle_packet_deserializer.rs @@ -0,0 +1,271 @@ +//! 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, +} + +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; + + 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(_) => { + saturating_add_assign!(num_dropped_bundles, 1); + } + } + } + + ReceiveBundleResults { + deserialized_bundles, + num_dropped_bundles, + } + } + + /// 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_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) = 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); + + // 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); + + 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_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) = 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); + } +} 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..c99d9aa020 --- /dev/null +++ b/core/src/bundle_stage/bundle_packet_receiver.rs @@ -0,0 +1,825 @@ +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, + }: 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_stats.increment_num_bundles_dropped(num_dropped_bundles as u64); + // TODO (LB): fix this + // 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::*, + crate::banking_stage::unprocessed_transaction_storage::BundleStorage, + 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, + }; + + /// 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 5 more than capacity + let bundles = make_random_bundles( + &mint_keypair, + BundleStorage::BUNDLE_STORAGE_CAPACITY + 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(); + // 1005 bundles were sent, but the capacity is 1000 + assert_eq!(bundle_storage.unprocessed_bundles_len(), 1000); + assert_eq!(bundle_storage.unprocessed_packets_len(), 2000); + 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 1000 bundles are the ones to process + assert_bundles_same( + &bundles[0..BundleStorage::BUNDLE_STORAGE_CAPACITY], + 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + 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) = Bank::new_no_wallclock_throttle_for_tests(&genesis_config); + + let mut unprocessed_storage = UnprocessedTransactionStorage::new_bundle_storage(); + + let (sender, receiver) = unbounded(); + let mut bundle_receiver = BundleReceiver::new(0, receiver, bank_forks.clone(), Some(5)); + + // send 500 bundles across the queue + let bundles0 = make_random_bundles( + &mint_keypair, + BundleStorage::BUNDLE_STORAGE_CAPACITY / 2, + 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(), 500); + + let bundles1 = make_random_bundles( + &mint_keypair, + BundleStorage::BUNDLE_STORAGE_CAPACITY / 2, + 2, + genesis_config.hash(), + ); + sender.send(bundles1.clone()).unwrap(); + // should get 500 more bundles, cost model buffered length should be 1000 + 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(), 1000); // full now + + // send 10 bundles to go over capacity + let bundles2 = make_random_bundles(&mint_keypair, 10, 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(), 1000); + + // 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..24cca76aa1 --- /dev/null +++ b/core/src/bundle_stage/bundle_reserved_space_manager.rs @@ -0,0 +1,237 @@ +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() % bank.ticks_per_slot() < 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::pubkey::Pubkey, 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_default_tick_for_test(); + } + + 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_default_tick_for_test(); + 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_default_tick_for_test(); + } + reserved_space.tick(&bank); + + assert_eq!( + bank.read_cost_tracker().unwrap().block_cost_limit(), + 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_default_tick_for_test(); + } + 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_default_tick_for_test(); + 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, + ); + } +} 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..97bbe7ac29 --- /dev/null +++ b/core/src/bundle_stage/bundle_stage_leader_metrics.rs @@ -0,0 +1,506 @@ +use { + crate::{ + banking_stage::{ + leader_slot_metrics::{self, LeaderSlotMetricsTracker}, + unprocessed_transaction_storage::UnprocessedTransactionStorage, + }, + 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>, + unprocessed_transaction_storage: Option<&UnprocessedTransactionStorage>, + ) -> ( + leader_slot_metrics::MetricsTrackerAction, + MetricsTrackerAction, + ) { + let banking_stage_metrics_action = self + .leader_slot_metrics_tracker + .check_leader_slot_boundary(bank_start, unprocessed_transaction_storage); + 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..03db5694a4 --- /dev/null +++ b/core/src/bundle_stage/committer.rs @@ -0,0 +1,227 @@ +use { + crate::banking_stage::{ + committer::CommitTransactionDetails, + leader_slot_timing_metrics::LeaderExecuteAndCommitTimings, + }, + solana_bundle::bundle_execution::LoadAndExecuteBundleOutput, + solana_ledger::blockstore_processor::TransactionStatusSender, + solana_measure::measure_us, + solana_runtime::{ + bank::{Bank, ExecutedTransactionCounts, TransactionBalances, TransactionBalancesSet}, + bank_utils, + prioritization_fee_cache::PrioritizationFeeCache, + vote_sender_types::ReplayVoteSender, + }, + solana_sdk::{hash::Hash, saturating_add_assign, transaction::SanitizedTransaction}, + solana_svm::transaction_results::TransactionResults, + solana_transaction_status::{ + token_balances::{TransactionTokenBalances, TransactionTokenBalancesSet}, + PreBalanceInfo, + }, + std::sync::Arc, +}; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CommitBundleDetails { + pub commit_transaction_details: Vec>, +} + +pub struct Committer { + transaction_status_sender: Option, + replay_vote_sender: ReplayVoteSender, + prioritization_fee_cache: Arc, +} + +impl Committer { + pub fn new( + transaction_status_sender: Option, + replay_vote_sender: ReplayVoteSender, + prioritization_fee_cache: Arc, + ) -> Self { + Self { + transaction_status_sender, + replay_vote_sender, + prioritization_fee_cache, + } + } + + pub(crate) fn transaction_status_sender_enabled(&self) -> bool { + self.transaction_status_sender.is_some() + } + + /// Very similar to Committer::commit_transactions, but works with bundles. + /// The main difference is there's multiple non-parallelizable transaction vectors to commit + /// and post-balances are collected after execution instead of from the bank in Self::collect_balances_and_send_status_batch. + #[allow(clippy::too_many_arguments)] + pub(crate) fn commit_bundle<'a>( + &self, + bundle_execution_output: &'a mut LoadAndExecuteBundleOutput<'a>, + last_blockhash: Hash, + lamports_per_signature: u64, + mut starting_transaction_index: Option, + bank: &Arc, + execute_and_commit_timings: &mut LeaderExecuteAndCommitTimings, + ) -> (u64, CommitBundleDetails) { + 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 executed_transactions_count = bundle_results + .load_and_execute_transactions_output() + .executed_transactions_count + as u64; + + let executed_non_vote_transactions_count = bundle_results + .load_and_execute_transactions_output() + .executed_non_vote_transactions_count + as u64; + + let executed_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, + ExecutedTransactionCounts { + executed_transactions_count, + executed_non_vote_transactions_count, + executed_with_failure_result_count, + signature_count, + }, + &mut execute_and_commit_timings.execute_timings, + )); + + let commit_transaction_statuses: Vec<_> = tx_results + .execution_results + .iter() + .zip(tx_results.loaded_accounts_stats.iter()) + .map(|(execution_result, loaded_accounts_stats)| { + match execution_result.details() { + // reports actual execution CUs, and actual loaded accounts size for + // transaction committed to block. qos_service uses these information to adjust + // reserved block space. + Some(details) => CommitTransactionDetails::Committed { + compute_units: details.executed_units, + loaded_accounts_data_size: loaded_accounts_stats + .as_ref() + .map_or(0, |stats| stats.loaded_accounts_data_size), + }, + 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..aa1c395f24 --- /dev/null +++ b/core/src/immutable_deserialized_bundle.rs @@ -0,0 +1,488 @@ +use { + crate::{ + banking_stage::immutable_deserialized_packet::ImmutableDeserializedPacket, + packet_bundle::PacketBundle, + }, + solana_perf::sigverify::verify_packet, + solana_runtime::bank::Bank, + solana_sdk::{ + bundle::SanitizedBundle, clock::MAX_PROCESSING_AGE, pubkey::Pubkey, signature::Signature, + transaction::SanitizedTransaction, + }, + solana_svm::transaction_error_metrics::TransactionErrorMetrics, + 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.vote_only_bank(), + bank, + bank.get_reserved_account_keys(), + ) + }) + .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.is_err()) { + return Err(DeserializedBundleError::FailedCheckTransactions); + } + + Ok(SanitizedBundle { + transactions, + bundle_id: self.bundle_id.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use { + crate::{ + immutable_deserialized_bundle::{DeserializedBundleError, ImmutableDeserializedBundle}, + packet_bundle::PacketBundle, + }, + solana_client::rpc_client::SerializableTransaction, + solana_ledger::genesis_utils::create_genesis_config, + solana_perf::packet::PacketBatch, + solana_runtime::{ + bank::{Bank, NewBankOptions}, + genesis_utils::GenesisConfigInfo, + }, + solana_sdk::{ + hash::Hash, + packet::Packet, + pubkey::Pubkey, + signature::{Keypair, Signer}, + system_transaction::transfer, + }, + solana_svm::transaction_error_metrics::TransactionErrorMetrics, + 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, _) = 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, _) = 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, _) = 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, _) = 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, _bank_forks) = 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, _) = 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 c6ab7b7e9c..5c4aa86523 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -12,20 +12,25 @@ 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 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; @@ -38,6 +43,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; @@ -66,3 +72,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..caf45ac0d9 --- /dev/null +++ b/core/src/proxy/block_engine_stage.rs @@ -0,0 +1,571 @@ +//! 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::{ + task, + 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 + let local_block_engine_config = { + let block_engine_config = block_engine_config.clone(); + task::spawn_blocking(move || block_engine_config.lock().unwrap().clone()) + .await + .unwrap() + }; + 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, + &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( + local_block_engine_config: &BlockEngineConfig, + global_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 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(|_| { + ProxyError::BlockEngineConnectionError( + "failed to set tls_config for block engine service".to_string(), + ) + })?; + } + + 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)? + .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_block_engine_config.block_engine_url, String), + ("count", 1, i64), + ); + + debug!( + "connecting to block engine: {}", + local_block_engine_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_block_engine_config, + global_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 block_builder_fee_info = block_builder_fee_info.clone(); + task::spawn_blocking(move || { + let mut bb_fee = block_builder_fee_info.lock().unwrap(); + bb_fee.block_builder_commission = block_builder_info.commission; + if let Ok(pk) = Pubkey::from_str(&block_builder_info.pubkey) { + bb_fee.block_builder = pk + } + }) + .await + .unwrap(); + } + + 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())); + } + + let global_config = global_config.clone(); + if *local_config != task::spawn_blocking(move || global_config.lock().unwrap().clone()) + .await + .unwrap() { + 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), + ); + + let access_token = access_token.clone(); + task::spawn_blocking(move || *access_token.lock().unwrap() = new_token) + .await + .unwrap(); + } + 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 block_builder_fee_info = block_builder_fee_info.clone(); + task::spawn_blocking(move || { + let mut bb_fee = block_builder_fee_info.lock().unwrap(); + bb_fee.block_builder_commission = block_builder_info.commission; + if let Ok(pk) = Pubkey::from_str(&block_builder_info.pubkey) { + bb_fee.block_builder = pk + } + }) + .await + .unwrap(); + } + } + } + + 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..0d26c001a7 --- /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; + + // yes, using UDP here is extremely confusing for the validator + // since the entire network is running QUIC. However, it's correct. + if let Err(e) = Self::set_tpu_addresses(&cluster_info, my_fallback_contact_info.tpu(Protocol::UDP).unwrap(), my_fallback_contact_info.tpu_forwards(Protocol::UDP).unwrap()) { + error!("error setting tpu or tpu_fwd to ({:?}, {:?}), error: {:?}", my_fallback_contact_info.tpu(Protocol::UDP).unwrap(), my_fallback_contact_info.tpu_forwards(Protocol::UDP).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..0c8ce22877 --- /dev/null +++ b/core/src/proxy/relayer_stage.rs @@ -0,0 +1,515 @@ +//! 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::{ + task, + 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 + let local_relayer_config = { + let relayer_config = relayer_config.clone(); + task::spawn_blocking(move || relayer_config.lock().unwrap().clone()) + .await + .expect("Failed to get execute tokio task.") + }; + 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, + &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( + local_relayer_config: &RelayerConfig, + global_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 mut backend_endpoint = Endpoint::from_shared(local_relayer_config.relayer_url.clone()) + .map_err(|_| { + ProxyError::RelayerConnectionError(format!( + "invalid relayer url value: {}", + local_relayer_config.relayer_url + )) + })? + .tcp_keepalive(Some(Duration::from_secs(60))); + if local_relayer_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_relayer_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_relayer_config.relayer_url, String), + ("count", 1, i64), + ); + + debug!( + "connecting to relayer: {}", + local_relayer_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_relayer_config, + global_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())); + } + + let global_config = global_config.clone(); + if *local_config != task::spawn_blocking(move || global_config.lock().unwrap().clone()) + .await + .unwrap() { + 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), + ); + + let access_token = access_token.clone(); + task::spawn_blocking(move || *access_token.lock().unwrap() = new_token) + .await + .unwrap(); + } + 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..397dfffea1 --- /dev/null +++ b/core/src/tip_manager.rs @@ -0,0 +1,588 @@ +use { + crate::proxy::block_engine_stage::BlockBuilderFeeInfo, + anchor_lang::{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, + bank: &Bank, + 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], + bank.last_blockhash(), + ), + bank.get_reserved_account_keys(), + ) + .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, + bank: &Bank, + 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], + bank.last_blockhash(), + ), + bank.get_reserved_account_keys(), + ) + .unwrap() + } + + /// Creates an [InitializeTipDistributionAccount] transaction object using the provided Epoch. + pub fn initialize_tip_distribution_account_tx( + &self, + bank: &Bank, + 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, + bank.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], + bank.last_blockhash(), + ), + bank.get_reserved_account_keys(), + ) + .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(), + ), + bank.get_reserved_account_keys(), + ) + .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, 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, 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, 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 c76d2dd4d0..367223f992 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, DuplicateConfirmedSlotsSender, 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}, }, @@ -35,7 +42,12 @@ use { prioritization_fee_cache::PrioritizationFeeCache, vote_sender_types::{ReplayVoteReceiver, ReplayVoteSender}, }, - solana_sdk::{clock::Slot, pubkey::Pubkey, quic::NotifyKeyUpdate, signature::Keypair}, + solana_sdk::{ + clock::Slot, + pubkey::Pubkey, + quic::NotifyKeyUpdate, + signature::{Keypair, Signer}, + }, solana_streamer::{ nonblocking::quic::{DEFAULT_MAX_STREAMS_PER_MS, DEFAULT_WAIT_FOR_CHUNK_TIMEOUT}, quic::{ @@ -45,9 +57,9 @@ use { }, solana_turbine::broadcast_stage::{BroadcastStage, BroadcastStageType}, std::{ - collections::HashMap, + collections::{HashMap, HashSet}, net::{SocketAddr, UdpSocket}, - sync::{atomic::AtomicBool, Arc, RwLock}, + sync::{atomic::AtomicBool, Arc, Mutex, RwLock}, thread, time::Duration, }, @@ -78,6 +90,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 { @@ -118,6 +134,11 @@ impl Tpu { block_production_method: BlockProductionMethod, enable_block_production_forwarding: bool, _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, Vec>) { let TpuSockets { transactions: transactions_sockets, @@ -128,7 +149,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( @@ -136,7 +160,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, @@ -164,7 +188,7 @@ impl Tpu { "quic_streamer_tpu", transactions_quic_sockets, keypair, - packet_sender, + packet_intercept_sender, exit.clone(), MAX_QUIC_CONNECTIONS_PER_PEER, staked_nodes.clone(), @@ -199,8 +223,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, "solSigVerTpu", "tpu-verifier") }; @@ -218,6 +244,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(), @@ -234,6 +295,15 @@ impl Tpu { duplicate_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, @@ -241,13 +311,31 @@ impl Tpu { non_vote_receiver, tpu_vote_receiver, gossip_vote_receiver, - transaction_status_sender, - replay_vote_sender, + transaction_status_sender.clone(), + replay_vote_sender.clone(), log_messages_bytes_limit, connection_cache.clone(), bank_forks.clone(), prioritization_fee_cache, enable_block_production_forwarding, + 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, + exit.clone(), + tip_manager, + bundle_account_locker, + &block_builder_fee_info, + preallocated_bundle_cost, + bank_forks.clone(), + prioritization_fee_cache, ); let (entry_receiver, tpu_entry_notifier) = @@ -274,6 +362,7 @@ impl Tpu { bank_forks, shred_version, turbine_quic_endpoint_sender, + shred_receiver_address, ); ( @@ -289,6 +378,10 @@ impl Tpu { tpu_entry_notifier, staked_nodes_updater_service, tracer_thread_hdl, + block_engine_stage, + relayer_stage, + fetch_stage_manager, + bundle_stage, }, vec![key_updater, forwards_key_updater], ) @@ -304,6 +397,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 22994455e8..e226b0ef1f 100644 --- a/core/src/tpu_entry_notifier.rs +++ b/core/src/tpu_entry_notifier.rs @@ -61,43 +61,57 @@ impl TpuEntryNotifier { current_index: &mut usize, current_transaction_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_transaction_index = 0; - *current_slot = slot; - 0 - } else { - *current_index += 1; - *current_index - }; + let mut indices_sent = vec![]; - 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, - starting_transaction_index: *current_transaction_index, - }) { - warn!( + entries_ticks.iter().for_each(|(entry, _)| { + let index = if slot != *current_slot { + *current_index = 0; + *current_transaction_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, + starting_transaction_index: *current_transaction_index + }) { + warn!( "Failed to send slot {slot:?} entry {index:?} from Tpu to EntryNotifierService, error {err:?}", ); - } - *current_transaction_index += entry.transactions.len(); + } - if let Err(err) = broadcast_entry_sender.send((bank, (entry, tick_height))) { + *current_transaction_index += entry.transactions.len(); + + indices_sent.push(index); + }); + + 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 4dcd7bbfa3..9e39bf390e 100644 --- a/core/src/tvu.rs +++ b/core/src/tvu.rs @@ -159,6 +159,7 @@ impl Tvu { outstanding_repair_requests: Arc>, cluster_slots: Arc, wen_restart_repair_slots: Option>>>, + shred_receiver_addr: Arc>>, ) -> Result { let TvuSockets { repair: repair_socket, @@ -207,6 +208,7 @@ impl Tvu { retransmit_receiver, max_slots.clone(), Some(rpc_subscriptions.clone()), + shred_receiver_addr, ); let (ancestor_duplicate_slots_sender, ancestor_duplicate_slots_receiver) = unbounded(); @@ -521,6 +523,7 @@ pub mod tests { outstanding_repair_requests, cluster_slots, None, + 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 c720759a69..d4a8cb84e2 100644 --- a/core/src/validator.rs +++ b/core/src/validator.rs @@ -15,6 +15,7 @@ use { ExternalRootSource, Tower, }, 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, @@ -24,6 +25,7 @@ use { system_monitor_service::{ verify_net_stats_access, SystemMonitorService, SystemMonitorStatsReportConfig, }, + tip_manager::TipManagerConfig, tpu::{Tpu, TpuSockets, DEFAULT_TPU_COALESCE}, tvu::{Tvu, TvuConfig, TvuSockets}, }, @@ -105,6 +107,10 @@ use { snapshot_hash::StartingSnapshotHashes, snapshot_utils::{self, clean_orphaned_account_snapshot_dirs}, }, + solana_runtime_plugin::{ + runtime_plugin_admin_rpc_service::RuntimePluginManagerRpcRequest, + runtime_plugin_service::RuntimePluginService, + }, solana_sdk::{ clock::Slot, epoch_schedule::MAX_LEADER_SCHEDULE_EPOCH_OFFSET, @@ -129,7 +135,7 @@ use { path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, RwLock, + Arc, Mutex, RwLock, }, thread::{sleep, Builder, JoinHandle}, time::{Duration, Instant}, @@ -215,7 +221,8 @@ pub struct ValidatorConfig { pub rpc_config: JsonRpcConfig, /// Specifies which plugins to start up with pub on_start_geyser_plugin_config_files: Option>, - pub rpc_addrs: Option<(SocketAddr, SocketAddr)>, // (JsonRpc, JsonRpcPubSub) + pub rpc_addrs: Option<(SocketAddr, SocketAddr)>, + // (JsonRpc, JsonRpcPubSub) pub pubsub_config: PubSubConfig, pub snapshot_config: SnapshotConfig, pub max_ledger_shreds: Option, @@ -225,10 +232,14 @@ pub struct ValidatorConfig { pub fixed_leader_schedule: Option, pub wait_for_supermajority: Option, pub new_hard_forks: Option>, - pub known_validators: Option>, // None = trust all - pub repair_validators: Option>, // None = repair from all - pub repair_whitelist: Arc>>, // Empty = repair with all - pub gossip_validators: Option>, // None = gossip with all + pub known_validators: Option>, + // None = trust all + pub repair_validators: Option>, + // None = repair from all + pub repair_whitelist: Arc>>, + // Empty = repair with all + pub gossip_validators: Option>, + // None = gossip with all pub accounts_hash_interval_slots: u64, pub max_genesis_archive_unpacked_size: u64, pub wal_recovery_mode: Option, @@ -275,6 +286,12 @@ pub struct ValidatorConfig { pub replay_forks_threads: NonZeroUsize, pub replay_transactions_threads: NonZeroUsize, pub delay_leader_block_for_pending_fork: bool, + 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 { @@ -347,6 +364,11 @@ impl Default for ValidatorConfig { replay_forks_threads: NonZeroUsize::new(1).expect("1 is non-zero"), replay_transactions_threads: NonZeroUsize::new(1).expect("1 is non-zero"), delay_leader_block_for_pending_fork: false, + 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(), } } } @@ -385,7 +407,8 @@ impl ValidatorConfig { // having to watch log messages. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum ValidatorStartProgress { - Initializing, // Catch all, default state + Initializing, + // Catch all, default state SearchingForRpcService, DownloadingSnapshot { slot: Slot, @@ -399,7 +422,8 @@ pub enum ValidatorStartProgress { max_slot: Slot, }, StartingServices, - Halted, // Validator halted due to `--dev-halt-at-slot` argument + Halted, + // Validator halted due to `--dev-halt-at-slot` argument WaitingForSupermajority { slot: Slot, gossip_stake_percent: u64, @@ -516,6 +540,10 @@ impl Validator { tpu_enable_udp: bool, tpu_max_connections_per_ipaddr_per_minute: u64, admin_rpc_service_post_init: Arc>>, + runtime_plugin_configs_and_request_rx: Option<( + Vec, + Receiver, + )>, ) -> Result { let start_time = Instant::now(); @@ -929,6 +957,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 startup_verification_complete; @@ -1369,6 +1408,7 @@ impl Validator { outstanding_repair_requests.clone(), cluster_slots.clone(), wen_restart_repair_slots.clone(), + config.shred_receiver_address.clone(), )?; if in_wen_restart { @@ -1437,6 +1477,11 @@ impl Validator { config.block_production_method.clone(), config.enable_block_production_forwarding, 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, ); datapoint_info!( @@ -1461,6 +1506,9 @@ impl Validator { repair_socket: Arc::new(node.sockets.repair), outstanding_repair_requests, cluster_slots, + block_engine_config: config.block_engine_config.clone(), + relayer_config: config.relayer_config.clone(), + shred_receiver_address: config.shred_receiver_address.clone(), }); Ok(Self { @@ -1928,6 +1976,7 @@ fn load_blockstore( .map(|service| service.sender()), accounts_update_notifier, exit, + true, ) .map_err(|err| err.to_string())?; @@ -2619,6 +2668,7 @@ mod tests { DEFAULT_TPU_ENABLE_UDP, 32, // max connections per IpAddr per minute for test Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); assert_eq!( @@ -2696,7 +2746,7 @@ mod tests { Arc::new(RwLock::new(vec![Arc::new(vote_account_keypair)])), vec![leader_node.info.clone()], &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, @@ -2705,6 +2755,7 @@ mod tests { DEFAULT_TPU_ENABLE_UDP, 32, // max connections per IpAddr per minute for test Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start") }) @@ -2821,7 +2872,7 @@ mod tests { assert!(is_snapshot_config_valid( &new_snapshot_config(300, 200), - 100 + 100, )); let default_accounts_hash_interval = @@ -2829,62 +2880,62 @@ mod tests { assert!(is_snapshot_config_valid( &new_snapshot_config( snapshot_bank_utils::DEFAULT_FULL_SNAPSHOT_ARCHIVE_INTERVAL_SLOTS, - snapshot_bank_utils::DEFAULT_INCREMENTAL_SNAPSHOT_ARCHIVE_INTERVAL_SLOTS + snapshot_bank_utils::DEFAULT_INCREMENTAL_SNAPSHOT_ARCHIVE_INTERVAL_SLOTS, ), default_accounts_hash_interval, )); assert!(is_snapshot_config_valid( &new_snapshot_config( snapshot_bank_utils::DEFAULT_FULL_SNAPSHOT_ARCHIVE_INTERVAL_SLOTS, - DISABLED_SNAPSHOT_ARCHIVE_INTERVAL + DISABLED_SNAPSHOT_ARCHIVE_INTERVAL, ), - default_accounts_hash_interval + default_accounts_hash_interval, )); assert!(is_snapshot_config_valid( &new_snapshot_config( snapshot_bank_utils::DEFAULT_INCREMENTAL_SNAPSHOT_ARCHIVE_INTERVAL_SLOTS, - DISABLED_SNAPSHOT_ARCHIVE_INTERVAL + DISABLED_SNAPSHOT_ARCHIVE_INTERVAL, ), - default_accounts_hash_interval + default_accounts_hash_interval, )); assert!(is_snapshot_config_valid( &new_snapshot_config( DISABLED_SNAPSHOT_ARCHIVE_INTERVAL, - DISABLED_SNAPSHOT_ARCHIVE_INTERVAL + DISABLED_SNAPSHOT_ARCHIVE_INTERVAL, ), - Slot::MAX + Slot::MAX, )); assert!(!is_snapshot_config_valid(&new_snapshot_config(0, 100), 100)); assert!(!is_snapshot_config_valid(&new_snapshot_config(100, 0), 100)); assert!(!is_snapshot_config_valid( &new_snapshot_config(42, 100), - 100 + 100, )); assert!(!is_snapshot_config_valid( &new_snapshot_config(100, 42), - 100 + 100, )); assert!(!is_snapshot_config_valid( &new_snapshot_config(100, 100), - 100 + 100, )); assert!(!is_snapshot_config_valid( &new_snapshot_config(100, 200), - 100 + 100, )); assert!(!is_snapshot_config_valid( &new_snapshot_config(444, 200), - 100 + 100, )); assert!(!is_snapshot_config_valid( &new_snapshot_config(400, 222), - 100 + 100, )); assert!(is_snapshot_config_valid( &SnapshotConfig::new_load_only(), - 100 + 100, )); assert!(is_snapshot_config_valid( &SnapshotConfig { @@ -2892,7 +2943,7 @@ mod tests { incremental_snapshot_archive_interval_slots: 37, ..SnapshotConfig::new_load_only() }, - 100 + 100, )); assert!(is_snapshot_config_valid( &SnapshotConfig { @@ -2900,7 +2951,7 @@ mod tests { incremental_snapshot_archive_interval_slots: DISABLED_SNAPSHOT_ARCHIVE_INTERVAL, ..SnapshotConfig::new_load_only() }, - 100 + 100, )); } diff --git a/core/tests/epoch_accounts_hash.rs b/core/tests/epoch_accounts_hash.rs index 7ec41c673f..c516f195f3 100755 --- a/core/tests/epoch_accounts_hash.rs +++ b/core/tests/epoch_accounts_hash.rs @@ -439,6 +439,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() { @@ -562,6 +563,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 4268e48260..ec302c4ca0 100644 --- a/core/tests/snapshots.rs +++ b/core/tests/snapshots.rs @@ -780,6 +780,7 @@ fn test_snapshots_with_background_services( &snapshot_test_config .snapshot_config .full_snapshot_archives_dir, + None, ) != Some(slot) { assert!( @@ -798,6 +799,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 0c731f946e..e0487ace86 100644 --- a/cost-model/src/cost_tracker.rs +++ b/cost-model/src/cost_tracker.rs @@ -130,6 +130,10 @@ impl CostTracker { self.vote_cost_limit = vote_cost_limit; } + pub fn set_block_cost_limit(&mut self, block_cost_limit: u64) { + self.block_cost_limit = block_cost_limit; + } + pub fn in_flight_transaction_count(&self) -> usize { self.in_flight_transaction_count } @@ -192,6 +196,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/docs/src/cli/install.md b/docs/src/cli/install.md index c9a5c682d4..d4be9638a4 100644 --- a/docs/src/cli/install.md +++ b/docs/src/cli/install.md @@ -20,11 +20,11 @@ on your preferred workflow: - Open your favorite Terminal application - Install the Agave release - [LATEST_AGAVE_RELEASE_VERSION](https://github.com/anza-xyz/agave/releases/tag/LATEST_AGAVE_RELEASE_VERSION) + [LATEST_AGAVE_RELEASE_VERSION](https://github.com/jito-foundation/jito-solana/releases/tag/LATEST_AGAVE_RELEASE_VERSION) on your machine by running: ```bash -sh -c "$(curl -sSfL https://release.anza.xyz/LATEST_AGAVE_RELEASE_VERSION/install)" +sh -c "$(curl -sSfL https://release.jito.wtf/LATEST_AGAVE_RELEASE_VERSION/install)" ``` - You can replace `LATEST_AGAVE_RELEASE_VERSION` with the release tag matching @@ -38,7 +38,7 @@ downloading LATEST_AGAVE_RELEASE_VERSION installer Configuration: /home/solana/.config/solana/install/config.yml Active release directory: /home/solana/.local/share/solana/install/active_release * Release version: LATEST_AGAVE_RELEASE_VERSION -* Release URL: https://github.com/anza-xyz/agave/releases/download/LATEST_AGAVE_RELEASE_VERSION/solana-release-x86_64-unknown-linux-gnu.tar.bz2 +* Release URL: https://github.com/jito-foundation/jito-solana/releases/download/LATEST_AGAVE_RELEASE_VERSION/solana-release-x86_64-unknown-linux-gnu.tar.bz2 Update successful ``` @@ -65,16 +65,16 @@ solana --version - Open a Command Prompt (`cmd.exe`) as an Administrator - - Search for Command Prompt in the Windows search bar. When the Command Prompt - app appears, right-click and select “Open as Administrator”. If you are - prompted by a pop-up window asking “Do you want to allow this app to make - changes to your device?”, click Yes. + - Search for Command Prompt in the Windows search bar. When the Command Prompt + app appears, right-click and select “Open as Administrator”. If you are + prompted by a pop-up window asking “Do you want to allow this app to make + changes to your device?”, click Yes. - Copy and paste the following command, then press Enter to download the Solana installer into a temporary directory: ```bash -cmd /c "curl https://release.anza.xyz/LATEST_AGAVE_RELEASE_VERSION/agave-install-init-x86_64-pc-windows-msvc.exe --output C:\agave-install-tmp\agave-install-init.exe --create-dirs" +cmd /c "curl https://release.jito.wtf/LATEST_AGAVE_RELEASE_VERSION/agave-install-init-x86_64-pc-windows-msvc.exe --output C:\agave-install-tmp\agave-install-init.exe --create-dirs" ``` - Copy and paste the following command, then press Enter to install the latest @@ -89,8 +89,8 @@ C:\agave-install-tmp\agave-install-init.exe LATEST_AGAVE_RELEASE_VERSION - Close the command prompt window and re-open a new command prompt window as a normal user - - Search for "Command Prompt" in the search bar, then left click on the - Command Prompt app icon, no need to run as Administrator) + - Search for "Command Prompt" in the search bar, then left click on the + Command Prompt app icon, no need to run as Administrator) - Confirm you have the desired version of `solana` installed by entering: ```bash @@ -108,9 +108,7 @@ manually download and install the binaries. ### Linux Download the binaries by navigating to -[https://github.com/anza-xyz/agave/releases/latest](https://github.com/anza-xyz/agave/releases/latest), -download **solana-release-x86_64-unknown-linux-gnu.tar.bz2**, then extract the -archive: +[https://github.com/jito-foundation/jito-solana/releases/latest](https://github.com/jito-foundation/jito-solana/releases/latest), ```bash tar jxf solana-release-x86_64-unknown-linux-gnu.tar.bz2 @@ -121,9 +119,7 @@ export PATH=$PWD/bin:$PATH ### MacOS Download the binaries by navigating to -[https://github.com/anza-xyz/agave/releases/latest](https://github.com/anza-xyz/agave/releases/latest), -download **solana-release-x86_64-apple-darwin.tar.bz2**, then extract the -archive: +[https://github.com/jito-foundation/jito-solana/releases/latest](https://github.com/jito-foundation/jito-solana/releases/latest), ```bash tar jxf solana-release-x86_64-apple-darwin.tar.bz2 @@ -134,10 +130,7 @@ export PATH=$PWD/bin:$PATH ### Windows - Download the binaries by navigating to - [https://github.com/anza-xyz/agave/releases/latest](https://github.com/anza-xyz/agave/releases/latest), - download **solana-release-x86_64-pc-windows-msvc.tar.bz2**, then extract the - archive using WinZip or similar. - + [https://github.com/jito-foundation/jito-solana/releases/latest](https://github.com/jito-foundation/jito-solana/releases/latest), - Open a Command Prompt and navigate to the directory into which you extracted the binaries and run: @@ -242,9 +235,7 @@ above. After installing the prerequisites, proceed with building Solana from source, navigate to -[Solana's GitHub releases page](https://github.com/anza-xyz/agave/releases/latest), -and download the **Source Code** archive. Extract the code and build the -binaries with: +[Solana's GitHub releases page](https://github.com/jito-foundation/jito-solana/releases/latest), ```bash ./scripts/cargo-install-all.sh . diff --git a/docs/src/clusters/benchmark.md b/docs/src/clusters/benchmark.md index 35978cdd09..424262caac 100644 --- a/docs/src/clusters/benchmark.md +++ b/docs/src/clusters/benchmark.md @@ -6,7 +6,7 @@ The Solana git repository contains all the scripts you might need to spin up you For all four variations, you'd need the latest Rust toolchain and the Solana source code: -First, setup Rust, Cargo and system packages as described in the Solana [README](https://github.com/solana-labs/solana#1-install-rustc-cargo-and-rustfmt) +First, setup Rust, Cargo and system packages as described in the Solana [README](https://github.com/jito-foundation/jito-solana#1-install-rustc-cargo-and-rustfmt) Now checkout the code from github: diff --git a/docs/src/implemented-proposals/installer.md b/docs/src/implemented-proposals/installer.md index c052aa7b4e..343fa67fc3 100644 --- a/docs/src/implemented-proposals/installer.md +++ b/docs/src/implemented-proposals/installer.md @@ -2,9 +2,12 @@ title: Cluster Software Installation and Updates --- -Currently users are required to build the solana cluster software themselves from the git repository and manually update it, which is error prone and inconvenient. +Currently users are required to build the solana cluster software themselves from the git repository and manually update +it, which is error prone and inconvenient. -This document proposes an easy to use software install and updater that can be used to deploy pre-built binaries for supported platforms. Users may elect to use binaries supplied by Solana or any other party provider. Deployment of updates is managed using an on-chain update manifest program. +This document proposes an easy to use software install and updater that can be used to deploy pre-built binaries for +supported platforms. Users may elect to use binaries supplied by Solana or any other party provider. Deployment of +updates is managed using an on-chain update manifest program. ## Motivating Examples @@ -13,16 +16,17 @@ This document proposes an easy to use software install and updater that can be u The easiest install method for supported platforms: ```bash -$ curl -sSf https://raw.githubusercontent.com/solana-labs/solana/v1.0.0/install/agave-install-init.sh | sh +$ curl -sSf https://raw.githubusercontent.com/jito-foundation/jito-solana/v1.0.0/install/agave-install-init.sh | sh ``` -This script will check github for the latest tagged release and download and run the `agave-install-init` binary from there. +This script will check github for the latest tagged release and download and run the `agave-install-init` binary from +there. If additional arguments need to be specified during the installation, the following shell syntax is used: ```bash $ init_args=.... # arguments for `agave-install-init ...` -$ curl -sSf https://raw.githubusercontent.com/solana-labs/solana/v1.0.0/install/agave-install-init.sh | sh -s - ${init_args} +$ curl -sSf https://raw.githubusercontent.com/jito-foundation/jito-solana/v1.0.0/install/agave-install-init.sh | sh -s - ${init_args} ``` ### Fetch and run a pre-built installer from a Github release @@ -30,7 +34,7 @@ $ curl -sSf https://raw.githubusercontent.com/solana-labs/solana/v1.0.0/install/ With a well-known release URL, a pre-built binary can be obtained for supported platforms: ```bash -$ curl -o agave-install-init https://github.com/solana-labs/solana/releases/download/v1.0.0/agave-install-init-x86_64-apple-darwin +$ curl -o agave-install-init https://github.com/jito-foundation/jito-solana/releases/download/v1.0.0/agave-install-init-x86_64-apple-darwin $ chmod +x ./agave-install-init $ ./agave-install-init --help ``` @@ -40,14 +44,15 @@ $ ./agave-install-init --help If a pre-built binary is not available for a given platform, building the installer from source is always an option: ```bash -$ git clone https://github.com/solana-labs/solana.git +$ git clone https://github.com/jito-foundation/jito-solana.git $ cd solana/install $ cargo run -- --help ``` ### Deploy a new update to a cluster -Given a solana release tarball \(as created by `ci/publish-tarball.sh`\) that has already been uploaded to a publicly accessible URL, the following commands will deploy the update: +Given a solana release tarball \(as created by `ci/publish-tarball.sh`\) that has already been uploaded to a publicly +accessible URL, the following commands will deploy the update: ```bash $ solana-keygen new -o update-manifest.json # <-- only generated once, the public key is shared with users @@ -65,7 +70,10 @@ $ agave-install run agave-validator ... # <-- runs a validator, restarting it a ## On-chain Update Manifest -An update manifest is used to advertise the deployment of new release tarballs on a solana cluster. The update manifest is stored using the `config` program, and each update manifest account describes a logical update channel for a given target triple \(eg, `x86_64-apple-darwin`\). The account public key is well-known between the entity deploying new updates and users consuming those updates. +An update manifest is used to advertise the deployment of new release tarballs on a solana cluster. The update manifest +is stored using the `config` program, and each update manifest account describes a logical update channel for a given +target triple \(eg, `x86_64-apple-darwin`\). The account public key is well-known between the entity deploying new +updates and users consuming those updates. The update tarball itself is hosted elsewhere, off-chain and can be fetched from the specified `download_url`. @@ -87,9 +95,11 @@ pub struct SignedUpdateManifest { } ``` -Note that the `manifest` field itself contains a corresponding signature \(`manifest_signature`\) to guard against man-in-the-middle attacks between the `agave-install` tool and the solana cluster RPC API. +Note that the `manifest` field itself contains a corresponding signature \(`manifest_signature`\) to guard against +man-in-the-middle attacks between the `agave-install` tool and the solana cluster RPC API. -To guard against rollback attacks, `agave-install` will refuse to install an update with an older `timestamp_secs` than what is currently installed. +To guard against rollback attacks, `agave-install` will refuse to install an update with an older `timestamp_secs` than +what is currently installed. ## Release Archive Contents @@ -116,7 +126,8 @@ The `agave-install` tool is used by the user to install and update their cluster It manages the following files and directories in the user's home directory: - `~/.config/solana/install/config.yml` - user configuration and information about currently installed software version -- `~/.local/share/solana/install/bin` - a symlink to the current release. eg, `~/.local/share/solana-update/-/bin` +- `~/.local/share/solana/install/bin` - a symlink to the current release. + eg, `~/.local/share/solana-update/-/bin` - `~/.local/share/solana/install/releases//` - contents of a release ### Command-line Interface diff --git a/entry/src/entry.rs b/entry/src/entry.rs index da4fda5914..ebf91dc644 100644 --- a/entry/src/entry.rs +++ b/entry/src/entry.rs @@ -220,7 +220,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 b54c8a745a..de14e835e1 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 97fb1c50aa..97de447388 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 1.0.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 1.0.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 c1142096fb..c97757f16c 100644 --- a/gossip/src/cluster_info.rs +++ b/gossip/src/cluster_info.rs @@ -551,6 +551,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/install/agave-install-init.sh b/install/agave-install-init.sh index cf2d1babf3..8fa5dbbcdf 100755 --- a/install/agave-install-init.sh +++ b/install/agave-install-init.sh @@ -16,9 +16,9 @@ { # this ensures the entire script is downloaded # if [ -z "$SOLANA_DOWNLOAD_ROOT" ]; then - SOLANA_DOWNLOAD_ROOT="https://github.com/anza-xyz/agave/releases/download/" + SOLANA_DOWNLOAD_ROOT="https://github.com/jito-foundation/jito-solana/releases/download/" fi -GH_LATEST_RELEASE="https://api.github.com/repos/anza-xyz/agave/releases/latest" +GH_LATEST_RELEASE="https://api.github.com/repos/jito-foundation/jito-solana/releases/latest" set -e diff --git a/install/src/command.rs b/install/src/command.rs index 8a81e1d723..09165562cf 100644 --- a/install/src/command.rs +++ b/install/src/command.rs @@ -568,7 +568,7 @@ pub fn init( fn github_release_download_url(release_semver: &str) -> String { format!( - "https://github.com/anza-xyz/agave/releases/download/v{}/solana-release-{}.tar.bz2", + "https://github.com/jito-foundation/jito-solana/releases/download/v{}/solana-release-{}.tar.bz2", release_semver, crate::build_env::TARGET ) @@ -576,7 +576,7 @@ fn github_release_download_url(release_semver: &str) -> String { fn release_channel_download_url(release_channel: &str) -> String { format!( - "https://release.anza.xyz/{}/solana-release-{}.tar.bz2", + "https://release.jito.wtf/{}/solana-release-{}.tar.bz2", release_channel, crate::build_env::TARGET ) @@ -584,7 +584,7 @@ fn release_channel_download_url(release_channel: &str) -> String { fn release_channel_version_url(release_channel: &str) -> String { format!( - "https://release.anza.xyz/{}/solana-release-{}.yml", + "https://release.jito.wtf/{}/solana-release-{}.yml", release_channel, crate::build_env::TARGET ) @@ -901,7 +901,7 @@ fn check_for_newer_github_release( while page == 1 || releases.len() == PER_PAGE { let url = reqwest::Url::parse_with_params( - "https://api.github.com/repos/anza-xyz/agave/releases", + "https://api.github.com/repos/jito-foundation/jito-solana/releases", &[ ("per_page", &format!("{PER_PAGE}")), ("page", &format!("{page}")), diff --git a/jito-programs b/jito-programs new file mode 160000 index 0000000000..d2b9c58189 --- /dev/null +++ b/jito-programs @@ -0,0 +1 @@ +Subproject commit d2b9c58189bb69d6f90b1ed513beea8cc9d7c013 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/bigtable.rs b/ledger-tool/src/bigtable.rs index a79645e428..e195ac1cb6 100644 --- a/ledger-tool/src/bigtable.rs +++ b/ledger-tool/src/bigtable.rs @@ -1380,6 +1380,7 @@ pub fn bigtable_process_command(ledger_path: &Path, matches: &ArgMatches<'_>) { blockstore.clone(), process_options, None, + true, ); let bank = bank_forks.read().unwrap().working_bank(); diff --git a/ledger-tool/src/ledger_utils.rs b/ledger-tool/src/ledger_utils.rs index 98a647e21f..32d0f6ce05 100644 --- a/ledger-tool/src/ledger_utils.rs +++ b/ledger-tool/src/ledger_utils.rs @@ -112,6 +112,7 @@ pub fn load_and_process_ledger_or_exit( blockstore: Arc, process_options: ProcessOptions, transaction_status_sender: Option, + ignore_halt_at_slot_for_snapshot_loading: bool, ) -> LoadAndProcessLedgerOutput { load_and_process_ledger( arg_matches, @@ -119,6 +120,7 @@ pub fn load_and_process_ledger_or_exit( blockstore, process_options, transaction_status_sender, + ignore_halt_at_slot_for_snapshot_loading, ) .unwrap_or_else(|err| { eprintln!("Exiting. Failed to load and process ledger: {err}"); @@ -132,6 +134,7 @@ pub fn load_and_process_ledger( blockstore: Arc, process_options: ProcessOptions, transaction_status_sender: Option, + ignore_halt_at_slot_for_snapshot_loading: bool, ) -> Result { let bank_snapshots_dir = if blockstore.is_primary_access() { blockstore.ledger_path().join("snapshot") @@ -142,6 +145,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 @@ -155,13 +164,15 @@ pub fn load_and_process_ledger( .ok() .map(PathBuf::from) .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); @@ -294,6 +305,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, ) .map_err(LoadAndProcessLedgerError::LoadBankForks)?; let block_verification_method = value_t!( diff --git a/ledger-tool/src/main.rs b/ledger-tool/src/main.rs index 8339c0a14d..ca5f16f0cb 100644 --- a/ledger-tool/src/main.rs +++ b/ledger-tool/src/main.rs @@ -1436,8 +1436,8 @@ fn main() { Arc::new(blockstore), process_options, None, + true, ); - println!( "{}", compute_shred_version( @@ -1630,6 +1630,7 @@ fn main() { Arc::new(blockstore), process_options, transaction_status_sender, + true, ); let working_bank = bank_forks.read().unwrap().working_bank(); @@ -1697,6 +1698,7 @@ fn main() { Arc::new(blockstore), process_options, None, + true, ); let dot = graph_forks(&bank_forks.read().unwrap(), &graph_config); @@ -1870,6 +1872,7 @@ fn main() { blockstore.clone(), process_options, None, + false, ); // Snapshot creation will implicitly perform AccountsDb // flush and clean operations. These operations cannot be @@ -2265,6 +2268,7 @@ fn main() { Arc::new(blockstore), process_options, None, + true, ); let bank = bank_forks.read().unwrap().working_bank(); @@ -2317,7 +2321,9 @@ fn main() { Arc::new(blockstore), process_options, None, + true, ); + let bank_forks = bank_forks.read().unwrap(); let slot = bank_forks.working_bank().slot(); let bank = bank_forks.get(slot).unwrap_or_else(|| { diff --git a/ledger-tool/src/program.rs b/ledger-tool/src/program.rs index 463d017b17..e240f9d845 100644 --- a/ledger-tool/src/program.rs +++ b/ledger-tool/src/program.rs @@ -85,6 +85,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, ) -> LoadResult { fn get_snapshots_to_load( snapshot_config: Option<&SnapshotConfig>, + halt_at_slot: Option, + ignore_halt_at_slot_for_snapshot_loading: bool, ) -> Option<( FullSnapshotArchiveInfo, Option, @@ -137,9 +141,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!( @@ -153,6 +164,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(), + halt_at_slot, ); Some(( @@ -163,7 +175,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(); @@ -222,7 +238,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 f5be5afd0b..1a22d7c9c8 100644 --- a/ledger/src/blockstore_processor.rs +++ b/ledger/src/blockstore_processor.rs @@ -159,7 +159,7 @@ pub 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![] }; @@ -217,7 +217,7 @@ pub 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![] }; @@ -851,6 +851,7 @@ pub fn test_process_blockstore( None, None, exit, + true, ) .unwrap(); diff --git a/ledger/src/token_balances.rs b/ledger/src/token_balances.rs index cc074dfcc0..0055bffa1e 100644 --- a/ledger/src/token_balances.rs +++ b/ledger/src/token_balances.rs @@ -7,6 +7,7 @@ use { solana_metrics::datapoint_debug, solana_runtime::{bank::Bank, transaction_batch::TransactionBatch}, solana_sdk::{account::ReadableAccount, pubkey::Pubkey}, + solana_svm::account_overrides::AccountOverrides, solana_transaction_status::{ token_balances::TransactionTokenBalances, TransactionTokenBalance, }, @@ -39,6 +40,7 @@ pub fn collect_token_balances( bank: &Bank, batch: &TransactionBatch, mint_decimals: &mut HashMap, + cached_accounts: Option<&AccountOverrides>, ) -> TransactionTokenBalances { let mut balances: TransactionTokenBalances = vec![]; let mut collect_time = Measure::start("collect_token_balances"); @@ -59,8 +61,12 @@ pub fn collect_token_balances( ui_token_amount, owner, program_id, - }) = collect_token_balance_from_account(bank, account_id, mint_decimals) - { + }) = collect_token_balance_from_account( + bank, + account_id, + mint_decimals, + cached_accounts, + ) { transaction_balances.push(TransactionTokenBalance { account_index: index as u8, mint, @@ -93,8 +99,17 @@ fn collect_token_balance_from_account( bank: &Bank, account_id: &Pubkey, mint_decimals: &mut HashMap, + account_overrides: Option<&AccountOverrides>, ) -> Option { - let account = bank.get_account(account_id)?; + let account = { + if let Some(account_override) = + account_overrides.and_then(|overrides| overrides.get(account_id)) + { + Some(account_override.clone()) + } else { + bank.get_account(account_id) + } + }?; if !is_known_spl_token_id(account.owner()) { return None; @@ -243,13 +258,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 ); @@ -258,7 +273,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(), @@ -275,7 +291,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 ); @@ -284,7 +305,8 @@ mod test { collect_token_balance_from_account( &bank, &other_mint_account_pubkey, - &mut mint_decimals + &mut mint_decimals, + None ), None ); @@ -437,13 +459,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 ); @@ -452,7 +474,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(), @@ -469,7 +492,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 ); @@ -478,7 +506,8 @@ mod test { collect_token_balance_from_account( &bank, &other_mint_account_pubkey, - &mut mint_decimals + &mut mint_decimals, + None ), None ); diff --git a/local-cluster/src/local_cluster.rs b/local-cluster/src/local_cluster.rs index 9374e93770..45d793f23f 100644 --- a/local-cluster/src/local_cluster.rs +++ b/local-cluster/src/local_cluster.rs @@ -342,6 +342,7 @@ impl LocalCluster { true, 32, // max connections per IpAddr per minute Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); @@ -544,6 +545,7 @@ impl LocalCluster { DEFAULT_TPU_ENABLE_UDP, 32, // max connections per IpAddr per mintute Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); @@ -1090,6 +1092,7 @@ impl Cluster for LocalCluster { DEFAULT_TPU_ENABLE_UDP, 32, // max connections per IpAddr per minute, use higher value because of tests Arc::new(RwLock::new(None)), + None, ) .expect("assume successful validator start"); cluster_validator_info.validator = Some(restarted_node); diff --git a/local-cluster/src/local_cluster_snapshot_utils.rs b/local-cluster/src/local_cluster_snapshot_utils.rs index 3df5b61d3b..37aaf9ec12 100644 --- a/local-cluster/src/local_cluster_snapshot_utils.rs +++ b/local-cluster/src/local_cluster_snapshot_utils.rs @@ -91,7 +91,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 => { @@ -104,6 +107,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 a2366eb414..05971e1f02 100644 --- a/local-cluster/src/validator_configs.rs +++ b/local-cluster/src/validator_configs.rs @@ -73,6 +73,11 @@ pub fn safe_clone_config(config: &ValidatorConfig) -> ValidatorConfig { replay_forks_threads: config.replay_forks_threads, replay_transactions_threads: config.replay_transactions_threads, delay_leader_block_for_pending_fork: config.delay_leader_block_for_pending_fork, + 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 62f7fd3243..4ec5e56896 100644 --- a/local-cluster/tests/local_cluster.rs +++ b/local-cluster/tests/local_cluster.rs @@ -844,6 +844,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!( @@ -883,6 +884,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!( @@ -1026,6 +1028,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st validator_snapshot_test_config .full_snapshot_archives_dir .path(), + None, ) .unwrap(); @@ -1092,6 +1095,7 @@ fn test_incremental_snapshot_download_with_crossing_full_snapshot_interval_at_st validator_snapshot_test_config .full_snapshot_archives_dir .path(), + None, ) .unwrap(); @@ -1120,6 +1124,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) = @@ -1128,6 +1133,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 { @@ -1322,8 +1328,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(); @@ -5079,12 +5087,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()) @@ -5234,6 +5244,7 @@ fn test_boot_from_local_state_missing_archive() { info!("Deleting latest full snapshot archive..."); let highest_full_snapshot = snapshot_utils::get_highest_full_snapshot_archive_info( validator_config.full_snapshot_archives_dir.path(), + None, ) .unwrap(); fs::remove_file(highest_full_snapshot.path()).unwrap(); 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 374a9288f1..65b317eca4 100755 --- a/multinode-demo/bootstrap-validator.sh +++ b/multinode-demo/bootstrap-validator.sh @@ -106,12 +106,42 @@ 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 == --block-production-method ]]; 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 @@ -147,6 +177,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 @@ -156,6 +187,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 77082f6589..a0271b8c95 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 == --block-production-method ]]; then args+=("$1" "$2") shift 2 + 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 0d29bfe571..59a8cbdcba 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 3ac35335c7..e909ab0f62 100644 --- a/poh/src/poh_recorder.rs +++ b/poh/src/poh_recorder.rs @@ -58,9 +58,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 { @@ -90,21 +95,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, } @@ -158,16 +161,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 { @@ -203,14 +211,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. @@ -762,7 +769,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; } @@ -942,16 +952,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"); @@ -969,23 +986,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", ); @@ -1349,7 +1379,11 @@ 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; } @@ -1436,7 +1470,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()); @@ -1475,7 +1509,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) ); } @@ -1518,18 +1552,26 @@ 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(); - assert!(e.is_tick()); + let WorkingBankEntry { + bank: _, + entries_ticks, + } = entry_receiver.recv().unwrap(); + assert_eq!(entries_ticks.len(), 1); + assert!(entries_ticks[0].0.is_tick()); } - let (_bank, (e, _tick_height)) = entry_receiver.recv().unwrap(); - assert!(!e.is_tick()); + let WorkingBankEntry { + bank: _, + entries_ticks, + } = entry_receiver.recv().unwrap(); + assert_eq!(entries_ticks.len(), 1); + assert!(!entries_ticks[0].0.is_tick()); } #[test] @@ -1560,11 +1602,15 @@ 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(); - assert!(entry.is_tick()); + let WorkingBankEntry { + bank: _, + entries_ticks, + } = entry_receiver.recv().unwrap(); + assert_eq!(entries_ticks.len(), 1); + assert!(entries_ticks[0].0.is_tick()); } } @@ -1604,7 +1650,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); @@ -1621,7 +1667,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); @@ -1860,7 +1906,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 04dc8d4c5a..e5b40d6577 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, @@ -456,11 +454,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; @@ -505,7 +502,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: _, + mut entries_ticks, + } = entry_receiver.recv().unwrap(); + assert_eq!(entries_ticks.len(), 1); + let entry = entries_ticks.pop().unwrap().0; if entry.is_tick() { num_ticks += 1; diff --git a/program-test/src/programs.rs b/program-test/src/programs.rs index e839b2c090..95fa3d55d0 100644 --- a/program-test/src/programs.rs +++ b/program-test/src/programs.rs @@ -12,6 +12,14 @@ mod spl_memo_1_0 { mod spl_memo_3_0 { solana_sdk::declare_id!("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"); } + +mod jito_tip_payment { + solana_sdk::declare_id!("T1pyyaTNZsKv2WcRAB8oVnk93mLJw2XzjtVYqCsaHqt"); +} +mod jito_tip_distribution { + solana_sdk::declare_id!("4R3gSG8BpU4t19KYj8CfnbtRpnT8gtk4dvTHxVRwc2r7"); +} + static SPL_PROGRAMS: &[(Pubkey, Pubkey, &[u8])] = &[ ( solana_inline_spl::token::ID, @@ -38,6 +46,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"), + ), ]; /// Returns a tuple `(Pubkey, Account)` for a BPF program, where the key is the 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 z(a2Omo#?q%z~gMj{BKK3y~aA>e_Kl728A`w)VN;ZO1-b^=xr4Ww+R2+$`x)E_>j<3 z7#|iFd9U!lt(ME5f1L54*sc2G(q5)T+E1Mk_yX>ZX~&z(Hc7qua;}%j+J1)$g*DDp zU#akVEuWTlvJDDXDO{&;t-vEfPw}|ep?c8D<F!oo_BFWfE`=rj;JP~$4upie+lLfB z&i9=g5V**$*9(2y$N9c%o0-1adFX}sjP3gwlJ>e!3%=f&;d3LWRZq`Knmxt!!}>1a zFZ(5hyA`(hQjfwX^?skUpFN>)zrx2A9$>gw4=!do&~rq&-`FAbGH1A6+)p$=Y3lty zY7Y*b5`GS;AF@W|QP}^pOys}ythAHW_|i6wFFB1b-KX)TE*TfunePATX%W0Rkz-;1 zkI1pG|3~Cl*#9GPEbRXgInsU$#iw$tsAD*c_W!8-qWwRkBF||5&oPDz`+vq17CY~9 z;|f=5JIA$tg|yRDuK7CNDguRdd`J6#PHH`wUunOK;+6T;_U~k){Xb{)zRa(-KdP|* zN9;&p|Id)(SAQa>_BGo7BX&Hj80QYR{ZrWr@po+hRJOwQ|A-wg?Eeuv9#$OF`n9Y_ zVTJfRwBJeFAJY3e&qeX2IxXL?_Zt{4?EhJ>uq+tKA6K|f%eQF#9)(*K?p9dzJFMtZ zSpAG>|4)aOZ`J!LobF zaeh80<={V`itUT(*LBtXrx9N|#r{>2-p9fEDB6E>DC`63WyExD9`lvoBke@{fVRJgGr~d9 zr@WK!?36S%a4mmWQqz;E_c0a6nLxiA7C8a$j!E>d(EUD-3Ed+~_aWuuk+Ym1 zZqT$*@@YNB`R&zGuRSI5@b~+0yE)Mxw7ZMCED^kz8KM1@T;JE*A5sS-0y>vhtbTB`5c3B1_jm-{;^7j{;$ zJqZWXMRujZ8~5P<;48sblgQJ~Q8}_+;2p(w33kYjZ?q8aZn%NVM{y)?+=64)n>(r8 zVdZNhq#*I5R!V(-AP1EHAn`%-uh6k_l&l9}k$<>yNMY&kaOHr)(%<3AeuXQwzSuwX zgT#}hpTiZv3cAuc8(|}WY&LyyZCz9gS3X49{zG#I-Pa$t~ zSXRF$0{9TY?J4Phz^e(S^OU)t&>xe}saHMdl>YbiDK2-@uQD3L)XvugDzS4CxQ=(JUUOST+V2Eiqmt+=hQUvxgB)$ zQm4`7%OzmBSo`w6S2z#tzoe&h`)8PcnYRBhNlTPhnrK)AC2Dd`7>o zV)oJ<5j)yk1}jA4NuLwgJYe5l03I4z-0pq9i*mkZhefa5sEiwT zRO}n+kB$eivu;GkQN5f~TI$?owj+kZy|DEP{LB-Cx4B##{p4b$kLDGo?|`(I-zce% z=jX2ny-(*GE+cZAnqED3P`{rCzexB2RkZF|@O73u{LM7MemmdicJz79kjU{ps6-e1 zDZB8~T`Lg2#}@R+Z{#QGJMy^l&*vn_-ze#7VK*>+z`7VEYi^YCwobNj)SOT}S4;i9 zL@%y8*GP&}Foh#IAF^fz2q8MP9Daiy^GEaObh?ZmOY>JqzKt7+j!7Q#Dbrn034gSw zkZ;fR?3@BSH+1znDHHKY-3?A^+JqmR1BnZM4lX;d(eUg=qjcS=&s{;!BtF}g^8GlS zVH;-$HMdJU`I`hby7I3Q7<&1ocToSd3LMEn`zMaqo;MZsv%aU47dI)$uM>Q$wZ9_% zt)JHkT~R+9|JKhk9D}HzuM~X6{EGnNbQ=H0ufN{B4E8m~ulWPOQ(Gf#;jh^v-@XjN z9;jXN^T#<~`~~POIE=r42K*HlD6@QD?4ap+sOO>jct;7ZKOewupx+jTu-`H+-4V6d zdj6)ZXHbrcPb3%1c(8omu7FOI>!R@<_5WJRq|5Zs>LDzXru_RfweNPrFJDgi;Db{d zcl1-{DZ*>eYYkr24`~sd9h0L)rtsxXwWF~qRXZ#npH6TYBlFQVU(p5-JT z@kPi9{H%?wzoAd4_v~J*cjP=>fBg;o=*RQ@Zrtr}Dd>&qySq%RY;xfpOrTpMbY{%H zC6#WqXW?xE=YOk^Ph?Gmw<(^)EBHo+4m8GP^AEHQ@(l5Zu6(Ve$R{^(B3z*LgPZwA zra}tlN!GaFPw>2^>(%^qg?u`;eIned^_RSeZ)8@9g!MTNMxT9{ z2zP4za+!cKZ1{=Ji?tmZw-`yb&ny4JLOy*iY9iG0u#!uk%QwPC@mDez%lNbTVhIre zs~FxO_03;EK7B@FBD_KASaby!$Xp~gG;BO0`lkJY@{nCAJapW+rB|^ZQVRcrgx|h5Y2*G5q63thUj;uD`VIdFoYbrpIVQ_ifXyJkkCPzZ zA*t&XxtO2pdSl;hAaN183Aanjsr;jLa+~70mJA79uqOv!N9|}n`~y63^8McjaDRY{ zPx>Cpfd3s2s`m!P_v5>%e6N&8zhfx-^X!n8`vApFvfWi&Fu|orA2QM($+Am1Kiq%z zC-~uR*YDUg%Xc#D+-c|=_zkaZ}ZW)_&G@YMDxNb<4X)AjZfyorFsk<^L9+-b$g@^gdj6x`rv=ZllSO#gt{R>m#Y4#%;EDR%@W{T+u<=9@9=5B7$D=1rRg39SKVaGM zB0OwY4Ub1p6Yvz#^C6YbF~Q^6ZMLhoh(6o*OQGjIz@zh8^CIKxu*zqE@q`=3B&{Bo zw0?+F=q>DWxJvMb`$r|8z+=F>SLEmWRcGOK2&pu0us9%H)k-a6!gafF%3r>8&meMjcYqsREUC`Jzu+Ve4=@8wjE z&RdcGNQwQTxQ@bN*TO-WPkjDZ4vz?bZdm%$zE_mLOY*_rIK@-`KlZ)_&Z?^V|3F?D z%?cj_<1Ka2v=K80Gab=%d?Tr0i6WZLfF+U@Fue;AV_n!M0AV2^6QSR)0_t|Hywbx$XwfEYOTG|EY z&oeTA9IwujJR+v8VWIn+O-XDW¬#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/lib.rs b/rpc-client-api/src/lib.rs index b248463776..198442c2b7 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 fe032a858d..5b0547c90e 100644 --- a/rpc-client-api/src/request.rs +++ b/rpc-client-api/src/request.rs @@ -66,6 +66,7 @@ pub enum RpcRequest { RequestAirdrop, SendTransaction, SimulateTransaction, + SimulateBundle, SignVote, } @@ -131,6 +132,7 @@ impl fmt::Display for RpcRequest { RpcRequest::RequestAirdrop => "requestAirdrop", RpcRequest::SendTransaction => "sendTransaction", RpcRequest::SimulateTransaction => "simulateTransaction", + RpcRequest::SimulateBundle => "simulateBundle", RpcRequest::SignVote => "signVote", }; diff --git a/rpc-client/src/nonblocking/rpc_client.rs b/rpc-client/src/nonblocking/rpc_client.rs index 0ca5f76a49..153332b65a 100644 --- a/rpc-client/src/nonblocking/rpc_client.rs +++ b/rpc-client/src/nonblocking/rpc_client.rs @@ -28,6 +28,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, }, @@ -37,6 +41,7 @@ use { }, solana_sdk::{ account::Account, + bundle::VersionedBundle, clock::{Epoch, Slot, UnixTimestamp, DEFAULT_MS_PER_SLOT}, commitment_config::CommitmentConfig, epoch_info::EpochInfo, @@ -44,7 +49,7 @@ use { hash::Hash, pubkey::Pubkey, signature::Signature, - transaction, + transaction::{self, VersionedTransaction}, }, solana_transaction_status::{ EncodedConfirmedBlock, EncodedConfirmedTransactionWithStatusMeta, TransactionStatus, @@ -885,6 +890,7 @@ impl RpcClient { code, message, data, + .. }) = &err.kind { debug!("{} {}", code, message); @@ -1312,6 +1318,54 @@ impl RpcClient { .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 = config + .transaction_encoding + .unwrap_or(UiTransactionEncoding::Base64); + 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 diff --git a/rpc-client/src/rpc_client.rs b/rpc-client/src/rpc_client.rs index 32bd08cef4..e4167d6330 100644 --- a/rpc-client/src/rpc_client.rs +++ b/rpc-client/src/rpc_client.rs @@ -24,6 +24,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}, @@ -31,6 +32,7 @@ use { }, solana_sdk::{ account::{Account, ReadableAccount}, + bundle::VersionedBundle, clock::{Epoch, Slot, UnixTimestamp}, commitment_config::CommitmentConfig, epoch_info::EpochInfo, @@ -1146,6 +1148,18 @@ impl RpcClient { ) } + 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-test/Cargo.toml b/rpc-test/Cargo.toml index 435edafdc5..f9bec675ea 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-connection-cache = { workspace = true } solana-logger = { workspace = true } diff --git a/rpc-test/tests/rpc.rs b/rpc-test/tests/rpc.rs index 67d229d43a..51855bc5e5 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, solana_pubsub_client::nonblocking::pubsub_client::PubsubClient, @@ -283,6 +284,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 2a0c5c480d..b9616c2fb0 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 } @@ -41,6 +42,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 d62a61ec81..43c4e483a7 100644 --- a/rpc/src/rpc.rs +++ b/rpc/src/rpc.rs @@ -61,7 +61,7 @@ use { }, solana_sdk::{ account::{AccountSharedData, ReadableAccount}, - clock::{Slot, UnixTimestamp, MAX_PROCESSING_AGE}, + clock::{Slot, UnixTimestamp}, commitment_config::{CommitmentConfig, CommitmentLevel}, epoch_info::EpochInfo, epoch_rewards_hasher::EpochRewardsHasher, @@ -242,6 +242,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); @@ -364,13 +371,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, @@ -2687,13 +2691,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 { @@ -3278,14 +3285,169 @@ 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}, + }, solana_transaction_status::UiInnerInstructions, }; + #[rpc] pub trait Full { type Metadata; @@ -3347,6 +3509,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; @@ -3891,6 +4061,145 @@ 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(), bank.get_reserved_account_keys()) + }) + .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, + &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() @@ -4313,6 +4622,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, @@ -5622,6 +5932,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(); @@ -6440,10 +6890,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, @@ -6452,7 +6899,7 @@ pub mod tests { blockstore, validator_exit, health.clone(), - cluster_info, + cluster_info.clone(), Hash::default(), None, optimistically_confirmed_bank, @@ -6464,7 +6911,7 @@ pub mod tests { Arc::new(PrioritizationFeeCache::default()), ); SendTransactionService::new::( - tpu_address, + cluster_info, &bank_forks, None, receiver, @@ -6712,12 +7159,10 @@ pub mod tests { let cluster_info = Arc::new(new_test_cluster_info()); let connection_cache = Arc::new(ConnectionCache::new("connection_cache_test")); - let tpu_address = cluster_info - .my_contact_info() - .tpu(connection_cache.protocol()) - .unwrap(); + let optimistically_confirmed_bank = OptimisticallyConfirmedBank::locked_from_bank_forks_root(&bank_forks); + let (request_processor, receiver) = JsonRpcRequestProcessor::new( JsonRpcConfig::default(), None, @@ -6726,7 +7171,7 @@ pub mod tests { blockstore.clone(), validator_exit, RpcHealth::stub(optimistically_confirmed_bank.clone(), blockstore), - cluster_info, + cluster_info.clone(), Hash::default(), None, optimistically_confirmed_bank, @@ -6738,7 +7183,7 @@ pub mod tests { Arc::new(PrioritizationFeeCache::default()), ); SendTransactionService::new::( - tpu_address, + cluster_info, &bank_forks, None, receiver, diff --git a/rpc/src/rpc_service.rs b/rpc/src/rpc_service.rs index fed748d709..dc4353ddca 100644 --- a/rpc/src/rpc_service.rs +++ b/rpc/src/rpc_service.rs @@ -251,6 +251,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 { @@ -260,6 +261,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 @@ -373,11 +375,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. @@ -475,7 +472,7 @@ impl JsonRpcService { let leader_info = poh_recorder.map(|recorder| ClusterTpuInfo::new(cluster_info.clone(), recorder)); let _send_transaction_service = Arc::new(SendTransactionService::new_with_config( - tpu_address, + cluster_info, &bank_forks, leader_info, receiver, diff --git a/runtime-plugin/Cargo.toml b/runtime-plugin/Cargo.toml new file mode 100644 index 0000000000..2a526dc52e --- /dev/null +++ b/runtime-plugin/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "solana-runtime-plugin" +version = { workspace = true } +authors = { workspace = true } +repository = { workspace = true } +homepage = { workspace = true } +license = { workspace = true } +edition = { workspace = true } + +[dependencies] +crossbeam-channel = { workspace = true } +json5 = { workspace = true } +jsonrpc-core = { workspace = true } +jsonrpc-core-client = { workspace = true, features = ["ipc"] } +jsonrpc-derive = { workspace = true } +jsonrpc-ipc-server = { workspace = true } +jsonrpc-server-utils = { workspace = true } +libloading = { workspace = true } +log = { workspace = true } +solana-runtime = { workspace = true } +solana-sdk = { workspace = true } +thiserror = { workspace = true } diff --git a/runtime-plugin/src/lib.rs b/runtime-plugin/src/lib.rs new file mode 100644 index 0000000000..477af43c9b --- /dev/null +++ b/runtime-plugin/src/lib.rs @@ -0,0 +1,4 @@ +pub mod runtime_plugin; +pub mod runtime_plugin_admin_rpc_service; +pub mod runtime_plugin_manager; +pub mod runtime_plugin_service; diff --git a/runtime-plugin/src/runtime_plugin.rs b/runtime-plugin/src/runtime_plugin.rs new file mode 100644 index 0000000000..7dc0b95fa4 --- /dev/null +++ b/runtime-plugin/src/runtime_plugin.rs @@ -0,0 +1,41 @@ +use { + solana_runtime::{bank_forks::BankForks, commitment::BlockCommitmentCache}, + std::{ + any::Any, + error, + fmt::Debug, + io, + sync::{atomic::AtomicBool, Arc, RwLock}, + }, + thiserror::Error, +}; + +pub type Result = std::result::Result; + +/// Errors returned by plugin calls +#[derive(Error, Debug)] +pub enum RuntimePluginError { + /// Error opening the configuration file; for example, when the file + /// is not found or when the validator process has no permission to read it. + #[error("Error opening config file. Error detail: ({0}).")] + ConfigFileOpenError(#[from] io::Error), + + /// Any custom error defined by the plugin. + #[error("Plugin-defined custom error. Error message: ({0})")] + Custom(Box), + + #[error("Failed to load a runtime plugin")] + FailedToLoadPlugin(#[from] Box), +} + +pub struct PluginDependencies { + pub bank_forks: Arc>, + pub block_commitment_cache: Arc>, + pub exit: Arc, +} + +pub trait RuntimePlugin: Any + Debug + Send + Sync { + fn name(&self) -> &'static str; + fn on_load(&mut self, config_file: &str, dependencies: PluginDependencies) -> Result<()>; + fn on_unload(&mut self); +} diff --git a/runtime-plugin/src/runtime_plugin_admin_rpc_service.rs b/runtime-plugin/src/runtime_plugin_admin_rpc_service.rs new file mode 100644 index 0000000000..fdc33b06c5 --- /dev/null +++ b/runtime-plugin/src/runtime_plugin_admin_rpc_service.rs @@ -0,0 +1,326 @@ +//! RPC interface to dynamically make changes to runtime plugins. + +use { + crossbeam_channel::Sender, + jsonrpc_core::{BoxFuture, ErrorCode, MetaIoHandler, Metadata, Result as JsonRpcResult}, + jsonrpc_core_client::{transports::ipc, RpcError}, + jsonrpc_derive::rpc, + jsonrpc_ipc_server::{ + tokio::{self, sync::oneshot::channel as oneshot_channel}, + RequestContext, ServerBuilder, + }, + jsonrpc_server_utils::tokio::sync::oneshot::Sender as OneShotSender, + log::*, + solana_sdk::exit::Exit, + std::{ + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, RwLock, + }, + }, +}; + +#[derive(Debug)] +pub enum RuntimePluginManagerRpcRequest { + ReloadPlugin { + name: String, + config_file: String, + response_sender: OneShotSender>, + }, + UnloadPlugin { + name: String, + response_sender: OneShotSender>, + }, + LoadPlugin { + config_file: String, + response_sender: OneShotSender>, + }, + ListPlugins { + response_sender: OneShotSender>>, + }, +} + +#[rpc] +pub trait RuntimePluginAdminRpc { + type Metadata; + + #[rpc(meta, name = "reloadPlugin")] + fn reload_plugin( + &self, + meta: Self::Metadata, + name: String, + config_file: String, + ) -> BoxFuture>; + + #[rpc(meta, name = "unloadPlugin")] + fn unload_plugin(&self, meta: Self::Metadata, name: String) -> BoxFuture>; + + #[rpc(meta, name = "loadPlugin")] + fn load_plugin( + &self, + meta: Self::Metadata, + config_file: String, + ) -> BoxFuture>; + + #[rpc(meta, name = "listPlugins")] + fn list_plugins(&self, meta: Self::Metadata) -> BoxFuture>>; +} + +#[derive(Clone)] +pub struct RuntimePluginAdminRpcRequestMetadata { + pub rpc_request_sender: Sender, + pub validator_exit: Arc>, +} + +impl Metadata for RuntimePluginAdminRpcRequestMetadata {} + +fn rpc_path(ledger_path: &Path) -> PathBuf { + #[cfg(target_family = "windows")] + { + // More information about the wackiness of pipe names over at + // https://docs.microsoft.com/en-us/windows/win32/ipc/pipe-names + if let Some(ledger_filename) = ledger_path.file_name() { + PathBuf::from(format!( + "\\\\.\\pipe\\{}-runtime_plugin_admin.rpc", + ledger_filename.to_string_lossy() + )) + } else { + PathBuf::from("\\\\.\\pipe\\runtime_plugin_admin.rpc") + } + } + #[cfg(not(target_family = "windows"))] + { + ledger_path.join("runtime_plugin_admin.rpc") + } +} + +/// Start the Runtime Plugin Admin RPC interface. +pub fn run( + ledger_path: &Path, + metadata: RuntimePluginAdminRpcRequestMetadata, + plugin_exit: Arc, +) { + let rpc_path = rpc_path(ledger_path); + + let event_loop = tokio::runtime::Builder::new_multi_thread() + .thread_name("solRuntimePluginAdminRpc") + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + + std::thread::Builder::new() + .name("solAdminRpc".to_string()) + .spawn(move || { + let mut io = MetaIoHandler::default(); + io.extend_with(RuntimePluginAdminRpcImpl.to_delegate()); + + let validator_exit = metadata.validator_exit.clone(); + + match ServerBuilder::with_meta_extractor(io, move |_req: &RequestContext| { + metadata.clone() + }) + .event_loop_executor(event_loop.handle().clone()) + .start(&format!("{}", rpc_path.display())) + { + Err(e) => { + error!("Unable to start runtime plugin admin rpc service: {e:?}, exiting"); + validator_exit.write().unwrap().exit(); + } + Ok(server) => { + info!("started runtime plugin admin rpc service!"); + let close_handle = server.close_handle(); + let c_plugin_exit = plugin_exit.clone(); + validator_exit + .write() + .unwrap() + .register_exit(Box::new(move || { + close_handle.close(); + c_plugin_exit.store(true, Ordering::Relaxed); + })); + + server.wait(); + plugin_exit.store(true, Ordering::Relaxed); + } + } + }) + .unwrap(); +} + +pub struct RuntimePluginAdminRpcImpl; +impl RuntimePluginAdminRpc for RuntimePluginAdminRpcImpl { + type Metadata = RuntimePluginAdminRpcRequestMetadata; + + fn reload_plugin( + &self, + meta: Self::Metadata, + name: String, + config_file: String, + ) -> BoxFuture> { + Box::pin(async move { + let (response_sender, response_receiver) = oneshot_channel(); + + if meta + .rpc_request_sender + .send(RuntimePluginManagerRpcRequest::ReloadPlugin { + name, + config_file, + response_sender, + }) + .is_err() + { + error!("rpc_request_sender channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + + return Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while sending the request".to_string(), + data: None, + }); + } + + match response_receiver.await { + Err(_) => { + error!("response_receiver channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while awaiting the response" + .to_string(), + data: None, + }) + } + Ok(resp) => resp, + } + }) + } + + fn unload_plugin(&self, meta: Self::Metadata, name: String) -> BoxFuture> { + Box::pin(async move { + let (response_sender, response_receiver) = oneshot_channel(); + + if meta + .rpc_request_sender + .send(RuntimePluginManagerRpcRequest::UnloadPlugin { + name, + response_sender, + }) + .is_err() + { + error!("rpc_request_sender channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + + return Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while sending the request".to_string(), + data: None, + }); + } + + match response_receiver.await { + Err(_) => { + error!("response_receiver channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while awaiting the response" + .to_string(), + data: None, + }) + } + Ok(resp) => resp, + } + }) + } + + fn load_plugin( + &self, + meta: Self::Metadata, + config_file: String, + ) -> BoxFuture> { + Box::pin(async move { + let (response_sender, response_receiver) = oneshot_channel(); + + if meta + .rpc_request_sender + .send(RuntimePluginManagerRpcRequest::LoadPlugin { + config_file, + response_sender, + }) + .is_err() + { + error!("rpc_request_sender channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + + return Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while sending the request".to_string(), + data: None, + }); + } + + match response_receiver.await { + Err(_) => { + error!("response_receiver channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while awaiting the response" + .to_string(), + data: None, + }) + } + Ok(resp) => resp, + } + }) + } + + fn list_plugins(&self, meta: Self::Metadata) -> BoxFuture>> { + Box::pin(async move { + let (response_sender, response_receiver) = oneshot_channel(); + + if meta + .rpc_request_sender + .send(RuntimePluginManagerRpcRequest::ListPlugins { response_sender }) + .is_err() + { + error!("rpc_request_sender channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + + return Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while sending the request".to_string(), + data: None, + }); + } + + match response_receiver.await { + Err(_) => { + error!("response_receiver channel closed, exiting"); + meta.validator_exit.write().unwrap().exit(); + Err(jsonrpc_core::Error { + code: ErrorCode::InternalError, + message: "Internal channel disconnected while awaiting the response" + .to_string(), + data: None, + }) + } + Ok(resp) => resp, + } + }) + } +} + +// Connect to the Runtime Plugin RPC interface +pub async fn connect(ledger_path: &Path) -> Result { + let rpc_path = rpc_path(ledger_path); + if !rpc_path.exists() { + Err(RpcError::Client(format!( + "{} does not exist", + rpc_path.display() + ))) + } else { + ipc::connect::<_, gen_client::Client>(&format!("{}", rpc_path.display())).await + } +} diff --git a/runtime-plugin/src/runtime_plugin_manager.rs b/runtime-plugin/src/runtime_plugin_manager.rs new file mode 100644 index 0000000000..af1dcf2cde --- /dev/null +++ b/runtime-plugin/src/runtime_plugin_manager.rs @@ -0,0 +1,275 @@ +use { + crate::runtime_plugin::{PluginDependencies, RuntimePlugin}, + jsonrpc_core::{serde_json, ErrorCode, Result as JsonRpcResult}, + libloading::Library, + log::*, + solana_runtime::{bank_forks::BankForks, commitment::BlockCommitmentCache}, + std::{ + fs::File, + io::Read, + path::{Path, PathBuf}, + sync::{atomic::AtomicBool, Arc, RwLock}, + }, +}; + +#[derive(thiserror::Error, Debug)] +pub enum RuntimePluginManagerError { + #[error("Cannot open the the plugin config file")] + CannotOpenConfigFile(String), + + #[error("Cannot read the the plugin config file")] + CannotReadConfigFile(String), + + #[error("The config file is not in a valid Json format")] + InvalidConfigFileFormat(String), + + #[error("Plugin library path is not specified in the config file")] + LibPathNotSet, + + #[error("Invalid plugin path")] + InvalidPluginPath, + + #[error("Cannot load plugin shared library")] + PluginLoadError(String), + + #[error("The runtime plugin {0} is already loaded shared library")] + PluginAlreadyLoaded(String), + + #[error("The RuntimePlugin on_load method failed")] + PluginStartError(String), +} + +pub struct RuntimePluginManager { + plugins: Vec>, + libs: Vec, + bank_forks: Arc>, + block_commitment_cache: Arc>, + exit: Arc, +} + +impl RuntimePluginManager { + pub fn new( + bank_forks: Arc>, + block_commitment_cache: Arc>, + exit: Arc, + ) -> Self { + Self { + plugins: vec![], + libs: vec![], + bank_forks, + block_commitment_cache, + exit, + } + } + + /// This method allows dynamic loading of a runtime plugin. + /// Adds to the existing list of loaded plugins. + pub(crate) fn load_plugin( + &mut self, + plugin_config_path: impl AsRef, + ) -> JsonRpcResult { + // First load plugin + let (mut new_plugin, new_lib, config_file) = + load_plugin_from_config(plugin_config_path.as_ref()).map_err(|e| { + jsonrpc_core::Error { + code: ErrorCode::InvalidRequest, + message: format!("Failed to load plugin: {e}"), + data: None, + } + })?; + + // Then see if a plugin with this name already exists, if so return Err. + let name = new_plugin.name(); + if self.plugins.iter().any(|plugin| name.eq(plugin.name())) { + return Err(jsonrpc_core::Error { + code: ErrorCode::InvalidRequest, + message: format!( + "There already exists a plugin named {} loaded. Did not load requested plugin", + name, + ), + data: None, + }); + } + + new_plugin + .on_load( + config_file, + PluginDependencies { + bank_forks: self.bank_forks.clone(), + block_commitment_cache: self.block_commitment_cache.clone(), + exit: self.exit.clone(), + }, + ) + .map_err(|on_load_err| jsonrpc_core::Error { + code: ErrorCode::InvalidRequest, + message: format!( + "on_load method of plugin {} failed: {on_load_err}", + new_plugin.name() + ), + data: None, + })?; + + self.plugins.push(new_plugin); + self.libs.push(new_lib); + + Ok(name.to_string()) + } + + /// Unloads the plugins and loaded plugin libraries, making sure to fire + /// their `on_plugin_unload()` methods so they can do any necessary cleanup. + pub(crate) fn unload_all_plugins(&mut self) { + (0..self.plugins.len()).for_each(|idx| { + self.try_drop_plugin(idx); + }); + } + + pub(crate) fn unload_plugin(&mut self, name: &str) -> JsonRpcResult<()> { + // Check if any plugin names match this one + let Some(idx) = self + .plugins + .iter() + .position(|plugin| plugin.name().eq(name)) + else { + // If we don't find one return an error + return Err(jsonrpc_core::error::Error { + code: ErrorCode::InvalidRequest, + message: String::from("The plugin you requested to unload is not loaded"), + data: None, + }); + }; + + // Unload and drop plugin and lib + self.try_drop_plugin(idx); + + Ok(()) + } + + /// Reloads an existing plugin. + pub(crate) fn reload_plugin(&mut self, name: &str, config_file: &str) -> JsonRpcResult<()> { + // Check if any plugin names match this one + let Some(idx) = self + .plugins + .iter() + .position(|plugin| plugin.name().eq(name)) + else { + // If we don't find one return an error + return Err(jsonrpc_core::error::Error { + code: ErrorCode::InvalidRequest, + message: String::from("The plugin you requested to reload is not loaded"), + data: None, + }); + }; + + self.try_drop_plugin(idx); + + // Try to load plugin, library + // SAFETY: It is up to the validator to ensure this is a valid plugin library. + let (mut new_plugin, new_lib, new_parsed_config_file) = + load_plugin_from_config(config_file.as_ref()).map_err(|err| jsonrpc_core::Error { + code: ErrorCode::InvalidRequest, + message: err.to_string(), + data: None, + })?; + + // Attempt to on_load with new plugin + match new_plugin.on_load( + new_parsed_config_file, + PluginDependencies { + bank_forks: self.bank_forks.clone(), + block_commitment_cache: self.block_commitment_cache.clone(), + exit: self.exit.clone(), + }, + ) { + // On success, push plugin and library + Ok(()) => { + self.plugins.push(new_plugin); + self.libs.push(new_lib); + Ok(()) + } + // On failure, return error + Err(err) => Err(jsonrpc_core::error::Error { + code: ErrorCode::InvalidRequest, + message: format!( + "Failed to start new plugin (previous plugin was dropped!): {err}" + ), + data: None, + }), + } + } + + pub(crate) fn list_plugins(&self) -> JsonRpcResult> { + Ok(self.plugins.iter().map(|p| p.name().to_owned()).collect()) + } + + fn try_drop_plugin(&mut self, idx: usize) { + if idx < self.plugins.len() { + let mut plugin = self.plugins.remove(idx); + let lib = self.libs.remove(idx); + drop(lib); + plugin.on_unload(); + } else { + error!("failed to drop plugin: index {idx} out of bounds"); + } + } +} + +fn load_plugin_from_config( + plugin_config_path: &Path, +) -> Result<(Box, Library, &str), RuntimePluginManagerError> { + type PluginConstructor = unsafe fn() -> *mut dyn RuntimePlugin; + use libloading::Symbol; + + let mut file = match File::open(plugin_config_path) { + Ok(file) => file, + Err(err) => { + return Err(RuntimePluginManagerError::CannotOpenConfigFile(format!( + "Failed to open the plugin config file {plugin_config_path:?}, error: {err:?}" + ))); + } + }; + + let mut contents = String::new(); + if let Err(err) = file.read_to_string(&mut contents) { + return Err(RuntimePluginManagerError::CannotReadConfigFile(format!( + "Failed to read the plugin config file {plugin_config_path:?}, error: {err:?}" + ))); + } + + let result: serde_json::Value = match json5::from_str(&contents) { + Ok(value) => value, + Err(err) => { + return Err(RuntimePluginManagerError::InvalidConfigFileFormat(format!( + "The config file {plugin_config_path:?} is not in a valid Json5 format, error: {err:?}" + ))); + } + }; + + let libpath = result["libpath"] + .as_str() + .ok_or(RuntimePluginManagerError::LibPathNotSet)?; + let mut libpath = PathBuf::from(libpath); + if libpath.is_relative() { + let config_dir = plugin_config_path.parent().ok_or_else(|| { + RuntimePluginManagerError::CannotOpenConfigFile(format!( + "Failed to resolve parent of {plugin_config_path:?}", + )) + })?; + libpath = config_dir.join(libpath); + } + + let config_file = plugin_config_path + .as_os_str() + .to_str() + .ok_or(RuntimePluginManagerError::InvalidPluginPath)?; + + let (plugin, lib) = unsafe { + let lib = Library::new(libpath) + .map_err(|e| RuntimePluginManagerError::PluginLoadError(e.to_string()))?; + let constructor: Symbol = lib + .get(b"_create_plugin") + .map_err(|e| RuntimePluginManagerError::PluginLoadError(e.to_string()))?; + (Box::from_raw(constructor()), lib) + }; + + Ok((plugin, lib, config_file)) +} diff --git a/runtime-plugin/src/runtime_plugin_service.rs b/runtime-plugin/src/runtime_plugin_service.rs new file mode 100644 index 0000000000..5fcb625a26 --- /dev/null +++ b/runtime-plugin/src/runtime_plugin_service.rs @@ -0,0 +1,123 @@ +use { + crate::{ + runtime_plugin::RuntimePluginError, + runtime_plugin_admin_rpc_service::RuntimePluginManagerRpcRequest, + runtime_plugin_manager::RuntimePluginManager, + }, + crossbeam_channel::Receiver, + log::{error, info}, + solana_runtime::{bank_forks::BankForks, commitment::BlockCommitmentCache}, + std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, RwLock, + }, + thread::{self, JoinHandle}, + time::Duration, + }, +}; + +pub struct RuntimePluginService { + plugin_manager: Arc>, + rpc_thread: JoinHandle<()>, +} + +impl RuntimePluginService { + pub fn start( + plugin_config_files: &[PathBuf], + rpc_receiver: Receiver, + bank_forks: Arc>, + block_commitment_cache: Arc>, + exit: Arc, + ) -> Result { + let mut plugin_manager = + RuntimePluginManager::new(bank_forks, block_commitment_cache, exit.clone()); + + for config in plugin_config_files { + let name = plugin_manager + .load_plugin(config) + .map_err(|e| RuntimePluginError::FailedToLoadPlugin(e.into()))?; + info!("Loaded Runtime Plugin: {name}"); + } + + let plugin_manager = Arc::new(RwLock::new(plugin_manager)); + let rpc_thread = + Self::start_rpc_request_handler(rpc_receiver, plugin_manager.clone(), exit); + + Ok(Self { + plugin_manager, + rpc_thread, + }) + } + + pub fn join(self) { + if let Err(e) = self.rpc_thread.join() { + error!("error joining rpc thread: {e:?}"); + } + self.plugin_manager.write().unwrap().unload_all_plugins(); + } + + fn start_rpc_request_handler( + rpc_receiver: Receiver, + plugin_manager: Arc>, + exit: Arc, + ) -> JoinHandle<()> { + thread::Builder::new() + .name("solRuntimePluginRpc".to_string()) + .spawn(move || { + const TIMEOUT: Duration = Duration::from_secs(3); + while !exit.load(Ordering::Relaxed) { + if let Ok(request) = rpc_receiver.recv_timeout(TIMEOUT) { + match request { + RuntimePluginManagerRpcRequest::ListPlugins { response_sender } => { + let plugin_list = plugin_manager.read().unwrap().list_plugins(); + if response_sender.send(plugin_list).is_err() { + error!("response_sender channel disconnected"); + return; + } + } + RuntimePluginManagerRpcRequest::ReloadPlugin { + ref name, + ref config_file, + response_sender, + } => { + let reload_result = plugin_manager + .write() + .unwrap() + .reload_plugin(name, config_file); + if response_sender.send(reload_result).is_err() { + error!("response_sender channel disconnected"); + return; + } + } + RuntimePluginManagerRpcRequest::LoadPlugin { + ref config_file, + response_sender, + } => { + let load_result = + plugin_manager.write().unwrap().load_plugin(config_file); + if response_sender.send(load_result).is_err() { + error!("response_sender channel disconnected"); + return; + } + } + RuntimePluginManagerRpcRequest::UnloadPlugin { + ref name, + response_sender, + } => { + let unload_result = + plugin_manager.write().unwrap().unload_plugin(name); + if response_sender.send(unload_result).is_err() { + error!("response_sender channel disconnected"); + return; + } + } + } + } + } + plugin_manager.write().unwrap().unload_all_plugins(); + }) + .unwrap() + } +} diff --git a/runtime/src/bank.rs b/runtime/src/bank.rs index c5a629d030..71c73e16fc 100644 --- a/runtime/src/bank.rs +++ b/runtime/src/bank.rs @@ -67,7 +67,7 @@ use { }, serde::Serialize, solana_accounts_db::{ - accounts::{AccountAddressFilter, Accounts, PubkeyAccountSlot}, + accounts::{AccountAddressFilter, AccountLocks, Accounts, PubkeyAccountSlot}, accounts_db::{ AccountShrinkThreshold, AccountStorageEntry, AccountsDb, AccountsDbConfig, CalcAccountsHashDataSource, PubkeyHashAccount, VerifyAccountsHashAndLamportsConfig, @@ -323,6 +323,7 @@ impl BankRc { } } +#[derive(Debug)] pub struct LoadAndExecuteTransactionsOutput { pub loaded_transactions: Vec, // Vector of results indicating whether a transaction was executed or could not @@ -340,6 +341,22 @@ pub struct LoadAndExecuteTransactionsOutput { pub error_counters: TransactionErrorMetrics, } +#[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, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AccountData { + pub pubkey: Pubkey, + pub data: AccountSharedData, +} + pub struct TransactionSimulationResult { pub result: Result<()>, pub logs: TransactionLogMessages, @@ -781,7 +798,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 @@ -3278,17 +3295,61 @@ impl Bank { &'a self, transactions: &'b [SanitizedTransaction], transaction_results: impl Iterator>, + additional_read_locks: Option<&HashSet>, + additional_write_locks: Option<&HashSet>, ) -> TransactionBatch<'a, 'b> { - // this lock_results could be: Ok, AccountInUse, WouldExceedBlockMaxLimit or WouldExceedAccountMaxLimit let tx_account_lock_limit = self.get_transaction_account_lock_limit(); let lock_results = self.rc.accounts.lock_accounts_with_results( transactions.iter(), transaction_results, tx_account_lock_limit, + additional_read_locks, + additional_write_locks, ); TransactionBatch::new(lock_results, self, Cow::Borrowed(transactions)) } + /// Prepare a locked transaction batch from a list of sanitized transactions, and their cost + /// limited packing status, where transactions will be locked sequentially until the first failure + pub fn prepare_sequential_sanitized_batch_with_results<'a, 'b>( + &'a self, + transactions: &'b [SanitizedTransaction], + ) -> TransactionBatch<'a, 'b> { + // this lock_results could be: Ok, AccountInUse, AccountLoadedTwice, or TooManyAccountLocks + let tx_account_lock_limit = self.get_transaction_account_lock_limit(); + let lock_results = self + .rc + .accounts + .lock_accounts_sequential_with_results(transactions.iter(), tx_account_lock_limit); + TransactionBatch::new(lock_results, self, Cow::Borrowed(transactions)) + } + + /// Prepare a locked transaction batch from a list of sanitized transactions for simulation. + /// This grabs as many sequential account locks that it can without a RW conflict. However, + /// it uses a temporary version of AccountLocks and not the Bank's account locks, so one can + /// use this during simulation on an unfrozen Bank without worrying about impacting the RW + /// lock usage in replay + pub fn prepare_sequential_sanitized_batch_with_results_for_simulation<'a, 'b>( + &'a self, + transactions: &'b [SanitizedTransaction], + ) -> TransactionBatch<'a, 'b> { + let tx_account_lock_limit = self.get_transaction_account_lock_limit(); + let tx_account_locks_results: Vec> = transactions + .iter() + .map(|tx| tx.get_account_locks(tx_account_lock_limit)) + .collect(); + + let mut account_locks = AccountLocks::default(); + let lock_results = + Accounts::lock_accounts_sequential(&mut account_locks, tx_account_locks_results); + let mut batch = TransactionBatch::new(lock_results, self, Cow::Borrowed(transactions)); + // this is required to ensure that accounts aren't unlocked accidentally, which can be problematic during replay. + // more specifically, during process_entries, if the lock counts are accidentally decremented, + // one might end up replaying a block incorrectly + batch.set_needs_unlock(false); + batch + } + /// Prepare a transaction batch from a single transaction without locking accounts pub fn prepare_unlocked_batch_from_single_tx<'a>( &'a self, @@ -3410,7 +3471,11 @@ impl Bank { } } - fn get_account_overrides_for_simulation(&self, account_keys: &AccountKeys) -> AccountOverrides { + // NOTE: Do not revert this back to private during rebases. + pub fn get_account_overrides_for_simulation( + &self, + account_keys: &AccountKeys, + ) -> AccountOverrides { let mut account_overrides = AccountOverrides::default(); let slot_history_id = sysvar::slot_history::id(); if account_keys.iter().any(|pubkey| *pubkey == slot_history_id) { @@ -3598,6 +3663,30 @@ impl Bank { balances } + 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 + } + + #[allow(clippy::too_many_arguments, clippy::type_complexity)] pub fn load_and_execute_transactions( &self, batch: &TransactionBatch, diff --git a/runtime/src/snapshot_bank_utils.rs b/runtime/src/snapshot_bank_utils.rs index 5b3f83f28a..8e42d859a7 100644 --- a/runtime/src/snapshot_bank_utils.rs +++ b/runtime/src/snapshot_bank_utils.rs @@ -87,13 +87,14 @@ pub fn bank_fields_from_snapshot_archives( storage_access: StorageAccess, ) -> snapshot_utils::Result { let full_snapshot_archive_info = - get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir).ok_or_else(|| { - SnapshotError::NoSnapshotArchives(full_snapshot_archives_dir.as_ref().to_path_buf()) - })?; + get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir, None).ok_or_else( + || SnapshotError::NoSnapshotArchives(full_snapshot_archives_dir.as_ref().to_path_buf()), + )?; 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()?; @@ -298,13 +299,14 @@ pub fn bank_from_latest_snapshot_archives( Option, )> { let full_snapshot_archive_info = - get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir).ok_or_else(|| { - SnapshotError::NoSnapshotArchives(full_snapshot_archives_dir.as_ref().to_path_buf()) - })?; + get_highest_full_snapshot_archive_info(&full_snapshot_archives_dir, None).ok_or_else( + || SnapshotError::NoSnapshotArchives(full_snapshot_archives_dir.as_ref().to_path_buf()), + )?; 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( @@ -2550,7 +2552,7 @@ mod tests { fs::remove_file(full_snapshot_archive_info.unwrap().path()).unwrap(); let highest_full_snapshot_archive = - get_highest_full_snapshot_archive_info(&snapshot_archives_dir).unwrap(); + get_highest_full_snapshot_archive_info(&snapshot_archives_dir, None).unwrap(); let highest_bank_snapshot_post = get_highest_bank_snapshot_post(&bank_snapshots_dir).unwrap(); let highest_bank_snapshot_pre = get_highest_bank_snapshot_pre(&bank_snapshots_dir).unwrap(); diff --git a/runtime/src/snapshot_utils.rs b/runtime/src/snapshot_utils.rs index 5d407eaf0b..7e0b55d9fd 100644 --- a/runtime/src/snapshot_utils.rs +++ b/runtime/src/snapshot_utils.rs @@ -701,7 +701,7 @@ pub fn get_highest_loadable_bank_snapshot( // Otherwise, the bank snapshot's full snapshot slot *must* be the same as // the highest full snapshot archive's slot. let highest_full_snapshot_archive_slot = - get_highest_full_snapshot_archive_slot(&snapshot_config.full_snapshot_archives_dir)?; + get_highest_full_snapshot_archive_slot(&snapshot_config.full_snapshot_archives_dir, None)?; let full_snapshot_file_slot = read_full_snapshot_slot_file(&highest_bank_snapshot.snapshot_dir).ok()?; (full_snapshot_file_slot == highest_full_snapshot_archive_slot).then_some(highest_bank_snapshot) @@ -2066,8 +2066,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()) } @@ -2076,10 +2077,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()) } @@ -2087,8 +2090,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() } @@ -2098,6 +2106,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 @@ -2109,6 +2118,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() } @@ -3136,7 +3148,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) ); } @@ -3162,7 +3174,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) ); @@ -3171,7 +3184,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 ea4ed6dd0f..017b6fb52f 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 27ec9d683c..08a7ed9581 100644 --- a/runtime/src/stakes.rs +++ b/runtime/src/stakes.rs @@ -49,18 +49,18 @@ pub enum InvalidCacheEntryReason { WrongOwner, } -type StakeAccount = stake_account::StakeAccount; +pub type StakeAccount = stake_account::StakeAccount; #[cfg_attr(feature = "frozen-abi", derive(AbiExample))] #[derive(Default, Debug)] -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() } @@ -188,7 +188,7 @@ pub struct Stakes { vote_accounts: VoteAccounts, /// stake_delegations - stake_delegations: ImHashMap, + pub stake_delegations: ImHashMap, /// unused unused: u64, @@ -231,7 +231,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 + Sync, { @@ -471,7 +471,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 ecec27e02e..3e92176db2 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, }; @@ -79,6 +79,28 @@ impl<'a, 'b> TransactionBatch<'a, 'b> { // that validity constraint. self.lock_results = transaction_results; } + + /// 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/agave-install-deploy.sh b/scripts/agave-install-deploy.sh index dcdec14ffb..86575954d3 100755 --- a/scripts/agave-install-deploy.sh +++ b/scripts/agave-install-deploy.sh @@ -57,10 +57,10 @@ esac case $TAG in edge|beta) - DOWNLOAD_URL=https://release.anza.xyz/"$TAG"/solana-release-$TARGET.tar.bz2 + DOWNLOAD_URL=https://release.jito.wtf/"$TAG"/solana-release-$TARGET.tar.bz2 ;; *) - DOWNLOAD_URL=https://github.com/anza-xyz/agave/releases/download/"$TAG"/solana-release-$TARGET.tar.bz2 + DOWNLOAD_URL=https://github.com/jito-foundation/jito-solana/releases/download/"$TAG"/solana-release-$TARGET.tar.bz2 ;; esac diff --git a/scripts/increment-cargo-version.sh b/scripts/increment-cargo-version.sh index 866f442874..41e1994ced 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 70994c921f..4a148dfed5 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -104,6 +104,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 fcc164c0e8..db2fdc8e74 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -18,7 +18,7 @@ program = [] default = [ "borsh", - "full", # functionality that is not compatible or needed for on-chain programs + "full", # functionality that is not compatible or needed for on-chain programs ] full = [ "byteorder", @@ -44,6 +44,9 @@ frozen-abi = [ ] [dependencies] +anchor-lang = { workspace = true } +assert_matches = { workspace = true, optional = true } +base64 = { workspace = true } bincode = { workspace = true } bitflags = { workspace = true, features = ["serde"] } borsh = { workspace = true, optional = 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 95a9b880ff..4fc3561dce 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -60,6 +60,7 @@ pub use solana_program::{ pub use solana_program::{borsh, borsh0_10, borsh1}; 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 a69c366a35..9da9b4d405 100644 --- a/send-transaction-service/Cargo.toml +++ b/send-transaction-service/Cargo.toml @@ -14,6 +14,7 @@ crossbeam-channel = { workspace = true } log = { workspace = true } solana-client = { workspace = true } solana-connection-cache = { workspace = true } +solana-gossip = { workspace = true } solana-measure = { workspace = true } solana-metrics = { workspace = true } solana-runtime = { workspace = true } @@ -23,6 +24,7 @@ solana-tpu-client = { workspace = true } [dev-dependencies] solana-logger = { workspace = true } solana-runtime = { workspace = true, features = ["dev-context-only-utils"] } +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 8cc21b1235..9403658206 100644 --- a/send-transaction-service/src/send_transaction_service.rs +++ b/send-transaction-service/src/send_transaction_service.rs @@ -4,6 +4,7 @@ use { log::*, solana_client::connection_cache::{ConnectionCache, Protocol}, solana_connection_cache::client_connection::ClientConnection as TpuConnection, + solana_gossip::cluster_info::ClusterInfo, solana_measure::measure::Measure, solana_runtime::{bank::Bank, bank_forks::BankForks}, solana_sdk::{ @@ -330,7 +331,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, @@ -345,7 +346,7 @@ impl SendTransactionService { ..Config::default() }; Self::new_with_config( - tpu_address, + cluster_info, bank_forks, leader_info, receiver, @@ -356,7 +357,7 @@ impl SendTransactionService { } pub fn new_with_config( - tpu_address: SocketAddr, + cluster_info: Arc, bank_forks: &Arc>, leader_info: Option, receiver: Receiver, @@ -371,7 +372,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(), @@ -382,7 +383,7 @@ impl SendTransactionService { ); let retry_thread = Self::retry_thread( - tpu_address, + cluster_info, bank_forks.clone(), leader_info_provider, connection_cache.clone(), @@ -400,7 +401,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, @@ -461,6 +462,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, @@ -506,7 +511,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, @@ -539,7 +544,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, @@ -824,27 +832,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 = BankForks::new_rw_arc(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, @@ -860,7 +881,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 = BankForks::new_rw_arc(bank); let (sender, receiver) = bounded(0); @@ -878,7 +899,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/svm/src/account_loader.rs b/svm/src/account_loader.rs index c62963c09e..4cf6c882d3 100644 --- a/svm/src/account_loader.rs +++ b/svm/src/account_loader.rs @@ -6,6 +6,7 @@ use { transaction_processing_callback::TransactionProcessingCallback, }, itertools::Itertools, + log::info, solana_compute_budget::compute_budget_processor::{ process_compute_budget_instructions, ComputeBudgetLimits, }, @@ -235,6 +236,10 @@ fn load_transaction_accounts( } else if let Some(account_override) = account_overrides.and_then(|overrides| overrides.get(key)) { + info!( + "loaded account from cache key: {:?} override: {:?}", + key, account_override + ); (account_override.data().len(), account_override.clone(), 0) } else if let Some(program) = (!instruction_account && !message.is_writable(i)) .then_some(()) diff --git a/svm/src/account_overrides.rs b/svm/src/account_overrides.rs index 8a205a798f..b92ba7d0ad 100644 --- a/svm/src/account_overrides.rs +++ b/svm/src/account_overrides.rs @@ -4,12 +4,16 @@ use { }; /// Encapsulates overridden accounts, typically used for transaction simulations -#[derive(Default)] +#[derive(Clone, Default, Debug)] pub struct AccountOverrides { accounts: HashMap, } impl AccountOverrides { + pub fn upsert_account_overrides(&mut self, other: AccountOverrides) { + self.accounts.extend(other.accounts); + } + /// Insert or remove an account with a given pubkey to/from the list of overrides. pub fn set_account(&mut self, pubkey: &Pubkey, account: Option) { match account { diff --git a/svm/src/transaction_processor.rs b/svm/src/transaction_processor.rs index 843060e22e..965ff80028 100644 --- a/svm/src/transaction_processor.rs +++ b/svm/src/transaction_processor.rs @@ -245,7 +245,8 @@ impl TransactionBatchProcessor { environment .rent_collector .unwrap_or(&RentCollector::default()), - &mut error_metrics + &mut error_metrics, + config.account_overrides )); let mut program_cache_time = Measure::start("program_cache"); @@ -387,6 +388,7 @@ impl TransactionBatchProcessor { fee_structure: &FeeStructure, rent_collector: &RentCollector, error_counters: &mut TransactionErrorMetrics, + account_overrides: Option<&AccountOverrides>, ) -> Vec { sanitized_txs .iter() @@ -402,6 +404,7 @@ impl TransactionBatchProcessor { fee_structure, rent_collector, error_counters, + account_overrides, ) }) }) @@ -420,6 +423,7 @@ impl TransactionBatchProcessor { fee_structure: &FeeStructure, rent_collector: &RentCollector, error_counters: &mut TransactionErrorMetrics, + account_overrides: Option<&AccountOverrides>, ) -> transaction::Result { let compute_budget_limits = process_compute_budget_instructions( message.program_instructions_iter(), @@ -430,8 +434,14 @@ impl TransactionBatchProcessor { })?; let fee_payer_address = message.fee_payer(); - let Some(mut fee_payer_account) = callbacks.get_account_shared_data(fee_payer_address) - else { + let maybe_account = if let Some(account_override) = + account_overrides.and_then(|overrides| overrides.get(fee_payer_address)) + { + Some(account_override.clone()) + } else { + callbacks.get_account_shared_data(fee_payer_address) + }; + let Some(mut fee_payer_account) = maybe_account else { error_counters.account_not_found += 1; return Err(TransactionError::AccountNotFound); }; @@ -2004,6 +2014,7 @@ mod tests { &FeeStructure::default(), &rent_collector, &mut error_counters, + None, ); let post_validation_fee_payer_account = { @@ -2076,6 +2087,7 @@ mod tests { &FeeStructure::default(), &rent_collector, &mut error_counters, + None, ); let post_validation_fee_payer_account = { @@ -2123,6 +2135,7 @@ mod tests { &FeeStructure::default(), &RentCollector::default(), &mut error_counters, + None, ); assert_eq!(error_counters.account_not_found, 1); @@ -2155,6 +2168,7 @@ mod tests { &FeeStructure::default(), &RentCollector::default(), &mut error_counters, + None, ); assert_eq!(error_counters.insufficient_funds, 1); @@ -2191,6 +2205,7 @@ mod tests { &FeeStructure::default(), &rent_collector, &mut error_counters, + None, ); assert_eq!( @@ -2225,6 +2240,7 @@ mod tests { &FeeStructure::default(), &RentCollector::default(), &mut error_counters, + None, ); assert_eq!(error_counters.invalid_account_for_fee, 1); @@ -2256,6 +2272,7 @@ mod tests { &FeeStructure::default(), &RentCollector::default(), &mut error_counters, + None, ); assert_eq!(error_counters.invalid_compute_budget, 1); @@ -2317,6 +2334,7 @@ mod tests { &FeeStructure::default(), &rent_collector, &mut error_counters, + None, ); let post_validation_fee_payer_account = { @@ -2374,10 +2392,13 @@ mod tests { &FeeStructure::default(), &rent_collector, &mut error_counters, + None, ); assert_eq!(error_counters.insufficient_funds, 1); assert_eq!(result, Err(TransactionError::InsufficientFundsForFee)); } } + + // TODO (LB): test loading invalid fee payer from cache w/ valid (and invalid) fees } diff --git a/test-validator/src/lib.rs b/test-validator/src/lib.rs index 39616c1fdd..4c21c81c2e 100644 --- a/test-validator/src/lib.rs +++ b/test-validator/src/lib.rs @@ -1025,6 +1025,7 @@ impl TestValidator { config.tpu_enable_udp, 32, // max connections per IpAddr per minute for test 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/timings/src/lib.rs b/timings/src/lib.rs index efc918c71d..85f0ceb594 100644 --- a/timings/src/lib.rs +++ b/timings/src/lib.rs @@ -10,7 +10,7 @@ use { }, }; -#[derive(Default, Debug, PartialEq, Eq)] +#[derive(Clone, Default, Debug, PartialEq, Eq)] pub struct ProgramTiming { pub accumulated_us: u64, pub accumulated_units: u64, @@ -58,6 +58,7 @@ pub enum ExecuteTimingType { CheckBlockLimitsUs, } +#[derive(Clone)] pub struct Metrics([u64; ExecuteTimingType::CARDINALITY]); impl Index for Metrics { @@ -300,7 +301,7 @@ eager_macro_rules! { $eager_1 } } -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct ExecuteTimings { pub metrics: Metrics, pub details: ExecuteDetailsTimings, @@ -324,9 +325,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, @@ -346,7 +359,7 @@ impl ExecuteProcessInstructionTimings { } } -#[derive(Default, Debug)] +#[derive(Clone, Default, Debug)] pub struct ExecuteAccessoryTimings { pub feature_set_clone_us: u64, pub get_executors_us: u64, @@ -366,7 +379,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/tip-distributor/Cargo.toml b/tip-distributor/Cargo.toml new file mode 100644 index 0000000000..76682d220a --- /dev/null +++ b/tip-distributor/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "solana-tip-distributor" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +description = "Collection of binaries used to distribute MEV rewards to delegators and validators." +publish = false + +[dependencies] +anchor-lang = { workspace = true } +clap = { version = "4.1.11", features = ["derive", "env"] } +crossbeam-channel = { workspace = true } +env_logger = { workspace = true } +futures = { workspace = true } +gethostname = { workspace = true } +im = { workspace = true } +itertools = { workspace = true } +jito-tip-distribution = { workspace = true } +jito-tip-payment = { workspace = true } +log = { workspace = true } +num-traits = { workspace = true } +rand = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +solana-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-transaction-status = { workspace = true } +solana-vote = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } + +[dev-dependencies] +solana-runtime = { workspace = true, features = ["dev-context-only-utils"] } +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" diff --git a/tip-distributor/README.md b/tip-distributor/README.md new file mode 100644 index 0000000000..fec682879a --- /dev/null +++ b/tip-distributor/README.md @@ -0,0 +1,52 @@ +# Tip Distributor +This library and collection of binaries are responsible for generating and uploading merkle roots to the on-chain +tip-distribution program found [here](https://github.com/jito-foundation/jito-programs/blob/submodule/tip-payment/programs/tip-distribution/src/lib.rs). + +## Background +Each individual validator is assigned a new PDA per epoch where their share of tips, in lamports, will be stored. +At the end of the epoch it's expected that validators take a commission and then distribute the rest of the funds +to their delegators such that delegators receive rewards proportional to their respective delegations. The distribution +mechanism is via merkle proofs similar to how airdrops work. + +The merkle roots are calculated off-chain and uploaded to the validator's **TipDistributionAccount** PDA. Validators may +elect an account to upload the merkle roots on their behalf. Once uploaded, users can invoke the **claim** instruction +and receive the rewards they're entitled to. Once all funds are claimed by users the validator can close the account and +refunded the rent. + +## Scripts + +### stake-meta-generator + +This script generates a JSON file identifying individual stake delegations to a validator, along with amount of lamports +in each validator's **TipDistributionAccount**. All validators will be contained in the JSON list, regardless of whether +the validator is a participant in the system; participant being indicative of running the jito-solana client to accept tips +having initialized a **TipDistributionAccount** PDA account for the epoch. + +One edge case that we've taken into account is the last validator in an epoch N receives tips but those tips don't get transferred +out into the PDA until some slot in epoch N + 1. Due to this we cannot rely on the bank's state at epoch N for lamports amount +in the PDAs. We use the bank solely to take a snapshot of delegations, but an RPC node to fetch the PDA lamports for more up-to-date data. + +### merkle-root-generator +This script accepts a path to the above JSON file as one of its arguments, and generates a merkle-root into a JSON file. + +### merkle-root-uploader +Uploads the root on-chain. + +### claim-mev-tips +This reads the file outputted by `merkle-root-generator` and finds all eligible accounts to receive mev tips. Transactions +are created and sent to the RPC server. + + +## How it works? +In order to use this library as the merkle root creator one must follow the following steps: +1. Download a ledger snapshot containing the slot of interest, i.e. the last slot in an epoch. The Solana foundation has snapshots that can be found [here](https://console.cloud.google.com/storage/browser/mainnet-beta-ledger-us-ny5). +2. Download the snapshot onto your worker machine (where this script will run). +3. Run `solana-ledger-tool -l ${PATH_TO_LEDGER} create-snapshot ${YOUR_SLOT} ${WHERE_TO_CREATE_SNAPSHOT}` + 1. The snapshot created at `${WHERE_TO_CREATE_SNAPSHOT}` will have the highest slot of `${YOUR_SLOT}`, assuming you downloaded the correct snapshot. +4. Run `stake-meta-generator --ledger-path ${WHERE_TO_CREATE_SNAPSHOT} --tip-distribution-program-id ${PUBKEY} --out-path ${JSON_OUT_PATH} --snapshot-slot ${SLOT} --rpc-url ${URL}` + 1. Note: `${WHERE_TO_CREATE_SNAPSHOT}` must be the same in steps 3 & 4. +5. Run `merkle-root-generator --stake-meta-coll-path ${STAKE_META_COLLECTION_JSON} --rpc-url ${URL} --out-path ${MERKLE_ROOT_PATH}` +6. Run `merkle-root-uploader --out-path ${MERKLE_ROOT_PATH} --keypair-path ${KEYPAIR_PATH} --rpc-url ${URL} --tip-distribution-program-id ${PROGRAM_ID}` +7. Run `solana-claim-mev-tips --merkle-trees-path /solana/ledger/autosnapshot/merkle-tree-221615999.json --rpc-url ${URL} --tip-distribution-program-id ${PROGRAM_ID} --keypair-path ${KEYPAIR_PATH}` + +Voila! diff --git a/tip-distributor/src/bin/claim-mev-tips.rs b/tip-distributor/src/bin/claim-mev-tips.rs new file mode 100644 index 0000000000..dd57db9231 --- /dev/null +++ b/tip-distributor/src/bin/claim-mev-tips.rs @@ -0,0 +1,190 @@ +//! This binary claims MEV tips. +use { + clap::Parser, + futures::future::join_all, + gethostname::gethostname, + log::*, + solana_metrics::{datapoint_error, datapoint_info, set_host_id}, + solana_sdk::{ + pubkey::Pubkey, + signature::{read_keypair_file, Keypair}, + }, + solana_tip_distributor::{ + claim_mev_workflow::{claim_mev_tips, ClaimMevError}, + read_json_from_file, + reclaim_rent_workflow::reclaim_rent, + GeneratedMerkleTreeCollection, + }, + std::{ + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, + }, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Path to JSON file containing the [GeneratedMerkleTreeCollection] object. + #[arg(long, env)] + merkle_trees_path: PathBuf, + + /// RPC to send transactions through + #[arg(long, env, default_value = "http://localhost:8899")] + rpc_url: String, + + /// Tip distribution program ID + #[arg(long, env)] + tip_distribution_program_id: Pubkey, + + /// Path to keypair + #[arg(long, env)] + keypair_path: PathBuf, + + /// Limits how long before send loop runs before stopping + #[arg(long, env, default_value_t = 60 * 60)] + max_retry_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, + + /// The price to pay for priority fee + #[arg(long, env, default_value_t = 1)] + micro_lamports: u64, +} + +async fn start_mev_claim_process( + merkle_trees: GeneratedMerkleTreeCollection, + rpc_url: String, + tip_distribution_program_id: Pubkey, + signer: Arc, + max_loop_duration: Duration, + micro_lamports: u64, +) -> Result<(), ClaimMevError> { + let start = Instant::now(); + + match claim_mev_tips( + &merkle_trees, + rpc_url, + tip_distribution_program_id, + signer, + max_loop_duration, + micro_lamports, + ) + .await + { + Err(e) => { + datapoint_error!( + "claim_mev_workflow-claim_error", + ("epoch", merkle_trees.epoch, i64), + ("error", 1, i64), + ("err_str", e.to_string(), String), + ("elapsed_us", start.elapsed().as_micros(), i64), + ); + Err(e) + } + Ok(()) => { + datapoint_info!( + "claim_mev_workflow-claim_completion", + ("epoch", merkle_trees.epoch, i64), + ("elapsed_us", start.elapsed().as_micros(), i64), + ); + Ok(()) + } + } +} + +async fn start_rent_claim( + rpc_url: String, + tip_distribution_program_id: Pubkey, + signer: Arc, + max_loop_duration: Duration, + should_reclaim_tdas: bool, + micro_lamports: u64, + epoch: u64, +) -> Result<(), ClaimMevError> { + let start = Instant::now(); + match reclaim_rent( + rpc_url, + tip_distribution_program_id, + signer, + max_loop_duration, + should_reclaim_tdas, + micro_lamports, + ) + .await + { + Err(e) => { + datapoint_error!( + "claim_mev_workflow-reclaim_rent_error", + ("epoch", epoch, i64), + ("error", 1, i64), + ("err_str", e.to_string(), String), + ("elapsed_us", start.elapsed().as_micros(), i64), + ); + Err(e) + } + Ok(()) => { + datapoint_info!( + "claim_mev_workflow-reclaim_rent_completion", + ("epoch", epoch, i64), + ("elapsed_us", start.elapsed().as_micros(), i64), + ); + Ok(()) + } + } +} + +#[tokio::main] +async fn main() -> Result<(), ClaimMevError> { + env_logger::init(); + + gethostname() + .into_string() + .map(set_host_id) + .expect("set hostname"); + + let args: Args = Args::parse(); + let keypair = Arc::new(read_keypair_file(&args.keypair_path).expect("read keypair file")); + let merkle_trees: GeneratedMerkleTreeCollection = + read_json_from_file(&args.merkle_trees_path).expect("read GeneratedMerkleTreeCollection"); + let max_loop_duration = Duration::from_secs(args.max_retry_duration_secs); + + info!( + "Starting to claim mev tips for epoch: {}", + merkle_trees.epoch + ); + let epoch = merkle_trees.epoch; + + let mut futs = vec![]; + futs.push(tokio::spawn(start_mev_claim_process( + merkle_trees, + args.rpc_url.clone(), + args.tip_distribution_program_id, + keypair.clone(), + max_loop_duration, + args.micro_lamports, + ))); + if args.should_reclaim_rent { + futs.push(tokio::spawn(start_rent_claim( + args.rpc_url.clone(), + args.tip_distribution_program_id, + keypair.clone(), + max_loop_duration, + args.should_reclaim_tdas, + args.micro_lamports, + epoch, + ))); + } + let results = join_all(futs).await; + solana_metrics::flush(); // sometimes last datapoint doesn't get emitted. this increases likelihood. + for r in results { + r.map_err(|e| ClaimMevError::UncaughtError { e: e.to_string() })??; + } + Ok(()) +} diff --git a/tip-distributor/src/bin/merkle-root-generator.rs b/tip-distributor/src/bin/merkle-root-generator.rs new file mode 100644 index 0000000000..9f2d0f9a4e --- /dev/null +++ b/tip-distributor/src/bin/merkle-root-generator.rs @@ -0,0 +1,34 @@ +//! This binary generates a merkle tree for each [TipDistributionAccount]; they are derived +//! using a user provided [StakeMetaCollection] JSON file. + +use { + clap::Parser, log::*, + solana_tip_distributor::merkle_root_generator_workflow::generate_merkle_root, + std::path::PathBuf, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Path to JSON file containing the [StakeMetaCollection] object. + #[arg(long, env)] + stake_meta_coll_path: PathBuf, + + /// RPC to send transactions through. Used to validate what's being claimed is equal to TDA balance minus rent. + #[arg(long, env)] + rpc_url: String, + + /// Path to JSON file to get populated with tree node data. + #[arg(long, env)] + out_path: PathBuf, +} + +fn main() { + env_logger::init(); + info!("Starting merkle-root-generator workflow..."); + + let args: Args = Args::parse(); + generate_merkle_root(&args.stake_meta_coll_path, &args.out_path, &args.rpc_url) + .expect("merkle tree produced"); + info!("saved merkle roots to {:?}", args.stake_meta_coll_path); +} diff --git a/tip-distributor/src/bin/merkle-root-uploader.rs b/tip-distributor/src/bin/merkle-root-uploader.rs new file mode 100644 index 0000000000..9000ce66d0 --- /dev/null +++ b/tip-distributor/src/bin/merkle-root-uploader.rs @@ -0,0 +1,54 @@ +use { + clap::Parser, log::info, solana_sdk::pubkey::Pubkey, + solana_tip_distributor::merkle_root_upload_workflow::upload_merkle_root, std::path::PathBuf, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Path to JSON file containing the [StakeMetaCollection] object. + #[arg(long, env)] + merkle_root_path: PathBuf, + + /// The path to the keypair used to sign and pay for the `upload_merkle_root` transactions. + #[arg(long, env)] + keypair_path: PathBuf, + + /// The RPC to send transactions to. + #[arg(long, env)] + rpc_url: String, + + /// Tip distribution program ID + #[arg(long, env)] + tip_distribution_program_id: Pubkey, + + /// Rate-limits the maximum number of requests per RPC connection + #[arg(long, env, default_value_t = 100)] + max_concurrent_rpc_get_reqs: usize, + + /// Number of transactions to send to RPC at a time. + #[arg(long, env, default_value_t = 64)] + txn_send_batch_size: usize, +} + +fn main() { + env_logger::init(); + + let args: Args = Args::parse(); + + info!("starting merkle root uploader..."); + if let Err(e) = upload_merkle_root( + &args.merkle_root_path, + &args.keypair_path, + &args.rpc_url, + &args.tip_distribution_program_id, + args.max_concurrent_rpc_get_reqs, + args.txn_send_batch_size, + ) { + panic!("failed to upload merkle roots: {:?}", e); + } + info!( + "uploaded merkle roots from file {:?}", + args.merkle_root_path + ); +} diff --git a/tip-distributor/src/bin/stake-meta-generator.rs b/tip-distributor/src/bin/stake-meta-generator.rs new file mode 100644 index 0000000000..be7993be02 --- /dev/null +++ b/tip-distributor/src/bin/stake-meta-generator.rs @@ -0,0 +1,67 @@ +//! This binary is responsible for generating a JSON file that contains meta-data about stake +//! & delegations given a ledger snapshot directory. The JSON file is structured as an array +//! of [StakeMeta] objects. + +use { + clap::Parser, + log::*, + solana_sdk::{clock::Slot, pubkey::Pubkey}, + solana_tip_distributor::{self, stake_meta_generator_workflow::generate_stake_meta}, + std::{ + fs::{self}, + path::PathBuf, + process::exit, + }, +}; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + /// Ledger path, where you created the snapshot. + #[arg(long, env, value_parser = Args::ledger_path_parser)] + ledger_path: PathBuf, + + /// The tip-distribution program id. + #[arg(long, env)] + tip_distribution_program_id: Pubkey, + + /// The tip-payment program id. + #[arg(long, env)] + tip_payment_program_id: Pubkey, + + /// Path to JSON file populated with the [StakeMetaCollection] object. + #[arg(long, env)] + out_path: String, + + /// The expected snapshot slot. + #[arg(long, env)] + snapshot_slot: Slot, +} + +impl Args { + fn ledger_path_parser(ledger_path: &str) -> Result { + Ok(fs::canonicalize(ledger_path).unwrap_or_else(|err| { + error!("Unable to access ledger path '{}': {}", ledger_path, err); + exit(1); + })) + } +} + +fn main() { + env_logger::init(); + info!("Starting stake-meta-generator..."); + + let args: Args = Args::parse(); + + if let Err(e) = generate_stake_meta( + &args.ledger_path, + &args.snapshot_slot, + &args.tip_distribution_program_id, + &args.out_path, + &args.tip_payment_program_id, + ) { + error!("error producing stake-meta: {:?}", e); + } else { + info!("produced stake meta"); + } +} diff --git a/tip-distributor/src/claim_mev_workflow.rs b/tip-distributor/src/claim_mev_workflow.rs new file mode 100644 index 0000000000..62b0b6fb02 --- /dev/null +++ b/tip-distributor/src/claim_mev_workflow.rs @@ -0,0 +1,398 @@ +use { + crate::{send_until_blockhash_expires, GeneratedMerkleTreeCollection}, + anchor_lang::{AccountDeserialize, InstructionData, ToAccountMetas}, + itertools::Itertools, + jito_tip_distribution::state::{ClaimStatus, Config, TipDistributionAccount}, + log::{error, info, warn}, + rand::{prelude::SliceRandom, thread_rng}, + solana_client::nonblocking::rpc_client::RpcClient, + solana_metrics::datapoint_info, + solana_program::{ + fee_calculator::DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE, native_token::LAMPORTS_PER_SOL, + system_program, + }, + solana_rpc_client_api::config::RpcSimulateTransactionConfig, + solana_sdk::{ + account::Account, + commitment_config::CommitmentConfig, + compute_budget::ComputeBudgetInstruction, + instruction::Instruction, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, + }, + std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, + }, + thiserror::Error, +}; + +#[derive(Error, Debug)] +pub enum ClaimMevError { + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + JsonError(#[from] serde_json::Error), + + #[error(transparent)] + AnchorError(anchor_lang::error::Error), + + #[error(transparent)] + RpcError(#[from] solana_rpc_client_api::client_error::Error), + + #[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, + }, + + #[error("Not finished with job, transactions left {transactions_left}")] + NotFinished { transactions_left: usize }, + + #[error("UncaughtError {e:?}")] + UncaughtError { e: String }, +} + +pub async fn get_claim_transactions_for_valid_unclaimed( + rpc_client: &RpcClient, + merkle_trees: &GeneratedMerkleTreeCollection, + tip_distribution_program_id: Pubkey, + micro_lamports: u64, + payer_pubkey: Pubkey, +) -> Result, ClaimMevError> { + let tree_nodes = merkle_trees + .generated_merkle_trees + .iter() + .flat_map(|tree| &tree.tree_nodes) + .collect_vec(); + + info!( + "reading tip distribution related accounts for epoch {}", + merkle_trees.epoch + ); + + let start = Instant::now(); + + let tda_pubkeys = merkle_trees + .generated_merkle_trees + .iter() + .map(|tree| tree.tip_distribution_account) + .collect_vec(); + let tdas: HashMap = crate::get_batched_accounts(rpc_client, &tda_pubkeys) + .await? + .into_iter() + .filter_map(|(pubkey, a)| Some((pubkey, a?))) + .collect(); + + let claimant_pubkeys = tree_nodes + .iter() + .map(|tree_node| tree_node.claimant) + .collect_vec(); + let claimants: HashMap = + crate::get_batched_accounts(rpc_client, &claimant_pubkeys) + .await? + .into_iter() + .filter_map(|(pubkey, a)| Some((pubkey, a?))) + .collect(); + + let claim_status_pubkeys = tree_nodes + .iter() + .map(|tree_node| tree_node.claim_status_pubkey) + .collect_vec(); + let claim_statuses: HashMap = + crate::get_batched_accounts(rpc_client, &claim_status_pubkeys) + .await? + .into_iter() + .filter_map(|(pubkey, a)| Some((pubkey, a?))) + .collect(); + + let elapsed_us = start.elapsed().as_micros(); + + // can be helpful for determining mismatch in state between requested and read + datapoint_info!( + "claim_mev-get_claim_transactions_account_data", + ("elapsed_us", elapsed_us, i64), + ("tdas", tda_pubkeys.len(), i64), + ("tdas_onchain", tdas.len(), i64), + ("claimants", claimant_pubkeys.len(), i64), + ("claimants_onchain", claimants.len(), i64), + ("claim_statuses", claim_status_pubkeys.len(), i64), + ("claim_statuses_onchain", claim_statuses.len(), i64), + ); + + let transactions = build_mev_claim_transactions( + tip_distribution_program_id, + merkle_trees, + tdas, + claimants, + claim_statuses, + micro_lamports, + payer_pubkey, + ); + + Ok(transactions) +} + +pub async fn claim_mev_tips( + merkle_trees: &GeneratedMerkleTreeCollection, + rpc_url: String, + tip_distribution_program_id: Pubkey, + keypair: Arc, + max_loop_duration: Duration, + micro_lamports: u64, +) -> Result<(), ClaimMevError> { + let rpc_client = RpcClient::new_with_timeout_and_commitment( + rpc_url, + Duration::from_secs(300), + CommitmentConfig::confirmed(), + ); + + let start = Instant::now(); + while start.elapsed() <= max_loop_duration { + let mut all_claim_transactions = get_claim_transactions_for_valid_unclaimed( + &rpc_client, + merkle_trees, + tip_distribution_program_id, + micro_lamports, + keypair.pubkey(), + ) + .await?; + + datapoint_info!( + "claim_mev_tips-send_summary", + ("claim_transactions_left", all_claim_transactions.len(), i64), + ); + + if all_claim_transactions.is_empty() { + return Ok(()); + } + + all_claim_transactions.shuffle(&mut thread_rng()); + let transactions: Vec<_> = all_claim_transactions.into_iter().take(10_000).collect(); + + // only check balance for the ones we need to currently send since reclaim rent running in parallel + if let Some((start_balance, desired_balance, sol_to_deposit)) = + is_sufficient_balance(&keypair.pubkey(), &rpc_client, transactions.len() as u64).await + { + return Err(ClaimMevError::InsufficientBalance { + desired_balance, + payer: keypair.pubkey(), + start_balance, + sol_to_deposit, + }); + } + + let blockhash = rpc_client.get_latest_blockhash().await?; + let _ = send_until_blockhash_expires(&rpc_client, transactions, blockhash, &keypair).await; + } + + let transactions = get_claim_transactions_for_valid_unclaimed( + &rpc_client, + merkle_trees, + tip_distribution_program_id, + micro_lamports, + keypair.pubkey(), + ) + .await?; + if transactions.is_empty() { + return Ok(()); + } + + // if more transactions left, we'll simulate them all to make sure its not an uncaught error + let mut is_error = false; + let mut error_str = String::new(); + for tx in &transactions { + match rpc_client + .simulate_transaction_with_config( + tx, + RpcSimulateTransactionConfig { + sig_verify: false, + replace_recent_blockhash: true, + commitment: Some(CommitmentConfig::processed()), + ..RpcSimulateTransactionConfig::default() + }, + ) + .await + { + Ok(_) => {} + Err(e) => { + error_str = e.to_string(); + is_error = true; + + match e.get_transaction_error() { + None => { + break; + } + Some(e) => { + warn!("transaction error. tx: {:?} error: {:?}", tx, e); + break; + } + } + } + } + } + + if is_error { + Err(ClaimMevError::UncaughtError { e: error_str }) + } else { + Err(ClaimMevError::NotFinished { + transactions_left: transactions.len(), + }) + } +} + +/// Returns a list of claim transactions for valid, unclaimed MEV tips +/// A valid, unclaimed transaction consists of the following: +/// - there must be lamports to claim for the tip distribution account. +/// - there must be a merkle root. +/// - the claimant (typically a stake account) must exist. +/// - the claimant (typically a stake account) must have a non-zero amount of tips to claim +/// - the claimant must have enough lamports post-claim to be rent-exempt. +/// - note: there aren't any rent exempt accounts on solana mainnet anymore. +/// - it must not have already been claimed. +fn build_mev_claim_transactions( + tip_distribution_program_id: Pubkey, + merkle_trees: &GeneratedMerkleTreeCollection, + tdas: HashMap, + claimants: HashMap, + claim_status: HashMap, + micro_lamports: u64, + payer_pubkey: Pubkey, +) -> Vec { + let tip_distribution_accounts: HashMap = tdas + .iter() + .filter_map(|(pubkey, account)| { + Some(( + *pubkey, + TipDistributionAccount::try_deserialize(&mut account.data.as_slice()).ok()?, + )) + }) + .collect(); + + let claim_statuses: HashMap = claim_status + .iter() + .filter_map(|(pubkey, account)| { + Some(( + *pubkey, + ClaimStatus::try_deserialize(&mut account.data.as_slice()).ok()?, + )) + }) + .collect(); + + datapoint_info!( + "build_mev_claim_transactions", + ( + "tip_distribution_accounts", + tip_distribution_accounts.len(), + i64 + ), + ("claim_statuses", claim_statuses.len(), i64), + ); + + let tip_distribution_config = + Pubkey::find_program_address(&[Config::SEED], &tip_distribution_program_id).0; + + let mut instructions = Vec::with_capacity(claimants.len()); + for tree in &merkle_trees.generated_merkle_trees { + if tree.max_total_claim == 0 { + continue; + } + + // if unwrap panics, there's a bug in the merkle tree code because the merkle tree code relies on the state + // of the chain to claim. + let tip_distribution_account = tip_distribution_accounts + .get(&tree.tip_distribution_account) + .unwrap(); + + // can continue here, as there might be tip distribution accounts this account doesn't upload for + if tip_distribution_account.merkle_root.is_none() { + continue; + } + + for node in &tree.tree_nodes { + // doesn't make sense to claim for claimants that don't exist anymore + // can't claim for something already claimed + // don't need to claim for claimants that get 0 MEV + if !claimants.contains_key(&node.claimant) + || claim_statuses.contains_key(&node.claim_status_pubkey) + || node.amount == 0 + { + continue; + } + + instructions.push(Instruction { + program_id: tip_distribution_program_id, + data: jito_tip_distribution::instruction::Claim { + proof: node.proof.clone().unwrap(), + amount: node.amount, + bump: node.claim_status_bump, + } + .data(), + accounts: jito_tip_distribution::accounts::Claim { + config: tip_distribution_config, + tip_distribution_account: tree.tip_distribution_account, + claimant: node.claimant, + claim_status: node.claim_status_pubkey, + payer: payer_pubkey, + system_program: system_program::id(), + } + .to_account_metas(None), + }); + } + } + + // TODO (LB): see if we can do >1 claim here + let transactions: Vec = instructions + .into_iter() + .map(|claim_ix| { + let priority_fee_ix = ComputeBudgetInstruction::set_compute_unit_price(micro_lamports); + Transaction::new_with_payer(&[priority_fee_ix, claim_ix], Some(&payer_pubkey)) + }) + .collect(); + + transactions +} + +/// heuristic to make sure we have enough funds to cover the rent costs if epoch has many validators +/// If insufficient funds, returns start balance, desired balance, and amount of sol to deposit +async fn is_sufficient_balance( + payer: &Pubkey, + rpc_client: &RpcClient, + instruction_count: u64, +) -> Option<(u64, u64, u64)> { + let start_balance = rpc_client + .get_balance(payer) + .await + .expect("Failed to get starting balance"); + // most amounts are for 0 lamports. had 1736 non-zero claims out of 164742 + let min_rent_per_claim = rpc_client + .get_minimum_balance_for_rent_exemption(ClaimStatus::SIZE) + .await + .expect("Failed to calculate min rent"); + let desired_balance = instruction_count + .checked_mul( + min_rent_per_claim + .checked_add(DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE) + .unwrap(), + ) + .unwrap(); + if start_balance < desired_balance { + let sol_to_deposit = desired_balance + .checked_sub(start_balance) + .unwrap() + .checked_add(LAMPORTS_PER_SOL) + .unwrap() + .checked_sub(1) + .unwrap() + .checked_div(LAMPORTS_PER_SOL) + .unwrap(); // rounds up to nearest sol + Some((start_balance, desired_balance, sol_to_deposit)) + } else { + None + } +} diff --git a/tip-distributor/src/lib.rs b/tip-distributor/src/lib.rs new file mode 100644 index 0000000000..8ee6b50f5d --- /dev/null +++ b/tip-distributor/src/lib.rs @@ -0,0 +1,1062 @@ +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, SerializableTransaction}, + }, + 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}, + config::RpcSendTransactionConfig, + request::{RpcError, RpcResponseErrorData, MAX_MULTIPLE_ACCOUNTS}, + response::RpcSimulateTransactionResult, + }, + solana_sdk::{ + account::{Account, AccountSharedData, ReadableAccount}, + clock::Slot, + commitment_config::{CommitmentConfig, CommitmentLevel}, + hash::{Hash, Hasher}, + pubkey::Pubkey, + signature::{Keypair, Signature}, + stake_history::Epoch, + transaction::{ + Transaction, + TransactionError::{self}, + }, + }, + solana_transaction_status::TransactionStatus, + std::{ + collections::{HashMap, HashSet}, + fs::File, + io::BufReader, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, + }, + tokio::{sync::Semaphore, time::sleep}, +}; + +#[derive(Clone, Deserialize, Serialize, Debug)] +pub struct GeneratedMerkleTreeCollection { + pub generated_merkle_trees: Vec, + pub bank_hash: String, + pub epoch: Epoch, + pub slot: Slot, +} + +#[derive(Clone, Eq, Debug, Hash, PartialEq, Deserialize, Serialize)] +pub struct GeneratedMerkleTree { + #[serde(with = "pubkey_string_conversion")] + pub tip_distribution_account: Pubkey, + #[serde(with = "pubkey_string_conversion")] + pub merkle_root_upload_authority: Pubkey, + pub merkle_root: Hash, + pub tree_nodes: Vec, + pub max_total_claim: u64, + pub max_num_nodes: u64, +} + +pub struct TipPaymentPubkeys { + config_pda: Pubkey, + tip_pdas: Vec, +} + +fn emit_inconsistent_tree_node_amount_dp( + tree_nodes: &[TreeNode], + tip_distribution_account: &Pubkey, + rpc_client: &SyncRpcClient, +) { + let actual_claims: u64 = tree_nodes.iter().map(|t| t.amount).sum(); + let tda = rpc_client.get_account(tip_distribution_account).unwrap(); + let min_rent = rpc_client + .get_minimum_balance_for_rent_exemption(tda.data.len()) + .unwrap(); + + let expected_claims = tda.lamports.checked_sub(min_rent).unwrap(); + if actual_claims == expected_claims { + return; + } + + if actual_claims > expected_claims { + datapoint_error!( + "tip-distributor", + ( + "actual_claims_exceeded", + format!("tip_distribution_account={tip_distribution_account},actual_claims={actual_claims}, expected_claims={expected_claims}"), + String + ), + ); + } else { + datapoint_warn!( + "tip-distributor", + ( + "actual_claims_below", + format!("tip_distribution_account={tip_distribution_account},actual_claims={actual_claims}, expected_claims={expected_claims}"), + String + ), + ); + } +} + +impl GeneratedMerkleTreeCollection { + pub fn new_from_stake_meta_collection( + stake_meta_coll: StakeMetaCollection, + maybe_rpc_client: Option, + ) -> Result { + let generated_merkle_trees = stake_meta_coll + .stake_metas + .into_iter() + .filter(|stake_meta| stake_meta.maybe_tip_distribution_meta.is_some()) + .filter_map(|stake_meta| { + let mut tree_nodes = match TreeNode::vec_from_stake_meta(&stake_meta) { + Err(e) => return Some(Err(e)), + Ok(maybe_tree_nodes) => maybe_tree_nodes, + }?; + + if let Some(rpc_client) = &maybe_rpc_client { + if let Some(tda) = stake_meta.maybe_tip_distribution_meta.as_ref() { + emit_inconsistent_tree_node_amount_dp( + &tree_nodes[..], + &tda.tip_distribution_pubkey, + rpc_client, + ); + } + } + + let hashed_nodes: Vec<[u8; 32]> = + tree_nodes.iter().map(|n| n.hash().to_bytes()).collect(); + + let tip_distribution_meta = stake_meta.maybe_tip_distribution_meta.unwrap(); + + let merkle_tree = MerkleTree::new(&hashed_nodes[..], true); + let max_num_nodes = tree_nodes.len() as u64; + + for (i, tree_node) in tree_nodes.iter_mut().enumerate() { + tree_node.proof = Some(get_proof(&merkle_tree, i)); + } + + Some(Ok(GeneratedMerkleTree { + max_num_nodes, + tip_distribution_account: tip_distribution_meta.tip_distribution_pubkey, + merkle_root_upload_authority: tip_distribution_meta + .merkle_root_upload_authority, + merkle_root: *merkle_tree.get_root().unwrap(), + tree_nodes, + max_total_claim: tip_distribution_meta.total_tips, + })) + }) + .collect::, MerkleRootGeneratorError>>()?; + + Ok(GeneratedMerkleTreeCollection { + generated_merkle_trees, + bank_hash: stake_meta_coll.bank_hash, + epoch: stake_meta_coll.epoch, + slot: stake_meta_coll.slot, + }) + } +} + +pub fn get_proof(merkle_tree: &MerkleTree, i: usize) -> Vec<[u8; 32]> { + let mut proof = Vec::new(); + let path = merkle_tree.find_path(i).expect("path to index"); + for branch in path.get_proof_entries() { + if let Some(hash) = branch.get_left_sibling() { + proof.push(hash.to_bytes()); + } else if let Some(hash) = branch.get_right_sibling() { + proof.push(hash.to_bytes()); + } else { + panic!("expected some hash at each level of the tree"); + } + } + proof +} + +fn derive_tip_payment_pubkeys(program_id: &Pubkey) -> TipPaymentPubkeys { + let config_pda = Pubkey::find_program_address(&[CONFIG_ACCOUNT_SEED], program_id).0; + let tip_pda_0 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_0], program_id).0; + let tip_pda_1 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_1], program_id).0; + let tip_pda_2 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_2], program_id).0; + let tip_pda_3 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_3], program_id).0; + let tip_pda_4 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_4], program_id).0; + let tip_pda_5 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_5], program_id).0; + let tip_pda_6 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_6], program_id).0; + let tip_pda_7 = Pubkey::find_program_address(&[TIP_ACCOUNT_SEED_7], program_id).0; + + TipPaymentPubkeys { + config_pda, + tip_pdas: vec![ + tip_pda_0, tip_pda_1, tip_pda_2, tip_pda_3, tip_pda_4, tip_pda_5, tip_pda_6, tip_pda_7, + ], + } +} + +#[derive(Clone, Eq, Debug, Hash, PartialEq, Deserialize, Serialize)] +pub struct TreeNode { + /// The stake account entitled to redeem. + #[serde(with = "pubkey_string_conversion")] + pub claimant: Pubkey, + + /// Pubkey of the ClaimStatus PDA account, this account should be closed to reclaim rent. + #[serde(with = "pubkey_string_conversion")] + pub claim_status_pubkey: Pubkey, + + /// Bump of the ClaimStatus PDA account + pub claim_status_bump: u8, + + #[serde(with = "pubkey_string_conversion")] + pub staker_pubkey: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub withdrawer_pubkey: Pubkey, + + /// The amount this account is entitled to. + pub amount: u64, + + /// The proof associated with this TreeNode + pub proof: Option>, +} + +impl TreeNode { + fn vec_from_stake_meta( + stake_meta: &StakeMeta, + ) -> Result>, MerkleRootGeneratorError> { + if let Some(tip_distribution_meta) = stake_meta.maybe_tip_distribution_meta.as_ref() { + let validator_amount = (tip_distribution_meta.total_tips as u128) + .checked_mul(tip_distribution_meta.validator_fee_bps as u128) + .unwrap() + .checked_div(10_000) + .unwrap() as u64; + let (claim_status_pubkey, claim_status_bump) = Pubkey::find_program_address( + &[ + ClaimStatus::SEED, + &stake_meta.validator_vote_account.to_bytes(), + &tip_distribution_meta.tip_distribution_pubkey.to_bytes(), + ], + &JitoTipDistribution::id(), + ); + let mut tree_nodes = vec![TreeNode { + claimant: stake_meta.validator_vote_account, + claim_status_pubkey, + claim_status_bump, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: validator_amount, + proof: None, + }]; + + let remaining_total_rewards = tip_distribution_meta + .total_tips + .checked_sub(validator_amount) + .unwrap() as u128; + + let total_delegated = stake_meta.total_delegated as u128; + tree_nodes.extend( + stake_meta + .delegations + .iter() + .map(|delegation| { + let amount_delegated = delegation.lamports_delegated as u128; + let reward_amount = (amount_delegated.checked_mul(remaining_total_rewards)) + .unwrap() + .checked_div(total_delegated) + .unwrap(); + let (claim_status_pubkey, claim_status_bump) = Pubkey::find_program_address( + &[ + ClaimStatus::SEED, + &delegation.stake_account_pubkey.to_bytes(), + &tip_distribution_meta.tip_distribution_pubkey.to_bytes(), + ], + &JitoTipDistribution::id(), + ); + Ok(TreeNode { + claimant: delegation.stake_account_pubkey, + claim_status_pubkey, + claim_status_bump, + staker_pubkey: delegation.staker_pubkey, + withdrawer_pubkey: delegation.withdrawer_pubkey, + amount: reward_amount as u64, + proof: None, + }) + }) + .collect::, MerkleRootGeneratorError>>()?, + ); + + Ok(Some(tree_nodes)) + } else { + Ok(None) + } + } + + fn hash(&self) -> Hash { + let mut hasher = Hasher::default(); + hasher.hash(self.claimant.as_ref()); + hasher.hash(self.amount.to_le_bytes().as_ref()); + hasher.result() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct StakeMetaCollection { + /// List of [StakeMeta]. + pub stake_metas: Vec, + + /// base58 encoded tip-distribution program id. + #[serde(with = "pubkey_string_conversion")] + pub tip_distribution_program_id: Pubkey, + + /// Base58 encoded bank hash this object was generated at. + pub bank_hash: String, + + /// Epoch for which this object was generated for. + pub epoch: Epoch, + + /// Slot at which this object was generated. + pub slot: Slot, +} + +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)] +pub struct StakeMeta { + #[serde(with = "pubkey_string_conversion")] + pub validator_vote_account: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub validator_node_pubkey: Pubkey, + + /// The validator's tip-distribution meta if it exists. + pub maybe_tip_distribution_meta: Option, + + /// Delegations to this validator. + pub delegations: Vec, + + /// The total amount of delegations to the validator. + pub total_delegated: u64, + + /// The validator's delegation commission rate as a percentage between 0-100. + pub commission: u8, +} + +impl Ord for StakeMeta { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.validator_vote_account + .cmp(&other.validator_vote_account) + } +} + +impl PartialOrd for StakeMeta { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)] +pub struct TipDistributionMeta { + #[serde(with = "pubkey_string_conversion")] + pub merkle_root_upload_authority: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub tip_distribution_pubkey: Pubkey, + + /// The validator's total tips in the [TipDistributionAccount]. + pub total_tips: u64, + + /// The validator's cut of tips from [TipDistributionAccount], calculated from the on-chain + /// commission fee bps. + pub validator_fee_bps: u16, +} + +impl TipDistributionMeta { + fn from_tda_wrapper( + tda_wrapper: TipDistributionAccountWrapper, + // The amount that will be left remaining in the tda to maintain rent exemption status. + rent_exempt_amount: u64, + ) -> Result { + Ok(TipDistributionMeta { + tip_distribution_pubkey: tda_wrapper.tip_distribution_pubkey, + total_tips: tda_wrapper + .account_data + .lamports() + .checked_sub(rent_exempt_amount) + .ok_or(CheckedMathError)?, + validator_fee_bps: tda_wrapper + .tip_distribution_account + .validator_commission_bps, + merkle_root_upload_authority: tda_wrapper + .tip_distribution_account + .merkle_root_upload_authority, + }) + } +} + +#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq)] +pub struct Delegation { + #[serde(with = "pubkey_string_conversion")] + pub stake_account_pubkey: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub staker_pubkey: Pubkey, + + #[serde(with = "pubkey_string_conversion")] + pub withdrawer_pubkey: Pubkey, + + /// Lamports delegated by the stake account + pub lamports_delegated: u64, +} + +impl Ord for Delegation { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + ( + self.stake_account_pubkey, + self.withdrawer_pubkey, + self.staker_pubkey, + self.lamports_delegated, + ) + .cmp(&( + other.stake_account_pubkey, + other.withdrawer_pubkey, + other.staker_pubkey, + other.lamports_delegated, + )) + } +} + +impl PartialOrd for Delegation { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Convenience wrapper around [TipDistributionAccount] +pub struct TipDistributionAccountWrapper { + pub tip_distribution_account: TipDistributionAccount, + pub account_data: AccountSharedData, + pub tip_distribution_pubkey: Pubkey, +} + +// TODO: move to program's sdk +pub fn derive_tip_distribution_account_address( + tip_distribution_program_id: &Pubkey, + vote_pubkey: &Pubkey, + epoch: Epoch, +) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + TipDistributionAccount::SEED, + vote_pubkey.to_bytes().as_ref(), + epoch.to_le_bytes().as_ref(), + ], + tip_distribution_program_id, + ) +} + +pub const MAX_RETRIES: usize = 5; +pub const FAIL_DELAY: Duration = Duration::from_millis(100); + +pub async fn sign_and_send_transactions_with_retries( + signer: &Keypair, + rpc_client: &RpcClient, + max_concurrent_rpc_get_reqs: usize, + transactions: Vec, + txn_send_batch_size: usize, + max_loop_duration: Duration, +) -> (Vec, HashMap) { + let semaphore = Arc::new(Semaphore::new(max_concurrent_rpc_get_reqs)); + let mut errors = HashMap::default(); + let mut blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("fetch latest blockhash"); + // track unsigned txns + let mut transactions_to_process = transactions + .into_iter() + .map(|txn| (txn.message_data(), txn)) + .collect::, Transaction>>(); + + let start = Instant::now(); + while start.elapsed() < max_loop_duration && !transactions_to_process.is_empty() { + // ensure we always have a recent blockhash + // blockhashes last max 150 blocks + // finalized commitment is ~32 slots behind tip + // assuming 0% skip rate (every slot has a block), we’d have roughly 120 slots + // or (120*0.4s) = 48s to land a tx before it expires + // if we’re refreshing every 30s, then any txs sent immediately before the refresh would likely expire + if start.elapsed() > Duration::from_secs(1) { + blockhash = rpc_client + .get_latest_blockhash() + .await + .expect("fetch latest blockhash"); + } + info!( + "Sending {txn_send_batch_size} of {} transactions to claim mev tips", + transactions_to_process.len() + ); + let send_futs = transactions_to_process + .iter() + .take(txn_send_batch_size) + .map(|(hash, txn)| { + let semaphore = semaphore.clone(); + async move { + let _permit = semaphore.acquire_owned().await.unwrap(); // wait until our turn + let (txn, res) = signed_send(signer, rpc_client, blockhash, txn.clone()).await; + (hash.clone(), txn, res) + } + }); + + let send_res = futures::future::join_all(send_futs).await; + let new_errors = send_res + .into_iter() + .filter_map(|(hash, txn, result)| match result { + Err(e) => Some((txn.signatures[0], e)), + Ok(..) => { + let _ = transactions_to_process.remove(&hash); + None + } + }) + .collect::>(); + + errors.extend(new_errors); + } + + (transactions_to_process.values().cloned().collect(), errors) +} + +pub async fn send_until_blockhash_expires( + rpc_client: &RpcClient, + transactions: Vec, + blockhash: Hash, + keypair: &Arc, +) -> solana_rpc_client_api::client_error::Result<()> { + let mut claim_transactions: HashMap = transactions + .into_iter() + .map(|mut tx| { + tx.sign(&[&keypair], blockhash); + (*tx.get_signature(), tx) + }) + .collect(); + + let txs_requesting_send = claim_transactions.len(); + + while rpc_client + .is_blockhash_valid(&blockhash, CommitmentConfig::processed()) + .await? + { + let mut check_signatures = HashSet::with_capacity(claim_transactions.len()); + let mut already_processed = HashSet::with_capacity(claim_transactions.len()); + let mut is_blockhash_not_found = false; + + for (signature, tx) in &claim_transactions { + match rpc_client + .send_transaction_with_config( + tx, + RpcSendTransactionConfig { + skip_preflight: false, + preflight_commitment: Some(CommitmentLevel::Confirmed), + max_retries: Some(2), + ..RpcSendTransactionConfig::default() + }, + ) + .await + { + Ok(_) => { + check_signatures.insert(*signature); + } + Err(e) => match e.get_transaction_error() { + Some(TransactionError::BlockhashNotFound) => { + is_blockhash_not_found = true; + break; + } + Some(TransactionError::AlreadyProcessed) => { + already_processed.insert(*tx.get_signature()); + } + Some(e) => { + warn!( + "TransactionError sending signature: {} error: {:?} tx: {:?}", + tx.get_signature(), + e, + tx + ); + } + None => { + warn!( + "Unknown error sending transaction signature: {} error: {:?}", + tx.get_signature(), + e + ); + } + }, + } + } + + sleep(Duration::from_secs(10)).await; + + let signatures: Vec = check_signatures.iter().cloned().collect(); + let statuses = get_batched_signatures_statuses(rpc_client, &signatures).await?; + + for (signature, maybe_status) in &statuses { + if let Some(_status) = maybe_status { + claim_transactions.remove(signature); + check_signatures.remove(signature); + } + } + + for signature in already_processed { + claim_transactions.remove(&signature); + } + + if claim_transactions.is_empty() || is_blockhash_not_found { + break; + } + } + + let num_landed = txs_requesting_send + .checked_sub(claim_transactions.len()) + .unwrap(); + info!("num_landed: {:?}", num_landed); + + Ok(()) +} + +pub async fn get_batched_signatures_statuses( + rpc_client: &RpcClient, + signatures: &[Signature], +) -> solana_rpc_client_api::client_error::Result)>> { + let mut signature_statuses = Vec::new(); + + for signatures_batch in signatures.chunks(100) { + // was using get_signature_statuses_with_history, but it blocks if the signatures don't exist + // bigtable calls to read signatures that don't exist block forever w/o --rpc-bigtable-timeout argument set + // get_signature_statuses looks in status_cache, which only has a 150 block history + // may have false negative, but for this workflow it doesn't matter + let statuses = rpc_client.get_signature_statuses(signatures_batch).await?; + signature_statuses.extend(signatures_batch.iter().cloned().zip(statuses.value)); + } + Ok(signature_statuses) +} + +/// 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) +} + +async fn get_batched_accounts( + rpc_client: &RpcClient, + pubkeys: &[Pubkey], +) -> solana_rpc_client_api::client_error::Result>> { + let mut batched_accounts = HashMap::new(); + + for pubkeys_chunk in pubkeys.chunks(MAX_MULTIPLE_ACCOUNTS) { + let accounts = rpc_client.get_multiple_accounts(pubkeys_chunk).await?; + batched_accounts.extend(pubkeys_chunk.iter().cloned().zip(accounts)); + } + Ok(batched_accounts) +} + +/// Calculates the minimum balance needed to be rent-exempt +/// taken from: https://github.com/jito-foundation/jito-solana/blob/d1ba42180d0093dd59480a77132477323a8e3f88/sdk/program/src/rent.rs#L78 +pub fn minimum_balance(data_len: usize) -> u64 { + ((((ACCOUNT_STORAGE_OVERHEAD + .checked_add(data_len as u64) + .unwrap()) + .checked_mul(DEFAULT_LAMPORTS_PER_BYTE_YEAR)) + .unwrap() as f64) + * DEFAULT_EXEMPTION_THRESHOLD) as u64 +} + +mod pubkey_string_conversion { + use { + serde::{self, Deserialize, Deserializer, Serializer}, + solana_sdk::pubkey::Pubkey, + std::str::FromStr, + }; + + pub(crate) fn serialize(pubkey: &Pubkey, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&pubkey.to_string()) + } + + pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Pubkey::from_str(&s).map_err(serde::de::Error::custom) + } +} + +pub fn read_json_from_file(path: &PathBuf) -> serde_json::Result +where + T: DeserializeOwned, +{ + let file = File::open(path).unwrap(); + let reader = BufReader::new(file); + serde_json::from_reader(reader) +} + +#[cfg(test)] +mod tests { + use {super::*, jito_tip_distribution::merkle_proof}; + + #[test] + fn test_merkle_tree_verify() { + // Create the merkle tree and proofs + let tda = Pubkey::new_unique(); + let (acct_0, acct_1) = (Pubkey::new_unique(), Pubkey::new_unique()); + let claim_statuses = &[(acct_0, tda), (acct_1, tda)] + .iter() + .map(|(claimant, tda)| { + Pubkey::find_program_address( + &[ClaimStatus::SEED, &claimant.to_bytes(), &tda.to_bytes()], + &JitoTipDistribution::id(), + ) + }) + .collect::>(); + let tree_nodes = vec![ + TreeNode { + claimant: acct_0, + claim_status_pubkey: claim_statuses[0].0, + claim_status_bump: claim_statuses[0].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 151_507, + proof: None, + }, + TreeNode { + claimant: acct_1, + claim_status_pubkey: claim_statuses[1].0, + claim_status_bump: claim_statuses[1].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 176_624, + proof: None, + }, + ]; + + // First the nodes are hashed and merkle tree constructed + let hashed_nodes: Vec<[u8; 32]> = tree_nodes.iter().map(|n| n.hash().to_bytes()).collect(); + let mk = MerkleTree::new(&hashed_nodes[..], true); + let root = mk.get_root().expect("to have valid root").to_bytes(); + + // verify first node + let node = solana_program::hash::hashv(&[&[0u8], &hashed_nodes[0]]); + let proof = get_proof(&mk, 0); + assert!(merkle_proof::verify(proof, root, node.to_bytes())); + + // verify second node + let node = solana_program::hash::hashv(&[&[0u8], &hashed_nodes[1]]); + let proof = get_proof(&mk, 1); + assert!(merkle_proof::verify(proof, root, node.to_bytes())); + } + + #[test] + fn test_new_from_stake_meta_collection_happy_path() { + let merkle_root_upload_authority = Pubkey::new_unique(); + + let (tda_0, tda_1) = (Pubkey::new_unique(), Pubkey::new_unique()); + + let stake_account_0 = Pubkey::new_unique(); + let stake_account_1 = Pubkey::new_unique(); + let stake_account_2 = Pubkey::new_unique(); + let stake_account_3 = Pubkey::new_unique(); + + let staker_account_0 = Pubkey::new_unique(); + let staker_account_1 = Pubkey::new_unique(); + let staker_account_2 = Pubkey::new_unique(); + let staker_account_3 = Pubkey::new_unique(); + + let validator_vote_account_0 = Pubkey::new_unique(); + let validator_vote_account_1 = Pubkey::new_unique(); + + let validator_id_0 = Pubkey::new_unique(); + let validator_id_1 = Pubkey::new_unique(); + + let stake_meta_collection = StakeMetaCollection { + stake_metas: vec![ + StakeMeta { + validator_vote_account: validator_vote_account_0, + validator_node_pubkey: validator_id_0, + maybe_tip_distribution_meta: Some(TipDistributionMeta { + merkle_root_upload_authority, + tip_distribution_pubkey: tda_0, + total_tips: 1_900_122_111_000, + validator_fee_bps: 100, + }), + delegations: vec![ + Delegation { + stake_account_pubkey: stake_account_0, + staker_pubkey: staker_account_0, + withdrawer_pubkey: staker_account_0, + lamports_delegated: 123_999_123_555, + }, + Delegation { + stake_account_pubkey: stake_account_1, + staker_pubkey: staker_account_1, + withdrawer_pubkey: staker_account_1, + lamports_delegated: 144_555_444_556, + }, + ], + total_delegated: 1_555_123_000_333_454_000, + commission: 100, + }, + StakeMeta { + validator_vote_account: validator_vote_account_1, + validator_node_pubkey: validator_id_1, + maybe_tip_distribution_meta: Some(TipDistributionMeta { + merkle_root_upload_authority, + tip_distribution_pubkey: tda_1, + total_tips: 1_900_122_111_333, + validator_fee_bps: 200, + }), + delegations: vec![ + Delegation { + stake_account_pubkey: stake_account_2, + staker_pubkey: staker_account_2, + withdrawer_pubkey: staker_account_2, + lamports_delegated: 224_555_444, + }, + Delegation { + stake_account_pubkey: stake_account_3, + staker_pubkey: staker_account_3, + withdrawer_pubkey: staker_account_3, + lamports_delegated: 700_888_944_555, + }, + ], + total_delegated: 2_565_318_909_444_123, + commission: 10, + }, + ], + tip_distribution_program_id: Pubkey::new_unique(), + bank_hash: Hash::new_unique().to_string(), + epoch: 100, + slot: 2_000_000, + }; + + let merkle_tree_collection = GeneratedMerkleTreeCollection::new_from_stake_meta_collection( + stake_meta_collection.clone(), + None, + ) + .unwrap(); + + assert_eq!(stake_meta_collection.epoch, merkle_tree_collection.epoch); + assert_eq!( + stake_meta_collection.bank_hash, + merkle_tree_collection.bank_hash + ); + assert_eq!(stake_meta_collection.slot, merkle_tree_collection.slot); + assert_eq!( + stake_meta_collection.stake_metas.len(), + merkle_tree_collection.generated_merkle_trees.len() + ); + let claim_statuses = &[ + (validator_vote_account_0, tda_0), + (stake_account_0, tda_0), + (stake_account_1, tda_0), + (validator_vote_account_1, tda_1), + (stake_account_2, tda_1), + (stake_account_3, tda_1), + ] + .iter() + .map(|(claimant, tda)| { + Pubkey::find_program_address( + &[ClaimStatus::SEED, &claimant.to_bytes(), &tda.to_bytes()], + &JitoTipDistribution::id(), + ) + }) + .collect::>(); + let tree_nodes = vec![ + TreeNode { + claimant: validator_vote_account_0, + claim_status_pubkey: claim_statuses[0].0, + claim_status_bump: claim_statuses[0].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 19_001_221_110, + proof: None, + }, + TreeNode { + claimant: stake_account_0, + claim_status_pubkey: claim_statuses[1].0, + claim_status_bump: claim_statuses[1].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 149_992, + proof: None, + }, + TreeNode { + claimant: stake_account_1, + claim_status_pubkey: claim_statuses[2].0, + claim_status_bump: claim_statuses[2].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 174_858, + proof: None, + }, + ]; + let hashed_nodes: Vec<[u8; 32]> = tree_nodes.iter().map(|n| n.hash().to_bytes()).collect(); + let merkle_tree = MerkleTree::new(&hashed_nodes[..], true); + let gmt_0 = GeneratedMerkleTree { + tip_distribution_account: tda_0, + merkle_root_upload_authority, + merkle_root: *merkle_tree.get_root().unwrap(), + tree_nodes, + max_total_claim: stake_meta_collection.stake_metas[0] + .clone() + .maybe_tip_distribution_meta + .unwrap() + .total_tips, + max_num_nodes: 3, + }; + + let tree_nodes = vec![ + TreeNode { + claimant: validator_vote_account_1, + claim_status_pubkey: claim_statuses[3].0, + claim_status_bump: claim_statuses[3].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 38_002_442_226, + proof: None, + }, + TreeNode { + claimant: stake_account_2, + claim_status_pubkey: claim_statuses[4].0, + claim_status_bump: claim_statuses[4].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 163_000, + proof: None, + }, + TreeNode { + claimant: stake_account_3, + claim_status_pubkey: claim_statuses[5].0, + claim_status_bump: claim_statuses[5].1, + staker_pubkey: Pubkey::default(), + withdrawer_pubkey: Pubkey::default(), + amount: 508_762_900, + proof: None, + }, + ]; + let hashed_nodes: Vec<[u8; 32]> = tree_nodes.iter().map(|n| n.hash().to_bytes()).collect(); + let merkle_tree = MerkleTree::new(&hashed_nodes[..], true); + let gmt_1 = GeneratedMerkleTree { + tip_distribution_account: tda_1, + merkle_root_upload_authority, + merkle_root: *merkle_tree.get_root().unwrap(), + tree_nodes, + max_total_claim: stake_meta_collection.stake_metas[1] + .clone() + .maybe_tip_distribution_meta + .unwrap() + .total_tips, + max_num_nodes: 3, + }; + + let expected_generated_merkle_trees = vec![gmt_0, gmt_1]; + let actual_generated_merkle_trees = merkle_tree_collection.generated_merkle_trees; + + expected_generated_merkle_trees + .iter() + .for_each(|expected_gmt| { + let actual_gmt = actual_generated_merkle_trees + .iter() + .find(|gmt| { + gmt.tip_distribution_account == expected_gmt.tip_distribution_account + }) + .unwrap(); + + assert_eq!(expected_gmt.max_num_nodes, actual_gmt.max_num_nodes); + assert_eq!(expected_gmt.max_total_claim, actual_gmt.max_total_claim); + assert_eq!( + expected_gmt.tip_distribution_account, + actual_gmt.tip_distribution_account + ); + assert_eq!(expected_gmt.tree_nodes.len(), actual_gmt.tree_nodes.len()); + expected_gmt + .tree_nodes + .iter() + .for_each(|expected_tree_node| { + let actual_tree_node = actual_gmt + .tree_nodes + .iter() + .find(|tree_node| tree_node.claimant == expected_tree_node.claimant) + .unwrap(); + assert_eq!(expected_tree_node.amount, actual_tree_node.amount); + }); + assert_eq!(expected_gmt.merkle_root, actual_gmt.merkle_root); + }); + } +} diff --git a/tip-distributor/src/merkle_root_generator_workflow.rs b/tip-distributor/src/merkle_root_generator_workflow.rs new file mode 100644 index 0000000000..bee3da016b --- /dev/null +++ b/tip-distributor/src/merkle_root_generator_workflow.rs @@ -0,0 +1,54 @@ +use { + crate::{read_json_from_file, GeneratedMerkleTreeCollection, StakeMetaCollection}, + log::*, + solana_client::rpc_client::RpcClient, + std::{ + fmt::Debug, + fs::File, + io::{BufWriter, Write}, + path::PathBuf, + }, + thiserror::Error, +}; + +#[derive(Error, Debug)] +pub enum MerkleRootGeneratorError { + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + RpcError(#[from] Box), + + #[error(transparent)] + SerdeJsonError(#[from] serde_json::Error), +} + +pub fn generate_merkle_root( + stake_meta_coll_path: &PathBuf, + out_path: &PathBuf, + rpc_url: &str, +) -> Result<(), MerkleRootGeneratorError> { + let stake_meta_coll: StakeMetaCollection = read_json_from_file(stake_meta_coll_path)?; + + let rpc_client = RpcClient::new(rpc_url); + let merkle_tree_coll = GeneratedMerkleTreeCollection::new_from_stake_meta_collection( + stake_meta_coll, + Some(rpc_client), + )?; + + write_to_json_file(&merkle_tree_coll, out_path)?; + Ok(()) +} + +fn write_to_json_file( + merkle_tree_coll: &GeneratedMerkleTreeCollection, + file_path: &PathBuf, +) -> Result<(), MerkleRootGeneratorError> { + let file = File::create(file_path)?; + let mut writer = BufWriter::new(file); + let json = serde_json::to_string_pretty(&merkle_tree_coll).unwrap(); + writer.write_all(json.as_bytes())?; + writer.flush()?; + + Ok(()) +} diff --git a/tip-distributor/src/merkle_root_upload_workflow.rs b/tip-distributor/src/merkle_root_upload_workflow.rs new file mode 100644 index 0000000000..e40465581f --- /dev/null +++ b/tip-distributor/src/merkle_root_upload_workflow.rs @@ -0,0 +1,138 @@ +use { + crate::{ + read_json_from_file, sign_and_send_transactions_with_retries, GeneratedMerkleTree, + GeneratedMerkleTreeCollection, + }, + anchor_lang::AccountDeserialize, + jito_tip_distribution::{ + sdk::instruction::{upload_merkle_root_ix, UploadMerkleRootAccounts, UploadMerkleRootArgs}, + state::{Config, TipDistributionAccount}, + }, + log::{error, info}, + solana_client::nonblocking::rpc_client::RpcClient, + solana_program::{ + fee_calculator::DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE, native_token::LAMPORTS_PER_SOL, + }, + solana_sdk::{ + commitment_config::CommitmentConfig, + pubkey::Pubkey, + signature::{read_keypair_file, Signer}, + transaction::Transaction, + }, + std::{path::PathBuf, time::Duration}, + thiserror::Error, + tokio::runtime::Builder, +}; + +#[derive(Error, Debug)] +pub enum MerkleRootUploadError { + #[error(transparent)] + IoError(#[from] std::io::Error), + + #[error(transparent)] + JsonError(#[from] serde_json::Error), +} + +pub fn upload_merkle_root( + merkle_root_path: &PathBuf, + keypair_path: &PathBuf, + rpc_url: &str, + tip_distribution_program_id: &Pubkey, + max_concurrent_rpc_get_reqs: usize, + txn_send_batch_size: usize, +) -> Result<(), MerkleRootUploadError> { + const MAX_RETRY_DURATION: Duration = Duration::from_secs(600); + + let merkle_tree: GeneratedMerkleTreeCollection = + read_json_from_file(merkle_root_path).expect("read GeneratedMerkleTreeCollection"); + let keypair = read_keypair_file(keypair_path).expect("read keypair file"); + + let tip_distribution_config = + Pubkey::find_program_address(&[Config::SEED], tip_distribution_program_id).0; + + let runtime = Builder::new_multi_thread() + .worker_threads(16) + .enable_all() + .build() + .expect("build runtime"); + + runtime.block_on(async move { + let rpc_client = + RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::confirmed()); + let trees: Vec = merkle_tree + .generated_merkle_trees + .into_iter() + .filter(|tree| tree.merkle_root_upload_authority == keypair.pubkey()) + .collect(); + + info!("num trees to upload: {:?}", trees.len()); + + // heuristic to make sure we have enough funds to cover execution, assumes all trees need updating + { + let initial_balance = rpc_client.get_balance(&keypair.pubkey()).await.expect("failed to get balance"); + let desired_balance = (trees.len() as u64).checked_mul(DEFAULT_TARGET_LAMPORTS_PER_SIGNATURE).unwrap(); + if initial_balance < desired_balance { + let sol_to_deposit = desired_balance.checked_sub(initial_balance).unwrap().checked_add(LAMPORTS_PER_SOL).unwrap().checked_sub(1).unwrap().checked_div(LAMPORTS_PER_SOL).unwrap(); // rounds up to nearest sol + panic!("Expected to have at least {} lamports in {}, current balance is {} lamports, deposit {} SOL to continue.", + desired_balance, &keypair.pubkey(), initial_balance, sol_to_deposit) + } + } + let mut trees_needing_update: Vec = vec![]; + for tree in trees { + let account = rpc_client + .get_account(&tree.tip_distribution_account) + .await + .expect("fetch expect"); + + let mut data = account.data.as_slice(); + let fetched_tip_distribution_account = + TipDistributionAccount::try_deserialize(&mut data) + .expect("failed to deserialize tip_distribution_account state"); + + let needs_upload = match fetched_tip_distribution_account.merkle_root { + Some(merkle_root) => { + merkle_root.total_funds_claimed == 0 + && merkle_root.root != tree.merkle_root.to_bytes() + } + None => true, + }; + + if needs_upload { + trees_needing_update.push(tree); + } + } + + info!("num trees need uploading: {:?}", trees_needing_update.len()); + + let transactions: Vec = trees_needing_update + .iter() + .map(|tree| { + let ix = upload_merkle_root_ix( + *tip_distribution_program_id, + UploadMerkleRootArgs { + root: tree.merkle_root.to_bytes(), + max_total_claim: tree.max_total_claim, + max_num_nodes: tree.max_num_nodes, + }, + UploadMerkleRootAccounts { + config: tip_distribution_config, + merkle_root_upload_authority: keypair.pubkey(), + tip_distribution_account: tree.tip_distribution_account, + }, + ); + Transaction::new_with_payer( + &[ix], + Some(&keypair.pubkey()), + ) + }) + .collect(); + + let (to_process, failed_transactions) = sign_and_send_transactions_with_retries( + &keypair, &rpc_client, max_concurrent_rpc_get_reqs, transactions, txn_send_batch_size, MAX_RETRY_DURATION).await; + if !to_process.is_empty() { + panic!("{} remaining mev claim transactions, {} failed requests.", to_process.len(), failed_transactions.len()); + } + }); + + Ok(()) +} diff --git a/tip-distributor/src/reclaim_rent_workflow.rs b/tip-distributor/src/reclaim_rent_workflow.rs new file mode 100644 index 0000000000..fc48c89d61 --- /dev/null +++ b/tip-distributor/src/reclaim_rent_workflow.rs @@ -0,0 +1,310 @@ +use { + crate::{ + claim_mev_workflow::ClaimMevError, get_batched_accounts, + reclaim_rent_workflow::ClaimMevError::AnchorError, send_until_blockhash_expires, + }, + 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, warn}, + rand::{prelude::SliceRandom, thread_rng}, + solana_client::nonblocking::rpc_client::RpcClient, + solana_metrics::datapoint_info, + solana_program::{clock::Epoch, pubkey::Pubkey}, + solana_rpc_client_api::config::RpcSimulateTransactionConfig, + solana_sdk::{ + account::Account, + commitment_config::CommitmentConfig, + compute_budget::ComputeBudgetInstruction, + signature::{Keypair, Signer}, + transaction::Transaction, + }, + std::{ + sync::Arc, + time::{Duration, Instant}, + }, +}; + +/// Clear old ClaimStatus accounts +pub async fn reclaim_rent( + rpc_url: String, + tip_distribution_program_id: Pubkey, + signer: Arc, + max_loop_duration: Duration, + // Optionally reclaim TipDistributionAccount rents on behalf of validators. + should_reclaim_tdas: bool, + micro_lamports: u64, +) -> Result<(), ClaimMevError> { + let rpc_client = RpcClient::new_with_timeout_and_commitment( + rpc_url.clone(), + Duration::from_secs(300), + CommitmentConfig::processed(), + ); + + let start = Instant::now(); + + let accounts = rpc_client + .get_program_accounts(&tip_distribution_program_id) + .await?; + + 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::try_deserialize(&mut config_account.data.as_slice()).map_err(AnchorError)?; + + let epoch = rpc_client.get_epoch_info().await?.epoch; + let mut claim_status_pubkeys_to_expire = + find_expired_claim_status_accounts(&accounts, epoch, signer.pubkey()); + let mut tda_pubkeys_to_expire = find_expired_tda_accounts(&accounts, epoch); + + while start.elapsed() <= max_loop_duration { + let mut transactions = build_close_claim_status_transactions( + &claim_status_pubkeys_to_expire, + tip_distribution_program_id, + config_pubkey, + micro_lamports, + signer.pubkey(), + ); + if should_reclaim_tdas { + transactions.extend(build_close_tda_transactions( + &tda_pubkeys_to_expire, + tip_distribution_program_id, + config_pubkey, + &config_account, + signer.pubkey(), + )); + } + + datapoint_info!( + "claim_mev_workflow-prepare_rent_reclaim_transactions", + ("transaction_count", transactions.len(), i64), + ); + + if transactions.is_empty() { + info!("Finished reclaim rent!"); + return Ok(()); + } + + transactions.shuffle(&mut thread_rng()); + let transactions: Vec<_> = transactions.into_iter().take(10_000).collect(); + let blockhash = rpc_client.get_latest_blockhash().await?; + send_until_blockhash_expires(&rpc_client, transactions, blockhash, &signer).await?; + + // can just refresh calling get_multiple_accounts since these operations should be subtractive and not additive + let claim_status_pubkeys: Vec<_> = claim_status_pubkeys_to_expire + .iter() + .map(|(pubkey, _)| *pubkey) + .collect(); + claim_status_pubkeys_to_expire = get_batched_accounts(&rpc_client, &claim_status_pubkeys) + .await? + .into_iter() + .filter_map(|(pubkey, account)| Some((pubkey, account?))) + .collect(); + + let tda_pubkeys: Vec<_> = tda_pubkeys_to_expire + .iter() + .map(|(pubkey, _)| *pubkey) + .collect(); + tda_pubkeys_to_expire = get_batched_accounts(&rpc_client, &tda_pubkeys) + .await? + .into_iter() + .filter_map(|(pubkey, account)| Some((pubkey, account?))) + .collect(); + } + + // one final refresh before double checking everything + let claim_status_pubkeys: Vec<_> = claim_status_pubkeys_to_expire + .iter() + .map(|(pubkey, _)| *pubkey) + .collect(); + claim_status_pubkeys_to_expire = get_batched_accounts(&rpc_client, &claim_status_pubkeys) + .await? + .into_iter() + .filter_map(|(pubkey, account)| Some((pubkey, account?))) + .collect(); + + let tda_pubkeys: Vec<_> = tda_pubkeys_to_expire + .iter() + .map(|(pubkey, _)| *pubkey) + .collect(); + tda_pubkeys_to_expire = get_batched_accounts(&rpc_client, &tda_pubkeys) + .await? + .into_iter() + .filter_map(|(pubkey, account)| Some((pubkey, account?))) + .collect(); + + let mut transactions = build_close_claim_status_transactions( + &claim_status_pubkeys_to_expire, + tip_distribution_program_id, + config_pubkey, + micro_lamports, + signer.pubkey(), + ); + if should_reclaim_tdas { + transactions.extend(build_close_tda_transactions( + &tda_pubkeys_to_expire, + tip_distribution_program_id, + config_pubkey, + &config_account, + signer.pubkey(), + )); + } + + if transactions.is_empty() { + return Ok(()); + } + + // if more transactions left, we'll simulate them all to make sure its not an uncaught error + let mut is_error = false; + let mut error_str = String::new(); + for tx in &transactions { + match rpc_client + .simulate_transaction_with_config( + tx, + RpcSimulateTransactionConfig { + sig_verify: false, + replace_recent_blockhash: true, + commitment: Some(CommitmentConfig::processed()), + ..RpcSimulateTransactionConfig::default() + }, + ) + .await + { + Ok(_) => {} + Err(e) => { + error_str = e.to_string(); + is_error = true; + + match e.get_transaction_error() { + None => { + break; + } + Some(e) => { + warn!("transaction error. tx: {:?} error: {:?}", tx, e); + break; + } + } + } + } + } + + if is_error { + Err(ClaimMevError::UncaughtError { e: error_str }) + } else { + Err(ClaimMevError::NotFinished { + transactions_left: transactions.len(), + }) + } +} + +fn find_expired_claim_status_accounts( + accounts: &[(Pubkey, Account)], + epoch: Epoch, + payer: Pubkey, +) -> Vec<(Pubkey, Account)> { + accounts + .iter() + .filter_map(|(pubkey, account)| { + let claim_status = ClaimStatus::try_deserialize(&mut account.data.as_slice()).ok()?; + if claim_status.claim_status_payer.eq(&payer) && epoch > claim_status.expires_at { + Some((*pubkey, account.clone())) + } else { + None + } + }) + .collect() +} + +fn find_expired_tda_accounts( + accounts: &[(Pubkey, Account)], + epoch: Epoch, +) -> Vec<(Pubkey, Account)> { + accounts + .iter() + .filter_map(|(pubkey, account)| { + let tda = TipDistributionAccount::try_deserialize(&mut account.data.as_slice()).ok()?; + if epoch > tda.expires_at { + Some((*pubkey, account.clone())) + } else { + None + } + }) + .collect() +} + +/// Assumes accounts is already pre-filtered with checks to ensure the account can be closed +fn build_close_claim_status_transactions( + accounts: &[(Pubkey, Account)], + tip_distribution_program_id: Pubkey, + config: Pubkey, + microlamports: u64, + payer: Pubkey, +) -> Vec { + accounts + .iter() + .map(|(claim_status_pubkey, account)| { + let claim_status = ClaimStatus::try_deserialize(&mut account.data.as_slice()).unwrap(); + close_claim_status_ix( + tip_distribution_program_id, + CloseClaimStatusArgs, + CloseClaimStatusAccounts { + config, + claim_status: *claim_status_pubkey, + claim_status_payer: claim_status.claim_status_payer, + }, + ) + }) + .collect::>() + .chunks(4) + .map(|close_claim_status_instructions| { + let mut instructions = vec![ComputeBudgetInstruction::set_compute_unit_price( + microlamports, + )]; + instructions.extend(close_claim_status_instructions.to_vec()); + Transaction::new_with_payer(&instructions, Some(&payer)) + }) + .collect() +} + +fn build_close_tda_transactions( + accounts: &[(Pubkey, Account)], + tip_distribution_program_id: Pubkey, + config_pubkey: Pubkey, + config: &Config, + payer: Pubkey, +) -> Vec { + let instructions: Vec<_> = accounts + .iter() + .map(|(pubkey, account)| { + let tda = + TipDistributionAccount::try_deserialize(&mut account.data.as_slice()).unwrap(); + close_tip_distribution_account_ix( + tip_distribution_program_id, + CloseTipDistributionAccountArgs { + _epoch: tda.epoch_created_at, + }, + CloseTipDistributionAccounts { + config: config_pubkey, + tip_distribution_account: *pubkey, + validator_vote_account: tda.validator_vote_account, + expired_funds_account: config.expired_funds_account, + signer: payer, + }, + ) + }) + .collect(); + + instructions + .chunks(4) + .map(|ix_chunk| Transaction::new_with_payer(ix_chunk, Some(&payer))) + .collect() +} 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..9616154c09 --- /dev/null +++ b/tip-distributor/src/stake_meta_generator_workflow.rs @@ -0,0 +1,973 @@ +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, OpenGenesisConfigError, MAX_GENESIS_ARCHIVE_UNPACKED_SIZE, + }, + solana_client::client_error::ClientError, + solana_ledger::{ + bank_forks_utils, + bank_forks_utils::BankForksUtilsError, + blockstore::{Blockstore, BlockstoreError}, + blockstore_options::{AccessType, BlockstoreOptions, LedgerColumnOptions}, + blockstore_processor::{BlockstoreProcessorError, ProcessOptions}, + }, + solana_program::{stake_history::StakeHistory, sysvar}, + solana_runtime::{bank::Bank, snapshot_config::SnapshotConfig, stakes::StakeAccount}, + solana_sdk::{ + account::{from_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, + + BankForksUtilsError(#[from] BankForksUtilsError), + + GenesisConfigError(#[from] OpenGenesisConfigError), +} + +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"))], + 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(), + &from_account::( + &bank.get_account(&sysvar::stake_history::id()).unwrap(), + ) + .unwrap(), + 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 (mut bank, _bank_forks) = Bank::new_with_bank_forks_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(), + &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 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 6a5eb5fb8d..67a8a4ba76 100644 --- a/transaction-status/src/lib.rs +++ b/transaction-status/src/lib.rs @@ -27,7 +27,7 @@ use { }, transaction_context::TransactionReturnData, }, - std::fmt, + std::{collections::HashMap, fmt}, thiserror::Error, }; @@ -301,6 +301,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 1f15137175..fffca1126f 100644 --- a/turbine/benches/cluster_info.rs +++ b/turbine/benches/cluster_info.rs @@ -76,6 +76,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 c5490d5670..56c672c7c8 100644 --- a/turbine/benches/retransmit_stage.rs +++ b/turbine/benches/retransmit_stage.rs @@ -33,7 +33,7 @@ use { net::{Ipv4Addr, UdpSocket}, sync::{ atomic::{AtomicUsize, Ordering}, - Arc, + Arc, RwLock, }, thread::{sleep, Builder}, time::Duration, @@ -126,6 +126,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 cce43f4fa5..29778e8962 100644 --- a/turbine/src/broadcast_stage.rs +++ b/turbine/src/broadcast_stage.rs @@ -118,6 +118,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( @@ -130,6 +131,7 @@ impl BroadcastStageType { bank_forks, quic_endpoint_sender, StandardBroadcastRun::new(shred_version), + shred_receiver_address, ), BroadcastStageType::FailEntryVerification => BroadcastStage::new( @@ -142,6 +144,7 @@ impl BroadcastStageType { bank_forks, quic_endpoint_sender, FailEntryVerificationBroadcastRun::new(shred_version), + Arc::new(RwLock::new(None)), ), BroadcastStageType::BroadcastFakeShreds => BroadcastStage::new( @@ -154,6 +157,7 @@ impl BroadcastStageType { bank_forks, quic_endpoint_sender, BroadcastFakeShredsRun::new(0, shred_version), + Arc::new(RwLock::new(None)), ), BroadcastStageType::BroadcastDuplicates(config) => BroadcastStage::new( @@ -166,6 +170,7 @@ impl BroadcastStageType { bank_forks, quic_endpoint_sender, BroadcastDuplicatesRun::new(shred_version, config.clone()), + Arc::new(RwLock::new(None)), ), } } @@ -187,6 +192,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<()>; } @@ -282,6 +288,7 @@ impl BroadcastStage { bank_forks: Arc>, quic_endpoint_sender: AsyncSender<(SocketAddr, Bytes)>, mut 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(); @@ -313,6 +320,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, @@ -320,6 +329,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 { @@ -429,6 +439,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], @@ -439,6 +450,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"); @@ -454,15 +466,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, @@ -697,6 +728,7 @@ pub mod test { bank_forks, quic_endpoint_sender, StandardBroadcastRun::new(0), + Arc::new(RwLock::new(None)), ); MockBroadcastStage { @@ -735,7 +767,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 3060fd27c8..64ef903151 100644 --- a/turbine/src/broadcast_stage/broadcast_duplicates_run.rs +++ b/turbine/src/broadcast_stage/broadcast_duplicates_run.rs @@ -297,6 +297,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 b82ca324b6..649e8d0383 100644 --- a/turbine/src/broadcast_stage/broadcast_fake_shreds_run.rs +++ b/turbine/src/broadcast_stage/broadcast_fake_shreds_run.rs @@ -150,6 +150,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 9c4b48bd5c..c4795a840d 100644 --- a/turbine/src/broadcast_stage/broadcast_utils.rs +++ b/turbine/src/broadcast_stage/broadcast_utils.rs @@ -31,13 +31,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 @@ -47,8 +57,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()); } @@ -59,8 +69,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; }; @@ -73,10 +85,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(); @@ -161,7 +175,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(); @@ -195,11 +213,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 e9ed6a1a6e..c0fa09ce7d 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, }; @@ -180,6 +180,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( @@ -192,6 +193,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 f108fc0822..39a466b8eb 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, }; @@ -179,10 +179,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(()) } @@ -405,6 +419,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(); @@ -421,6 +436,7 @@ impl StandardBroadcastRun { bank_forks, cluster_info.socket_addr_space(), quic_endpoint_sender, + shred_receiver_addr, )?; transmit_time.stop(); @@ -488,6 +504,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( @@ -497,6 +514,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 32537db6b9..ec1616a9ab 100644 --- a/turbine/src/retransmit_stage.rs +++ b/turbine/src/retransmit_stage.rs @@ -184,6 +184,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)?; @@ -264,6 +265,7 @@ fn retransmit( &sockets[index % sockets.len()], quic_endpoint_sender, stats, + &shred_receiver_address.read().unwrap(), ) .map_err(|err| { stats.record_error(&err); @@ -289,6 +291,7 @@ fn retransmit( &sockets[index % sockets.len()], quic_endpoint_sender, stats, + &shred_receiver_address.read().unwrap(), ) .map_err(|err| { stats.record_error(&err); @@ -308,6 +311,7 @@ fn retransmit( Ok(()) } +#[allow(clippy::too_many_arguments)] fn retransmit_shred( key: &ShredId, shred: &[u8], @@ -318,15 +322,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 @@ -383,6 +392,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, @@ -414,6 +424,7 @@ pub fn retransmitter( &mut shred_deduper, &max_slots, rpc_subscriptions.as_deref(), + &shred_receiver_addr, ) { Ok(()) => (), Err(RecvTimeoutError::Timeout) => (), @@ -437,6 +448,7 @@ impl RetransmitStage { retransmit_receiver: Receiver>>, max_slots: Arc, rpc_subscriptions: Option>, + shred_receiver_addr: Arc>>, ) -> Self { let retransmit_thread_handle = retransmitter( retransmit_sockets, @@ -447,6 +459,7 @@ impl RetransmitStage { retransmit_receiver, max_slots, rpc_subscriptions, + shred_receiver_addr, ); Self { diff --git a/validator/Cargo.toml b/validator/Cargo.toml index 39e5c4d1b1..0e2a028939 100644 --- a/validator/Cargo.toml +++ b/validator/Cargo.toml @@ -55,6 +55,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 } @@ -67,6 +68,7 @@ solana-vote-program = { workspace = true } symlink = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +tonic = { workspace = true, features = ["tls", "tls-roots", "tls-webpki-roots"] } [dev-dependencies] assert_cmd = { workspace = true } diff --git a/validator/src/admin_rpc_service.rs b/validator/src/admin_rpc_service.rs index 99ef4b53a0..4cd7b44c93 100644 --- a/validator/src/admin_rpc_service.rs +++ b/validator/src/admin_rpc_service.rs @@ -12,6 +12,10 @@ use { solana_core::{ admin_rpc_post_init::AdminRpcRequestMetadataPostInit, consensus::{tower_storage::TowerStorage, Tower}, + proxy::{ + block_engine_stage::{BlockEngineConfig, BlockEngineStage}, + relayer_stage::{RelayerConfig, RelayerStage}, + }, repair::repair_service, validator::ValidatorStartProgress, }, @@ -30,6 +34,7 @@ use { fmt::{self, Display}, net::SocketAddr, path::{Path, PathBuf}, + str::FromStr, sync::{Arc, RwLock}, thread::{self, Builder}, time::{Duration, SystemTime}, @@ -243,6 +248,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; @@ -441,6 +467,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, @@ -475,6 +525,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| { @@ -880,7 +979,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)] @@ -918,6 +1020,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(), @@ -938,6 +1043,9 @@ mod tests { cluster_slots: Arc::new( solana_core::cluster_slots_service::cluster_slots::ClusterSlots::default(), ), + 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 ef1ddf348b..dc195fae2a 100644 --- a/validator/src/bootstrap.rs +++ b/validator/src/bootstrap.rs @@ -816,12 +816,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 0f0b417039..5f396c3dc9 100644 --- a/validator/src/cli.rs +++ b/validator/src/cli.rs @@ -69,6 +69,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!()) @@ -80,6 +84,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") @@ -1198,7 +1283,14 @@ pub fn app<'a>(version: &'a str, default_args: &'a DefaultArgs) -> App<'a, 'a> { .value_name("BOOLEAN") .takes_value(true) .default_value("false") - .help("Еnable Geyser interface even if no Geyser configs are specified."), + .help("Еnable Geyser interface even if no Geyser configs are specified.")) +.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") @@ -1601,6 +1693,68 @@ pub fn app<'a>(version: &'a str, default_args: &'a DefaultArgs) -> App<'a, 'a> { .args(&thread_args(&default_args.thread_args)) .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") @@ -1777,6 +1931,48 @@ pub fn app<'a>(version: &'a str, default_args: &'a DefaultArgs) -> App<'a, 'a> { ) .subcommand(SubCommand::with_name("monitor").about("Monitor the validator")) .subcommand(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") @@ -2776,6 +2972,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 45c35a43ef..4fb2a68c1e 100644 --- a/validator/src/main.rs +++ b/validator/src/main.rs @@ -1,4 +1,5 @@ #![allow(clippy::arithmetic_side_effects)] + #[cfg(not(target_env = "msvc"))] use jemallocator::Jemalloc; use { @@ -30,7 +31,9 @@ use { solana_core::{ banking_trace::DISABLED_BAKING_TRACE_DIR, consensus::tower_storage, + 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, @@ -63,6 +66,10 @@ use { snapshot_config::{SnapshotConfig, SnapshotUsage}, snapshot_utils::{self, 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, @@ -82,9 +89,10 @@ use { path::{Path, PathBuf}, process::exit, str::FromStr, - sync::{Arc, RwLock}, + sync::{atomic::AtomicBool, Arc, Mutex, RwLock}, time::{Duration, SystemTime}, }, + tokio::runtime::Runtime, }; #[cfg(not(target_env = "msvc"))] @@ -474,6 +482,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)) => { @@ -625,6 +687,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); @@ -1381,6 +1529,44 @@ pub fn main() { tvu_receive_threads, } = cli::thread_args::parse_num_threads_args(&matches); + 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, @@ -1512,6 +1698,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(), use_snapshot_archives_at_startup: value_t_or_exit!( matches, @@ -1523,6 +1717,8 @@ pub fn main() { replay_transactions_threads, delay_leader_block_for_pending_fork: matches .is_present("delay_leader_block_for_pending_fork"), + preallocated_bundle_cost: value_of(&matches, "preallocated_bundle_cost") + .expect("preallocated_bundle_cost set as default"), ..ValidatorConfig::default() }; @@ -1878,6 +2074,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| { @@ -2055,6 +2276,7 @@ pub fn main() { tpu_enable_udp, tpu_max_connections_per_ipaddr_per_minute, admin_service_post_init, + Some(runtime_plugin_config_and_rpc_rx), ) .unwrap_or_else(|e| { error!("Failed to start validator: {:?}", e); @@ -2120,3 +2342,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 a6f8e13b8a..b103433c26 100644 --- a/version/src/lib.rs +++ b/version/src/lib.rs @@ -80,7 +80,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::Agave).unwrap(), + client: u16::try_from(ClientId::JitoLabs).unwrap(), } } } diff --git a/wen-restart/src/wen_restart.rs b/wen-restart/src/wen_restart.rs index 07275e333c..e3a8265d85 100644 --- a/wen-restart/src/wen_restart.rs +++ b/wen-restart/src/wen_restart.rs @@ -452,7 +452,7 @@ pub(crate) fn generate_snapshot( // snapshot to use as base, so the logic is more complicated. For now we always generate // an incremental snapshot. let mut directory = &snapshot_config.full_snapshot_archives_dir; - let Some(full_snapshot_slot) = get_highest_full_snapshot_archive_slot(directory) else { + let Some(full_snapshot_slot) = get_highest_full_snapshot_archive_slot(directory, None) else { return Err(WenRestartError::MissingFullSnapshot( snapshot_config .full_snapshot_archives_dir @@ -472,7 +472,7 @@ pub(crate) fn generate_snapshot( check_slot_smaller_than_intended_snapshot_slot(full_snapshot_slot, new_root_slot, directory)?; directory = &snapshot_config.incremental_snapshot_archives_dir; if let Some(incremental_snapshot_slot) = - get_highest_incremental_snapshot_archive_slot(directory, full_snapshot_slot) + get_highest_incremental_snapshot_archive_slot(directory, full_snapshot_slot, None) { check_slot_smaller_than_intended_snapshot_slot( incremental_snapshot_slot,