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