Skip to main content

bark/round/
mod.rs

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