Skip to main content

foundry_common/provider/mpp/
transport.rs

1//! Foundry policy for the canonical MPP Alloy HTTP transport.
2
3use 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
33/// Keep high-fanout fork database reads from overwhelming paid RPC endpoints.
34const MAX_CONCURRENT_MPP_HTTP_REQUESTS: usize = 4;
35
36/// Open a channel with 0.02 tokens when the server does not suggest a deposit.
37const DEFAULT_MPP_SESSION_DEPOSIT: u128 = 20_000;
38
39/// Never let a paid RPC reserve more than one six-decimal token automatically.
40const MAX_MPP_SESSION_DEPOSIT: u128 = 1_000_000;
41
42/// The MPP transport used by Foundry's runtime transport builder.
43#[derive(Clone, Debug)]
44pub struct LazyMppHttpTransport(MppHttpTransport<LazyAccountsProvider>);
45
46impl LazyMppHttpTransport {
47    /// Create a transport that opens Tempo Accounts only after a paid challenge.
48    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    /// Return the underlying HTTP client.
58    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
114/// Build the canonical MPP WebSocket connector with Foundry's lazy Accounts
115/// provider.
116pub(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/// Lazily resolves a chain-bound MPP Charge provider from Tempo Accounts.
130///
131/// HTTP payment mechanics live in `alloy-transport-mpp`; this wrapper contains
132/// only Foundry CLI policy: account-store discovery, optional interactive login
133/// and funding, and session channel configuration.
134#[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
485/// Extract `(chainId, currency)` from a Tempo Charge or Session challenge.
486pub(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        // SAFETY: serialized with every other test that mutates TEMPO_HOME.
589        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        // SAFETY: serialized with every other test that mutates TEMPO_HOME.
596        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}