Skip to main content

onemoney_protocol/api/
transactions.rs

1//! Transaction-related API operations.
2
3use 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    /// Send a payment transaction.
32    ///
33    /// # Arguments
34    ///
35    /// * `payload` - Payment transaction parameters
36    /// * `private_key` - Private key for signing the transaction
37    ///
38    /// # Returns
39    ///
40    /// The payment response containing the transaction hash.
41    ///
42    /// # Example
43    ///
44    /// ```rust,no_run
45    /// use std::str::FromStr;
46    ///
47    /// use alloy_primitives::{Address, U256};
48    /// use onemoney_protocol::{Client, NamedChain, PaymentPayload};
49    ///
50    /// #[tokio::main]
51    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
52    ///     let client = Client::mainnet()?;
53    ///
54    ///     let payload = PaymentPayload {
55    ///         chain_id: NamedChain::TESTNET_CHAIN_ID,
56    ///         nonce: 0,
57    ///         recipient: Address::from_str("0x742d35Cc6634C0532925a3b8D91D6F4A81B8Cbc0")?,
58    ///         value: U256::from(1000000000000000000u64), // 1 token
59    ///         token: Address::from_str("0x1234567890abcdef1234567890abcdef12345678")?,
60    ///     };
61    ///
62    ///     let private_key = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
63    ///     let result = client.send_payment(payload, private_key).await?;
64    ///     println!("Transaction hash: {}", result.hash);
65    ///
66    ///     Ok(())
67    /// }
68    /// ```
69    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    /// Submit a raw transaction envelope.
78    ///
79    /// This is used for advanced transaction types such as multi-sig creation
80    /// and multi-sig payments.
81    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    /// Get transaction by hash.
87    ///
88    /// # Arguments
89    ///
90    /// * `hash` - Transaction hash
91    ///
92    /// # Returns
93    ///
94    /// The transaction details.
95    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    /// Get transaction receipt by hash.
101    ///
102    /// # Arguments
103    ///
104    /// * `hash` - Transaction hash
105    ///
106    /// # Returns
107    ///
108    /// The transaction receipt.
109    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    /// Wait for a transaction receipt using the default timeout.
115    ///
116    /// This method polls the receipt endpoint every 50ms for up to 30 seconds.
117    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    /// Wait for a transaction receipt with a custom timeout.
123    ///
124    /// # Arguments
125    /// * `hash` - Transaction hash
126    /// * `timeout` - Maximum duration to poll before returning a timeout error
127    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    /// Estimate transaction fee.
145    ///
146    /// # Arguments
147    ///
148    /// * `request` - Fee estimation parameters
149    ///
150    /// # Returns
151    ///
152    /// The estimated fee.
153    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    /// Get finalized transaction and receipt by hash.
163    ///
164    /// # Arguments
165    ///
166    /// * `hash` - Transaction hash
167    ///
168    /// # Returns
169    ///
170    /// The finalized transaction and receipt.
171    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    /// Get and verify finalized transaction by hash with BLS signature
177    /// verification.
178    ///
179    /// This is a convenience method that fetches the finalized transaction and
180    /// automatically verifies the BLS aggregate signature from validators.
181    ///
182    /// # Arguments
183    ///
184    /// * `hash` - Transaction hash (with or without 0x prefix)
185    ///
186    /// # Returns
187    ///
188    /// The finalized transaction if signature verification passes, error
189    /// otherwise.
190    ///
191    /// # Example
192    ///
193    /// ```rust,ignore
194    /// use onemoney_protocol::Client;
195    ///
196    /// #[tokio::main]
197    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
198    ///     let client = Client::testnet()?;
199    ///
200    ///     // This will fetch AND verify the transaction
201    ///     let finalized_tx = client
202    ///         .get_and_verify_finalized_transaction_by_hash("0x1234...")
203    ///         .await?;
204    ///
205    ///     println!("Transaction verified! Epoch: {}", finalized_tx.epoch);
206    ///     Ok(())
207    /// }
208    /// ```
209    pub async fn get_and_verify_finalized_transaction_by_hash(&self, hash: &str) -> Result<FinalizedTransaction> {
210        // Fetch the finalized transaction
211        let finalized_tx = self.get_finalized_transaction_by_hash(hash).await?;
212
213        // Parse transaction hash
214        let tx_hash =
215            B256::from_str(hash).map_err(|e| Error::validation("hash", format!("Invalid transaction hash: {}", e)))?;
216
217        // Compute the counter-sign message hash
218        let message_hash = signature_hash_for_counter_sign(&tx_hash, &finalized_tx.epoch);
219
220        // Verify the BLS signature
221        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        // Test serialization
320        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}