Skip to main content

ark/vtxo/
validation.rs

1
2use std::borrow::Cow;
3
4use bitcoin::{Amount, OutPoint, Transaction, TxOut};
5
6use crate::vtxo::{Full, Policy, Vtxo, VtxoPolicyKind};
7use crate::vtxo::genesis::{GenesisTransition, TransitionKind};
8
9#[derive(Debug, PartialEq, Eq, thiserror::Error)]
10#[error("VTXO validation error")]
11pub enum VtxoValidationError {
12	#[error("the VTXO is invalid: {0}")]
13	Invalid(&'static str),
14	#[error("the chain anchor output doesn't match the VTXO; expected: {expected:?}, got: {got:?}")]
15	IncorrectChainAnchor {
16		expected: TxOut,
17		got: TxOut,
18	},
19	#[error("Cosigned genesis transitions don't have any common pubkeys")]
20	InconsistentCosignPubkeys,
21	#[error("error verifying one of the genesis transitions \
22		(idx={genesis_idx}/{genesis_len} type={transition_kind}): {error}")]
23	GenesisTransition {
24		error: &'static str,
25		genesis_idx: usize,
26		genesis_len: usize,
27		// NB we use str here because we don't want to expose the kind enum
28		transition_kind: &'static str,
29	},
30	#[error("invalid arkoor policy of type {policy}: {msg}")]
31	InvalidArkoorPolicy {
32		policy: VtxoPolicyKind,
33		msg: &'static str,
34	},
35	#[error("Expected genesis items but found none")]
36	MissingGenesisItems,
37	#[error("Genesis items were found but none were expected")]
38	UnexpectedGenesisItems,
39}
40
41impl VtxoValidationError {
42	/// Constructor for [VtxoValidationError::GenesisTransition].
43	fn transition(
44		genesis_idx: usize,
45		genesis_len: usize,
46		transition_kind: TransitionKind,
47		error: &'static str,
48	) -> Self {
49		let transition_kind = transition_kind.as_str();
50		VtxoValidationError::GenesisTransition { error, genesis_idx, genesis_len, transition_kind }
51	}
52}
53
54#[inline]
55#[allow(unused_variables)]
56fn verify_transition<P: Policy>(
57	vtxo: &Vtxo<Full, P>,
58	genesis_idx: usize,
59	prev_tx: &Transaction,
60	prev_vout: usize,
61	next_amount: Amount,
62	check_signatures: bool,
63) -> Result<Transaction, &'static str> {
64	let item = vtxo.genesis.items.get(genesis_idx).expect("genesis_idx out of range");
65
66	let prev_txout = prev_tx.output.get(prev_vout).ok_or_else(|| "output idx out of range")?;
67
68	let next_output = vtxo.genesis.items.get(genesis_idx.saturating_add(1)).map(|item| {
69		item.transition.input_txout(
70			next_amount, vtxo.server_pubkey, vtxo.expiry_height, vtxo.exit_delta,
71		)
72	}).unwrap_or_else(|| {
73		// when we reach the end of the chain, we take the eventual output of the vtxo
74		vtxo.policy.txout(vtxo.amount, vtxo.server_pubkey, vtxo.exit_delta, vtxo.expiry_height)
75	});
76
77	let prevout = OutPoint::new(prev_tx.compute_txid(), u32::try_from(prev_vout).expect("vout fits in u32"));
78	let tx = item.tx(prevout, next_output, vtxo.server_pubkey, vtxo.expiry_height);
79
80	if check_signatures {
81		match &item.transition {
82			GenesisTransition::Cosigned(inner) => {
83				inner.validate_sigs(&tx, 0, prev_txout, vtxo.server_pubkey, vtxo.expiry_height)?
84			}
85			GenesisTransition::Arkoor(inner) => {
86				inner.validate_sigs(&tx, 0, prev_txout, vtxo.server_pubkey)?
87			}
88			GenesisTransition::HashLockedCosigned(inner) => {
89				inner.validate_sigs(&tx, 0, prev_txout, vtxo.server_pubkey, vtxo.expiry_height)?
90			}
91			GenesisTransition::HashLockedCosigned_v0(inner) => {
92				inner.validate_sigs(&tx, 0, prev_txout, vtxo.server_pubkey, vtxo.expiry_height)?
93			}
94		};
95
96		#[cfg(test)]
97		{
98			if let Err(e) = crate::test_util::verify_tx(&[prev_txout.clone()], 0, &tx) {
99				// just print error because this is unit test context
100				println!("TX VALIDATION FAILED: invalid tx in genesis of vtxo {}: idx={}: {}",
101					vtxo.id(), genesis_idx, e,
102				);
103				return Err("transaction validation failed");
104			}
105		}
106	}
107
108
109	Ok(tx)
110}
111
112fn validate_inner<P: Policy>(
113	vtxo: &Vtxo<Full, P>,
114	chain_anchor_tx: &Transaction,
115	check_signatures: bool,
116) -> Result<(), VtxoValidationError> {
117	// We start by validating the chain anchor output.
118	let anchor_txout = chain_anchor_tx.output.get(vtxo.chain_anchor().vout as usize)
119		.ok_or(VtxoValidationError::Invalid("chain anchor vout out of range"))?;
120
121	// For empty genesis, validate that the chain anchor output matches the policy's txout
122	if vtxo.genesis.items.is_empty() {
123		let expected_anchor_txout = vtxo.policy.txout(
124			vtxo.amount(),
125			vtxo.server_pubkey(),
126			vtxo.exit_delta(),
127			vtxo.expiry_height(),
128		);
129		if *anchor_txout != expected_anchor_txout {
130			return Err(VtxoValidationError::IncorrectChainAnchor {
131				expected: expected_anchor_txout,
132				got: anchor_txout.clone(),
133			});
134		}
135
136		if vtxo.point != vtxo.chain_anchor() {
137			return Err(VtxoValidationError::Invalid(
138				"point of empty genesis vtxo doesn't match anchor point",
139			));
140		}
141		return Ok(());
142	}
143
144	// For non-empty genesis, validate using the first genesis item's transition
145	let onchain_amount = vtxo.chain_anchor_amount()
146		.ok_or_else(|| VtxoValidationError::Invalid("onchain amount overflow"))?;
147	let expected_anchor_txout = vtxo.genesis.items.get(0).unwrap().transition.input_txout(
148		onchain_amount, vtxo.server_pubkey(), vtxo.expiry_height(), vtxo.exit_delta(),
149	);
150	if *anchor_txout != expected_anchor_txout {
151		return Err(VtxoValidationError::IncorrectChainAnchor {
152			expected: expected_anchor_txout,
153			got: anchor_txout.clone(),
154		});
155	}
156
157	let mut prev = (Cow::Borrowed(chain_anchor_tx), vtxo.chain_anchor().vout as usize, onchain_amount);
158	for (idx, item) in vtxo.genesis.items.iter().enumerate() {
159		let output_sum = item.other_output_sum()
160			.ok_or(VtxoValidationError::Invalid("output sum overflow"))?;
161		let next_amount = prev.2.checked_sub(output_sum)
162			.ok_or(VtxoValidationError::Invalid("insufficient onchain amount"))?;
163		let next_tx = verify_transition(&vtxo, idx, prev.0.as_ref(), prev.1, next_amount, check_signatures)
164			.map_err(|e| VtxoValidationError::transition(
165				idx, vtxo.genesis.items.len(), item.transition.kind(), e,
166			))?;
167		prev = (Cow::Owned(next_tx), item.output_idx as usize, next_amount);
168	}
169
170	// Verify the point field matches the computed exit outpoint
171	let expected_point = OutPoint::new(prev.0.compute_txid(), u32::try_from(prev.1).expect("vout fits in u32"));
172	if vtxo.point != expected_point {
173		return Err(VtxoValidationError::Invalid("point doesn't match computed exit outpoint"));
174	}
175
176	Ok(())
177}
178
179/// Validate that the [Vtxo] is valid and can be constructed from its
180/// chain anchor.
181///
182/// General checks and chain-anchor related checks are performed first,
183/// transitions are checked last.
184pub fn validate<P: Policy>(
185	vtxo: &Vtxo<Full, P>,
186	chain_anchor_tx: &Transaction,
187) -> Result<(), VtxoValidationError> {
188	validate_inner(vtxo, chain_anchor_tx, true)
189}
190
191/// Validate VTXO structure without checking signatures.
192pub fn validate_unsigned<P: Policy>(
193	vtxo: &Vtxo<Full, P>,
194	chain_anchor_tx: &Transaction,
195) -> Result<(), VtxoValidationError> {
196	validate_inner(vtxo, chain_anchor_tx, false)
197}
198
199#[cfg(test)]
200mod test {
201	use bitcoin::{OutPoint, Transaction};
202
203	use crate::{ProtocolEncoding, Vtxo};
204	use crate::vtxo::Full;
205	use crate::test_util::VTXO_VECTORS;
206
207	#[test]
208	pub fn validate_vtxos() {
209		let vtxos = &*VTXO_VECTORS;
210
211		assert!(vtxos.board_vtxo.is_standard());
212		let err = vtxos.board_vtxo.validate(&vtxos.anchor_tx).err();
213		assert!(err.is_none(), "err: {err:?}");
214
215		assert!(vtxos.arkoor_htlc_out_vtxo.is_standard());
216		let err = vtxos.arkoor_htlc_out_vtxo.validate(&vtxos.anchor_tx).err();
217		assert!(err.is_none(), "err: {err:?}");
218
219		assert!(vtxos.arkoor2_vtxo.is_standard());
220		let err = vtxos.arkoor2_vtxo.validate(&vtxos.anchor_tx).err();
221		assert!(err.is_none(), "err: {err:?}");
222
223		assert!(vtxos.round1_vtxo.is_standard());
224		let err = vtxos.round1_vtxo.validate(&vtxos.round_tx).err();
225		assert!(err.is_none(), "err: {err:?}");
226
227		assert!(vtxos.round2_vtxo.is_standard());
228		let err = vtxos.round2_vtxo.validate(&vtxos.round_tx).err();
229		assert!(err.is_none(), "err: {err:?}");
230
231		assert!(vtxos.arkoor3_vtxo.is_standard());
232		let err = vtxos.arkoor3_vtxo.validate(&vtxos.round_tx).err();
233		assert!(err.is_none(), "err: {err:?}");
234	}
235
236	#[test]
237	fn terminal_output_idx_out_of_range_is_rejected() {
238		fn check(mut vtxo: Vtxo<Full>, anchor: &Transaction) {
239			// Baseline: the fixture validates and places its funds at vout 0.
240			vtxo.validate(anchor).expect("baseline vtxo must validate");
241			assert_eq!(vtxo.point().vout, 0, "fixtures place the funds at vout 0");
242			let genesis_txid = vtxo.point().txid;
243
244			// Craft the malicious encoding: bump the terminal item's output_idx
245			// to nb_outputs and move `point` to the same (out-of-range) vout.
246			let nb_outputs = {
247				let item = vtxo.genesis.items.last_mut().unwrap();
248				let nb = item.other_outputs.len() + 1;
249				item.output_idx = nb as u8; // == nb_outputs: out of range
250				nb
251			};
252			vtxo.point = OutPoint::new(genesis_txid, nb_outputs as u32);
253
254			// The decoder MUST reject it. Before the fix this returned Ok, and
255			// the decoded VTXO's id was the P2A fee-anchor outpoint while
256			// validate() still passed.
257			let bytes = vtxo.serialize();
258			let res = Vtxo::<Full>::deserialize(&bytes);
259			assert!(res.is_err(),
260				"decoder must reject a genesis item with output_idx >= nb_outputs; \
261				accepted a VTXO whose point is the fee anchor: {:?}",
262				res.map(|v| v.point()));
263		}
264
265		// A board VTXO (single cosigned item) and an arkoor VTXO (the real
266		// delivery vector, terminal `arkoor` item) both exercise the gap.
267		check(VTXO_VECTORS.board_vtxo.clone(), &VTXO_VECTORS.anchor_tx);
268		check(VTXO_VECTORS.arkoor2_vtxo.clone(), &VTXO_VECTORS.anchor_tx);
269	}
270}