11use clap:: { Parser , Subcommand } ;
22use serde:: Serialize ;
33
4- // ── Key resolution ────────────────────────────────────────────────────────────
4+ // ── Network profile management ────────────────────────────────────────────────
5+
6+ #[ derive( Serialize , serde:: Deserialize , Clone , Debug ) ]
7+ struct NetworkProfile {
8+ name : String ,
9+ rpc_url : String ,
10+ network_passphrase : String ,
11+ horizon_url : Option < String > ,
12+ #[ serde( default ) ]
13+ is_default : bool ,
14+ }
15+
16+ fn networks_path ( ) -> std:: path:: PathBuf {
17+ let dir = dirs_home ( ) . join ( ".anchorkit" ) ;
18+ std:: fs:: create_dir_all ( & dir) . ok ( ) ;
19+ dir. join ( "networks.json" )
20+ }
21+
22+ fn dirs_home ( ) -> std:: path:: PathBuf {
23+ std:: env:: var ( "HOME" )
24+ . map ( std:: path:: PathBuf :: from)
25+ . unwrap_or_else ( |_| std:: path:: PathBuf :: from ( "." ) )
26+ }
27+
28+ fn load_network_profiles ( ) -> Vec < NetworkProfile > {
29+ let path = networks_path ( ) ;
30+ if !path. exists ( ) { return Vec :: new ( ) ; }
31+ let content = std:: fs:: read_to_string ( & path) . unwrap_or_default ( ) ;
32+ serde_json:: from_str ( & content) . unwrap_or_default ( )
33+ }
34+
35+ fn save_network_profiles ( profiles : & [ NetworkProfile ] ) {
36+ let path = networks_path ( ) ;
37+ let json = serde_json:: to_string_pretty ( profiles) . unwrap_or_default ( ) ;
38+ std:: fs:: write ( path, json) . ok ( ) ;
39+ }
40+
41+ fn find_profile < ' a > ( profiles : & ' a [ NetworkProfile ] , name : & str ) -> Option < & ' a NetworkProfile > {
42+ profiles. iter ( ) . find ( |p| p. name == name)
43+ }
44+
45+ fn rpc_url_for ( network : & str ) -> String {
46+ let profiles = load_network_profiles ( ) ;
47+ if let Some ( p) = find_profile ( & profiles, network) {
48+ return p. rpc_url . clone ( ) ;
49+ }
50+ rpc_url ( network) . to_string ( )
51+ }
52+
53+ fn passphrase_for ( network : & str ) -> String {
54+ let profiles = load_network_profiles ( ) ;
55+ if let Some ( p) = find_profile ( & profiles, network) {
56+ return p. network_passphrase . clone ( ) ;
57+ }
58+ passphrase ( network) . to_string ( )
59+ }
60+
61+ fn default_network ( ) -> String {
62+ let profiles = load_network_profiles ( ) ;
63+ profiles. iter ( )
64+ . find ( |p| p. is_default )
65+ . map ( |p| p. name . clone ( ) )
66+ . unwrap_or_else ( || "testnet" . to_string ( ) )
67+ }
68+
69+
570
671/// Resolve the signing source from flags or environment.
772/// Priority: --secret-key > ANCHOR_ADMIN_SECRET > --keypair-file
@@ -53,12 +118,14 @@ fn stellar_invoke(
53118 network : & str ,
54119 fn_args : & [ & str ] ,
55120) -> String {
121+ let url = rpc_url_for ( network) ;
122+ let phrase = passphrase_for ( network) ;
56123 let output = std:: process:: Command :: new ( "stellar" )
57124 . args ( [ "contract" , "invoke" ,
58125 "--id" , contract_id,
59126 "--source" , source,
60- "--rpc-url" , rpc_url ( network ) ,
61- "--network-passphrase" , passphrase ( network ) ,
127+ "--rpc-url" , & url ,
128+ "--network-passphrase" , & phrase ,
62129 "--" ] )
63130 . args ( fn_args)
64131 . output ( )
@@ -81,9 +148,9 @@ struct Cli {
81148 #[ arg( long, global = true , env = "ANCHOR_CONTRACT_ID" ) ]
82149 contract_id : Option < String > ,
83150
84- /// Stellar network: testnet | mainnet | futurenet (or set STELLAR_NETWORK)
85- #[ arg( long, global = true , env = "STELLAR_NETWORK" , default_value = "testnet" ) ]
86- network : String ,
151+ /// Stellar network: testnet | mainnet | futurenet | <custom> (or set STELLAR_NETWORK)
152+ #[ arg( long, global = true , env = "STELLAR_NETWORK" ) ]
153+ network : Option < String > ,
87154
88155 #[ command( subcommand) ]
89156 command : Commands ,
@@ -163,6 +230,32 @@ enum Commands {
163230 #[ arg( long) ]
164231 fix : bool ,
165232 } ,
233+ /// Manage custom network profiles
234+ Network {
235+ #[ command( subcommand) ]
236+ action : NetworkAction ,
237+ } ,
238+ }
239+
240+ #[ derive( Subcommand ) ]
241+ enum NetworkAction {
242+ /// Add a custom network profile
243+ Add {
244+ #[ arg( long) ] name : String ,
245+ #[ arg( long) ] rpc_url : String ,
246+ #[ arg( long) ] passphrase : String ,
247+ #[ arg( long) ] horizon_url : Option < String > ,
248+ } ,
249+ /// List all configured network profiles
250+ List ,
251+ /// Remove a custom network profile
252+ Remove {
253+ #[ arg( long) ] name : String ,
254+ } ,
255+ /// Set the default network
256+ SetDefault {
257+ #[ arg( long) ] name : String ,
258+ } ,
166259}
167260
168261// ── Output types (JSON) ───────────────────────────────────────────────────────
@@ -292,11 +385,13 @@ fn deploy(network: &str, source: &str, admin: Option<&str>, dry_run: bool, list:
292385
293386 let wasm = "target/wasm32-unknown-unknown/release/anchorkit.wasm" ;
294387 println ! ( "Deploying {wasm} to {network}..." ) ;
388+ let net_url = rpc_url_for ( network) ;
389+ let net_phrase = passphrase_for ( network) ;
295390 let output = std:: process:: Command :: new ( "stellar" )
296391 . args ( [ "contract" , "deploy" , "--wasm" , wasm,
297392 "--source" , source,
298- "--rpc-url" , rpc_url ( network ) ,
299- "--network-passphrase" , passphrase ( network ) ] )
393+ "--rpc-url" , & net_url ,
394+ "--network-passphrase" , & net_phrase ] )
300395 . output ( )
301396 . expect ( "failed to run stellar contract deploy — is the Stellar CLI installed?" ) ;
302397
@@ -326,8 +421,8 @@ fn deploy(network: &str, source: &str, admin: Option<&str>, dry_run: bool, list:
326421 . args ( [ "contract" , "invoke" ,
327422 "--id" , & contract_id,
328423 "--source" , source,
329- "--rpc-url" , rpc_url ( network ) ,
330- "--network-passphrase" , passphrase ( network ) ,
424+ "--rpc-url" , & net_url ,
425+ "--network-passphrase" , & net_phrase ,
331426 "--" , "initialize" ,
332427 "--admin" , admin_addr] )
333428 . output ( ) ;
@@ -571,18 +666,8 @@ fn check_admin_secret_env() -> CheckResult {
571666}
572667
573668fn check_network_connectivity ( network : & str ) -> CheckResult {
574- let url = rpc_url ( network) ;
575- match reqwest:: blocking:: Client :: builder ( )
576- . timeout ( std:: time:: Duration :: from_secs ( 5 ) )
577- . build ( )
578- . and_then ( |client| client. get ( url) . send ( ) )
579- {
580- Ok ( resp) if resp. status ( ) . is_success ( ) || resp. status ( ) . as_u16 ( ) == 404 => {
581- CheckResult :: pass ( format ! ( "Network connectivity to {} OK" , network) )
582- }
583- Ok ( resp) => CheckResult :: warn ( format ! ( "Network {} responded with HTTP {}" , network, resp. status( ) ) ) ,
584- Err ( e) => CheckResult :: fail ( format ! ( "Cannot connect to {} network: {}" , network, e) ) ,
585- }
669+ let url = rpc_url_for ( network) ;
670+ check_network_connectivity_url ( & url)
586671}
587672
588673fn check_contract_deployment ( contract_id : & str , network : & str ) -> CheckResult {
@@ -592,8 +677,8 @@ fn check_contract_deployment(contract_id: &str, network: &str) -> CheckResult {
592677 . args ( [ "contract" , "invoke" ,
593678 "--id" , contract_id,
594679 "--source" , & source,
595- "--rpc-url" , rpc_url ( network) ,
596- "--network-passphrase" , passphrase ( network) ,
680+ "--rpc-url" , & rpc_url_for ( network) ,
681+ "--network-passphrase" , & passphrase_for ( network) ,
597682 "--" ,
598683 "get_attestor_count" ] )
599684 . output ( ) ;
@@ -697,35 +782,129 @@ fn doctor(network: &str, fix: bool) {
697782 }
698783}
699784
785+ // ── Network command ───────────────────────────────────────────────────────────
786+
787+ fn network_cmd ( action : NetworkAction ) {
788+ match action {
789+ NetworkAction :: Add { name, rpc_url, passphrase, horizon_url } => {
790+ // Validate RPC URL connectivity before saving
791+ let check = check_network_connectivity_url ( & rpc_url) ;
792+ if !check. passed {
793+ eprintln ! ( "error: RPC URL validation failed: {}" , check. message) ;
794+ std:: process:: exit ( 1 ) ;
795+ }
796+ let mut profiles = load_network_profiles ( ) ;
797+ if find_profile ( & profiles, & name) . is_some ( ) {
798+ eprintln ! ( "error: network '{}' already exists. Remove it first." , name) ;
799+ std:: process:: exit ( 1 ) ;
800+ }
801+ profiles. push ( NetworkProfile {
802+ name : name. clone ( ) ,
803+ rpc_url,
804+ network_passphrase : passphrase,
805+ horizon_url,
806+ is_default : false ,
807+ } ) ;
808+ save_network_profiles ( & profiles) ;
809+ println ! ( "Network '{}' added." , name) ;
810+ }
811+ NetworkAction :: List => {
812+ let profiles = load_network_profiles ( ) ;
813+ // Always show built-ins
814+ let builtins = [
815+ ( "testnet" , "https://soroban-testnet.stellar.org" , "Test SDF Network ; September 2015" ) ,
816+ ( "mainnet" , "https://horizon.stellar.org" , "Public Global Stellar Network ; September 2015" ) ,
817+ ( "futurenet" , "https://rpc-futurenet.stellar.org" , "Test SDF Future Network ; October 2022" ) ,
818+ ] ;
819+ println ! ( "{:<16} {:<45} {}" , "NAME" , "RPC URL" , "PASSPHRASE" ) ;
820+ for ( name, url, phrase) in & builtins {
821+ println ! ( "{:<16} {:<45} {} (built-in)" , name, url, phrase) ;
822+ }
823+ for p in & profiles {
824+ let default_marker = if p. is_default { " (default)" } else { "" } ;
825+ println ! ( "{:<16} {:<45} {}{}" , p. name, p. rpc_url, p. network_passphrase, default_marker) ;
826+ }
827+ }
828+ NetworkAction :: Remove { name } => {
829+ let mut profiles = load_network_profiles ( ) ;
830+ let before = profiles. len ( ) ;
831+ profiles. retain ( |p| p. name != name) ;
832+ if profiles. len ( ) == before {
833+ eprintln ! ( "error: network '{}' not found." , name) ;
834+ std:: process:: exit ( 1 ) ;
835+ }
836+ save_network_profiles ( & profiles) ;
837+ println ! ( "Network '{}' removed." , name) ;
838+ }
839+ NetworkAction :: SetDefault { name } => {
840+ let mut profiles = load_network_profiles ( ) ;
841+ // Allow setting built-in names as default (stored as a marker profile)
842+ let found = profiles. iter ( ) . any ( |p| p. name == name) ;
843+ if !found {
844+ // Check if it's a built-in
845+ let builtins = [ "testnet" , "mainnet" , "futurenet" ] ;
846+ if !builtins. contains ( & name. as_str ( ) ) {
847+ eprintln ! ( "error: network '{}' not found." , name) ;
848+ std:: process:: exit ( 1 ) ;
849+ }
850+ }
851+ for p in & mut profiles {
852+ p. is_default = p. name == name;
853+ }
854+ save_network_profiles ( & profiles) ;
855+ println ! ( "Default network set to '{}'." , name) ;
856+ }
857+ }
858+ }
859+
860+ fn check_network_connectivity_url ( url : & str ) -> CheckResult {
861+ match reqwest:: blocking:: Client :: builder ( )
862+ . timeout ( std:: time:: Duration :: from_secs ( 5 ) )
863+ . build ( )
864+ . and_then ( |client| client. get ( url) . send ( ) )
865+ {
866+ Ok ( resp) if resp. status ( ) . is_success ( ) || resp. status ( ) . as_u16 ( ) == 404 => {
867+ CheckResult :: pass ( format ! ( "RPC URL {} reachable" , url) )
868+ }
869+ Ok ( resp) => CheckResult :: warn ( format ! ( "RPC URL {} responded with HTTP {}" , url, resp. status( ) ) ) ,
870+ Err ( e) => CheckResult :: fail ( format ! ( "Cannot connect to {}: {}" , url, e) ) ,
871+ }
872+ }
873+
700874// ── Entry point ───────────────────────────────────────────────────────────────
701875
702876fn main ( ) {
703877 let cli = Cli :: parse ( ) ;
878+ let network = cli. network . unwrap_or_else ( default_network) ;
704879 match cli. command {
705880 Commands :: Deploy { source, admin, dry_run, list } => {
706- deploy ( & cli . network , & source, admin. as_deref ( ) , dry_run, list) ;
881+ deploy ( & network, & source, admin. as_deref ( ) , dry_run, list) ;
707882 }
708- Commands :: Register { address, services, contract_id, network, secret_key, keypair_file, sep10_token, sep10_issuer } => {
883+ Commands :: Register { address, services, contract_id, network : cmd_net, secret_key, keypair_file, sep10_token, sep10_issuer } => {
884+ let net = cmd_net;
709885 let source = resolve_source ( secret_key. as_deref ( ) , keypair_file. as_deref ( ) ) ;
710- register ( & address, & services, & contract_id, & network , & source, & sep10_token, & sep10_issuer) ;
886+ register ( & address, & services, & contract_id, & net , & source, & sep10_token, & sep10_issuer) ;
711887 }
712- Commands :: Attest { subject, payload_hash, contract_id, network, secret_key, keypair_file, issuer, session_id } => {
888+ Commands :: Attest { subject, payload_hash, contract_id, network : cmd_net , secret_key, keypair_file, issuer, session_id } => {
713889 let source = resolve_source ( secret_key. as_deref ( ) , keypair_file. as_deref ( ) ) ;
714- attest ( & subject, & payload_hash, & contract_id, & network , & source, & issuer, session_id) ;
890+ attest ( & subject, & payload_hash, & contract_id, & cmd_net , & source, & issuer, session_id) ;
715891 }
716- Commands :: Quote { from, to, amount, contract_id, network, secret_key, keypair_file } => {
892+ Commands :: Quote { from, to, amount, contract_id, network : cmd_net , secret_key, keypair_file } => {
717893 let source = resolve_source ( secret_key. as_deref ( ) , keypair_file. as_deref ( ) ) ;
718- quote ( & from, & to, amount, & contract_id, & network , & source) ;
894+ quote ( & from, & to, amount, & contract_id, & cmd_net , & source) ;
719895 }
720896 Commands :: Status { tx_id, anchor_url } => {
721897 status ( & tx_id, & anchor_url) ;
722898 }
723- Commands :: Revoke { address, contract_id, network, secret_key, keypair_file } => {
899+ Commands :: Revoke { address, contract_id, network : cmd_net , secret_key, keypair_file } => {
724900 let source = resolve_source ( secret_key. as_deref ( ) , keypair_file. as_deref ( ) ) ;
725- revoke ( & address, & contract_id, & network , & source) ;
901+ revoke ( & address, & contract_id, & cmd_net , & source) ;
726902 }
727903 Commands :: Doctor { fix } => {
728- doctor ( & cli. network , fix) ;
904+ doctor ( & network, fix) ;
905+ }
906+ Commands :: Network { action } => {
907+ network_cmd ( action) ;
729908 }
730909 }
731910}
0 commit comments