Skip to content
Merged
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
176 changes: 31 additions & 145 deletions rust/Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ resolver = "2"

[workspace.package]
edition = "2024"
version = "0.7.2"
version = "0.7.3"
license = "Apache-2.0"

[workspace.dependencies]
Expand Down
28 changes: 14 additions & 14 deletions rust/crates/cli/src/commands/account/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,10 +312,6 @@ pub fn pick_backend() -> pay_core::Result<String> {
));
}

if options.len() == 1 {
return Ok(options.remove(0).id.to_string());
}

let items: Vec<String> = options.iter().map(|o| o.label.clone()).collect();

eprintln!();
Expand Down Expand Up @@ -377,24 +373,28 @@ pub fn print_next_steps(
if !amount.is_empty() {
eprintln!(" {} Account funded with {}", "✔".green(), amount.green());
}
eprintln!();
eprintln!(
" {}",
"Ready to go. Time to make HTTP pay for itself.".dimmed()
);
eprintln!();
eprintln!(" {}", "$ pay claude".bold());
eprintln!(" {}", "$ pay codex".bold());
} else {
let topup_cmd = if name == "default" {
"pay topup".to_string()
} else {
format!("pay topup --account {name}")
};
eprintln!();
eprintln!(" {}", "Fund your account:".dimmed());
eprintln!(" {}", format!("$ {topup_cmd}").bold());
eprintln!(
" {}",
"A top-up is required before making paid requests.".dimmed()
);
eprintln!(" {}", format!("When ready, run: $ {topup_cmd}").dimmed());
}
eprintln!();
eprintln!(
" {}",
"Ready to go. Time to make HTTP pay for itself.".dimmed()
);
eprintln!();
eprintln!(" {}", "$ pay claude".bold());
eprintln!(" {}", "$ pay codex".bold());

eprintln!();
}

Expand Down
32 changes: 19 additions & 13 deletions rust/crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ fn handle_outcome(
}

RunOutcome::X402Challenge {
requirements,
challenge,
resource_url,
} => {
if auto_pay {
Expand All @@ -321,13 +321,13 @@ fn handle_outcome(
"{}",
format!(
"402 Payment Required (x402) — {} {}",
requirements.amount, requirements.currency
challenge.requirements.amount, challenge.requirements.currency
)
.dimmed()
);
}
return pay_x402_and_retry(
&requirements,
&challenge,
tool,
output_fmt,
fetch_headers,
Expand All @@ -342,11 +342,11 @@ fn handle_outcome(
"status": 402,
"protocol": "x402",
"challenge": {
"amount": requirements.amount,
"currency": requirements.currency,
"recipient": requirements.recipient,
"description": requirements.description,
"cluster": requirements.cluster,
"amount": challenge.requirements.amount,
"currency": challenge.requirements.currency,
"recipient": challenge.requirements.recipient,
"description": challenge.requirements.description,
"cluster": challenge.requirements.cluster,
},
"resource": resource_url,
}))?;
Expand All @@ -355,7 +355,7 @@ fn handle_outcome(
"{}",
format!(
"402 Payment Required (x402) — {} {} — use --yolo to pay automatically",
requirements.amount, requirements.currency
challenge.requirements.amount, challenge.requirements.currency
)
.dimmed()
);
Expand Down Expand Up @@ -457,7 +457,7 @@ fn pay_mpp_and_retry(
}

