Skip to main content

hns_transaction/
name.rs

1use hns_covenants::{
2    Covenant, CovenantError, CovenantKind, FinalizeCovenant, MAX_RESOURCE_SIZE, NameState,
3    TransferCovenant,
4};
5use hns_primitives::{BlockHash, Height, NameHash};
6use thiserror::Error;
7
8use crate::{Address, Coin, Input, Output, Transaction, TransactionError, Witness};
9
10const NAME_TRANSACTION_VERSION: u32 = 0;
11const NAME_TRANSACTION_LOCKTIME: u32 = 0;
12const NAME_INPUT_SEQUENCE: u32 = u32::MAX;
13
14/// Build the exact linked `TRANSFER` output for a currently owned name coin.
15///
16/// The locked value and current owner address are copied from `owner`; the
17/// recipient is committed only inside the four-item covenant, as required by
18/// HSD. The source must be an exact REGISTER, UPDATE, RENEW, or FINALIZE
19/// covenant.
20pub fn build_transfer_output(
21    owner: &Coin,
22    recipient: &Address,
23) -> Result<Output, NameTransactionError> {
24    validate_name_coin_source(owner, "TRANSFER")?;
25    owner.address.validate()?;
26    recipient.validate()?;
27    let (name_hash, start_height) = transferable_owner_anchor(&owner.covenant)?;
28    let transfer = TransferCovenant::new(
29        name_hash,
30        start_height,
31        recipient.version,
32        recipient.hash.clone(),
33    )?;
34    let output = Output {
35        value: owner.value,
36        address: owner.address.clone(),
37        covenant: transfer.to_covenant()?,
38    };
39    output.encode()?;
40    Ok(output)
41}
42
43/// Verify an exact linked `TRANSFER` output against its owner coin and
44/// independently selected recipient.
45pub fn verify_transfer_output(
46    output: &Output,
47    owner: &Coin,
48    recipient: &Address,
49) -> Result<(), NameTransactionError> {
50    if output != &build_transfer_output(owner, recipient)? {
51        return Err(NameTransactionError::InvalidTransfer(
52            "linked output differs from the canonical owner-preserving TRANSFER",
53        ));
54    }
55    TransferCovenant::try_from(&output.covenant)?;
56    Ok(())
57}
58
59/// Build an unsigned HSD-style name `TRANSFER` transaction with this
60/// transition at input/output index zero and caller-supplied suffixes.
61///
62/// The returned name witness is empty for the wallet signing layer.
63/// `additional_inputs` and `additional_outputs` are appended without
64/// covenant-link validation; they may contain funding or independent batched
65/// name transitions. The caller remains responsible for validating and
66/// signing the complete batch. The complete transaction is checked against
67/// transaction allocation and weight bounds before return.
68pub fn build_transfer_transaction(
69    owner: &Coin,
70    recipient: &Address,
71    mut additional_inputs: Vec<Input>,
72    additional_outputs: Vec<Output>,
73) -> Result<Transaction, NameTransactionError> {
74    reject_repeated_name_input(owner, &additional_inputs)?;
75    let mut inputs = vec![Input {
76        previous_output: owner.outpoint,
77        sequence: NAME_INPUT_SEQUENCE,
78        witness: Witness::default(),
79    }];
80    inputs.append(&mut additional_inputs);
81    let mut outputs = vec![build_transfer_output(owner, recipient)?];
82    outputs.extend(additional_outputs);
83    let transaction = Transaction {
84        version: NAME_TRANSACTION_VERSION,
85        inputs,
86        outputs,
87        locktime: NAME_TRANSACTION_LOCKTIME,
88    };
89    transaction.size()?;
90    Ok(transaction)
91}
92
93/// Verify the canonical header and `TRANSFER` transition at input/output index
94/// zero.
95///
96/// Additional inputs are checked only to prevent reuse of `owner`; additional
97/// outputs are not covenant-validated. Independent batched transitions,
98/// resolved coins, signatures, balance, and fee policy must be verified by
99/// their respective authorities.
100pub fn verify_transfer_at_index_zero(
101    transaction: &Transaction,
102    owner: &Coin,
103    recipient: &Address,
104) -> Result<(), NameTransactionError> {
105    verify_name_transaction_header(transaction, owner, "TRANSFER")?;
106    let output = transaction
107        .outputs
108        .first()
109        .ok_or(NameTransactionError::InvalidTransfer(
110            "linked output at index zero is missing",
111        ))?;
112    verify_transfer_output(output, owner, recipient)?;
113    Ok(())
114}
115
116/// Build the exact linked `FINALIZE` output for a confirmed TRANSFER coin and
117/// authenticated current name state.
118///
119/// The output preserves the locked value, moves the owner address to the
120/// recipient committed by TRANSFER, and carries the exact name, claim,
121/// renewal, and renewal-block fields required by HSD. The caller must supply
122/// chain-verified current state and an eligible renewal block after checking
123/// the network's transfer lockup at the intended inclusion height.
124pub fn build_finalize_output(
125    transfer_coin: &Coin,
126    state: &NameState,
127    renewal_block: BlockHash,
128) -> Result<Output, NameTransactionError> {
129    validate_name_coin_source(transfer_coin, "FINALIZE")?;
130    transfer_coin.address.validate()?;
131    let transfer = TransferCovenant::try_from(&transfer_coin.covenant)?;
132    validate_transfer_state(transfer_coin, state, &transfer)?;
133    let recipient = Address::new(transfer.recipient_version, transfer.recipient_hash.clone())?;
134    let finalize = FinalizeCovenant::from_name_state(state, renewal_block)?;
135    let output = Output {
136        value: transfer_coin.value,
137        address: recipient,
138        covenant: finalize.to_covenant()?,
139    };
140    output.encode()?;
141    Ok(output)
142}
143
144/// Verify an exact linked `FINALIZE` output against its TRANSFER coin,
145/// authenticated current state, and independently supplied renewal block.
146/// This structural verifier does not replace chain-height maturity or renewal
147/// block ancestry checks.
148pub fn verify_finalize_output(
149    output: &Output,
150    transfer_coin: &Coin,
151    state: &NameState,
152    renewal_block: BlockHash,
153) -> Result<(), NameTransactionError> {
154    if output != &build_finalize_output(transfer_coin, state, renewal_block)? {
155        return Err(NameTransactionError::InvalidFinalize(
156            "linked output differs from the canonical value-preserving FINALIZE",
157        ));
158    }
159    FinalizeCovenant::try_from(&output.covenant)?;
160    Ok(())
161}
162
163/// Build an unsigned HSD-style name `FINALIZE` transaction with this
164/// transition at input/output index zero and caller-supplied suffixes.
165///
166/// `additional_inputs` and `additional_outputs` are appended without
167/// covenant-link validation; they may contain funding or independent batched
168/// name transitions. The caller remains responsible for validating and
169/// signing the complete batch. The complete transaction is checked against
170/// transaction allocation and weight bounds before return.
171pub fn build_finalize_transaction(
172    transfer_coin: &Coin,
173    state: &NameState,
174    renewal_block: BlockHash,
175    mut additional_inputs: Vec<Input>,
176    additional_outputs: Vec<Output>,
177) -> Result<Transaction, NameTransactionError> {
178    reject_repeated_name_input(transfer_coin, &additional_inputs)?;
179    let mut inputs = vec![Input {
180        previous_output: transfer_coin.outpoint,
181        sequence: NAME_INPUT_SEQUENCE,
182        witness: Witness::default(),
183    }];
184    inputs.append(&mut additional_inputs);
185    let mut outputs = vec![build_finalize_output(transfer_coin, state, renewal_block)?];
186    outputs.extend(additional_outputs);
187    let transaction = Transaction {
188        version: NAME_TRANSACTION_VERSION,
189        inputs,
190        outputs,
191        locktime: NAME_TRANSACTION_LOCKTIME,
192    };
193    transaction.size()?;
194    Ok(transaction)
195}
196
197/// Verify the canonical header and `FINALIZE` transition at input/output index
198/// zero.
199///
200/// Additional inputs are checked only to prevent reuse of `transfer_coin`;
201/// additional outputs are not covenant-validated. Independent batched
202/// transitions, resolved coins, signatures, balance, and fee policy must be
203/// verified by their respective authorities.
204pub fn verify_finalize_at_index_zero(
205    transaction: &Transaction,
206    transfer_coin: &Coin,
207    state: &NameState,
208    renewal_block: BlockHash,
209) -> Result<(), NameTransactionError> {
210    verify_name_transaction_header(transaction, transfer_coin, "FINALIZE")?;
211    let output = transaction
212        .outputs
213        .first()
214        .ok_or(NameTransactionError::InvalidFinalize(
215            "linked output at index zero is missing",
216        ))?;
217    verify_finalize_output(output, transfer_coin, state, renewal_block)?;
218    Ok(())
219}
220
221fn transferable_owner_anchor(
222    covenant: &Covenant,
223) -> Result<(NameHash, Height), NameTransactionError> {
224    match covenant.kind {
225        CovenantKind::Register => {
226            require_exact_items(covenant, 4, "REGISTER")?;
227            require_bounded_resource(covenant, 2, "REGISTER")?;
228            require_hash(covenant, 3, "REGISTER renewal block")?;
229        }
230        CovenantKind::Update => {
231            require_exact_items(covenant, 3, "UPDATE")?;
232            require_bounded_resource(covenant, 2, "UPDATE")?;
233        }
234        CovenantKind::Renew => {
235            require_exact_items(covenant, 3, "RENEW")?;
236            require_hash(covenant, 2, "RENEW renewal block")?;
237        }
238        CovenantKind::Finalize => {
239            let finalize = FinalizeCovenant::try_from(covenant)?;
240            return Ok((finalize.name_hash, finalize.start_height));
241        }
242        kind => return Err(NameTransactionError::UnsupportedTransferSource { kind }),
243    }
244    Ok((
245        NameHash::new(require_hash(covenant, 0, "name hash")?),
246        Height::new(require_u32(covenant, 1, "start height")?),
247    ))
248}
249
250fn validate_transfer_state(
251    transfer_coin: &Coin,
252    state: &NameState,
253    transfer: &TransferCovenant,
254) -> Result<(), NameTransactionError> {
255    state.validate_key_binding()?;
256    if state.is_null()
257        || !state.registered
258        || state.expired
259        || state.revoked.get() != 0
260        || state.transfer.get() == 0
261    {
262        return Err(NameTransactionError::InvalidFinalize(
263            "name state is not an active registered transfer",
264        ));
265    }
266    if state.owner_outpoint() != Some(transfer_coin.outpoint) {
267        return Err(NameTransactionError::InvalidFinalize(
268            "name-state owner does not identify the TRANSFER coin",
269        ));
270    }
271    if state.value != transfer_coin.value {
272        return Err(NameTransactionError::InvalidFinalize(
273            "name-state locked value differs from the TRANSFER coin",
274        ));
275    }
276    if state.transfer != transfer_coin.height {
277        return Err(NameTransactionError::InvalidFinalize(
278            "name-state transfer height differs from the TRANSFER coin height",
279        ));
280    }
281    if state.name_hash != transfer.name_hash || state.height != transfer.start_height {
282        return Err(NameTransactionError::InvalidFinalize(
283            "name-state identity differs from the TRANSFER covenant",
284        ));
285    }
286    Ok(())
287}
288
289fn verify_name_transaction_header(
290    transaction: &Transaction,
291    name_coin: &Coin,
292    operation: &'static str,
293) -> Result<(), NameTransactionError> {
294    transaction.size()?;
295    if transaction.version != NAME_TRANSACTION_VERSION
296        || transaction.locktime != NAME_TRANSACTION_LOCKTIME
297    {
298        return Err(NameTransactionError::InvalidTransaction(
299            "name transaction version or locktime differs from HSD construction",
300        ));
301    }
302    let name_input = transaction
303        .inputs
304        .first()
305        .ok_or(NameTransactionError::InvalidTransaction(
306            "name input at index zero is missing",
307        ))?;
308    if name_input.previous_output != name_coin.outpoint
309        || name_input.sequence != NAME_INPUT_SEQUENCE
310    {
311        return Err(NameTransactionError::InvalidTransaction(
312            "name input is not the canonical index-zero outpoint",
313        ));
314    }
315    if transaction.inputs[1..]
316        .iter()
317        .any(|input| input.previous_output == name_coin.outpoint)
318    {
319        return Err(NameTransactionError::RepeatedNameInput { operation });
320    }
321    Ok(())
322}
323
324fn reject_repeated_name_input(
325    name_coin: &Coin,
326    additional_inputs: &[Input],
327) -> Result<(), NameTransactionError> {
328    if additional_inputs
329        .iter()
330        .any(|input| input.previous_output == name_coin.outpoint)
331    {
332        return Err(NameTransactionError::RepeatedNameInput {
333            operation: "name transition",
334        });
335    }
336    Ok(())
337}
338
339fn validate_name_coin_source(
340    coin: &Coin,
341    operation: &'static str,
342) -> Result<(), NameTransactionError> {
343    if coin.outpoint.is_null() || coin.coinbase {
344        return Err(NameTransactionError::InvalidSourceCoin { operation });
345    }
346    Ok(())
347}
348
349fn require_exact_items(
350    covenant: &Covenant,
351    expected: usize,
352    kind: &'static str,
353) -> Result<(), NameTransactionError> {
354    if covenant.items.len() != expected {
355        return Err(NameTransactionError::MalformedOwnerCovenant { kind });
356    }
357    Ok(())
358}
359
360fn require_bounded_resource(
361    covenant: &Covenant,
362    index: usize,
363    kind: &'static str,
364) -> Result<(), NameTransactionError> {
365    if covenant
366        .item(index)
367        .is_none_or(|resource| resource.len() > MAX_RESOURCE_SIZE)
368    {
369        return Err(NameTransactionError::MalformedOwnerCovenant { kind });
370    }
371    Ok(())
372}
373
374fn require_hash(
375    covenant: &Covenant,
376    index: usize,
377    field: &'static str,
378) -> Result<[u8; 32], NameTransactionError> {
379    covenant
380        .item(index)
381        .and_then(|item| item.try_into().ok())
382        .ok_or(NameTransactionError::MalformedOwnerField { field })
383}
384
385fn require_u32(
386    covenant: &Covenant,
387    index: usize,
388    field: &'static str,
389) -> Result<u32, NameTransactionError> {
390    covenant
391        .item_u32(index)
392        .ok_or(NameTransactionError::MalformedOwnerField { field })
393}
394
395#[derive(Debug, Error)]
396pub enum NameTransactionError {
397    #[error(transparent)]
398    Covenant(#[from] CovenantError),
399    #[error(transparent)]
400    Transaction(#[from] TransactionError),
401    #[error("{kind:?} cannot be the source of a TRANSFER")]
402    UnsupportedTransferSource { kind: CovenantKind },
403    #[error("malformed owner {kind} covenant")]
404    MalformedOwnerCovenant { kind: &'static str },
405    #[error("malformed owner covenant field: {field}")]
406    MalformedOwnerField { field: &'static str },
407    #[error("invalid canonical TRANSFER: {0}")]
408    InvalidTransfer(&'static str),
409    #[error("invalid canonical FINALIZE: {0}")]
410    InvalidFinalize(&'static str),
411    #[error("invalid canonical name transaction: {0}")]
412    InvalidTransaction(&'static str),
413    #[error("{operation} source is a null-outpoint or coinbase coin")]
414    InvalidSourceCoin { operation: &'static str },
415    #[error("{operation} repeats the name outpoint in its funding inputs")]
416    RepeatedNameInput { operation: &'static str },
417}
418
419#[cfg(test)]
420mod tests {
421    use hns_covenants::hash_name;
422    use hns_primitives::{Dollarydoos, Outpoint, TransactionHash};
423
424    use super::*;
425
426    fn owner_coin() -> Coin {
427        let name = b"handshake";
428        Coin {
429            outpoint: Outpoint {
430                transaction_hash: TransactionHash::new([1; 32]),
431                index: 2,
432            },
433            value: Dollarydoos::new(3),
434            height: Height::new(4),
435            coinbase: false,
436            address: Address::new(0, vec![5; 20]).expect("address"),
437            covenant: FinalizeCovenant::new(
438                name.to_vec(),
439                Height::new(6),
440                false,
441                Height::new(0),
442                1,
443                BlockHash::new([7; 32]),
444            )
445            .expect("finalize")
446            .to_covenant()
447            .expect("covenant"),
448        }
449    }
450
451    #[test]
452    fn transfer_preserves_owner_value_and_address() {
453        let owner = owner_coin();
454        let recipient = Address::new(0, vec![8; 20]).expect("recipient");
455        let transaction = build_transfer_transaction(&owner, &recipient, Vec::new(), Vec::new())
456            .expect("transfer");
457        assert_eq!(transaction.outputs[0].value, owner.value);
458        assert_eq!(transaction.outputs[0].address, owner.address);
459        verify_transfer_at_index_zero(&transaction, &owner, &recipient).expect("valid");
460    }
461
462    #[test]
463    fn index_zero_transfer_accepts_an_independent_name_transition_suffix() {
464        let owner = owner_coin();
465        let recipient = Address::new(0, vec![8; 20]).expect("recipient");
466        let mut additional_owner = owner_coin();
467        additional_owner.outpoint = Outpoint {
468            transaction_hash: TransactionHash::new([12; 32]),
469            index: 3,
470        };
471        additional_owner.covenant = FinalizeCovenant::new(
472            b"batched".to_vec(),
473            Height::new(7),
474            false,
475            Height::new(0),
476            1,
477            BlockHash::new([13; 32]),
478        )
479        .expect("additional finalize")
480        .to_covenant()
481        .expect("additional covenant");
482        let additional_recipient = Address::new(0, vec![14; 20]).expect("recipient");
483        let additional_output = build_transfer_output(&additional_owner, &additional_recipient)
484            .expect("additional transfer output");
485        let transaction = build_transfer_transaction(
486            &owner,
487            &recipient,
488            vec![Input {
489                previous_output: additional_owner.outpoint,
490                sequence: NAME_INPUT_SEQUENCE,
491                witness: Witness::default(),
492            }],
493            vec![additional_output],
494        )
495        .expect("batched transfer");
496
497        verify_transfer_at_index_zero(&transaction, &owner, &recipient)
498            .expect("index-zero transfer");
499        assert_eq!(
500            crate::verify_covenant_links(&transaction, &[owner, additional_owner])
501                .expect("valid covenant batch")
502                .linked_outputs,
503            2
504        );
505    }
506
507    #[test]
508    fn finalize_binds_current_state_and_transfer_recipient() {
509        let owner = owner_coin();
510        let recipient = Address::new(0, vec![8; 20]).expect("recipient");
511        let transfer_output = build_transfer_output(&owner, &recipient).expect("transfer output");
512        let transfer_coin = Coin {
513            outpoint: Outpoint {
514                transaction_hash: TransactionHash::new([9; 32]),
515                index: 0,
516            },
517            value: transfer_output.value,
518            height: Height::new(10),
519            coinbase: false,
520            address: transfer_output.address,
521            covenant: transfer_output.covenant,
522        };
523        let name = b"handshake".to_vec();
524        let mut state = NameState::null(hash_name(&name).expect("name hash"));
525        state.name = name;
526        state.height = Height::new(6);
527        state.owner = transfer_coin.outpoint;
528        state.value = transfer_coin.value;
529        state.transfer = transfer_coin.height;
530        state.registered = true;
531        let renewal_block = BlockHash::new([11; 32]);
532        let transaction = build_finalize_transaction(
533            &transfer_coin,
534            &state,
535            renewal_block,
536            Vec::new(),
537            Vec::new(),
538        )
539        .expect("finalize");
540        assert_eq!(transaction.outputs[0].value, transfer_coin.value);
541        assert_eq!(transaction.outputs[0].address, recipient);
542        verify_finalize_at_index_zero(&transaction, &transfer_coin, &state, renewal_block)
543            .expect("valid");
544    }
545
546    #[test]
547    fn name_transition_authority_mutations_fail_closed() {
548        let owner = owner_coin();
549        let recipient = Address::new(0, vec![8; 20]).expect("recipient");
550
551        let mut null_owner = owner.clone();
552        null_owner.outpoint = Outpoint::NULL;
553        assert!(matches!(
554            build_transfer_output(&null_owner, &recipient),
555            Err(NameTransactionError::InvalidSourceCoin {
556                operation: "TRANSFER"
557            })
558        ));
559        let mut coinbase_owner = owner.clone();
560        coinbase_owner.coinbase = true;
561        assert!(build_transfer_output(&coinbase_owner, &recipient).is_err());
562        assert!(matches!(
563            build_transfer_transaction(
564                &owner,
565                &recipient,
566                vec![Input {
567                    previous_output: owner.outpoint,
568                    sequence: NAME_INPUT_SEQUENCE,
569                    witness: Witness::default(),
570                }],
571                Vec::new(),
572            ),
573            Err(NameTransactionError::RepeatedNameInput { .. })
574        ));
575
576        let transfer_output = build_transfer_output(&owner, &recipient).expect("transfer output");
577        let transfer_coin = Coin {
578            outpoint: Outpoint {
579                transaction_hash: TransactionHash::new([9; 32]),
580                index: 0,
581            },
582            value: transfer_output.value,
583            height: Height::new(10),
584            coinbase: false,
585            address: transfer_output.address,
586            covenant: transfer_output.covenant,
587        };
588        let name = b"handshake".to_vec();
589        let mut state = NameState::null(hash_name(&name).expect("name hash"));
590        state.name = name;
591        state.height = Height::new(6);
592        state.owner = transfer_coin.outpoint;
593        state.value = transfer_coin.value;
594        state.transfer = transfer_coin.height;
595        state.registered = true;
596        let renewal_block = BlockHash::new([11; 32]);
597
598        let assert_state_rejected = |mutated: NameState| {
599            assert!(build_finalize_output(&transfer_coin, &mutated, renewal_block).is_err());
600        };
601        let mut wrong_owner = state.clone();
602        wrong_owner.owner.index += 1;
603        assert_state_rejected(wrong_owner);
604        let mut wrong_value = state.clone();
605        wrong_value.value = Dollarydoos::new(state.value.get() + 1);
606        assert_state_rejected(wrong_value);
607        let mut wrong_transfer_height = state.clone();
608        wrong_transfer_height.transfer = Height::new(state.transfer.get() + 1);
609        assert_state_rejected(wrong_transfer_height);
610        let mut unregistered = state.clone();
611        unregistered.registered = false;
612        assert_state_rejected(unregistered);
613        let mut expired = state.clone();
614        expired.expired = true;
615        assert_state_rejected(expired);
616        let mut revoked = state.clone();
617        revoked.revoked = Height::new(1);
618        assert_state_rejected(revoked);
619        let mut wrong_identity = state.clone();
620        wrong_identity.height = Height::new(state.height.get() + 1);
621        assert_state_rejected(wrong_identity);
622
623        let transaction = build_finalize_transaction(
624            &transfer_coin,
625            &state,
626            renewal_block,
627            Vec::new(),
628            Vec::new(),
629        )
630        .expect("finalize");
631        let mut wrong_version = transaction.clone();
632        wrong_version.version += 1;
633        assert!(
634            verify_finalize_at_index_zero(&wrong_version, &transfer_coin, &state, renewal_block,)
635                .is_err()
636        );
637        let mut wrong_sequence = transaction.clone();
638        wrong_sequence.inputs[0].sequence -= 1;
639        assert!(
640            verify_finalize_at_index_zero(&wrong_sequence, &transfer_coin, &state, renewal_block,)
641                .is_err()
642        );
643        let mut wrong_outpoint = transaction.clone();
644        wrong_outpoint.inputs[0].previous_output.index += 1;
645        assert!(
646            verify_finalize_at_index_zero(&wrong_outpoint, &transfer_coin, &state, renewal_block,)
647                .is_err()
648        );
649        let mut wrong_output = transaction;
650        wrong_output.outputs[0].value = Dollarydoos::new(transfer_coin.value.get() + 1);
651        assert!(
652            verify_finalize_at_index_zero(&wrong_output, &transfer_coin, &state, renewal_block,)
653                .is_err()
654        );
655    }
656}