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