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    /// Create a provider for payments requested by `origin`.
151    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
486/// Extract `(chainId, currency)` from a Tempo Charge or Session challenge.
487pub(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        // SAFETY: serialized with every other test that mutates TEMPO_HOME.
590        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        // SAFETY: serialized with every other test that mutates TEMPO_HOME.
597        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}