Skip to main content

ark_rest/
conversions.rs

1//! Type conversions between generated API types and ark-core types
2
3use crate::models::GetInfoResponse;
4use crate::models::GetSubscriptionResponse;
5use crate::models::IndexerAsset;
6use crate::models::IndexerVtxo;
7use bitcoin::base64;
8use bitcoin::base64::Engine;
9use bitcoin::hex::FromHex;
10use bitcoin::secp256k1::PublicKey;
11use bitcoin::Amount;
12use bitcoin::OutPoint;
13use bitcoin::Psbt;
14use bitcoin::ScriptBuf;
15use bitcoin::Transaction;
16use bitcoin::Txid;
17use std::collections::HashMap;
18use std::error::Error as StdError;
19use std::str::FromStr;
20
21pub mod stream;
22
23#[derive(Debug)]
24pub struct ConversionError(pub String);
25
26impl std::fmt::Display for ConversionError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "Conversion error: {}", self.0)
29    }
30}
31
32impl StdError for ConversionError {}
33
34impl TryFrom<crate::models::IntentFeeInfo> for ark_core::server::IntentFeeInfo {
35    type Error = ConversionError;
36
37    fn try_from(value: crate::models::IntentFeeInfo) -> Result<Self, Self::Error> {
38        Ok(ark_core::server::IntentFeeInfo {
39            offchain_input: value.offchain_input,
40            offchain_output: value.offchain_output,
41            onchain_input: value.onchain_input,
42            onchain_output: value.onchain_output,
43        })
44    }
45}
46
47impl TryFrom<crate::models::FeeInfo> for ark_core::server::FeeInfo {
48    type Error = ConversionError;
49
50    fn try_from(value: crate::models::FeeInfo) -> Result<Self, Self::Error> {
51        let intent_fee = value
52            .intent_fee
53            .map(ark_core::server::IntentFeeInfo::try_from)
54            .transpose()?
55            .unwrap_or_default();
56
57        let tx_fee_rate = value.tx_fee_rate.unwrap_or_default();
58
59        Ok(ark_core::server::FeeInfo {
60            intent_fee,
61            tx_fee_rate,
62        })
63    }
64}
65
66impl TryFrom<crate::models::ScheduledSession> for ark_core::server::ScheduledSession {
67    type Error = ConversionError;
68
69    fn try_from(value: crate::models::ScheduledSession) -> Result<Self, Self::Error> {
70        let next_start_time_str = value
71            .next_start_time
72            .ok_or_else(|| ConversionError("Missing next_start_time".to_string()))?;
73        let next_start_time = i64::from_str(&next_start_time_str)
74            .map_err(|e| ConversionError(format!("Could not parse next_start_time: {e:#}")))?;
75
76        let next_end_time_str = value
77            .next_end_time
78            .ok_or_else(|| ConversionError("Missing next_end_time".to_string()))?;
79        let next_end_time = i64::from_str(&next_end_time_str)
80            .map_err(|e| ConversionError(format!("Could not parse next_end_time: {e:#}")))?;
81
82        let period_str = value
83            .period
84            .ok_or_else(|| ConversionError("Missing period".to_string()))?;
85        let period = i64::from_str(&period_str)
86            .map_err(|e| ConversionError(format!("Could not parse period: {e:#}")))?;
87
88        let duration_str = value
89            .duration
90            .ok_or_else(|| ConversionError("Missing duration".to_string()))?;
91        let duration = i64::from_str(&duration_str)
92            .map_err(|e| ConversionError(format!("Could not parse duration: {e:#}")))?;
93
94        let fees = value
95            .fees
96            .map(ark_core::server::FeeInfo::try_from)
97            .transpose()?;
98
99        Ok(ark_core::server::ScheduledSession {
100            next_start_time,
101            next_end_time,
102            period,
103            duration,
104            fees,
105        })
106    }
107}
108
109impl TryFrom<crate::models::DeprecatedSigner> for ark_core::server::DeprecatedSigner {
110    type Error = ConversionError;
111
112    fn try_from(value: crate::models::DeprecatedSigner) -> Result<Self, Self::Error> {
113        let pubkey_str = value
114            .pubkey
115            .ok_or_else(|| ConversionError("Missing pubkey in deprecated signer".to_string()))?;
116        let pk = pubkey_str
117            .parse::<PublicKey>()
118            .map_err(|e| ConversionError(format!("Invalid pubkey '{pubkey_str}': {e}")))?;
119
120        // A missing cutoffDate means "rotate immediately" (same as 0).
121        let cutoff_date = match value.cutoff_date {
122            Some(s) => i64::from_str(&s)
123                .map_err(|e| ConversionError(format!("Could not parse cutoff_date: {e:#}")))?,
124            None => 0,
125        };
126
127        Ok(ark_core::server::DeprecatedSigner { pk, cutoff_date })
128    }
129}
130
131impl TryFrom<GetInfoResponse> for ark_core::server::Info {
132    type Error = ConversionError;
133
134    fn try_from(response: GetInfoResponse) -> Result<Self, Self::Error> {
135        // Parse signer_pk
136        let signer_pubkey_str = response
137            .signer_pubkey
138            .ok_or_else(|| ConversionError("Missing signer_pubkey".to_string()))?;
139        let signer_pk = signer_pubkey_str.parse::<PublicKey>().map_err(|e| {
140            ConversionError(format!("Invalid signer_pubkey '{signer_pubkey_str}': {e}"))
141        })?;
142
143        // Parse forfeit_pk
144        let forfeit_pubkey_str = response
145            .forfeit_pubkey
146            .ok_or_else(|| ConversionError("Missing forfeit_pubkey".to_string()))?;
147        let forfeit_pk = forfeit_pubkey_str.parse::<PublicKey>().map_err(|e| {
148            ConversionError(format!(
149                "Invalid forfeit_pubkey '{forfeit_pubkey_str}': {e}"
150            ))
151        })?;
152
153        // Parse checkpoint_tapscript
154        let checkpoint_tapscript_str = response
155            .checkpoint_tapscript
156            .ok_or_else(|| ConversionError("Missing checkpoint_tapscript".to_string()))?;
157        let checkpoint_tapscript = ScriptBuf::from_hex(&checkpoint_tapscript_str).map_err(|e| {
158            ConversionError(format!(
159                "Invalid checkpoint_tapscript hex '{checkpoint_tapscript_str}': {e}"
160            ))
161        })?;
162
163        // Parse unilateral_exit_delay
164        let unilateral_exit_delay_str = response
165            .unilateral_exit_delay
166            .ok_or_else(|| ConversionError("Missing unilateral_exit_delay".to_string()))?;
167        let unilateral_exit_delay_val = i64::from_str(&unilateral_exit_delay_str).map_err(|e| {
168            ConversionError(format!("Could not parse unilateral_exit_delay: {e:#}"))
169        })?;
170        let unilateral_exit_delay = parse_sequence_number(unilateral_exit_delay_val)?;
171
172        // Parse boarding_exit_delay
173        let boarding_exit_delay_str = response
174            .boarding_exit_delay
175            .ok_or_else(|| ConversionError("Missing boarding_exit_delay".to_string()))?;
176        let boarding_exit_delay_val = i64::from_str(&boarding_exit_delay_str)
177            .map_err(|e| ConversionError(format!("Could not parse boarding_exit_delay: {e:#}")))?;
178        let boarding_exit_delay = parse_sequence_number(boarding_exit_delay_val)?;
179
180        // Parse network
181        let network_str = response
182            .network
183            .ok_or_else(|| ConversionError("Missing network".to_string()))?;
184        let network = ark_core::server::Network::from_str(&network_str)
185            .map_err(|e| ConversionError(format!("Invalid network '{network_str}': {e}")))?;
186        let network = bitcoin::Network::from(network);
187
188        // Parse session_duration
189        let session_duration_str = response
190            .session_duration
191            .ok_or_else(|| ConversionError("Missing session_duration".to_string()))?;
192        let session_duration = i64::from_str(&session_duration_str)
193            .map_err(|e| ConversionError(format!("Could not parse session_duration: {e:#}")))?
194            as u64;
195
196        // Parse dust
197        let dust_str = response
198            .dust
199            .ok_or_else(|| ConversionError("Missing dust".to_string()))?;
200        let dust_val = i64::from_str(&dust_str)
201            .map_err(|e| ConversionError(format!("Could not parse dust: {e:#}")))?;
202        let dust = Amount::from_sat(dust_val as u64);
203
204        // Parse forfeit_address
205        let forfeit_address_str = response
206            .forfeit_address
207            .ok_or_else(|| ConversionError("Missing forfeit_address".to_string()))?;
208        let forfeit_address = forfeit_address_str
209            .parse::<bitcoin::Address<bitcoin::address::NetworkUnchecked>>()
210            .map_err(|e| {
211                ConversionError(format!(
212                    "Invalid forfeit_address '{forfeit_address_str}': {e}"
213                ))
214            })?
215            .require_network(network)
216            .map_err(|e| {
217                ConversionError(format!(
218                    "Address network mismatch for '{forfeit_address_str}': {e}"
219                ))
220            })?;
221
222        // Parse version
223        let version = response
224            .version
225            .ok_or_else(|| ConversionError("Missing version".to_string()))?;
226
227        // Parse digest
228        let digest = response.digest.unwrap_or_default();
229
230        // Parse utxo amount limits
231        let utxo_min_amount = response
232            .utxo_min_amount
233            .and_then(|s| i64::from_str(&s).ok())
234            .and_then(|val| {
235                if val >= 0 {
236                    Some(Amount::from_sat(val as u64))
237                } else {
238                    None
239                }
240            });
241
242        let utxo_max_amount = response
243            .utxo_max_amount
244            .and_then(|s| i64::from_str(&s).ok())
245            .and_then(|val| {
246                if val >= 0 {
247                    Some(Amount::from_sat(val as u64))
248                } else {
249                    None
250                }
251            });
252
253        let vtxo_min_amount = response
254            .vtxo_min_amount
255            .and_then(|s| i64::from_str(&s).ok())
256            .and_then(|val| {
257                if val >= 0 {
258                    Some(Amount::from_sat(val as u64))
259                } else {
260                    None
261                }
262            });
263
264        let vtxo_max_amount = response
265            .vtxo_max_amount
266            .and_then(|s| i64::from_str(&s).ok())
267            .and_then(|val| {
268                if val >= 0 {
269                    Some(Amount::from_sat(val as u64))
270                } else {
271                    None
272                }
273            });
274
275        // Parse fees
276        let fees = response
277            .fees
278            .map(ark_core::server::FeeInfo::try_from)
279            .transpose()?;
280
281        // Parse scheduled_session
282        let scheduled_session = response
283            .scheduled_session
284            .map(ark_core::server::ScheduledSession::try_from)
285            .transpose()?;
286
287        // Parse deprecated_signers
288        let deprecated_signers = response
289            .deprecated_signers
290            .unwrap_or_default()
291            .into_iter()
292            .map(ark_core::server::DeprecatedSigner::try_from)
293            .collect::<Result<Vec<_>, _>>()?;
294
295        // Parse service_status
296        let service_status = response.service_status.unwrap_or_default();
297
298        let max_tx_weight_str = response
299            .max_tx_weight
300            .ok_or_else(|| ConversionError("Missing max_tx_weight".to_string()))?;
301        let max_tx_weight = i64::from_str(&max_tx_weight_str)
302            .map_err(|e| ConversionError(format!("Could not parse max_tx_weight: {e:#}")))?;
303
304        let max_op_return_outputs_str = response
305            .max_op_return_outputs
306            .ok_or_else(|| ConversionError("Missing max_op_return_outputs".to_string()))?;
307        let max_op_return_outputs = i64::from_str(&max_op_return_outputs_str).map_err(|e| {
308            ConversionError(format!("Could not parse max_op_return_outputs: {e:#}"))
309        })?;
310
311        Ok(ark_core::server::Info {
312            version,
313            signer_pk,
314            forfeit_pk,
315            forfeit_address,
316            checkpoint_tapscript,
317            network,
318            session_duration,
319            unilateral_exit_delay,
320            boarding_exit_delay,
321            utxo_min_amount,
322            utxo_max_amount,
323            vtxo_min_amount,
324            vtxo_max_amount,
325            dust,
326            fees,
327            scheduled_session,
328            deprecated_signers,
329            service_status,
330            digest,
331            max_tx_weight,
332            max_op_return_outputs,
333        })
334    }
335}
336
337impl TryFrom<IndexerVtxo> for ark_core::server::VirtualTxOutPoint {
338    type Error = ConversionError;
339
340    fn try_from(value: IndexerVtxo) -> Result<Self, Self::Error> {
341        // Parse outpoint
342        let outpoint_data = value
343            .outpoint
344            .ok_or_else(|| ConversionError("Missing outpoint".to_string()))?;
345
346        let txid_str = outpoint_data
347            .txid
348            .ok_or_else(|| ConversionError("Missing outpoint txid".to_string()))?;
349        let txid = txid_str
350            .parse::<Txid>()
351            .map_err(|e| ConversionError(format!("Invalid outpoint txid '{txid_str}': {e}")))?;
352
353        let vout = outpoint_data
354            .vout
355            .ok_or_else(|| ConversionError("Missing outpoint vout".to_string()))?;
356        let vout = vout as u32; // Convert i64 to u32
357
358        let outpoint = OutPoint { txid, vout };
359
360        // Parse timestamps
361        let created_at_str = value
362            .created_at
363            .ok_or_else(|| ConversionError("Missing created_at".to_string()))?;
364        let created_at = i64::from_str(&created_at_str)
365            .map_err(|e| ConversionError(format!("Could not parse created_at: {e:#}")))?;
366
367        let expires_at_str = value
368            .expires_at
369            .ok_or_else(|| ConversionError("Missing expires_at".to_string()))?;
370        let expires_at = i64::from_str(&expires_at_str)
371            .map_err(|e| ConversionError(format!("Could not parse expires_at: {e:#}")))?;
372
373        // Parse amount
374        let amount_str = value
375            .amount
376            .ok_or_else(|| ConversionError("Missing amount".to_string()))?;
377        let amount_val = u64::from_str(&amount_str)
378            .map_err(|e| ConversionError(format!("Could not parse amount: {e:#}")))?;
379        let amount = Amount::from_sat(amount_val);
380
381        // Parse script
382        let script_str = value
383            .script
384            .ok_or_else(|| ConversionError("Missing script".to_string()))?;
385        let script = ScriptBuf::from_hex(&script_str)
386            .map_err(|e| ConversionError(format!("Invalid script hex '{script_str}': {e}")))?;
387
388        // Parse optional spent_by
389        let spent_by = value
390            .spent_by
391            .filter(|s| !s.is_empty())
392            .map(|s| s.parse::<Txid>())
393            .transpose()
394            .map_err(|e| ConversionError(format!("Invalid spent_by txid: {e}")))?;
395
396        // Parse commitment_txids
397        let commitment_txids = value
398            .commitment_txids
399            .unwrap_or_default()
400            .into_iter()
401            .map(|s| s.parse::<Txid>())
402            .collect::<Result<Vec<_>, _>>()
403            .map_err(|e| ConversionError(format!("Invalid commitment_txid: {e}")))?;
404
405        // Parse optional settled_by
406        let settled_by = value
407            .settled_by
408            .filter(|s| !s.is_empty())
409            .map(|s| s.parse::<Txid>())
410            .transpose()
411            .map_err(|e| ConversionError(format!("Invalid settled_by txid: {e}")))?;
412
413        // Parse optional ark_txid
414        let ark_txid = value
415            .ark_txid
416            .filter(|s| !s.is_empty())
417            .map(|s| s.parse::<Txid>())
418            .transpose()
419            .map_err(|e| ConversionError(format!("Invalid ark_txid: {e}")))?;
420
421        let assets = value
422            .assets
423            .unwrap_or_default()
424            .into_iter()
425            .filter_map(|a| match a {
426                IndexerAsset {
427                    amount: Some(amount),
428                    asset_id: Some(asset_id),
429                } => {
430                    let asset_id = match asset_id.parse() {
431                        Ok(asset_id) => asset_id,
432                        Err(e) => {
433                            return Some(Err(ConversionError(format!("Invalid asset ID: {e}"))));
434                        }
435                    };
436
437                    Some(Ok(ark_core::server::Asset {
438                        asset_id,
439                        amount: amount as u64,
440                    }))
441                }
442                _ => None,
443            })
444            .collect::<Result<Vec<_>, _>>()?;
445
446        Ok(ark_core::server::VirtualTxOutPoint {
447            outpoint,
448            created_at,
449            expires_at,
450            amount,
451            script,
452            is_preconfirmed: value.is_preconfirmed.unwrap_or(false),
453            is_swept: value.is_swept.unwrap_or(false),
454            is_unrolled: value.is_unrolled.unwrap_or(false),
455            is_spent: value.is_spent.unwrap_or(false),
456            spent_by,
457            commitment_txids,
458            settled_by,
459            ark_txid,
460            assets,
461        })
462    }
463}
464
465fn parse_sequence_number(value: i64) -> Result<bitcoin::Sequence, ConversionError> {
466    /// The threshold that determines whether an expiry or exit delay should be parsed as a
467    /// number of blocks or a number of seconds.
468    ///
469    /// - A value below 512 is considered a number of blocks.
470    /// - A value over 512 is considered a number of seconds.
471    const ARBITRARY_SEQUENCE_THRESHOLD: i64 = 512;
472
473    let sequence = if value.is_negative() {
474        return Err(ConversionError(format!("invalid sequence number: {value}")));
475    } else if value < ARBITRARY_SEQUENCE_THRESHOLD {
476        bitcoin::Sequence::from_height(value as u16)
477    } else {
478        bitcoin::Sequence::from_seconds_ceil(value as u32)
479            .map_err(|e| ConversionError(format!("Failed parsing sequence number: {e}")))?
480    };
481
482    Ok(sequence)
483}
484
485impl TryFrom<crate::models::IndexerSubscriptionEvent> for ark_core::server::SubscriptionEvent {
486    type Error = ConversionError;
487
488    fn try_from(event: crate::models::IndexerSubscriptionEvent) -> Result<Self, Self::Error> {
489        // Parse txid
490        let txid_str = event
491            .txid
492            .ok_or_else(|| ConversionError("Missing txid in subscription event".to_string()))?;
493        let txid = txid_str
494            .parse::<Txid>()
495            .map_err(|e| ConversionError(format!("Invalid txid '{txid_str}': {e}")))?;
496
497        // Parse scripts
498        let scripts = event
499            .scripts
500            .unwrap_or_default()
501            .iter()
502            .map(|h| {
503                ScriptBuf::from_hex(h)
504                    .map_err(|e| ConversionError(format!("Invalid script hex: {e}")))
505            })
506            .collect::<Result<Vec<_>, _>>()?;
507
508        // Parse new_vtxos
509        let new_vtxos = event
510            .new_vtxos
511            .unwrap_or_default()
512            .into_iter()
513            .map(ark_core::server::VirtualTxOutPoint::try_from)
514            .collect::<Result<Vec<_>, _>>()
515            .map_err(|e| ConversionError(format!("Invalid new_vtxos: {e}")))?;
516
517        // Parse spent_vtxos
518        let spent_vtxos = event
519            .spent_vtxos
520            .unwrap_or_default()
521            .into_iter()
522            .map(ark_core::server::VirtualTxOutPoint::try_from)
523            .collect::<Result<Vec<_>, _>>()
524            .map_err(|e| ConversionError(format!("Invalid spent_vtxos: {e}")))?;
525
526        // Parse tx (raw tx hex or base64 PSBT)
527        let tx = if let Some(tx_str) = event.tx.filter(|s| !s.is_empty()) {
528            match Vec::from_hex(&tx_str)
529                .ok()
530                .and_then(|bytes| bitcoin::consensus::deserialize::<Transaction>(&bytes).ok())
531            {
532                Some(raw_tx) => Some(raw_tx),
533                None => {
534                    let base64 = base64::engine::GeneralPurpose::new(
535                        &base64::alphabet::STANDARD,
536                        base64::engine::GeneralPurposeConfig::new(),
537                    );
538                    let bytes = base64
539                        .decode(&tx_str)
540                        .map_err(|e| ConversionError(format!("Invalid tx payload: {e}")))?;
541                    let psbt = Psbt::deserialize(&bytes)
542                        .map_err(|e| ConversionError(format!("Invalid tx psbt: {e}")))?;
543                    Some(psbt.unsigned_tx)
544                }
545            }
546        } else {
547            None
548        };
549
550        // Parse checkpoint_txs
551        let checkpoint_txs = event
552            .checkpoint_txs
553            .unwrap_or_default()
554            .into_iter()
555            .map(|(k, v)| {
556                let out_point = OutPoint::from_str(&k)
557                    .map_err(|e| ConversionError(format!("Invalid checkpoint outpoint: {e}")))?;
558                let txid_str = v
559                    .txid
560                    .ok_or_else(|| ConversionError("Missing checkpoint txid".to_string()))?;
561                let txid = txid_str
562                    .parse::<Txid>()
563                    .map_err(|e| ConversionError(format!("Invalid checkpoint txid: {e}")))?;
564                Ok((out_point, txid))
565            })
566            .collect::<Result<HashMap<_, _>, ConversionError>>()?;
567
568        Ok(ark_core::server::SubscriptionEvent {
569            txid,
570            scripts,
571            new_vtxos,
572            spent_vtxos,
573            tx,
574            checkpoint_txs,
575        })
576    }
577}
578
579impl TryFrom<GetSubscriptionResponse> for ark_core::server::SubscriptionResponse {
580    type Error = ConversionError;
581
582    fn try_from(value: GetSubscriptionResponse) -> Result<Self, Self::Error> {
583        // Check if it's a heartbeat or an event
584        if value.heartbeat.is_some() {
585            Ok(ark_core::server::SubscriptionResponse::Heartbeat)
586        } else if let Some(event) = value.event {
587            let subscription_event = ark_core::server::SubscriptionEvent::try_from(event)?;
588            Ok(ark_core::server::SubscriptionResponse::Event(Box::new(
589                subscription_event,
590            )))
591        } else {
592            Err(ConversionError(
593                "GetSubscriptionResponse must have either event or heartbeat".to_string(),
594            ))
595        }
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use crate::models::DeprecatedSigner as ModelDeprecatedSigner;
602
603    fn model(pubkey: Option<&str>, cutoff_date: Option<&str>) -> ModelDeprecatedSigner {
604        ModelDeprecatedSigner {
605            pubkey: pubkey.map(str::to_string),
606            cutoff_date: cutoff_date.map(str::to_string),
607        }
608    }
609
610    const PK_A: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
611
612    // ── DeprecatedSigner conversion ──────────────────────────────────────────
613
614    #[test]
615    fn deprecated_signer_parses_cutoff_date() {
616        let result = ark_core::server::DeprecatedSigner::try_from(model(Some(PK_A), Some("12345")));
617        let ds = result.expect("should succeed");
618        assert_eq!(ds.cutoff_date, 12345);
619        assert_eq!(ds.pk.to_string(), PK_A);
620    }
621
622    #[test]
623    fn deprecated_signer_missing_cutoff_defaults_to_zero() {
624        // A missing cutoffDate means "rotate immediately" (cutoff_date == 0).
625        let result = ark_core::server::DeprecatedSigner::try_from(model(Some(PK_A), None));
626        let ds = result.expect("should succeed");
627        assert_eq!(ds.cutoff_date, 0);
628    }
629
630    #[test]
631    fn deprecated_signer_missing_pubkey_returns_error() {
632        let result = ark_core::server::DeprecatedSigner::try_from(model(None, Some("100")));
633        assert!(result.is_err());
634        let msg = result.unwrap_err().to_string();
635        assert!(msg.contains("Missing pubkey"), "unexpected: {msg}");
636    }
637
638    #[test]
639    fn deprecated_signer_invalid_pubkey_returns_error() {
640        let result =
641            ark_core::server::DeprecatedSigner::try_from(model(Some("notahex"), Some("100")));
642        assert!(result.is_err());
643        let msg = result.unwrap_err().to_string();
644        assert!(msg.contains("Invalid pubkey"), "unexpected: {msg}");
645    }
646
647    #[test]
648    fn deprecated_signer_invalid_cutoff_date_returns_error() {
649        let result =
650            ark_core::server::DeprecatedSigner::try_from(model(Some(PK_A), Some("not-a-number")));
651        assert!(result.is_err());
652        let msg = result.unwrap_err().to_string();
653        assert!(msg.contains("cutoff_date"), "unexpected: {msg}");
654    }
655}