Skip to main content

smplx_sdk/provider/
esplora.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::str::FromStr;
4use std::time::Duration;
5
6use simplicityhl::elements::hashes::{Hash, sha256};
7
8use simplicityhl::elements::encode;
9use simplicityhl::elements::{Address, OutPoint, Script, Transaction, Txid};
10
11use serde::Deserialize;
12
13use crate::provider::SimplicityNetwork;
14use crate::transaction::{TxReceipt, UTXO};
15
16use super::core::{DEFAULT_ESPLORA_TIMEOUT_SECS, ProviderTrait};
17use super::error::ProviderError;
18
19/// A provider implementation that interacts with the Esplora REST API backend.
20#[derive(Debug)]
21pub struct EsploraProvider {
22    /// The base URL of the Esplora REST API.
23    pub esplora_url: String,
24    /// The currently configured Simplicity network (e.g. Liquid, Testnet, Regtest).
25    pub network: SimplicityNetwork,
26    /// Timeout duration used in underlying HTTP requests.
27    pub timeout: Duration,
28}
29
30#[derive(Deserialize)]
31#[allow(dead_code)]
32struct TxStatus {
33    confirmed: bool,
34    block_height: Option<u32>,
35}
36
37#[derive(Deserialize)]
38#[allow(dead_code)]
39struct EsploraBlock {
40    id: String,
41    height: u32,
42    timestamp: u64,
43    tx_count: u32,
44}
45
46#[derive(Clone, Deserialize)]
47#[allow(dead_code)]
48struct UtxoStatus {
49    pub confirmed: bool,
50    pub block_height: Option<u64>,
51    pub block_hash: Option<String>,
52    pub block_time: Option<u64>,
53}
54
55#[derive(Clone, Deserialize)]
56#[allow(dead_code)]
57struct EsploraUtxo {
58    pub txid: String,
59    pub vout: u32,
60    pub value: Option<u64>,
61    pub valuecommitment: Option<String>,
62    pub asset: Option<String>,
63    pub assetcommitment: Option<String>,
64    pub status: UtxoStatus,
65}
66
67impl EsploraProvider {
68    /// Creates a new `EsploraProvider` connected to the provided endpoint targeting the specific network.
69    #[must_use]
70    pub fn new(url: String, network: SimplicityNetwork) -> Self {
71        Self {
72            esplora_url: url,
73            network,
74            timeout: Duration::from_secs(DEFAULT_ESPLORA_TIMEOUT_SECS),
75        }
76    }
77
78    fn esplora_utxo_to_outpoint(utxo: &EsploraUtxo) -> Result<OutPoint, ProviderError> {
79        let txid = Txid::from_str(&utxo.txid).map_err(|e| ProviderError::InvalidTxid(e.to_string()))?;
80
81        Ok(OutPoint::new(txid, utxo.vout))
82    }
83
84    fn populate_txouts_from_outpoints(&self, outpoints: &[OutPoint]) -> Result<Vec<UTXO>, ProviderError> {
85        let set: HashSet<_> = outpoints.iter().collect();
86        let mut map = HashMap::new();
87
88        // filter unique transactions
89        for point in set {
90            let tx = self.fetch_transaction(&point.txid)?;
91            map.insert(point.txid, tx);
92        }
93
94        // populate TxOuts
95        Ok(outpoints
96            .iter()
97            .map(|point| UTXO {
98                outpoint: *point,
99                txout: map.get(&point.txid).unwrap().output[point.vout as usize].clone(),
100                secrets: None,
101            })
102            .collect())
103    }
104}
105
106impl ProviderTrait for EsploraProvider {
107    fn get_network(&self) -> &SimplicityNetwork {
108        &self.network
109    }
110
111    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
112    fn broadcast_transaction(&self, tx: &Transaction) -> Result<TxReceipt<'_>, ProviderError> {
113        let tx_hex = encode::serialize_hex(tx);
114        let url = format!("{}/tx", self.esplora_url);
115        let timeout_secs = self.timeout.as_secs();
116
117        let response = minreq::post(&url)
118            .with_timeout(timeout_secs)
119            .with_body(tx_hex)
120            .send()
121            .map_err(|e| ProviderError::Request(e.to_string()))?;
122
123        let status = response.status_code;
124        let body = response.as_str().unwrap_or("").trim().to_owned();
125
126        if !(200..300).contains(&status) {
127            return Err(ProviderError::BroadcastRejected {
128                status: status as u16,
129                url: format!("{}/tx", self.esplora_url),
130                message: body,
131            });
132        }
133
134        Txid::from_str(&body)
135            .map_err(|e| ProviderError::InvalidTxid(e.to_string()))
136            .map(|tx_id| TxReceipt::new(self, tx_id))
137    }
138
139    fn wait(&self, txid: &Txid) -> Result<(), ProviderError> {
140        let url = format!("{}/tx/{}/status", self.esplora_url, txid);
141        let timeout_secs = self.timeout.as_secs();
142
143        let confirmation_poll = match self.network {
144            SimplicityNetwork::ElementsRegtest { .. } => Duration::from_millis(100),
145            _ => Duration::from_secs(10),
146        };
147
148        // polling needs to be > 1 min on mainnet/testnet
149        for _ in 1..10 {
150            let response = minreq::get(&url)
151                .with_timeout(timeout_secs)
152                .send()
153                .map_err(|e| ProviderError::Request(e.to_string()))?;
154
155            if response.status_code != 200 {
156                std::thread::sleep(confirmation_poll);
157                continue;
158            }
159
160            let status: TxStatus = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?;
161
162            if status.confirmed {
163                return Ok(());
164            }
165
166            std::thread::sleep(confirmation_poll);
167        }
168
169        Err(ProviderError::Confirmation())
170    }
171
172    fn fetch_tip_height(&self) -> Result<u32, ProviderError> {
173        let url = format!("{}/blocks/tip/height", self.esplora_url);
174        let timeout_secs = self.timeout.as_secs();
175
176        let response = minreq::get(&url)
177            .with_timeout(timeout_secs)
178            .send()
179            .map_err(|e| ProviderError::Request(e.to_string()))?;
180
181        if response.status_code != 200 {
182            return Err(ProviderError::Request(format!(
183                "HTTP {}: {}",
184                response.status_code, response.reason_phrase
185            )));
186        }
187
188        let body_str = response
189            .as_str()
190            .map_err(|e| ProviderError::Deserialize(e.to_string()))?;
191
192        let height: u32 = body_str
193            .trim()
194            .parse()
195            .map_err(|e: std::num::ParseIntError| ProviderError::Deserialize(e.to_string()))?;
196
197        Ok(height)
198    }
199
200    fn fetch_tip_timestamp(&self) -> Result<u64, ProviderError> {
201        let timeout_secs = self.timeout.as_secs();
202
203        let hash_url = format!("{}/blocks/tip/hash", self.esplora_url);
204        let hash_response = minreq::get(&hash_url)
205            .with_timeout(timeout_secs)
206            .send()
207            .map_err(|e| ProviderError::Request(e.to_string()))?;
208
209        if hash_response.status_code != 200 {
210            return Err(ProviderError::Request(format!(
211                "HTTP {}: {}",
212                hash_response.status_code, hash_response.reason_phrase
213            )));
214        }
215
216        let tip_hash = hash_response
217            .as_str()
218            .map_err(|e| ProviderError::Deserialize(e.to_string()))?
219            .trim();
220
221        let block_url = format!("{}/block/{}", self.esplora_url, tip_hash);
222        let block_response = minreq::get(&block_url)
223            .with_timeout(timeout_secs)
224            .send()
225            .map_err(|e| ProviderError::Request(e.to_string()))?;
226
227        if block_response.status_code != 200 {
228            return Err(ProviderError::Request(format!(
229                "HTTP {}: {}",
230                block_response.status_code, block_response.reason_phrase
231            )));
232        }
233
234        let block: EsploraBlock = block_response
235            .json()
236            .map_err(|e| ProviderError::Deserialize(e.to_string()))?;
237
238        Ok(block.timestamp)
239    }
240
241    fn fetch_transaction(&self, txid: &Txid) -> Result<Transaction, ProviderError> {
242        let url = format!("{}/tx/{}/raw", self.esplora_url, txid);
243        let timeout_secs = self.timeout.as_secs();
244
245        let response = minreq::get(&url)
246            .with_timeout(timeout_secs)
247            .send()
248            .map_err(|e| ProviderError::Request(e.to_string()))?;
249
250        if response.status_code != 200 {
251            return Err(ProviderError::Request(format!(
252                "HTTP {}: {}",
253                response.status_code, response.reason_phrase
254            )));
255        }
256
257        let bytes = response.as_bytes();
258        let tx: Transaction = encode::deserialize(bytes).map_err(|e| ProviderError::Deserialize(e.to_string()))?;
259
260        Ok(tx)
261    }
262
263    fn fetch_address_utxos(&self, address: &Address) -> Result<Vec<UTXO>, ProviderError> {
264        let url = format!("{}/address/{}/utxo", self.esplora_url, address);
265        let timeout_secs = self.timeout.as_secs();
266
267        let response = minreq::get(&url)
268            .with_timeout(timeout_secs)
269            .send()
270            .map_err(|e| ProviderError::Request(e.to_string()))?;
271
272        if response.status_code != 200 {
273            return Err(ProviderError::Request(format!(
274                "HTTP {}: {}",
275                response.status_code, response.reason_phrase
276            )));
277        }
278
279        let utxos: Vec<EsploraUtxo> = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?;
280        let outpoints = utxos
281            .iter()
282            .map(Self::esplora_utxo_to_outpoint)
283            .collect::<Result<Vec<OutPoint>, ProviderError>>()?;
284
285        self.populate_txouts_from_outpoints(&outpoints)
286    }
287
288    fn fetch_scripthash_utxos(&self, script: &Script) -> Result<Vec<UTXO>, ProviderError> {
289        let hash = sha256::Hash::hash(script.as_bytes());
290        let hash_bytes = hash.to_byte_array();
291        let scripthash = hex::encode(hash_bytes);
292
293        let url = format!("{}/scripthash/{}/utxo", self.esplora_url, scripthash);
294        let timeout_secs = self.timeout.as_secs();
295
296        let response = minreq::get(&url)
297            .with_timeout(timeout_secs)
298            .send()
299            .map_err(|e| ProviderError::Request(e.to_string()))?;
300
301        if response.status_code != 200 {
302            return Err(ProviderError::Request(format!(
303                "HTTP {}: {}",
304                response.status_code, response.reason_phrase
305            )));
306        }
307
308        let utxos: Vec<EsploraUtxo> = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?;
309        let outpoints = utxos
310            .iter()
311            .map(Self::esplora_utxo_to_outpoint)
312            .collect::<Result<Vec<OutPoint>, ProviderError>>()?;
313
314        self.populate_txouts_from_outpoints(&outpoints)
315    }
316
317    fn fetch_fee_estimates(&self) -> Result<HashMap<String, f64>, ProviderError> {
318        let url = format!("{}/fee-estimates", self.esplora_url);
319        let timeout_secs = self.timeout.as_secs();
320
321        let response = minreq::get(&url)
322            .with_timeout(timeout_secs)
323            .send()
324            .map_err(|e| ProviderError::Request(e.to_string()))?;
325
326        if response.status_code != 200 {
327            return Err(ProviderError::Request(format!(
328                "HTTP {}: {}",
329                response.status_code, response.reason_phrase
330            )));
331        }
332
333        let estimates: HashMap<String, f64> = response.json().map_err(|e| ProviderError::Deserialize(e.to_string()))?;
334
335        Ok(estimates)
336    }
337}