spark_rust/wallet/handlers/cooperative_exit.rs
1use crate::with_handler_lock;
2use crate::{
3 constants::spark::{DEFAULT_COOPERATIVE_EXIT_EXPIRY, DEFAULT_WITHDRAWAL_AMOUNT},
4 error::{IoError, SparkSdkError, ValidationError},
5 signer::traits::{derivation_path::SparkKeyType, SparkSigner},
6 wallet::{
7 internal_handlers::traits::{
8 cooperative_exit::CooperativeExitInternalHandlers,
9 leaves::LeavesInternalHandlers,
10 ssp::SspInternalHandlers,
11 transfer::{LeafKeyTweak, TransferInternalHandlers},
12 },
13 leaf_manager::SparkNodeStatus,
14 utils::transaction::create_connector_refund_tx,
15 },
16 SparkSdk,
17};
18use bitcoin::{secp256k1::PublicKey, Address, OutPoint, Transaction, Txid};
19use serde::{Deserialize, Serialize};
20use uuid::Uuid;
21
22// An opaque ID representing a cooperative exit request.
23#[derive(Serialize, Deserialize, Debug, Clone)]
24pub struct CoopExitRequestId(pub String);
25
26#[derive(Serialize, Deserialize, Debug, Clone)]
27pub struct CoopExitResponse {
28 pub request_id: CoopExitRequestId,
29 pub exit_txid: Txid,
30}
31
32#[derive(Serialize, Deserialize, Debug, Clone)]
33pub enum CoopExitStatus {
34 Pending,
35 Completed,
36}
37
38/// Input for requesting a cooperative exit
39pub struct RequestCoopExitInput {
40 /// The leaf external IDs to exit
41 pub leaf_external_ids: Uuid,
42 /// The withdrawal address
43 pub withdrawal_address: Address,
44}
45
46/// Input for completing a cooperative exit
47#[derive(Serialize, Deserialize)]
48pub struct CompleteCoopExitInput {
49 /// The user outbound transfer external ID
50 pub user_outbound_transfer_external_id: String,
51 /// The cooperative exit request ID
52 pub coop_exit_request_id: CoopExitRequestId,
53}
54
55/// Cooperative exit request response
56#[derive(Serialize, Deserialize, Debug, Clone)]
57pub struct CoopExitRequest {
58 /// The ID of the request
59 pub id: CoopExitRequestId,
60 /// The raw connector transaction. Only present if the request is pending.
61 pub raw_connector_transaction: Option<Transaction>,
62 /// The status of the request
63 pub status: CoopExitStatus,
64 /// The timestamp of the request
65 pub created_at: u64,
66 /// The timestamp of the last update
67 pub updated_at: u64,
68}
69
70/// Represents a signing nonce with binding and hiding keys
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct SigningNonce {
73 /// The binding key
74 pub binding: Vec<u8>,
75 /// The hiding key
76 pub hiding: Vec<u8>,
77}
78
79/// Represents a signing commitment with binding and hiding public keys
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct SigningCommitment {
82 /// The binding public key
83 pub binding: Vec<u8>,
84 /// The hiding public key
85 pub hiding: Vec<u8>,
86}
87
88/// CoopExitService implementation for Rust
89/// This is a 1-to-1 port of the TypeScript CoopExitService class
90impl<S: SparkSigner + Send + Sync + Clone + 'static> SparkSdk<S> {
91 /// Initiates a withdrawal to move funds from the Spark network to an on-chain Bitcoin address.
92 ///
93 /// This function allows you to exit the Spark network by withdrawing funds back to the Bitcoin
94 /// blockchain through a cooperative process with the Spark Service Provider (SSP). The SSP
95 /// facilitates the on-chain transaction and charges a service fee for this operation.
96 ///
97 /// # Arguments
98 ///
99 /// * `onchain_address` - The Bitcoin address where the funds should be sent
100 /// * `target_amount_sats` - Optional amount in satoshis to withdraw. If not specified,
101 /// attempts to withdraw all available funds in your wallet
102 ///
103 /// # Returns
104 ///
105 /// * `Ok(CoopExitResponse)` - Contains the cooperative exit request ID and exit transaction ID
106 /// * `Err(SparkSdkError)` - If the withdrawal request failed
107 ///
108 /// # Example
109 ///
110 /// ```
111 /// # use spark_rust::SparkSdk;
112 /// # use bitcoin::Address;
113 /// # use std::str::FromStr;
114 /// # async fn example(sdk: SparkSdk) -> Result<(), Box<dyn std::error::Error>> {
115 /// // Create a Bitcoin address to receive the funds
116 /// let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")?;
117 ///
118 /// // Withdraw all available funds
119 /// let response = sdk.withdraw(&address, None).await?;
120 /// println!("Withdrawal initiated with request ID: {:?}", response.request_id);
121 /// println!("Exit transaction ID: {}", response.exit_txid);
122 ///
123 /// // Or withdraw a specific amount (e.g., 50,000 satoshis)
124 /// // let response = sdk.withdraw(&address, Some(50_000)).await?;
125 /// # Ok(())
126 /// # }
127 /// ```
128 ///
129 /// # Note
130 ///
131 /// Withdrawals incur a service fee charged by the SSP. You can estimate this fee before
132 /// initiating a withdrawal using the `get_cooperative_exit_fee_estimate` method.
133 #[cfg_attr(feature = "telemetry", tracing::instrument(skip_all))]
134 pub async fn withdraw(
135 &self,
136 onchain_address: &Address,
137 target_amount_sats: Option<u64>,
138 ) -> Result<CoopExitResponse, SparkSdkError> {
139 // Then perform the cooperative exit
140 with_handler_lock!(self, async {
141 self.cooperative_exit(onchain_address, target_amount_sats)
142 .await
143 })
144 .await
145 }
146
147 /// Performs a cooperative exit operation.
148 ///
149 /// This method allows a user to exit the Spark network cooperatively with the connector.
150 /// It signs refund transactions for the leaves being exited and sends them to the connector.
151 ///
152 /// # Arguments
153 ///
154 /// * `onchain_address` - The Bitcoin address to withdraw funds to
155 /// * `target_amount_sats` - Optional target amount in satoshis to withdraw
156 ///
157 /// # Returns
158 ///
159 /// * `Ok(String)` - The ID of the completed cooperative exit request
160 /// * `Err(SparkSdkError)` - If there was an error during the cooperative exit process
161 #[cfg_attr(feature = "telemetry", tracing::instrument(skip_all))]
162 async fn cooperative_exit(
163 &self,
164 onchain_address: &Address,
165 target_amount_sats: Option<u64>,
166 ) -> Result<CoopExitResponse, SparkSdkError> {
167 if let Some(amount) = target_amount_sats {
168 if amount < DEFAULT_WITHDRAWAL_AMOUNT {
169 return Err(SparkSdkError::from(ValidationError::InvalidInput {
170 field: "Target amount is less than the minimum withdrawal amount".to_string(),
171 }));
172 }
173 } else {
174 let leaves = self
175 .leaf_manager
176 .get_available_bitcoin_leaves(None, SparkNodeStatus::Available);
177
178 let amount = leaves
179 .iter()
180 .map(|leaf| leaf.get_tree_node().unwrap().value)
181 .sum::<u64>();
182
183 if amount < DEFAULT_WITHDRAWAL_AMOUNT {
184 return Err(SparkSdkError::from(ValidationError::InvalidInput {
185 field: "Target amount is less than the minimum withdrawal amount".to_string(),
186 }));
187 }
188 }
189
190 let leaves_to_send = match target_amount_sats {
191 Some(amount) => {
192 // Get leaves for the target amount
193 self.prepare_leaves_for_amount(amount).await?.leaves
194 }
195 None => {
196 // Get all available BTC leaves
197 self.leaf_manager
198 .get_available_bitcoin_leaves(None, SparkNodeStatus::CooperativeExit)
199 }
200 };
201
202 // return early if no leaves found
203 if leaves_to_send.is_empty() {
204 return Err(SparkSdkError::from(ValidationError::InvalidInput {
205 field: "No available leaves found".to_string(),
206 }));
207 }
208
209 // generate leaf key tweaks
210 let mut leaf_key_tweaks = Vec::new();
211 let network = self.config.spark_config.network.to_bitcoin_network();
212 for leaf in &leaves_to_send {
213 // generate signing public key from leaf ID
214 let old_signing_private_key = self.signer.expose_leaf_secret_key_for_transfer(
215 leaf.get_id().clone(),
216 SparkKeyType::BaseSigning,
217 0,
218 network,
219 )?;
220 // generate new signing public key
221 let new_signing_public_key = self.signer.new_ephemeral_keypair()?;
222
223 leaf_key_tweaks.push(LeafKeyTweak {
224 leaf: leaf.get_tree_node()?,
225 old_signing_private_key,
226 new_signing_public_key,
227 });
228 }
229
230 // initiate the coop exit with SSP
231 let leaf_ids = leaves_to_send
232 .iter()
233 .map(|leaf| leaf.get_id().clone())
234 .collect();
235
236 let initiate_response = self
237 .initiate_cooperative_exit_with_ssp(leaf_ids, onchain_address)
238 .await?;
239
240 // TODO: make a function
241 let connector_txid = initiate_response.connector_tx.compute_txid();
242 let coop_exit_txid = initiate_response.connector_tx.input[0].previous_output.txid;
243
244 // extract connector outputs
245 let mut connector_outputs = Vec::new();
246 // Only include outputs up to the second-to-last one (len - 1)
247 // This is because the last output is typically the change output
248 let output_count = initiate_response.connector_tx.output.len();
249 for i in 0..output_count.saturating_sub(1) {
250 connector_outputs.push(OutPoint {
251 txid: connector_txid,
252 vout: i as u32,
253 });
254 }
255
256 // get connector refund signatures
257 let coop_exit_txid_bytes = hex::decode(coop_exit_txid.to_string())
258 .map_err(|err| SparkSdkError::from(IoError::Decoding(err)))?;
259
260 let expiry = std::time::SystemTime::now()
261 .duration_since(std::time::UNIX_EPOCH)
262 .unwrap()
263 .as_secs()
264 + DEFAULT_COOPERATIVE_EXIT_EXPIRY;
265 let (transfer, _) = self
266 .get_connector_refund_signatures(
267 &leaf_key_tweaks,
268 &coop_exit_txid_bytes,
269 &connector_outputs,
270 &self.config.spark_config.ssp_identity_public_key,
271 expiry,
272 )
273 .await?;
274
275 // complete the cooperative exit using the internal handler
276 let complete_response = match self
277 .complete_cooperative_exit_with_ssp(transfer.id.clone(), initiate_response.request_id)
278 .await
279 {
280 Ok(response) => CoopExitResponse {
281 request_id: response,
282 exit_txid: coop_exit_txid,
283 },
284 Err(status) => {
285 // If the request fails, cancel the transfer and propagate the error
286 if let Err(cancel_err) = self.cancel_send_transfer(transfer.id.clone()).await {
287 // Log the cancellation error but return the original error
288 #[cfg(feature = "telemetry")]
289 tracing::error!(
290 "Failed to cancel transfer after complete_cooperative_exit error: {}",
291 cancel_err
292 );
293 }
294 return Err(status);
295 }
296 };
297
298 Ok(complete_response)
299 }
300
301 /// Creates a connector refund transaction.
302 ///
303 /// This method is equivalent to the TypeScript `createConnectorRefundTransaction` method.
304 /// It creates a refund transaction for a connector output.
305 ///
306 /// # Arguments
307 ///
308 /// * `sequence` - The sequence number for the transaction
309 /// * `node_outpoint` - The outpoint of the node transaction
310 /// * `connector_output` - The connector output
311 /// * `amount_sats` - The amount in satoshis
312 /// * `receiver_pubkey` - The receiver public key
313 ///
314 /// # Returns
315 ///
316 /// The created refund transaction
317 pub fn create_connector_refund_transaction(
318 &self,
319 sequence: u32,
320 node_outpoint: OutPoint,
321 connector_output: OutPoint,
322 amount_sats: u64,
323 receiver_pubkey: &PublicKey,
324 ) -> Transaction {
325 // Use the utility function from the codebase
326 create_connector_refund_tx(
327 sequence,
328 node_outpoint,
329 connector_output,
330 amount_sats,
331 receiver_pubkey,
332 self.config.spark_config.network.to_bitcoin_network(),
333 )
334 }
335}