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