@@ -65,7 +65,9 @@ pub struct Config {
6565 #[ serde( default = "default_vault_path" ) ]
6666 pub vault_path : PathBuf ,
6767 /// Legacy flat RPC endpoints (chain_id -> url). Used for backward compatibility.
68- #[ serde( default ) ]
68+ /// Empty by default when loading from disk — built-in defaults come from
69+ /// `Config::default_rpc()` at runtime and are not persisted.
70+ #[ serde( default , skip_serializing_if = "HashMap::is_empty" ) ]
6971 pub rpc : HashMap < String , String > ,
7072 /// New structured RPC config with profile support.
7173 /// When present, takes precedence over legacy `rpc` field for profile lookups.
@@ -144,9 +146,13 @@ impl Default for Config {
144146}
145147
146148impl Config {
147- /// Look up an RPC URL by chain identifier from legacy flat rpc map.
148- pub fn rpc_url ( & self , chain : & str ) -> Option < & str > {
149- self . rpc . get ( chain) . map ( |s| s. as_str ( ) )
149+ /// Look up an RPC URL by chain identifier.
150+ /// Checks: user-defined global rpc > built-in defaults.
151+ pub fn rpc_url ( & self , chain : & str ) -> Option < String > {
152+ self . rpc
153+ . get ( chain)
154+ . cloned ( )
155+ . or_else ( || Config :: default_rpc ( ) . get ( chain) . cloned ( ) )
150156 }
151157
152158 /// Returns the name of the active profile, if any.
@@ -209,6 +215,7 @@ impl Config {
209215 }
210216
211217 /// Remove a chain endpoint from a profile. Deletes profile if empty.
218+ /// Clears active_profile if the deleted profile was the active one.
212219 pub fn remove_profile_endpoint ( & mut self , profile_name : & str , chain : & str ) -> bool {
213220 let config = match self . rpc_config . as_mut ( ) {
214221 Some ( c) => c,
@@ -221,6 +228,9 @@ impl Config {
221228 let removed = profile. chains . remove ( chain) . is_some ( ) ;
222229 if removed && profile. chains . is_empty ( ) {
223230 config. profiles . remove ( profile_name) ;
231+ if config. active_profile . as_deref ( ) == Some ( profile_name) {
232+ config. active_profile = None ;
233+ }
224234 }
225235 removed
226236 }
@@ -265,7 +275,12 @@ impl Config {
265275
266276 /// Load config from a specific path, merging user overrides on top of defaults.
267277 pub fn load_or_default_from ( path : & std:: path:: Path ) -> Self {
268- let mut config = Config :: default ( ) ;
278+ let mut config = Config {
279+ // Start with empty rpc — built-in defaults come from `default_rpc()` at
280+ // runtime; this avoids baking them into the saved config file.
281+ rpc : HashMap :: new ( ) ,
282+ ..Config :: default ( )
283+ } ;
269284 if path. exists ( ) {
270285 if let Ok ( contents) = std:: fs:: read_to_string ( path) {
271286 if let Ok ( user_config) = serde_json:: from_str :: < Config > ( & contents) {
@@ -326,20 +341,26 @@ mod tests {
326341 #[ test]
327342 fn test_rpc_lookup_hit ( ) {
328343 let config = Config :: default ( ) ;
329- assert_eq ! ( config. rpc_url( "eip155:1" ) , Some ( "https://eth.llamarpc.com" ) ) ;
344+ assert_eq ! (
345+ config. rpc_url( "eip155:1" ) ,
346+ Some ( "https://eth.llamarpc.com" . to_string( ) )
347+ ) ;
330348 }
331349
332350 #[ test]
333351 fn test_default_rpc_endpoints ( ) {
334352 let config = Config :: default ( ) ;
335- assert_eq ! ( config. rpc_url( "eip155:1" ) , Some ( "https://eth.llamarpc.com" ) ) ;
353+ assert_eq ! (
354+ config. rpc_url( "eip155:1" ) ,
355+ Some ( "https://eth.llamarpc.com" . to_string( ) )
356+ ) ;
336357 assert_eq ! (
337358 config. rpc_url( "eip155:137" ) ,
338- Some ( "https://polygon-rpc.com" )
359+ Some ( "https://polygon-rpc.com" . to_string ( ) )
339360 ) ;
340361 assert_eq ! (
341362 config. rpc_url( "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" ) ,
342- Some ( "https://api.mainnet-beta.solana.com" )
363+ Some ( "https://api.mainnet-beta.solana.com" . to_string ( ) )
343364 ) ;
344365 }
345366
@@ -383,9 +404,13 @@ mod tests {
383404 #[ test]
384405 fn test_load_or_default_nonexistent ( ) {
385406 let config = Config :: load_or_default_from ( std:: path:: Path :: new ( "/nonexistent/config.json" ) ) ;
386- // Should have all default RPCs
387- assert_eq ! ( config. rpc. len( ) , 18 ) ;
388- assert_eq ! ( config. rpc_url( "eip155:1" ) , Some ( "https://eth.llamarpc.com" ) ) ;
407+ // rpc is empty (built-in defaults come from default_rpc() at runtime)
408+ assert ! ( config. rpc. is_empty( ) ) ;
409+ // rpc_url still resolves defaults via fallback
410+ assert_eq ! (
411+ config. rpc_url( "eip155:1" ) ,
412+ Some ( "https://eth.llamarpc.com" . to_string( ) )
413+ ) ;
389414 }
390415
391416 #[ test]
@@ -401,10 +426,13 @@ mod tests {
401426 std:: fs:: write ( & config_path, serde_json:: to_string ( & user_config) . unwrap ( ) ) . unwrap ( ) ;
402427
403428 let config = Config :: load_or_default_from ( & config_path) ;
404- assert_eq ! ( config. rpc_url( "eip155:1" ) , Some ( "https://custom-eth.rpc" ) ) ;
429+ assert_eq ! (
430+ config. rpc_url( "eip155:1" ) ,
431+ Some ( "https://custom-eth.rpc" . to_string( ) )
432+ ) ;
405433 assert_eq ! (
406434 config. rpc_url( "eip155:137" ) ,
407- Some ( "https://polygon-rpc.com" )
435+ Some ( "https://polygon-rpc.com" . to_string ( ) )
408436 ) ;
409437 assert_eq ! ( config. vault_path, PathBuf :: from( "/tmp/custom-vault" ) ) ;
410438 }
@@ -536,7 +564,7 @@ mod tests {
536564 // Defaults still present via legacy rpc
537565 assert_eq ! (
538566 config. rpc_url( "eip155:137" ) ,
539- Some ( "https://polygon-rpc.com" )
567+ Some ( "https://polygon-rpc.com" . to_string ( ) )
540568 ) ;
541569 }
542570
@@ -555,7 +583,10 @@ mod tests {
555583
556584 let config = Config :: load_or_default_from ( & config_path) ;
557585 // Old flat rpc is preserved
558- assert_eq ! ( config. rpc_url( "eip155:1" ) , Some ( "https://old-custom.eth" ) ) ;
586+ assert_eq ! (
587+ config. rpc_url( "eip155:1" ) ,
588+ Some ( "https://old-custom.eth" . to_string( ) )
589+ ) ;
559590 // rpc_config is None since old format doesn't have it
560591 assert ! ( config. rpc_config. is_none( ) ) ;
561592 }
@@ -589,7 +620,10 @@ mod tests {
589620 Some ( "https://new-profile.eth" . to_string( ) )
590621 ) ;
591622 // But legacy rpc is still accessible directly
592- assert_eq ! ( config. rpc_url( "eip155:1" ) , Some ( "https://legacy.eth" ) ) ;
623+ assert_eq ! (
624+ config. rpc_url( "eip155:1" ) ,
625+ Some ( "https://legacy.eth" . to_string( ) )
626+ ) ;
593627 }
594628
595629 #[ test]
@@ -632,6 +666,20 @@ mod tests {
632666 assert_eq ! ( config. active_profile( ) , None ) ;
633667 }
634668
669+ #[ test]
670+ fn test_remove_last_chain_clears_active ( ) {
671+ // Regression: removing the last chain from the active profile via
672+ // remove_profile_endpoint should clear active_profile.
673+ let mut config = Config :: default ( ) ;
674+ config. upsert_profile_endpoint ( "dev" , "eip155:1" , "https://dev-eth.example.com" . into ( ) ) ;
675+ config. set_active_profile ( Some ( "dev" . into ( ) ) ) ;
676+
677+ assert_eq ! ( config. active_profile( ) , Some ( "dev" ) ) ;
678+ assert ! ( config. remove_profile_endpoint( "dev" , "eip155:1" ) ) ;
679+ assert ! ( config. profile( "dev" ) . is_none( ) ) ;
680+ assert_eq ! ( config. active_profile( ) , None ) ;
681+ }
682+
635683 #[ test]
636684 fn test_delete_profile_nonexistent ( ) {
637685 let mut config = Config :: default ( ) ;
@@ -700,4 +748,48 @@ mod tests {
700748 let config = Config :: default ( ) ;
701749 assert ! ( config. profile_names( ) . collect:: <Vec <_>>( ) . is_empty( ) ) ;
702750 }
751+
752+ #[ test]
753+ fn test_save_config_does_not_bake_in_defaults ( ) {
754+ // Regression: saving a config with only profile-based RPC should NOT
755+ // write the built-in defaults into the rpc field.
756+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
757+ let config_path = dir. path ( ) . join ( "config.json" ) ;
758+
759+ // Build a config that only has a profile (no global rpc overrides)
760+ let mut config = Config :: load_or_default_from ( & config_path) ;
761+ config. upsert_profile_endpoint ( "dev" , "eip155:1" , "https://dev-eth.example.com" . into ( ) ) ;
762+ config. set_active_profile ( Some ( "dev" . into ( ) ) ) ;
763+
764+ // Serialize and write to disk (as CLI save does)
765+ let json = serde_json:: to_string ( & config) . unwrap ( ) ;
766+ std:: fs:: write ( & config_path, & json) . unwrap ( ) ;
767+
768+ // Read back as Value and verify rpc field is absent/empty
769+ let parsed: serde_json:: Value = serde_json:: from_str ( & json) . unwrap ( ) ;
770+
771+ // rpc field must be absent or empty — no baked-in defaults
772+ let rpc = parsed. get ( "rpc" ) ;
773+ assert ! (
774+ rpc. is_none( ) || rpc == Some ( & serde_json:: Value :: Object ( Default :: default ( ) ) ) ,
775+ "rpc field should be absent or empty, got: {:?}" ,
776+ rpc
777+ ) ;
778+
779+ // profiles should be present
780+ assert ! (
781+ parsed
782+ . get( "rpc_config" )
783+ . and_then( |c| c. get( "profiles" ) )
784+ . is_some( ) ,
785+ "profiles should be saved"
786+ ) ;
787+
788+ // Reload from disk and verify profile still works
789+ let reloaded = Config :: load_or_default_from ( & config_path) ;
790+ assert_eq ! (
791+ reloaded. profile_rpc_url( "eip155:1" ) ,
792+ Some ( "https://dev-eth.example.com" )
793+ ) ;
794+ }
703795}
0 commit comments