equanetwork-cli 0.0.4

The Equa Network command line interface
use std::fs::read;

use cached::once;
use clap::{Parser, Subcommand};
use serde::Deserialize;
use solana_client::rpc_client::RpcClient;
use solana_commitment_config::{CommitmentConfig, CommitmentLevel};
use solana_keypair::Keypair;
use solana_pubkey::Pubkey;

#[derive(Parser, Debug, Clone)]
#[command(author, version, about = "Equa Network protocol CLI", long_about = None)]
pub struct Config {
    #[arg(long, env, global = true)]
    signer: Option<String>,

    #[arg(long = "rpc", env, global = true)]
    rpc_url: Option<String>,

    #[arg(long, env, global = true)]
    commitment: Option<CommitmentLevel>,

    #[arg(long, env, global = true, default_value = "false")]
    pub no_decoration: bool,

    #[arg(long, env, global = true, default_value = "false")]
    pub debug: bool,

    #[arg(long, env, global = true, default_value = "false")]
    pub skip_simulation: bool,

    #[arg(long, env, global = true, default_value = "false")]
    pub yes: bool,

    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Debug, Clone)]
pub enum Command {
    /// Show program id
    Show,

    /// Program bootstrap / version commands
    Program {
        #[command(subcommand)]
        command: ProgramCommand,
    },

    /// Permission account commands
    Permission {
        #[command(subcommand)]
        command: PermissionCommand,
    },

    /// Network (swap group) commands
    Network {
        #[command(subcommand)]
        command: NetworkCommand,
    },

    /// Vault (per-mint inventory market) commands
    Vault {
        #[command(subcommand)]
        command: VaultCommand,
    },
}

#[derive(Subcommand, Debug, Clone)]
pub enum ProgramCommand {
    /// Bootstrap: create admin Permission (all privileges)
    Initialize,

    /// Print CLI / RPC versions
    Version,
}

#[derive(Subcommand, Debug, Clone)]
pub enum PermissionCommand {
    /// Create a Permission PDA for an account (requires PermissionInitialize)
    Initialize {
        #[arg(long)]
        account: Pubkey,
    },
}

#[derive(Subcommand, Debug, Clone)]
pub enum NetworkCommand {
    /// Create a Network PDA (`["network", network_id.to_le_bytes()]`)
    Initialize {
        /// Network id as u32 decimal or hex (`0x…`)
        #[arg(long)]
        network_id: String,
    },

    /// Swap between two vaults in a network
    Swap {
        #[arg(long)]
        network: Pubkey,
        #[arg(long)]
        input_mint: Pubkey,
        #[arg(long)]
        output_mint: Pubkey,
        #[arg(long)]
        amount: u64,
        #[arg(long, default_value = "0")]
        minimum_output_amount: u64,
        #[arg(long, default_value = "false")]
        allow_partial_fill: bool,
    },
}

#[derive(Subcommand, Debug, Clone)]
pub enum VaultCommand {
    /// Register a mint vault inside a network
    Initialize {
        #[arg(long)]
        network: Pubkey,
        #[arg(long)]
        mint: Pubkey,
    },

    /// Deposit inventory into a network vault (admin)
    Deposit {
        #[arg(long)]
        network: Pubkey,
        #[arg(long)]
        mint: Pubkey,
        #[arg(long)]
        amount: u64,
    },

    /// Withdraw inventory from a network vault (admin)
    Withdraw {
        #[arg(long)]
        network: Pubkey,
        #[arg(long)]
        mint: Pubkey,
        #[arg(long)]
        amount: u64,
        #[arg(long)]
        destination: Option<Pubkey>,
    },
}

#[once]
fn solana_config() -> SolanaConfig {
    let config_path = std::env::var("HOME").unwrap() + "/.config/solana/cli/config.yml";
    let config = std::fs::read_to_string(config_path).unwrap();
    let config: SolanaConfig = serde_yaml::from_str(&config).unwrap();
    config
}

#[once]
fn solana_config_keypair() -> String {
    let config = solana_config();
    let keypair_file = std::fs::read(config.keypair_path).unwrap();
    let keypair_bytes: Vec<u8> = serde_json::from_slice(&keypair_file).unwrap();
    bs58::encode(keypair_bytes).into_string()
}

impl Config {
    pub fn rpc_client(&self) -> RpcClient {
        let config = solana_config();
        let commitment = CommitmentConfig {
            commitment: self.commitment.unwrap_or(config.commitment),
        };
        if let Some(rpc_url) = &self.rpc_url {
            RpcClient::new_with_commitment(rpc_url.clone(), commitment)
        } else {
            RpcClient::new_with_commitment(config.json_rpc_url.clone(), commitment)
        }
    }

