clawpowers 2.2.5 → 2.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +186 -160
- package/COMPATIBILITY.md +48 -13
- package/KNOWN_LIMITATIONS.md +20 -19
- package/LICENSE +44 -44
- package/LICENSING.md +10 -10
- package/README.md +486 -462
- package/SECURITY.md +52 -52
- package/dist/index.d.ts +17 -5
- package/dist/index.js +187 -92
- package/dist/index.js.map +1 -1
- package/native/Cargo.lock +4927 -4927
- package/native/Cargo.toml +73 -73
- package/native/crates/canonical/Cargo.toml +24 -24
- package/native/crates/canonical/src/lib.rs +677 -673
- package/native/crates/compression/Cargo.toml +20 -20
- package/native/crates/compression/benches/compression_bench.rs +42 -42
- package/native/crates/compression/src/lib.rs +393 -393
- package/native/crates/evm-eth/Cargo.toml +13 -13
- package/native/crates/evm-eth/src/lib.rs +105 -105
- package/native/crates/fee/Cargo.toml +15 -15
- package/native/crates/fee/src/lib.rs +281 -281
- package/native/crates/index/Cargo.toml +16 -16
- package/native/crates/index/src/lib.rs +277 -277
- package/native/crates/policy/Cargo.toml +17 -17
- package/native/crates/policy/src/lib.rs +614 -614
- package/native/crates/security/Cargo.toml +22 -22
- package/native/crates/security/src/lib.rs +478 -478
- package/native/crates/tokens/Cargo.toml +13 -13
- package/native/crates/tokens/src/lib.rs +534 -534
- package/native/crates/verification/Cargo.toml +23 -23
- package/native/crates/verification/src/lib.rs +333 -333
- package/native/crates/wallet/Cargo.toml +20 -20
- package/native/crates/wallet/src/lib.rs +261 -261
- package/native/crates/x402/Cargo.toml +30 -30
- package/native/crates/x402/src/lib.rs +423 -423
- package/native/ffi/Cargo.toml +34 -34
- package/native/ffi/build.rs +4 -4
- package/native/ffi/src/lib.rs +352 -352
- package/native/ffi/tests/integration.rs +354 -354
- package/native/pyo3/Cargo.toml +26 -26
- package/native/pyo3/pyproject.toml +16 -16
- package/native/pyo3/src/lib.rs +407 -407
- package/native/pyo3/tests/test_smoke.py +180 -180
- package/native/wasm/Cargo.toml +47 -44
- package/native/wasm/pkg/.gitignore +6 -6
- package/native/wasm/pkg/clawpowers_wasm.d.ts +208 -208
- package/native/wasm/pkg/clawpowers_wasm.js +872 -872
- package/native/wasm/pkg/clawpowers_wasm_bg.wasm.d.ts +40 -40
- package/native/wasm/pkg/package.json +16 -16
- package/native/wasm/pkg-node/clawpowers_wasm.d.ts +143 -143
- package/native/wasm/pkg-node/clawpowers_wasm.js +798 -798
- package/native/wasm/pkg-node/clawpowers_wasm_bg.wasm.d.ts +40 -40
- package/native/wasm/pkg-node/package.json +12 -12
- package/native/wasm/src/lib.rs +433 -433
- package/package.json +13 -8
- package/scripts/build-wasm.mjs +59 -0
- package/scripts/generate_hermes_wrappers.py +211 -0
- package/scripts/hermes_wrapper_overrides.json +184 -0
- package/scripts/run-python-script.mjs +48 -0
- package/scripts/verify-consumer-install.mjs +109 -0
- package/scripts/verify-wasm-artifacts.mjs +26 -3
- package/scripts/verify_hermes_wrappers.py +154 -0
- package/skill.json +20 -0
- package/skills/1password/SKILL.md +34 -0
- package/skills/README.md +44 -0
- package/skills/agent-nexus-2/SKILL.md +34 -0
- package/skills/apple-notes/SKILL.md +34 -0
- package/skills/apple-reminders/SKILL.md +34 -0
- package/skills/autoresearch/SKILL.md +43 -0
- package/skills/bear-notes/SKILL.md +34 -0
- package/skills/blogwatcher/SKILL.md +34 -0
- package/skills/blucli/SKILL.md +34 -0
- package/skills/bluebubbles/SKILL.md +34 -0
- package/skills/business-strategy/SKILL.md +41 -0
- package/skills/camsnap/SKILL.md +34 -0
- package/skills/canvas/SKILL.md +34 -0
- package/skills/clawhub/SKILL.md +34 -0
- package/skills/coding-agent/SKILL.md +34 -0
- package/skills/coding-discipline.skill/SKILL.md +34 -0
- package/skills/content-writer/SKILL.md +41 -0
- package/skills/discord/SKILL.md +34 -0
- package/skills/eightctl/SKILL.md +34 -0
- package/skills/execution-validation.skill/SKILL.md +34 -0
- package/skills/gemini/SKILL.md +34 -0
- package/skills/gh-issues/SKILL.md +34 -0
- package/skills/gifgrep/SKILL.md +34 -0
- package/skills/github/SKILL.md +41 -0
- package/skills/gog/SKILL.md +34 -0
- package/skills/goplaces/SKILL.md +34 -0
- package/skills/healthcheck/SKILL.md +34 -0
- package/skills/himalaya/SKILL.md +34 -0
- package/skills/humanize/SKILL.md +41 -0
- package/skills/imsg/SKILL.md +34 -0
- package/skills/itp/SKILL.md +112 -0
- package/skills/mcporter/SKILL.md +34 -0
- package/skills/model-usage/SKILL.md +34 -0
- package/skills/nano-pdf/SKILL.md +34 -0
- package/skills/node-connect/SKILL.md +34 -0
- package/skills/notion/SKILL.md +34 -0
- package/skills/obsidian/SKILL.md +34 -0
- package/skills/openai-whisper/SKILL.md +34 -0
- package/skills/openai-whisper-api/SKILL.md +34 -0
- package/skills/openhue/SKILL.md +34 -0
- package/skills/oracle/SKILL.md +34 -0
- package/skills/ordercli/SKILL.md +34 -0
- package/skills/peekaboo/SKILL.md +34 -0
- package/skills/polyclaw/SKILL.md +34 -0
- package/skills/prospector/SKILL.md +41 -0
- package/skills/rsi.skill/SKILL.md +34 -0
- package/skills/sag/SKILL.md +34 -0
- package/skills/security/SKILL.md +41 -0
- package/skills/session-logs/SKILL.md +34 -0
- package/skills/sherpa-onnx-tts/SKILL.md +34 -0
- package/skills/skill-creator/SKILL.md +34 -0
- package/skills/slack/SKILL.md +34 -0
- package/skills/songsee/SKILL.md +34 -0
- package/skills/sonoscli/SKILL.md +34 -0
- package/skills/spotify-player/SKILL.md +34 -0
- package/skills/strykr-prism/SKILL.md +41 -0
- package/skills/summarize/SKILL.md +34 -0
- package/skills/taskbridge/SKILL.md +34 -0
- package/skills/things-mac/SKILL.md +34 -0
- package/skills/tmux/SKILL.md +34 -0
- package/skills/trello/SKILL.md +34 -0
- package/skills/validator-agent/SKILL.md +41 -0
- package/skills/video-frames/SKILL.md +34 -0
- package/skills/voice-call/SKILL.md +34 -0
- package/skills/wacli/SKILL.md +34 -0
- package/skills/weather/SKILL.md +34 -0
- package/skills/webmcp-payments/SKILL.md +41 -0
- package/skills/xurl/SKILL.md +34 -0
- package/src/skills/catalog.ts +435 -435
- package/src/skills/executor.ts +56 -56
- package/src/skills/index.ts +3 -3
- package/src/skills/itp/SKILL.md +112 -112
- package/src/skills/loader.ts +262 -193
- package/native/ffi/index.node +0 -0
- package/native/wasm/pkg-node/.gitignore +0 -6
|
@@ -1,423 +1,423 @@
|
|
|
1
|
-
//! clawpowers-x402 — HTTP 402 Payment Required client.
|
|
2
|
-
//!
|
|
3
|
-
//! Implements the [x402 payment protocol](https://x402.org) for automatically
|
|
4
|
-
//! handling `402 Payment Required` responses from API endpoints.
|
|
5
|
-
//!
|
|
6
|
-
//! # Protocol flow
|
|
7
|
-
//!
|
|
8
|
-
//! 1. Client makes a request; server responds `402` with `X-Payment-*` headers.
|
|
9
|
-
//! 2. Client parses the payment requirements via
|
|
10
|
-
//! [`X402Client::parse_402_response`].
|
|
11
|
-
//! 3. Wallet signs the payment descriptor off-chain.
|
|
12
|
-
//! 4. Client retries with the `X-Payment` header set via
|
|
13
|
-
//! [`X402Client::create_payment_header`].
|
|
14
|
-
//!
|
|
15
|
-
//! # Example
|
|
16
|
-
//!
|
|
17
|
-
//! ```rust,ignore
|
|
18
|
-
//! let client = X402Client::new();
|
|
19
|
-
//! let payment = client.parse_402_response(&response.headers())?;
|
|
20
|
-
//! let sig = wallet.sign(&payment).await?;
|
|
21
|
-
//! let header = client.create_payment_header(&payment, &sig);
|
|
22
|
-
//! let resp = client.request_with_payment(url, "GET", &header).await?;
|
|
23
|
-
//! ```
|
|
24
|
-
|
|
25
|
-
use reqwest::{
|
|
26
|
-
Client, Method,
|
|
27
|
-
header::{HeaderMap, HeaderValue},
|
|
28
|
-
};
|
|
29
|
-
use std::str::FromStr;
|
|
30
|
-
use thiserror::Error;
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Error type
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
/// Errors produced by the x402 payment client.
|
|
37
|
-
#[derive(Debug, Error)]
|
|
38
|
-
pub enum X402Error {
|
|
39
|
-
/// An underlying HTTP transport error.
|
|
40
|
-
#[error("HTTP error: {0}")]
|
|
41
|
-
HttpError(#[from] reqwest::Error),
|
|
42
|
-
|
|
43
|
-
/// A required x402 header was absent from the `402` response.
|
|
44
|
-
#[error("missing required x402 header: {0}")]
|
|
45
|
-
MissingHeader(String),
|
|
46
|
-
|
|
47
|
-
/// A header value could not be parsed into the expected type.
|
|
48
|
-
#[error("invalid payment parameter: {0}")]
|
|
49
|
-
InvalidPayment(String),
|
|
50
|
-
|
|
51
|
-
/// The server rejected the payment (non-2xx after retry).
|
|
52
|
-
#[error("payment failed: {0}")]
|
|
53
|
-
PaymentFailed(String),
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// X402PaymentRequired
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
/// Payment requirements parsed from a `402 Payment Required` response.
|
|
61
|
-
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
62
|
-
pub struct X402PaymentRequired {
|
|
63
|
-
/// URL the client should use to submit payment or retrieve further info.
|
|
64
|
-
pub payment_url: String,
|
|
65
|
-
/// Human-readable amount string (e.g. `"1.00"` — interpreted relative to
|
|
66
|
-
/// the token's decimal precision).
|
|
67
|
-
pub amount: String,
|
|
68
|
-
/// Token symbol or contract address accepted for payment.
|
|
69
|
-
pub token: String,
|
|
70
|
-
/// EVM chain identifier.
|
|
71
|
-
pub chain_id: u64,
|
|
72
|
-
/// Address of the recipient that must receive the payment.
|
|
73
|
-
pub recipient: String,
|
|
74
|
-
/// Optional human-readable memo attached to the payment.
|
|
75
|
-
pub memo: Option<String>,
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// X402Client
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
/// HTTP client that handles the x402 payment flow.
|
|
83
|
-
///
|
|
84
|
-
/// Wraps [`reqwest::Client`] and adds helpers for parsing `402` responses and
|
|
85
|
-
/// attaching payment proofs to retry requests.
|
|
86
|
-
#[derive(Debug, Clone)]
|
|
87
|
-
pub struct X402Client {
|
|
88
|
-
inner: Client,
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
impl X402Client {
|
|
92
|
-
/// Creates a new [`X402Client`] with default [`reqwest`] settings.
|
|
93
|
-
pub fn new() -> Self {
|
|
94
|
-
Self {
|
|
95
|
-
inner: Client::new(),
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/// Parses x402 payment requirements from a `HeaderMap`.
|
|
100
|
-
///
|
|
101
|
-
/// Required headers:
|
|
102
|
-
/// - `X-Payment-URL`
|
|
103
|
-
/// - `X-Payment-Amount`
|
|
104
|
-
/// - `X-Payment-Token`
|
|
105
|
-
/// - `X-Payment-ChainId`
|
|
106
|
-
/// - `X-Payment-Recipient`
|
|
107
|
-
///
|
|
108
|
-
/// Optional headers:
|
|
109
|
-
/// - `X-Payment-Memo`
|
|
110
|
-
///
|
|
111
|
-
/// # Errors
|
|
112
|
-
///
|
|
113
|
-
/// Returns [`X402Error::MissingHeader`] if any required header is absent,
|
|
114
|
-
/// or [`X402Error::InvalidPayment`] if a header value is malformed.
|
|
115
|
-
pub fn parse_402_response(headers: &HeaderMap) -> Result<X402PaymentRequired, X402Error> {
|
|
116
|
-
let payment_url = Self::required_str(headers, "x-payment-url")?;
|
|
117
|
-
let amount = Self::required_str(headers, "x-payment-amount")?;
|
|
118
|
-
let token = Self::required_str(headers, "x-payment-token")?;
|
|
119
|
-
let chain_id_str = Self::required_str(headers, "x-payment-chainid")?;
|
|
120
|
-
let recipient = Self::required_str(headers, "x-payment-recipient")?;
|
|
121
|
-
let memo = Self::optional_str(headers, "x-payment-memo")?;
|
|
122
|
-
|
|
123
|
-
let chain_id = chain_id_str
|
|
124
|
-
.parse::<u64>()
|
|
125
|
-
.map_err(|_| X402Error::InvalidPayment(format!("invalid chain_id: {chain_id_str}")))?;
|
|
126
|
-
|
|
127
|
-
Ok(X402PaymentRequired {
|
|
128
|
-
payment_url,
|
|
129
|
-
amount,
|
|
130
|
-
token,
|
|
131
|
-
chain_id,
|
|
132
|
-
recipient,
|
|
133
|
-
memo,
|
|
134
|
-
})
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/// Formats the `X-Payment` header value for the follow-up request.
|
|
138
|
-
///
|
|
139
|
-
/// The format is:
|
|
140
|
-
/// `<recipient>:<amount>:<token>:<chain_id>:<signature>[:<memo>]`
|
|
141
|
-
pub fn create_payment_header(payment: &X402PaymentRequired, signature: &str) -> String {
|
|
142
|
-
let mut parts = vec![
|
|
143
|
-
payment.recipient.clone(),
|
|
144
|
-
payment.amount.clone(),
|
|
145
|
-
payment.token.clone(),
|
|
146
|
-
payment.chain_id.to_string(),
|
|
147
|
-
signature.to_string(),
|
|
148
|
-
];
|
|
149
|
-
if let Some(memo) = &payment.memo {
|
|
150
|
-
parts.push(memo.clone());
|
|
151
|
-
}
|
|
152
|
-
parts.join(":")
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/// Makes an authenticated request carrying a pre-built `X-Payment` header.
|
|
156
|
-
///
|
|
157
|
-
/// # Errors
|
|
158
|
-
///
|
|
159
|
-
/// Returns [`X402Error::HttpError`] on transport failure, or
|
|
160
|
-
/// [`X402Error::PaymentFailed`] if the server responds with a non-2xx
|
|
161
|
-
/// status code.
|
|
162
|
-
pub async fn request_with_payment(
|
|
163
|
-
&self,
|
|
164
|
-
url: &str,
|
|
165
|
-
method: &str,
|
|
166
|
-
payment_header: &str,
|
|
167
|
-
) -> Result<reqwest::Response, X402Error> {
|
|
168
|
-
let method = Method::from_str(method)
|
|
169
|
-
.map_err(|_| X402Error::InvalidPayment(format!("unknown HTTP method: {method}")))?;
|
|
170
|
-
|
|
171
|
-
let header_value = HeaderValue::from_str(payment_header).map_err(|_| {
|
|
172
|
-
X402Error::InvalidPayment("payment header contains invalid characters".to_string())
|
|
173
|
-
})?;
|
|
174
|
-
|
|
175
|
-
let response = self
|
|
176
|
-
.inner
|
|
177
|
-
.request(method, url)
|
|
178
|
-
.header("x-payment", header_value)
|
|
179
|
-
.send()
|
|
180
|
-
.await?;
|
|
181
|
-
|
|
182
|
-
if response.status().is_success() {
|
|
183
|
-
Ok(response)
|
|
184
|
-
} else {
|
|
185
|
-
Err(X402Error::PaymentFailed(format!(
|
|
186
|
-
"server returned {}",
|
|
187
|
-
response.status()
|
|
188
|
-
)))
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/// Executes the full x402 flow: initial request → parse 402 → sign →
|
|
193
|
-
/// retry with payment.
|
|
194
|
-
///
|
|
195
|
-
/// `sign_fn` is a synchronous callback that receives a reference to the
|
|
196
|
-
/// parsed [`X402PaymentRequired`] and returns the hex-encoded signature
|
|
197
|
-
/// string to include in the `X-Payment` header.
|
|
198
|
-
///
|
|
199
|
-
/// # Errors
|
|
200
|
-
///
|
|
201
|
-
/// - [`X402Error::HttpError`] — transport failure on either leg.
|
|
202
|
-
/// - [`X402Error::MissingHeader`] / [`X402Error::InvalidPayment`] — bad 402 headers.
|
|
203
|
-
/// - [`X402Error::PaymentFailed`] — server rejects the payment on retry.
|
|
204
|
-
pub async fn execute_with_payment(
|
|
205
|
-
&self,
|
|
206
|
-
url: &str,
|
|
207
|
-
method: &str,
|
|
208
|
-
sign_fn: impl Fn(&X402PaymentRequired) -> String,
|
|
209
|
-
) -> Result<reqwest::Response, X402Error> {
|
|
210
|
-
let method_obj = Method::from_str(method)
|
|
211
|
-
.map_err(|_| X402Error::InvalidPayment(format!("unknown HTTP method: {method}")))?;
|
|
212
|
-
|
|
213
|
-
// First attempt — expect a 402.
|
|
214
|
-
let initial = self.inner.request(method_obj, url).send().await?;
|
|
215
|
-
|
|
216
|
-
if initial.status() != reqwest::StatusCode::PAYMENT_REQUIRED {
|
|
217
|
-
if initial.status().is_success() {
|
|
218
|
-
return Ok(initial);
|
|
219
|
-
}
|
|
220
|
-
return Err(X402Error::PaymentFailed(format!(
|
|
221
|
-
"unexpected status: {}",
|
|
222
|
-
initial.status()
|
|
223
|
-
)));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
let payment = Self::parse_402_response(initial.headers())?;
|
|
227
|
-
let signature = sign_fn(&payment);
|
|
228
|
-
let payment_header = Self::create_payment_header(&payment, &signature);
|
|
229
|
-
|
|
230
|
-
self.request_with_payment(url, method, &payment_header)
|
|
231
|
-
.await
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// -------------------------------------------------------------------
|
|
235
|
-
// Internal helpers
|
|
236
|
-
// -------------------------------------------------------------------
|
|
237
|
-
|
|
238
|
-
fn required_str(headers: &HeaderMap, name: &str) -> Result<String, X402Error> {
|
|
239
|
-
headers
|
|
240
|
-
.get(name)
|
|
241
|
-
.ok_or_else(|| X402Error::MissingHeader(name.to_string()))
|
|
242
|
-
.and_then(|v| {
|
|
243
|
-
v.to_str()
|
|
244
|
-
.map(str::to_owned)
|
|
245
|
-
.map_err(|_| X402Error::InvalidPayment(format!("non-UTF8 value in {name}")))
|
|
246
|
-
})
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
fn optional_str(headers: &HeaderMap, name: &str) -> Result<Option<String>, X402Error> {
|
|
250
|
-
match headers.get(name) {
|
|
251
|
-
None => Ok(None),
|
|
252
|
-
Some(v) => v
|
|
253
|
-
.to_str()
|
|
254
|
-
.map(|s| Some(s.to_owned()))
|
|
255
|
-
.map_err(|_| X402Error::InvalidPayment(format!("non-UTF8 value in {name}"))),
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
impl Default for X402Client {
|
|
261
|
-
fn default() -> Self {
|
|
262
|
-
Self::new()
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ---------------------------------------------------------------------------
|
|
267
|
-
// Tests
|
|
268
|
-
// ---------------------------------------------------------------------------
|
|
269
|
-
|
|
270
|
-
#[cfg(test)]
|
|
271
|
-
mod tests {
|
|
272
|
-
use super::*;
|
|
273
|
-
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
|
274
|
-
|
|
275
|
-
// Helper: build a HeaderMap from key-value pairs.
|
|
276
|
-
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
|
277
|
-
let mut map = HeaderMap::new();
|
|
278
|
-
for (k, v) in pairs {
|
|
279
|
-
map.insert(
|
|
280
|
-
HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
|
281
|
-
HeaderValue::from_str(v).unwrap(),
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
map
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
fn full_402_headers() -> HeaderMap {
|
|
288
|
-
headers(&[
|
|
289
|
-
("x-payment-url", "https://pay.example.com/pay"),
|
|
290
|
-
("x-payment-amount", "1.00"),
|
|
291
|
-
("x-payment-token", "USDC"),
|
|
292
|
-
("x-payment-chainid", "8453"),
|
|
293
|
-
(
|
|
294
|
-
"x-payment-recipient",
|
|
295
|
-
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
|
296
|
-
),
|
|
297
|
-
])
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// --- parse_402_response ---
|
|
301
|
-
|
|
302
|
-
#[test]
|
|
303
|
-
fn parse_full_402_headers() {
|
|
304
|
-
let hdrs = full_402_headers();
|
|
305
|
-
let payment = X402Client::parse_402_response(&hdrs).unwrap();
|
|
306
|
-
assert_eq!(payment.payment_url, "https://pay.example.com/pay");
|
|
307
|
-
assert_eq!(payment.amount, "1.00");
|
|
308
|
-
assert_eq!(payment.token, "USDC");
|
|
309
|
-
assert_eq!(payment.chain_id, 8453);
|
|
310
|
-
assert_eq!(
|
|
311
|
-
payment.recipient,
|
|
312
|
-
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
|
313
|
-
);
|
|
314
|
-
assert_eq!(payment.memo, None);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
#[test]
|
|
318
|
-
fn parse_402_with_memo() {
|
|
319
|
-
let mut hdrs = full_402_headers();
|
|
320
|
-
hdrs.insert(
|
|
321
|
-
HeaderName::from_static("x-payment-memo"),
|
|
322
|
-
HeaderValue::from_static("invoice-42"),
|
|
323
|
-
);
|
|
324
|
-
let payment = X402Client::parse_402_response(&hdrs).unwrap();
|
|
325
|
-
assert_eq!(payment.memo, Some("invoice-42".to_string()));
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
#[test]
|
|
329
|
-
fn parse_missing_url_header() {
|
|
330
|
-
let hdrs = headers(&[
|
|
331
|
-
("x-payment-amount", "1.00"),
|
|
332
|
-
("x-payment-token", "USDC"),
|
|
333
|
-
("x-payment-chainid", "1"),
|
|
334
|
-
("x-payment-recipient", "0xabc"),
|
|
335
|
-
]);
|
|
336
|
-
let err = X402Client::parse_402_response(&hdrs).unwrap_err();
|
|
337
|
-
assert!(matches!(err, X402Error::MissingHeader(h) if h == "x-payment-url"));
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
#[test]
|
|
341
|
-
fn parse_missing_amount_header() {
|
|
342
|
-
let hdrs = headers(&[
|
|
343
|
-
("x-payment-url", "https://pay.example.com/pay"),
|
|
344
|
-
("x-payment-token", "USDC"),
|
|
345
|
-
("x-payment-chainid", "1"),
|
|
346
|
-
("x-payment-recipient", "0xabc"),
|
|
347
|
-
]);
|
|
348
|
-
let err = X402Client::parse_402_response(&hdrs).unwrap_err();
|
|
349
|
-
assert!(matches!(err, X402Error::MissingHeader(h) if h == "x-payment-amount"));
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
#[test]
|
|
353
|
-
fn parse_invalid_chain_id() {
|
|
354
|
-
let hdrs = headers(&[
|
|
355
|
-
("x-payment-url", "https://pay.example.com/pay"),
|
|
356
|
-
("x-payment-amount", "1.00"),
|
|
357
|
-
("x-payment-token", "USDC"),
|
|
358
|
-
("x-payment-chainid", "not-a-number"),
|
|
359
|
-
("x-payment-recipient", "0xabc"),
|
|
360
|
-
]);
|
|
361
|
-
let err = X402Client::parse_402_response(&hdrs).unwrap_err();
|
|
362
|
-
assert!(matches!(err, X402Error::InvalidPayment(_)));
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// --- create_payment_header ---
|
|
366
|
-
|
|
367
|
-
#[test]
|
|
368
|
-
fn create_payment_header_without_memo() {
|
|
369
|
-
let payment = X402PaymentRequired {
|
|
370
|
-
payment_url: "https://pay.example.com/pay".to_string(),
|
|
371
|
-
amount: "1.00".to_string(),
|
|
372
|
-
token: "USDC".to_string(),
|
|
373
|
-
chain_id: 8453,
|
|
374
|
-
recipient: "0xrecipient".to_string(),
|
|
375
|
-
memo: None,
|
|
376
|
-
};
|
|
377
|
-
let header = X402Client::create_payment_header(&payment, "0xsig");
|
|
378
|
-
assert_eq!(header, "0xrecipient:1.00:USDC:8453:0xsig");
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
#[test]
|
|
382
|
-
fn create_payment_header_with_memo() {
|
|
383
|
-
let payment = X402PaymentRequired {
|
|
384
|
-
payment_url: "https://pay.example.com/pay".to_string(),
|
|
385
|
-
amount: "2.50".to_string(),
|
|
386
|
-
token: "ETH".to_string(),
|
|
387
|
-
chain_id: 1,
|
|
388
|
-
recipient: "0xrecipient".to_string(),
|
|
389
|
-
memo: Some("purchase-99".to_string()),
|
|
390
|
-
};
|
|
391
|
-
let header = X402Client::create_payment_header(&payment, "0xsig");
|
|
392
|
-
assert_eq!(header, "0xrecipient:2.50:ETH:1:0xsig:purchase-99");
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// --- X402Client construction ---
|
|
396
|
-
|
|
397
|
-
#[test]
|
|
398
|
-
fn client_new_and_default_are_equivalent() {
|
|
399
|
-
// Both constructors should produce a usable client (structural test).
|
|
400
|
-
let _a = X402Client::new();
|
|
401
|
-
let _b = X402Client::default();
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// --- Error display ---
|
|
405
|
-
|
|
406
|
-
#[test]
|
|
407
|
-
fn missing_header_error_display() {
|
|
408
|
-
let err = X402Error::MissingHeader("x-payment-url".to_string());
|
|
409
|
-
assert!(err.to_string().contains("x-payment-url"));
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
#[test]
|
|
413
|
-
fn invalid_payment_error_display() {
|
|
414
|
-
let err = X402Error::InvalidPayment("bad chain_id".to_string());
|
|
415
|
-
assert!(err.to_string().contains("bad chain_id"));
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
#[test]
|
|
419
|
-
fn payment_failed_error_display() {
|
|
420
|
-
let err = X402Error::PaymentFailed("server returned 402".to_string());
|
|
421
|
-
assert!(err.to_string().contains("402"));
|
|
422
|
-
}
|
|
423
|
-
}
|
|
1
|
+
//! clawpowers-x402 — HTTP 402 Payment Required client.
|
|
2
|
+
//!
|
|
3
|
+
//! Implements the [x402 payment protocol](https://x402.org) for automatically
|
|
4
|
+
//! handling `402 Payment Required` responses from API endpoints.
|
|
5
|
+
//!
|
|
6
|
+
//! # Protocol flow
|
|
7
|
+
//!
|
|
8
|
+
//! 1. Client makes a request; server responds `402` with `X-Payment-*` headers.
|
|
9
|
+
//! 2. Client parses the payment requirements via
|
|
10
|
+
//! [`X402Client::parse_402_response`].
|
|
11
|
+
//! 3. Wallet signs the payment descriptor off-chain.
|
|
12
|
+
//! 4. Client retries with the `X-Payment` header set via
|
|
13
|
+
//! [`X402Client::create_payment_header`].
|
|
14
|
+
//!
|
|
15
|
+
//! # Example
|
|
16
|
+
//!
|
|
17
|
+
//! ```rust,ignore
|
|
18
|
+
//! let client = X402Client::new();
|
|
19
|
+
//! let payment = client.parse_402_response(&response.headers())?;
|
|
20
|
+
//! let sig = wallet.sign(&payment).await?;
|
|
21
|
+
//! let header = client.create_payment_header(&payment, &sig);
|
|
22
|
+
//! let resp = client.request_with_payment(url, "GET", &header).await?;
|
|
23
|
+
//! ```
|
|
24
|
+
|
|
25
|
+
use reqwest::{
|
|
26
|
+
Client, Method,
|
|
27
|
+
header::{HeaderMap, HeaderValue},
|
|
28
|
+
};
|
|
29
|
+
use std::str::FromStr;
|
|
30
|
+
use thiserror::Error;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Error type
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/// Errors produced by the x402 payment client.
|
|
37
|
+
#[derive(Debug, Error)]
|
|
38
|
+
pub enum X402Error {
|
|
39
|
+
/// An underlying HTTP transport error.
|
|
40
|
+
#[error("HTTP error: {0}")]
|
|
41
|
+
HttpError(#[from] reqwest::Error),
|
|
42
|
+
|
|
43
|
+
/// A required x402 header was absent from the `402` response.
|
|
44
|
+
#[error("missing required x402 header: {0}")]
|
|
45
|
+
MissingHeader(String),
|
|
46
|
+
|
|
47
|
+
/// A header value could not be parsed into the expected type.
|
|
48
|
+
#[error("invalid payment parameter: {0}")]
|
|
49
|
+
InvalidPayment(String),
|
|
50
|
+
|
|
51
|
+
/// The server rejected the payment (non-2xx after retry).
|
|
52
|
+
#[error("payment failed: {0}")]
|
|
53
|
+
PaymentFailed(String),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// X402PaymentRequired
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/// Payment requirements parsed from a `402 Payment Required` response.
|
|
61
|
+
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
62
|
+
pub struct X402PaymentRequired {
|
|
63
|
+
/// URL the client should use to submit payment or retrieve further info.
|
|
64
|
+
pub payment_url: String,
|
|
65
|
+
/// Human-readable amount string (e.g. `"1.00"` — interpreted relative to
|
|
66
|
+
/// the token's decimal precision).
|
|
67
|
+
pub amount: String,
|
|
68
|
+
/// Token symbol or contract address accepted for payment.
|
|
69
|
+
pub token: String,
|
|
70
|
+
/// EVM chain identifier.
|
|
71
|
+
pub chain_id: u64,
|
|
72
|
+
/// Address of the recipient that must receive the payment.
|
|
73
|
+
pub recipient: String,
|
|
74
|
+
/// Optional human-readable memo attached to the payment.
|
|
75
|
+
pub memo: Option<String>,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// X402Client
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/// HTTP client that handles the x402 payment flow.
|
|
83
|
+
///
|
|
84
|
+
/// Wraps [`reqwest::Client`] and adds helpers for parsing `402` responses and
|
|
85
|
+
/// attaching payment proofs to retry requests.
|
|
86
|
+
#[derive(Debug, Clone)]
|
|
87
|
+
pub struct X402Client {
|
|
88
|
+
inner: Client,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl X402Client {
|
|
92
|
+
/// Creates a new [`X402Client`] with default [`reqwest`] settings.
|
|
93
|
+
pub fn new() -> Self {
|
|
94
|
+
Self {
|
|
95
|
+
inner: Client::new(),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Parses x402 payment requirements from a `HeaderMap`.
|
|
100
|
+
///
|
|
101
|
+
/// Required headers:
|
|
102
|
+
/// - `X-Payment-URL`
|
|
103
|
+
/// - `X-Payment-Amount`
|
|
104
|
+
/// - `X-Payment-Token`
|
|
105
|
+
/// - `X-Payment-ChainId`
|
|
106
|
+
/// - `X-Payment-Recipient`
|
|
107
|
+
///
|
|
108
|
+
/// Optional headers:
|
|
109
|
+
/// - `X-Payment-Memo`
|
|
110
|
+
///
|
|
111
|
+
/// # Errors
|
|
112
|
+
///
|
|
113
|
+
/// Returns [`X402Error::MissingHeader`] if any required header is absent,
|
|
114
|
+
/// or [`X402Error::InvalidPayment`] if a header value is malformed.
|
|
115
|
+
pub fn parse_402_response(headers: &HeaderMap) -> Result<X402PaymentRequired, X402Error> {
|
|
116
|
+
let payment_url = Self::required_str(headers, "x-payment-url")?;
|
|
117
|
+
let amount = Self::required_str(headers, "x-payment-amount")?;
|
|
118
|
+
let token = Self::required_str(headers, "x-payment-token")?;
|
|
119
|
+
let chain_id_str = Self::required_str(headers, "x-payment-chainid")?;
|
|
120
|
+
let recipient = Self::required_str(headers, "x-payment-recipient")?;
|
|
121
|
+
let memo = Self::optional_str(headers, "x-payment-memo")?;
|
|
122
|
+
|
|
123
|
+
let chain_id = chain_id_str
|
|
124
|
+
.parse::<u64>()
|
|
125
|
+
.map_err(|_| X402Error::InvalidPayment(format!("invalid chain_id: {chain_id_str}")))?;
|
|
126
|
+
|
|
127
|
+
Ok(X402PaymentRequired {
|
|
128
|
+
payment_url,
|
|
129
|
+
amount,
|
|
130
|
+
token,
|
|
131
|
+
chain_id,
|
|
132
|
+
recipient,
|
|
133
|
+
memo,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Formats the `X-Payment` header value for the follow-up request.
|
|
138
|
+
///
|
|
139
|
+
/// The format is:
|
|
140
|
+
/// `<recipient>:<amount>:<token>:<chain_id>:<signature>[:<memo>]`
|
|
141
|
+
pub fn create_payment_header(payment: &X402PaymentRequired, signature: &str) -> String {
|
|
142
|
+
let mut parts = vec![
|
|
143
|
+
payment.recipient.clone(),
|
|
144
|
+
payment.amount.clone(),
|
|
145
|
+
payment.token.clone(),
|
|
146
|
+
payment.chain_id.to_string(),
|
|
147
|
+
signature.to_string(),
|
|
148
|
+
];
|
|
149
|
+
if let Some(memo) = &payment.memo {
|
|
150
|
+
parts.push(memo.clone());
|
|
151
|
+
}
|
|
152
|
+
parts.join(":")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// Makes an authenticated request carrying a pre-built `X-Payment` header.
|
|
156
|
+
///
|
|
157
|
+
/// # Errors
|
|
158
|
+
///
|
|
159
|
+
/// Returns [`X402Error::HttpError`] on transport failure, or
|
|
160
|
+
/// [`X402Error::PaymentFailed`] if the server responds with a non-2xx
|
|
161
|
+
/// status code.
|
|
162
|
+
pub async fn request_with_payment(
|
|
163
|
+
&self,
|
|
164
|
+
url: &str,
|
|
165
|
+
method: &str,
|
|
166
|
+
payment_header: &str,
|
|
167
|
+
) -> Result<reqwest::Response, X402Error> {
|
|
168
|
+
let method = Method::from_str(method)
|
|
169
|
+
.map_err(|_| X402Error::InvalidPayment(format!("unknown HTTP method: {method}")))?;
|
|
170
|
+
|
|
171
|
+
let header_value = HeaderValue::from_str(payment_header).map_err(|_| {
|
|
172
|
+
X402Error::InvalidPayment("payment header contains invalid characters".to_string())
|
|
173
|
+
})?;
|
|
174
|
+
|
|
175
|
+
let response = self
|
|
176
|
+
.inner
|
|
177
|
+
.request(method, url)
|
|
178
|
+
.header("x-payment", header_value)
|
|
179
|
+
.send()
|
|
180
|
+
.await?;
|
|
181
|
+
|
|
182
|
+
if response.status().is_success() {
|
|
183
|
+
Ok(response)
|
|
184
|
+
} else {
|
|
185
|
+
Err(X402Error::PaymentFailed(format!(
|
|
186
|
+
"server returned {}",
|
|
187
|
+
response.status()
|
|
188
|
+
)))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Executes the full x402 flow: initial request → parse 402 → sign →
|
|
193
|
+
/// retry with payment.
|
|
194
|
+
///
|
|
195
|
+
/// `sign_fn` is a synchronous callback that receives a reference to the
|
|
196
|
+
/// parsed [`X402PaymentRequired`] and returns the hex-encoded signature
|
|
197
|
+
/// string to include in the `X-Payment` header.
|
|
198
|
+
///
|
|
199
|
+
/// # Errors
|
|
200
|
+
///
|
|
201
|
+
/// - [`X402Error::HttpError`] — transport failure on either leg.
|
|
202
|
+
/// - [`X402Error::MissingHeader`] / [`X402Error::InvalidPayment`] — bad 402 headers.
|
|
203
|
+
/// - [`X402Error::PaymentFailed`] — server rejects the payment on retry.
|
|
204
|
+
pub async fn execute_with_payment(
|
|
205
|
+
&self,
|
|
206
|
+
url: &str,
|
|
207
|
+
method: &str,
|
|
208
|
+
sign_fn: impl Fn(&X402PaymentRequired) -> String,
|
|
209
|
+
) -> Result<reqwest::Response, X402Error> {
|
|
210
|
+
let method_obj = Method::from_str(method)
|
|
211
|
+
.map_err(|_| X402Error::InvalidPayment(format!("unknown HTTP method: {method}")))?;
|
|
212
|
+
|
|
213
|
+
// First attempt — expect a 402.
|
|
214
|
+
let initial = self.inner.request(method_obj, url).send().await?;
|
|
215
|
+
|
|
216
|
+
if initial.status() != reqwest::StatusCode::PAYMENT_REQUIRED {
|
|
217
|
+
if initial.status().is_success() {
|
|
218
|
+
return Ok(initial);
|
|
219
|
+
}
|
|
220
|
+
return Err(X402Error::PaymentFailed(format!(
|
|
221
|
+
"unexpected status: {}",
|
|
222
|
+
initial.status()
|
|
223
|
+
)));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let payment = Self::parse_402_response(initial.headers())?;
|
|
227
|
+
let signature = sign_fn(&payment);
|
|
228
|
+
let payment_header = Self::create_payment_header(&payment, &signature);
|
|
229
|
+
|
|
230
|
+
self.request_with_payment(url, method, &payment_header)
|
|
231
|
+
.await
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// -------------------------------------------------------------------
|
|
235
|
+
// Internal helpers
|
|
236
|
+
// -------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
fn required_str(headers: &HeaderMap, name: &str) -> Result<String, X402Error> {
|
|
239
|
+
headers
|
|
240
|
+
.get(name)
|
|
241
|
+
.ok_or_else(|| X402Error::MissingHeader(name.to_string()))
|
|
242
|
+
.and_then(|v| {
|
|
243
|
+
v.to_str()
|
|
244
|
+
.map(str::to_owned)
|
|
245
|
+
.map_err(|_| X402Error::InvalidPayment(format!("non-UTF8 value in {name}")))
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
fn optional_str(headers: &HeaderMap, name: &str) -> Result<Option<String>, X402Error> {
|
|
250
|
+
match headers.get(name) {
|
|
251
|
+
None => Ok(None),
|
|
252
|
+
Some(v) => v
|
|
253
|
+
.to_str()
|
|
254
|
+
.map(|s| Some(s.to_owned()))
|
|
255
|
+
.map_err(|_| X402Error::InvalidPayment(format!("non-UTF8 value in {name}"))),
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
impl Default for X402Client {
|
|
261
|
+
fn default() -> Self {
|
|
262
|
+
Self::new()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Tests
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
#[cfg(test)]
|
|
271
|
+
mod tests {
|
|
272
|
+
use super::*;
|
|
273
|
+
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
|
274
|
+
|
|
275
|
+
// Helper: build a HeaderMap from key-value pairs.
|
|
276
|
+
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
|
277
|
+
let mut map = HeaderMap::new();
|
|
278
|
+
for (k, v) in pairs {
|
|
279
|
+
map.insert(
|
|
280
|
+
HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
|
281
|
+
HeaderValue::from_str(v).unwrap(),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
map
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fn full_402_headers() -> HeaderMap {
|
|
288
|
+
headers(&[
|
|
289
|
+
("x-payment-url", "https://pay.example.com/pay"),
|
|
290
|
+
("x-payment-amount", "1.00"),
|
|
291
|
+
("x-payment-token", "USDC"),
|
|
292
|
+
("x-payment-chainid", "8453"),
|
|
293
|
+
(
|
|
294
|
+
"x-payment-recipient",
|
|
295
|
+
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
|
|
296
|
+
),
|
|
297
|
+
])
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- parse_402_response ---
|
|
301
|
+
|
|
302
|
+
#[test]
|
|
303
|
+
fn parse_full_402_headers() {
|
|
304
|
+
let hdrs = full_402_headers();
|
|
305
|
+
let payment = X402Client::parse_402_response(&hdrs).unwrap();
|
|
306
|
+
assert_eq!(payment.payment_url, "https://pay.example.com/pay");
|
|
307
|
+
assert_eq!(payment.amount, "1.00");
|
|
308
|
+
assert_eq!(payment.token, "USDC");
|
|
309
|
+
assert_eq!(payment.chain_id, 8453);
|
|
310
|
+
assert_eq!(
|
|
311
|
+
payment.recipient,
|
|
312
|
+
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
|
|
313
|
+
);
|
|
314
|
+
assert_eq!(payment.memo, None);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#[test]
|
|
318
|
+
fn parse_402_with_memo() {
|
|
319
|
+
let mut hdrs = full_402_headers();
|
|
320
|
+
hdrs.insert(
|
|
321
|
+
HeaderName::from_static("x-payment-memo"),
|
|
322
|
+
HeaderValue::from_static("invoice-42"),
|
|
323
|
+
);
|
|
324
|
+
let payment = X402Client::parse_402_response(&hdrs).unwrap();
|
|
325
|
+
assert_eq!(payment.memo, Some("invoice-42".to_string()));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
#[test]
|
|
329
|
+
fn parse_missing_url_header() {
|
|
330
|
+
let hdrs = headers(&[
|
|
331
|
+
("x-payment-amount", "1.00"),
|
|
332
|
+
("x-payment-token", "USDC"),
|
|
333
|
+
("x-payment-chainid", "1"),
|
|
334
|
+
("x-payment-recipient", "0xabc"),
|
|
335
|
+
]);
|
|
336
|
+
let err = X402Client::parse_402_response(&hdrs).unwrap_err();
|
|
337
|
+
assert!(matches!(err, X402Error::MissingHeader(h) if h == "x-payment-url"));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#[test]
|
|
341
|
+
fn parse_missing_amount_header() {
|
|
342
|
+
let hdrs = headers(&[
|
|
343
|
+
("x-payment-url", "https://pay.example.com/pay"),
|
|
344
|
+
("x-payment-token", "USDC"),
|
|
345
|
+
("x-payment-chainid", "1"),
|
|
346
|
+
("x-payment-recipient", "0xabc"),
|
|
347
|
+
]);
|
|
348
|
+
let err = X402Client::parse_402_response(&hdrs).unwrap_err();
|
|
349
|
+
assert!(matches!(err, X402Error::MissingHeader(h) if h == "x-payment-amount"));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
#[test]
|
|
353
|
+
fn parse_invalid_chain_id() {
|
|
354
|
+
let hdrs = headers(&[
|
|
355
|
+
("x-payment-url", "https://pay.example.com/pay"),
|
|
356
|
+
("x-payment-amount", "1.00"),
|
|
357
|
+
("x-payment-token", "USDC"),
|
|
358
|
+
("x-payment-chainid", "not-a-number"),
|
|
359
|
+
("x-payment-recipient", "0xabc"),
|
|
360
|
+
]);
|
|
361
|
+
let err = X402Client::parse_402_response(&hdrs).unwrap_err();
|
|
362
|
+
assert!(matches!(err, X402Error::InvalidPayment(_)));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --- create_payment_header ---
|
|
366
|
+
|
|
367
|
+
#[test]
|
|
368
|
+
fn create_payment_header_without_memo() {
|
|
369
|
+
let payment = X402PaymentRequired {
|
|
370
|
+
payment_url: "https://pay.example.com/pay".to_string(),
|
|
371
|
+
amount: "1.00".to_string(),
|
|
372
|
+
token: "USDC".to_string(),
|
|
373
|
+
chain_id: 8453,
|
|
374
|
+
recipient: "0xrecipient".to_string(),
|
|
375
|
+
memo: None,
|
|
376
|
+
};
|
|
377
|
+
let header = X402Client::create_payment_header(&payment, "0xsig");
|
|
378
|
+
assert_eq!(header, "0xrecipient:1.00:USDC:8453:0xsig");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#[test]
|
|
382
|
+
fn create_payment_header_with_memo() {
|
|
383
|
+
let payment = X402PaymentRequired {
|
|
384
|
+
payment_url: "https://pay.example.com/pay".to_string(),
|
|
385
|
+
amount: "2.50".to_string(),
|
|
386
|
+
token: "ETH".to_string(),
|
|
387
|
+
chain_id: 1,
|
|
388
|
+
recipient: "0xrecipient".to_string(),
|
|
389
|
+
memo: Some("purchase-99".to_string()),
|
|
390
|
+
};
|
|
391
|
+
let header = X402Client::create_payment_header(&payment, "0xsig");
|
|
392
|
+
assert_eq!(header, "0xrecipient:2.50:ETH:1:0xsig:purchase-99");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// --- X402Client construction ---
|
|
396
|
+
|
|
397
|
+
#[test]
|
|
398
|
+
fn client_new_and_default_are_equivalent() {
|
|
399
|
+
// Both constructors should produce a usable client (structural test).
|
|
400
|
+
let _a = X402Client::new();
|
|
401
|
+
let _b = X402Client::default();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// --- Error display ---
|
|
405
|
+
|
|
406
|
+
#[test]
|
|
407
|
+
fn missing_header_error_display() {
|
|
408
|
+
let err = X402Error::MissingHeader("x-payment-url".to_string());
|
|
409
|
+
assert!(err.to_string().contains("x-payment-url"));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#[test]
|
|
413
|
+
fn invalid_payment_error_display() {
|
|
414
|
+
let err = X402Error::InvalidPayment("bad chain_id".to_string());
|
|
415
|
+
assert!(err.to_string().contains("bad chain_id"));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
#[test]
|
|
419
|
+
fn payment_failed_error_display() {
|
|
420
|
+
let err = X402Error::PaymentFailed("server returned 402".to_string());
|
|
421
|
+
assert!(err.to_string().contains("402"));
|
|
422
|
+
}
|
|
423
|
+
}
|