Skip to content

Commit f036c83

Browse files
refactor(sdk): share document transition preparation with embedders
Extracts the document create/replace preparation out of dash-sdk's PutDocument broadcast path into dash-platform-queries: property sanitization for the transition (prepare_document_for_transition) and the entropy/document-id consistency check (ensure_entropy_matches_document_id) that surfaces an id/entropy drift locally instead of after the broadcast has paid a bumped identity-contract nonce. dash-sdk delegates to the shared helpers with unchanged behavior; transport-free embedders (packages/rs-platform-cxx) assemble their own transitions through the same code instead of reimplementing it in C++. Split out of #4389 to keep that PR to its declared decode/builders/verification scope.
1 parent b02a1a2 commit f036c83

3 files changed

Lines changed: 182 additions & 161 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
//! Transport-free state transition helpers.
2+
pub mod put_document;
23
pub mod validation;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
//! Transport-free helpers for document create/replace transitions.
2+
//!
3+
//! `dash-sdk`'s `PutDocument` broadcast path calls these; embedders that
4+
//! assemble their own transitions share the same preparation and
5+
//! entropy/id consistency check.
6+
7+
use crate::Error;
8+
use dpp::data_contract::document_type::methods::DocumentTypeV0Methods;
9+
use dpp::data_contract::document_type::DocumentType;
10+
use dpp::document::{Document, DocumentV0Getters};
11+
use dpp::prelude::Identifier;
12+
13+
/// Returns a copy of `document` with its properties sanitized for the given
14+
/// document type (e.g. integer arrays coerced back into byte arrays after a
15+
/// WASM boundary crossing), leaving the caller's document untouched.
16+
pub fn prepare_document_for_transition(
17+
document: &Document,
18+
document_type: &DocumentType,
19+
) -> Document {
20+
let mut document = document.clone();
21+
document_type
22+
.as_ref()
23+
.sanitize_document_properties(document.properties_mut());
24+
document
25+
}
26+
27+
/// Ensures a caller-supplied `entropy` derives the same document id already set
28+
/// on a create document.
29+
///
30+
/// A document-create state transition carries both the document id and the
31+
/// entropy, and Drive recomputes the id from the entropy during
32+
/// `advanced_structure` validation, rejecting the transition with
33+
/// `InvalidDocumentTransitionIdError` when they disagree. Because the
34+
/// broadcast path trusts the caller's id verbatim when entropy is supplied,
35+
/// a two-phase caller whose id and entropy have drifted would only discover
36+
/// the mismatch after paying (a bumped identity-contract nonce). This check
37+
/// surfaces the mismatch locally before broadcasting.
38+
pub fn ensure_entropy_matches_document_id(
39+
contract_id: &Identifier,
40+
owner_id: &Identifier,
41+
document_type_name: &str,
42+
entropy: &[u8; 32],
43+
document_id: Identifier,
44+
) -> Result<(), Error> {
45+
let expected_id = Document::generate_document_id_v0(
46+
contract_id,
47+
owner_id,
48+
document_type_name,
49+
entropy.as_slice(),
50+
);
51+
if expected_id != document_id {
52+
return Err(Error::InvalidInput(format!(
53+
"document id {document_id} does not match the id {expected_id} derived from the \
54+
supplied entropy; the entropy must be the one used to generate the document id"
55+
)));
56+
}
57+
Ok(())
58+
}
59+
60+
#[cfg(test)]
61+
mod tests {
62+
use super::*;
63+
use dpp::data_contract::config::DataContractConfig;
64+
use dpp::document::{DocumentV0, INITIAL_REVISION};
65+
use dpp::platform_value::{platform_value, Value};
66+
use dpp::version::PlatformVersion;
67+
use std::collections::BTreeMap;
68+
69+
fn contract_id() -> Identifier {
70+
Identifier::from([1u8; 32])
71+
}
72+
73+
fn owner_id() -> Identifier {
74+
Identifier::from([2u8; 32])
75+
}
76+
77+
#[test]
78+
fn matching_entropy_and_id_pass() {
79+
let entropy = [7u8; 32];
80+
let id = Document::generate_document_id_v0(
81+
&contract_id(),
82+
&owner_id(),
83+
"contactRequest",
84+
entropy.as_slice(),
85+
);
86+
87+
ensure_entropy_matches_document_id(
88+
&contract_id(),
89+
&owner_id(),
90+
"contactRequest",
91+
&entropy,
92+
id,
93+
)
94+
.expect("id derived from the supplied entropy must be accepted");
95+
}
96+
97+
#[test]
98+
fn mismatched_entropy_and_id_error_before_broadcast() {
99+
// The id was derived from E1, but the caller passes E2 != E1 (mirroring
100+
// the very drift consensus rejects with InvalidDocumentTransitionIdError).
101+
let entropy_used = [1u8; 32];
102+
let id = Document::generate_document_id_v0(
103+
&contract_id(),
104+
&owner_id(),
105+
"contactRequest",
106+
entropy_used.as_slice(),
107+
);
108+
109+
let different_entropy = [2u8; 32];
110+
let result = ensure_entropy_matches_document_id(
111+
&contract_id(),
112+
&owner_id(),
113+
"contactRequest",
114+
&different_entropy,
115+
id,
116+
);
117+
118+
assert!(
119+
matches!(result, Err(Error::InvalidInput(_))),
120+
"a document id derived from a different entropy must be rejected locally"
121+
);
122+
}
123+
124+
#[test]
125+
fn should_normalize_wasm_uint8_array_property_without_mutating_caller_document() {
126+
let platform_version = PlatformVersion::latest();
127+
let config = DataContractConfig::default_for_version(platform_version)
128+
.expect("should create default data contract config");
129+
let document_type = DocumentType::try_from_schema(
130+
contract_id(),
131+
1,
132+
config.version(),
133+
"preorder",
134+
platform_value!({
135+
"type": "object",
136+
"properties": {
137+
"saltedDomainHash": {
138+
"type": "array",
139+
"byteArray": true,
140+
"minItems": 32_u32,
141+
"maxItems": 32_u32,
142+
"position": 0
143+
}
144+
},
145+
"required": ["saltedDomainHash"],
146+
"additionalProperties": false,
147+
}),
148+
None,
149+
&BTreeMap::new(),
150+
&config,
151+
false,
152+
&mut Vec::new(),
153+
platform_version,
154+
)
155+
.expect("should create DPNS-like document type");
156+
let integer_array = Value::Array(vec![Value::U64(7); 32]);
157+
let document = Document::V0(DocumentV0 {
158+
id: Identifier::new([3; 32]),
159+
owner_id: owner_id(),
160+
properties: BTreeMap::from([("saltedDomainHash".to_string(), integer_array.clone())]),
161+
revision: Some(INITIAL_REVISION),
162+
..Default::default()
163+
});
164+
165+
let prepared = prepare_document_for_transition(&document, &document_type);
166+
167+
assert_eq!(
168+
prepared.properties().get("saltedDomainHash"),
169+
Some(&Value::Bytes32([7; 32]))
170+
);
171+
assert_eq!(
172+
document.properties().get("saltedDomainHash"),
173+
Some(&integer_array)
174+
);
175+
}
176+
}

