1use hns_covenants::{Covenant, CovenantKind, blind_bid};
2use thiserror::Error;
3
4use crate::{Address, Coin, Outpoint, Output, Transaction};
5
6#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
7pub struct CovenantLinkSummary {
8 pub inputs_checked: usize,
9 pub linked_outputs: usize,
10 pub name_inputs: usize,
11}
12
13pub fn verify_covenant_links(
14 transaction: &Transaction,
15 input_coins: &[Coin],
16) -> Result<CovenantLinkSummary, CovenantLinkError> {
17 if transaction.is_coinbase() {
18 return Err(CovenantLinkError::CoinbaseRequiresIssuanceVerifier);
19 }
20 if transaction.inputs.len() != input_coins.len() {
21 return Err(CovenantLinkError::InputCountMismatch {
22 transaction: transaction.inputs.len(),
23 coins: input_coins.len(),
24 });
25 }
26 let mut summary = CovenantLinkSummary {
27 inputs_checked: input_coins.len(),
28 ..CovenantLinkSummary::default()
29 };
30 for (input_index, (input, coin)) in transaction.inputs.iter().zip(input_coins).enumerate() {
31 if input.previous_output != coin.outpoint {
32 return Err(CovenantLinkError::CoinOutpointMismatch {
33 input_index,
34 expected: input.previous_output,
35 actual: coin.outpoint,
36 });
37 }
38 let output = transaction.outputs.get(input_index);
39 let spent = &coin.covenant;
40 let spent_kind = spent.kind;
41 if spent_kind.is_name() {
42 summary.name_inputs += 1;
43 }
44 match spent_kind {
45 CovenantKind::None | CovenantKind::Open | CovenantKind::Redeem => {
46 let Some(output) = output else {
47 continue;
48 };
49 if !matches!(
50 output.covenant.kind,
51 CovenantKind::None | CovenantKind::Open | CovenantKind::Bid
52 ) {
53 return Err(CovenantLinkError::InvalidTransition {
54 input_index,
55 from: spent_kind,
56 to: output.covenant.kind,
57 });
58 }
59 }
60 CovenantKind::Bid => {
61 let output = require_linked_output(input_index, spent_kind, output)?;
62 require_transition(input_index, spent_kind, output, CovenantKind::Reveal)?;
63 require_name_and_start_match(input_index, spent, &output.covenant)?;
64 let nonce = required_hash(input_index, &output.covenant, 2, "reveal nonce")?;
65 let commitment = required_hash(input_index, spent, 3, "bid commitment")?;
66 if blind_bid(output.value.get(), &nonce) != commitment {
67 return Err(CovenantLinkError::BlindCommitmentMismatch { input_index });
68 }
69 if coin.value < output.value {
70 return Err(CovenantLinkError::BidValueInflation { input_index });
71 }
72 summary.linked_outputs += 1;
73 }
74 CovenantKind::Claim | CovenantKind::Reveal => {
75 let output = require_linked_output(input_index, spent_kind, output)?;
76 match output.covenant.kind {
77 CovenantKind::Register => {
78 require_name_and_start_match(input_index, spent, &output.covenant)?;
79 require_address_match(input_index, &coin.address, output)?;
80 }
81 CovenantKind::Redeem => {
82 require_name_and_start_match(input_index, spent, &output.covenant)?;
83 if spent_kind == CovenantKind::Claim {
84 return Err(CovenantLinkError::ClaimCannotRedeem { input_index });
85 }
86 }
87 to => {
88 return Err(CovenantLinkError::InvalidTransition {
89 input_index,
90 from: spent_kind,
91 to,
92 });
93 }
94 }
95 summary.linked_outputs += 1;
96 }
97 CovenantKind::Register
98 | CovenantKind::Update
99 | CovenantKind::Renew
100 | CovenantKind::Finalize => {
101 let output = require_linked_output(input_index, spent_kind, output)?;
102 require_locked_value(input_index, coin, output)?;
103 require_address_match(input_index, &coin.address, output)?;
104 if !matches!(
105 output.covenant.kind,
106 CovenantKind::Update
107 | CovenantKind::Renew
108 | CovenantKind::Transfer
109 | CovenantKind::Revoke
110 ) {
111 return Err(CovenantLinkError::InvalidTransition {
112 input_index,
113 from: spent_kind,
114 to: output.covenant.kind,
115 });
116 }
117 require_name_and_start_match(input_index, spent, &output.covenant)?;
118 summary.linked_outputs += 1;
119 }
120 CovenantKind::Transfer => {
121 let output = require_linked_output(input_index, spent_kind, output)?;
122 require_locked_value(input_index, coin, output)?;
123 match output.covenant.kind {
124 CovenantKind::Update | CovenantKind::Renew | CovenantKind::Revoke => {
125 require_name_and_start_match(input_index, spent, &output.covenant)?;
126 require_address_match(input_index, &coin.address, output)?;
127 }
128 CovenantKind::Finalize => {
129 require_name_and_start_match(input_index, spent, &output.covenant)?;
130 let version = required_u8(input_index, spent, 2, "transfer version")?;
131 let hash = required_item(input_index, spent, 3, "transfer hash")?;
132 if output.address.version != version
133 || output.address.hash.as_slice() != hash
134 {
135 return Err(CovenantLinkError::TransferDestinationMismatch {
136 input_index,
137 });
138 }
139 }
140 to => {
141 return Err(CovenantLinkError::InvalidTransition {
142 input_index,
143 from: spent_kind,
144 to,
145 });
146 }
147 }
148 summary.linked_outputs += 1;
149 }
150 CovenantKind::Revoke => {
151 return Err(CovenantLinkError::RevokedCoinSpent { input_index });
152 }
153 CovenantKind::Unknown(_) => {
154 if let Some(output) = output
155 && output.covenant.kind.is_name()
156 {
157 return Err(CovenantLinkError::UnknownCovenantCreatesName {
158 input_index,
159 to: output.covenant.kind,
160 });
161 }
162 }
163 }
164 }
165 for (output_index, output) in transaction
166 .outputs
167 .iter()
168 .enumerate()
169 .skip(transaction.inputs.len())
170 {
171 if output.covenant.kind.is_linked() {
172 return Err(CovenantLinkError::UnpairedLinkedOutput {
173 output_index,
174 kind: output.covenant.kind,
175 });
176 }
177 }
178 Ok(summary)
179}
180
181fn require_linked_output(
182 input_index: usize,
183 from: CovenantKind,
184 output: Option<&Output>,
185) -> Result<&Output, CovenantLinkError> {
186 output.ok_or(CovenantLinkError::MissingLinkedOutput { input_index, from })
187}
188
189fn require_transition(
190 input_index: usize,
191 from: CovenantKind,
192 output: &Output,
193 expected: CovenantKind,
194) -> Result<(), CovenantLinkError> {
195 if output.covenant.kind != expected {
196 return Err(CovenantLinkError::InvalidTransition {
197 input_index,
198 from,
199 to: output.covenant.kind,
200 });
201 }
202 Ok(())
203}
204
205fn require_name_and_start_match(
206 input_index: usize,
207 spent: &Covenant,
208 created: &Covenant,
209) -> Result<(), CovenantLinkError> {
210 if required_hash(input_index, spent, 0, "spent name")?
211 != required_hash(input_index, created, 0, "created name")?
212 {
213 return Err(CovenantLinkError::NameHashMismatch { input_index });
214 }
215 if required_u32(input_index, spent, 1, "spent start")?
216 != required_u32(input_index, created, 1, "created start")?
217 {
218 return Err(CovenantLinkError::StartHeightMismatch { input_index });
219 }
220 Ok(())
221}
222
223fn require_locked_value(
224 input_index: usize,
225 coin: &Coin,
226 output: &Output,
227) -> Result<(), CovenantLinkError> {
228 if output.value != coin.value {
229 return Err(CovenantLinkError::LockedValueMismatch { input_index });
230 }
231 Ok(())
232}
233
234fn require_address_match(
235 input_index: usize,
236 expected: &Address,
237 output: &Output,
238) -> Result<(), CovenantLinkError> {
239 if &output.address != expected {
240 return Err(CovenantLinkError::AddressMismatch { input_index });
241 }
242 Ok(())
243}
244
245fn required_item<'a>(
246 input_index: usize,
247 covenant: &'a Covenant,
248 item_index: usize,
249 field: &'static str,
250) -> Result<&'a [u8], CovenantLinkError> {
251 covenant
252 .item(item_index)
253 .ok_or(CovenantLinkError::MalformedCovenant { input_index, field })
254}
255
256fn required_u8(
257 input_index: usize,
258 covenant: &Covenant,
259 item_index: usize,
260 field: &'static str,
261) -> Result<u8, CovenantLinkError> {
262 covenant
263 .item_u8(item_index)
264 .ok_or(CovenantLinkError::MalformedCovenant { input_index, field })
265}
266
267fn required_u32(
268 input_index: usize,
269 covenant: &Covenant,
270 item_index: usize,
271 field: &'static str,
272) -> Result<u32, CovenantLinkError> {
273 covenant
274 .item_u32(item_index)
275 .ok_or(CovenantLinkError::MalformedCovenant { input_index, field })
276}
277
278fn required_hash(
279 input_index: usize,
280 covenant: &Covenant,
281 item_index: usize,
282 field: &'static str,
283) -> Result<[u8; 32], CovenantLinkError> {
284 required_item(input_index, covenant, item_index, field)?
285 .try_into()
286 .map_err(|_| CovenantLinkError::MalformedCovenant { input_index, field })
287}
288
289#[derive(Debug, Error, Eq, PartialEq)]
290pub enum CovenantLinkError {
291 #[error("coinbase covenant issuance requires its dedicated verifier")]
292 CoinbaseRequiresIssuanceVerifier,
293 #[error("transaction input count does not match resolved coins")]
294 InputCountMismatch { transaction: usize, coins: usize },
295 #[error("input {input_index} outpoint does not match resolved coin")]
296 CoinOutpointMismatch {
297 input_index: usize,
298 expected: Outpoint,
299 actual: Outpoint,
300 },
301 #[error("input {input_index} covenant {from:?} requires a linked output")]
302 MissingLinkedOutput {
303 input_index: usize,
304 from: CovenantKind,
305 },
306 #[error("input {input_index} covenant transition {from:?} -> {to:?} is invalid")]
307 InvalidTransition {
308 input_index: usize,
309 from: CovenantKind,
310 to: CovenantKind,
311 },
312 #[error("input {input_index} mis-encodes {field}")]
313 MalformedCovenant {
314 input_index: usize,
315 field: &'static str,
316 },
317 #[error("input {input_index} name hash differs from linked output")]
318 NameHashMismatch { input_index: usize },
319 #[error("input {input_index} start height differs from linked output")]
320 StartHeightMismatch { input_index: usize },
321 #[error("input {input_index} reveal does not match blind commitment")]
322 BlindCommitmentMismatch { input_index: usize },
323 #[error("input {input_index} reveal value exceeds locked bid value")]
324 BidValueInflation { input_index: usize },
325 #[error("input {input_index} claim cannot redeem")]
326 ClaimCannotRedeem { input_index: usize },
327 #[error("input {input_index} output address differs from locked address")]
328 AddressMismatch { input_index: usize },
329 #[error("input {input_index} output value differs from locked value")]
330 LockedValueMismatch { input_index: usize },
331 #[error("input {input_index} finalize destination differs from transfer")]
332 TransferDestinationMismatch { input_index: usize },
333 #[error("input {input_index} attempts to spend a revoked name")]
334 RevokedCoinSpent { input_index: usize },
335 #[error("input {input_index} unknown covenant creates name covenant {to:?}")]
336 UnknownCovenantCreatesName {
337 input_index: usize,
338 to: CovenantKind,
339 },
340 #[error("output {output_index} linked covenant {kind:?} has no corresponding input")]
341 UnpairedLinkedOutput {
342 output_index: usize,
343 kind: CovenantKind,
344 },
345}
346
347#[cfg(test)]
348mod tests {
349 use hns_primitives::{Dollarydoos, Height, TransactionHash};
350
351 use super::*;
352 use crate::{Input, Witness};
353
354 #[test]
355 fn bid_reveal_commitment_and_revoke_rules_match_hsd() {
356 let outpoint = Outpoint {
357 transaction_hash: TransactionHash::new([1; 32]),
358 index: 0,
359 };
360 let nonce = [3; 32];
361 let spent = Covenant {
362 kind: CovenantKind::Bid,
363 items: vec![
364 vec![2; 32],
365 9_u32.to_le_bytes().to_vec(),
366 b"name".to_vec(),
367 blind_bid(100, &nonce).to_vec(),
368 ],
369 };
370 let revealed = Covenant {
371 kind: CovenantKind::Reveal,
372 items: vec![vec![2; 32], 9_u32.to_le_bytes().to_vec(), nonce.to_vec()],
373 };
374 let address = Address::new(0, vec![4; 20]).expect("address");
375 let coin = Coin {
376 outpoint,
377 value: Dollarydoos::new(100),
378 height: Height::new(1),
379 coinbase: false,
380 address: address.clone(),
381 covenant: spent,
382 };
383 let transaction = Transaction {
384 version: 1,
385 inputs: vec![Input {
386 previous_output: outpoint,
387 sequence: u32::MAX,
388 witness: Witness::default(),
389 }],
390 outputs: vec![Output {
391 value: Dollarydoos::new(100),
392 address,
393 covenant: revealed,
394 }],
395 locktime: 0,
396 };
397 assert_eq!(
398 verify_covenant_links(&transaction, &[coin])
399 .expect("valid")
400 .linked_outputs,
401 1
402 );
403 }
404
405 fn transaction_with_unpaired_output(kind: CovenantKind) -> (Transaction, Coin) {
406 let outpoint = Outpoint {
407 transaction_hash: TransactionHash::new([1; 32]),
408 index: 0,
409 };
410 let address = Address::new(0, vec![4; 20]).expect("address");
411 let coin = Coin {
412 outpoint,
413 value: Dollarydoos::new(100),
414 height: Height::new(1),
415 coinbase: false,
416 address: address.clone(),
417 covenant: Covenant {
418 kind: CovenantKind::None,
419 items: Vec::new(),
420 },
421 };
422 let transaction = Transaction {
423 version: 1,
424 inputs: vec![Input {
425 previous_output: outpoint,
426 sequence: u32::MAX,
427 witness: Witness::default(),
428 }],
429 outputs: vec![
430 Output {
431 value: Dollarydoos::new(100),
432 address: address.clone(),
433 covenant: Covenant {
434 kind: CovenantKind::None,
435 items: Vec::new(),
436 },
437 },
438 Output {
439 value: Dollarydoos::new(0),
440 address,
441 covenant: Covenant {
442 kind,
443 items: Vec::new(),
444 },
445 },
446 ],
447 locktime: 0,
448 };
449
450 (transaction, coin)
451 }
452
453 #[test]
454 fn unpaired_hsd_linked_outputs_are_rejected() {
455 let linked_kinds = [
456 CovenantKind::Reveal,
457 CovenantKind::Redeem,
458 CovenantKind::Register,
459 CovenantKind::Update,
460 CovenantKind::Renew,
461 CovenantKind::Transfer,
462 CovenantKind::Finalize,
463 CovenantKind::Revoke,
464 ];
465
466 for kind in linked_kinds {
467 let (transaction, coin) = transaction_with_unpaired_output(kind);
468
469 assert_eq!(
470 verify_covenant_links(&transaction, &[coin]),
471 Err(CovenantLinkError::UnpairedLinkedOutput {
472 output_index: 1,
473 kind,
474 }),
475 "unpaired {kind:?} output must be rejected",
476 );
477 }
478 }
479
480 #[test]
481 fn unpaired_nonlinked_outputs_remain_allowed() {
482 let unlinked_kinds = [
485 CovenantKind::None,
486 CovenantKind::Open,
487 CovenantKind::Bid,
488 CovenantKind::Unknown(255),
489 ];
490
491 for kind in unlinked_kinds {
492 let (transaction, coin) = transaction_with_unpaired_output(kind);
493
494 assert_eq!(
495 verify_covenant_links(&transaction, &[coin]),
496 Ok(CovenantLinkSummary {
497 inputs_checked: 1,
498 linked_outputs: 0,
499 name_inputs: 0,
500 }),
501 "unpaired {kind:?} output must remain allowed by linkage verification",
502 );
503 }
504 }
505}