Skip to main content

cow_sdk_app_data/
info.rs

1use std::ops::Deref;
2
3use alloy_primitives::keccak256;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::{AppDataDoc, AppDataError, AppDataInfo, app_data_hex_to_cid};
8
9/// Client-side size ceiling for stringified app-data documents.
10///
11/// Matches the upstream orderbook's 8 KB app-data limit. Surfaces the limit as
12/// a typed [`AppDataError::TooLarge`] at the client boundary instead of
13/// waiting for the orderbook's 422 response.
14pub const APP_DATA_MAX_BYTES: usize = 8192;
15
16/// Fraction of [`APP_DATA_MAX_BYTES`] at which a typed
17/// [`AppDataWarning::ApproachingSizeLimit`] is emitted.
18///
19/// A stringified deterministic payload whose byte size reaches or exceeds
20/// this fraction of the configured ceiling surfaces a soft warning so
21/// callers can react before the hard [`AppDataError::TooLarge`] path fires.
22pub const APP_DATA_APPROACHING_LIMIT_RATIO: f64 = 0.75;
23
24/// Successful outcome of [`app_data_info`], pairing the canonical
25/// [`AppDataInfo`] result with typed validation metadata.
26///
27/// `AppDataValidated` implements [`Deref`] with
28/// [`AppDataInfo`] as its target so every existing field access via dot
29/// notation (for example `validated.app_data_hex`) continues to compile
30/// without code change. Destructure `validated.info` when moving the
31/// underlying [`AppDataInfo`] out of the wrapper.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[non_exhaustive]
34pub struct AppDataValidated {
35    /// Canonical [`AppDataInfo`] for the validated document.
36    pub info: AppDataInfo,
37    /// Validation metadata captured alongside the canonical result.
38    pub validation: AppDataValidation,
39}
40
41impl AppDataValidated {
42    /// Creates a validated app-data result from canonical info and validation metadata.
43    #[must_use]
44    pub const fn new(info: AppDataInfo, validation: AppDataValidation) -> Self {
45        Self { info, validation }
46    }
47}
48
49impl Deref for AppDataValidated {
50    type Target = AppDataInfo;
51
52    fn deref(&self) -> &Self::Target {
53        &self.info
54    }
55}
56
57/// Validation metadata captured alongside a successful [`AppDataValidated`]
58/// result.
59///
60/// The struct is `#[non_exhaustive]` so future additions to the validation
61/// surface may be introduced as a minor change without breaking downstream
62/// exhaustive matches.
63#[non_exhaustive]
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct AppDataValidation {
66    /// Byte size of the stringified deterministic payload — the same value
67    /// the reviewed services validator measures.
68    pub bytes_used: usize,
69    /// Soft-warning channel carrying non-fatal observations about the
70    /// validated document. Hard errors remain on the
71    /// [`AppDataError`] path.
72    pub warnings: Vec<AppDataWarning>,
73}
74
75/// Non-fatal observation emitted alongside a successful
76/// [`AppDataValidated`] result.
77///
78/// The enum is `#[non_exhaustive]` so future soft-warning variants may be
79/// introduced as a minor change without breaking downstream exhaustive
80/// matches. Hard errors — unknown keys, schema violations, and oversized
81/// payloads — stay on the [`AppDataError`] path and
82/// are never demoted to warnings.
83#[non_exhaustive]
84#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
85#[serde(tag = "kind", rename_all = "camelCase")]
86pub enum AppDataWarning {
87    /// The stringified deterministic payload reached the configured
88    /// near-limit fraction of [`APP_DATA_MAX_BYTES`]. The payload is still
89    /// within the hard ceiling; callers that want headroom for subsequent
90    /// edits may want to trim metadata before sealing the document.
91    #[serde(rename_all = "camelCase")]
92    ApproachingSizeLimit {
93        /// Byte size of the stringified deterministic payload.
94        bytes_used: usize,
95        /// Configured hard ceiling for stringified app-data documents.
96        max_bytes: usize,
97    },
98}
99
100/// Source abstraction for app-data generation helpers.
101pub trait AppDataSource {
102    /// Converts the source into a parsed document plus the serialized content string.
103    ///
104    /// When `deterministic` is true, implementations should use canonical key ordering.
105    ///
106    /// # Errors
107    ///
108    /// Returns an error when the source cannot be parsed or serialized into a valid app-data
109    /// document.
110    fn into_document_and_content(
111        self,
112        deterministic: bool,
113    ) -> Result<(AppDataDoc, String), AppDataError>;
114}
115
116impl AppDataSource for &AppDataDoc {
117    fn into_document_and_content(
118        self,
119        deterministic: bool,
120    ) -> Result<(AppDataDoc, String), AppDataError> {
121        let content = if deterministic {
122            stringify_deterministic(self)?
123        } else {
124            serde_json::to_string(self).map_err(AppDataError::from)?
125        };
126        Ok((self.clone(), content))
127    }
128}
129
130impl AppDataSource for AppDataDoc {
131    fn into_document_and_content(
132        self,
133        deterministic: bool,
134    ) -> Result<(AppDataDoc, String), AppDataError> {
135        let content = if deterministic {
136            stringify_deterministic(&self)?
137        } else {
138            serde_json::to_string(&self).map_err(AppDataError::from)?
139        };
140        Ok((self, content))
141    }
142}
143
144impl AppDataSource for &str {
145    fn into_document_and_content(
146        self,
147        _deterministic: bool,
148    ) -> Result<(AppDataDoc, String), AppDataError> {
149        let document: Value = serde_json::from_str(self).map_err(AppDataError::from)?;
150        Ok((document, self.to_string()))
151    }
152}
153
154impl AppDataSource for String {
155    fn into_document_and_content(
156        self,
157        _deterministic: bool,
158    ) -> Result<(AppDataDoc, String), AppDataError> {
159        let document: Value = serde_json::from_str(&self).map_err(AppDataError::from)?;
160        Ok((document, self))
161    }
162}
163
164/// Returns the canonical [`AppDataInfo`] plus the typed
165/// [`AppDataValidation`] metadata for the supplied app-data source.
166///
167/// On the success path the wrapper carries the deterministic payload size
168/// in `validation.bytes_used` and an ordered soft-warning channel in
169/// `validation.warnings`. A stringified payload whose byte size reaches or
170/// exceeds [`APP_DATA_APPROACHING_LIMIT_RATIO`] of [`APP_DATA_MAX_BYTES`]
171/// emits an [`AppDataWarning::ApproachingSizeLimit`]; the hard
172/// [`AppDataError::TooLarge`] path continues to fire at the configured
173/// ceiling and the wrapper is never constructed on the error path.
174///
175/// # Errors
176///
177/// Returns [`AppDataError`] if the source cannot be parsed, the document
178/// fails the single [`crate::validate_app_data_doc`] pass this function
179/// runs internally, the stringified payload exceeds [`APP_DATA_MAX_BYTES`],
180/// or CID conversion fails.
181pub fn app_data_info(source: impl AppDataSource) -> Result<AppDataValidated, AppDataError> {
182    let (document, app_data_content) = source.into_document_and_content(true)?;
183    ensure_document_under_size_limit(&app_data_content, APP_DATA_MAX_BYTES)?;
184    // The one validation pass for this document: the same
185    // `validate_app_data_doc` check callers can run standalone, so deriving
186    // the digest never re-validates what a separate call already proved.
187    crate::schema::validate_app_data_doc(&document)?;
188
189    let bytes_used = app_data_content.len();
190    let digest = keccak256(app_data_content.as_bytes());
191    let app_data_hex = alloy_primitives::hex::encode_prefixed(digest);
192    let cid = app_data_hex_to_cid(&app_data_hex)?;
193
194    let info = AppDataInfo {
195        cid,
196        app_data_content,
197        app_data_hex,
198    };
199
200    let mut warnings = Vec::new();
201    if approaching_size_limit(bytes_used, APP_DATA_MAX_BYTES) {
202        warnings.push(AppDataWarning::ApproachingSizeLimit {
203            bytes_used,
204            max_bytes: APP_DATA_MAX_BYTES,
205        });
206    }
207
208    Ok(AppDataValidated {
209        info,
210        validation: AppDataValidation {
211            bytes_used,
212            warnings,
213        },
214    })
215}
216
217fn approaching_size_limit(bytes_used: usize, max_bytes: usize) -> bool {
218    #[allow(
219        clippy::cast_precision_loss,
220        clippy::cast_possible_truncation,
221        clippy::cast_sign_loss,
222        reason = "byte-size and ratio multiplication produces values that fit back inside usize with the floor the test contract requires"
223    )]
224    let threshold = (max_bytes as f64 * APP_DATA_APPROACHING_LIMIT_RATIO) as usize;
225    bytes_used >= threshold
226}
227
228/// Serializes an app-data document as RFC 8785 canonical JSON via
229/// [`serde_jcs::to_string`].
230///
231/// The output applies UTF-16 code-unit key ordering, decimal-only number
232/// serialisation, and the canonical insignificant-whitespace rules
233/// specified by RFC 8785 (JSON Canonicalization Scheme). ASCII-only
234/// documents serialise byte-identically to the previous bytewise key
235/// ordering; documents whose object keys carry characters whose UTF-16
236/// representation diverges from their UTF-8 byte ordering now match the
237/// canonical RFC 8785 form, closing a latent gap with the upstream
238/// `@cowprotocol/cow-sdk` TypeScript implementation.
239///
240/// # Errors
241///
242/// Returns [`AppDataError::Serialization`] if the canonicalisation pass fails.
243pub fn stringify_deterministic(value: &AppDataDoc) -> Result<String, AppDataError> {
244    serde_jcs::to_string(value).map_err(AppDataError::from)
245}
246
247const fn ensure_document_under_size_limit(
248    content: &str,
249    max_bytes: usize,
250) -> Result<(), AppDataError> {
251    let actual = content.len();
252    if actual > max_bytes {
253        return Err(AppDataError::TooLarge {
254            actual_bytes: actual,
255            max_bytes,
256        });
257    }
258    Ok(())
259}
260
261/// Returns only the app-data hex digest.
262///
263/// # Errors
264///
265/// Returns any error from [`app_data_info`].
266pub fn app_data_info_hex(source: impl AppDataSource) -> Result<String, AppDataError> {
267    Ok(app_data_info(source)?.info.app_data_hex)
268}
269
270/// Returns only the CID derived from the app-data content.
271///
272/// # Errors
273///
274/// Returns any error from [`app_data_info`].
275pub fn app_data_cid(source: impl AppDataSource) -> Result<String, AppDataError> {
276    Ok(app_data_info(source)?.info.cid)
277}
278
279/// Returns only the serialized app-data content.
280///
281/// # Errors
282///
283/// Returns any error from [`app_data_info`].
284pub fn app_data_content(source: impl AppDataSource) -> Result<String, AppDataError> {
285    Ok(app_data_info(source)?.info.app_data_content)
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use serde_json::{Value, json};
292
293    #[test]
294    fn stringify_deterministic_orders_keys_without_corrupting_arrays() {
295        let document = json!({
296            "version": "0.7.0",
297            "metadata": {
298                "nested": {
299                    "b": 2,
300                    "a": 1
301                },
302                "array": [3, 2, 1]
303            },
304            "appCode": "CoW Swap"
305        });
306
307        assert_eq!(
308            stringify_deterministic(&document).unwrap(),
309            r#"{"appCode":"CoW Swap","metadata":{"array":[3,2,1],"nested":{"a":1,"b":2}},"version":"0.7.0"}"#
310        );
311    }
312
313    #[test]
314    fn string_sources_preserve_the_original_content_and_document_shape() {
315        let raw = r#"{"metadata":{"b":2,"a":1},"version":"0.7.0","appCode":"CoW Swap"}"#;
316        let expected = serde_json::from_str::<Value>(raw).unwrap();
317
318        let (borrowed_document, borrowed_content) =
319            <&str as AppDataSource>::into_document_and_content(raw, false).unwrap();
320        assert_eq!(borrowed_document, expected);
321        assert_eq!(borrowed_content, raw);
322
323        let owned = raw.to_owned();
324        let (owned_document, owned_content) =
325            owned.clone().into_document_and_content(false).unwrap();
326        assert_eq!(owned_document, expected);
327        assert_eq!(owned_content, owned);
328    }
329
330    #[test]
331    fn accessors_match_the_primary_app_data_info_result() {
332        let document = json!({
333            "appCode": "CoW Swap",
334            "metadata": {
335                "quote": {
336                    "version": "0.2.0",
337                    "slippageBips": "5"
338                }
339            },
340            "version": "0.7.0"
341        });
342
343        let info = app_data_info(&document).unwrap();
344
345        assert_eq!(app_data_info_hex(&document).unwrap(), info.app_data_hex);
346        assert_eq!(app_data_cid(&document).unwrap(), info.cid);
347        assert_eq!(app_data_content(&document).unwrap(), info.app_data_content);
348    }
349}