Merge pull request #587 from logos-blockchain/arjentix/wallet-wait-for-tx-committed

feat!(wallet): wait for deploy tx inclusion in block
This commit is contained in:
Daniil Polyakov 2026-07-09 17:03:15 +03:00 committed by GitHub
commit 2921438e93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 135 additions and 101 deletions

4
Cargo.lock generated
View File

@ -1854,9 +1854,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]

View File

@ -343,4 +343,4 @@ clippy.cargo = { level = "deny", priority = -1 }
clippy.cargo-common-metadata = "allow"
# Reason: hard to address right now and mostly comes from dependencies
# so the fix would be just a long list of exceptions.
clippy.multiple-crate-versions = "allow"
clippy.multiple-crate-versions = "allow"

View File

@ -69,8 +69,8 @@ async fn private_transfer_to_foreign_account() -> Result<()> {
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
@ -158,8 +158,8 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Expected TransactionExecuted return value");
};
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;
@ -237,8 +237,8 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> {
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
@ -297,7 +297,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Failed to send transaction");
};

View File

@ -57,8 +57,8 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Expected TransactionExecuted return value");
};
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;

View File

@ -162,8 +162,8 @@ async fn claim_pinata_to_existing_private_account() -> Result<()> {
let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash: _ } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash: _ } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");

View File

@ -11,7 +11,7 @@ use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account
use log::info;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::cli::Command;
use wallet::{cli::Command, config::WalletConfigOverrides};
#[test]
async fn deploy_and_execute_program() -> Result<()> {
@ -29,9 +29,6 @@ async fn deploy_and_execute_program() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let account_id = new_account(&mut ctx, false, None).await?;
let nonces = ctx.wallet().get_accounts_nonces(vec![account_id]).await?;
@ -65,3 +62,37 @@ async fn deploy_and_execute_program() -> Result<()> {
Ok(())
}
#[test]
async fn deploy_invalid_program_fails() -> Result<()> {
// An invalid program bytecode is rejected by the sequencer during block production, so the
// deployment transaction is never included in a block. Shrink the wallet's polling window so
// the command gives up quickly instead of waiting for the full default timeout.
let mut ctx = TestContext::builder()
.with_wallet_config_overrides(WalletConfigOverrides {
seq_poll_timeout: Some(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)),
seq_tx_poll_max_blocks: Some(5),
seq_poll_max_retries: Some(2),
..WalletConfigOverrides::default()
})
.build()
.await?;
let mut tempfile = tempfile::NamedTempFile::new()?;
tempfile.write_all(b"this is not a valid program binary")?;
let command = Command::DeployProgram {
binary_filepath: tempfile.path().to_owned(),
};
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await;
assert!(
result.is_err(),
"Deploying an invalid program should fail, but got: {result:?}"
);
info!("Deploying an invalid program failed as expected");
Ok(())
}

View File

