Skip to main content

dpp/identity/identity_public_key/contract_bounds/
mod.rs

1#[cfg(feature = "state-transitions")]
2use crate::contract_group::ContractGroupMember;
3use crate::identifier::Identifier;
4use crate::identity::identity_public_key::contract_bounds::ContractBounds::{
5    ContractGroup, SingleContract, SingleContractDocumentType,
6};
7#[cfg(feature = "json-conversion")]
8use crate::serialization::JsonConvertible;
9#[cfg(feature = "value-conversion")]
10use crate::serialization::ValueConvertible;
11#[cfg(feature = "state-transitions")]
12use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods;
13#[cfg(feature = "state-transitions")]
14use crate::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods;
15#[cfg(feature = "state-transitions")]
16use crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef;
17#[cfg(feature = "state-transitions")]
18use crate::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods;
19use crate::ProtocolError;
20use bincode::{Decode, DecodeUntrusted, Encode};
21use serde::{Deserialize, Serialize};
22
23pub type ContractBoundsType = u8;
24
25/// A contract bounds is the bounds that the key has influence on.
26/// For authentication keys the bounds mean that the keys can only be used to sign
27/// within the specified contract, or within the members of the specified contract group.
28/// For encryption decryption this tells clients to only use these keys for specific
29/// contracts.
30///
31/// New variants go last: the bincode tag is the declaration index, and every stored identity
32/// key carries one.
33#[cfg_attr(feature = "json-conversion", derive(JsonConvertible))]
34#[repr(u8)]
35#[derive(
36    Debug,
37    PartialEq,
38    Eq,
39    Clone,
40    Serialize,
41    Deserialize,
42    Encode,
43    Decode,
44    Ord,
45    PartialOrd,
46    Hash,
47    DecodeUntrusted,
48)]
49#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))]
50#[serde(tag = "$type", rename_all = "camelCase")]
51pub enum ContractBounds {
52    /// this key can only be used within a specific contract
53    #[serde(rename = "singleContract")]
54    SingleContract { id: Identifier } = 0,
55    /// this key can only be used within a specific contract and for a specific document type
56    #[serde(rename = "documentType", rename_all = "camelCase")]
57    SingleContractDocumentType {
58        id: Identifier,
59        document_type_name: String,
60    } = 1,
61    /// this key can only be used within the members of a contract group: the contracts,
62    /// document types and tokens the group holds when the key signs (protocol version 14)
63    #[serde(rename = "contractGroup")]
64    ContractGroup { id: Identifier } = 2,
65}
66
67/// What authorizing one batch member with a contract-bound AUTHENTICATION key needs.
68#[cfg(feature = "state-transitions")]
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum BatchedTransitionBoundsCheck {
71    /// The member lies inside the bounds.
72    Allowed,
73    /// The member lies outside the bounds.
74    Denied,
75    /// The bounds name a contract group. The member lies inside them when its contract holds
76    /// this membership in the group, or is a whole-contract member of the group. Only state can
77    /// answer that; consensus reads the contract's memberships and bills the read.
78    RequiresContractGroupMembership {
79        contract_group_id: Identifier,
80        contract_id: Identifier,
81        member: ContractGroupMember,
82    },
83}
84
85impl ContractBounds {
86    /// Creates a new contract bounds for the key
87    pub fn new_from_type(
88        contract_bounds_type: u8,
89        identifier: Vec<u8>,
90        document_type: String,
91    ) -> Result<Self, ProtocolError> {
92        Ok(match contract_bounds_type {
93            0 => SingleContract {
94                id: Identifier::from_bytes(identifier.as_slice())?,
95            },
96            1 => SingleContractDocumentType {
97                id: Identifier::from_bytes(identifier.as_slice())?,
98                document_type_name: document_type,
99            },
100            2 => ContractGroup {
101                id: Identifier::from_bytes(identifier.as_slice())?,
102            },
103            _ => {
104                return Err(ProtocolError::InvalidKeyContractBoundsError(format!(
105                    "unrecognized contract bounds type: {}",
106                    contract_bounds_type
107                )))
108            }
109        })
110    }
111
112    /// Gets the contract bounds type
113    pub fn contract_bounds_type(&self) -> ContractBoundsType {
114        match self {
115            SingleContract { .. } => 0,
116            SingleContractDocumentType { .. } => 1,
117            ContractGroup { .. } => 2,
118        }
119    }
120
121    pub fn contract_bounds_type_from_str(str: &str) -> Result<ContractBoundsType, ProtocolError> {
122        match str {
123            "singleContract" => Ok(0),
124            "documentType" => Ok(1),
125            "contractGroup" => Ok(2),
126            _ => Err(ProtocolError::DecodingError(String::from(
127                "Expected type to be one of singleContract, documentType or contractGroup",
128            ))),
129        }
130    }
131    /// Gets the contract bounds type
132    pub fn contract_bounds_type_string(&self) -> &str {
133        match self {
134            SingleContract { .. } => "singleContract",
135            SingleContractDocumentType { .. } => "documentType",
136            ContractGroup { .. } => "contractGroup",
137        }
138    }
139
140    /// Gets the identifier: the contract id, or the contract group id for a group bound. Use
141    /// [`Self::contract_id`] or [`Self::contract_group_id`] when the kind matters.
142    pub fn identifier(&self) -> &Identifier {
143        match self {
144            SingleContract { id } => id,
145            SingleContractDocumentType { id, .. } => id,
146            ContractGroup { id } => id,
147        }
148    }
149
150    /// The bound contract id, when the bounds name one contract.
151    pub fn contract_id(&self) -> Option<&Identifier> {
152        match self {
153            SingleContract { id } | SingleContractDocumentType { id, .. } => Some(id),
154            ContractGroup { .. } => None,
155        }
156    }
157
158    /// The bound contract group id, when the bounds name a contract group.
159    pub fn contract_group_id(&self) -> Option<&Identifier> {
160        match self {
161            SingleContract { .. } | SingleContractDocumentType { .. } => None,
162            ContractGroup { id } => Some(id),
163        }
164    }
165
166    /// Gets the document type
167    pub fn document_type(&self) -> Option<&String> {
168        match self {
169            SingleContract { .. } => None,
170            SingleContractDocumentType {
171                document_type_name: document_type,
172                ..
173            } => Some(document_type),
174            ContractGroup { .. } => None,
175        }
176    }
177
178    /// What authorizing `transition` with a key carrying these bounds needs. Consensus uses this
179    /// to authorize a batch signed by a contract-bound AUTHENTICATION key. Token operations are
180    /// contract-wide, so a document-type bound never covers them. A contract group bound is
181    /// answered by state: the member's contract must hold the returned membership, or be a
182    /// whole-contract member of the group.
183    #[cfg(feature = "state-transitions")]
184    pub fn check_batched_transition(
185        &self,
186        transition: BatchedTransitionRef<'_>,
187    ) -> BatchedTransitionBoundsCheck {
188        use BatchedTransitionBoundsCheck::{Allowed, Denied, RequiresContractGroupMembership};
189        let allowed_if = |inside: bool| if inside { Allowed } else { Denied };
190        match (self, transition) {
191            (SingleContract { id }, BatchedTransitionRef::Document(document)) => {
192                allowed_if(document.data_contract_id() == *id)
193            }
194            (SingleContract { id }, BatchedTransitionRef::Token(token)) => {
195                allowed_if(token.data_contract_id() == *id)
196            }
197            (
198                SingleContractDocumentType {
199                    id,
200                    document_type_name,
201                },
202                BatchedTransitionRef::Document(document),
203            ) => allowed_if(
204                document.data_contract_id() == *id
205                    && document.document_type_name() == document_type_name.as_str(),
206            ),
207            (SingleContractDocumentType { .. }, BatchedTransitionRef::Token(_)) => Denied,
208            (ContractGroup { id }, BatchedTransitionRef::Document(document)) => {
209                RequiresContractGroupMembership {
210                    contract_group_id: *id,
211                    contract_id: document.data_contract_id(),
212                    member: ContractGroupMember::DocumentType(
213                        document.document_type_name().to_string(),
214                    ),
215                }
216            }
217            (ContractGroup { id }, BatchedTransitionRef::Token(token)) => {
218                RequiresContractGroupMembership {
219                    contract_group_id: *id,
220                    contract_id: token.data_contract_id(),
221                    member: ContractGroupMember::Token(token.base().token_contract_position()),
222                }
223            }
224        }
225    }
226}
227
228#[cfg(test)]
229mod core_tests {
230    use super::*;
231
232    // -- new_from_type: valid types --
233    #[test]
234    fn test_new_from_type_single_contract() {
235        let id_bytes = vec![0xAAu8; 32];
236        let bounds =
237            ContractBounds::new_from_type(0, id_bytes.clone(), "ignored".to_string()).unwrap();
238        assert!(matches!(bounds, ContractBounds::SingleContract { .. }));
239        assert_eq!(bounds.contract_bounds_type(), 0);
240        assert_eq!(bounds.contract_bounds_type_string(), "singleContract");
241        assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice());
242        // document_type is None for SingleContract regardless of what we passed in.
243        assert!(bounds.document_type().is_none());
244    }
245
246    #[test]
247    fn test_new_from_type_single_contract_document_type() {
248        let id_bytes = vec![0xBBu8; 32];
249        let bounds = ContractBounds::new_from_type(1, id_bytes.clone(), "myDoc".to_string())
250            .expect("expected to construct SingleContractDocumentType");
251        assert!(matches!(
252            bounds,
253            ContractBounds::SingleContractDocumentType { .. }
254        ));
255        assert_eq!(bounds.contract_bounds_type(), 1);
256        assert_eq!(bounds.contract_bounds_type_string(), "documentType");
257        assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice());
258        assert_eq!(bounds.document_type().map(String::as_str), Some("myDoc"));
259    }
260
261    #[test]
262    fn should_build_contract_group_bounds_from_type_two() {
263        let id_bytes = vec![0xEEu8; 32];
264        let bounds = ContractBounds::new_from_type(2, id_bytes.clone(), "ignored".to_string())
265            .expect("expected to construct ContractGroup");
266        assert!(matches!(bounds, ContractBounds::ContractGroup { .. }));
267        assert_eq!(bounds.contract_bounds_type(), 2);
268        assert_eq!(bounds.contract_bounds_type_string(), "contractGroup");
269        assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice());
270        assert_eq!(
271            bounds.contract_group_id().map(|id| id.as_slice()),
272            Some(id_bytes.as_slice())
273        );
274        assert!(bounds.contract_id().is_none());
275        assert!(bounds.document_type().is_none());
276        assert_eq!(
277            ContractBounds::contract_bounds_type_from_str("contractGroup").unwrap(),
278            2
279        );
280    }
281
282    #[test]
283    fn should_expose_the_contract_id_only_for_contract_bounds() {
284        let id = Identifier::from([0x12u8; 32]);
285        let single = ContractBounds::SingleContract { id };
286        let typed = ContractBounds::SingleContractDocumentType {
287            id,
288            document_type_name: "note".to_string(),
289        };
290        assert_eq!(single.contract_id(), Some(&id));
291        assert_eq!(typed.contract_id(), Some(&id));
292        assert!(single.contract_group_id().is_none());
293        assert!(typed.contract_group_id().is_none());
294    }
295
296    // -- new_from_type: invalid type --
297    #[test]
298    fn test_new_from_type_unrecognized_type_returns_error() {
299        let id_bytes = vec![0xCCu8; 32];
300        let err = ContractBounds::new_from_type(99, id_bytes, "".to_string()).unwrap_err();
301        match err {
302            ProtocolError::InvalidKeyContractBoundsError(msg) => {
303                assert!(msg.contains("99"), "expected error message to mention 99");
304            }
305            other => panic!("expected InvalidKeyContractBoundsError, got {:?}", other),
306        }
307    }
308
309    // -- new_from_type: identifier wrong length --
310    #[test]
311    fn test_new_from_type_invalid_identifier_length_returns_error() {
312        // Identifier::from_bytes requires exactly 32 bytes.
313        let short = vec![0x01u8; 10];
314        assert!(ContractBounds::new_from_type(0, short, "".to_string()).is_err());
315    }
316
317    // -- contract_bounds_type_from_str --
318    #[test]
319    fn test_contract_bounds_type_from_str_single_contract() {
320        assert_eq!(
321            ContractBounds::contract_bounds_type_from_str("singleContract").unwrap(),
322            0
323        );
324    }
325
326    #[test]
327    fn test_contract_bounds_type_from_str_document_type() {
328        assert_eq!(
329            ContractBounds::contract_bounds_type_from_str("documentType").unwrap(),
330            1
331        );
332    }
333
334    #[test]
335    fn test_contract_bounds_type_from_str_unknown_returns_error() {
336        let err = ContractBounds::contract_bounds_type_from_str("garbage").unwrap_err();
337        match err {
338            ProtocolError::DecodingError(_) => {}
339            other => panic!("expected ProtocolError::DecodingError, got {:?}", other),
340        }
341    }
342
343    // -- equality / clone / hash (derives) --
344    #[test]
345    fn test_contract_bounds_equality_and_clone() {
346        let id = Identifier::from([0x11u8; 32]);
347        let a = ContractBounds::SingleContract { id };
348        let b = a.clone();
349        assert_eq!(a, b);
350
351        let different = ContractBounds::SingleContractDocumentType {
352            id,
353            document_type_name: "foo".to_string(),
354        };
355        assert_ne!(a, different);
356    }
357
358    #[test]
359    fn test_contract_bounds_type_string_roundtrip_with_from_str() {
360        // The string form produced by the variant should round-trip through from_str
361        // back to its numeric discriminant.
362        let id_bytes = vec![0xD0u8; 32];
363        let sc = ContractBounds::new_from_type(0, id_bytes.clone(), "".to_string()).unwrap();
364        let sctd = ContractBounds::new_from_type(1, id_bytes, "docType".to_string()).unwrap();
365
366        assert_eq!(
367            ContractBounds::contract_bounds_type_from_str(sc.contract_bounds_type_string())
368                .unwrap(),
369            sc.contract_bounds_type()
370        );
371        assert_eq!(
372            ContractBounds::contract_bounds_type_from_str(sctd.contract_bounds_type_string())
373                .unwrap(),
374            sctd.contract_bounds_type()
375        );
376    }
377}
378
379#[cfg(all(test, feature = "json-conversion"))]
380mod tests {
381    use super::*;
382    use crate::serialization::JsonConvertible;
383
384    #[test]
385    fn contract_bounds_single_contract_json_round_trip() {
386        let id = Identifier::from([0xABu8; 32]);
387        let bounds = ContractBounds::SingleContract { id };
388
389        let json = bounds.to_json().expect("to_json should succeed");
390        assert!(
391            json["id"].is_string(),
392            "Identifier should be a base58 string, got: {:?}",
393            json["id"]
394        );
395
396        let expected_base58 = id.to_string(platform_value::string_encoding::Encoding::Base58);
397        assert_eq!(json["id"].as_str().unwrap(), expected_base58);
398
399        let restored = ContractBounds::from_json(json).expect("from_json should succeed");
400        assert_eq!(bounds, restored);
401    }
402
403    #[test]
404    fn contract_bounds_document_type_json_round_trip() {
405        let id = Identifier::from([0xCDu8; 32]);
406        let bounds = ContractBounds::SingleContractDocumentType {
407            id,
408            document_type_name: "myDocument".to_string(),
409        };
410
411        let json = bounds.to_json().expect("to_json should succeed");
412        assert!(json["id"].is_string());
413        assert_eq!(json["documentTypeName"].as_str().unwrap(), "myDocument");
414
415        let restored = ContractBounds::from_json(json).expect("from_json should succeed");
416        assert_eq!(bounds, restored);
417    }
418
419    #[test]
420    fn should_round_trip_contract_group_bounds_through_json() {
421        let id = Identifier::from([0xEFu8; 32]);
422        let bounds = ContractBounds::ContractGroup { id };
423
424        let json = bounds.to_json().expect("to_json should succeed");
425        assert_eq!(json["$type"].as_str().unwrap(), "contractGroup");
426        assert_eq!(
427            json["id"].as_str().unwrap(),
428            id.to_string(platform_value::string_encoding::Encoding::Base58)
429        );
430        assert!(json.get("documentTypeName").is_none());
431
432        let restored = ContractBounds::from_json(json).expect("from_json should succeed");
433        assert_eq!(bounds, restored);
434
435        let obj = bounds.to_object().expect("to_object should succeed");
436        assert_eq!(
437            ContractBounds::from_object(obj).expect("from_object"),
438            bounds
439        );
440    }
441
442    #[test]
443    fn contract_bounds_value_round_trip() {
444        let id = Identifier::from([0x55u8; 32]);
445        let bounds = ContractBounds::SingleContractDocumentType {
446            id,
447            document_type_name: "note".to_string(),
448        };
449
450        let obj = bounds.to_object().expect("to_object should succeed");
451        let restored = ContractBounds::from_object(obj).expect("from_object should succeed");
452        assert_eq!(bounds, restored);
453    }
454}
455
456#[cfg(all(test, feature = "state-transitions"))]
457mod batched_transition_tests {
458    use super::{BatchedTransitionBoundsCheck, ContractBounds};
459    use crate::contract_group::ContractGroupMember;
460    use crate::identifier::Identifier;
461    use crate::state_transition::batch_transition::batched_transition::{
462        document_create_transition::DocumentCreateTransitionV0,
463        document_delete_transition::DocumentDeleteTransitionV0,
464        document_index_only_delete_transition::DocumentIndexOnlyDeleteTransitionV0,
465        document_purchase_transition::DocumentPurchaseTransitionV0,
466        document_replace_transition::DocumentReplaceTransitionV0,
467        document_transfer_transition::DocumentTransferTransitionV0,
468        document_update_price_transition::DocumentUpdatePriceTransitionV0,
469        token_transfer_transition::TokenTransferTransitionV0, BatchedTransitionRef,
470        DocumentTransition, TokenTransition,
471    };
472
473    // Default transitions target contract [0; 32] and the empty document type name.
474    fn documents() -> Vec<DocumentTransition> {
475        vec![
476            DocumentTransition::Create(DocumentCreateTransitionV0::default().into()),
477            DocumentTransition::Replace(DocumentReplaceTransitionV0::default().into()),
478            DocumentTransition::Delete(DocumentDeleteTransitionV0::default().into()),
479            DocumentTransition::IndexOnlyDelete(
480                DocumentIndexOnlyDeleteTransitionV0::default().into(),
481            ),
482            DocumentTransition::Transfer(DocumentTransferTransitionV0::default().into()),
483            DocumentTransition::UpdatePrice(DocumentUpdatePriceTransitionV0::default().into()),
484            DocumentTransition::Purchase(DocumentPurchaseTransitionV0::default().into()),
485        ]
486    }
487
488    fn tokens() -> Vec<TokenTransition> {
489        vec![
490            TokenTransition::Burn(Default::default()),
491            TokenTransition::Mint(Default::default()),
492            TokenTransition::Transfer(TokenTransferTransitionV0::default().into()),
493            TokenTransition::Freeze(Default::default()),
494            TokenTransition::Unfreeze(Default::default()),
495            TokenTransition::DestroyFrozenFunds(Default::default()),
496            TokenTransition::Claim(Default::default()),
497            TokenTransition::EmergencyAction(Default::default()),
498            TokenTransition::ConfigUpdate(Default::default()),
499            TokenTransition::DirectPurchase(Default::default()),
500            TokenTransition::SetPriceForDirectPurchase(Default::default()),
501        ]
502    }
503
504    #[test]
505    fn single_contract_bounds_cover_every_operation_on_that_contract_only() {
506        let bounds = ContractBounds::SingleContract {
507            id: Identifier::from([0; 32]),
508        };
509        let foreign = ContractBounds::SingleContract {
510            id: Identifier::from([1; 32]),
511        };
512        for document in documents() {
513            let member = BatchedTransitionRef::Document(&document);
514            assert_eq!(
515                bounds.check_batched_transition(member),
516                BatchedTransitionBoundsCheck::Allowed,
517                "{document:?}"
518            );
519            assert_eq!(
520                foreign.check_batched_transition(member),
521                BatchedTransitionBoundsCheck::Denied,
522                "{document:?}"
523            );
524        }
525        for token in tokens() {
526            let member = BatchedTransitionRef::Token(&token);
527            assert_eq!(
528                bounds.check_batched_transition(member),
529                BatchedTransitionBoundsCheck::Allowed,
530                "{token:?}"
531            );
532            assert_eq!(
533                foreign.check_batched_transition(member),
534                BatchedTransitionBoundsCheck::Denied,
535                "{token:?}"
536            );
537        }
538    }
539
540    #[test]
541    fn document_type_bounds_cover_that_type_only_and_never_tokens() {
542        let bounds = ContractBounds::SingleContractDocumentType {
543            id: Identifier::from([0; 32]),
544            document_type_name: String::new(),
545        };
546        let other_type = ContractBounds::SingleContractDocumentType {
547            id: Identifier::from([0; 32]),
548            document_type_name: "other".to_string(),
549        };
550        for document in documents() {
551            let member = BatchedTransitionRef::Document(&document);
552            assert_eq!(
553                bounds.check_batched_transition(member),
554                BatchedTransitionBoundsCheck::Allowed,
555                "{document:?}"
556            );
557            assert_eq!(
558                other_type.check_batched_transition(member),
559                BatchedTransitionBoundsCheck::Denied,
560                "{document:?}"
561            );
562        }
563        for token in tokens() {
564            let member = BatchedTransitionRef::Token(&token);
565            assert_eq!(
566                bounds.check_batched_transition(member),
567                BatchedTransitionBoundsCheck::Denied,
568                "token operations are contract-wide: {token:?}"
569            );
570        }
571    }
572
573    #[test]
574    fn should_defer_contract_group_bounds_to_state_with_the_member_to_look_up() {
575        let group_id = Identifier::from([7; 32]);
576        let bounds = ContractBounds::ContractGroup { id: group_id };
577        for document in documents() {
578            let member = BatchedTransitionRef::Document(&document);
579            assert_eq!(
580                bounds.check_batched_transition(member),
581                BatchedTransitionBoundsCheck::RequiresContractGroupMembership {
582                    contract_group_id: group_id,
583                    contract_id: Identifier::from([0; 32]),
584                    member: ContractGroupMember::DocumentType(String::new()),
585                },
586                "{document:?}"
587            );
588        }
589        for token in tokens() {
590            let member = BatchedTransitionRef::Token(&token);
591            assert_eq!(
592                bounds.check_batched_transition(member),
593                BatchedTransitionBoundsCheck::RequiresContractGroupMembership {
594                    contract_group_id: group_id,
595                    contract_id: Identifier::from([0; 32]),
596                    member: ContractGroupMember::Token(0),
597                },
598                "{token:?}"
599            );
600        }
601    }
602}