66//! refreshes a short-lived session JWT automatically. Because each CLI
77//! invocation is a fresh process, we persist that JWT to `tokens.toml` and
88//! re-seed it next time (see `crate::config` token cache), so a valid token
9- //! means no control-plane round trip.
9+ //! means no control-plane round trip. The cache is keyed by account id (all API
10+ //! keys for one account share a token); the account is resolved offline on a hit
11+ //! and learned via `account_info` only on a miss, where a mint already occurs.
1012//!
1113//! On a never-provisioned account the first call fails with "not enabled"; we
1214//! offer to enable (prompt on a TTY, `--yes` for scripts/agents, otherwise an
@@ -104,10 +106,8 @@ async fn run_list_networks(global: GlobalArgs) -> Result<(), CliError> {
104106 let config_path = global. resolve_config_path ( ) ;
105107 let networks_path = config:: networks_cache_path ( config_path. as_deref ( ) ) ;
106108 let token_path = config:: token_cache_path ( config_path. as_deref ( ) ) ;
107- let seed = match ( & token_path, resolve_key_quietly ( & global) ) {
108- ( Some ( p) , Some ( key) ) => config:: load_token ( p, & key) ,
109- _ => None ,
110- } ;
109+ let ( seed, _account_id) =
110+ load_cached_token ( & token_path, resolve_key_quietly ( & global) . as_deref ( ) ) ;
111111 let ( ctx, _api_key) = Ctx :: from_global_with_rpc_seed ( global, seed, None ) ?;
112112 let map = ensure_networks ( & ctx, networks_path. as_deref ( ) ) . await ?;
113113 emit_networks ( & ctx, & map)
@@ -134,13 +134,13 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> {
134134 let token_path = config:: token_cache_path ( config_path. as_deref ( ) ) ;
135135 let networks_path = config:: networks_cache_path ( config_path. as_deref ( ) ) ;
136136
137- // We don't know the API key until Ctx builds it, but the cache is keyed by
138- // the key's fingerprint. Resolve it once here for the load; Ctx re-resolves
139- // and returns it for the write-back (cheap, and keeps Ctx the source of truth).
140- let seed = match ( & token_path , resolve_key_quietly ( & global ) ) {
141- ( Some ( p ) , Some ( key ) ) => config :: load_token ( p , & key ) ,
142- _ => None ,
143- } ;
137+ // The cache is keyed by account id, resolved offline from the key's
138+ // fingerprint. Resolve the key once here for the load; Ctx re-resolves and
139+ // returns it for the write-back (cheap, and keeps Ctx the source of truth).
140+ // A known key yields both a seed token and its account id, so a cache hit
141+ // never needs a control-plane account lookup on write-back.
142+ let ( seed , mut account_id ) =
143+ load_cached_token ( & token_path , resolve_key_quietly ( & global ) . as_deref ( ) ) ;
144144
145145 let ( ctx, api_key) = Ctx :: from_global_with_rpc_seed ( global, seed, config_endpoint_url) ?;
146146
@@ -185,10 +185,13 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> {
185185 if disabled_per_status ( & ctx) . await {
186186 // The stale token points at the disabled endpoint. Drop it both
187187 // in memory (so this retry mints fresh) and on disk (so the next
188- // process does too) before enabling and retrying.
188+ // process does too) before enabling and retrying. Only this
189+ // account's entry is removed; other accounts' tokens and every
190+ // key mapping in the shared file are preserved. A stale token
191+ // implies a cache hit, so `account_id` is known here.
189192 ctx. sdk . rpc . clear_cached_token ( ) ;
190- if let Some ( p) = & token_path {
191- let _ = config:: delete_config ( p ) ;
193+ if let ( Some ( p) , Some ( id ) ) = ( & token_path, account_id ) {
194+ let _ = config:: delete_account_token ( p , id ) ;
192195 }
193196 maybe_enable ( & ctx) . await ?;
194197 call_after_enable ( & ctx, method, & params, args. network . clone ( ) ) . await ?
@@ -203,13 +206,26 @@ async fn run_call(args: CallArgs, global: GlobalArgs) -> Result<(), CliError> {
203206
204207 // Snapshot the (possibly refreshed) token and write it back. On the custom-URL
205208 // lane no token is minted, so `current_token()` is `None` and nothing is
206- // written — the existing cache is left untouched. Otherwise we always persist
207- // when a token is present: the write is an idempotent atomic replace, the
208- // token is short-lived, and re-writing an unchanged token is harmless.
209- // Best-effort — a cache write failure must not fail the call, which already
210- // succeeded; the next run simply re-mints.
209+ // written — the existing cache is left untouched. Otherwise we persist under
210+ // the account id: an idempotent atomic replace preserving other accounts'
211+ // entries. On a cache hit `account_id` is already known (no extra call); on a
212+ // miss (a new key that just minted) we learn it from `account_info()` once,
213+ // which is cheap relative to the mint that just happened. Best-effort — a
214+ // cache write failure must not fail the call, which already succeeded.
211215 if let ( Some ( p) , Some ( current) ) = ( & token_path, ctx. sdk . rpc . current_token ( ) ) {
212- let _ = config:: save_token ( p, & api_key, & current) ;
216+ if account_id. is_none ( ) {
217+ account_id = ctx
218+ . sdk
219+ . admin
220+ . account_info ( )
221+ . await
222+ . ok ( )
223+ . and_then ( |r| r. data )
224+ . map ( |a| a. id ) ;
225+ }
226+ if let Some ( id) = account_id {
227+ let _ = config:: save_token ( p, & api_key, id, & current) ;
228+ }
213229 }
214230
215231 emit_result ( & ctx, & result)
@@ -317,6 +333,27 @@ fn emit_networks(
317333 Ok ( ( ) )
318334}
319335
336+ /// Offline two-level cache load: resolve `key`'s account id from `[keys]`, then
337+ /// load that account's cached JWT. Returns `(seed, account_id)`. The account id
338+ /// is returned even when there's no token yet, so a subsequent write-back can
339+ /// reuse it without a control-plane `account_info` call. A cache miss (unknown
340+ /// key) yields `(None, None)`.
341+ fn load_cached_token (
342+ token_path : & Option < PathBuf > ,
343+ key : Option < & str > ,
344+ ) -> ( Option < quicknode_sdk:: CachedToken > , Option < i64 > ) {
345+ let ( Some ( p) , Some ( key) ) = ( token_path, key) else {
346+ return ( None , None ) ;
347+ } ;
348+ let Some ( account_id) = config:: account_for_key ( p, key) else {
349+ return ( None , None ) ;
350+ } ;
351+ (
352+ config:: load_token_for_account ( p, account_id) ,
353+ Some ( account_id) ,
354+ )
355+ }
356+
320357/// Resolve the API key without prompting, swallowing errors (the real
321358/// resolution + error happens in `Ctx::build`). Used only to scope the cache
322359/// load before `Ctx` is constructed.
0 commit comments