use std::{str::FromStr, time::Duration};
use alloy_primitives::B256;
use om_primitives_types::transaction::{envelope::RawTransactionEnvelope, payload::PaymentPayload};
use om_rest_types::{
FinalizedTransaction, Transaction,
requests::{FeeEstimateRequest, PaymentTransactionRequest},
responses::{FeeEstimate, TransactionReceipt, TransactionResponse},
};
use tokio::time::{Instant, sleep};
use crate::{
client::{
Client,
config::{
API_VERSION, api_path,
endpoints::transactions::{BY_HASH, ESTIMATE_FEE, FINALIZED_BY_HASH, PAYMENT, RAW, RECEIPT_BY_HASH},
},
},
crypto::sign_transaction_payload,
error::{Error, Result},
utils::{signature_hash_for_counter_sign, verify_bls_aggregate_signature},
};
const DEFAULT_RECEIPT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_RECEIPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
impl Client {
pub async fn send_payment(&self, data: PaymentPayload, private_key: &str) -> Result<TransactionResponse> {
let signature = sign_transaction_payload(&data, private_key)?;
let request = PaymentTransactionRequest { data, signature };
let path = api_path(PAYMENT);
self.post(&path, &request).await
}
pub async fn submit_raw_transaction(&self, envelope: RawTransactionEnvelope) -> Result<TransactionResponse> {
let path = api_path(RAW);
self.post(&path, &envelope).await
}
pub async fn get_transaction_by_hash(&self, hash: &str) -> Result<Transaction> {
let path = format!("{}{}?hash={}", API_VERSION, BY_HASH, hash);
self.get(&path).await
}
pub async fn get_transaction_receipt_by_hash(&self, hash: &str) -> Result<TransactionReceipt> {
let path = format!("{}{}?hash={}", API_VERSION, RECEIPT_BY_HASH, hash);
self.get(&path).await
}
pub async fn wait_for_transaction_receipt(&self, hash: &str) -> Result<TransactionReceipt> {
self.wait_for_transaction_receipt_with_timeout(hash, DEFAULT_RECEIPT_TIMEOUT)
.await
}
pub async fn wait_for_transaction_receipt_with_timeout(
&self,
hash: &str,
timeout: Duration,
) -> Result<TransactionReceipt> {
let hash_owned = hash.to_string();
let request_path = format!("{}{}?hash={}", API_VERSION, RECEIPT_BY_HASH, hash);
poll_for_transaction_receipt(
|| async { self.get_transaction_receipt_by_hash(&hash_owned).await },
request_path,
timeout,
DEFAULT_RECEIPT_POLL_INTERVAL,
)
.await
}
pub async fn estimate_fee(&self, request: FeeEstimateRequest) -> Result<FeeEstimate> {
let path = api_path(ESTIMATE_FEE);
let full_path = format!(
"{}?from={}&to={}&token={}&value={}",
path, request.from, request.to, request.token, request.value,
);
self.get(&full_path).await
}
pub async fn get_finalized_transaction_by_hash(&self, hash: &str) -> Result<FinalizedTransaction> {
let path = format!("{}{}?hash={}", API_VERSION, FINALIZED_BY_HASH, hash);
self.get(&path).await
}
pub async fn get_and_verify_finalized_transaction_by_hash(&self, hash: &str) -> Result<FinalizedTransaction> {
let finalized_tx = self.get_finalized_transaction_by_hash(hash).await?;
let tx_hash =
B256::from_str(hash).map_err(|e| Error::validation("hash", format!("Invalid transaction hash: {}", e)))?;
let message_hash = signature_hash_for_counter_sign(&tx_hash, &finalized_tx.epoch);
verify_bls_aggregate_signature(&message_hash, &finalized_tx.counter_signature)?;
Ok(finalized_tx)
}
}
async fn poll_for_transaction_receipt<F, Fut>(
mut fetch_receipt: F,
request_path: String,
timeout: Duration,
poll_interval: Duration,
) -> Result<TransactionReceipt>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<TransactionReceipt>>,
{
if timeout.is_zero() {
return Err(Error::invalid_parameter("timeout", "Timeout must be greater than zero"));
}
if poll_interval.is_zero() {
return Err(Error::invalid_parameter(
"poll_interval",
"Poll interval must be greater than zero",
));
}
let start = Instant::now();
loop {
match fetch_receipt().await {
Ok(receipt) => return Ok(receipt),
Err(err) => {
if !matches!(err, Error::ResourceNotFound { .. }) {
return Err(err);
}
}
}
let elapsed = start.elapsed();
if elapsed >= timeout {
return Err(Error::request_timeout(
request_path.clone(),
duration_to_millis(timeout),
));
}
if let Some(remaining) = timeout.checked_sub(elapsed) {
let sleep_duration = poll_interval.min(remaining);
sleep(sleep_duration).await;
} else {
return Err(Error::request_timeout(
request_path.clone(),
duration_to_millis(timeout),
));
}
}
}
fn duration_to_millis(duration: Duration) -> u64 {
duration.as_millis().min(u128::from(u64::MAX)) as u64
}
#[cfg(test)]
mod tests {
use std::{collections::VecDeque, str::FromStr, sync::Mutex, time::Duration};
use alloy_primitives::{Address, B256, U256};
use super::*;
use crate::NamedChain;
#[test]
fn test_payment_payload_alloy_rlp() {
use alloy_rlp::Encodable as AlloyEncodable;
let payload = PaymentPayload {
chain_id: NamedChain::TESTNET_CHAIN_ID,
nonce: 0,
recipient: Address::from_str("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0")
.expect("Test data should be valid"),
value: U256::from(1000000000000000000u64),
token: Address::from_str("0x1234567890abcdef1234567890abcdef12345678").expect("Test data should be valid"),
};
let mut encoded = Vec::new();
payload.encode(&mut encoded);
assert!(!encoded.is_empty());
}
#[test]
fn test_fee_estimate_request() {
let request = FeeEstimateRequest {
from: "0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0".to_string(),
to: "0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc1".to_string(),
token: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
value: "1000000000000000000".to_string(),
};
let json = serde_json::to_string(&request).expect("Should serialize");
assert!(json.contains("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0"));
assert!(json.contains("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc1"));
assert!(json.contains("0x1234567890abcdef1234567890abcdef12345678"));
assert!(json.contains("1000000000000000000"));
}
#[test]
fn test_finalized_transaction_api_path_construction() {
let hash = "0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777";
let expected_path = format!("{}{}?hash={}", API_VERSION, FINALIZED_BY_HASH, hash);
assert!(expected_path.contains("/v1"));
assert!(expected_path.contains("/transactions/finalized/by_hash"));
assert!(expected_path.contains("hash=0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777"));
}
#[test]
fn test_finalized_transaction_structure() {
use alloy_primitives::{Address, B256};
use om_rest_types::{FinalizedTransaction, RestBlsAggregateSignature, responses::TransactionReceipt};
let finalized_tx = FinalizedTransaction {
epoch: 100,
receipt: TransactionReceipt {
success: true,
transaction_hash: B256::from_str("0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777")
.expect("Test data should be valid"),
transaction_index: Some(5),
checkpoint_hash: Some(
B256::from_str("0x20e081da293ae3b81e30f864f38f6911663d7f2cf98337fca38db3cf5bbe7a8f")
.expect("Test data should be valid"),
),
checkpoint_number: Some(1500),
fee_used: 1000000,
from: Address::from_str("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0")
.expect("Test data should be valid"),
recipient: Some(
Address::from_str("0x1234567890abcdef1234567890abcdef12345678").expect("Test data should be valid"),
),
token_address: None,
success_info: None,
},
counter_signature: RestBlsAggregateSignature::default(),
};
assert_eq!(finalized_tx.epoch, 100);
assert!(finalized_tx.receipt.success);
assert_eq!(finalized_tx.receipt.fee_used, 1000000);
}
#[test]
fn test_finalized_transaction_json_output() {
use alloy_primitives::{Address, B256};
use om_rest_types::{FinalizedTransaction, RestBlsAggregateSignature, responses::TransactionReceipt};
let finalized_tx = FinalizedTransaction {
epoch: 200,
receipt: TransactionReceipt {
success: true,
transaction_hash: B256::from_str("0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777")
.expect("Test data should be valid"),
transaction_index: Some(0),
checkpoint_hash: Some(
B256::from_str("0x20e081da293ae3b81e30f864f38f6911663d7f2cf98337fca38db3cf5bbe7a8f")
.expect("Test data should be valid"),
),
checkpoint_number: Some(1500),
fee_used: 1000000,
from: Address::from_str("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0")
.expect("Test data should be valid"),
recipient: Some(
Address::from_str("0x1234567890abcdef1234567890abcdef12345678").expect("Test data should be valid"),
),
token_address: Some(
Address::from_str("0xabcdef1234567890abcdef1234567890abcdef12").expect("Test data should be valid"),
),
success_info: None,
},
counter_signature: RestBlsAggregateSignature::new(
"0xff".to_string(),
"0x1234".to_string(),
vec!["0xpubkey1".to_string()],
),
};
let json = serde_json::to_string(&finalized_tx).expect("Should serialize to JSON");
assert!(json.contains("\"epoch\":200"));
assert!(
json.contains(
"\"transaction_hash\":\"0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777\""
)
);
assert!(json.contains("\"success\":true"));
assert!(json.contains("\"fee_used\":\"1000000\""));
assert!(json.contains("\"counter_signature\""));
}
fn sample_receipt(hash: &str) -> TransactionReceipt {
TransactionReceipt {
success: true,
transaction_hash: B256::from_str(hash).expect("valid hash"),
transaction_index: Some(0),
checkpoint_hash: None,
checkpoint_number: Some(42),
fee_used: 1,
from: Address::from_str("0x0000000000000000000000000000000000000001").expect("valid address"),
recipient: Some(Address::from_str("0x0000000000000000000000000000000000000002").expect("valid address")),
token_address: Some(
Address::from_str("0x0000000000000000000000000000000000000003").expect("valid address"),
),
success_info: None,
}
}
#[tokio::test]
async fn test_wait_for_transaction_receipt_eventually_succeeds() {
let tx_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let request_path = format!("/v1/transactions/receipt/by_hash?hash={tx_hash}");
let responses = Mutex::new(VecDeque::from([
Err(Error::resource_not_found("receipt", "pending")),
Ok(sample_receipt(tx_hash)),
]));
let receipt = poll_for_transaction_receipt(
|| {
let result = responses
.lock()
.expect("lock poisoned")
.pop_front()
.expect("response available");
async move { result }
},
request_path,
Duration::from_millis(100),
Duration::from_millis(10),
)
.await
.expect("should eventually succeed");
assert!(receipt.success);
assert_eq!(receipt.checkpoint_number, Some(42));
assert_eq!(
receipt.recipient,
Some(Address::from_str("0x0000000000000000000000000000000000000002").unwrap())
);
}
#[tokio::test]
async fn test_wait_for_transaction_receipt_respects_errors() {
let request_path = "/v1/transactions/receipt/by_hash?hash=0xbb".to_string();
let responses = Mutex::new(VecDeque::from([Err(Error::http_transport("boom", Some(500)))]));
let err = poll_for_transaction_receipt(
|| {
let result = responses
.lock()
.expect("lock poisoned")
.pop_front()
.expect("response available");
async move { result }
},
request_path,
Duration::from_millis(50),
Duration::from_millis(10),
)
.await
.expect_err("should propagate error");
assert!(matches!(err, Error::HttpTransport { .. }));
}
#[tokio::test]
async fn test_wait_for_transaction_receipt_with_zero_timeout_is_rejected() {
let err = poll_for_transaction_receipt(
|| async {
Ok(sample_receipt(
"0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
))
},
"/v1/transactions/receipt/by_hash?hash=0xcc".to_string(),
Duration::from_secs(0),
Duration::from_millis(10),
)
.await
.expect_err("zero timeout invalid");
assert!(matches!(err, Error::InvalidParameter { .. }));
}
}