Skip to main content

dpp/tokens/token_payment_info/
mod.rs

1//! Token payment metadata and helpers.
2//!
3//! This module defines the versioned `TokenPaymentInfo` wrapper used to describe how a
4//! client intends to pay with tokens for an operation (for example, creating,
5//! transferring, purchasing, or updating the price of a document/NFT).
6//! It captures which token to use, optional price bounds, and who covers gas fees.
7//!
8//! The enum is versioned to allow future evolution without breaking callers.
9//! [`v0::TokenPaymentInfoV0`] pays from the identity's token balance; [`v1::TokenPaymentInfoV1`]
10//! adds a [`v1::TokenShieldedPayment`], a spend bundle that pays the cost out of the token's
11//! shielded pool instead (protocol version 14 and up). Accessors are provided via
12//! [`v0::v0_accessors::TokenPaymentInfoAccessorsV0`], and convenience methods (such
13//! as `token_id()` and `is_valid_for_required_cost()`) are available through
14//! [`methods::v0::TokenPaymentInfoMethodsV0`].
15//!
16//! Typical usage:
17//!
18//! ```ignore
19//! use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy;
20//! use dpp::data_contract::TokenContractPosition;
21//! use dpp::tokens::token_payment_info::{TokenPaymentInfo, v0::TokenPaymentInfoV0};
22//!
23//! // Client indicates payment preferences for a transition
24//! let info: TokenPaymentInfo = TokenPaymentInfoV0 {
25//!     // `None` => use a token defined on the current contract
26//!     payment_token_contract_id: None,
27//!     // Which token (by position/index) on the contract to use
28//!     token_contract_position: 0u16,
29//!     // Optional bounds to guard against unexpected price changes
30//!     minimum_token_cost: None,
31//!     maximum_token_cost: Some(1_000u64.into()),
32//!     // Who pays gas: user, contract owner, or prefer contract owner
33//!     gas_fees_paid_by: GasFeesPaidBy::DocumentOwner,
34//! }.into();
35//! ```
36//!
37//! Deserialization from a platform `BTreeMap<String, Value>` requires a
38//! `$formatVersion` key. For V0 the map may contain:
39//! - `paymentTokenContractId` (`Identifier` as bytes)
40//! - `tokenContractPosition` (`u16`)
41//! - `minimumTokenCost` (`u64`)
42//! - `maximumTokenCost` (`u64`)
43//! - `gasFeesPaidBy` (one of: `"DocumentOwner"`, `"ContractOwner"`, `"PreferContractOwner"`)
44//!
45//! For V1 the map additionally carries `shieldedPayment` (`amount`, `actions`, `anchor`,
46//! `proof`, `bindingSignature`).
47//!
48//! Unknown `$formatVersion` values yield an `UnknownVersionMismatch` error.
49//!
50use crate::balances::credits::TokenAmount;
51use crate::data_contract::TokenContractPosition;
52#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))]
53use crate::serialization::JsonConvertible;
54#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))]
55use crate::serialization::ValueConvertible;
56use crate::tokens::gas_fees_paid_by::GasFeesPaidBy;
57use crate::tokens::token_payment_info::methods::v0::TokenPaymentInfoMethodsV0;
58use crate::tokens::token_payment_info::v0::v0_accessors::TokenPaymentInfoAccessorsV0;
59use crate::tokens::token_payment_info::v0::TokenPaymentInfoV0;
60use crate::tokens::token_payment_info::v1::v1_accessors::TokenPaymentInfoAccessorsV1;
61use crate::tokens::token_payment_info::v1::{TokenPaymentInfoV1, TokenShieldedPayment};
62use crate::ProtocolError;
63use bincode::{Decode, DecodeUntrusted, Encode};
64use derive_more::{Display, From};
65use platform_serialization_derive::{
66    PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize,
67};
68use platform_value::btreemap_extensions::BTreeValueMapHelper;
69#[cfg(feature = "value-conversion")]
70use platform_value::Error;
71use platform_value::{Identifier, Value};
72#[cfg(feature = "serde-conversion")]
73use serde::{Deserialize, Serialize};
74use std::collections::BTreeMap;
75
76pub mod methods;
77pub mod v0;
78pub mod v1;
79
80#[derive(
81    Debug,
82    Clone,
83    Encode,
84    Decode,
85    PlatformDeserializeTrusted,
86    PlatformDeserializeUntrusted,
87    PlatformSerialize,
88    PartialEq,
89    Display,
90    From,
91    DecodeUntrusted,
92)]
93#[cfg_attr(
94    feature = "serde-conversion",
95    derive(Serialize, Deserialize),
96    serde(tag = "$formatVersion")
97)]
98/// Versioned container describing how a client intends to pay with tokens.
99///
100/// The `TokenPaymentInfo` enum allows the protocol to evolve the underlying structure
101/// across versions while keeping a stable API for callers. Use the accessor trait
102/// [`v0::v0_accessors::TokenPaymentInfoAccessorsV0`] to read or update fields, and
103/// [`methods::v0::TokenPaymentInfoMethodsV0`] for helpers like `token_id()` and
104/// `is_valid_for_required_cost()`.
105///
106/// See [`v0::TokenPaymentInfoV0`] for the current set of fields and semantics.
107pub enum TokenPaymentInfo {
108    #[display("V0({})", "_0")]
109    #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))]
110    V0(TokenPaymentInfoV0),
111    /// `V0` plus a shielded payment: the token cost is paid out of the token's shielded pool.
112    #[display("V1({})", "_0")]
113    #[cfg_attr(feature = "serde-conversion", serde(rename = "1"))]
114    V1(TokenPaymentInfoV1),
115}
116
117#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))]
118impl JsonConvertible for TokenPaymentInfo {}
119
120#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))]
121impl ValueConvertible for TokenPaymentInfo {}
122
123impl TokenPaymentInfoMethodsV0 for TokenPaymentInfo {}
124
125impl TokenPaymentInfoAccessorsV1 for TokenPaymentInfo {
126    fn shielded_payment(&self) -> Option<&TokenShieldedPayment> {
127        match self {
128            TokenPaymentInfo::V0(_) => None,
129            TokenPaymentInfo::V1(v1) => Some(v1.shielded_payment()),
130        }
131    }
132
133    fn set_shielded_payment(&mut self, shielded_payment: Option<TokenShieldedPayment>) {
134        let current = std::mem::replace(self, TokenPaymentInfo::V0(TokenPaymentInfoV0::default()));
135        *self = match (current, shielded_payment) {
136            (TokenPaymentInfo::V0(v0), Some(payment)) => {
137                TokenPaymentInfo::V1(TokenPaymentInfoV1::from_v0(v0, payment))
138            }
139            (TokenPaymentInfo::V1(v1), Some(payment)) => TokenPaymentInfo::V1(TokenPaymentInfoV1 {
140                shielded_payment: Box::new(payment),
141                ..v1
142            }),
143            (TokenPaymentInfo::V1(v1), None) => TokenPaymentInfo::V0(v1.into_v0()),
144            (v0 @ TokenPaymentInfo::V0(_), None) => v0,
145        };
146    }
147}
148
149impl TokenPaymentInfoAccessorsV0 for TokenPaymentInfo {
150    // Getters
151    fn payment_token_contract_id(&self) -> Option<Identifier> {
152        match self {
153            TokenPaymentInfo::V0(v0) => v0.payment_token_contract_id(),
154            TokenPaymentInfo::V1(v1) => v1.payment_token_contract_id(),
155        }
156    }
157
158    fn payment_token_contract_id_ref(&self) -> &Option<Identifier> {
159        match self {
160            TokenPaymentInfo::V0(v0) => v0.payment_token_contract_id_ref(),
161            TokenPaymentInfo::V1(v1) => v1.payment_token_contract_id_ref(),
162        }
163    }
164
165    fn token_contract_position(&self) -> TokenContractPosition {
166        match self {
167            TokenPaymentInfo::V0(v0) => v0.token_contract_position(),
168            TokenPaymentInfo::V1(v1) => v1.token_contract_position(),
169        }
170    }
171
172    fn minimum_token_cost(&self) -> Option<TokenAmount> {
173        match self {
174            TokenPaymentInfo::V0(v0) => v0.minimum_token_cost(),
175            TokenPaymentInfo::V1(v1) => v1.minimum_token_cost(),
176        }
177    }
178
179    fn maximum_token_cost(&self) -> Option<TokenAmount> {
180        match self {
181            TokenPaymentInfo::V0(v0) => v0.maximum_token_cost(),
182            TokenPaymentInfo::V1(v1) => v1.maximum_token_cost(),
183        }
184    }
185
186    fn gas_fees_paid_by(&self) -> GasFeesPaidBy {
187        match self {
188            TokenPaymentInfo::V0(v0) => v0.gas_fees_paid_by(),
189            TokenPaymentInfo::V1(v1) => v1.gas_fees_paid_by(),
190        }
191    }
192
193    // Setters
194    fn set_payment_token_contract_id(&mut self, id: Option<Identifier>) {
195        match self {
196            TokenPaymentInfo::V0(v0) => v0.set_payment_token_contract_id(id),
197            TokenPaymentInfo::V1(v1) => v1.set_payment_token_contract_id(id),
198        }
199    }
200
201    fn set_token_contract_position(&mut self, position: TokenContractPosition) {
202        match self {
203            TokenPaymentInfo::V0(v0) => v0.set_token_contract_position(position),
204            TokenPaymentInfo::V1(v1) => v1.set_token_contract_position(position),
205        }
206    }
207
208    fn set_minimum_token_cost(&mut self, cost: Option<TokenAmount>) {
209        match self {
210            TokenPaymentInfo::V0(v0) => v0.set_minimum_token_cost(cost),
211            TokenPaymentInfo::V1(v1) => v1.set_minimum_token_cost(cost),
212        }
213    }
214
215    fn set_maximum_token_cost(&mut self, cost: Option<TokenAmount>) {
216        match self {
217            TokenPaymentInfo::V0(v0) => v0.set_maximum_token_cost(cost),
218            TokenPaymentInfo::V1(v1) => v1.set_maximum_token_cost(cost),
219        }
220    }
221
222    fn set_gas_fees_paid_by(&mut self, payer: GasFeesPaidBy) {
223        match self {
224            TokenPaymentInfo::V0(v0) => v0.set_gas_fees_paid_by(payer),
225            TokenPaymentInfo::V1(v1) => v1.set_gas_fees_paid_by(payer),
226        }
227    }
228}
229
230impl TryFrom<BTreeMap<String, Value>> for TokenPaymentInfo {
231    type Error = ProtocolError;
232
233    fn try_from(map: BTreeMap<String, Value>) -> Result<Self, Self::Error> {
234        // Expect a `$formatVersion` discriminator and dispatch to the
235        // corresponding versioned structure. This allows backward-compatible
236        // support for older serialized payloads.
237        let format_version = map.get_str("$formatVersion")?;
238        match format_version {
239            "0" => {
240                let token_payment_info: TokenPaymentInfoV0 = map.try_into()?;
241
242                Ok(token_payment_info.into())
243            }
244            "1" => {
245                let token_payment_info: TokenPaymentInfoV1 = map.try_into()?;
246
247                Ok(token_payment_info.into())
248            }
249            version => Err(ProtocolError::UnknownVersionMismatch {
250                method: "TokenPaymentInfo::from_value".to_string(),
251                known_versions: vec![0, 1],
252                received: version
253                    .parse()
254                    .map_err(|_| ProtocolError::Generic("Conversion error".to_string()))?,
255            }),
256        }
257    }
258}
259
260#[cfg(feature = "value-conversion")]
261impl TryFrom<TokenPaymentInfo> for Value {
262    type Error = Error;
263    /// Serialize the versioned token payment info into a platform `Value`.
264    ///
265    /// This mirrors the map format accepted by `TryFrom<BTreeMap<String, Value>>`,
266    /// including the `$formatVersion` discriminator.
267    fn try_from(value: TokenPaymentInfo) -> Result<Self, Self::Error> {
268        platform_value::to_value(value)
269    }
270}
271
272#[cfg(all(
273    test,
274    feature = "json-conversion",
275    feature = "value-conversion",
276    feature = "serde-conversion"
277))]
278mod json_convertible_tests {
279    use super::*;
280    use platform_value::platform_value;
281    use serde_json::json;
282
283    fn fixture() -> TokenPaymentInfo {
284        TokenPaymentInfo::V0(TokenPaymentInfoV0 {
285            payment_token_contract_id: Some(Identifier::new([0x99; 32])),
286            token_contract_position: 3,
287            minimum_token_cost: Some(100),
288            maximum_token_cost: Some(1_000),
289            gas_fees_paid_by: GasFeesPaidBy::ContractOwner,
290        })
291    }
292
293    #[test]
294    fn json_round_trip_with_full_wire_shape() {
295        use crate::serialization::JsonConvertible;
296        let original = fixture();
297        let json = original.to_json().expect("to_json");
298        // Internally-tagged enum (`tag = "$formatVersion"`); inner V0 has
299        // `rename_all = "camelCase"`. `Identifier` -> base58 in JSON.
300        // `token_contract_position` is `TokenContractPosition` (= u16) and
301        // `minimum_token_cost` / `maximum_token_cost` are `TokenAmount` (= u64);
302        // JSON erases the size — see the value-path assertion for typed locks.
303        // `gas_fees_paid_by` is the unit enum `GasFeesPaidBy` and serializes
304        // as `"ContractOwner"` (no `rename_all`).
305        assert_eq!(
306            json,
307            json!({
308                "$formatVersion": "0",
309                "paymentTokenContractId": "BLbDu5FZUdSfLrGejhuaWw5iMJBo3j3TVRyPv9rfJyMA",
310                "tokenContractPosition": 3,
311                "minimumTokenCost": 100,
312                "maximumTokenCost": 1_000,
313                "gasFeesPaidBy": "ContractOwner",
314            })
315        );
316        let recovered = TokenPaymentInfo::from_json(json).expect("from_json");
317        assert_eq!(original, recovered);
318    }
319
320    fn v1_fixture() -> TokenPaymentInfo {
321        use crate::shielded::SerializedAction;
322        use crate::tokens::token_payment_info::v1::{TokenPaymentInfoV1, TokenShieldedPayment};
323        TokenPaymentInfo::V1(TokenPaymentInfoV1 {
324            payment_token_contract_id: None,
325            token_contract_position: 1,
326            minimum_token_cost: None,
327            maximum_token_cost: Some(10),
328            gas_fees_paid_by: GasFeesPaidBy::DocumentOwner,
329            shielded_payment: Box::new(TokenShieldedPayment {
330                amount: 10,
331                actions: vec![SerializedAction {
332                    nullifier: [1u8; 32],
333                    rk: [2u8; 32],
334                    cmx: [3u8; 32],
335                    encrypted_note: vec![4u8; 216],
336                    cv_net: [5u8; 32],
337                    spend_auth_sig: [6u8; 64],
338                }],
339                anchor: [7u8; 32],
340                proof: vec![8u8; 10],
341                binding_signature: [9u8; 64],
342            }),
343        })
344    }
345
346    #[test]
347    fn v1_json_and_value_round_trip() {
348        use crate::serialization::{JsonConvertible, ValueConvertible};
349        let original = v1_fixture();
350        let json = original.to_json().expect("to_json");
351        assert_eq!(json["$formatVersion"], "1");
352        assert_eq!(json["shieldedPayment"]["amount"], 10);
353        let recovered = TokenPaymentInfo::from_json(json).expect("from_json");
354        assert_eq!(original, recovered);
355        let value = original.to_object().expect("to_object");
356        let recovered = TokenPaymentInfo::from_object(value.clone()).expect("from_object");
357        assert_eq!(original, recovered);
358        // the manual map path (`$tokenPaymentInfo` inside a document map) parses V1 too
359        let map = value.into_btree_string_map().expect("map");
360        let recovered: TokenPaymentInfo = map.try_into().expect("try_into");
361        assert_eq!(original, recovered);
362    }
363
364    /// A client that holds the payment info as JSON hands its map to the map
365    /// parser. JSON carries raw byte fields as base64 strings — the shape
366    /// `to_json` writes for them — and a JSON string becomes `Value::Text`, so
367    /// the parser has to read `Value::Text` as base64 to agree with the JSON
368    /// the same type produces. Base58 is this repo's identifier encoding, not
369    /// its raw-byte encoding.
370    #[test]
371    fn v1_parses_a_map_whose_byte_fields_arrived_as_base64_json_text() {
372        use crate::serialization::JsonConvertible;
373        let original = v1_fixture();
374        let json = original.to_json().expect("to_json");
375        // Pin the JSON encoding the parser has to match: base64, padded.
376        assert_eq!(
377            json["shieldedPayment"]["anchor"],
378            json!("BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=")
379        );
380        assert_eq!(json["shieldedPayment"]["proof"], json!("CAgICAgICAgICA=="));
381        assert_eq!(
382            json["shieldedPayment"]["actions"][0]["nullifier"],
383            json!("AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=")
384        );
385        let map = Value::from(json)
386            .into_btree_string_map()
387            .expect("the JSON object becomes a string-keyed map");
388        let recovered: TokenPaymentInfo = map.try_into().expect("the JSON map parses");
389        assert_eq!(original, recovered);
390    }
391
392    #[test]
393    fn setting_and_clearing_the_shielded_payment_switches_the_format_version() {
394        let mut info = fixture();
395        let TokenPaymentInfo::V1(v1) = v1_fixture() else {
396            unreachable!()
397        };
398        info.set_shielded_payment(Some((*v1.shielded_payment).clone()));
399        assert!(matches!(info, TokenPaymentInfo::V1(_)));
400        assert_eq!(info.shielded_payment(), Some(&*v1.shielded_payment));
401        assert_eq!(info.maximum_token_cost(), Some(1_000));
402        info.set_shielded_payment(None);
403        assert_eq!(info, fixture());
404    }
405
406    #[test]
407    fn value_round_trip_with_full_wire_shape() {
408        use crate::serialization::ValueConvertible;
409        let original = fixture();
410        let value = original.to_object().expect("to_object");
411        // `Identifier` flows as `Value::Identifier` when interpolated.
412        // `3u16` locks `Value::U16`; `100u64` / `1_000u64` lock `Value::U64`.
413        let payment_token_contract_id = Identifier::new([0x99; 32]);
414        assert_eq!(
415            value,
416            platform_value!({
417                "$formatVersion": "0",
418                "paymentTokenContractId": payment_token_contract_id,
419                "tokenContractPosition": 3u16,
420                "minimumTokenCost": 100u64,
421                "maximumTokenCost": 1_000u64,
422                "gasFeesPaidBy": "ContractOwner",
423            })
424        );
425        let recovered = TokenPaymentInfo::from_object(value).expect("from_object");
426        assert_eq!(original, recovered);
427    }
428}