Skip to main content

dpp/withdrawal/
mod.rs

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