ergo_node_interface/
node_interface.rs

1//! The `NodeInterface` struct is defined which allows for interacting with an Ergo Node via Rust.
2
3use crate::{BlockHeight, NanoErg, P2PKAddressString, P2SAddressString};
4use ergo_lib::chain::ergo_state_context::{ErgoStateContext, Headers};
5use ergo_lib::ergo_chain_types::{Header, PreHeader};
6use ergo_lib::ergotree_ir::chain::ergo_box::ErgoBox;
7use ergo_lib::ergotree_ir::chain::token::TokenId;
8use reqwest::Url;
9use serde_json::from_str;
10use std::convert::TryInto;
11use thiserror::Error;
12
13pub type Result<T> = std::result::Result<T, NodeError>;
14
15#[derive(Error, Debug)]
16pub enum NodeError {
17    #[error("The configured node is unreachable. Please ensure your config is correctly filled out and the node is running.")]
18    NodeUnreachable,
19    #[error("Failed reading response from node: {0}")]
20    FailedParsingNodeResponse(String),
21    #[error("Failed parsing JSON box from node: {0}")]
22    FailedParsingBox(String),
23    #[error("No Boxes Were Found.")]
24    NoBoxesFound,
25    #[error("An insufficient number of Ergs were found.")]
26    InsufficientErgsBalance(),
27    #[error("Failed registering UTXO-set scan with the node: {0}")]
28    FailedRegisteringScan(String),
29    #[error("The node rejected the request you provided.\nNode Response: {0}")]
30    BadRequest(String),
31    #[error("The node wallet has no addresses.")]
32    NoAddressesInWallet,
33    #[error("The node is still syncing.")]
34    NodeSyncing,
35    #[error("Error while processing Node Interface Config Yaml: {0}")]
36    YamlError(String),
37    #[error("{0}")]
38    Other(String),
39    #[error("Failed parsing wallet status from node: {0}")]
40    FailedParsingWalletStatus(String),
41    #[error("Failed to parse URL: {0}")]
42    InvalidUrl(String),
43    #[error("Failed to parse scan ID: {0}")]
44    InvalidScanId(String),
45}
46
47/// The `NodeInterface` struct which holds the relevant Ergo node data
48/// and has methods implemented to interact with the node.
49#[derive(Debug, Clone)]
50pub struct NodeInterface {
51    pub api_key: String,
52    pub url: Url,
53}
54
55pub fn is_mainnet_address(address: &str) -> bool {
56    address.starts_with('9')
57}
58
59pub fn is_testnet_address(address: &str) -> bool {
60    address.starts_with('3')
61}
62
63impl NodeInterface {
64    /// Create a new `NodeInterface` using details about the Node
65    /// Sets url to `http://ip:port` using `ip` and `port`
66    pub fn new(api_key: &str, ip: &str, port: &str) -> Result<Self> {
67        let url = Url::parse(("http://".to_string() + ip + ":" + port + "/").as_str())
68            .map_err(|e| NodeError::InvalidUrl(e.to_string()))?;
69        Ok(NodeInterface {
70            api_key: api_key.to_string(),
71            url,
72        })
73    }
74
75    pub fn from_url(api_key: &str, url: Url) -> Self {
76        NodeInterface {
77            api_key: api_key.to_string(),
78            url,
79        }
80    }
81
82    pub fn from_url_str(api_key: &str, url: &str) -> Result<Self> {
83        let url = Url::parse(url).map_err(|e| NodeError::InvalidUrl(e.to_string()))?;
84        Ok(NodeInterface {
85            api_key: api_key.to_string(),
86            url,
87        })
88    }
89
90    /// Acquires unspent boxes from the blockchain by specific address
91    pub fn unspent_boxes_by_address(
92        &self,
93        address: &P2PKAddressString,
94        offset: u64,
95        limit: u64,
96    ) -> Result<Vec<ErgoBox>> {
97        let endpoint = format!(
98            "/blockchain/box/unspent/byAddress?offset={}&limit={}",
99            offset, limit
100        );
101        let res = self.send_post_req(endpoint.as_str(), address.clone());
102        let res_json = self.parse_response_to_json(res)?;
103
104        let mut box_list = vec![];
105
106        for i in 0.. {
107            let box_json = &res_json[i];
108            if box_json.is_null() {
109                break;
110            } else if let Ok(ergo_box) = from_str(&box_json.to_string()) {
111                // This condition is added due to a bug in the node indexer that returns some spent boxes as unspent.
112                if box_json["spentTransactionId"].is_null() {
113                    box_list.push(ergo_box);
114                }
115            }
116        }
117        Ok(box_list)
118    }
119
120    /// Acquires unspent boxes from the blockchain by specific token_id
121    pub fn unspent_boxes_by_token_id(
122        &self,
123        token_id: &TokenId,
124        offset: u64,
125        limit: u64,
126    ) -> Result<Vec<ErgoBox>> {
127        let id: String = (*token_id).into();
128        let endpoint = format!(
129            "/blockchain/box/unspent/byTokenId/{}?offset={}&limit={}",
130            id, offset, limit
131        );
132        let res = self.send_get_req(endpoint.as_str());
133        let res_json = self.parse_response_to_json(res)?;
134
135        let mut box_list = vec![];
136
137        for i in 0.. {
138            let box_json = &res_json[i];
139            if box_json.is_null() {
140                break;
141            } else if let Ok(ergo_box) = from_str(&box_json.to_string()) {
142                // This condition is added due to a bug in the node indexer that returns some spent boxes as unspent.
143                if box_json["spentTransactionId"].is_null() {
144                    box_list.push(ergo_box);
145                }
146            }
147        }
148        Ok(box_list)
149    }
150
151    /// Get the current nanoErgs balance held in the `address`
152    pub fn nano_ergs_balance(&self, address: &P2PKAddressString) -> Result<NanoErg> {
153        let endpoint = "/blockchain/balance";
154        let res = self.send_post_req(endpoint, address.clone());
155        let res_json = self.parse_response_to_json(res)?;
156
157        let balance = res_json["confirmed"]["nanoErgs"].clone();
158
159        if balance.is_null() {
160            Err(NodeError::NodeSyncing)
161        } else {
162            balance
163                .as_u64()
164                .ok_or_else(|| NodeError::FailedParsingNodeResponse(res_json.to_string()))
165        }
166    }
167
168    /// Given a P2S Ergo address, extract the hex-encoded serialized ErgoTree (script)
169    pub fn p2s_to_tree(&self, address: &P2SAddressString) -> Result<String> {
170        let endpoint = "/script/addressToTree/".to_string() + address;
171        let res = self.send_get_req(&endpoint);
172        let res_json = self.parse_response_to_json(res)?;
173
174        Ok(res_json["tree"].to_string())
175    }
176
177    /// Given a P2S Ergo address, convert it to a hex-encoded Sigma byte array constant
178    pub fn p2s_to_bytes(&self, address: &P2SAddressString) -> Result<String> {
179        let endpoint = "/script/addressToBytes/".to_string() + address;
180        let res = self.send_get_req(&endpoint);
181        let res_json = self.parse_response_to_json(res)?;
182
183        Ok(res_json["bytes"].to_string())
184    }
185
186    /// Given an Ergo P2PK Address, convert it to a raw hex-encoded EC point
187    pub fn p2pk_to_raw(&self, address: &P2PKAddressString) -> Result<String> {
188        let endpoint = "/utils/addressToRaw/".to_string() + address;
189        let res = self.send_get_req(&endpoint);
190        let res_json = self.parse_response_to_json(res)?;
191
192        Ok(res_json["raw"].to_string())
193    }
194
195    /// Given an Ergo P2PK Address, convert it to a raw hex-encoded EC point
196    /// and prepend the type bytes so it is encoded and ready
197    /// to be used in a register.
198    pub fn p2pk_to_raw_for_register(&self, address: &P2PKAddressString) -> Result<String> {
199        let add = self.p2pk_to_raw(address)?;
200        Ok("07".to_string() + &add)
201    }
202
203    /// Given a raw hex-encoded EC point, convert it to a P2PK address
204    pub fn raw_to_p2pk(&self, raw: &str) -> Result<P2PKAddressString> {
205        let endpoint = "/utils/rawToAddress/".to_string() + raw;
206        let res = self.send_get_req(&endpoint);
207        let res_json = self.parse_response_to_json(res)?;
208
209        Ok(res_json["address"].to_string())
210    }
211
212    /// Given a raw hex-encoded EC point from a register (thus with type encoded characters in front),
213    /// convert it to a P2PK address
214    pub fn raw_from_register_to_p2pk(&self, typed_raw: &str) -> Result<P2PKAddressString> {
215        self.raw_to_p2pk(&typed_raw[2..])
216    }
217
218    /// Given a `Vec<ErgoBox>` return the given boxes (which must be part of the UTXO-set) as
219    /// a vec of serialized strings in Base16 encoding
220    pub fn serialize_boxes(&self, b: &[ErgoBox]) -> Result<Vec<String>> {
221        Ok(b.iter()
222            .map(|b| {
223                self.serialized_box_from_id(&b.box_id().into())
224                    .unwrap_or_else(|_| "".to_string())
225            })
226            .collect())
227    }
228
229    /// Given an `ErgoBox` return the given box (which must be part of the UTXO-set) as
230    /// a serialized string in Base16 encoding
231    pub fn serialize_box(&self, b: &ErgoBox) -> Result<String> {
232        self.serialized_box_from_id(&b.box_id().into())
233    }
234
235    /// Given a box id return the given box (which must be part of the
236    /// UTXO-set) as a serialized string in Base16 encoding
237    pub fn serialized_box_from_id(&self, box_id: &String) -> Result<String> {
238        let endpoint = "/utxo/byIdBinary/".to_string() + box_id;
239        let res = self.send_get_req(&endpoint);
240        let res_json = self.parse_response_to_json(res)?;
241
242        Ok(res_json["bytes"].to_string())
243    }
244
245    /// Given a box id return the given box (which must be part of the
246    /// UTXO-set) as a serialized string in Base16 encoding
247    pub fn box_from_id(&self, box_id: &String) -> Result<ErgoBox> {
248        let endpoint = "/utxo/byId/".to_string() + box_id;
249        let res = self.send_get_req(&endpoint);
250        let res_json = self.parse_response_to_json(res)?;
251
252        if let Ok(ergo_box) = from_str(&res_json.to_string()) {
253            Ok(ergo_box)
254        } else {
255            Err(NodeError::FailedParsingBox(res_json.pretty(2)))
256        }
257    }
258
259    /// Get the current block height of the blockchain
260    pub fn current_block_height(&self) -> Result<BlockHeight> {
261        let endpoint = "/info";
262        let res = self.send_get_req(endpoint);
263        let res_json = self.parse_response_to_json(res)?;
264
265        let height_json = res_json["fullHeight"].clone();
266
267        if height_json.is_null() {
268            Err(NodeError::NodeSyncing)
269        } else {
270            height_json
271                .to_string()
272                .parse()
273                .map_err(|_| NodeError::FailedParsingNodeResponse(res_json.to_string()))
274        }
275    }
276
277    /// Get the current state context of the blockchain
278    pub fn get_state_context(&self) -> Result<ErgoStateContext> {
279        let mut vec_headers = self.get_last_block_headers(10)?;
280        vec_headers.reverse();
281        let ten_headers: [Header; 10] = vec_headers.try_into().unwrap();
282        let headers = Headers::from(ten_headers);
283        let pre_header = PreHeader::from(headers.first().unwrap().clone());
284        let state_context = ErgoStateContext::new(pre_header, headers);
285
286        Ok(state_context)
287    }
288
289    /// Get the last `number` of block headers from the blockchain
290    pub fn get_last_block_headers(&self, number: u32) -> Result<Vec<Header>> {
291        let endpoint = format!("/blocks/lastHeaders/{}", number);
292        let res = self.send_get_req(endpoint.as_str());
293        let res_json = self.parse_response_to_json(res)?;
294
295        let mut headers: Vec<Header> = vec![];
296
297        for i in 0.. {
298            let header_json = &res_json[i];
299            if header_json.is_null() {
300                break;
301            } else if let Ok(header) = from_str(&header_json.to_string()) {
302                headers.push(header);
303            }
304        }
305        Ok(headers)
306    }
307
308    /// Checks if the blockchain indexer is active by querying the node.
309    pub fn indexer_status(&self) -> Result<IndexerStatus> {
310        let endpoint = "/blockchain/indexedHeight";
311        let res = self.send_get_req(endpoint);
312        let res_json = self.parse_response_to_json(res)?;
313
314        let error = res_json["error"].clone();
315        if !error.is_null() {
316            return Ok(IndexerStatus {
317                is_active: false,
318                is_sync: false,
319            });
320        }
321
322        let full_height = res_json["fullHeight"]
323            .as_u64()
324            .ok_or(NodeError::FailedParsingNodeResponse(res_json.to_string()))?;
325        let indexed_height = res_json["indexedHeight"]
326            .as_u64()
327            .ok_or(NodeError::FailedParsingNodeResponse(res_json.to_string()))?;
328
329        let is_sync = full_height.abs_diff(indexed_height) < 10;
330        Ok(IndexerStatus {
331            is_active: true,
332            is_sync,
333        })
334    }
335}
336
337pub struct IndexerStatus {
338    pub is_active: bool,
339    pub is_sync: bool,
340}