Skip to main content

hns_script/
policy.rs

1//! Runtime-independent HSD transaction fee-policy arithmetic.
2//!
3//! The formulas and constants in this module follow
4//! `handshake-org/hsd@698e252ebc7b5c1dd0a9587e342fdd153d020ae4`:
5//! `lib/primitives/tx.js#getSigopsSize` and
6//! `lib/protocol/policy.js#getMinFee`. Policy virtual size is measured in
7//! virtual bytes after sigop adjustment. [`FeeRate`] is measured in
8//! dollarydoos per 1,000 policy virtual bytes.
9
10use hns_primitives::Dollarydoos;
11use hns_transaction::{Coin, Transaction, TransactionError};
12use thiserror::Error;
13
14use crate::{ScriptError, transaction_sigops};
15
16/// HSD witness weight units per policy virtual byte.
17pub const POLICY_WITNESS_SCALE_FACTOR: u32 = 4;
18
19/// HSD weight units charged per signature operation for policy sizing.
20pub const POLICY_BYTES_PER_SIGOP: u32 = 20;
21
22/// Number of policy virtual bytes in HSD's fee-rate unit.
23pub const POLICY_FEE_RATE_SCALE: u32 = 1_000;
24
25/// HSD's default minimum relay rate in dollarydoos per 1,000 policy virtual
26/// bytes.
27pub const MIN_RELAY_FEE_RATE: FeeRate = FeeRate::new(1_000);
28
29/// Maximum standard transaction weight admitted by the pinned HSD policy.
30///
31/// This bound is deliberately not enforced by [`sigop_adjusted_virtual_size`]:
32/// HSD calculates size and applies standardness checks as separate operations.
33pub const MAX_POLICY_TRANSACTION_WEIGHT: TransactionWeight = TransactionWeight::new(400_000);
34
35/// Maximum standard transaction sigop cost admitted by the pinned HSD policy.
36///
37/// This bound is deliberately not enforced by [`sigop_adjusted_virtual_size`]
38/// so callers can calculate evidence before reporting a policy rejection.
39pub const MAX_POLICY_TRANSACTION_SIGOPS: SigopCost = SigopCost::new(16_000);
40
41/// Serialized transaction weight, in HSD witness weight units.
42#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
43pub struct TransactionWeight(u32);
44
45impl TransactionWeight {
46    pub const fn new(weight_units: u32) -> Self {
47        Self(weight_units)
48    }
49
50    pub const fn get(self) -> u32 {
51        self.0
52    }
53}
54
55impl From<u32> for TransactionWeight {
56    fn from(weight_units: u32) -> Self {
57        Self::new(weight_units)
58    }
59}
60
61/// HSD signature-operation cost used by transaction policy sizing.
62#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
63pub struct SigopCost(u32);
64
65impl SigopCost {
66    pub const fn new(sigops: u32) -> Self {
67        Self(sigops)
68    }
69
70    pub const fn get(self) -> u32 {
71        self.0
72    }
73}
74
75impl From<u32> for SigopCost {
76    fn from(sigops: u32) -> Self {
77        Self::new(sigops)
78    }
79}
80
81/// Sigop-adjusted transaction size in HSD policy virtual bytes.
82#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
83pub struct PolicyVirtualSize(u32);
84
85impl PolicyVirtualSize {
86    pub const fn new(virtual_bytes: u32) -> Self {
87        Self(virtual_bytes)
88    }
89
90    pub const fn get(self) -> u32 {
91        self.0
92    }
93}
94
95impl From<u32> for PolicyVirtualSize {
96    fn from(virtual_bytes: u32) -> Self {
97        Self::new(virtual_bytes)
98    }
99}
100
101/// Fee rate in dollarydoos per 1,000 HSD policy virtual bytes.
102///
103/// HSD runtime configuration restricts relay and wallet fee rates to unsigned
104/// 32-bit values. Keeping that bound in the type makes the multiplication in
105/// [`minimum_policy_fee`] exact in `u64`.
106#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
107pub struct FeeRate(u32);
108
109impl FeeRate {
110    pub const fn new(dollarydoos_per_thousand_virtual_bytes: u32) -> Self {
111        Self(dollarydoos_per_thousand_virtual_bytes)
112    }
113
114    pub const fn get(self) -> u32 {
115        self.0
116    }
117}
118
119impl From<u32> for FeeRate {
120    fn from(dollarydoos_per_thousand_virtual_bytes: u32) -> Self {
121        Self::new(dollarydoos_per_thousand_virtual_bytes)
122    }
123}
124
125/// Calculate HSD's sigop-adjusted policy virtual size.
126///
127/// HSD takes the greater of serialized transaction weight and `sigops * 20`,
128/// then divides by four with ceiling. This function does not decide whether
129/// the weight or sigop cost is standard; the pinned implementation performs
130/// those admission checks separately.
131pub fn sigop_adjusted_virtual_size(
132    transaction_weight: TransactionWeight,
133    sigops: SigopCost,
134) -> Result<PolicyVirtualSize, FeePolicyError> {
135    let sigop_weight = u64::from(sigops.get())
136        .checked_mul(u64::from(POLICY_BYTES_PER_SIGOP))
137        .ok_or(FeePolicyError::ArithmeticOverflow)?;
138    let adjusted_weight = u64::from(transaction_weight.get()).max(sigop_weight);
139    let virtual_bytes = adjusted_weight
140        .checked_add(u64::from(POLICY_WITNESS_SCALE_FACTOR - 1))
141        .ok_or(FeePolicyError::ArithmeticOverflow)?
142        / u64::from(POLICY_WITNESS_SCALE_FACTOR);
143    let virtual_bytes = u32::try_from(virtual_bytes)
144        .map_err(|_| FeePolicyError::VirtualSizeOutOfRange { virtual_bytes })?;
145    Ok(PolicyVirtualSize::new(virtual_bytes))
146}
147
148/// Calculate the exact HSD policy virtual size for a transaction and its
149/// resolved input coins.
150///
151/// Input coins are outpoint-bound by [`transaction_sigops`]. Coinbase
152/// transactions have zero sigops, matching HSD. Transaction encoding bounds
153/// are enforced by [`Transaction::weight`].
154pub fn transaction_policy_virtual_size(
155    transaction: &Transaction,
156    input_coins: &[Coin],
157) -> Result<PolicyVirtualSize, FeePolicyError> {
158    let weight = transaction.weight()?;
159    let weight = u32::try_from(weight).map_err(|_| FeePolicyError::TransactionWeightOutOfRange)?;
160    let sigops = transaction_sigops(transaction, input_coins)?;
161    sigop_adjusted_virtual_size(TransactionWeight::new(weight), SigopCost::new(sigops))
162}
163
164/// Calculate HSD's minimum policy fee for a virtual size and fee rate.
165///
166/// The multiplication is divided by 1,000 with floor rounding. HSD's unusual
167/// low-value rule is preserved exactly: for nonzero size and rate, a zero
168/// quotient returns the entire rate rather than one dollarydoo. Zero size or a
169/// zero rate returns zero.
170pub fn minimum_policy_fee(
171    virtual_size: PolicyVirtualSize,
172    rate: FeeRate,
173) -> Result<Dollarydoos, FeePolicyError> {
174    if virtual_size.get() == 0 {
175        return Ok(Dollarydoos::new(0));
176    }
177
178    let fee = u64::from(rate.get())
179        .checked_mul(u64::from(virtual_size.get()))
180        .ok_or(FeePolicyError::ArithmeticOverflow)?
181        / u64::from(POLICY_FEE_RATE_SCALE);
182    if fee == 0 && rate.get() > 0 {
183        return Ok(Dollarydoos::new(u64::from(rate.get())));
184    }
185    Ok(Dollarydoos::new(fee))
186}
187
188#[derive(Debug, Error)]
189pub enum FeePolicyError {
190    #[error(transparent)]
191    Transaction(#[from] TransactionError),
192    #[error(transparent)]
193    Script(#[from] ScriptError),
194    #[error("transaction weight exceeds the public 32-bit weight unit")]
195    TransactionWeightOutOfRange,
196    #[error("sigop-adjusted virtual size {virtual_bytes} exceeds the public 32-bit unit")]
197    VirtualSizeOutOfRange { virtual_bytes: u64 },
198    #[error("fee-policy arithmetic overflow")]
199    ArithmeticOverflow,
200}
201
202#[cfg(test)]
203mod tests {
204    use hns_covenants::Covenant;
205    use hns_primitives::{Height, Outpoint, TransactionHash};
206    use hns_transaction::{Address, Input, Output, Witness};
207    use sha2::{Digest, Sha256};
208
209    use super::*;
210
211    const HSD_FEE_POLICY_VECTORS: &str = include_str!("../fixtures/hsd/fee-policy-v1.txt");
212    const HSD_FEE_POLICY_VECTORS_SHA256: &str =
213        include_str!("../fixtures/hsd/fee-policy-v1.txt.sha256");
214    const PINNED_HSD_FEE_POLICY_VECTORS_SHA256: &str =
215        "ec01d6f43456aa28c3b40549349e9c430473d3c074da4e3d7280ac3db817c0c5";
216
217    #[test]
218    fn exact_pinned_hsd_fee_policy_vectors() {
219        let sidecar_hash = HSD_FEE_POLICY_VECTORS_SHA256
220            .split_ascii_whitespace()
221            .next()
222            .expect("fixture digest");
223        assert_eq!(sidecar_hash, PINNED_HSD_FEE_POLICY_VECTORS_SHA256);
224        assert_eq!(
225            hex::encode(Sha256::digest(HSD_FEE_POLICY_VECTORS)),
226            sidecar_hash
227        );
228
229        for line in HSD_FEE_POLICY_VECTORS.lines() {
230            if line.is_empty() || line.starts_with('#') {
231                continue;
232            }
233            let fields = line
234                .split('|')
235                .map(str::parse::<u64>)
236                .collect::<Result<Vec<_>, _>>()
237                .expect("numeric HSD fee-policy vector");
238            assert_eq!(fields.len(), 5, "five fields in vector: {line}");
239            let weight = u32::try_from(fields[0]).expect("weight unit");
240            let sigops = u32::try_from(fields[1]).expect("sigop unit");
241            let expected_size = u32::try_from(fields[2]).expect("virtual-size unit");
242            let rate = u32::try_from(fields[3]).expect("fee-rate unit");
243            let expected_fee = fields[4];
244
245            let virtual_size =
246                sigop_adjusted_virtual_size(TransactionWeight::new(weight), SigopCost::new(sigops))
247                    .expect("bounded HSD vector");
248            assert_eq!(
249                virtual_size,
250                PolicyVirtualSize::new(expected_size),
251                "{line}"
252            );
253            assert_eq!(
254                minimum_policy_fee(virtual_size, FeeRate::new(rate))
255                    .expect("bounded HSD fee")
256                    .get(),
257                expected_fee,
258                "{line}",
259            );
260        }
261    }
262
263    #[test]
264    fn policy_size_binds_resolved_coin_outpoints() {
265        let outpoint = Outpoint {
266            transaction_hash: TransactionHash::new([7; 32]),
267            index: 3,
268        };
269        let address = Address::new(0, vec![9; 20]).expect("address");
270        let transaction = Transaction {
271            version: 0,
272            inputs: vec![Input {
273                previous_output: outpoint,
274                sequence: u32::MAX,
275                witness: Witness::default(),
276            }],
277            outputs: vec![Output {
278                value: Dollarydoos::new(1),
279                address: address.clone(),
280                covenant: Covenant::default(),
281            }],
282            locktime: 0,
283        };
284        let coin = Coin {
285            outpoint,
286            value: Dollarydoos::new(2),
287            height: Height::new(1),
288            coinbase: false,
289            address,
290            covenant: Covenant::default(),
291        };
292
293        let weight = u32::try_from(transaction.weight().expect("transaction weight"))
294            .expect("bounded weight");
295        assert_eq!(
296            transaction_policy_virtual_size(&transaction, std::slice::from_ref(&coin))
297                .expect("bound policy size"),
298            sigop_adjusted_virtual_size(TransactionWeight::new(weight), SigopCost::new(1))
299                .expect("direct policy size"),
300        );
301
302        let mut wrong_coin = coin;
303        wrong_coin.outpoint.index = 4;
304        assert!(matches!(
305            transaction_policy_virtual_size(&transaction, &[wrong_coin]),
306            Err(FeePolicyError::Script(ScriptError::InputCoinMismatch))
307        ));
308    }
309
310    #[test]
311    fn out_of_range_sigop_size_fails_closed() {
312        assert!(matches!(
313            sigop_adjusted_virtual_size(TransactionWeight::new(0), SigopCost::new(u32::MAX),),
314            Err(FeePolicyError::VirtualSizeOutOfRange { .. })
315        ));
316    }
317
318    #[test]
319    fn pinned_policy_constants_retain_their_units() {
320        assert_eq!(POLICY_WITNESS_SCALE_FACTOR, 4);
321        assert_eq!(POLICY_BYTES_PER_SIGOP, 20);
322        assert_eq!(POLICY_FEE_RATE_SCALE, 1_000);
323        assert_eq!(MIN_RELAY_FEE_RATE.get(), 1_000);
324        assert_eq!(MAX_POLICY_TRANSACTION_WEIGHT.get(), 400_000);
325        assert_eq!(MAX_POLICY_TRANSACTION_SIGOPS.get(), 16_000);
326    }
327}