Rust client: Revamp transaction confirmation (#850)

- Allow user configuration of confirmation settings
- Provide a timeout setting, default is 60s
This commit is contained in:
Christian Kamm 2024-01-17 10:30:25 +01:00 committed by GitHub
parent 18729cf04c
commit 511814ca97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 148 additions and 73 deletions

1
Cargo.lock generated
View File

@ -3464,6 +3464,7 @@ dependencies = [
"solana-client",
"solana-rpc",
"solana-sdk",
"solana-transaction-status",
"spl-associated-token-account 1.1.3",
"thiserror",
"tokio",

View File

@ -24,6 +24,7 @@ solana-program = "~1.16.7"
solana-program-test = "~1.16.7"
solana-rpc = "~1.16.7"
solana-sdk = { version = "~1.16.7", default-features = false }
solana-transaction-status = { version = "~1.16.7" }
[profile.release]
overflow-checks = true

View File

@ -30,6 +30,7 @@ solana-client = { workspace = true }
solana-rpc = { workspace = true }
solana-sdk = { workspace = true }
solana-address-lookup-table-program = { workspace = true }
solana-transaction-status = { workspace = true }
mango-feeds-connector = { workspace = true }
spl-associated-token-account = "1.0.3"
thiserror = "1.0.31"

View File

@ -34,6 +34,7 @@ use solana_sdk::signer::keypair;
use solana_sdk::transaction::TransactionError;
use crate::account_fetcher::*;
use crate::confirm_transaction::{wait_for_transaction_confirmation, RpcConfirmTransactionConfig};
use crate::context::MangoGroupContext;
use crate::gpa::{fetch_anchor_account, fetch_mango_accounts};
use crate::util::PreparedInstructions;
@ -66,6 +67,9 @@ pub struct Client {
pub commitment: CommitmentConfig,
/// Timeout, defaults to 60s
///
/// This timeout applies to rpc requests. Note that the timeout for transaction
/// confirmation is configured separately in rpc_confirm_transaction_config.
#[builder(default = "Some(Duration::from_secs(60))")]
pub timeout: Option<Duration>,
@ -76,6 +80,10 @@ pub struct Client {
#[builder(default = "ClientBuilder::default_rpc_send_transaction_config()")]
pub rpc_send_transaction_config: RpcSendTransactionConfig,
/// Defaults to waiting up to 60s for confirmation
#[builder(default = "ClientBuilder::default_rpc_confirm_transaction_config()")]
pub rpc_confirm_transaction_config: RpcConfirmTransactionConfig,
#[builder(default = "\"https://quote-api.jup.ag/v4\".into()")]
pub jupiter_v4_url: String,
@ -93,6 +101,13 @@ impl ClientBuilder {
..Default::default()
}
}
pub fn default_rpc_confirm_transaction_config() -> RpcConfirmTransactionConfig {
RpcConfirmTransactionConfig {
timeout: Some(Duration::from_secs(60)),
..Default::default()
}
}
}
impl Client {
@ -1869,10 +1884,19 @@ impl TransactionBuilder {
pub async fn send_and_confirm(&self, client: &Client) -> anyhow::Result<Signature> {
let rpc = client.rpc_async();
let tx = self.transaction(&rpc).await?;
// TODO: Wish we could use client.rpc_send_transaction_config here too!
rpc.send_and_confirm_transaction(&tx)
let recent_blockhash = tx.message.recent_blockhash();
let signature = rpc
.send_transaction_with_config(&tx, client.rpc_send_transaction_config)
.await
.map_err(prettify_solana_client_error)
.map_err(prettify_solana_client_error)?;
wait_for_transaction_confirmation(
&rpc,
&signature,
recent_blockhash,
&client.rpc_confirm_transaction_config,
)
.await?;
Ok(signature)
}
pub fn transaction_size(&self) -> anyhow::Result<TransactionSize> {

View File

@ -0,0 +1,117 @@
use solana_client::nonblocking::rpc_client::RpcClient as RpcClientAsync;
use solana_client::rpc_request::RpcError;
use solana_sdk::{commitment_config::CommitmentConfig, signature::Signature};
use solana_transaction_status::TransactionStatus;
use crate::util::delay_interval;
use std::time::Duration;
#[derive(thiserror::Error, Debug)]
pub enum WaitForTransactionConfirmationError {
#[error("blockhash has expired")]
BlockhashExpired,
#[error("timeout expired")]
Timeout,
#[error("client error: {0:?}")]
ClientError(#[from] solana_client::client_error::ClientError),
}
#[derive(Clone, Debug, Builder)]
#[builder(default)]
pub struct RpcConfirmTransactionConfig {
/// If none, defaults to the RpcClient's configured default commitment.
pub commitment: Option<CommitmentConfig>,
/// Time after which to start checking for blockhash expiry.
pub recent_blockhash_initial_timeout: Duration,
/// Interval between signature status queries.
pub signature_status_interval: Duration,
/// If none, there's no timeout. The confirmation will still abort eventually
/// when the blockhash expires.
pub timeout: Option<Duration>,
}
impl Default for RpcConfirmTransactionConfig {
fn default() -> Self {
Self {
commitment: None,
recent_blockhash_initial_timeout: Duration::from_secs(5),
signature_status_interval: Duration::from_millis(500),
timeout: None,
}
}
}
impl RpcConfirmTransactionConfig {
pub fn builder() -> RpcConfirmTransactionConfigBuilder {
RpcConfirmTransactionConfigBuilder::default()
}
}
/// Wait for `signature` to be confirmed at `commitment` or until either
/// - `recent_blockhash` is so old that the tx can't be confirmed _and_
/// `blockhash_initial_timeout` is reached
/// - the `signature_status_timeout` is reached
/// While waiting, query for confirmation every `signature_status_interval`
///
/// NOTE: RpcClient::config contains confirm_transaction_initial_timeout which is the
/// same as blockhash_initial_timeout. Unfortunately the former is private.
///
/// Returns:
/// - blockhash and blockhash_initial_timeout expired -> BlockhashExpired error
/// - signature_status_timeout expired -> Timeout error (possibly just didn't reach commitment in time?)
/// - any rpc error -> ClientError error
/// - confirmed at commitment -> ok(slot, opt<tx_error>)
pub async fn wait_for_transaction_confirmation(
rpc_client: &RpcClientAsync,
signature: &Signature,
recent_blockhash: &solana_sdk::hash::Hash,
config: &RpcConfirmTransactionConfig,
) -> Result<TransactionStatus, WaitForTransactionConfirmationError> {
let mut signature_status_interval = delay_interval(config.signature_status_interval);
let commitment = config.commitment.unwrap_or(rpc_client.commitment());
let start = std::time::Instant::now();
let is_timed_out = || config.timeout.map(|t| start.elapsed() > t).unwrap_or(false);
loop {
signature_status_interval.tick().await;
if is_timed_out() {
return Err(WaitForTransactionConfirmationError::Timeout);
}
let statuses = rpc_client
.get_signature_statuses(&[signature.clone()])
.await?;
let status_opt = match statuses.value.into_iter().next() {
Some(v) => v,
None => {
return Err(WaitForTransactionConfirmationError::ClientError(
RpcError::ParseError(
"must contain an entry for each requested signature".into(),
)
.into(),
));
}
};
// If the tx isn't seen at all (not even processed), check blockhash expiry
if status_opt.is_none() {
if start.elapsed() > config.recent_blockhash_initial_timeout {
let blockhash_is_valid = rpc_client
.is_blockhash_valid(recent_blockhash, CommitmentConfig::processed())
.await?;
if !blockhash_is_valid {
return Err(WaitForTransactionConfirmationError::BlockhashExpired);
}
}
continue;
}
let status = status_opt.unwrap();
if status.satisfies_commitment(commitment) {
return Ok(status);
}
}
}

View File

@ -8,6 +8,7 @@ pub mod account_update_stream;
pub mod chain_data;
mod chain_data_fetcher;
mod client;
pub mod confirm_transaction;
mod context;
pub mod error_tracking;
pub mod gpa;

View File

@ -1,17 +1,8 @@
use solana_client::{
client_error::Result as ClientResult, rpc_client::RpcClient, rpc_request::RpcError,
};
use solana_sdk::compute_budget::ComputeBudgetInstruction;
use solana_sdk::instruction::Instruction;
use solana_sdk::transaction::Transaction;
use solana_sdk::{
clock::Slot, commitment_config::CommitmentConfig, signature::Signature,
transaction::uses_durable_nonce,
};
use anchor_lang::prelude::{AccountMeta, Pubkey};
use anyhow::Context;
use std::{thread, time};
/// Some Result<> types don't convert to anyhow::Result nicely. Force them through stringification.
pub trait AnyhowWrap {
@ -57,67 +48,6 @@ pub fn delay_interval(period: std::time::Duration) -> tokio::time::Interval {
interval
}
/// A copy of RpcClient::send_and_confirm_transaction that returns the slot the
/// transaction confirmed in.
pub fn send_and_confirm_transaction(
rpc_client: &RpcClient,
transaction: &Transaction,
) -> ClientResult<(Signature, Slot)> {
const SEND_RETRIES: usize = 1;
const GET_STATUS_RETRIES: usize = usize::MAX;
'sending: for _ in 0..SEND_RETRIES {
let signature = rpc_client.send_transaction(transaction)?;
let recent_blockhash = if uses_durable_nonce(transaction).is_some() {
let (recent_blockhash, ..) =
rpc_client.get_latest_blockhash_with_commitment(CommitmentConfig::processed())?;
recent_blockhash
} else {
transaction.message.recent_blockhash
};
for status_retry in 0..GET_STATUS_RETRIES {
let response = rpc_client.get_signature_statuses(&[signature])?.value;
match response[0]
.clone()
.filter(|result| result.satisfies_commitment(rpc_client.commitment()))
{
Some(tx_status) => {
return if let Some(e) = tx_status.err {
Err(e.into())
} else {
Ok((signature, tx_status.slot))
};
}
None => {
if !rpc_client
.is_blockhash_valid(&recent_blockhash, CommitmentConfig::processed())?
{
// Block hash is not found by some reason
break 'sending;
} else if cfg!(not(test))
// Ignore sleep at last step.
&& status_retry < GET_STATUS_RETRIES
{
// Retry twice a second
thread::sleep(time::Duration::from_millis(500));
continue;
}
}
}
}
}
Err(RpcError::ForUser(
"unable to confirm transaction. \
This can happen in situations such as transaction expiration \
and insufficient fee-payer funds"
.to_string(),
)
.into())
}
/// Convenience function used in binaries to set up the fmt tracing_subscriber,
/// with cololring enabled only if logging to a terminal and with EnvFilter.
pub fn tracing_subscriber_init() {