use std::fmt;
use alloy_primitives::B256;
use serde::Deserialize;
use serde_json::json;
use crate::error::CowError;
use super::{
cid::{appdata_hex_to_cid, cid_to_appdata_hex, extract_digest},
hash::{appdata_hex, stringify_deterministic},
types::{AppDataDoc, Metadata},
validation::{ValidationError, validate_constraints},
};
pub const DEFAULT_IPFS_READ_URI: &str = "https://cloudflare-ipfs.com/ipfs";
pub const DEFAULT_IPFS_WRITE_URI: &str = "https://api.pinata.cloud";
#[derive(Debug, Clone)]
pub struct AppDataInfo {
pub cid: String,
pub app_data_content: String,
pub app_data_hex: String,
}
impl AppDataInfo {
#[must_use]
pub fn new(
cid: impl Into<String>,
app_data_content: impl Into<String>,
app_data_hex: impl Into<String>,
) -> Self {
Self {
cid: cid.into(),
app_data_content: app_data_content.into(),
app_data_hex: app_data_hex.into(),
}
}
}
impl fmt::Display for AppDataInfo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "app-data-info({}, {})", self.cid, self.app_data_hex)
}
}
#[derive(Debug, Clone, Default)]
pub struct Ipfs {
pub read_uri: Option<String>,
pub write_uri: Option<String>,
pub pinata_api_key: Option<String>,
pub pinata_api_secret: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub success: bool,
pub errors: Vec<String>,
pub typed_errors: Vec<ValidationError>,
}
impl ValidationResult {
#[must_use]
pub const fn new(success: bool, errors: Vec<String>) -> Self {
Self { success, errors, typed_errors: Vec::new() }
}
#[must_use]
pub const fn is_valid(&self) -> bool {
self.success
}
#[must_use]
pub const fn has_errors(&self) -> bool {
!self.typed_errors.is_empty()
}
#[must_use]
pub const fn error_count(&self) -> usize {
self.typed_errors.len()
}
#[must_use]
pub fn errors_ref(&self) -> &[ValidationError] {
&self.typed_errors
}
#[must_use]
pub fn first_error(&self) -> Option<&ValidationError> {
self.typed_errors.first()
}
}
impl fmt::Display for ValidationResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.success {
f.write_str("valid")
} else {
write!(f, "invalid({} errors)", self.typed_errors.len())
}
}
}
impl Ipfs {
#[must_use]
pub fn with_read_uri(mut self, uri: impl Into<String>) -> Self {
self.read_uri = Some(uri.into());
self
}
#[must_use]
pub fn with_write_uri(mut self, uri: impl Into<String>) -> Self {
self.write_uri = Some(uri.into());
self
}
#[must_use]
pub fn with_pinata(
mut self,
api_key: impl Into<String>,
api_secret: impl Into<String>,
) -> Self {
self.pinata_api_key = Some(api_key.into());
self.pinata_api_secret = Some(api_secret.into());
self
}
}
impl fmt::Display for Ipfs {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let uri = self.read_uri.as_deref().map_or("default", |s| s);
write!(f, "ipfs(read={uri})")
}
}
pub fn get_app_data_info(doc: &AppDataDoc) -> Result<AppDataInfo, CowError> {
let app_data_content = stringify_deterministic(doc)?;
let hash: B256 = alloy_primitives::keccak256(app_data_content.as_bytes());
let app_data_hex = format!("0x{}", alloy_primitives::hex::encode(hash.as_slice()));
let cid = appdata_hex_to_cid(&app_data_hex)?;
Ok(AppDataInfo { cid, app_data_content, app_data_hex })
}
pub fn get_app_data_info_from_str(json: &str) -> Result<AppDataInfo, CowError> {
let hash: alloy_primitives::B256 = alloy_primitives::keccak256(json.as_bytes());
let app_data_hex = format!("0x{}", alloy_primitives::hex::encode(hash.as_slice()));
let cid = appdata_hex_to_cid(&app_data_hex)?;
Ok(AppDataInfo { cid, app_data_content: json.to_owned(), app_data_hex })
}
#[must_use]
pub fn validate_app_data_doc(doc: &AppDataDoc) -> ValidationResult {
let mut typed_errors: Vec<ValidationError> = Vec::new();
if doc.version.is_empty() {
typed_errors.push(ValidationError::InvalidVersion("version must not be empty".to_owned()));
} else {
let parts: Vec<&str> = doc.version.split('.').collect();
if parts.len() != 3 || parts.iter().any(|p| p.parse::<u32>().is_err()) {
typed_errors.push(ValidationError::InvalidVersion(format!(
"version '{}' is not valid semver",
doc.version
)));
}
}
validate_constraints(doc, &mut typed_errors);
#[cfg(feature = "schema-validation")]
match super::schema::validate(doc) {
Ok(()) => {}
Err(super::schema::SchemaError::Violations(violations)) => {
for v in violations {
typed_errors
.push(ValidationError::SchemaViolation { path: v.path, message: v.message });
}
}
Err(super::schema::SchemaError::UnsupportedVersion { requested, supported }) => {
typed_errors.push(ValidationError::SchemaViolation {
path: "/version".to_owned(),
message: format!(
"AppData version `{requested}` is not backed by a bundled schema in \
this build (supported: {})",
supported.join(", ")
),
});
}
}
let string_errors: Vec<String> = typed_errors.iter().map(|e| e.to_string()).collect();
let success = typed_errors.is_empty();
ValidationResult { success, errors: string_errors, typed_errors }
}
pub async fn fetch_doc_from_cid(cid: &str, ipfs_uri: Option<&str>) -> Result<AppDataDoc, CowError> {
let base = ipfs_uri.map_or(DEFAULT_IPFS_READ_URI, |s| s);
let url = format!("{base}/{cid}");
let text = reqwest::get(&url).await?.text().await?;
serde_json::from_str(&text)
.map_err(|e| CowError::Parse { field: "app_data_doc", reason: e.to_string() })
}
pub async fn fetch_doc_from_app_data_hex(
app_data_hex: &str,
ipfs_uri: Option<&str>,
) -> Result<AppDataDoc, CowError> {
let cid = appdata_hex_to_cid(app_data_hex)?;
fetch_doc_from_cid(&cid, ipfs_uri).await
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct PinataResponse {
ipfs_hash: String,
}
pub async fn upload_app_data_to_pinata(doc: &AppDataDoc, ipfs: &Ipfs) -> Result<String, CowError> {
let api_key = ipfs
.pinata_api_key
.as_deref()
.ok_or_else(|| CowError::AppData("pinata_api_key is required for IPFS upload".into()))?;
let api_secret = ipfs
.pinata_api_secret
.as_deref()
.ok_or_else(|| CowError::AppData("pinata_api_secret is required for IPFS upload".into()))?;
let info = get_app_data_info(doc)?;
let write_uri = ipfs.write_uri.as_deref().map_or(DEFAULT_IPFS_WRITE_URI, |s| s);
let url = format!("{write_uri}/pinning/pinJSONToIPFS");
let content: serde_json::Value = serde_json::from_str(&info.app_data_content)
.map_err(|e| CowError::AppData(e.to_string()))?;
let body = json!({
"pinataContent": content,
"pinataOptions": { "cidVersion": 1 },
"pinataMetadata": { "name": info.app_data_hex }
});
let resp = reqwest::Client::new()
.post(&url)
.header("pinata_api_key", api_key)
.header("pinata_secret_api_key", api_secret)
.json(&body)
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
if status != 200 {
return Err(CowError::Api { status, body: text });
}
let pinata: PinataResponse =
serde_json::from_str(&text).map_err(|e| CowError::AppData(e.to_string()))?;
Ok(pinata.ipfs_hash)
}
#[allow(
clippy::type_complexity,
reason = "mirrors the TypeScript SDK's pluggable CID derivation pattern"
)]
fn app_data_to_cid_aux(
full_app_data: &str,
derive_cid: fn(&str) -> Result<String, CowError>,
) -> Result<AppDataInfo, CowError> {
let cid = derive_cid(full_app_data)?;
let app_data_hex = extract_digest(&cid)?;
if app_data_hex.is_empty() {
return Err(CowError::AppData(format!(
"Could not extract appDataHex from calculated cid {cid}"
)));
}
Ok(AppDataInfo { cid, app_data_content: full_app_data.to_owned(), app_data_hex })
}
#[allow(deprecated, reason = "wraps the deprecated legacy CID function intentionally")]
fn app_data_to_cid_legacy(full_app_data_json: &str) -> Result<String, CowError> {
let hash = alloy_primitives::keccak256(full_app_data_json.as_bytes());
let app_data_hex = format!("0x{}", alloy_primitives::hex::encode(hash.as_slice()));
super::cid::app_data_hex_to_cid_legacy(&app_data_hex)
}
#[deprecated(
note = "Use get_app_data_info instead — legacy CID encoding is no longer used by CoW Protocol"
)]
pub fn get_app_data_info_legacy(doc: &AppDataDoc) -> Result<AppDataInfo, CowError> {
let full_app_data = serde_json::to_string(doc).map_err(|e| CowError::AppData(e.to_string()))?;
app_data_to_cid_aux(&full_app_data, app_data_to_cid_legacy)
}
#[allow(
clippy::type_complexity,
reason = "mirrors the TypeScript SDK's pluggable hex-to-CID conversion pattern"
)]
async fn fetch_doc_from_cid_aux(
hex_to_cid: fn(&str) -> Result<String, CowError>,
app_data_hex: &str,
ipfs_uri: Option<&str>,
) -> Result<AppDataDoc, CowError> {
let cid = hex_to_cid(app_data_hex).map_err(|e| {
CowError::AppData(format!("Error decoding AppData: appDataHex={app_data_hex}, message={e}"))
})?;
if cid.is_empty() {
return Err(CowError::AppData("Error getting serialized CID".into()));
}
fetch_doc_from_cid(&cid, ipfs_uri).await
}
#[deprecated(
note = "Use fetch_doc_from_app_data_hex instead — legacy CID encoding is no longer used by CoW Protocol"
)]
#[allow(
deprecated,
reason = "this function is itself deprecated and wraps other deprecated functions"
)]
pub async fn fetch_doc_from_app_data_hex_legacy(
app_data_hex: &str,
ipfs_uri: Option<&str>,
) -> Result<AppDataDoc, CowError> {
fetch_doc_from_cid_aux(super::cid::app_data_hex_to_cid_legacy, app_data_hex, ipfs_uri).await
}
#[deprecated(
note = "Use upload_app_data_to_pinata instead — legacy Pinata pinning relied on implicit encoding"
)]
pub async fn upload_metadata_doc_to_ipfs_legacy(
doc: &AppDataDoc,
ipfs: &Ipfs,
) -> Result<IpfsUploadResult, CowError> {
let cid = upload_app_data_to_pinata_legacy(doc, ipfs).await?;
let app_data = extract_digest(&cid)?;
Ok(IpfsUploadResult { app_data, cid })
}
#[derive(Debug, Clone)]
pub struct IpfsUploadResult {
pub app_data: String,
pub cid: String,
}
impl fmt::Display for IpfsUploadResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ipfs-upload(cid={}, appData={})", self.cid, self.app_data)
}
}
async fn upload_app_data_to_pinata_legacy(
doc: &AppDataDoc,
ipfs: &Ipfs,
) -> Result<String, CowError> {
let api_key = ipfs
.pinata_api_key
.as_deref()
.ok_or_else(|| CowError::AppData("You need to pass IPFS api credentials.".into()))?;
let api_secret = ipfs
.pinata_api_secret
.as_deref()
.ok_or_else(|| CowError::AppData("You need to pass IPFS api credentials.".into()))?;
if api_key.is_empty() || api_secret.is_empty() {
return Err(CowError::AppData("You need to pass IPFS api credentials.".into()));
}
let content: serde_json::Value =
serde_json::to_value(doc).map_err(|e| CowError::AppData(e.to_string()))?;
let body = json!({
"pinataContent": content,
"pinataMetadata": { "name": "appData" }
});
let write_uri = ipfs.write_uri.as_deref().map_or(DEFAULT_IPFS_WRITE_URI, |s| s);
let url = format!("{write_uri}/pinning/pinJSONToIPFS");
let resp = reqwest::Client::new()
.post(&url)
.header("Content-Type", "application/json")
.header("pinata_api_key", api_key)
.header("pinata_secret_api_key", api_secret)
.json(&body)
.send()
.await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
if status != 200 {
return Err(CowError::Api { status, body: text });
}
let pinata: PinataResponse =
serde_json::from_str(&text).map_err(|e| CowError::AppData(e.to_string()))?;
Ok(pinata.ipfs_hash)
}
const KNOWN_SCHEMA_VERSIONS: &[&str] = &["0.7.0", "1.3.0"];
pub fn import_schema(version: &str) -> Result<AppDataDoc, CowError> {
let re_parts: Vec<&str> = version.split('.').collect();
if re_parts.len() != 3 || re_parts.iter().any(|p| p.parse::<u32>().is_err()) {
return Err(CowError::AppData(format!("AppData version {version} is not a valid version")));
}
if !KNOWN_SCHEMA_VERSIONS.contains(&version) {
return Err(CowError::AppData(format!("AppData version {version} doesn't exist")));
}
Ok(AppDataDoc {
version: version.to_owned(),
app_code: None,
environment: None,
metadata: Metadata::default(),
})
}
pub fn get_app_data_schema(version: &str) -> Result<AppDataDoc, CowError> {
import_schema(version).map_err(|e| CowError::AppData(format!("{e}")))
}
#[derive(Debug, Clone, Default)]
pub struct MetadataApi {
pub ipfs: Ipfs,
}
impl MetadataApi {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn with_ipfs(ipfs: Ipfs) -> Self {
Self { ipfs }
}
#[must_use]
pub fn generate_app_data_doc(&self, app_code: impl Into<String>) -> AppDataDoc {
AppDataDoc::new(app_code)
}
#[must_use]
pub fn validate_app_data_doc(&self, doc: &AppDataDoc) -> ValidationResult {
validate_app_data_doc(doc)
}
pub fn appdata_hex(&self, doc: &AppDataDoc) -> Result<B256, CowError> {
appdata_hex(doc)
}
pub fn get_app_data_info(&self, doc: &AppDataDoc) -> Result<AppDataInfo, CowError> {
get_app_data_info(doc)
}
pub fn get_app_data_info_from_str(&self, json: &str) -> Result<AppDataInfo, CowError> {
get_app_data_info_from_str(json)
}
pub fn app_data_hex_to_cid(&self, app_data_hex: &str) -> Result<String, CowError> {
appdata_hex_to_cid(app_data_hex)
}
pub fn cid_to_app_data_hex(&self, cid: &str) -> Result<String, CowError> {
cid_to_appdata_hex(cid)
}
pub async fn fetch_doc_from_cid(&self, cid: &str) -> Result<AppDataDoc, CowError> {
let uri = self.ipfs.read_uri.as_deref();
fetch_doc_from_cid(cid, uri).await
}
pub async fn fetch_doc_from_app_data_hex(
&self,
app_data_hex: &str,
) -> Result<AppDataDoc, CowError> {
let uri = self.ipfs.read_uri.as_deref();
fetch_doc_from_app_data_hex(app_data_hex, uri).await
}
pub async fn upload_app_data(&self, doc: &AppDataDoc) -> Result<String, CowError> {
upload_app_data_to_pinata(doc, &self.ipfs).await
}
#[deprecated(note = "Use get_app_data_info instead")]
#[allow(
deprecated,
reason = "this method is itself deprecated and delegates to a deprecated function"
)]
pub fn get_app_data_info_legacy(&self, doc: &AppDataDoc) -> Result<AppDataInfo, CowError> {
get_app_data_info_legacy(doc)
}
#[deprecated(note = "Use fetch_doc_from_app_data_hex instead")]
#[allow(
deprecated,
reason = "this method is itself deprecated and delegates to a deprecated function"
)]
pub async fn fetch_doc_from_app_data_hex_legacy(
&self,
app_data_hex: &str,
) -> Result<AppDataDoc, CowError> {
let uri = self.ipfs.read_uri.as_deref();
fetch_doc_from_app_data_hex_legacy(app_data_hex, uri).await
}
#[deprecated(note = "Use upload_app_data instead")]
#[allow(
deprecated,
reason = "this method is itself deprecated and delegates to a deprecated function"
)]
pub async fn upload_metadata_doc_to_ipfs_legacy(
&self,
doc: &AppDataDoc,
) -> Result<IpfsUploadResult, CowError> {
upload_metadata_doc_to_ipfs_legacy(doc, &self.ipfs).await
}
pub fn get_app_data_schema(&self, version: &str) -> Result<AppDataDoc, CowError> {
get_app_data_schema(version)
}
pub fn import_schema(&self, version: &str) -> Result<AppDataDoc, CowError> {
import_schema(version)
}
#[deprecated(note = "Use app_data_hex_to_cid instead")]
#[allow(
deprecated,
reason = "this method is itself deprecated and delegates to a deprecated function"
)]
pub fn app_data_hex_to_cid_legacy(&self, app_data_hex: &str) -> Result<String, CowError> {
super::cid::app_data_hex_to_cid_legacy(app_data_hex)
}
pub fn parse_cid(&self, ipfs_hash: &str) -> Result<super::cid::CidComponents, CowError> {
super::cid::parse_cid(ipfs_hash)
}
pub fn decode_cid(&self, bytes: &[u8]) -> Result<super::cid::CidComponents, CowError> {
super::cid::decode_cid(bytes)
}
pub fn extract_digest(&self, cid: &str) -> Result<String, CowError> {
super::cid::extract_digest(cid)
}
}
impl fmt::Display for MetadataApi {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("metadata-api")
}
}