Skip to main content

bark/
arkoor.rs

1use anyhow::Context;
2use bitcoin::{Amount, NetworkKind};
3use bitcoin::hex::DisplayHex;
4use bitcoin::secp256k1::Keypair;
5use log::{error, info, warn};
6
7use ark::{ProtocolEncoding, VtxoPolicy};
8use ark::arkoor::ArkoorDestination;
9use ark::arkoor::package::{ArkoorPackageBuilder, ArkoorPackageCosignResponse};
10use ark::vtxo::{Full, Vtxo, VtxoId};
11use server_rpc::{protos, ServerConnection};
12
13use crate::{VtxoDelivery, Wallet, WalletVtxo};
14use crate::actions::DriveMode;
15use crate::actions::arkoor_send::{ArkoorSend, start_arkoor_send};
16
17/// The result of creating an arkoor transaction
18pub struct ArkoorCreateResult {
19	pub inputs: Vec<VtxoId>,
20	pub created: Vec<Vtxo<Full>>,
21	pub change: Vec<Vtxo<Full>>,
22}
23
24/// Error returned by [`Wallet::create_checkpointed_arkoor_with_vtxos`].
25///
26/// The cosign RPC failure is kept as a typed [`tonic::Status`] rather
27/// than flattened into `anyhow`, so a caller driving this as a wallet
28/// action can route a genuine server rejection to its `on_rejection`
29/// path (via `AdvanceError::is_server_rejection`) instead of retrying a
30/// doomed request forever. Every other failure is opaque `Other`.
31#[derive(Debug, thiserror::Error)]
32pub enum ArkoorCreateError {
33	/// The `request_arkoor_cosign` RPC failed. May be a rejection
34	/// (`InvalidArgument`/`NotFound`) or a transient error; the caller
35	/// classifies it via the status code.
36	#[error("server failed to cosign arkoor: {0}")]
37	Cosign(#[source] tonic::Status),
38	#[error(transparent)]
39	Other(#[from] anyhow::Error),
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
43pub enum ArkoorAddressError {
44	#[error("Ark address is for different network")]
45	NetworkMismatch,
46	#[error("Ark address is for different server")]
47	ServerMismatch,
48	#[error("VTXO policy in address cannot be used for arkoor payment: {0:?}")]
49	PolicyNotSupported(VtxoPolicy),
50	#[error("Unknown delivery mechanism: {0}")]
51	UnknownDeliveryMechanism(String),
52	#[error("Other error: {0}")]
53	Other(String),
54}
55
56/// Split a change amount into the piece amounts of the change destinations.
57///
58/// Change exceeding the payment is split in `split_factor` pieces
59/// (see [crate::Config::change_vtxo_split_factor]) so that repeated payments
60/// build a tree of change VTXOs rather than a chain. Pieces below the dust
61/// threshold are fine here: [ark::arkoor::ArkoorBuilder] isolates them.
62///
63/// Retries reuse the pieces stored on the action, so this policy can
64/// change between versions.
65pub(crate) fn split_change_amount(change: Amount, pay: Amount, split_factor: u8) -> Vec<Amount> {
66	if change == Amount::ZERO {
67		return Vec::new();
68	}
69	let pieces = if change > pay { u64::from(split_factor.max(1)) } else { 1 };
70	let base = change / pieces;
71	let mut ret = vec![base; pieces as usize];
72	*ret.last_mut().unwrap() = change - base * (pieces - 1);
73	ret
74}
75
76/// Resolve the change outputs of an arkoor package from the pieces stored
77/// on the action. `None` means the action was persisted by a pre-split
78/// bark, which built a single whole change output.
79pub(crate) fn resolve_change_pieces(
80	stored: Option<Vec<Amount>>,
81	change: Amount,
82) -> anyhow::Result<Vec<Amount>> {
83	match stored {
84		Some(pieces) => {
85			let sum = pieces.iter().copied().sum::<Amount>();
86			ensure!(sum == change, "stored change pieces sum to {}, expected {}", sum, change);
87			Ok(pieces)
88		},
89		None if change == Amount::ZERO => Ok(Vec::new()),
90		None => Ok(vec![change]),
91	}
92}
93
94/// Outcome of one [`post_arkoor_to_mailboxes`] pass.
95pub(crate) enum DeliveryOutcome {
96	/// At least one mailbox accepted the post.
97	AnySucceeded,
98	/// No mailbox accepted the post. `summary` describes why and is meant to
99	/// be captured in a caller's park error for observability.
100	AllFailed { summary: String },
101}
102
103/// Posts `vtxos` to every [`VtxoDelivery::ServerMailbox`] method found in
104/// `delivery`, in order, skipping any other delivery variant. Mailbox posts
105/// are idempotent on the server.
106///
107/// Any-success semantics: one accepted post is enough, since the recipient
108/// only needs the signed chain to arrive once.
109pub(crate) async fn post_arkoor_to_mailboxes(
110	srv: &mut ServerConnection,
111	delivery: &[VtxoDelivery],
112	vtxos: impl IntoIterator<Item = impl AsRef<Vtxo<Full>>>,
113) -> DeliveryOutcome {
114	let serialized = vtxos.into_iter()
115		.map(|v| v.as_ref().serialize().to_vec())
116		.collect::<Vec<_>>();
117
118	let mut any_succeeded = false;
119	let mut failures: Vec<String> = Vec::new();
120	for method in delivery {
121		let VtxoDelivery::ServerMailbox { blinded_id } = method else { continue };
122		let req = protos::mailbox_server::PostArkoorMessageRequest {
123			blinded_id: blinded_id.as_ref().to_vec(),
124			vtxos: serialized.clone(),
125		};
126		match srv.mailbox_client.post_arkoor_message(req).await {
127			Ok(_) => any_succeeded = true,
128			Err(e) => {
129				let reason = format!("{:#}", e);
130				error!("failed to post arkoor vtxos to mailbox: {}", reason);
131				failures.push(reason);
132			},
133		}
134	}
135
136	if any_succeeded {
137		return DeliveryOutcome::AnySucceeded;
138	}
139	let summary = if failures.is_empty() {
140		"no mailbox delivery mechanism configured on destination".to_string()
141	} else {
142		format!("no delivery mechanism accepted the arkoor vtxos: {}", failures.join("; "))
143	};
144	DeliveryOutcome::AllFailed { summary }
145}
146
147/// Checks that the address lists a useable delivery mechanism.
148///
149/// If the address doesn't specify a delivery mechanism that is clearly
150/// the receiver choice and this is allowed.
151///
152/// If all delivery methods are unknown we will error and not initiate
153/// a payment.
154fn check_delivery(delivery: &[VtxoDelivery]) -> Result<(), ArkoorAddressError> {
155	// The receiver explicitly wants no delivery. We should honour it
156	if delivery.is_empty() {
157		return Ok(());
158	}
159
160	if delivery.iter().any(|d| matches!(d, VtxoDelivery::ServerMailbox { .. })) {
161		return Ok(());
162	}
163
164	let listed = delivery.iter()
165		.map(|d| match d {
166			VtxoDelivery::Unknown { delivery_type, data } => {
167				format!("type={:#x}, data={}", delivery_type, data.as_hex())
168			},
169			other => format!("{:?}", other),
170		})
171		.collect::<Vec<_>>()
172		.join("; ");
173	Err(ArkoorAddressError::UnknownDeliveryMechanism(listed))
174}
175
176impl Wallet {
177	/// Validate if we can send arkoor payments to the given [ark::Address], for example an error
178	/// will be returned if the given [ark::Address] belongs to a different server (see
179	/// [ark::address::ArkId]).
180	pub async fn validate_arkoor_address(&self, address: &ark::Address) -> Result<(), ArkoorAddressError> {
181		let network = self.network().await
182			.map_err(|e| ArkoorAddressError::Other(e.to_string()))?;
183		let (_, ark_info) = self.require_server().await
184			.map_err(|e| ArkoorAddressError::Other(e.to_string()))?;
185
186		let network_kind = NetworkKind::from(network);
187		if address.is_testnet() == network_kind.is_mainnet() {
188			return Err(ArkoorAddressError::NetworkMismatch);
189		}
190
191		if !address.ark_id().is_for_server(ark_info.server_pubkey) {
192			return Err(ArkoorAddressError::ServerMismatch);
193		}
194
195		// Not all policies are supported for sending arkoor
196		match address.policy() {
197			VtxoPolicy::Pubkey(_) => {},
198			VtxoPolicy::ServerHtlcRecv_v0(_) | VtxoPolicy::ServerHtlcSend_v0(_)
199				| VtxoPolicy::ServerHtlcRecv(_) | VtxoPolicy::ServerHtlcSend(_) =>
200			{
201				return Err(ArkoorAddressError::PolicyNotSupported(address.policy().clone()));
202			}
203		}
204
205		check_delivery(address.delivery())?;
206
207		Ok(())
208	}
209
210	/// Build, cosign and split an arkoor package using a caller-provided
211	/// change keypair.
212	///
213	/// Reusing the same change keypair on a retry keeps the implied
214	/// `spending_txid` stable, so the server's `check_spendable_for_oor`
215	/// idempotency check accepts the retry rather than rejecting it as a
216	/// conflicting double-spend.
217	pub(crate) async fn create_checkpointed_arkoor_with_vtxos(
218		&self,
219		arkoor_dest: ArkoorDestination,
220		inputs: impl IntoIterator<Item = WalletVtxo>,
221		change_keypair: Keypair,
222		change_pieces: Option<Vec<Amount>>,
223	) -> Result<ArkoorCreateResult, ArkoorCreateError> {
224		let (mut srv, _) = self.require_server().await?;
225		let input_ids = inputs.into_iter().map(|v| v.id()).collect::<Vec<_>>();
226
227		// Hydrate the inputs to their full form: the arkoor builder needs
228		// the genesis chain and the server registration call sends the
229		// full bytes over the wire.
230		let inputs = self.inner.db.get_full_vtxos(&input_ids).await
231			.context("failed to hydrate arkoor input vtxos")?;
232
233		// Pre-register the input chains so the post-cosign register call
234		// for the outputs finds a signed chain anchor:
235		// register_vtxo_transactions validates a vtxo against its anchor's
236		// signed_tx in the DB, and boarded inputs sit unsigned in
237		// virtual_transaction (see register_board) until a
238		// register_vtxo_transactions call backfills them.
239		self.register_vtxo_transactions_with_server(&inputs).await
240			.context("failed to register arkoor input vtxo transactions with server")?;
241
242		let change_pubkey = change_keypair.public_key();
243		if arkoor_dest.policy.user_pubkey() == change_pubkey {
244			return Err(anyhow!("Cannot create arkoor to same address as change").into());
245		}
246
247		let mut user_keypairs = vec![];
248		for vtxo in &inputs {
249			user_keypairs.push(self.get_vtxo_key(vtxo).await?);
250		}
251
252		let total_input = inputs.iter().map(|v| v.amount()).sum::<Amount>();
253		let change_amount = total_input.checked_sub(arkoor_dest.total_amount)
254			.ok_or_else(|| anyhow!("arkoor inputs ({}) don't cover destination ({})",
255				total_input, arkoor_dest.total_amount,
256			))?;
257
258		let change_policy = VtxoPolicy::new_pubkey(change_pubkey);
259		let mut outputs = vec![arkoor_dest.clone()];
260		for piece in resolve_change_pieces(change_pieces, change_amount)? {
261			outputs.push(ArkoorDestination {
262				total_amount: piece,
263				policy: change_policy.clone(),
264			});
265		}
266
267		let builder = ArkoorPackageBuilder::new_with_checkpoints(inputs, outputs)
268			.context("Failed to construct arkoor package")?
269			.generate_user_nonces(&user_keypairs)
270			.context("invalid nb of keypairs")?;
271
272		let cosign_request = protos::ArkoorPackageCosignRequest::from(
273			builder.cosign_request(),
274		);
275
276		let response = srv.client.request_arkoor_cosign(cosign_request).await
277			.map_err(ArkoorCreateError::Cosign)?
278			.into_inner();
279
280		let cosign_responses = ArkoorPackageCosignResponse::try_from(response)
281			.context("Failed to parse cosign response from server")?;
282
283		let vtxos = builder
284			.user_cosign(&user_keypairs, cosign_responses)
285			.context("Failed to cosign vtxos")?
286			.build_signed_vtxos();
287
288		// divide between change and destination
289		let (dest, change) = vtxos.into_iter()
290			.partition::<Vec<_>, _>(|v| *v.policy() == arkoor_dest.policy);
291
292		Ok(ArkoorCreateResult {
293			inputs: input_ids,
294			created: dest,
295			change,
296		})
297	}
298
299	/// Makes an out-of-round payment to the given [ark::Address]. This does not require waiting for
300	/// a round, so it should be relatively instantaneous.
301	///
302	/// If the [Wallet] doesn't contain a VTXO larger than the given [Amount], multiple payments
303	/// will be chained together, resulting in the recipient receiving multiple VTXOs.
304	///
305	/// Note that a change [Vtxo] may be created as a result of this call. With each payment these
306	/// will become more uneconomical to unilaterally exit, so you should eventually refresh them
307	/// with [Wallet::refresh_vtxos] or periodically call [Wallet::maintenance_refresh].
308	pub async fn send_arkoor_payment(
309		&self,
310		destination: &ark::Address,
311		amount: Amount,
312	) -> anyhow::Result<()> {
313		let action = start_arkoor_send(self, destination.clone(), amount).await?;
314
315		// Persist the action together with the input locks so the executor has
316		// something to drive on restart; otherwise a crash between this point and
317		// `drive_action` leaves vtxos locked under an action id that has no
318		// checkpoint row.
319		self.inner.db.upsert_wallet_action_checkpoint(&action.id, &action.clone().into()).await?;
320
321		self.drive_action(action, DriveMode::UntilDone).await
322	}
323
324	/// Returns every in-progress arkoor send checkpoint.
325	pub async fn pending_arkoor_sends(&self) -> anyhow::Result<Vec<ArkoorSend>> {
326		Ok(self.inner.db.get_all_wallet_action_checkpoints().await?
327			.into_iter()
328			.filter_map(|cp| cp.into_arkoor_send())
329			.collect())
330	}
331
332	/// Drives every pending arkoor send forward by one step or to
333	/// completion if it's ready.
334	pub async fn sync_pending_arkoor_sends(&self) -> anyhow::Result<()> {
335		let pending = self.pending_arkoor_sends().await?;
336		if pending.is_empty() {
337			return Ok(());
338		}
339		info!("Syncing {} pending arkoor sends", pending.len());
340		for send in pending {
341			let id = send.id.clone();
342			if let Err(e) = self.drive_action(send, DriveMode::UntilParkOrDone).await {
343				warn!("Failed to sync arkoor send {}: {:#}", id, e);
344			}
345		}
346		Ok(())
347	}
348}
349
350#[cfg(test)]
351mod test {
352	use super::*;
353
354	#[test]
355	fn resolve_change_pieces_fallback_and_validation() {
356		let change = Amount::from_sat(30_000);
357		let pieces = vec![Amount::from_sat(10_000), Amount::from_sat(20_000)];
358
359		// stored pieces are used as-is when they sum to the change
360		assert_eq!(resolve_change_pieces(Some(pieces.clone()), change).unwrap(), pieces);
361
362		// a sum mismatch is an error, not a silently different package
363		assert!(resolve_change_pieces(Some(pieces), Amount::from_sat(30_001)).is_err());
364
365		// no stored pieces (pre-split checkpoint) rebuilds a single whole output
366		assert_eq!(resolve_change_pieces(None, change).unwrap(), vec![change]);
367		assert_eq!(resolve_change_pieces(None, Amount::ZERO).unwrap(), Vec::<Amount>::new());
368	}
369
370	#[test]
371	fn check_delivery_requires_a_mailbox() {
372		use std::str::FromStr;
373
374		let mailbox = VtxoDelivery::ServerMailbox {
375			blinded_id: ark::mailbox::BlindedMailboxIdentifier::from_str(
376				"024b0d4a4e8a29d2f36a83b4ff4a0e5c5e6f0f8b8d1f2a3b4c5d6e7f80912a3b4c",
377			).unwrap(),
378		};
379		let unknown = VtxoDelivery::Unknown { delivery_type: 0xff, data: vec![1, 2, 3] };
380
381		// an address without any delivery mechanism is the receiver's choice
382		assert_eq!(check_delivery(&[]), Ok(()));
383
384		// an address that only lists mechanisms we can't deliver to can't be paid
385		assert!(matches!(
386			check_delivery(&[unknown.clone()]),
387			Err(ArkoorAddressError::UnknownDeliveryMechanism(_)),
388		));
389
390		// one usable mechanism is enough, whatever else is listed
391		assert_eq!(check_delivery(&[mailbox.clone()]), Ok(()));
392		assert_eq!(check_delivery(&[unknown, mailbox]), Ok(()));
393	}
394
395	#[test]
396	fn split_change_amount_pieces() {
397		let pay = Amount::from_sat(10_000);
398
399		for factor in 1..=3u8 {
400			// zero change yields no pieces
401			assert_eq!(split_change_amount(Amount::ZERO, pay, factor), Vec::<Amount>::new());
402
403			// change at or below the payment stays whole
404			for sats in [1, 5_000, 10_000] {
405				let change = Amount::from_sat(sats);
406				assert_eq!(split_change_amount(change, pay, factor), vec![change]);
407			}
408
409			// change above the payment splits into factor pieces that add back up
410			for sats in [10_001, 123_457, 100_000_000] {
411				let change = Amount::from_sat(sats);
412				let pieces = split_change_amount(change, pay, factor);
413				assert_eq!(pieces.len(), factor as usize);
414				assert_eq!(pieces.iter().copied().sum::<Amount>(), change);
415			}
416		}
417	}
418}