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