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 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 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 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 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 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
144pub fn validate(
150 vtxo: &Vtxo,
151 chain_anchor_tx: &Transaction,
152) -> Result<(), VtxoValidationError> {
153 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 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 match &item.transition {
180 GenesisTransition::Cosigned { .. } => {},
181 GenesisTransition::HashLockedCosigned { .. } => {
182 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 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 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 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}