Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,744 changes: 1,681 additions & 63 deletions Cargo.lock

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ reqwest = { version = "0.12", features = ["json"] }

# Solana
solana-sdk = "~2.3"
# Fireblocks is intentionally omitted: some modes can auto-broadcast, while
# Vulcan expects every signer to return a locally submittable transaction.
solana-keychain = { version = "1.2", default-features = false, features = [
"memory",
"vault",
"privy",
"turnkey",
"gcp_kms",
"cdp",
"para",
"crossmint",
"dfns",
"openfort",
"sdk-v2",
] }
solana-pubkey = { version = "~2.4", features = ["curve25519"] }
solana-instruction = { version = "~2.3", default-features = false, features = ["std"] }
solana-rpc-client = "~2.3"
Expand Down
5 changes: 5 additions & 0 deletions vulcan-lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ version = "0.6.2"
edition = "2021"
description = "Core library for the Vulcan CLI"

[features]
default = []
aws-kms-signer = ["solana-keychain/aws_kms"]

[dependencies]
# Rise SDK
phoenix-rise = { workspace = true }
Expand All @@ -24,6 +28,7 @@ reqwest = { workspace = true }

# Solana
solana-sdk = { workspace = true }
solana-keychain = { workspace = true }
solana-pubkey = { workspace = true }
solana-rpc-client = { workspace = true }

Expand Down
185 changes: 183 additions & 2 deletions vulcan-lib/src/cli/wallet.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Wallet subcommand definitions.

use clap::Subcommand;
use clap::{Args, Subcommand};
use std::path::PathBuf;

#[derive(Debug, Subcommand)]
Expand Down Expand Up @@ -88,6 +88,148 @@ pub enum WalletCommand {
/// Wallet name (defaults to default wallet)
name: Option<String>,
},

/// Register provider-backed signer wallets
Signer {
#[command(subcommand)]
command: WalletSignerCommand,
},
}

#[derive(Debug, Subcommand)]
pub enum WalletSignerCommand {
/// Register a HashiCorp Vault transit signer
AddVault {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
vault_addr: String,
#[arg(long)]
key_name: String,
#[arg(long)]
token_env: Option<String>,
},
/// Register an AWS KMS signer
AddAwsKms {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
key_id: String,
#[arg(long)]
region: Option<String>,
},
/// Register a GCP KMS signer
AddGcpKms {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
key_name: String,
},
/// Register a Turnkey signer
AddTurnkey {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
organization_id: String,
#[arg(long)]
private_key_id: String,
#[arg(long)]
api_public_key_env: Option<String>,
#[arg(long)]
api_private_key_env: Option<String>,
#[arg(long)]
api_base_url: Option<String>,
},
/// Register a Privy signer
AddPrivy {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
wallet_id: String,
#[arg(long)]
app_id_env: Option<String>,
#[arg(long)]
app_secret_env: Option<String>,
#[arg(long)]
api_base_url: Option<String>,
},
/// Register a Coinbase Developer Platform signer
AddCdp {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
api_key_id_env: Option<String>,
#[arg(long)]
api_key_secret_env: Option<String>,
#[arg(long)]
wallet_secret_env: Option<String>,
#[arg(long)]
api_base_url: Option<String>,
},
/// Register a Para signer
AddPara {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
wallet_id: String,
#[arg(long)]
api_key_env: Option<String>,
#[arg(long)]
api_base_url: Option<String>,
},
/// Register a Crossmint signer
AddCrossmint {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
wallet_locator: String,
#[arg(long)]
api_key_env: Option<String>,
#[arg(long)]
signer_secret_env: Option<String>,
#[arg(long)]
signer: Option<String>,
#[arg(long)]
api_base_url: Option<String>,
},
/// Register a Dfns signer
AddDfns {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
auth_token_env: Option<String>,
#[arg(long)]
cred_id: String,
#[arg(long)]
private_key_pem_env: Option<String>,
#[arg(long)]
wallet_id: String,
#[arg(long)]
api_base_url: Option<String>,
},
/// Register an Openfort backend wallet signer
AddOpenfort {
#[command(flatten)]
common: RemoteSignerCommon,
#[arg(long)]
account_id: String,
#[arg(long)]
secret_key_env: Option<String>,
#[arg(long)]
wallet_secret_env: Option<String>,
#[arg(long)]
api_base_url: Option<String>,
},
}

#[derive(Debug, Clone, Args)]
pub struct RemoteSignerCommon {
/// Wallet record name
#[arg(long)]
pub name: String,
/// Base58 Solana public key controlled by the signer provider
#[arg(long)]
pub public_key: String,
}

