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 {
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
111fn 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
207async 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
222fn 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
252static GLOBAL_PAY_LOCKS: LazyLock<Mutex<HashMap<String, Arc<AsyncMutex<()>>>>> =
257 LazyLock::new(|| Mutex::new(HashMap::new()));
258
259pub type LazyMppHttpTransport = MppHttpTransport<LazySessionProvider>;
262
263#[derive(Clone, Debug)]
266pub struct LazySessionProvider {
267 inner: Arc<Mutex<Option<SessionProvider>>>,
268 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 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#[derive(Clone, Debug)]
386pub struct MppHttpTransport<P> {
387 client: reqwest::Client,
388 url: Url,
389 provider: P,
390}
391
392impl MppHttpTransport<LazySessionProvider> {
393 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 pub const fn new(client: reqwest::Client, url: Url, provider: P) -> Self {
407 Self { client, url, provider }
408 }
409
410 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 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 let _pay_guard = self.provider.lock_pay().await;
455
456 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 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 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 if retry_resp.status() == StatusCode::NO_CONTENT {
527 debug!("MPP topUp accepted (204), retrying with voucher");
528
529 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 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 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 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 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 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 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 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 clear_channels(&self) {}
988 fn flush_pending(&self) {}
989 fn rollback_pending(&self) {}
990 fn commit_topup_and_track_voucher(&self) {}
991 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 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 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 #[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 #[test]
1456 fn classifier_only_triggers_on_explicit_insufficient_balance_problem() {
1457 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 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 let (challenge, _) = test_challenge();
1600 let err = provider.pay(&challenge).await.unwrap_err();
1601 assert!(
1603 !err.to_string().contains("not supported"),
1604 "expected charge path to be wired up, got: {err}"
1605 );
1606 }
1607
1608 #[tokio::test]
1612 async fn lazy_session_provider_invalidate_clears_cache() {
1613 let _g = crate::tempo::test_env_mutex().lock().await;
1614 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 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 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 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 #[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 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 #[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 let mismatched = mk("0x20c0bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
1868 assert_eq!(pick_chain_needing_auth(&url, &[mismatched]), Some(4217));
1869
1870 let matched = mk("0x20c0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
1872 assert_eq!(pick_chain_needing_auth(&url, &[matched]), None);
1873
1874 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}