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