Skip to main content

bark/round/
mod.rs

1//!
2//! Round State Machine
3//!
4
5use std::collections::HashMap;
6use std::iter;
7use std::borrow::Cow;
8use std::convert::Infallible;
9use std::sync::Arc;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use anyhow::Context;
13use ark::vtxo::{TransitionKind, VtxoValidationError};
14use bdk_esplora::esplora_client::Amount;
15use bip39::rand;
16use bitcoin::{OutPoint, SignedAmount, Transaction, Txid};
17use bitcoin::consensus::encode::{deserialize, serialize_hex};
18use bitcoin::hashes::Hash;
19use bitcoin::hex::DisplayHex;
20use bitcoin::key::Keypair;
21use bitcoin::secp256k1::schnorr;
22use futures::future::join_all;
23use futures::{Stream, StreamExt};
24use log::{debug, error, info, trace, warn};
25
26use ark::{ProtocolEncoding, SignedVtxoRequest, Vtxo, VtxoRequest};
27use ark::vtxo::Full;
28use ark::attestations::{DelegatedRoundParticipationAttestation, RoundAttemptAttestation};
29use ark::forfeit::HashLockedForfeitBundle;
30use ark::musig::{self, PublicNonce, SecretNonce};
31use ark::rounds::{RoundAttempt, RoundEvent, RoundFinished, RoundSeq, ROUND_TX_VTXO_TREE_VOUT};
32use ark::tree::signed::{LeafVtxoCosignContext, UnlockHash, VtxoTreeSpec};
33use bitcoin_ext::{BlockHeight, TxStatus};
34use server_rpc::{protos, ServerConnection, TryFromBytes, MAX_NB_FORFEIT_NONCE_IDS};
35
36use crate::movement::manager::OnDropStatus;
37use crate::{Wallet, WalletVtxo, SECP, SUBSCRIBE_REQUEST_TIMEOUT};
38use crate::movement::{MovementId, MovementStatus};
39use crate::movement::update::MovementUpdate;
40use crate::persist::models::{RoundStateId, StoredRoundState, Unlocked};
41
42/// How long [`Wallet::lock_wait_round_state`] waits for a contended
43/// round lock before giving up. Long enough to outlast a normal round.
44const ROUND_LOCK_TIMEOUT: Duration = Duration::from_secs(10);
45use crate::subsystem::{RoundMovement, Subsystem};
46
47
48/// Struct to communicate your specific participation for an Ark round.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct RoundParticipation {
51	#[serde(with = "ark::encode::serde::vec")]
52	pub inputs: Vec<Vtxo<Full>>,
53	/// The output VTXOs that we request in the round,
54	/// including change
55	pub outputs: Vec<VtxoRequest>,
56	/// Optional mailbox identifier for round completion notification
57	#[serde(default, skip_serializing_if = "Option::is_none", with = "ark::encode::serde::opt")]
58	pub unblinded_mailbox_id: Option<ark::mailbox::MailboxIdentifier>,
59}
60
61impl RoundParticipation {
62	pub fn to_movement_update(&self) -> anyhow::Result<MovementUpdate> {
63		let input_amount = self.inputs.iter().map(|i| i.amount()).sum::<Amount>();
64		let output_amount = self.outputs.iter().map(|r| r.amount).sum::<Amount>();
65		let fee = input_amount - output_amount;
66		Ok(MovementUpdate::new()
67			.consumed_vtxos(&self.inputs)
68			.intended_balance(SignedAmount::ZERO)
69			.effective_balance( - fee.to_signed()?)
70			.fee(fee)
71		)
72	}
73}
74
75#[derive(Debug, Clone)]
76pub enum RoundStatus {
77	/// The round was successful and is fully confirmed
78	Confirmed {
79		funding_txid: Txid,
80	},
81	/// Round successful but not fully confirmed
82	Unconfirmed {
83		funding_txid: Txid,
84	},
85	/// Round didn't finish yet
86	Pending,
87	/// The round failed
88	Failed {
89		error: String,
90	},
91	/// User canceled the round
92	Canceled,
93}
94
95impl RoundStatus {
96	/// Whether this is the final state and it won't change anymore
97	pub fn is_final(&self) -> bool {
98		match self {
99			Self::Confirmed { .. } => true,
100			Self::Unconfirmed { .. } => false,
101			Self::Pending => false,
102			Self::Failed { .. } => true,
103			Self::Canceled => true,
104		}
105	}
106
107	/// Whether it looks like the round succeeded
108	pub fn is_success(&self) -> bool {
109		match self {
110			Self::Confirmed { .. } => true,
111			Self::Unconfirmed { .. } => true,
112			Self::Pending => false,
113			Self::Failed { .. } => false,
114			Self::Canceled => false,
115		}
116	}
117}
118
119/// State of the progress of a round participation
120///
121/// An instance of this struct is kept all the way from the intention of joining
122/// the next round, until either the round fully confirms or it fails and we are
123/// sure it won't have any effect on our wallet.
124///
125/// As soon as we have signed forfeit txs for the round, we keep track of this
126/// round attempt until we see another attempt we participated in confirm or
127/// we gain confidence that the failed attempt will never confirm.
128//
129//TODO(stevenroose) move the id in here and have the state persist itself with the wallet
130// to have better control. this way we can touch db before we sent forfeit sigs
131pub struct RoundState {
132	/// Round is fully done
133	pub(crate) done: bool,
134
135	/// Our participation in this round
136	pub(crate) participation: RoundParticipation,
137
138	/// The flow of the round in case it is still ongoing with the server
139	pub(crate) flow: RoundFlowState,
140
141	/// The new output vtxos of this round participation
142	///
143	/// After we finish the interactive part, we fill this with the uncompleted
144	/// VTXOs which we then try to complete with the unlock preimage.
145	pub(crate) new_vtxos: Vec<Vtxo<Full>>,
146
147	/// Whether we sent our forfeit signatures to the server
148	///
149	/// If we did this and the server refused to reveal our new VTXOs,
150	/// we will be forced to exit.
151	//TODO(stevenroose) implement exit when this is true and we can't make progress
152	// probably based on the input vtxos becoming close to expiry
153	pub(crate) sent_forfeit_sigs: bool,
154
155	/// The ID of the [Movement] associated with this round
156	pub(crate) movement_id: Option<MovementId>,
157}
158
159impl RoundState {
160	fn new_interactive(
161		participation: RoundParticipation,
162		movement_id: Option<MovementId>,
163	) -> Self {
164		Self {
165			participation,
166			movement_id,
167			flow: RoundFlowState::InteractivePending,
168			new_vtxos: Vec::new(),
169			sent_forfeit_sigs: false,
170			done: false,
171		}
172	}
173
174	fn new_delegated(
175		participation: RoundParticipation,
176		unlock_hash: UnlockHash,
177		movement_id: Option<MovementId>,
178	) -> Self {
179		Self {
180			participation,
181			movement_id,
182			flow: RoundFlowState::NonInteractivePending { unlock_hash },
183			new_vtxos: Vec::new(),
184			sent_forfeit_sigs: false,
185			done: false,
186		}
187	}
188
189	/// Our participation in this round
190	pub fn participation(&self) -> &RoundParticipation {
191		&self.participation
192	}
193
194	/// the unlock hash if already known
195	pub fn unlock_hash(&self) -> Option<UnlockHash> {
196		match self.flow {
197			RoundFlowState::NonInteractivePending { unlock_hash } => Some(unlock_hash),
198			RoundFlowState::InteractivePending => None,
199			RoundFlowState::InteractiveOngoing { .. } => None,
200			RoundFlowState::Failed { .. } => None,
201			RoundFlowState::Canceled => None,
202			RoundFlowState::Finished { unlock_hash, .. } => Some(unlock_hash),
203		}
204	}
205
206	pub fn funding_tx(&self) -> Option<&Transaction> {
207		match self.flow {
208			RoundFlowState::NonInteractivePending { .. } => None,
209			RoundFlowState::InteractivePending => None,
210			RoundFlowState::InteractiveOngoing { .. } => None,
211			RoundFlowState::Failed { .. } => None,
212			RoundFlowState::Canceled => None,
213			RoundFlowState::Finished { ref funding_tx, .. } => Some(funding_tx),
214		}
215	}
216
217	/// Whether the interactive part of the round is still ongoing
218	pub fn ongoing_participation(&self) -> bool {
219		match self.flow {
220			RoundFlowState::NonInteractivePending { .. } => false,
221			RoundFlowState::InteractivePending => true,
222			RoundFlowState::InteractiveOngoing { .. } => true,
223			RoundFlowState::Failed { .. } => false,
224			RoundFlowState::Canceled => false,
225			RoundFlowState::Finished { .. } => false,
226		}
227	}
228
229	/// Tries to cancel the round and returns whether it was succesfully canceled
230	/// or if it was already canceled or failed
231	pub async fn try_cancel(&mut self, wallet: &Wallet) -> anyhow::Result<bool> {
232		let ret = match self.flow {
233			RoundFlowState::NonInteractivePending { .. } => {
234				//TODO(stevenroose) we have to cancel with server
235				bail!("it is currently not yet possible to cancel pending delegated rounds");
236			},
237			RoundFlowState::Canceled => true,
238			RoundFlowState::Failed { .. } => true,
239			RoundFlowState::InteractivePending | RoundFlowState::InteractiveOngoing { .. } => {
240				self.flow = RoundFlowState::Canceled;
241				true
242			},
243			RoundFlowState::Finished { .. } => false,
244		};
245		if ret {
246			persist_round_failure(wallet, &self.participation, self.movement_id).await
247				.context("failed to persist round failure for cancelation")?;
248		}
249		Ok(ret)
250	}
251
252	async fn try_start_attempt(
253		&mut self,
254		wallet: &Wallet,
255		attempt: &RoundAttempt,
256	) {
257		// Drop the previous attempt's stashed nonces: the new attempt
258		// regenerates cosign keys, so the old key becomes unreachable.
259		if let RoundFlowState::InteractiveOngoing {
260			state: AttemptState::AwaitingUnsignedVtxoTree { ref cosign_keys, .. },
261			..
262		} = self.flow {
263			if let Some(k) = cosign_keys.first() {
264				wallet.inner.round_secret_nonces.forget(&k.public_key());
265			}
266		}
267
268		match start_attempt(wallet, &self.participation, attempt).await {
269			Ok(state) => {
270				self.flow = RoundFlowState::InteractiveOngoing {
271					round_seq: attempt.round_seq,
272					attempt_seq: attempt.attempt_seq,
273					state: state,
274				};
275			},
276			Err(e) => {
277				self.flow = RoundFlowState::Failed {
278					error: format!("{:#}", e),
279				};
280			},
281		}
282	}
283
284	/// Processes the given event and returns true if some update was made to the state
285	pub async fn process_event(
286		&mut self,
287		wallet: &Wallet,
288		event: &RoundEvent,
289	) -> bool {
290		let _: Infallible = match self.flow {
291			RoundFlowState::InteractivePending => {
292				if let RoundEvent::Attempt(e) = event && e.attempt_seq == 0 {
293					trace!("Joining round attempt {}:{}", e.round_seq, e.attempt_seq);
294					self.try_start_attempt(wallet, e).await;
295					return true;
296				} else {
297					trace!("Ignoring {} event (seq {}:{}), waiting for round to start",
298						event.kind(), event.round_seq(), event.attempt_seq(),
299					);
300					return false;
301				}
302			},
303			RoundFlowState::InteractiveOngoing { round_seq, attempt_seq, ref mut state } => {
304				// here we catch the cases where we're in a wrong flow
305
306				if let RoundEvent::Failed(e) = event && e.round_seq == round_seq {
307					warn!("Round {} failed by server", round_seq);
308					self.flow = RoundFlowState::Failed {
309						error: format!("round {} failed by server", round_seq),
310					};
311					return true;
312				}
313
314				if event.round_seq() > round_seq {
315					// new round started, we don't support multiple parallel rounds,
316					// this means we failed
317					self.flow = RoundFlowState::Failed {
318						error: format!("round {} started while we were on {}",
319							event.round_seq(), round_seq,
320						),
321					};
322					return true;
323				}
324
325				if event.attempt_seq() < attempt_seq {
326					trace!("ignoring replayed message from old attempt");
327					return false;
328				}
329
330				if let RoundEvent::Attempt(e) = event && e.attempt_seq > attempt_seq {
331					trace!("Joining new round attempt {}:{}", e.round_seq, e.attempt_seq);
332					self.try_start_attempt(wallet, e).await;
333					return true;
334				}
335				trace!("Processing event {} for round attempt {}:{} in state {}",
336					event.kind(), round_seq, attempt_seq, state.kind(),
337				);
338
339				return match progress_attempt(state, wallet, &self.participation, event).await {
340					AttemptProgressResult::NotUpdated => false,
341					AttemptProgressResult::Updated { new_state } => {
342						*state = new_state;
343						true
344					},
345					AttemptProgressResult::Failed(e) => {
346						warn!("Round failed with error: {:#}", e);
347						self.flow = RoundFlowState::Failed {
348							error: format!("{:#}", e),
349						};
350						true
351					},
352					AttemptProgressResult::Finished { funding_tx, vtxos, unlock_hash } => {
353						self.new_vtxos = vtxos;
354						let funding_txid = funding_tx.compute_txid();
355						self.flow = RoundFlowState::Finished { funding_tx, unlock_hash };
356						if let Some(mid) = self.movement_id {
357							if let Err(e) = update_funding_txid(wallet, mid, funding_txid).await {
358								warn!("Error updating the round funding txid: {:#}", e);
359							}
360						}
361						true
362					},
363				};
364			},
365			RoundFlowState::NonInteractivePending { .. }
366				| RoundFlowState::Finished { .. }
367				| RoundFlowState::Failed { .. }
368				| RoundFlowState::Canceled => return false,
369		};
370	}
371
372	/// Sync the round's status and return it
373	///
374	/// When success or failure is returned, the round state can be eliminated
375	//TODO(stevenroose) make RoundState manage its own db record
376	pub async fn sync(&mut self, wallet: &Wallet) -> anyhow::Result<RoundStatus> {
377		match self.flow {
378			RoundFlowState::Finished { ref funding_tx, .. } if self.done => {
379				Ok(RoundStatus::Confirmed {
380					funding_txid: funding_tx.compute_txid(),
381				})
382			},
383
384			RoundFlowState::InteractivePending | RoundFlowState::InteractiveOngoing { .. } => {
385				Ok(RoundStatus::Pending)
386			},
387			RoundFlowState::Failed { ref error } => {
388				persist_round_failure(wallet, &self.participation, self.movement_id).await
389					.context("failed to persist round failure")?;
390				Ok(RoundStatus::Failed { error: error.clone() })
391			},
392			RoundFlowState::Canceled => {
393				persist_round_failure(wallet, &self.participation, self.movement_id).await
394					.context("failed to persist round failure")?;
395				Ok(RoundStatus::Canceled)
396			},
397
398			RoundFlowState::NonInteractivePending { unlock_hash } => {
399				match progress_delegated(wallet, &self.participation, unlock_hash).await {
400					Ok(HarkProgressResult::RoundPending) => Ok(RoundStatus::Pending),
401					// We don't lock inputs on delegated rounds, so if we don't find it,
402					// we just mark the refresh movement as failed
403					Ok(HarkProgressResult::RoundNotFound) => {
404						info!("Server reports round participation not found (no forfeits sent)");
405						self.flow = RoundFlowState::Failed {
406							error: "server reports round participation not found".into(),
407						};
408						if let Some(movement_id) = self.movement_id {
409							wallet.inner.movements.finish_movement(movement_id, MovementStatus::Failed).await
410								.context("failed to mark refresh movement as failed")?;
411						}
412						Ok(RoundStatus::Failed {
413							error: "server reports round participation not found".into(),
414						})
415					},
416					Ok(HarkProgressResult::Ok { funding_tx, new_vtxos }) => {
417						let funding_txid = funding_tx.compute_txid();
418						self.new_vtxos = new_vtxos;
419						self.flow = RoundFlowState::Finished {
420							funding_tx: funding_tx.clone(),
421							unlock_hash: unlock_hash,
422						};
423
424						persist_round_success(
425							wallet,
426							&self.participation,
427							self.movement_id,
428							&self.new_vtxos,
429							&funding_tx,
430						).await.context("failed to store successful round in DB!")?;
431
432						self.done = true;
433
434						Ok(RoundStatus::Confirmed { funding_txid })
435					},
436					Ok(HarkProgressResult::FundingTxUnconfirmed { funding_txid }) => {
437						if let Some(mid) = self.movement_id {
438							update_funding_txid(wallet, mid, funding_txid).await
439								.context("failed to update funding txid in DB")?;
440						}
441						Ok(RoundStatus::Unconfirmed { funding_txid })
442					},
443
444					//TODO(stevenroose) should we mark as failed for these cases?
445
446					Err(HarkForfeitError::Err(e)) => {
447						//TODO(stevenroose) we failed here but we might actualy be able to
448						// succeed if we retry. should we implement some kind of limited
449						// retry after which we mark as failed?
450						Err(e.context("error progressing delegated round"))
451					},
452					Err(HarkForfeitError::SentForfeits(e)) => {
453						self.sent_forfeit_sigs = true;
454						Err(e.context("error progressing delegated round \
455							after sending forfeit tx signatures"))
456					},
457				}
458			},
459			// interactive part finished, but didn't forfeit yet
460			RoundFlowState::Finished { ref funding_tx, unlock_hash } => {
461				let funding_txid = funding_tx.compute_txid();
462				let confirmed = check_funding_tx_confirmations(
463					wallet, funding_txid, &funding_tx,
464				).await.context("error checking funding tx confirmations")?;
465				if !confirmed {
466					trace!("Funding tx {} not yet deeply enough confirmed", funding_txid);
467					return Ok(RoundStatus::Unconfirmed { funding_txid });
468				}
469
470				match hark_vtxo_swap(
471					wallet, &self.participation, &mut self.new_vtxos, &funding_tx, unlock_hash,
472				).await {
473					Ok(()) => {
474						persist_round_success(
475							wallet,
476							&self.participation,
477							self.movement_id,
478							&self.new_vtxos,
479							&funding_tx,
480						).await.context("failed to store successful round in DB!")?;
481
482						self.done = true;
483
484						Ok(RoundStatus::Confirmed { funding_txid })
485					},
486					Err(HarkForfeitError::Err(e)) => {
487						Err(e.context("error forfeiting VTXOs after round"))
488					},
489					Err(HarkForfeitError::SentForfeits(e)) => {
490						self.sent_forfeit_sigs = true;
491						Err(e.context("error after having signed and sent \
492							forfeit signatures to server"))
493					},
494				}
495			},
496		}
497	}
498
499	/// Once we know the signed round funding tx, this returns the output VTXOs
500	/// for this round.
501	pub fn output_vtxos(&self) -> Option<&[Vtxo<Full>]> {
502		if self.new_vtxos.is_empty() {
503			None
504		} else {
505			Some(&self.new_vtxos)
506		}
507	}
508
509	/// Returns the input VTXOs that are locked in this round, but only
510	/// if no output VTXOs were issued yet.
511	pub fn locked_pending_inputs(&self) -> &[Vtxo<Full>] {
512		//TODO(stevenroose) consider if we can't just drop the state after forfeit exchange
513		match self.flow {
514			RoundFlowState::NonInteractivePending { .. }
515				| RoundFlowState::InteractivePending
516				| RoundFlowState::InteractiveOngoing { .. }
517			=> {
518				&self.participation.inputs
519			},
520			RoundFlowState::Finished { .. } => if self.done {
521				// inputs already unlocked
522				&[]
523			} else {
524				&self.participation.inputs
525			},
526			RoundFlowState::Failed { .. }
527				| RoundFlowState::Canceled
528			=> {
529				// inputs already unlocked
530				&[]
531			},
532		}
533	}
534
535	/// The balance pending in this round
536	///
537	/// This becomes zero once the new round VTXOs are unlocked.
538	pub fn pending_balance(&self) -> Amount {
539		if self.done {
540			return Amount::ZERO;
541		}
542
543		match self.flow {
544			RoundFlowState::NonInteractivePending { .. }
545				| RoundFlowState::InteractivePending
546				| RoundFlowState::InteractiveOngoing { .. }
547				| RoundFlowState::Finished { .. }
548			=> {
549				self.participation.outputs.iter().map(|o| o.amount).sum()
550			},
551			RoundFlowState::Failed { .. } | RoundFlowState::Canceled => {
552				Amount::ZERO
553			},
554		}
555	}
556
557}
558
559/// The state of the process flow of a round
560///
561/// This tracks the progress of the interactive part of the round, from
562/// waiting to start until finishing either succesfully or with a failure.
563pub enum RoundFlowState {
564	/// We don't do flow and we just wait for the round to finish
565	NonInteractivePending {
566		unlock_hash: UnlockHash,
567	},
568
569	/// Waiting for round to happen
570	InteractivePending,
571	/// Interactive part ongoing
572	InteractiveOngoing {
573		round_seq: RoundSeq,
574		attempt_seq: usize,
575		state: AttemptState,
576	},
577
578	/// Interactive part finished, waiting for confirmation
579	Finished {
580		funding_tx: Transaction,
581		unlock_hash: UnlockHash,
582	},
583
584	/// Failed during round
585	Failed {
586		error: String,
587	},
588
589	/// User canceled round
590	Canceled,
591}
592
593/// The state of a single round attempt
594///
595/// For each attempt that we participate in, we keep the state of our concrete
596/// participation.
597pub enum AttemptState {
598	AwaitingAttempt,
599	AwaitingUnsignedVtxoTree {
600		cosign_keys: Vec<Keypair>,
601		unlock_hash: UnlockHash,
602	},
603	AwaitingFinishedRound {
604		unsigned_round_tx: Transaction,
605		vtxos_spec: VtxoTreeSpec,
606		unlock_hash: UnlockHash,
607	},
608}
609
610impl AttemptState {
611	/// The state kind represented as a string
612	fn kind(&self) -> &'static str {
613		match self {
614			Self::AwaitingAttempt => "AwaitingAttempt",
615			Self::AwaitingUnsignedVtxoTree { .. } => "AwaitingUnsignedVtxoTree",
616			Self::AwaitingFinishedRound { .. } => "AwaitingFinishedRound",
617		}
618	}
619}
620
621/// Result from trying to progress an ongoing round attempt
622enum AttemptProgressResult {
623	Finished {
624		funding_tx: Transaction,
625		vtxos: Vec<Vtxo<Full>>,
626		unlock_hash: UnlockHash,
627	},
628	Failed(anyhow::Error),
629	/// When the state changes, this variant is returned
630	///
631	/// If during the processing, we have signed any forfeit txs and tried
632	/// sending them to the server, the [UnconfirmedRound] instance is returned
633	/// so that it can be stored in the state.
634	Updated {
635		new_state: AttemptState,
636	},
637	NotUpdated,
638}
639
640/// Participate in the new round attempt by submitting our round participation
641async fn start_attempt(
642	wallet: &Wallet,
643	participation: &RoundParticipation,
644	event: &RoundAttempt,
645) -> anyhow::Result<AttemptState> {
646	let (mut srv, ark_info) = wallet.require_server().await.context("server not available")?;
647
648	// Assign cosign pubkeys to the payment requests.
649	let cosign_keys = iter::repeat_with(|| Keypair::new(&SECP, &mut rand::thread_rng()))
650		.take(participation.outputs.len())
651		.collect::<Vec<_>>();
652
653	// Prepare round participation info.
654	// For each of our requested vtxo output, we need a set of public and secret nonces.
655	let cosign_nonces = cosign_keys.iter()
656		.map(|key| {
657			let mut secs = Vec::with_capacity(ark_info.nb_round_nonces);
658			let mut pubs = Vec::with_capacity(ark_info.nb_round_nonces);
659			for _ in 0..ark_info.nb_round_nonces {
660				let (s, p) = musig::nonce_pair(key);
661				secs.push(s);
662				pubs.push(p);
663			}
664			(secs, pubs)
665		})
666		.take(participation.outputs.len())
667		.collect::<Vec<(Vec<SecretNonce>, Vec<PublicNonce>)>>();
668
669
670	// The round has now started. We can submit our payment.
671	debug!("Submitting payment request with {} inputs and {} vtxo outputs",
672		participation.inputs.len(), participation.outputs.len(),
673	);
674
675	// Build signed requests with mailbox IDs
676	let unblinded_mailbox_id = wallet.mailbox_identifier();
677	let signed_reqs = participation.outputs.iter()
678		.zip(cosign_keys.iter())
679		.zip(cosign_nonces.iter())
680		.map(|((req, cosign_key), (_sec, pub_nonces))| {
681			SignedVtxoRequest {
682				vtxo: req.clone(),
683				cosign_pubkey: cosign_key.public_key(),
684				nonces: pub_nonces.clone(),
685			}
686		})
687		.collect::<Vec<_>>();
688
689	let mut input_vtxos = Vec::with_capacity(participation.inputs.len());
690	for vtxo in participation.inputs.iter() {
691		let keypair = wallet.get_vtxo_key(vtxo).await
692			.map_err(HarkForfeitError::Err)?;
693		input_vtxos.push(protos::InputVtxo {
694			vtxo_id: vtxo.id().to_bytes().to_vec(),
695			attestation: {
696				let attestation = RoundAttemptAttestation::new(
697					event.challenge, vtxo.id(), &signed_reqs, &keypair,
698				);
699				attestation.serialize()
700			},
701		});
702	}
703
704	// Register VTXO transaction chains with server before round participation
705	wallet.register_vtxo_transactions_with_server(&participation.inputs).await
706		.map_err(HarkForfeitError::Err)?;
707
708	let resp = srv.client.submit_payment(protos::SubmitPaymentRequest {
709		input_vtxos: input_vtxos,
710		vtxo_requests: signed_reqs.into_iter().map(Into::into).collect(),
711		#[allow(deprecated)]
712		offboard_requests: vec![],
713		unblinded_mailbox_id: Some(unblinded_mailbox_id.serialize()),
714	}).await.context("Ark server refused our payment submission")?;
715	let unlock_hash = UnlockHash::from_bytes(&resp.into_inner().unlock_hash)?;
716
717	// Stash nonces in memory only. Empty `cosign_keys` means no VTXO
718	// outputs (offboard-only) — nothing to stash.
719	if let Some(k) = cosign_keys.first() {
720		wallet.inner.round_secret_nonces.stash(
721			k.public_key(),
722			cosign_nonces.into_iter().map(|(sec, _pub)| sec).collect(),
723		);
724	}
725
726	Ok(AttemptState::AwaitingUnsignedVtxoTree { unlock_hash, cosign_keys })
727}
728
729/// just an internal type; need Error trait to work with anyhow
730#[derive(Debug, thiserror::Error)]
731enum HarkForfeitError {
732	/// An error happened after we sent forfeit signatures to the server
733	#[error("error after forfeits were sent")]
734	SentForfeits(#[source] anyhow::Error),
735	/// An error happened before we sent forfeit signatures to the server
736	#[error("error before forfeits were sent")]
737	Err(#[source] anyhow::Error),
738}
739
740async fn hark_cosign_leaf(
741	wallet: &Wallet,
742	srv: &mut ServerConnection,
743	funding_tx: &Transaction,
744	vtxo: &mut Vtxo<Full>,
745) -> anyhow::Result<()> {
746	let key = wallet.pubkey_keypair(&vtxo.user_pubkey()).await
747		.context("error fetching keypair").map_err(HarkForfeitError::Err)?
748		.with_context(|| format!(
749			"keypair {} not found for VTXO {}", vtxo.user_pubkey(), vtxo.id(),
750		))?.1;
751	let (ctx, cosign_req) = LeafVtxoCosignContext::new(vtxo, funding_tx, &key);
752	let cosign_resp = srv.client.request_leaf_vtxo_cosign(
753		protos::LeafVtxoCosignRequest::from(cosign_req),
754	).await
755		.with_context(|| format!("error requesting leaf cosign for vtxo {}", vtxo.id()))?
756		.into_inner().try_into()
757		.context("bad leaf vtxo cosign response")?;
758	ensure!(ctx.finalize(vtxo, cosign_resp),
759		"failed to finalize VTXO leaf signature for VTXO {}", vtxo.id(),
760	);
761
762	Ok(())
763}
764
765/// Finish the hArk VTXO swap protocol
766///
767/// This includes:
768/// - requesting cosignature of the locked hArk leaves
769/// - sending forfeit txs to the server in return for the unlock preimage
770///
771/// NB all the actions in this function are idempotent, meaning that the server
772/// allows them to be done multiple times. this means that if this function calls
773/// fails, it's safe to just call it again at a later time
774async fn hark_vtxo_swap(
775	wallet: &Wallet,
776	participation: &RoundParticipation,
777	output_vtxos: &mut [Vtxo<Full>],
778	funding_tx: &Transaction,
779	unlock_hash: UnlockHash,
780) -> Result<(), HarkForfeitError> {
781	let (mut srv, _) = wallet.require_server().await.map_err(HarkForfeitError::Err)?;
782
783	// before we start make sure the server has our input vtxo signatures
784	wallet.register_vtxo_transactions_with_server(&participation.inputs).await
785		.context("couldn't send our input vtxo transactions to server")
786		.map_err(HarkForfeitError::Err)?;
787
788	// first get the leaves signed
789	for vtxo in output_vtxos.iter_mut() {
790		hark_cosign_leaf(wallet, &mut srv, funding_tx, vtxo).await
791			.map_err(HarkForfeitError::Err)?;
792	}
793
794	// then do the forfeit dance
795
796	// the server caps the number of vtxo ids per nonces request, so for
797	// large participations we request the nonces in chunks
798	let mut server_nonces = Vec::with_capacity(participation.inputs.len());
799	for inputs in participation.inputs.chunks(MAX_NB_FORFEIT_NONCE_IDS) {
800		let nonces = srv.client.request_forfeit_nonces(protos::ForfeitNoncesRequest {
801			unlock_hash: unlock_hash.to_byte_array().to_vec(),
802			vtxo_ids: inputs.iter().map(|v| v.id().to_bytes().to_vec()).collect(),
803		}).await
804			.context("request forfeits nonces call failed")
805			.map_err(HarkForfeitError::Err)?
806			.into_inner().public_nonces.into_iter()
807			.map(|b| musig::PublicNonce::from_bytes(b))
808			.collect::<Result<Vec<_>, _>>()
809			.context("invalid forfeit nonces")
810			.map_err(HarkForfeitError::Err)?;
811
812		if nonces.len() != inputs.len() {
813			return Err(HarkForfeitError::Err(anyhow!(
814				"server sent {} nonce pairs, expected {}",
815				nonces.len(), inputs.len(),
816			)));
817		}
818		server_nonces.extend(nonces);
819	}
820
821	let mut forfeit_bundles = Vec::with_capacity(participation.inputs.len());
822	for (input, nonces) in participation.inputs.iter().zip(server_nonces.into_iter()) {
823		let user_key = wallet.pubkey_keypair(&input.user_pubkey()).await
824			.ok().flatten().with_context(|| format!(
825				"failed to fetch keypair for vtxo user pubkey {}", input.user_pubkey(),
826			)).map_err(HarkForfeitError::Err)?.1;
827		forfeit_bundles.push(HashLockedForfeitBundle::new(
828			input, unlock_hash, &user_key, &nonces,
829		))
830	}
831
832	let preimage = srv.client.forfeit_vtxos(protos::ForfeitVtxosRequest {
833		forfeit_bundles: forfeit_bundles.iter().map(|b| b.serialize()).collect(),
834	}).await
835		.context("forfeit vtxos call failed")
836		.map_err(HarkForfeitError::SentForfeits)?
837		.into_inner().unlock_preimage.as_slice().try_into()
838		.context("invalid preimage length")
839		.map_err(HarkForfeitError::SentForfeits)?;
840
841	for vtxo in output_vtxos.iter_mut() {
842		if !vtxo.provide_unlock_preimage(preimage) {
843			return Err(HarkForfeitError::SentForfeits(anyhow!(
844				"invalid preimage {} for vtxo {} with supposed unlock hash {}",
845				preimage.as_hex(), vtxo.id(), unlock_hash,
846			)));
847		}
848
849		// then validate the vtxo works
850		vtxo.validate(&funding_tx).with_context(|| format!(
851			"new VTXO {} does not pass validation after hArk forfeit protocol", vtxo.id(),
852		)).map_err(HarkForfeitError::SentForfeits)?;
853	}
854
855	// then register the output vtxos with the server
856	wallet.register_vtxo_transactions_with_server(output_vtxos).await
857		.context("couldn't register output vtxo transactions with server")
858		.map_err(HarkForfeitError::SentForfeits)?;
859
860	Ok(())
861}
862
863fn check_vtxo_fails_hash_lock(funding_tx: &Transaction, vtxo: &Vtxo<Full>) -> anyhow::Result<()> {
864	match vtxo.validate(funding_tx) {
865		Err(VtxoValidationError::GenesisTransition {
866			genesis_idx, genesis_len, transition_kind, ..
867		}) if genesis_idx + 1 == genesis_len
868			&& transition_kind == TransitionKind::HashLockedCosigned.as_str() => Ok(()),
869		Ok(()) => Err(anyhow!("new un-unlocked VTXO should fail validation but doesn't: {}",
870			vtxo.serialize_hex(),
871		)),
872		Err(e) => Err(anyhow!("new VTXO {} failed validation: {:#}", vtxo.id(), e)),
873	}
874}
875
876fn check_round_matches_participation(
877	part: &RoundParticipation,
878	new_vtxos: &[Vtxo<Full>],
879	funding_tx: &Transaction,
880) -> anyhow::Result<()> {
881	ensure!(new_vtxos.len() == part.outputs.len(),
882		"unexpected number of VTXOs: got {}, expected {}", new_vtxos.len(), part.outputs.len(),
883	);
884
885	for (vtxo, req) in new_vtxos.iter().zip(&part.outputs) {
886		ensure!(vtxo.amount() == req.amount,
887			"unexpected VTXO amount: got {}, expected {}", vtxo.amount(), req.amount,
888		);
889		ensure!(*vtxo.policy() == req.policy,
890			"unexpected VTXO policy: got {:?}, expected {:?}", vtxo.policy(), req.policy,
891		);
892
893		// We accept the VTXO if only the hArk transition (last) failure happens
894		check_vtxo_fails_hash_lock(funding_tx, vtxo)?;
895	}
896
897	Ok(())
898}
899
900/// Check the confirmation status of a funding tx
901///
902/// Returns true if the funding tx is confirmed deeply enough for us to accept it.
903/// The required number of confirmations depends on the wallet's configuration.
904///
905/// Returns false if the funding tx seems valid but not confirmed yet.
906///
907/// Returns an error if the chain source fails or if we can't submit the tx to the
908/// mempool, suggesting it might be double spent.
909async fn check_funding_tx_confirmations(
910	wallet: &Wallet,
911	funding_txid: Txid,
912	funding_tx: &Transaction,
913) -> anyhow::Result<bool> {
914	let tip = wallet.inner.chain.tip().await.context("chain source error")?;
915	let conf_height = tip - wallet.inner.config.round_tx_required_confirmations + 1;
916	let tx_status = wallet.inner.chain.tx_status(funding_txid).await.context("chain source error")?;
917	trace!("Round funding tx {} confirmation status: {:?} (tip={})",
918		funding_txid, tx_status, tip,
919	);
920	match tx_status {
921		TxStatus::Confirmed(b) if b.height <= conf_height => Ok(true),
922		TxStatus::Mempool | TxStatus::Confirmed(_) => {
923			if wallet.inner.config.round_tx_required_confirmations == 0 {
924				debug!("Accepting round funding tx without confirmations because of configuration");
925				Ok(true)
926			} else {
927				trace!("Hark round funding tx not confirmed (deep enough) yet: {:?}", tx_status);
928				Ok(false)
929			}
930		},
931		TxStatus::NotFound => {
932			// let's try to submit it to our mempool
933			//TODO(stevenroose) change this to an explicit "testmempoolaccept" so that we can
934			// reliably distinguish the cases of our chain source having issues and the tx
935			// actually being rejected which suggests the round was double-spent
936			if let Err(e) = wallet.inner.chain.broadcast_tx(&funding_tx).await {
937				Err(anyhow!("hark funding tx {} server sent us is rejected by mempool (hex={}): {:#}",
938					funding_txid, serialize_hex(funding_tx), e,
939				))
940			} else {
941				trace!("hark funding tx {} was not in mempool but we broadcast it", funding_txid);
942				Ok(false)
943			}
944		},
945	}
946}
947
948enum HarkProgressResult {
949	RoundPending,
950	RoundNotFound,
951	FundingTxUnconfirmed {
952		funding_txid: Txid,
953	},
954	Ok {
955		funding_tx: Transaction,
956		new_vtxos: Vec<Vtxo<Full>>,
957	},
958}
959
960async fn progress_delegated(
961	wallet: &Wallet,
962	participation: &RoundParticipation,
963	unlock_hash: UnlockHash,
964) -> Result<HarkProgressResult, HarkForfeitError> {
965	let (mut srv, _) = wallet.require_server().await.map_err(HarkForfeitError::Err)?;
966
967	let resp = match srv.client.round_participation_status(protos::RoundParticipationStatusRequest {
968		unlock_hash: unlock_hash.to_byte_array().to_vec(),
969	}).await {
970		Ok(resp) => resp.into_inner(),
971		Err(err) if err.code() == tonic::Code::NotFound => {
972			return Ok(HarkProgressResult::RoundNotFound);
973		},
974		Err(err) => {
975			return Err(HarkForfeitError::Err(
976				anyhow::Error::from(err).context("error checking round participation status"),
977			));
978		},
979	};
980	let status = protos::RoundParticipationStatus::try_from(resp.status)
981		.context("unknown status from server")
982		.map_err(HarkForfeitError::Err)	?;
983
984	if status == protos::RoundParticipationStatus::RoundPartPending {
985		trace!("Hark round still pending");
986		return Ok(HarkProgressResult::RoundPending);
987	}
988
989	// Since we got here, we clearly don't think we're finished.
990	// So even if the server thinks we did the dance before, we need the
991	// cosignature on the leaf tx so we need to do the dance again.
992	// "Guilty feet have got no rhythm."
993	if status == protos::RoundParticipationStatus::RoundPartReleased {
994		let preimage = resp.unlock_preimage.as_ref().map(|p| p.as_hex());
995		warn!("Server says preimage was already released for hArk participation \
996			with unlock hash {}. Supposed preimage: {:?}", unlock_hash, preimage,
997		);
998	}
999
1000	let funding_tx_bytes = resp.round_funding_tx
1001		.context("funding txid should be provided when status is not pending")
1002		.map_err(HarkForfeitError::Err)?;
1003	let funding_tx = deserialize::<Transaction>(&funding_tx_bytes)
1004		.context("invalid funding txid")
1005		.map_err(HarkForfeitError::Err)?;
1006	let funding_txid = funding_tx.compute_txid();
1007	trace!("Funding tx for round participation with unlock hash {}: {} ({})",
1008		unlock_hash, funding_tx.compute_txid(), funding_tx_bytes.as_hex(),
1009	);
1010
1011	// Check the confirmation status of the funding tx
1012	match check_funding_tx_confirmations(wallet, funding_txid, &funding_tx).await {
1013		Ok(true) => {},
1014		Ok(false) => return Ok(HarkProgressResult::FundingTxUnconfirmed { funding_txid }),
1015		Err(e) => return Err(HarkForfeitError::Err(e.context("checking funding tx confirmations"))),
1016	}
1017
1018	let mut new_vtxos = resp.output_vtxos.into_iter()
1019		.map(|v| <Vtxo<Full>>::deserialize(&v))
1020		.collect::<Result<Vec<_>, _>>()
1021		.context("invalid output VTXOs from server")
1022		.map_err(HarkForfeitError::Err)?;
1023
1024	// Check that the vtxos match our participation in the exact order
1025	check_round_matches_participation(participation, &new_vtxos, &funding_tx)
1026		.context("new VTXOs received from server don't match our participation")
1027		.map_err(HarkForfeitError::Err)?;
1028
1029	hark_vtxo_swap(wallet, participation, &mut new_vtxos, &funding_tx, unlock_hash).await
1030		.context("error forfeiting hArk VTXOs")
1031		.map_err(HarkForfeitError::SentForfeits)?;
1032
1033	Ok(HarkProgressResult::Ok { funding_tx, new_vtxos })
1034}
1035
1036async fn progress_attempt(
1037	state: &mut AttemptState,
1038	wallet: &Wallet,
1039	part: &RoundParticipation,
1040	event: &RoundEvent,
1041) -> AttemptProgressResult {
1042	// we will match only the states and messages required to make progress,
1043	// all else we ignore, except an unexpected finish
1044
1045	match (state, event) {
1046
1047		(
1048			AttemptState::AwaitingUnsignedVtxoTree { cosign_keys, unlock_hash },
1049			RoundEvent::VtxoProposal(e),
1050		) => {
1051			trace!("Received VtxoProposal: {:#?}", e);
1052
1053			// Missing nonces means we restarted before signing —
1054			// abandon the attempt rather than reuse on retry.
1055			let secret_nonces = if let Some(first) = cosign_keys.first() {
1056				match wallet.inner.round_secret_nonces.take(&first.public_key()) {
1057					Some(n) => n,
1058					None => return AttemptProgressResult::Failed(anyhow!(
1059						"secret cosign nonces unavailable (likely after a restart); \
1060						 abandoning round attempt to avoid nonce reuse",
1061					)),
1062				}
1063			} else {
1064				vec![]
1065			};
1066
1067			match sign_vtxo_tree(
1068				wallet,
1069				part,
1070				&cosign_keys,
1071				secret_nonces,
1072				&e.unsigned_round_tx,
1073				&e.vtxos_spec,
1074				&e.cosign_agg_nonces,
1075			).await {
1076				Ok(()) => {
1077					AttemptProgressResult::Updated {
1078						new_state: AttemptState::AwaitingFinishedRound {
1079							unsigned_round_tx: e.unsigned_round_tx.clone(),
1080							vtxos_spec: e.vtxos_spec.clone(),
1081							unlock_hash: *unlock_hash,
1082						},
1083					}
1084				},
1085				Err(e) => {
1086					trace!("Error signing VTXO tree: {:#}", e);
1087					AttemptProgressResult::Failed(e)
1088				},
1089			}
1090		},
1091
1092		(
1093			AttemptState::AwaitingFinishedRound { unsigned_round_tx, vtxos_spec, unlock_hash },
1094			RoundEvent::Finished(RoundFinished { cosign_sigs, signed_round_tx, .. }),
1095		) => {
1096			if unsigned_round_tx.compute_txid() != signed_round_tx.compute_txid() {
1097				return AttemptProgressResult::Failed(anyhow!(
1098					"signed funding tx ({}) doesn't match tx received before ({})",
1099					signed_round_tx.compute_txid(), unsigned_round_tx.compute_txid(),
1100				));
1101			}
1102
1103			if let Err(e) = wallet.inner.chain.broadcast_tx(&signed_round_tx).await {
1104				warn!("Failed to broadcast signed round tx: {:#}", e);
1105			}
1106
1107			match construct_new_vtxos(
1108				part, unsigned_round_tx, vtxos_spec, cosign_sigs,
1109			).await {
1110				Ok(v) => AttemptProgressResult::Finished {
1111					funding_tx: signed_round_tx.clone(),
1112					vtxos: v,
1113					unlock_hash: *unlock_hash,
1114				},
1115				Err(e) => AttemptProgressResult::Failed(anyhow!(
1116					"failed to construct new VTXOs for round: {:#}", e,
1117				)),
1118			}
1119		},
1120
1121		(state, RoundEvent::Finished(RoundFinished { .. })) => {
1122			AttemptProgressResult::Failed(anyhow!(
1123				"unexpectedly received a finished round while we were in state {}",
1124				state.kind(),
1125			))
1126		},
1127
1128		(state, _) => {
1129			trace!("Ignoring round event {} in state {}", event.kind(), state.kind());
1130			AttemptProgressResult::NotUpdated
1131		},
1132	}
1133}
1134
1135async fn sign_vtxo_tree(
1136	wallet: &Wallet,
1137	participation: &RoundParticipation,
1138	cosign_keys: &[Keypair],
1139	secret_nonces: Vec<Vec<SecretNonce>>,
1140	unsigned_round_tx: &Transaction,
1141	vtxo_tree: &VtxoTreeSpec,
1142	cosign_agg_nonces: &[musig::AggregatedNonce],
1143) -> anyhow::Result<()> {
1144	let (mut srv, _) = wallet.require_server().await.context("server not available")?;
1145
1146	let vtxos_utxo = OutPoint::new(unsigned_round_tx.compute_txid(), ROUND_TX_VTXO_TREE_VOUT);
1147
1148	// Check that the proposal contains our inputs.
1149	let mut my_vtxos = participation.outputs.iter().collect::<Vec<_>>();
1150	for vtxo_req in vtxo_tree.iter_vtxos() {
1151		if let Some(i) = my_vtxos.iter().position(|v| {
1152			v.policy == vtxo_req.vtxo.policy && v.amount == vtxo_req.vtxo.amount
1153		}) {
1154			my_vtxos.swap_remove(i);
1155		}
1156	}
1157	if !my_vtxos.is_empty() {
1158		bail!("server didn't include all of our vtxos, missing: {:?}", my_vtxos);
1159	}
1160
1161	let unsigned_vtxos = vtxo_tree.clone().into_unsigned_tree(vtxos_utxo);
1162	trace!("Sending vtxo signatures to server...");
1163	// Sequential: SecretNonce is consume-once and not Clone, so we move
1164	// one Vec<SecretNonce> into cosign_branch per output. Going parallel
1165	// would require sharing the server connection across futures, which
1166	// isn't worth the complexity for a per-output RPC.
1167	for ((req, key), sec) in participation.outputs.iter().zip(cosign_keys).zip(secret_nonces) {
1168		let leaf_idx = unsigned_vtxos.spec.leaf_idx_of_req(req).expect("req included");
1169		let part_sigs = unsigned_vtxos.cosign_branch(
1170			&cosign_agg_nonces, leaf_idx, key, sec,
1171		).context("failed to cosign branch: our request not part of tree")?;
1172
1173		info!("Sending {} partial vtxo cosign signatures for pk {}",
1174			part_sigs.len(), key.public_key(),
1175		);
1176
1177		srv.client.provide_vtxo_signatures(protos::VtxoSignaturesRequest {
1178			pubkey: key.public_key().serialize().to_vec(),
1179			signatures: part_sigs.iter().map(|s| s.serialize().to_vec()).collect(),
1180		}).await.context("error sending vtxo signatures")?;
1181	}
1182	trace!("Done sending vtxo signatures to server");
1183
1184	Ok(())
1185}
1186
1187async fn construct_new_vtxos(
1188	participation: &RoundParticipation,
1189	unsigned_round_tx: &Transaction,
1190	vtxo_tree: &VtxoTreeSpec,
1191	vtxo_cosign_sigs: &[schnorr::Signature],
1192) -> anyhow::Result<Vec<Vtxo<Full>>> {
1193	let round_txid = unsigned_round_tx.compute_txid();
1194	let vtxos_utxo = OutPoint::new(round_txid, ROUND_TX_VTXO_TREE_VOUT);
1195	let vtxo_tree = vtxo_tree.clone().into_unsigned_tree(vtxos_utxo);
1196
1197	// Validate the vtxo tree and cosign signatures.
1198	if vtxo_tree.verify_cosign_sigs(&vtxo_cosign_sigs).is_err() {
1199		// bad server!
1200		bail!("Received incorrect vtxo cosign signatures from server");
1201	}
1202
1203	let signed_vtxos = vtxo_tree
1204		.into_signed_tree(vtxo_cosign_sigs.to_vec())
1205		.into_cached_tree();
1206
1207	let mut expected_vtxos = participation.outputs.iter().collect::<Vec<_>>();
1208	let total_nb_expected_vtxos = expected_vtxos.len();
1209
1210	let mut new_vtxos = vec![];
1211	for (idx, req) in signed_vtxos.spec.spec.vtxos.iter().enumerate() {
1212		if let Some(expected_idx) = expected_vtxos.iter().position(|r| **r == req.vtxo) {
1213			let vtxo = signed_vtxos.build_vtxo(idx);
1214
1215			// validate the received vtxos
1216			// This is more like a sanity check since we crafted them ourselves.
1217			check_vtxo_fails_hash_lock(unsigned_round_tx, &vtxo)
1218				.context("constructed invalid vtxo from tree")?;
1219
1220			info!("New VTXO from round: {} ({}, {})",
1221				vtxo.id(), vtxo.amount(), vtxo.policy_type(),
1222			);
1223
1224			new_vtxos.push(vtxo);
1225			expected_vtxos.swap_remove(expected_idx);
1226		}
1227	}
1228
1229	if !expected_vtxos.is_empty() {
1230		if expected_vtxos.len() == total_nb_expected_vtxos {
1231			// we must have done something wrong
1232			bail!("None of our VTXOs were present in round!");
1233		} else {
1234			bail!("Server included some of our VTXOs but not all: {} missing: {:?}",
1235				expected_vtxos.len(), expected_vtxos,
1236			);
1237		}
1238	}
1239	Ok(new_vtxos)
1240}
1241
1242//TODO(stevenroose) should be made idempotent
1243async fn persist_round_success(
1244	wallet: &Wallet,
1245	participation: &RoundParticipation,
1246	movement_id: Option<MovementId>,
1247	new_vtxos: &[Vtxo<Full>],
1248	funding_tx: &Transaction,
1249) -> anyhow::Result<()> {
1250	debug!("Persisting newly finished round. {} new vtxos, movement ID {:?}",
1251		new_vtxos.len(), movement_id,
1252	);
1253
1254	// we first try all actions that need to happen and only afterwards return errors
1255	// so that we achieve maximum success
1256
1257	let store_result = wallet.store_spendable_vtxos(new_vtxos).await
1258		.context("failed to store new VTXOs");
1259	let spent_result = wallet.mark_vtxos_as_spent(&participation.inputs).await
1260		.context("failed to mark input VTXOs as spent");
1261	let update_result = if let Some(mid) = movement_id {
1262		wallet.inner.movements.finish_movement_with_update(
1263			mid,
1264			MovementStatus::Successful,
1265			MovementUpdate::new()
1266				.produced_vtxos(new_vtxos)
1267				.metadata([("funding_txid".into(), serde_json::to_value(funding_tx.compute_txid())?)]),
1268		).await.context("failed to mark movement as finished")
1269	} else {
1270		Ok(())
1271	};
1272
1273	store_result?;
1274	spent_result?;
1275	update_result?;
1276
1277	Ok(())
1278}
1279
1280async fn persist_round_failure(
1281	wallet: &Wallet,
1282	participation: &RoundParticipation,
1283	movement_id: Option<MovementId>,
1284) -> anyhow::Result<()> {
1285	debug!("Attempting to persist the failure of a round with the movement ID {:?}", movement_id);
1286	let unlock_result = wallet.unlock_vtxos(&participation.inputs).await;
1287	let finish_result = if let Some(movement_id) = movement_id {
1288		wallet.inner.movements.finish_movement(movement_id, MovementStatus::Failed).await
1289	} else {
1290		Ok(())
1291	};
1292	if let Err(e) = &finish_result {
1293		error!("Failed to mark movement as failed: {:#}", e);
1294	}
1295	match (unlock_result, finish_result) {
1296		(Ok(()), Ok(())) => Ok(()),
1297		(Err(e), _) => Err(e),
1298		(_, Err(e)) => Err(anyhow!("Failed to mark movement as failed: {:#}", e)),
1299	}
1300}
1301
1302async fn update_funding_txid(
1303	wallet: &Wallet,
1304	movement_id: MovementId,
1305	funding_txid: Txid,
1306) -> anyhow::Result<()> {
1307	wallet.inner.movements.update_movement(
1308		movement_id,
1309		MovementUpdate::new()
1310			.metadata([("funding_txid".into(), serde_json::to_value(&funding_txid)?)])
1311	).await.context("Unable to update funding txid of round")
1312}
1313
1314/// In-memory store for MuSig2 secret cosign nonces used during round
1315/// signing. Entries are keyed by the first cosign pubkey of each round
1316/// attempt — that pubkey is freshly generated in `start_attempt`,
1317/// uniquely identifies the attempt within a process, and is reachable
1318/// from the persisted `AttemptState::AwaitingUnsignedVtxoTree`.
1319///
1320/// Nonces never touch disk: persisting them risks signing twice with
1321/// the same nonce, which is unsafe with MuSig2.
1322#[derive(Default)]
1323pub struct RoundSecretNonces {
1324	inner: parking_lot::Mutex<HashMap<bitcoin::secp256k1::PublicKey, Vec<Vec<SecretNonce>>>>,
1325}
1326
1327impl RoundSecretNonces {
1328	pub fn new() -> Self {
1329		Self { inner: parking_lot::Mutex::new(HashMap::new()) }
1330	}
1331
1332	/// Insert nonces under the given key, replacing any previous entry.
1333	pub fn stash(
1334		&self,
1335		first_cosign_pubkey: bitcoin::secp256k1::PublicKey,
1336		nonces: Vec<Vec<SecretNonce>>,
1337	) {
1338		self.inner.lock().insert(first_cosign_pubkey, nonces);
1339	}
1340
1341	/// Remove and return the nonces stashed under the given key.
1342	/// `None` after a process restart or if the entry was never stashed.
1343	pub fn take(
1344		&self,
1345		first_cosign_pubkey: &bitcoin::secp256k1::PublicKey,
1346	) -> Option<Vec<Vec<SecretNonce>>> {
1347		self.inner.lock().remove(first_cosign_pubkey)
1348	}
1349
1350	/// Drop the entry under the given key without returning its
1351	/// contents. Use when a stashed attempt is being replaced by a new
1352	/// one and its key would otherwise be unreachable.
1353	pub fn forget(&self, first_cosign_pubkey: &bitcoin::secp256k1::PublicKey) {
1354		self.inner.lock().remove(first_cosign_pubkey);
1355	}
1356}
1357
1358impl Wallet {
1359	/// Load and lock a single given round state (by id), waiting for the lock.
1360	///
1361	/// Returns `Some(state)` if the round state is found and locked, `None`
1362	/// if it is not found after acquiring the lock.
1363	pub async fn lock_wait_round_state(&self, id: RoundStateId) -> anyhow::Result<Option<StoredRoundState>> {
1364		let guard = self.inner.lock_manager.lock(
1365			&format!("{}.round.{}", self.fingerprint(), id),
1366			ROUND_LOCK_TIMEOUT,
1367		).await.with_context(|| format!(
1368			"timed out waiting for lock on round state {} (wallet {})",
1369			id, self.fingerprint(),
1370		))?;
1371
1372		if let Some(state) = self.inner.db.get_round_state_by_id(id).await? {
1373			return Ok(Some(state.lock(guard)));
1374		}
1375
1376		Ok(None)
1377	}
1378
1379	/// Ask the server when the next round is scheduled to start
1380	pub async fn next_round_start_time(&self) -> anyhow::Result<SystemTime> {
1381		let (mut srv, _) = self.require_server().await?;
1382		let ts = srv.client.next_round_time(protos::Empty {}).await?.into_inner().timestamp;
1383		Ok(UNIX_EPOCH.checked_add(Duration::from_secs(ts)).context("invalid timestamp")?)
1384	}
1385
1386	/// Start a new round participation
1387	///
1388	/// This function will store the state in the db and mark the VTXOs as locked.
1389	///
1390	/// ### Return
1391	///
1392	/// - By default, the returned state will be locked to prevent race conditions.
1393	/// To unlock the state, [StoredRoundState::unlock()] can be called.
1394	pub async fn join_next_round(
1395		&self,
1396		participation: RoundParticipation,
1397		movement_kind: Option<RoundMovement>,
1398	) -> anyhow::Result<StoredRoundState> {
1399		let movement = if let Some(kind) = movement_kind {
1400			Some(self.inner.movements.new_guarded_movement_with_update(
1401				Subsystem::ROUND,
1402				kind.to_string(),
1403				OnDropStatus::Failed,
1404				participation.to_movement_update()?
1405			).await?)
1406		} else {
1407			None
1408		};
1409		let movement_id = movement.as_ref().map(|m| m.id());
1410		let input_vtxos = participation.inputs.iter().map(|v| v.id()).collect::<Vec<_>>();
1411		let state = RoundState::new_interactive(participation, movement_id);
1412
1413		self.lock_vtxos(&input_vtxos, movement_id.map(|m| m.into())).await
1414			.context("failed to lock input VTXOs")?;
1415
1416		match (async || {
1417			let id = self.inner.db.store_round_state(&state).await?;
1418			Ok(self.lock_wait_round_state(id).await?
1419				.context("failed to lock fresh round state")?)
1420		})().await {
1421			Ok(state) => {
1422				if let Some(mut m) = movement {
1423					m.stop();
1424				}
1425				Ok(state)
1426			},
1427			Err(e) => {
1428				self.unlock_vtxos(&input_vtxos).await
1429					.context("failed to unlock input VTXOs")?;
1430				if let Some(mut m) = movement {
1431					m.fail().await.context("failed to mark movement as failed")?;
1432				}
1433				Err(e)
1434			},
1435		}
1436	}
1437
1438	/// Join a round in delegated mode.
1439	///
1440	/// When `scheduled_height` is set, the server won't include the participation in a round
1441	/// before the chain tip reaches it. When `None`, it is eligible for the next round (see
1442	/// [Wallet::join_next_round_delegated]).
1443	pub async fn join_delegated_round(
1444		&self,
1445		participation: RoundParticipation,
1446		movement_kind: Option<RoundMovement>,
1447		scheduled_height: Option<BlockHeight>,
1448	) -> anyhow::Result<StoredRoundState<Unlocked>> {
1449		let movement = if let Some(kind) = movement_kind {
1450			Some(self.inner.movements.new_guarded_movement_with_update(
1451				Subsystem::ROUND,
1452				kind.to_string(),
1453				OnDropStatus::Failed,
1454				participation.to_movement_update()?,
1455			).await?)
1456		} else {
1457			None
1458		};
1459		let movement_id = movement.as_ref().map(|m| m.id());
1460
1461		match self.join_delegated_round_inner(participation, movement_id, scheduled_height).await {
1462			Ok(state) => {
1463				if let Some(mut m) = movement {
1464					m.stop();
1465				}
1466				Ok(state)
1467			},
1468			Err(e) => {
1469				if let Some(mut m) = movement {
1470					m.fail().await.context("error marking movement as failed")?;
1471				}
1472				Err(e)
1473			},
1474		}
1475	}
1476
1477	/// Join the next delegated round, i.e. [Wallet::join_delegated_round] with no scheduled
1478	/// height, so the participation is eligible for the very next round.
1479	pub async fn join_next_round_delegated(
1480		&self,
1481		participation: RoundParticipation,
1482		movement_kind: Option<RoundMovement>,
1483	) -> anyhow::Result<StoredRoundState<Unlocked>> {
1484		self.join_delegated_round(participation, movement_kind, None).await
1485	}
1486
1487	/// Join a round in delegated mode.
1488	///
1489	/// When `scheduled_height` is set, the server won't include the participation in a round
1490	/// before the chain tip reaches it. When `None`, it is eligible for the next round.
1491	async fn join_delegated_round_inner(
1492		&self,
1493		participation: RoundParticipation,
1494		movement_id: Option<MovementId>,
1495		scheduled_height: Option<BlockHeight>,
1496	) -> anyhow::Result<StoredRoundState<Unlocked>> {
1497		let (mut srv, _) = self.require_server().await?;
1498
1499		// Get mailbox identifier for VTXO delivery
1500		let unblinded_mailbox_id = self.mailbox_identifier();
1501
1502		// Register VTXO transaction chains with server before round participation
1503		self.register_vtxo_transactions_with_server(&participation.inputs).await
1504			.context("failed to register input vtxo transactions with server")?;
1505
1506		// Generate attestations for input vtxos
1507		let mut input_vtxos = Vec::with_capacity(participation.inputs.len());
1508		for vtxo in participation.inputs.iter() {
1509			let keypair = self.get_vtxo_key(vtxo).await
1510				.context("failed to get vtxo keypair")?;
1511			input_vtxos.push(protos::InputVtxo {
1512				vtxo_id: vtxo.id().to_bytes().to_vec(),
1513				attestation: {
1514					let attestation = DelegatedRoundParticipationAttestation::new(
1515						vtxo.id(), &participation.outputs, &keypair,
1516					);
1517					attestation.serialize()
1518				},
1519			});
1520		}
1521
1522		// Build proto VtxoRequests
1523		let vtxo_requests = participation.outputs.iter()
1524			.map(|req|
1525				protos::VtxoRequest {
1526					policy: req.policy.serialize(),
1527					amount: req.amount.to_sat(),
1528			})
1529			.collect::<Vec<_>>();
1530
1531		// Submit participation to server and get unlock_hash
1532		let resp = srv.client.submit_round_participation(protos::RoundParticipationRequest {
1533			input_vtxos,
1534			vtxo_requests,
1535			unblinded_mailbox_id: Some(unblinded_mailbox_id.serialize()),
1536			scheduled_height,
1537		}).await.context("error submitting round participation to server")?.into_inner();
1538
1539		let unlock_hash = UnlockHash::from_bytes(resp.unlock_hash)
1540			.context("invalid unlock hash from server")?;
1541
1542		let state = RoundState::new_delegated(participation, unlock_hash, movement_id);
1543
1544		info!("Delegated round participation submitted, it will automatically execute \
1545			when you next sync your wallet after the round happened \
1546			(and has sufficient confirmations).",
1547		);
1548
1549		let id = self.inner.db.store_round_state(&state).await?;
1550		Ok(StoredRoundState::new(id, state))
1551	}
1552
1553	/// Join an already-started round attempt interactively, submitting our
1554	/// participation synchronously.
1555	///
1556	/// Unlike [Wallet::join_next_round] — which stores a pending participation
1557	/// and waits for a round to start before submitting inside the round state
1558	/// machine — this submits to the in-flight `attempt` right away. This allows
1559	/// us to react to any unspendable VTXOs and exclude them from the refresh.
1560	pub(crate) async fn join_attempt_interactive(
1561		&self,
1562		participation: RoundParticipation,
1563		attempt: &RoundAttempt,
1564		movement_kind: Option<RoundMovement>,
1565	) -> anyhow::Result<StoredRoundState<Unlocked>> {
1566		let movement = if let Some(kind) = movement_kind {
1567			Some(self.inner.movements.new_guarded_movement_with_update(
1568				Subsystem::ROUND,
1569				kind.to_string(),
1570				OnDropStatus::Failed,
1571				participation.to_movement_update()?,
1572			).await?)
1573		} else {
1574			None
1575		};
1576		let movement_id = movement.as_ref().map(|m| m.id());
1577
1578		let input_ids = participation.inputs.iter().map(|v| v.id()).collect::<Vec<_>>();
1579		self.lock_vtxos(&input_ids, movement_id.map(|m| m.into())).await
1580			.context("error locking input VTXOs")?;
1581
1582		match self.join_attempt_interactive_inner(participation, attempt, movement_id).await {
1583			Ok(state) => {
1584				if let Some(mut m) = movement {
1585					m.stop();
1586				}
1587				Ok(state)
1588			},
1589			Err(e) => {
1590				self.unlock_vtxos(&input_ids).await
1591					.context("error unlocking input VTXOs")?;
1592				if let Some(mut m) = movement {
1593					m.fail().await.context("error marking movement as failed")?;
1594				}
1595				Err(e)
1596			},
1597		}
1598	}
1599
1600	async fn join_attempt_interactive_inner(
1601		&self,
1602		participation: RoundParticipation,
1603		attempt: &RoundAttempt,
1604		movement_id: Option<MovementId>,
1605	) -> anyhow::Result<StoredRoundState<Unlocked>> {
1606		// Submit synchronously to the in-flight attempt. On rejection the
1607		// tonic::Status (carrying the unusable input ids in its `identifiers`
1608		// metadata) propagates up the error chain untouched.
1609		let attempt_state = start_attempt(self, &participation, attempt).await?;
1610
1611		let mut state = RoundState::new_interactive(participation, movement_id);
1612		state.flow = RoundFlowState::InteractiveOngoing {
1613			round_seq: attempt.round_seq,
1614			attempt_seq: attempt.attempt_seq,
1615			state: attempt_state,
1616		};
1617
1618		let id = self.inner.db.store_round_state(&state).await?;
1619		Ok(StoredRoundState::new(id, state))
1620	}
1621
1622	/// Get all pending round states
1623	pub async fn pending_round_state_ids(&self) -> anyhow::Result<Vec<RoundStateId>> {
1624		self.inner.db.get_pending_round_state_ids().await
1625	}
1626
1627	/// Get all pending round states
1628	pub async fn pending_round_states(&self) -> anyhow::Result<Vec<StoredRoundState<Unlocked>>> {
1629		let ids = self.inner.db.get_pending_round_state_ids().await?;
1630		let mut states = Vec::with_capacity(ids.len());
1631		for id in ids {
1632			if let Some(state) = self.inner.db.get_round_state_by_id(id).await? {
1633				states.push(state);
1634			}
1635		}
1636		Ok(states)
1637	}
1638
1639	/// Balance locked in pending rounds
1640	pub async fn pending_round_balance(&self) -> anyhow::Result<Amount> {
1641		let mut ret = Amount::ZERO;
1642		for round in self.pending_round_states().await? {
1643			ret += round.state().pending_balance();
1644		}
1645		Ok(ret)
1646	}
1647
1648	/// Returns all VTXOs that are locked in a pending round
1649	///
1650	/// This excludes all input VTXOs for which the output VTXOs have already
1651	/// been created.
1652	pub async fn pending_round_input_vtxos(&self) -> anyhow::Result<Vec<WalletVtxo>> {
1653		let mut ret = Vec::new();
1654		for round in self.pending_round_states().await? {
1655			let inputs = round.state().locked_pending_inputs();
1656			ret.reserve(inputs.len());
1657			for input in inputs {
1658				let v = self.get_vtxo_by_id(input.id()).await
1659					.context("unknown round input VTXO")?;
1660				ret.push(v);
1661			}
1662		}
1663		Ok(ret)
1664	}
1665
1666	/// Sync pending rounds that have finished but are waiting for confirmations
1667	pub async fn sync_pending_rounds(&self) -> anyhow::Result<HashMap<RoundStateId, RoundStatus>> {
1668		let states = self.pending_round_states().await?;
1669		if states.is_empty() {
1670			return Ok(HashMap::new());
1671		}
1672
1673		debug!("Syncing {} pending round states...", states.len());
1674
1675		let ret = Arc::new(parking_lot::Mutex::new(HashMap::with_capacity(states.len())));
1676		tokio_stream::iter(states).for_each_concurrent(10, |state| {
1677			let ret = ret.clone();
1678			async move {
1679				// not processing events here
1680				if state.state().ongoing_participation() {
1681					return;
1682				}
1683
1684				let mut state = match self.lock_wait_round_state(state.id()).await {
1685					Ok(Some(state)) => state,
1686					Ok(None) => return,
1687					Err(e) => {
1688						warn!("Error locking round state: {:#}", e);
1689						return;
1690					},
1691				};
1692
1693				let status = match state.state_mut().sync(self).await {
1694					Ok(s) => s,
1695					Err(e) => {
1696						warn!("Error syncing round: {:#}", e);
1697						return;
1698					},
1699				};
1700				trace!("Synced round #{}, status: {:?}", state.id(), status);
1701				match status {
1702					RoundStatus::Confirmed { funding_txid } => {
1703						info!("Round confirmed. Funding tx {}", funding_txid);
1704						if let Err(e) = self.inner.db.remove_round_state(&state).await {
1705							warn!("Error removing confirmed round state from db: {:#}", e);
1706						}
1707					},
1708					RoundStatus::Unconfirmed { funding_txid } => {
1709						info!("Waiting for confirmations for round funding tx {}", funding_txid);
1710						if let Err(e) = self.inner.db.update_round_state(&state).await {
1711							warn!("Error updating pending round state in db: {:#}", e);
1712						}
1713					},
1714					RoundStatus::Pending => {
1715						if let Err(e) = self.inner.db.update_round_state(&state).await {
1716							warn!("Error updating pending round state in db: {:#}", e);
1717						}
1718					},
1719					RoundStatus::Failed { ref error } => {
1720						error!("Round failed: {}", error);
1721						if let Err(e) = self.inner.db.remove_round_state(&state).await {
1722							warn!("Error removing failed round state from db: {:#}", e);
1723						}
1724					},
1725					RoundStatus::Canceled => {
1726						error!("Round canceled");
1727						if let Err(e) = self.inner.db.remove_round_state(&state).await {
1728							warn!("Error removing canceled round state from db: {:#}", e);
1729						}
1730					},
1731				}
1732				ret.lock().insert(state.id(), status);
1733			}
1734		}).await;
1735
1736		Ok(Arc::into_inner(ret).expect("only ref left").into_inner())
1737	}
1738
1739	/// Fetch last round event from server
1740	async fn get_last_round_event(&self) -> anyhow::Result<RoundEvent> {
1741		let (mut srv, _) = self.require_server().await?;
1742		let e = srv.client.last_round_event(protos::Empty {}).await?.into_inner();
1743		Ok(RoundEvent::try_from(e).context("invalid event format from server")?)
1744	}
1745
1746	async fn inner_process_event(
1747		&self,
1748		state: &mut StoredRoundState,
1749		event: Option<&RoundEvent>,
1750	) {
1751		if let Some(event) = event && state.state().ongoing_participation() {
1752			let updated = state.state_mut().process_event(self, &event).await;
1753			if updated {
1754				if let Err(e) = self.inner.db.update_round_state(&state).await {
1755					error!("Error storing round state #{} after progress: {:#}", state.id(), e);
1756				}
1757			}
1758		}
1759
1760		match state.state_mut().sync(self).await {
1761			Err(e) => warn!("Error syncing round #{}: {:#}", state.id(), e),
1762			Ok(s) if s.is_final() => {
1763				info!("Round #{} finished with result: {:?}", state.id(), s);
1764				if let Err(e) = self.inner.db.remove_round_state(&state).await {
1765					warn!("Failed to remove finished round #{} from db: {:#}", state.id(), e);
1766				}
1767			},
1768			Ok(s) => {
1769				trace!("Round state #{} is now in state {:?}", state.id(), s);
1770				if let Err(e) = self.inner.db.update_round_state(&state).await {
1771					warn!("Error storing round state #{}: {:#}", state.id(), e);
1772				}
1773			},
1774		}
1775	}
1776
1777	/// Try to make incremental progress on all pending round states
1778	///
1779	/// If the `last_round_event` argument is not provided, it will be fetched
1780	/// from the server.
1781	pub async fn progress_pending_rounds(
1782		&self,
1783		last_round_event: Option<&RoundEvent>,
1784	) -> anyhow::Result<()> {
1785		let states = self.pending_round_states().await?;
1786		if states.is_empty() {
1787			return Ok(());
1788		}
1789
1790		info!("Processing {} rounds...", states.len());
1791
1792		let mut last_round_event = last_round_event.map(|e| Cow::Borrowed(e));
1793
1794		let has_ongoing_participation = states.iter()
1795			.any(|s| s.state().ongoing_participation());
1796		if has_ongoing_participation && last_round_event.is_none() {
1797			match self.get_last_round_event().await {
1798				Ok(e) => last_round_event = Some(Cow::Owned(e)),
1799				Err(e) => {
1800					warn!("Error fetching round event, \
1801						failed to progress ongoing rounds: {:#}", e);
1802				},
1803			}
1804		}
1805
1806		let event = last_round_event.as_ref().map(|c| c.as_ref());
1807
1808		let futs = states.into_iter().map(async |state| {
1809			let locked = self.lock_wait_round_state(state.id()).await?;
1810			if let Some(mut locked) = locked {
1811				self.inner_process_event(&mut locked, event).await;
1812			}
1813			Ok::<_, anyhow::Error>(())
1814		});
1815
1816		futures::future::join_all(futs).await;
1817
1818		Ok(())
1819	}
1820
1821	pub async fn subscribe_round_events(&self)
1822		-> anyhow::Result<impl Stream<Item = anyhow::Result<RoundEvent>> + Unpin>
1823	{
1824		let (mut srv, _) = self.require_server().await?;
1825		let mut req = tonic::IntoRequest::into_request(protos::Empty {});
1826		req.set_timeout(SUBSCRIBE_REQUEST_TIMEOUT);
1827		let events = srv.client.subscribe_rounds(req).await?
1828			.into_inner().map(|m| {
1829				let m = m.context("received error on event stream")?;
1830				let e = RoundEvent::try_from(m.clone())
1831					.with_context(|| format!("error converting rpc round event: {:?}", m))?;
1832				trace!("Received round event: {}", e);
1833				Ok::<_, anyhow::Error>(e)
1834			});
1835		Ok(events)
1836	}
1837
1838	/// A blocking call that will try to perform a full round participation
1839	/// for all ongoing rounds
1840	///
1841	/// Returns only once there is no ongoing rounds anymore.
1842	pub async fn participate_ongoing_rounds(&self) -> anyhow::Result<()> {
1843		let mut events = self.subscribe_round_events().await?;
1844
1845		loop {
1846			// NB: we need to load all ongoing rounds on every iteration here
1847			// because some might be finished by another call
1848			let state_ids = self.pending_round_states().await?.iter()
1849				.filter(|s| s.state().ongoing_participation())
1850				.map(|s| s.id())
1851				.collect::<Vec<_>>();
1852
1853			if state_ids.is_empty() {
1854				info!("All rounds handled");
1855				return Ok(());
1856			}
1857
1858			let event = events.next().await
1859				.context("events stream broke")?
1860				.context("error on event stream")?;
1861
1862			let futs = state_ids.into_iter().map(async |state| {
1863				let locked = self.lock_wait_round_state(state).await?;
1864				if let Some(mut locked) = locked {
1865					self.inner_process_event(&mut locked, Some(&event)).await;
1866				}
1867				Ok::<_, anyhow::Error>(())
1868			});
1869
1870			futures::future::join_all(futs).await;
1871		}
1872	}
1873
1874	/// Will cancel all pending rounds that can safely be canceled
1875	///
1876	/// All rounds that have not started yet can safely be canceled,
1877	/// as well as rounds where we have not yet signed any forfeit txs.
1878	pub async fn cancel_all_pending_rounds(&self) -> anyhow::Result<()> {
1879		// initial load to get all pending round states ids
1880		let state_ids = self.inner.db.get_pending_round_state_ids().await?;
1881
1882		let futures = state_ids.into_iter().map(|state_id| {
1883			async move {
1884				// wait for lock and load again to ensure most recent state
1885				let mut state = match self.lock_wait_round_state(state_id).await {
1886					Ok(Some(s)) => s,
1887					Ok(None) => return,
1888					Err(e) => return warn!("Error loading round state #{}: {:#}", state_id, e),
1889				};
1890
1891				match state.state_mut().try_cancel(self).await {
1892					Ok(true) => {
1893						if let Err(e) = self.inner.db.remove_round_state(&state).await {
1894							warn!("Error removing canceled round state from db: {:#}", e);
1895						}
1896					},
1897					Ok(false) => {},
1898					Err(e) => warn!("Error trying to cancel round #{}: {:#}", state_id, e),
1899				}
1900			}
1901		});
1902
1903		join_all(futures).await;
1904
1905		Ok(())
1906	}
1907
1908	/// Try to cancel the given round
1909	pub async fn cancel_pending_round(&self, id: RoundStateId) -> anyhow::Result<()> {
1910		let mut state = self.lock_wait_round_state(id).await?
1911			.context("round state not found")?;
1912
1913		if state.state_mut().try_cancel(self).await.context("failed to cancel round")? {
1914			self.inner.db.remove_round_state(&state).await
1915				.context("error removing canceled round state from db")?;
1916		} else {
1917			bail!("failed to cancel round");
1918		}
1919
1920		Ok(())
1921	}
1922
1923	/// Participate in a round
1924	///
1925	/// This function will start a new round participation and block until
1926	/// the round is finished.
1927	/// After this method returns the round state will be kept active until
1928	/// the round tx fully confirms.
1929	pub(crate) async fn participate_round(
1930		&self,
1931		participation: RoundParticipation,
1932		movement_kind: Option<RoundMovement>,
1933	) -> anyhow::Result<RoundStatus> {
1934		let state = self.join_next_round(participation, movement_kind).await?;
1935
1936		info!("Waiting for a round start...");
1937		let mut events = self.subscribe_round_events().await?;
1938
1939		self.drive_round_state(state, &mut events).await
1940	}
1941
1942	/// Drive an already-joined round state to its final [RoundStatus], blocking
1943	/// on `events` and persisting each update.
1944	///
1945	/// Shared by [Wallet::participate_round] and the blocking maintenance
1946	/// refresh: the latter submits its participation up-front (against an
1947	/// in-flight attempt) and then drives the resulting round to completion
1948	/// here.
1949	pub(crate) async fn drive_round_state<S>(
1950		&self,
1951		mut state: StoredRoundState,
1952		events: &mut S,
1953	) -> anyhow::Result<RoundStatus>
1954	where
1955		S: Stream<Item = anyhow::Result<RoundEvent>> + Unpin,
1956	{
1957		loop {
1958			if !state.state().ongoing_participation() {
1959				let status = state.state_mut().sync(self).await?;
1960				match status {
1961					RoundStatus::Failed { error } => bail!("round failed: {}", error),
1962					RoundStatus::Canceled => bail!("round canceled"),
1963					status => return Ok(status),
1964				}
1965			}
1966
1967			let event = events.next().await
1968				.context("events stream broke")?
1969				.context("error on event stream")?;
1970			if state.state_mut().process_event(self, &event).await {
1971				self.inner.db.update_round_state(&state).await?;
1972			}
1973		}
1974	}
1975}
1976
1977#[cfg(test)]
1978mod test {
1979	use super::*;
1980
1981	use bitcoin::secp256k1::Secp256k1;
1982
1983	fn pubkey() -> bitcoin::secp256k1::PublicKey {
1984		let secp = Secp256k1::new();
1985		Keypair::new(&secp, &mut rand::thread_rng()).public_key()
1986	}
1987
1988	fn nonces() -> Vec<Vec<SecretNonce>> {
1989		let secp = Secp256k1::new();
1990		let key = Keypair::new(&secp, &mut rand::thread_rng());
1991		// Shape mirrors what start_attempt produces: outer Vec is one
1992		// entry per cosign keypair, inner Vec is the tree-depth set of
1993		// pre-generated nonces.
1994		vec![vec![musig::nonce_pair(&key).0, musig::nonce_pair(&key).0]]
1995	}
1996
1997	#[test]
1998	fn stash_and_take() {
1999		let store = RoundSecretNonces::new();
2000		let k = pubkey();
2001		store.stash(k, nonces());
2002
2003		assert!(store.take(&k).is_some());
2004	}
2005
2006	#[test]
2007	fn cannot_take_twice() {
2008		let store = RoundSecretNonces::new();
2009		let k = pubkey();
2010		store.stash(k, nonces());
2011
2012		assert!(store.take(&k).is_some());
2013		assert!(store.take(&k).is_none());
2014	}
2015
2016	#[test]
2017	fn cannot_take_after_forget() {
2018		let store = RoundSecretNonces::new();
2019		let k = pubkey();
2020		store.stash(k, nonces());
2021		store.forget(&k);
2022
2023		assert!(store.take(&k).is_none());
2024	}
2025
2026	#[test]
2027	fn stash_overrides_stash() {
2028		let secp = Secp256k1::new();
2029		let key = Keypair::new(&secp, &mut rand::thread_rng());
2030		let nonces_1 = vec![vec![musig::nonce_pair(&key).0]];
2031		let nonces_2 = vec![];
2032
2033		let store = RoundSecretNonces::new();
2034		store.stash(key.public_key(), nonces_1);
2035		store.stash(key.public_key(), nonces_2);
2036
2037		let taken = store.take(&key.public_key()).expect("nonces present");
2038		assert_eq!(taken.len(), 0);
2039	}
2040}