fn pay_x402_and_retry(
requirements: &X402Challenge,
challenge: &X402Challenge,
tool: &Tool,
output_fmt: Option<OutputFormat>,
fetch_headers: Option<Vec<(String, String)>>,
Expand All @@ -472,8 +472,8 @@ fn pay_x402_and_retry(
}

let store = pay_core::accounts::FileAccountsStore::default_path();
let (payment_json, ephemeral_notice) =
x402::build_payment(requirements, &store, network_override, account_override)?;
let (payment_header_name, payment_json, ephemeral_notice) =
x402::build_payment(challenge, &store, network_override, account_override)?;

if let Some(resolved) = ephemeral_notice {
render_generated_wallet_notice(&resolved, is_json)?;
Expand All @@ -483,7 +483,7 @@ fn pay_x402_and_retry(
eprintln!("{}", "Payment signed, retrying...\n".dimmed());
}

let retry_outcome = retry_with_header(tool, "X-PAYMENT", &payment_json, fetch_headers)?;
let retry_outcome = retry_with_header(tool, payment_header_name, &payment_json, fetch_headers)?;
handle_retry_outcome(retry_outcome, is_json)
}

Expand Down Expand Up @@ -759,4 +759,10 @@ mod tests {
fn tool_kind_mcp() {
assert!(matches!(Command::Mcp.tool_kind(), ToolKind::Mcp));
}

#[test]
fn x402_retry_supports_v1_and_v2_header_names() {
assert_eq!(pay_core::x402::X402_V1_PAYMENT_HEADER, "X-PAYMENT");
assert_eq!(pay_core::x402::X402_V2_PAYMENT_HEADER, "PAYMENT-SIGNATURE");
}
}
18 changes: 1 addition & 17 deletions rust/crates/cli/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,23 +381,7 @@ fn run_topup(
trigger_check(&tx, initial_balances.as_ref().unwrap(), rpc_url, pubkey);
}
KeyCode::Char('q') | KeyCode::Esc => {
blink_checkmark(
terminal,
pubkey,
account_name,
&options,
selected,
&providers,
provider_selected,
focus,
amount_pos,
)?;
return Ok(Some(TopupDetected {
received: ReceivedFunds {
sol_lamports: 0,
tokens: vec![],
},
}));
return Ok(None);
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return Ok(None);
Expand Down
125 changes: 111 additions & 14 deletions rust/crates/core/src/client/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ use std::process::{Command, Stdio};
use tempfile::NamedTempFile;
use tracing::{debug, info};

use solana_x402::protocol::methods::solana::PaymentRequirements;

use crate::client::mpp;
use crate::client::x402;
use crate::{Error, Result};
Expand All @@ -25,7 +23,7 @@ pub enum RunOutcome {
},
/// The server returned 402 with an x402 challenge.
X402Challenge {
requirements: Box<PaymentRequirements>,
challenge: Box<x402::Challenge>,
resource_url: String,
},
/// The server returned 402 but without a recognized payment protocol.
Expand Down Expand Up @@ -185,29 +183,95 @@ pub(crate) fn classify_402(
};
}

// Check for MPP challenge in www-authenticate header
if let Some(www_auth) = headers.iter().find(|(k, _)| k == "www-authenticate")
&& let Some(challenge) = mpp::parse(&www_auth.1)
// Parse both protocols — multi-chain endpoints may advertise both
// x402 (Solana + Base) and Tempo/MPP (EVM-only).
//
// Some servers use `payment-required` instead of `x-payment-required`
// for x402. If the standard parse fails, try decoding `payment-required`
// as base64 JSON and re-parse.
let x402_challenge = x402::parse(headers, body).or_else(|| {
headers
.iter()
.find(|(k, _)| k == "payment-required")
.and_then(|(_, v)| {
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD.decode(v).ok()?;
let json_str = String::from_utf8(decoded).ok()?;
// Re-parse with the decoded JSON as the body
let synthetic_headers: Vec<(String, String)> =
vec![("x-payment-required".to_string(), json_str.clone())];
x402::parse(&synthetic_headers, Some(&json_str))
})
});
let mpp_challenge = headers
.iter()
.find(|(k, _)| k == "www-authenticate")
.and_then(|(_, v)| mpp::parse(v));

// x402::parse (from solana_x402) only returns Some when a Solana-
// compatible `accepts` entry exists — it's already a Solana filter.
// MPP is chain-agnostic at the parse level, so we need to validate
// the recipient is a valid Solana pubkey.
let mpp_is_solana = mpp_challenge.as_ref().is_some_and(|c| {
c.request
.decode()
.ok()
.map(|r: solana_mpp::ChargeRequest| {
// Solana recipients are 32-44 char Base58 strings.
// EVM addresses start with "0x" — quick reject.
r.recipient
.as_deref()
.is_some_and(|s| !s.starts_with("0x") && s.len() >= 32 && s.len() <= 44)
})
.unwrap_or(false)
});

// Session MPP: the method field ("solana") indicates chain support.
// Session requests don't use ChargeRequest so mpp_is_solana doesn't apply.
if let Some(challenge) = &mpp_challenge
&& challenge.intent.as_str() == "session"
{
if challenge.intent.as_str() == "session" {
info!(resource = resource_url, "Detected MPP session challenge");
let is_solana_method = challenge.method.as_str() == "solana";
if is_solana_method {
info!(
resource = resource_url,
"Detected MPP session challenge (Solana)"
);
return RunOutcome::SessionChallenge {
challenge: Box::new(challenge),
challenge: Box::new(challenge.clone()),
resource_url: resource_url.to_string(),
};
}
info!(resource = resource_url, "Detected MPP challenge");
// Non-Solana session — fall through to x402 or error.
}

// Prefer MPP for one-shot Solana payments (native protocol).
if mpp_is_solana {
let challenge = mpp_challenge.unwrap();
info!(resource = resource_url, "Detected MPP challenge (Solana)");
return RunOutcome::MppChallenge {
challenge: Box::new(challenge),
resource_url: resource_url.to_string(),
};
}

// Check for x402 challenge (header or body)
if let Some(requirements) = x402::parse(headers, body) {
info!(resource = resource_url, "Detected x402 challenge");
// Fall back to x402 if it has a Solana path.
if let Some(challenge) = x402_challenge {
info!(resource = resource_url, "Detected x402 challenge (Solana)");
return RunOutcome::X402Challenge {
requirements: Box::new(requirements),
challenge: Box::new(challenge),
resource_url: resource_url.to_string(),
};
}

// Neither protocol supports Solana — tell the user clearly.
if mpp_challenge.is_some() {
return RunOutcome::PaymentRejected {
reason: "Server requires payment but only accepts non-Solana chains \
(e.g. Base/EVM). This endpoint is not compatible with `pay`. \
Check if the provider supports Solana USDC."
.to_string(),
retryable: false,
resource_url: resource_url.to_string(),
};
}
Expand Down Expand Up @@ -446,6 +510,39 @@ HTTP request sent, awaiting response...
assert!(matches!(outcome, RunOutcome::X402Challenge { .. }));
}

#[test]
fn classify_402_rejects_evm_only_mpp_with_clear_error() {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;

// MPP challenge with EVM-style Tempo recipient (not Solana)
let request_json = serde_json::json!({
"amount": "10000",
"currency": "0x20c00000000000000000000b9537d11c60e8b50",
"methodDetails": { "chainId": 4217 },
"recipient": "0x325bdF6F7efAB24a2210c48c1b64cAb2eAe1d430"
});
let b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&request_json).unwrap());
let headers = vec![(
"www-authenticate".to_string(),
format!(
"Payment id=\"test\", realm=\"test\", method=\"tempo\", intent=\"charge\", request=\"{b64}\""
),
)];

// EVM-only MPP with no x402 fallback → clear rejection
let outcome = classify_402(&headers, None, "https://evm-only.example.com/api");
match outcome {
RunOutcome::PaymentRejected { reason, .. } => {
assert!(
reason.contains("non-Solana"),
"Expected non-Solana message, got: {reason}"
);
}
other => panic!("Expected PaymentRejected, got: {other:?}"),
}
}

#[test]
fn classify_402_without_mpp() {
let headers = vec![("content-type".to_string(), "text/html".to_string())];
Expand Down
Loading
Loading