use serde::{Deserialize, Serialize};
use crate::{
protocol::core::{
challenge::{PaymentChallenge, PaymentCredential, Receipt},
Base64UrlJson,
},
MppError,
};
#[cfg(feature = "client")]
pub mod client;
pub const PAYMENT_REQUIRED_CODE: i32 = -32042;
pub const PAYMENT_VERIFICATION_FAILED_CODE: i32 = -32043;
pub const CREDENTIAL_META_KEY: &str = "org.paymentauth/credential";
pub const PAYMENT_REQUIRED_META_KEY: &str = "org.paymentauth/payment-required";
pub const RECEIPT_META_KEY: &str = "org.paymentauth/receipt";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpReceipt {
#[serde(flatten)]
pub receipt: Receipt,
#[serde(rename = "challengeId")]
pub challenge_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPaymentError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<McpPaymentErrorData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPaymentErrorData {
#[serde(rename = "httpStatus")]
pub http_status: u16,
pub challenges: Vec<PaymentChallenge>,
#[serde(skip_serializing_if = "Option::is_none")]
pub problem: Option<crate::error::PaymentErrorDetails>,
}
pub fn extract_credential(meta: &serde_json::Value) -> Option<PaymentCredential> {
let mut credential = meta.get(CREDENTIAL_META_KEY)?.clone();
normalize_wire_challenge(credential.get_mut("challenge")?)?;
serde_json::from_value(credential).ok()
}
pub fn payment_required_error(challenge: &PaymentChallenge) -> McpPaymentError {
McpPaymentError {
code: PAYMENT_REQUIRED_CODE,
message: "Payment Required".to_string(),
data: Some(McpPaymentErrorData {
http_status: 402,
challenges: vec![challenge.clone()],
problem: None,
}),
}
}
pub fn payment_required_error_with_problem(
challenge: &PaymentChallenge,
mut problem: crate::error::PaymentErrorDetails,
) -> McpPaymentError {
problem.challenge_id = Some(challenge.id.clone());
McpPaymentError {
code: PAYMENT_REQUIRED_CODE,
message: problem.detail.clone(),
data: Some(McpPaymentErrorData {
http_status: 402,
challenges: vec![challenge.clone()],
problem: Some(problem),
}),
}
}
pub fn attach_receipt(result: &mut serde_json::Value, receipt: &Receipt, challenge_id: &str) {
let mcp_receipt = McpReceipt {
receipt: receipt.clone(),
challenge_id: challenge_id.to_string(),
};
let receipt_value =
serde_json::to_value(&mcp_receipt).expect("McpReceipt must be serializable");
let meta = result
.as_object_mut()
.expect("result must be a JSON object")
.entry("_meta")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
meta.as_object_mut()
.expect("_meta must be a JSON object")
.insert(RECEIPT_META_KEY.to_string(), receipt_value);
}
pub fn is_payment_required(error: &serde_json::Value) -> bool {
error
.get("code")
.and_then(|c| c.as_i64())
.is_some_and(|c| c == PAYMENT_REQUIRED_CODE as i64)
}
pub fn extract_challenges(error: &serde_json::Value) -> Option<Vec<PaymentChallenge>> {
extract_challenges_from_data(error.get("data")?)
}
pub fn extract_challenges_from_data(
payment_required: &serde_json::Value,
) -> Option<Vec<PaymentChallenge>> {
let challenges_value = payment_required.get("challenges")?;
extract_wire_challenges(challenges_value)
}
pub fn extract_result_challenges(meta: &serde_json::Value) -> Option<Vec<PaymentChallenge>> {
extract_challenges_from_data(meta.get(PAYMENT_REQUIRED_META_KEY)?)
}
fn extract_wire_challenges(value: &serde_json::Value) -> Option<Vec<PaymentChallenge>> {
value
.as_array()?
.iter()
.map(|challenge| {
let mut challenge = challenge.clone();
normalize_wire_challenge(&mut challenge)?;
serde_json::from_value(challenge).ok()
})
.collect()
}
fn normalize_wire_challenge(challenge: &mut serde_json::Value) -> Option<()> {
let object = challenge.as_object_mut()?;
let request = object.get("request")?;
if !request.is_string() {
let request = Base64UrlJson::from_value(request).ok()?;
object.insert(
"request".to_owned(),
serde_json::Value::String(request.raw().to_owned()),
);
}
if !object.contains_key("opaque") {
if let Some(meta) = object.get("meta") {
let opaque = Base64UrlJson::from_value(meta).ok()?;
object.insert(
"opaque".to_owned(),
serde_json::Value::String(opaque.raw().to_owned()),
);
}
}
Some(())
}
pub fn credential_value(credential: &PaymentCredential) -> Result<serde_json::Value, MppError> {
let mut value = serde_json::to_value(credential)
.map_err(|error| MppError::InvalidConfig(error.to_string()))?;
let challenge = value
.get_mut("challenge")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| MppError::InvalidConfig("credential challenge is invalid".to_owned()))?;
challenge.insert(
"request".to_owned(),
credential.challenge.request.decode_value()?,
);
if let Some(opaque) = &credential.challenge.opaque {
challenge.insert("meta".to_owned(), opaque.decode_value()?);
}
Ok(value)
}
pub fn attach_credential(params: &mut serde_json::Value, credential: &PaymentCredential) {
let cred_value = credential_value(credential)
.expect("PaymentCredential challenge must contain valid base64url JSON");
let meta = params
.as_object_mut()
.expect("params must be a JSON object")
.entry("_meta")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
meta.as_object_mut()
.expect("_meta must be a JSON object")
.insert(CREDENTIAL_META_KEY.to_string(), cred_value);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::core::challenge::{ChallengeEcho, PaymentPayload};
use crate::protocol::core::types::Base64UrlJson;
use serde_json::json;
fn test_challenge() -> PaymentChallenge {
PaymentChallenge::new(
"ch_test_123",
"api.example.com",
"tempo",
"charge",
Base64UrlJson::from_value(&json!({"amount": "1000", "currency": "USD"})).unwrap(),
)
}
fn test_credential() -> PaymentCredential {
PaymentCredential::with_source(
ChallengeEcho {
id: "ch_test_123".to_string(),
realm: "api.example.com".to_string(),
method: "tempo".into(),
intent: "charge".into(),
request: Base64UrlJson::from_raw("eyJhbW91bnQiOiIxMDAwIn0"),
expires: None,
digest: None,
opaque: None,
},
"did:pkh:eip155:42161:0xabc",
PaymentPayload::transaction("0xdeadbeef"),
)
}
fn test_receipt() -> Receipt {
Receipt::success("tempo", "0xtxhash123")
}
#[test]
fn test_mcp_payment_error_roundtrip() {
let challenge = test_challenge();
let error = payment_required_error(&challenge);
let json = serde_json::to_string(&error).unwrap();
let parsed: McpPaymentError = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.code, PAYMENT_REQUIRED_CODE);
assert_eq!(parsed.message, "Payment Required");
let data = parsed.data.unwrap();
assert_eq!(data.http_status, 402);
assert_eq!(data.challenges.len(), 1);
assert_eq!(data.challenges[0].id, "ch_test_123");
}
#[test]
fn test_mcp_payment_error_without_data() {
let error = McpPaymentError {
code: PAYMENT_REQUIRED_CODE,
message: "Payment Required".to_string(),
data: None,
};
let json = serde_json::to_string(&error).unwrap();
let parsed: McpPaymentError = serde_json::from_str(&json).unwrap();
assert!(parsed.data.is_none());
}
#[test]
fn test_mcp_payment_error_with_problem() {
use crate::error::PaymentErrorDetails;
let challenge = test_challenge();
let problem = PaymentErrorDetails::core("verification-failed")
.with_title("VerificationFailedError")
.with_status(402)
.with_detail("Payment verification failed: bad signature.");
let error = payment_required_error_with_problem(&challenge, problem);
assert_eq!(error.code, PAYMENT_REQUIRED_CODE);
assert_eq!(error.message, "Payment verification failed: bad signature.");
let json = serde_json::to_string(&error).unwrap();
let parsed: McpPaymentError = serde_json::from_str(&json).unwrap();
let data = parsed.data.unwrap();
assert_eq!(data.challenges[0].id, "ch_test_123");
let p = data.problem.unwrap();
assert_eq!(
p.problem_type,
"https://paymentauth.org/problems/verification-failed"
);
assert_eq!(p.title, "VerificationFailedError");
assert_eq!(p.status, 402);
assert_eq!(p.detail, "Payment verification failed: bad signature.");
assert_eq!(p.challenge_id.as_deref(), Some("ch_test_123"));
}
#[test]
fn test_extract_credential_valid() {
let cred = test_credential();
let meta = json!({
CREDENTIAL_META_KEY: cred,
});
let extracted = extract_credential(&meta).unwrap();
assert_eq!(extracted.challenge.id, "ch_test_123");
assert_eq!(
extracted.source.as_deref(),
Some("did:pkh:eip155:42161:0xabc")
);
}
#[test]
fn test_extract_credential_missing() {
let meta = json!({});
assert!(extract_credential(&meta).is_none());
}
#[test]
fn test_extract_credential_malformed() {
let meta = json!({
CREDENTIAL_META_KEY: "not-a-valid-credential",
});
assert!(extract_credential(&meta).is_none());
}
#[test]
fn test_extract_credential_null_value() {
let meta = json!({
CREDENTIAL_META_KEY: null,
});
assert!(extract_credential(&meta).is_none());
}
#[test]
fn test_payment_required_error_construction() {
let challenge = test_challenge();
let error = payment_required_error(&challenge);
assert_eq!(error.code, PAYMENT_REQUIRED_CODE);
assert_eq!(error.message, "Payment Required");
let data = error.data.as_ref().unwrap();
assert_eq!(data.http_status, 402);
assert_eq!(data.challenges.len(), 1);
assert_eq!(data.challenges[0].method.as_str(), "tempo");
assert_eq!(data.challenges[0].intent.as_str(), "charge");
}
#[test]
fn test_attach_receipt_to_empty_result() {
let receipt = test_receipt();
let mut result = json!({});
attach_receipt(&mut result, &receipt, "ch_test_123");
let meta = result.get("_meta").unwrap();
let mcp_receipt = meta.get(RECEIPT_META_KEY).unwrap();
assert_eq!(mcp_receipt["status"], "success");
assert_eq!(mcp_receipt["method"], "tempo");
assert_eq!(mcp_receipt["reference"], "0xtxhash123");
assert_eq!(mcp_receipt["challengeId"], "ch_test_123");
}
#[test]
fn test_attach_receipt_preserves_existing_meta() {
let receipt = test_receipt();
let mut result = json!({
"_meta": {
"other_key": "other_value"
},
"content": [{"type": "text", "text": "hello"}]
});
attach_receipt(&mut result, &receipt, "ch_456");
let meta = result.get("_meta").unwrap();
assert_eq!(meta["other_key"], "other_value");
assert!(meta.get(RECEIPT_META_KEY).is_some());
assert_eq!(result["content"][0]["text"], "hello");
}
#[test]
fn test_attach_receipt_deserializes_as_mcp_receipt() {
let receipt = test_receipt();
let mut result = json!({});
attach_receipt(&mut result, &receipt, "ch_789");
let receipt_value = result["_meta"][RECEIPT_META_KEY].clone();
let mcp_receipt: McpReceipt = serde_json::from_value(receipt_value).unwrap();
assert_eq!(mcp_receipt.challenge_id, "ch_789");
assert!(mcp_receipt.receipt.is_success());
}
#[test]
fn test_is_payment_required_matching() {
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"message": "Payment Required"
});
assert!(is_payment_required(&error));
}
#[test]
fn test_is_payment_required_wrong_code() {
let error = json!({
"code": -32600,
"message": "Invalid Request"
});
assert!(!is_payment_required(&error));
}
#[test]
fn test_is_payment_required_verification_failed_code() {
let error = json!({
"code": PAYMENT_VERIFICATION_FAILED_CODE,
"message": "Payment Verification Failed"
});
assert!(!is_payment_required(&error));
}
#[test]
fn test_is_payment_required_no_code() {
let error = json!({"message": "something"});
assert!(!is_payment_required(&error));
}
#[test]
fn test_extract_challenges_valid() {
let challenge = test_challenge();
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"message": "Payment Required",
"data": {
"httpStatus": 402,
"challenges": [challenge]
}
});
let challenges = extract_challenges(&error).unwrap();
assert_eq!(challenges.len(), 1);
assert_eq!(challenges[0].id, "ch_test_123");
}
#[test]
fn test_extract_challenges_accepts_expanded_mcp_request() {
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"message": "Payment Required",
"data": {
"httpStatus": 402,
"challenges": [{
"id": "mercator-challenge",
"realm": "mercator.example",
"method": "tempo",
"intent": "charge",
"request": {
"amount": "6000",
"currency": "0x20c000000000000000000000b9537d11c60e8b50",
"methodDetails": {"chainId": 4217}
},
"meta": {"scope": "job:123"}
}]
}
});
let challenges = extract_challenges(&error).unwrap();
assert_eq!(challenges[0].id, "mercator-challenge");
assert_eq!(
challenges[0].request.decode_value().unwrap()["amount"],
"6000"
);
assert_eq!(
challenges[0]
.opaque
.as_ref()
.unwrap()
.decode_value()
.unwrap()["scope"],
"job:123"
);
}
#[test]
fn test_extract_challenges_rejects_invalid_method_name() {
let mut challenge = serde_json::to_value(test_challenge()).unwrap();
challenge["method"] = json!("123");
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"message": "Payment Required",
"data": {
"httpStatus": 402,
"challenges": [challenge]
}
});
assert!(extract_challenges(&error).is_none());
}
#[test]
fn test_extract_challenges_no_data() {
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"message": "Payment Required"
});
assert!(extract_challenges(&error).is_none());
}
#[test]
fn test_extract_challenges_no_challenges_field() {
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"data": {"httpStatus": 402}
});
assert!(extract_challenges(&error).is_none());
}
#[test]
fn test_extract_challenges_multiple() {
let c1 = test_challenge();
let mut c2 = test_challenge();
c2.id = "ch_test_456".to_string();
c2.method = "base".into();
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"data": {
"httpStatus": 402,
"challenges": [c1, c2]
}
});
let challenges = extract_challenges(&error).unwrap();
assert_eq!(challenges.len(), 2);
assert_eq!(challenges[1].method.as_str(), "base");
}
#[test]
fn test_attach_credential_to_empty_params() {
let cred = test_credential();
let mut params = json!({"name": "premium_tool"});
attach_credential(&mut params, &cred);
let meta = params.get("_meta").unwrap();
let cred_value = meta.get(CREDENTIAL_META_KEY).unwrap();
assert_eq!(cred_value["challenge"]["id"], "ch_test_123");
assert_eq!(cred_value["challenge"]["request"]["amount"], "1000");
}
#[test]
fn test_mcp_credential_wire_roundtrip_preserves_opaque_meta() {
let mut credential = test_credential();
credential.challenge.opaque =
Some(Base64UrlJson::from_value(&json!({"scope": "job:123"})).unwrap());
let encoded = credential_value(&credential).unwrap();
assert_eq!(encoded["challenge"]["request"]["amount"], "1000");
assert_eq!(encoded["challenge"]["meta"]["scope"], "job:123");
let decoded = extract_credential(&json!({CREDENTIAL_META_KEY: encoded})).unwrap();
assert_eq!(
decoded.challenge.request.raw(),
credential.challenge.request.raw()
);
assert_eq!(
decoded.challenge.opaque.unwrap().raw(),
credential.challenge.opaque.unwrap().raw()
);
}
#[test]
fn test_attach_credential_preserves_existing_meta() {
let cred = test_credential();
let mut params = json!({
"name": "tool",
"_meta": {"progressToken": 42}
});
attach_credential(&mut params, &cred);
let meta = params.get("_meta").unwrap();
assert_eq!(meta["progressToken"], 42);
assert!(meta.get(CREDENTIAL_META_KEY).is_some());
}
#[test]
fn test_attach_credential_preserves_params() {
let cred = test_credential();
let mut params = json!({
"name": "tool",
"arguments": {"query": "test"}
});
attach_credential(&mut params, &cred);
assert_eq!(params["name"], "tool");
assert_eq!(params["arguments"]["query"], "test");
}
#[test]
fn test_mcp_receipt_serde_roundtrip() {
let mcp_receipt = McpReceipt {
receipt: test_receipt(),
challenge_id: "ch_abc".to_string(),
};
let json = serde_json::to_value(&mcp_receipt).unwrap();
assert_eq!(json["status"], "success");
assert_eq!(json["method"], "tempo");
assert_eq!(json["challengeId"], "ch_abc");
let parsed: McpReceipt = serde_json::from_value(json).unwrap();
assert_eq!(parsed.challenge_id, "ch_abc");
assert!(parsed.receipt.is_success());
}
#[test]
fn test_mcp_payment_roundtrip() {
let secret = "mcp-test-secret";
let challenge = PaymentChallenge::with_secret_key(
secret,
"api.example.com",
"tempo",
"charge",
Base64UrlJson::from_value(&json!({"amount": "1000", "currency": "USD"})).unwrap(),
);
assert!(challenge.verify(secret));
let error = payment_required_error(&challenge);
let error_json = serde_json::to_value(&error).unwrap();
assert!(is_payment_required(&error_json));
let challenges = extract_challenges(&error_json).unwrap();
assert_eq!(challenges.len(), 1);
assert_eq!(challenges[0].id, challenge.id);
assert_eq!(challenges[0].method.as_str(), "tempo");
assert_eq!(challenges[0].intent.as_str(), "charge");
let received = &challenges[0];
let credential = PaymentCredential::with_source(
received.to_echo(),
"did:pkh:eip155:42161:0xabc",
PaymentPayload::hash("0xtxhash_roundtrip"),
);
let mut params = json!({"name": "premium_tool", "arguments": {"query": "test"}});
attach_credential(&mut params, &credential);
assert!(params["_meta"][CREDENTIAL_META_KEY].is_object());
let meta = params.get("_meta").unwrap();
let extracted = extract_credential(meta).unwrap();
assert_eq!(extracted.challenge.id, challenge.id);
assert_eq!(extracted.challenge.realm, "api.example.com");
assert_eq!(
extracted.source.as_deref(),
Some("did:pkh:eip155:42161:0xabc")
);
let echoed_challenge = PaymentChallenge {
id: extracted.challenge.id.clone(),
realm: extracted.challenge.realm.clone(),
method: extracted.challenge.method.clone(),
intent: extracted.challenge.intent.clone(),
request: extracted.challenge.request.clone(),
expires: None,
description: None,
digest: None,
opaque: None,
};
assert!(echoed_challenge.verify(secret));
let receipt = Receipt::success("tempo", "0xtxhash_roundtrip");
let mut result = json!({"content": [{"type": "text", "text": "paid response"}]});
attach_receipt(&mut result, &receipt, &challenge.id);
let receipt_value = &result["_meta"][RECEIPT_META_KEY];
assert_eq!(receipt_value["status"], "success");
assert_eq!(receipt_value["method"], "tempo");
assert_eq!(receipt_value["reference"], "0xtxhash_roundtrip");
assert_eq!(receipt_value["challengeId"], challenge.id);
assert_eq!(result["content"][0]["text"], "paid response");
let mcp_receipt: McpReceipt = serde_json::from_value(receipt_value.clone()).unwrap();
assert_eq!(mcp_receipt.challenge_id, challenge.id);
assert!(mcp_receipt.receipt.is_success());
}
#[test]
fn test_verification_failed_code_not_payment_required() {
let error = json!({
"code": PAYMENT_VERIFICATION_FAILED_CODE,
"message": "Payment Verification Failed",
"data": {
"httpStatus": 403,
"reason": "invalid signature"
}
});
assert!(!is_payment_required(&error));
assert!(extract_challenges(&error).is_none());
}
}