Skip to main content

dpp/withdrawal/
mod.rs

1mod core_dust_threshold;
2pub mod daily_withdrawal_limit;
3#[cfg(all(feature = "withdrawals-contract", feature = "system_contracts"))]
4mod document_try_into_asset_unlock_base_transaction_info;
5
6pub use core_dust_threshold::core_dust_threshold_duffs;
7
8use bincode::{Decode, DecodeUntrusted, Encode};
9use serde_repr::{Deserialize_repr, Serialize_repr};
10
11use crate::balances::credits::CREDITS_PER_DUFF;
12#[cfg(feature = "state-transitions")]
13use crate::consensus::basic::identity::InvalidCreditWithdrawalTransitionCoreFeeError;
14use crate::fee::Credits;
15#[cfg(feature = "state-transitions")]
16use crate::state_transition::identity_credit_withdrawal_transition::MIN_CORE_FEE_PER_BYTE;
17#[cfg(feature = "state-transitions")]
18use crate::validation::SimpleConsensusValidationResult;
19use dashcore::transaction::special_transaction::asset_unlock::qualified_asset_unlock::ASSET_UNLOCK_TX_SIZE;
20use platform_version::version::PlatformVersion;
21
22#[cfg(feature = "json-conversion")]
23use crate::serialization::JsonConvertible;
24#[cfg(feature = "value-conversion")]
25use crate::serialization::ValueConvertible;
26
27#[repr(u8)]
28#[derive(
29    Serialize_repr,
30    Deserialize_repr,
31    PartialEq,
32    Eq,
33    Clone,
34    Copy,
35    Debug,
36    Encode,
37    Decode,
38    Default,
39    DecodeUntrusted,
40)]
41pub enum Pooling {
42    #[default]
43    Never = 0,
44    IfAvailable = 1,
45    Standard = 2,
46}
47
48#[cfg(feature = "json-conversion")]
49impl JsonConvertible for Pooling {}
50
51#[cfg(feature = "value-conversion")]
52impl ValueConvertible for Pooling {}
53
54/// Transaction index type
55pub type WithdrawalTransactionIndex = u64;
56
57/// Simple type alias for withdrawal transaction with it's index
58pub type WithdrawalTransactionIndexAndBytes = (WithdrawalTransactionIndex, Vec<u8>);
59
60/// Core fee charged by an asset unlock transaction, expressed in Platform credits.
61pub fn core_fee_in_credits(core_fee_per_byte: u32) -> Option<Credits> {
62    (ASSET_UNLOCK_TX_SIZE as u64)
63        .checked_mul(core_fee_per_byte as u64)?
64        .checked_mul(CREDITS_PER_DUFF)
65}
66
67/// Rejects a Core fee rate above the protocol version's `max_core_fee_per_byte`.
68///
69/// Stateless rule from protocol version 14, shared by the identity, address and shielded
70/// withdrawal structure validators of that generation. A protocol version without a cap
71/// accepts any rate here: `None` preserves the behavior of the protocol versions that predate
72/// the limit, per the field's contract.
73#[cfg(feature = "state-transitions")]
74pub fn validate_core_fee_per_byte_cap(
75    core_fee_per_byte: u32,
76    platform_version: &PlatformVersion,
77) -> SimpleConsensusValidationResult {
78    match platform_version.system_limits.max_core_fee_per_byte {
79        Some(max_core_fee_per_byte) if core_fee_per_byte > max_core_fee_per_byte => {
80            SimpleConsensusValidationResult::new_with_error(
81                InvalidCreditWithdrawalTransitionCoreFeeError::new(
82                    core_fee_per_byte,
83                    MIN_CORE_FEE_PER_BYTE,
84                )
85                .into(),
86            )
87        }
88        _ => SimpleConsensusValidationResult::new(),
89    }
90}
91
92/// Minimum amount a withdrawal must reserve for Core from protocol version 14:
93/// `min_withdrawal_amount` plus the Core fee of the asset unlock transaction at
94/// `core_fee_per_byte`. From that version the fee is carved out of the reserved amount instead
95/// of being drawn from the Core credit pool on top of it, so the amount has to leave the
96/// protocol floor above the fee.
97///
98/// The sum cannot overflow: the fee of a 190-byte transaction at a `u32` rate is below 2^50
99/// credits and the floor is a table constant. Saturating keeps the rejecting direction should
100/// either bound ever change.
101pub fn min_withdrawal_amount_with_core_fee(
102    core_fee_per_byte: u32,
103    platform_version: &PlatformVersion,
104) -> Credits {
105    let core_fee = core_fee_in_credits(core_fee_per_byte).unwrap_or(Credits::MAX);
106    platform_version
107        .system_limits
108        .min_withdrawal_amount
109        .saturating_add(core_fee)
110}
111
112/// Serde helper for `Pooling` fields exposed through the JS surface.
113///
114/// `Pooling` is `#[repr(u8)]` with `Serialize_repr` / `Deserialize_repr`, so the
115/// default wire shape is the numeric discriminant (`0`/`1`/`2`). That number
116/// leaks into JSON / Object output and makes `XxxJSON.pooling: string`
117/// declarations false. The helper switches the **human-readable** path to a
118/// camelCase string (`"never"`/`"ifAvailable"`/`"standard"`) while keeping the
119/// non-HR path at the original `u8` so bincode (consensus binary format) is
120/// untouched.
121///
122/// Apply via `#[serde(with = "crate::withdrawal::pooling_serde")]` on the
123/// `pooling` field of any state transition that surfaces it to JS.
124#[cfg(feature = "serde-conversion")]
125pub mod pooling_serde {
126    use super::Pooling;
127    use serde::{Deserializer, Serialize, Serializer};
128
129    pub fn serialize<S: Serializer>(pooling: &Pooling, serializer: S) -> Result<S::Ok, S::Error> {
130        if serializer.is_human_readable() {
131            let name = match pooling {
132                Pooling::Never => "never",
133                Pooling::IfAvailable => "ifAvailable",
134                Pooling::Standard => "standard",
135            };
136            serializer.serialize_str(name)
137        } else {
138            (*pooling as u8).serialize(serializer)
139        }
140    }
141
142    /// Deserialize accepts both shapes regardless of the deserializer's
143    /// human-readable flag — mirrors the `BinaryData` / `Identifier` pattern.
144    /// Necessary because `platform_value::to_value` reports HR=false (emits the
145    /// numeric discriminant on the way to `JsValue`), but
146    /// `platform_value::from_value` reports HR=true on the way back. Without
147    /// dual acceptance, the `fromObject(toObject())` round-trip fails on the
148    /// `pooling` field.
149    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Pooling, D::Error> {
150        struct PoolingVisitor;
151
152        impl<'de> serde::de::Visitor<'de> for PoolingVisitor {
153            type Value = Pooling;
154
155            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
156                f.write_str("a Pooling variant: 'never'/'ifAvailable'/'standard' or 0/1/2")
157            }
158
159            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Pooling, E> {
160                match v {
161                    "never" | "Never" => Ok(Pooling::Never),
162                    "ifAvailable" | "IfAvailable" | "ifavailable" => Ok(Pooling::IfAvailable),
163                    "standard" | "Standard" => Ok(Pooling::Standard),
164                    other => Err(E::custom(format!(
165                        "unknown pooling variant '{}', expected 'never' | 'ifAvailable' | 'standard'",
166                        other
167                    ))),
168                }
169            }
170
171            fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Pooling, E> {
172                self.visit_str(&v)
173            }
174
175            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Pooling, E> {
176                match v {
177                    0 => Ok(Pooling::Never),
178                    1 => Ok(Pooling::IfAvailable),
179                    2 => Ok(Pooling::Standard),
180                    other => Err(E::custom(format!("unknown pooling discriminant {}", other))),
181                }
182            }
183
184            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Pooling, E> {
185                if v < 0 {
186                    return Err(E::custom(format!("negative pooling discriminant {}", v)));
187                }
188                self.visit_u64(v as u64)
189            }
190
191            fn visit_u8<E: serde::de::Error>(self, v: u8) -> Result<Pooling, E> {
192                self.visit_u64(v as u64)
193            }
194        }
195
196        if deserializer.is_human_readable() {
197            deserializer.deserialize_any(PoolingVisitor)
198        } else {
199            deserializer.deserialize_u8(PoolingVisitor)
200        }
201    }
202
203    #[cfg(test)]
204    mod tests {
205        use super::*;
206        use serde::{Deserialize, Serialize};
207
208        #[derive(Serialize, Deserialize, PartialEq, Debug)]
209        struct Wrap(#[serde(with = "super")] Pooling);
210
211        #[test]
212        fn json_emits_camelcase_string() {
213            for (variant, expected) in [
214                (Pooling::Never, "\"never\""),
215                (Pooling::IfAvailable, "\"ifAvailable\""),
216                (Pooling::Standard, "\"standard\""),
217            ] {
218                let json = serde_json::to_string(&Wrap(variant)).expect("serialize");
219                assert_eq!(json, expected);
220                let restored: Wrap = serde_json::from_str(expected).expect("deserialize");
221                assert_eq!(restored, Wrap(variant));
222            }
223        }
224
225        #[test]
226        fn bincode_keeps_u8_discriminant() {
227            for (variant, expected_u8) in [
228                (Pooling::Never, 0),
229                (Pooling::IfAvailable, 1),
230                (Pooling::Standard, 2),
231            ] {
232                let bytes =
233                    bincode::serde::encode_to_vec(Wrap(variant), bincode::config::standard())
234                        .expect("bincode encode");
235                assert_eq!(bytes.last(), Some(&expected_u8));
236                let (restored, _): (Wrap, usize) =
237                    bincode::serde::decode_from_slice(&bytes, bincode::config::standard())
238                        .expect("bincode decode");
239                assert_eq!(restored, Wrap(variant));
240            }
241        }
242    }
243}
244
245#[cfg(all(
246    test,
247    feature = "json-conversion",
248    feature = "value-conversion",
249    feature = "serde-conversion"
250))]
251mod json_convertible_tests_pooling {
252    use super::*;
253    use platform_value::platform_value;
254    use serde_json::json;
255
256    // `Pooling` is `#[repr(u8)]` with `Serialize_repr` / `Deserialize_repr`, so
257    // the wire shape is the raw `u8` discriminant: `0` / `1` / `2`. JSON has
258    // only one number type, so `0u8` is erased to `Number(0)`; the value-path
259    // assertion uses explicit `0u8` etc. to lock in `Value::U8`.
260
261    #[test]
262    fn json_round_trip_never() {
263        use crate::serialization::JsonConvertible;
264        let original = Pooling::Never;
265        let json = original.to_json().expect("to_json");
266        // u8 size erased in JSON.
267        assert_eq!(json, json!(0));
268        let recovered = Pooling::from_json(json).expect("from_json");
269        assert_eq!(original, recovered);
270    }
271
272    #[test]
273    fn json_round_trip_if_available() {
274        use crate::serialization::JsonConvertible;
275        let original = Pooling::IfAvailable;
276        let json = original.to_json().expect("to_json");
277        assert_eq!(json, json!(1));
278        let recovered = Pooling::from_json(json).expect("from_json");
279        assert_eq!(original, recovered);
280    }
281
282    #[test]
283    fn json_round_trip_standard() {
284        use crate::serialization::JsonConvertible;
285        let original = Pooling::Standard;
286        let json = original.to_json().expect("to_json");
287        assert_eq!(json, json!(2));
288        let recovered = Pooling::from_json(json).expect("from_json");
289        assert_eq!(original, recovered);
290    }
291
292    #[test]
293    fn value_round_trip_never() {
294        use crate::serialization::ValueConvertible;
295        let original = Pooling::Never;
296        let value = original.to_object().expect("to_object");
297        // `0u8` locks `Value::U8` (not I32 from a bare `0`).
298        assert_eq!(value, platform_value!(0u8));
299        let recovered = Pooling::from_object(value).expect("from_object");
300        assert_eq!(original, recovered);
301    }
302
303    #[test]
304    fn value_round_trip_if_available() {
305        use crate::serialization::ValueConvertible;
306        let original = Pooling::IfAvailable;
307        let value = original.to_object().expect("to_object");
308        assert_eq!(value, platform_value!(1u8));
309        let recovered = Pooling::from_object(value).expect("from_object");
310        assert_eq!(original, recovered);
311    }
312
313    #[test]
314    fn value_round_trip_standard() {
315        use crate::serialization::ValueConvertible;
316        let original = Pooling::Standard;
317        let value = original.to_object().expect("to_object");
318        assert_eq!(value, platform_value!(2u8));
319        let recovered = Pooling::from_object(value).expect("from_object");
320        assert_eq!(original, recovered);
321    }
322}
323
324#[cfg(all(test, feature = "state-transitions"))]
325mod core_fee_tests {
326    use super::*;
327    use crate::consensus::basic::BasicError;
328    use crate::consensus::ConsensusError;
329    use assert_matches::assert_matches;
330
331    #[test]
332    fn should_reject_a_core_fee_rate_above_the_cap() {
333        let platform_version = PlatformVersion::latest();
334        let cap = platform_version
335            .system_limits
336            .max_core_fee_per_byte
337            .expect("the latest protocol version caps the Core fee rate");
338
339        assert!(validate_core_fee_per_byte_cap(cap, platform_version).is_valid());
340        assert_matches!(
341            validate_core_fee_per_byte_cap(cap + 1, platform_version)
342                .errors
343                .as_slice(),
344            [ConsensusError::BasicError(
345                BasicError::InvalidCreditWithdrawalTransitionCoreFeeError(error)
346            )] if error.core_fee_per_byte() == cap + 1
347        );
348    }
349
350    #[test]
351    fn should_accept_any_core_fee_rate_without_a_cap() {
352        // Protocol version 13 is live without the limit, so its table carries no cap.
353        let platform_version = PlatformVersion::get(13).expect("protocol version 13");
354        assert_eq!(platform_version.system_limits.max_core_fee_per_byte, None);
355
356        assert!(validate_core_fee_per_byte_cap(u32::MAX, platform_version).is_valid());
357    }
358
359    #[test]
360    fn should_add_the_core_fee_to_the_withdrawal_floor() {
361        let platform_version = PlatformVersion::latest();
362        let floor = platform_version.system_limits.min_withdrawal_amount;
363
364        assert_eq!(
365            min_withdrawal_amount_with_core_fee(1, platform_version),
366            floor + ASSET_UNLOCK_TX_SIZE as u64 * CREDITS_PER_DUFF
367        );
368        // The largest rate the wire format allows still sums exactly, without saturating.
369        assert_eq!(
370            min_withdrawal_amount_with_core_fee(u32::MAX, platform_version),
371            floor + core_fee_in_credits(u32::MAX).expect("a u32 rate fits in credits")
372        );
373    }
374}