use candid::{CandidType, Principal};
use ic_auth_types::ByteBufB64;
use ic_auth_types::deterministic_cbor_into_vec;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
use std::{
fmt::{self, Debug, Display, Formatter},
str::FromStr,
};
pub use serde_json::{Map, Value};
use crate::{SignedEnvelope, sha3_256};
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum X402Error {
#[error("insufficient_funds: {0}")]
InsufficientFunds(String),
#[error("invalid_payload_authorization_value: {0}")]
InvalidPayloadAuthorizationValue(String),
#[error("invalid_payload_signature: {0}")]
InvalidPayloadSignature(String),
#[error("invalid_payload_recipient_mismatch: {0}")]
InvalidPayloadRecipientMismatch(String),
#[error("invalid_network: {0}")]
InvalidNetwork(String),
#[error("invalid_payload: {0}")]
InvalidPayload(String),
#[error("invalid_payment_requirements: {0}")]
InvalidPaymentRequirements(String),
#[error("invalid_scheme: {0}")]
InvalidScheme(String),
#[error("unsupported_scheme: {0}")]
UnsupportedScheme(String),
#[error("invalid_x402_version: {0}")]
InvalidX402Version(u8),
#[error("invalid_transaction_state: {0}")]
InvalidTransactionState(String),
#[error("unexpected_verify_error: {0}")]
VerifyError(String),
#[error("unexpected_settle_error: {0}")]
SettleError(String),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentRequired {
pub x402_version: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub resource: ResourceInfo,
pub accepts: Vec<PaymentRequirements>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Extensions>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentRequirements {
pub scheme: String,
pub network: String,
pub amount: TokenAmount,
pub asset: Principal,
pub pay_to: Principal,
pub max_timeout_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<Map<String, Value>>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResourceInfo {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Extensions {
pub info: Map<String, Value>,
pub schema: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentPayload {
pub x402_version: u8,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource: Option<ResourceInfo>,
pub accepted: PaymentRequirements,
pub payload: IcpPayload,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<Extensions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IcpPayload {
pub signature: ByteBufB64,
pub authorization: IcpPayloadAuthorization,
}
impl IcpPayload {
pub fn verify_signature(
&self,
now_ms: u64,
expect_target: Option<Principal>,
) -> Result<Principal, String> {
let digest = self.authorization.digest();
let envelope = SignedEnvelope::from_bytes(&self.signature)?;
envelope.verify(now_ms, expect_target, Some(&digest))?;
Ok(envelope.sender())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IcpPayloadAuthorization {
pub to: Principal,
pub value: TokenAmount,
pub expires_at: u64,
pub nonce: u64,
}
impl IcpPayloadAuthorization {
pub fn digest(&self) -> [u8; 32] {
IcpPayloadAuthorizationRaw::from(self).digest()
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IcpPayloadAuthorizationRaw {
pub to: String,
pub value: String,
pub expires_at: u64,
pub nonce: u64,
}
impl From<&IcpPayloadAuthorization> for IcpPayloadAuthorizationRaw {
fn from(auth: &IcpPayloadAuthorization) -> Self {
IcpPayloadAuthorizationRaw {
to: auth.to.to_string(),
value: auth.value.to_string(),
expires_at: auth.expires_at,
nonce: auth.nonce,
}
}
}
impl IcpPayloadAuthorizationRaw {
pub fn digest(&self) -> [u8; 32] {
let data = deterministic_cbor_into_vec(&self)
.expect("failed to serialize IcpPayloadAuthorization");
sha3_256(&data)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct X402Request {
pub payment_payload: PaymentPayload,
pub payment_requirements: PaymentRequirements,
}
impl X402Request {
pub fn validate(&self) -> Result<(), X402Error> {
if self.payment_payload.accepted != self.payment_requirements {
return Err(X402Error::InvalidPaymentRequirements(
"Payment payload's accepted requirements do not match the provided payment requirements.".to_string(),
));
}
if self.payment_payload.payload.authorization.to != self.payment_requirements.pay_to {
return Err(X402Error::InvalidPayloadRecipientMismatch(format!(
"{}, expected: {}",
self.payment_payload.payload.authorization.to, self.payment_requirements.pay_to,
)));
}
if self.payment_payload.payload.authorization.value.0 != self.payment_requirements.amount.0
{
return Err(X402Error::InvalidPayloadAuthorizationValue(format!(
"{}, expected: {}",
self.payment_payload.payload.authorization.value, self.payment_requirements.amount
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyResponse {
pub is_valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub payer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub invalid_reason: Option<String>,
}
impl VerifyResponse {
pub fn valid(payer: String) -> Self {
VerifyResponse {
is_valid: true,
payer: Some(payer),
invalid_reason: None,
}
}
pub fn invalid(payer: Option<String>, reason: X402Error) -> Self {
VerifyResponse {
is_valid: false,
payer,
invalid_reason: Some(reason.to_string()),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SettleResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_reason: Option<String>,
pub transaction: String,
pub network: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub payer: Option<String>,
}
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq, Hash)]
pub struct TokenAmount(pub u128);
impl<'de> Deserialize<'de> for TokenAmount {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let string = String::deserialize(deserializer)?;
let value = u128::from_str(&string).map_err(serde::de::Error::custom)?;
Ok(TokenAmount(value))
}
}
impl Serialize for TokenAmount {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0.to_string())
}
}
impl Display for TokenAmount {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<u128> for TokenAmount {
fn from(value: u128) -> Self {
TokenAmount(value)
}
}
impl From<u64> for TokenAmount {
fn from(value: u64) -> Self {
TokenAmount(value as u128)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SupportedKind {
pub x402_version: u8,
pub scheme: String,
pub network: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<Map<String, Value>>,
}
#[derive(CandidType, Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SupportedKindCan {
pub x402_version: u8,
pub scheme: String,
pub network: String,
}
impl From<&SupportedKind> for SupportedKindCan {
fn from(kind: &SupportedKind) -> Self {
SupportedKindCan {
x402_version: kind.x402_version,
scheme: kind.scheme.clone(),
network: kind.network.clone(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SupportedResponse {
pub kinds: Vec<SupportedKind>,
pub extensions: Vec<Extensions>,
pub signers: BTreeMap<String, Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
use candid::Principal;
#[test]
fn test_token_amount_serialization() {
let amount = TokenAmount(123456789012345678901234567890);
let serialized = serde_json::to_string(&amount).unwrap();
assert_eq!(serialized, "\"123456789012345678901234567890\"");
let deserialized: TokenAmount = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized, amount);
}
#[test]
fn test_payment_requirements_serialization() {
let principal = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap();
let req = PaymentRequirements {
scheme: "exact".to_string(),
network: "icp:1".to_string(),
amount: TokenAmount(1000),
asset: principal,
pay_to: principal,
max_timeout_seconds: 300,
extra: None,
};
let json = serde_json::to_string(&req).unwrap();
let deserialized: PaymentRequirements = serde_json::from_str(&json).unwrap();
assert_eq!(req, deserialized);
}
#[test]
fn test_verify_response() {
let payer = Principal::anonymous().to_text();
let valid_response = VerifyResponse::valid(payer.clone());
assert!(valid_response.is_valid);
assert_eq!(valid_response.payer, Some(payer.clone()));
assert!(valid_response.invalid_reason.is_none());
let reason = X402Error::InsufficientFunds(100.to_string());
let invalid_response = VerifyResponse::invalid(Some(payer.clone()), reason.clone());
assert!(!invalid_response.is_valid);
assert_eq!(invalid_response.payer, Some(payer));
assert_eq!(invalid_response.invalid_reason, Some(reason.to_string()));
}
#[test]
fn test_icp_payload_authorization_digest() {
let auth = IcpPayloadAuthorization {
to: Principal::from_text(
"77ibd-jp5kr-moeco-kgoar-rro5v-5tng4-krif5-5h2i6-osf2f-2sjtv-kqe",
)
.unwrap(),
value: TokenAmount(100000000),
expires_at: 1761536123382,
nonce: 42,
};
let data = deterministic_cbor_into_vec(&auth)
.expect("failed to serialize IcpPayloadAuthorization");
println!("CBOR Data: {}", hex::encode(&data));
let digest = auth.digest();
let expected_hex = "269d40d6a23a75d9e4935d3010a8b8327115bb3dbadc7c311f43fec2445ae8f9"; assert_eq!(hex::encode(digest), expected_hex);
}
}