#[derive(Debug, Clone, clap::ValueEnum)]
Expand All @@ -110,7 +252,7 @@ pub enum PrivateKeyExportFormat {

#[cfg(test)]
mod tests {
use super::{PrivateKeyExportFormat, WalletCommand};
use super::{PrivateKeyExportFormat, WalletCommand, WalletSignerCommand};
use crate::cli::{Cli, Command};
use crate::output::OutputFormat;
use clap::Parser;
Expand Down Expand Up @@ -205,4 +347,43 @@ mod tests {
other => panic!("unexpected command: {other:?}"),
}
}

#[test]
fn signer_add_vault_parses() {
let cli = Cli::parse_from([
"vulcan",
"wallet",
"signer",
"add-vault",
"--name",
"vault-main",
"--public-key",
"11111111111111111111111111111111",
"--vault-addr",
"https://vault.example.com",
"--key-name",
"solana-main",
"--token-env",
"VAULT_TOKEN",
]);

match cli.command {
Command::Wallet(WalletCommand::Signer {
command:
WalletSignerCommand::AddVault {
common,
vault_addr,
key_name,
token_env,
},
}) => {
assert_eq!(common.name, "vault-main");
assert_eq!(common.public_key, "11111111111111111111111111111111");
assert_eq!(vault_addr, "https://vault.example.com");
assert_eq!(key_name, "solana-main");
assert_eq!(token_env.as_deref(), Some("VAULT_TOKEN"));
}
other => panic!("unexpected command: {other:?}"),
}
}
}
26 changes: 4 additions & 22 deletions vulcan-lib/src/commands/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,17 +241,8 @@ pub async fn execute(ctx: &AppContext, cmd: AccountCommand) -> Result<(), Vulcan
.build_register_trader(authority, pda_index, subaccount_index)
.map_err(|e| VulcanError::api("BUILD_REGISTER_FAILED", e.to_string()))?;

let wallet = if let Some(sw) = &ctx.session_wallet {
sw.to_wallet()?
} else {
let wallet_file = ctx
.wallet_store
.load(&wallet_name)
.map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?;
let password = crate::commands::trade::prompt_password()?;
crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password)
.map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?
};
let (wallet, _, _) =
crate::commands::trade::resolve_wallet_and_pda(ctx, Some(&wallet_name)).await?;

let sig = crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await?;

Expand Down Expand Up @@ -411,17 +402,8 @@ async fn register_authority(
.build_register_trader(authority, 0, 0)
.map_err(|e| VulcanError::api("BUILD_REGISTER_FAILED", e.to_string()))?;

let wallet = if let Some(sw) = &ctx.session_wallet {
sw.to_wallet()?
} else {
let wallet_file = ctx
.wallet_store
.load(wallet_name)
.map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?;
let password = crate::commands::trade::prompt_password()?;
crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password)
.map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?
};
let (wallet, _, _) =
crate::commands::trade::resolve_wallet_and_pda(ctx, Some(wallet_name)).await?;

match crate::commands::trade::send_or_dry_run(ctx, ixs, &wallet).await {
Ok(sig) => sig,
Expand Down
18 changes: 16 additions & 2 deletions vulcan-lib/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,13 @@ fn resolve_mcp_wallet_switch_secret(
password: crate::secrets::SecretString::new(String::new()),
});
}
if !wallet_file.is_local_encrypted() {
return Ok(McpInstallSecret {
wallet_name: wallet_name.to_string(),
wallet_address: wallet_file.public_key,
password: crate::secrets::SecretString::new(String::new()),
});
}

use std::io::IsTerminal;
if !io::stdin().is_terminal() {
Expand All @@ -2123,7 +2130,7 @@ fn resolve_mcp_wallet_switch_secret(
.map_err(|e| VulcanError::io("FLUSH_FAILED", e.to_string()))?;
let password = rpassword::read_password()
.map_err(|e| VulcanError::io("PASSWORD_READ_FAILED", e.to_string()))?;
crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password)
crate::wallet::Wallet::decrypt(wallet_file.encrypted_data()?, &password)
.map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?;

Ok(McpInstallSecret {
Expand Down Expand Up @@ -2418,13 +2425,20 @@ fn resolve_mcp_install_secret(
.wallet_store
.load(&wallet_name)
.map_err(|e| VulcanError::auth("WALLET_NOT_FOUND", e.to_string()))?;
if !wallet_file.is_local_encrypted() {
return Ok(Some(McpInstallSecret {
wallet_name,
wallet_address: wallet_file.public_key,
password: crate::secrets::SecretString::new(String::new()),
}));
}
eprint!("Wallet password for '{}': ", wallet_name);
io::stderr()
.flush()
.map_err(|e| VulcanError::io("FLUSH_FAILED", e.to_string()))?;
let password = rpassword::read_password()
.map_err(|e| VulcanError::io("PASSWORD_READ_FAILED", e.to_string()))?;
crate::wallet::Wallet::decrypt(&wallet_file.encrypted, &password)
crate::wallet::Wallet::decrypt(wallet_file.encrypted_data()?, &password)
.map_err(|e| VulcanError::auth("DECRYPT_FAILED", e.to_string()))?;

Ok(Some(McpInstallSecret {
Expand Down
Loading
Loading