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