Skip to main content

foundry_common/tempo/
auth.rs

1//! Tempo wallet device-code authorization flow.
2//!
3//! Implements the CLI side of the tempoxyz/accounts `cli-auth` device-code
4//! protocol: generates a local secp256k1 access key, creates a PKCE-protected
5//! device code, opens `wallet.tempo.xyz/cli-auth?code=<CODE>` in the browser,
6//! polls until the user authorizes the key on their passkey wallet, and writes
7//! the resulting access key to the Tempo Accounts `store.json`.
8
9use crate::tempo::decode_key_authorization;
10use alloy_primitives::{Address, B256, hex};
11use alloy_signer_local::PrivateKeySigner;
12use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
13use eyre::Result;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::{
17    env,
18    sync::LazyLock,
19    time::{Duration, Instant},
20};
21use tempo_alloy::accounts::{TempoAccountsKeyAuthorization, TempoAccountsStore};
22use tempo_primitives::transaction::{SignatureType, SignedKeyAuthorization};
23use tokio::sync::Mutex;
24
25#[cfg(any(unix, windows))]
26use std::process::Command;
27
28/// Default device-code service URL (production wallet.tempo.xyz).
29const DEFAULT_CLI_AUTH_URL: &str = "https://wallet.tempo.xyz/cli-auth";
30
31/// Env var to override the device-code service URL (for tests / staging).
32const TEMPO_CLI_AUTH_URL_ENV: &str = "TEMPO_CLI_AUTH_URL";
33
34const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2);
35const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
36
37/// Per-process serialization of concurrent `ensure_access_key` calls.
38///
39/// Prevents two `cast` invocations in the same process from racing two browser
40/// popups for the same chain.
41static AUTH_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
42
43/// Configuration for [`ensure_access_key`].
44#[derive(Clone, Debug)]
45pub struct EnsureAccessKeyConfig {
46    /// Chain ID the access key is being authorized for.
47    pub chain_id: u64,
48    /// Device-code service base URL. Defaults to [`DEFAULT_CLI_AUTH_URL`].
49    pub(crate) service_url: String,
50    /// Poll interval.
51    pub(crate) poll_interval: Duration,
52    /// Total timeout for the authorization flow.
53    pub(crate) timeout: Duration,
54    /// If `true`, print the authorization URL to stderr instead of opening a
55    /// browser.
56    pub no_browser: bool,
57}
58
59impl EnsureAccessKeyConfig {
60    /// Build a config from the environment for the given chain.
61    ///
62    /// `no_browser` defaults to `true` under `CI`; callers (e.g. `cast tempo
63    /// login --no-browser`) may override it.
64    pub fn from_env(chain_id: u64) -> Self {
65        Self {
66            chain_id,
67            service_url: env::var(TEMPO_CLI_AUTH_URL_ENV)
68                .unwrap_or_else(|_| DEFAULT_CLI_AUTH_URL.to_string()),
69            poll_interval: DEFAULT_POLL_INTERVAL,
70            timeout: DEFAULT_TIMEOUT,
71            no_browser: env::var_os("CI").is_some(),
72        }
73    }
74}
75
76/// Open `url` via the OS default browser handler. On platforms without a known
77/// opener, this is a no-op (the URL is still printed by [`ensure_access_key`]).
78fn open_browser(_url: &str) {
79    #[cfg(target_os = "macos")]
80    let _ = Command::new("open").arg(_url).spawn();
81    #[cfg(target_os = "windows")]
82    let _ = Command::new("cmd").args(["/c", "start", "", _url]).spawn();
83    #[cfg(all(unix, not(target_os = "macos")))]
84    let _ = Command::new("xdg-open").arg(_url).spawn();
85}
86
87/// Result of [`ensure_access_key`].
88#[derive(Debug, Clone)]
89pub struct AccessKeyOutcome {
90    pub wallet_address: Address,
91    pub key_address: Address,
92    pub chain_id: u64,
93}
94
95/// Run the device-code flow, persist the resulting key to `store.json`, and
96/// return the new entry's identifying fields.
97pub async fn ensure_access_key(cfg: EnsureAccessKeyConfig) -> Result<AccessKeyOutcome> {
98    let _guard = AUTH_LOCK.lock().await;
99
100    let signer = PrivateKeySigner::random();
101    let key_address = signer.address();
102    // The server requires uncompressed SEC1 (65-byte `0x04 || X || Y`); the
103    // default `to_sec1_bytes()` would emit the compressed 33-byte form.
104    let pub_key_hex = format!(
105        "0x{}",
106        hex::encode(signer.credential().verifying_key().to_encoded_point(false).as_bytes()),
107    );
108
109    let code_verifier = random_code_verifier();
110    let client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?;
111    let service = cfg.service_url.trim_end_matches('/');
112
113    let create_req = CreateCodeRequest {
114        chain_id: cfg.chain_id,
115        code_challenge: sha256_b64url(&code_verifier),
116        key_type: "secp256k1",
117        pub_key: pub_key_hex,
118    };
119    let code = create_code_with_retry(&client, service, &create_req, cfg.timeout).await?;
120
121    let browser_url = format!("{service}?code={code}");
122    if cfg.no_browser {
123        let _ = crate::sh_eprintln!("Open this URL to authorize: {browser_url}");
124    } else {
125        let _ = crate::sh_eprintln!(
126            "Opening wallet.tempo to authorize an access key…\n  {browser_url}"
127        );
128        open_browser(&browser_url);
129    }
130
131    let poll = PollRequest { code_verifier };
132    let started = Instant::now();
133    loop {
134        // Retry transient network/5xx/429 failures within `cfg.timeout`.
135        let send_res = client.post(format!("{service}/poll/{code}")).json(&poll).send().await;
136
137        let resp = match send_res {
138            Ok(r) => r,
139            Err(e) if is_transient_error(&e) && started.elapsed() < cfg.timeout => {
140                tracing::debug!(error = %e, "transient error polling device code, retrying");
141                tokio::time::sleep(cfg.poll_interval).await;
142                continue;
143            }
144            Err(e) => return Err(e.into()),
145        };
146
147        let status = resp.status();
148        if !status.is_success() {
149            if is_transient_status(status) && started.elapsed() < cfg.timeout {
150                tracing::debug!(%status, "transient HTTP status polling device code, retrying");
151                tokio::time::sleep(cfg.poll_interval).await;
152                continue;
153            }
154            let body = resp.text().await.unwrap_or_default();
155            eyre::bail!("device-code poll failed ({status}): {body}");
156        }
157
158        let body: PollResponse = resp.json().await?;
159        match body {
160            PollResponse::Pending => {
161                if started.elapsed() > cfg.timeout {
162                    eyre::bail!("timed out waiting for wallet authorization (code {code})");
163                }
164                tokio::time::sleep(cfg.poll_interval).await;
165            }
166            PollResponse::Expired => {
167                eyre::bail!("device code {code} expired before authorization");
168            }
169            PollResponse::Authorized { account_address, key_authorization } => {
170                let key_authorization = key_authorization.ok_or_else(|| {
171                    eyre::eyre!("wallet authorized response missing key_authorization")
172                })?;
173                let signed = key_authorization.into_signed()?;
174                // Reject mismatches before persisting — an unusable store
175                // entry would silently break the next 402 retry.
176                if signed.authorization.key_id != key_address {
177                    eyre::bail!(
178                        "wallet authorized key {} but the locally generated key is {}",
179                        signed.authorization.key_id,
180                        key_address,
181                    );
182                }
183                if signed.authorization.chain_id != cfg.chain_id {
184                    eyre::bail!(
185                        "wallet authorized chain {} but {} was requested",
186                        signed.authorization.chain_id,
187                        cfg.chain_id,
188                    );
189                }
190                if signed.authorization.key_type != SignatureType::Secp256k1 {
191                    eyre::bail!(
192                        "wallet returned keyType {:?} but secp256k1 was requested",
193                        signed.authorization.key_type,
194                    );
195                }
196                // A 402 access key is a limited key; an admin key is never valid here.
197                if signed.authorization.is_admin() {
198                    eyre::bail!(
199                        "wallet returned an admin key authorization, expected a limited access key"
200                    );
201                }
202                // A T6 account-bound authorization must target the authorizing account.
203                if let Some(account) = signed.authorization.account
204                    && account != account_address
205                {
206                    eyre::bail!(
207                        "wallet authorized account {account} but the authorizing account is {account_address}",
208                    );
209                }
210                let chain_id = signed.authorization.chain_id;
211                TempoAccountsStore::default_path()?.upsert_secp256k1_access_key(
212                    account_address,
213                    &signer,
214                    &signed,
215                )?;
216                return Ok(AccessKeyOutcome {
217                    wallet_address: account_address,
218                    key_address,
219                    chain_id,
220                });
221            }
222        }
223    }
224}
225
226fn is_transient_error(err: &reqwest::Error) -> bool {
227    err.is_timeout() || err.is_connect() || err.is_request()
228}
229
230fn is_transient_status(status: reqwest::StatusCode) -> bool {
231    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
232}
233
234/// POST `/code` with exponential backoff on transient errors, bounded by `timeout`.
235async fn create_code_with_retry(
236    client: &reqwest::Client,
237    service: &str,
238    req: &CreateCodeRequest,
239    timeout: Duration,
240) -> Result<String> {
241    let started = Instant::now();
242    let mut backoff = Duration::from_millis(500);
243    loop {
244        let send_res = client.post(format!("{service}/code")).json(req).send().await;
245
246        match send_res {
247            Ok(resp) => {
248                let status = resp.status();
249                if status.is_success() {
250                    let CreateCodeResponse { code } = resp.json().await?;
251                    return Ok(code);
252                }
253                if is_transient_status(status) && started.elapsed() < timeout {
254                    tracing::debug!(%status, "transient HTTP status creating device code, retrying");
255                    tokio::time::sleep(backoff).await;
256                    backoff = (backoff * 2).min(Duration::from_secs(5));
257                    continue;
258                }
259                let body = resp.text().await.unwrap_or_default();
260                eyre::bail!("device-code create failed ({status}): {body}");
261            }
262            Err(e) if is_transient_error(&e) && started.elapsed() < timeout => {
263                tracing::debug!(error = %e, "transient error creating device code, retrying");
264                tokio::time::sleep(backoff).await;
265                backoff = (backoff * 2).min(Duration::from_secs(5));
266            }
267            Err(e) => return Err(e.into()),
268        }
269    }
270}
271
272fn random_code_verifier() -> String {
273    let bytes = B256::random();
274    URL_SAFE_NO_PAD.encode(bytes.as_slice())
275}
276
277fn sha256_b64url(input: &str) -> String {
278    let digest = Sha256::digest(input.as_bytes());
279    URL_SAFE_NO_PAD.encode(digest)
280}
281
282#[derive(Serialize)]
283#[serde(rename_all = "camelCase")]
284struct CreateCodeRequest {
285    /// `0x`-hex per the SDK schema (server accepts hex string or bigint, not a plain JSON number).
286    #[serde(serialize_with = "serialize_u64_hex")]
287    chain_id: u64,
288    code_challenge: String,
289    key_type: &'static str,
290    pub_key: String,
291}
292
293fn serialize_u64_hex<S: serde::Serializer>(v: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
294    s.serialize_str(&format!("0x{v:x}"))
295}
296
297#[derive(Deserialize)]
298struct CreateCodeResponse {
299    code: String,
300}
301
302#[derive(Serialize)]
303#[serde(rename_all = "camelCase")]
304struct PollRequest {
305    code_verifier: String,
306}
307
308/// Matches `tempoxyz/wallet` poll response shape.
309#[derive(Deserialize)]
310#[serde(tag = "status", rename_all = "lowercase")]
311enum PollResponse {
312    Pending,
313    Expired,
314    Authorized {
315        #[serde(rename = "accountAddress", alias = "account_address")]
316        account_address: Address,
317        #[serde(rename = "keyAuthorization", alias = "key_authorization", default)]
318        key_authorization: Option<PollKeyAuthorization>,
319    },
320}
321
322#[derive(Deserialize)]
323#[serde(untagged)]
324enum PollKeyAuthorization {
325    Accounts(Box<TempoAccountsKeyAuthorization>),
326    Legacy(String),
327}
328
329impl PollKeyAuthorization {
330    fn into_signed(self) -> Result<SignedKeyAuthorization> {
331        match self {
332            Self::Accounts(authorization) => Ok(authorization.into_signed()),
333            Self::Legacy(encoded) => decode_key_authorization(&encoded),
334        }
335    }
336}
337
338/// Returns `true` if `url`'s host is `tempo.xyz` or a subdomain of it.
339pub(crate) fn is_known_tempo_endpoint(url: &url::Url) -> bool {
340    url.host_str().is_some_and(|host| host == "tempo.xyz" || host.ends_with(".tempo.xyz"))
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use crate::tempo::{TEMPO_HOME_ENV, read_tempo_accounts_store, test_env_mutex};
347    use axum::{Json, Router, extract::State, routing::post};
348    use std::sync::{Arc, Mutex};
349
350    #[test]
351    fn pkce_challenge_matches_sdk_format() {
352        // Vector from RFC 7636 §4.2.
353        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
354        let challenge = sha256_b64url(verifier);
355        assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
356    }
357
358    /// Recover the EOA from a SEC1-encoded public key (compressed or
359    /// uncompressed).
360    fn address_from_sec1_hex(s: &str) -> Address {
361        let stripped = s.strip_prefix("0x").unwrap_or(s);
362        let bytes = hex::decode(stripped).expect("valid hex");
363        let vk = k256::ecdsa::VerifyingKey::from_sec1_bytes(&bytes).expect("valid SEC1 pubkey");
364        Address::from_public_key(&vk)
365    }
366
367    /// Shape of the `keyAuthorization` the mock `/poll` returns.
368    #[derive(Clone, Copy, Default)]
369    struct MockAuthShape {
370        /// Return a T6 admin key authorization.
371        admin: bool,
372        /// Bind the authorization to this account.
373        account: Option<Address>,
374    }
375
376    #[derive(Clone)]
377    struct MockState {
378        wallet: Arc<Mutex<Option<Address>>>,
379        /// Derived from the `pubKey` posted to `/code` so `/poll` can echo
380        /// back a matching `keyId`, like a real wallet would.
381        key_id: Arc<Mutex<Option<Address>>>,
382        /// Chain ID the mock `/poll` returns in `keyAuthorization`.
383        poll_chain_id: u64,
384        /// Shape of the returned authorization, for exercising rejection paths.
385        shape: MockAuthShape,
386        /// Emit the current Tempo Accounts SDK response instead of the legacy
387        /// RLP compatibility response.
388        current_wire: bool,
389    }
390
391    async fn create_code_handler(
392        State(state): State<MockState>,
393        Json(body): Json<serde_json::Value>,
394    ) -> Json<serde_json::Value> {
395        // Sanity: required fields present and chainId is a 0x-hex string,
396        // matching the SDK wire format the live server enforces.
397        let pub_key = body
398            .get("pubKey")
399            .and_then(|v| v.as_str())
400            .unwrap_or_else(|| panic!("pubKey missing: {body}"));
401        assert!(body.get("codeChallenge").is_some(), "codeChallenge missing: {body}");
402        let chain_id = body.get("chainId").unwrap_or_else(|| panic!("chainId missing: {body}"));
403        let chain_str = chain_id
404            .as_str()
405            .unwrap_or_else(|| panic!("chainId must be string, got {chain_id}: {body}"));
406        assert!(chain_str.starts_with("0x"), "chainId must be 0x-hex, got {chain_str}");
407        let wallet: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
408        *state.wallet.lock().unwrap() = Some(wallet);
409        *state.key_id.lock().unwrap() = Some(address_from_sec1_hex(pub_key));
410        Json(serde_json::json!({ "code": "ABCDEFGH" }))
411    }
412
413    /// Build the RLP-hex `SignedKeyAuthorization` blob the live server returns
414    /// in the `key_authorization` field.
415    fn signed_key_auth_hex(
416        chain_id: u64,
417        key_id: Address,
418        expiry: u64,
419        shape: MockAuthShape,
420    ) -> String {
421        use alloy_rlp::Encodable;
422        use tempo_primitives::transaction::{KeyAuthorization, PrimitiveSignature};
423        let mut auth = KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, key_id);
424        if shape.admin {
425            // An admin authorization carries no expiry; bind it to its account (or zero, which the
426            // `is_admin` check rejects before the account is even inspected).
427            auth = auth.into_admin(shape.account.unwrap_or(Address::ZERO));
428        } else {
429            auth = auth.with_expiry(expiry);
430            if let Some(account) = shape.account {
431                auth = auth.with_account(account);
432            }
433        }
434        let sig: PrimitiveSignature = serde_json::from_value(serde_json::json!({
435            "type": "secp256k1", "r": "0x0", "s": "0x0", "yParity": 0
436        }))
437        .unwrap();
438        let signed = auth.into_signed(sig);
439        let mut buf = Vec::new();
440        signed.encode(&mut buf);
441        format!("0x{}", hex::encode(buf))
442    }
443
444    async fn poll_handler(State(state): State<MockState>) -> Json<serde_json::Value> {
445        let wallet = state.wallet.lock().unwrap().expect("create_code must be called first");
446        let key_id = state.key_id.lock().unwrap().expect("create_code must be called first");
447        if state.current_wire {
448            Json(serde_json::json!({
449                "status": "authorized",
450                "accountAddress": wallet,
451                "keyAuthorization": {
452                    "address": key_id,
453                    "chainId": state.poll_chain_id,
454                    "expiry": 9_999_999_999u64,
455                    "keyId": key_id,
456                    "keyType": "secp256k1",
457                    "limits": [],
458                    "signature": {
459                        "type": "secp256k1",
460                        "r": "0x0",
461                        "s": "0x0",
462                        "yParity": 0,
463                    },
464                },
465            }))
466        } else {
467            Json(serde_json::json!({
468                "status": "authorized",
469                "account_address": wallet,
470                "key_authorization":
471                    signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999, state.shape),
472            }))
473        }
474    }
475
476    /// Spawn a mock wallet.tempo server whose `/poll` echoes `poll_chain_id`.
477    async fn spawn_mock_wallet(poll_chain_id: u64) -> (String, tokio::task::JoinHandle<()>) {
478        spawn_mock_wallet_inner(poll_chain_id, MockAuthShape::default(), true).await
479    }
480
481    /// Spawn a mock wallet.tempo server with a custom authorization shape.
482    async fn spawn_mock_wallet_with(
483        poll_chain_id: u64,
484        shape: MockAuthShape,
485    ) -> (String, tokio::task::JoinHandle<()>) {
486        spawn_mock_wallet_inner(poll_chain_id, shape, false).await
487    }
488
489    async fn spawn_mock_wallet_inner(
490        poll_chain_id: u64,
491        shape: MockAuthShape,
492        current_wire: bool,
493    ) -> (String, tokio::task::JoinHandle<()>) {
494        let app = Router::new()
495            .route("/code", post(create_code_handler))
496            .route("/poll/{code}", post(poll_handler))
497            .with_state(MockState {
498                wallet: Arc::default(),
499                key_id: Arc::default(),
500                poll_chain_id,
501                shape,
502                current_wire,
503            });
504
505        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
506        let addr = listener.local_addr().unwrap();
507        let handle = tokio::spawn(async move {
508            axum::serve(listener, app).await.unwrap();
509        });
510        (format!("http://{addr}"), handle)
511    }
512
513    fn test_cfg(service_url: String) -> EnsureAccessKeyConfig {
514        EnsureAccessKeyConfig {
515            chain_id: 4217,
516            service_url,
517            poll_interval: Duration::from_millis(10),
518            timeout: Duration::from_secs(2),
519            no_browser: true,
520        }
521    }
522
523    #[tokio::test(flavor = "multi_thread")]
524    async fn ensure_access_key_happy_path_writes_accounts_store() {
525        // SAFETY: serialized with other tests that mutate TEMPO_HOME.
526        let _g = test_env_mutex().lock().await;
527        let tmp = tempfile::tempdir().unwrap();
528        unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
529
530        let (service_url, server) = spawn_mock_wallet(4217).await;
531        let outcome = ensure_access_key(test_cfg(service_url)).await.unwrap();
532
533        let expected_wallet: Address =
534            "0x0000000000000000000000000000000000000042".parse().unwrap();
535        assert_eq!(outcome.chain_id, 4217);
536        assert_eq!(outcome.wallet_address, expected_wallet);
537
538        let file = read_tempo_accounts_store().expect("store.json written");
539        assert_eq!(file.keys.len(), 1);
540        let entry = &file.keys[0];
541        assert_eq!(entry.wallet_address, outcome.wallet_address);
542        assert_eq!(entry.key_address, outcome.key_address);
543        assert_eq!(entry.chain_id, 4217);
544        assert_eq!(entry.expiry, Some(9_999_999_999));
545        let decoded = entry.key_authorization.as_ref().expect("pending authorization");
546        assert_eq!(decoded.authorization.chain_id, 4217);
547
548        server.abort();
549        unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
550    }
551
552    #[tokio::test(flavor = "multi_thread")]
553    async fn ensure_access_key_rejects_wrong_chain_id() {
554        // Wallet returns chain 99999 but client requested 4217 → must reject
555        // and persist nothing, else discovery would later fail to find a key
556        // for the requested chain.
557        let _g = test_env_mutex().lock().await;
558        let tmp = tempfile::tempdir().unwrap();
559        unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
560
561        let (service_url, server) = spawn_mock_wallet(99999).await;
562        let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
563        assert!(
564            err.to_string().contains("wallet authorized chain 99999 but 4217 was requested"),
565            "expected chain mismatch error, got: {err}"
566        );
567        assert!(read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()));
568
569        server.abort();
570        unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
571    }
572
573    #[tokio::test(flavor = "multi_thread")]
574    async fn ensure_access_key_rejects_admin_authorization() {
575        // An admin key authorization from the wallet must be rejected before store.json is written.
576        let _g = test_env_mutex().lock().await;
577        let tmp = tempfile::tempdir().unwrap();
578        unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
579
580        // Bind the admin auth to the mock wallet account (0x..42) so it is rejected purely for
581        // being an admin key, not for an account mismatch.
582        let account: Address = "0x0000000000000000000000000000000000000042".parse().unwrap();
583        let shape = MockAuthShape { admin: true, account: Some(account) };
584        let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
585
586        let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
587        assert!(
588            err.to_string().contains("admin key authorization"),
589            "expected admin-key rejection, got: {err}"
590        );
591        assert!(
592            read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()),
593            "an admin authorization must not be persisted to store.json"
594        );
595
596        server.abort();
597        unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
598    }
599
600    #[tokio::test(flavor = "multi_thread")]
601    async fn ensure_access_key_rejects_cross_account_binding() {
602        // An authorization bound to an account other than the authorizing one must be rejected
603        // before store.json is written.
604        let _g = test_env_mutex().lock().await;
605        let tmp = tempfile::tempdir().unwrap();
606        unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) };
607
608        // The mock authorizes account 0x..42 but binds the authorization to 0x..dead.
609        let other: Address = "0x000000000000000000000000000000000000dead".parse().unwrap();
610        let shape = MockAuthShape { admin: false, account: Some(other) };
611        let (service_url, server) = spawn_mock_wallet_with(4217, shape).await;
612
613        let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err();
614        assert!(
615            err.to_string().contains("wallet authorized account"),
616            "expected cross-account rejection, got: {err}"
617        );
618        assert!(
619            read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()),
620            "a cross-account authorization must not be persisted to store.json"
621        );
622
623        server.abort();
624        unsafe { std::env::remove_var(TEMPO_HOME_ENV) };
625    }
626}