Skip to main content

bark/actions/
mod.rs

1//! Wallet action infrastructure.
2//!
3//! A *wallet action* is a multi-step operation that moves vtxos (e.g. a
4//! lightning send). Each step is small, persists its outcome to a
5//! checkpoint, and is safe to re-drive after a crash.
6//!
7//! This module defines the generic vocabulary; per-kind machinery (state
8//! machines, transition functions) lives in submodules.
9
10pub mod arkoor_send;
11pub mod board;
12pub mod lightning;
13pub mod offboard;
14
15use std::time::Duration;
16
17use log::{debug, trace, warn};
18use server_rpc::StatusExt;
19
20use crate::{Wallet, WalletVtxo};
21use crate::actions::arkoor_send::ArkoorSend;
22use crate::actions::board::Board;
23use crate::actions::lightning::pay::LightningSend;
24use crate::actions::lightning::receive::LightningReceive;
25use crate::actions::offboard::Offboard;
26use crate::lock_manager::LockGuard;
27use crate::utils::time::sleep;
28use crate::vtxo::{VtxoState, VtxoStateKind, VtxoValidationError};
29
30pub(crate) const BASE_RETRY_BACKOFF: Duration = Duration::from_secs(1);
31
32/// Tagged union of every kind of checkpoint the wallet persists.
33///
34/// Used as the serialization boundary for the
35/// `bark_wallet_action_checkpoint` table; per-kind logic lives on each
36/// variant's payload type.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub enum WalletActionCheckpoint {
39	LightningSend(LightningSend),
40	LightningReceive(LightningReceive),
41	ArkoorSend(ArkoorSend),
42	Board(Board),
43	Offboard(Offboard),
44}
45
46impl WalletActionCheckpoint {
47	pub fn id(&self) -> WalletActionId {
48		match self {
49			WalletActionCheckpoint::LightningSend(s) => s.id(),
50			WalletActionCheckpoint::LightningReceive(r) => r.id(),
51			WalletActionCheckpoint::ArkoorSend(s) => s.id(),
52			WalletActionCheckpoint::Board(s) => s.id(),
53			WalletActionCheckpoint::Offboard(o) => o.id(),
54		}
55	}
56
57	pub fn as_lightning_send(&self) -> Option<&LightningSend> {
58		match self {
59			WalletActionCheckpoint::LightningSend(s) => Some(s),
60			_ => None,
61		}
62	}
63
64	pub fn into_lightning_send(self) -> Option<LightningSend> {
65		match self {
66			WalletActionCheckpoint::LightningSend(s) => Some(s),
67			_ => None,
68		}
69	}
70
71	pub fn as_lightning_receive(&self) -> Option<&LightningReceive> {
72		match self {
73			WalletActionCheckpoint::LightningReceive(r) => Some(r),
74			_ => None,
75		}
76	}
77
78	pub fn into_lightning_receive(self) -> Option<LightningReceive> {
79		match self {
80			WalletActionCheckpoint::LightningReceive(r) => Some(r),
81			_ => None,
82		}
83	}
84
85	pub fn as_arkoor_send(&self) -> Option<&ArkoorSend> {
86		match self {
87			WalletActionCheckpoint::ArkoorSend(s) => Some(s),
88			_ => None,
89		}
90	}
91
92	pub fn into_arkoor_send(self) -> Option<ArkoorSend> {
93		match self {
94			WalletActionCheckpoint::ArkoorSend(s) => Some(s),
95			_ => None,
96		}
97	}
98
99	pub fn as_board(&self) -> Option<&Board> {
100		match self {
101			WalletActionCheckpoint::Board(s) => Some(s),
102			_ => None,
103		}
104	}
105
106	pub fn into_board(self) -> Option<Board> {
107		match self {
108			WalletActionCheckpoint::Board(s) => Some(s),
109			_ => None,
110		}
111	}
112
113	pub fn as_offboard(&self) -> Option<&Offboard> {
114		match self {
115			WalletActionCheckpoint::Offboard(o) => Some(o),
116			_ => None,
117		}
118	}
119
120	pub fn into_offboard(self) -> Option<Offboard> {
121		match self {
122			WalletActionCheckpoint::Offboard(o) => Some(o),
123			_ => None,
124		}
125	}
126}
127
128impl From<LightningSend> for WalletActionCheckpoint {
129	fn from(s: LightningSend) -> Self {
130		WalletActionCheckpoint::LightningSend(s)
131	}
132}
133
134impl From<LightningReceive> for WalletActionCheckpoint {
135	fn from(r: LightningReceive) -> Self {
136		WalletActionCheckpoint::LightningReceive(r)
137	}
138}
139
140impl From<ArkoorSend> for WalletActionCheckpoint {
141	fn from(s: ArkoorSend) -> Self {
142		WalletActionCheckpoint::ArkoorSend(s)
143	}
144}
145
146impl From<Board> for WalletActionCheckpoint {
147	fn from(s: Board) -> Self {
148		WalletActionCheckpoint::Board(s)
149	}
150}
151
152impl From<Offboard> for WalletActionCheckpoint {
153	fn from(o: Offboard) -> Self {
154		WalletActionCheckpoint::Offboard(o)
155	}
156}
157
158/// Stable identifier for a wallet action.
159///
160/// The id must be derivable from the action's identity (e.g. the payment
161/// hash for a lightning send) so that restarting the same action picks
162/// up the same checkpoint row.
163pub type WalletActionId = String;
164
165/// Outcome of one `WalletAction::advance` call.
166///
167/// The executor uses these to decide whether to persist, loop, schedule
168/// a wake-up or remove the checkpoint.
169pub enum Advance<A> {
170	/// Transition to a new state. Executor persists `state` and calls
171	/// `advance` on it.
172	Next(A),
173	/// Pause until something external (notification, periodic sync) or
174	/// `wake_after` (when set) re-drives the action. Executor persists
175	/// `state` and returns.
176	///
177	/// `wake_after` is a hint, not a guarantee: it lives only in this
178	/// process and is lost across restarts. `advance` MUST tolerate
179	/// being called before the hint has elapsed.
180	///
181	/// `error` is the error that caused the park, if any.
182	Park {
183		state: A,
184		wake_after: Option<Duration>,
185		error: Option<AdvanceError>,
186	},
187	/// Terminal: executor removes the checkpoint row. Any permanent fact
188	/// the action wants to retain (e.g. an "invoice paid" record) must
189	/// be written to its own table before returning `Done`.
190	Done,
191	/// Terminal: executor removes the checkpoint row because of a fatal error.
192	/// This advance should only be returned when no server change occured yet
193	/// or when process has checked server status is expected one and it is
194	/// safe to remove checkpoint
195	Failed(anyhow::Error),
196}
197
198#[derive(Debug, thiserror::Error)]
199pub enum AdvanceError {
200	#[error("An error occurred while communicating with the server: {0}")]
201	Server(tonic::Status),
202	#[error("An error occurred while validating a VTXO: {0}")]
203	Vtxo(VtxoValidationError),
204	#[error("An error occurred while processing the action: {0}")]
205	Other(#[from] anyhow::Error),
206}
207
208impl AdvanceError {
209	pub fn is_server_rejection(&self) -> bool {
210		match self {
211			AdvanceError::Server(err) => err.is_rejection(),
212			_ => false,
213		}
214	}
215}
216
217pub fn park_with_backoff<A: WalletAction>(state: A, attempts: u32) -> Advance<A> {
218	let delay = attempts.pow(2) * BASE_RETRY_BACKOFF;
219	debug!("action {} retrying; sleeping {:?} before re-drive", state.id(), delay);
220	Advance::Park { state, wake_after: Some(delay), error: None }
221}
222
223/// Whether to double-drive each action step to check reentrancy, set via the
224/// `BARK_DOUBLE_DRIVE_ACTIONS` env var. Debug-only, compiled out of release.
225/// See `just int-bark-sdk-action-reentrancy`.
226#[cfg(debug_assertions)]
227fn double_drive_actions() -> bool {
228	std::env::var_os("BARK_DOUBLE_DRIVE_ACTIONS").is_some()
229}
230
231/// Assert advancing the same state twice produced an equivalent outcome (same
232/// [`Advance`] kind, same checkpoint for non-terminal kinds); a divergence is a
233/// non-idempotency bug and panics, naming the offending step. Two errors count
234/// as equivalent: [`AdvanceError`] isn't comparable.
235#[cfg(debug_assertions)]
236fn assert_reentrant<A>(
237	first: &Result<Advance<A>, AdvanceError>,
238	second: &Result<Advance<A>, AdvanceError>,
239) where
240	A: Into<WalletActionCheckpoint> + Clone,
241{
242	fn describe<A: Into<WalletActionCheckpoint> + Clone>(
243		result: &Result<Advance<A>, AdvanceError>,
244	) -> (&'static str, Option<WalletActionCheckpoint>) {
245		match result {
246			Ok(Advance::Next(state)) => ("Next", Some(state.clone().into())),
247			Ok(Advance::Park { state, .. }) => ("Park", Some(state.clone().into())),
248			Ok(Advance::Done) => ("Done", None),
249			Ok(Advance::Failed(_)) => ("Failed", None),
250			Err(_) => ("Err", None),
251		}
252	}
253
254	assert_eq!(
255		describe(first), describe(second),
256		"wallet action is not reentrant: advancing the same state twice diverged",
257	);
258}
259
260/// A wallet action that can be driven step-by-step.
261///
262/// Implementors define the per-kind state machine; the executor owns the
263/// loop, persistence, retry tracking and wake scheduling.
264#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
265#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
266pub trait WalletAction: Sized + Send + Sync {
267	/// Get an identifier for this action
268	///
269	/// The `id` returned MUST be stable across calls on the same logical
270	/// action (different states of the same action share an id).
271	fn id(&self) -> WalletActionId;
272
273	/// Called to advance the action state
274	///
275	/// MUST be re-entrant: it may be called more than once for the same logical
276	/// step (after a crash, after an early wake, after a notification arrives).
277	/// All side effects it triggers must therefore be idempotent.
278	async fn advance(self, wallet: &Wallet) -> Result<Advance<Self>, AdvanceError>;
279
280	/// Called when the action should be retried
281	///
282	/// `error` is the failure that triggered the retry; implementations can
283	/// attach it to their park so callers driving [DriveMode::UntilParkOrDone]
284	/// see why the action stopped.
285	async fn on_retry(
286		self,
287		_wallet: &Wallet,
288		attempts: u32,
289		_error: AdvanceError,
290	) -> anyhow::Result<Advance<Self>> {
291		Ok(park_with_backoff(self, attempts))
292	}
293
294	/// Called when the server rejected one of our requests
295	///
296	/// MUST be re-entrant for the same reason as [WalletAction::advance]:
297	/// it may run partially, crash, and be re-driven against the state the action
298	/// subsequently lands in.
299	async fn on_rejection(self, _wallet: &Wallet, _error: AdvanceError)
300		-> anyhow::Result<Advance<Self>>;
301}
302
303/// How aggressively the executor should drive an action.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum DriveMode {
306	/// Drive until the action parks or completes, then return.
307	UntilParkOrDone,
308	/// Drive past parks, sleeping between iterations, until the action
309	/// returns [`Advance::Done`].
310	UntilDone,
311}
312
313impl Wallet {
314	/// List the VTXOs currently locked by a specific wallet action.
315	///
316	/// Used by the executor to free reservations when an action fails
317	/// terminally without having transitioned its vtxos through the
318	/// normal Spent/Spendable channels.
319	async fn get_vtxos_locked_by_action(
320		&self,
321		action_id: &WalletActionId,
322	) -> anyhow::Result<Vec<WalletVtxo>> {
323		let all = self.inner.db.get_vtxos_by_state(&[VtxoStateKind::Locked]).await?;
324		Ok(all.into_iter().filter(|v| match &v.state {
325			VtxoState::Locked { holder: Some(crate::vtxo::VtxoLockHolder::Action { id }) } => {
326				id == action_id
327			},
328			_ => false,
329		}).collect())
330	}
331
332	/// Release every vtxo currently locked by the given action,
333	/// returning each one to [`crate::vtxo::VtxoState::Spendable`].
334	///
335	/// Cheap when nothing is held (no-op). Used as the cleanup hook by
336	/// the executor on `Advance::Done` and by manual cancellation via
337	/// [`Self::stop_wallet_action`].
338	pub async fn release_action_locks(&self, action_id: &WalletActionId) -> anyhow::Result<()> {
339		let vtxos = self.get_vtxos_locked_by_action(action_id).await?;
340		if vtxos.is_empty() {
341			return Ok(());
342		}
343		debug!("releasing {} vtxo lock(s) held by action {}", vtxos.len(), action_id);
344		self.unlock_vtxos(vtxos).await
345	}
346
347	/// Finish a wallet action: release its vtxo locks and remove the
348	/// checkpoint row. Intended for manual cleanup of stuck actions;
349	/// the normal terminal path is `Advance::Done` from `advance`.
350	pub async fn stop_wallet_action(&self, action_id: &WalletActionId) -> anyhow::Result<()> {
351		self.release_action_locks(action_id).await?;
352		self.inner.db.remove_wallet_action_checkpoint(action_id).await?;
353		Ok(())
354	}
355
356	/// Drive a wallet action to its next park or terminal state.
357	///
358	/// Holds a per-action-id in-flight guard so concurrent drives of
359	/// the same action (e.g. the periodic sync racing a user call)
360	/// don't step on each other.
361	pub async fn drive_action<A>(&self, action: A, mode: DriveMode) -> anyhow::Result<()>
362	where
363		A: WalletAction + Into<WalletActionCheckpoint> + Clone,
364	{
365		let guard = match self.inner.lock_manager.try_lock(&action.id()).await {
366			Some(g) => g,
367			None => {
368				trace!("action {} is already being driven, skipping", action.id());
369				return Ok(());
370			},
371		};
372
373		self.drive_action_with_guard(action, mode, guard).await
374	}
375
376	/// Drive an action assuming the caller already holds its per-id
377	/// lock. `lock_guard` MUST be the guard returned by
378	/// `lock_manager.try_lock(&lock_key::<A>(&action.id()))`; it is
379	/// held for RAII and dropped when this function returns.
380	pub(crate) async fn drive_action_with_guard<A>(
381		&self,
382		action: A,
383		mode: DriveMode,
384		_lock_guard: Box<dyn LockGuard>,
385	) -> anyhow::Result<()>
386	where
387		A: WalletAction + Into<WalletActionCheckpoint> + Clone,
388	{
389		// Box the driver so its state machine lives on the heap rather
390		// than inline in the caller's future.
391		Box::pin(self.run_action_loop(action, mode)).await
392	}
393
394	/// Run one `advance` step.
395	///
396	/// In debug builds with `BARK_DOUBLE_DRIVE_ACTIONS` set (see
397	/// [`double_drive_actions`]) the step runs twice from the same state and
398	/// [`assert_reentrant`] checks both reach an equivalent checkpoint,
399	/// exercising `advance`'s idempotency contract. Keep the second run; its
400	/// side effects are the ones the persisted state references.
401	async fn advance_step<A>(&self, action: A) -> Result<Advance<A>, AdvanceError>
402	where
403		A: WalletAction + Into<WalletActionCheckpoint> + Clone,
404	{
405		#[cfg(debug_assertions)]
406		if double_drive_actions() {
407			let snapshot = action.clone();
408			let first = action.advance(self).await;
409			let second = snapshot.advance(self).await;
410
411			// A first run that parks made no committed progress: it is a
412			// "nothing finished, re-drive me later" outcome (waiting on a
413			// confirmation, polling for an incoming payment, retrying a
414			// transient server rejection). The second run is that later
415			// re-drive and may legitimately diverge once the awaited condition
416			// clears, progressing to the next step or completing. So only
417			// assert equivalence when the first run committed a step (Next),
418			// finished (Done) or failed terminally.
419			let first_parked = matches!(first, Ok(Advance::Park { .. }));
420			if !first_parked {
421				assert_reentrant(&first, &second);
422			}
423			return second;
424		}
425
426		action.advance(self).await
427	}
428
429	async fn run_action_loop<A>(&self, mut action: A, mode: DriveMode) -> anyhow::Result<()>
430	where
431		A: WalletAction + Into<WalletActionCheckpoint> + Clone,
432	{
433		// In-memory counter for transient errors. Lives only for this
434		// drive_action call so the backoff curve resets between drives.
435		let mut retries: u32 = 0;
436
437		loop {
438			let id = action.id();
439			// Snapshot for the error path: advance consumes self, and
440			// on_rejection also takes self by value, so we need a
441			// copy around if budget exhausts.
442			let snapshot = action.clone();
443
444			let advance = match self.advance_step(action).await {
445				Ok(advance) => { advance },
446				Err(e) if e.is_server_rejection() => {
447					warn!("action {} got rejected by server: {:#}", id, e);
448					snapshot.on_rejection(self, e).await.inspect_err(|err| {
449						warn!("action {} on_rejection failed, leaving checkpoint for retry: {:#}", id, err);
450					})?
451				}
452				Err(e) => {
453					retries = retries.saturating_add(1);
454					log::error!("Got error {:?} from action {}, retrying", e, id);
455					snapshot.on_retry(self, retries, e).await.inspect_err(|err| {
456						warn!("action {} on_retry failed, leaving checkpoint for retry: {:#}", id, err);
457					})?
458				},
459			};
460
461			match advance {
462				Advance::Next(next) => {
463					retries = 0;
464					let checkpoint: WalletActionCheckpoint = next.clone().into();
465					self.inner.db.upsert_wallet_action_checkpoint(&id, &checkpoint).await?;
466					action = next;
467				},
468				Advance::Park { state, wake_after, error } => {
469					let checkpoint: WalletActionCheckpoint = state.clone().into();
470					self.inner.db.upsert_wallet_action_checkpoint(&id, &checkpoint).await?;
471					match mode {
472						DriveMode::UntilParkOrDone => {
473							return match error {
474								Some(error) => Err(error.into()),
475								None => Ok(()),
476							};
477						},
478						DriveMode::UntilDone => {
479							if let Some(delay) = wake_after {
480								debug!("action {} parked; sleeping {:?} before re-drive", id, delay);
481								sleep(delay).await;
482								action = state;
483							} else {
484								return match error {
485									Some(error) => Err(error.into()),
486									None => Ok(()),
487								};
488							}
489						},
490					}
491				},
492				Advance::Done => {
493					if let Err(e) = self.stop_wallet_action(&id).await {
494						warn!("action {} done but couldn't cancel: {:#}", id, e);
495					}
496					return Ok(());
497				},
498				Advance::Failed(e) => {
499					if let Err(e) = self.stop_wallet_action(&id).await {
500						warn!("action {} failed but couldn't cancel: {:#}", id, e);
501					}
502					return Err(e);
503				},
504			}
505		}
506	}
507}