Skip to main content

hns_transaction/
linkage.rs

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        // CLAIM is intentionally omitted: it is not linked, but is only valid
483        // in coinbase transactions handled by the dedicated issuance verifier.
484        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}