1use 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
40const DEFAULT_DEPOSIT: u128 = 100_000;
42
43const MPP_RETRY_TIMEOUT: Duration = Duration::from_secs(120);
45
46fn 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
109fn 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
205async 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
220fn 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
250static GLOBAL_PAY_LOCKS: LazyLock<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>> =
255 LazyLock::new(|| Mutex::new(HashMap::new()));
256
257pub type LazyMppHttpTransport = MppHttpTransport<LazySessionProvider>;
260
261#[derive(Clone, Debug)]
264pub struct LazySessionProvider {
265 inner: Arc<Mutex<Option<SessionProvider>>>,
266 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 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#[derive(Clone, Debug)]
384pub struct MppHttpTransport<P> {
385 client: reqwest::Client,
386 url: Url,
387 provider: P,
388}
389
390impl MppHttpTransport<LazySessionProvider> {
391 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 pub const fn new(client: reqwest::Client, url: Url, provider: P) -> Self {
405 Self { client, url, provider }
406 }
407
408 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 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 let _pay_guard = self.provider.lock_pay().await;
453
454 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 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 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 if retry_resp.status() == StatusCode::NO_CONTENT {
525 debug!("MPP topUp accepted (204), retrying with voucher");
526
527 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 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 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 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 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 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 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 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 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 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 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 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
914fn 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
928fn 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
939fn 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 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
965pub(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
979pub(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 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 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 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 #[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 #[test]
1458 fn classifier_only_triggers_on_explicit_insufficient_balance_problem() {
1459 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 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 let (challenge, _) = test_challenge();
1602 let err = provider.pay(&challenge).await.unwrap_err();
1603 assert!(
1605 !err.to_string().contains("not supported"),
1606 "expected charge path to be wired up, got: {err}"
1607 );
1608 }
1609
1610 #[tokio::test]
1614 async fn lazy_session_provider_invalidate_clears_cache() {
1615 let _g = crate::tempo::test_env_mutex().lock().await;
1616 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 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 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 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 #[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 let mismatched = mk("0x20c0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
1722 assert_eq!(pick_chain_needing_auth(&url, &[mismatched]), Some(4217));
1723
1724 let matched = mk("0x20c0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
1726 assert_eq!(pick_chain_needing_auth(&url, &[matched]), None);
1727
1728 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}