use alloy_primitives::{Address, B256, Bytes, U256, b256, keccak256};
use cid::multihash::Multihash;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use serde_with::{DisplayFromStr, serde_as};
use crate::order::{OrderClass, OrderUid};
pub type AppDataHash = B256;
pub const EMPTY_APP_DATA_HASH: AppDataHash =
b256!("b48d38f93eaa084033fc5970bf96e559c33c4cdc07d889ab00b4d63f9590739d");
pub const EMPTY_APP_DATA_JSON: &str = "{}";
pub const LATEST_APP_DATA_VERSION: &str = "1.6.0";
pub const COW_RS_APP_CODE: &str = "cow-rs";
pub const COW_RS_WASM_APP_CODE: &str = "cow-rs-wasm";
pub const APP_DATA_SIZE_LIMIT: usize = 8192;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataDoc {
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(default)]
pub metadata: AppDataMetadata,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quote: Option<AppDataQuote>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub order_class: Option<AppDataOrderClass>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partner_fee: Option<AppDataPartnerFee>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub referrer: Option<AppDataReferrer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub utm: Option<AppDataUtm>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hooks: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flashloan: Option<AppDataFlashloan>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replaced_order: Option<AppDataReplacedOrder>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub wrappers: Vec<AppDataWrapperCall>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataQuote {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slippage_bips: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataOrderClass {
pub order_class: crate::order::OrderClass,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AppDataPartnerFee {
pub policy: FeePolicy,
pub recipient: Address,
}
impl Serialize for AppDataPartnerFee {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
use serde::ser::SerializeMap as _;
let entry_count = match self.policy {
FeePolicy::Volume { .. } => 2,
FeePolicy::Surplus { .. } | FeePolicy::PriceImprovement { .. } => 3,
};
let mut map = serializer.serialize_map(Some(entry_count))?;
match self.policy {
FeePolicy::Volume { bps } => {
map.serialize_entry("bps", &bps)?;
}
FeePolicy::Surplus {
bps,
max_volume_bps,
} => {
map.serialize_entry("surplusBps", &bps)?;
map.serialize_entry("maxVolumeBps", &max_volume_bps)?;
}
FeePolicy::PriceImprovement {
bps,
max_volume_bps,
} => {
map.serialize_entry("priceImprovementBps", &bps)?;
map.serialize_entry("maxVolumeBps", &max_volume_bps)?;
}
}
map.serialize_entry("recipient", &self.recipient)?;
map.end()
}
}
impl<'de> Deserialize<'de> for AppDataPartnerFee {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Helper {
recipient: Address,
#[serde(default)]
bps: Option<u64>,
#[serde(default)]
volume_bps: Option<u64>,
#[serde(default)]
surplus_bps: Option<u64>,
#[serde(default)]
price_improvement_bps: Option<u64>,
#[serde(default)]
max_volume_bps: Option<u64>,
}
let h = Helper::deserialize(deserializer)?;
let policy = match h {
Helper {
surplus_bps: Some(bps),
max_volume_bps: Some(max_volume_bps),
price_improvement_bps: None,
volume_bps: None,
bps: None,
..
} => FeePolicy::Surplus {
bps,
max_volume_bps,
},
Helper {
surplus_bps: None,
max_volume_bps: Some(max_volume_bps),
price_improvement_bps: Some(bps),
volume_bps: None,
bps: None,
..
} => FeePolicy::PriceImprovement {
bps,
max_volume_bps,
},
Helper {
surplus_bps: None,
max_volume_bps: None,
price_improvement_bps: None,
volume_bps: Some(bps),
bps: None,
..
}
| Helper {
surplus_bps: None,
max_volume_bps: None,
price_improvement_bps: None,
volume_bps: None,
bps: Some(bps),
..
} => FeePolicy::Volume { bps },
_ => {
return Err(D::Error::custom("unknown partner-fee policy shape"));
}
};
validate_fee_policy(&policy).map_err(D::Error::custom)?;
Ok(Self {
policy,
recipient: h.recipient,
})
}
}
pub fn validate_fee_policy(policy: &FeePolicy) -> Result<(), AppDataError> {
let check = |field: &'static str, value: u64| -> Result<(), AppDataError> {
if value > PARTNER_FEE_BPS_MAX {
Err(AppDataError::FeeOutOfRange {
field,
value,
max: PARTNER_FEE_BPS_MAX,
})
} else {
Ok(())
}
};
match *policy {
FeePolicy::Volume { bps } => check("bps", bps),
FeePolicy::Surplus {
bps,
max_volume_bps,
} => {
check("surplusBps", bps)?;
check("maxVolumeBps", max_volume_bps)
}
FeePolicy::PriceImprovement {
bps,
max_volume_bps,
} => {
check("priceImprovementBps", bps)?;
check("maxVolumeBps", max_volume_bps)
}
}
}
impl AppDataPartnerFee {
pub fn new(policy: FeePolicy, recipient: Address) -> Result<Self, AppDataError> {
validate_fee_policy(&policy)?;
Ok(Self { policy, recipient })
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FeePolicy {
Volume {
bps: u64,
},
Surplus {
bps: u64,
max_volume_bps: u64,
},
PriceImprovement {
bps: u64,
max_volume_bps: u64,
},
}
#[serde_as]
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataFlashloan {
pub liquidity_provider: Address,
pub protocol_adapter: Address,
pub receiver: Address,
pub token: Address,
#[serde_as(as = "DisplayFromStr")]
pub amount: U256,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct AppDataReplacedOrder {
pub uid: OrderUid,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataWrapperCall {
pub address: Address,
pub data: Bytes,
#[serde(default)]
pub is_omittable: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataReferrer {
pub address: Address,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataUtm {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub utm_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub utm_medium: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub utm_campaign: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub utm_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub utm_term: Option<String>,
}
pub type AppDataHooks = serde_json::Value;
impl AppDataDoc {
pub fn new(app_code: impl Into<String>) -> Self {
Self {
version: LATEST_APP_DATA_VERSION.to_string(),
app_code: Some(app_code.into()),
environment: None,
metadata: AppDataMetadata::default(),
}
}
pub fn sdk_attribution(app_code: &str) -> Self {
Self {
version: LATEST_APP_DATA_VERSION.to_string(),
app_code: Some(app_code.to_string()),
environment: None,
metadata: AppDataMetadata {
quote: Some(AppDataQuote {
slippage_bips: None,
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}),
..AppDataMetadata::default()
},
}
}
pub fn with_referrer(mut self, address: Address) -> Self {
self.metadata.referrer = Some(AppDataReferrer {
address,
version: None,
});
self
}
pub fn with_partner_fee(mut self, bps: u32, recipient: Address) -> Result<Self, AppDataError> {
let policy = FeePolicy::Volume { bps: bps as u64 };
validate_fee_policy(&policy)?;
self.metadata.partner_fee = Some(AppDataPartnerFee { policy, recipient });
Ok(self)
}
pub fn with_partner_fee_policy(
mut self,
policy: FeePolicy,
recipient: Address,
) -> Result<Self, AppDataError> {
validate_fee_policy(&policy)?;
self.metadata.partner_fee = Some(AppDataPartnerFee { policy, recipient });
Ok(self)
}
pub const fn with_flashloan(mut self, flashloan: AppDataFlashloan) -> Self {
self.metadata.flashloan = Some(flashloan);
self
}
pub const fn with_replaced_order(mut self, uid: OrderUid) -> Self {
self.metadata.replaced_order = Some(AppDataReplacedOrder { uid });
self
}
pub fn with_wrapper(mut self, wrapper: AppDataWrapperCall) -> Self {
self.metadata.wrappers.push(wrapper);
self
}
pub const fn with_order_class(mut self, order_class: OrderClass) -> Self {
self.metadata.order_class = Some(AppDataOrderClass { order_class });
self
}
pub fn with_slippage_bips(mut self, slippage_bips: u32) -> Self {
self.metadata
.quote
.get_or_insert_with(AppDataQuote::default)
.slippage_bips = Some(slippage_bips);
self
}
pub fn with_environment(mut self, environment: impl Into<String>) -> Self {
self.environment = Some(environment.into());
self
}
pub fn canonical_json(&self) -> String {
let value = serde_json::to_value(self).expect("AppDataDoc must serialise");
let sorted = sort_value(value);
serde_json::to_string(&sorted).expect("Value must re-serialise")
}
pub fn hash(&self) -> AppDataHash {
self.try_hash()
.expect("AppDataDoc must fit within APP_DATA_SIZE_LIMIT")
}
pub fn try_hash(&self) -> Result<AppDataHash, AppDataError> {
let json = self.canonical_json();
if json.len() > APP_DATA_SIZE_LIMIT {
return Err(AppDataError::DocumentTooLarge {
len: json.len(),
max: APP_DATA_SIZE_LIMIT,
});
}
Ok(keccak256(json.as_bytes()))
}
}
impl std::str::FromStr for AppDataDoc {
type Err = AppDataError;
fn from_str(json: &str) -> Result<Self, Self::Err> {
if json.len() > APP_DATA_SIZE_LIMIT {
return Err(AppDataError::DocumentTooLarge {
len: json.len(),
max: APP_DATA_SIZE_LIMIT,
});
}
serde_json::from_str(json).map_err(|e| AppDataError::Parse(e.to_string()))
}
}
impl TryFrom<&str> for AppDataDoc {
type Error = AppDataError;
fn try_from(json: &str) -> Result<Self, Self::Error> {
json.parse()
}
}
#[derive(Debug, thiserror::Error, Eq, PartialEq)]
pub enum AppDataError {
#[error("app-data document too large: {len} bytes (max {max})")]
DocumentTooLarge {
len: usize,
max: usize,
},
#[error("partner fee {field} = {value} exceeds maximum {max}")]
FeeOutOfRange {
field: &'static str,
value: u64,
max: u64,
},
#[error("invalid app-data JSON: {0}")]
Parse(String),
}
pub const PARTNER_FEE_BPS_MAX: u64 = 10_000;
fn sort_value(value: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match value {
Value::Object(map) => {
let mut sorted = serde_json::Map::new();
let mut entries: Vec<(String, Value)> = map.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in entries {
sorted.insert(k, sort_value(v));
}
Value::Object(sorted)
}
Value::Array(items) => Value::Array(items.into_iter().map(sort_value).collect()),
other => other,
}
}
pub type AppDataCid = cid::Cid;
const CID_CODEC_RAW: u64 = 0x55;
const MULTIHASH_KECCAK_256: u64 = 0x1b;
pub const MAX_CID_STR_LEN: usize = 128;
pub fn parse_app_data_cid(s: &str) -> Result<AppDataCid, AppDataCidError> {
if s.len() > MAX_CID_STR_LEN {
return Err(AppDataCidError::CidTooLong {
len: s.len(),
max: MAX_CID_STR_LEN,
});
}
Ok(s.parse::<AppDataCid>()?)
}
pub fn app_data_cid(hash: AppDataHash) -> AppDataCid {
let multihash = Multihash::<32>::wrap(MULTIHASH_KECCAK_256, hash.as_slice())
.expect("digest fits a 32-byte multihash by construction");
AppDataCid::new_v1(CID_CODEC_RAW, multihash.resize().expect("32 <= 64"))
}
pub fn app_data_hash_from_cid(cid: &AppDataCid) -> Result<AppDataHash, AppDataCidError> {
if cid.codec() != CID_CODEC_RAW {
return Err(AppDataCidError::UnexpectedCodec(cid.codec()));
}
let multihash = cid.hash();
if multihash.code() != MULTIHASH_KECCAK_256 {
return Err(AppDataCidError::UnexpectedMultihashCode(multihash.code()));
}
let digest = multihash.digest();
if digest.len() != 32 {
return Err(AppDataCidError::UnexpectedDigestLength(digest.len()));
}
Ok(AppDataHash::from_slice(digest))
}
#[derive(Debug, thiserror::Error)]
pub enum AppDataCidError {
#[error("invalid CID: {0}")]
InvalidCid(#[from] cid::Error),
#[error("CID string exceeds {max}-char cap (got {len})")]
CidTooLong {
len: usize,
max: usize,
},
#[error("expected raw codec (0x55), got 0x{0:02x}")]
UnexpectedCodec(u64),
#[error("expected keccak-256 multihash (0x1b), got 0x{0:02x}")]
UnexpectedMultihashCode(u64),
#[error("expected 32-byte digest, got {0}")]
UnexpectedDigestLength(usize),
}
#[cfg(test)]
mod tests {
use {super::*, alloy_primitives::address};
#[test]
fn json_round_trip_zero() {
let zero = AppDataHash::default();
let json = serde_json::to_value(zero).unwrap();
assert_eq!(
json,
serde_json::json!("0x0000000000000000000000000000000000000000000000000000000000000000")
);
let parsed: AppDataHash = serde_json::from_value(json).unwrap();
assert_eq!(parsed, zero);
}
#[test]
fn json_round_trip_non_zero() {
let mut bytes = [0u8; 32];
bytes[0] = 0xab;
bytes[31] = 0xcd;
let original = AppDataHash::from(bytes);
let json = serde_json::to_value(original).unwrap();
let parsed: AppDataHash = serde_json::from_value(json).unwrap();
assert_eq!(parsed, original);
}
#[test]
fn rejects_wrong_length() {
let json = serde_json::json!("0xabcd");
let result: Result<AppDataHash, _> = serde_json::from_value(json);
assert!(result.is_err());
}
#[test]
fn empty_app_data_hash_matches_keccak() {
let computed = alloy_primitives::keccak256(EMPTY_APP_DATA_JSON);
assert_eq!(EMPTY_APP_DATA_HASH, computed);
}
#[test]
fn sdk_attribution_doc_pins_app_code_and_version() {
for app_code in [COW_RS_APP_CODE, COW_RS_WASM_APP_CODE] {
let doc = AppDataDoc::sdk_attribution(app_code);
assert_eq!(doc.app_code.as_deref(), Some(app_code));
let json = doc.canonical_json();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["appCode"], app_code);
assert_eq!(
value["metadata"]["quote"]["version"],
env!("CARGO_PKG_VERSION")
);
assert_eq!(value["version"], LATEST_APP_DATA_VERSION);
assert_eq!(
AppDataDoc::sdk_attribution(app_code).hash(),
AppDataDoc::sdk_attribution(app_code).hash(),
);
}
assert_ne!(
AppDataDoc::sdk_attribution(COW_RS_APP_CODE).hash(),
AppDataDoc::sdk_attribution(COW_RS_WASM_APP_CODE).hash(),
"cow-rs and cow-rs-wasm must produce distinct app-data digests"
);
}
#[test]
fn empty_doc_matches_constant() {
let doc = AppDataDoc::new("");
assert!(doc.metadata.quote.is_none());
assert!(doc.metadata.order_class.is_none());
assert!(doc.metadata.partner_fee.is_none());
assert!(doc.metadata.referrer.is_none());
assert!(doc.metadata.utm.is_none());
assert!(doc.metadata.hooks.is_none());
assert!(doc.metadata.flashloan.is_none());
assert!(doc.metadata.replaced_order.is_none());
assert!(doc.metadata.wrappers.is_empty());
let json = doc.canonical_json();
assert_eq!(json, r#"{"appCode":"","metadata":{},"version":"1.6.0"}"#);
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, doc);
}
#[test]
fn referrer_doc_round_trips() {
let referrer = address!("1234567890AbcdEF1234567890aBcdef12345678");
let doc = AppDataDoc::new("my-app").with_referrer(referrer);
let json = doc.canonical_json();
assert!(
json.to_lowercase()
.contains(r#""referrer":{"address":"0x1234567890abcdef1234567890abcdef12345678"}"#)
);
let hash = doc.hash();
let direct = alloy_primitives::keccak256(json.as_bytes());
assert_eq!(hash, direct);
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
let parsed_referrer = parsed.metadata.referrer.expect("referrer preserved");
assert_eq!(parsed_referrer.address, referrer);
}
#[test]
fn minimal_doc_golden_hash() {
let doc = AppDataDoc::new("");
assert_eq!(
doc.canonical_json(),
r#"{"appCode":"","metadata":{},"version":"1.6.0"}"#
);
let expected = b256!("3929e2c230dc41c0c053ff5f9211eb32def3a737b2bf36eb5b8862ea317fcd9e");
assert_eq!(doc.hash(), expected);
}
#[test]
fn canonical_json_preserves_utf8_non_ascii_bytes() {
let doc = AppDataDoc::new("café-\u{1F40c}"); let json = doc.canonical_json();
assert!(
json.contains("café-\u{1F40c}"),
"expected raw UTF-8 non-ASCII bytes in canonical JSON, got: {json}"
);
assert!(
!json.contains("\\u00e9"),
"expected raw UTF-8, found ASCII escape: {json}"
);
assert!(
!json.contains("\\ud83d"),
"expected raw UTF-8, found surrogate-pair escape: {json}"
);
let direct = alloy_primitives::keccak256(json.as_bytes());
assert_eq!(doc.hash(), direct);
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.app_code.as_deref(), Some("café-\u{1F40c}"));
}
#[test]
fn canonical_json_sorts_keys_deterministically() {
let doc = AppDataDoc::new("app")
.with_referrer(address!("0000000000000000000000000000000000000001"))
.with_partner_fee(50, address!("0000000000000000000000000000000000000002"))
.unwrap()
.with_order_class(OrderClass::Limit)
.with_slippage_bips(25)
.with_environment("prod");
let json = doc.canonical_json();
let app_code_pos = json.find("appCode").unwrap();
let environment_pos = json.find("environment").unwrap();
let metadata_pos = json.find("metadata").unwrap();
let version_pos = json.find("\"version\"").unwrap();
assert!(app_code_pos < environment_pos);
assert!(environment_pos < metadata_pos);
assert!(metadata_pos < version_pos);
assert_eq!(json, doc.canonical_json());
}
#[test]
fn partner_fee_volume_round_trips_with_legacy_bps_key() {
let recipient = address!("00000000219AB540356CBb839CbE05303D7705FA");
let doc = AppDataDoc::new("app")
.with_partner_fee(75, recipient)
.unwrap();
let json = doc.canonical_json();
assert!(
json.to_lowercase()
.contains(r#""partnerfee":{"bps":75,"recipient":"#),
"got: {json}",
);
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
let fee = parsed.metadata.partner_fee.expect("partner fee preserved");
assert!(matches!(fee.policy, FeePolicy::Volume { bps: 75 }));
assert_eq!(fee.recipient, recipient);
}
#[test]
fn partner_fee_surplus_emits_typed_keys() {
let recipient = address!("00000000219AB540356CBb839CbE05303D7705FA");
let doc = AppDataDoc::new("app")
.with_partner_fee_policy(
FeePolicy::Surplus {
bps: 25,
max_volume_bps: 100,
},
recipient,
)
.unwrap();
let json = doc.canonical_json();
assert!(json.contains(r#""maxVolumeBps":100"#), "got: {json}");
assert!(json.contains(r#""surplusBps":25"#), "got: {json}");
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
let fee = parsed.metadata.partner_fee.expect("partner fee preserved");
assert!(matches!(
fee.policy,
FeePolicy::Surplus {
bps: 25,
max_volume_bps: 100,
}
));
}
#[test]
fn partner_fee_price_improvement_emits_typed_keys() {
let recipient = address!("00000000219AB540356CBb839CbE05303D7705FA");
let doc = AppDataDoc::new("app")
.with_partner_fee_policy(
FeePolicy::PriceImprovement {
bps: 30,
max_volume_bps: 150,
},
recipient,
)
.unwrap();
let json = doc.canonical_json();
assert!(json.contains(r#""priceImprovementBps":30"#), "got: {json}");
assert!(json.contains(r#""maxVolumeBps":150"#), "got: {json}");
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
let fee = parsed.metadata.partner_fee.expect("partner fee preserved");
assert!(matches!(
fee.policy,
FeePolicy::PriceImprovement {
bps: 30,
max_volume_bps: 150,
}
));
}
#[test]
fn partner_fee_deserialises_volume_bps_alias() {
let recipient = address!("00000000219AB540356CBb839CbE05303D7705FA");
let json = format!(r#"{{"volumeBps":42,"recipient":"{recipient:?}"}}"#,);
let fee: AppDataPartnerFee = serde_json::from_str(&json).unwrap();
assert!(matches!(fee.policy, FeePolicy::Volume { bps: 42 }));
assert_eq!(fee.recipient, recipient);
}
#[test]
fn partner_fee_builder_rejects_over_cap_bps() {
let recipient = address!("00000000219AB540356CBb839CbE05303D7705FA");
let err = AppDataDoc::new("app")
.with_partner_fee(10_001, recipient)
.unwrap_err();
assert!(matches!(
err,
AppDataError::FeeOutOfRange {
field: "bps",
value: 10_001,
max: PARTNER_FEE_BPS_MAX,
}
));
let err = AppDataDoc::new("app")
.with_partner_fee_policy(
FeePolicy::Surplus {
bps: 1,
max_volume_bps: 10_001,
},
recipient,
)
.unwrap_err();
assert!(matches!(
err,
AppDataError::FeeOutOfRange {
field: "maxVolumeBps",
value: 10_001,
..
}
));
let err = AppDataDoc::new("app")
.with_partner_fee_policy(
FeePolicy::PriceImprovement {
bps: 10_001,
max_volume_bps: 1,
},
recipient,
)
.unwrap_err();
assert!(matches!(
err,
AppDataError::FeeOutOfRange {
field: "priceImprovementBps",
value: 10_001,
..
}
));
let _ = AppDataDoc::new("app")
.with_partner_fee(PARTNER_FEE_BPS_MAX as u32, recipient)
.expect("bps at the cap must be accepted");
}
#[test]
fn partner_fee_rejects_mixed_policy_fields() {
let recipient = address!("00000000219AB540356CBb839CbE05303D7705FA");
let json = format!(
r#"{{"surplusBps":10,"priceImprovementBps":20,"maxVolumeBps":50,"recipient":"{recipient:?}"}}"#,
);
let err = serde_json::from_str::<AppDataPartnerFee>(&json).unwrap_err();
assert!(err.to_string().contains("unknown partner-fee policy"));
}
#[test]
fn flashloan_round_trips() {
let flashloan = AppDataFlashloan {
liquidity_provider: address!("1111111111111111111111111111111111111111"),
protocol_adapter: address!("2222222222222222222222222222222222222222"),
receiver: address!("3333333333333333333333333333333333333333"),
token: address!("4444444444444444444444444444444444444444"),
amount: U256::from(1_000_000_u64),
};
let doc = AppDataDoc::new("app").with_flashloan(flashloan.clone());
let json = doc.canonical_json();
assert!(
json.contains(r#""amount":"1000000""#),
"amount must serialise as decimal string, got: {json}",
);
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed.metadata.flashloan.expect("flashloan preserved"),
flashloan
);
}
#[test]
fn replaced_order_round_trips() {
let uid = OrderUid::from([0x55; 56]);
let doc = AppDataDoc::new("app").with_replaced_order(uid);
let json = doc.canonical_json();
assert!(
json.contains(r#""replacedOrder":{"uid":"0x"#),
"got: {json}"
);
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed.metadata.replaced_order.expect("replaced order").uid,
uid
);
}
#[test]
fn wrappers_round_trip_and_skip_when_empty() {
let doc = AppDataDoc::new("app");
assert!(!doc.canonical_json().contains("wrappers"));
let wrapper = AppDataWrapperCall {
address: address!("5555555555555555555555555555555555555555"),
data: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]),
is_omittable: true,
};
let doc = doc.with_wrapper(wrapper.clone());
let json = doc.canonical_json();
assert!(json.contains(r#""data":"0xdeadbeef""#), "got: {json}");
assert!(json.contains(r#""isOmittable":true"#), "got: {json}");
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.metadata.wrappers, vec![wrapper]);
}
#[test]
fn order_class_serialises_as_lowercase() {
let doc = AppDataDoc::new("app").with_order_class(OrderClass::Market);
let json = doc.canonical_json();
assert!(json.contains(r#""orderClass":{"orderClass":"market"}"#));
}
#[test]
fn hooks_pass_through_as_opaque_json() {
let mut doc = AppDataDoc::new("app");
doc.metadata.hooks = Some(serde_json::json!({
"version": "0.1.0",
"pre": [{"target": "0xabc", "callData": "0xdef", "gasLimit": "21000"}],
"post": [],
}));
let json = doc.canonical_json();
let parsed: AppDataDoc = serde_json::from_str(&json).unwrap();
let hooks = parsed.metadata.hooks.expect("hooks preserved");
assert_eq!(hooks["version"], "0.1.0");
assert_eq!(hooks["pre"][0]["target"], "0xabc");
}
#[test]
fn cid_round_trip_default_and_walking_bytes() {
let default = AppDataHash::default();
assert_eq!(
app_data_hash_from_cid(&app_data_cid(default)).unwrap(),
default
);
for i in 0..32 {
let mut bytes = [0u8; 32];
bytes[i] = 0xff;
let hash = AppDataHash::from(bytes);
let cid = app_data_cid(hash);
let rendered = cid.to_string();
assert_eq!(
app_data_hash_from_cid(&cid).unwrap(),
hash,
"round-trip failed at byte {i}"
);
assert!(rendered.starts_with('b'));
assert!(
rendered
.chars()
.skip(1)
.all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
);
}
}
#[test]
fn cid_for_empty_app_data_hash_starts_with_bafkrw() {
let cid = app_data_cid(EMPTY_APP_DATA_HASH).to_string();
assert!(
cid.starts_with("bafkrw"),
"expected bafkrw prefix, got {cid}"
);
}
#[test]
fn cid_matches_services_known_good_vector() {
let hash = b256!("8af4e8c9973577b08ac21d17d331aade86c11ebcc5124744d621ca8365ec9424");
let cid = app_data_cid(hash);
assert_eq!(
cid.to_string(),
"bafkrwiek6tumtfzvo6yivqq5c7jtdkw6q3ar5pgfcjdujvrbzkbwl3eueq"
);
assert_eq!(app_data_hash_from_cid(&cid).unwrap(), hash);
}
#[test]
fn cid_for_empty_doc_golden() {
let cid = app_data_cid(EMPTY_APP_DATA_HASH);
assert_eq!(
cid.to_string(),
"bafkrwifuru4pspvkbbadh7czoc7znzkzym6ezxah3ce2wafu2y7zledttu"
);
}
#[test]
fn cid_parse_rejects_missing_multibase_prefix() {
let err = "afkrwifuru4pspvkbbadh7czoc7znzkzym6ezxah3ce2wafu2y7zledttu"
.parse::<AppDataCid>()
.unwrap_err();
assert!(matches!(err, cid::Error::ParsingError), "got: {err:?}");
}
#[test]
fn cid_parse_accepts_base16_multibase_prefix() {
let hash = b256!("8af4e8c9973577b08ac21d17d331aade86c11ebcc5124744d621ca8365ec9424");
let mut hex_body = String::with_capacity(72);
hex_body.push_str("01551b20");
hex_body.push_str(&const_hex::encode(hash));
let cid = format!("f{hex_body}").parse::<AppDataCid>().unwrap();
assert_eq!(app_data_hash_from_cid(&cid).unwrap(), hash);
}
#[test]
fn cid_parse_rejects_invalid_base16_body() {
let err = "f01551b20zzzz".parse::<AppDataCid>().unwrap_err();
assert!(matches!(err, cid::Error::ParsingError), "got: {err:?}");
}
#[test]
fn parse_app_data_cid_rejects_oversize_string() {
let oversize = format!("b{}", "a".repeat(MAX_CID_STR_LEN));
let err = parse_app_data_cid(&oversize).unwrap_err();
assert!(
matches!(err, AppDataCidError::CidTooLong { max, .. } if max == MAX_CID_STR_LEN),
"got: {err:?}"
);
}
#[test]
fn parse_app_data_cid_accepts_canonical_string() {
let cid = app_data_cid(EMPTY_APP_DATA_HASH).to_string();
assert!(cid.len() <= MAX_CID_STR_LEN);
let parsed = parse_app_data_cid(&cid).unwrap();
assert_eq!(
app_data_hash_from_cid(&parsed).unwrap(),
EMPTY_APP_DATA_HASH
);
}
#[test]
fn cid_parse_rejects_wrong_codec() {
let multihash =
Multihash::<32>::wrap(MULTIHASH_KECCAK_256, EMPTY_APP_DATA_HASH.as_slice()).unwrap();
let cid = AppDataCid::new_v1(0x70, multihash.resize().unwrap());
let err = app_data_hash_from_cid(&cid).unwrap_err();
assert!(
matches!(err, AppDataCidError::UnexpectedCodec(0x70)),
"got: {err:?}"
);
}
#[test]
fn cid_parse_rejects_wrong_multihash() {
let multihash = Multihash::<32>::wrap(0x12, EMPTY_APP_DATA_HASH.as_slice()).unwrap();
let cid = AppDataCid::new_v1(CID_CODEC_RAW, multihash.resize().unwrap());
let err = app_data_hash_from_cid(&cid).unwrap_err();
assert!(
matches!(err, AppDataCidError::UnexpectedMultihashCode(0x12)),
"got: {err:?}"
);
}
#[test]
fn cid_parse_rejects_truncated_body() {
let err = "babcdefgh".parse::<AppDataCid>().unwrap_err();
assert!(
matches!(
err,
cid::Error::ParsingError
| cid::Error::VarIntDecodeError
| cid::Error::InputTooShort
| cid::Error::InvalidExplicitCidV0
| cid::Error::Io(_)
),
"got: {err:?}"
);
}
#[test]
fn cid_parse_rejects_wrong_version() {
let bytes = [
0x00, 0x55, 0x1b, 0x20, 0xb4, 0x8d, 0x38, 0xf9, 0x3e, 0xaa, 0x08, 0x40, 0x33, 0xfc,
0x59, 0x70, 0xbf, 0x96, 0xe5, 0x59, 0xc3, 0x3c, 0x4c, 0xdc, 0x07, 0xd8, 0x89, 0xab,
0x00, 0xb4, 0xd6, 0x3f, 0x95, 0x90, 0x73, 0x9d,
];
let encoded = cid::multibase::encode(cid::multibase::Base::Base32Lower, bytes);
let err = encoded.parse::<AppDataCid>().unwrap_err();
assert!(
matches!(err, cid::Error::InvalidExplicitCidV0),
"got: {err:?}"
);
}
#[test]
fn cid_parse_rejects_wrong_digest_length() {
let multihash =
Multihash::<32>::wrap(MULTIHASH_KECCAK_256, &EMPTY_APP_DATA_HASH.as_slice()[..16])
.unwrap();
let cid = AppDataCid::new_v1(CID_CODEC_RAW, multihash.resize().unwrap());
let err = app_data_hash_from_cid(&cid).unwrap_err();
assert!(
matches!(err, AppDataCidError::UnexpectedDigestLength(16)),
"got: {err:?}"
);
}
#[test]
fn cid_parse_rejects_invalid_base32_char() {
let err = "b8".parse::<AppDataCid>().unwrap_err();
assert!(
matches!(err, cid::Error::ParsingError | cid::Error::InputTooShort),
"got: {err:?}"
);
}
}