Skip to main content

ark_client/
batch.rs

1use crate::error::ErrorContext as _;
2use crate::swap_storage::SwapStorage;
3use crate::utils::sleep;
4use crate::utils::timeout_op;
5use crate::wallet::OnchainWallet;
6use crate::Blockchain;
7use crate::Client;
8use crate::Error;
9use ark_core::batch;
10use ark_core::batch::aggregate_nonces;
11use ark_core::batch::complete_delegate_forfeit_txs;
12use ark_core::batch::create_and_sign_forfeit_txs;
13use ark_core::batch::create_asset_preservation_packet;
14use ark_core::batch::generate_nonce_tree;
15use ark_core::batch::sign_batch_tree_tx;
16use ark_core::batch::sign_commitment_psbt;
17use ark_core::batch::Delegate;
18use ark_core::batch::NonceKps;
19use ark_core::contract::SpendPathKind;
20use ark_core::intent;
21use ark_core::script::extract_checksig_pubkeys;
22use ark_core::server;
23use ark_core::server::BatchTreeEventType;
24use ark_core::server::PartialSigTree;
25use ark_core::server::StreamEvent;
26use ark_core::ArkAddress;
27use ark_core::ArkNote;
28use ark_core::ExplorerUtxo;
29use ark_core::TxGraph;
30use backon::ExponentialBuilder;
31use backon::Retryable;
32use bitcoin::hashes::sha256;
33use bitcoin::hashes::Hash;
34use bitcoin::hex::DisplayHex;
35use bitcoin::key::Keypair;
36use bitcoin::key::Secp256k1;
37use bitcoin::psbt;
38use bitcoin::secp256k1;
39use bitcoin::secp256k1::schnorr;
40use bitcoin::secp256k1::PublicKey;
41use bitcoin::Address;
42use bitcoin::Amount;
43use bitcoin::OutPoint;
44use bitcoin::Psbt;
45use bitcoin::TxOut;
46use bitcoin::Txid;
47use bitcoin::XOnlyPublicKey;
48use futures::StreamExt;
49use rand::CryptoRng;
50use rand::Rng;
51use std::collections::HashMap;
52use std::collections::HashSet;
53
54impl<B, W, S> Client<B, W, S>
55where
56    B: Blockchain,
57    W: OnchainWallet,
58    S: SwapStorage + 'static,
59{
60    /// Settle _all_ prior VTXOs and boarding outputs into the next batch, generating new confirmed
61    /// VTXOs.
62    ///
63    /// Most callers should prefer [`Self::settle`], which only renews VTXOs that have actually
64    /// expired. Settling unexpired VTXOs is rarely necessary.
65    pub async fn settle_all<R>(&self, rng: &mut R) -> Result<Option<Txid>, Error>
66    where
67        R: Rng + CryptoRng + Clone,
68    {
69        self.settle_at(crate::utils::unix_now()?, rng).await
70    }
71
72    pub(crate) async fn settle_at<R>(&self, now: i64, rng: &mut R) -> Result<Option<Txid>, Error>
73    where
74        R: Rng + CryptoRng + Clone,
75    {
76        let server_info = self.server_info().await?;
77
78        // Get off-chain address and send all funds to this address, no change output 🦄
79        let (to_address, _) = self.get_offchain_address_with_server_info(&server_info)?;
80
81        let (boarding_inputs, vtxo_inputs, total_amount) = self
82            .fetch_commitment_transaction_inputs(&server_info, now)
83            .await?;
84
85        tracing::debug!(
86            offchain_adress = %to_address.encode(),
87            ?boarding_inputs,
88            ?vtxo_inputs,
89            "Attempting to settle outputs"
90        );
91
92        if boarding_inputs.is_empty() && vtxo_inputs.is_empty() {
93            tracing::debug!("No inputs to board with");
94            return Ok(None);
95        }
96
97        let join_next_batch = || async {
98            self.join_next_batch(
99                &mut rng.clone(),
100                &server_info,
101                boarding_inputs.clone(),
102                vtxo_inputs.clone(),
103                BatchOutputType::Board {
104                    to_address,
105                    to_amount: total_amount,
106                },
107            )
108            .await
109        };
110
111        // Joining a batch can fail depending on the timing, so we try a few times.
112        let commitment_txid = join_next_batch
113            .retry(ExponentialBuilder::default().with_max_times(0))
114            .sleep(sleep)
115            .when(|err| !err.is_server_info_changed())
116            .notify(|err: &Error, dur: std::time::Duration| {
117                tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}",);
118            })
119            .await
120            .context("Failed to join batch")?;
121
122        tracing::info!(%commitment_txid, "Settlement success");
123
124        Ok(Some(commitment_txid))
125    }
126
127    /// Settle prior VTXOs that have expired or are recoverable, together with all available
128    /// boarding outputs, into the next batch, generating new confirmed VTXOs.
129    ///
130    /// Healthy (unexpired) VTXOs are left untouched. This is the path callers typically want when
131    /// periodically renewing their wallet: healthy VTXOs do not need to be touched, and including
132    /// them would only inflate batch fees. Boarding outputs are always included because callers
133    /// generally want freshly funded coins to enter the Ark.
134    ///
135    /// NOTE: sub-dust recoverable VTXOs can only be rescued when their combined value exceeds the
136    /// server's dust threshold; otherwise the batch protocol rejects the settlement with a
137    /// `cannot settle into sub-dust VTXO` error. When the wallet holds isolated sub-dust amounts,
138    /// fall back to [`Self::settle_all`], which can roll them in alongside healthy VTXOs that
139    /// act as carrier value.
140    pub async fn settle<R>(&self, rng: &mut R) -> Result<Option<Txid>, Error>
141    where
142        R: Rng + CryptoRng + Clone,
143    {
144        let server_info = self.server_info().await?;
145
146        let vtxo_list = self.list_vtxos_with_server_info(&server_info).await?;
147        let vtxo_outpoints: Vec<OutPoint> = vtxo_list
148            .recoverable()
149            .map(|entry| entry.vtxo().outpoint)
150            .collect();
151
152        let (boarding_inputs, _, _) = self
153            .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?)
154            .await?;
155        let boarding_outpoints: Vec<OutPoint> =
156            boarding_inputs.iter().map(|i| i.outpoint()).collect();
157
158        if vtxo_outpoints.is_empty() && boarding_outpoints.is_empty() {
159            tracing::debug!("No expired/recoverable VTXOs or boarding outputs to settle");
160            return Ok(None);
161        }
162
163        tracing::debug!(
164            num_vtxos = vtxo_outpoints.len(),
165            num_boarding = boarding_outpoints.len(),
166            "Attempting to settle expired/recoverable VTXOs and boarding outputs"
167        );
168
169        self.settle_vtxos_with_server_info(rng, &server_info, &vtxo_outpoints, &boarding_outpoints)
170            .await
171    }
172
173    /// Settle _all_ prior VTXOs, boarding outputs, and the provided ArkNotes into the next batch.
174    ///
175    /// ArkNotes are bearer tokens that can be redeemed by revealing their preimage.
176    /// This method combines them with regular VTXOs and boarding outputs into a single
177    /// settlement transaction.
178    pub async fn settle_with_notes<R>(
179        &self,
180        rng: &mut R,
181        notes: Vec<ArkNote>,
182    ) -> Result<Option<Txid>, Error>
183    where
184        R: Rng + CryptoRng + Clone,
185    {
186        let server_info = self.server_info().await?;
187
188        let (to_address, _) = self.get_offchain_address_with_server_info(&server_info)?;
189
190        let (boarding_inputs, vtxo_inputs, mut total_amount) = self
191            .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?)
192            .await?;
193
194        // Convert arknotes to intent inputs and add their value to total
195        let note_inputs: Vec<intent::Input> = notes
196            .iter()
197            .map(|note| {
198                total_amount += note.value();
199                note.to_intent_input()
200            })
201            .collect::<Result<Vec<_>, _>>()?;
202
203        // Combine VTXO inputs with note inputs
204        let all_vtxo_inputs: Vec<intent::Input> =
205            vtxo_inputs.into_iter().chain(note_inputs).collect();
206
207        tracing::debug!(
208            offchain_address = %to_address.encode(),
209            ?boarding_inputs,
210            num_vtxo_inputs = all_vtxo_inputs.len(),
211            num_notes = notes.len(),
212            %total_amount,
213            "Attempting to settle outputs with notes"
214        );
215
216        if boarding_inputs.is_empty() && all_vtxo_inputs.is_empty() {
217            tracing::debug!("No inputs to settle");
218            return Ok(None);
219        }
220
221        let join_next_batch = || async {
222            self.join_next_batch(
223                &mut rng.clone(),
224                &server_info,
225                boarding_inputs.clone(),
226                all_vtxo_inputs.clone(),
227                BatchOutputType::Board {
228                    to_address,
229                    to_amount: total_amount,
230                },
231            )
232            .await
233        };
234
235        let commitment_txid = join_next_batch
236            .retry(ExponentialBuilder::default().with_max_times(0))
237            .sleep(sleep)
238            .when(|err| !err.is_server_info_changed())
239            .notify(|err: &Error, dur: std::time::Duration| {
240                tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}");
241            })
242            .await
243            .context("Failed to join batch")?;
244
245        tracing::info!(%commitment_txid, num_notes = notes.len(), "Settlement with notes success");
246
247        Ok(Some(commitment_txid))
248    }
249
250    /// Settle specific VTXOs and boarding outputs by outpoint into the next batch, generating new
251    /// confirmed VTXOs.
252    ///
253    /// Unlike [`Self::settle`], this method allows the caller to specify exactly which VTXOs and
254    /// boarding outputs to settle by providing their outpoints.
255    pub async fn settle_vtxos<R>(
256        &self,
257        rng: &mut R,
258        vtxo_outpoints: &[OutPoint],
259        boarding_outpoints: &[OutPoint],
260    ) -> Result<Option<Txid>, Error>
261    where
262        R: Rng + CryptoRng + Clone,
263    {
264        let server_info = self.server_info().await?;
265        self.settle_vtxos_with_server_info(rng, &server_info, vtxo_outpoints, boarding_outpoints)
266            .await
267    }
268
269    pub(crate) async fn settle_vtxos_with_server_info<R>(
270        &self,
271        rng: &mut R,
272        server_info: &server::Info,
273        vtxo_outpoints: &[OutPoint],
274        boarding_outpoints: &[OutPoint],
275    ) -> Result<Option<Txid>, Error>
276    where
277        R: Rng + CryptoRng + Clone,
278    {
279        // Get off-chain address and send all funds to this address, no change output.
280        let (to_address, _) = self.get_offchain_address_with_server_info(server_info)?;
281
282        let (all_boarding_inputs, all_vtxo_inputs, _) = self
283            .fetch_commitment_transaction_inputs(server_info, crate::utils::unix_now()?)
284            .await?;
285
286        // Filter boarding inputs to only those specified.
287        let boarding_inputs: Vec<_> = all_boarding_inputs
288            .into_iter()
289            .filter(|input| boarding_outpoints.contains(&input.outpoint()))
290            .collect();
291
292        // Filter VTXO inputs to only those specified.
293        let vtxo_inputs: Vec<_> = all_vtxo_inputs
294            .into_iter()
295            .filter(|input| vtxo_outpoints.contains(&input.outpoint()))
296            .collect();
297
298        // Recalculate total amount from filtered inputs.
299        let total_amount = boarding_inputs
300            .iter()
301            .map(|i| i.amount())
302            .chain(vtxo_inputs.iter().map(|i| i.amount()))
303            .fold(Amount::ZERO, |acc, a| acc + a);
304
305        tracing::debug!(
306            offchain_address = %to_address.encode(),
307            ?boarding_inputs,
308            ?vtxo_inputs,
309            %total_amount,
310            "Attempting to settle specific outputs"
311        );
312
313        if boarding_inputs.is_empty() && vtxo_inputs.is_empty() {
314            tracing::debug!("No matching inputs to settle");
315            return Ok(None);
316        }
317
318        let join_next_batch = || async {
319            self.join_next_batch(
320                &mut rng.clone(),
321                server_info,
322                boarding_inputs.clone(),
323                vtxo_inputs.clone(),
324                BatchOutputType::Board {
325                    to_address,
326                    to_amount: total_amount,
327                },
328            )
329            .await
330        };
331
332        // Joining a batch can fail depending on the timing, so we try a few times.
333        let commitment_txid = join_next_batch
334            .retry(ExponentialBuilder::default().with_max_times(0))
335            .sleep(sleep)
336            .when(|err| !err.is_server_info_changed())
337            .notify(|err: &Error, dur: std::time::Duration| {
338                tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}",);
339            })
340            .await
341            .context("Failed to join batch")?;
342
343        tracing::info!(%commitment_txid, "Settlement of specific VTXOs success");
344
345        Ok(Some(commitment_txid))
346    }
347
348    /// Settle _some_ prior VTXOs and boarding outputs into the next batch, generating UTXOs as
349    /// outputs to a new commitment transaction.
350    pub async fn collaborative_redeem<R>(
351        &self,
352        rng: &mut R,
353        to_address: Address,
354        to_amount: Amount,
355    ) -> Result<Txid, Error>
356    where
357        R: Rng + CryptoRng + Clone,
358    {
359        let server_info = self.server_info().await?;
360
361        let (change_address, _) = self.get_offchain_address_with_server_info(&server_info)?;
362
363        let (boarding_inputs, vtxo_inputs, total_amount) = self
364            .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?)
365            .await?;
366
367        let onchain_fee = self.eval_onchain_output_fee(ark_fees::Output {
368            amount: to_amount.to_sat(),
369            script: to_address.script_pubkey().to_string(),
370        })?;
371
372        // Fee comes out of change, not the send amount.
373        let change_amount = total_amount
374            .checked_sub(to_amount)
375            .and_then(|a| a.checked_sub(onchain_fee))
376            .ok_or_else(|| {
377                Error::coin_select(format!(
378                    "insufficient balance: {total_amount} < {to_amount} (send) + {onchain_fee} (fee)"
379                ))
380            })?;
381
382        tracing::info!(
383            %to_address,
384            send_amount = %to_amount,
385            fee = %onchain_fee,
386            change_address = %change_address.encode(),
387            %change_amount,
388            ?boarding_inputs,
389            "Attempting to collaboratively redeem outputs"
390        );
391
392        let join_next_batch = || async {
393            self.join_next_batch(
394                &mut rng.clone(),
395                &server_info,
396                boarding_inputs.clone(),
397                vtxo_inputs.clone(),
398                BatchOutputType::OffBoard {
399                    to_address: to_address.clone(),
400                    to_amount,
401                    change_address,
402                    change_amount,
403                },
404            )
405            .await
406        };
407
408        // Joining a batch can fail depending on the timing, so we try a few times.
409        let commitment_txid = join_next_batch
410            .retry(ExponentialBuilder::default().with_max_times(3))
411            .sleep(sleep)
412            .when(|err| !err.is_server_info_changed())
413            .notify(|err: &Error, dur: std::time::Duration| {
414                tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}");
415            })
416            .await
417            .context("Failed to join batch")?;
418
419        tracing::info!(%commitment_txid, "Collaborative redeem success");
420
421        Ok(commitment_txid)
422    }
423
424    /// Settle a selection of VTXOs into the next batch, generating UTXOs as
425    /// outputs to a new commitment transaction.
426    pub async fn collaborative_redeem_vtxo_selection<R>(
427        &self,
428        rng: &mut R,
429        input_vtxos: impl Iterator<Item = OutPoint> + Clone,
430        to_address: Address,
431        to_amount: Amount,
432    ) -> Result<Txid, Error>
433    where
434        R: Rng + CryptoRng + Clone,
435    {
436        let server_info = self.server_info().await?;
437
438        let (change_address, _) = self.get_offchain_address_with_server_info(&server_info)?;
439
440        let vtxo_inputs = self
441            .selected_batch_settleable_vtxo_inputs(&server_info, input_vtxos)
442            .await?;
443
444        if vtxo_inputs.is_empty() {
445            return Err(Error::ad_hoc("no matching VTXO outpoints found"));
446        }
447
448        // Check that total amount is sufficient
449        let total_input_amount = vtxo_inputs
450            .iter()
451            .fold(Amount::ZERO, |acc, vtxo| acc + vtxo.amount());
452
453        let onchain_fee = self.eval_onchain_output_fee(ark_fees::Output {
454            amount: to_amount.to_sat(),
455            script: to_address.script_pubkey().to_string(),
456        })?;
457
458        // Fee comes out of change, not the send amount.
459        let change_amount = total_input_amount
460            .checked_sub(to_amount)
461            .and_then(|a| a.checked_sub(onchain_fee))
462            .ok_or_else(|| {
463                Error::coin_select(format!(
464                    "insufficient VTXO amount: {total_input_amount} < {to_amount} (send) + {onchain_fee} (fee)"
465                ))
466            })?;
467
468        tracing::info!(
469            %to_address,
470            send_amount = %to_amount,
471            fee = %onchain_fee,
472            change_address = %change_address.encode(),
473            %change_amount,
474            "Attempting to collaboratively redeem outputs"
475        );
476
477        let join_next_batch = || async {
478            self.join_next_batch(
479                &mut rng.clone(),
480                &server_info,
481                Vec::new(),
482                vtxo_inputs.clone(),
483                BatchOutputType::OffBoard {
484                    to_address: to_address.clone(),
485                    to_amount,
486                    change_address,
487                    change_amount,
488                },
489            )
490            .await
491        };
492
493        // Joining a batch can fail depending on the timing, so we try a few times.
494        let commitment_txid = join_next_batch
495            .retry(ExponentialBuilder::default().with_max_times(3))
496            .sleep(sleep)
497            .when(|err| !err.is_server_info_changed())
498            .notify(|err: &Error, dur: std::time::Duration| {
499                tracing::warn!("Retrying joining next batch after {dur:?}. Error: {err}");
500            })
501            .await
502            .context("Failed to join batch")?;
503
504        tracing::info!(%commitment_txid, "Collaborative redeem success");
505
506        Ok(commitment_txid)
507    }
508
509    pub(crate) async fn selected_batch_settleable_vtxo_inputs(
510        &self,
511        server_info: &server::Info,
512        input_vtxos: impl IntoIterator<Item = OutPoint>,
513    ) -> Result<Vec<intent::Input>, Error> {
514        let requested: HashSet<OutPoint> = input_vtxos.into_iter().collect();
515
516        let vtxo_list = self
517            .list_vtxos_with_server_info(server_info)
518            .await
519            .context("failed to get VTXO list")?;
520        let now = crate::utils::unix_now()?;
521
522        let matching_unspent = vtxo_list
523            .all_unspent()
524            .filter(|entry| requested.contains(&entry.vtxo().outpoint))
525            .collect::<Vec<_>>();
526
527        let settleable = vtxo_list
528            .batch_settleable_at(server_info, now)
529            .filter(|entry| requested.contains(&entry.vtxo().outpoint))
530            .collect::<Vec<_>>();
531        let settleable_outpoints = settleable
532            .iter()
533            .map(|entry| entry.vtxo().outpoint)
534            .collect::<HashSet<_>>();
535
536        let blocked = matching_unspent
537            .iter()
538            .filter(|entry| !settleable_outpoints.contains(&entry.vtxo().outpoint))
539            .map(|entry| entry.vtxo().outpoint.to_string())
540            .collect::<Vec<_>>();
541        if !blocked.is_empty() {
542            return Err(Error::ad_hoc(format!(
543                "selected VTXO outpoints are not batch-settleable because their signer cutoff has passed: {}",
544                blocked.join(", ")
545            )));
546        }
547
548        settleable
549            .into_iter()
550            .map(|entry| {
551                let spend_selection = entry.spend_selection(SpendPathKind::Forfeit)?;
552
553                Ok(intent::Input::new_with_spend_selection(
554                    entry.vtxo().outpoint,
555                    entry.exit_delay()?,
556                    TxOut {
557                        value: entry.vtxo().amount,
558                        script_pubkey: entry.script_pubkey(),
559                    },
560                    entry.tapscripts(),
561                    spend_selection,
562                    false,
563                    entry.vtxo().is_swept,
564                    entry.vtxo().assets.clone(),
565                ))
566            })
567            .collect::<Result<Vec<_>, Error>>()
568    }
569
570    /// Generate a delegate for settling VTXOs on behalf of the owner.
571    ///
572    /// The owner pre-signs the intent and forfeit transactions, allowing another party to complete
573    /// the settlement at a later time using the provided `delegate_cosigner_pk`.
574    ///
575    /// # Arguments
576    ///
577    /// * `delegate_cosigner_pk` - The cosigner public key that the delegate will use
578    /// * `select_recoverable_vtxos` - Whether to include recoverable VTXOs
579    ///
580    /// # Returns
581    ///
582    /// A [`Delegate`] struct containing all the pre-signed data needed for settlement.
583    pub async fn generate_delegate(
584        &self,
585        delegate_cosigner_pk: PublicKey,
586    ) -> Result<Delegate, Error> {
587        let server_info = self.server_info().await?;
588
589        // Get off-chain address and send all funds to this address.
590        let (to_address, _) = self.get_offchain_address_with_server_info(&server_info)?;
591
592        // Simply collect all VTXOs that can be settled.
593        let (_, vtxo_inputs, _) = self
594            .fetch_commitment_transaction_inputs(&server_info, crate::utils::unix_now()?)
595            .await?;
596
597        let total_amount = vtxo_inputs
598            .iter()
599            .fold(Amount::ZERO, |acc, v| acc + v.amount());
600
601        if vtxo_inputs.is_empty() {
602            return Err(Error::ad_hoc("no inputs to settle via delegate"));
603        }
604
605        let mut outputs = vec![intent::Output::Offchain(TxOut {
606            value: total_amount,
607            script_pubkey: to_address.to_p2tr_script_pubkey(),
608        })];
609
610        if let Some(packet) = create_asset_preservation_packet(&vtxo_inputs, &outputs)? {
611            outputs.push(intent::Output::AssetPacket(packet.to_txout()));
612        }
613
614        let delegate = batch::prepare_delegate_psbts(
615            vtxo_inputs,
616            outputs,
617            delegate_cosigner_pk,
618            &server_info.forfeit_address,
619            server_info.dust,
620        )?;
621
622        Ok(delegate)
623    }
624
625    /// Sign a set of delegate PSBTs, including the intent PSBT and the forfeit PSBTs.
626    pub fn sign_delegate_psbts(
627        &self,
628        intent_psbt: &mut Psbt,
629        forfeit_psbts: &mut [Psbt],
630    ) -> Result<(), Error> {
631        let sign_fn =
632            |input: &mut psbt::Input,
633             msg: secp256k1::Message|
634             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
635                match &input.witness_script {
636                    None => Err(ark_core::Error::ad_hoc(
637                        "Missing witness script for psbt::Input",
638                    )),
639                    Some(script) => {
640                        let mut res = vec![];
641                        let pks = extract_checksig_pubkeys(script);
642                        for pk in pks {
643                            if let Ok(keypair) = self.keypair_by_pk(&pk) {
644                                let sig = Secp256k1::new().sign_schnorr_no_aux_rand(&msg, &keypair);
645                                let pk = keypair.x_only_public_key().0;
646                                res.push((sig, pk));
647                            }
648                        }
649                        Ok(res)
650                    }
651                }
652            };
653
654        batch::sign_delegate_psbts(sign_fn, intent_psbt, forfeit_psbts)?;
655
656        Ok(())
657    }
658
659    /// Settle a delegate by completing the batch protocol using pre-signed data.
660    ///
661    /// This method allows Bob to settle Alice's VTXOs using the pre-signed intent and forfeit
662    /// transactions from the [`Delegate`] struct.
663    ///
664    /// # Arguments
665    ///
666    /// * `rng` - Random number generator for nonce generation
667    /// * `delegate` - The delegate struct containing pre-signed data
668    /// * `own_cosigner_kp` - Bob's cosigner keypair (must match the delegate_cosigner_pk)
669    ///
670    /// # Returns
671    ///
672    /// The commitment transaction ID if successful.
673    pub async fn settle_delegate<R>(
674        &self,
675        rng: &mut R,
676        delegate: Delegate,
677        own_cosigner_kp: Keypair,
678    ) -> Result<Txid, Error>
679    where
680        R: Rng + CryptoRng,
681    {
682        // Verify the cosigner key matches
683        if own_cosigner_kp.public_key() != delegate.delegate_cosigner_pk {
684            return Err(Error::ad_hoc(
685                "provided cosigner keypair does not match delegate_cosigner_pk",
686            ));
687        }
688
689        let server_info = self.server_info().await?;
690
691        // Register the pre-signed intent
692        let intent_id = timeout_op(
693            self.inner.timeout,
694            self.network_client()
695                .register_intent(delegate.intent.clone()),
696        )
697        .await
698        .context("failed to register delegated intent")??;
699
700        tracing::debug!(intent_id, "Registered delegated intent");
701
702        let network_client = self.network_client();
703
704        #[derive(Debug, PartialEq, Eq)]
705        enum Step {
706            Start,
707            BatchStarted,
708            BatchSigningStarted,
709            Finalized,
710        }
711
712        impl Step {
713            fn next(&self) -> Step {
714                match self {
715                    Step::Start => Step::BatchStarted,
716                    Step::BatchStarted => Step::BatchSigningStarted,
717                    Step::BatchSigningStarted => Step::Finalized,
718                    Step::Finalized => Step::Finalized,
719                }
720            }
721        }
722
723        let mut step = Step::Start;
724
725        let own_cosigner_kps = [own_cosigner_kp];
726        let own_cosigner_pks = own_cosigner_kps
727            .iter()
728            .map(|k| k.public_key())
729            .collect::<Vec<_>>();
730
731        let mut batch_id: Option<String> = None;
732
733        let vtxo_input_outpoints = delegate
734            .forfeit_psbts
735            .iter()
736            .map(|psbt| psbt.unsigned_tx.input[0].previous_output)
737            .collect::<Vec<_>>();
738
739        let topics = vtxo_input_outpoints
740            .iter()
741            .map(ToString::to_string)
742            .chain(
743                own_cosigner_pks
744                    .iter()
745                    .map(|pk| pk.serialize().to_lower_hex_string()),
746            )
747            .collect();
748
749        let mut stream = network_client.get_event_stream(topics).await?;
750
751        let (ark_forfeit_pk, _) = server_info.forfeit_pk.x_only_public_key();
752
753        let mut unsigned_commitment_tx = None;
754
755        let mut vtxo_batch_tree_graph_chunks = Some(Vec::new());
756        let mut vtxo_batch_tree_graph: Option<TxGraph> = None;
757
758        let mut connectors_graph_chunks = Some(Vec::new());
759        let mut batch_expiry = None;
760
761        let mut agg_nonce_pks = HashMap::new();
762
763        let mut our_nonce_trees: Option<HashMap<Keypair, NonceKps>> = None;
764
765        loop {
766            match timeout_op(self.inner.timeout, stream.next())
767                .await
768                .context("timed out waiting for batch event")?
769            {
770                Some(Ok(event)) => match event {
771                    StreamEvent::BatchStarted(e) => {
772                        if step != Step::Start {
773                            continue;
774                        }
775
776                        let hash = sha256::Hash::hash(intent_id.as_bytes());
777                        let hash = hash.as_byte_array().to_vec().to_lower_hex_string();
778
779                        if e.intent_id_hashes.iter().any(|h| h == &hash) {
780                            timeout_op(
781                                self.inner.timeout,
782                                self.network_client()
783                                    .confirm_registration(intent_id.clone()),
784                            )
785                            .await
786                            .context("failed to confirm intent registration")??;
787
788                            tracing::info!(batch_id = e.id, intent_id, "Intent ID found for batch");
789
790                            batch_id = Some(e.id);
791
792                            step = Step::BatchStarted;
793
794                            batch_expiry = Some(e.batch_expiry);
795                        } else {
796                            tracing::debug!(
797                                batch_id = e.id,
798                                intent_id,
799                                "Intent ID not found for batch"
800                            );
801                        }
802                    }
803                    StreamEvent::TreeTx(e) => {
804                        if step != Step::BatchStarted && step != Step::BatchSigningStarted {
805                            continue;
806                        }
807
808                        match e.batch_tree_event_type {
809                            BatchTreeEventType::Vtxo => {
810                                match &mut vtxo_batch_tree_graph_chunks {
811                                    Some(vtxo_batch_tree_graph_chunks) => {
812                                        tracing::debug!("Got new VTXO batch-tree graph chunk");
813
814                                        vtxo_batch_tree_graph_chunks.push(e.tx_graph_chunk)
815                                    }
816                                    None => {
817                                        return Err(Error::ark_server(
818                                            "received unexpected VTXO batch-tree graph chunk",
819                                        ));
820                                    }
821                                };
822                            }
823                            BatchTreeEventType::Connector => {
824                                match connectors_graph_chunks {
825                                    Some(ref mut connectors_graph_chunks) => {
826                                        tracing::debug!("Got new connectors graph chunk");
827
828                                        connectors_graph_chunks.push(e.tx_graph_chunk)
829                                    }
830                                    None => {
831                                        return Err(Error::ark_server(
832                                            "received unexpected connectors graph chunk",
833                                        ));
834                                    }
835                                };
836                            }
837                        }
838                    }
839                    StreamEvent::TreeSignature(e) => {
840                        if step != Step::BatchSigningStarted {
841                            continue;
842                        }
843
844                        match e.batch_tree_event_type {
845                            BatchTreeEventType::Vtxo => {
846                                match vtxo_batch_tree_graph {
847                                    Some(ref mut vtxo_batch_tree_graph) => {
848                                        vtxo_batch_tree_graph.apply(|graph| {
849                                            if graph.root().unsigned_tx.compute_txid() != e.txid {
850                                                Ok(true)
851                                            } else {
852                                                graph.set_signature(e.signature);
853
854                                                Ok(false)
855                                            }
856                                        })?;
857                                    }
858                                    None => {
859                                        return Err(Error::ark_server(
860                                            "received batch-tree signature without transaction graph",
861                                        ));
862                                    }
863                                };
864                            }
865                            BatchTreeEventType::Connector => {
866                                return Err(Error::ark_server(
867                                    "received batch-tree signature for connector tree",
868                                ));
869                            }
870                        }
871                    }
872                    StreamEvent::TreeSigningStarted(e) => {
873                        if step != Step::BatchStarted {
874                            continue;
875                        }
876
877                        let chunks = vtxo_batch_tree_graph_chunks.take().ok_or(Error::ark_server(
878                            "received batch-tree signing started event without VTXO batch-tree graph chunks",
879                        ))?;
880                        vtxo_batch_tree_graph =
881                            Some(TxGraph::new(chunks).map_err(Error::from).context(
882                                "failed to build VTXO batch-tree graph before generating nonces",
883                            )?);
884
885                        tracing::info!(batch_id = e.id, "Batch signing started");
886
887                        for own_cosigner_pk in own_cosigner_pks.iter() {
888                            if !&e.cosigners_pubkeys.iter().any(|p| p == own_cosigner_pk) {
889                                return Err(Error::ark_server(format!(
890                                    "own cosigner PK is not present in cosigner PKs: {own_cosigner_pk}"
891                                )));
892                            }
893                        }
894
895                        let mut our_nonce_tree_map = HashMap::new();
896                        for own_cosigner_kp in own_cosigner_kps {
897                            let own_cosigner_pk = own_cosigner_kp.public_key();
898                            let nonce_tree = generate_nonce_tree(
899                                rng,
900                                vtxo_batch_tree_graph
901                                    .as_ref()
902                                    .expect("VTXO batch-tree graph"),
903                                own_cosigner_pk,
904                                &e.unsigned_commitment_tx,
905                            )
906                            .map_err(Error::from)
907                            .context("failed to generate VTXO nonce tree")?;
908
909                            tracing::info!(
910                                cosigner_pk = %own_cosigner_pk,
911                                "Submitting nonce tree for cosigner PK"
912                            );
913
914                            network_client
915                                .submit_tree_nonces(
916                                    &e.id,
917                                    own_cosigner_pk,
918                                    nonce_tree.to_nonce_pks(),
919                                )
920                                .await
921                                .map_err(Error::ark_server)
922                                .context("failed to submit VTXO nonce tree")?;
923
924                            our_nonce_tree_map.insert(own_cosigner_kp, nonce_tree);
925                        }
926
927                        unsigned_commitment_tx = Some(e.unsigned_commitment_tx);
928                        our_nonce_trees = Some(our_nonce_tree_map);
929
930                        step = step.next();
931                    }
932                    StreamEvent::TreeNonces(e) => {
933                        if step != Step::BatchSigningStarted {
934                            continue;
935                        }
936
937                        let tree_tx_nonce_pks = e.nonces;
938
939                        let cosigner_pk = match tree_tx_nonce_pks.0.iter().find(|(pk, _)| {
940                            own_cosigner_pks
941                                .iter()
942                                .any(|p| &&p.x_only_public_key().0 == pk)
943                        }) {
944                            Some((pk, _)) => *pk,
945                            None => {
946                                tracing::debug!(
947                                    batch_id = e.id,
948                                    txid = %e.txid,
949                                    "Received irrelevant TreeNonces event"
950                                );
951
952                                continue;
953                            }
954                        };
955
956                        tracing::debug!(
957                            batch_id = e.id,
958                            txid = %e.txid,
959                            %cosigner_pk,
960                            "Received TreeNonces event"
961                        );
962
963                        let agg_nonce_pk = aggregate_nonces(tree_tx_nonce_pks);
964
965                        agg_nonce_pks.insert(e.txid, agg_nonce_pk);
966
967                        if vtxo_batch_tree_graph.is_none() {
968                            let chunks = vtxo_batch_tree_graph_chunks.take().ok_or(Error::ark_server(
969                                "received batch-tree nonces event without VTXO batch-tree graph chunks",
970                            ))?;
971                            vtxo_batch_tree_graph = Some(
972                                TxGraph::new(chunks)
973                                    .map_err(Error::from)
974                                    .context("failed to build VTXO batch-tree graph before batch-tree signing")?,
975                            );
976                        }
977                        let vtxo_batch_tree_graph_ref =
978                            vtxo_batch_tree_graph.as_ref().expect("just populated");
979
980                        if agg_nonce_pks.len() == vtxo_batch_tree_graph_ref.nb_of_nodes() {
981                            let cosigner_kp = own_cosigner_kps
982                                .iter()
983                                .find(|kp| kp.public_key().x_only_public_key().0 == cosigner_pk)
984                                .ok_or_else(|| {
985                                    Error::ad_hoc("no cosigner keypair to sign for own PK")
986                                })?;
987
988                            let our_nonce_trees = our_nonce_trees.as_mut().ok_or(
989                                Error::ark_server("missing nonce trees during batch protocol"),
990                            )?;
991
992                            let our_nonce_tree =
993                                our_nonce_trees
994                                    .get_mut(cosigner_kp)
995                                    .ok_or(Error::ark_server(
996                                        "missing nonce tree during batch protocol",
997                                    ))?;
998
999                            let unsigned_commitment_tx = unsigned_commitment_tx
1000                                .as_ref()
1001                                .ok_or_else(|| Error::ad_hoc("missing commitment TX"))?;
1002
1003                            let batch_expiry = batch_expiry
1004                                .ok_or_else(|| Error::ad_hoc("missing batch expiry"))?;
1005
1006                            let mut partial_sig_tree = PartialSigTree::default();
1007                            for (txid, _) in vtxo_batch_tree_graph_ref.as_map() {
1008                                let agg_nonce_pk = agg_nonce_pks.get(&txid).ok_or_else(|| {
1009                                    Error::ad_hoc(format!(
1010                                        "missing aggregated nonce PK for TX {txid}"
1011                                    ))
1012                                })?;
1013
1014                                let sigs = sign_batch_tree_tx(
1015                                    txid,
1016                                    batch_expiry,
1017                                    ark_forfeit_pk,
1018                                    cosigner_kp,
1019                                    *agg_nonce_pk,
1020                                    vtxo_batch_tree_graph_ref,
1021                                    unsigned_commitment_tx,
1022                                    our_nonce_tree,
1023                                )
1024                                .map_err(Error::from)
1025                                .context("failed to sign VTXO batch-tree transactions")?;
1026
1027                                partial_sig_tree.0.extend(sigs.0);
1028                            }
1029
1030                            network_client
1031                                .submit_tree_signatures(
1032                                    &e.id,
1033                                    cosigner_kp.public_key(),
1034                                    partial_sig_tree,
1035                                )
1036                                .await
1037                                .map_err(Error::ark_server)
1038                                .context("failed to submit VTXO batch-tree signatures")?;
1039                        }
1040                    }
1041                    StreamEvent::TreeNoncesAggregated(e) => {
1042                        tracing::debug!(batch_id = e.id, "Batch combined nonces generated");
1043                    }
1044                    StreamEvent::BatchFinalization(e) => {
1045                        if step != Step::BatchSigningStarted {
1046                            continue;
1047                        }
1048
1049                        tracing::debug!(
1050                            commitment_txid = %e.commitment_tx.unsigned_tx.compute_txid(),
1051                            "Batch finalization started (delegate)"
1052                        );
1053
1054                        let chunks = connectors_graph_chunks.take().ok_or(Error::ark_server(
1055                            "received batch finalization event without connectors",
1056                        ))?;
1057
1058                        if chunks.is_empty() {
1059                            tracing::debug!(batch_id = e.id, "No delegated forfeit transactions");
1060                        } else {
1061                            let connectors_graph = TxGraph::new(chunks)
1062                                .map_err(Error::from)
1063                                .context(
1064                                "failed to build connectors graph before completing forfeit TXs",
1065                            )?;
1066
1067                            tracing::debug!(
1068                                batch_id = e.id,
1069                                "Completing delegated forfeit transactions"
1070                            );
1071
1072                            let signed_forfeit_psbts = complete_delegate_forfeit_txs(
1073                                &delegate.forfeit_psbts,
1074                                &connectors_graph.leaves(),
1075                            )?;
1076
1077                            network_client
1078                                .submit_signed_forfeit_txs(signed_forfeit_psbts, None)
1079                                .await?;
1080                        }
1081
1082                        step = step.next();
1083                    }
1084                    StreamEvent::BatchFinalized(e) => {
1085                        if step != Step::Finalized {
1086                            continue;
1087                        }
1088
1089                        let commitment_txid = e.commitment_txid;
1090
1091                        tracing::info!(batch_id = e.id, %commitment_txid, "Delegated batch finalized");
1092
1093                        return Ok(commitment_txid);
1094                    }
1095                    StreamEvent::BatchFailed(ref e) => {
1096                        if Some(&e.id) == batch_id.as_ref() {
1097                            return Err(Error::ark_server(format!(
1098                                "batch failed {}: {}",
1099                                e.id, e.reason
1100                            )));
1101                        }
1102
1103                        tracing::debug!("Unrelated batch failed: {e:?}");
1104                    }
1105                    StreamEvent::Heartbeat => {}
1106                    StreamEvent::StreamStarted(_) => {}
1107                },
1108                Some(Err(e)) => {
1109                    tracing::error!("Got error from event stream");
1110
1111                    return Err(Error::ark_server(e));
1112                }
1113                None => {
1114                    return Err(Error::ark_server("dropped batch event stream"));
1115                }
1116            }
1117        }
1118    }
1119
1120    /// Get all the [`batch::OnChainInput`]s and [`batch::VtxoInput`]s that can be used to join an
1121    /// upcoming batch.
1122    pub(crate) async fn fetch_commitment_transaction_inputs(
1123        &self,
1124        server_info: &server::Info,
1125        now: i64,
1126    ) -> Result<(Vec<batch::OnChainInput>, Vec<intent::Input>, Amount), Error> {
1127        let now = u64::try_from(now).map_err(|_| Error::ad_hoc("negative timestamp"))?;
1128
1129        // Get all known boarding outputs.
1130        let boarding_outputs = self.boarding_outputs()?;
1131
1132        let mut boarding_inputs: Vec<batch::OnChainInput> = Vec::new();
1133        let mut total_amount = Amount::ZERO;
1134
1135        // To track unique outpoints and prevent duplicates
1136        let mut seen_outpoints = HashSet::new();
1137
1138        // Find outpoints for each boarding output.
1139        for boarding_output in boarding_outputs {
1140            let outpoints = timeout_op(
1141                self.inner.timeout,
1142                self.blockchain().find_outpoints(boarding_output.address()),
1143            )
1144            .await
1145            .context("failed to find outpoints")??;
1146
1147            for o in outpoints.iter() {
1148                if let ExplorerUtxo {
1149                    outpoint,
1150                    amount,
1151                    confirmation_blocktime: Some(confirmation_blocktime),
1152                    confirmations,
1153                    is_spent: false,
1154                } = o
1155                {
1156                    // Check for duplicate outpoints
1157                    if seen_outpoints.contains(outpoint) {
1158                        continue;
1159                    }
1160
1161                    // Skip boarding outputs whose server key is past its cooperative-sign
1162                    // cutoff — the operator won't co-sign the old key's forfeit path.
1163                    // These must be recovered via unilateral exit (send_on_chain).
1164                    if server_info
1165                        .signer_requires_recovery_at(boarding_output.server_pk(), now as i64)
1166                    {
1167                        continue;
1168                    }
1169
1170                    // Only include confirmed boarding outputs with an _inactive_ exit path.
1171                    if !boarding_output.can_be_claimed_unilaterally_by_owner(
1172                        std::time::Duration::from_secs(now),
1173                        std::time::Duration::from_secs(*confirmation_blocktime),
1174                        *confirmations,
1175                    ) {
1176                        // Mark this outpoint as seen
1177                        seen_outpoints.insert(*outpoint);
1178
1179                        let script_pubkey = boarding_output.script_pubkey();
1180                        let tapscripts = boarding_output.tapscripts();
1181                        let spend_selection =
1182                            boarding_output.spend_selection(SpendPathKind::Forfeit)?;
1183
1184                        boarding_inputs.push(batch::OnChainInput::new_with_spend_selection(
1185                            boarding_output.exit_delay(),
1186                            script_pubkey,
1187                            tapscripts,
1188                            spend_selection,
1189                            boarding_output.owner_pk(),
1190                            *amount,
1191                            *outpoint,
1192                        ));
1193                        total_amount += *amount;
1194                    }
1195                }
1196            }
1197        }
1198
1199        let vtxo_list = self.list_vtxos_with_server_info(server_info).await?;
1200        // Reuse the caller-supplied timestamp (not a fresh wall-clock) so the VTXO cutoff filter
1201        // below is evaluated against the same instant as the boarding filter above, and so a
1202        // test-injected `now` deterministically controls both.
1203        let settleable_vtxos: Vec<_> = vtxo_list
1204            .batch_settleable_at(server_info, now as i64)
1205            .collect();
1206
1207        total_amount += settleable_vtxos
1208            .iter()
1209            .fold(Amount::ZERO, |acc, entry| acc + entry.vtxo().amount);
1210
1211        let vtxo_inputs = settleable_vtxos
1212            .into_iter()
1213            .map(|entry| {
1214                let spend_selection = entry.spend_selection(SpendPathKind::Forfeit)?;
1215
1216                Ok(intent::Input::new_with_spend_selection(
1217                    entry.vtxo().outpoint,
1218                    entry.exit_delay()?,
1219                    TxOut {
1220                        value: entry.vtxo().amount,
1221                        script_pubkey: entry.script_pubkey(),
1222                    },
1223                    entry.tapscripts(),
1224                    spend_selection,
1225                    false,
1226                    entry.vtxo().is_swept,
1227                    entry.vtxo().assets.clone(),
1228                ))
1229            })
1230            .collect::<Result<Vec<_>, Error>>()?;
1231
1232        Ok((boarding_inputs, vtxo_inputs, total_amount))
1233    }
1234
1235    /// Prepare an intent for batch registration or fee estimation.
1236    ///
1237    /// This creates a signed intent PSBT along with all the data needed to participate
1238    /// in the batch protocol.
1239    pub(crate) fn prepare_intent<R>(
1240        &self,
1241        rng: &mut R,
1242        onchain_inputs: Vec<batch::OnChainInput>,
1243        vtxo_inputs: Vec<intent::Input>,
1244        output_type: BatchOutputType,
1245        intent_kind: PrepareIntentKind,
1246        dust: Amount,
1247    ) -> Result<PreparedIntent, Error>
1248    where
1249        R: Rng + CryptoRng,
1250    {
1251        if onchain_inputs.is_empty() && vtxo_inputs.is_empty() {
1252            return Err(Error::ad_hoc("cannot prepare intent without inputs"));
1253        }
1254
1255        // Generate an (ephemeral) cosigner keypair.
1256        let cosigner_keypair = Keypair::new(self.secp(), rng);
1257
1258        let vtxo_input_outpoints = vtxo_inputs.iter().map(|i| i.outpoint()).collect::<Vec<_>>();
1259
1260        let inputs = {
1261            let boarding_inputs = onchain_inputs.clone().into_iter().map(|o| {
1262                intent::Input::new(
1263                    o.outpoint(),
1264                    o.sequence(),
1265                    None,
1266                    TxOut {
1267                        value: o.amount(),
1268                        script_pubkey: o.script_pubkey().clone(),
1269                    },
1270                    o.tapscripts().to_vec(),
1271                    o.spend_info().clone(),
1272                    true,
1273                    false,
1274                    Vec::new(),
1275                )
1276            });
1277
1278            boarding_inputs
1279                .chain(vtxo_inputs.clone())
1280                .collect::<Vec<_>>()
1281        };
1282
1283        let mut outputs = vec![];
1284
1285        match output_type {
1286            BatchOutputType::Board {
1287                to_address,
1288                to_amount,
1289            } => {
1290                if to_amount < dust {
1291                    return Err(Error::ad_hoc(format!(
1292                        "cannot settle into sub-dust VTXO: {to_amount} < {dust}"
1293                    )));
1294                }
1295
1296                outputs.push(intent::Output::Offchain(TxOut {
1297                    value: to_amount,
1298                    script_pubkey: to_address.to_p2tr_script_pubkey(),
1299                }));
1300            }
1301            BatchOutputType::OffBoard {
1302                to_address,
1303                to_amount,
1304                change_amount,
1305                ..
1306            } if change_amount == Amount::ZERO => {
1307                outputs.push(intent::Output::Onchain(TxOut {
1308                    value: to_amount,
1309                    script_pubkey: to_address.script_pubkey(),
1310                }));
1311            }
1312            BatchOutputType::OffBoard {
1313                to_address,
1314                to_amount,
1315                change_address,
1316                change_amount,
1317            } => {
1318                if change_amount < dust {
1319                    return Err(Error::ad_hoc(format!(
1320                        "cannot settle with sub-dust change VTXO: {change_amount} < {dust}"
1321                    )));
1322                }
1323
1324                outputs.push(intent::Output::Onchain(TxOut {
1325                    value: to_amount,
1326                    script_pubkey: to_address.script_pubkey(),
1327                }));
1328
1329                outputs.push(intent::Output::Offchain(TxOut {
1330                    value: change_amount,
1331                    script_pubkey: change_address.to_p2tr_script_pubkey(),
1332                }));
1333            }
1334        }
1335
1336        let cosigner_pk = cosigner_keypair.public_key();
1337
1338        let secp = Secp256k1::new();
1339
1340        let sign_for_vtxo_fn =
1341            |input: &mut psbt::Input,
1342             msg: secp256k1::Message|
1343             -> Result<Vec<(schnorr::Signature, XOnlyPublicKey)>, ark_core::Error> {
1344                match &input.witness_script {
1345                    None => Err(ark_core::Error::ad_hoc(
1346                        "Missing witness script in psbt::Input when signing intent",
1347                    )),
1348                    Some(script) => {
1349                        let pks = extract_checksig_pubkeys(script);
1350                        let mut res = vec![];
1351                        for pk in pks {
1352                            if let Ok(keypair) = self.keypair_by_pk(&pk) {
1353                                let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
1354                                res.push((sig, keypair.public_key().into()))
1355                            }
1356                        }
1357                        Ok(res)
1358                    }
1359                }
1360            };
1361
1362        let sign_for_onchain_fn =
1363            |input: &mut psbt::Input,
1364             msg: secp256k1::Message|
1365             -> Result<(schnorr::Signature, XOnlyPublicKey), ark_core::Error> {
1366                let onchain_input = onchain_inputs
1367                    .iter()
1368                    .find(|o| {
1369                        Some(o.script_pubkey().clone())
1370                            == input.witness_utxo.clone().map(|w| w.script_pubkey)
1371                    })
1372                    .ok_or_else(|| {
1373                        ark_core::Error::ad_hoc(
1374                            "could not find signing key for onchain input: {input:?}",
1375                        )
1376                    })?;
1377
1378                let owner_pk = onchain_input.owner_pk();
1379                let sig = self
1380                    .sign_for_pk(&owner_pk, &msg)
1381                    .map_err(|e| ark_core::Error::ad_hoc(e.to_string()))?;
1382
1383                Ok((sig, owner_pk))
1384            };
1385
1386        let now = std::time::SystemTime::now()
1387            .duration_since(std::time::UNIX_EPOCH)
1388            .map_err(|e| Error::ad_hoc(e.to_string()))
1389            .context("failed to compute now timestamp")?;
1390        let now = now.as_secs();
1391        let expire_at = now + (2 * 60);
1392
1393        if let Some(packet) = create_asset_preservation_packet(&inputs, &outputs)? {
1394            outputs.push(intent::Output::AssetPacket(packet.to_txout()));
1395        }
1396
1397        let mut onchain_output_indexes = Vec::new();
1398        for (i, output) in outputs.iter().enumerate() {
1399            if matches!(output, intent::Output::Onchain(_)) {
1400                onchain_output_indexes.push(i);
1401            }
1402        }
1403
1404        let message = match intent_kind {
1405            PrepareIntentKind::EstimateFee => intent::IntentMessage::EstimateIntentFee {
1406                onchain_output_indexes,
1407                valid_at: now,
1408                expire_at,
1409                own_cosigner_pks: vec![cosigner_pk],
1410            },
1411            PrepareIntentKind::Register => intent::IntentMessage::Register {
1412                onchain_output_indexes,
1413                valid_at: now,
1414                expire_at,
1415                own_cosigner_pks: vec![cosigner_pk],
1416            },
1417        };
1418
1419        let intent = intent::make_intent(
1420            sign_for_vtxo_fn,
1421            sign_for_onchain_fn,
1422            inputs,
1423            outputs.clone(),
1424            message,
1425        )?;
1426
1427        Ok(PreparedIntent {
1428            intent,
1429            cosigner_keypair,
1430            vtxo_input_outpoints,
1431            outputs,
1432            onchain_inputs,
1433            vtxo_inputs,
1434        })
1435    }
1436
1437    pub(crate) async fn join_next_batch<R>(
1438        &self,
1439        rng: &mut R,
1440        server_info: &server::Info,
1441        onchain_inputs: Vec<batch::OnChainInput>,
1442        vtxo_inputs: Vec<intent::Input>,
1443        output_type: BatchOutputType,
1444    ) -> Result<Txid, Error>
1445    where
1446        R: Rng + CryptoRng,
1447    {
1448        let prepared = self.prepare_intent(
1449            rng,
1450            onchain_inputs,
1451            vtxo_inputs,
1452            output_type,
1453            PrepareIntentKind::Register,
1454            server_info.dust,
1455        )?;
1456
1457        let PreparedIntent {
1458            intent,
1459            cosigner_keypair,
1460            vtxo_input_outpoints,
1461            outputs,
1462            onchain_inputs,
1463            vtxo_inputs,
1464        } = prepared;
1465
1466        let onchain_input_outpoints = onchain_inputs
1467            .iter()
1468            .map(|i| i.outpoint())
1469            .collect::<Vec<_>>();
1470
1471        let own_cosigner_kps = [cosigner_keypair];
1472        let own_cosigner_pks = own_cosigner_kps
1473            .iter()
1474            .map(|k| k.public_key())
1475            .collect::<Vec<_>>();
1476
1477        let secp = Secp256k1::new();
1478
1479        let mut step = Step::Start;
1480
1481        let intent_id = timeout_op(
1482            self.inner.timeout,
1483            self.network_client().register_intent(intent),
1484        )
1485        .await
1486        .context("failed to register intent")??;
1487
1488        tracing::debug!(
1489            intent_id,
1490            ?onchain_input_outpoints,
1491            ?vtxo_input_outpoints,
1492            ?outputs,
1493            "Registered intent for batch"
1494        );
1495
1496        let network_client = self.network_client();
1497
1498        let mut batch_id: Option<String> = None;
1499
1500        let topics = vtxo_input_outpoints
1501            .iter()
1502            .map(ToString::to_string)
1503            .chain(
1504                own_cosigner_pks
1505                    .iter()
1506                    .map(|pk| pk.serialize().to_lower_hex_string()),
1507            )
1508            .collect();
1509
1510        let mut stream = network_client.get_event_stream(topics).await?;
1511
1512        let (ark_forfeit_pk, _) = server_info.forfeit_pk.x_only_public_key();
1513
1514        let mut unsigned_commitment_tx = None;
1515
1516        let mut vtxo_batch_tree_graph_chunks = Some(Vec::new());
1517        let mut vtxo_batch_tree_graph: Option<TxGraph> = None;
1518
1519        let mut connectors_graph_chunks = Some(Vec::new());
1520        let mut batch_expiry = None;
1521
1522        let mut agg_nonce_pks = HashMap::new();
1523
1524        let mut our_nonce_trees: Option<HashMap<Keypair, NonceKps>> = None;
1525        loop {
1526            match timeout_op(self.inner.timeout, stream.next())
1527                .await
1528                .context("timed out waiting for batch event")?
1529            {
1530                Some(Ok(event)) => match event {
1531                    StreamEvent::BatchStarted(e) => {
1532                        if step != Step::Start {
1533                            continue;
1534                        }
1535
1536                        let hash = sha256::Hash::hash(intent_id.as_bytes());
1537                        let hash = hash.as_byte_array().to_vec().to_lower_hex_string();
1538
1539                        if e.intent_id_hashes.iter().any(|h| h == &hash) {
1540                            timeout_op(
1541                                self.inner.timeout,
1542                                self.network_client()
1543                                    .confirm_registration(intent_id.clone()),
1544                            )
1545                            .await
1546                            .context("failed to confirm intent registration")??;
1547
1548                            tracing::info!(batch_id = e.id, intent_id, "Intent ID found for batch");
1549
1550                            batch_id = Some(e.id);
1551
1552                            // Depending on whether we are generating new VTXOs or not, we continue
1553                            // with a different step in the state machine.
1554                            step = match outputs
1555                                .iter()
1556                                .any(|o| matches!(o, intent::Output::Offchain(_)))
1557                            {
1558                                true => Step::BatchStarted,
1559                                false => Step::BatchSigningStarted,
1560                            };
1561
1562                            batch_expiry = Some(e.batch_expiry);
1563                        } else {
1564                            tracing::debug!(
1565                                batch_id = e.id,
1566                                intent_id,
1567                                "Intent ID not found for batch"
1568                            );
1569                        }
1570                    }
1571                    StreamEvent::TreeTx(e) => {
1572                        if step != Step::BatchStarted && step != Step::BatchSigningStarted {
1573                            continue;
1574                        }
1575
1576                        match e.batch_tree_event_type {
1577                            BatchTreeEventType::Vtxo => {
1578                                match &mut vtxo_batch_tree_graph_chunks {
1579                                    Some(vtxo_batch_tree_graph_chunks) => {
1580                                        tracing::debug!("Got new VTXO batch-tree graph chunk");
1581
1582                                        vtxo_batch_tree_graph_chunks.push(e.tx_graph_chunk)
1583                                    }
1584                                    None => {
1585                                        return Err(Error::ark_server(
1586                                            "received unexpected VTXO batch-tree graph chunk",
1587                                        ));
1588                                    }
1589                                };
1590                            }
1591                            BatchTreeEventType::Connector => {
1592                                match connectors_graph_chunks {
1593                                    Some(ref mut connectors_graph_chunks) => {
1594                                        tracing::debug!("Got new connectors graph chunk");
1595
1596                                        connectors_graph_chunks.push(e.tx_graph_chunk)
1597                                    }
1598                                    None => {
1599                                        return Err(Error::ark_server(
1600                                            "received unexpected connectors graph chunk",
1601                                        ));
1602                                    }
1603                                };
1604                            }
1605                        }
1606                    }
1607                    StreamEvent::TreeSignature(e) => {
1608                        if step != Step::BatchSigningStarted {
1609                            continue;
1610                        }
1611
1612                        match e.batch_tree_event_type {
1613                            BatchTreeEventType::Vtxo => {
1614                                match vtxo_batch_tree_graph {
1615                                    Some(ref mut vtxo_batch_tree_graph) => {
1616                                        vtxo_batch_tree_graph.apply(|graph| {
1617                                            if graph.root().unsigned_tx.compute_txid() != e.txid {
1618                                                Ok(true)
1619                                            } else {
1620                                                graph.set_signature(e.signature);
1621
1622                                                Ok(false)
1623                                            }
1624                                        })?;
1625                                    }
1626                                    None => {
1627                                        return Err(Error::ark_server(
1628                                            "received batch-tree signature without transaction graph",
1629                                        ));
1630                                    }
1631                                };
1632                            }
1633                            BatchTreeEventType::Connector => {
1634                                return Err(Error::ark_server(
1635                                    "received batch-tree signature for connector tree",
1636                                ));
1637                            }
1638                        }
1639                    }
1640                    StreamEvent::TreeSigningStarted(e) => {
1641                        if step != Step::BatchStarted {
1642                            continue;
1643                        }
1644
1645                        let chunks = vtxo_batch_tree_graph_chunks.take().ok_or(Error::ark_server(
1646                            "received batch-tree signing started event without VTXO batch-tree graph chunks",
1647                        ))?;
1648                        vtxo_batch_tree_graph =
1649                            Some(TxGraph::new(chunks).map_err(Error::from).context(
1650                                "failed to build VTXO batch-tree graph before generating nonces",
1651                            )?);
1652
1653                        tracing::info!(batch_id = e.id, "Batch signing started");
1654
1655                        for own_cosigner_pk in own_cosigner_pks.iter() {
1656                            if !&e.cosigners_pubkeys.iter().any(|p| p == own_cosigner_pk) {
1657                                return Err(Error::ark_server(format!(
1658                                    "own cosigner PK is not present in cosigner PKs: {own_cosigner_pk}"
1659                                )));
1660                            }
1661                        }
1662
1663                        // We generate and submit a nonce tree for every cosigner key we provide.
1664                        let mut our_nonce_tree_map = HashMap::new();
1665                        for own_cosigner_kp in own_cosigner_kps {
1666                            let own_cosigner_pk = own_cosigner_kp.public_key();
1667                            let nonce_tree = generate_nonce_tree(
1668                                rng,
1669                                vtxo_batch_tree_graph
1670                                    .as_ref()
1671                                    .expect("VTXO batch-tree graph"),
1672                                own_cosigner_pk,
1673                                &e.unsigned_commitment_tx,
1674                            )
1675                            .map_err(Error::from)
1676                            .context("failed to generate VTXO nonce tree")?;
1677
1678                            tracing::info!(
1679                                cosigner_pk = %own_cosigner_pk,
1680                                "Submitting nonce tree for cosigner PK"
1681                            );
1682
1683                            network_client
1684                                .submit_tree_nonces(
1685                                    &e.id,
1686                                    own_cosigner_pk,
1687                                    nonce_tree.to_nonce_pks(),
1688                                )
1689                                .await
1690                                .map_err(Error::ark_server)
1691                                .context("failed to submit VTXO nonce tree")?;
1692
1693                            our_nonce_tree_map.insert(own_cosigner_kp, nonce_tree);
1694                        }
1695
1696                        unsigned_commitment_tx = Some(e.unsigned_commitment_tx);
1697                        our_nonce_trees = Some(our_nonce_tree_map);
1698
1699                        step = step.next();
1700                    }
1701                    StreamEvent::TreeNonces(e) => {
1702                        if step != Step::BatchSigningStarted {
1703                            continue;
1704                        }
1705
1706                        let tree_tx_nonce_pks = e.nonces;
1707
1708                        let cosigner_pk = match tree_tx_nonce_pks.0.iter().find(|(pk, _)| {
1709                            own_cosigner_pks
1710                                .iter()
1711                                .any(|p| &&p.x_only_public_key().0 == pk)
1712                        }) {
1713                            Some((pk, _)) => *pk,
1714                            None => {
1715                                tracing::debug!(
1716                                    batch_id = e.id,
1717                                    txid = %e.txid,
1718                                    "Received irrelevant TreeNonces event"
1719                                );
1720
1721                                continue;
1722                            }
1723                        };
1724
1725                        tracing::debug!(
1726                            batch_id = e.id,
1727                            txid = %e.txid,
1728                            %cosigner_pk,
1729                            "Received TreeNonces event"
1730                        );
1731
1732                        let agg_nonce_pk = aggregate_nonces(tree_tx_nonce_pks);
1733
1734                        agg_nonce_pks.insert(e.txid, agg_nonce_pk);
1735
1736                        if vtxo_batch_tree_graph.is_none() {
1737                            let chunks = vtxo_batch_tree_graph_chunks.take().ok_or(Error::ark_server(
1738                                "received batch-tree nonces event without VTXO batch-tree graph chunks",
1739                            ))?;
1740                            vtxo_batch_tree_graph = Some(
1741                                TxGraph::new(chunks)
1742                                    .map_err(Error::from)
1743                                    .context("failed to build VTXO batch-tree graph before batch-tree signing")?,
1744                            );
1745                        }
1746                        let vtxo_batch_tree_graph_ref =
1747                            vtxo_batch_tree_graph.as_ref().expect("just populated");
1748
1749                        // Once we collect an aggregated nonce per transaction in our VTXO
1750                        // batch-tree graph, we can sign and submit our partial signatures.
1751                        if agg_nonce_pks.len() == vtxo_batch_tree_graph_ref.nb_of_nodes() {
1752                            let cosigner_kp = own_cosigner_kps
1753                                .iter()
1754                                .find(|kp| kp.public_key().x_only_public_key().0 == cosigner_pk)
1755                                .ok_or_else(|| {
1756                                    Error::ad_hoc("no cosigner keypair to sign for own PK")
1757                                })?;
1758
1759                            let our_nonce_trees = our_nonce_trees.as_mut().ok_or(
1760                                Error::ark_server("missing nonce trees during batch protocol"),
1761                            )?;
1762
1763                            let our_nonce_tree =
1764                                our_nonce_trees
1765                                    .get_mut(cosigner_kp)
1766                                    .ok_or(Error::ark_server(
1767                                        "missing nonce tree during batch protocol",
1768                                    ))?;
1769
1770                            let unsigned_commitment_tx = unsigned_commitment_tx
1771                                .as_ref()
1772                                .ok_or_else(|| Error::ad_hoc("missing commitment TX"))?;
1773
1774                            let batch_expiry = batch_expiry
1775                                .ok_or_else(|| Error::ad_hoc("missing batch expiry"))?;
1776
1777                            let mut partial_sig_tree = PartialSigTree::default();
1778                            for (txid, _) in vtxo_batch_tree_graph_ref.as_map() {
1779                                let agg_nonce_pk = agg_nonce_pks.get(&txid).ok_or_else(|| {
1780                                    Error::ad_hoc(format!(
1781                                        "missing aggregated nonce PK for TX {txid}"
1782                                    ))
1783                                })?;
1784
1785                                let sigs = sign_batch_tree_tx(
1786                                    txid,
1787                                    batch_expiry,
1788                                    ark_forfeit_pk,
1789                                    cosigner_kp,
1790                                    *agg_nonce_pk,
1791                                    vtxo_batch_tree_graph_ref,
1792                                    unsigned_commitment_tx,
1793                                    our_nonce_tree,
1794                                )
1795                                .map_err(Error::from)
1796                                .context("failed to sign VTXO batch-tree transactions")?;
1797
1798                                partial_sig_tree.0.extend(sigs.0);
1799                            }
1800
1801                            network_client
1802                                .submit_tree_signatures(
1803                                    &e.id,
1804                                    cosigner_kp.public_key(),
1805                                    partial_sig_tree,
1806                                )
1807                                .await
1808                                .map_err(Error::ark_server)
1809                                .context("failed to submit VTXO batch-tree signatures")?;
1810                        }
1811                    }
1812                    StreamEvent::TreeNoncesAggregated(e) => {
1813                        tracing::debug!(batch_id = e.id, "Batch combined nonces generated");
1814                    }
1815                    StreamEvent::BatchFinalization(e) => {
1816                        if step != Step::BatchSigningStarted {
1817                            continue;
1818                        }
1819
1820                        tracing::debug!(
1821                            commitment_txid = %e.commitment_tx.unsigned_tx.compute_txid(),
1822                            "Batch finalization started"
1823                        );
1824
1825                        let signed_forfeit_psbts = if !vtxo_inputs.is_empty() {
1826                            let chunks =
1827                                connectors_graph_chunks.take().ok_or(Error::ark_server(
1828                                    "received batch finalization event without connectors",
1829                                ))?;
1830
1831                            if chunks.is_empty() {
1832                                tracing::debug!(batch_id = e.id, "No forfeit transactions");
1833
1834                                Vec::new()
1835                            } else {
1836                                let connectors_graph = TxGraph::new(chunks)
1837                                    .map_err(Error::from)
1838                                    .context(
1839                                    "failed to build connectors graph before signing forfeit TXs",
1840                                )?;
1841
1842                                tracing::debug!(batch_id = e.id, "Batch finalization started");
1843
1844                                create_and_sign_forfeit_txs(
1845                                    |input: &mut psbt::Input, msg: secp256k1::Message| match &input
1846                                    .witness_script
1847                                {
1848                                    None => Err(ark_core::Error::ad_hoc(
1849                                        "Missing witness script in psbt::Input when signing forfeit",
1850                                    )),
1851                                    Some(script) => {
1852                                        let pks = extract_checksig_pubkeys(script);
1853                                        let mut res = vec![];
1854                                        for pk in pks {
1855                                            if let Ok(keypair) =
1856                                            self.keypair_by_pk(&pk) {
1857                                                let sig =
1858                                                    secp.sign_schnorr_no_aux_rand(&msg, &keypair);
1859                                                res.push((sig, keypair.public_key().into()))
1860                                            }
1861                                        }
1862                                        Ok(res)
1863                                    }
1864                                    },
1865                                    vtxo_inputs.as_slice(),
1866                                    &connectors_graph.leaves(),
1867                                    &server_info.forfeit_address,
1868                                    server_info.dust,
1869                                )
1870                                .map_err(Error::from)?
1871                            }
1872                        } else {
1873                            Vec::new()
1874                        };
1875
1876                        let commitment_psbt = if onchain_inputs.is_empty() {
1877                            None
1878                        } else {
1879                            let mut commitment_psbt = e.commitment_tx;
1880
1881                            let sign_for_pk_fn = |pk: &XOnlyPublicKey,
1882                                                  msg: &secp256k1::Message|
1883                             -> Result<
1884                                schnorr::Signature,
1885                                ark_core::Error,
1886                            > {
1887                                self.sign_for_pk(pk, msg)
1888                                    .map_err(|e| ark_core::Error::ad_hoc(e.to_string()))
1889                            };
1890
1891                            sign_commitment_psbt(
1892                                sign_for_pk_fn,
1893                                &mut commitment_psbt,
1894                                &onchain_inputs,
1895                            )
1896                            .map_err(Error::from)?;
1897
1898                            Some(commitment_psbt)
1899                        };
1900
1901                        if !signed_forfeit_psbts.is_empty() || commitment_psbt.is_some() {
1902                            network_client
1903                                .submit_signed_forfeit_txs(signed_forfeit_psbts, commitment_psbt)
1904                                .await?;
1905                        }
1906
1907                        step = step.next();
1908                    }
1909                    StreamEvent::BatchFinalized(e) => {
1910                        if step != Step::Finalized {
1911                            continue;
1912                        }
1913
1914                        let commitment_txid = e.commitment_txid;
1915
1916                        tracing::info!(batch_id = e.id, %commitment_txid, "Batch finalized");
1917
1918                        return Ok(commitment_txid);
1919                    }
1920                    StreamEvent::BatchFailed(ref e) => {
1921                        if Some(&e.id) == batch_id.as_ref() {
1922                            return Err(Error::ark_server(format!(
1923                                "batch failed {}: {}",
1924                                e.id, e.reason
1925                            )));
1926                        }
1927
1928                        tracing::debug!("Unrelated batch failed: {e:?}");
1929                    }
1930                    StreamEvent::Heartbeat => {}
1931                    StreamEvent::StreamStarted(_) => {}
1932                },
1933                Some(Err(e)) => {
1934                    tracing::error!("Got error from event stream");
1935
1936                    return Err(Error::ark_server(e));
1937                }
1938                None => {
1939                    return Err(Error::ark_server("dropped batch event stream"));
1940                }
1941            }
1942        }
1943
1944        #[derive(Debug, PartialEq, Eq)]
1945        enum Step {
1946            Start,
1947            BatchStarted,
1948            BatchSigningStarted,
1949            Finalized,
1950        }
1951
1952        impl Step {
1953            fn next(&self) -> Step {
1954                match self {
1955                    Step::Start => Step::BatchStarted,
1956                    Step::BatchStarted => Step::BatchSigningStarted,
1957                    Step::BatchSigningStarted => Step::Finalized,
1958                    Step::Finalized => Step::Finalized, // we can't go further
1959                }
1960            }
1961        }
1962    }
1963}
1964
1965#[derive(Debug, Clone)]
1966pub(crate) enum PrepareIntentKind {
1967    Register,
1968    EstimateFee,
1969}
1970
1971#[derive(Debug, Clone)]
1972pub(crate) enum BatchOutputType {
1973    Board {
1974        to_address: ArkAddress,
1975        to_amount: Amount,
1976    },
1977    OffBoard {
1978        to_address: Address,
1979        to_amount: Amount,
1980        change_address: ArkAddress,
1981        change_amount: Amount,
1982    },
1983}
1984
1985/// Prepared intent data ready for batch registration.
1986pub(crate) struct PreparedIntent {
1987    /// The signed intent.
1988    pub intent: intent::Intent,
1989    /// The ephemeral cosigner keypair.
1990    pub cosigner_keypair: Keypair,
1991    /// VTXO input outpoints (used for event stream topics).
1992    pub vtxo_input_outpoints: Vec<OutPoint>,
1993    /// Intent outputs (used to determine batch protocol steps).
1994    pub outputs: Vec<intent::Output>,
1995    /// The original onchain inputs (needed for commitment signing).
1996    pub onchain_inputs: Vec<batch::OnChainInput>,
1997    /// The original VTXO inputs (needed for forfeit signing).
1998    pub vtxo_inputs: Vec<intent::Input>,
1999}