Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ows/crates/ows-pay/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ pub struct X402Response {
pub accepts: Vec<PaymentRequirements>,
#[serde(default)]
pub resource: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extensions: Option<serde_json::Value>,
}

/// The signed payment payload sent to the server in the payment header.
Expand Down Expand Up @@ -142,6 +144,8 @@ pub struct PaymentPayloadV2 {
pub accepted: PaymentRequirements,
pub resource: Option<serde_json::Value>,
pub payload: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extensions: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down
85 changes: 66 additions & 19 deletions ows/crates/ows-pay/src/x402.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ pub(crate) async fn handle_x402(
resp_headers: &reqwest::header::HeaderMap,
body_402: &str,
) -> Result<PayResult, PayError> {
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());
Expand Down Expand Up @@ -58,9 +58,10 @@ fn build_signed_payment(
network: &str,
x402_version: u32,
resource: Option<serde_json::Value>,
extensions: Option<serde_json::Value>,
) -> 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}"),
Expand All @@ -75,6 +76,7 @@ fn build_evm_exact(
network: &str,
x402_version: u32,
resource: Option<serde_json::Value>,
extensions: Option<serde_json::Value>,
) -> Result<(PaymentPayload, PaymentInfo), PayError> {
let account = wallet.account(network)?;

Expand Down Expand Up @@ -162,6 +164,7 @@ fn build_evm_exact(
accepted: req.clone(),
resource,
payload: inner,
extensions,
})
} else {
PaymentPayload::V1(PaymentPayloadV1 {
Expand Down Expand Up @@ -189,7 +192,7 @@ fn build_evm_exact(
fn parse_requirements(
headers: &reqwest::header::HeaderMap,
body_text: &str,
) -> Result<(u32, Option<serde_json::Value>, Vec<PaymentRequirements>), PayError> {
) -> Result<(u32, Option<serde_json::Value>, Option<serde_json::Value>, Vec<PaymentRequirements>), 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() {
Expand All @@ -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));
}
}
}
Expand All @@ -225,6 +228,7 @@ fn parse_requirements(
Ok((
parsed.x402_version.unwrap_or(1),
parsed.resource,
parsed.extensions,
parsed.accepts,
))
}
Expand Down Expand Up @@ -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");
Expand All @@ -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");
}
Expand All @@ -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");
}

Expand All @@ -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");
}
Expand All @@ -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");
Expand All @@ -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) => {
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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"];

Expand All @@ -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);
}

Expand All @@ -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
Expand Down Expand Up @@ -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"),
}
}

}