From 641273f4217f57df7dc92d7352eff335d3bf21bc Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Wed, 15 Oct 2025 18:18:56 -0400 Subject: [PATCH 01/10] x402 not real yet, just a checkpoint commit because I'm going to have Claude get real jiggy with it --- metadata.json | 5 -- provider/provider/Cargo.toml | 4 +- provider/provider/src/lib.rs | 158 ++++++++++++++++++++++++++++++++++- 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/metadata.json b/metadata.json index 4e08c90..53052cc 100644 --- a/metadata.json +++ b/metadata.json @@ -4,13 +4,8 @@ "image": "https://raw.githubusercontent.com/hyperware-ai/hpn/ccd0e9c0d08b2344b06ce4a5b8584f819b92e43e/hypergrid-logo.webp", "properties": { "package_name": "hypergrid", -<<<<<<< Updated upstream "current_version": "1.2.1", - "publisher": "ware.hypr", -======= - "current_version": "1.2.2", "publisher": "test.hypr", ->>>>>>> Stashed changes "mirrors": ["ware.hypr","sam.hypr", "backup-distro-node.os"], "code_hashes": { "1.0.0": "001a49117374abc3bdb38179d8ce05d76205b008bb55683e116be36f3e1635ce", diff --git a/provider/provider/Cargo.toml b/provider/provider/Cargo.toml index 158508a..d2d70d6 100644 --- a/provider/provider/Cargo.toml +++ b/provider/provider/Cargo.toml @@ -14,7 +14,7 @@ optional = true path = "../target/caller-utils" [dependencies.hyperprocess_macro] -branch = "develop" +branch = "set-response-body" git = "https://github.com/hyperware-ai/hyperprocess-macro" [dependencies.hyperware_process_lib] @@ -22,7 +22,7 @@ features = [ "hyperapp", "logging", ] -branch = "develop" +branch = "set-response-body" git = "https://github.com/hyperware-ai/process_lib" [dependencies.serde] diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index a039043..ac58fcf 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -4,12 +4,13 @@ use hyperware_process_lib::logging::RemoteLogSettings; use hyperware_process_lib::{ eth::{Provider, Address as EthAddress}, get_state, + http::StatusCode, hypermap, logging::{debug, error, info, warn, init_logging, Level}, our, vfs::{create_drive, create_file, open_file}, Address, - hyperapp::{source, SaveOptions, sleep, get_server}, + hyperapp::{source, SaveOptions, sleep, get_server, set_response_status, set_response_body, add_response_header, APP_HELPERS}, }; use crate::constants::HYPR_SUFFIX; use rmp_serde; @@ -52,6 +53,37 @@ pub struct ValidateAndRegisterRequest { pub validation_arguments: Vec<(String, String)>, } +// x402 payment protocol structures +// These use camelCase field names per x402 spec (not Rust's snake_case convention) +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentRequirements { + pub x402_version: u8, + pub accepts: Vec, + pub error: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcceptedPayment { + pub scheme: String, + pub network: String, + pub max_amount_required: String, // USDC in atomic units (6 decimals) + pub resource: String, + pub description: String, + pub mime_type: String, + pub pay_to: String, // Ethereum address + pub max_timeout_seconds: u64, + pub asset: String, // USDC contract address + pub extra: ExtraData, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExtraData { + pub name: String, + pub version: String, +} + // Type system for API endpoints #[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] pub enum HttpMethod { @@ -357,6 +389,10 @@ impl Default for HypergridProviderState { path: "/api", config: HttpBindingConfig::new(false, false, false, None), }, + Binding::Http { + path: "/xfour", + config: HttpBindingConfig::new(false, false, false, None), + }, Binding::Ws { path: "/ws", config: WsBindingConfig::new(false, false, false), @@ -843,6 +879,126 @@ impl HypergridProviderState { self.export_providers_json() } + /// HTTP 402 Payment Required endpoint for x402 micropayment protocol + /// + /// This endpoint implements the x402 payment flow: + /// 1. Initial request: Client sends query params (providername + provider args), gets 402 response with PaymentRequirements + /// 2. Payment retry: Client retries with X-PAYMENT header (not yet implemented) + /// 3. Final response: After payment validation, return actual provider response (not yet implemented) + #[http(path = "/xfour")] + async fn handle_xfour(&self) -> Result { + info!("x402 endpoint called"); + + // ===== QUERY PARAMETER VALIDATION ===== + // Get query params using APP_HELPERS pattern + let params = APP_HELPERS.with(|helpers| { + helpers + .borrow() + .current_http_context + .as_ref() + .map(|ctx| ctx.request.query_params().clone()) + }); + + // Check if params exist and are non-empty + let params = match params { + Some(p) if !p.is_empty() => p, + _ => { + // Return 400 Bad Request if no query parameters + let error_json = serde_json::json!({ + "error": "Missing query parameters. Expected ?providername=...&..." + }); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::BAD_REQUEST); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + }; + + // ===== PROVIDER NAME EXTRACTION ===== + let provider_name = match params.get("providername") { + Some(name) => name, + None => { + // Return 400 Bad Request if providername missing + let error_json = serde_json::json!({ + "error": "Missing required parameter: providername" + }); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::BAD_REQUEST); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + }; + + // ===== PROVIDER LOOKUP ===== + let provider = match self.registered_providers.iter().find(|p| &p.provider_name == provider_name) { + Some(p) => p, + None => { + // Return 404 Not Found if provider doesn't exist + let error_json = serde_json::json!({ + "error": format!("Provider not found: {}", provider_name) + }); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::NOT_FOUND); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + }; + + info!("Provider '{}' found, building payment requirements", provider_name); + + // ===== GET FULL REQUEST URL FOR RESOURCE FIELD ===== + let resource_url = APP_HELPERS.with(|helpers| { + helpers + .borrow() + .current_http_context + .as_ref() + .and_then(|ctx| ctx.request.url().ok()) + .map(|url| url.to_string()) + .unwrap_or_else(|| format!("http://unknown/provider:hypergrid:test.hypr/xfour?providername={}", provider_name)) + }); + + // ===== CONVERT PRICE TO ATOMIC UNITS ===== + // USDC has 6 decimal places: 1 USDC = 1,000,000 atomic units + let max_amount_atomic = ((provider.price * 1_000_000.0) as u64).to_string(); + + // ===== BUILD X402 PAYMENT REQUIREMENTS ===== + let accepted_payment = AcceptedPayment { + scheme: "exact".to_string(), + network: "base-sepolia".to_string(), + max_amount_required: max_amount_atomic, + resource: resource_url, + description: provider.description.clone(), + mime_type: "application/json".to_string(), + pay_to: provider.registered_provider_wallet.clone(), + max_timeout_seconds: 60, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".to_string(), // Base Sepolia USDC + extra: ExtraData { + name: "USD Coin".to_string(), + version: "2".to_string(), + }, + }; + + let payment_reqs = PaymentRequirements { + x402_version: 1, + accepts: vec![accepted_payment], + error: "".to_string(), + }; + + // ===== SERIALIZE AND SET RESPONSE ===== + let payment_json = serde_json::to_vec(&payment_reqs) + .map_err(|e| format!("Failed to serialize payment requirements: {}", e))?; + + set_response_body(payment_json); + set_response_status(StatusCode::PAYMENT_REQUIRED); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + + info!("Returning 402 Payment Required for provider '{}'", provider_name); + Ok("".to_string()) + } + #[http] async fn get_provider_namehash(&self, provider_name: String) -> Result { debug!("Getting namehash for provider: {}", provider_name); From 7c6b404ef2f6545cd9f5de292223dac2ccf150e1 Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Wed, 15 Oct 2025 19:39:52 -0400 Subject: [PATCH 02/10] Update lib.rs Checkpoint commit, some jank when calling out to the facilitator that I can't figure out easily --- provider/provider/src/lib.rs | 450 ++++++++++++++++++++++++++++++----- 1 file changed, 393 insertions(+), 57 deletions(-) diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index ac58fcf..cfe17ac 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -4,7 +4,10 @@ use hyperware_process_lib::logging::RemoteLogSettings; use hyperware_process_lib::{ eth::{Provider, Address as EthAddress}, get_state, - http::StatusCode, + http::{ + StatusCode, + Method as HyperwareHttpMethod, + }, hypermap, logging::{debug, error, info, warn, init_logging, Level}, our, @@ -13,11 +16,12 @@ use hyperware_process_lib::{ hyperapp::{source, SaveOptions, sleep, get_server, set_response_status, set_response_body, add_response_header, APP_HELPERS}, }; use crate::constants::HYPR_SUFFIX; +use base64ct::{Base64, Encoding}; use rmp_serde; use serde::{Deserialize, Serialize}; use serde_json; use std::str::FromStr; // Needed for EthAddress::from_str -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; pub const CHAIN_ID: u64 = hypermap::HYPERMAP_CHAIN_ID; @@ -58,7 +62,8 @@ pub struct ValidateAndRegisterRequest { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PaymentRequirements { - pub x402_version: u8, + #[serde(rename = "x402Version")] + pub protocol_version: u8, pub accepts: Vec, pub error: String, } @@ -84,6 +89,65 @@ pub struct ExtraData { pub version: String, } +// X-PAYMENT header payload structures (from x402 client) +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentPayload { + #[serde(rename = "x402Version")] + pub protocol_version: u8, + pub scheme: String, + pub network: String, + pub payload: ExactPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExactPayload { + pub signature: String, + pub authorization: Authorization, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Authorization { + pub from: String, + pub to: String, + pub value: String, + pub valid_after: String, + pub valid_before: String, + pub nonce: String, +} + +// Facilitator API request/response structures +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FacilitatorVerifyRequest { + #[serde(rename = "x402Version")] + pub protocol_version: u8, + pub payment_header: String, // Raw base64 X-PAYMENT header string (per GitHub spec) + pub payment_requirements: AcceptedPayment, // Single payment method, not the full PaymentRequirements wrapper +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyResponse { + pub is_valid: bool, + pub payer: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub invalid_reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettleResponse { + pub success: bool, + pub payer: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction: Option, + pub network: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_reason: Option, +} + // Type system for API endpoints #[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] pub enum HttpMethod { @@ -241,6 +305,9 @@ pub struct RegisteredProvider { pub struct HypergridProviderState { pub registered_providers: Vec, pub spent_tx_hashes: Vec, + // TODO: Replace with persistent storage for production - tracks used payment nonces to prevent replay attacks + #[serde(skip)] + pub used_nonces: HashSet, #[serde(skip, default = "util::default_provider")] pub rpc_provider: Provider, #[serde(skip, default = "util::default_hypermap")] @@ -267,6 +334,7 @@ impl HypergridProviderState { Self { registered_providers: Vec::new(), spent_tx_hashes: Vec::new(), + used_nonces: HashSet::new(), rpc_provider: provider.clone(), hypermap: hypermap::Hypermap::new(provider.clone(), hypermap_contract_address), vfs_drive_path: None, @@ -380,6 +448,49 @@ impl Default for HypergridProviderState { } } +// x402 helper functions (standalone, not in impl block to avoid WIT export requirements) + +/// Parse X-PAYMENT header value: base64 decode and deserialize to PaymentPayload +fn parse_x_payment_header(header_value: &str) -> Result { + // Allocate buffer for decoded data (base64 decoding produces smaller output than input) + let max_decoded_len = (header_value.len() * 3) / 4 + 3; + let mut decoded_bytes = vec![0u8; max_decoded_len]; + + let decoded_slice = Base64::decode(header_value.as_bytes(), &mut decoded_bytes) + .map_err(|e| format!("Failed to base64 decode X-PAYMENT header: {}", e))?; + + serde_json::from_slice(decoded_slice) + .map_err(|e| format!("Failed to parse X-PAYMENT JSON: {}", e)) +} + +/// Build PaymentRequirements structure from provider and resource URL +fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) -> PaymentRequirements { + // Convert USDC price to atomic units (6 decimals) + let max_amount_atomic = ((provider.price * 1_000_000.0) as u64).to_string(); + + let accepted_payment = AcceptedPayment { + scheme: "exact".to_string(), + network: "base-sepolia".to_string(), + max_amount_required: max_amount_atomic, + resource: resource_url.to_string(), + description: provider.description.clone(), + mime_type: "application/json".to_string(), + pay_to: provider.registered_provider_wallet.clone(), + max_timeout_seconds: 60, + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".to_string(), // Base Sepolia USDC + extra: ExtraData { + name: "USD Coin".to_string(), + version: "2".to_string(), + }, + }; + + PaymentRequirements { + protocol_version: 1, + accepts: vec![accepted_payment], + error: "".to_string(), + } +} + // --- Hyperware Process --- #[hyperprocess( name = "provider", @@ -883,14 +994,22 @@ impl HypergridProviderState { /// /// This endpoint implements the x402 payment flow: /// 1. Initial request: Client sends query params (providername + provider args), gets 402 response with PaymentRequirements - /// 2. Payment retry: Client retries with X-PAYMENT header (not yet implemented) - /// 3. Final response: After payment validation, return actual provider response (not yet implemented) + /// 2. Payment retry: Client retries with X-PAYMENT header containing signed payment authorization + /// 3. Final response: After payment validation, return actual provider response with X-PAYMENT-RESPONSE header #[http(path = "/xfour")] - async fn handle_xfour(&self) -> Result { + async fn handle_xfour(&mut self) -> Result { info!("x402 endpoint called"); - // ===== QUERY PARAMETER VALIDATION ===== - // Get query params using APP_HELPERS pattern + // ===== CHECK FOR X-PAYMENT HEADER ===== + let x_payment_header = APP_HELPERS.with(|helpers| { + helpers + .borrow() + .current_http_context + .as_ref() + .and_then(|ctx| ctx.request.headers().get("x-payment").cloned()) + }); + + // ===== SHARED: QUERY PARAMETER VALIDATION ===== let params = APP_HELPERS.with(|helpers| { helpers .borrow() @@ -899,14 +1018,10 @@ impl HypergridProviderState { .map(|ctx| ctx.request.query_params().clone()) }); - // Check if params exist and are non-empty let params = match params { Some(p) if !p.is_empty() => p, _ => { - // Return 400 Bad Request if no query parameters - let error_json = serde_json::json!({ - "error": "Missing query parameters. Expected ?providername=...&..." - }); + let error_json = serde_json::json!({"error": "Missing query parameters. Expected ?providername=...&..."}); let error_bytes = serde_json::to_vec(&error_json).unwrap(); set_response_body(error_bytes); set_response_status(StatusCode::BAD_REQUEST); @@ -915,14 +1030,11 @@ impl HypergridProviderState { } }; - // ===== PROVIDER NAME EXTRACTION ===== + // ===== SHARED: PROVIDER NAME EXTRACTION ===== let provider_name = match params.get("providername") { Some(name) => name, None => { - // Return 400 Bad Request if providername missing - let error_json = serde_json::json!({ - "error": "Missing required parameter: providername" - }); + let error_json = serde_json::json!({"error": "Missing required parameter: providername"}); let error_bytes = serde_json::to_vec(&error_json).unwrap(); set_response_body(error_bytes); set_response_status(StatusCode::BAD_REQUEST); @@ -931,14 +1043,11 @@ impl HypergridProviderState { } }; - // ===== PROVIDER LOOKUP ===== - let provider = match self.registered_providers.iter().find(|p| &p.provider_name == provider_name) { + // ===== SHARED: PROVIDER LOOKUP ===== + let provider = match self.registered_providers.iter().find(|p| &p.provider_name == provider_name).cloned() { Some(p) => p, None => { - // Return 404 Not Found if provider doesn't exist - let error_json = serde_json::json!({ - "error": format!("Provider not found: {}", provider_name) - }); + let error_json = serde_json::json!({"error": format!("Provider not found: {}", provider_name)}); let error_bytes = serde_json::to_vec(&error_json).unwrap(); set_response_body(error_bytes); set_response_status(StatusCode::NOT_FOUND); @@ -947,47 +1056,274 @@ impl HypergridProviderState { } }; - info!("Provider '{}' found, building payment requirements", provider_name); - - // ===== GET FULL REQUEST URL FOR RESOURCE FIELD ===== + // ===== SHARED: GET RESOURCE URL ===== let resource_url = APP_HELPERS.with(|helpers| { - helpers - .borrow() - .current_http_context - .as_ref() + helpers.borrow().current_http_context.as_ref() .and_then(|ctx| ctx.request.url().ok()) .map(|url| url.to_string()) .unwrap_or_else(|| format!("http://unknown/provider:hypergrid:test.hypr/xfour?providername={}", provider_name)) }); - // ===== CONVERT PRICE TO ATOMIC UNITS ===== - // USDC has 6 decimal places: 1 USDC = 1,000,000 atomic units - let max_amount_atomic = ((provider.price * 1_000_000.0) as u64).to_string(); - - // ===== BUILD X402 PAYMENT REQUIREMENTS ===== - let accepted_payment = AcceptedPayment { - scheme: "exact".to_string(), - network: "base-sepolia".to_string(), - max_amount_required: max_amount_atomic, - resource: resource_url, - description: provider.description.clone(), - mime_type: "application/json".to_string(), - pay_to: provider.registered_provider_wallet.clone(), - max_timeout_seconds: 60, - asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".to_string(), // Base Sepolia USDC - extra: ExtraData { - name: "USD Coin".to_string(), - version: "2".to_string(), - }, - }; + // ===== BRANCH: PAYMENT VERIFICATION FLOW ===== + if let Some(x_payment_value) = x_payment_header { + info!("X-PAYMENT header detected, processing payment"); - let payment_reqs = PaymentRequirements { - x402_version: 1, - accepts: vec![accepted_payment], - error: "".to_string(), - }; + // Parse X-PAYMENT header (convert HeaderValue to &str) + let x_payment_str = std::str::from_utf8(x_payment_value.as_bytes()) + .map_err(|e| format!("X-PAYMENT header is not valid UTF-8: {}", e))?; + + // DEBUG: Log raw X-PAYMENT header + info!("DEBUG: Raw X-PAYMENT header (first 200 chars): {}", &x_payment_str.chars().take(200).collect::()); + info!("DEBUG: X-PAYMENT header length: {} chars", x_payment_str.len()); + + let payment_payload = match parse_x_payment_header(x_payment_str) { + Ok(payload) => payload, + Err(e) => { + let error_json = serde_json::json!({"error": format!("Invalid X-PAYMENT header: {}", e)}); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::BAD_REQUEST); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + }; + + // DEBUG: Log parsed payment payload structure + info!("DEBUG: Parsed PaymentPayload - protocol_version: {}, scheme: {}, network: {}", + payment_payload.protocol_version, payment_payload.scheme, payment_payload.network); + info!("DEBUG: Authorization - from: {}, to: {}, value: {}, nonce: {}", + payment_payload.payload.authorization.from, + payment_payload.payload.authorization.to, + payment_payload.payload.authorization.value, + payment_payload.payload.authorization.nonce); + info!("DEBUG: Signature (first 20 chars): {}", + &payment_payload.payload.signature.chars().take(20).collect::()); + + // Rebuild PaymentRequirements for verification + let payment_requirements = build_payment_requirements(&provider, &resource_url); + + // Find the matching payment method based on scheme and network + let payment_method = payment_requirements.accepts + .iter() + .find(|method| { + method.scheme == payment_payload.scheme && + method.network == payment_payload.network + }) + .cloned(); + + let payment_method = match payment_method { + Some(method) => { + info!("Found matching payment method for scheme: {}, network: {}", + payment_payload.scheme, payment_payload.network); + method + }, + None => { + error!("No matching payment method found for scheme: {}, network: {}", + payment_payload.scheme, payment_payload.network); + let error_json = serde_json::json!({ + "error": format!("No matching payment method for scheme: {}, network: {}", + payment_payload.scheme, payment_payload.network) + }); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::BAD_REQUEST); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + }; + + // Build facilitator verify request with the matched payment method + // Note: We send the raw base64 header string, not the decoded payload (per GitHub spec) + let verify_request = FacilitatorVerifyRequest { + protocol_version: 1, + payment_header: x_payment_str.to_string(), + payment_requirements: payment_method, + }; + + let verify_body = serde_json::to_vec(&verify_request) + .map_err(|e| format!("Failed to serialize verify request: {}", e))?; + + // DEBUG: Log the exact JSON we're sending to facilitator + if let Ok(json_str) = serde_json::to_string_pretty(&verify_request) { + info!("DEBUG: Sending to facilitator /verify:"); + info!("DEBUG: {}", json_str); + } + + // Call facilitator /verify + let verify_url = url::Url::parse("https://facilitator.x402.rs/verify") + .map_err(|e| format!("Invalid facilitator URL: {}", e))?; + + let mut verify_headers = HashMap::new(); + verify_headers.insert("Content-Type".to_string(), "application/json".to_string()); + + let verify_response = match send_async_http_request( + HyperwareHttpMethod::POST, + verify_url, + Some(verify_headers), + 30, + verify_body, + ).await { + Ok(resp) => resp, + Err(e) => { + error!("Facilitator /verify request failed: {:?}", e); + let error_json = serde_json::json!({"error": "Payment verification service unavailable"}); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::SERVICE_UNAVAILABLE); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + }; + + // DEBUG: Log facilitator response + info!("DEBUG: Facilitator /verify response - status: {:?}", verify_response.status()); + let response_body_str = String::from_utf8_lossy(verify_response.body()); + info!("DEBUG: Facilitator /verify response body: {}", response_body_str); + + // Parse verify response + let verify_result: VerifyResponse = serde_json::from_slice(verify_response.body()) + .map_err(|e| format!("Failed to parse verify response: {}", e))?; + + if !verify_result.is_valid { + warn!("Payment verification failed: {:?}", verify_result.invalid_reason); + let mut error_payment_reqs = payment_requirements.clone(); + error_payment_reqs.error = verify_result.invalid_reason.unwrap_or_else(|| "Payment verification failed".to_string()); + let error_bytes = serde_json::to_vec(&error_payment_reqs).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::PAYMENT_REQUIRED); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + + info!("Payment verified for payer: {}", verify_result.payer); + + // Check replay protection + let nonce_key = format!("{}:{}", payment_payload.network, payment_payload.payload.authorization.nonce); + if self.used_nonces.contains(&nonce_key) { + warn!("Replay attack detected: nonce {} already used", nonce_key); + let error_json = serde_json::json!({"error": "Payment nonce already used (replay attempt)"}); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::BAD_REQUEST); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + + // Call upstream provider API + let args_vec: Vec<(String, String)> = params.iter() + .filter(|(k, _)| k != &"providername") + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + let upstream_response = match call_provider( + provider.provider_name.clone(), + provider.endpoint.clone(), + &args_vec, + our().node.to_string(), + ).await { + Ok(resp) => resp, + Err(e) => { + error!("Upstream API call failed: {}", e); + let error_json = serde_json::json!({"error": format!("Provider API call failed: {}", e)}); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::BAD_GATEWAY); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + }; + + info!("Upstream API call successful, settling payment"); + + // Call facilitator /settle + let settle_body = serde_json::to_vec(&verify_request) + .map_err(|e| format!("Failed to serialize settle request: {}", e))?; + + // DEBUG: Log settle request + if let Ok(json_str) = serde_json::to_string_pretty(&verify_request) { + info!("DEBUG: Sending to facilitator /settle:"); + info!("DEBUG: {}", json_str); + } + + let settle_url = url::Url::parse("https://facilitator.x402.rs/settle") + .map_err(|e| format!("Invalid facilitator URL: {}", e))?; + + let mut settle_headers = HashMap::new(); + settle_headers.insert("Content-Type".to_string(), "application/json".to_string()); + + // Call facilitator /settle and parse response + let settle_result: SettleResponse = match send_async_http_request( + HyperwareHttpMethod::POST, + settle_url, + Some(settle_headers), + 30, + settle_body, + ).await { + Ok(http_response) => { + // DEBUG: Log settle response + info!("DEBUG: Facilitator /settle response - status: {:?}", http_response.status()); + let settle_body_str = String::from_utf8_lossy(http_response.body()); + info!("DEBUG: Facilitator /settle response body: {}", settle_body_str); + + // Parse the HTTP response body into SettleResponse + serde_json::from_slice(http_response.body()) + .unwrap_or_else(|e| { + error!("Failed to parse settlement response: {}", e); + SettleResponse { + success: false, + payer: verify_result.payer.clone(), + transaction: None, + network: payment_payload.network.clone(), + error_reason: Some(format!("Failed to parse settlement response: {}", e)), + } + }) + } + Err(e) => { + // HTTP request failed - settlement service unavailable + error!("Facilitator /settle request failed but continuing: {:?}", e); + SettleResponse { + success: false, + payer: verify_result.payer.clone(), + transaction: None, + network: payment_payload.network.clone(), + error_reason: Some(format!("Settlement service error: {:?}", e)), + } + } + }; + + if !settle_result.success { + // TODO: Decide on settlement failure policy - currently continuing since work was done + warn!("Settlement failed but work was completed: {:?}", settle_result.error_reason); + } + + // Mark nonce as used + self.used_nonces.insert(nonce_key); + + // Encode settle response for X-PAYMENT-RESPONSE header + let settle_json = serde_json::to_vec(&settle_result) + .map_err(|e| format!("Failed to serialize settle response: {}", e))?; + + // Base64 encode for header + let encoded_len = Base64::encoded_len(&settle_json); + let mut buf = vec![0u8; encoded_len]; + let settle_b64 = Base64::encode(&settle_json, &mut buf) + .map_err(|e| format!("Failed to base64 encode settlement response: {}", e))? + .to_string(); + + // Return upstream response with X-PAYMENT-RESPONSE header + set_response_body(upstream_response.into_bytes()); + set_response_status(StatusCode::OK); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + add_response_header("X-PAYMENT-RESPONSE".to_string(), settle_b64); + + info!("Payment flow completed successfully for provider '{}'", provider_name); + return Ok("".to_string()); + } + + // ===== BRANCH: 402 PAYMENT REQUIRED FLOW ===== + info!("No X-PAYMENT header, returning 402 Payment Required"); - // ===== SERIALIZE AND SET RESPONSE ===== + let payment_reqs = build_payment_requirements(&provider, &resource_url); let payment_json = serde_json::to_vec(&payment_reqs) .map_err(|e| format!("Failed to serialize payment requirements: {}", e))?; From 1b74ec226645778dc87aed30cab3f0d84fb27d66 Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Thu, 16 Oct 2025 09:02:19 -0400 Subject: [PATCH 03/10] ok it kind of works now checkpoint commit. lfg --- provider/provider/src/lib.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index cfe17ac..d587b36 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -123,7 +123,7 @@ pub struct Authorization { pub struct FacilitatorVerifyRequest { #[serde(rename = "x402Version")] pub protocol_version: u8, - pub payment_header: String, // Raw base64 X-PAYMENT header string (per GitHub spec) + pub payment_payload: PaymentPayload, // Decoded payment object pub payment_requirements: AcceptedPayment, // Single payment method, not the full PaymentRequirements wrapper } @@ -479,7 +479,7 @@ fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) max_timeout_seconds: 60, asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".to_string(), // Base Sepolia USDC extra: ExtraData { - name: "USD Coin".to_string(), + name: "USDC".to_string(), // EIP-712 domain name for Base Sepolia USDC version: "2".to_string(), }, }; @@ -1133,10 +1133,9 @@ impl HypergridProviderState { }; // Build facilitator verify request with the matched payment method - // Note: We send the raw base64 header string, not the decoded payload (per GitHub spec) let verify_request = FacilitatorVerifyRequest { protocol_version: 1, - payment_header: x_payment_str.to_string(), + payment_payload: payment_payload.clone(), payment_requirements: payment_method, }; From 4a09bc1db3829ac47b68afbbc08631136c3080c8 Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Thu, 16 Oct 2025 10:15:33 -0400 Subject: [PATCH 04/10] this the one it works (on testnet at least) --- constants/production.rs | 9 ++++++++- constants/staging.rs | 9 ++++++++- provider/provider/src/lib.rs | 21 ++++++++++++++------- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/constants/production.rs b/constants/production.rs index a260382..a6f585b 100644 --- a/constants/production.rs +++ b/constants/production.rs @@ -8,4 +8,11 @@ pub const HYPERGRID_ADDRESS: &str = "0xd65cb2ae7212e9b767c6953bb11cad1876d81cc8" pub const HYPERGRID_NAMESPACE_MINTER_ADDRESS: &str = "0x44a8Bd4f9370b248c91d54773Ac4a457B3454b50"; pub const HYPR_HASH: &str = "0x29575a1a0473dcc0e00d7137198ed715215de7bffd92911627d5e008410a5826"; pub const USDC_BASE_ADDRESS: &str = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; -pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; \ No newline at end of file +pub const USDC_SEPOLIA_ADDRESS: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; + +// X402 Payment Configuration +pub const X402_PAYMENT_NETWORK: &str = "base"; +pub const USDC_EIP712_NAME: &str = "USDC"; +pub const USDC_EIP712_VERSION: &str = "2"; +pub const X402_FACILITATOR_BASE_URL: &str = "https://facilitator.x402.rs"; \ No newline at end of file diff --git a/constants/staging.rs b/constants/staging.rs index 25f17a8..16f4382 100644 --- a/constants/staging.rs +++ b/constants/staging.rs @@ -8,4 +8,11 @@ pub const HYPERGRID_ADDRESS: &str = "0x2138da52cbf52adf2e73139a898370e03bbebf0a" pub const HYPERGRID_NAMESPACE_MINTER_ADDRESS: &str = "0x44a8Bd4f9370b248c91d54773Ac4a457B3454b50"; pub const HYPR_HASH: &str = "0x29575a1a0473dcc0e00d7137198ed715215de7bffd92911627d5e008410a5826"; // TODO: Update with staging hash pub const USDC_BASE_ADDRESS: &str = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"; -pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; \ No newline at end of file +pub const USDC_SEPOLIA_ADDRESS: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; + +// X402 Payment Configuration +pub const X402_PAYMENT_NETWORK: &str = "base-sepolia"; +pub const USDC_EIP712_NAME: &str = "USDC"; +pub const USDC_EIP712_VERSION: &str = "2"; +pub const X402_FACILITATOR_BASE_URL: &str = "https://facilitator.x402.rs"; \ No newline at end of file diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index d587b36..7ef0b99 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -15,7 +15,14 @@ use hyperware_process_lib::{ Address, hyperapp::{source, SaveOptions, sleep, get_server, set_response_status, set_response_body, add_response_header, APP_HELPERS}, }; -use crate::constants::HYPR_SUFFIX; +use crate::constants::{ + HYPR_SUFFIX, + USDC_SEPOLIA_ADDRESS, + USDC_EIP712_NAME, + USDC_EIP712_VERSION, + X402_PAYMENT_NETWORK, + X402_FACILITATOR_BASE_URL, +}; use base64ct::{Base64, Encoding}; use rmp_serde; use serde::{Deserialize, Serialize}; @@ -470,17 +477,17 @@ fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) let accepted_payment = AcceptedPayment { scheme: "exact".to_string(), - network: "base-sepolia".to_string(), + network: X402_PAYMENT_NETWORK.to_string(), max_amount_required: max_amount_atomic, resource: resource_url.to_string(), description: provider.description.clone(), mime_type: "application/json".to_string(), pay_to: provider.registered_provider_wallet.clone(), max_timeout_seconds: 60, - asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e".to_string(), // Base Sepolia USDC + asset: USDC_SEPOLIA_ADDRESS.to_string(), extra: ExtraData { - name: "USDC".to_string(), // EIP-712 domain name for Base Sepolia USDC - version: "2".to_string(), + name: USDC_EIP712_NAME.to_string(), + version: USDC_EIP712_VERSION.to_string(), }, }; @@ -1149,7 +1156,7 @@ impl HypergridProviderState { } // Call facilitator /verify - let verify_url = url::Url::parse("https://facilitator.x402.rs/verify") + let verify_url = url::Url::parse(&format!("{}/verify", X402_FACILITATOR_BASE_URL)) .map_err(|e| format!("Invalid facilitator URL: {}", e))?; let mut verify_headers = HashMap::new(); @@ -1244,7 +1251,7 @@ impl HypergridProviderState { info!("DEBUG: {}", json_str); } - let settle_url = url::Url::parse("https://facilitator.x402.rs/settle") + let settle_url = url::Url::parse(&format!("{}/settle", X402_FACILITATOR_BASE_URL)) .map_err(|e| format!("Invalid facilitator URL: {}", e))?; let mut settle_headers = HashMap::new(); From 0ef629cc45bf3d414a510990efaa8af9790a29e4 Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Fri, 17 Oct 2025 11:25:16 -0400 Subject: [PATCH 05/10] Ready for merge, in principle Ok seems good, but need to test this with Mainnet Base / USDC (should be fine but there's some fiddly stuff with EIP-3009). Also need to ensure that a migration will be fine. --- constants/production.rs | 2 +- metadata.json | 2 +- provider/provider/src/lib.rs | 73 ++++++++++++++++++------------------ 3 files changed, 39 insertions(+), 38 deletions(-) diff --git a/constants/production.rs b/constants/production.rs index a6f585b..6ec0cf1 100644 --- a/constants/production.rs +++ b/constants/production.rs @@ -13,6 +13,6 @@ pub const CIRCLE_PAYMASTER: &str = "0x0578cFB241215b77442a541325d6A4E6dFE700Ec"; // X402 Payment Configuration pub const X402_PAYMENT_NETWORK: &str = "base"; -pub const USDC_EIP712_NAME: &str = "USDC"; +pub const USDC_EIP712_NAME: &str = "USD Coin"; pub const USDC_EIP712_VERSION: &str = "2"; pub const X402_FACILITATOR_BASE_URL: &str = "https://facilitator.x402.rs"; \ No newline at end of file diff --git a/metadata.json b/metadata.json index 53052cc..f888df2 100644 --- a/metadata.json +++ b/metadata.json @@ -5,7 +5,7 @@ "properties": { "package_name": "hypergrid", "current_version": "1.2.1", - "publisher": "test.hypr", + "publisher": "ware.hypr", "mirrors": ["ware.hypr","sam.hypr", "backup-distro-node.os"], "code_hashes": { "1.0.0": "001a49117374abc3bdb38179d8ce05d76205b008bb55683e116be36f3e1635ce", diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index 7ef0b99..5f60384 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -17,6 +17,7 @@ use hyperware_process_lib::{ }; use crate::constants::{ HYPR_SUFFIX, + USDC_BASE_ADDRESS, USDC_SEPOLIA_ADDRESS, USDC_EIP712_NAME, USDC_EIP712_VERSION, @@ -484,7 +485,11 @@ fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) mime_type: "application/json".to_string(), pay_to: provider.registered_provider_wallet.clone(), max_timeout_seconds: 60, - asset: USDC_SEPOLIA_ADDRESS.to_string(), + asset: if X402_PAYMENT_NETWORK == "base-sepolia" { + USDC_SEPOLIA_ADDRESS.to_string() + } else { + USDC_BASE_ADDRESS.to_string() + }, extra: ExtraData { name: USDC_EIP712_NAME.to_string(), version: USDC_EIP712_VERSION.to_string(), @@ -1068,6 +1073,8 @@ impl HypergridProviderState { helpers.borrow().current_http_context.as_ref() .and_then(|ctx| ctx.request.url().ok()) .map(|url| url.to_string()) + // NOTE: Fallback URL uses test.hypr - this should never actually be used in production + // as ctx.request.url() should always succeed. If this fallback triggers, investigate. .unwrap_or_else(|| format!("http://unknown/provider:hypergrid:test.hypr/xfour?providername={}", provider_name)) }); @@ -1079,9 +1086,7 @@ impl HypergridProviderState { let x_payment_str = std::str::from_utf8(x_payment_value.as_bytes()) .map_err(|e| format!("X-PAYMENT header is not valid UTF-8: {}", e))?; - // DEBUG: Log raw X-PAYMENT header - info!("DEBUG: Raw X-PAYMENT header (first 200 chars): {}", &x_payment_str.chars().take(200).collect::()); - info!("DEBUG: X-PAYMENT header length: {} chars", x_payment_str.len()); + info!("X-PAYMENT header received, length: {} chars", x_payment_str.len()); let payment_payload = match parse_x_payment_header(x_payment_str) { Ok(payload) => payload, @@ -1095,16 +1100,21 @@ impl HypergridProviderState { } }; - // DEBUG: Log parsed payment payload structure - info!("DEBUG: Parsed PaymentPayload - protocol_version: {}, scheme: {}, network: {}", + // Validate protocol version + if payment_payload.protocol_version != 1 { + error!("Unsupported x402 protocol version: {}", payment_payload.protocol_version); + let error_json = serde_json::json!({ + "error": format!("Unsupported x402 protocol version: {}. Expected version 1.", payment_payload.protocol_version) + }); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::BAD_REQUEST); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); + } + + info!("Payment parsed - protocol v{}, scheme: {}, network: {}", payment_payload.protocol_version, payment_payload.scheme, payment_payload.network); - info!("DEBUG: Authorization - from: {}, to: {}, value: {}, nonce: {}", - payment_payload.payload.authorization.from, - payment_payload.payload.authorization.to, - payment_payload.payload.authorization.value, - payment_payload.payload.authorization.nonce); - info!("DEBUG: Signature (first 20 chars): {}", - &payment_payload.payload.signature.chars().take(20).collect::()); // Rebuild PaymentRequirements for verification let payment_requirements = build_payment_requirements(&provider, &resource_url); @@ -1149,12 +1159,6 @@ impl HypergridProviderState { let verify_body = serde_json::to_vec(&verify_request) .map_err(|e| format!("Failed to serialize verify request: {}", e))?; - // DEBUG: Log the exact JSON we're sending to facilitator - if let Ok(json_str) = serde_json::to_string_pretty(&verify_request) { - info!("DEBUG: Sending to facilitator /verify:"); - info!("DEBUG: {}", json_str); - } - // Call facilitator /verify let verify_url = url::Url::parse(&format!("{}/verify", X402_FACILITATOR_BASE_URL)) .map_err(|e| format!("Invalid facilitator URL: {}", e))?; @@ -1181,10 +1185,7 @@ impl HypergridProviderState { } }; - // DEBUG: Log facilitator response - info!("DEBUG: Facilitator /verify response - status: {:?}", verify_response.status()); - let response_body_str = String::from_utf8_lossy(verify_response.body()); - info!("DEBUG: Facilitator /verify response body: {}", response_body_str); + info!("Facilitator /verify response: {:?}", verify_response.status()); // Parse verify response let verify_result: VerifyResponse = serde_json::from_slice(verify_response.body()) @@ -1245,12 +1246,6 @@ impl HypergridProviderState { let settle_body = serde_json::to_vec(&verify_request) .map_err(|e| format!("Failed to serialize settle request: {}", e))?; - // DEBUG: Log settle request - if let Ok(json_str) = serde_json::to_string_pretty(&verify_request) { - info!("DEBUG: Sending to facilitator /settle:"); - info!("DEBUG: {}", json_str); - } - let settle_url = url::Url::parse(&format!("{}/settle", X402_FACILITATOR_BASE_URL)) .map_err(|e| format!("Invalid facilitator URL: {}", e))?; @@ -1266,10 +1261,7 @@ impl HypergridProviderState { settle_body, ).await { Ok(http_response) => { - // DEBUG: Log settle response - info!("DEBUG: Facilitator /settle response - status: {:?}", http_response.status()); - let settle_body_str = String::from_utf8_lossy(http_response.body()); - info!("DEBUG: Facilitator /settle response body: {}", settle_body_str); + info!("Facilitator /settle response: {:?}", http_response.status()); // Parse the HTTP response body into SettleResponse serde_json::from_slice(http_response.body()) @@ -1297,12 +1289,21 @@ impl HypergridProviderState { } }; + // Reject request if settlement fails - provider does not get paid if !settle_result.success { - // TODO: Decide on settlement failure policy - currently continuing since work was done - warn!("Settlement failed but work was completed: {:?}", settle_result.error_reason); + error!("Settlement failed, rejecting request: {:?}", settle_result.error_reason); + let error_json = serde_json::json!({ + "error": "Payment settlement failed. Please try again.", + "reason": settle_result.error_reason.unwrap_or_else(|| "Unknown settlement error".to_string()) + }); + let error_bytes = serde_json::to_vec(&error_json).unwrap(); + set_response_body(error_bytes); + set_response_status(StatusCode::PAYMENT_REQUIRED); + add_response_header("Content-Type".to_string(), "application/json".to_string()); + return Ok("".to_string()); } - // Mark nonce as used + // Mark nonce as used only if settlement succeeded self.used_nonces.insert(nonce_key); // Encode settle response for X-PAYMENT-RESPONSE header From 302180217dd42c805f649e5d08e796c02e541777 Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Tue, 21 Oct 2025 11:12:53 -0400 Subject: [PATCH 06/10] abstracts APP_HELPERS functions @Gohlub insisting I abstract my shit. smdh. --- metadata.json | 2 +- provider/provider/src/lib.rs | 39 +++++++++--------------------------- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/metadata.json b/metadata.json index f888df2..53052cc 100644 --- a/metadata.json +++ b/metadata.json @@ -5,7 +5,7 @@ "properties": { "package_name": "hypergrid", "current_version": "1.2.1", - "publisher": "ware.hypr", + "publisher": "test.hypr", "mirrors": ["ware.hypr","sam.hypr", "backup-distro-node.os"], "code_hashes": { "1.0.0": "001a49117374abc3bdb38179d8ce05d76205b008bb55683e116be36f3e1635ce", diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index 5f60384..c632486 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -13,7 +13,7 @@ use hyperware_process_lib::{ our, vfs::{create_drive, create_file, open_file}, Address, - hyperapp::{source, SaveOptions, sleep, get_server, set_response_status, set_response_body, add_response_header, APP_HELPERS}, + hyperapp::{source, SaveOptions, sleep, get_server, set_response_status, set_response_body, add_response_header, get_request_header, get_request_url, get_parsed_query_params}, }; use crate::constants::{ HYPR_SUFFIX, @@ -1013,22 +1013,10 @@ impl HypergridProviderState { info!("x402 endpoint called"); // ===== CHECK FOR X-PAYMENT HEADER ===== - let x_payment_header = APP_HELPERS.with(|helpers| { - helpers - .borrow() - .current_http_context - .as_ref() - .and_then(|ctx| ctx.request.headers().get("x-payment").cloned()) - }); + let x_payment_header = get_request_header("x-payment"); // ===== SHARED: QUERY PARAMETER VALIDATION ===== - let params = APP_HELPERS.with(|helpers| { - helpers - .borrow() - .current_http_context - .as_ref() - .map(|ctx| ctx.request.query_params().clone()) - }); + let params = get_parsed_query_params(); let params = match params { Some(p) if !p.is_empty() => p, @@ -1069,26 +1057,17 @@ impl HypergridProviderState { }; // ===== SHARED: GET RESOURCE URL ===== - let resource_url = APP_HELPERS.with(|helpers| { - helpers.borrow().current_http_context.as_ref() - .and_then(|ctx| ctx.request.url().ok()) - .map(|url| url.to_string()) - // NOTE: Fallback URL uses test.hypr - this should never actually be used in production - // as ctx.request.url() should always succeed. If this fallback triggers, investigate. - .unwrap_or_else(|| format!("http://unknown/provider:hypergrid:test.hypr/xfour?providername={}", provider_name)) - }); + // NOTE: Fallback URL uses test.hypr - this should never actually be used in production + // as get_request_url() should always succeed in HTTP context. If this fallback triggers, investigate. + let resource_url = get_request_url() + .unwrap_or_else(|| format!("http://unknown/provider:hypergrid:test.hypr/xfour?providername={}", provider_name)); // ===== BRANCH: PAYMENT VERIFICATION FLOW ===== - if let Some(x_payment_value) = x_payment_header { + if let Some(x_payment_str) = x_payment_header { info!("X-PAYMENT header detected, processing payment"); - - // Parse X-PAYMENT header (convert HeaderValue to &str) - let x_payment_str = std::str::from_utf8(x_payment_value.as_bytes()) - .map_err(|e| format!("X-PAYMENT header is not valid UTF-8: {}", e))?; - info!("X-PAYMENT header received, length: {} chars", x_payment_str.len()); - let payment_payload = match parse_x_payment_header(x_payment_str) { + let payment_payload = match parse_x_payment_header(&x_payment_str) { Ok(payload) => payload, Err(e) => { let error_json = serde_json::json!({"error": format!("Invalid X-PAYMENT header: {}", e)}); From ff9f74f3a45657fd31c89789f9d905a088c472c7 Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Tue, 21 Oct 2025 11:32:19 -0400 Subject: [PATCH 07/10] eliminates replay checks not necessary, EIP-3009 doesn't give much surface area for this kind of thing anyway --- provider/provider/src/lib.rs | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index c632486..0c9c56c 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -29,7 +29,7 @@ use rmp_serde; use serde::{Deserialize, Serialize}; use serde_json; use std::str::FromStr; // Needed for EthAddress::from_str -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; pub const CHAIN_ID: u64 = hypermap::HYPERMAP_CHAIN_ID; @@ -313,9 +313,6 @@ pub struct RegisteredProvider { pub struct HypergridProviderState { pub registered_providers: Vec, pub spent_tx_hashes: Vec, - // TODO: Replace with persistent storage for production - tracks used payment nonces to prevent replay attacks - #[serde(skip)] - pub used_nonces: HashSet, #[serde(skip, default = "util::default_provider")] pub rpc_provider: Provider, #[serde(skip, default = "util::default_hypermap")] @@ -342,7 +339,6 @@ impl HypergridProviderState { Self { registered_providers: Vec::new(), spent_tx_hashes: Vec::new(), - used_nonces: HashSet::new(), rpc_provider: provider.clone(), hypermap: hypermap::Hypermap::new(provider.clone(), hypermap_contract_address), vfs_drive_path: None, @@ -1183,18 +1179,6 @@ impl HypergridProviderState { info!("Payment verified for payer: {}", verify_result.payer); - // Check replay protection - let nonce_key = format!("{}:{}", payment_payload.network, payment_payload.payload.authorization.nonce); - if self.used_nonces.contains(&nonce_key) { - warn!("Replay attack detected: nonce {} already used", nonce_key); - let error_json = serde_json::json!({"error": "Payment nonce already used (replay attempt)"}); - let error_bytes = serde_json::to_vec(&error_json).unwrap(); - set_response_body(error_bytes); - set_response_status(StatusCode::BAD_REQUEST); - add_response_header("Content-Type".to_string(), "application/json".to_string()); - return Ok("".to_string()); - } - // Call upstream provider API let args_vec: Vec<(String, String)> = params.iter() .filter(|(k, _)| k != &"providername") @@ -1282,9 +1266,6 @@ impl HypergridProviderState { return Ok("".to_string()); } - // Mark nonce as used only if settlement succeeded - self.used_nonces.insert(nonce_key); - // Encode settle response for X-PAYMENT-RESPONSE header let settle_json = serde_json::to_vec(&settle_result) .map_err(|e| format!("Failed to serialize settle response: {}", e))?; From aabcb658dff4081450c00fe0992a9f3c9000feef Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Tue, 21 Oct 2025 11:47:02 -0400 Subject: [PATCH 08/10] muh floating point you know what it is --- provider/provider/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index 0c9c56c..5a3d26c 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -470,7 +470,7 @@ fn parse_x_payment_header(header_value: &str) -> Result /// Build PaymentRequirements structure from provider and resource URL fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) -> PaymentRequirements { // Convert USDC price to atomic units (6 decimals) - let max_amount_atomic = ((provider.price * 1_000_000.0) as u64).to_string(); + let max_amount_atomic = ((provider.price * 1_000_000.0).round() as u64).to_string(); let accepted_payment = AcceptedPayment { scheme: "exact".to_string(), From 18ea0fcb35d4c37fa431ec34b18da5562e6ce083 Mon Sep 17 00:00:00 2001 From: Samuel Henriquez Date: Tue, 21 Oct 2025 12:30:13 -0400 Subject: [PATCH 09/10] More advanced schema for x402scan should still work with orthodox x402 clients (whatever that means) and also now x402scans more involved schema will let them generate a UI to make calls in-app --- provider/provider/src/lib.rs | 171 +++++++++++++++++++++++++++++++---- 1 file changed, 152 insertions(+), 19 deletions(-) diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index 5a3d26c..0319a6f 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -72,8 +72,15 @@ pub struct ValidateAndRegisterRequest { pub struct PaymentRequirements { #[serde(rename = "x402Version")] pub protocol_version: u8, - pub accepts: Vec, - pub error: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub accepts: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub payer: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -88,13 +95,64 @@ pub struct AcceptedPayment { pub pay_to: String, // Ethereum address pub max_timeout_seconds: u64, pub asset: String, // USDC contract address - pub extra: ExtraData, + + #[serde(skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub extra: Option, +} + +// x402scan registry schema types +// FieldDef describes individual field requirements (type, required, enum, nested properties) +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FieldDef { + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, // Can be bool or string[] + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(skip_serializing_if = "Option::is_none", rename = "enum")] + pub r#enum: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option>, // Recursive for nested objects +} + +// InputSchema describes HTTP request requirements (method, params, body structure) +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InputSchema { + pub r#type: String, // Always "http" for our use case + + pub method: String, // "GET", "POST", etc + + #[serde(skip_serializing_if = "Option::is_none")] + pub body_type: Option, // "json", "form-data", "multipart-form-data", "text", "binary" + + #[serde(skip_serializing_if = "Option::is_none")] + pub query_params: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub body_fields: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub header_fields: Option>, } +// OutputSchema is the top-level schema wrapper for x402scan registry #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct ExtraData { - pub name: String, - pub version: String, +#[serde(rename_all = "camelCase")] +pub struct OutputSchema { + pub input: InputSchema, + + #[serde(skip_serializing_if = "Option::is_none")] + pub output: Option, // Flexible JSON for response format } // X-PAYMENT header payload structures (from x402 client) @@ -467,11 +525,81 @@ fn parse_x_payment_header(header_value: &str) -> Result .map_err(|e| format!("Failed to parse X-PAYMENT JSON: {}", e)) } +/// Convert a ParameterDefinition to x402scan's FieldDef format +fn parameter_to_field_def(param: &ParameterDefinition) -> FieldDef { + FieldDef { + r#type: Some(param.value_type.clone()), + required: Some(serde_json::Value::Bool(true)), // All provider params are required + description: Some(format!("Parameter: {}", param.parameter_name)), + r#enum: None, + properties: None, + } +} + +/// Build InputSchema from provider's endpoint definition +fn build_input_schema(endpoint: &EndpointDefinition) -> InputSchema { + let mut query_params = HashMap::new(); + let mut body_fields = HashMap::new(); + let mut header_fields = HashMap::new(); + + // Add the fixed providername parameter + query_params.insert( + "providername".to_string(), + FieldDef { + r#type: Some("string".to_string()), + required: Some(serde_json::Value::Bool(true)), + description: Some("Name of the registered provider to call".to_string()), + r#enum: None, + properties: None, + } + ); + + // Convert provider's parameters by location + for param in &endpoint.parameters { + let field_def = parameter_to_field_def(param); + match param.location.as_str() { + "query" => { query_params.insert(param.parameter_name.clone(), field_def); }, + "body" => { body_fields.insert(param.parameter_name.clone(), field_def); }, + "header" => { header_fields.insert(param.parameter_name.clone(), field_def); }, + "path" => { + // Path params are part of the URL, not separate fields + // Could document them in description if needed + }, + _ => {}, + } + } + + InputSchema { + r#type: "http".to_string(), + method: endpoint.method.clone(), + body_type: if !body_fields.is_empty() { + Some("json".to_string()) + } else { + None + }, + query_params: if !query_params.is_empty() { Some(query_params) } else { None }, + body_fields: if !body_fields.is_empty() { Some(body_fields) } else { None }, + header_fields: if !header_fields.is_empty() { Some(header_fields) } else { None }, + } +} + /// Build PaymentRequirements structure from provider and resource URL fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) -> PaymentRequirements { // Convert USDC price to atomic units (6 decimals) let max_amount_atomic = ((provider.price * 1_000_000.0).round() as u64).to_string(); + // Build input schema from provider's endpoint definition + let input_schema = build_input_schema(&provider.endpoint); + + // Create output schema for x402scan registry compliance + let output_schema = OutputSchema { + input: input_schema, + output: Some(serde_json::json!({ + "type": "object", + "description": "Response from the provider's API endpoint" + })), + }; + let accepted_payment = AcceptedPayment { scheme: "exact".to_string(), network: X402_PAYMENT_NETWORK.to_string(), @@ -486,16 +614,18 @@ fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) } else { USDC_BASE_ADDRESS.to_string() }, - extra: ExtraData { - name: USDC_EIP712_NAME.to_string(), - version: USDC_EIP712_VERSION.to_string(), - }, + output_schema: Some(output_schema), + extra: Some(serde_json::json!({ + "name": USDC_EIP712_NAME, + "version": USDC_EIP712_VERSION + })), }; PaymentRequirements { protocol_version: 1, - accepts: vec![accepted_payment], - error: "".to_string(), + accepts: Some(vec![accepted_payment]), + error: Some("".to_string()), // Empty string for no error (x402 clients expect this field) + payer: None, } } @@ -1096,12 +1226,15 @@ impl HypergridProviderState { // Find the matching payment method based on scheme and network let payment_method = payment_requirements.accepts - .iter() - .find(|method| { - method.scheme == payment_payload.scheme && - method.network == payment_payload.network - }) - .cloned(); + .as_ref() + .and_then(|accepts| { + accepts.iter() + .find(|method| { + method.scheme == payment_payload.scheme && + method.network == payment_payload.network + }) + .cloned() + }); let payment_method = match payment_method { Some(method) => { @@ -1169,7 +1302,7 @@ impl HypergridProviderState { if !verify_result.is_valid { warn!("Payment verification failed: {:?}", verify_result.invalid_reason); let mut error_payment_reqs = payment_requirements.clone(); - error_payment_reqs.error = verify_result.invalid_reason.unwrap_or_else(|| "Payment verification failed".to_string()); + error_payment_reqs.error = Some(verify_result.invalid_reason.unwrap_or_else(|| "Payment verification failed".to_string())); let error_bytes = serde_json::to_vec(&error_payment_reqs).unwrap(); set_response_body(error_bytes); set_response_status(StatusCode::PAYMENT_REQUIRED); From 6f1556ecee21979bc767ff03939c5851b3ab54f6 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:24:26 -0400 Subject: [PATCH 10/10] moved logic to util.rs --- provider/provider/src/lib.rs | 125 +------------------------------- provider/provider/src/util.rs | 131 +++++++++++++++++++++++++++++++++- 2 files changed, 130 insertions(+), 126 deletions(-) diff --git a/provider/provider/src/lib.rs b/provider/provider/src/lib.rs index 0319a6f..5ffe6c6 100644 --- a/provider/provider/src/lib.rs +++ b/provider/provider/src/lib.rs @@ -17,13 +17,9 @@ use hyperware_process_lib::{ }; use crate::constants::{ HYPR_SUFFIX, - USDC_BASE_ADDRESS, - USDC_SEPOLIA_ADDRESS, - USDC_EIP712_NAME, - USDC_EIP712_VERSION, - X402_PAYMENT_NETWORK, X402_FACILITATOR_BASE_URL, }; +use crate::util::{parse_x_payment_header, build_payment_requirements}; use base64ct::{Base64, Encoding}; use rmp_serde; use serde::{Deserialize, Serialize}; @@ -510,125 +506,6 @@ impl Default for HypergridProviderState { } } -// x402 helper functions (standalone, not in impl block to avoid WIT export requirements) - -/// Parse X-PAYMENT header value: base64 decode and deserialize to PaymentPayload -fn parse_x_payment_header(header_value: &str) -> Result { - // Allocate buffer for decoded data (base64 decoding produces smaller output than input) - let max_decoded_len = (header_value.len() * 3) / 4 + 3; - let mut decoded_bytes = vec![0u8; max_decoded_len]; - - let decoded_slice = Base64::decode(header_value.as_bytes(), &mut decoded_bytes) - .map_err(|e| format!("Failed to base64 decode X-PAYMENT header: {}", e))?; - - serde_json::from_slice(decoded_slice) - .map_err(|e| format!("Failed to parse X-PAYMENT JSON: {}", e)) -} - -/// Convert a ParameterDefinition to x402scan's FieldDef format -fn parameter_to_field_def(param: &ParameterDefinition) -> FieldDef { - FieldDef { - r#type: Some(param.value_type.clone()), - required: Some(serde_json::Value::Bool(true)), // All provider params are required - description: Some(format!("Parameter: {}", param.parameter_name)), - r#enum: None, - properties: None, - } -} - -/// Build InputSchema from provider's endpoint definition -fn build_input_schema(endpoint: &EndpointDefinition) -> InputSchema { - let mut query_params = HashMap::new(); - let mut body_fields = HashMap::new(); - let mut header_fields = HashMap::new(); - - // Add the fixed providername parameter - query_params.insert( - "providername".to_string(), - FieldDef { - r#type: Some("string".to_string()), - required: Some(serde_json::Value::Bool(true)), - description: Some("Name of the registered provider to call".to_string()), - r#enum: None, - properties: None, - } - ); - - // Convert provider's parameters by location - for param in &endpoint.parameters { - let field_def = parameter_to_field_def(param); - match param.location.as_str() { - "query" => { query_params.insert(param.parameter_name.clone(), field_def); }, - "body" => { body_fields.insert(param.parameter_name.clone(), field_def); }, - "header" => { header_fields.insert(param.parameter_name.clone(), field_def); }, - "path" => { - // Path params are part of the URL, not separate fields - // Could document them in description if needed - }, - _ => {}, - } - } - - InputSchema { - r#type: "http".to_string(), - method: endpoint.method.clone(), - body_type: if !body_fields.is_empty() { - Some("json".to_string()) - } else { - None - }, - query_params: if !query_params.is_empty() { Some(query_params) } else { None }, - body_fields: if !body_fields.is_empty() { Some(body_fields) } else { None }, - header_fields: if !header_fields.is_empty() { Some(header_fields) } else { None }, - } -} - -/// Build PaymentRequirements structure from provider and resource URL -fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) -> PaymentRequirements { - // Convert USDC price to atomic units (6 decimals) - let max_amount_atomic = ((provider.price * 1_000_000.0).round() as u64).to_string(); - - // Build input schema from provider's endpoint definition - let input_schema = build_input_schema(&provider.endpoint); - - // Create output schema for x402scan registry compliance - let output_schema = OutputSchema { - input: input_schema, - output: Some(serde_json::json!({ - "type": "object", - "description": "Response from the provider's API endpoint" - })), - }; - - let accepted_payment = AcceptedPayment { - scheme: "exact".to_string(), - network: X402_PAYMENT_NETWORK.to_string(), - max_amount_required: max_amount_atomic, - resource: resource_url.to_string(), - description: provider.description.clone(), - mime_type: "application/json".to_string(), - pay_to: provider.registered_provider_wallet.clone(), - max_timeout_seconds: 60, - asset: if X402_PAYMENT_NETWORK == "base-sepolia" { - USDC_SEPOLIA_ADDRESS.to_string() - } else { - USDC_BASE_ADDRESS.to_string() - }, - output_schema: Some(output_schema), - extra: Some(serde_json::json!({ - "name": USDC_EIP712_NAME, - "version": USDC_EIP712_VERSION - })), - }; - - PaymentRequirements { - protocol_version: 1, - accepts: Some(vec![accepted_payment]), - error: Some("".to_string()), // Empty string for no error (x402 clients expect this field) - payer: None, - } -} - // --- Hyperware Process --- #[hyperprocess( name = "provider", diff --git a/provider/provider/src/util.rs b/provider/provider/src/util.rs index c63c8ae..19bd8ff 100644 --- a/provider/provider/src/util.rs +++ b/provider/provider/src/util.rs @@ -1,5 +1,12 @@ -use crate::{EndpointDefinition, ProviderRequest}; -use crate::constants::{USDC_BASE_ADDRESS, WALLET_PREFIX}; +use crate::{ + EndpointDefinition, ProviderRequest, PaymentPayload, FieldDef, InputSchema, + OutputSchema, AcceptedPayment, PaymentRequirements, ParameterDefinition, + RegisteredProvider +}; +use crate::constants::{ + USDC_BASE_ADDRESS, WALLET_PREFIX, USDC_SEPOLIA_ADDRESS, USDC_EIP712_NAME, + USDC_EIP712_VERSION, X402_PAYMENT_NETWORK +}; use hyperware_process_lib::{ eth::{Address as EthAddress, EthError, TransactionReceipt, TxHash, U256}, get_blob, @@ -17,6 +24,7 @@ use serde_json; use std::collections::HashMap; use std::str::FromStr; use url::Url; +use base64ct::{Base64, Encoding}; /// Make an HTTP request using http-client and await its response. /// @@ -1065,3 +1073,122 @@ pub fn validate_response_status(response: &str) -> Result<(), String> { } } } + + + +/// Parse X-PAYMENT header value: base64 decode and deserialize to PaymentPayload +pub fn parse_x_payment_header(header_value: &str) -> Result { + // Allocate buffer for decoded data (base64 decoding produces smaller output than input) + let max_decoded_len = (header_value.len() * 3) / 4 + 3; + let mut decoded_bytes = vec![0u8; max_decoded_len]; + + let decoded_slice = Base64::decode(header_value.as_bytes(), &mut decoded_bytes) + .map_err(|e| format!("Failed to base64 decode X-PAYMENT header: {}", e))?; + + serde_json::from_slice(decoded_slice) + .map_err(|e| format!("Failed to parse X-PAYMENT JSON: {}", e)) +} + +/// Convert a ParameterDefinition to x402scan's FieldDef format +pub fn parameter_to_field_def(param: &ParameterDefinition) -> FieldDef { + FieldDef { + r#type: Some(param.value_type.clone()), + required: Some(serde_json::Value::Bool(true)), // All provider params are required + description: Some(format!("Parameter: {}", param.parameter_name)), + r#enum: None, + properties: None, + } +} + +/// Build InputSchema from provider's endpoint definition +pub fn build_input_schema(endpoint: &EndpointDefinition) -> InputSchema { + let mut query_params = HashMap::new(); + let mut body_fields = HashMap::new(); + let mut header_fields = HashMap::new(); + + // Add the fixed providername parameter + query_params.insert( + "providername".to_string(), + FieldDef { + r#type: Some("string".to_string()), + required: Some(serde_json::Value::Bool(true)), + description: Some("Name of the registered provider to call".to_string()), + r#enum: None, + properties: None, + } + ); + + // Convert provider's parameters by location + for param in &endpoint.parameters { + let field_def = parameter_to_field_def(param); + match param.location.as_str() { + "query" => { query_params.insert(param.parameter_name.clone(), field_def); }, + "body" => { body_fields.insert(param.parameter_name.clone(), field_def); }, + "header" => { header_fields.insert(param.parameter_name.clone(), field_def); }, + "path" => { + // Path params are part of the URL, not separate fields + // Could document them in description if needed + }, + _ => {}, + } + } + + InputSchema { + r#type: "http".to_string(), + method: endpoint.method.clone(), + body_type: if !body_fields.is_empty() { + Some("json".to_string()) + } else { + None + }, + query_params: if !query_params.is_empty() { Some(query_params) } else { None }, + body_fields: if !body_fields.is_empty() { Some(body_fields) } else { None }, + header_fields: if !header_fields.is_empty() { Some(header_fields) } else { None }, + } +} + +/// Build PaymentRequirements structure from provider and resource URL +pub fn build_payment_requirements(provider: &RegisteredProvider, resource_url: &str) -> PaymentRequirements { + // Convert USDC price to atomic units (6 decimals) + let max_amount_atomic = ((provider.price * 1_000_000.0).round() as u64).to_string(); + + // Build input schema from provider's endpoint definition + let input_schema = build_input_schema(&provider.endpoint); + + // Create output schema for x402scan registry compliance + let output_schema = OutputSchema { + input: input_schema, + output: Some(serde_json::json!({ + "type": "object", + "description": "Response from the provider's API endpoint" + })), + }; + + let accepted_payment = AcceptedPayment { + scheme: "exact".to_string(), + network: X402_PAYMENT_NETWORK.to_string(), + max_amount_required: max_amount_atomic, + resource: resource_url.to_string(), + description: provider.description.clone(), + mime_type: "application/json".to_string(), + pay_to: provider.registered_provider_wallet.clone(), + max_timeout_seconds: 60, + asset: if X402_PAYMENT_NETWORK == "base-sepolia" { + USDC_SEPOLIA_ADDRESS.to_string() + } else { + USDC_BASE_ADDRESS.to_string() + }, + output_schema: Some(output_schema), + extra: Some(serde_json::json!({ + "name": USDC_EIP712_NAME, + "version": USDC_EIP712_VERSION + })), + }; + + PaymentRequirements { + protocol_version: 1, + accepts: Some(vec![accepted_payment]), + error: Some("".to_string()), // Empty string for no error (x402 clients expect this field) + payer: None, + } +}