use super::ChainSendTransactionRequest::{self, *}; use super::IndexerState; use super::tx_signer::{TransactionSigner, TransactionSigners}; use crate::config::RespondConfig; use crate::metrics; use crate::types::{ LogTransaction, SignerContext, SubmittedTransaction, SubmittedTransactionStatus, SubmittedTxMetadata, }; use anyhow::Context; use ed25519_dalek::SigningKey; use mpc_attestation::attestation::DEFAULT_EXPIRATION_DURATION_SECONDS; use near_account_id::AccountId; use near_indexer_primitives::types::Gas; use near_mpc_contract_interface::types::{Attestation, Ed25519PublicKey, VerifiedAttestation}; use near_time::Clock; use std::future::Future; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::sync::{mpsc, oneshot}; use tokio::time; const TRANSACTION_PROCESSOR_CHANNEL_SIZE: usize = 10000; const TRANSACTION_TIMEOUT: Duration = Duration::from_secs(10); const MAX_ATTESTATION_AGE: Duration = Duration::from_secs(60 * 2); pub trait TransactionSender: Clone + Send + Sync { fn send( &self, transaction: ChainSendTransactionRequest, ) -> impl Future> + Send; fn send_and_wait( &self, transaction: ChainSendTransactionRequest, ) -> impl Future> + Send; } #[derive(Clone, Debug, thiserror::Error)] pub enum TransactionProcessorError { #[error("The transaction processor is closed.")] ProcessorIsClosed, } #[derive(Clone, Debug)] pub struct TransactionProcessorHandle { transaction_sender: mpsc::Sender, } impl TransactionProcessorHandle { pub(crate) fn start_transaction_processor( owner_account_id: AccountId, owner_secret_key: SigningKey, config: RespondConfig, indexer_state: Arc, tx_logger: impl LogTransaction, ) -> anyhow::Result { let mut signers = TransactionSigners::new(config, owner_account_id, owner_secret_key) .context("Failed to initialize transaction signers")?; let (transaction_sender, mut transaction_receiver) = mpsc::channel::(TRANSACTION_PROCESSOR_CHANNEL_SIZE); tokio::spawn(async move { while let Some(transaction_submission) = transaction_receiver.recv().await { let tx_request = transaction_submission.transaction; let tx_response_channel = transaction_submission.response_sender; let tx_signer = signers.signer_for(&tx_request); let indexer_state = indexer_state.clone(); let tx_logger = tx_logger.clone(); tokio::spawn(async move { let Ok(txn_json) = serde_json::to_string(&tx_request) else { tracing::error!(target: "mpc", "Failed to serialize response args"); return; }; tracing::debug!(target = "mpc", "tx args {:?}", txn_json); let (transaction_status, recent_transaction) = ensure_send_transaction( tx_signer.clone(), indexer_state, tx_request, txn_json, ) .await; tx_logger.log_transaction(recent_transaction); if let Some(tx_response_channel) = tx_response_channel { let _ = tx_response_channel.send(transaction_status); } }); } }); Ok(TransactionProcessorHandle { transaction_sender }) } } impl TransactionSender for TransactionProcessorHandle { async fn send( &self, transaction: ChainSendTransactionRequest, ) -> Result<(), TransactionProcessorError> { self.transaction_sender .send(TransactionSenderSubmission { transaction, response_sender: None, }) .await .map_err(|_| TransactionProcessorError::ProcessorIsClosed) } async fn send_and_wait( &self, transaction: ChainSendTransactionRequest, ) -> Result { let (response_sender, response_receiver) = oneshot::channel(); self.transaction_sender .send(TransactionSenderSubmission { transaction, response_sender: Some(response_sender), }) .await .map_err(|_| TransactionProcessorError::ProcessorIsClosed)?; response_receiver .await .map_err(|_| TransactionProcessorError::ProcessorIsClosed) } } struct TransactionSenderSubmission { transaction: ChainSendTransactionRequest, response_sender: Option>, } #[derive(Debug)] pub enum TransactionStatus { Executed, NotExecuted, Unknown, } /// Creates, signs, and submits a function call with the given method and serialized arguments. /// On success, returns the metadata of the submitted transaction for debugging. async fn submit_tx( tx_signer: Arc, indexer_state: Arc, method: String, params_ser: String, gas: Gas, ) -> anyhow::Result { let block = indexer_state.view_client.latest_final_block().await?; let transaction = tx_signer.create_and_sign_function_call_tx( indexer_state.mpc_contract_id.clone(), method, params_ser.into(), gas, block.header.hash, block.header.height, ); let tx_hash = transaction.get_hash(); let nonce = transaction.transaction.nonce().nonce(); let signature = transaction.signature.clone(); tracing::info!( target = "mpc", "sending tx {:?} with ak={:?} nonce={:?}", tx_hash, tx_signer.public_key(), nonce, ); indexer_state.rpc_handler.submit_tx(transaction).await?; Ok(SubmittedTxMetadata { tx_hash, nonce, signature, block_height: block.header.height, }) } /// Confirms whether the intended effect of the transaction request has been observed on chain. async fn observe_tx_result( indexer_state: Arc, request: &ChainSendTransactionRequest, ) -> anyhow::Result { match request { Respond(respond_args) => { // Confirm whether the respond call succeeded by checking whether the // pending signature request still exists in the contract state. // A successful respond removes the request from contract state. let pending_request_response = indexer_state .view_client .get_pending_request(&indexer_state.mpc_contract_id, &respond_args.request) .await?; let transaction_status = match pending_request_response { Some(_) => TransactionStatus::NotExecuted, None => TransactionStatus::Executed, }; Ok(transaction_status) } CKDRespond(respond_args) => { // Confirm whether the respond call succeeded by checking whether the // pending ckd request still exists in the contract state. // A successful respond removes the request from contract state. let pending_request_response = indexer_state .view_client .get_pending_ckd_request(&indexer_state.mpc_contract_id, &respond_args.request) .await?; let transaction_status = match pending_request_response { Some(_) => TransactionStatus::NotExecuted, None => TransactionStatus::Executed, }; Ok(transaction_status) } VerifyForeignTransactionRespond(respond_args) => { // Confirm whether the respond call succeeded by checking whether the // pending verify foreign tx request still exists in the contract state. // A successful respond removes the request from contract state. let pending_request_response = indexer_state .view_client .get_pending_verify_foreign_tx_request( &indexer_state.mpc_contract_id, &respond_args.request, ) .await?; let transaction_status = match pending_request_response { Some(_) => TransactionStatus::NotExecuted, None => TransactionStatus::Executed, }; Ok(transaction_status) } SubmitParticipantInfo(submit_participant_info_args) => { let tls_public_key = &submit_participant_info_args.tls_public_key; let attestation_stored_on_contract = indexer_state .view_client .get_participant_attestation(&indexer_state.mpc_contract_id, tls_public_key) .await?; let Some(stored_attestation) = attestation_stored_on_contract else { return Ok(TransactionStatus::NotExecuted); }; let submitted_attestation = &submit_participant_info_args.proposed_participant_attestation; let submitted_attestation_is_on_chain = match (stored_attestation, submitted_attestation) { ( VerifiedAttestation::Dstack(verified_dstack_attestation), Attestation::Dstack(_), ) => { // Check if the attestation stored on chain is fresh by verifying its age // is less than `MAX_ATTESTATION_AGE` // // TODO(#1637): extract expiration timestamp from the certificate itself, // instead of using heuristics. let expiry_timestamp_seconds = verified_dstack_attestation.expiry_timestamp_seconds; let Some(attestation_duration_since_unix_epoch) = expiry_timestamp_seconds .checked_sub(DEFAULT_EXPIRATION_DURATION_SECONDS) .map(Duration::from_secs) else { tracing::error!( ?expiry_timestamp_seconds, "could not calculate attestation storage time" ); return Ok(TransactionStatus::NotExecuted); }; let timestamp_seconds_now = SystemTime::now() .duration_since(UNIX_EPOCH) .context("could not calculate system time")?; let attestation_age = attestation_duration_since_unix_epoch.abs_diff(timestamp_seconds_now); let attestation_is_fresh = attestation_age < MAX_ATTESTATION_AGE; tracing::info!( ?attestation_age, ?attestation_is_fresh, "node found dstack attestation on chain" ); attestation_is_fresh } ( VerifiedAttestation::Mock(stored_mock_attestation), Attestation::Mock(submitted_mock_attestation), ) => stored_mock_attestation == *submitted_mock_attestation, _ => false, }; if submitted_attestation_is_on_chain { Ok(TransactionStatus::Executed) } else { Ok(TransactionStatus::NotExecuted) } } // We don't care. The contract state change will handle this. StartKeygen(_) | StartReshare(_) | VotePk(_) | VoteReshared(_) | VoteAbortKeyEventInstance(_) | VerifyTee() | ConcludeNodeMigration(_) | RegisterForeignChainConfig(_) => Ok(TransactionStatus::Unknown), } } /// Attempts to ensure that a function call with the given method and args is /// included on-chain. Submits the transaction, waits `TRANSACTION_TIMEOUT` for /// it to be included, then observes once whether it had its intended on-chain /// effect. async fn ensure_send_transaction( tx_signer: Arc, indexer_state: Arc, request: ChainSendTransactionRequest, params_ser: String, ) -> (TransactionStatus, SubmittedTransaction) { let method = request.method(); let signer = SignerContext { account_id: tx_signer.account_id().clone(), public_key: Ed25519PublicKey::from(&tx_signer.public_key()), method, }; let submitted_metadata = submit_tx( tx_signer.clone(), indexer_state.clone(), method.to_string(), params_ser.clone(), request.gas_required(), ) .await; // Stamp the submission time now, before the observation wait below, so the // debug page reflects when the transaction was actually routed. let submitted_at = Clock::real().now_utc(); let metadata = match submitted_metadata { Ok(metadata) => metadata, Err(err) => { metrics::MPC_OUTGOING_TRANSACTION_OUTCOMES .with_label_values(&[method, "local_error"]) .inc(); tracing::error!(%err, "Failed to forward transaction {:?}", request); return ( TransactionStatus::NotExecuted, SubmittedTransaction::submit_failed(signer, submitted_at), ); } }; // Allow time for the transaction to be included time::sleep(TRANSACTION_TIMEOUT).await; // Then try to check whether it had the intended effect let transaction_status = observe_tx_result(indexer_state.clone(), &request).await; let (outcome_label, recorded_status) = match &transaction_status { Ok(TransactionStatus::Executed) => ("succeeded", SubmittedTransactionStatus::Executed), Ok(TransactionStatus::NotExecuted) => { ("timed_out", SubmittedTransactionStatus::NotExecuted) } Ok(TransactionStatus::Unknown) => ("unknown", SubmittedTransactionStatus::Unknown), Err(err) => { tracing::warn!(target:"mpc", %err, "encountered error trying to confirm result of transaction {:?}", request); ("unknown_err", SubmittedTransactionStatus::ObserveError) } }; metrics::MPC_OUTGOING_TRANSACTION_OUTCOMES .with_label_values(&[method, outcome_label]) .inc(); ( transaction_status.unwrap_or(TransactionStatus::Unknown), SubmittedTransaction::submitted(signer, metadata, recorded_status, submitted_at), ) }