packages/rs-sdk/src/platform/transition/put_document.rs

Lines changed: 5 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ use super::validation::ensure_valid_state_transition_structure;
33
use super::waitable::Waitable;
44
use crate::platform::transition::put_settings::PutSettings;
55
use crate::{Error, Sdk};
6+
// Transport-free helpers shared with embedders; the implementations moved to
7+
// `dash-platform-queries`.
8+
pub use dash_platform_queries::transition::put_document::{
9+
ensure_entropy_matches_document_id, prepare_document_for_transition,
10+
};
611
use dpp::dashcore::secp256k1::rand::rngs::StdRng;
712
use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng};
813
use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
9-
use dpp::data_contract::document_type::methods::DocumentTypeV0Methods;
1014
use dpp::data_contract::document_type::DocumentType;
1115
use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION};
1216
use dpp::identity::signer::Signer;
1317
use dpp::identity::IdentityPublicKey;
14-
use dpp::prelude::Identifier;
1518
use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0;
1619
use dpp::state_transition::batch_transition::BatchTransition;
1720
use dpp::state_transition::StateTransition;
@@ -162,162 +165,3 @@ impl<S: Signer<IdentityPublicKey>> PutDocument<S> for Document {
162165
Self::wait_for_response(sdk, state_transition, settings).await
163166
}
164167
}
165-
166-
fn prepare_document_for_transition(document: &Document, document_type: &DocumentType) -> Document {
167-
let mut document = document.clone();
168-
document_type
169-
.as_ref()
170-
.sanitize_document_properties(document.properties_mut());
171-
document
172-
}
173-
174-
/// Ensures a caller-supplied `entropy` derives the same document id already set
175-
/// on a create document.
176-
///
177-
/// A document-create state transition carries both the document id and the
178-
/// entropy, and Drive recomputes the id from the entropy during
179-
/// `advanced_structure` validation, rejecting the transition with
180-
/// `InvalidDocumentTransitionIdError` when they disagree. Because
181-
/// [`PutDocument::put_to_platform`] trusts the caller's id verbatim in the
182-
/// `Some(entropy)` arm, a two-phase caller whose id and entropy have drifted
183-
/// would only discover the mismatch after paying (a bumped identity-contract
184-
/// nonce). This check surfaces the mismatch locally before broadcasting.
185-
fn ensure_entropy_matches_document_id(
186-
contract_id: &Identifier,
187-
owner_id: &Identifier,
188-
document_type_name: &str,
189-
entropy: &[u8; 32],
190-
document_id: Identifier,
191-
) -> Result<(), Error> {
192-
let expected_id = Document::generate_document_id_v0(
193-
contract_id,
194-
owner_id,
195-
document_type_name,
196-
entropy.as_slice(),
197-
);
198-
if expected_id != document_id {
199-
return Err(Error::Generic(format!(
200-
"document id {document_id} does not match the id {expected_id} derived from the \
201-
supplied entropy; the entropy must be the one used to generate the document id"
202-
)));
203-
}
204-
Ok(())
205-
}
206-
207-
#[cfg(test)]
208-
mod tests {
209-
use super::*;
210-
use dpp::data_contract::config::DataContractConfig;
211-
use dpp::document::DocumentV0;
212-
use dpp::platform_value::{platform_value, Value};
213-
use dpp::version::PlatformVersion;
214-
use std::collections::BTreeMap;
215-
216-
fn contract_id() -> Identifier {
217-
Identifier::from([1u8; 32])
218-
}
219-
220-
fn owner_id() -> Identifier {
221-
Identifier::from([2u8; 32])
222-
}
223-
224-
#[test]
225-
fn matching_entropy_and_id_pass() {
226-
let entropy = [7u8; 32];
227-
let id = Document::generate_document_id_v0(
228-
&contract_id(),
229-
&owner_id(),
230-
"contactRequest",
231-
entropy.as_slice(),
232-
);
233-
234-
ensure_entropy_matches_document_id(
235-
&contract_id(),
236-
&owner_id(),
237-
"contactRequest",
238-
&entropy,
239-
id,
240-
)
241-
.expect("id derived from the supplied entropy must be accepted");
242-
}
243-
244-
#[test]
245-
fn mismatched_entropy_and_id_error_before_broadcast() {
246-
// The id was derived from E1, but the caller passes E2 != E1 (mirroring
247-
// the very drift consensus rejects with InvalidDocumentTransitionIdError).
248-
let entropy_used = [1u8; 32];
249-
let id = Document::generate_document_id_v0(
250-
&contract_id(),
251-
&owner_id(),
252-
"contactRequest",
253-
entropy_used.as_slice(),
254-
);
255-
256-
let different_entropy = [2u8; 32];
257-
let result = ensure_entropy_matches_document_id(
258-
&contract_id(),
259-
&owner_id(),
260-
"contactRequest",
261-
&different_entropy,
262-
id,
263-
);
264-
265-
assert!(
266-
matches!(result, Err(Error::Generic(_))),
267-
"a document id derived from a different entropy must be rejected locally"
268-
);
269-
}
270-
271-
#[test]
272-
fn should_normalize_wasm_uint8_array_property_without_mutating_caller_document() {
273-
let platform_version = PlatformVersion::latest();
274-
let config = DataContractConfig::default_for_version(platform_version)
275-
.expect("should create default data contract config");
276-
let document_type = DocumentType::try_from_schema(
277-
contract_id(),
278-
1,
279-
config.version(),
280-
"preorder",
281-
platform_value!({
282-
"type": "object",
283-
"properties": {
284-
"saltedDomainHash": {
285-
"type": "array",
286-
"byteArray": true,
287-
"minItems": 32_u32,
288-
"maxItems": 32_u32,
289-
"position": 0
290-
}
291-
},
292-
"required": ["saltedDomainHash"],
293-
"additionalProperties": false,
294-
}),
295-
None,
296-
&BTreeMap::new(),
297-
&config,
298-
false,
299-
&mut Vec::new(),
300-
platform_version,
301-
)
302-
.expect("should create DPNS-like document type");
303-
let integer_array = Value::Array(vec![Value::U64(7); 32]);
304-
let document = Document::V0(DocumentV0 {
305-
id: Identifier::new([3; 32]),
306-
owner_id: owner_id(),
307-
properties: BTreeMap::from([("saltedDomainHash".to_string(), integer_array.clone())]),
308-
revision: Some(INITIAL_REVISION),
309-
..Default::default()
310-
});
311-
312-
let prepared = prepare_document_for_transition(&document, &document_type);
313-
314-
assert_eq!(
315-
prepared.properties().get("saltedDomainHash"),
316-
Some(&Value::Bytes32([7; 32]))
317-
);
318-
assert_eq!(
319-
document.properties().get("saltedDomainHash"),
320-
Some(&integer_array)
321-
);
322-
}
323-
}

0 commit comments

Comments
 (0)