Skip to content

Commit 9971061

Browse files
authored
Merge pull request #220 from joshuagit706/feature/issues-196-197-198-199
feat: SEP-6 polling, multi-network CLI, no_std audit, AnchorErrorBoundary
2 parents ed14204 + f98d929 commit 9971061

8 files changed

Lines changed: 758 additions & 63 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ jobs:
3636
- name: cargo check (native)
3737
run: cargo check
3838

39+
- name: no_std compliance check (WASM)
40+
run: cargo check --target ${{ env.WASM_TARGET }} --no-default-features --features wasm
41+
3942
- name: cargo test
4043
run: cargo test
4144

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ pub use sep6::{
138138
fetch_transaction_status, initiate_deposit, initiate_withdrawal, DepositResponse,
139139
RawDepositResponse, RawTransactionResponse, RawWithdrawalResponse, TransactionKind,
140140
TransactionStatus, TransactionStatusResponse, WithdrawalResponse,
141+
poll_transaction_status, PollConfig, PollResult,
141142
};
142143
pub use sep24::{
143144
initiate_interactive_deposit, initiate_interactive_withdrawal, fetch_sep24_transaction_status,

src/main.rs

Lines changed: 213 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,72 @@
11
use clap::{Parser, Subcommand};
22
use 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

573668
fn 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

588673
fn 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

702876
fn 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

Comments
 (0)