1use alloy_chains::Chain;
4use alloy_json_rpc::{RequestPacket, ResponsePacket, RpcError};
5use alloy_transport::{TransportError, TransportErrorKind, TransportFut};
6use alloy_transport_mpp::{MppHttpTransport, MppWsConnect};
7use mpp::{
8 MppError, PaymentErrorDetails,
9 client::{
10 PaymentContext, PaymentProvider, TempoAccountsProvider,
11 tempo::{
12 autoswap::{AutoswapConfig, DEFAULT_SLIPPAGE_BPS},
13 session::store::{SqliteChannelStore, SqliteChannelStoreOptions},
14 },
15 },
16 protocol::{
17 core::{PaymentChallenge, PaymentCredential},
18 intents::{ChargeRequest, SessionRequest},
19 },
20};
21use std::{
22 collections::HashMap,
23 env, fmt, io,
24 io::IsTerminal,
25 process::{Command, Stdio},
26 sync::{Arc, Mutex, MutexGuard, PoisonError},
27 task,
28};
29use tempo_alloy::accounts::{TempoAccountsError, TempoAccountsStore};
30use tower::Service;
31use url::Url;
32
33const MAX_CONCURRENT_MPP_HTTP_REQUESTS: usize = 4;
35
36const DEFAULT_MPP_SESSION_DEPOSIT: u128 = 20_000;
38
39const MAX_MPP_SESSION_DEPOSIT: u128 = 1_000_000;
41
42#[derive(Clone, Debug)]
44pub struct LazyMppHttpTransport(MppHttpTransport<LazyAccountsProvider>);
45
46impl LazyMppHttpTransport {
47 pub fn lazy(client: reqwest::Client, url: Url, headers: reqwest::header::HeaderMap) -> Self {
49 let provider = LazyAccountsProvider::new(url.to_string());
50 Self(
51 MppHttpTransport::new(client, url, provider)
52 .with_headers(headers)
53 .with_max_concurrent_requests(MAX_CONCURRENT_MPP_HTTP_REQUESTS),
54 )
55 }
56
57 pub const fn client(&self) -> &reqwest::Client {
59 self.0.client()
60 }
61}
62
63impl Service<RequestPacket> for LazyMppHttpTransport {
64 type Response = ResponsePacket;
65 type Error = TransportError;
66 type Future = TransportFut<'static>;
67
68 fn poll_ready(&mut self, cx: &mut task::Context<'_>) -> task::Poll<Result<(), Self::Error>> {
69 self.0.poll_ready(cx)
70 }
71
72 fn call(&mut self, request: RequestPacket) -> Self::Future {
73 let retry = request.clone();
74 let mut transport = self.0.clone();
75 let provider = self.0.payment_provider().clone();
76 Box::pin(async move {
77 match transport.call(request).await {
78 Err(error) => {
79 let Some(problem) = insufficient_balance_details(&error)
80 .filter(|problem| problem.problem_type.ends_with("/insufficient-balance"))
81 else {
82 return Err(error);
83 };
84 let context = provider.take_funding_context(problem.challenge_id.as_deref());
85 if run_interactive_tempo_fund(&context)
86 .await
87 .map_err(TransportErrorKind::custom)?
88 {
89 match transport.call(retry).await {
90 Err(error) => {
91 let Some(problem) =
92 insufficient_balance_details(&error).filter(|problem| {
93 problem.problem_type.ends_with("/insufficient-balance")
94 })
95 else {
96 return Err(error);
97 };
98 let context =
99 provider.take_funding_context(problem.challenge_id.as_deref());
100 Err(with_transport_funding_help(error, &context))
101 }
102 result => result,
103 }
104 } else {
105 Err(with_transport_funding_help(error, &context))
106 }
107 }
108 result => result,
109 }
110 })
111 }
112}
113
114pub(crate) fn lazy_mpp_ws_connect(url: &Url) -> MppWsConnect<LazyAccountsProvider> {
117 let mut origin = url.clone();
118 let http_scheme = match origin.scheme() {
119 "ws" => Some("http"),
120 "wss" => Some("https"),
121 _ => None,
122 };
123 if let Some(http_scheme) = http_scheme {
124 let _ = origin.set_scheme(http_scheme);
125 }
126 MppWsConnect::new(url.to_string(), LazyAccountsProvider::new(origin.to_string()))
127}
128
129#[derive(Clone)]
135pub struct LazyAccountsProvider {
136 inner: Arc<Mutex<HashMap<Option<u64>, TempoAccountsProvider>>>,
137 funding_by_challenge: Arc<Mutex<HashMap<String, FundingContext>>>,
138 origin: String,
139}
140
141impl fmt::Debug for LazyAccountsProvider {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 f.debug_struct("LazyAccountsProvider")
144 .field("origin", &redacted_url(&self.origin))
145 .finish_non_exhaustive()
146 }
147}
148
149impl LazyAccountsProvider {
150 pub fn new(origin: String) -> Self {
152 Self {
153 inner: Arc::new(Mutex::new(HashMap::new())),
154 funding_by_challenge: Arc::new(Mutex::new(HashMap::new())),
155 origin,
156 }
157 }
158
159 fn resolve(&self, chain_id: Option<u64>) -> Result<TempoAccountsProvider, MppError> {
160 let mut providers = lock_map(&self.inner);
161 if let Some(provider) = providers.get(&chain_id) {
162 return Ok(provider.clone());
163 }
164
165 let mut provider = TempoAccountsProvider::from_default_store().map_err(|error| {
166 MppError::InvalidConfig(format!(
167 "RPC endpoint returned HTTP 402 Payment Required, but the Tempo Accounts store \
168 could not provide a Charge wallet: {error}\n\nAuthorize an access key with:\n \
169 cast tempo login\n\nIn a headless environment, use:\n cast tempo login --no-browser"
170 ))
171 })?;
172 if let Some(chain_id) = chain_id {
173 provider = provider.with_expected_chain_id(chain_id);
174 }
175 let request_url =
176 Url::parse(&self.origin).map_err(|error| MppError::InvalidConfig(error.to_string()))?;
177 let store = SqliteChannelStore::open(SqliteChannelStoreOptions {
178 namespace: request_url.origin().ascii_serialization(),
179 path: None,
180 request_url: Some(redacted_url(&self.origin)),
181 })
182 .map_err(|error| {
183 MppError::InvalidConfig(format!("failed to open Tempo channel store: {error}"))
184 })?;
185 provider = provider
186 .with_autoswap(AutoswapConfig::new(
187 crate::tempo::PATH_USD_ADDRESS,
188 DEFAULT_SLIPPAGE_BPS,
189 ))
190 .with_session_store(Arc::new(store))
191 .with_session_default_deposit(DEFAULT_MPP_SESSION_DEPOSIT)
192 .with_session_top_up_amount(DEFAULT_MPP_SESSION_DEPOSIT)
193 .with_session_max_deposit(MAX_MPP_SESSION_DEPOSIT);
194 providers.insert(chain_id, provider.clone());
195 Ok(provider)
196 }
197
198 fn invalidate(&self) {
199 lock_map(&self.inner).clear();
200 }
201
202 fn funding_context(&self, challenge: &PaymentChallenge) -> FundingContext {
203 let (chain_id, token) = extract_challenge_chain_and_currency(challenge);
204 let context = FundingContext {
205 wallet_address: lock_map(&self.inner)
206 .values()
207 .next()
208 .and_then(|provider| provider.wallet().active_account().ok())
209 .or_else(|| {
210 TempoAccountsStore::try_open_default().ok().flatten()?.active_account().ok()
211 }),
212 token,
213 chain_id: chain_id.map(Chain::from_id),
214 };
215 let mut contexts = lock_map(&self.funding_by_challenge);
216 if contexts.len() >= 32
217 && !contexts.contains_key(&challenge.id)
218 && let Some(oldest) = contexts.keys().next().cloned()
219 {
220 contexts.remove(&oldest);
221 }
222 contexts.insert(challenge.id.clone(), context.clone());
223 context
224 }
225
226 fn take_funding_context(&self, challenge_id: Option<&str>) -> FundingContext {
227 let mut contexts = lock_map(&self.funding_by_challenge);
228 let context =
229 challenge_id.and_then(|challenge_id| contexts.remove(challenge_id)).or_else(|| {
230 (contexts.len() == 1)
231 .then(|| contexts.keys().next().cloned())
232 .flatten()
233 .and_then(|challenge_id| contexts.remove(&challenge_id))
234 });
235 context.unwrap_or_else(|| FundingContext {
236 wallet_address: TempoAccountsStore::try_open_default()
237 .ok()
238 .flatten()
239 .and_then(|store| store.active_account().ok()),
240 ..Default::default()
241 })
242 }
243
244 async fn needs_access_key(
245 &self,
246 challenge: &PaymentChallenge,
247 chain_id: u64,
248 ) -> Result<bool, MppError> {
249 match TempoAccountsStore::try_open_default() {
250 Ok(None) => Ok(true),
251 Ok(Some(_)) => {
252 let provider = self.resolve(Some(chain_id))?;
253 has_access_key_for_challenge(&provider, challenge, chain_id)
254 .await
255 .map(|has_access_key| !has_access_key)
256 }
257 Err(error) => Err(MppError::InvalidConfig(format!(
258 "failed to inspect Tempo Accounts store: {error}"
259 ))),
260 }
261 }
262}
263
264impl PaymentProvider for LazyAccountsProvider {
265 fn supports(&self, method: &str, intent: &str) -> bool {
266 method == "tempo" && matches!(intent, "session" | "charge")
267 }
268
269 async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
270 let (chain_id, _) = extract_challenge_chain_and_currency(challenge);
271 let provider = self.resolve(chain_id)?;
272 match provider.pay(challenge).await {
273 Ok(credential) => Ok(credential),
274 Err(error @ MppError::InsufficientBalance(_)) => {
275 let context = self.funding_context(challenge);
276 if run_interactive_tempo_fund(&context).await? {
277 provider.pay(challenge).await
278 } else {
279 Err(with_funding_help(error, &context))
280 }
281 }
282 Err(error) => Err(error),
283 }
284 }
285
286 async fn pay_with_context(
287 &self,
288 challenge: &PaymentChallenge,
289 context: PaymentContext,
290 ) -> Result<PaymentCredential, MppError> {
291 let (chain_id, _) = extract_challenge_chain_and_currency(challenge);
292 let provider = self.resolve(chain_id)?;
293 match provider.pay_with_context(challenge, context.clone()).await {
294 Ok(credential) => Ok(credential),
295 Err(error @ MppError::InsufficientBalance(_)) => {
296 let funding = self.funding_context(challenge);
297 if run_interactive_tempo_fund(&funding).await? {
298 provider.pay_with_context(challenge, context).await
299 } else {
300 Err(with_funding_help(error, &funding))
301 }
302 }
303 Err(error) => Err(error),
304 }
305 }
306
307 async fn prepare_http_payment_challenge(
308 &self,
309 challenge: &PaymentChallenge,
310 _context: PaymentContext,
311 ) -> Result<Option<PaymentChallenge>, MppError> {
312 self.funding_context(challenge);
313 let (Some(chain_id), _) = extract_challenge_chain_and_currency(challenge) else {
314 return Ok(Some(challenge.clone()));
315 };
316 if !interactive_login_allowed()
317 || !Url::parse(&self.origin)
318 .is_ok_and(|origin| crate::tempo::is_known_tempo_endpoint(&origin))
319 {
320 return Ok(Some(challenge.clone()));
321 }
322
323 if !self.needs_access_key(challenge, chain_id).await? {
324 return Ok(Some(challenge.clone()));
325 }
326
327 let config = crate::tempo::EnsureAccessKeyConfig::from_env(chain_id);
328 crate::tempo::ensure_access_key(config).await.map_err(|error| {
329 MppError::InvalidConfig(format!("Tempo access key authorization failed: {error}"))
330 })?;
331 self.invalidate();
332 Ok(None)
333 }
334
335 async fn commit_payment(
336 &self,
337 challenge: &PaymentChallenge,
338 credential: &PaymentCredential,
339 ) -> Result<(), MppError> {
340 lock_map(&self.funding_by_challenge).remove(&challenge.id);
341 let (chain_id, _) = extract_challenge_chain_and_currency(challenge);
342 self.resolve(chain_id)?.commit_payment(challenge, credential).await
343 }
344
345 async fn rollback_payment(
346 &self,
347 challenge: &PaymentChallenge,
348 credential: &PaymentCredential,
349 ) -> Result<(), MppError> {
350 let (chain_id, _) = extract_challenge_chain_and_currency(challenge);
351 self.resolve(chain_id)?.rollback_payment(challenge, credential).await
352 }
353
354 fn abandon_payment(&self, challenge: &PaymentChallenge, credential: &PaymentCredential) {
355 let (chain_id, _) = extract_challenge_chain_and_currency(challenge);
356 if let Ok(provider) = self.resolve(chain_id) {
357 provider.abandon_payment(challenge, credential);
358 }
359 }
360
361 fn accept_payment_header(&self) -> Option<String> {
362 Some("tempo/session, tempo/charge;q=0.5".to_owned())
363 }
364}
365
366async fn has_access_key_for_challenge(
367 provider: &TempoAccountsProvider,
368 challenge: &PaymentChallenge,
369 chain_id: u64,
370) -> Result<bool, MppError> {
371 if challenge.intent.as_str() == "charge" {
372 return provider.has_access_key_for_challenge(challenge).await;
373 }
374 match provider.wallet().clone().with_chain_id(chain_id).active_access_key() {
375 Ok(_) => Ok(true),
376 Err(TempoAccountsError::MissingAccessKey { .. }) => Ok(false),
377 Err(error) => Err(MppError::InvalidConfig(format!(
378 "failed to inspect Tempo Accounts access key: {error}"
379 ))),
380 }
381}
382
383#[derive(Clone, Debug, Default)]
384struct FundingContext {
385 wallet_address: Option<alloy_primitives::Address>,
386 token: Option<String>,
387 chain_id: Option<Chain>,
388}
389
390impl FundingContext {
391 fn help(&self) -> String {
392 let mut command = "tempo wallet fund".to_owned();
393 if let Some(address) = self.wallet_address {
394 command.push_str(&format!(" --address {address}"));
395 }
396 if let Some(chain) = self.chain_id.filter(|chain| chain.is_tempo()) {
397 command.push_str(&format!(" --network {chain}"));
398 }
399 let token = self
400 .token
401 .as_ref()
402 .map(|token| format!("Requested payment token: {token}\n\n"))
403 .unwrap_or_default();
404 format!(
405 "\n\nTempo wallet payment could not be funded for this paid RPC request.\n\n{token}\
406 Fund the wallet, then rerun the command:\n {command}\n\n\
407 If this CLI is running on a remote or headless host, use:\n {command} --no-browser"
408 )
409 }
410}
411
412fn with_funding_help(error: MppError, context: &FundingContext) -> MppError {
413 MppError::InsufficientBalance(Some(format!("{error}{}", context.help())))
414}
415
416fn insufficient_balance_details(error: &TransportError) -> Option<PaymentErrorDetails> {
417 let RpcError::Transport(kind) = error else {
418 return None;
419 };
420 let http = kind.as_http_error().filter(|http| http.status == 402)?;
421 let mut deserializer = serde_json::Deserializer::from_str(&http.body);
422 <PaymentErrorDetails as serde::Deserialize>::deserialize(&mut deserializer).ok()
423}
424
425fn with_transport_funding_help(error: TransportError, context: &FundingContext) -> TransportError {
426 match error {
427 RpcError::Transport(TransportErrorKind::HttpError(http)) => {
428 TransportErrorKind::http_error(http.status, format!("{}{}", http.body, context.help()))
429 }
430 error => error,
431 }
432}
433
434fn interactive_login_allowed() -> bool {
435 !cfg!(test) && env::var_os("CI").is_none() && io::stderr().is_terminal()
436}
437
438fn interactive_fund_allowed() -> bool {
439 if cfg!(test) || env::var_os("CI").is_some() {
440 return false;
441 }
442 if env::var("FOUNDRY_MPP_NO_AUTO_FUND").ok().is_some_and(|value| {
443 !(value == "0" || value.eq_ignore_ascii_case("false") || value.eq_ignore_ascii_case("off"))
444 }) {
445 return false;
446 }
447 io::stdin().is_terminal() && io::stderr().is_terminal()
448}
449
450async fn run_interactive_tempo_fund(context: &FundingContext) -> Result<bool, MppError> {
451 if !interactive_fund_allowed() {
452 return Ok(false);
453 }
454
455 let binary = env::var("TEMPO_BIN").unwrap_or_else(|_| "tempo".to_owned());
456 let mut args = vec!["wallet".to_owned(), "fund".to_owned()];
457 if let Some(address) = context.wallet_address {
458 args.push("--address".to_owned());
459 args.push(address.to_string());
460 }
461 if let Some(chain) = context.chain_id.filter(|chain| chain.is_tempo()) {
462 args.push("--network".to_owned());
463 args.push(chain.to_string());
464 }
465 let help = context.help();
466 let status = tokio::task::spawn_blocking(move || {
467 Command::new(binary)
468 .args(args)
469 .stdin(Stdio::inherit())
470 .stdout(Stdio::inherit())
471 .stderr(Stdio::inherit())
472 .status()
473 })
474 .await
475 .map_err(|error| MppError::InvalidConfig(format!("failed to join wallet fund: {error}{help}")))?
476 .map_err(|error| {
477 MppError::InvalidConfig(format!("failed to run wallet fund: {error}{help}"))
478 })?;
479 if status.success() {
480 Ok(true)
481 } else {
482 Err(MppError::InvalidConfig(format!("wallet fund exited with status {status}{help}")))
483 }
484}
485
486pub(super) fn extract_challenge_chain_and_currency(
488 challenge: &PaymentChallenge,
489) -> (Option<u64>, Option<String>) {
490 use mpp::protocol::methods::tempo::{TempoChargeExt, TempoSessionExt};
491
492 if challenge.method.as_str() != "tempo" {
493 return (None, None);
494 }
495 match challenge.intent.as_str() {
496 "charge" => challenge
497 .request
498 .decode::<ChargeRequest>()
499 .map(|request| (request.chain_id(), Some(request.currency)))
500 .unwrap_or_default(),
501 "session" => challenge
502 .request
503 .decode::<SessionRequest>()
504 .map(|request| (request.chain_id(), Some(request.currency)))
505 .unwrap_or_default(),
506 _ => (None, None),
507 }
508}
509
510fn lock_map<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
511 mutex.lock().unwrap_or_else(PoisonError::into_inner)
512}
513
514fn redacted_url(raw: &str) -> String {
515 let Ok(mut redacted) = Url::parse(raw) else {
516 return "<invalid>".to_owned();
517 };
518 let _ = redacted.set_username("");
519 let _ = redacted.set_password(None);
520 redacted.set_query(None);
521 redacted.to_string()
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use mpp::protocol::core::{Base64UrlJson, IntentName, MethodName};
528
529 fn challenge(method: &str, intent: &str) -> PaymentChallenge {
530 PaymentChallenge {
531 id: "test".to_owned(),
532 realm: "rpc.example".to_owned(),
533 method: MethodName::new(method),
534 intent: IntentName::new(intent),
535 request: Base64UrlJson::from_value(&serde_json::json!({
536 "amount": "1",
537 "currency": "0x20c0000000000000000000000000000000000000",
538 "recipient": "0x0000000000000000000000000000000000000001",
539 "methodDetails": {"chainId": 42431}
540 }))
541 .unwrap(),
542 expires: None,
543 description: None,
544 digest: None,
545 opaque: None,
546 }
547 }
548
549 #[test]
550 fn extracts_tempo_charge_routing() {
551 assert_eq!(
552 extract_challenge_chain_and_currency(&challenge("tempo", "charge")),
553 (Some(42431), Some("0x20c0000000000000000000000000000000000000".to_owned()))
554 );
555 assert_eq!(
556 extract_challenge_chain_and_currency(&challenge("tempo", "session")),
557 (Some(42431), Some("0x20c0000000000000000000000000000000000000".to_owned()))
558 );
559 assert_eq!(
560 extract_challenge_chain_and_currency(&challenge("stripe", "charge")),
561 (None, None)
562 );
563 }
564
565 #[test]
566 fn advertises_sessions_before_charges() {
567 let provider = LazyAccountsProvider::new("https://rpc.example".to_owned());
568 assert_eq!(
569 provider.accept_payment_header().as_deref(),
570 Some("tempo/session, tempo/charge;q=0.5")
571 );
572 }
573
574 #[test]
575 fn debug_redacts_origin_secrets() {
576 let provider = LazyAccountsProvider::new(
577 "https://user:password@example.com/rpc?token=secret".to_owned(),
578 );
579 let debug = format!("{provider:?}");
580 assert!(!debug.contains("password"));
581 assert!(!debug.contains("secret"));
582 assert!(debug.contains("https://example.com/rpc"));
583 }
584
585 #[tokio::test]
586 async fn missing_accounts_store_is_detected_before_provider_resolution() {
587 let _guard = crate::tempo::test_env_mutex().lock().await;
588 let directory = tempfile::tempdir().unwrap();
589 unsafe { env::set_var(crate::tempo::TEMPO_HOME_ENV, directory.path()) };
591
592 let provider = LazyAccountsProvider::new("https://rpc.mpp.tempo.xyz".to_owned());
593 assert!(provider.needs_access_key(&challenge("tempo", "charge"), 42431).await.unwrap());
594 assert!(lock_map(&provider.inner).is_empty());
595
596 unsafe { env::remove_var(crate::tempo::TEMPO_HOME_ENV) };
598 }
599
600 #[test]
601 fn recognizes_structured_insufficient_balance_with_http_diagnostics() {
602 let error = TransportErrorKind::http_error(
603 402,
604 concat!(
605 r#"{"type":"https://paymentauth.org/problems/insufficient-balance","#,
606 r#""title":"Insufficient Balance","status":402,"detail":"fund me"}"#,
607 "\n\nHTTP diagnostics:\nserver: test"
608 )
609 .to_owned(),
610 );
611 let problem = insufficient_balance_details(&error).unwrap();
612 assert!(problem.problem_type.ends_with("/insufficient-balance"));
613 }
614
615 #[test]
616 fn appends_funding_help_to_structured_402() {
617 let error = TransportErrorKind::http_error(
618 402,
619 r#"{"type":"https://paymentauth.org/problems/insufficient-balance"}"#.to_owned(),
620 );
621 let error = with_transport_funding_help(
622 error,
623 &FundingContext {
624 token: Some("0x20c0000000000000000000000000000000000000".to_owned()),
625 chain_id: Some(Chain::from_id(42431)),
626 ..Default::default()
627 },
628 );
629 let RpcError::Transport(kind) = error else { panic!("expected transport error") };
630 let http = kind.as_http_error().expect("expected HTTP error");
631 assert_eq!(http.status, 402);
632 assert!(http.body.contains("insufficient-balance"));
633 assert!(http.body.contains("Requested payment token"));
634 assert!(http.body.contains("tempo wallet fund"));
635 }
636
637 #[test]
638 fn funding_contexts_are_selected_by_challenge_id() {
639 let provider = LazyAccountsProvider::new("https://rpc.mpp.tempo.xyz".to_owned());
640 let first = challenge("tempo", "charge");
641 let mut second = challenge("tempo", "charge");
642 second.id = "second".to_owned();
643 second.request = Base64UrlJson::from_value(&serde_json::json!({
644 "amount": "1",
645 "currency": "0x20c0000000000000000000000000000000000001",
646 "recipient": "0x0000000000000000000000000000000000000001",
647 "methodDetails": {"chainId": 4217}
648 }))
649 .unwrap();
650
651 provider.funding_context(&first);
652 provider.funding_context(&second);
653 let second_context = provider.take_funding_context(Some("second"));
654 let first_context = provider.take_funding_context(Some("test"));
655
656 assert_eq!(second_context.chain_id, Some(Chain::from_id(4217)));
657 assert_eq!(
658 second_context.token.as_deref(),
659 Some("0x20c0000000000000000000000000000000000001")
660 );
661 assert_eq!(first_context.chain_id, Some(Chain::from_id(42431)));
662 assert_eq!(
663 first_context.token.as_deref(),
664 Some("0x20c0000000000000000000000000000000000000")
665 );
666 }
667}