1use std::{str::FromStr, time::Duration};
4
5use alloy_primitives::B256;
6use om_primitives_types::transaction::{envelope::RawTransactionEnvelope, payload::PaymentPayload};
7use om_rest_types::{
8 FinalizedTransaction, Transaction,
9 requests::{FeeEstimateRequest, PaymentTransactionRequest},
10 responses::{FeeEstimate, TransactionReceipt, TransactionResponse},
11};
12use tokio::time::{Instant, sleep};
13
14use crate::{
15 client::{
16 Client,
17 config::{
18 API_VERSION, api_path,
19 endpoints::transactions::{BY_HASH, ESTIMATE_FEE, FINALIZED_BY_HASH, PAYMENT, RAW, RECEIPT_BY_HASH},
20 },
21 },
22 crypto::sign_transaction_payload,
23 error::{Error, Result},
24 utils::{signature_hash_for_counter_sign, verify_bls_aggregate_signature},
25};
26
27const DEFAULT_RECEIPT_TIMEOUT: Duration = Duration::from_secs(30);
28const DEFAULT_RECEIPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
29
30impl Client {
31 pub async fn send_payment(&self, data: PaymentPayload, private_key: &str) -> Result<TransactionResponse> {
70 let signature = sign_transaction_payload(&data, private_key)?;
71 let request = PaymentTransactionRequest { data, signature };
72
73 let path = api_path(PAYMENT);
74 self.post(&path, &request).await
75 }
76
77 pub async fn submit_raw_transaction(&self, envelope: RawTransactionEnvelope) -> Result<TransactionResponse> {
82 let path = api_path(RAW);
83 self.post(&path, &envelope).await
84 }
85
86 pub async fn get_transaction_by_hash(&self, hash: &str) -> Result<Transaction> {
96 let path = format!("{}{}?hash={}", API_VERSION, BY_HASH, hash);
97 self.get(&path).await
98 }
99
100 pub async fn get_transaction_receipt_by_hash(&self, hash: &str) -> Result<TransactionReceipt> {
110 let path = format!("{}{}?hash={}", API_VERSION, RECEIPT_BY_HASH, hash);
111 self.get(&path).await
112 }
113
114 pub async fn wait_for_transaction_receipt(&self, hash: &str) -> Result<TransactionReceipt> {
118 self.wait_for_transaction_receipt_with_timeout(hash, DEFAULT_RECEIPT_TIMEOUT)
119 .await
120 }
121
122 pub async fn wait_for_transaction_receipt_with_timeout(
128 &self,
129 hash: &str,
130 timeout: Duration,
131 ) -> Result<TransactionReceipt> {
132 let hash_owned = hash.to_string();
133 let request_path = format!("{}{}?hash={}", API_VERSION, RECEIPT_BY_HASH, hash);
134
135 poll_for_transaction_receipt(
136 || async { self.get_transaction_receipt_by_hash(&hash_owned).await },
137 request_path,
138 timeout,
139 DEFAULT_RECEIPT_POLL_INTERVAL,
140 )
141 .await
142 }
143
144 pub async fn estimate_fee(&self, request: FeeEstimateRequest) -> Result<FeeEstimate> {
154 let path = api_path(ESTIMATE_FEE);
155 let full_path = format!(
156 "{}?from={}&to={}&token={}&value={}",
157 path, request.from, request.to, request.token, request.value,
158 );
159 self.get(&full_path).await
160 }
161
162 pub async fn get_finalized_transaction_by_hash(&self, hash: &str) -> Result<FinalizedTransaction> {
172 let path = format!("{}{}?hash={}", API_VERSION, FINALIZED_BY_HASH, hash);
173 self.get(&path).await
174 }
175
176 pub async fn get_and_verify_finalized_transaction_by_hash(&self, hash: &str) -> Result<FinalizedTransaction> {
210 let finalized_tx = self.get_finalized_transaction_by_hash(hash).await?;
212
213 let tx_hash =
215 B256::from_str(hash).map_err(|e| Error::validation("hash", format!("Invalid transaction hash: {}", e)))?;
216
217 let message_hash = signature_hash_for_counter_sign(&tx_hash, &finalized_tx.epoch);
219
220 verify_bls_aggregate_signature(&message_hash, &finalized_tx.counter_signature)?;
222
223 Ok(finalized_tx)
224 }
225}
226
227async fn poll_for_transaction_receipt<F, Fut>(
228 mut fetch_receipt: F,
229 request_path: String,
230 timeout: Duration,
231 poll_interval: Duration,
232) -> Result<TransactionReceipt>
233where
234 F: FnMut() -> Fut,
235 Fut: Future<Output = Result<TransactionReceipt>>,
236{
237 if timeout.is_zero() {
238 return Err(Error::invalid_parameter("timeout", "Timeout must be greater than zero"));
239 }
240 if poll_interval.is_zero() {
241 return Err(Error::invalid_parameter(
242 "poll_interval",
243 "Poll interval must be greater than zero",
244 ));
245 }
246
247 let start = Instant::now();
248
249 loop {
250 match fetch_receipt().await {
251 Ok(receipt) => return Ok(receipt),
252 Err(err) => {
253 if !matches!(err, Error::ResourceNotFound { .. }) {
254 return Err(err);
255 }
256 }
257 }
258
259 let elapsed = start.elapsed();
260 if elapsed >= timeout {
261 return Err(Error::request_timeout(
262 request_path.clone(),
263 duration_to_millis(timeout),
264 ));
265 }
266
267 if let Some(remaining) = timeout.checked_sub(elapsed) {
268 let sleep_duration = poll_interval.min(remaining);
269 sleep(sleep_duration).await;
270 } else {
271 return Err(Error::request_timeout(
272 request_path.clone(),
273 duration_to_millis(timeout),
274 ));
275 }
276 }
277}
278
279fn duration_to_millis(duration: Duration) -> u64 {
280 duration.as_millis().min(u128::from(u64::MAX)) as u64
281}
282
283#[cfg(test)]
284mod tests {
285 use std::{collections::VecDeque, str::FromStr, sync::Mutex, time::Duration};
286
287 use alloy_primitives::{Address, B256, U256};
288
289 use super::*;
290 use crate::NamedChain;
291
292 #[test]
293 fn test_payment_payload_alloy_rlp() {
294 use alloy_rlp::Encodable as AlloyEncodable;
295
296 let payload = PaymentPayload {
297 chain_id: NamedChain::TESTNET_CHAIN_ID,
298 nonce: 0,
299 recipient: Address::from_str("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0")
300 .expect("Test data should be valid"),
301 value: U256::from(1000000000000000000u64),
302 token: Address::from_str("0x1234567890abcdef1234567890abcdef12345678").expect("Test data should be valid"),
303 };
304
305 let mut encoded = Vec::new();
306 payload.encode(&mut encoded);
307 assert!(!encoded.is_empty());
308 }
309
310 #[test]
311 fn test_fee_estimate_request() {
312 let request = FeeEstimateRequest {
313 from: "0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0".to_string(),
314 to: "0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc1".to_string(),
315 token: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
316 value: "1000000000000000000".to_string(),
317 };
318
319 let json = serde_json::to_string(&request).expect("Should serialize");
321 assert!(json.contains("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0"));
322 assert!(json.contains("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc1"));
323 assert!(json.contains("0x1234567890abcdef1234567890abcdef12345678"));
324 assert!(json.contains("1000000000000000000"));
325 }
326
327 #[test]
328 fn test_finalized_transaction_api_path_construction() {
329 let hash = "0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777";
330 let expected_path = format!("{}{}?hash={}", API_VERSION, FINALIZED_BY_HASH, hash);
331
332 assert!(expected_path.contains("/v1"));
333 assert!(expected_path.contains("/transactions/finalized/by_hash"));
334 assert!(expected_path.contains("hash=0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777"));
335 }
336
337 #[test]
338 fn test_finalized_transaction_structure() {
339 use alloy_primitives::{Address, B256};
340 use om_rest_types::{FinalizedTransaction, RestBlsAggregateSignature, responses::TransactionReceipt};
341
342 let finalized_tx = FinalizedTransaction {
343 epoch: 100,
344 receipt: TransactionReceipt {
345 success: true,
346 transaction_hash: B256::from_str("0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777")
347 .expect("Test data should be valid"),
348 transaction_index: Some(5),
349 checkpoint_hash: Some(
350 B256::from_str("0x20e081da293ae3b81e30f864f38f6911663d7f2cf98337fca38db3cf5bbe7a8f")
351 .expect("Test data should be valid"),
352 ),
353 checkpoint_number: Some(1500),
354 fee_used: 1000000,
355 from: Address::from_str("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0")
356 .expect("Test data should be valid"),
357 recipient: Some(
358 Address::from_str("0x1234567890abcdef1234567890abcdef12345678").expect("Test data should be valid"),
359 ),
360 token_address: None,
361 success_info: None,
362 },
363 counter_signature: RestBlsAggregateSignature::default(),
364 };
365
366 assert_eq!(finalized_tx.epoch, 100);
367 assert!(finalized_tx.receipt.success);
368 assert_eq!(finalized_tx.receipt.fee_used, 1000000);
369 }
370
371 #[test]
372 fn test_finalized_transaction_json_output() {
373 use alloy_primitives::{Address, B256};
374 use om_rest_types::{FinalizedTransaction, RestBlsAggregateSignature, responses::TransactionReceipt};
375
376 let finalized_tx = FinalizedTransaction {
377 epoch: 200,
378 receipt: TransactionReceipt {
379 success: true,
380 transaction_hash: B256::from_str("0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777")
381 .expect("Test data should be valid"),
382 transaction_index: Some(0),
383 checkpoint_hash: Some(
384 B256::from_str("0x20e081da293ae3b81e30f864f38f6911663d7f2cf98337fca38db3cf5bbe7a8f")
385 .expect("Test data should be valid"),
386 ),
387 checkpoint_number: Some(1500),
388 fee_used: 1000000,
389 from: Address::from_str("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0")
390 .expect("Test data should be valid"),
391 recipient: Some(
392 Address::from_str("0x1234567890abcdef1234567890abcdef12345678").expect("Test data should be valid"),
393 ),
394 token_address: Some(
395 Address::from_str("0xabcdef1234567890abcdef1234567890abcdef12").expect("Test data should be valid"),
396 ),
397 success_info: None,
398 },
399 counter_signature: RestBlsAggregateSignature::new(
400 "0xff".to_string(),
401 "0x1234".to_string(),
402 vec!["0xpubkey1".to_string()],
403 ),
404 };
405
406 let json = serde_json::to_string(&finalized_tx).expect("Should serialize to JSON");
407
408 assert!(json.contains("\"epoch\":200"));
409 assert!(
410 json.contains(
411 "\"transaction_hash\":\"0x902006665c369834a0cf52eea2780f934a90b3c86a3918fb57371ac1fbbd7777\""
412 )
413 );
414 assert!(json.contains("\"success\":true"));
415 assert!(json.contains("\"fee_used\":\"1000000\""));
416 assert!(json.contains("\"counter_signature\""));
417 }
418
419 fn sample_receipt(hash: &str) -> TransactionReceipt {
420 TransactionReceipt {
421 success: true,
422 transaction_hash: B256::from_str(hash).expect("valid hash"),
423 transaction_index: Some(0),
424 checkpoint_hash: None,
425 checkpoint_number: Some(42),
426 fee_used: 1,
427 from: Address::from_str("0x0000000000000000000000000000000000000001").expect("valid address"),
428 recipient: Some(Address::from_str("0x0000000000000000000000000000000000000002").expect("valid address")),
429 token_address: Some(
430 Address::from_str("0x0000000000000000000000000000000000000003").expect("valid address"),
431 ),
432 success_info: None,
433 }
434 }
435
436 #[tokio::test]
437 async fn test_wait_for_transaction_receipt_eventually_succeeds() {
438 let tx_hash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
439 let request_path = format!("/v1/transactions/receipt/by_hash?hash={tx_hash}");
440
441 let responses = Mutex::new(VecDeque::from([
442 Err(Error::resource_not_found("receipt", "pending")),
443 Ok(sample_receipt(tx_hash)),
444 ]));
445
446 let receipt = poll_for_transaction_receipt(
447 || {
448 let result = responses
449 .lock()
450 .expect("lock poisoned")
451 .pop_front()
452 .expect("response available");
453 async move { result }
454 },
455 request_path,
456 Duration::from_millis(100),
457 Duration::from_millis(10),
458 )
459 .await
460 .expect("should eventually succeed");
461
462 assert!(receipt.success);
463 assert_eq!(receipt.checkpoint_number, Some(42));
464 assert_eq!(
465 receipt.recipient,
466 Some(Address::from_str("0x0000000000000000000000000000000000000002").unwrap())
467 );
468 }
469
470 #[tokio::test]
471 async fn test_wait_for_transaction_receipt_respects_errors() {
472 let request_path = "/v1/transactions/receipt/by_hash?hash=0xbb".to_string();
473 let responses = Mutex::new(VecDeque::from([Err(Error::http_transport("boom", Some(500)))]));
474
475 let err = poll_for_transaction_receipt(
476 || {
477 let result = responses
478 .lock()
479 .expect("lock poisoned")
480 .pop_front()
481 .expect("response available");
482 async move { result }
483 },
484 request_path,
485 Duration::from_millis(50),
486 Duration::from_millis(10),
487 )
488 .await
489 .expect_err("should propagate error");
490
491 assert!(matches!(err, Error::HttpTransport { .. }));
492 }
493
494 #[tokio::test]
495 async fn test_wait_for_transaction_receipt_with_zero_timeout_is_rejected() {
496 let err = poll_for_transaction_receipt(
497 || async {
498 Ok(sample_receipt(
499 "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
500 ))
501 },
502 "/v1/transactions/receipt/by_hash?hash=0xcc".to_string(),
503 Duration::from_secs(0),
504 Duration::from_millis(10),
505 )
506 .await
507 .expect_err("zero timeout invalid");
508
509 assert!(matches!(err, Error::InvalidParameter { .. }));
510 }
511}