From 1555c0328b0f964c2476bc29741e7ea322d96f19 Mon Sep 17 00:00:00 2001 From: Richard Janis Goldschmidt Date: Sat, 13 Jul 2024 17:21:19 +0200 Subject: [PATCH] feat(cli): add cmd to collect withdrawal events and submit as actions (#1261) ## Summary Adds `bridge collect-withdrawals` and `bridge submit-withdrawals` subcommands to `astria-cli`. ## Background The worker service `astria-bridge-withdrawer` is a closed system that collects withdrawal event from the rollup and submits them to the sequencer in a closed loop. But it can be desirable to inspect the withdrawal events generated by the bridge contracts, and then submit them manually. This functionality is now provided by `astria-cli`. ## Changes - Add subcommands `bridge collect-withdrawal-s` to `astria-cli`, which has two different modes of operation, depending on whether `--to-rollup-height` is set: - if set, it fetches all blocks between `--from-rollup-height` and `--to-rollup-height` (inclusive), converts them to seuquencer actions, and then writes them to a file. - if not set, it fetches blocks from `--from-rollup-height` until SIGINT (Ctrl-C) is received. After the signal, the converted actions are written to a file. ## Testing - Added `run-smoke-cli` in `charts/deploy.just` (@joroshiba) - Added a `smoke-cli:` in `.github/workflows/docker-build.yaml` (@joroshiba) --------- Co-authored-by: Jordan Oroshiba --- .github/workflows/docker-build.yml | 36 +- Cargo.lock | 9 + charts/deploy.just | 130 +++- crates/astria-cli/Cargo.toml | 13 +- crates/astria-cli/src/cli/bridge.rs | 22 + crates/astria-cli/src/cli/mod.rs | 9 +- crates/astria-cli/src/cli/sequencer.rs | 64 +- .../astria-cli/src/commands/bridge/collect.rs | 582 ++++++++++++++++++ crates/astria-cli/src/commands/bridge/mod.rs | 2 + .../astria-cli/src/commands/bridge/submit.rs | 155 +++++ crates/astria-cli/src/commands/mod.rs | 6 +- crates/astria-cli/src/main.rs | 26 +- 12 files changed, 993 insertions(+), 61 deletions(-) create mode 100644 crates/astria-cli/src/cli/bridge.rs create mode 100644 crates/astria-cli/src/commands/bridge/collect.rs create mode 100644 crates/astria-cli/src/commands/bridge/mod.rs create mode 100644 crates/astria-cli/src/commands/bridge/submit.rs diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 9a34b861f7..147f455e64 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -146,9 +146,43 @@ jobs: timeout-minutes: 3 run: just run-smoke-test + smoke-cli: + needs: [run_checker, composer, conductor, sequencer, sequencer-relayer, evm-bridge-withdrawer] + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'astriaorg/astria') && (github.event_name == 'merge_group' || needs.run_checker.outputs.run_docker == 'true') + runs-on: buildjet-8vcpu-ubuntu-2204 + steps: + - uses: actions/checkout@v4 + - name: Install just + uses: taiki-e/install-action@just + - name: Install kind + uses: helm/kind-action@v1 + with: + install_only: true + - name: Install astria cli (rust) + run: | + just install-cli + - name: Log in to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Smoke Test Environment + timeout-minutes: 5 + run: | + TAG=sha-$(git rev-parse --short HEAD) + just deploy cluster + kubectl create secret generic regcred --from-file=.dockerconfigjson=$HOME/.docker/config.json --type=kubernetes.io/dockerconfigjson + echo -e "\n\nDeploying with astria images tagged $TAG" + just deploy smoke-cli $TAG + - name: Run Smoke test + timeout-minutes: 3 + run: just run-smoke-cli + + docker: if: ${{ always() && !cancelled() }} - needs: [composer, conductor, sequencer, sequencer-relayer, evm-bridge-withdrawer, smoke-test] + needs: [composer, conductor, sequencer, sequencer-relayer, evm-bridge-withdrawer, smoke-test, smoke-cli] uses: ./.github/workflows/reusable-success.yml with: success: ${{ !contains(needs.*.result, 'failure') }} diff --git a/Cargo.lock b/Cargo.lock index 21684370bd..94e5939f2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -539,17 +539,26 @@ dependencies = [ name = "astria-cli" version = "0.3.1" dependencies = [ + "astria-bridge-contracts", "astria-core", "astria-sequencer-client", "clap", "color-eyre", + "ethers", + "futures", "hex", + "humantime", + "ibc-types", "rand 0.8.5", "serde", + "serde_json", "serde_yaml", "sha2 0.10.8", + "tendermint", "tokio", "tracing", + "tracing-subscriber 0.3.18", + "tryhard", "which", ] diff --git a/charts/deploy.just b/charts/deploy.just index 513fc92ae3..520270f195 100644 --- a/charts/deploy.just +++ b/charts/deploy.just @@ -11,6 +11,9 @@ delete tool *ARGS: init tool *ARGS: @just init-{{tool}} {{ARGS}} +run-smoke type *ARGS: + @just run-smoke-{{type}} {{ARGS}} + load-image image: kind load docker-image {{image}} --name astria-dev-cluster @@ -148,7 +151,6 @@ deploy-local-metrics: kubectl apply -f kubernetes/metrics-server-local.yml defaultTag := "" - deploy-smoke-test tag=defaultTag: @echo "Deploying ingress controller..." && just deploy ingress-controller > /dev/null @just wait-for-ingress-controller > /dev/null @@ -171,6 +173,25 @@ deploy-smoke-test tag=defaultTag: {{ if tag != '' { replace('--set images.evmBridgeWithdrawer.devTag=#', '#', tag) } else { '' } }} > /dev/null @just wait-for-rollup > /dev/null +deploy-smoke-cli tag=defaultTag: + @echo "Deploying ingress controller..." && just deploy ingress-controller > /dev/null + @just wait-for-ingress-controller > /dev/null + @echo "Deploying local celestia instance..." && just deploy celestia-local > /dev/null + @helm dependency update charts/sequencer > /dev/null + @helm dependency update charts/evm-rollup > /dev/null + @echo "Setting up single astria sequencer..." && helm install \ + -n astria-validator-single single-sequencer-chart ./charts/sequencer \ + -f dev/values/validators/all.yml \ + -f dev/values/validators/single.yml \ + {{ if tag != '' { replace('--set images.sequencer.devTag=# --set sequencer-relayer.images.sequencerRelayer.devTag=#', '#', tag) } else { '' } }} \ + --create-namespace > /dev/null + @just wait-for-sequencer > /dev/null + @echo "Starting EVM rollup..." && helm install -n astria-dev-cluster astria-chain-chart ./charts/evm-rollup -f dev/values/rollup/dev.yaml \ + {{ if tag != '' { replace('--set images.conductor.devTag=# --set images.composer.devTag=#', '#', tag) } else { '' } }} \ + --set config.blockscout.enabled=false \ + --set config.faucet.enabled=false > /dev/null + @just wait-for-dev-rollup > /dev/null + evm_destination_address := "0xaC21B97d35Bf75A7dAb16f35b111a50e78A72F30" # 1 RIA is 10^9 nRIA @@ -180,32 +201,33 @@ rollup_multiplier := "1000000000" # 10 RIA sequencer_transfer_amount := "10" sequencer_rpc_url := "http://rpc.sequencer.localdev.me" +sequencer_bridge_address := "astria13ahqz4pjqfmynk9ylrqv4fwe4957x2p0h5782u" +sequencer_bridge_pkey := "dfa7108e38ab71f89f356c72afc38600d5758f11a8c337164713e4471411d2e0" +sequencer_chain_id := "sequencer-test-chain-0" init-rollup-bridge rollupName=defaultRollupName evmDestinationAddress=evm_destination_address transferAmount=sequencer_transfer_amount: #!/usr/bin/env bash - SEQUENCER_BRIDGE_PKEY="dfa7108e38ab71f89f356c72afc38600d5758f11a8c337164713e4471411d2e0" SEQUENCER_FUNDS_PKEY="934ab488f9e1900f6a08f50605ce1409ca9d95ebdc400dafc2e8a4306419fd52" - SEQUENCER_BRIDGE_ADDRESS="astria13ahqz4pjqfmynk9ylrqv4fwe4957x2p0h5782u" - SEQUENCER_CHAIN_ID="sequencer-test-chain-0" ASSET="nria" FEE_ASSET="nria" TRANSFER_AMOUNT=$(echo "{{transferAmount}} * {{sequencer_base_amount}}" | bc) astria-cli sequencer init-bridge-account \ --rollup-name {{rollupName}} \ - --private-key $SEQUENCER_BRIDGE_PKEY \ - --sequencer.chain-id $SEQUENCER_CHAIN_ID \ + --private-key {{sequencer_bridge_pkey}} \ + --sequencer.chain-id {{sequencer_chain_id}} \ --sequencer-url {{sequencer_rpc_url}} \ --fee-asset=$FEE_ASSET --asset=$ASSET || exit 1 - astria-cli sequencer bridge-lock $SEQUENCER_BRIDGE_ADDRESS \ + astria-cli sequencer bridge-lock {{sequencer_bridge_address}} \ --amount $TRANSFER_AMOUNT \ --destination-chain-address {{evmDestinationAddress}} \ --private-key $SEQUENCER_FUNDS_PKEY \ - --sequencer.chain-id $SEQUENCER_CHAIN_ID \ + --sequencer.chain-id {{sequencer_chain_id}} \ --sequencer-url {{sequencer_rpc_url}} \ --fee-asset=$FEE_ASSET --asset=$ASSET || exit 1 eth_rpc_url := "http://executor.astria.localdev.me/" +eth_ws_url := "ws://ws-executor.astria.localdev.me/" bridge_tx_bytes := "0xf8f280843c54e7f182898594a58639fb5458e65e4fa917ff951c390292c24a15880de0b6b3a7640000b884bab916d00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000002d617374726961313777306164656736346b7930646178776432756779756e65656c6c6d6a676e786c333935303400000000000000000000000000000000000000820a96a086b85348c9816f6d34533669db3d3626cf55eecea6a380d4d072efb1839df443a04b8b60c8b91dd30add1ca4a96097238d73bab29b0a958322d9a51755d5a5f287" bridge_tx_hash := "0x67db5b0825e8f60b926234e209d54e0336cd94defe6720e7acadf871e0377150" run-smoke-test: @@ -304,6 +326,98 @@ delete-smoke-test: just delete sequencer just delete rollup +evm_contract_address := "0xA58639fB5458e65E4fA917FF951C390292C24A15" +run-smoke-cli: + #!/usr/bin/env bash + MAX_CHECKS=30 + + # Checking starting balance + BALANCE=$(just evm-get-balance {{evm_destination_address}}) + if [ $BALANCE -ne 0 ]; then + echo "Starting balance is not correct" + exit 1 + fi + + echo "Testing Bridge In..." + just init rollup-bridge + CHECKS=0 + EXPECTED_BALANCE=$(echo "{{sequencer_transfer_amount}} * {{sequencer_base_amount}} * {{rollup_multiplier}}" | bc) + while [ $CHECKS -lt $MAX_CHECKS ]; do + CHECKS=$((CHECKS+1)) + BALANCE=$(just evm-get-balance {{evm_destination_address}}) + echo "Check $CHECKS, Balance: $BALANCE, Expected: $EXPECTED_BALANCE" + if [ "$BALANCE" == "$EXPECTED_BALANCE" ]; then + echo "Bridge In success" + break + else + sleep 1 + fi + done + if [ $CHECKS -eq $MAX_CHECKS ]; then + echo "Bridge In failure" + exit 1 + fi + + echo "Testing Bridge Out..." + just evm-send-raw-transaction {{bridge_tx_bytes}} + TRANSFERED_BALANCE=$(echo "1 * {{sequencer_base_amount}} * {{rollup_multiplier}}" | bc) + EXPECTED_BALANCE=$(echo "$EXPECTED_BALANCE - $TRANSFERED_BALANCE" | bc) + CHECKS=0 + while [ $CHECKS -lt $MAX_CHECKS ]; do + CHECKS=$((CHECKS+1)) + BALANCE=$(just evm-get-balance {{evm_destination_address}}) + echo "Check $CHECKS, Balance: $BALANCE, Expected: $EXPECTED_BALANCE" + if [ "$BALANCE" == "$EXPECTED_BALANCE" ]; then + echo "Bridge Out EVM success" + break + else + sleep 1 + fi + done + if [ $CHECKS -eq $MAX_CHECKS ]; then + echo "Bridge Out EVM failure" + exit 1 + fi + + CURRENT_BLOCK_HEX=$(just evm-get-block-by-number latest | jq -r '.number') + CURRENT_BLOCK=$(just hex-to-dec $CURRENT_BLOCK_HEX) + echo {{sequencer_bridge_pkey}} > test_se + + astria-cli bridge collect-withdrawals \ + --rollup-endpoint {{eth_ws_url}} \ + --contract-address {{evm_contract_address}} \ + --from-rollup-height 1 \ + --to-rollup-height $CURRENT_BLOCK \ + --rollup-asset-denom nria \ + --bridge-address {{sequencer_bridge_address}} \ + --output ./withdrawals.json + astria-cli bridge submit-withdrawals \ + --signing-key <(printf "%s" "{{sequencer_bridge_pkey}}") \ + --sequencer-chain-id {{sequencer_chain_id}} \ + --sequencer-url {{sequencer_rpc_url}} \ + --input ./withdrawals.json + + + CHECKS=0 + EXPECTED_BALANCE=$(echo "1 * {{sequencer_base_amount}}" | bc) + while [ $CHECKS -lt $MAX_CHECKS ]; do + CHECKS=$((CHECKS+1)) + BALANCE=$(astria-cli sequencer account balance astria17w0adeg64ky0daxwd2ugyuneellmjgnxl39504 --sequencer-url {{sequencer_rpc_url}} | awk '/nria/{print $(NF-1)}') + echo "Check $CHECKS, Balance: $BALANCE, Expected: $EXPECTED_BALANCE" + if [ "$BALANCE" == "$EXPECTED_BALANCE" ]; then + echo "Bridge Out Sequencer success" + break + else + sleep 1 + fi + done + if [ $CHECKS -gt $MAX_CHECKS ]; then + echo "Bridge Out Sequencer failure" + exit 1 + fi + + exit 0 + ############################################# ## EVM Curl Command Helper Functions ## ############################################# diff --git a/crates/astria-cli/Cargo.toml b/crates/astria-cli/Cargo.toml index f9717fde75..c5ed24f76c 100644 --- a/crates/astria-cli/Cargo.toml +++ b/crates/astria-cli/Cargo.toml @@ -14,17 +14,26 @@ name = "astria-cli" [dependencies] color-eyre = "0.6" -astria-core = { path = "../astria-core" } +astria-bridge-contracts = { path = "../astria-bridge-contracts" } +astria-core = { path = "../astria-core", features = ["serde"] } clap = { workspace = true, features = ["derive", "env"] } +ethers = { workspace = true, features = ["ws"] } hex = { workspace = true } +ibc-types = { workspace = true } rand = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_yaml = "0.9.25" sha2 = { workspace = true } -tokio = { workspace = true, features = ["rt", "macros"] } +tendermint = { workspace = true } +tokio = { workspace = true, features = ["rt", "macros", "signal"] } tracing = { workspace = true } which = { workspace = true } +humantime.workspace = true +tryhard.workspace = true +serde_json.workspace = true +futures.workspace = true +tracing-subscriber = "0.3.18" [dependencies.astria-sequencer-client] package = "astria-sequencer-client" diff --git a/crates/astria-cli/src/cli/bridge.rs b/crates/astria-cli/src/cli/bridge.rs new file mode 100644 index 0000000000..efd0eccd25 --- /dev/null +++ b/crates/astria-cli/src/cli/bridge.rs @@ -0,0 +1,22 @@ +use clap::Subcommand; +use color_eyre::eyre; + +/// Interact with a Sequencer node +// allow: these are one-shot variants. the size doesn't matter as they are +// passed around only once. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Subcommand)] +pub(crate) enum Command { + /// Commands for interacting with Sequencer accounts + CollectWithdrawals(crate::commands::bridge::collect::WithdrawalEvents), + SubmitWithdrawals(crate::commands::bridge::submit::WithdrawalEvents), +} + +impl Command { + pub(crate) async fn run(self) -> eyre::Result<()> { + match self { + Command::CollectWithdrawals(args) => args.run().await, + Command::SubmitWithdrawals(args) => args.run().await, + } + } +} diff --git a/crates/astria-cli/src/cli/mod.rs b/crates/astria-cli/src/cli/mod.rs index f4d07e4878..83711795c9 100644 --- a/crates/astria-cli/src/cli/mod.rs +++ b/crates/astria-cli/src/cli/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod bridge; pub(crate) mod sequencer; use clap::{ @@ -16,7 +17,7 @@ const DEFAULT_SEQUENCER_CHAIN_ID: &str = "astria-dusk-7"; #[command(name = "astria-cli", version)] pub struct Cli { #[command(subcommand)] - pub command: Option, + pub(crate) command: Option, } impl Cli { @@ -33,7 +34,11 @@ impl Cli { /// Commands that can be run #[derive(Debug, Subcommand)] -pub enum Command { +pub(crate) enum Command { + Bridge { + #[command(subcommand)] + command: bridge::Command, + }, Sequencer { #[command(subcommand)] command: SequencerCommand, diff --git a/crates/astria-cli/src/cli/sequencer.rs b/crates/astria-cli/src/cli/sequencer.rs index 68faa04db3..783282aedd 100644 --- a/crates/astria-cli/src/cli/sequencer.rs +++ b/crates/astria-cli/src/cli/sequencer.rs @@ -7,7 +7,7 @@ use clap::{ /// Interact with a Sequencer node #[derive(Debug, Subcommand)] -pub enum Command { +pub(crate) enum Command { /// Commands for interacting with Sequencer accounts Account { #[command(subcommand)] @@ -43,7 +43,7 @@ pub enum Command { } #[derive(Debug, Subcommand)] -pub enum AccountCommand { +pub(crate) enum AccountCommand { /// Create a new Sequencer account Create, Balance(BasicAccountArgs), @@ -51,19 +51,19 @@ pub enum AccountCommand { } #[derive(Debug, Subcommand)] -pub enum AddressCommand { +pub(crate) enum AddressCommand { /// Construct a bech32m Sequencer address given a public key Bech32m(Bech32mAddressArgs), } #[derive(Debug, Subcommand)] -pub enum BalanceCommand { +pub(crate) enum BalanceCommand { /// Get the balance of a Sequencer account Get(BasicAccountArgs), } #[derive(Debug, Subcommand)] -pub enum SudoCommand { +pub(crate) enum SudoCommand { IbcRelayer { #[command(subcommand)] command: IbcRelayerChangeCommand, @@ -77,7 +77,7 @@ pub enum SudoCommand { } #[derive(Debug, Subcommand)] -pub enum IbcRelayerChangeCommand { +pub(crate) enum IbcRelayerChangeCommand { /// Add IBC Relayer Add(IbcRelayerChangeArgs), /// Remove IBC Relayer @@ -85,7 +85,7 @@ pub enum IbcRelayerChangeCommand { } #[derive(Debug, Subcommand)] -pub enum FeeAssetChangeCommand { +pub(crate) enum FeeAssetChangeCommand { /// Add Fee Asset Add(FeeAssetChangeArgs), /// Remove Fee Asset @@ -93,7 +93,7 @@ pub enum FeeAssetChangeCommand { } #[derive(Args, Debug)] -pub struct BasicAccountArgs { +pub(crate) struct BasicAccountArgs { /// The url of the Sequencer node #[arg( long, @@ -106,7 +106,7 @@ pub struct BasicAccountArgs { } #[derive(Args, Debug)] -pub struct Bech32mAddressArgs { +pub(crate) struct Bech32mAddressArgs { /// The hex formatted byte part of the bech32m address #[arg(long)] pub(crate) bytes: String, @@ -116,7 +116,7 @@ pub struct Bech32mAddressArgs { } #[derive(Args, Debug)] -pub struct TransferArgs { +pub(crate) struct TransferArgs { // The address of the Sequencer account to send amount to pub(crate) to_address: Address, // The amount being sent @@ -145,17 +145,17 @@ pub struct TransferArgs { env = "ROLLUP_SEQUENCER_CHAIN_ID", default_value = crate::cli::DEFAULT_SEQUENCER_CHAIN_ID )] - pub sequencer_chain_id: String, + pub(crate) sequencer_chain_id: String, /// The asset to transer. #[arg(long, default_value = "nria")] - pub asset: asset::Denom, + pub(crate) asset: asset::Denom, /// The asset to pay the transfer fees with. #[arg(long, default_value = "nria")] - pub fee_asset: asset::Denom, + pub(crate) fee_asset: asset::Denom, } #[derive(Args, Debug)] -pub struct FeeAssetChangeArgs { +pub(crate) struct FeeAssetChangeArgs { /// The bech32m prefix that will be used for constructing addresses using the private key #[arg(long, default_value = "astria")] pub(crate) prefix: String, @@ -178,14 +178,14 @@ pub struct FeeAssetChangeArgs { env = "ROLLUP_SEQUENCER_CHAIN_ID", default_value = crate::cli::DEFAULT_SEQUENCER_CHAIN_ID )] - pub sequencer_chain_id: String, + pub(crate) sequencer_chain_id: String, /// Asset's denomination string #[arg(long)] pub(crate) asset: asset::Denom, } #[derive(Args, Debug)] -pub struct IbcRelayerChangeArgs { +pub(crate) struct IbcRelayerChangeArgs { /// The prefix to construct a bech32m address given the private key. #[arg(long, default_value = "astria")] pub(crate) prefix: String, @@ -208,14 +208,14 @@ pub struct IbcRelayerChangeArgs { env = "ROLLUP_SEQUENCER_CHAIN_ID", default_value = crate::cli::DEFAULT_SEQUENCER_CHAIN_ID )] - pub sequencer_chain_id: String, + pub(crate) sequencer_chain_id: String, /// The address to add or remove as an IBC relayer #[arg(long)] pub(crate) address: Address, } #[derive(Args, Debug)] -pub struct InitBridgeAccountArgs { +pub(crate) struct InitBridgeAccountArgs { /// The bech32m prefix that will be used for constructing addresses using the private key #[arg(long, default_value = "astria")] pub(crate) prefix: String, @@ -238,21 +238,21 @@ pub struct InitBridgeAccountArgs { env = "ROLLUP_SEQUENCER_CHAIN_ID", default_value = crate::cli::DEFAULT_SEQUENCER_CHAIN_ID )] - pub sequencer_chain_id: String, + pub(crate) sequencer_chain_id: String, /// Plaintext rollup name (to be hashed into a rollup ID) /// to initialize the bridge account with. #[arg(long)] pub(crate) rollup_name: String, /// The asset to transer. #[arg(long, default_value = "nria")] - pub asset: asset::Denom, + pub(crate) asset: asset::Denom, /// The asset to pay the transfer fees with. #[arg(long, default_value = "nria")] - pub fee_asset: asset::Denom, + pub(crate) fee_asset: asset::Denom, } #[derive(Args, Debug)] -pub struct BridgeLockArgs { +pub(crate) struct BridgeLockArgs { /// The address of the Sequencer account to lock amount to pub(crate) to_address: Address, /// The amount being locked @@ -282,23 +282,23 @@ pub struct BridgeLockArgs { env = "ROLLUP_SEQUENCER_CHAIN_ID", default_value = crate::cli::DEFAULT_SEQUENCER_CHAIN_ID )] - pub sequencer_chain_id: String, + pub(crate) sequencer_chain_id: String, /// The asset to lock. #[arg(long, default_value = "nria")] - pub asset: asset::Denom, + pub(crate) asset: asset::Denom, /// The asset to pay the transfer fees with. #[arg(long, default_value = "nria")] - pub fee_asset: asset::Denom, + pub(crate) fee_asset: asset::Denom, } #[derive(Debug, Subcommand)] -pub enum BlockHeightCommand { +pub(crate) enum BlockHeightCommand { /// Get the current block height of the Sequencer node Get(BlockHeightGetArgs), } #[derive(Args, Debug)] -pub struct BlockHeightGetArgs { +pub(crate) struct BlockHeightGetArgs { /// The url of the Sequencer node #[arg( long, @@ -312,11 +312,11 @@ pub struct BlockHeightGetArgs { env = "ROLLUP_SEQUENCER_CHAIN_ID", default_value = crate::cli::DEFAULT_SEQUENCER_CHAIN_ID )] - pub sequencer_chain_id: String, + pub(crate) sequencer_chain_id: String, } #[derive(Args, Debug)] -pub struct SudoAddressChangeArgs { +pub(crate) struct SudoAddressChangeArgs { /// The bech32m prefix that will be used for constructing addresses using the private key #[arg(long, default_value = "astria")] pub(crate) prefix: String, @@ -339,14 +339,14 @@ pub struct SudoAddressChangeArgs { env = "ROLLUP_SEQUENCER_CHAIN_ID", default_value = crate::cli::DEFAULT_SEQUENCER_CHAIN_ID )] - pub sequencer_chain_id: String, + pub(crate) sequencer_chain_id: String, /// The new address to take over sudo privileges #[arg(long)] pub(crate) address: Address, } #[derive(Args, Debug)] -pub struct ValidatorUpdateArgs { +pub(crate) struct ValidatorUpdateArgs { /// The url of the Sequencer node #[arg( long, @@ -360,7 +360,7 @@ pub struct ValidatorUpdateArgs { env = "ROLLUP_SEQUENCER_CHAIN_ID", default_value = crate::cli::DEFAULT_SEQUENCER_CHAIN_ID )] - pub sequencer_chain_id: String, + pub(crate) sequencer_chain_id: String, /// The bech32m prefix that will be used for constructing addresses using the private key #[arg(long, default_value = "astria")] pub(crate) prefix: String, diff --git a/crates/astria-cli/src/commands/bridge/collect.rs b/crates/astria-cli/src/commands/bridge/collect.rs new file mode 100644 index 0000000000..46cf8dbcf2 --- /dev/null +++ b/crates/astria-cli/src/commands/bridge/collect.rs @@ -0,0 +1,582 @@ +use std::{ + collections::BTreeMap, + path::{ + Path, + PathBuf, + }, + sync::Arc, + time::Duration, +}; + +use astria_bridge_contracts::i_astria_withdrawer::{ + IAstriaWithdrawer, + Ics20WithdrawalFilter, + SequencerWithdrawalFilter, +}; +use astria_core::{ + bridge::{ + self, + Ics20WithdrawalFromRollupMemo, + }, + primitive::v1::{ + asset::{ + self, + TracePrefixed, + }, + Address, + }, + protocol::transaction::v1alpha1::{ + action::{ + BridgeUnlockAction, + Ics20Withdrawal, + }, + Action, + }, +}; +use clap::Args; +use color_eyre::eyre::{ + self, + bail, + ensure, + eyre, + OptionExt as _, + WrapErr as _, +}; +use ethers::{ + contract::EthEvent, + core::types::Block, + providers::{ + Middleware, + Provider, + ProviderError, + StreamExt as _, + Ws, + }, + types::{ + Filter, + Log, + H256, + }, +}; +use futures::stream::BoxStream; +use tracing::{ + error, + info, + instrument, + warn, +}; + +#[derive(Args, Debug)] +pub(crate) struct WithdrawalEvents { + /// The websocket endpoint of a geth compatible rollup. + #[arg(long)] + rollup_endpoint: String, + /// The eth address of the astria bridge contracts. + #[arg(long)] + contract_address: ethers::types::Address, + /// The start rollup height from which blocks will be checked for withdrawal events. + #[arg(long)] + from_rollup_height: u64, + /// The end rollup height from which blocks will be checked for withdrawal events. + /// If not set, then this tool will stream blocks until SIGINT is received. + #[arg(long)] + to_rollup_height: Option, + /// The asset that will be used to pay the Sequencer fees (should the generated + /// actions be submitted to the Sequencer). + #[arg(long, default_value = "nria")] + fee_asset: asset::Denom, + /// The asset denomination of the asset that's withdrawn from the bridge. + #[arg(long)] + rollup_asset_denom: asset::Denom, + /// The bech32-encoded bridge address corresponding to the bridged rollup + /// asset on the sequencer. Should match the bridge address in the geth + /// rollup's bridge configuration for that asset. + #[arg(long)] + bridge_address: Address, + /// The path to write the collected withdrawal events converted + /// to Sequencer actions. + #[arg(long, short)] + output: PathBuf, +} + +impl WithdrawalEvents { + pub(crate) async fn run(self) -> eyre::Result<()> { + let Self { + rollup_endpoint, + contract_address, + from_rollup_height, + to_rollup_height, + fee_asset, + rollup_asset_denom, + bridge_address, + output, + } = self; + + let output = open_output(&output).wrap_err("failed to open output for writing")?; + + let block_provider = connect_to_rollup(&rollup_endpoint) + .await + .wrap_err("failed to connect to rollup")?; + + let asset_withdrawal_divisor = + get_asset_withdrawal_divisor(contract_address, block_provider.clone()) + .await + .wrap_err("failed determining asset withdrawal divisor")?; + + let mut incoming_blocks = + create_stream_of_blocks(&block_provider, from_rollup_height, to_rollup_height) + .await + .wrap_err("failed initializing stream of rollup blocks")?; + + let mut actions_by_rollup_height = ActionsByRollupHeight::new(); + loop { + tokio::select! { + biased; + + _ = tokio::signal::ctrl_c() => { + break; + } + + block = incoming_blocks.next() => { + match block { + Some(Ok(block)) => + if let Err(err) = actions_by_rollup_height.convert_and_insert(BlockToActions { + block_provider: block_provider.clone(), + contract_address, + block, + fee_asset: fee_asset.clone(), + rollup_asset_denom: rollup_asset_denom.clone(), + bridge_address, + asset_withdrawal_divisor, + }).await { + error!( + err = AsRef::::as_ref(&err), + "failed converting contract block to Sequencer actions and storing them; exiting stream"); + break; + } + Some(Err(error)) => { + error!( + error = AsRef::::as_ref(&error), + "encountered an error getting block; exiting stream", + ); + break; + }, + None => { + info!("block subscription ended"); + break; + } + } + } + } + } + + actions_by_rollup_height + .write_to_output(output) + .wrap_err("failed to write actions to file") + } +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(transparent)] +pub(crate) struct ActionsByRollupHeight(BTreeMap>); + +impl ActionsByRollupHeight { + fn new() -> Self { + Self(BTreeMap::new()) + } + + pub(crate) fn into_inner(self) -> BTreeMap> { + self.0 + } + + #[instrument(skip_all, err)] + async fn convert_and_insert(&mut self, block_to_actions: BlockToActions) -> eyre::Result<()> { + let rollup_height = block_to_actions + .block + .number + .ok_or_eyre("block was missing a number")? + .as_u64(); + let actions = block_to_actions.run().await; + ensure!( + self.0.insert(rollup_height, actions).is_none(), + "already collected actions for block at rollup height `{rollup_height}`; no 2 blocks \ + with the same height should have been seen", + ); + Ok(()) + } + + #[instrument(skip_all, fields(target = %output.path.display()), err)] + fn write_to_output(self, output: Output) -> eyre::Result<()> { + let writer = std::io::BufWriter::new(output.handle); + serde_json::to_writer(writer, &self.0).wrap_err("failed writing actions to file") + } +} + +/// Constructs a block stream from `start` until `maybe_end`, if `Some`. +/// Constructs an open ended stream from `start` if `None`. +#[instrument(skip_all, fields(start, end = maybe_end), err)] +async fn create_stream_of_blocks( + block_provider: &Provider, + start: u64, + maybe_end: Option, +) -> eyre::Result>>> { + let subscription = if let Some(end) = maybe_end { + futures::stream::iter(start..=end) + .then(move |height| async move { + block_provider + .get_block(height) + .await + .wrap_err("failed to get block")? + .ok_or_else(|| eyre!("block with number {height} missing")) + }) + .boxed() + } else { + let mut block_subscription = block_provider + .subscribe_blocks() + .await + .wrap_err("failed to subscribe to blocks from rollup")? + .boxed(); + + let Some(current_rollup_block) = block_subscription.next().await else { + bail!("failed to get current rollup block from subscription") + }; + + let Some(current_rollup_block_height) = current_rollup_block.number else { + bail!( + "couldn't determine current rollup block height; value was not set on current on \ + most recent block", + ); + }; + + futures::stream::iter(start..current_rollup_block_height.as_u64()) + .then(move |height| async move { + block_provider + .get_block(height) + .await + .wrap_err("failed to get block")? + .ok_or_else(|| eyre!("block with number {height} missing")) + }) + .chain(futures::stream::once( + async move { Ok(current_rollup_block) }, + )) + .chain(block_subscription.map(Ok)) + .boxed() + }; + Ok(subscription) +} + +#[derive(Debug)] +struct Output { + handle: std::fs::File, + path: PathBuf, +} + +#[instrument(skip_all, fields(target = %target.as_ref().display()), err)] +fn open_output>(target: P) -> eyre::Result { + let handle = std::fs::File::options() + .write(true) + .create_new(true) + .open(&target) + .wrap_err("failed to open specified fil}e for writing")?; + Ok(Output { + handle, + path: target.as_ref().to_path_buf(), + }) +} + +#[instrument(err)] +async fn connect_to_rollup(rollup_endpoint: &str) -> eyre::Result>> { + let retry_config = tryhard::RetryFutureConfig::new(10) + .fixed_backoff(Duration::from_secs(2)) + .on_retry( + |attempt, next_delay: Option, error: &ProviderError| { + let wait_duration = next_delay + .map(humantime::format_duration) + .map(tracing::field::display); + warn!( + attempt, + wait_duration, + error = error as &dyn std::error::Error, + "attempt to connect to rollup node failed; retrying after backoff", + ); + std::future::ready(()) + }, + ); + + let provider = tryhard::retry_fn(|| Provider::::connect(rollup_endpoint)) + .with_config(retry_config) + .await + .wrap_err("failed connecting to rollup after several retries; giving up")?; + Ok(Arc::new(provider)) +} + +#[instrument(skip_all, fields(%contract_address), err(Display))] +async fn get_asset_withdrawal_divisor( + contract_address: ethers::types::Address, + provider: Arc>, +) -> eyre::Result { + let contract = IAstriaWithdrawer::new(contract_address, provider); + + let base_chain_asset_precision = contract + .base_chain_asset_precision() + .call() + .await + .wrap_err("failed to get asset withdrawal decimals")?; + + let exponent = 18u32.checked_sub(base_chain_asset_precision).ok_or_eyre( + "failed calculating asset divisor. The base chain asset precision should be <= 18 as \ + that's enforced by the contract, so the construction should work. Did the precision \ + change?", + )?; + Ok(10u128.pow(exponent)) +} + +fn packet_timeout_time() -> eyre::Result { + tendermint::Time::now() + .checked_add(Duration::from_secs(300)) + .ok_or_eyre("adding 5 minutes to current time caused overflow")? + .unix_timestamp_nanos() + .try_into() + .wrap_err("failed to i128 nanoseconds to u64") +} + +struct BlockToActions { + block_provider: Arc>, + contract_address: ethers::types::Address, + block: Block, + fee_asset: asset::Denom, + rollup_asset_denom: asset::Denom, + bridge_address: Address, + asset_withdrawal_divisor: u128, +} + +impl BlockToActions { + async fn run(self) -> Vec { + let mut actions = Vec::new(); + + let Some(block_hash) = self.block.hash else { + warn!("block hash missing; skipping"); + return actions; + }; + + match get_log::( + self.block_provider.clone(), + self.contract_address, + block_hash, + ) + .await + { + Err(error) => warn!( + error = AsRef::::as_ref(&error), + "encountered an error getting logs for sequencer withdrawal events", + ), + Ok(logs) => { + for log in logs { + match self.log_to_sequencer_withdrawal_action(log) { + Ok(action) => actions.push(action), + Err(error) => { + warn!( + error = AsRef::::as_ref(&error), + "failed converting ethers contract log to sequencer withdrawal \ + action; skipping" + ); + } + } + } + } + } + match get_log::( + self.block_provider.clone(), + self.contract_address, + block_hash, + ) + .await + { + Err(error) => warn!( + error = AsRef::::as_ref(&error), + "encountered an error getting logs for ics20 withdrawal events", + ), + Ok(logs) => { + for log in logs { + match self.log_to_ics20_withdrawal_action(log) { + Ok(action) => actions.push(action), + Err(error) => { + warn!( + error = AsRef::::as_ref(&error), + "failed converting ethers contract log to ics20 withdrawal \ + action; skipping" + ); + } + } + } + } + } + actions + } + + fn log_to_ics20_withdrawal_action(&self, log: Log) -> eyre::Result { + LogToIcs20WithdrawalAction { + log, + fee_asset: self.fee_asset.clone(), + rollup_asset_denom: self.rollup_asset_denom.clone(), + asset_withdrawal_divisor: self.asset_withdrawal_divisor, + bridge_address: self.bridge_address, + } + .try_convert() + .wrap_err("failed converting log to ics20 withdrawal action") + } + + fn log_to_sequencer_withdrawal_action(&self, log: Log) -> eyre::Result { + LogToSequencerWithdrawalAction { + log, + bridge_address: self.bridge_address, + fee_asset: self.fee_asset.clone(), + asset_withdrawal_divisor: self.asset_withdrawal_divisor, + } + .try_into_action() + .wrap_err("failed converting log to sequencer withdrawal action") + } +} + +fn action_inputs_from_log(log: Log) -> eyre::Result<(T, u64, [u8; 32])> { + let block_number = log + .block_number + .ok_or_eyre("log did not contain block number")? + .as_u64(); + let transaction_hash = log + .transaction_hash + .ok_or_eyre("log did not contain transaction hash")? + .into(); + + let event = T::decode_log(&log.into()) + .wrap_err_with(|| format!("failed decoding contract log as `{}`", T::name()))?; + Ok((event, block_number, transaction_hash)) +} + +#[derive(Debug)] +struct LogToIcs20WithdrawalAction { + log: Log, + fee_asset: asset::Denom, + rollup_asset_denom: asset::Denom, + asset_withdrawal_divisor: u128, + bridge_address: Address, +} + +impl LogToIcs20WithdrawalAction { + fn try_convert(self) -> eyre::Result { + let Self { + log, + fee_asset, + rollup_asset_denom, + asset_withdrawal_divisor, + bridge_address, + } = self; + + let (event, block_number, transaction_hash) = + action_inputs_from_log::(log) + .wrap_err("failed getting required data from log")?; + + let source_channel = rollup_asset_denom + .as_trace_prefixed() + .and_then(TracePrefixed::last_channel) + .ok_or_eyre("rollup asset denom must have a channel to be withdrawn via IBC")? + .parse() + .wrap_err("failed to parse channel from rollup asset denom")?; + + let memo = Ics20WithdrawalFromRollupMemo { + memo: event.memo, + block_number, + rollup_return_address: event.sender.to_string(), + transaction_hash, + }; + + let action = Ics20Withdrawal { + denom: rollup_asset_denom, + destination_chain_address: event.destination_chain_address, + // note: this is actually a rollup address; we expect failed ics20 withdrawals to be + // returned to the rollup. + // this is only ok for now because addresses on the sequencer and the rollup are both 20 + // bytes, but this won't work otherwise. + return_address: bridge_address, + amount: event + .amount + .as_u128() + .checked_div(asset_withdrawal_divisor) + .ok_or(eyre::eyre!( + "failed to divide amount by asset withdrawal multiplier" + ))?, + memo: serde_json::to_string(&memo).wrap_err("failed to serialize memo to json")?, + fee_asset, + // note: this refers to the timeout on the destination chain, which we are unaware of. + // thus, we set it to the maximum possible value. + timeout_height: ibc_types::core::client::Height::new(u64::MAX, u64::MAX) + .wrap_err("failed to generate timeout height")?, + timeout_time: packet_timeout_time() + .wrap_err("failed to calculate packet timeout time")?, + source_channel, + bridge_address: Some(bridge_address), + }; + Ok(Action::Ics20Withdrawal(action)) + } +} + +#[derive(Debug)] +struct LogToSequencerWithdrawalAction { + log: Log, + fee_asset: asset::Denom, + asset_withdrawal_divisor: u128, + bridge_address: Address, +} + +impl LogToSequencerWithdrawalAction { + fn try_into_action(self) -> eyre::Result { + let Self { + log, + fee_asset, + asset_withdrawal_divisor, + bridge_address, + } = self; + let (event, block_number, transaction_hash) = + action_inputs_from_log::(log) + .wrap_err("failed getting required data from log")?; + + let memo = bridge::UnlockMemo { + block_number, + transaction_hash, + }; + + let action = BridgeUnlockAction { + to: event + .destination_chain_address + .parse() + .wrap_err("failed to parse destination chain address")?, + amount: event + .amount + .as_u128() + .checked_div(asset_withdrawal_divisor) + .ok_or_eyre("failed to divide amount by asset withdrawal multiplier")?, + memo: serde_json::to_string(&memo).wrap_err("failed to serialize memo to json")?, + fee_asset, + bridge_address: Some(bridge_address), + }; + + Ok(Action::BridgeUnlock(action)) + } +} + +async fn get_log( + provider: Arc>, + contract_address: ethers::types::Address, + block_hash: H256, +) -> eyre::Result> { + let event_sig = T::signature(); + let filter = Filter::new() + .at_block_hash(block_hash) + .address(contract_address) + .topic0(event_sig); + + provider + .get_logs(&filter) + .await + .wrap_err("failed to get sequencer withdrawal events") +} diff --git a/crates/astria-cli/src/commands/bridge/mod.rs b/crates/astria-cli/src/commands/bridge/mod.rs new file mode 100644 index 0000000000..9f12fb87d5 --- /dev/null +++ b/crates/astria-cli/src/commands/bridge/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod collect; +pub(crate) mod submit; diff --git a/crates/astria-cli/src/commands/bridge/submit.rs b/crates/astria-cli/src/commands/bridge/submit.rs new file mode 100644 index 0000000000..7287fee1a4 --- /dev/null +++ b/crates/astria-cli/src/commands/bridge/submit.rs @@ -0,0 +1,155 @@ +use std::path::{ + Path, + PathBuf, +}; + +use astria_core::{ + crypto::SigningKey, + protocol::transaction::v1alpha1::{ + Action, + TransactionParams, + UnsignedTransaction, + }, +}; +use astria_sequencer_client::{ + tendermint_rpc::endpoint, + Address, + HttpClient, + SequencerClientExt as _, +}; +use clap::Args; +use color_eyre::eyre::{ + self, + ensure, + WrapErr as _, +}; +use tracing::{ + error, + info, + instrument, + warn, +}; + +#[derive(Args, Debug)] +pub(crate) struct WithdrawalEvents { + #[arg(long, short)] + input: PathBuf, + #[arg(long)] + signing_key: PathBuf, + #[arg(long, default_value = "astria")] + sequencer_address_prefix: String, + #[arg(long)] + sequencer_chain_id: String, + #[arg(long)] + sequencer_url: String, +} + +impl WithdrawalEvents { + pub(crate) async fn run(self) -> eyre::Result<()> { + let signing_key = read_signing_key(&self.signing_key).wrap_err_with(|| { + format!( + "failed reading signing key from file: {}", + self.signing_key.display() + ) + })?; + + let actions_by_rollup_number = read_actions(&self.input).wrap_err_with(|| { + format!("failed reading actions from file: {}", self.input.display()) + })?; + + let sequencer_client = HttpClient::new(&*self.sequencer_url) + .wrap_err("failed constructing http sequencer client")?; + + for (rollup_height, actions) in actions_by_rollup_number.into_inner() { + if actions.is_empty() { + warn!( + rollup_height, + "entry for rollup height exists, but actions were empty; skipping" + ); + continue; + } + match submit_transaction( + sequencer_client.clone(), + &self.sequencer_chain_id, + &self.sequencer_address_prefix, + &signing_key, + actions, + ) + .await + .wrap_err_with(|| { + format!("submitting withdrawal actions for rollup height `{rollup_height}` failed") + }) { + Err(e) => { + error!( + rollup_height, + "failed submitting actions; bailing and not submitting the rest" + ); + return Err(e); + } + Ok(response) => info!( + sequencer_height = %response.height, + rollup_height, + "actions derived from rollup succesfully submitted to sequencer" + ), + } + } + Ok(()) + } +} + +fn read_actions>(path: P) -> eyre::Result { + let s = std::fs::read_to_string(path).wrap_err("failed buffering file contents as string")?; + serde_json::from_str(&s) + .wrap_err("failed deserializing file contents height-to-sequencer-actions serde object") +} + +fn read_signing_key>(path: P) -> eyre::Result { + let hex = + std::fs::read_to_string(&path).wrap_err("failed to read file contents into buffer")?; + let bytes = hex::decode(hex.trim()).wrap_err("failed to decode file contents as hex")?; + SigningKey::try_from(&*bytes).wrap_err("failed to construct signing key hex-decoded bytes") +} + +#[instrument(skip_all, fields(actions = actions.len()), err)] +async fn submit_transaction( + client: HttpClient, + chain_id: &str, + prefix: &str, + signing_key: &SigningKey, + actions: Vec, +) -> eyre::Result { + let from_address = Address::builder() + .array(signing_key.verification_key().address_bytes()) + .prefix(prefix) + .try_build() + .wrap_err("failed constructing a valid from address from the provided prefix")?; + + let nonce_res = client + .get_latest_nonce(from_address) + .await + .wrap_err("failed to get nonce")?; + + let tx = UnsignedTransaction { + params: TransactionParams::builder() + .nonce(nonce_res.nonce) + .chain_id(chain_id) + .build(), + actions, + } + .into_signed(signing_key); + let res = client + .submit_transaction_commit(tx) + .await + .wrap_err("failed to submit transaction")?; + ensure!( + res.check_tx.code.is_ok(), + "failed to check tx: {}", + res.check_tx.log + ); + ensure!( + res.tx_result.code.is_ok(), + "failed to execute tx: {}", + res.tx_result.log + ); + Ok(res) +} diff --git a/crates/astria-cli/src/commands/mod.rs b/crates/astria-cli/src/commands/mod.rs index f4f7814e78..436d5c74fc 100644 --- a/crates/astria-cli/src/commands/mod.rs +++ b/crates/astria-cli/src/commands/mod.rs @@ -1,10 +1,10 @@ +pub(crate) mod bridge; mod sequencer; use color_eyre::{ eyre, eyre::eyre, }; -use tracing::instrument; use crate::cli::{ sequencer::{ @@ -34,10 +34,12 @@ use crate::cli::{ /// # Panics /// /// * If the command is not recognized -#[instrument] pub async fn run(cli: Cli) -> eyre::Result<()> { if let Some(command) = cli.command { match command { + Command::Bridge { + command, + } => command.run().await?, Command::Sequencer { command, } => match command { diff --git a/crates/astria-cli/src/main.rs b/crates/astria-cli/src/main.rs index 81c257e2ad..c587580795 100644 --- a/crates/astria-cli/src/main.rs +++ b/crates/astria-cli/src/main.rs @@ -4,13 +4,16 @@ use astria_cli::{ cli::Cli, commands, }; -use color_eyre::{ - eyre, - eyre::Context, -}; +use color_eyre::eyre; + +#[tokio::main] +async fn main() -> ExitCode { + tracing_subscriber::fmt() + .pretty() + .with_writer(std::io::stderr) + .init(); -fn main() -> ExitCode { - if let Err(err) = run() { + if let Err(err) = run().await { eprintln!("{err:?}"); return ExitCode::FAILURE; } @@ -18,12 +21,7 @@ fn main() -> ExitCode { ExitCode::SUCCESS } -/// Run our asynchronous command code in a blocking manner -fn run() -> eyre::Result<()> { - let rt = tokio::runtime::Runtime::new().wrap_err("failed to create a new runtime")?; - - rt.block_on(async { - let args = Cli::get_args()?; - commands::run(args).await - }) +async fn run() -> eyre::Result<()> { + let args = Cli::get_args()?; + commands::run(args).await }