use std::ops::Deref;
use alloy_primitives::keccak256;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{AppDataDoc, AppDataError, AppDataInfo, app_data_hex_to_cid};
pub const APP_DATA_MAX_BYTES: usize = 8192;
pub const APP_DATA_APPROACHING_LIMIT_RATIO: f64 = 0.75;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AppDataValidated {
pub info: AppDataInfo,
pub validation: AppDataValidation,
}
impl AppDataValidated {
#[must_use]
pub const fn new(info: AppDataInfo, validation: AppDataValidation) -> Self {
Self { info, validation }
}
}
impl Deref for AppDataValidated {
type Target = AppDataInfo;
fn deref(&self) -> &Self::Target {
&self.info
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppDataValidation {
pub bytes_used: usize,
pub warnings: Vec<AppDataWarning>,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum AppDataWarning {
#[serde(rename_all = "camelCase")]
ApproachingSizeLimit {
bytes_used: usize,
max_bytes: usize,
},
}
pub trait AppDataSource {
fn into_document_and_content(
self,
deterministic: bool,
) -> Result<(AppDataDoc, String), AppDataError>;
}
impl AppDataSource for &AppDataDoc {
fn into_document_and_content(
self,
deterministic: bool,
) -> Result<(AppDataDoc, String), AppDataError> {
let content = if deterministic {
stringify_deterministic(self)?
} else {
serde_json::to_string(self).map_err(AppDataError::from)?
};
Ok((self.clone(), content))
}
}
impl AppDataSource for AppDataDoc {
fn into_document_and_content(
self,
deterministic: bool,
) -> Result<(AppDataDoc, String), AppDataError> {
let content = if deterministic {
stringify_deterministic(&self)?
} else {
serde_json::to_string(&self).map_err(AppDataError::from)?
};
Ok((self, content))
}
}
impl AppDataSource for &str {
fn into_document_and_content(
self,
_deterministic: bool,
) -> Result<(AppDataDoc, String), AppDataError> {
let document: Value = serde_json::from_str(self).map_err(AppDataError::from)?;
Ok((document, self.to_string()))
}
}
impl AppDataSource for String {
fn into_document_and_content(
self,
_deterministic: bool,
) -> Result<(AppDataDoc, String), AppDataError> {
let document: Value = serde_json::from_str(&self).map_err(AppDataError::from)?;
Ok((document, self))
}
}
pub fn app_data_info(source: impl AppDataSource) -> Result<AppDataValidated, AppDataError> {
let (document, app_data_content) = source.into_document_and_content(true)?;
ensure_document_under_size_limit(&app_data_content, APP_DATA_MAX_BYTES)?;
crate::schema::validate_app_data_doc(&document)?;
let bytes_used = app_data_content.len();
let digest = keccak256(app_data_content.as_bytes());
let app_data_hex = alloy_primitives::hex::encode_prefixed(digest);
let cid = app_data_hex_to_cid(&app_data_hex)?;
let info = AppDataInfo {
cid,
app_data_content,
app_data_hex,
};
let mut warnings = Vec::new();
if approaching_size_limit(bytes_used, APP_DATA_MAX_BYTES) {
warnings.push(AppDataWarning::ApproachingSizeLimit {
bytes_used,
max_bytes: APP_DATA_MAX_BYTES,
});
}
Ok(AppDataValidated {
info,
validation: AppDataValidation {
bytes_used,
warnings,
},
})
}
fn approaching_size_limit(bytes_used: usize, max_bytes: usize) -> bool {
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "byte-size and ratio multiplication produces values that fit back inside usize with the floor the test contract requires"
)]
let threshold = (max_bytes as f64 * APP_DATA_APPROACHING_LIMIT_RATIO) as usize;
bytes_used >= threshold
}
pub fn stringify_deterministic(value: &AppDataDoc) -> Result<String, AppDataError> {
serde_jcs::to_string(value).map_err(AppDataError::from)
}
const fn ensure_document_under_size_limit(
content: &str,
max_bytes: usize,
) -> Result<(), AppDataError> {
let actual = content.len();
if actual > max_bytes {
return Err(AppDataError::TooLarge {
actual_bytes: actual,
max_bytes,
});
}
Ok(())
}
pub fn app_data_info_hex(source: impl AppDataSource) -> Result<String, AppDataError> {
Ok(app_data_info(source)?.info.app_data_hex)
}
pub fn app_data_cid(source: impl AppDataSource) -> Result<String, AppDataError> {
Ok(app_data_info(source)?.info.cid)
}
pub fn app_data_content(source: impl AppDataSource) -> Result<String, AppDataError> {
Ok(app_data_info(source)?.info.app_data_content)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
#[test]
fn stringify_deterministic_orders_keys_without_corrupting_arrays() {
let document = json!({
"version": "0.7.0",
"metadata": {
"nested": {
"b": 2,
"a": 1
},
"array": [3, 2, 1]
},
"appCode": "CoW Swap"
});
assert_eq!(
stringify_deterministic(&document).unwrap(),
r#"{"appCode":"CoW Swap","metadata":{"array":[3,2,1],"nested":{"a":1,"b":2}},"version":"0.7.0"}"#
);
}
#[test]
fn string_sources_preserve_the_original_content_and_document_shape() {
let raw = r#"{"metadata":{"b":2,"a":1},"version":"0.7.0","appCode":"CoW Swap"}"#;
let expected = serde_json::from_str::<Value>(raw).unwrap();
let (borrowed_document, borrowed_content) =
<&str as AppDataSource>::into_document_and_content(raw, false).unwrap();
assert_eq!(borrowed_document, expected);
assert_eq!(borrowed_content, raw);
let owned = raw.to_owned();
let (owned_document, owned_content) =
owned.clone().into_document_and_content(false).unwrap();
assert_eq!(owned_document, expected);
assert_eq!(owned_content, owned);
}
#[test]
fn accessors_match_the_primary_app_data_info_result() {
let document = json!({
"appCode": "CoW Swap",
"metadata": {
"quote": {
"version": "0.2.0",
"slippageBips": "5"
}
},
"version": "0.7.0"
});
let info = app_data_info(&document).unwrap();
assert_eq!(app_data_info_hex(&document).unwrap(), info.app_data_hex);
assert_eq!(app_data_cid(&document).unwrap(), info.cid);
assert_eq!(app_data_content(&document).unwrap(), info.app_data_content);
}
}