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/// Outcome of one [`post_arkoor_to_mailboxes`] pass.
59pub(crate) enum DeliveryOutcome {
60	/// At least one mailbox accepted the post.
61	AnySucceeded,
62	/// No mailbox accepted the post. `summary` describes why and is meant to
63	/// be captured in a caller's park error for observability.
64	AllFailed { summary: String },
65}
66
67/// Posts `vtxos` to every [`VtxoDelivery::ServerMailbox`] method found in
68/// `delivery`, in order, skipping any other delivery variant. Mailbox posts
69/// are idempotent on the server.
70///
71/// Any-success semantics: one accepted post is enough, since the recipient
72/// only needs the signed chain to arrive once.
73pub(crate) async fn post_arkoor_to_mailboxes(
74	srv: &mut ServerConnection,
75	delivery: &[VtxoDelivery],
76	vtxos: impl IntoIterator<Item = impl AsRef<Vtxo<Full>>>,
77) -> DeliveryOutcome {
78	let serialized = vtxos.into_iter()
79		.map(|v| v.as_ref().serialize().to_vec())
80		.collect::<Vec<_>>();
81
82	let mut any_succeeded = false;
83	let mut failures: Vec<String> = Vec::new();
84	for method in delivery {
85		let VtxoDelivery::ServerMailbox { blinded_id } = method else { continue };
86		let req = protos::mailbox_server::PostArkoorMessageRequest {
87			blinded_id: blinded_id.as_ref().to_vec(),
88			vtxos: serialized.clone(),
89		};
90		match srv.mailbox_client.post_arkoor_message(req).await {
91			Ok(_) => any_succeeded = true,
92			Err(e) => {
93				let reason = format!("{:#}", e);
94				error!("failed to post arkoor vtxos to mailbox: {}", reason);
95				failures.push(reason);
96			},
97		}
98	}
99
100	if any_succeeded {
101		return DeliveryOutcome::AnySucceeded;
102	}
103	let summary = if failures.is_empty() {
104		"no mailbox delivery mechanism configured on destination".to_string()
105	} else {
106		format!("no delivery mechanism accepted the arkoor vtxos: {}", failures.join("; "))
107	};
108	DeliveryOutcome::AllFailed { summary }
109}
110
111impl Wallet {
112	/// Validate if we can send arkoor payments to the given [ark::Address], for example an error
113	/// will be returned if the given [ark::Address] belongs to a different server (see
114	/// [ark::address::ArkId]).
115	pub async fn validate_arkoor_address(&self, address: &ark::Address) -> Result<(), ArkoorAddressError> {
116		let network = self.network().await
117			.map_err(|e| ArkoorAddressError::Other(e.to_string()))?;
118		let (_, ark_info) = self.require_server().await
119			.map_err(|e| ArkoorAddressError::Other(e.to_string()))?;
120
121		let network_kind = NetworkKind::from(network);
122		if address.is_testnet() == network_kind.is_mainnet() {
123			return Err(ArkoorAddressError::NetworkMismatch);
124		}
125
126		if !address.ark_id().is_for_server(ark_info.server_pubkey) {
127			return Err(ArkoorAddressError::ServerMismatch);
128		}
129
130		// Not all policies are supported for sending arkoor
131		match address.policy() {
132			VtxoPolicy::Pubkey(_) => {},
133			VtxoPolicy::ServerHtlcRecv_v0(_) | VtxoPolicy::ServerHtlcSend_v0(_)
134				| VtxoPolicy::ServerHtlcRecv(_) | VtxoPolicy::ServerHtlcSend(_) =>
135			{
136				return Err(ArkoorAddressError::PolicyNotSupported(address.policy().clone()));
137			}
138		}
139
140		if address.delivery().is_empty() {
141			return Err(ArkoorAddressError::NoDeliveryMechanism);
142		}
143		// We first see if we know any of the deliveries, if not, we will log
144		// the unknown onces.
145		// We do this in two parts because we shouldn't log unknown ones if there is one known.
146		if !address.delivery().iter().any(|d| !d.is_unknown()) {
147			for d in address.delivery() {
148				if let VtxoDelivery::Unknown { delivery_type, data } = d {
149					info!("Unknown delivery in address: type={:#x}, data={}",
150						delivery_type, data.as_hex(),
151					);
152				}
153			}
154		}
155
156		Ok(())
157	}
158
159	/// Build, cosign and split an arkoor package using a caller-provided
160	/// change keypair.
161	///
162	/// Reusing the same change keypair on a retry keeps the implied
163	/// `spending_txid` stable, so the server's `check_spendable_for_oor`
164	/// idempotency check accepts the retry rather than rejecting it as a
165	/// conflicting double-spend.
166	pub(crate) async fn create_checkpointed_arkoor_with_vtxos(
167		&self,
168		arkoor_dest: ArkoorDestination,
169		inputs: impl IntoIterator<Item = WalletVtxo>,
170		change_keypair: Keypair,
171	) -> Result<ArkoorCreateResult, ArkoorCreateError> {
172		let (mut srv, _) = self.require_server().await?;
173		let input_ids = inputs.into_iter().map(|v| v.id()).collect::<Vec<_>>();
174
175		// Hydrate the inputs to their full form: the arkoor builder needs
176		// the genesis chain and the server registration call sends the
177		// full bytes over the wire.
178		let inputs = self.inner.db.get_full_vtxos(&input_ids).await
179			.context("failed to hydrate arkoor input vtxos")?;
180
181		// Pre-register the input chains so the post-cosign register call
182		// for the outputs finds a signed chain anchor:
183		// register_vtxo_transactions validates a vtxo against its anchor's
184		// signed_tx in the DB, and boarded inputs sit unsigned in
185		// virtual_transaction (see register_board) until a
186		// register_vtxo_transactions call backfills them.
187		self.register_vtxo_transactions_with_server(&inputs).await
188			.context("failed to register arkoor input vtxo transactions with server")?;
189
190		let change_pubkey = change_keypair.public_key();
191		if arkoor_dest.policy.user_pubkey() == change_pubkey {
192			return Err(anyhow!("Cannot create arkoor to same address as change").into());
193		}
194
195		let mut user_keypairs = vec![];
196		for vtxo in &inputs {
197			user_keypairs.push(self.get_vtxo_key(vtxo).await?);
198		}
199
200		let builder = ArkoorPackageBuilder::new_single_output_with_checkpoints(
201			inputs.into_iter(),
202			arkoor_dest.clone(),
203			VtxoPolicy::new_pubkey(change_pubkey),
204		)
205			.context("Failed to construct arkoor package")?
206			.generate_user_nonces(&user_keypairs)
207			.context("invalid nb of keypairs")?;
208
209		let cosign_request = protos::ArkoorPackageCosignRequest::from(
210			builder.cosign_request(),
211		);
212
213		let response = srv.client.request_arkoor_cosign(cosign_request).await
214			.map_err(ArkoorCreateError::Cosign)?
215			.into_inner();
216
217		let cosign_responses = ArkoorPackageCosignResponse::try_from(response)
218			.context("Failed to parse cosign response from server")?;
219
220		let vtxos = builder
221			.user_cosign(&user_keypairs, cosign_responses)
222			.context("Failed to cosign vtxos")?
223			.build_signed_vtxos();
224
225		// divide between change and destination
226		let (dest, change) = vtxos.into_iter()
227			.partition::<Vec<_>, _>(|v| *v.policy() == arkoor_dest.policy);
228
229		Ok(ArkoorCreateResult {
230			inputs: input_ids,
231			created: dest,
232			change,
233		})
234	}
235
236	/// Makes an out-of-round payment to the given [ark::Address]. This does not require waiting for
237	/// a round, so it should be relatively instantaneous.
238	///
239	/// If the [Wallet] doesn't contain a VTXO larger than the given [Amount], multiple payments
240	/// will be chained together, resulting in the recipient receiving multiple VTXOs.
241	///
242	/// Note that a change [Vtxo] may be created as a result of this call. With each payment these
243	/// will become more uneconomical to unilaterally exit, so you should eventually refresh them
244	/// with [Wallet::refresh_vtxos] or periodically call [Wallet::maintenance_refresh].
245	pub async fn send_arkoor_payment(
246		&self,
247		destination: &ark::Address,
248		amount: Amount,
249	) -> anyhow::Result<()> {
250		let action = start_arkoor_send(self, destination.clone(), amount).await?;
251
252		// Persist the action together with the input locks so the executor has
253		// something to drive on restart; otherwise a crash between this point and
254		// `drive_action` leaves vtxos locked under an action id that has no
255		// checkpoint row.
256		self.inner.db.upsert_wallet_action_checkpoint(&action.id, &action.clone().into()).await?;
257
258		self.drive_action(action, DriveMode::UntilDone).await
259	}
260
261	/// Returns every in-progress arkoor send checkpoint.
262	pub async fn pending_arkoor_sends(&self) -> anyhow::Result<Vec<ArkoorSend>> {
263		Ok(self.inner.db.get_all_wallet_action_checkpoints().await?
264			.into_iter()
265			.filter_map(|cp| cp.into_arkoor_send())
266			.collect())
267	}
268
269	/// Drives every pending arkoor send forward by one step or to
270	/// completion if it's ready.
271	pub async fn sync_pending_arkoor_sends(&self) -> anyhow::Result<()> {
272		let pending = self.pending_arkoor_sends().await?;
273		if pending.is_empty() {
274			return Ok(());
275		}
276		info!("Syncing {} pending arkoor sends", pending.len());
277		for send in pending {
278			let id = send.id.clone();
279			if let Err(e) = self.drive_action(send, DriveMode::UntilParkOrDone).await {
280				warn!("Failed to sync arkoor send {}: {:#}", id, e);
281			}
282		}
283		Ok(())
284	}
285}