Skip to main content

ark_introspector_client/
lib.rs

1use bitcoin::base64;
2use bitcoin::base64::Engine;
3use bitcoin::Psbt;
4use bitcoin::PublicKey;
5use bitcoin::XOnlyPublicKey;
6use serde::Deserialize;
7use serde::Serialize;
8use std::time::Duration;
9
10const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
11
12#[derive(Debug, thiserror::Error)]
13pub enum Error {
14    #[error(transparent)]
15    Http(#[from] reqwest::Error),
16    #[error("http {status}: {body}")]
17    HttpStatus {
18        status: reqwest::StatusCode,
19        body: String,
20    },
21    #[error(transparent)]
22    Json(#[from] serde_json::Error),
23    #[error(transparent)]
24    Base64(#[from] base64::DecodeError),
25    #[error("invalid signer public key: {0}")]
26    InvalidSignerPubkey(#[source] bitcoin::key::ParsePublicKeyError),
27    #[error("psbt error: {0}")]
28    Psbt(#[from] bitcoin::psbt::Error),
29}
30
31#[derive(Clone, Debug)]
32pub struct Info {
33    pub version: String,
34    pub signer_pubkey: PublicKey,
35}
36
37impl Info {
38    pub fn signer_xonly(&self) -> XOnlyPublicKey {
39        self.signer_pubkey.inner.x_only_public_key().0
40    }
41}
42
43#[derive(Clone, Debug)]
44pub struct SubmitTxResponse {
45    pub signed_ark_tx: Psbt,
46    pub signed_checkpoint_txs: Vec<Psbt>,
47}
48
49#[derive(Clone, Debug)]
50pub struct SubmitOnchainTxResponse {
51    pub signed_tx: Psbt,
52}
53
54#[derive(Clone, Debug)]
55pub struct Intent {
56    pub proof: String,
57    pub message: String,
58}
59
60#[derive(Clone, Debug)]
61pub struct TxTreeNode {
62    pub txid: String,
63    pub tx: String,
64    pub children: std::collections::BTreeMap<u32, String>,
65}
66
67#[derive(Clone, Debug)]
68pub struct IntrospectorClient {
69    base_url: String,
70    http: reqwest::Client,
71}
72
73impl IntrospectorClient {
74    pub fn new(base_url: impl Into<String>) -> Self {
75        Self {
76            base_url: base_url.into().trim_end_matches('/').to_owned(),
77            http: reqwest::Client::builder()
78                .timeout(DEFAULT_HTTP_TIMEOUT)
79                .build()
80                .expect("building reqwest client with default timeout"),
81        }
82    }
83
84    pub fn with_http_client(base_url: impl Into<String>, http: reqwest::Client) -> Self {
85        Self {
86            base_url: base_url.into().trim_end_matches('/').to_owned(),
87            http,
88        }
89    }
90
91    pub async fn get_info(&self) -> Result<Info, Error> {
92        let response: GetInfoResponse = send_json(
93            self.http
94                .get(format!("{}/v1/info", self.base_url))
95                .send()
96                .await?,
97        )
98        .await?;
99
100        Ok(Info {
101            version: response.version,
102            signer_pubkey: response
103                .signer_pubkey
104                .parse()
105                .map_err(Error::InvalidSignerPubkey)?,
106        })
107    }
108
109    pub async fn submit_tx(
110        &self,
111        ark_tx: &Psbt,
112        checkpoint_txs: &[Psbt],
113    ) -> Result<SubmitTxResponse, Error> {
114        let response: SubmitTxResponseWire = send_json(
115            self.http
116                .post(format!("{}/v1/tx", self.base_url))
117                .json(&SubmitTxRequest {
118                    ark_tx: encode_psbt(ark_tx),
119                    checkpoint_txs: checkpoint_txs.iter().map(encode_psbt).collect(),
120                })
121                .send()
122                .await?,
123        )
124        .await?;
125
126        Ok(SubmitTxResponse {
127            signed_ark_tx: decode_psbt(&response.signed_ark_tx)?,
128            signed_checkpoint_txs: response
129                .signed_checkpoint_txs
130                .iter()
131                .map(|tx| decode_psbt(tx))
132                .collect::<Result<Vec<_>, _>>()?,
133        })
134    }
135
136    pub async fn submit_intent(&self, intent: &Intent) -> Result<String, Error> {
137        let response: SubmitIntentResponse = send_json(
138            self.http
139                .post(format!("{}/v1/intent", self.base_url))
140                .json(&SubmitIntentRequest {
141                    intent: IntentWire {
142                        proof: intent.proof.clone(),
143                        message: intent.message.clone(),
144                    },
145                })
146                .send()
147                .await?,
148        )
149        .await?;
150
151        Ok(response.signed_proof)
152    }
153
154    pub async fn submit_finalization(
155        &self,
156        signed_intent: &Intent,
157        forfeits: &[String],
158        connector_tree: &[TxTreeNode],
159        commitment_tx: &str,
160    ) -> Result<SubmitFinalizationResponse, Error> {
161        let response: SubmitFinalizationResponse = send_json(
162            self.http
163                .post(format!("{}/v1/finalization", self.base_url))
164                .json(&SubmitFinalizationRequest {
165                    signed_intent: IntentWire {
166                        proof: signed_intent.proof.clone(),
167                        message: signed_intent.message.clone(),
168                    },
169                    forfeits: forfeits.to_vec(),
170                    connector_tree: connector_tree
171                        .iter()
172                        .map(|node| TxTreeNodeWire {
173                            txid: node.txid.clone(),
174                            tx: node.tx.clone(),
175                            children: node.children.clone(),
176                        })
177                        .collect(),
178                    commitment_tx: commitment_tx.to_owned(),
179                })
180                .send()
181                .await?,
182        )
183        .await?;
184
185        Ok(response)
186    }
187
188    pub async fn submit_onchain_tx(&self, tx: &Psbt) -> Result<SubmitOnchainTxResponse, Error> {
189        let response: SubmitOnchainTxResponseWire = send_json(
190            self.http
191                .post(format!("{}/v1/onchain-tx", self.base_url))
192                .json(&SubmitOnchainTxRequest {
193                    tx: encode_psbt(tx),
194                })
195                .send()
196                .await?,
197        )
198        .await?;
199
200        Ok(SubmitOnchainTxResponse {
201            signed_tx: decode_psbt(&response.signed_tx)?,
202        })
203    }
204}
205
206async fn send_json<T: serde::de::DeserializeOwned>(
207    response: reqwest::Response,
208) -> Result<T, Error> {
209    let status = response.status();
210    let body = response.text().await?;
211
212    if !status.is_success() {
213        return Err(Error::HttpStatus { status, body });
214    }
215
216    Ok(serde_json::from_str(&body)?)
217}
218
219fn base64_engine() -> base64::engine::GeneralPurpose {
220    base64::engine::GeneralPurpose::new(
221        &base64::alphabet::STANDARD,
222        base64::engine::GeneralPurposeConfig::new(),
223    )
224}
225
226fn encode_psbt(psbt: &Psbt) -> String {
227    base64_engine().encode(psbt.serialize())
228}
229
230fn decode_psbt(psbt: &str) -> Result<Psbt, Error> {
231    let bytes = base64_engine().decode(psbt)?;
232    Ok(Psbt::deserialize(&bytes)?)
233}
234
235#[derive(Deserialize)]
236#[serde(rename_all = "camelCase")]
237struct GetInfoResponse {
238    version: String,
239    signer_pubkey: String,
240}
241
242#[derive(Serialize)]
243#[serde(rename_all = "camelCase")]
244struct SubmitTxRequest {
245    ark_tx: String,
246    checkpoint_txs: Vec<String>,
247}
248
249#[derive(Deserialize)]
250#[serde(rename_all = "camelCase")]
251struct SubmitTxResponseWire {
252    signed_ark_tx: String,
253    signed_checkpoint_txs: Vec<String>,
254}
255
256#[derive(Serialize)]
257#[serde(rename_all = "camelCase")]
258struct SubmitIntentRequest {
259    intent: IntentWire,
260}
261
262#[derive(Serialize)]
263#[serde(rename_all = "camelCase")]
264struct IntentWire {
265    proof: String,
266    message: String,
267}
268
269#[derive(Deserialize)]
270#[serde(rename_all = "camelCase")]
271struct SubmitIntentResponse {
272    signed_proof: String,
273}
274
275#[derive(Serialize)]
276#[serde(rename_all = "camelCase")]
277struct SubmitFinalizationRequest {
278    signed_intent: IntentWire,
279    forfeits: Vec<String>,
280    connector_tree: Vec<TxTreeNodeWire>,
281    commitment_tx: String,
282}
283
284#[derive(Serialize)]
285#[serde(rename_all = "camelCase")]
286struct TxTreeNodeWire {
287    txid: String,
288    tx: String,
289    children: std::collections::BTreeMap<u32, String>,
290}
291
292#[derive(Clone, Debug, Deserialize)]
293#[serde(rename_all = "camelCase")]
294pub struct SubmitFinalizationResponse {
295    pub signed_forfeits: Vec<String>,
296    pub signed_commitment_tx: String,
297}
298
299#[derive(Serialize)]
300#[serde(rename_all = "camelCase")]
301struct SubmitOnchainTxRequest {
302    tx: String,
303}
304
305#[derive(Deserialize)]
306#[serde(rename_all = "camelCase")]
307struct SubmitOnchainTxResponseWire {
308    signed_tx: String,
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[tokio::test]
316    #[ignore]
317    async fn get_info() {
318        let client = IntrospectorClient::new("http://localhost:7073");
319        let _info = client.get_info().await.unwrap();
320    }
321}