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}