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