Skip to main content

foundry_common/provider/mpp/
transport.rs

1//! MPP (Machine Payments Protocol) HTTP transport.
2//!
3//! Wraps a standard reqwest HTTP transport with automatic 402 Payment Required
4//! handling via the MPP protocol. When the RPC endpoint returns a 402 response,
5//! this transport automatically pays the challenge and retries the request.
6
7use alloy_chains::Chain;
8use alloy_json_rpc::{RequestPacket, ResponsePacket};
9use alloy_transport::{TransportError, TransportErrorKind, TransportFut, TransportResult};
10use mpp::{
11    client::PaymentProvider,
12    protocol::core::{
13        AUTHORIZATION_HEADER, WWW_AUTHENTICATE_HEADER, format_authorization,
14        parse_www_authenticate_all,
15    },
16};
17use reqwest::{StatusCode, header::HeaderMap};
18use std::{
19    collections::HashMap,
20    env, fmt, io,
21    io::IsTerminal,
22    process::{Command, Stdio},
23    sync::{
24        Arc, LazyLock, Mutex,
25        atomic::{AtomicBool, Ordering},
26    },
27    task,
28    time::Duration,
29};
30use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard};
31use tower::Service;
32use tracing::{Instrument, debug, debug_span, trace};
33use url::Url;
34
35use super::{
36    keys::{DiscoverOptions, discover_mpp_config},
37    session::SessionProvider,
38};
39
40/// Default deposit amount for new channels (in base units).
41const DEFAULT_DEPOSIT: u128 = 100_000;
42
43/// Timeout for MPP retry requests (open/topUp may wait for on-chain settlement).
44const MPP_RETRY_TIMEOUT: Duration = Duration::from_secs(120);
45
46/// Resolve the deposit amount from `MPP_DEPOSIT` env var or the default.
47fn default_deposit() -> u128 {
48    env::var("MPP_DEPOSIT").ok().and_then(|s| s.parse().ok()).unwrap_or(DEFAULT_DEPOSIT)
49}
50
51#[derive(Clone, Debug, Default)]
52pub(crate) struct FundingContext {
53    wallet_address: Option<alloy_primitives::Address>,
54    token: Option<String>,
55    chain_id: Option<Chain>,
56}
57
58impl FundingContext {
59    fn token_line(&self) -> String {
60        self.token
61            .as_ref()
62            .map(|token| format!("Requested payment token: {token}\n\n"))
63            .unwrap_or_default()
64    }
65
66    fn network(&self) -> Option<String> {
67        self.chain_id.filter(|chain| chain.is_tempo()).map(|chain| chain.to_string())
68    }
69}
70
71fn format_http_diagnostics(headers: &HeaderMap) -> String {
72    const DIAGNOSTIC_HEADERS: &[&str] = &["x-request-id", "cf-ray", "server", "report-to", "nel"];
73
74    let pairs: Vec<String> = DIAGNOSTIC_HEADERS
75        .iter()
76        .filter_map(|name| {
77            headers.get(*name).and_then(|value| value.to_str().ok().map(|v| (*name, v)))
78        })
79        .map(|(name, value)| format!("{name}: {value}"))
80        .collect();
81
82    if pairs.is_empty() {
83        String::new()
84    } else {
85        format!("\n\nHTTP diagnostics:\n{}", pairs.join("\n"))
86    }
87}
88
89fn tempo_wallet_fund_help(ctx: &FundingContext) -> String {
90    let mut command = "tempo wallet fund".to_string();
91    if let Some(address) = ctx.wallet_address {
92        command.push_str(&format!(" --address {address}"));
93    }
94    if let Some(network) = ctx.network() {
95        command.push_str(&format!(" --network {network}"));
96    }
97
98    let mut no_browser = command.clone();
99    no_browser.push_str(" --no-browser");
100
101    format!(
102        "\n\nTempo wallet payment could not be funded for this paid RPC request.\n\n{}\
103         Fund the wallet, then rerun the command:\n  {command}\n\n\
104         If this CLI is running on a remote or headless host, use:\n  {no_browser}",
105        ctx.token_line()
106    )
107}
108
109/// Decide whether the interactive `tempo wallet fund` flow may be launched.
110///
111/// Policy (library-safe):
112/// - never run inside CI
113/// - never run unless both stdin and stderr are real terminals
114/// - `FOUNDRY_MPP_NO_AUTO_FUND` is honored as an opt-out; it must not bypass CI/TTY guards in
115///   shared transport code that may be embedded inside long-running RPC daemons.
116fn interactive_tempo_fund_allowed(
117    no_auto_fund: Option<&str>,
118    in_ci: bool,
119    stdin_is_terminal: bool,
120    stderr_is_terminal: bool,
121) -> bool {
122    if no_auto_fund.is_some_and(|v| {
123        !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off"))
124    }) {
125        return false;
126    }
127
128    if in_ci {
129        return false;
130    }
131
132    stdin_is_terminal && stderr_is_terminal
133}
134
135fn can_run_interactive_tempo_fund() -> bool {
136    if cfg!(test) {
137        return false;
138    }
139
140    interactive_tempo_fund_allowed(
141        std::env::var("FOUNDRY_MPP_NO_AUTO_FUND").ok().as_deref(),
142        std::env::var_os("CI").is_some(),
143        std::io::stdin().is_terminal(),
144        std::io::stderr().is_terminal(),
145    )
146}
147
148fn tempo_bin() -> String {
149    std::env::var("TEMPO_BIN").unwrap_or_else(|_| "tempo".to_string())
150}
151
152async fn run_interactive_tempo_fund(ctx: &FundingContext) -> TransportResult<bool> {
153    if !can_run_interactive_tempo_fund() {
154        return Ok(false);
155    }
156
157    let tempo = tempo_bin();
158    let mut args = vec!["wallet".to_string(), "fund".to_string()];
159    if let Some(address) = ctx.wallet_address {
160        args.push("--address".to_string());
161        args.push(address.to_string());
162    }
163    if let Some(network) = ctx.network() {
164        args.push("--network".to_string());
165        args.push(network);
166    }
167
168    tracing::warn!(
169        token = ?ctx.token,
170        chain_id = ?ctx.chain_id,
171        "MPP payment could not be funded; opening `tempo wallet fund`"
172    );
173
174    let status = tokio::task::spawn_blocking(move || {
175        Command::new(tempo)
176            .args(args)
177            .stdin(Stdio::inherit())
178            .stdout(Stdio::inherit())
179            .stderr(Stdio::inherit())
180            .status()
181    })
182    .await
183    .map_err(|e| {
184        TransportErrorKind::custom(std::io::Error::other(format!(
185            "failed to join tempo wallet fund process: {e}"
186        )))
187    })?
188    .map_err(|e| {
189        TransportErrorKind::custom(std::io::Error::other(format!(
190            "failed to run `tempo wallet fund`: {e}{}",
191            tempo_wallet_fund_help(ctx)
192        )))
193    })?;
194
195    if status.success() {
196        Ok(true)
197    } else {
198        Err(TransportErrorKind::custom(std::io::Error::other(format!(
199            "`tempo wallet fund` exited with status {status}{}",
200            tempo_wallet_fund_help(ctx)
201        ))))
202    }
203}
204
205/// Single-attempt guard around [`run_interactive_tempo_fund`].
206///
207/// Ensures that for one logical request we launch `tempo wallet fund` at most
208/// once, regardless of how many recovery paths (`do_request`, `pay_and_retry`,
209/// `handle_response_or_retry_after_fund`, ...) attempt it.
210async fn maybe_auto_fund(used: &AtomicBool, ctx: &FundingContext) -> TransportResult<bool> {
211    if !can_run_interactive_tempo_fund() {
212        return Ok(false);
213    }
214    if used.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
215        return Ok(false);
216    }
217    run_interactive_tempo_fund(ctx).await
218}
219
220/// Returns true iff a 402 response carries a structured insufficient-balance
221/// problem (RFC 9457 `PaymentErrorDetails`).
222///
223/// We deliberately do **not** match on free-text body content or on generic
224/// `verification-failed` problem types, as those have many non-funding causes
225/// (bad signature, replay, expired challenge, clock skew, key provisioning,
226/// malformed auth, ...).
227fn should_suggest_tempo_fund(status: StatusCode, body: &[u8]) -> bool {
228    if status != StatusCode::PAYMENT_REQUIRED {
229        return false;
230    }
231    let Ok(problem) = serde_json::from_slice::<mpp::error::PaymentErrorDetails>(body) else {
232        return false;
233    };
234    problem.problem_type.ends_with("/insufficient-balance")
235}
236
237fn format_mpp_payment_failure(
238    error: impl fmt::Display,
239    ctx: &FundingContext,
240    suggest_fund: bool,
241) -> String {
242    let message = error.to_string();
243    if suggest_fund {
244        format!("MPP payment failed: {message}{}", tempo_wallet_fund_help(ctx))
245    } else {
246        format!("MPP payment failed: {message}")
247    }
248}
249
250/// Process-wide payment serialization locks, keyed by origin URL.
251///
252/// Created eagerly so the lock exists before the first provider init,
253/// preventing concurrent first-402 races.
254static GLOBAL_PAY_LOCKS: LazyLock<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>> =
255    LazyLock::new(|| Mutex::new(HashMap::new()));
256
257/// Production transport: lazily discovers MPP keys from the Tempo wallet on
258/// first 402 response.
259pub type LazyMppHttpTransport = MppHttpTransport<LazySessionProvider>;
260
261/// A payment provider that lazily initializes a [`SessionProvider`] from the
262/// Tempo wallet configuration on first use.
263#[derive(Clone, Debug)]
264pub struct LazySessionProvider {
265    inner: Arc<Mutex<Option<SessionProvider>>>,
266    /// Eagerly-created, process-wide payment serialization lock for this origin.
267    pay_lock: Arc<AsyncMutex<()>>,
268    origin: String,
269}
270
271impl LazySessionProvider {
272    pub(super) fn new(origin: String) -> Self {
273        let pay_lock = GLOBAL_PAY_LOCKS
274            .lock()
275            .unwrap()
276            .entry(origin.clone())
277            .or_insert_with(|| Arc::new(AsyncMutex::new(())))
278            .clone();
279        Self { inner: Arc::new(Mutex::new(None)), pay_lock, origin }
280    }
281
282    fn set_key_provisioned(&self, provisioned: bool) {
283        if let Some(p) = self.inner.lock().unwrap().as_ref() {
284            p.set_key_provisioned(provisioned);
285        }
286    }
287
288    fn clear_channels(&self) {
289        if let Some(p) = self.inner.lock().unwrap().as_ref() {
290            p.clear_channels();
291        }
292    }
293
294    pub(super) fn flush_pending(&self) {
295        if let Some(p) = self.inner.lock().unwrap().as_ref() {
296            p.flush_pending();
297        }
298    }
299
300    pub(super) fn rollback_pending(&self) {
301        if let Some(p) = self.inner.lock().unwrap().as_ref() {
302            p.rollback_pending();
303        }
304    }
305
306    fn commit_topup_and_track_voucher(&self) {
307        if let Some(p) = self.inner.lock().unwrap().as_ref() {
308            p.commit_topup_and_track_voucher();
309        }
310    }
311
312    /// Drop the cached `SessionProvider` so the next `get_or_init` re-runs
313    /// discovery. Called after the device-code flow writes a fresh
314    /// `keys.toml` entry, so a long-lived transport doesn't keep paying with
315    /// the superseded key.
316    fn invalidate(&self) {
317        *self.inner.lock().unwrap() = None;
318    }
319
320    pub(super) fn get_or_init(&self, opts: DiscoverOptions) -> TransportResult<SessionProvider> {
321        let mut guard = self.inner.lock().unwrap();
322        if let Some(ref provider) = *guard {
323            return Ok(provider.clone());
324        }
325
326        let config = discover_mpp_config(opts).ok_or_else(|| {
327            TransportErrorKind::custom(io::Error::other(
328                "RPC endpoint returned HTTP 402 Payment Required. \
329                 This endpoint requires payment via the Machine Payments Protocol (MPP).\n\n\
330                 Authorize an access key against your Tempo wallet:\n\
331                 \n  cast tempo login\
332                 \n\nIn headless environments, pass `--no-browser` to print the authorization \
333                 URL instead of launching a browser:\n\
334                 \n  cast tempo login --no-browser\
335                 \n\nSee https://docs.tempo.xyz for more information.",
336            ))
337        })?;
338
339        let signer: mpp::PrivateKeySigner = config.key.parse().map_err(|e| {
340            TransportErrorKind::custom(io::Error::other(format!("invalid MPP key: {e}")))
341        })?;
342
343        let signing_mode = if let Some(wallet) = config.wallet_address {
344            let key_authorization = config
345                .key_authorization
346                .as_ref()
347                .map(|hex_str| {
348                    crate::tempo::decode_key_authorization(hex_str).map(Box::new).map_err(|e| {
349                        TransportErrorKind::custom(io::Error::other(format!(
350                            "invalid MPP key_authorization: {e}"
351                        )))
352                    })
353                })
354                .transpose()?;
355
356            mpp::client::tempo::signing::TempoSigningMode::Keychain {
357                wallet,
358                key_authorization,
359                version: mpp::client::tempo::signing::KeychainVersion::V2,
360            }
361        } else {
362            mpp::client::tempo::signing::TempoSigningMode::Direct
363        };
364
365        let mut provider = SessionProvider::new(signer, self.origin.clone())
366            .with_signing_mode(signing_mode)
367            .with_default_deposit(default_deposit())
368            .with_key_filters(config.chain_id, config.currencies);
369
370        if let Some(addr) = config.key_address {
371            provider = provider.with_authorized_signer(addr);
372        }
373
374        *guard = Some(provider.clone());
375        Ok(provider)
376    }
377}
378
379/// HTTP transport with automatic MPP (Machine Payments Protocol) 402 handling.
380///
381/// Generic over the payment provider `P`. Works as a normal HTTP transport until
382/// a 402 Payment Required response is received, then delegates payment to `P`.
383#[derive(Clone, Debug)]
384pub struct MppHttpTransport<P> {
385    client: reqwest::Client,
386    url: Url,
387    provider: P,
388}
389
390impl MppHttpTransport<LazySessionProvider> {
391    /// Create a new lazy MPP transport that discovers keys on first 402.
392    ///
393    /// Uses the provided `client` for all requests. Per-request timeouts are
394    /// extended on retry requests that involve on-chain settlement (channel
395    /// open/topUp).
396    pub fn lazy(client: reqwest::Client, url: Url) -> Self {
397        let origin = url.to_string();
398        Self { client, url, provider: LazySessionProvider::new(origin) }
399    }
400}
401
402impl<P> MppHttpTransport<P> {
403    /// Create a new MPP transport with an explicit payment provider.
404    pub const fn new(client: reqwest::Client, url: Url, provider: P) -> Self {
405        Self { client, url, provider }
406    }
407
408    /// Returns a reference to the underlying reqwest client.
409    pub const fn client(&self) -> &reqwest::Client {
410        &self.client
411    }
412}
413
414#[allow(private_bounds)]
415impl<P: ResolveProvider + Clone + Send + Sync + 'static> MppHttpTransport<P>
416where
417    P::Provider: Send + Sync + 'static,
418{
419    async fn do_request(self, req: RequestPacket) -> TransportResult<ResponsePacket> {
420        // Per-request guard: launch `tempo wallet fund` at most once for one
421        // logical request, regardless of how many recovery paths attempt it.
422        let auto_fund_used = AtomicBool::new(false);
423        self.do_request_inner(req, &auto_fund_used).await
424    }
425
426    async fn do_request_inner(
427        self,
428        req: RequestPacket,
429        auto_fund_used: &AtomicBool,
430    ) -> TransportResult<ResponsePacket> {
431        let body = serde_json::to_vec(&req).map_err(TransportErrorKind::custom)?;
432        let headers = req.headers();
433
434        let resp = self
435            .client
436            .post(self.url.clone())
437            .headers(headers.clone())
438            .header("content-type", "application/json")
439            .body(body.clone())
440            .send()
441            .await
442            .map_err(TransportErrorKind::custom)?;
443
444        if resp.status() != StatusCode::PAYMENT_REQUIRED {
445            return Self::handle_response(resp).await;
446        }
447
448        // Serialize the entire 402 → pay → retry → response cycle.
449        // This prevents concurrent requests from opening duplicate channels
450        // or producing colliding expiring-nonce transactions. The lock is
451        // held until the retry response is fully handled.
452        let _pay_guard = self.provider.lock_pay().await;
453
454        // No local key for any offered challenge → run device-code flow,
455        // invalidate the cached provider, and fetch a fresh 402 (the original
456        // may have expired during the browser/passkey flow).
457        let (resolved, challenge) =
458            if let Some(chain_id) = tempo_chain_needing_auth(&self.url, &resp) {
459                debug!(chain_id, "launching wallet.tempo authorization");
460                let cfg = crate::tempo::EnsureAccessKeyConfig::from_env(chain_id);
461                crate::tempo::ensure_access_key(cfg).await.map_err(|e| {
462                    TransportErrorKind::custom(io::Error::other(format!(
463                        "tempo access key authorization failed: {e}"
464                    )))
465                })?;
466                self.provider.invalidate_cached_provider();
467                self.fetch_fresh_challenge(&headers, &body).await?
468            } else {
469                Self::select_challenge(&resp, &self.provider)?
470            };
471        let funding_ctx = self.provider.funding_context(&challenge);
472
473        debug!(id = %challenge.id, method = %challenge.method, intent = %challenge.intent, "received MPP 402 challenge, paying");
474
475        let credential = match resolved.pay(&challenge).await {
476            Ok(credential) => credential,
477            Err(e) => {
478                // Only the explicit `InsufficientBalance` variant is treated as
479                // a fundable error. Any other failure must surface unchanged so
480                // we don't mask payment/protocol issues behind a fund prompt.
481                let is_insufficient = matches!(e, mpp::MppError::InsufficientBalance(_));
482                self.provider.rollback_pending();
483                if is_insufficient && maybe_auto_fund(auto_fund_used, &funding_ctx).await? {
484                    resolved.pay(&challenge).await.map_err(|e2| {
485                        let suggest = matches!(e2, mpp::MppError::InsufficientBalance(_));
486                        self.provider.rollback_pending();
487                        TransportErrorKind::custom(std::io::Error::other(
488                            format_mpp_payment_failure(e2, &funding_ctx, suggest),
489                        ))
490                    })?
491                } else {
492                    return Err(TransportErrorKind::custom(std::io::Error::other(
493                        format_mpp_payment_failure(e, &funding_ctx, is_insufficient),
494                    )));
495                }
496            }
497        };
498
499        let auth_header = format_authorization(&credential).map_err(|e| {
500            self.provider.rollback_pending();
501            TransportErrorKind::custom(std::io::Error::other(format!(
502                "failed to format MPP credential: {e}"
503            )))
504        })?;
505
506        // Use a longer per-request timeout because the server may need to
507        // settle an on-chain transaction (channel open/topUp) before responding.
508        let retry_resp = self
509            .client
510            .post(self.url.clone())
511            .timeout(MPP_RETRY_TIMEOUT)
512            .headers(headers.clone())
513            .header("content-type", "application/json")
514            .header(AUTHORIZATION_HEADER, &auth_header)
515            .body(body.clone())
516            .send()
517            .await
518            .map_err(|e| {
519                self.provider.rollback_pending();
520                TransportErrorKind::custom(e)
521            })?;
522
523        // 204 No Content → topUp accepted, re-pay with voucher
524        if retry_resp.status() == StatusCode::NO_CONTENT {
525            debug!("MPP topUp accepted (204), retrying with voucher");
526
527            // Top-up is confirmed — commit the deposit increase and start
528            // tracking the follow-up voucher cumulative bump separately.
529            self.provider.commit_topup_and_track_voucher();
530
531            let resolved = self.provider.resolve()?;
532            let voucher_resp =
533                self.pay_and_retry(&challenge, &resolved, &headers, &body, auto_fund_used).await?;
534
535            // Route the voucher response through the funding-aware handler so
536            // a final 402 here also gets the fund retry / contextual help.
537            let result = self
538                .handle_response_or_retry_after_fund(
539                    voucher_resp,
540                    &headers,
541                    &body,
542                    &funding_ctx,
543                    auto_fund_used,
544                )
545                .await;
546            if result.is_ok() {
547                self.provider.set_key_provisioned(true);
548                self.provider.flush_pending();
549            } else {
550                self.provider.rollback_pending();
551            }
552            return result;
553        }
554
555        // 410 Gone → channel stale
556        if retry_resp.status() == StatusCode::GONE {
557            debug!("MPP channel not found (410), clearing stale local state");
558            self.provider.rollback_pending();
559            self.provider.clear_channels();
560
561            return Err(TransportErrorKind::custom(io::Error::other(
562                "MPP channel not found on server (410 Gone). \
563                 The server may have restarted or the channel was closed externally.\n\
564                 Local channel state has been cleared. Re-run to open a new channel.",
565            )));
566        }
567
568        // Retry 402 → handle specific recoverable errors before giving up.
569        if retry_resp.status() == StatusCode::PAYMENT_REQUIRED {
570            let diagnostics = format_http_diagnostics(retry_resp.headers());
571            let retry_body = retry_resp.bytes().await.map_err(TransportErrorKind::custom)?;
572            let retry_text = String::from_utf8_lossy(&retry_body);
573
574            // Parse RFC 9457 Problem Details if present. The `type` URI is the
575            // structured error code; the `detail` string provides context.
576            let problem: Option<mpp::error::PaymentErrorDetails> =
577                serde_json::from_slice(&retry_body).ok();
578            let problem_type = problem.as_ref().map(|p| p.problem_type.as_str()).unwrap_or("");
579            let detail = problem.as_ref().map(|p| p.detail.as_str()).unwrap_or("");
580
581            // Stale voucher: another provider instance (or a previous process)
582            // already used a higher cumulative_amount. Re-pay with a fresh
583            // voucher whose amount will be strictly greater.
584            let is_stale_voucher = problem_type.ends_with("/stale-voucher")
585                || detail.contains("cumulativeAmount must be strictly greater");
586            if is_stale_voucher {
587                debug!("MPP voucher stale, retrying with fresh voucher");
588                let resolved = self.provider.resolve()?;
589                if resolved.supports(challenge.method.as_str(), challenge.intent.as_str()) {
590                    let final_resp = self
591                        .pay_and_retry(&challenge, &resolved, &headers, &body, auto_fund_used)
592                        .await?;
593
594                    let result = self
595                        .handle_response_or_retry_after_fund(
596                            final_resp,
597                            &headers,
598                            &body,
599                            &funding_ctx,
600                            auto_fund_used,
601                        )
602                        .await;
603                    if result.is_ok() {
604                        self.provider.flush_pending();
605                    } else {
606                        self.provider.rollback_pending();
607                    }
608                    return result;
609                }
610            }
611
612            // Retry with key_authorization when the error explicitly indicates
613            // the access key is not provisioned on-chain, or when verification
614            // failed and the key appears provisioned (first-time provisioning
615            // where key_auth was stripped but not yet provisioned on-chain).
616            //
617            // We fetch a fresh challenge because the server may have consumed
618            // the original challenge ID on first use.
619            let needs_key_provisioning = problem_type.ends_with("/key-not-provisioned")
620                || detail.contains("access key does not exist")
621                || detail.contains("key is not provisioned");
622
623            let needs_verification_retry = (problem_type.ends_with("/verification-failed")
624                || detail.contains("verification-failed"))
625                && self.provider.is_key_provisioned();
626
627            if needs_key_provisioning || needs_verification_retry {
628                debug!(
629                    problem_type,
630                    "MPP 402 key not provisioned/verification-failed, retrying with key_authorization"
631                );
632                self.provider.set_key_provisioned(false);
633                self.provider.rollback_pending();
634
635                let (resolved, fresh_challenge) =
636                    self.fetch_fresh_challenge(&headers, &body).await?;
637
638                let final_resp = self
639                    .pay_and_retry(&fresh_challenge, &resolved, &headers, &body, auto_fund_used)
640                    .await?;
641
642                let result = self
643                    .handle_response_or_retry_after_fund(
644                        final_resp,
645                        &headers,
646                        &body,
647                        &funding_ctx,
648                        auto_fund_used,
649                    )
650                    .await;
651                if result.is_ok() {
652                    self.provider.set_key_provisioned(true);
653                    self.provider.flush_pending();
654                } else {
655                    self.provider.rollback_pending();
656                }
657                return result;
658            }
659
660            self.provider.rollback_pending();
661            if should_suggest_tempo_fund(StatusCode::PAYMENT_REQUIRED, &retry_body)
662                && maybe_auto_fund(auto_fund_used, &funding_ctx).await?
663            {
664                let (resolved, fresh_challenge) =
665                    self.fetch_fresh_challenge(&headers, &body).await?;
666                let final_resp = self
667                    .pay_and_retry(&fresh_challenge, &resolved, &headers, &body, auto_fund_used)
668                    .await?;
669
670                let result = self
671                    .handle_response_or_retry_after_fund(
672                        final_resp,
673                        &headers,
674                        &body,
675                        &funding_ctx,
676                        auto_fund_used,
677                    )
678                    .await;
679                if result.is_ok() {
680                    self.provider.set_key_provisioned(true);
681                    self.provider.flush_pending();
682                } else {
683                    self.provider.rollback_pending();
684                }
685                return result;
686            }
687
688            let mut error_text = format!("{retry_text}{diagnostics}");
689            if should_suggest_tempo_fund(StatusCode::PAYMENT_REQUIRED, &retry_body) {
690                error_text.push_str(&tempo_wallet_fund_help(&funding_ctx));
691            }
692            return Err(TransportErrorKind::http_error(
693                StatusCode::PAYMENT_REQUIRED.as_u16(),
694                error_text,
695            ));
696        }
697
698        let result = Self::handle_response(retry_resp).await;
699        if result.is_ok() {
700            self.provider.set_key_provisioned(true);
701            self.provider.flush_pending();
702        } else {
703            self.provider.rollback_pending();
704        }
705        result
706    }
707
708    /// Pay a challenge and send the authenticated retry request.
709    async fn pay_and_retry(
710        &self,
711        challenge: &mpp::protocol::core::PaymentChallenge,
712        provider: &P::Provider,
713        headers: &reqwest::header::HeaderMap,
714        body: &[u8],
715        auto_fund_used: &AtomicBool,
716    ) -> TransportResult<reqwest::Response> {
717        let funding_ctx = self.provider.funding_context(challenge);
718        let credential = match provider.pay(challenge).await {
719            Ok(credential) => credential,
720            Err(e) => {
721                self.provider.rollback_pending();
722                let is_insufficient = matches!(e, mpp::MppError::InsufficientBalance(_));
723                if is_insufficient && maybe_auto_fund(auto_fund_used, &funding_ctx).await? {
724                    provider.pay(challenge).await.map_err(|e2| {
725                        let suggest = matches!(e2, mpp::MppError::InsufficientBalance(_));
726                        TransportErrorKind::custom(std::io::Error::other(
727                            format_mpp_payment_failure(e2, &funding_ctx, suggest),
728                        ))
729                    })?
730                } else {
731                    return Err(TransportErrorKind::custom(std::io::Error::other(
732                        format_mpp_payment_failure(e, &funding_ctx, is_insufficient),
733                    )));
734                }
735            }
736        };
737
738        let auth_header = format_authorization(&credential).map_err(|e| {
739            self.provider.rollback_pending();
740            TransportErrorKind::custom(io::Error::other(format!(
741                "failed to format MPP credential: {e}"
742            )))
743        })?;
744
745        self.client
746            .post(self.url.clone())
747            .timeout(MPP_RETRY_TIMEOUT)
748            .headers(headers.clone())
749            .header("content-type", "application/json")
750            .header(AUTHORIZATION_HEADER, auth_header)
751            .body(body.to_vec())
752            .send()
753            .await
754            .map_err(|e| {
755                self.provider.rollback_pending();
756                TransportErrorKind::custom(e)
757            })
758    }
759
760    async fn handle_response_or_retry_after_fund(
761        &self,
762        resp: reqwest::Response,
763        headers: &reqwest::header::HeaderMap,
764        body: &[u8],
765        funding_ctx: &FundingContext,
766        auto_fund_used: &AtomicBool,
767    ) -> TransportResult<ResponsePacket> {
768        if resp.status() != StatusCode::PAYMENT_REQUIRED {
769            return Self::handle_response_with_funding(resp, Some(funding_ctx)).await;
770        }
771
772        let diagnostics = format_http_diagnostics(resp.headers());
773        let status = resp.status();
774        let resp_body = resp.bytes().await.map_err(TransportErrorKind::custom)?;
775
776        if should_suggest_tempo_fund(status, &resp_body)
777            && maybe_auto_fund(auto_fund_used, funding_ctx).await?
778        {
779            self.provider.rollback_pending();
780
781            let (resolved, fresh_challenge) = self.fetch_fresh_challenge(headers, body).await?;
782            let final_resp = self
783                .pay_and_retry(&fresh_challenge, &resolved, headers, body, auto_fund_used)
784                .await?;
785            return Self::handle_response_with_funding(final_resp, Some(funding_ctx)).await;
786        }
787
788        let mut error_text = format!("{}{diagnostics}", String::from_utf8_lossy(&resp_body));
789        if should_suggest_tempo_fund(status, &resp_body) {
790            error_text.push_str(&tempo_wallet_fund_help(funding_ctx));
791        }
792        Err(TransportErrorKind::http_error(status.as_u16(), error_text))
793    }
794
795    /// Fetch a fresh 402 challenge from the server (unauthenticated request).
796    ///
797    /// Returns `Ok(Some((provider, challenge)))` if the server returns a 402
798    /// with a matching challenge. Returns `Ok(None)` with the response handled
799    /// if the server returns a non-402 status. Errors on network or parse failures.
800    async fn fetch_fresh_challenge(
801        &self,
802        headers: &reqwest::header::HeaderMap,
803        body: &[u8],
804    ) -> TransportResult<(P::Provider, mpp::protocol::core::PaymentChallenge)> {
805        let fresh_resp = self
806            .client
807            .post(self.url.clone())
808            .timeout(MPP_RETRY_TIMEOUT)
809            .headers(headers.clone())
810            .header("content-type", "application/json")
811            .body(body.to_vec())
812            .send()
813            .await
814            .map_err(TransportErrorKind::custom)?;
815
816        if fresh_resp.status() != StatusCode::PAYMENT_REQUIRED {
817            // Non-402 → return whatever the server sent (could be success or error).
818            let result = Self::handle_response(fresh_resp).await;
819            return Err(result.err().unwrap_or_else(|| {
820                TransportErrorKind::custom(io::Error::other(
821                    "unexpected success on unauthenticated fresh probe",
822                ))
823            }));
824        }
825
826        Self::select_challenge(&fresh_resp, &self.provider)
827    }
828
829    /// Parse `WWW-Authenticate` challenges from a 402 response and resolve
830    /// the first one matching a locally configured key (chain + currency).
831    fn select_challenge(
832        resp: &reqwest::Response,
833        provider: &P,
834    ) -> TransportResult<(P::Provider, mpp::protocol::core::PaymentChallenge)> {
835        let challenges = parse_challenges(resp);
836        if challenges.is_empty() && resp.headers().get(WWW_AUTHENTICATE_HEADER).is_none() {
837            return Err(TransportErrorKind::custom(io::Error::other(format!(
838                "402 response missing WWW-Authenticate header{}",
839                format_http_diagnostics(resp.headers())
840            ))));
841        }
842
843        let mut last_resolve_err: Option<TransportError> = None;
844        let resolved_pair = challenges.iter().find_map(|c| {
845            let (chain_id, currency) = extract_challenge_chain_and_currency(c);
846            let currency = currency.and_then(|s| s.parse().ok());
847            match provider.resolve_for(DiscoverOptions { chain_id, currency }) {
848                Ok(p) => p.supports(c.method.as_str(), c.intent.as_str()).then_some((p, c.clone())),
849                Err(e) => {
850                    last_resolve_err = Some(e);
851                    None
852                }
853            }
854        });
855
856        resolved_pair.ok_or_else(|| {
857            if let Some(err) = last_resolve_err {
858                return err;
859            }
860            let offered: Vec<_> =
861                challenges.iter().map(|c| format!("{}.{}", c.method, c.intent)).collect();
862            TransportErrorKind::custom(io::Error::other(format!(
863                "no supported MPP challenge; server offered [{}]",
864                offered.join(", "),
865            )))
866        })
867    }
868
869    async fn handle_response(resp: reqwest::Response) -> TransportResult<ResponsePacket> {
870        Self::handle_response_with_funding(resp, None).await
871    }
872
873    /// Like [`Self::handle_response`] but, when an unsuccessful 402 looks like a
874    /// fundable error, appends actionable `tempo wallet fund` help that uses
875    /// the per-request `FundingContext` (so the suggested command includes
876    /// `--address` and `--network` when known).
877    async fn handle_response_with_funding(
878        resp: reqwest::Response,
879        funding_ctx: Option<&FundingContext>,
880    ) -> TransportResult<ResponsePacket> {
881        let status = resp.status();
882        debug!(%status, "received response from MPP transport");
883        let diagnostics = format_http_diagnostics(resp.headers());
884
885        let body = resp.bytes().await.map_err(TransportErrorKind::custom)?;
886
887        if tracing::enabled!(tracing::Level::TRACE) {
888            trace!(body = %String::from_utf8_lossy(&body), "response body");
889        } else {
890            debug!(bytes = body.len(), "retrieved response body");
891        }
892
893        if !status.is_success() {
894            let mut body_text = format!("{}{diagnostics}", String::from_utf8_lossy(&body));
895            if should_suggest_tempo_fund(status, &body) {
896                let default_ctx;
897                let ctx = match funding_ctx {
898                    Some(c) => c,
899                    None => {
900                        default_ctx = FundingContext::default();
901                        &default_ctx
902                    }
903                };
904                body_text.push_str(&tempo_wallet_fund_help(ctx));
905            }
906            return Err(TransportErrorKind::http_error(status.as_u16(), body_text));
907        }
908
909        serde_json::from_slice(&body)
910            .map_err(|err| TransportError::deser_err(err, String::from_utf8_lossy(&body)))
911    }
912}
913
914/// Returns `Some(chain_id)` when a 402 response should trigger the
915/// `wallet.tempo.xyz` device-code authorization flow.
916///
917/// Conditions: known Tempo endpoint, interactive (TTY, not `CI`), and no
918/// offered Tempo challenge resolves against a local key on `(chain, currency)`.
919/// The picked chain matches the first unresolved challenge — same iteration
920/// order [`MppHttpTransport::select_challenge`] uses.
921fn tempo_chain_needing_auth(url: &Url, resp: &reqwest::Response) -> Option<u64> {
922    if !io::stderr().is_terminal() || env::var_os("CI").is_some() {
923        return None;
924    }
925    pick_chain_needing_auth(url, &parse_challenges(resp))
926}
927
928/// Extract all parseable MPP challenges from a 402 response's `WWW-Authenticate` headers.
929fn parse_challenges(resp: &reqwest::Response) -> Vec<mpp::protocol::core::PaymentChallenge> {
930    let values: Vec<&str> = resp
931        .headers()
932        .get_all(WWW_AUTHENTICATE_HEADER)
933        .iter()
934        .filter_map(|v| v.to_str().ok())
935        .collect();
936    parse_www_authenticate_all(values).into_iter().filter_map(|r| r.ok()).collect()
937}
938
939/// Inner logic of [`tempo_chain_needing_auth`], factored out for testing.
940fn pick_chain_needing_auth(
941    url: &Url,
942    challenges: &[mpp::protocol::core::PaymentChallenge],
943) -> Option<u64> {
944    if !crate::tempo::is_known_tempo_endpoint(url) {
945        return None;
946    }
947
948    let tempo_challenges: Vec<_> =
949        challenges.iter().filter(|c| c.method.as_str() == "tempo").collect();
950
951    // If any challenge already resolves with a local key, no auth needed.
952    let any_resolvable = tempo_challenges.iter().any(|c| {
953        let (chain_id, currency) = extract_challenge_chain_and_currency(c);
954        let currency = currency.and_then(|s| s.parse().ok());
955        super::keys::discover_mpp_config(super::keys::DiscoverOptions { chain_id, currency })
956            .is_some()
957    });
958    if any_resolvable {
959        return None;
960    }
961
962    tempo_challenges.iter().find_map(|c| extract_challenge_chain_and_currency(c).0)
963}
964
965/// Extract `(chainId, currency)` from a parsed MPP challenge.
966pub(super) fn extract_challenge_chain_and_currency(
967    c: &mpp::protocol::core::PaymentChallenge,
968) -> (Option<u64>, Option<String>) {
969    if c.method.as_str() == "tempo" {
970        let val = c.request.decode_value().ok();
971        let chain_id = val.as_ref().and_then(|v| v.get("methodDetails")?.get("chainId")?.as_u64());
972        let currency = val.as_ref().and_then(|v| v.get("currency")?.as_str().map(String::from));
973        (chain_id, currency)
974    } else {
975        (None, None)
976    }
977}
978
979/// Trait for resolving a concrete `PaymentProvider` from a potentially lazy wrapper.
980pub(crate) trait ResolveProvider {
981    type Provider: PaymentProvider;
982    fn resolve(&self) -> TransportResult<Self::Provider> {
983        self.resolve_for(Default::default())
984    }
985    fn resolve_for(&self, opts: DiscoverOptions) -> TransportResult<Self::Provider>;
986    fn set_key_provisioned(&self, _provisioned: bool) {}
987    fn is_key_provisioned(&self) -> bool {
988        true
989    }
990    fn clear_channels(&self) {}
991    fn flush_pending(&self) {}
992    fn rollback_pending(&self) {}
993    fn commit_topup_and_track_voucher(&self) {}
994    /// Drop any cached payment provider so the next `resolve_for` re-runs
995    /// discovery. Called after the device-code flow writes a fresh
996    /// `keys.toml` entry.
997    fn invalidate_cached_provider(&self) {}
998    fn funding_wallet_address(&self) -> Option<alloy_primitives::Address> {
999        None
1000    }
1001    fn funding_chain_id(&self) -> Option<u64> {
1002        None
1003    }
1004    fn funding_context(&self, challenge: &mpp::protocol::core::PaymentChallenge) -> FundingContext {
1005        let (challenge_chain_id, token) = extract_challenge_chain_and_currency(challenge);
1006        FundingContext {
1007            wallet_address: self.funding_wallet_address(),
1008            token,
1009            chain_id: challenge_chain_id.or_else(|| self.funding_chain_id()).map(Chain::from_id),
1010        }
1011    }
1012    /// Acquire the payment serialization lock. The returned guard must be held
1013    /// across the entire 402 → pay → retry → response cycle to prevent
1014    /// concurrent channel opens and colliding expiring-nonce transactions.
1015    fn lock_pay(&self) -> impl Future<Output = Option<OwnedMutexGuard<()>>> + Send {
1016        async { None }
1017    }
1018}
1019
1020impl<P: PaymentProvider + Clone> ResolveProvider for P {
1021    type Provider = P;
1022    fn resolve_for(&self, _opts: DiscoverOptions) -> TransportResult<P> {
1023        Ok(self.clone())
1024    }
1025}
1026
1027impl ResolveProvider for LazySessionProvider {
1028    type Provider = SessionProvider;
1029    fn resolve_for(&self, opts: DiscoverOptions) -> TransportResult<SessionProvider> {
1030        let provider = self.get_or_init(opts.clone())?;
1031        // After the first init, get_or_init returns the cached provider
1032        // regardless of opts. Re-check that the provider's key is compatible
1033        // with this challenge's chain/currency.
1034        if !provider.matches_challenge(opts.chain_id, opts.currency) {
1035            return Err(TransportErrorKind::custom(io::Error::other(
1036                "cached provider does not match challenge chain/currency",
1037            )));
1038        }
1039        Ok(provider)
1040    }
1041    fn set_key_provisioned(&self, provisioned: bool) {
1042        Self::set_key_provisioned(self, provisioned)
1043    }
1044    fn is_key_provisioned(&self) -> bool {
1045        self.inner.lock().unwrap().as_ref().is_none_or(|p| p.is_key_provisioned())
1046    }
1047    fn clear_channels(&self) {
1048        Self::clear_channels(self)
1049    }
1050    fn flush_pending(&self) {
1051        Self::flush_pending(self)
1052    }
1053    fn rollback_pending(&self) {
1054        Self::rollback_pending(self)
1055    }
1056    fn commit_topup_and_track_voucher(&self) {
1057        Self::commit_topup_and_track_voucher(self)
1058    }
1059    fn invalidate_cached_provider(&self) {
1060        Self::invalidate(self)
1061    }
1062    fn funding_wallet_address(&self) -> Option<alloy_primitives::Address> {
1063        self.inner.lock().unwrap().as_ref().map(|p| p.funding_wallet_address())
1064    }
1065    fn funding_chain_id(&self) -> Option<u64> {
1066        self.inner.lock().unwrap().as_ref().and_then(|p| p.key_chain_id())
1067    }
1068    fn lock_pay(&self) -> impl Future<Output = Option<OwnedMutexGuard<()>>> + Send {
1069        let lock = self.pay_lock.clone();
1070        async move { Some(lock.lock_owned().await) }
1071    }
1072}
1073
1074impl<P> fmt::Display for MppHttpTransport<P> {
1075    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1076        write!(f, "MppHttpTransport({})", self.url)
1077    }
1078}
1079
1080#[allow(private_bounds)]
1081impl<P: ResolveProvider + Clone + Send + Sync + fmt::Debug + 'static> Service<RequestPacket>
1082    for MppHttpTransport<P>
1083where
1084    P::Provider: Send + Sync + 'static,
1085{
1086    type Response = ResponsePacket;
1087    type Error = TransportError;
1088    type Future = TransportFut<'static>;
1089
1090    #[inline]
1091    fn poll_ready(&mut self, _cx: &mut task::Context<'_>) -> task::Poll<Result<(), Self::Error>> {
1092        task::Poll::Ready(Ok(()))
1093    }
1094
1095    #[inline]
1096    fn call(&mut self, req: RequestPacket) -> Self::Future {
1097        let this = self.clone();
1098        let span = debug_span!("MppHttpTransport", url = %this.url);
1099        Box::pin(this.do_request(req).instrument(span.or_current()))
1100    }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106    use crate::provider::runtime_transport::RuntimeTransportBuilder;
1107    use alloy_json_rpc::{Id, Request, RequestMeta};
1108    use axum::{
1109        extract::State, http::StatusCode as AxumStatusCode, response::IntoResponse, routing::post,
1110    };
1111    use mpp::{
1112        MppError,
1113        protocol::core::{
1114            Base64UrlJson, IntentName, MethodName, PaymentChallenge, PaymentCredential,
1115            format_www_authenticate, parse_authorization,
1116        },
1117    };
1118
1119    #[derive(Clone, Debug)]
1120    struct MockPaymentProvider;
1121
1122    impl PaymentProvider for MockPaymentProvider {
1123        fn supports(&self, method: &str, intent: &str) -> bool {
1124            method == "tempo" && (intent == "session" || intent == "charge")
1125        }
1126
1127        fn pay(
1128            &self,
1129            challenge: &PaymentChallenge,
1130        ) -> impl Future<Output = Result<PaymentCredential, MppError>> + Send {
1131            let echo = challenge.to_echo();
1132            async move {
1133                Ok(PaymentCredential::with_source(
1134                    echo,
1135                    "test-source".to_string(),
1136                    serde_json::json!({"action": "voucher", "channelId": "0xtest", "cumulativeAmount": "1000", "signature": "0xtest"}),
1137                ))
1138            }
1139        }
1140    }
1141
1142    #[derive(Clone, Debug)]
1143    struct InsufficientBalanceProvider;
1144
1145    impl PaymentProvider for InsufficientBalanceProvider {
1146        fn supports(&self, method: &str, intent: &str) -> bool {
1147            method == "tempo" && (intent == "session" || intent == "charge")
1148        }
1149
1150        async fn pay(&self, _challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
1151            Err(MppError::InsufficientBalance(Some(
1152                "wallet has 0 pathUSD but needs 100000".to_string(),
1153            )))
1154        }
1155    }
1156
1157    fn test_challenge() -> (PaymentChallenge, String) {
1158        let request = Base64UrlJson::from_value(&serde_json::json!({
1159            "amount": "1000",
1160            "currency": "0x20c0",
1161            "recipient": "0xpayee",
1162            "methodDetails": {
1163                "chainId": 42431
1164            }
1165        }))
1166        .unwrap();
1167
1168        let challenge = PaymentChallenge {
1169            id: "test-id-42".to_string(),
1170            realm: "test-realm".to_string(),
1171            method: MethodName::new("tempo"),
1172            intent: IntentName::new("session"),
1173            request,
1174            expires: None,
1175            description: None,
1176            digest: None,
1177            opaque: None,
1178        };
1179
1180        let www_auth = format_www_authenticate(&challenge).unwrap();
1181        (challenge, www_auth)
1182    }
1183
1184    fn test_request() -> RequestPacket {
1185        let req: Request<serde_json::Value> = Request {
1186            meta: RequestMeta::new("eth_blockNumber".into(), Id::Number(1)),
1187            params: serde_json::Value::Array(vec![]),
1188        };
1189        RequestPacket::Single(req.serialize().unwrap())
1190    }
1191
1192    async fn spawn_server(app: axum::Router) -> (String, tokio::task::JoinHandle<()>) {
1193        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1194        let addr = listener.local_addr().unwrap();
1195        let handle = tokio::spawn(async move {
1196            axum::serve(listener, app).await.unwrap();
1197        });
1198        (format!("http://{addr}"), handle)
1199    }
1200
1201    #[tokio::test]
1202    async fn test_mpp_transport_no_402() {
1203        let app = axum::Router::new().route(
1204            "/",
1205            post(|| async {
1206                axum::Json(serde_json::json!({
1207                    "jsonrpc": "2.0",
1208                    "id": 1,
1209                    "result": "0x123"
1210                }))
1211            }),
1212        );
1213
1214        let (base_url, handle) = spawn_server(app).await;
1215        let mut transport = MppHttpTransport::new(
1216            reqwest::Client::new(),
1217            Url::parse(&base_url).unwrap(),
1218            MockPaymentProvider,
1219        );
1220
1221        let resp = tower::Service::call(&mut transport, test_request()).await.unwrap();
1222        match resp {
1223            ResponsePacket::Single(r) => assert!(r.is_success()),
1224            _ => panic!("expected single response"),
1225        }
1226
1227        handle.abort();
1228    }
1229
1230    #[tokio::test]
1231    async fn test_mpp_transport_402_then_success() {
1232        let (_, www_auth) = test_challenge();
1233        let state = AppState { www_auth };
1234
1235        #[derive(Clone)]
1236        struct AppState {
1237            www_auth: String,
1238        }
1239
1240        let app =
1241            axum::Router::new()
1242                .route(
1243                    "/",
1244                    post(
1245                        |State(state): State<AppState>,
1246                         req: axum::http::Request<axum::body::Body>| async move {
1247                            if let Some(auth) = req.headers().get("authorization") {
1248                                let auth_str = auth.to_str().unwrap();
1249                                let credential = parse_authorization(auth_str).unwrap();
1250                                assert_eq!(credential.challenge.id, "test-id-42");
1251                                assert_eq!(credential.challenge.method.as_str(), "tempo");
1252                                assert!(credential.source.is_some());
1253
1254                                (
1255                                    AxumStatusCode::OK,
1256                                    axum::Json(serde_json::json!({
1257                                        "jsonrpc": "2.0",
1258                                        "id": 1,
1259                                        "result": "0xvalidated"
1260                                    })),
1261                                )
1262                                    .into_response()
1263                            } else {
1264                                (
1265                                    AxumStatusCode::PAYMENT_REQUIRED,
1266                                    [("www-authenticate", state.www_auth)],
1267                                    "Payment Required",
1268                                )
1269                                    .into_response()
1270                            }
1271                        },
1272                    ),
1273                )
1274                .with_state(state);
1275
1276        let (base_url, handle) = spawn_server(app).await;
1277        let mut transport = MppHttpTransport::new(
1278            reqwest::Client::new(),
1279            Url::parse(&base_url).unwrap(),
1280            MockPaymentProvider,
1281        );
1282
1283        let resp = tower::Service::call(&mut transport, test_request()).await.unwrap();
1284        match resp {
1285            ResponsePacket::Single(r) => assert!(r.is_success()),
1286            _ => panic!("expected single response"),
1287        }
1288
1289        handle.abort();
1290    }
1291
1292    #[tokio::test]
1293    async fn test_mpp_transport_402_missing_www_authenticate() {
1294        let app = axum::Router::new()
1295            .route("/", post(|| async { (AxumStatusCode::PAYMENT_REQUIRED, "pay up") }));
1296
1297        let (base_url, handle) = spawn_server(app).await;
1298        let mut transport = MppHttpTransport::new(
1299            reqwest::Client::new(),
1300            Url::parse(&base_url).unwrap(),
1301            MockPaymentProvider,
1302        );
1303
1304        let err = tower::Service::call(&mut transport, test_request()).await.unwrap_err();
1305        assert!(
1306            err.to_string().contains("WWW-Authenticate"),
1307            "expected WWW-Authenticate error, got: {err}"
1308        );
1309
1310        handle.abort();
1311    }
1312
1313    #[tokio::test]
1314    async fn test_mpp_transport_payment_failure_suggests_tempo_wallet_fund() {
1315        let (_, www_auth) = test_challenge();
1316
1317        let app = axum::Router::new().route(
1318            "/",
1319            post(move || {
1320                let www_auth = www_auth.clone();
1321                async move {
1322                    (
1323                        AxumStatusCode::PAYMENT_REQUIRED,
1324                        [("www-authenticate", www_auth)],
1325                        "Payment Required",
1326                    )
1327                }
1328            }),
1329        );
1330
1331        let (base_url, handle) = spawn_server(app).await;
1332        let mut transport = MppHttpTransport::new(
1333            reqwest::Client::new(),
1334            Url::parse(&base_url).unwrap(),
1335            InsufficientBalanceProvider,
1336        );
1337
1338        let err = tower::Service::call(&mut transport, test_request()).await.unwrap_err();
1339        let msg = err.to_string();
1340        assert!(msg.contains("Tempo wallet payment could not be funded"), "got: {msg}");
1341        assert!(msg.contains("tempo wallet fund"), "got: {msg}");
1342        assert!(msg.contains("--no-browser"), "got: {msg}");
1343        assert!(msg.contains("Requested payment token: 0x20c0"), "got: {msg}");
1344
1345        handle.abort();
1346    }
1347
1348    #[tokio::test]
1349    async fn test_mpp_transport_retry_402_insufficient_balance_suggests_fund() {
1350        let (_, www_auth) = test_challenge();
1351
1352        let app = axum::Router::new().route(
1353            "/",
1354            post(move |req: axum::http::Request<axum::body::Body>| {
1355                let www_auth = www_auth.clone();
1356                async move {
1357                    if req.headers().get("authorization").is_some() {
1358                        (
1359                            AxumStatusCode::PAYMENT_REQUIRED,
1360                            [("content-type", "application/problem+json")],
1361                            serde_json::to_string(
1362                                &mpp::error::PaymentErrorDetails::session("insufficient-balance")
1363                                    .with_title("InsufficientBalanceError")
1364                                    .with_detail(
1365                                        "Insufficient pathUSD balance: have 0, need 100000",
1366                                    ),
1367                            )
1368                            .unwrap(),
1369                        )
1370                            .into_response()
1371                    } else {
1372                        (
1373                            AxumStatusCode::PAYMENT_REQUIRED,
1374                            [("www-authenticate", www_auth)],
1375                            "Payment Required".to_string(),
1376                        )
1377                            .into_response()
1378                    }
1379                }
1380            }),
1381        );
1382
1383        let (base_url, handle) = spawn_server(app).await;
1384        let mut transport = MppHttpTransport::new(
1385            reqwest::Client::new(),
1386            Url::parse(&base_url).unwrap(),
1387            MockPaymentProvider,
1388        );
1389
1390        let err = tower::Service::call(&mut transport, test_request()).await.unwrap_err();
1391        let msg = err.to_string();
1392        assert!(msg.contains("InsufficientBalanceError"), "got: {msg}");
1393        assert!(msg.contains("Tempo wallet payment could not be funded"), "got: {msg}");
1394        assert!(msg.contains("tempo wallet fund"), "got: {msg}");
1395        assert!(msg.contains("--no-browser"), "got: {msg}");
1396        assert!(msg.contains("Requested payment token: 0x20c0"), "got: {msg}");
1397
1398        handle.abort();
1399    }
1400
1401    /// Generic `verification-failed` has many non-funding causes (bad signature,
1402    /// replay, expired challenge, clock skew, ...). The transport must surface
1403    /// the original error verbatim and must NOT add a "fund your wallet" hint.
1404    #[tokio::test]
1405    async fn test_mpp_transport_final_402_verification_failed_does_not_suggest_fund() {
1406        let (_, www_auth) = test_challenge();
1407
1408        let app = axum::Router::new().route(
1409            "/",
1410            post(move |req: axum::http::Request<axum::body::Body>| {
1411                let www_auth = www_auth.clone();
1412                async move {
1413                    if req.headers().get("authorization").is_some() {
1414                        (
1415                            AxumStatusCode::PAYMENT_REQUIRED,
1416                            [("content-type", "application/problem+json")],
1417                            serde_json::to_string(
1418                                &mpp::error::PaymentErrorDetails::core("verification-failed")
1419                                    .with_title("Verification Failed")
1420                                    .with_detail("Payment verification failed."),
1421                            )
1422                            .unwrap(),
1423                        )
1424                            .into_response()
1425                    } else {
1426                        (
1427                            AxumStatusCode::PAYMENT_REQUIRED,
1428                            [("www-authenticate", www_auth)],
1429                            "Payment Required".to_string(),
1430                        )
1431                            .into_response()
1432                    }
1433                }
1434            }),
1435        );
1436
1437        let (base_url, handle) = spawn_server(app).await;
1438        let mut transport = MppHttpTransport::new(
1439            reqwest::Client::new(),
1440            Url::parse(&base_url).unwrap(),
1441            MockPaymentProvider,
1442        );
1443
1444        let err = tower::Service::call(&mut transport, test_request()).await.unwrap_err();
1445        let msg = err.to_string();
1446        assert!(msg.contains("Verification Failed"), "got: {msg}");
1447        assert!(
1448            !msg.contains("Tempo wallet payment could not be funded"),
1449            "verification-failed must not be classified as fundable; got: {msg}"
1450        );
1451
1452        handle.abort();
1453    }
1454
1455    // --- Classifier unit tests --------------------------------------------
1456
1457    #[test]
1458    fn classifier_only_triggers_on_explicit_insufficient_balance_problem() {
1459        // explicit insufficient-balance → true
1460        let body = serde_json::to_vec(
1461            &mpp::error::PaymentErrorDetails::session("insufficient-balance")
1462                .with_title("InsufficientBalanceError")
1463                .with_detail("Insufficient pathUSD balance"),
1464        )
1465        .unwrap();
1466        assert!(should_suggest_tempo_fund(StatusCode::PAYMENT_REQUIRED, &body));
1467    }
1468
1469    #[test]
1470    fn classifier_does_not_trigger_on_verification_failed() {
1471        let body = serde_json::to_vec(
1472            &mpp::error::PaymentErrorDetails::core("verification-failed")
1473                .with_title("Verification Failed")
1474                .with_detail("Payment verification failed."),
1475        )
1476        .unwrap();
1477        assert!(!should_suggest_tempo_fund(StatusCode::PAYMENT_REQUIRED, &body));
1478    }
1479
1480    #[test]
1481    fn classifier_does_not_trigger_on_unrelated_text_with_balance_words() {
1482        // Free-text 402 body that just happens to mention the word "balance"
1483        // must NOT trigger the fund suggestion (no structured problem details).
1484        let body =
1485            b"402 Payment Required: server could not balance ledger entries; insufficient inputs.";
1486        assert!(!should_suggest_tempo_fund(StatusCode::PAYMENT_REQUIRED, body));
1487    }
1488
1489    #[test]
1490    fn classifier_does_not_trigger_outside_402() {
1491        let body = serde_json::to_vec(
1492            &mpp::error::PaymentErrorDetails::session("insufficient-balance")
1493                .with_detail("Insufficient balance"),
1494        )
1495        .unwrap();
1496        assert!(!should_suggest_tempo_fund(StatusCode::INTERNAL_SERVER_ERROR, &body));
1497        assert!(!should_suggest_tempo_fund(StatusCode::OK, &body));
1498    }
1499
1500    #[test]
1501    fn fund_help_includes_address_and_network_for_known_chain() {
1502        let ctx = FundingContext {
1503            wallet_address: Some("0x000000000000000000000000000000000000dEaD".parse().unwrap()),
1504            token: Some("0x20c0".to_string()),
1505            chain_id: Some(Chain::from_id(42431)),
1506        };
1507        let help = tempo_wallet_fund_help(&ctx);
1508        assert!(help.contains("--address 0x"), "missing --address: {help}");
1509        assert!(help.contains("--network tempo-moderato"), "missing --network: {help}");
1510        assert!(help.contains("--no-browser"), "missing --no-browser: {help}");
1511        assert!(help.contains("Requested payment token: 0x20c0"), "missing token: {help}");
1512
1513        let mainnet = FundingContext { chain_id: Some(Chain::from_id(4217)), ..ctx };
1514        let help2 = tempo_wallet_fund_help(&mainnet);
1515        assert!(help2.contains("--network tempo"), "missing tempo network: {help2}");
1516    }
1517
1518    #[test]
1519    fn auto_fund_policy_blocks_in_ci_and_non_tty() {
1520        assert!(!interactive_tempo_fund_allowed(Some("1"), true, true, true), "must not run in CI");
1521        assert!(
1522            interactive_tempo_fund_allowed(Some("0"), false, true, true),
1523            "FOUNDRY_MPP_NO_AUTO_FUND=0 must not disable"
1524        );
1525        assert!(
1526            interactive_tempo_fund_allowed(Some("false"), false, true, true),
1527            "FOUNDRY_MPP_NO_AUTO_FUND=false must not disable"
1528        );
1529        assert!(
1530            !interactive_tempo_fund_allowed(None, false, false, true),
1531            "stdin must be a terminal"
1532        );
1533        assert!(
1534            !interactive_tempo_fund_allowed(None, false, true, false),
1535            "stderr must be a terminal"
1536        );
1537        assert!(!interactive_tempo_fund_allowed(Some("1"), false, true, true));
1538        assert!(!interactive_tempo_fund_allowed(Some("true"), false, true, true));
1539        assert!(interactive_tempo_fund_allowed(None, false, true, true));
1540    }
1541
1542    #[tokio::test]
1543    async fn test_plain_http_402_shows_mpp_setup_instructions() {
1544        let _g = crate::tempo::test_env_mutex().lock().await;
1545        let (_, www_auth) = test_challenge();
1546
1547        let app = axum::Router::new().route(
1548            "/",
1549            post(move || {
1550                let www_auth = www_auth.clone();
1551                async move {
1552                    (
1553                        AxumStatusCode::PAYMENT_REQUIRED,
1554                        [("www-authenticate", www_auth)],
1555                        "Payment Required",
1556                    )
1557                }
1558            }),
1559        );
1560
1561        let (base_url, handle) = spawn_server(app).await;
1562
1563        unsafe {
1564            std::env::set_var("TEMPO_HOME", "/nonexistent/path");
1565            std::env::remove_var("TEMPO_PRIVATE_KEY");
1566        }
1567
1568        let transport = RuntimeTransportBuilder::new(Url::parse(&base_url).unwrap()).build();
1569        let err = transport.request(test_request()).await.unwrap_err();
1570        let msg = err.to_string();
1571
1572        assert!(
1573            msg.contains("402 Payment Required") || msg.contains("no supported MPP challenge"),
1574            "expected MPP setup instructions or 'no supported MPP challenge' in error, got: {msg}"
1575        );
1576
1577        handle.abort();
1578        unsafe { std::env::remove_var("TEMPO_HOME") };
1579    }
1580
1581    #[test]
1582    fn test_session_provider_supports_charge_and_session() {
1583        let signer = mpp::PrivateKeySigner::random();
1584        let provider =
1585            super::super::session::SessionProvider::new(signer, "https://rpc.example.com".into());
1586
1587        assert!(provider.supports("tempo", "session"));
1588        assert!(provider.supports("tempo", "charge"));
1589        assert!(!provider.supports("stripe", "charge"));
1590        assert!(!provider.supports("tempo", "subscribe"));
1591    }
1592
1593    #[tokio::test]
1594    async fn test_session_provider_pay_charge_parses_challenge() {
1595        let signer = mpp::PrivateKeySigner::random();
1596        let provider =
1597            super::super::session::SessionProvider::new(signer, "https://rpc.example.com".into());
1598
1599        // Valid charge challenge — pay_charge wires through to TempoCharge,
1600        // which will fail at gas estimation (no RPC), but confirms the path is connected.
1601        let (challenge, _) = test_challenge();
1602        let err = provider.pay(&challenge).await.unwrap_err();
1603        // Should fail deeper than "not supported" — proves charge dispatch works
1604        assert!(
1605            !err.to_string().contains("not supported"),
1606            "expected charge path to be wired up, got: {err}"
1607        );
1608    }
1609
1610    /// `invalidate_cached_provider` clears the cache so the next
1611    /// `get_or_init` re-runs discovery — the path `do_request` takes after
1612    /// `ensure_access_key` writes a fresh `keys.toml` entry.
1613    #[tokio::test]
1614    async fn lazy_session_provider_invalidate_clears_cache() {
1615        let _g = crate::tempo::test_env_mutex().lock().await;
1616        // TEMPO_PRIVATE_KEY lets discovery succeed without a keys.toml.
1617        let key_hex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1618        unsafe {
1619            std::env::set_var(crate::tempo::TEMPO_PRIVATE_KEY_ENV, key_hex);
1620            std::env::remove_var(crate::tempo::TEMPO_HOME_ENV);
1621        }
1622
1623        let lazy = LazySessionProvider::new("https://rpc.example.com".into());
1624        let _ = lazy.get_or_init(Default::default()).expect("discovery succeeds");
1625        assert!(lazy.inner.lock().unwrap().is_some(), "expected provider to be cached");
1626
1627        ResolveProvider::invalidate_cached_provider(&lazy);
1628        assert!(lazy.inner.lock().unwrap().is_none(), "expected cache to be cleared");
1629
1630        let _ = lazy.get_or_init(Default::default()).expect("re-discovery succeeds");
1631        assert!(lazy.inner.lock().unwrap().is_some(), "expected re-init to repopulate cache");
1632
1633        unsafe { std::env::remove_var(crate::tempo::TEMPO_PRIVATE_KEY_ENV) };
1634    }
1635
1636    #[test]
1637    fn challenge_chain_and_currency_extraction() {
1638        let extract = |headers: Vec<&str>| -> Vec<(Option<u64>, Option<String>)> {
1639            let challenges: Vec<_> =
1640                parse_www_authenticate_all(headers).into_iter().filter_map(|r| r.ok()).collect();
1641            challenges.iter().map(extract_challenge_chain_and_currency).collect()
1642        };
1643
1644        let b64 = |v: serde_json::Value| -> String {
1645            Base64UrlJson::from_value(&v).unwrap().raw().to_string()
1646        };
1647
1648        // Tempo challenge with chainId + currency
1649        let tempo_header = format!(
1650            r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="{}""#,
1651            b64(
1652                serde_json::json!({"amount":"1000","currency":"0x20c0","methodDetails":{"chainId":42431},"recipient":"0xabc"})
1653            )
1654        );
1655        assert_eq!(extract(vec![&tempo_header]), vec![(Some(42431), Some("0x20c0".into()))]);
1656
1657        // Non-tempo challenge → (None, None)
1658        let stripe_header = format!(
1659            r#"Payment id="xyz", realm="api", method="stripe", intent="charge", request="{}""#,
1660            b64(serde_json::json!({"amount":"100"}))
1661        );
1662        assert_eq!(extract(vec![&stripe_header]), vec![(None, None)]);
1663
1664        // Tempo challenge without methodDetails → chainId None, currency present
1665        let no_details = format!(
1666            r#"Payment id="def", realm="api", method="tempo", intent="charge", request="{}""#,
1667            b64(serde_json::json!({"amount":"1000","currency":"0x20c0","recipient":"0xabc"}))
1668        );
1669        assert_eq!(extract(vec![&no_details]), vec![(None, Some("0x20c0".into()))]);
1670    }
1671
1672    /// Auth must trigger when a key matches the chain but not the currency.
1673    #[test]
1674    fn pick_chain_needing_auth_currency_aware() {
1675        let _g = crate::tempo::test_env_mutex().blocking_lock();
1676        let dir = tempfile::tempdir().unwrap();
1677        let wallet = dir.path().join("wallet");
1678        std::fs::create_dir_all(&wallet).unwrap();
1679        std::fs::write(
1680            wallet.join("keys.toml"),
1681            r#"
1682[[keys]]
1683wallet_type = "passkey"
1684wallet_address = "0x0000000000000000000000000000000000000001"
1685key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
1686chain_id = 4217
1687
1688[[keys.limits]]
1689currency = "0x20c0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1690limit = "1000"
1691"#,
1692        )
1693        .unwrap();
1694        unsafe {
1695            std::env::set_var(crate::tempo::TEMPO_HOME_ENV, dir.path());
1696            std::env::remove_var(crate::tempo::TEMPO_PRIVATE_KEY_ENV);
1697        }
1698
1699        let url = Url::parse("https://rpc.mpp.tempo.xyz").unwrap();
1700        let mk = |currency: &str| -> PaymentChallenge {
1701            PaymentChallenge {
1702                id: "x".into(),
1703                realm: "api".into(),
1704                method: MethodName::new("tempo"),
1705                intent: IntentName::new("charge"),
1706                request: Base64UrlJson::from_value(&serde_json::json!({
1707                    "amount": "1",
1708                    "currency": currency,
1709                    "recipient": "0xabc",
1710                    "methodDetails": { "chainId": 4217 }
1711                }))
1712                .unwrap(),
1713                expires: None,
1714                description: None,
1715                digest: None,
1716                opaque: None,
1717            }
1718        };
1719
1720        // Currency mismatch → auth needed.
1721        let mismatched = mk("0x20c0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
1722        assert_eq!(pick_chain_needing_auth(&url, &[mismatched]), Some(4217));
1723
1724        // Currency match → no auth.
1725        let matched = mk("0x20c0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
1726        assert_eq!(pick_chain_needing_auth(&url, &[matched]), None);
1727
1728        // Non-Tempo host → never triggers, even without a key.
1729        let stripe_url = Url::parse("https://api.stripe.com").unwrap();
1730        assert_eq!(
1731            pick_chain_needing_auth(
1732                &stripe_url,
1733                &[mk("0x20c0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")]
1734            ),
1735            None,
1736        );
1737
1738        unsafe { std::env::remove_var(crate::tempo::TEMPO_HOME_ENV) };
1739    }
1740}