use crate::config::{AuthConfig, BuilderConfig, RelayerApiKeyConfig};
use crate::error::RelayError;
use alloy::primitives::Address;
use alloy::signers::local::PrivateKeySigner;
#[cfg(feature = "keychain")]
pub const KEYCHAIN_SERVICE: &str = "polyoxide-relay";
#[derive(Clone)]
pub struct BuilderAccount {
pub(crate) signer: PrivateKeySigner,
pub(crate) config: Option<AuthConfig>,
}
fn parse_signer(private_key: impl Into<String>) -> Result<PrivateKeySigner, RelayError> {
private_key
.into()
.parse::<PrivateKeySigner>()
.map_err(|e| RelayError::Signer(format!("Failed to parse private key: {}", e)))
}
impl std::fmt::Debug for BuilderAccount {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BuilderAccount")
.field("address", &self.signer.address())
.field("config", &self.config)
.finish()
}
}
impl BuilderAccount {
pub fn new(
private_key: impl Into<String>,
config: Option<BuilderConfig>,
) -> Result<Self, RelayError> {
let signer = parse_signer(private_key)?;
Ok(Self {
signer,
config: config.map(AuthConfig::Builder),
})
}
pub fn with_relayer_api_key(
private_key: impl Into<String>,
key: String,
address: String,
) -> Result<Self, RelayError> {
let signer = parse_signer(private_key)?;
let relayer = RelayerApiKeyConfig::new(key, address)?;
Ok(Self {
signer,
config: Some(AuthConfig::RelayerApiKey(relayer)),
})
}
pub fn with_auth_config(
private_key: impl Into<String>,
config: Option<AuthConfig>,
) -> Result<Self, RelayError> {
let signer = parse_signer(private_key)?;
Ok(Self { signer, config })
}
pub fn address(&self) -> Address {
self.signer.address()
}
pub fn signer(&self) -> &PrivateKeySigner {
&self.signer
}
pub fn auth_config(&self) -> Option<&AuthConfig> {
self.config.as_ref()
}
#[cfg(feature = "keychain")]
pub fn from_keychain() -> Result<Self, RelayError> {
Self::from_keychain_in_service(KEYCHAIN_SERVICE)
}
#[cfg(feature = "keychain")]
fn from_keychain_in_service(service: &str) -> Result<Self, RelayError> {
use polyoxide_core::keychain;
let private_key = keychain::get(service, "private_key")
.map_err(|e| RelayError::Api(format!("Keychain error for private_key: {e}")))?;
let config = match keychain::get(service, "api_key") {
Ok(key) => {
let secret = keychain::get(service, "api_secret")
.map_err(|e| RelayError::Api(format!("Keychain error for api_secret: {e}")))?;
let passphrase = keychain::get(service, "passphrase").ok();
Some(BuilderConfig::new(key, secret, passphrase))
}
Err(polyoxide_core::KeychainError::NotFound { .. }) => None,
Err(e) => return Err(RelayError::Api(format!("Keychain error: {e}"))),
};
Self::new(private_key, config)
}
#[cfg(feature = "keychain")]
pub fn from_keychain_relayer_api_key() -> Result<Self, RelayError> {
Self::from_keychain_relayer_api_key_in_service(KEYCHAIN_SERVICE)
}
#[cfg(feature = "keychain")]
fn from_keychain_relayer_api_key_in_service(service: &str) -> Result<Self, RelayError> {
use polyoxide_core::keychain;
let private_key = keychain::get(service, "private_key")
.map_err(|e| RelayError::Api(format!("Keychain error for private_key: {e}")))?;
let key = keychain::get(service, "relayer_api_key")
.map_err(|e| RelayError::Api(format!("Keychain error for relayer_api_key: {e}")))?;
let address = keychain::get(service, "relayer_api_key_address").map_err(|e| {
RelayError::Api(format!("Keychain error for relayer_api_key_address: {e}"))
})?;
Self::with_relayer_api_key(private_key, key, address)
}
#[cfg(feature = "keychain")]
pub fn delete_from_keychain() -> Result<(), RelayError> {
Self::delete_from_keychain_in_service(KEYCHAIN_SERVICE)
}
#[cfg(feature = "keychain")]
fn delete_from_keychain_in_service(service: &str) -> Result<(), RelayError> {
use polyoxide_core::keychain;
for key in [
"private_key",
"api_key",
"api_secret",
"passphrase",
"relayer_api_key",
"relayer_api_key_address",
] {
keychain::delete(service, key)
.map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
}
Ok(())
}
}
#[cfg(feature = "keychain")]
pub fn save_private_key_to_keychain(private_key: &str) -> Result<(), RelayError> {
save_private_key_to_keychain_in_service(KEYCHAIN_SERVICE, private_key)
}
#[cfg(feature = "keychain")]
fn save_private_key_to_keychain_in_service(
service: &str,
private_key: &str,
) -> Result<(), RelayError> {
polyoxide_core::keychain::set(service, "private_key", private_key)
.map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
Ok(())
}
#[cfg(feature = "keychain")]
pub fn save_builder_config_to_keychain(config: &BuilderConfig) -> Result<(), RelayError> {
save_builder_config_to_keychain_in_service(KEYCHAIN_SERVICE, config)
}
#[cfg(feature = "keychain")]
fn save_builder_config_to_keychain_in_service(
service: &str,
config: &BuilderConfig,
) -> Result<(), RelayError> {
use polyoxide_core::keychain;
keychain::set(service, "api_key", &config.key)
.map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
keychain::set(service, "api_secret", &config.secret)
.map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
match &config.passphrase {
Some(passphrase) => {
keychain::set(service, "passphrase", passphrase)
.map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
}
None => {
keychain::delete(service, "passphrase")
.map_err(|e| RelayError::Api(format!("Keychain error: {e}")))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AuthConfig;
const TEST_PRIVATE_KEY: &str =
"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
#[test]
fn test_new_valid_private_key() {
let account = BuilderAccount::new(TEST_PRIVATE_KEY, None);
assert!(account.is_ok());
}
#[test]
fn test_new_with_0x_prefix() {
let key = format!("0x{}", TEST_PRIVATE_KEY);
let account = BuilderAccount::new(key, None);
assert!(account.is_ok());
}
#[test]
fn test_new_invalid_private_key() {
let result = BuilderAccount::new("not_a_valid_key", None);
assert!(result.is_err());
let err = result.unwrap_err();
match err {
RelayError::Signer(msg) => {
assert!(
msg.contains("Failed to parse private key"),
"unexpected: {msg}"
);
}
other => panic!("Expected Signer error, got: {other:?}"),
}
}
#[test]
fn test_new_empty_key() {
let result = BuilderAccount::new("", None);
assert!(result.is_err());
}
#[test]
fn test_address_derivation_deterministic() {
let a1 = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
let a2 = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
assert_eq!(a1.address(), a2.address());
}
#[test]
fn test_address_matches_known_value() {
let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
let expected: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
.parse()
.unwrap();
assert_eq!(account.address(), expected);
}
#[test]
fn test_debug_redacts_private_key() {
let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
let debug_output = format!("{:?}", account);
assert!(
debug_output.contains("address"),
"Debug should show address, got: {debug_output}"
);
assert!(
!debug_output.contains(TEST_PRIVATE_KEY),
"Debug should not contain the private key, got: {debug_output}"
);
}
#[test]
fn test_config_none() {
let account = BuilderAccount::new(TEST_PRIVATE_KEY, None).unwrap();
assert!(account.auth_config().is_none());
}
#[test]
fn test_config_some() {
let config = BuilderConfig::new("key".into(), "secret".into(), None);
let account = BuilderAccount::new(TEST_PRIVATE_KEY, Some(config)).unwrap();
assert!(account.auth_config().is_some());
}
#[test]
fn test_with_relayer_api_key() {
let account = BuilderAccount::with_relayer_api_key(
TEST_PRIVATE_KEY,
"my-key".to_string(),
"0xaddr".to_string(),
)
.unwrap();
assert!(account.auth_config().is_some());
assert!(matches!(
account.auth_config(),
Some(AuthConfig::RelayerApiKey(_))
));
}
#[test]
fn test_new_wraps_builder_config_in_auth_config() {
let config = BuilderConfig::new("key".into(), "secret".into(), None);
let account = BuilderAccount::new(TEST_PRIVATE_KEY, Some(config)).unwrap();
assert!(matches!(
account.auth_config(),
Some(AuthConfig::Builder(_))
));
}
#[test]
fn test_with_auth_config_none() {
let account = BuilderAccount::with_auth_config(TEST_PRIVATE_KEY, None).unwrap();
assert!(account.auth_config().is_none());
}
#[test]
fn test_with_auth_config_relayer_api_key_variant() {
let relayer =
crate::config::RelayerApiKeyConfig::new("rk".into(), "0xaddr".into()).unwrap();
let auth = AuthConfig::RelayerApiKey(relayer);
let account = BuilderAccount::with_auth_config(TEST_PRIVATE_KEY, Some(auth)).unwrap();
assert!(matches!(
account.auth_config(),
Some(AuthConfig::RelayerApiKey(_))
));
}
#[cfg(feature = "keychain")]
mod keychain_tests {
use super::*;
const TEST_PRIVATE_KEY: &str =
"ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
#[test]
#[ignore] fn builder_account_keychain_roundtrip() {
const SERVICE: &str = "polyoxide-relay-test-builder-roundtrip";
save_private_key_to_keychain_in_service(SERVICE, TEST_PRIVATE_KEY).unwrap();
let config = BuilderConfig::new("rk".into(), "rs".into(), Some("rp".into()));
save_builder_config_to_keychain_in_service(SERVICE, &config).unwrap();
let account = BuilderAccount::from_keychain_in_service(SERVICE).unwrap();
assert_eq!(
account.address(),
BuilderAccount::new(TEST_PRIVATE_KEY, None)
.unwrap()
.address()
);
assert!(account.auth_config().is_some());
BuilderAccount::delete_from_keychain_in_service(SERVICE).unwrap();
}
#[test]
#[ignore] fn builder_account_keychain_no_config() {
use polyoxide_core::keychain;
const SERVICE: &str = "polyoxide-relay-test-no-config";
let _ = keychain::delete(SERVICE, "api_key");
let _ = keychain::delete(SERVICE, "api_secret");
let _ = keychain::delete(SERVICE, "passphrase");
save_private_key_to_keychain_in_service(SERVICE, TEST_PRIVATE_KEY).unwrap();
let account = BuilderAccount::from_keychain_in_service(SERVICE).unwrap();
assert!(
account.auth_config().is_none(),
"Expected no auth config when api_key is absent"
);
let _ = keychain::delete(SERVICE, "private_key");
}
#[test]
#[ignore] fn save_builder_config_none_passphrase_clears_stale() {
use polyoxide_core::keychain;
const SERVICE: &str = "polyoxide-relay-test-clears-stale";
save_private_key_to_keychain_in_service(SERVICE, TEST_PRIVATE_KEY).unwrap();
let config_with = BuilderConfig::new("k".into(), "s".into(), Some("pp".into()));
save_builder_config_to_keychain_in_service(SERVICE, &config_with).unwrap();
assert!(keychain::get(SERVICE, "passphrase").is_ok());
let config_without = BuilderConfig::new("k".into(), "s".into(), None);
save_builder_config_to_keychain_in_service(SERVICE, &config_without).unwrap();
let result = keychain::get(SERVICE, "passphrase");
assert!(
matches!(result, Err(polyoxide_core::KeychainError::NotFound { .. })),
"Expected passphrase to be deleted, got: {result:?}"
);
let account = BuilderAccount::from_keychain_in_service(SERVICE).unwrap();
if let Some(AuthConfig::Builder(bc)) = account.auth_config() {
assert!(
bc.passphrase.is_none(),
"Expected passphrase=None after clearing"
);
} else {
panic!("Expected Builder auth config");
}
BuilderAccount::delete_from_keychain_in_service(SERVICE).unwrap();
}
#[test]
#[ignore] fn relayer_api_key_keychain_roundtrip() {
use polyoxide_core::keychain;
const SERVICE: &str = "polyoxide-relay-test-relayer-key";
save_private_key_to_keychain_in_service(SERVICE, TEST_PRIVATE_KEY).unwrap();
keychain::set(SERVICE, "relayer_api_key", "test-rk").unwrap();
keychain::set(SERVICE, "relayer_api_key_address", "0xaddr").unwrap();
let account =
BuilderAccount::from_keychain_relayer_api_key_in_service(SERVICE).unwrap();
assert_eq!(
account.address(),
BuilderAccount::new(TEST_PRIVATE_KEY, None)
.unwrap()
.address()
);
assert!(matches!(
account.auth_config(),
Some(AuthConfig::RelayerApiKey(_))
));
BuilderAccount::delete_from_keychain_in_service(SERVICE).unwrap();
}
}
#[test]
fn test_with_auth_config_invalid_private_key() {
let result = BuilderAccount::with_auth_config("not_a_valid_key", None);
assert!(result.is_err());
match result.unwrap_err() {
RelayError::Signer(msg) => {
assert!(
msg.contains("Failed to parse private key"),
"unexpected: {msg}"
);
}
other => panic!("Expected Signer error, got: {other:?}"),
}
}
}