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