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