ark/vtxo/
validation.rs

1
2use std::borrow::Cow;
3
4use bitcoin::{sighash, Amount, OutPoint, TapLeafHash, Transaction, TxOut};
5
6use bitcoin_ext::{TxOutExt, P2TR_DUST};
7
8use crate::tree::signed::unlock_clause;
9use crate::{musig, SECP};
10use crate::vtxo::{GenesisTransition, TransitionKind, Vtxo, VtxoPolicyKind};
11
12
13#[derive(Debug, PartialEq, Eq, thiserror::Error)]
14#[error("VTXO validation error")]
15pub enum VtxoValidationError {
16	#[error("the VTXO is invalid: {0}")]
17	Invalid(&'static str),
18	#[error("the chain anchor output doesn't match the VTXO; expected: {expected:?}, got: {got:?}")]
19	IncorrectChainAnchor {
20		expected: TxOut,
21		got: TxOut,
22	},
23	#[error("Cosigned genesis transitions don't have any common pubkeys")]
24	InconsistentCosignPubkeys,
25	#[error("error verifying one of the genesis transitions \
26		(idx={genesis_idx}/{genesis_len} type={transition_kind}): {error}")]
27	GenesisTransition {
28		error: &'static str,
29		genesis_idx: usize,
30		genesis_len: usize,
31		// NB we use str here because we don't want to expose the kind enum
32		transition_kind: &'static str,
33	},
34	#[error("non-standard output on genesis item #{genesis_item_idx} other \
35		output #{other_output_idx}")]
36	NonStandardTxOut {
37		genesis_item_idx: usize,
38		other_output_idx: usize,
39	},
40	#[error("invalid arkoor policy of type {policy}: {msg}")]
41	InvalidArkoorPolicy {
42		policy: VtxoPolicyKind,
43		msg: &'static str,
44	},
45}
46
47impl VtxoValidationError {
48	/// Constructor for [VtxoValidationError::GenesisTransition].
49	fn transition(
50		genesis_idx: usize,
51		genesis_len: usize,
52		transition_kind: TransitionKind,
53		error: &'static str,
54	) -> Self {
55		let transition_kind = transition_kind.as_str();
56		VtxoValidationError::GenesisTransition { error, genesis_idx, genesis_len, transition_kind }
57	}
58}
59
60#[inline]
61fn verify_transition(
62	vtxo: &Vtxo,
63	genesis_idx: usize,
64	prev_tx: &Transaction,
65	prev_vout: usize,
66	next_amount: Amount,
67) -> Result<Transaction, &'static str> {
68	let item = vtxo.genesis.get(genesis_idx).expect("genesis_idx out of range");
69
70	let prev_txout = prev_tx.output.get(prev_vout).ok_or_else(|| "output idx out of range")?;
71
72	let next_output = vtxo.genesis.get(genesis_idx + 1).map(|item| {
73		item.transition.input_txout(
74			next_amount, vtxo.server_pubkey, vtxo.expiry_height, vtxo.exit_delta,
75		)
76	}).unwrap_or_else(|| {
77		// when we reach the end of the chain, we take the eventual output of the vtxo
78		vtxo.policy.txout(vtxo.amount, vtxo.server_pubkey, vtxo.exit_delta, vtxo.expiry_height)
79	});
80
81	let prevout = OutPoint::new(prev_tx.compute_txid(), prev_vout as u32);
82	let tx = item.tx(prevout, next_output, vtxo.server_pubkey, vtxo.expiry_height);
83
84	let sighash = match item.transition {
85		GenesisTransition::HashLockedCosigned { user_pubkey, unlock, .. } => {
86			let mut shc = sighash::SighashCache::new(&tx);
87			let agg_pk = musig::combine_keys([user_pubkey, vtxo.server_pubkey]);
88			let script = unlock_clause(agg_pk, unlock.hash());
89			let leaf = TapLeafHash::from_script(&script, bitcoin::taproot::LeafVersion::TapScript);
90			shc.taproot_script_spend_signature_hash(
91				0, &sighash::Prevouts::All(&[prev_txout]), leaf, sighash::TapSighashType::Default,
92			).expect("correct prevouts")
93		},
94		GenesisTransition::Cosigned { .. } | GenesisTransition::Arkoor { .. } => {
95			let mut shc = sighash::SighashCache::new(&tx);
96			shc.taproot_key_spend_signature_hash(
97				0, &sighash::Prevouts::All(&[prev_txout]), sighash::TapSighashType::Default,
98			).expect("correct prevouts")
99		},
100	};
101
102	let pubkey = {
103		let taproot = item.transition.input_taproot(
104			vtxo.server_pubkey(), vtxo.expiry_height(), vtxo.exit_delta(),
105		);
106		match item.transition {
107			GenesisTransition::Cosigned { .. } | GenesisTransition::Arkoor { .. } => {
108				taproot.output_key().to_x_only_public_key()
109			},
110			// hark transition is script-spend that uses internal key
111			GenesisTransition::HashLockedCosigned { .. } => taproot.internal_key(),
112		}
113	};
114
115	let signature = match item.transition {
116		GenesisTransition::Cosigned { signature, .. } => signature,
117		GenesisTransition::HashLockedCosigned { signature: Some(sig), .. } => sig,
118		GenesisTransition::HashLockedCosigned { signature: None, .. } => {
119			return Err("missing signature of hash-locked cosign leaf");
120		},
121		GenesisTransition::Arkoor { signature: Some(signature), .. } => signature,
122		GenesisTransition::Arkoor { signature: None, .. } => {
123			return Err("missing arkoor signature");
124		},
125	};
126
127	SECP.verify_schnorr(&signature, &sighash.into(), &pubkey)
128		.map_err(|_| "invalid signature")?;
129
130	#[cfg(test)]
131	{
132		if let Err(e) = crate::test::verify_tx(&[prev_txout.clone()], 0, &tx) {
133			// just print error because this is unit test context
134			println!("TX VALIDATION FAILED: invalid tx in genesis of vtxo {}: idx={}: {}",
135				vtxo.id(), genesis_idx, e,
136			);
137			return Err("transaction validation failed");
138		}
139	}
140
141	Ok(tx)
142}
143
144/// Validate that the [Vtxo] is valid and can be constructed from its
145/// chain anchor.
146///
147/// General checks and chain-anchor related checks are performed first,
148/// transitions are checked last.
149pub fn validate(
150	vtxo: &Vtxo,
151	chain_anchor_tx: &Transaction,
152) -> Result<(), VtxoValidationError> {
153	// We start by validating the chain anchor output.
154	let anchor_txout = chain_anchor_tx.output.get(vtxo.chain_anchor().vout as usize)
155		.ok_or(VtxoValidationError::Invalid("chain anchor vout out of range"))?;
156	let onchain_amount = vtxo.amount() + vtxo.genesis.iter().map(|i| {
157		i.other_outputs.iter().map(|o| o.value).sum()
158	}).sum();
159	let expected_anchor_txout = vtxo.genesis.get(0).unwrap().transition.input_txout(
160		onchain_amount, vtxo.server_pubkey(), vtxo.expiry_height(), vtxo.exit_delta(),
161	);
162	if *anchor_txout != expected_anchor_txout {
163		return Err(VtxoValidationError::IncorrectChainAnchor {
164			expected: expected_anchor_txout,
165			got: anchor_txout.clone(),
166		});
167	}
168
169	// Every VTXO should have one or more `Cosigned` transitions, followed by 0 or more
170	// `Arkoor` transitions.
171	if vtxo.genesis.is_empty() {
172		return Err(VtxoValidationError::Invalid("no genesis items"));
173	}
174
175	let mut prev = (Cow::Borrowed(chain_anchor_tx), vtxo.chain_anchor().vout as usize, onchain_amount);
176	let mut iter = vtxo.genesis.iter().enumerate().peekable();
177	while let Some((idx, item)) = iter.next() {
178		// transition-dependent validation
179		match &item.transition {
180			GenesisTransition::Cosigned { .. } => {},
181			GenesisTransition::HashLockedCosigned { .. } => {
182				// can only be followed by arkoor
183				if let Some((_idx, next)) = iter.peek() {
184					match &next.transition {
185						GenesisTransition::Arkoor { .. } => {},
186						GenesisTransition::Cosigned { .. }
187						| GenesisTransition::HashLockedCosigned { .. } => {
188							return Err(VtxoValidationError::transition(
189								idx, vtxo.genesis.len(), item.transition.kind(),
190								"hash-locked cosigned transition must \
191									be followed by arkoor transitions",
192							));
193						},
194					}
195				}
196			},
197			GenesisTransition::Arkoor { policy, .. } => {
198				if policy.arkoor_pubkey().is_none() {
199					return Err(VtxoValidationError::InvalidArkoorPolicy {
200						policy: policy.policy_type(),
201						msg: "arkoor transition without arkoor pubkey",
202					});
203				}
204
205				// can only be followed by more arkoor
206				if let Some((_idx, next)) = iter.peek() {
207					match &next.transition {
208						GenesisTransition::Arkoor { .. } => {},
209						GenesisTransition::Cosigned { .. }
210						| GenesisTransition::HashLockedCosigned { .. } => {
211							return Err(VtxoValidationError::transition(
212								idx, vtxo.genesis.len(), item.transition.kind(),
213								"Arkoor transition must be followed by arkoor transitions",
214							));
215						},
216					}
217				}
218			},
219		}
220
221		// All outputs have to be standard otherwise we can't relay.
222		// Skip this check for subdust vtxos since they can have subdust sibling outputs.
223		if vtxo.amount >= P2TR_DUST {
224			if let Some(out_idx) = item.other_outputs.iter().position(|o| !o.is_standard()) {
225				return Err(VtxoValidationError::NonStandardTxOut {
226					genesis_item_idx: idx,
227					other_output_idx: out_idx,
228				});
229			}
230		}
231
232		let next_amount = prev.2.checked_sub(item.other_outputs.iter().map(|o| o.value).sum())
233			.ok_or(VtxoValidationError::Invalid("insufficient onchain amount"))?;
234		let next_tx = verify_transition(&vtxo, idx, prev.0.as_ref(), prev.1, next_amount)
235			.map_err(|e| VtxoValidationError::transition(
236				idx, vtxo.genesis.len(), item.transition.kind(), e,
237			))?;
238		prev = (Cow::Owned(next_tx), item.output_idx as usize, next_amount);
239	}
240
241	// Verify the point field matches the computed exit outpoint
242	let expected_point = OutPoint::new(prev.0.compute_txid(), prev.1 as u32);
243	if vtxo.point != expected_point {
244		return Err(VtxoValidationError::Invalid("point doesn't match computed exit outpoint"));
245	}
246
247	Ok(())
248}
249
250#[cfg(test)]
251mod test {
252	use crate::vtxo::test::VTXO_VECTORS;
253
254	#[test]
255	pub fn validate_vtxos() {
256		let vtxos = &*VTXO_VECTORS;
257
258		let err = vtxos.board_vtxo.validate(&vtxos.anchor_tx).err();
259		assert!(err.is_none(), "err: {err:?}");
260
261		let err = vtxos.arkoor_htlc_out_vtxo.validate(&vtxos.anchor_tx).err();
262		assert!(err.is_none(), "err: {err:?}");
263
264		let err = vtxos.arkoor2_vtxo.validate(&vtxos.anchor_tx).err();
265		assert!(err.is_none(), "err: {err:?}");
266
267		let err = vtxos.round1_vtxo.validate(&vtxos.round_tx).err();
268		assert!(err.is_none(), "err: {err:?}");
269
270		let err = vtxos.round2_vtxo.validate(&vtxos.round_tx).err();
271		assert!(err.is_none(), "err: {err:?}");
272
273		let err = vtxos.arkoor3_vtxo.validate(&vtxos.round_tx).err();
274		assert!(err.is_none(), "err: {err:?}");
275	}
276}