diff --git a/ows/crates/ows-pay/src/types.rs b/ows/crates/ows-pay/src/types.rs index 65482250..08a9427e 100644 --- a/ows/crates/ows-pay/src/types.rs +++ b/ows/crates/ows-pay/src/types.rs @@ -114,6 +114,8 @@ pub struct X402Response { pub accepts: Vec, #[serde(default)] pub resource: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extensions: Option, } /// The signed payment payload sent to the server in the payment header. @@ -142,6 +144,8 @@ pub struct PaymentPayloadV2 { pub accepted: PaymentRequirements, pub resource: Option, pub payload: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extensions: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/ows/crates/ows-pay/src/x402.rs b/ows/crates/ows-pay/src/x402.rs index eb27a04a..9ce2eef0 100644 --- a/ows/crates/ows-pay/src/x402.rs +++ b/ows/crates/ows-pay/src/x402.rs @@ -22,11 +22,11 @@ pub(crate) async fn handle_x402( resp_headers: &reqwest::header::HeaderMap, body_402: &str, ) -> Result { - let (x402_version, resource, requirements) = parse_requirements(resp_headers, body_402)?; + let (x402_version, resource, extensions, requirements) = parse_requirements(resp_headers, body_402)?; let (req, network) = pick_payment_option(wallet, &requirements)?; let (payload, payment_info) = - build_signed_payment(wallet, req, &network, x402_version, resource)?; + build_signed_payment(wallet, req, &network, x402_version, resource, extensions.clone())?; let payload_json = serde_json::to_string(&payload)?; let payload_b64 = B64.encode(payload_json.as_bytes()); @@ -58,9 +58,10 @@ fn build_signed_payment( network: &str, x402_version: u32, resource: Option, + extensions: Option, ) -> Result<(PaymentPayload, PaymentInfo), PayError> { match req.scheme.as_str() { - "exact" => build_evm_exact(wallet, req, network, x402_version, resource), + "exact" => build_evm_exact(wallet, req, network, x402_version, resource, extensions), scheme => Err(PayError::new( PayErrorCode::ProtocolUnknown, format!("unsupported payment scheme: {scheme}"), @@ -75,6 +76,7 @@ fn build_evm_exact( network: &str, x402_version: u32, resource: Option, + extensions: Option, ) -> Result<(PaymentPayload, PaymentInfo), PayError> { let account = wallet.account(network)?; @@ -162,6 +164,7 @@ fn build_evm_exact( accepted: req.clone(), resource, payload: inner, + extensions, }) } else { PaymentPayload::V1(PaymentPayloadV1 { @@ -189,7 +192,7 @@ fn build_evm_exact( fn parse_requirements( headers: &reqwest::header::HeaderMap, body_text: &str, -) -> Result<(u32, Option, Vec), PayError> { +) -> Result<(u32, Option, Option, Vec), PayError> { for header_name in &[HEADER_PAYMENT_REQUIRED_V2, HEADER_PAYMENT_REQUIRED] { if let Some(header_val) = headers.get(*header_name) { if let Ok(header_str) = header_val.to_str() { @@ -200,7 +203,7 @@ fn parse_requirements( HEADER_PAYMENT_REQUIRED_V2 => parsed.x402_version.unwrap_or(2), _ => parsed.x402_version.unwrap_or(1), }; - return Ok((version, parsed.resource, parsed.accepts)); + return Ok((version, parsed.resource, parsed.extensions, parsed.accepts)); } } } @@ -225,6 +228,7 @@ fn parse_requirements( Ok(( parsed.x402_version.unwrap_or(1), parsed.resource, + parsed.extensions, parsed.accepts, )) } @@ -579,7 +583,7 @@ mod tests { }) .to_string(); - let (_, _, reqs) = parse_requirements(&headers, &body).unwrap(); + let (_, _, _, reqs) = parse_requirements(&headers, &body).unwrap(); assert_eq!(reqs.len(), 1); assert_eq!(reqs[0].scheme, "exact"); assert_eq!(reqs[0].network, "eip155:8453"); @@ -601,7 +605,7 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("x-payment-required", encoded.parse().unwrap()); - let (_, _, reqs) = parse_requirements(&headers, "not json").unwrap(); + let (_, _, _, reqs) = parse_requirements(&headers, "not json").unwrap(); assert_eq!(reqs.len(), 1); assert_eq!(reqs[0].pay_to, "0xdef"); } @@ -622,7 +626,7 @@ mod tests { }) .to_string(); - let (_, _, reqs) = parse_requirements(&headers, &body).unwrap(); + let (_, _, _, reqs) = parse_requirements(&headers, &body).unwrap(); assert_eq!(reqs[0].pay_to, "0xbbb"); } @@ -642,7 +646,7 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("payment-required", encoded.parse().unwrap()); - let (_, _, reqs) = parse_requirements(&headers, "not json").unwrap(); + let (_, _, _, reqs) = parse_requirements(&headers, "not json").unwrap(); assert_eq!(reqs.len(), 1); assert_eq!(reqs[0].pay_to, "0xv2"); } @@ -663,7 +667,7 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("payment-required", encoded.parse().unwrap()); - let (version, _, reqs) = parse_requirements(&headers, "not json").unwrap(); + let (version, _, _, reqs) = parse_requirements(&headers, "not json").unwrap(); assert_eq!(version, 2); assert_eq!(reqs.len(), 1); assert_eq!(reqs[0].pay_to, "0xv2"); @@ -689,10 +693,10 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("payment-required", encoded.parse().unwrap()); - let (version, resource, reqs) = parse_requirements(&headers, "not json").unwrap(); + let (version, resource, extensions, reqs) = parse_requirements(&headers, "not json").unwrap(); let (req, network) = pick_payment_option(&EvmWallet, &reqs).unwrap(); let (payload, _) = - build_signed_payment(&EvmWallet, req, &network, version, resource).unwrap(); + build_signed_payment(&EvmWallet, req, &network, version, resource, extensions).unwrap(); match payload { PaymentPayload::V2(v2) => { @@ -725,7 +729,7 @@ mod tests { .unwrap(), ); - let (_, _, reqs) = parse_requirements(&headers, "not json").unwrap(); + let (_, _, _, reqs) = parse_requirements(&headers, "not json").unwrap(); assert_eq!(reqs[0].pay_to, "0xv2"); } @@ -876,7 +880,7 @@ mod tests { #[test] fn build_evm_exact_produces_valid_payload() { let req = base_requirement(); - let (payload, info) = build_evm_exact(&EvmWallet, &req, "eip155:8453", 1, None).unwrap(); + let (payload, info) = build_evm_exact(&EvmWallet, &req, "eip155:8453", 1, None, None).unwrap(); let v1 = match &payload { PaymentPayload::V1(p) => p, @@ -906,7 +910,7 @@ mod tests { "mimeType": "application/json" }); let (payload, _) = - build_evm_exact(&EvmWallet, &req, "eip155:8453", 2, Some(resource.clone())).unwrap(); + build_evm_exact(&EvmWallet, &req, "eip155:8453", 2, Some(resource.clone()), None).unwrap(); let v2 = match &payload { PaymentPayload::V2(p) => p, @@ -928,7 +932,7 @@ mod tests { #[test] fn build_evm_exact_v2_with_no_resource() { let req = base_requirement(); - let (payload, _) = build_evm_exact(&EvmWallet, &req, "eip155:8453", 2, None).unwrap(); + let (payload, _) = build_evm_exact(&EvmWallet, &req, "eip155:8453", 2, None, None).unwrap(); let v2 = match &payload { PaymentPayload::V2(p) => p, @@ -945,7 +949,7 @@ mod tests { req.description = None; req.resource = None; - let (payload, _) = build_evm_exact(&EvmWallet, &req, "eip155:8453", 2, None).unwrap(); + let (payload, _) = build_evm_exact(&EvmWallet, &req, "eip155:8453", 2, None, None).unwrap(); let encoded = serde_json::to_value(payload).unwrap(); let accepted = &encoded["accepted"]; @@ -957,7 +961,7 @@ mod tests { #[test] fn build_evm_exact_fails_for_non_numeric_chain_id() { let req = base_requirement(); - let err = build_evm_exact(&EvmWallet, &req, "solana:mainnet", 1, None).unwrap_err(); + let err = build_evm_exact(&EvmWallet, &req, "solana:mainnet", 1, None, None).unwrap_err(); assert_eq!(err.code, PayErrorCode::ProtocolMalformed); } @@ -982,7 +986,7 @@ mod tests { .to_string(); let headers = HeaderMap::new(); - let (_, _, reqs) = parse_requirements(&headers, &body).unwrap(); + let (_, _, _, reqs) = parse_requirements(&headers, &body).unwrap(); let (req, network) = pick_payment_option(&EvmWallet, &reqs).unwrap(); assert_eq!(req.pay_to, "0x7d9d1821d15B9e0b8Ab98A058361233E255E405D"); assert_eq!(network, "eip155:8453"); // "base" resolved to CAIP-2 @@ -1082,4 +1086,47 @@ mod tests { PaymentPayload::V1(_) => panic!("expected v2 payload for payment-required flow"), } } + + #[test] + fn build_evm_exact_v2_forwards_extensions() { + let mut req = base_requirement(); + req.extra = serde_json::json!({"name": "USD Coin", "version": "2"}); + + let extensions = serde_json::json!({ + "bazaar": { + "description": "Test service", + "input": {"type": "string"}, + "output": {"type": "string"} + } + }); + + let (payload, _) = build_evm_exact( + &EvmWallet, &req, "eip155:8453", 2, None, Some(extensions.clone()) + ).unwrap(); + + match payload { + PaymentPayload::V2(v2) => { + assert_eq!(v2.extensions, Some(extensions), "extensions must be forwarded in v2 payload"); + } + _ => panic!("expected V2 payload"), + } + } + + #[test] + fn build_evm_exact_v2_no_extensions_is_none() { + let mut req = base_requirement(); + req.extra = serde_json::json!({"name": "USD Coin", "version": "2"}); + + let (payload, _) = build_evm_exact( + &EvmWallet, &req, "eip155:8453", 2, None, None + ).unwrap(); + + match payload { + PaymentPayload::V2(v2) => { + assert!(v2.extensions.is_none(), "extensions must be None when not provided"); + } + _ => panic!("expected V2 payload"), + } + } + }