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