mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(x402): add EVM/Base chain support and dedicated agent tool (#3564)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
id = "crypto_agent"
|
||||
display_name = "Crypto Agent"
|
||||
delegate_name = "do_crypto"
|
||||
when_to_use = "Crypto wallet & market specialist — drives wallet balances, quotes, transfers, swaps, contract calls, and exchange trading workflows on the user's connected wallet identities. Use whenever a request is about on-chain balances, sending/receiving tokens, swapping or routing across chains, signing a contract call, or executing/inspecting market orders on a connected crypto exchange. Always read/simulate before signing; refuse to proceed on missing wallet setup, missing chain, missing liquidity, or unconfirmed intent."
|
||||
when_to_use = "Crypto wallet & market specialist — drives wallet balances, quotes, transfers, swaps, contract calls, exchange trading workflows, and x402 paid API requests on the user's connected wallet identities. Use whenever a request is about on-chain balances, sending/receiving tokens, swapping or routing across chains, signing a contract call, executing/inspecting market orders on a connected crypto exchange, or making HTTP requests to x402-payable APIs (HTTP 402 Payment Required with on-chain USDC settlement). Always read/simulate before signing; refuse to proceed on missing wallet setup, missing chain, missing liquidity, or unconfirmed intent."
|
||||
temperature = 0.2
|
||||
max_iterations = 8
|
||||
sandbox_mode = "none"
|
||||
@@ -73,4 +73,8 @@ named = [
|
||||
# Time grounding for "as of <when>" framing and freshness checks on
|
||||
# quotes before execute.
|
||||
"current_time",
|
||||
# x402 HTTP 402 payment protocol — makes paid API requests by signing
|
||||
# an on-chain payment (EVM EIP-3009 on Base/Ethereum or Solana SPL)
|
||||
# and retrying with a PAYMENT-SIGNATURE header.
|
||||
"x402_request",
|
||||
]
|
||||
|
||||
@@ -8,12 +8,13 @@ You are the **Crypto Agent** — OpenHuman's specialist for wallet and market op
|
||||
- Quoting transfers, swaps and contract calls; surfacing fees, slippage and the destination route.
|
||||
- Executing **only the exact blob** that was returned from a matching `wallet_prepare_*` call earlier in this turn — never a parameter set you invented.
|
||||
- Pulling crypto / FX market data to sanity-check a quote before signing.
|
||||
- Making paid API requests via the **x402 protocol** (HTTP 402 Payment Required). When a server returns 402 with a `PAYMENT-REQUIRED` header, `x402_request` automatically signs a USDC payment (EIP-3009 on Base/Ethereum, or SPL transfer on Solana) and retries with the proof. Use this for x402-enabled APIs (e.g. twit.sh). The wallet must have USDC on the target chain.
|
||||
- Pointing the user back to **Settings → Connections** when a chain, exchange, or wallet identity isn't set up.
|
||||
|
||||
## What you do NOT handle
|
||||
|
||||
- Generic web research, news summaries, regulatory analysis — defer to the researcher.
|
||||
- Code writing, file edits, shell access, broad HTTP. You have no shell, no file_write, no curl.
|
||||
- Code writing, file edits, shell access, broad HTTP. You have no shell, no file_write, no curl. (For x402-payable endpoints, use `x402_request` — not generic HTTP tools.)
|
||||
- Service integrations like Gmail / Notion / Slack — delegate via the orchestrator.
|
||||
- Autonomous background trading. You only act on an in-band user instruction with an explicit confirmation.
|
||||
|
||||
|
||||
@@ -876,6 +876,12 @@ mod tests {
|
||||
"crypto_agent needs supporting tool `{required}`"
|
||||
);
|
||||
}
|
||||
// x402 paid HTTP requests — signs on-chain USDC payments
|
||||
// for APIs behind HTTP 402 challenges.
|
||||
assert!(
|
||||
tools.iter().any(|t| t == "x402_request"),
|
||||
"crypto_agent needs x402_request for paid API access"
|
||||
);
|
||||
assert!(!tools.iter().any(|t| t == "call_memory_agent"));
|
||||
// Hard exclusions — no broad-surface or write-anywhere tools.
|
||||
// Includes the orchestrator-level delegate_* tools so a future
|
||||
|
||||
@@ -546,6 +546,13 @@ pub fn all_tools_with_runtime(
|
||||
http_config.timeout_secs,
|
||||
)));
|
||||
|
||||
// x402 — dedicated tool for making paid HTTP requests to x402-enabled
|
||||
// APIs (Base USDC / Solana USDC). Handles the 402 challenge, EIP-3009
|
||||
// or SPL payment signing, and ledger recording.
|
||||
tools.push(Box::new(
|
||||
crate::openhuman::x402::tools::X402RequestTool::new(),
|
||||
));
|
||||
|
||||
// Coding-harness baseline `web_fetch` (issue #1205) — single-purpose
|
||||
// GET-and-read primitive that reuses the same allowed-domains gate
|
||||
// as `http_request`. Use this for docs/READMEs; reach for
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
mod ops;
|
||||
mod schemas;
|
||||
pub(crate) mod store;
|
||||
pub mod tools;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -23,5 +24,6 @@ pub use schemas::all_controller_schemas as all_x402_controller_schemas;
|
||||
pub use schemas::all_registered_controllers as all_x402_registered_controllers;
|
||||
pub use store::{init_global as init_ledger, PaymentRecord, PaymentStatus, SpendingBudget};
|
||||
pub use types::{
|
||||
PaymentPayload, PaymentRequired, PaymentRequirements, ResourceInfo, SettlementResponse,
|
||||
EvmAuthorization, EvmPaymentProof, PaymentChain, PaymentPayload, PaymentProof, PaymentRequired,
|
||||
PaymentRequirements, ResourceInfo, SettlementResponse, SolanaPaymentProof,
|
||||
};
|
||||
|
||||
+312
-29
@@ -1,14 +1,15 @@
|
||||
//! x402 client operations — parse 402 challenges, build Solana payment
|
||||
//! transactions, sign, and retry with proof.
|
||||
//! x402 client operations — parse 402 challenges, build payment transactions
|
||||
//! (Solana SPL or EVM ERC-20), sign, and retry with proof.
|
||||
//!
|
||||
//! Transaction layout for the `exact` Solana scheme:
|
||||
//! Solana `exact` scheme layout:
|
||||
//! 1. ComputeBudget::SetComputeUnitLimit
|
||||
//! 2. ComputeBudget::SetComputeUnitPrice
|
||||
//! 3. SPL Token `TransferChecked`
|
||||
//! 4. (optional) SPL Memo with `extra.memo` or random nonce
|
||||
//!
|
||||
//! The client signs as the transfer authority; the facilitator's fee-payer
|
||||
//! signature slot is left zeroed for co-signing at settlement time.
|
||||
//! EVM `exact` scheme:
|
||||
//! EIP-3009 `transferWithAuthorization` signed by the wallet's EVM key.
|
||||
//! The facilitator submits the signed authorization on-chain.
|
||||
|
||||
use base64::engine::{general_purpose::STANDARD as B64, Engine as _};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
@@ -77,9 +78,9 @@ impl X402Client {
|
||||
challenge.accepts.len()
|
||||
);
|
||||
|
||||
let requirement = challenge
|
||||
.solana_exact_requirement()
|
||||
.ok_or_else(|| X402Error::NoSolanaOption)?;
|
||||
let (requirement, chain) = challenge
|
||||
.best_exact_requirement()
|
||||
.ok_or_else(|| X402Error::NoPaymentOption)?;
|
||||
|
||||
let amount: u64 = requirement.amount.parse().map_err(|e| {
|
||||
X402Error::Protocol(format!("invalid amount '{}': {e}", requirement.amount))
|
||||
@@ -95,14 +96,20 @@ impl X402Client {
|
||||
}
|
||||
|
||||
debug!(
|
||||
"{LOG_PREFIX} paying {} atomic units of {} to {} (fee_payer={:?})",
|
||||
"{LOG_PREFIX} paying {} atomic units of {} to {} chain={:?} (fee_payer={:?})",
|
||||
amount,
|
||||
requirement.asset,
|
||||
requirement.pay_to,
|
||||
chain,
|
||||
requirement.fee_payer_pubkey(),
|
||||
);
|
||||
|
||||
let payment = build_solana_payment(signing_key, &challenge, requirement).await?;
|
||||
let payment = match chain {
|
||||
PaymentChain::Solana => {
|
||||
build_solana_payment(signing_key, &challenge, requirement).await?
|
||||
}
|
||||
PaymentChain::Evm => build_evm_payment(&challenge, requirement).await?,
|
||||
};
|
||||
let encoded = B64.encode(serde_json::to_string(&payment).unwrap());
|
||||
|
||||
let mut retry_req = self.http.request(method, url);
|
||||
@@ -141,16 +148,28 @@ impl X402Client {
|
||||
}
|
||||
|
||||
/// Standalone entry point — parse a 402 response's headers and return the
|
||||
/// challenge with the index of the Solana payment option. Useful for
|
||||
/// approval-gated flows where the agent must surface the cost before paying.
|
||||
pub fn handle_402(headers: &HeaderMap) -> Result<(PaymentRequired, usize), X402Error> {
|
||||
/// challenge with the index of the best payment option and its chain family.
|
||||
pub fn handle_402(
|
||||
headers: &HeaderMap,
|
||||
) -> Result<(PaymentRequired, usize, PaymentChain), X402Error> {
|
||||
let challenge = parse_402_headers(headers)?;
|
||||
let idx = challenge
|
||||
// Prefer Solana (lower fees, faster finality), fall back to EVM
|
||||
let (idx, chain) = challenge
|
||||
.accepts
|
||||
.iter()
|
||||
.position(|r| r.scheme == "exact" && r.network.starts_with("solana:"))
|
||||
.ok_or(X402Error::NoSolanaOption)?;
|
||||
Ok((challenge, idx))
|
||||
.enumerate()
|
||||
.find(|(_, r)| r.scheme == "exact" && r.network.starts_with("solana:"))
|
||||
.map(|(i, _)| (i, PaymentChain::Solana))
|
||||
.or_else(|| {
|
||||
challenge
|
||||
.accepts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, r)| r.scheme == "exact" && r.network.starts_with("eip155:"))
|
||||
.map(|(i, _)| (i, PaymentChain::Evm))
|
||||
})
|
||||
.ok_or(X402Error::NoPaymentOption)?;
|
||||
Ok((challenge, idx, chain))
|
||||
}
|
||||
|
||||
/// Build a payment and return the encoded header value ready to attach.
|
||||
@@ -161,7 +180,15 @@ pub async fn try_paid_request(
|
||||
challenge: &PaymentRequired,
|
||||
requirement: &PaymentRequirements,
|
||||
) -> Result<String, X402Error> {
|
||||
let payment = build_solana_payment(signing_key, challenge, requirement).await?;
|
||||
let chain = if requirement.network.starts_with("eip155:") {
|
||||
PaymentChain::Evm
|
||||
} else {
|
||||
PaymentChain::Solana
|
||||
};
|
||||
let payment = match chain {
|
||||
PaymentChain::Solana => build_solana_payment(signing_key, challenge, requirement).await?,
|
||||
PaymentChain::Evm => build_evm_payment(challenge, requirement).await?,
|
||||
};
|
||||
let json = serde_json::to_string(&payment)
|
||||
.map_err(|e| X402Error::Protocol(format!("serialize payment: {e}")))?;
|
||||
Ok(B64.encode(json))
|
||||
@@ -183,7 +210,7 @@ pub struct X402PaymentResult {
|
||||
///
|
||||
/// 1. Parses the PAYMENT-REQUIRED challenge
|
||||
/// 2. Checks the spending budget
|
||||
/// 3. Derives the wallet's Solana signing key
|
||||
/// 3. Derives the wallet's signing key (Solana preferred, EVM fallback)
|
||||
/// 4. Builds a partially-signed payment transaction
|
||||
/// 5. Returns the encoded PAYMENT-SIGNATURE header value
|
||||
///
|
||||
@@ -193,7 +220,7 @@ pub async fn handle_402_and_pay(
|
||||
response_headers: &HeaderMap,
|
||||
request_url: &str,
|
||||
) -> Result<X402PaymentResult, X402Error> {
|
||||
let (challenge, idx) = handle_402(response_headers)?;
|
||||
let (challenge, idx, chain) = handle_402(response_headers)?;
|
||||
let requirement = &challenge.accepts[idx];
|
||||
|
||||
let amount: u64 = requirement.amount.parse().map_err(|e| {
|
||||
@@ -224,14 +251,22 @@ pub async fn handle_402_and_pay(
|
||||
}
|
||||
}
|
||||
|
||||
let signing_key = derive_wallet_signing_key().await?;
|
||||
|
||||
debug!(
|
||||
"{LOG_PREFIX} paying {} atomic {} to {} for {}",
|
||||
amount, requirement.asset, requirement.pay_to, request_url
|
||||
"{LOG_PREFIX} paying {} atomic {} to {} for {} chain={:?}",
|
||||
amount, requirement.asset, requirement.pay_to, request_url, chain
|
||||
);
|
||||
|
||||
let header_value = try_paid_request(&signing_key, &challenge, requirement).await?;
|
||||
let payment = match chain {
|
||||
PaymentChain::Solana => {
|
||||
let signing_key = derive_wallet_signing_key().await?;
|
||||
build_solana_payment(&signing_key, &challenge, requirement).await?
|
||||
}
|
||||
PaymentChain::Evm => build_evm_payment(&challenge, requirement).await?,
|
||||
};
|
||||
|
||||
let header_value = serde_json::to_string(&payment)
|
||||
.map(|json| B64.encode(json))
|
||||
.map_err(|e| X402Error::Protocol(format!("serialize payment: {e}")))?;
|
||||
|
||||
Ok(X402PaymentResult {
|
||||
header_value,
|
||||
@@ -341,7 +376,7 @@ fn parse_derivation_path(path: &str) -> Result<Vec<u32>, X402Error> {
|
||||
pub enum X402Error {
|
||||
Transport(reqwest::Error),
|
||||
NoPaymentHeader,
|
||||
NoSolanaOption,
|
||||
NoPaymentOption,
|
||||
AmountExceedsCap {
|
||||
requested: u64,
|
||||
cap: u64,
|
||||
@@ -360,7 +395,12 @@ impl std::fmt::Display for X402Error {
|
||||
match self {
|
||||
Self::Transport(e) => write!(f, "x402 transport: {e}"),
|
||||
Self::NoPaymentHeader => write!(f, "402 response missing PAYMENT-REQUIRED header"),
|
||||
Self::NoSolanaOption => write!(f, "no Solana exact payment option in 402 challenge"),
|
||||
Self::NoPaymentOption => {
|
||||
write!(
|
||||
f,
|
||||
"no supported payment option (Solana exact or EVM exact) in 402 challenge"
|
||||
)
|
||||
}
|
||||
Self::AmountExceedsCap { requested, cap } => {
|
||||
write!(f, "x402 amount {requested} exceeds per-request cap {cap}")
|
||||
}
|
||||
@@ -524,13 +564,256 @@ async fn build_solana_payment(
|
||||
x402_version: X402_VERSION,
|
||||
resource: Some(challenge.resource.clone()),
|
||||
accepted: req.clone(),
|
||||
payload: SolanaPaymentProof {
|
||||
payload: PaymentProof::Solana(SolanaPaymentProof {
|
||||
transaction: tx_b64,
|
||||
},
|
||||
}),
|
||||
extensions: serde_json::Map::new(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EVM payment construction (EIP-3009 transferWithAuthorization)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build an EVM payment using EIP-3009 `transferWithAuthorization`.
|
||||
/// Signs the typed data with the wallet's EVM key and returns the proof
|
||||
/// for the facilitator to submit on-chain.
|
||||
async fn build_evm_payment(
|
||||
challenge: &PaymentRequired,
|
||||
req: &PaymentRequirements,
|
||||
) -> Result<PaymentPayload, X402Error> {
|
||||
let (signer, from_address) = derive_evm_signer().await?;
|
||||
build_evm_payment_with_signer(&signer, from_address, challenge, req)
|
||||
}
|
||||
|
||||
/// Core EVM payment construction — separated from wallet derivation for testability.
|
||||
pub(crate) fn build_evm_payment_with_signer(
|
||||
signer: ðers_signers::LocalWallet,
|
||||
from_address: ethers_core::types::Address,
|
||||
challenge: &PaymentRequired,
|
||||
req: &PaymentRequirements,
|
||||
) -> Result<PaymentPayload, X402Error> {
|
||||
use ethers_core::types::{Address, U256};
|
||||
use ethers_signers::Signer;
|
||||
use std::str::FromStr;
|
||||
|
||||
let chain_id = req
|
||||
.evm_chain_id()
|
||||
.ok_or_else(|| X402Error::Protocol(format!("not an EVM network: {}", req.network)))?;
|
||||
|
||||
let amount = U256::from_dec_str(&req.amount)
|
||||
.map_err(|e| X402Error::Protocol(format!("invalid amount '{}': {e}", req.amount)))?;
|
||||
|
||||
let pay_to = Address::from_str(&req.pay_to).map_err(|e| {
|
||||
X402Error::Protocol(format!("invalid EVM payTo address '{}': {e}", req.pay_to))
|
||||
})?;
|
||||
|
||||
let token_address = Address::from_str(&req.asset).map_err(|e| {
|
||||
X402Error::Protocol(format!("invalid EVM token address '{}': {e}", req.asset))
|
||||
})?;
|
||||
|
||||
// EIP-3009 parameters
|
||||
let valid_after = U256::zero();
|
||||
let valid_before = U256::from(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
+ req.max_timeout_seconds,
|
||||
);
|
||||
|
||||
// Random nonce for EIP-3009
|
||||
let nonce = {
|
||||
let mut hasher = Sha256::new();
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
hasher.update(ts.to_le_bytes());
|
||||
hasher.update(std::process::id().to_le_bytes());
|
||||
let hash: [u8; 32] = hasher.finalize().into();
|
||||
hash
|
||||
};
|
||||
|
||||
// EIP-712 typed data for `transferWithAuthorization`
|
||||
let domain_name = req
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|e| e.name.as_deref())
|
||||
.unwrap_or("USD Coin");
|
||||
let domain_version = req
|
||||
.extra
|
||||
.as_ref()
|
||||
.and_then(|e| e.version.as_deref())
|
||||
.unwrap_or("2");
|
||||
let domain_separator =
|
||||
eip712_domain_separator_named(token_address, chain_id, domain_name, domain_version);
|
||||
let struct_hash = eip3009_struct_hash(
|
||||
from_address,
|
||||
pay_to,
|
||||
amount,
|
||||
valid_after,
|
||||
valid_before,
|
||||
nonce,
|
||||
);
|
||||
|
||||
let mut digest_input = Vec::with_capacity(66);
|
||||
digest_input.extend(b"\x19\x01");
|
||||
digest_input.extend(domain_separator);
|
||||
digest_input.extend(struct_hash);
|
||||
let digest: [u8; 32] = {
|
||||
use ethers_core::types::H256;
|
||||
let h = H256::from(ethers_core::utils::keccak256(&digest_input));
|
||||
h.into()
|
||||
};
|
||||
|
||||
let signature = signer
|
||||
.sign_hash(ethers_core::types::H256::from(digest))
|
||||
.map_err(|e| X402Error::Wallet(format!("EVM sign EIP-3009: {e}")))?;
|
||||
|
||||
let sig_hex = format!("0x{}", hex::encode(signature.to_vec()));
|
||||
let nonce_hex = format!("0x{}", hex::encode(nonce));
|
||||
|
||||
debug!(
|
||||
"{LOG_PREFIX} built EVM payment chain_id={chain_id} amount={} asset={} from={:#x} to={:#x}",
|
||||
req.amount, req.asset, from_address, pay_to
|
||||
);
|
||||
|
||||
Ok(PaymentPayload {
|
||||
x402_version: X402_VERSION,
|
||||
resource: Some(challenge.resource.clone()),
|
||||
accepted: req.clone(),
|
||||
payload: PaymentProof::Evm(EvmPaymentProof {
|
||||
signature: sig_hex,
|
||||
authorization: EvmAuthorization {
|
||||
from: format!("{from_address:#x}"),
|
||||
to: format!("{pay_to:#x}"),
|
||||
value: req.amount.clone(),
|
||||
valid_after: "0".to_string(),
|
||||
valid_before: valid_before.to_string(),
|
||||
nonce: nonce_hex,
|
||||
},
|
||||
}),
|
||||
extensions: serde_json::Map::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Derive the wallet's EVM signer from the encrypted mnemonic.
|
||||
async fn derive_evm_signer(
|
||||
) -> Result<(ethers_signers::LocalWallet, ethers_core::types::Address), X402Error> {
|
||||
use crate::openhuman::wallet::WalletChain;
|
||||
use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer};
|
||||
|
||||
let secret = crate::openhuman::wallet::secret_material(WalletChain::Evm)
|
||||
.await
|
||||
.map_err(|e| X402Error::Wallet(format!("wallet secret: {e}")))?;
|
||||
|
||||
let config = crate::openhuman::config::rpc::load_config_with_timeout()
|
||||
.await
|
||||
.map_err(|e| X402Error::Wallet(format!("load config: {e}")))?;
|
||||
|
||||
let mnemonic =
|
||||
crate::openhuman::encryption::rpc::decrypt_secret(&config, &secret.encrypted_mnemonic)
|
||||
.await
|
||||
.map_err(|e| X402Error::Wallet(format!("decrypt mnemonic: {e}")))?
|
||||
.value;
|
||||
|
||||
let wallet = MnemonicBuilder::<English>::default()
|
||||
.phrase(mnemonic.as_str())
|
||||
.derivation_path(&secret.derivation_path)
|
||||
.map_err(|e| {
|
||||
X402Error::Wallet(format!(
|
||||
"invalid EVM derivation path '{}': {e}",
|
||||
secret.derivation_path
|
||||
))
|
||||
})?
|
||||
.build()
|
||||
.map_err(|e| X402Error::Wallet(format!("derive EVM signer: {e}")))?;
|
||||
|
||||
let address = wallet.address();
|
||||
Ok((wallet, address))
|
||||
}
|
||||
|
||||
/// EIP-712 domain separator with default USDC params ("USD Coin", "2").
|
||||
pub(crate) fn eip712_domain_separator(
|
||||
verifying_contract: ethers_core::types::Address,
|
||||
chain_id: u64,
|
||||
) -> [u8; 32] {
|
||||
eip712_domain_separator_named(verifying_contract, chain_id, "USD Coin", "2")
|
||||
}
|
||||
|
||||
/// EIP-712 domain separator with explicit name and version from the 402 extra.
|
||||
pub(crate) fn eip712_domain_separator_named(
|
||||
verifying_contract: ethers_core::types::Address,
|
||||
chain_id: u64,
|
||||
name: &str,
|
||||
version: &str,
|
||||
) -> [u8; 32] {
|
||||
use ethers_core::utils::keccak256;
|
||||
|
||||
let type_hash = keccak256(
|
||||
b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)",
|
||||
);
|
||||
let name_hash = keccak256(name.as_bytes());
|
||||
let version_hash = keccak256(version.as_bytes());
|
||||
|
||||
let mut encoded = Vec::with_capacity(5 * 32);
|
||||
encoded.extend(type_hash);
|
||||
encoded.extend(name_hash);
|
||||
encoded.extend(version_hash);
|
||||
let mut chain_id_bytes = [0u8; 32];
|
||||
chain_id_bytes[24..].copy_from_slice(&chain_id.to_be_bytes());
|
||||
encoded.extend(chain_id_bytes);
|
||||
let mut addr_bytes = [0u8; 32];
|
||||
addr_bytes[12..].copy_from_slice(verifying_contract.as_bytes());
|
||||
encoded.extend(addr_bytes);
|
||||
|
||||
keccak256(&encoded)
|
||||
}
|
||||
|
||||
/// EIP-3009 `TransferWithAuthorization` struct hash.
|
||||
pub(crate) fn eip3009_struct_hash(
|
||||
from: ethers_core::types::Address,
|
||||
to: ethers_core::types::Address,
|
||||
value: ethers_core::types::U256,
|
||||
valid_after: ethers_core::types::U256,
|
||||
valid_before: ethers_core::types::U256,
|
||||
nonce: [u8; 32],
|
||||
) -> [u8; 32] {
|
||||
use ethers_core::utils::keccak256;
|
||||
|
||||
let type_hash = keccak256(
|
||||
b"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)",
|
||||
);
|
||||
|
||||
let mut encoded = Vec::with_capacity(7 * 32);
|
||||
encoded.extend(type_hash);
|
||||
|
||||
let mut from_bytes = [0u8; 32];
|
||||
from_bytes[12..].copy_from_slice(from.as_bytes());
|
||||
encoded.extend(from_bytes);
|
||||
|
||||
let mut to_bytes = [0u8; 32];
|
||||
to_bytes[12..].copy_from_slice(to.as_bytes());
|
||||
encoded.extend(to_bytes);
|
||||
|
||||
let mut value_bytes = [0u8; 32];
|
||||
value.to_big_endian(&mut value_bytes);
|
||||
encoded.extend(value_bytes);
|
||||
|
||||
let mut va_bytes = [0u8; 32];
|
||||
valid_after.to_big_endian(&mut va_bytes);
|
||||
encoded.extend(va_bytes);
|
||||
|
||||
let mut vb_bytes = [0u8; 32];
|
||||
valid_before.to_big_endian(&mut vb_bytes);
|
||||
encoded.extend(vb_bytes);
|
||||
|
||||
encoded.extend(nonce);
|
||||
|
||||
keccak256(&encoded)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solana wire-format helpers (mirrors wallet/chains/solana.rs primitives)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Agent tool for making x402-paid HTTP requests.
|
||||
//!
|
||||
//! Unlike the general `http_request` tool (which silently handles 402s as a
|
||||
//! fallback), this tool is purpose-built for x402 endpoints: it always expects
|
||||
//! a payment challenge, surfaces pricing to the agent, and records the payment
|
||||
//! in the ledger.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::{general_purpose::STANDARD as B64, Engine as _};
|
||||
use log::debug;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCallOptions, ToolResult};
|
||||
|
||||
use super::store;
|
||||
use super::types::*;
|
||||
|
||||
const LOG_PREFIX: &str = "[tool.x402_request]";
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
pub struct X402RequestTool;
|
||||
|
||||
impl X402RequestTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for X402RequestTool {
|
||||
fn name(&self) -> &str {
|
||||
"x402_request"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Make an HTTP request to an x402-payable API endpoint. Automatically handles the \
|
||||
HTTP 402 payment challenge by signing a payment (EVM EIP-3009 on Base/Ethereum, or \
|
||||
Solana SPL transfer) with the wallet and retrying with the payment proof. \
|
||||
Returns the API response after payment. Use this for x402-enabled APIs like twit.sh. \
|
||||
The wallet must be set up with USDC on the target chain."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL of the x402-payable API endpoint (e.g. https://x402.twit.sh/tweets/by/id?id=1110302988)"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": "HTTP method (default: GET)",
|
||||
"default": "GET",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"description": "Optional HTTP headers as key-value pairs",
|
||||
"default": {}
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Optional request body (for POST/PUT/PATCH)"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
})
|
||||
}
|
||||
|
||||
fn permission_level(&self) -> PermissionLevel {
|
||||
PermissionLevel::Write
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.execute_with_options(args, ToolCallOptions::default())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_with_options(
|
||||
&self,
|
||||
args: serde_json::Value,
|
||||
_options: ToolCallOptions,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let url = match args.get("url").and_then(|v| v.as_str()) {
|
||||
Some(u) => u.to_string(),
|
||||
None => return Ok(ToolResult::error("Missing required 'url' parameter")),
|
||||
};
|
||||
|
||||
let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
|
||||
let method: reqwest::Method = match method_str.parse() {
|
||||
Ok(m) => m,
|
||||
Err(_) => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"Unsupported HTTP method: {method_str}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let headers = parse_header_args(args.get("headers"));
|
||||
let body = args.get("body").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
debug!("{LOG_PREFIX} requesting {method} {url}");
|
||||
|
||||
// Step 1: Initial request to get the 402 challenge
|
||||
let client = match build_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult::error(format!(
|
||||
"Failed to build HTTP client: {e}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let initial_response =
|
||||
match send_request(&client, &method, &url, &headers, body.as_deref()).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Ok(ToolResult::error(format!("Initial request failed: {e}"))),
|
||||
};
|
||||
|
||||
// If the response is not 402, return it directly
|
||||
if initial_response.status() != reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||
let status = initial_response.status().as_u16();
|
||||
debug!("{LOG_PREFIX} got {status} (not 402), returning directly");
|
||||
return format_response(initial_response, &url).await;
|
||||
}
|
||||
|
||||
// Step 2: Parse the 402 challenge
|
||||
let initial_headers = initial_response.headers().clone();
|
||||
if initial_headers.get(HEADER_PAYMENT_REQUIRED).is_none()
|
||||
&& initial_headers.get(HEADER_PAYMENT_REQUIRED_V1).is_none()
|
||||
{
|
||||
return Ok(ToolResult::error(
|
||||
"Server returned 402 but without a PAYMENT-REQUIRED header — not an x402 endpoint",
|
||||
));
|
||||
}
|
||||
|
||||
debug!("{LOG_PREFIX} got 402 with PAYMENT-REQUIRED header, processing payment");
|
||||
|
||||
// Step 3: Build and sign the payment
|
||||
let payment_result = match super::handle_402_and_pay(&initial_headers, &url).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult::error(format!("x402 payment failed: {e}")));
|
||||
}
|
||||
};
|
||||
|
||||
let amount_display = format!(
|
||||
"{:.6} USDC",
|
||||
payment_result.amount_atomic as f64 / 1_000_000.0
|
||||
);
|
||||
|
||||
debug!(
|
||||
"{LOG_PREFIX} payment built: {} to {} on {} for {}",
|
||||
amount_display, payment_result.recipient, payment_result.network, url
|
||||
);
|
||||
|
||||
// Record the pending payment
|
||||
let record = store::PaymentRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
url: payment_result.url.clone(),
|
||||
asset: payment_result.asset.clone(),
|
||||
amount_atomic: payment_result.amount_atomic,
|
||||
amount_display: amount_display.clone(),
|
||||
recipient: payment_result.recipient.clone(),
|
||||
network: payment_result.network.clone(),
|
||||
tx_signature: None,
|
||||
status: store::PaymentStatus::Pending,
|
||||
timestamp: chrono::Utc::now(),
|
||||
session_id: String::new(),
|
||||
};
|
||||
let record_id = record.id.clone();
|
||||
let _ = store::with_ledger_mut(|l| l.record_payment(record));
|
||||
|
||||
// Step 4: Retry with the payment signature
|
||||
let mut retry_headers = headers.clone();
|
||||
retry_headers.push((
|
||||
HEADER_PAYMENT_SIGNATURE.to_string(),
|
||||
payment_result.header_value,
|
||||
));
|
||||
|
||||
let paid_response =
|
||||
match send_request(&client, &method, &url, &retry_headers, body.as_deref()).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = store::with_ledger_mut(|l| {
|
||||
let mut updated = store::PaymentRecord {
|
||||
id: record_id,
|
||||
url: url.clone(),
|
||||
asset: payment_result.asset.clone(),
|
||||
amount_atomic: payment_result.amount_atomic,
|
||||
amount_display: amount_display.clone(),
|
||||
recipient: payment_result.recipient.clone(),
|
||||
network: payment_result.network.clone(),
|
||||
tx_signature: None,
|
||||
status: store::PaymentStatus::Failed,
|
||||
timestamp: chrono::Utc::now(),
|
||||
session_id: String::new(),
|
||||
};
|
||||
l.record_payment(updated);
|
||||
});
|
||||
return Ok(ToolResult::error(format!("x402 retry request failed: {e}")));
|
||||
}
|
||||
};
|
||||
|
||||
// Step 5: Parse settlement response and update ledger
|
||||
let settled_status = if paid_response.status().is_success() {
|
||||
store::PaymentStatus::Settled
|
||||
} else {
|
||||
store::PaymentStatus::Failed
|
||||
};
|
||||
|
||||
let tx_sig = paid_response
|
||||
.headers()
|
||||
.get(HEADER_PAYMENT_RESPONSE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|b64| B64.decode(b64).ok())
|
||||
.and_then(|bytes| serde_json::from_slice::<SettlementResponse>(&bytes).ok())
|
||||
.and_then(|r| {
|
||||
if r.success && !r.transaction.is_empty() {
|
||||
Some(r.transaction)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let _ = store::with_ledger_mut(|l| {
|
||||
let updated = store::PaymentRecord {
|
||||
id: record_id.clone(),
|
||||
url: url.clone(),
|
||||
asset: payment_result.asset.clone(),
|
||||
amount_atomic: payment_result.amount_atomic,
|
||||
amount_display: amount_display.clone(),
|
||||
recipient: payment_result.recipient.clone(),
|
||||
network: payment_result.network.clone(),
|
||||
tx_signature: tx_sig.clone(),
|
||||
status: settled_status,
|
||||
timestamp: chrono::Utc::now(),
|
||||
session_id: String::new(),
|
||||
};
|
||||
l.record_payment(updated);
|
||||
});
|
||||
|
||||
if settled_status == store::PaymentStatus::Settled {
|
||||
debug!(
|
||||
"{LOG_PREFIX} payment settled for {url} tx={:?} amount={}",
|
||||
tx_sig, amount_display
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"{LOG_PREFIX} payment failed for {url} status={}",
|
||||
paid_response.status()
|
||||
);
|
||||
}
|
||||
|
||||
// Step 6: Format and return the response with payment metadata
|
||||
format_response_with_payment(
|
||||
paid_response,
|
||||
&url,
|
||||
&amount_display,
|
||||
&payment_result.network,
|
||||
tx_sig.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn build_client() -> Result<reqwest::Client, reqwest::Error> {
|
||||
let builder = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::limited(5));
|
||||
let builder =
|
||||
crate::openhuman::config::apply_runtime_proxy_to_builder(builder, "tool.x402_request");
|
||||
builder.build()
|
||||
}
|
||||
|
||||
fn parse_header_args(headers_val: Option<&serde_json::Value>) -> Vec<(String, String)> {
|
||||
let mut result = Vec::new();
|
||||
if let Some(obj) = headers_val.and_then(|v| v.as_object()) {
|
||||
for (key, value) in obj {
|
||||
if let Some(str_val) = value.as_str() {
|
||||
result.push((key.clone(), str_val.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
client: &reqwest::Client,
|
||||
method: &reqwest::Method,
|
||||
url: &str,
|
||||
headers: &[(String, String)],
|
||||
body: Option<&str>,
|
||||
) -> Result<reqwest::Response, reqwest::Error> {
|
||||
let mut request = client.request(method.clone(), url);
|
||||
for (key, value) in headers {
|
||||
request = request.header(key, value);
|
||||
}
|
||||
if let Some(body_str) = body {
|
||||
request = request.body(body_str.to_string());
|
||||
}
|
||||
request.send().await
|
||||
}
|
||||
|
||||
async fn format_response(response: reqwest::Response, url: &str) -> anyhow::Result<ToolResult> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let truncated = if body.len() > 50_000 {
|
||||
format!("{}…(truncated)", &body[..50_000])
|
||||
} else {
|
||||
body
|
||||
};
|
||||
|
||||
Ok(ToolResult::success(format!(
|
||||
"HTTP {status} from {url}\n\n{truncated}"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn format_response_with_payment(
|
||||
response: reqwest::Response,
|
||||
url: &str,
|
||||
amount_display: &str,
|
||||
network: &str,
|
||||
tx_sig: Option<&str>,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let truncated = if body.len() > 50_000 {
|
||||
format!("{}…(truncated)", &body[..50_000])
|
||||
} else {
|
||||
body
|
||||
};
|
||||
|
||||
let chain_label = if network.starts_with("eip155:8453") {
|
||||
"Base"
|
||||
} else if network.starts_with("eip155:1") {
|
||||
"Ethereum"
|
||||
} else if network.starts_with("eip155:") {
|
||||
"EVM"
|
||||
} else if network.starts_with("solana:") {
|
||||
"Solana"
|
||||
} else {
|
||||
network
|
||||
};
|
||||
|
||||
let tx_line = tx_sig
|
||||
.map(|sig| format!("\nTransaction: {sig}"))
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ToolResult::success(format!(
|
||||
"HTTP {status} from {url}\n\
|
||||
x402 payment: {amount_display} on {chain_label}{tx_line}\n\n\
|
||||
{truncated}"
|
||||
)))
|
||||
}
|
||||
@@ -27,6 +27,15 @@ pub const SPL_TOKEN_PROGRAM: &str = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
|
||||
pub const SPL_MEMO_PROGRAM: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
|
||||
pub const COMPUTE_BUDGET_PROGRAM: &str = "ComputeBudget111111111111111111111111111111";
|
||||
|
||||
// EVM / Base chain constants (CAIP-2 format: eip155:<chain_id>)
|
||||
pub const BASE_MAINNET_CAIP2: &str = "eip155:8453";
|
||||
pub const BASE_SEPOLIA_CAIP2: &str = "eip155:84532";
|
||||
pub const ETHEREUM_MAINNET_CAIP2: &str = "eip155:1";
|
||||
|
||||
pub const USDC_BASE_MAINNET: &str = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
||||
pub const USDC_BASE_SEPOLIA: &str = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
|
||||
pub const USDC_ETHEREUM_MAINNET: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 402 challenge — server → client (PAYMENT-REQUIRED header)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,12 +81,18 @@ pub struct PaymentRequirements {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PaymentExtra {
|
||||
/// Facilitator pubkey that will co-sign as fee payer.
|
||||
/// Facilitator pubkey that will co-sign as fee payer (Solana).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fee_payer: Option<String>,
|
||||
/// Required memo value for transaction uniqueness.
|
||||
/// Required memo value for transaction uniqueness (Solana).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memo: Option<String>,
|
||||
/// EIP-712 domain name for the token contract (EVM, e.g. "USD Coin").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// EIP-712 domain version for the token contract (EVM, e.g. "2").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,11 +106,21 @@ pub struct PaymentPayload {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resource: Option<ResourceInfo>,
|
||||
pub accepted: PaymentRequirements,
|
||||
pub payload: SolanaPaymentProof,
|
||||
pub payload: PaymentProof,
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub extensions: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Chain-specific payment proof. Serializes flat (untagged) so the facilitator
|
||||
/// sees either `{ "transaction": "..." }` (Solana) or
|
||||
/// `{ "signature": "0x...", "authorization": {...} }` (EVM).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum PaymentProof {
|
||||
Solana(SolanaPaymentProof),
|
||||
Evm(EvmPaymentProof),
|
||||
}
|
||||
|
||||
/// Solana `exact` scheme payload — a partially-signed `VersionedTransaction`
|
||||
/// serialized as standard base64. The facilitator adds its fee-payer signature
|
||||
/// and broadcasts.
|
||||
@@ -104,6 +129,27 @@ pub struct SolanaPaymentProof {
|
||||
pub transaction: String,
|
||||
}
|
||||
|
||||
/// EVM `exact` scheme payload — a signed EIP-3009 `transferWithAuthorization`
|
||||
/// or plain ERC-20 transfer authorization for the facilitator to submit.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvmPaymentProof {
|
||||
pub signature: String,
|
||||
pub authorization: EvmAuthorization,
|
||||
}
|
||||
|
||||
/// EIP-3009 `transferWithAuthorization` parameters signed by the token holder.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EvmAuthorization {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub value: String,
|
||||
pub valid_after: String,
|
||||
pub valid_before: String,
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settlement response — server → client (PAYMENT-RESPONSE header)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -137,6 +183,32 @@ impl PaymentRequired {
|
||||
.iter()
|
||||
.find(|r| r.scheme == "exact" && r.network.starts_with("solana:"))
|
||||
}
|
||||
|
||||
/// Find the first `accepts` entry whose network starts with `"eip155:"` and
|
||||
/// whose scheme is `"exact"`.
|
||||
pub fn evm_exact_requirement(&self) -> Option<&PaymentRequirements> {
|
||||
self.accepts
|
||||
.iter()
|
||||
.find(|r| r.scheme == "exact" && r.network.starts_with("eip155:"))
|
||||
}
|
||||
|
||||
/// Find the best payment option — prefer EVM (Base), fall back to Solana.
|
||||
pub fn best_exact_requirement(&self) -> Option<(&PaymentRequirements, PaymentChain)> {
|
||||
if let Some(sol) = self.solana_exact_requirement() {
|
||||
Some((sol, PaymentChain::Solana))
|
||||
} else if let Some(evm) = self.evm_exact_requirement() {
|
||||
Some((evm, PaymentChain::Evm))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which chain family a payment requirement targets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaymentChain {
|
||||
Solana,
|
||||
Evm,
|
||||
}
|
||||
|
||||
impl PaymentRequirements {
|
||||
@@ -144,6 +216,17 @@ impl PaymentRequirements {
|
||||
self.network == SOLANA_MAINNET_CAIP2
|
||||
}
|
||||
|
||||
pub fn is_base_mainnet(&self) -> bool {
|
||||
self.network == BASE_MAINNET_CAIP2
|
||||
}
|
||||
|
||||
/// Parse the EVM chain ID from an `eip155:<chain_id>` network string.
|
||||
pub fn evm_chain_id(&self) -> Option<u64> {
|
||||
self.network
|
||||
.strip_prefix("eip155:")
|
||||
.and_then(|s| s.parse().ok())
|
||||
}
|
||||
|
||||
pub fn fee_payer_pubkey(&self) -> Option<&str> {
|
||||
self.extra.as_ref()?.fee_payer.as_deref()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use base64::engine::{general_purpose::STANDARD as B64, Engine as _};
|
||||
use serde_json::json;
|
||||
|
||||
use super::ops::{build_evm_payment_with_signer, eip3009_struct_hash, eip712_domain_separator};
|
||||
use super::types::*;
|
||||
|
||||
#[test]
|
||||
@@ -23,6 +24,8 @@ fn parse_payment_required_round_trips() {
|
||||
extra: Some(PaymentExtra {
|
||||
fee_payer: Some("EwWqGE4ZFKLofuestmU4LDdK7XM1N4ALgdZccwYugwGd".into()),
|
||||
memo: Some("pi_3abc123".into()),
|
||||
name: None,
|
||||
version: None,
|
||||
}),
|
||||
}],
|
||||
extensions: serde_json::Map::new(),
|
||||
@@ -111,6 +114,8 @@ fn payment_extra_accessors() {
|
||||
extra: Some(PaymentExtra {
|
||||
fee_payer: Some("FeePayer123".into()),
|
||||
memo: Some("order_456".into()),
|
||||
name: None,
|
||||
version: None,
|
||||
}),
|
||||
};
|
||||
assert_eq!(req.fee_payer_pubkey(), Some("FeePayer123"));
|
||||
@@ -187,3 +192,392 @@ fn base64_header_round_trip() {
|
||||
let parsed: PaymentRequired = serde_json::from_slice(&decoded).unwrap();
|
||||
assert_eq!(parsed.accepts[0].amount, "1000000");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EVM tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn evm_exact_requirement_finds_match() {
|
||||
let challenge = PaymentRequired {
|
||||
x402_version: 2,
|
||||
error: None,
|
||||
resource: ResourceInfo {
|
||||
url: "https://twit.sh/post".into(),
|
||||
description: None,
|
||||
mime_type: None,
|
||||
},
|
||||
accepts: vec![
|
||||
PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: BASE_MAINNET_CAIP2.into(),
|
||||
amount: "100".into(),
|
||||
asset: USDC_BASE_MAINNET.into(),
|
||||
pay_to: "0x1234567890abcdef1234567890abcdef12345678".into(),
|
||||
max_timeout_seconds: 30,
|
||||
extra: None,
|
||||
},
|
||||
PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: SOLANA_MAINNET_CAIP2.into(),
|
||||
amount: "5000".into(),
|
||||
asset: USDC_MINT_MAINNET.into(),
|
||||
pay_to: "SomeRecipient".into(),
|
||||
max_timeout_seconds: 60,
|
||||
extra: None,
|
||||
},
|
||||
],
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
let evm = challenge.evm_exact_requirement().unwrap();
|
||||
assert_eq!(evm.amount, "100");
|
||||
assert!(evm.is_base_mainnet());
|
||||
assert_eq!(evm.evm_chain_id(), Some(8453));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_exact_requirement_prefers_solana() {
|
||||
let challenge = PaymentRequired {
|
||||
x402_version: 2,
|
||||
error: None,
|
||||
resource: ResourceInfo {
|
||||
url: "https://example.com".into(),
|
||||
description: None,
|
||||
mime_type: None,
|
||||
},
|
||||
accepts: vec![
|
||||
PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: SOLANA_MAINNET_CAIP2.into(),
|
||||
amount: "5000".into(),
|
||||
asset: USDC_MINT_MAINNET.into(),
|
||||
pay_to: "SolRecipient".into(),
|
||||
max_timeout_seconds: 60,
|
||||
extra: None,
|
||||
},
|
||||
PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: BASE_MAINNET_CAIP2.into(),
|
||||
amount: "100".into(),
|
||||
asset: USDC_BASE_MAINNET.into(),
|
||||
pay_to: "0xRecipient".into(),
|
||||
max_timeout_seconds: 30,
|
||||
extra: None,
|
||||
},
|
||||
],
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
let (req, chain) = challenge.best_exact_requirement().unwrap();
|
||||
assert_eq!(chain, PaymentChain::Solana);
|
||||
assert_eq!(req.amount, "5000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_exact_requirement_falls_back_to_evm() {
|
||||
let challenge = PaymentRequired {
|
||||
x402_version: 2,
|
||||
error: None,
|
||||
resource: ResourceInfo {
|
||||
url: "https://example.com".into(),
|
||||
description: None,
|
||||
mime_type: None,
|
||||
},
|
||||
accepts: vec![PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: BASE_MAINNET_CAIP2.into(),
|
||||
amount: "100".into(),
|
||||
asset: USDC_BASE_MAINNET.into(),
|
||||
pay_to: "0xRecipient".into(),
|
||||
max_timeout_seconds: 30,
|
||||
extra: None,
|
||||
}],
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
let (req, chain) = challenge.best_exact_requirement().unwrap();
|
||||
assert_eq!(chain, PaymentChain::Evm);
|
||||
assert_eq!(req.amount, "100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evm_chain_id_parsing() {
|
||||
let req = PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: "eip155:8453".into(),
|
||||
amount: "100".into(),
|
||||
asset: USDC_BASE_MAINNET.into(),
|
||||
pay_to: "0xRecipient".into(),
|
||||
max_timeout_seconds: 30,
|
||||
extra: None,
|
||||
};
|
||||
assert_eq!(req.evm_chain_id(), Some(8453));
|
||||
assert!(req.is_base_mainnet());
|
||||
|
||||
let eth_req = PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: "eip155:1".into(),
|
||||
amount: "100".into(),
|
||||
asset: USDC_ETHEREUM_MAINNET.into(),
|
||||
pay_to: "0xRecipient".into(),
|
||||
max_timeout_seconds: 30,
|
||||
extra: None,
|
||||
};
|
||||
assert_eq!(eth_req.evm_chain_id(), Some(1));
|
||||
assert!(!eth_req.is_base_mainnet());
|
||||
|
||||
let sol_req = PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: SOLANA_MAINNET_CAIP2.into(),
|
||||
amount: "100".into(),
|
||||
asset: USDC_MINT_MAINNET.into(),
|
||||
pay_to: "Recipient".into(),
|
||||
max_timeout_seconds: 30,
|
||||
extra: None,
|
||||
};
|
||||
assert_eq!(sol_req.evm_chain_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evm_payment_proof_serializes_correctly() {
|
||||
let proof = EvmPaymentProof {
|
||||
signature: "0xdeadbeef".into(),
|
||||
authorization: EvmAuthorization {
|
||||
from: "0xaaaa".into(),
|
||||
to: "0xbbbb".into(),
|
||||
value: "1000000".into(),
|
||||
valid_after: "0".into(),
|
||||
valid_before: "99999999".into(),
|
||||
nonce: "0xabcd".into(),
|
||||
},
|
||||
};
|
||||
let payload = PaymentPayload {
|
||||
x402_version: 2,
|
||||
resource: None,
|
||||
accepted: PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: BASE_MAINNET_CAIP2.into(),
|
||||
amount: "1000000".into(),
|
||||
asset: USDC_BASE_MAINNET.into(),
|
||||
pay_to: "0xbbbb".into(),
|
||||
max_timeout_seconds: 60,
|
||||
extra: None,
|
||||
},
|
||||
payload: PaymentProof::Evm(proof),
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
assert!(json.contains("\"signature\":\"0xdeadbeef\""));
|
||||
assert!(json.contains("\"authorization\""));
|
||||
assert!(!json.contains("\"transaction\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solana_payment_proof_serializes_correctly() {
|
||||
let payload = PaymentPayload {
|
||||
x402_version: 2,
|
||||
resource: None,
|
||||
accepted: PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: SOLANA_MAINNET_CAIP2.into(),
|
||||
amount: "5000".into(),
|
||||
asset: USDC_MINT_MAINNET.into(),
|
||||
pay_to: "Recipient".into(),
|
||||
max_timeout_seconds: 60,
|
||||
extra: None,
|
||||
},
|
||||
payload: PaymentProof::Solana(SolanaPaymentProof {
|
||||
transaction: "base64tx".into(),
|
||||
}),
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
assert!(json.contains("\"transaction\":\"base64tx\""));
|
||||
assert!(!json.contains("\"signature\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eip712_domain_separator_is_deterministic() {
|
||||
use std::str::FromStr;
|
||||
let contract =
|
||||
ethers_core::types::Address::from_str("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")
|
||||
.unwrap();
|
||||
let sep1 = eip712_domain_separator(contract, 8453);
|
||||
let sep2 = eip712_domain_separator(contract, 8453);
|
||||
assert_eq!(sep1, sep2);
|
||||
|
||||
// Different chain ID produces different separator
|
||||
let sep_eth = eip712_domain_separator(contract, 1);
|
||||
assert_ne!(sep1, sep_eth);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eip3009_struct_hash_is_deterministic() {
|
||||
use std::str::FromStr;
|
||||
let from = ethers_core::types::Address::from_str("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
.unwrap();
|
||||
let to = ethers_core::types::Address::from_str("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
||||
.unwrap();
|
||||
let value = ethers_core::types::U256::from(1_000_000u64);
|
||||
let valid_after = ethers_core::types::U256::zero();
|
||||
let valid_before = ethers_core::types::U256::from(99999u64);
|
||||
let nonce = [42u8; 32];
|
||||
|
||||
let h1 = eip3009_struct_hash(from, to, value, valid_after, valid_before, nonce);
|
||||
let h2 = eip3009_struct_hash(from, to, value, valid_after, valid_before, nonce);
|
||||
assert_eq!(h1, h2);
|
||||
|
||||
// Different nonce produces different hash
|
||||
let h3 = eip3009_struct_hash(from, to, value, valid_after, valid_before, [43u8; 32]);
|
||||
assert_ne!(h1, h3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_twit_sh_402_challenge() {
|
||||
let b64 = "eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJQYXltZW50IHJlcXVpcmVkIiwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly94NDAyLnR3aXQuc2gvdHdlZXRzL2J5L2lkIiwiZGVzY3JpcHRpb24iOiJMb29rIHVwIGEgc2luZ2xlIFR3aXR0ZXIvWCB0d2VldCBieSBpdHMgbnVtZXJpYyB0d2VldCBJRC4iLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24ifSwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJhbW91bnQiOiIyNTAwIiwiYXNzZXQiOiIweDgzMzU4OWZDRDZlRGI2RTA4ZjRjN0MzMkQ0ZjcxYjU0YmRBMDI5MTMiLCJwYXlUbyI6IjB4OURCQTQxNDYzN2M2MTFhMTZCRWE2ZjA3OTZCRmNiY0JkYzQxMGRmOCIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImV4dHJhIjp7Im5hbWUiOiJVU0QgQ29pbiIsInZlcnNpb24iOiIyIn19XX0=";
|
||||
let json_bytes = B64.decode(b64).unwrap();
|
||||
let challenge: PaymentRequired = serde_json::from_slice(&json_bytes).unwrap();
|
||||
|
||||
assert_eq!(challenge.x402_version, 2);
|
||||
assert_eq!(challenge.error.as_deref(), Some("Payment required"));
|
||||
assert_eq!(challenge.resource.url, "https://x402.twit.sh/tweets/by/id");
|
||||
assert_eq!(challenge.accepts.len(), 1);
|
||||
|
||||
let req = &challenge.accepts[0];
|
||||
assert_eq!(req.scheme, "exact");
|
||||
assert_eq!(req.network, BASE_MAINNET_CAIP2);
|
||||
assert_eq!(req.amount, "2500");
|
||||
assert_eq!(req.asset, USDC_BASE_MAINNET);
|
||||
assert_eq!(req.pay_to, "0x9DBA414637c611a16BEa6f0796BFcbcBdc410df8");
|
||||
assert_eq!(req.max_timeout_seconds, 300);
|
||||
assert!(req.is_base_mainnet());
|
||||
assert_eq!(req.evm_chain_id(), Some(8453));
|
||||
|
||||
let extra = req.extra.as_ref().unwrap();
|
||||
assert_eq!(extra.name.as_deref(), Some("USD Coin"));
|
||||
assert_eq!(extra.version.as_deref(), Some("2"));
|
||||
|
||||
// Should select EVM path
|
||||
let (best, chain) = challenge.best_exact_requirement().unwrap();
|
||||
assert_eq!(chain, PaymentChain::Evm);
|
||||
assert_eq!(best.amount, "2500");
|
||||
assert!(challenge.solana_exact_requirement().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_evm_payment_with_test_key_produces_valid_payload() {
|
||||
use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer};
|
||||
use std::str::FromStr;
|
||||
|
||||
let test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
let wallet = MnemonicBuilder::<English>::default()
|
||||
.phrase(test_mnemonic)
|
||||
.derivation_path("m/44'/60'/0'/0/0")
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let from_address = wallet.address();
|
||||
|
||||
let challenge = PaymentRequired {
|
||||
x402_version: 2,
|
||||
error: Some("Payment required".into()),
|
||||
resource: ResourceInfo {
|
||||
url: "https://x402.twit.sh/tweets/by/id".into(),
|
||||
description: Some("Look up a tweet".into()),
|
||||
mime_type: Some("application/json".into()),
|
||||
},
|
||||
accepts: vec![PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: BASE_MAINNET_CAIP2.into(),
|
||||
amount: "2500".into(),
|
||||
asset: USDC_BASE_MAINNET.into(),
|
||||
pay_to: "0x9DBA414637c611a16BEa6f0796BFcbcBdc410df8".into(),
|
||||
max_timeout_seconds: 300,
|
||||
extra: Some(PaymentExtra {
|
||||
fee_payer: None,
|
||||
memo: None,
|
||||
name: Some("USD Coin".into()),
|
||||
version: Some("2".into()),
|
||||
}),
|
||||
}],
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
|
||||
let req = &challenge.accepts[0];
|
||||
let payload = build_evm_payment_with_signer(&wallet, from_address, &challenge, req).unwrap();
|
||||
|
||||
assert_eq!(payload.x402_version, 2);
|
||||
assert_eq!(payload.accepted.network, BASE_MAINNET_CAIP2);
|
||||
assert_eq!(payload.accepted.amount, "2500");
|
||||
|
||||
match &payload.payload {
|
||||
PaymentProof::Evm(evm) => {
|
||||
assert!(evm.signature.starts_with("0x"));
|
||||
// 0x prefix + 65 bytes (r=32 + s=32 + v=1) as hex = 130 chars + 2 = 132
|
||||
assert_eq!(evm.signature.len(), 132);
|
||||
assert_eq!(evm.authorization.value, "2500");
|
||||
assert_eq!(evm.authorization.valid_after, "0");
|
||||
assert!(evm.authorization.nonce.starts_with("0x"));
|
||||
assert_eq!(
|
||||
evm.authorization.to,
|
||||
format!(
|
||||
"{:#x}",
|
||||
ethers_core::types::Address::from_str(
|
||||
"0x9DBA414637c611a16BEa6f0796BFcbcBdc410df8"
|
||||
)
|
||||
.unwrap()
|
||||
)
|
||||
);
|
||||
assert_eq!(evm.authorization.from, format!("{from_address:#x}"));
|
||||
}
|
||||
PaymentProof::Solana(_) => panic!("expected EVM proof, got Solana"),
|
||||
}
|
||||
|
||||
// Verify the payload round-trips through base64 (as PAYMENT-SIGNATURE header)
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let b64_header = B64.encode(&json);
|
||||
let decoded = B64.decode(&b64_header).unwrap();
|
||||
let parsed: PaymentPayload = serde_json::from_slice(&decoded).unwrap();
|
||||
assert_eq!(parsed.x402_version, 2);
|
||||
match &parsed.payload {
|
||||
PaymentProof::Evm(evm) => assert_eq!(evm.authorization.value, "2500"),
|
||||
_ => panic!("round-trip lost EVM proof"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_evm_payment_rejects_solana_network() {
|
||||
use ethers_signers::{coins_bip39::English, MnemonicBuilder, Signer};
|
||||
|
||||
let test_mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
let wallet = MnemonicBuilder::<English>::default()
|
||||
.phrase(test_mnemonic)
|
||||
.derivation_path("m/44'/60'/0'/0/0")
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let challenge = PaymentRequired {
|
||||
x402_version: 2,
|
||||
error: None,
|
||||
resource: ResourceInfo {
|
||||
url: "https://example.com".into(),
|
||||
description: None,
|
||||
mime_type: None,
|
||||
},
|
||||
accepts: vec![PaymentRequirements {
|
||||
scheme: "exact".into(),
|
||||
network: SOLANA_MAINNET_CAIP2.into(),
|
||||
amount: "5000".into(),
|
||||
asset: USDC_MINT_MAINNET.into(),
|
||||
pay_to: "SomeRecipient".into(),
|
||||
max_timeout_seconds: 60,
|
||||
extra: None,
|
||||
}],
|
||||
extensions: serde_json::Map::new(),
|
||||
};
|
||||
|
||||
let req = &challenge.accepts[0];
|
||||
let result = build_evm_payment_with_signer(&wallet, wallet.address(), &challenge, req);
|
||||
assert!(result.is_err());
|
||||
let err_msg = format!("{}", result.unwrap_err());
|
||||
assert!(err_msg.contains("not an EVM network"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Live integration test — makes a real x402 payment to twit.sh on Base.
|
||||
//!
|
||||
//! Run manually (requires a funded wallet):
|
||||
//! GGML_NATIVE=OFF cargo test --test x402_twit_sh_live -- --ignored --nocapture
|
||||
|
||||
use openhuman_core::openhuman::tools::traits::Tool;
|
||||
use openhuman_core::openhuman::x402;
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // requires funded wallet + network access
|
||||
async fn x402_pay_twit_sh_for_hal_finney_tweet() {
|
||||
env_logger::init();
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
x402::init_ledger(tmp.path(), "test-session");
|
||||
|
||||
let tool = x402::tools::X402RequestTool::new();
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"url": "https://x402.twit.sh/tweets/by/id?id=1110302988",
|
||||
"method": "GET"
|
||||
}))
|
||||
.await
|
||||
.expect("tool execute should not panic");
|
||||
|
||||
println!("=== x402 tool result ===");
|
||||
for content in &result.content {
|
||||
if let openhuman_core::openhuman::workflows::types::ToolContent::Text { text } = content {
|
||||
println!("{text}");
|
||||
}
|
||||
}
|
||||
println!("is_error: {}", result.is_error);
|
||||
|
||||
assert!(!result.is_error, "x402 request should succeed");
|
||||
}
|
||||
Reference in New Issue
Block a user