@ -30,7 +30,7 @@ async fn public_transfer_and_public_claim() -> Result<()> {
.get_account_balance(recipient_vault_id)
.await?;
let transfer_result = wallet::cli::execute_subcommand(
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Vault(VaultSubcommand::Transfer {
from: public_mention(sender),
@ -39,10 +39,6 @@ async fn public_transfer_and_public_claim() -> Result<()> {
}),
)
.await?;
assert!(
matches!(transfer_result, SubcommandReturnValue::Empty),
"Expected Empty return value for public vault transfer"
);
let sender_balance_after_transfer = ctx.sequencer_client().get_account_balance(sender).await?;
let recipient_balance_after_transfer = ctx
@ -64,7 +60,7 @@ async fn public_transfer_and_public_claim() -> Result<()> {
recipient_vault_balance_before + amount
);
let claim_result = wallet::cli::execute_subcommand(
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Vault(VaultSubcommand::Claim {
account_id: public_mention(recipient),
@ -72,10 +68,6 @@ async fn public_transfer_and_public_claim() -> Result<()> {
}),
)
.await?;
assert!(
matches!(claim_result, SubcommandReturnValue::Empty),
"Expected Empty return value for public vault claim"
);
let sender_balance_after_claim = ctx.sequencer_client().get_account_balance(sender).await?;
let recipient_balance_after_claim = ctx
@ -138,9 +130,9 @@ async fn private_transfer_and_private_claim() -> Result<()> {
assert!(
matches!(
transfer_result,
SubcommandReturnValue::PrivacyPreservingTransfer { .. }
SubcommandReturnValue::TransactionExecuted { .. }
),
"Expected PrivacyPreservingTransfer return value for private vault transfer"
"Expected TransactionExecuted return value for private vault transfer"
);
let sender_balance_after_transfer = ctx
@ -179,9 +171,9 @@ async fn private_transfer_and_private_claim() -> Result<()> {
assert!(
matches!(
claim_result,
SubcommandReturnValue::PrivacyPreservingTransfer { .. }
SubcommandReturnValue::TransactionExecuted { .. }
),
"Expected PrivacyPreservingTransfer return value for private vault claim"
"Expected TransactionExecuted return value for private vault claim"
);
let sender_balance_after_claim = ctx

View File

@ -1,10 +1,18 @@
use borsh::{BorshDeserialize, BorshSerialize};
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct Message {
pub(crate) bytecode: Vec<u8>,
}
impl std::fmt::Debug for Message {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Message")
.field("bytecode", &format_args!("<{} bytes>", self.bytecode.len()))
.finish()
}
}
impl Message {
#[must_use]
pub const fn new(bytecode: Vec<u8>) -> Self {

View File

@ -7,6 +7,7 @@ use common::{
transaction::LeeTransaction,
};
use lee::V03State;
use lee_core::BlockId;
use log::info;
use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint;
pub use storage::DbResult;
@ -18,7 +19,7 @@ use storage::sequencer::{
pub struct SequencerStore {
dbio: Arc<RocksDBIO>,
// TODO: Consider adding the hashmap to the database for faster recovery.
tx_hash_to_block_map: HashMap<HashType, u64>,
tx_hash_to_block_map: HashMap<HashType, BlockId>,
genesis_id: u64,
signing_key: lee::PrivateKey,
}
@ -96,7 +97,7 @@ impl SequencerStore {
/// Returns the transaction corresponding to the given hash, if it exists in the blockchain.
#[must_use]
pub fn get_transaction_by_hash(&self, hash: HashType) -> Option<LeeTransaction> {
pub fn get_transaction_by_hash(&self, hash: HashType) -> Option<(LeeTransaction, BlockId)> {
let block_id = *self.tx_hash_to_block_map.get(&hash)?;
let block = self
.get_block_at_id(block_id)
@ -105,7 +106,7 @@ impl SequencerStore {
.expect("Block should be present since the hash is in the map");
for transaction in block.body.transactions {
if transaction.hash() == hash {
return Some(transaction);
return Some((transaction, block_id));
}
}
panic!(
@ -181,8 +182,6 @@ pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap<HashType, u64>
#[cfg(test)]
mod tests {
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
use common::{block::HashableBlockData, test_utils::sequencer_sign_key_for_testing};
use tempfile::tempdir;
@ -224,8 +223,8 @@ mod tests {
.update(&block, &[], vec![], &dummy_state)
.unwrap();
// Try again
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some(tx), retrieved_tx);
let output = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some((tx, 1)), output);
}
#[test]
@ -383,7 +382,7 @@ mod tests {
// Re-open the store and verify that the transaction is still retrievable (which means it
// was cached correctly)
let node_store = SequencerStore::open_db(path, signing_key).unwrap();
let retrieved_tx = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some(tx), retrieved_tx);
let output = node_store.get_transaction_by_hash(tx.hash());
assert_eq!(Some((tx, 1)), output);
}
}

View File

@ -68,7 +68,7 @@ pub trait Rpc {
async fn get_transaction(
&self,
tx_hash: HashType,
) -> Result<Option<LeeTransaction>, ErrorObjectOwned>;
) -> Result<Option<(LeeTransaction, BlockId)>, ErrorObjectOwned>;
#[method(name = "getAccountsNonces")]
async fn get_accounts_nonces(

View File

@ -131,7 +131,7 @@ impl<BC: BlockPublisherTrait + Send + 'static> sequencer_service_rpc::RpcServer
async fn get_transaction(
&self,
tx_hash: HashType,
) -> Result<Option<LeeTransaction>, ErrorObjectOwned> {
) -> Result<Option<(LeeTransaction, BlockId)>, ErrorObjectOwned> {
let sequencer = self.sequencer.lock().await;
Ok(sequencer.block_store().get_transaction_by_hash(tx_hash))
}

View File

@ -7,6 +7,7 @@ use common::{HashType, transaction::LeeTransaction};
use derive_more::Display;
use futures::TryFutureExt as _;
use lee::ProgramDeploymentTransaction;
use lee_core::BlockId;
use sequencer_service_rpc::RpcClient as _;
pub use crate::helperfunctions::{read_mnemonic, read_pin};
@ -117,11 +118,11 @@ pub struct Args {
#[derive(Debug, Clone)]
pub enum SubcommandReturnValue {
PrivacyPreservingTransfer { tx_hash: HashType },
TransactionExecuted { tx_hash: HashType },
RegisterAccount { account_id: lee::AccountId },
Account(lee::Account),
Empty,
SyncedToBlock(u64),
SyncedToBlock(BlockId),
}
#[derive(Debug, Display, Clone, PartialEq, Eq, Hash)]
@ -286,13 +287,16 @@ pub async fn execute_subcommand(
))?;
let message = lee::program_deployment_transaction::Message::new(bytecode);
let transaction = ProgramDeploymentTransaction::new(message);
let _response = wallet_core
let tx_hash = wallet_core
.sequencer_client
.send_transaction(LeeTransaction::ProgramDeployment(transaction))
.await
.context("Transaction submission error")?;
SubcommandReturnValue::Empty
wallet_core
.poll_and_finalize_public_transaction(tx_hash)
.await
.context("Transaction finalization error")?
}
};

View File

@ -47,9 +47,10 @@ impl WalletSubcommand for BridgeSubcommand {
.send_withdraw(sender_account_id, amount, bedrock_account_pk)
.await?;
println!("Transaction hash is {tx_hash}");
Ok(SubcommandReturnValue::Empty)
wallet_core
.poll_and_finalize_public_transaction(tx_hash)
.await
.context("Transaction finalization error")
}
}
}

View File

@ -443,7 +443,7 @@ impl NativeTokenTransferProgramSubcommandShielded {
wallet_core.store_persistent_data()?;
Ok(SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash })
Ok(SubcommandReturnValue::TransactionExecuted { tx_hash })
}
}

View File

@ -490,11 +490,6 @@ impl WalletCore {
Some(Commitment::new(&account_id, account))
}
/// Poll transactions.
pub async fn poll_native_token_transfer(&self, hash: HashType) -> Result<LeeTransaction> {
self.poller.poll_tx(hash).await
}
pub async fn check_private_account_initialized(
&self,
account_id: AccountId,
@ -549,7 +544,6 @@ impl WalletCore {
}
}
println!("Transaction data is {:?}", tx.message);
Ok(())
}
@ -558,10 +552,11 @@ impl WalletCore {
tx_hash: HashType,
) -> Result<cli::SubcommandReturnValue> {
println!("Transaction hash is {tx_hash}");
let transfer_tx = self.poll_native_token_transfer(tx_hash).await?;
println!("Transaction data is {transfer_tx:?}");
let (tx, block_id) = self.poller.poll_tx(tx_hash).await?;
println!("Transaction is included in block {block_id}");
println!("Transaction data is {tx:?}");
self.store_persistent_data()?;
Ok(cli::SubcommandReturnValue::Empty)
Ok(cli::SubcommandReturnValue::TransactionExecuted { tx_hash })
}
/// Pass an empty slice when the recipient is foreign and no accounts need decoding.
@ -571,12 +566,17 @@ impl WalletCore {
acc_decode_data: &[AccDecodeData],
) -> Result<cli::SubcommandReturnValue> {
println!("Transaction hash is {tx_hash}");
let transfer_tx = self.poll_native_token_transfer(tx_hash).await?;
if let common::transaction::LeeTransaction::PrivacyPreserving(tx) = transfer_tx {
self.decode_insert_privacy_preserving_transaction_results(&tx, acc_decode_data)?;
let (tx, block_id) = self.poller.poll_tx(tx_hash).await?;
println!("Transaction is included in block {block_id}");
println!("Transaction data is {tx:?}");
if let common::transaction::LeeTransaction::PrivacyPreserving(private_tx) = tx {
self.decode_insert_privacy_preserving_transaction_results(
&private_tx,
acc_decode_data,
)?;
}
self.store_persistent_data()?;
Ok(cli::SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash })
Ok(cli::SubcommandReturnValue::TransactionExecuted { tx_hash })
}
pub async fn send_privacy_preserving_tx(

View File

@ -2,6 +2,7 @@ use std::time::Duration;
use anyhow::Result;
use common::{HashType, block::Block, transaction::LeeTransaction};
use lee_core::BlockId;
use log::{info, warn};
use sequencer_service_rpc::{RpcClient as _, SequencerClient};
@ -30,7 +31,7 @@ impl TxPoller {
}
// TODO: this polling is not based on blocks, but on timeouts, need to fix this.
pub async fn poll_tx(&self, tx_hash: HashType) -> Result<LeeTransaction> {
pub async fn poll_tx(&self, tx_hash: HashType) -> Result<(LeeTransaction, BlockId)> {
let max_blocks_to_query = self.polling_max_blocks_to_query;
info!("Starting poll for transaction {tx_hash}");
@ -41,7 +42,7 @@ impl TxPoller {
loop {
match self.client.get_transaction(tx_hash).await {
Ok(Some(tx)) => return Ok(tx),
Ok(Some((tx, block_id))) => return Ok((tx, block_id)),
Ok(None) => {}
Err(err) => {
warn!("Failed to get transaction by hash {tx_hash} with error: {err:#?}");

View File

@ -16,7 +16,10 @@ use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuil
use serde::Serialize;
use tempfile::TempDir;
use testcontainers::compose::DockerCompose;
use wallet::{WalletCore, account::AccountIdWithPrivacy, cli::CliAccountMention};
use wallet::{
WalletCore, account::AccountIdWithPrivacy, cli::CliAccountMention,
config::WalletConfigOverrides,
};
use crate::{
indexer_client::IndexerClient,
@ -92,7 +95,7 @@ impl TestContext {
/// Get a builder for the test context to customize its configuration.
#[must_use]
pub const fn builder() -> TestContextBuilder {
pub fn builder() -> TestContextBuilder {
TestContextBuilder::new()
}
@ -250,17 +253,29 @@ pub struct TestContextBuilder {
genesis_transactions: Option<Vec<GenesisAction>>,
sequencer_partial_config: Option<config::SequencerPartialConfig>,
enable_indexer: bool,
wallet_config_overrides: WalletConfigOverrides,
}
impl TestContextBuilder {
const fn new() -> Self {
fn new() -> Self {
Self {
genesis_transactions: None,
sequencer_partial_config: None,
enable_indexer: true,
wallet_config_overrides: WalletConfigOverrides::default(),
}
}
/// Override wallet config fields (e.g. polling timeouts) for the wallet built by this context.
#[must_use]
pub fn with_wallet_config_overrides(
mut self,
wallet_config_overrides: WalletConfigOverrides,
) -> Self {
self.wallet_config_overrides = wallet_config_overrides;
self
}
#[must_use]
pub fn with_genesis(mut self, genesis_transactions: Vec<GenesisAction>) -> Self {
self.genesis_transactions = Some(genesis_transactions);
@ -292,6 +307,7 @@ impl TestContextBuilder {
genesis_transactions,
sequencer_partial_config,
enable_indexer,
wallet_config_overrides,
} = self;
// Ensure logger is initialized only once
@ -347,6 +363,7 @@ impl TestContextBuilder {
sequencer_handle.addr(),
&initial_public_accounts,
&initial_private_accounts,
wallet_config_overrides,
)
.context("Failed to setup wallet")?;
@ -459,7 +476,7 @@ pub async fn fetch_privacy_preserving_tx(
seq_client: &SequencerClient,
tx_hash: HashType,
) -> PrivacyPreservingTransaction {
let tx = seq_client.get_transaction(tx_hash).await.unwrap().unwrap();
let (tx, _block_id) = seq_client.get_transaction(tx_hash).await.unwrap().unwrap();
match tx {
LeeTransaction::PrivacyPreserving(privacy_preserving_transaction) => {

View File

@ -137,6 +137,7 @@ pub fn setup_wallet(
sequencer_addr: SocketAddr,
initial_public_accounts: &[(PrivateKey, u128)],
initial_private_accounts: &[InitialPrivateAccountForWallet],
config_overrides: WalletConfigOverrides,
) -> Result<(WalletCore, TempDir, String)> {
let config = config::wallet_config(sequencer_addr).context("Failed to create Wallet config")?;
let config_serialized =
@ -150,7 +151,6 @@ pub fn setup_wallet(
.context("Failed to write wallet config in temp dir")?;
let storage_path = temp_wallet_dir.path().join("storage.json");
let config_overrides = WalletConfigOverrides::default();
let wallet_password = "test_pass".to_owned();
let (mut wallet, _mnemonic) = WalletCore::new_init_storage(
@ -192,10 +192,13 @@ pub async fn setup_public_accounts_with_initial_supply(
initial_public_accounts: &[(PrivateKey, u128)],
) -> Result<()> {
for (private_key, amount) in initial_public_accounts {
claim_funds_from_vault(
let account_id = AccountId::from(&PublicKey::new_from_private_key(private_key));
wallet::cli::execute_subcommand(
wallet,
AccountId::from(&PublicKey::new_from_private_key(private_key)),
*amount,
Command::Vault(VaultSubcommand::Claim {
account_id: public_mention(account_id),
amount: *amount,
}),
)
.await
.context("Failed to claim funds from vault into public account")?;
@ -221,28 +224,6 @@ pub async fn setup_private_accounts_with_initial_supply(
Ok(())
}
async fn claim_funds_from_vault(
wallet: &mut WalletCore,
owner_id: AccountId,
amount: u128,
) -> Result<()> {
let result = wallet::cli::execute_subcommand(
wallet,
Command::Vault(VaultSubcommand::Claim {
account_id: public_mention(owner_id),
amount,
}),
)
.await
.context("Failed to execute public vault claim command")?;
let SubcommandReturnValue::Empty = result else {
bail!("Expected Empty return value for public vault claim");
};
Ok(())
}
async fn claim_funds_from_vault_to_private(
wallet: &mut WalletCore,
owner_id: AccountId,
@ -262,8 +243,8 @@ async fn claim_funds_from_vault_to_private(
.await
.context("Failed to execute private vault claim command")?;
let SubcommandReturnValue::PrivacyPreservingTransfer { .. } = result else {
bail!("Expected PrivacyPreservingTransfer return value for private vault claim");
let SubcommandReturnValue::TransactionExecuted { .. } = result else {
bail!("Expected TransactionExecuted return value for private vault claim");
};
Ok(())

View File

@ -108,7 +108,7 @@ async fn begin_step(ctx: &TestContext) -> Result<u64> {
/// Finish a timed wallet step. Records submit (the time between `started`
/// being captured and `ret` being received) and, if `ret` is a
/// [`SubcommandReturnValue::PrivacyPreservingTransfer`], polls the sequencer
/// [`SubcommandReturnValue::TransactionExecuted`], polls the sequencer
/// for inclusion and records the inclusion latency. Returns a [`StepResult`].
async fn finalize_step(
label: impl Into<String>,
@ -132,7 +132,7 @@ async fn finalize_step(
// recorded" signal.
let should_wait_for_chain = !matches!(ret, SubcommandReturnValue::RegisterAccount { .. });
if should_wait_for_chain {
if let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = ret {
if let SubcommandReturnValue::TransactionExecuted { tx_hash } = ret {
tx_hash_str = Some(format!("{tx_hash}"));
}
let started_inclusion = Instant::now();