use alloy_primitives::{Address, U256, keccak256};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
use serde_with::{DisplayFromStr, serde_as};
use std::fmt;
use crate::bytes_hex::BytesHex;
use crate::order::{OrderClass, OrderUid};
#[derive(Clone, Copy, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct AppDataHash(pub [u8; 32]);
impl fmt::Debug for AppDataHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "AppDataHash({})", const_hex::encode_prefixed(self.0))
}
}
impl fmt::Display for AppDataHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&const_hex::encode_prefixed(self.0))
}
}
impl From<[u8; 32]> for AppDataHash {
fn from(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl AsRef<[u8]> for AppDataHash {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl AppDataHash {
pub fn to_cid(&self) -> AppDataCid {
AppDataCid::from_hash(*self)
}
}
pub const EMPTY_APP_DATA_HASH: AppDataHash = AppDataHash(hex_literal::hex!(
"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;
impl Serialize for AppDataHash {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
crate::bytes_hex::serialize(self.0, serializer)
}
}
impl<'de> Deserialize<'de> for AppDataHash {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let bytes = crate::bytes_hex::deserialize(deserializer)?;
let arr: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| D::Error::custom(format!("expected 32 bytes, got {}", bytes.len())))?;
Ok(Self(arr))
}
}
#[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,
}
#[serde_as]
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppDataWrapperCall {
pub address: Address,
#[serde_as(as = "BytesHex")]
pub data: Vec<u8>,
#[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 {
let quote = self
.metadata
.quote
.get_or_insert_with(AppDataQuote::default);
quote.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 try_from_str(json: &str) -> Result<Self, AppDataError> {
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()))
}
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(AppDataHash(keccak256(json.as_bytes()).0))
}
}
#[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,
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct AppDataCid(String);
const CID_V1: u8 = 0x01;
const CID_CODEC_RAW: u8 = 0x55;
const MULTIHASH_KECCAK_256: u8 = 0x1b;
const MULTIHASH_LEN_32: u8 = 0x20;
const CID_BYTES_LEN: usize = 4 + 32;
const CID_STRING_MAX_LEN: usize = 96;
impl AppDataCid {
pub fn from_hash(hash: AppDataHash) -> Self {
let mut bytes = [0u8; CID_BYTES_LEN];
bytes[0] = CID_V1;
bytes[1] = CID_CODEC_RAW;
bytes[2] = MULTIHASH_KECCAK_256;
bytes[3] = MULTIHASH_LEN_32;
bytes[4..].copy_from_slice(&hash.0);
let mut out = String::with_capacity(1 + base32_encoded_len(CID_BYTES_LEN));
out.push('b');
base32_encode_into(&bytes, &mut out);
Self(out)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_hash(&self) -> Result<AppDataHash, AppDataCidError> {
if self.0.len() > CID_STRING_MAX_LEN {
return Err(AppDataCidError::CidTooLong {
len: self.0.len(),
max: CID_STRING_MAX_LEN,
});
}
let bytes = match self.0.as_bytes().first() {
Some(b'b') => base32_decode(&self.0[1..])?,
Some(b'f') => {
const_hex::decode(&self.0[1..]).map_err(|_| AppDataCidError::InvalidBase16Body)?
}
_ => return Err(AppDataCidError::MissingMultibasePrefix),
};
if bytes.len() != CID_BYTES_LEN {
return Err(AppDataCidError::InvalidLength {
expected: CID_BYTES_LEN,
actual: bytes.len(),
});
}
if bytes[0] != CID_V1 {
return Err(AppDataCidError::UnexpectedVersion(bytes[0]));
}
if bytes[1] != CID_CODEC_RAW {
return Err(AppDataCidError::UnexpectedCodec(bytes[1]));
}
if bytes[2] != MULTIHASH_KECCAK_256 {
return Err(AppDataCidError::UnexpectedMultihashCode(bytes[2]));
}
if bytes[3] != MULTIHASH_LEN_32 {
return Err(AppDataCidError::UnexpectedDigestLength(bytes[3]));
}
let mut digest = [0u8; 32];
digest.copy_from_slice(&bytes[4..]);
Ok(AppDataHash(digest))
}
}
impl fmt::Display for AppDataCid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for AppDataCid {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, thiserror::Error, Eq, PartialEq)]
pub enum AppDataCidError {
#[error("expected multibase `b` (base32) or `f` (base16) prefix")]
MissingMultibasePrefix,
#[error("invalid base32 character {0:?}")]
InvalidBase32Char(char),
#[error("invalid base16 (hex) body")]
InvalidBase16Body,
#[error("expected {expected}-byte CID body, got {actual}")]
InvalidLength {
expected: usize,
actual: usize,
},
#[error("expected CIDv1 (0x01), got 0x{0:02x}")]
UnexpectedVersion(u8),
#[error("expected raw codec (0x55), got 0x{0:02x}")]
UnexpectedCodec(u8),
#[error("expected keccak-256 multihash (0x1b), got 0x{0:02x}")]
UnexpectedMultihashCode(u8),
#[error("expected 32-byte digest (0x20), got 0x{0:02x}")]
UnexpectedDigestLength(u8),
#[error("cid string too long: {len} chars (max {max})")]
CidTooLong {
len: usize,
max: usize,
},
}
const BASE32_ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
const fn base32_encoded_len(input_len: usize) -> usize {
input_len.div_ceil(5) * 8
}
fn base32_encode_into(input: &[u8], out: &mut String) {
let mut chunks = input.chunks_exact(5);
for chunk in chunks.by_ref() {
let buf: u64 = (u64::from(chunk[0]) << 32)
| (u64::from(chunk[1]) << 24)
| (u64::from(chunk[2]) << 16)
| (u64::from(chunk[3]) << 8)
| u64::from(chunk[4]);
for shift in (0..8).rev() {
let idx = ((buf >> (shift * 5)) & 0x1f) as usize;
out.push(BASE32_ALPHABET[idx] as char);
}
}
let tail = chunks.remainder();
if !tail.is_empty() {
let mut buf: u64 = 0;
for (i, b) in tail.iter().enumerate() {
buf |= u64::from(*b) << ((4 - i) * 8);
}
let out_chars = match tail.len() {
1 => 2,
2 => 4,
3 => 5,
4 => 7,
_ => unreachable!("chunks_exact remainder is < 5"),
};
for i in 0..out_chars {
let idx = ((buf >> ((7 - i) * 5)) & 0x1f) as usize;
out.push(BASE32_ALPHABET[idx] as char);
}
}
}
fn base32_decode(input: &str) -> Result<Vec<u8>, AppDataCidError> {
let bytes = input.as_bytes();
let len = bytes.iter().rposition(|b| *b != b'=').map_or(0, |p| p + 1);
let trimmed = &bytes[..len];
let mut out = Vec::with_capacity(trimmed.len() * 5 / 8);
let mut buf: u64 = 0;
let mut bits: u32 = 0;
for &b in trimmed {
let value = match b {
b'a'..=b'z' => b - b'a',
b'2'..=b'7' => b - b'2' + 26,
_ => return Err(AppDataCidError::InvalidBase32Char(b as char)),
};
buf = (buf << 5) | u64::from(value);
bits += 5;
if bits >= 8 {
bits -= 8;
let byte = ((buf >> bits) & 0xff) as u8;
out.push(byte);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use {super::*, alloy_primitives::address};
#[test]
fn json_round_trip_zero() {
let zero = AppDataHash([0; 32]);
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(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 rejects_missing_prefix() {
let json =
serde_json::json!("0000000000000000000000000000000000000000000000000000000000000000");
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.0, *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.0, *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 =
hex_literal::hex!("3929e2c230dc41c0c053ff5f9211eb32def3a737b2bf36eb5b8862ea317fcd9e");
assert_eq!(doc.hash().0, 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().0, *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([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: vec![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!(default.to_cid().to_hash().unwrap(), default);
for i in 0..32 {
let mut bytes = [0u8; 32];
bytes[i] = 0xff;
let hash = AppDataHash(bytes);
let cid = hash.to_cid();
assert_eq!(
cid.to_hash().unwrap(),
hash,
"round-trip failed at byte {i}"
);
assert!(cid.as_str().starts_with('b'));
assert!(
cid.as_str()
.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 = EMPTY_APP_DATA_HASH.to_cid();
assert!(
cid.as_str().starts_with("bafkrw"),
"expected bafkrw prefix, got {}",
cid.as_str()
);
}
#[test]
fn cid_matches_services_known_good_vector() {
let hash = AppDataHash(hex_literal::hex!(
"8af4e8c9973577b08ac21d17d331aade86c11ebcc5124744d621ca8365ec9424"
));
let cid = hash.to_cid();
assert_eq!(
cid.as_str(),
"bafkrwiek6tumtfzvo6yivqq5c7jtdkw6q3ar5pgfcjdujvrbzkbwl3eueq"
);
assert_eq!(cid.to_hash().unwrap(), hash);
}
#[test]
fn cid_for_empty_doc_golden() {
let cid = EMPTY_APP_DATA_HASH.to_cid();
assert_eq!(
cid.as_str(),
"bafkrwifuru4pspvkbbadh7czoc7znzkzym6ezxah3ce2wafu2y7zledttu"
);
}
#[test]
fn cid_parse_rejects_missing_multibase_prefix() {
let cid =
AppDataCid("afkrwifuru4pspvkbbadh7czoc7znzkzym6ezxah3ce2wafu2y7zledttu".to_string());
assert_eq!(
cid.to_hash().unwrap_err(),
AppDataCidError::MissingMultibasePrefix
);
}
#[test]
fn cid_parse_accepts_base16_multibase_prefix() {
let hash = AppDataHash(hex_literal::hex!(
"8af4e8c9973577b08ac21d17d331aade86c11ebcc5124744d621ca8365ec9424"
));
let mut hex_body = String::with_capacity(2 * CID_BYTES_LEN);
hex_body.push_str("01551b20");
hex_body.push_str(&const_hex::encode(hash.0));
let cid = AppDataCid(format!("f{hex_body}"));
assert_eq!(cid.to_hash().unwrap(), hash);
}
#[test]
fn cid_parse_rejects_invalid_base16_body() {
let cid = AppDataCid("f01551b20zzzz".to_string());
assert_eq!(
cid.to_hash().unwrap_err(),
AppDataCidError::InvalidBase16Body
);
}
#[test]
fn cid_parse_rejects_wrong_codec() {
let mut bytes = [0u8; CID_BYTES_LEN];
bytes[0] = 0x01;
bytes[1] = 0x70; bytes[2] = 0x1b;
bytes[3] = 0x20;
bytes[4..].copy_from_slice(&EMPTY_APP_DATA_HASH.0);
let mut s = String::from("b");
base32_encode_into(&bytes, &mut s);
let cid = AppDataCid(s);
assert_eq!(
cid.to_hash().unwrap_err(),
AppDataCidError::UnexpectedCodec(0x70)
);
}
#[test]
fn cid_parse_rejects_wrong_multihash() {
let mut bytes = [0u8; CID_BYTES_LEN];
bytes[0] = 0x01;
bytes[1] = 0x55;
bytes[2] = 0x12; bytes[3] = 0x20;
bytes[4..].copy_from_slice(&EMPTY_APP_DATA_HASH.0);
let mut s = String::from("b");
base32_encode_into(&bytes, &mut s);
let cid = AppDataCid(s);
assert_eq!(
cid.to_hash().unwrap_err(),
AppDataCidError::UnexpectedMultihashCode(0x12)
);
}
#[test]
fn cid_parse_rejects_wrong_length() {
let cid = AppDataCid("babcdefgh".to_string());
match cid.to_hash() {
Err(AppDataCidError::InvalidLength { expected, actual }) => {
assert_eq!(expected, CID_BYTES_LEN);
assert_ne!(actual, CID_BYTES_LEN);
}
other => panic!("expected InvalidLength, got {other:?}"),
}
}
#[test]
fn cid_parse_rejects_wrong_version() {
let mut bytes = [0u8; CID_BYTES_LEN];
bytes[0] = 0x00;
bytes[1] = 0x55;
bytes[2] = 0x1b;
bytes[3] = 0x20;
bytes[4..].copy_from_slice(&EMPTY_APP_DATA_HASH.0);
let mut s = String::from("b");
base32_encode_into(&bytes, &mut s);
let cid = AppDataCid(s);
assert_eq!(
cid.to_hash().unwrap_err(),
AppDataCidError::UnexpectedVersion(0x00)
);
}
#[test]
fn cid_parse_rejects_wrong_digest_length() {
let mut bytes = [0u8; CID_BYTES_LEN];
bytes[0] = 0x01;
bytes[1] = 0x55;
bytes[2] = 0x1b;
bytes[3] = 0x10; bytes[4..].copy_from_slice(&EMPTY_APP_DATA_HASH.0);
let mut s = String::from("b");
base32_encode_into(&bytes, &mut s);
let cid = AppDataCid(s);
assert_eq!(
cid.to_hash().unwrap_err(),
AppDataCidError::UnexpectedDigestLength(0x10)
);
}
#[test]
fn cid_parse_rejects_invalid_base32_char() {
let cid = AppDataCid("b8".to_string());
assert_eq!(
cid.to_hash().unwrap_err(),
AppDataCidError::InvalidBase32Char('8')
);
}
#[test]
fn base32_encode_rfc4648_vectors() {
let cases: &[(&[u8], &str)] = &[
(b"", ""),
(b"f", "my"),
(b"fo", "mzxq"),
(b"foo", "mzxw6"),
(b"foob", "mzxw6yq"),
(b"fooba", "mzxw6ytb"),
(b"foobar", "mzxw6ytboi"),
];
for (input, expected) in cases {
let mut out = String::new();
base32_encode_into(input, &mut out);
assert_eq!(&out, expected, "encode mismatch for {input:?}");
let decoded = base32_decode(expected).unwrap();
assert_eq!(&decoded, input, "decode mismatch for {expected:?}");
}
}
}