Skip to main content

bark/
mailbox.rs

1
2pub extern crate ark;
3
4pub extern crate bip39;
5pub extern crate lightning_invoice;
6pub extern crate lnurl as lnurllib;
7
8use std::collections::HashMap;
9use std::ops::ControlFlow;
10use std::sync::{Arc, Weak};
11
12use anyhow::Context;
13use ark::tree::signed::UnlockHash;
14use bitcoin::hashes::Hash;
15use bitcoin::Amount;
16use bitcoin::hex::DisplayHex;
17use bitcoin::secp256k1::Keypair;
18use futures::{FutureExt, Stream, StreamExt};
19use log::{debug, error, info, trace, warn};
20use tokio_util::sync::CancellationToken;
21
22use bitcoin_ext::BlockHeight;
23
24use ark::{ProtocolEncoding, Vtxo, VtxoId};
25use ark::lightning::{PaymentHash, Preimage};
26use ark::mailbox::{MailboxAuthorization, MailboxIdentifier};
27use ark::vtxo::Full;
28use ark::vtxo::policy::signing::VtxoSigner;
29use server_rpc::{protos, MAX_NB_MAILBOX_RECOVERY_IDS};
30use server_rpc::protos::mailbox_server::MailboxMessage;
31
32use crate::{Wallet, WalletInner, SUBSCRIBE_REQUEST_TIMEOUT};
33use crate::actions::DriveMode;
34use crate::actions::lightning::pay::Progress;
35use crate::movement::{MovementDestination, MovementStatus};
36use crate::movement::update::MovementUpdate;
37use crate::subsystem::{ArkoorMovement, Subsystem};
38use crate::utils::ReconnectBackoff;
39
40
41/// The maximum number of times we will call the fetch mailbox endpoint in one go
42///
43/// We can't trust the server to honestly tell us to keep trying more forever.
44/// A malicious server could send us empty messages or invalid messages and
45/// lock up our resources forever. So we limit the number of times we will fetch.
46/// If a user actually has more messages left, he will have to call sync again.
47///
48/// (Note that currently the server sends 100 messages per fetch, so this would
49/// only happen for users with more than 1000 pending items.)
50const MAX_MAILBOX_REQUEST_BURST: usize = 10;
51
52/// Key for the lock that serializes the arkoor receive dedup within a wallet.
53///
54/// Several consumers of one wallet process the same mailbox messages: the
55/// daemon's always-on stream ([`Wallet::subscribe_process_mailbox_messages`])
56/// runs alongside the startup / periodic `sync()` (which calls
57/// [`Wallet::sync_mailbox`]), and a REST deployment can fire concurrent
58/// `/sync` and `/sync/mailbox` requests. The server hands each consumer the
59/// same messages from its checkpoint, so without serialization the
60/// peek-then-store dedup in arkoor processing races: every consumer that wins
61/// the [`crate::persist::BarkPersister::get_wallet_vtxo`] check before the
62/// others store records its own receive movement, double-counting the receive.
63///
64/// Holding this lock across that check-then-store (in
65/// [`Wallet::process_received_arkoor_package`]) makes it atomic across
66/// consumers. The VTXO row is `INSERT OR IGNORE`, so once one consumer has
67/// stored the package the rest see the VTXO already present and skip the
68/// movement.
69const MAILBOX_PROCESSING_LOCK_KEY: &str = "mailbox.processing";
70
71/// How long a consumer waits for the arkoor dedup lock before giving up.
72///
73/// The critical section is a couple of server round trips plus local DB
74/// writes, normally well under a second. On timeout we error rather than
75/// block, handled like any other arkoor processing error: this message's
76/// checkpoint isn't advanced and processing halts (see
77/// [`Wallet::process_mailbox_message`]) so a later message can't bury it. A
78/// timeout only happens if a holder is stuck for the full duration; normally
79/// waiters acquire the lock and dedup as usual.
80const MAILBOX_PROCESSING_LOCK_TIMEOUT: std::time::Duration =
81	std::time::Duration::from_secs(30);
82
83/// Whether a structurally-valid arkoor vtxo is safe to store as a spendable
84/// received payment.
85///
86/// [`Wallet::validate_vtxo`] only proves the vtxo's genesis chain was cosigned
87/// by the server pubkey embedded in the vtxo. A received payment additionally
88/// has to be a final, self-custodial coin, so we require that:
89///
90/// - the policy is [`VtxoPolicyKind::Pubkey`]: an in-flight HTLC (or any other
91///   server contract) is not a payment, and the server refuses to let us spend
92///   it as an arkoor/round/offboard input;
93/// - the vtxo commits to *our* server's pubkey, not a foreign server's;
94/// - the vtxo isn't about to expire, so a unilateral exit is still possible.
95///
96/// Ownership (that the wallet holds a signable key) is checked by the caller,
97/// which has async database access.
98fn check_arkoor_receive_policy(
99	vtxo: &Vtxo<Full>,
100	server_pubkey: bitcoin::secp256k1::PublicKey,
101	tip: BlockHeight,
102	expiry_margin: BlockHeight,
103) -> anyhow::Result<()> {
104	let kind = vtxo.policy().policy_type();
105	if kind != ark::vtxo::policy::VtxoPolicyKind::Pubkey {
106		bail!("not a final payment VTXO (policy: {})", kind);
107	}
108	if vtxo.server_pubkey() != server_pubkey {
109		bail!("VTXO commits to a foreign server pubkey {}", vtxo.server_pubkey());
110	}
111	let safe_until = tip.saturating_add(expiry_margin);
112	if vtxo.expiry_height() <= safe_until {
113		bail!("VTXO expires too soon to accept safely (expiry {} <= tip {} + margin {})",
114			vtxo.expiry_height(), tip, expiry_margin);
115	}
116	Ok(())
117}
118
119impl Wallet {
120	/// Get the keypair used for the server mailbox
121	pub fn mailbox_keypair(&self) -> Keypair {
122		self.inner.seed.to_mailbox_keypair()
123	}
124
125	/// Get the keypair used for the server recovery mailbox
126	pub fn recovery_mailbox_keypair(&self) -> Keypair {
127		self.inner.seed.to_recovery_mailbox_keypair()
128	}
129
130	/// Get this wallet's server mailbox ID
131	pub fn mailbox_identifier(&self) -> MailboxIdentifier {
132		let mailbox_kp = self.mailbox_keypair();
133		MailboxIdentifier::from_pubkey(mailbox_kp.public_key())
134	}
135
136	/// Get this wallet's server recovery mailbox ID
137	pub fn recovery_mailbox_identifier(&self) -> MailboxIdentifier {
138		let mailbox_kp = self.recovery_mailbox_keypair();
139		MailboxIdentifier::from_pubkey(mailbox_kp.public_key())
140	}
141
142	/// Create a mailbox authorization that is valid until the given expiry time
143	///
144	/// This authorization can be used by third parties to lookup your mailbox
145	/// with the Ark server.
146	pub fn mailbox_authorization(
147		&self,
148		authorization_expiry: chrono::DateTime<chrono::Local>,
149	) -> MailboxAuthorization {
150		MailboxAuthorization::new(&self.mailbox_keypair(), authorization_expiry)
151	}
152
153	/// Subscribe to mailbox message stream.
154	///
155	/// If `since` is `None`, the stream will start from the last checkpoint stored in the database.
156	///
157	/// Returns a stream of mailbox messages.
158	pub async fn subscribe_mailbox_messages(
159		&self,
160		since_checkpoint: Option<u64>,
161	) -> anyhow::Result<impl Stream<Item = anyhow::Result<MailboxMessage>> + Unpin + use<>> {
162		let (mut srv, _) = self.require_server().await?;
163
164		let checkpoint = if let Some(since) = since_checkpoint {
165			since
166		} else {
167			self.get_mailbox_checkpoint().await?
168		};
169
170		// we just need a short authorization for the stream initialization
171		let expiry = chrono::Local::now() + std::time::Duration::from_secs(10);
172		let auth = self.mailbox_authorization(expiry);
173		let mailbox_id = auth.mailbox();
174
175		let mut req = tonic::IntoRequest::into_request(protos::mailbox_server::MailboxRequest {
176			mailbox_id: mailbox_id.serialize(),
177			authorization: Some(auth.serialize()),
178			checkpoint: checkpoint,
179		});
180		req.set_timeout(SUBSCRIBE_REQUEST_TIMEOUT);
181		trace!("Requesting mailbox stream from checkpoint {}", checkpoint);
182
183		let stream = srv.mailbox_client.subscribe_mailbox(req).await?.into_inner().map(|m| {
184			let m = m.context("received error on mailbox message stream")?;
185			Ok::<_, anyhow::Error>(m)
186		});
187
188		Ok(stream)
189	}
190
191	/// Similar to [Wallet::subscribe_mailbox_messages] but it will also process each mailbox
192	/// message indefinitely. This method won't stop until the given `shutdown` `CancellationToken`
193	/// is triggered.
194	///
195	/// If `since_checkpoint` is `None`, the stream will start from the last checkpoint stored in
196	/// the database.
197	///
198	/// Returns only once the stream is closed.
199	pub async fn subscribe_process_mailbox_messages(
200		&self,
201		since_checkpoint: Option<u64>,
202		shutdown: CancellationToken,
203	) -> anyhow::Result<()> {
204		Wallet::subscribe_process_mailbox_messages_weak(
205			Arc::downgrade(&self.inner), since_checkpoint, shutdown,
206		).await
207	}
208
209	/// The body of [Wallet::subscribe_process_mailbox_messages], holding the
210	/// wallet only weakly.
211	///
212	/// The subscription is always-on, so this future must not own a strong
213	/// wallet reference across its lifetime: the daemon relies on the wallet's
214	/// strong count reaching zero — running [crate::WalletInner]'s drop glue —
215	/// to learn that it should shut down. The weak reference is therefore only
216	/// upgraded for one unit of work at a time (a resubscribe or a single
217	/// message); between messages this future owns just the gRPC stream.
218	///
219	/// Returns cleanly when the wallet is dropped, like on shutdown.
220	pub(crate) async fn subscribe_process_mailbox_messages_weak(
221		wallet: Weak<WalletInner>,
222		since_checkpoint: Option<u64>,
223		shutdown: CancellationToken,
224	) -> anyhow::Result<()> {
225		let upgrade = || wallet.upgrade().map(|inner| Wallet { inner });
226
227		// Count consecutive reconnects that made no progress. A delivered
228		// message resets it (the connection is healthy); enough failures in a
229		// row means the server is unreachable, so we bail and let the daemon
230		// mark itself disconnected and back off a full sync interval.
231		let mut reconnect_count = 0;
232		const MAX_RECONNECT_ATTEMPTS: usize = 5;
233		let mut backoff = ReconnectBackoff::new();
234
235		loop {
236			let mut stream = {
237				let Some(wallet) = upgrade() else { return Ok(()) };
238				wallet.subscribe_mailbox_messages(since_checkpoint).await?
239			};
240			trace!("Connected to mailbox stream with server");
241
242			'stream: loop {
243				futures::select! {
244					message = stream.next().fuse() => {
245						match message {
246							Some(Ok(message)) => {
247								// A delivered message proves the connection is
248								// healthy, so stop counting reconnects as failures.
249								reconnect_count = 0;
250								let Some(wallet) = upgrade() else { return Ok(()) };
251								if wallet.process_mailbox_message(message).await.is_break() {
252									// A message failed without advancing its
253									// checkpoint. Stop consuming this stream and
254									// resubscribe from the unadvanced checkpoint
255									// so it's redelivered before a later message
256									// can bury it. We deliberately do NOT reset
257									// the backoff here: if the same message keeps
258									// failing, the growing delay keeps this from
259									// becoming a tight redeliver loop.
260									trace!("Halting mailbox stream after unadvanced message; resubscribing");
261									break 'stream;
262								}
263								// Progress made: reconnect promptly next time.
264								backoff.reset();
265							},
266							// A tonic h2 stream reset is almost always a
267							// proxy- or server-side idle timeout rather than
268							// a real failure; resubscribe quietly. Unlike before
269							// we count it toward the reconnect cap, so a server
270							// that keeps resetting us is treated as unreachable
271							// rather than triggering an unbounded resubscribe.
272							Some(Err(e)) if crate::utils::is_h2_stream_error(&e) => {
273								reconnect_count += 1;
274								trace!("Mailbox stream reset by server, reconnecting: {e:#}");
275								break 'stream;
276							},
277							Some(Err(e)) => {
278								return Err(e).context("error on mailbox message stream");
279							},
280							None => {
281								reconnect_count += 1;
282								warn!("Mailbox stream dropped by server, reconnecting");
283								break 'stream;
284							},
285						}
286					},
287					_ = shutdown.cancelled().fuse() => {
288						info!("Shutdown signal received! Shutting mailbox messages process...");
289						return Ok(());
290					},
291				}
292			}
293
294			// Reached only by breaking out of 'stream to resubscribe.
295			if reconnect_count >= MAX_RECONNECT_ATTEMPTS {
296				bail!("Mailbox stream dropped by server, giving up to retry later");
297			}
298
299			// Back off before resubscribing so a fast-closing or rate-limited
300			// stream can't become a tight reconnect loop that floods the server
301			// with opened-then-reset streams.
302			futures::select! {
303				_ = backoff.wait().fuse() => {},
304				_ = shutdown.cancelled().fuse() => {
305					info!("Shutdown signal received! Shutting mailbox messages process...");
306					return Ok(());
307				},
308			}
309		}
310	}
311
312	/// Sync with the mailbox on the Ark server and look for out-of-round received VTXOs.
313	pub async fn sync_mailbox(&self) -> anyhow::Result<()> {
314		let (mut srv, _) = self.require_server().await?;
315
316		// we should be able to do all our syncing in 10 minutes
317		let expiry = chrono::Local::now() + std::time::Duration::from_secs(10 * 60);
318		let auth = self.mailbox_authorization(expiry);
319		let mailbox_id = auth.mailbox();
320
321		for _ in 0..MAX_MAILBOX_REQUEST_BURST {
322			let checkpoint = self.get_mailbox_checkpoint().await?;
323			let mailbox_req = protos::mailbox_server::MailboxRequest {
324				mailbox_id: mailbox_id.serialize(),
325				authorization: Some(auth.serialize()),
326				checkpoint,
327			};
328
329			let mailbox_resp = srv.mailbox_client.read_mailbox(mailbox_req).await
330				.context("error fetching mailbox")?.into_inner();
331			debug!("Ark server has {} mailbox messages for us", mailbox_resp.messages.len());
332
333			for mailbox_msg in mailbox_resp.messages {
334				if self.process_mailbox_message(mailbox_msg).await.is_break() {
335					// A message failed without advancing its checkpoint. Stop
336					// so we don't advance past it; the next sync retries from
337					// the same checkpoint.
338					return Ok(());
339				}
340			}
341
342			if !mailbox_resp.have_more {
343				break;
344			}
345		}
346
347		Ok(())
348	}
349
350	/// Turn raw byte arrays into VTXOs, then validate them.
351	///
352	/// This function only returns an error when it cannot process any VTXOs,
353	/// for example when the server is down or the chain tip is unknown.
354	/// No errors are returned for issues on individual VTXOs.
355	async fn process_raw_vtxos(
356		&self,
357		raw_vtxos: Vec<Vec<u8>>,
358	) -> anyhow::Result<Vec<Vtxo<Full>>> {
359		let ark_info  = self.require_server().await
360			.context("refuse_htlc_send_vtxo_posted_to_arkoor_mailbox")?.1;
361		let tip = self.inner.chain.tip().await
362			.context("cannot vet received arkoor VTXOs, no chain tip")?;
363
364		let mut invalid_vtxos = Vec::with_capacity(raw_vtxos.len());
365		let mut valid_vtxos = Vec::with_capacity(raw_vtxos.len());
366
367		let expiry_margin = ark_info.vtxo_exit_delta as BlockHeight;
368		for bytes in &raw_vtxos {
369			let vtxo = match Vtxo::<Full>::deserialize(&bytes) {
370				Ok(vtxo) => vtxo,
371				Err(e) => {
372					error!("Failed to deserialize arkoor VTXO: {}: {}", bytes.as_hex(), e);
373					invalid_vtxos.push(bytes);
374					continue;
375				}
376			};
377
378			if let Err(e) = self.validate_vtxo(&vtxo).await {
379				error!("Received invalid arkoor VTXO {} from server: {}", vtxo.id(), e);
380				invalid_vtxos.push(bytes);
381				continue;
382			}
383
384			if let Err(e) = check_arkoor_receive_policy(&vtxo, ark_info.server_pubkey, tip, expiry_margin) {
385				error!("Refusing received arkoor VTXO {}: {}", vtxo.id(), e);
386				invalid_vtxos.push(bytes);
387				continue;
388			}
389			if self.find_signable_clause(&vtxo).await.is_none() {
390				error!("Refusing received arkoor VTXO {}: not owned by this wallet", vtxo.id());
391				invalid_vtxos.push(bytes);
392				continue;
393			}
394
395			valid_vtxos.push(vtxo);
396		}
397
398		// We log all invalid VTXOs to keep track
399		if !invalid_vtxos.is_empty() {
400			error!("Received {} invalid arkoor VTXOs out of {} from server",
401				invalid_vtxos.len(), raw_vtxos.len(),
402			);
403		}
404
405		Ok(valid_vtxos)
406	}
407
408	/// Process a single mailbox message and report whether the caller should
409	/// keep consuming the mailbox.
410	///
411	/// Returns [`ControlFlow::Break`] when an arkoor package failed to process
412	/// and its checkpoint was therefore not advanced. Because checkpoints are
413	/// monotonic, the caller must stop before a later message stores a higher
414	/// checkpoint and buries the unprocessed one; the next sync/resubscribe
415	/// re-fetches from the unadvanced checkpoint and retries.
416	pub(crate) async fn process_mailbox_message(
417		&self,
418		mailbox_msg: MailboxMessage,
419	) -> ControlFlow<()> {
420		use protos::mailbox_server::mailbox_message::Message;
421
422		// Each arm returns whether the checkpoint should advance. Only
423		// arkoor returns false on processing error so the server
424		// redelivers and we retry. Every other arm advances regardless,
425		// either because the work is idempotent and re-done on every
426		// wallet sync, or because the message is informational/ignored.
427		let advance = match mailbox_msg.message {
428			Some(Message::Arkoor(msg)) => {
429				match self.process_received_arkoor_package(msg.vtxos).await {
430					Ok(()) => true,
431					Err(e) => {
432						error!("Error processing received arkoor package: {:#}", e);
433						false
434					}
435				}
436			}
437			Some(Message::RoundParticipationCompleted(m)) => {
438				info!("Server informed that round participation is ready, unlock_hash:{:?}",
439					UnlockHash::from_slice(&m.unlock_hash).ok(),
440				);
441				if let Err(e) = self.sync_pending_rounds().await {
442					error!("Error syncing pending rounds: {:#}", e);
443				}
444				true
445			},
446			Some(Message::IncomingLightningPayment(msg)) => {
447				if let Err(e) = self.handle_lightning_receive_notification(msg).await {
448					error!("Error handling lightning receive notification: {:#}", e);
449				}
450				true
451			},
452			Some(Message::RecoveryVtxoIds(_)) => {
453				trace!("Received recovery VTXO IDs, ignoring");
454				true
455			}
456			Some(Message::LightningSendFinished(msg)) => {
457				if let Err(e) = self.handle_lightning_send_finished(msg, mailbox_msg.checkpoint).await {
458					error!("Error handling lightning send finished notification: {:#}", e);
459				}
460				true
461			}
462			None => {
463				warn!("Received unknown mailbox message kind at checkpoint {}; bark may need to be upgraded",
464					mailbox_msg.checkpoint);
465				true
466			}
467		};
468
469		if advance {
470			if let Err(e) = self.store_mailbox_checkpoint(mailbox_msg.checkpoint).await {
471				error!("Error storing mailbox checkpoint: {:#}", e);
472			}
473			ControlFlow::Continue(())
474		} else {
475			// An arkoor package didn't process and its checkpoint wasn't
476			// advanced. Stop here so a later message can't store a higher
477			// checkpoint and bury it.
478			ControlFlow::Break(())
479		}
480	}
481
482	async fn process_received_arkoor_package(
483		&self,
484		raw_vtxos: Vec<Vec<u8>>,
485	) -> anyhow::Result<()> {
486		let vtxos = self.process_raw_vtxos(raw_vtxos).await?;
487
488		// Serialize the receive dedup across all consumers of this wallet's
489		// mailbox so two of them can't both record a movement for the same
490		// package. See MAILBOX_PROCESSING_LOCK_KEY. On lock failure we
491		// return like any other arkoor processing error, leaving this
492		// message's checkpoint unadvanced.
493		let _guard = self.inner.lock_manager.lock(
494			MAILBOX_PROCESSING_LOCK_KEY, MAILBOX_PROCESSING_LOCK_TIMEOUT,
495		).await.context("failed to acquire mailbox processing lock")?;
496
497		let mut new_vtxos = Vec::with_capacity(vtxos.len());
498		for vtxo in &vtxos {
499			// Skip if already in wallet
500			if self.inner.db.get_wallet_vtxo(vtxo.id()).await?.is_some() {
501				debug!("Ignoring duplicate arkoor VTXO {}", vtxo.id());
502				continue;
503			}
504
505			trace!("Received arkoor VTXO {} for {}", vtxo.id(), vtxo.amount());
506			new_vtxos.push(vtxo);
507		}
508
509		if new_vtxos.is_empty() {
510			return Ok(());
511		}
512
513		// Redundantly re-register the received vtxos with the server. An
514		// up-to-date sender already does this after cosign, but older
515		// senders may not, so we do it on receive too to make sure the
516		// server has signed_tx rows for our spendable vtxos. Any failure
517		// is logged and swallowed: the receive must still proceed so we
518		// don't lose track of the vtxos locally, and later spends will
519		// retry registration if still needed.
520		if let Err(e) = self.register_vtxo_transactions_with_server(&new_vtxos).await {
521			warn!("Failed to register received arkoor vtxo transactions with server: {:#}", e);
522		}
523
524		let balance = vtxos
525			.iter()
526			.map(|vtxo| vtxo.amount()).sum::<Amount>()
527			.to_signed()?;
528		self.store_spendable_vtxos(&vtxos).await?;
529
530		// Build received_on destinations from received VTXOs, aggregated by address
531		let mut received_by_address = HashMap::<ark::Address, Amount>::new();
532		for vtxo in &vtxos {
533			if let Ok(Some((index, _))) = self.pubkey_keypair(&vtxo.user_pubkey()).await {
534				if let Ok(address) = self.peek_address(index).await {
535					*received_by_address.entry(address).or_default() += vtxo.amount();
536				}
537			}
538		}
539		let received_on: Vec<_> = received_by_address
540			.iter()
541			.map(|(addr, amount)| MovementDestination::ark(addr.clone(), *amount))
542			.collect();
543
544		let movement_id = self.inner.movements.new_finished_movement(
545			Subsystem::ARKOOR,
546			ArkoorMovement::Receive.to_string(),
547			MovementStatus::Successful,
548			MovementUpdate::new()
549				.produced_vtxos(&vtxos)
550				.intended_and_effective_balance(balance)
551				.received_on(received_on),
552		).await?;
553
554		info!("Received arkoor (movement {}) for {}", movement_id, balance);
555
556		Ok(())
557	}
558
559	/// Handle a lightning receive notification from the mailbox.
560	///
561	/// This is a signal that the server has received a lightning payment for us
562	/// and we should come online to claim it.
563	async fn handle_lightning_receive_notification(
564		&self,
565		notif: protos::mailbox_server::IncomingLightningPaymentMessage,
566	) -> anyhow::Result<()> {
567		let payment_hash = PaymentHash::try_from(notif.payment_hash)
568			.context("invalid payment hash in lightning receive notification")?;
569
570		debug!("Lightning receive notification: payment_hash={}", payment_hash);
571
572		match self.try_claim_lightning_receive(payment_hash, false).await {
573			Ok(_) => info!("Lightning receive claimed via mailbox notification for {}", payment_hash),
574			Err(e) => error!("Failed to claim lightning receive for {}: {:#}", payment_hash, e),
575		}
576
577		Ok(())
578	}
579
580	/// Handle a lightning send finished notification from the mailbox.
581	///
582	/// This notification indicates that the server has completed processing
583	/// a lightning payment we initiated, either successfully or with failure.
584	async fn handle_lightning_send_finished(
585		&self,
586		notif: protos::mailbox_server::LightningSendFinishedMessage,
587		checkpoint: u64,
588	) -> anyhow::Result<()> {
589		let payment_hash = PaymentHash::try_from(notif.payment_hash)
590			.context("invalid payment hash in lightning send finished notification")?;
591
592		let known_preimage = notif.preimage
593			.and_then(|bytes| Preimage::try_from(bytes).ok());
594
595		if known_preimage.is_some() {
596			debug!("Lightning send finished notification (success): payment_hash={}", payment_hash);
597		} else {
598			debug!("Lightning send finished notification (failed): payment_hash={}", payment_hash);
599		}
600
601		// Errors are logged but not propagated: we always advance the
602		// mailbox checkpoint to avoid re-processing the same
603		// notification on the next poll.
604		match self.is_invoice_paid(payment_hash).await {
605			Ok(true) => {
606				debug!("Lightning send {} already settled; ignoring notification", payment_hash);
607			},
608			Ok(false) => {
609				let lookup = self.lightning_send_checkpoint(payment_hash).await;
610				match lookup {
611					Ok(Some(send)) => {
612						let result = match (&send.progress, known_preimage) {
613							(Progress::PaymentInitiated(htlcs), Some(preimage)) => {
614								let htlcs = htlcs.clone();
615								self.settle_lightning_send_with_preimage(send, htlcs, preimage).await
616							},
617							(Progress::PaymentInitiated(_), None) => {
618								self.drive_action(send, DriveMode::UntilParkOrDone).await
619							},
620							_ => {
621								debug!("Lightning send finished notification for {} but checkpoint is not PaymentInitiated; ignoring", payment_hash);
622								Ok(())
623							},
624						};
625						match result {
626							Ok(()) => info!("Processed lightning send finished for {}", payment_hash),
627							Err(e) => error!("Failed to process lightning send finished for {}: {:#}", payment_hash, e),
628						}
629					},
630					Ok(None) => {
631						warn!("Lightning send finished notification for unknown payment hash {}", payment_hash);
632					},
633					Err(e) => {
634						error!("Failed to look up lightning send checkpoint for {}: {:#}", payment_hash, e);
635					},
636				}
637			},
638			Err(e) => {
639				error!("Failed to look up paid_invoice for {}: {:#}", payment_hash, e);
640			},
641		}
642
643		self.store_mailbox_checkpoint(checkpoint).await?;
644		Ok(())
645	}
646
647	/// Post vtxo IDs to the server's recovery mailbox
648	pub async fn post_recovery_vtxo_ids(
649		&self,
650		vtxo_ids: impl IntoIterator<Item = VtxoId>,
651	) -> anyhow::Result<()> {
652		let vtxo_ids = vtxo_ids.into_iter().map(|id| id.to_bytes().to_vec()).collect::<Vec<_>>();
653		if vtxo_ids.is_empty() {
654			return Ok(());
655		}
656		let nb_vtxos = vtxo_ids.len();
657
658		// Prove ownership of the recovery mailbox; short validity is enough as
659		// it's consumed by this single request.
660		let expiry = chrono::Local::now() + std::time::Duration::from_secs(60);
661		let auth = MailboxAuthorization::new(&self.recovery_mailbox_keypair(), expiry);
662		let mailbox_id = self.recovery_mailbox_identifier().serialize();
663
664		let (mut srv, _) = self.require_server().await?;
665		for chunk in vtxo_ids.chunks(MAX_NB_MAILBOX_RECOVERY_IDS) {
666			let req = protos::mailbox_server::PostRecoveryVtxoIdsRequest {
667				mailbox_id: mailbox_id.clone(),
668				vtxo_ids: chunk.to_vec(),
669				authorization: Some(auth.serialize()),
670			};
671
672			srv.mailbox_client.post_recovery_vtxo_ids(req).await
673				.context("error posting recovery vtxo IDs to server")?;
674		}
675
676		debug!("Posted {} recovery vtxo IDs to server", nb_vtxos);
677		Ok(())
678	}
679
680	/// Return the stored mailbox checkpoint — the tip position the wallet
681	/// has consumed up to. After a successful [`Self::sync_mailbox`], this value
682	/// reflects the server's latest advertised tip.
683	pub async fn get_mailbox_checkpoint(&self) -> anyhow::Result<u64> {
684		Ok(self.inner.db.get_mailbox_checkpoint().await?)
685	}
686
687	async fn store_mailbox_checkpoint(&self, checkpoint: u64) -> anyhow::Result<()> {
688		Ok(self.inner.db.store_mailbox_checkpoint(checkpoint).await?)
689	}
690}
691
692#[cfg(test)]
693mod test {
694	use super::*;
695
696	use ark::SECP;
697	use ark::test_util::VTXO_VECTORS;
698	use bitcoin::secp256k1::Keypair;
699
700	/// The arkoor receive path must only store final, self-custodial coins for
701	/// our own server. This pins the four ways a received vtxo can be unsafe.
702	#[test]
703	fn arkoor_receive_policy_gate() {
704		let vectors = &*VTXO_VECTORS;
705		let server_pubkey = vectors.server_key.public_key();
706		let margin: BlockHeight = 144;
707
708		// A final Pubkey payment vtxo, committing to our server, with plenty of
709		// runway before expiry is acceptable.
710		let good = &vectors.board_vtxo;
711		let healthy_tip = good.expiry_height() - margin - 1;
712		check_arkoor_receive_policy(good, server_pubkey, healthy_tip, margin)
713			.expect("a healthy Pubkey vtxo must be accepted");
714
715		// An HTLC-send vtxo is an in-flight contract, not a payment.
716		let htlc = &vectors.arkoor_htlc_out_vtxo;
717		let htlc_tip = htlc.expiry_height() - margin - 1;
718		check_arkoor_receive_policy(htlc, server_pubkey, htlc_tip, margin)
719			.expect_err("an HTLC-send vtxo must be refused as a payment");
720
721		// A vtxo committing to some other server's key is not ours to trust.
722		let foreign_server = Keypair::from_seckey_slice(&SECP, &[0x11; 32])
723			.unwrap().public_key();
724		check_arkoor_receive_policy(good, foreign_server, healthy_tip, margin)
725			.expect_err("a vtxo for a foreign server must be refused");
726
727		// A vtxo within the expiry margin can't be safely exited if the server
728		// misbehaves after we've credited it.
729		let near_expiry_tip = good.expiry_height() - 1;
730		check_arkoor_receive_policy(good, server_pubkey, near_expiry_tip, margin)
731			.expect_err("a near-expiry vtxo must be refused");
732	}
733}