    #[allow(dead_code)]
    pub fn keypair(&self) -> Option<Keypair> {
        if let Some(keypair) = &self.signer {
            let bytes = read(keypair)
                .ok()
                .and_then(|x| serde_json::from_slice::<Vec<u8>>(&x).ok())
                .map(|x| bs58::encode(x).into_string())
                .unwrap_or(keypair.clone());
            if bytes.len() > 44 {
                Some(Keypair::from_base58_string(&bytes))
            } else {
                None
            }
        } else {
            let bytes = solana_config_keypair();
            Some(Keypair::from_base58_string(&bytes))
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
struct SolanaConfig {
    json_rpc_url: String,
    keypair_path: String,
    commitment: CommitmentLevel,
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    const SYSTEM_PROGRAM: &str = "11111111111111111111111111111111";

    #[test]
    fn parses_version() {
        let config = Config::try_parse_from(["equa", "program", "version"]).unwrap();
        assert!(matches!(
            config.command,
            Command::Program {
                command: ProgramCommand::Version,
            }
        ));
    }

    #[test]
    fn parses_program_initialize() {
        let config = Config::try_parse_from(["equa", "program", "initialize"]).unwrap();
        assert!(matches!(
            config.command,
            Command::Program {
                command: ProgramCommand::Initialize,
            }
        ));
    }

    #[test]
    fn parses_show() {
        let config = Config::try_parse_from(["equa", "show"]).unwrap();
        assert!(matches!(config.command, Command::Show));
    }

    #[test]
    fn parses_network_initialize() {
        let config =
            Config::try_parse_from(["equa", "network", "initialize", "--network-id", "7"]).unwrap();
        match config.command {
            Command::Network {
                command: NetworkCommand::Initialize { network_id },
            } => assert_eq!(network_id, "7"),
            other => panic!("expected Network::Initialize, got {other:?}"),
        }
    }

    #[test]
    fn parses_network_swap() {
        let config = Config::try_parse_from([
            "equa",
            "network",
            "swap",
            "--network",
            SYSTEM_PROGRAM,
            "--input-mint",
            SYSTEM_PROGRAM,
            "--output-mint",
            SYSTEM_PROGRAM,
            "--amount",
            "100",
        ])
        .unwrap();
        match config.command {
            Command::Network {
                command:
                    NetworkCommand::Swap {
                        amount,
                        minimum_output_amount,
                        allow_partial_fill,
                        ..
                    },
            } => {
                assert_eq!(amount, 100);
                assert_eq!(minimum_output_amount, 0);
                assert!(!allow_partial_fill);
            }
            other => panic!("expected Network::Swap, got {other:?}"),
        }
    }

    #[test]
    fn parses_vault_initialize() {
        let config = Config::try_parse_from([
            "equa",
            "vault",
            "initialize",
            "--network",
            SYSTEM_PROGRAM,
            "--mint",
            SYSTEM_PROGRAM,
        ])
        .unwrap();
        match config.command {
            Command::Vault {
                command: VaultCommand::Initialize { network, mint },
            } => {
                assert_eq!(network.to_string(), SYSTEM_PROGRAM);
                assert_eq!(mint.to_string(), SYSTEM_PROGRAM);
            }
            other => panic!("expected Vault::Initialize, got {other:?}"),
        }
    }

    #[test]
    fn parses_vault_deposit() {
        let config = Config::try_parse_from([
            "equa",
            "vault",
            "deposit",
            "--network",
            SYSTEM_PROGRAM,
            "--mint",
            SYSTEM_PROGRAM,
            "--amount",
            "42",
        ])
        .unwrap();
        match config.command {
            Command::Vault {
                command: VaultCommand::Deposit { amount, .. },
            } => assert_eq!(amount, 42),
            other => panic!("expected Vault::Deposit, got {other:?}"),
        }
    }

    #[test]
    fn parses_global_flags() {
        let config = Config::try_parse_from([
            "equa",
            "--rpc",
            "http://127.0.0.1:8899",
            "--debug",
            "--yes",
            "--skip-simulation",
            "show",
        ])
        .unwrap();
        assert_eq!(config.rpc_url.as_deref(), Some("http://127.0.0.1:8899"));
        assert!(config.debug);
        assert!(config.yes);
        assert!(config.skip_simulation);
        assert!(matches!(config.command, Command::Show));
    }

    #[test]
    fn rejects_unknown_subcommand() {
        let err = Config::try_parse_from(["equa", "not-a-command"]).unwrap_err();
        assert_eq!(err.kind(), clap::error::ErrorKind::InvalidSubcommand);
    }
}