use hns_covenants::{Covenant, CovenantKind, blind_bid};
use thiserror::Error;
use crate::{Address, Coin, Outpoint, Output, Transaction};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct CovenantLinkSummary {
pub inputs_checked: usize,
pub linked_outputs: usize,
pub name_inputs: usize,
}
pub fn verify_covenant_links(
transaction: &Transaction,
input_coins: &[Coin],
) -> Result<CovenantLinkSummary, CovenantLinkError> {
if transaction.is_coinbase() {
return Err(CovenantLinkError::CoinbaseRequiresIssuanceVerifier);
}
if transaction.inputs.len() != input_coins.len() {
return Err(CovenantLinkError::InputCountMismatch {
transaction: transaction.inputs.len(),
coins: input_coins.len(),
});
}
let mut summary = CovenantLinkSummary {
inputs_checked: input_coins.len(),
..CovenantLinkSummary::default()
};
for (input_index, (input, coin)) in transaction.inputs.iter().zip(input_coins).enumerate() {
if input.previous_output != coin.outpoint {
return Err(CovenantLinkError::CoinOutpointMismatch {
input_index,
expected: input.previous_output,
actual: coin.outpoint,
});
}
let output = transaction.outputs.get(input_index);
let spent = &coin.covenant;
let spent_kind = spent.kind;
if spent_kind.is_name() {
summary.name_inputs += 1;
}
match spent_kind {
CovenantKind::None | CovenantKind::Open | CovenantKind::Redeem => {
let Some(output) = output else {
continue;
};
if !matches!(
output.covenant.kind,
CovenantKind::None | CovenantKind::Open | CovenantKind::Bid
) {
return Err(CovenantLinkError::InvalidTransition {
input_index,
from: spent_kind,
to: output.covenant.kind,
});
}
}
CovenantKind::Bid => {
let output = require_linked_output(input_index, spent_kind, output)?;
require_transition(input_index, spent_kind, output, CovenantKind::Reveal)?;
require_name_and_start_match(input_index, spent, &output.covenant)?;
let nonce = required_hash(input_index, &output.covenant, 2, "reveal nonce")?;
let commitment = required_hash(input_index, spent, 3, "bid commitment")?;
if blind_bid(output.value.get(), &nonce) != commitment {
return Err(CovenantLinkError::BlindCommitmentMismatch { input_index });
}
if coin.value < output.value {
return Err(CovenantLinkError::BidValueInflation { input_index });
}
summary.linked_outputs += 1;
}
CovenantKind::Claim | CovenantKind::Reveal => {
let output = require_linked_output(input_index, spent_kind, output)?;
match output.covenant.kind {
CovenantKind::Register => {
require_name_and_start_match(input_index, spent, &output.covenant)?;
require_address_match(input_index, &coin.address, output)?;
}
CovenantKind::Redeem => {
require_name_and_start_match(input_index, spent, &output.covenant)?;
if spent_kind == CovenantKind::Claim {
return Err(CovenantLinkError::ClaimCannotRedeem { input_index });
}
}
to => {
return Err(CovenantLinkError::InvalidTransition {
input_index,
from: spent_kind,
to,
});
}
}
summary.linked_outputs += 1;
}
CovenantKind::Register
| CovenantKind::Update
| CovenantKind::Renew
| CovenantKind::Finalize => {
let output = require_linked_output(input_index, spent_kind, output)?;
require_locked_value(input_index, coin, output)?;
require_address_match(input_index, &coin.address, output)?;
if !matches!(
output.covenant.kind,
CovenantKind::Update
| CovenantKind::Renew
| CovenantKind::Transfer
| CovenantKind::Revoke
) {
return Err(CovenantLinkError::InvalidTransition {
input_index,
from: spent_kind,
to: output.covenant.kind,
});
}
require_name_and_start_match(input_index, spent, &output.covenant)?;
summary.linked_outputs += 1;
}
CovenantKind::Transfer => {
let output = require_linked_output(input_index, spent_kind, output)?;
require_locked_value(input_index, coin, output)?;
match output.covenant.kind {
CovenantKind::Update | CovenantKind::Renew | CovenantKind::Revoke => {
require_name_and_start_match(input_index, spent, &output.covenant)?;
require_address_match(input_index, &coin.address, output)?;
}
CovenantKind::Finalize => {
require_name_and_start_match(input_index, spent, &output.covenant)?;
let version = required_u8(input_index, spent, 2, "transfer version")?;
let hash = required_item(input_index, spent, 3, "transfer hash")?;
if output.address.version != version
|| output.address.hash.as_slice() != hash
{
return Err(CovenantLinkError::TransferDestinationMismatch {
input_index,
});
}
}
to => {
return Err(CovenantLinkError::InvalidTransition {
input_index,
from: spent_kind,
to,
});
}
}
summary.linked_outputs += 1;
}
CovenantKind::Revoke => {
return Err(CovenantLinkError::RevokedCoinSpent { input_index });
}
CovenantKind::Unknown(_) => {
if let Some(output) = output
&& output.covenant.kind.is_name()
{
return Err(CovenantLinkError::UnknownCovenantCreatesName {
input_index,
to: output.covenant.kind,
});
}
}
}
}
for (output_index, output) in transaction
.outputs
.iter()
.enumerate()
.skip(transaction.inputs.len())
{
if output.covenant.kind.is_linked() {
return Err(CovenantLinkError::UnpairedLinkedOutput {
output_index,
kind: output.covenant.kind,
});
}
}
Ok(summary)
}
fn require_linked_output(
input_index: usize,
from: CovenantKind,
output: Option<&Output>,
) -> Result<&Output, CovenantLinkError> {
output.ok_or(CovenantLinkError::MissingLinkedOutput { input_index, from })
}
fn require_transition(
input_index: usize,
from: CovenantKind,
output: &Output,
expected: CovenantKind,
) -> Result<(), CovenantLinkError> {
if output.covenant.kind != expected {
return Err(CovenantLinkError::InvalidTransition {
input_index,
from,
to: output.covenant.kind,
});
}
Ok(())
}
fn require_name_and_start_match(
input_index: usize,
spent: &Covenant,
created: &Covenant,
) -> Result<(), CovenantLinkError> {
if required_hash(input_index, spent, 0, "spent name")?
!= required_hash(input_index, created, 0, "created name")?
{
return Err(CovenantLinkError::NameHashMismatch { input_index });
}
if required_u32(input_index, spent, 1, "spent start")?
!= required_u32(input_index, created, 1, "created start")?
{
return Err(CovenantLinkError::StartHeightMismatch { input_index });
}
Ok(())
}
fn require_locked_value(
input_index: usize,
coin: &Coin,
output: &Output,
) -> Result<(), CovenantLinkError> {
if output.value != coin.value {
return Err(CovenantLinkError::LockedValueMismatch { input_index });
}
Ok(())
}
fn require_address_match(
input_index: usize,
expected: &Address,
output: &Output,
) -> Result<(), CovenantLinkError> {
if &output.address != expected {
return Err(CovenantLinkError::AddressMismatch { input_index });
}
Ok(())
}
fn required_item<'a>(
input_index: usize,
covenant: &'a Covenant,
item_index: usize,
field: &'static str,
) -> Result<&'a [u8], CovenantLinkError> {
covenant
.item(item_index)
.ok_or(CovenantLinkError::MalformedCovenant { input_index, field })
}
fn required_u8(
input_index: usize,
covenant: &Covenant,
item_index: usize,
field: &'static str,
) -> Result<u8, CovenantLinkError> {
covenant
.item_u8(item_index)
.ok_or(CovenantLinkError::MalformedCovenant { input_index, field })
}
fn required_u32(
input_index: usize,
covenant: &Covenant,
item_index: usize,
field: &'static str,
) -> Result<u32, CovenantLinkError> {
covenant
.item_u32(item_index)
.ok_or(CovenantLinkError::MalformedCovenant { input_index, field })
}
fn required_hash(
input_index: usize,
covenant: &Covenant,
item_index: usize,
field: &'static str,
) -> Result<[u8; 32], CovenantLinkError> {
required_item(input_index, covenant, item_index, field)?
.try_into()
.map_err(|_| CovenantLinkError::MalformedCovenant { input_index, field })
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum CovenantLinkError {
#[error("coinbase covenant issuance requires its dedicated verifier")]
CoinbaseRequiresIssuanceVerifier,
#[error("transaction input count does not match resolved coins")]
InputCountMismatch { transaction: usize, coins: usize },
#[error("input {input_index} outpoint does not match resolved coin")]
CoinOutpointMismatch {
input_index: usize,
expected: Outpoint,
actual: Outpoint,
},
#[error("input {input_index} covenant {from:?} requires a linked output")]
MissingLinkedOutput {
input_index: usize,
from: CovenantKind,
},
#[error("input {input_index} covenant transition {from:?} -> {to:?} is invalid")]
InvalidTransition {
input_index: usize,
from: CovenantKind,
to: CovenantKind,
},
#[error("input {input_index} mis-encodes {field}")]
MalformedCovenant {
input_index: usize,
field: &'static str,
},
#[error("input {input_index} name hash differs from linked output")]
NameHashMismatch { input_index: usize },
#[error("input {input_index} start height differs from linked output")]
StartHeightMismatch { input_index: usize },
#[error("input {input_index} reveal does not match blind commitment")]
BlindCommitmentMismatch { input_index: usize },
#[error("input {input_index} reveal value exceeds locked bid value")]
BidValueInflation { input_index: usize },
#[error("input {input_index} claim cannot redeem")]
ClaimCannotRedeem { input_index: usize },
#[error("input {input_index} output address differs from locked address")]
AddressMismatch { input_index: usize },
#[error("input {input_index} output value differs from locked value")]
LockedValueMismatch { input_index: usize },
#[error("input {input_index} finalize destination differs from transfer")]
TransferDestinationMismatch { input_index: usize },
#[error("input {input_index} attempts to spend a revoked name")]
RevokedCoinSpent { input_index: usize },
#[error("input {input_index} unknown covenant creates name covenant {to:?}")]
UnknownCovenantCreatesName {
input_index: usize,
to: CovenantKind,
},
#[error("output {output_index} linked covenant {kind:?} has no corresponding input")]
UnpairedLinkedOutput {
output_index: usize,
kind: CovenantKind,
},
}
#[cfg(test)]
mod tests {
use hns_primitives::{Dollarydoos, Height, TransactionHash};
use super::*;
use crate::{Input, Witness};
#[test]
fn bid_reveal_commitment_and_revoke_rules_match_hsd() {
let outpoint = Outpoint {
transaction_hash: TransactionHash::new([1; 32]),
index: 0,
};
let nonce = [3; 32];
let spent = Covenant {
kind: CovenantKind::Bid,
items: vec![
vec![2; 32],
9_u32.to_le_bytes().to_vec(),
b"name".to_vec(),
blind_bid(100, &nonce).to_vec(),
],
};
let revealed = Covenant {
kind: CovenantKind::Reveal,
items: vec![vec![2; 32], 9_u32.to_le_bytes().to_vec(), nonce.to_vec()],
};
let address = Address::new(0, vec![4; 20]).expect("address");
let coin = Coin {
outpoint,
value: Dollarydoos::new(100),
height: Height::new(1),
coinbase: false,
address: address.clone(),
covenant: spent,
};
let transaction = Transaction {
version: 1,
inputs: vec![Input {
previous_output: outpoint,
sequence: u32::MAX,
witness: Witness::default(),
}],
outputs: vec![Output {
value: Dollarydoos::new(100),
address,
covenant: revealed,
}],
locktime: 0,
};
assert_eq!(
verify_covenant_links(&transaction, &[coin])
.expect("valid")
.linked_outputs,
1
);
}
fn transaction_with_unpaired_output(kind: CovenantKind) -> (Transaction, Coin) {
let outpoint = Outpoint {
transaction_hash: TransactionHash::new([1; 32]),
index: 0,
};
let address = Address::new(0, vec![4; 20]).expect("address");
let coin = Coin {
outpoint,
value: Dollarydoos::new(100),
height: Height::new(1),
coinbase: false,
address: address.clone(),
covenant: Covenant {
kind: CovenantKind::None,
items: Vec::new(),
},
};
let transaction = Transaction {
version: 1,
inputs: vec![Input {
previous_output: outpoint,
sequence: u32::MAX,
witness: Witness::default(),
}],
outputs: vec![
Output {
value: Dollarydoos::new(100),
address: address.clone(),
covenant: Covenant {
kind: CovenantKind::None,
items: Vec::new(),
},
},
Output {
value: Dollarydoos::new(0),
address,
covenant: Covenant {
kind,
items: Vec::new(),
},
},
],
locktime: 0,
};
(transaction, coin)
}
#[test]
fn unpaired_hsd_linked_outputs_are_rejected() {
let linked_kinds = [
CovenantKind::Reveal,
CovenantKind::Redeem,
CovenantKind::Register,
CovenantKind::Update,
CovenantKind::Renew,
CovenantKind::Transfer,
CovenantKind::Finalize,
CovenantKind::Revoke,
];
for kind in linked_kinds {
let (transaction, coin) = transaction_with_unpaired_output(kind);
assert_eq!(
verify_covenant_links(&transaction, &[coin]),
Err(CovenantLinkError::UnpairedLinkedOutput {
output_index: 1,
kind,
}),
"unpaired {kind:?} output must be rejected",
);
}
}
#[test]
fn unpaired_nonlinked_outputs_remain_allowed() {
let unlinked_kinds = [
CovenantKind::None,
CovenantKind::Open,
CovenantKind::Bid,
CovenantKind::Unknown(255),
];
for kind in unlinked_kinds {
let (transaction, coin) = transaction_with_unpaired_output(kind);
assert_eq!(
verify_covenant_links(&transaction, &[coin]),
Ok(CovenantLinkSummary {
inputs_checked: 1,
linked_outputs: 0,
name_inputs: 0,
}),
"unpaired {kind:?} output must remain allowed by linkage verification",
);
}
}
}