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.
47/// Only applied at channel open. On T5 precompile channels the cumulative
48/// amount is capped at `uint96`.
49fn default_deposit() -> u128 {
50    env::var("MPP_DEPOSIT").ok().and_then(|s| s.parse().ok()).unwrap_or(DEFAULT_DEPOSIT)
51}
52
53#[derive(Clone, Debug, Default)]
54pub(crate) struct FundingContext {
55    wallet_address: Option<alloy_primitives::Address>,
56    token: Option<String>,
57    chain_id: Option<Chain>,
58}
59
60impl FundingContext {
61    fn token_line(&self) -> String {
62        self.token
63            .as_ref()
64            .map(|token| format!("Requested payment token: {token}\n\n"))
65            .unwrap_or_default()
66    }
67
68    fn network(&self) -> Option<String> {
69        self.chain_id.filter(|chain| chain.is_tempo()).map(|chain| chain.to_string())
70    }
71}
72
73fn format_http_diagnostics(headers: &HeaderMap) -> String {
74    const DIAGNOSTIC_HEADERS: &[&str] = &["x-request-id", "cf-ray", "server", "report-to", "nel"];
75
76    let pairs: Vec<String> = DIAGNOSTIC_HEADERS
77        .iter()
78        .filter_map(|name| {
79            headers.get(*name).and_then(|value| value.to_str().ok().map(|v| (*name, v)))
80        })
81        .map(|(name, value)| format!("{name}: {value}"))
82        .collect();
83
84    if pairs.is_empty() {
85        String::new()
86    } else {
87        format!("\n\nHTTP diagnostics:\n{}", pairs.join("\n"))
88    }
89}
90
91fn tempo_wallet_fund_help(ctx: &FundingContext) -> String {
92    let mut command = "tempo wallet fund".to_string();
93    if let Some(address) = ctx.wallet_address {
94        command.push_str(&format!(" --address {address}"));
95    }
96    if let Some(network) = ctx.network() {
97        command.push_str(&format!(" --network {network}"));
98    }
99
100    let mut no_browser = command.clone();
101    no_browser.push_str(" --no-browser");
102
103    format!(
104        "\n\nTempo wallet payment could not be funded for this paid RPC request.\n\n{}\
105         Fund the wallet, then rerun the command:\n  {command}\n\n\
106         If this CLI is running on a remote or headless host, use:\n  {no_browser}",
107        ctx.token_line()
108    )
109}
110
111/// Decide whether the interactive `tempo wallet fund` flow may be launched.
112///
113/// Policy (library-safe):
114/// - never run inside CI
115/// - never run unless both stdin and stderr are real terminals
116/// - `FOUNDRY_MPP_NO_AUTO_FUND` is honored as an opt-out; it must not bypass CI/TTY guards in
117///   shared transport code that may be embedded inside long-running RPC daemons.
118fn interactive_tempo_fund_allowed(
119    no_auto_fund: Option<&str>,
120    in_ci: bool,
121    stdin_is_terminal: bool,
122    stderr_is_terminal: bool,
123) -> bool {
124    if no_auto_fund.is_some_and(|v| {
125        !(v == "0" || v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("off"))
126    }) {
127        return false;
128    }
129
130    if in_ci {
131        return false;
132    }
133
134    stdin_is_terminal && stderr_is_terminal
135}
136
137fn can_run_interactive_tempo_fund() -> bool {
138    if cfg!(test) {
139        return false;
140    }
141
142    interactive_tempo_fund_allowed(
143        std::env::var("FOUNDRY_MPP_NO_AUTO_FUND").ok().as_deref(),
144        std::env::var_os("CI").is_some(),
145        std::io::stdin().is_terminal(),
146        std::io::stderr().is_terminal(),
147    )
148}
149
150fn tempo_bin() -> String {
151    std::env::var("TEMPO_BIN").unwrap_or_else(|_| "tempo".to_string())
152}
153
154async fn run_interactive_tempo_fund(ctx: &FundingContext) -> TransportResult<bool> {
155    if !can_run_interactive_tempo_fund() {
156        return Ok(false);
157    }
158
159    let tempo = tempo_bin();
160    let mut args = vec!["wallet".to_string(), "fund".to_string()];
161    if let Some(address) = ctx.wallet_address {
162        args.push("--address".to_string());
163        args.push(address.to_string());
164    }
165    if let Some(network) = ctx.network() {
166        args.push("--network".to_string());
167        args.push(network);
168    }
169
170    tracing::warn!(
171        token = ?ctx.token,
172        chain_id = ?ctx.chain_id,
173        "MPP payment could not be funded; opening `tempo wallet fund`"
174    );
175
176    let status = tokio::task::spawn_blocking(move || {
177        Command::new(tempo)
178            .args(args)
179            .stdin(Stdio::inherit())
180            .stdout(Stdio::inherit())
181            .stderr(Stdio::inherit())
182            .status()
183    })
184    .await
185    .map_err(|e| {
186        TransportErrorKind::custom(std::io::Error::other(format!(
187            "failed to join tempo wallet fund process: {e}"
188        )))
189    })?
190    .map_err(|e| {
191        TransportErrorKind::custom(std::io::Error::other(format!(
192            "failed to run `tempo wallet fund`: {e}{}",
193            tempo_wallet_fund_help(ctx)
194        )))
195    })?;
196
197    if status.success() {
198        Ok(true)
199    } else {
200        Err(TransportErrorKind::custom(std::io::Error::other(format!(
201            "`tempo wallet fund` exited with status {status}{}",
202            tempo_wallet_fund_help(ctx)
203        ))))
204    }
205}
206
207/// Single-attempt guard around [`run_interactive_tempo_fund`].
208///
209/// Ensures that for one logical request we launch `tempo wallet fund` at most
210/// once, regardless of how many recovery paths (`do_request`, `pay_and_retry`,
211/// `handle_response_or_retry_after_fund`, ...) attempt it.
212async fn maybe_auto_fund(used: &AtomicBool, ctx: &FundingContext) -> TransportResult<bool> {
213    if !can_run_interactive_tempo_fund() {
214        return Ok(false);
215    }
216    if used.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
217        return Ok(false);
218    }
219    run_interactive_tempo_fund(ctx).await
220}
221
222/// Returns true iff a 402 response carries a structured insufficient-balance
223/// problem (RFC 9457 `PaymentErrorDetails`).
224///
225/// We deliberately do **not** match on free-text body content or on generic
226/// `verification-failed` problem types, as those have many non-funding causes
227/// (bad signature, replay, expired challenge, clock skew, key provisioning,
228/// malformed auth, ...).
229fn should_suggest_tempo_fund(status: StatusCode, body: &[u8]) -> bool {
230    if status != StatusCode::PAYMENT_REQUIRED {
231        return false;
232    }
233    let Ok(problem) = serde_json::from_slice::<mpp::error::PaymentErrorDetails>(body) else {
234        return false;
235    };
236    problem.problem_type.ends_with("/insufficient-balance")
237}
238
239fn format_mpp_payment_failure(
240    error: impl fmt::Display,
241    ctx: &FundingContext,
242    suggest_fund: bool,
243) -> String {
244    let message = error.to_string();
245    if suggest_fund {
246        format!("MPP payment failed: {message}{}", tempo_wallet_fund_help(ctx))
247    } else {
248        format!("MPP payment failed: {message}")
249    }
250}
251
252/// Process-wide payment serialization locks, keyed by origin URL.
253///
254/// Created eagerly so the lock exists before the first provider init,
255/// preventing concurrent first-402 races.
256static GLOBAL_PAY_LOCKS: LazyLock<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>> =
257    LazyLock::new(|| Mutex::new(HashMap::new()));
258
259/// Production transport: lazily discovers MPP keys from the Tempo wallet on
260/// first 402 response.
261pub type LazyMppHttpTransport = MppHttpTransport<LazySessionProvider>;
262
263/// A payment provider that lazily initializes a [`SessionProvider`] from the
264/// Tempo wallet configuration on first use.
265#[derive(Clone, Debug)]
266pub struct LazySessionProvider {
267    inner: Arc<Mutex<Option<SessionProvider>>>,
268    /// Eagerly-created, process-wide payment serialization lock for this origin.
269    pay_lock: Arc<AsyncMutex<()>>,
270    origin: String,
271}
272
273impl LazySessionProvider {
274    pub(super) fn new(origin: String) -> Self {
275        let pay_lock = GLOBAL_PAY_LOCKS
276            .lock()
277            .unwrap()
278            .entry(origin.clone())
279            .or_insert_with(|| Arc::new(AsyncMutex::new(())))
280            .clone();
281        Self { inner: Arc::new(Mutex::new(None)), pay_lock, origin }
282    }
283
284    fn set_key_provisioned(&self, provisioned: bool) {
285        if let Some(p) = self.inner.lock().unwrap().as_ref() {
286            p.set_key_provisioned(provisioned);
287        }
288    }
289
290    fn clear_channels(&self) {
291        if let Some(p) = self.inner.lock().unwrap().as_ref() {
292            p.clear_channels();
293        }
294    }
295
296    pub(super) fn flush_pending(&self) {
297        if let Some(p) = self.inner.lock().unwrap().as_ref() {
298            p.flush_pending();
299        }
300    }
301
302    pub(super) fn rollback_pending(&self) {
303        if let Some(p) = self.inner.lock().unwrap().as_ref() {
304            p.rollback_pending();
305        }
306    }
307
308    fn commit_topup_and_track_voucher(&self) {
309        if let Some(p) = self.inner.lock().unwrap().as_ref() {
310            p.commit_topup_and_track_voucher();
311        }
312    }
313
314    /// Drop the cached `SessionProvider` so the next `get_or_init` re-runs
315    /// discovery. Called after the device-code flow writes a fresh
316    /// `keys.toml` entry, so a long-lived transport doesn't keep paying with
317    /// the superseded key.
318    fn invalidate(&self) {
319        *self.inner.lock().unwrap() = None;
320    }
321
322    pub(super) fn get_or_init(&self, opts: DiscoverOptions) -> TransportResult<SessionProvider> {
323        let mut guard = self.inner.lock().unwrap();
324        if let Some(ref provider) = *guard {
325            return Ok(provider.clone());
326        }
327
328        let config = discover_mpp_config(opts).ok_or_else(|| {
329            TransportErrorKind::custom(io::Error::other(
330                "RPC endpoint returned HTTP 402 Payment Required. \
331                 This endpoint requires payment via the Machine Payments Protocol (MPP).\n\n\
332                 Authorize an access key against your Tempo wallet:\n\
333                 \n  cast tempo login\
334                 \n\nIn headless environments, pass `--no-browser` to print the authorization \
335                 URL instead of launching a browser:\n\
336                 \n  cast tempo login --no-browser\
337                 \n\nSee https://docs.tempo.xyz for more information.",
338            ))
339        })?;
340
341        let signer: mpp::PrivateKeySigner = config.key.parse().map_err(|e| {
342            TransportErrorKind::custom(io::Error::other(format!("invalid MPP key: {e}")))
343        })?;
344
345        let signing_mode = if let Some(wallet) = config.wallet_address {
346            let key_authorization = config
347                .key_authorization
348                .as_ref()
349                .map(|hex_str| {
350                    crate::tempo::decode_key_authorization(hex_str).map(Box::new).map_err(|e| {
351                        TransportErrorKind::custom(io::Error::other(format!(
352                            "invalid MPP key_authorization: {e}"
353                        )))
354                    })
355                })
356                .transpose()?;
357
358            mpp::client::tempo::signing::TempoSigningMode::Keychain {
359                wallet,
360                key_authorization,
361                version: mpp::client::tempo::signing::KeychainVersion::V2,
362            }
363        } else {
364            mpp::client::tempo::signing::TempoSigningMode::Direct
365        };
366
367        let mut provider = SessionProvider::new(signer, self.origin.clone())
368            .with_signing_mode(signing_mode)
369            .with_default_deposit(default_deposit())
370            .with_key_filters(config.chain_id, config.currencies);
371
372        if let Some(addr) = config.key_address {
373            provider = provider.with_authorized_signer(addr);
374        }
375
376        *guard = Some(provider.clone());
377        Ok(provider)
378    }
379}
380
381/// HTTP transport with automatic MPP (Machine Payments Protocol) 402 handling.
382///
383/// Generic over the payment provider `P`. Works as a normal HTTP transport until
384/// a 402 Payment Required response is received, then delegates payment to `P`.
385#[derive(Clone, Debug)]
386pub struct MppHttpTransport<P> {
387    client: reqwest::Client,
388    url: Url,
389    provider: P,
390}
391
392impl MppHttpTransport<LazySessionProvider> {
393    /// Create a new lazy MPP transport that discovers keys on first 402.
394    ///
395    /// Uses the provided `client` for all requests. Per-request timeouts are
396    /// extended on retry requests that involve on-chain settlement (channel
397    /// open/topUp).
398    pub fn lazy(client: reqwest::Client, url: Url) -> Self {
399        let origin = url.to_string();
400        Self { client, url, provider: LazySessionProvider::new(origin) }
401    }
402}
403
404impl<P> MppHttpTransport<P> {
405    /// Create a new MPP transport with an explicit payment provider.
406    pub const fn new(client: reqwest::Client, url: Url, provider: P) -> Self {
407        Self { client, url, provider }
408    }
409
410    /// Returns a reference to the underlying reqwest client.
411    pub const fn client(&self) -> &reqwest::Client {
412        &self.client
413    }
414}
415
416#[allow(private_bounds)]
417impl<P: ResolveProvider + Clone + Send + Sync + 'static> MppHttpTransport<P>
418where
419    P::Provider: Send + Sync + 'static,
420{
421    async fn do_request(self, req: RequestPacket) -> TransportResult<ResponsePacket> {
422        // Per-request guard: launch `tempo wallet fund` at most once for one
423        // logical request, regardless of how many recovery paths attempt it.
424        let auto_fund_used = AtomicBool::new(false);
425        self.do_request_inner(req, &auto_fund_used).await
426    }
427
428    async fn do_request_inner(
429        self,
430        req: RequestPacket,
431        auto_fund_used: &AtomicBool,
432    ) -> TransportResult<ResponsePacket> {
433        let body = serde_json::to_vec(&req).map_err(TransportErrorKind::custom)?;
434        let headers = req.headers();
435
436        let resp = self
437            .client
438            .post(self.url.clone())
439            .headers(headers.clone())
440            .header("content-type", "application/json")
441            .body(body.clone())
442            .send()
443            .await
444            .map_err(TransportErrorKind::custom)?;
445
446        if resp.status() != StatusCode::PAYMENT_REQUIRED {
447            return Self::handle_response(resp).await;
448        }
449
450        // Serialize the entire 402 → pay → retry → response cycle.
451        // This prevents concurrent requests from opening duplicate channels
452        // or producing colliding expiring-nonce transactions. The lock is
453        // held until the retry response is fully handled.
454        let _pay_guard = self.provider.lock_pay().await;
455
456        // No local key for any offered challenge → run device-code flow,
457        // invalidate the cached provider, and fetch a fresh 402 (the original
458        // may have expired during the browser/passkey flow).
459        let (resolved, challenge) =
460            if let Some(chain_id) = tempo_chain_needing_auth(&self.url, &resp) {
461                debug!(chain_id, "launching wallet.tempo authorization");
462                let cfg = crate::tempo::EnsureAccessKeyConfig::from_env(chain_id);
463                crate::tempo::ensure_access_key(cfg).await.map_err(|e| {
464                    TransportErrorKind::custom(io::Error::other(format!(
465                        "tempo access key authorization failed: {e}"
466                    )))
467                })?;
468                self.provider.invalidate_cached_provider();
469                self.fetch_fresh_challenge(&headers, &body).await?
470            } else {
471                Self::select_challenge(&resp, &self.provider)?
472            };
473        let funding_ctx = self.provider.funding_context(&challenge);
474
475        debug!(id = %challenge.id, method = %challenge.method, intent = %challenge.intent, "received MPP 402 challenge, paying");
476
477        let credential = match resolved.pay(&challenge).await {
478            Ok(credential) => credential,
479            Err(e) => {
480                // Only the explicit `InsufficientBalance` variant is treated as
481                // a fundable error. Any other failure must surface unchanged so
482                // we don't mask payment/protocol issues behind a fund prompt.
483                let is_insufficient = matches!(e, mpp::MppError::InsufficientBalance(_));
484                self.provider.rollback_pending();
485                if is_insufficient && maybe_auto_fund(auto_fund_used, &funding_ctx).await? {
486                    resolved.pay(&challenge).await.map_err(|e2| {
487                        let suggest = matches!(e2, mpp::MppError::InsufficientBalance(_));
488                        self.provider.rollback_pending();
489                        TransportErrorKind::custom(std::io::Error::other(
490                            format_mpp_payment_failure(e2, &funding_ctx, suggest),
491                        ))
492                    })?
493                } else {
494                    return Err(TransportErrorKind::custom(std::io::Error::other(
495                        format_mpp_payment_failure(e, &funding_ctx, is_insufficient),
496                    )));
497                }
498            }
499        };
500
501        let auth_header = format_authorization(&credential).map_err(|e| {
502            self.provider.rollback_pending();
503            TransportErrorKind::custom(std::io::Error::other(format!(
504                "failed to format MPP credential: {e}"
505            )))
506        })?;
507
508        // Use a longer per-request timeout because the server may need to
509        // settle an on-chain transaction (channel open/topUp) before responding.
510        let retry_resp = self
511            .client
512            .post(self.url.clone())
513            .timeout(MPP_RETRY_TIMEOUT)
514            .headers(headers.clone())
515            .header("content-type", "application/json")
516            .header(AUTHORIZATION_HEADER, &auth_header)
517            .body(body.clone())
518            .send()
519            .await
520            .map_err(|e| {
521                self.provider.rollback_pending();
522                TransportErrorKind::custom(e)
523            })?;
524
525        // 204 No Content → topUp accepted, re-pay with voucher
526        if retry_resp.status() == StatusCode::NO_CONTENT {
527            debug!("MPP topUp accepted (204), retrying with voucher");
528
529            // Top-up is confirmed — commit the deposit increase and start
530            // tracking the follow-up voucher cumulative bump separately.
531            self.provider.commit_topup_and_track_voucher();
532
533            let resolved = self.provider.resolve()?;
534            let voucher_resp =
535                self.pay_and_retry(&challenge, &resolved, &headers, &body, auto_fund_used).await?;
536
537            // Route the voucher response through the funding-aware handler so
538            // a final 402 here also gets the fund retry / contextual help.
539            let result = self
540                .handle_response_or_retry_after_fund(
541                    voucher_resp,
542                    &headers,
543                    &body,
544                    &funding_ctx,
545                    auto_fund_used,
546                )
547                .await;
548            if result.is_ok() {
549                self.provider.set_key_provisioned(true);
550                self.provider.flush_pending();
551            } else {
552                self.provider.rollback_pending();
553            }
554            return result;
555        }
556
557        // 410 Gone → channel stale
558        if retry_resp.status() == StatusCode::GONE {
559            debug!("MPP channel not found (410), clearing stale local state");
560            self.provider.rollback_pending();
561            self.provider.clear_channels();
562
563            return Err(TransportErrorKind::custom(io::Error::other(
564                "MPP channel not found on server (410 Gone). \
565                 The server may have restarted or the channel was closed externally.\n\
566                 Local channel state has been cleared. Re-run to open a new channel.",
567            )));
568        }
569
570        // Retry 402 → handle specific recoverable errors before giving up.
571        if retry_resp.status() == StatusCode::PAYMENT_REQUIRED {
572            let diagnostics = format_http_diagnostics(retry_resp.headers());
573            let retry_body = retry_resp.bytes().await.map_err(TransportErrorKind::custom)?;
574            let retry_text = String::from_utf8_lossy(&retry_body);
575
576            // Parse RFC 9457 Problem Details if present. The `type` URI is the
577            // structured error code; the `detail` string provides context.
578            let problem: Option<mpp::error::PaymentErrorDetails> =
579                serde_json::from_slice(&retry_body).ok();
580            let problem_type = problem.as_ref().map(|p| p.problem_type.as_str()).unwrap_or("");
581            let detail = problem.as_ref().map(|p| p.detail.as_str()).unwrap_or("");
582
583            // Stale voucher: another provider instance (or a previous process)
584            // already used a higher cumulative_amount. Re-pay with a fresh
585            // voucher whose amount will be strictly greater.
586            let is_stale_voucher = problem_type.ends_with("/stale-voucher")
587                || detail.contains("cumulativeAmount must be strictly greater");
588            if is_stale_voucher {
589                debug!("MPP voucher stale, retrying with fresh voucher");
590                let resolved = self.provider.resolve()?;
591                if resolved.supports(challenge.method.as_str(), challenge.intent.as_str()) {
592                    let final_resp = self
593                        .pay_and_retry(&challenge, &resolved, &headers, &body, auto_fund_used)
594                        .await?;
595
596                    let result = self
597                        .handle_response_or_retry_after_fund(
598                            final_resp,
599                            &headers,
600                            &body,
601                            &funding_ctx,
602                            auto_fund_used,
603                        )
604                        .await;
605                    if result.is_ok() {
606                        self.provider.flush_pending();
607                    } else {
608                        self.provider.rollback_pending();
609                    }
610                    return result;
611                }
612            }
613
614            // Retry with key_authorization only when the error explicitly
615            // indicates the access key is not provisioned on-chain. Retrying on
616            // a generic verification-failed is unsafe: if the key is already
617            // provisioned, including a fresh key_authorization causes the chain
618            // to reject the open with KeyAlreadyExists, masking the real first-
619            // attempt failure.
620            //
621            // We fetch a fresh challenge because the server may have consumed
622            // the original challenge ID on first use.
623            let needs_key_provisioning = problem_type.ends_with("/key-not-provisioned")
624                || detail.contains("access key does not exist")
625                || detail.contains("key is not provisioned");
626
627            if needs_key_provisioning {
628                debug!(
629                    problem_type,
630                    "MPP 402 key not provisioned, 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 clear_channels(&self) {}
988    fn flush_pending(&self) {}
989    fn rollback_pending(&self) {}
990    fn commit_topup_and_track_voucher(&self) {}
991    /// Drop any cached payment provider so the next `resolve_for` re-runs
992    /// discovery. Called after the device-code flow writes a fresh
993    /// `keys.toml` entry.
994    fn invalidate_cached_provider(&self) {}
995    fn funding_wallet_address(&self) -> Option<alloy_primitives::Address> {
996        None
997    }
998    fn funding_chain_id(&self) -> Option<u64> {
999        None
1000    }
1001    fn funding_context(&self, challenge: &mpp::protocol::core::PaymentChallenge) -> FundingContext {
1002        let (challenge_chain_id, token) = extract_challenge_chain_and_currency(challenge);
1003        FundingContext {
1004            wallet_address: self.funding_wallet_address(),
1005            token,
1006            chain_id: challenge_chain_id.or_else(|| self.funding_chain_id()).map(Chain::from_id),
1007        }
1008    }
1009    /// Acquire the payment serialization lock. The returned guard must be held
1010    /// across the entire 402 → pay → retry → response cycle to prevent
1011    /// concurrent channel opens and colliding expiring-nonce transactions.
1012    fn lock_pay(&self) -> impl Future<Output = Option<OwnedMutexGuard<()>>> + Send {
1013        async { None }
1014    }
1015}
1016
1017impl<P: PaymentProvider + Clone> ResolveProvider for P {
1018    type Provider = P;
1019    fn resolve_for(&self, _opts: DiscoverOptions) -> TransportResult<P> {
1020        Ok(self.clone())
1021    }
1022}
1023
1024impl ResolveProvider for LazySessionProvider {
1025    type Provider = SessionProvider;
1026    fn resolve_for(&self, opts: DiscoverOptions) -> TransportResult<SessionProvider> {
1027        let provider = self.get_or_init(opts.clone())?;
1028        // After the first init, get_or_init returns the cached provider
1029        // regardless of opts. Re-check that the provider's key is compatible
1030        // with this challenge's chain/currency.
1031        if !provider.matches_challenge(opts.chain_id, opts.currency) {
1032            return Err(TransportErrorKind::custom(io::Error::other(
1033                "cached provider does not match challenge chain/currency",
1034            )));
1035        }
1036        Ok(provider)
1037    }
1038    fn set_key_provisioned(&self, provisioned: bool) {
1039        Self::set_key_provisioned(self, provisioned)
1040    }
1041    fn clear_channels(&self) {
1042        Self::clear_channels(self)
1043    }
1044    fn flush_pending(&self) {
1045        Self::flush_pending(self)
1046    }
1047    fn rollback_pending(&self) {
1048        Self::rollback_pending(self)
1049    }
1050    fn commit_topup_and_track_voucher(&self) {
1051        Self::commit_topup_and_track_voucher(self)
1052    }
1053    fn invalidate_cached_provider(&self) {
1054        Self::invalidate(self)
1055    }
1056    fn funding_wallet_address(&self) -> Option<alloy_primitives::Address> {
1057        self.inner.lock().unwrap().as_ref().map(|p| p.funding_wallet_address())
1058    }
1059    fn funding_chain_id(&self) -> Option<u64> {
1060        self.inner.lock().unwrap().as_ref().and_then(|p| p.key_chain_id())
1061    }
1062    fn lock_pay(&self) -> impl Future<Output = Option<OwnedMutexGuard<()>>> + Send {
1063        let lock = self.pay_lock.clone();
1064        async move { Some(lock.lock_owned().await) }
1065    }
1066}
1067
1068impl<P> fmt::Display for MppHttpTransport<P> {
1069    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1070        write!(f, "MppHttpTransport({})", self.url)
1071    }
1072}
1073
1074#[allow(private_bounds)]
1075impl<P: ResolveProvider + Clone + Send + Sync + fmt::Debug + 'static> Service<RequestPacket>
1076    for MppHttpTransport<P>
1077where
1078    P::Provider: Send + Sync + 'static,
1079{
1080    type Response = ResponsePacket;
1081    type Error = TransportError;
1082    type Future = TransportFut<'static>;
1083
1084    #[inline]
1085    fn poll_ready(&mut self, _cx: &mut task::Context<'_>) -> task::Poll<Result<(), Self::Error>> {
1086        task::Poll::Ready(Ok(()))
1087    }
1088
1089    #[inline]
1090    fn call(&mut self, req: RequestPacket) -> Self::Future {
1091        let this = self.clone();
1092        let span = debug_span!("MppHttpTransport", url = %this.url);
1093        Box::pin(this.do_request(req).instrument(span.or_current()))
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100    use crate::provider::runtime_transport::RuntimeTransportBuilder;
1101    use alloy_json_rpc::{Id, Request, RequestMeta};
1102    use axum::{
1103        extract::State, http::StatusCode as AxumStatusCode, response::IntoResponse, routing::post,
1104    };
1105    use mpp::{
1106        MppError,
1107        protocol::core::{
1108            Base64UrlJson, IntentName, MethodName, PaymentChallenge, PaymentCredential,
1109            format_www_authenticate, parse_authorization,
1110        },
1111    };
1112
1113    #[derive(Clone, Debug)]
1114    struct MockPaymentProvider;
1115
1116    impl PaymentProvider for MockPaymentProvider {
1117        fn supports(&self, method: &str, intent: &str) -> bool {
1118            method == "tempo" && (intent == "session" || intent == "charge")
1119        }
1120
1121        fn pay(
1122            &self,
1123            challenge: &PaymentChallenge,
1124        ) -> impl Future<Output = Result<PaymentCredential, MppError>> + Send {
1125            let echo = challenge.to_echo();
1126            async move {
1127                Ok(PaymentCredential::with_source(
1128                    echo,
1129                    "test-source".to_string(),
1130                    serde_json::json!({"action": "voucher", "channelId": "0xtest", "cumulativeAmount": "1000", "signature": "0xtest"}),
1131                ))
1132            }
1133        }
1134    }
1135
1136    #[derive(Clone, Debug)]
1137    struct InsufficientBalanceProvider;
1138
1139    impl PaymentProvider for InsufficientBalanceProvider {
1140        fn supports(&self, method: &str, intent: &str) -> bool {
1141            method == "tempo" && (intent == "session" || intent == "charge")
1142        }
1143
1144        async fn pay(&self, _challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
1145            Err(MppError::InsufficientBalance(Some(
1146                "wallet has 0 pathUSD but needs 100000".to_string(),
1147            )))
1148        }
1149    }
1150
1151    fn test_challenge() -> (PaymentChallenge, String) {
1152        let request = Base64UrlJson::from_value(&serde_json::json!({
1153            "amount": "1000",
1154            "currency": "0x20c0",
1155            "recipient": "0xpayee",
1156            "methodDetails": {
1157                "chainId": 42431
1158            }
1159        }))
1160        .unwrap();
1161
1162        let challenge = PaymentChallenge {
1163            id: "test-id-42".to_string(),
1164            realm: "test-realm".to_string(),
1165            method: MethodName::new("tempo"),
1166            intent: IntentName::new("session"),
1167            request,
1168            expires: None,
1169            description: None,
1170            digest: None,
1171            opaque: None,
1172        };
1173
1174        let www_auth = format_www_authenticate(&challenge).unwrap();
1175        (challenge, www_auth)
1176    }
1177
1178    fn test_request() -> RequestPacket {
1179        let req: Request<serde_json::Value> = Request {
1180            meta: RequestMeta::new("eth_blockNumber".into(), Id::Number(1)),
1181            params: serde_json::Value::Array(vec![]),
1182        };
1183        RequestPacket::Single(req.serialize().unwrap())
1184    }
1185
1186    async fn spawn_server(app: axum::Router) -> (String, tokio::task::JoinHandle<()>) {
1187        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1188        let addr = listener.local_addr().unwrap();
1189        let handle = tokio::spawn(async move {
1190            axum::serve(listener, app).await.unwrap();
1191        });
1192        (format!("http://{addr}"), handle)
1193    }
1194
1195    fn test_client() -> reqwest::Client {
1196        reqwest::Client::builder().no_proxy().build().unwrap()
1197    }
1198
1199    #[tokio::test]
1200    async fn test_mpp_transport_no_402() {
1201        let app = axum::Router::new().route(
1202            "/",
1203            post(|| async {
1204                axum::Json(serde_json::json!({
1205                    "jsonrpc": "2.0",
1206                    "id": 1,
1207                    "result": "0x123"
1208                }))
1209            }),
1210        );
1211
1212        let (base_url, handle) = spawn_server(app).await;
1213        let mut transport = MppHttpTransport::new(
1214            test_client(),
1215            Url::parse(&base_url).unwrap(),
1216            MockPaymentProvider,
1217        );
1218
1219        let resp = tower::Service::call(&mut transport, test_request()).await.unwrap();
1220        match resp {
1221            ResponsePacket::Single(r) => assert!(r.is_success()),
1222            _ => panic!("expected single response"),
1223        }
1224
1225        handle.abort();
1226    }
1227
1228    #[tokio::test]
1229    async fn test_mpp_transport_402_then_success() {
1230        let (_, www_auth) = test_challenge();
1231        let state = AppState { www_auth };
1232
1233        #[derive(Clone)]
1234        struct AppState {
1235            www_auth: String,
1236        }
1237
1238        let app =
1239            axum::Router::new()
1240                .route(
1241                    "/",
1242                    post(
1243                        |State(state): State<AppState>,
1244                         req: axum::http::Request<axum::body::Body>| async move {
1245                            if let Some(auth) = req.headers().get("authorization") {
1246                                let auth_str = auth.to_str().unwrap();
1247                                let credential = parse_authorization(auth_str).unwrap();
1248                                assert_eq!(credential.challenge.id, "test-id-42");
1249                                assert_eq!(credential.challenge.method.as_str(), "tempo");
1250                                assert!(credential.source.is_some());
1251
1252                                (
1253                                    AxumStatusCode::OK,
1254                                    axum::Json(serde_json::json!({
1255                                        "jsonrpc": "2.0",
1256                                        "id": 1,
1257                                        "result": "0xvalidated"
1258                                    })),
1259                                )
1260                                    .into_response()
1261                            } else {
1262                                (
1263                                    AxumStatusCode::PAYMENT_REQUIRED,
1264                                    [("www-authenticate", state.www_auth)],
1265                                    "Payment Required",
1266                                )
1267                                    .into_response()
1268                            }
1269                        },
1270                    ),
1271                )
1272                .with_state(state);
1273
1274        let (base_url, handle) = spawn_server(app).await;
1275        let mut transport = MppHttpTransport::new(
1276            test_client(),
1277            Url::parse(&base_url).unwrap(),
1278            MockPaymentProvider,
1279        );
1280
1281        let resp = tower::Service::call(&mut transport, test_request()).await.unwrap();
1282        match resp {
1283            ResponsePacket::Single(r) => assert!(r.is_success()),
1284            _ => panic!("expected single response"),
1285        }
1286
1287        handle.abort();
1288    }
1289
1290    #[tokio::test]
1291    async fn test_mpp_transport_402_missing_www_authenticate() {
1292        let app = axum::Router::new()
1293            .route("/", post(|| async { (AxumStatusCode::PAYMENT_REQUIRED, "pay up") }));
1294
1295        let (base_url, handle) = spawn_server(app).await;
1296        let mut transport = MppHttpTransport::new(
1297            test_client(),
1298            Url::parse(&base_url).unwrap(),
1299            MockPaymentProvider,
1300        );
1301
1302        let err = tower::Service::call(&mut transport, test_request()).await.unwrap_err();
1303        assert!(
1304            err.to_string().contains("WWW-Authenticate"),
1305            "expected WWW-Authenticate error, got: {err}"
1306        );
1307
1308        handle.abort();
1309    }
1310
1311    #[tokio::test]
1312    async fn test_mpp_transport_payment_failure_suggests_tempo_wallet_fund() {
1313        let (_, www_auth) = test_challenge();
1314
1315        let app = axum::Router::new().route(
1316            "/",
1317            post(move || {
1318                let www_auth = www_auth.clone();
1319                async move {
1320                    (
1321                        AxumStatusCode::PAYMENT_REQUIRED,
1322                        [("www-authenticate", www_auth)],
1323                        "Payment Required",
1324                    )
1325                }
1326            }),
1327        );
1328
1329        let (base_url, handle) = spawn_server(app).await;
1330        let mut transport = MppHttpTransport::new(
1331            test_client(),
1332            Url::parse(&base_url).unwrap(),
1333            InsufficientBalanceProvider,
1334        );
1335
1336        let err = tower::Service::call(&mut transport, test_request()).await.unwrap_err();
1337        let msg = err.to_string();
1338        assert!(msg.contains("Tempo wallet payment could not be funded"), "got: {msg}");
1339        assert!(msg.contains("tempo wallet fund"), "got: {msg}");
1340        assert!(msg.contains("--no-browser"), "got: {msg}");
1341        assert!(msg.contains("Requested payment token: 0x20c0"), "got: {msg}");
1342
1343        handle.abort();
1344    }
1345
1346    #[tokio::test]
1347    async fn test_mpp_transport_retry_402_insufficient_balance_suggests_fund() {
1348        let (_, www_auth) = test_challenge();
1349
1350        let app = axum::Router::new().route(
1351            "/",
1352            post(move |req: axum::http::Request<axum::body::Body>| {
1353                let www_auth = www_auth.clone();
1354                async move {
1355                    if req.headers().get("authorization").is_some() {
1356                        (
1357                            AxumStatusCode::PAYMENT_REQUIRED,
1358                            [("content-type", "application/problem+json")],
1359                            serde_json::to_string(
1360                                &mpp::error::PaymentErrorDetails::session("insufficient-balance")
1361                                    .with_title("InsufficientBalanceError")
1362                                    .with_detail(
1363                                        "Insufficient pathUSD balance: have 0, need 100000",
1364                                    ),
1365                            )
1366                            .unwrap(),
1367                        )
1368                            .into_response()
1369                    } else {
1370                        (
1371                            AxumStatusCode::PAYMENT_REQUIRED,
1372                            [("www-authenticate", www_auth)],
1373                            "Payment Required".to_string(),
1374                        )
1375                            .into_response()
1376                    }
1377                }
1378            }),
1379        );
1380
1381        let (base_url, handle) = spawn_server(app).await;
1382        let mut transport = MppHttpTransport::new(
1383            test_client(),
1384            Url::parse(&base_url).unwrap(),
1385            MockPaymentProvider,
1386        );
1387
1388        let err = tower::Service::call(&mut transport, test_request()).await.unwrap_err();
1389        let msg = err.to_string();
1390        assert!(msg.contains("InsufficientBalanceError"), "got: {msg}");
1391        assert!(msg.contains("Tempo wallet payment could not be funded"), "got: {msg}");
1392        assert!(msg.contains("tempo wallet fund"), "got: {msg}");
1393        assert!(msg.contains("--no-browser"), "got: {msg}");
1394        assert!(msg.contains("Requested payment token: 0x20c0"), "got: {msg}");
1395
1396        handle.abort();
1397    }
1398
1399    /// Generic `verification-failed` has many non-funding causes (bad signature,
1400    /// replay, expired challenge, clock skew, ...). The transport must surface
1401    /// the original error verbatim and must NOT add a "fund your wallet" hint.
1402    #[tokio::test]
1403    async fn test_mpp_transport_final_402_verification_failed_does_not_suggest_fund() {
1404        let (_, www_auth) = test_challenge();
1405
1406        let app = axum::Router::new().route(
1407            "/",
1408            post(move |req: axum::http::Request<axum::body::Body>| {
1409                let www_auth = www_auth.clone();
1410                async move {
1411                    if req.headers().get("authorization").is_some() {
1412                        (
1413                            AxumStatusCode::PAYMENT_REQUIRED,
1414                            [("content-type", "application/problem+json")],
1415                            serde_json::to_string(
1416                                &mpp::error::PaymentErrorDetails::core("verification-failed")
1417                                    .with_title("Verification Failed")
1418                                    .with_detail("Payment verification failed."),
1419                            )
1420                            .unwrap(),
1421                        )
1422                            .into_response()
1423                    } else {
1424                        (
1425                            AxumStatusCode::PAYMENT_REQUIRED,
1426                            [("www-authenticate", www_auth)],
1427                            "Payment Required".to_string(),
1428                        )
1429                            .into_response()
1430                    }
1431                }
1432            }),
1433        );
1434
1435        let (base_url, handle) = spawn_server(app).await;
1436        let mut transport = MppHttpTransport::new(
1437            test_client(),
1438            Url::parse(&base_url).unwrap(),
1439            MockPaymentProvider,
1440        );
1441
1442        let err = tower::Service::call(&mut transport, test_request()).await.unwrap_err();
1443        let msg = err.to_string();
1444        assert!(msg.contains("Verification Failed"), "got: {msg}");
1445        assert!(
1446            !msg.contains("Tempo wallet payment could not be funded"),
1447            "verification-failed must not be classified as fundable; got: {msg}"
1448        );
1449
1450        handle.abort();
1451    }
1452
1453    // --- Classifier unit tests --------------------------------------------
1454
1455    #[test]
1456    fn classifier_only_triggers_on_explicit_insufficient_balance_problem() {
1457        // explicit insufficient-balance → true
1458        let body = serde_json::to_vec(
1459            &mpp::error::PaymentErrorDetails::session("insufficient-balance")
1460                .with_title("InsufficientBalanceError")
1461                .with_detail("Insufficient pathUSD balance"),
1462        )
1463        .unwrap();
1464        assert!(should_suggest_tempo_fund(StatusCode::PAYMENT_REQUIRED, &body));
1465    }
1466
1467    #[test]
1468    fn classifier_does_not_trigger_on_verification_failed() {
1469        let body = serde_json::to_vec(
1470            &mpp::error::PaymentErrorDetails::core("verification-failed")
1471                .with_title("Verification Failed")
1472                .with_detail("Payment verification failed."),
1473        )
1474        .unwrap();
1475        assert!(!should_suggest_tempo_fund(StatusCode::PAYMENT_REQUIRED, &body));
1476    }
1477
1478    #[test]
1479    fn classifier_does_not_trigger_on_unrelated_text_with_balance_words() {
1480        // Free-text 402 body that just happens to mention the word "balance"
1481        // must NOT trigger the fund suggestion (no structured problem details).
1482        let body =
1483            b"402 Payment Required: server could not balance ledger entries; insufficient inputs.";
1484        assert!(!should_suggest_tempo_fund(StatusCode::PAYMENT_REQUIRED, body));
1485    }
1486
1487    #[test]
1488    fn classifier_does_not_trigger_outside_402() {
1489        let body = serde_json::to_vec(
1490            &mpp::error::PaymentErrorDetails::session("insufficient-balance")
1491                .with_detail("Insufficient balance"),
1492        )
1493        .unwrap();
1494        assert!(!should_suggest_tempo_fund(StatusCode::INTERNAL_SERVER_ERROR, &body));
1495        assert!(!should_suggest_tempo_fund(StatusCode::OK, &body));
1496    }
1497
1498    #[test]
1499    fn fund_help_includes_address_and_network_for_known_chain() {
1500        let ctx = FundingContext {
1501            wallet_address: Some("0x000000000000000000000000000000000000dEaD".parse().unwrap()),
1502            token: Some("0x20c0".to_string()),
1503            chain_id: Some(Chain::from_id(42431)),
1504        };
1505        let help = tempo_wallet_fund_help(&ctx);
1506        assert!(help.contains("--address 0x"), "missing --address: {help}");
1507        assert!(help.contains("--network tempo-moderato"), "missing --network: {help}");
1508        assert!(help.contains("--no-browser"), "missing --no-browser: {help}");
1509        assert!(help.contains("Requested payment token: 0x20c0"), "missing token: {help}");
1510
1511        let mainnet = FundingContext { chain_id: Some(Chain::from_id(4217)), ..ctx };
1512        let help2 = tempo_wallet_fund_help(&mainnet);
1513        assert!(help2.contains("--network tempo"), "missing tempo network: {help2}");
1514    }
1515
1516    #[test]
1517    fn auto_fund_policy_blocks_in_ci_and_non_tty() {
1518        assert!(!interactive_tempo_fund_allowed(Some("1"), true, true, true), "must not run in CI");
1519        assert!(
1520            interactive_tempo_fund_allowed(Some("0"), false, true, true),
1521            "FOUNDRY_MPP_NO_AUTO_FUND=0 must not disable"
1522        );
1523        assert!(
1524            interactive_tempo_fund_allowed(Some("false"), false, true, true),
1525            "FOUNDRY_MPP_NO_AUTO_FUND=false must not disable"
1526        );
1527        assert!(
1528            !interactive_tempo_fund_allowed(None, false, false, true),
1529            "stdin must be a terminal"
1530        );
1531        assert!(
1532            !interactive_tempo_fund_allowed(None, false, true, false),
1533            "stderr must be a terminal"
1534        );
1535        assert!(!interactive_tempo_fund_allowed(Some("1"), false, true, true));
1536        assert!(!interactive_tempo_fund_allowed(Some("true"), false, true, true));
1537        assert!(interactive_tempo_fund_allowed(None, false, true, true));
1538    }
1539
1540    #[tokio::test]
1541    async fn test_plain_http_402_shows_mpp_setup_instructions() {
1542        let _g = crate::tempo::test_env_mutex().lock().await;
1543        let (_, www_auth) = test_challenge();
1544
1545        let app = axum::Router::new().route(
1546            "/",
1547            post(move || {
1548                let www_auth = www_auth.clone();
1549                async move {
1550                    (
1551                        AxumStatusCode::PAYMENT_REQUIRED,
1552                        [("www-authenticate", www_auth)],
1553                        "Payment Required",
1554                    )
1555                }
1556            }),
1557        );
1558
1559        let (base_url, handle) = spawn_server(app).await;
1560
1561        unsafe {
1562            std::env::set_var("TEMPO_HOME", "/nonexistent/path");
1563            std::env::remove_var("TEMPO_PRIVATE_KEY");
1564        }
1565
1566        let transport = RuntimeTransportBuilder::new(Url::parse(&base_url).unwrap()).build();
1567        let err = transport.request(test_request()).await.unwrap_err();
1568        let msg = err.to_string();
1569
1570        assert!(
1571            msg.contains("402 Payment Required") || msg.contains("no supported MPP challenge"),
1572            "expected MPP setup instructions or 'no supported MPP challenge' in error, got: {msg}"
1573        );
1574
1575        handle.abort();
1576        unsafe { std::env::remove_var("TEMPO_HOME") };
1577    }
1578
1579    #[test]
1580    fn test_session_provider_supports_charge_and_session() {
1581        let signer = mpp::PrivateKeySigner::random();
1582        let provider =
1583            super::super::session::SessionProvider::new(signer, "https://rpc.example.com".into());
1584
1585        assert!(provider.supports("tempo", "session"));
1586        assert!(provider.supports("tempo", "charge"));
1587        assert!(!provider.supports("stripe", "charge"));
1588        assert!(!provider.supports("tempo", "subscribe"));
1589    }
1590
1591    #[tokio::test]
1592    async fn test_session_provider_pay_charge_parses_challenge() {
1593        let signer = mpp::PrivateKeySigner::random();
1594        let provider =
1595            super::super::session::SessionProvider::new(signer, "https://rpc.example.com".into());
1596
1597        // Valid charge challenge — pay_charge wires through to TempoCharge,
1598        // which will fail at gas estimation (no RPC), but confirms the path is connected.
1599        let (challenge, _) = test_challenge();
1600        let err = provider.pay(&challenge).await.unwrap_err();
1601        // Should fail deeper than "not supported" — proves charge dispatch works
1602        assert!(
1603            !err.to_string().contains("not supported"),
1604            "expected charge path to be wired up, got: {err}"
1605        );
1606    }
1607
1608    /// `invalidate_cached_provider` clears the cache so the next
1609    /// `get_or_init` re-runs discovery — the path `do_request` takes after
1610    /// `ensure_access_key` writes a fresh `keys.toml` entry.
1611    #[tokio::test]
1612    async fn lazy_session_provider_invalidate_clears_cache() {
1613        let _g = crate::tempo::test_env_mutex().lock().await;
1614        // TEMPO_PRIVATE_KEY lets discovery succeed without a keys.toml.
1615        let key_hex = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1616        unsafe {
1617            std::env::set_var(crate::tempo::TEMPO_PRIVATE_KEY_ENV, key_hex);
1618            std::env::remove_var(crate::tempo::TEMPO_HOME_ENV);
1619        }
1620
1621        let lazy = LazySessionProvider::new("https://rpc.example.com".into());
1622        let _ = lazy.get_or_init(Default::default()).expect("discovery succeeds");
1623        assert!(lazy.inner.lock().unwrap().is_some(), "expected provider to be cached");
1624
1625        ResolveProvider::invalidate_cached_provider(&lazy);
1626        assert!(lazy.inner.lock().unwrap().is_none(), "expected cache to be cleared");
1627
1628        let _ = lazy.get_or_init(Default::default()).expect("re-discovery succeeds");
1629        assert!(lazy.inner.lock().unwrap().is_some(), "expected re-init to repopulate cache");
1630
1631        unsafe { std::env::remove_var(crate::tempo::TEMPO_PRIVATE_KEY_ENV) };
1632    }
1633
1634    #[test]
1635    fn challenge_chain_and_currency_extraction() {
1636        let extract = |headers: Vec<&str>| -> Vec<(Option<u64>, Option<String>)> {
1637            let challenges: Vec<_> =
1638                parse_www_authenticate_all(headers).into_iter().filter_map(|r| r.ok()).collect();
1639            challenges.iter().map(extract_challenge_chain_and_currency).collect()
1640        };
1641
1642        let b64 = |v: serde_json::Value| -> String {
1643            Base64UrlJson::from_value(&v).unwrap().raw().to_string()
1644        };
1645
1646        // Tempo challenge with chainId + currency
1647        let tempo_header = format!(
1648            r#"Payment id="abc", realm="api", method="tempo", intent="charge", request="{}""#,
1649            b64(
1650                serde_json::json!({"amount":"1000","currency":"0x20c0","methodDetails":{"chainId":42431},"recipient":"0xabc"})
1651            )
1652        );
1653        assert_eq!(extract(vec![&tempo_header]), vec![(Some(42431), Some("0x20c0".into()))]);
1654
1655        // Non-tempo challenge → (None, None)
1656        let stripe_header = format!(
1657            r#"Payment id="xyz", realm="api", method="stripe", intent="charge", request="{}""#,
1658            b64(serde_json::json!({"amount":"100"}))
1659        );
1660        assert_eq!(extract(vec![&stripe_header]), vec![(None, None)]);
1661
1662        // Tempo challenge without methodDetails → chainId None, currency present
1663        let no_details = format!(
1664            r#"Payment id="def", realm="api", method="tempo", intent="charge", request="{}""#,
1665            b64(serde_json::json!({"amount":"1000","currency":"0x20c0","recipient":"0xabc"}))
1666        );
1667        assert_eq!(extract(vec![&no_details]), vec![(None, Some("0x20c0".into()))]);
1668    }
1669
1670    /// Real `SessionProvider` + mock server: 402 with precompile escrow →
1671    /// `Open` credential, then second 402 → `Voucher` reusing the channel.
1672    #[tokio::test]
1673    async fn mpp_transport_t5_precompile_open_then_voucher() {
1674        use alloy_eips::eip2718::Decodable2718;
1675        use alloy_primitives::{Address, TxKind};
1676        use alloy_sol_types::SolCall;
1677        use mpp::protocol::methods::tempo::session::SessionCredentialPayload;
1678        use tempo_alloy::contracts::precompiles::{
1679            ITIP20ChannelReserve, TIP20_CHANNEL_RESERVE_ADDRESS,
1680        };
1681        use tempo_primitives::transaction::TempoTxEnvelope;
1682
1683        let payee = Address::repeat_byte(0x11);
1684        let currency = Address::repeat_byte(0x22);
1685        let operator = Address::repeat_byte(0x99);
1686        let chain_id = 4217u64;
1687
1688        let request_json = serde_json::json!({
1689            "amount": "1000",
1690            "currency": format!("{currency:#x}"),
1691            "recipient": format!("{payee:#x}"),
1692            "methodDetails": {
1693                "chainId": chain_id,
1694                "escrowContract": format!("{TIP20_CHANNEL_RESERVE_ADDRESS:#x}"),
1695                "operator": format!("{operator:#x}"),
1696            },
1697        });
1698        let request = Base64UrlJson::from_value(&request_json).unwrap();
1699        let challenge = PaymentChallenge {
1700            id: "t5-precompile-challenge".to_string(),
1701            realm: "test-t5".to_string(),
1702            method: MethodName::new("tempo"),
1703            intent: IntentName::new("session"),
1704            request,
1705            expires: None,
1706            description: None,
1707            digest: None,
1708            opaque: None,
1709        };
1710        let www_auth = format_www_authenticate(&challenge).unwrap();
1711
1712        #[derive(Clone)]
1713        struct AppState {
1714            www_auth: String,
1715            captured: Arc<Mutex<Vec<PaymentCredential>>>,
1716        }
1717        let state = AppState { www_auth, captured: Arc::new(Mutex::new(Vec::new())) };
1718
1719        let captured = state.captured.clone();
1720        let app =
1721            axum::Router::new()
1722                .route(
1723                    "/",
1724                    post(
1725                        |State(state): State<AppState>,
1726                         req: axum::http::Request<axum::body::Body>| async move {
1727                            if let Some(auth) = req.headers().get("authorization") {
1728                                let auth_str = auth.to_str().unwrap();
1729                                let credential = parse_authorization(auth_str).unwrap();
1730                                state.captured.lock().unwrap().push(credential);
1731                                (
1732                                    AxumStatusCode::OK,
1733                                    axum::Json(serde_json::json!({
1734                                        "jsonrpc": "2.0",
1735                                        "id": 1,
1736                                        "result": "0xabc",
1737                                    })),
1738                                )
1739                                    .into_response()
1740                            } else {
1741                                (
1742                                    AxumStatusCode::PAYMENT_REQUIRED,
1743                                    [("www-authenticate", state.www_auth.clone())],
1744                                    "Payment Required",
1745                                )
1746                                    .into_response()
1747                            }
1748                        },
1749                    ),
1750                )
1751                .with_state(state);
1752
1753        let (base_url, handle) = spawn_server(app).await;
1754
1755        let signer = mpp::PrivateKeySigner::random();
1756        let session_provider =
1757            super::super::session::SessionProvider::new(signer, base_url.clone())
1758                .with_default_deposit(100_000);
1759        let mut transport =
1760            MppHttpTransport::new(test_client(), Url::parse(&base_url).unwrap(), session_provider);
1761
1762        let resp1 = tower::Service::call(&mut transport, test_request()).await.unwrap();
1763        assert!(matches!(resp1, ResponsePacket::Single(r) if r.is_success()));
1764        let resp2 = tower::Service::call(&mut transport, test_request()).await.unwrap();
1765        assert!(matches!(resp2, ResponsePacket::Single(r) if r.is_success()));
1766
1767        let captured = captured.lock().unwrap();
1768        assert_eq!(captured.len(), 2);
1769
1770        let open: SessionCredentialPayload = captured[0].payload_as().expect("Open payload");
1771        let (open_channel_id, open_transaction, open_cumulative, open_descriptor) = match open {
1772            SessionCredentialPayload::Open {
1773                channel_id,
1774                transaction,
1775                cumulative_amount,
1776                descriptor,
1777                ..
1778            } => (channel_id, transaction, cumulative_amount, descriptor),
1779            other => panic!("first credential must be Open, got {other:?}"),
1780        };
1781        assert_eq!(open_cumulative, "1000");
1782        let open_descriptor = open_descriptor.expect("precompile Open must include descriptor");
1783
1784        let tx_bytes = alloy_primitives::hex::decode(&open_transaction).expect("hex tx");
1785        let envelope =
1786            TempoTxEnvelope::decode_2718(&mut tx_bytes.as_slice()).expect("decode envelope");
1787        let TempoTxEnvelope::AA(aa_signed) = envelope else {
1788            panic!("expected AA envelope (0x76)");
1789        };
1790        let unsigned = aa_signed.strip_signature();
1791        // Single-call (no TIP20.approve per TIP-1035), targets the precompile.
1792        assert_eq!(unsigned.calls.len(), 1);
1793        let call = &unsigned.calls[0];
1794        assert_eq!(call.to, TxKind::Call(TIP20_CHANNEL_RESERVE_ADDRESS));
1795        let decoded =
1796            ITIP20ChannelReserve::openCall::abi_decode(&call.input).expect("decode openCall");
1797        assert_eq!(decoded.operator, operator);
1798        assert_eq!(decoded.payee, payee);
1799        assert_eq!(decoded.token, currency);
1800
1801        let voucher: SessionCredentialPayload = captured[1].payload_as().expect("Voucher payload");
1802        let (voucher_channel_id, voucher_cumulative, voucher_descriptor) = match voucher {
1803            SessionCredentialPayload::Voucher {
1804                channel_id, cumulative_amount, descriptor, ..
1805            } => (channel_id, cumulative_amount, descriptor),
1806            other => panic!("second credential must be Voucher, got {other:?}"),
1807        };
1808        assert_eq!(voucher_channel_id, open_channel_id);
1809        assert_eq!(voucher_cumulative, "2000");
1810        assert_eq!(
1811            voucher_descriptor.expect("precompile Voucher must include descriptor"),
1812            open_descriptor
1813        );
1814
1815        handle.abort();
1816    }
1817
1818    /// Auth must trigger when a key matches the chain but not the currency.
1819    #[test]
1820    fn pick_chain_needing_auth_currency_aware() {
1821        let _g = crate::tempo::test_env_mutex().blocking_lock();
1822        let dir = tempfile::tempdir().unwrap();
1823        let wallet = dir.path().join("wallet");
1824        std::fs::create_dir_all(&wallet).unwrap();
1825        std::fs::write(
1826            wallet.join("keys.toml"),
1827            r#"
1828[[keys]]
1829wallet_type = "passkey"
1830wallet_address = "0x0000000000000000000000000000000000000001"
1831key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
1832chain_id = 4217
1833
1834[[keys.limits]]
1835currency = "0x20c0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1836limit = "1000"
1837"#,
1838        )
1839        .unwrap();
1840        unsafe {
1841            std::env::set_var(crate::tempo::TEMPO_HOME_ENV, dir.path());
1842            std::env::remove_var(crate::tempo::TEMPO_PRIVATE_KEY_ENV);
1843        }
1844
1845        let url = Url::parse("https://rpc.mpp.tempo.xyz").unwrap();
1846        let mk = |currency: &str| -> PaymentChallenge {
1847            PaymentChallenge {
1848                id: "x".into(),
1849                realm: "api".into(),
1850                method: MethodName::new("tempo"),
1851                intent: IntentName::new("charge"),
1852                request: Base64UrlJson::from_value(&serde_json::json!({
1853                    "amount": "1",
1854                    "currency": currency,
1855                    "recipient": "0xabc",
1856                    "methodDetails": { "chainId": 4217 }
1857                }))
1858                .unwrap(),
1859                expires: None,
1860                description: None,
1861                digest: None,
1862                opaque: None,
1863            }
1864        };
1865
1866        // Currency mismatch → auth needed.
1867        let mismatched = mk("0x20c0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
1868        assert_eq!(pick_chain_needing_auth(&url, &[mismatched]), Some(4217));
1869
1870        // Currency match → no auth.
1871        let matched = mk("0x20c0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
1872        assert_eq!(pick_chain_needing_auth(&url, &[matched]), None);
1873
1874        // Non-Tempo host → never triggers, even without a key.
1875        let stripe_url = Url::parse("https://api.stripe.com").unwrap();
1876        assert_eq!(
1877            pick_chain_needing_auth(
1878                &stripe_url,
1879                &[mk("0x20c0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")]
1880            ),
1881            None,
1882        );
1883
1884        unsafe { std::env::remove_var(crate::tempo::TEMPO_HOME_ENV) };
1885    }
1886}