Skip to main content

bark/actions/lightning/
pay.rs

1//! State machine for outgoing lightning payments.
2//!
3//! Identity (`invoice`, `original_payment_method`) and the parameters
4//! fixed at the start (inputs, amounts, htlc key, expiry) live on the
5//! action as top-level fields; the mutable bit is [`Progress`], a small
6//! enum that names the four phases of the state machine and only carries
7//! the fields the phase actually has.
8//!
9//! Transition functions take `&LightningSend` and return the new phase
10//! output. The [`WalletAction`] impl
11//! pattern-matches on progress and dispatches; persistence is the
12//! executor's job.
13
14use std::time::Duration;
15
16use anyhow::Context;
17use bitcoin::hex::DisplayHex;
18use bitcoin::secp256k1::PublicKey;
19use bitcoin::{Amount, SignedAmount};
20use log::{debug, error, info, trace, warn};
21
22use ark::arkoor::ArkoorDestination;
23use ark::arkoor::package::{ArkoorPackageBuilder, ArkoorPackageCosignResponse};
24use ark::lightning::{Invoice, PaymentHash, PaymentStatus, Preimage};
25use ark::mailbox::MailboxIdentifier;
26use ark::util::IteratorExt;
27use ark::{ProtocolEncoding, VtxoId, VtxoPolicy};
28use bitcoin_ext::BlockHeight;
29use server_rpc::protos::{self, lightning_payment_status};
30
31use crate::Wallet;
32use crate::actions::{Advance, AdvanceError, WalletAction, WalletActionId, park_with_backoff};
33use crate::movement::update::MovementUpdate;
34use crate::movement::{MovementDestination, MovementId, MovementStatus, PaymentMethod};
35use crate::persist::models::PaidInvoice;
36use crate::subsystem::{LightningMovement, LightningSendMovement, Subsystem};
37use crate::vtxo::VtxoLockHolder;
38
39const LN_PAY_NAMESPACE: &str = "ln_pay";
40
41pub(crate) fn ln_pay_action_id(payment_hash: PaymentHash) -> WalletActionId {
42	format!("{LN_PAY_NAMESPACE}.{payment_hash}")
43}
44
45/// Outcome of a lightning send lookup by payment hash.
46///
47/// `Paid` records come from `bark_paid_invoice` and are kept forever.
48/// `InProgress` records come from `bark_wallet_action_checkpoint`.
49/// `Unknown` means the wallet has no memory of this payment hash.
50#[derive(Debug, Clone, PartialEq)]
51pub enum LightningSendState {
52	Unknown,
53	InProgress(LightningSend),
54	Paid(PaidInvoice),
55}
56
57/// An outgoing lightning payment, persisted as a single checkpoint row
58/// and driven across crashes by the executor.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct LightningSend {
61	// Set at start, immutable thereafter:
62	pub invoice: Invoice,
63	pub original_payment_method: PaymentMethod,
64	pub input_vtxo_ids: Vec<VtxoId>,
65	pub payment_amount: Amount,
66	pub fee: Amount,
67
68	/// Used as both the HTLC output's locked pubkey and as the change
69	/// pubkey (reused to avoid a second key derivation).
70	pub htlc_key: PublicKey,
71	pub htlc_expiry: BlockHeight,
72
73	/// Movement recording this payment, created up front in
74	/// `start_lightning_send` so re-driving `Start` reuses it rather than
75	/// creating a duplicate. `None` for checkpoints predating this field.
76	#[serde(default)]
77	pub movement_id: Option<MovementId>,
78
79	/// HTLC revocation key, pre-derived at start (like `htlc_key`) so the
80	/// failure step is idempotent. `None` for checkpoints predating this field.
81	#[serde(default)]
82	pub revocation_key: Option<PublicKey>,
83
84	// Mutable state:
85	pub progress: Progress,
86	/// Unless a developer allows it, we will not auto-exit the HTLCs if revocation fails.
87	pub allow_exit_of_htlcs: bool,
88}
89
90impl LightningSend {
91	pub fn id(&self) -> WalletActionId {
92		ln_pay_action_id(self.invoice.payment_hash())
93	}
94
95	pub fn total_amount(&self) -> Amount {
96		self.payment_amount + self.fee
97	}
98
99	/// Returns whether the HTLCs are near expiry. It also returns true
100	/// if the HTLCs are actually expired.
101	pub async fn is_htlc_near_expiry(&self, wallet: &Wallet) -> anyhow::Result<bool> {
102		let tip = wallet.inner.chain.tip().await?;
103		Ok(tip > self.htlc_expiry
104			.saturating_sub(wallet.config().vtxo_refresh_expiry_threshold))
105	}
106
107	/// Returns whether the lightning payment has failed to revoke HTLCs after a failed payment.
108	/// Previously these would be auto-exited when approaching expiry, instead developers can use
109	/// [`crate::Wallet::allow_lightning_send_to_exit`] to control this behaviour.
110	pub fn has_failed_revocation(&self) -> bool {
111		matches!(self.progress, Progress::RevocationStuck { .. })
112	}
113}
114
115#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
116#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
117impl WalletAction for LightningSend {
118	fn id(&self) -> WalletActionId { LightningSend::id(self) }
119
120	async fn advance(self, wallet: &Wallet) -> Result<Advance<Self>, AdvanceError> {
121		let new_progress = match self.progress.clone() {
122			Progress::Start => {
123				let htlcs = request_lightning_send_htlcs(wallet, &self).await?;
124				Progress::HtlcReceived(htlcs)
125			},
126			Progress::HtlcReceived(htlcs) => {
127				initiate_lightning_send_payment(wallet, &self, &htlcs).await?;
128				Progress::PaymentInitiated(htlcs)
129			},
130			Progress::PaymentInitiated(htlcs) => {
131				let wait = false;
132				match check_lightning_send_payment_status(
133					wallet, &self, &htlcs, wait,
134				).await? {
135					PaymentStatus::Success(preimage) => {
136						settle_lightning_send_payment(wallet, &self, &htlcs, preimage).await?;
137						return Ok(Advance::Done);
138					},
139					PaymentStatus::Failed => {
140						let revocation = fail_lightning_send_payment(wallet, &self).await?;
141						Progress::RevocableHtlcs { htlcs, revocation }
142					},
143					PaymentStatus::Pending => {
144						if self.is_htlc_near_expiry(wallet).await? {
145							let revocation = fail_lightning_send_payment(wallet, &self).await?;
146							Progress::RevocableHtlcs { htlcs, revocation }
147						} else {
148							return Ok(Advance::Park {
149								state: LightningSend {
150									progress: Progress::PaymentInitiated(htlcs),
151									..self
152								},
153								wake_after: Some(PAYMENT_PENDING_POLL_INTERVAL),
154								error: None,
155							});
156						}
157					},
158				}
159			},
160			Progress::RevocableHtlcs { htlcs, revocation } |
161			Progress::RevocationStuck { htlcs, revocation } => {
162				handle_lightning_send_htlcs_revocation(wallet, &self, &htlcs, &revocation).await?;
163				return Ok(Advance::Done);
164			},
165		};
166
167		Ok(Advance::Next(LightningSend { progress: new_progress, ..self }))
168	}
169
170	async fn on_retry(self, wallet: &Wallet, retries: u32, _error: AdvanceError)
171		-> anyhow::Result<Advance<Self>>
172	{
173		match self.progress.clone() {
174			Progress::Start => {
175				if self.is_htlc_near_expiry(wallet).await? {
176					let err = anyhow!("Could not start lightning send and HTLCs are near expiry");
177					return Ok(Advance::Failed(err));
178				}
179			},
180			Progress::HtlcReceived(htlcs) |
181			Progress::PaymentInitiated(htlcs) => {
182				if self.is_htlc_near_expiry(wallet).await? {
183					let revocation = fail_lightning_send_payment(wallet, &self).await?;
184					let next = LightningSend {
185						progress: Progress::RevocableHtlcs { htlcs, revocation },
186						..self
187					};
188					return Ok(Advance::Next(next));
189				}
190			},
191			Progress::RevocableHtlcs { htlcs, revocation } => {
192				warn!("We could not revoke HTLCs, will continue retrying but the attempt will be marked as such");
193				let next = LightningSend {
194					progress: Progress::RevocationStuck { htlcs, revocation },
195					..self
196				};
197				return Ok(Advance::Next(next));
198			},
199			Progress::RevocationStuck { htlcs, .. } => {
200				if self.allow_exit_of_htlcs && self.is_htlc_near_expiry(wallet).await? {
201					exit_lightning_send_htlcs(wallet, &self, &htlcs).await?;
202					return Ok(Advance::Done);
203				}
204				// Just keep retrying...
205			},
206		}
207
208		Ok(park_with_backoff(self, retries))
209	}
210
211	async fn on_rejection(self, wallet: &Wallet, error: AdvanceError) -> anyhow::Result<Advance<Self>> {
212		match self.progress.clone() {
213			// Nothing committed server-side: drop the locks and the row
214			// ourselves, then bail. We can't rely on the executor's
215			// `Advance::Done` path because we want the original error
216			// surfaced to the caller.
217			Progress::Start => {
218				let id = self.id();
219				error!("Could not start lightning send {}: {:?}", id, error);
220				if let Err(cancel_err) = wallet.stop_wallet_action(&id).await {
221					warn!("could not cancel start-phase lightning send {}: {:#}", id, cancel_err);
222				}
223				Ok(Advance::Failed(error.into()))
224			},
225			Progress::HtlcReceived(htlcs) |
226			Progress::PaymentInitiated(htlcs) => {
227				let revocation = fail_lightning_send_payment(wallet, &self).await?;
228				let next = LightningSend {
229					progress: Progress::RevocableHtlcs { htlcs, revocation },
230					..self
231				};
232				Ok(Advance::Next(next))
233			},
234			Progress::RevocableHtlcs { htlcs, revocation } => {
235				warn!("We could not revoke HTLCs, will continue retrying but the attempt will be marked as such");
236				let next = LightningSend {
237					progress: Progress::RevocationStuck { htlcs, revocation },
238					..self
239				};
240				Ok(Advance::Next(next))
241			},
242			Progress::RevocationStuck { htlcs, .. } => {
243				if self.allow_exit_of_htlcs && self.is_htlc_near_expiry(wallet).await? {
244					exit_lightning_send_htlcs(wallet, &self, &htlcs).await?;
245					return Ok(Advance::Failed(anyhow!("Server refused to revoke HTLCs, exiting")));
246				}
247				// Park instead of looping: re-driving immediately would just
248				// hit the same server rejection. Surface the original error
249				// so callers driving `UntilParkOrDone` see why we stopped.
250				Ok(Advance::Park { state: self, wake_after: None, error: Some(error) })
251			},
252		}
253	}
254}
255
256/// The four phases of an outgoing lightning send. The enum tag is the
257/// phase; each variant carries only the data that exists by that
258/// phase, so impossible combinations are unrepresentable.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub enum Progress {
261	/// Inputs are locked, no server interaction yet.
262	Start,
263	/// Server cosigned the HTLC outputs; vtxos and movement persisted.
264	HtlcReceived(Htlcs),
265	/// Server has been told to pay; outcome is pending.
266	PaymentInitiated(Htlcs),
267	/// Payment failed; HTLCs must be revoked back to a spendable vtxo.
268	RevocableHtlcs { htlcs: Htlcs, revocation: Revocation },
269	/// We've tried to revoke the HTLCs previously and failed, the user
270	/// should consider forcing an exit. This step will keep retrying
271	/// until automatic exit is permissible when the HTLCs are near expiry,
272	/// provided [Wallet::allow_lightning_send_to_exit] is called.
273	RevocationStuck { htlcs: Htlcs, revocation: Revocation },
274}
275
276/// The HTLC vtxos the server cosigned for us, plus the movement they
277/// belong to and the mailbox the server will push notifications to.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct Htlcs {
280	pub vtxo_ids: Vec<VtxoId>,
281	#[serde(with = "ark::encode::serde")]
282	pub mailbox_id: MailboxIdentifier,
283	pub movement_id: MovementId,
284}
285
286/// Revocation keypair derived when a payment is determined to have
287/// failed; the public key is used to ask the server to cosign a claim
288/// back to us.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct Revocation {
291	pub key: PublicKey,
292}
293
294/// How long to sleep between poll attempts when the server reports `Pending`.
295const PAYMENT_PENDING_POLL_INTERVAL: Duration = Duration::from_secs(2);
296
297/// Build a fresh [`LightningSend`] in `Progress::Start`: pick inputs,
298/// lock them, derive the htlc key, snapshot expiry.
299///
300/// The executor persists the returned state. Idempotent under re-run
301/// only if no checkpoint exists yet for this invoice (the caller is
302/// responsible for the existence check).
303pub(crate) async fn start_lightning_send(
304	wallet: &Wallet,
305	invoice: Invoice,
306	user_amount: Option<Amount>,
307	original_payment_method: PaymentMethod,
308) -> anyhow::Result<LightningSend> {
309	let (_, ark_info) = wallet.require_server().await?;
310	let tip = wallet.inner.chain.tip().await?;
311
312	let properties = wallet.inner.db.read_properties().await?.context("Missing config")?;
313	if invoice.network() != properties.network {
314		bail!("Invoice is for wrong network: {}", invoice.network());
315	}
316
317	invoice.check_signature()?;
318
319	let payment_amount = invoice.get_payment_amount(user_amount)?;
320	if payment_amount == Amount::ZERO {
321		bail!("Cannot pay invoice for 0 sats (0 sat invoices are not any-amount invoices)");
322	}
323
324	let (inputs, fee) = wallet.select_any_vtxos_to_cover_with_fee(
325		payment_amount,
326		|a, v| ark_info.fees.lightning_send.calculate(a, v).context("fee overflowed"),
327	).await.context("Could not find enough suitable VTXOs to cover lightning payment")?;
328
329	let action_id = ln_pay_action_id(invoice.payment_hash());
330	wallet.lock_vtxos(
331		&inputs,
332		Some(crate::vtxo::VtxoLockHolder::Action { id: action_id }),
333	).await?;
334
335	let (change_keypair, _) = wallet.derive_store_next_keypair().await?;
336	let (revocation_keypair, _) = wallet.derive_store_next_keypair().await?;
337
338	let htlc_expiry = tip + ark_info.htlc_send_expiry_delta as BlockHeight;
339
340	let movement_id = wallet.inner.movements.new_movement_with_update(
341		Subsystem::LIGHTNING_SEND,
342		LightningSendMovement::Send.to_string(),
343		MovementUpdate::new()
344			.intended_balance(-payment_amount.to_signed().context("payment amount out of range")?)
345			.fee(fee)
346			.consumed_vtxos(&inputs)
347			.sent_to([MovementDestination::new(original_payment_method.clone(), payment_amount)])
348			.metadata(LightningMovement::metadata(invoice.payment_hash(), Vec::<VtxoId>::new(), None))
349	).await.context("failed to create lightning-send movement")?;
350
351	Ok(LightningSend {
352		invoice,
353		original_payment_method,
354		input_vtxo_ids: inputs.iter().map(|v| v.id()).collect(),
355		payment_amount,
356		fee,
357		htlc_key: change_keypair.public_key(),
358		htlc_expiry,
359		movement_id: Some(movement_id),
360		revocation_key: Some(revocation_keypair.public_key()),
361		allow_exit_of_htlcs: false,
362		progress: Progress::Start,
363	})
364}
365
366/// Start -> HtlcReceived. Server cosigns the HTLC outputs; the wallet
367/// records the resulting vtxos and movement.
368///
369/// Server-side contract: `request_lightning_pay_htlc_cosign` is
370/// idempotent on payment_hash and returns a fresh partial signature for
371/// each set of user nonces. Re-driving generates new nonces, which the
372/// server combines into a new valid response.
373pub(crate) async fn request_lightning_send_htlcs(
374	wallet: &Wallet,
375	send: &LightningSend,
376) -> Result<Htlcs, AdvanceError> {
377	let (mut srv, _) = wallet.require_server().await?;
378
379	let full_inputs = wallet.inner.db.get_full_vtxos(&send.input_vtxo_ids).await
380		.context("failed to hydrate lightning-send input vtxos")?;
381
382	// Ensure inputs are fully registered server-side before the cosign.
383	wallet.register_vtxo_transactions_with_server(&full_inputs).await
384		.context("failed to register lightning-send input vtxo transactions with server")?;
385
386	let mut input_keypairs = Vec::with_capacity(full_inputs.len());
387	for input in full_inputs.iter() {
388		input_keypairs.push(wallet.get_vtxo_key(input).await?);
389	}
390
391	let policy = VtxoPolicy::new_server_htlc_send(
392		send.htlc_key, send.invoice.payment_hash(), send.htlc_expiry,
393	);
394	let total_amount = send.total_amount();
395	let input_amount = full_inputs.iter().map(|v| v.amount()).sum::<Amount>();
396	let pay_dest = ArkoorDestination { total_amount, policy };
397	let outputs = if input_amount == total_amount {
398		vec![pay_dest]
399	} else {
400		let change_dest = ArkoorDestination {
401			total_amount: input_amount - total_amount,
402			policy: VtxoPolicy::new_pubkey(send.htlc_key),
403		};
404		vec![pay_dest, change_dest]
405	};
406
407	let builder = ArkoorPackageBuilder::new_with_checkpoints(
408		full_inputs.clone(),
409		outputs,
410	)
411		.context("Failed to construct arkoor package")?
412		.generate_user_nonces(&input_keypairs)
413		.context("invalid nb of keypairs")?;
414
415	let cosign_request = protos::LightningPayHtlcCosignRequest {
416		parts: protos::ArkoorPackageCosignRequest::from(builder.cosign_request()).parts,
417	};
418	let response = srv.client.request_lightning_pay_htlc_cosign(cosign_request).await
419		.map_err(AdvanceError::Server)?.into_inner();
420	let cosign_responses = ArkoorPackageCosignResponse::try_from(response)
421		.context("Failed to parse cosign response from server")?;
422
423	let vtxos = builder
424		.user_cosign(&input_keypairs, cosign_responses)
425		.context("Failed to cosign vtxos")?
426		.build_signed_vtxos();
427
428	let (htlc_vtxos, change_vtxos) = vtxos.clone().into_iter()
429		.partition::<Vec<_>, _>(|v| matches!(v.policy(), VtxoPolicy::ServerHtlcSend(_)));
430
431	let mut effective_balance = Amount::ZERO;
432	for vtxo in &htlc_vtxos {
433		wallet.validate_vtxo(vtxo).await.map_err(AdvanceError::Vtxo)?;
434		effective_balance += vtxo.amount();
435	}
436	for change in &change_vtxos {
437		let last_input = full_inputs.last().context("no inputs provided")?;
438		let tx = wallet.inner.chain.get_tx(&last_input.chain_anchor().txid).await?;
439		let tx = tx.with_context(|| format!(
440			"input vtxo chain anchor not found for lightning change vtxo: {}",
441			last_input.chain_anchor().txid,
442		))?;
443		change.validate(&tx).context("invalid lightning change vtxo")?;
444	}
445
446	if let Err(e) = wallet.register_vtxo_transactions_with_server(&vtxos).await {
447		warn!("failed to register lightning-send output vtxo transactions with server: {:#}", e);
448	}
449
450	// The movement was created up front in `start_lightning_send` and its id
451	// carried on the action, so re-driving `Start` updates that same movement
452	// rather than creating a duplicate. Legacy checkpoints predating the field
453	// have `None`; create one on demand to preserve the old behaviour.
454	// TODO: remove this `None` fallback (and make `movement_id` non-optional)
455	// after v0.2.6 ships, once no pre-v0.2.6 checkpoints can remain in flight.
456	let movement_id = match send.movement_id {
457		Some(id) => id,
458		None => wallet.inner.movements.new_movement_with_update(
459			Subsystem::LIGHTNING_SEND,
460			LightningSendMovement::Send.to_string(),
461			MovementUpdate::new()
462				.intended_balance(-send.payment_amount.to_signed().context("payment amount out of range")?)
463				.fee(send.fee)
464				.consumed_vtxos(&full_inputs)
465				.sent_to([MovementDestination::new(send.original_payment_method.clone(), send.payment_amount)])
466		).await.context("failed to create lightning-send movement")?,
467	};
468	wallet.store_locked_vtxos(
469		&htlc_vtxos,
470		Some(VtxoLockHolder::Action { id: send.id() })
471	).await?;
472	wallet.mark_vtxos_as_spent(&send.input_vtxo_ids).await?;
473	wallet.store_spendable_vtxos(&change_vtxos).await?;
474	wallet.inner.movements.update_movement(
475		movement_id,
476		MovementUpdate::new()
477			.effective_balance(-effective_balance.to_signed().context("effective balance out of range")?)
478			.produced_vtxos(change_vtxos)
479			.metadata(LightningMovement::metadata(send.invoice.payment_hash(), &htlc_vtxos, None))
480	).await.context("failed to update lightning-send movement")?;
481
482	// Sort for a deterministic checkpoint across re-drives.
483	let mut vtxo_ids = htlc_vtxos.iter().map(|v| v.id()).collect::<Vec<_>>();
484	vtxo_ids.sort();
485
486	Ok(Htlcs {
487		vtxo_ids,
488		mailbox_id: wallet.mailbox_identifier(),
489		movement_id,
490	})
491}
492
493/// HtlcReceived -> PaymentInitiated. Tells the server to actually pay
494/// the invoice. Server-side `initiate_lightning_payment` is idempotent
495/// on payment_hash.
496pub(crate) async fn initiate_lightning_send_payment(
497	wallet: &Wallet,
498	send: &LightningSend,
499	htlcs: &Htlcs,
500) -> Result<(), AdvanceError> {
501	let (mut srv, _) = wallet.require_server().await?;
502
503	let req = protos::InitiateLightningPaymentRequest {
504		invoice: send.invoice.to_string(),
505		htlc_vtxo_ids: htlcs.vtxo_ids.iter().map(|v| v.to_bytes().to_vec()).collect(),
506		payment_amount_sat: send.payment_amount.to_sat(),
507		mailbox_id: Some(htlcs.mailbox_id.serialize()),
508	};
509	srv.client.initiate_lightning_payment(req).await
510		.map_err(AdvanceError::Server)?;
511
512	Ok(())
513}
514
515/// Poll the server for payment status. Treats expired HTLCs as failed
516/// (server response of Pending plus tip past expiry collapses to Failed
517/// so the caller can revoke).
518pub(crate) async fn check_lightning_send_payment_status(
519	wallet: &Wallet,
520	send: &LightningSend,
521	htlcs: &Htlcs,
522	wait: bool,
523) -> anyhow::Result<PaymentStatus> {
524	let (mut srv, _) = wallet.require_server().await?;
525	let payment_hash = send.invoice.payment_hash();
526
527	let mut htlc_vtxos = Vec::with_capacity(htlcs.vtxo_ids.len());
528	for id in htlcs.vtxo_ids.iter() {
529		htlc_vtxos.push(wallet.get_vtxo_by_id(*id).await?);
530	}
531
532	let policy = htlc_vtxos.iter()
533		.all_same(|v| v.vtxo.policy())
534		.context("All lightning htlc should have the same policy")?;
535	let policy = policy.as_server_htlc_send().context("VTXO is not an HTLC send")?;
536	if policy.payment_hash != payment_hash {
537		bail!("Payment hash mismatch on stored HTLC policy");
538	}
539
540	let tip = wallet.inner.chain.tip().await?;
541	let expired = tip > policy.htlc_expiry;
542	let pending_status = if expired { PaymentStatus::Failed } else { PaymentStatus::Pending };
543
544	let req = protos::CheckLightningPaymentRequest {
545		hash: payment_hash.to_vec(),
546		wait,
547	};
548	// NB: don't early-return on transport errors; collapse to
549	// expired-or-pending so the executor can revoke when appropriate.
550	let response = srv.client.check_lightning_payment(req).await
551		.map(|r| r.into_inner().payment_status);
552
553	match response {
554		Ok(Some(lightning_payment_status::PaymentStatus::Success(s))) => {
555			match Preimage::try_from(s.preimage) {
556				Ok(preimage) if preimage.compute_payment_hash() == payment_hash => {
557					Ok(PaymentStatus::Success(preimage))
558				},
559				other => {
560					error!(
561						"Server reported success but returned an invalid preimage for {}: {:?}",
562						payment_hash, other,
563					);
564					Ok(pending_status)
565				},
566			}
567		},
568		Ok(Some(lightning_payment_status::PaymentStatus::Failed(_))) => {
569			Ok(PaymentStatus::Failed)
570		},
571		Ok(Some(lightning_payment_status::PaymentStatus::Pending(_))) => {
572			trace!("Payment {} is still pending", payment_hash);
573			Ok(pending_status)
574		},
575		Ok(None) | Err(_) => Ok(pending_status),
576	}
577}
578
579/// Terminal success: mark HTLC vtxos spent, finalise the movement with
580/// the preimage, and persist the replay-protection record.
581pub(crate) async fn settle_lightning_send_payment(
582	wallet: &Wallet,
583	send: &LightningSend,
584	htlcs: &Htlcs,
585	preimage: Preimage,
586) -> anyhow::Result<()> {
587	let payment_hash = send.invoice.payment_hash();
588	if preimage.compute_payment_hash() != payment_hash {
589		bail!("preimage does not match payment hash {}", payment_hash);
590	}
591	info!(
592		"Lightning payment succeeded! Preimage: {}. Payment hash: {}",
593		preimage.as_hex(), payment_hash.as_hex(),
594	);
595
596	wallet.inner.db.record_paid_invoice(payment_hash, preimage).await?;
597	wallet.mark_vtxos_as_spent(&htlcs.vtxo_ids).await?;
598	wallet.inner.movements.finish_movement_with_update(
599		htlcs.movement_id,
600		MovementStatus::Successful,
601		MovementUpdate::new().metadata([(
602			"payment_preimage".into(),
603			serde_json::to_value(preimage).expect("payment preimage can serde"),
604		)]),
605	).await?;
606
607	Ok(())
608}
609
610/// PaymentInitiated -> RevocableHtlcs. Derives a revocation keypair;
611/// the actual server-side cosign happens in
612/// [`revoke_lightning_send_htlcs`].
613pub(crate) async fn fail_lightning_send_payment(
614	wallet: &Wallet,
615	send: &LightningSend,
616) -> anyhow::Result<Revocation> {
617	info!("Lightning payment {} failed, preparing to revoke", send.invoice.payment_hash());
618	// Use the key pre-derived at start so re-driving is idempotent; older
619	// checkpoints without it (`None`) derive on demand.
620	// TODO: remove this `None` fallback (and make `revocation_key` non-optional)
621	// after v0.2.6 ships, once no pre-v0.2.6 checkpoints can remain in flight.
622	let key = match send.revocation_key {
623		Some(key) => key,
624		None => wallet.derive_store_next_keypair().await?.0.public_key(),
625	};
626	Ok(Revocation { key })
627}
628
629/// Cosign the revocation with the server, mark the HTLC vtxos spent
630/// and the revocation outputs spendable, and finish the movement as
631/// failed.
632pub(crate) async fn revoke_lightning_send_htlcs(
633	wallet: &Wallet,
634	send: &LightningSend,
635	htlcs: &Htlcs,
636	revocation: &Revocation,
637) -> Result<(), AdvanceError> {
638	let (mut srv, _) = wallet.require_server().await?;
639
640	debug!("Revoking {} HTLC vtxos for payment {}",
641		htlcs.vtxo_ids.len(), send.invoice.payment_hash());
642
643	let mut htlc_keypairs = Vec::with_capacity(htlcs.vtxo_ids.len());
644	let mut htlc_vtxos = Vec::with_capacity(htlcs.vtxo_ids.len());
645	for id in htlcs.vtxo_ids.iter() {
646		let vtxo = wallet.inner.db.get_full_vtxo(*id).await?
647			.with_context(|| format!("htlc vtxo with id {} not found", id))?;
648		htlc_keypairs.push(wallet.get_vtxo_key(&vtxo).await?);
649		htlc_vtxos.push(vtxo);
650	}
651
652	let revocation_claim_policy = VtxoPolicy::new_pubkey(revocation.key);
653	let builder = ArkoorPackageBuilder::new_claim_all_with_checkpoints(
654		htlc_vtxos.iter().cloned(),
655		revocation_claim_policy,
656	)
657		.context("Failed to construct arkoor package")?
658		.generate_user_nonces(&htlc_keypairs)
659		.context("failed to generate user nonces")?;
660
661	let cosign_request = protos::ArkoorPackageCosignRequest::from(builder.cosign_request());
662	let response = srv.client
663		.request_lightning_pay_htlc_revocation(cosign_request).await
664		.map_err(AdvanceError::Server)?.into_inner();
665	let cosign_resp = ArkoorPackageCosignResponse::try_from(response)
666		.context("Failed to parse cosign response from server")?;
667
668	let vtxos = builder
669		.user_cosign(&htlc_keypairs, cosign_resp)
670		.context("Failed to cosign vtxos")?
671		.build_signed_vtxos();
672
673	// Ensure revocation vtxos are fully registered server-side before the cosign.
674	if let Err(e) = wallet.register_vtxo_transactions_with_server(&vtxos).await {
675		warn!("failed to register lightning-send revocation vtxo transactions with server: {:#}", e);
676	}
677
678	let revoked = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
679	let effective = -send.total_amount().to_signed().context("total amount out of range")? +
680		revoked.to_signed().context("revoked amount out of range")?;
681	if effective != SignedAmount::ZERO {
682		warn!(
683			"Movement {} should have fee of zero, but got {}: total = {}, revoked = {}",
684			htlcs.movement_id, effective, send.total_amount(), revoked,
685		);
686	}
687	wallet.inner.movements.finish_movement_with_update(
688		htlcs.movement_id,
689		MovementStatus::Failed,
690		MovementUpdate::new()
691			.effective_balance(effective)
692			.fee(effective.unsigned_abs())
693			.produced_vtxos(&vtxos),
694	).await.context("failed to update movement")?;
695	wallet.store_spendable_vtxos(&vtxos).await?;
696	wallet.mark_vtxos_as_spent(&htlc_vtxos).await?;
697
698	Ok(())
699}
700
701/// Escalation: when revocation has failed and the HTLC vtxos are about
702/// to expire, mark them for unilateral exit and finish the movement
703/// as failed.
704pub(crate) async fn exit_lightning_send_htlcs(
705	wallet: &Wallet,
706	send: &LightningSend,
707	htlcs: &Htlcs,
708) -> anyhow::Result<()> {
709	let payment_hash = send.invoice.payment_hash();
710	warn!("HTLC VTXOs for payment {} are near expiry, marking to exit", payment_hash);
711
712	let mut vtxos = Vec::with_capacity(htlcs.vtxo_ids.len());
713	for id in htlcs.vtxo_ids.iter() {
714		vtxos.push(wallet.get_vtxo_by_id(*id).await?.vtxo);
715	}
716
717	wallet.inner.exit.start_exit_for_vtxos(&vtxos).await?;
718
719	let exited = vtxos.iter().map(|v| v.amount()).sum::<Amount>();
720	let effective = -send.total_amount().to_signed()? + exited.to_signed()?;
721	if effective != SignedAmount::ZERO {
722		warn!(
723			"Movement {} should have fee of zero, but got {}: total = {}, exited = {}",
724			htlcs.movement_id, effective, send.total_amount(), exited,
725		);
726	}
727	wallet.inner.movements.finish_movement_with_update(
728		htlcs.movement_id,
729		MovementStatus::Failed,
730		MovementUpdate::new()
731			.effective_balance(effective)
732			.fee(effective.unsigned_abs())
733			.exited_vtxos(&vtxos),
734	).await?;
735
736	Ok(())
737}
738
739/// Drives revocation forward: tries to revoke, escalates to exit if
740/// the vtxos are close to expiry. Returns `Ok(())` if either path
741/// finished cleanly, otherwise propagates the revocation error so the
742/// executor can retry later.
743pub(crate) async fn handle_lightning_send_htlcs_revocation(
744	wallet: &Wallet,
745	send: &LightningSend,
746	htlcs: &Htlcs,
747	revocation: &Revocation,
748) -> Result<(), AdvanceError> {
749	let payment_hash = send.invoice.payment_hash();
750	let tip = wallet.inner.chain.tip().await?;
751
752	debug!("Revoking HTLC VTXOs for payment {} (tip: {}, expiry: {})",
753		payment_hash, tip, send.htlc_expiry);
754
755
756	revoke_lightning_send_htlcs(wallet, send, htlcs, revocation).await
757		.inspect_err(|e| {
758			warn!("Failed to revoke HTLC VTXOs for payment {}: {:#}", payment_hash, e);
759		})
760}