Adding New Signers
Guide for wallet service providers to integrate new key management solutions
This guide is for wallet service providers and developers who want to integrate new
key management solutions into the solana-keychain library. By adding your signer
implementation, you'll enable developers to use your service for secure Solana
transaction signing through a unified interface.
Using an LLM? Check out the Adding Signers Skill.
Architecture Overview
The library uses a trait-based architecture where all signers implement the
SolanaSigner trait defined in src/traits.rs. The library also provides a unified
Signer enum that wraps all implementations, allowing runtime selection of signing
backends while maintaining a consistent API.
Quick Integration Checklist
- Create your signer module with implementation
- Implement the
SolanaSignertrait (3 async methods +pubkey()) - Add a feature flag in
Cargo.toml - Update the
Signerenum insrc/lib.rs(4 match arms) - Update
src/error.rsreqwestFromimpl cfg gate (if your signer uses reqwest) - Enforce HTTPS and configure timeouts on HTTP clients
- Add comprehensive tests
- Update documentation
- Submit PR
Step 1: Create Your Signer Module
Create a new directory under src/ for your implementation:
src/
├── your_service/
│ ├── mod.rs # Main implementation with SolanaSigner trait
│ └── types.rs # API request/response types (if needed)Step 2: Define Your Signer Struct
In src/your_service/mod.rs, define your signer struct:
//! YourService API signer integration
use crate::{error::SignerError, traits::SolanaSigner};
use solana_sdk::{pubkey::Pubkey, signature::Signature, transaction::Transaction};
use std::str::FromStr;
/// YourService-based signer using YourService's API
#[derive(Clone)]
pub struct YourServiceSigner {
api_key: String,
api_secret: String,
wallet_id: String,
api_base_url: String,
client: reqwest::Client,
public_key: Pubkey,
}
impl std::fmt::Debug for YourServiceSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("YourServiceSigner")
.field("public_key", &self.public_key)
.finish_non_exhaustive()
}
}Step 3: Implement Constructor and Helper Methods
Remote signers must enforce HTTPS and configure HTTP timeouts. Use the shared
HttpClientConfig struct for timeout settings.
use crate::http_client_config::HttpClientConfig;
impl YourServiceSigner {
pub fn new(
api_key: String,
api_secret: String,
wallet_id: String,
public_key: String,
http_config: Option<HttpClientConfig>,
) -> Result<Self, SignerError> {
let pubkey = Pubkey::from_str(&public_key)
.map_err(|e| SignerError::InvalidPublicKey(format!("Invalid public key: {e}")))?;
let http = http_config.unwrap_or_default();
let builder = reqwest::Client::builder()
.timeout(http.resolved_request_timeout())
.connect_timeout(http.resolved_connect_timeout());
// Enforce HTTPS in production; wiremock uses HTTP in tests
#[cfg(not(test))]
let builder = builder.https_only(true);
let client = builder.build().map_err(|e| {
SignerError::ConfigError(format!("Failed to build HTTP client: {e}"))
})?;
Ok(Self {
api_key,
api_secret,
wallet_id,
api_base_url: "https://api.yourservice.com/v1".to_string(),
client,
public_key: pubkey,
})
}
/// Sign raw bytes using your service's API
async fn sign(&self, message: &[u8]) -> Result<Signature, SignerError> {
let encoded_message = base64::engine::general_purpose::STANDARD.encode(message);
let url = format!("{}/sign", self.api_base_url);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.json(&serde_json::json!({
"wallet_id": self.wallet_id,
"message": encoded_message,
}))
.send()
.await?;
// Use generic error messages — never expose raw API response text
if !response.status().is_success() {
let status = response.status().as_u16();
return Err(SignerError::RemoteApiError(format!(
"YourService API returned status {status}"
)));
}
// Parse response — always use map_err, never .expect() or .unwrap()
let response_data: SignResponse = response
.json()
.await
.map_err(|e| SignerError::SerializationError(format!("Failed to parse response: {e}")))?;
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(&response_data.signature)
.map_err(|e| SignerError::SerializationError(format!("Failed to decode signature: {e}")))?;
let sig_array: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| SignerError::SigningFailed("Invalid signature length".to_string()))?;
Ok(Signature::from(sig_array))
}
}Step 4: Implement the SolanaSigner Trait
The trait has 3 async methods (sign_transaction, sign_message, is_available)
plus pubkey(). Note that sign_transaction returns SignTransactionResult — a
tagged enum indicating whether the transaction is fully signed or partially signed.
Use the shared TransactionUtil helpers for signing and serialization.
use crate::transaction_util::TransactionUtil;
use crate::traits::SignTransactionResult;
#[async_trait::async_trait]
impl SolanaSigner for YourServiceSigner {
fn pubkey(&self) -> Pubkey {
self.public_key
}
async fn sign_transaction(
&self,
tx: &mut Transaction,
) -> Result<SignTransactionResult, SignerError> {
let tx_bytes = bincode::serialize(tx)
.map_err(|e| SignerError::SerializationError(format!("Failed to serialize: {e}")))?;
let signature = self.sign(&tx_bytes).await?;
// Add the signature at the correct position
TransactionUtil::add_signature_to_transaction(tx, &self.public_key, signature)?;
// Serialize and classify as Complete or Partial
let serialized = TransactionUtil::serialize_transaction(tx)?;
Ok(TransactionUtil::classify_signed_transaction(
tx,
(serialized, signature),
))
}
async fn sign_message(&self, message: &[u8]) -> Result<Signature, SignerError> {
self.sign(message).await
}
async fn is_available(&self) -> bool {
let url = format!("{}/health", self.api_base_url);
self.client
.get(&url)
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
}Step 5: Add API Types (Optional)
If your API needs custom types, create src/your_service/types.rs:
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct SignRequest {
pub wallet_id: String,
pub message: String,
}
#[derive(Deserialize)]
pub struct SignResponse {
pub signature: String,
}Step 6: Add Feature Flag
Update Cargo.toml to add your signer as an optional feature:
[features]
default = ["memory"]
memory = []
vault = ["dep:reqwest", "dep:vaultrs", "dep:base64"]
privy = ["dep:reqwest", "dep:base64"]
turnkey = ["dep:reqwest", "dep:base64", "dep:p256", "dep:hex", "dep:chrono"]
your_service = ["dep:reqwest", "dep:base64"] # Add your feature
all = ["memory", "vault", "privy", "turnkey", "your_service"] # Update allStep 7: Update the Signer Enum
Add your signer to src/lib.rs. You need 4 match arms in the SolanaSigner impl:
pubkey, sign_transaction, sign_message, and is_available.
// Add feature-gated module
#[cfg(feature = "your_service")]
pub mod your_service;
// Re-export your signer type
#[cfg(feature = "your_service")]
pub use your_service::YourServiceSigner;
// Add to Signer enum
#[derive(Debug)]
pub enum Signer {
#[cfg(feature = "memory")]
Memory(MemorySigner),
// ... existing variants
#[cfg(feature = "your_service")]
YourService(YourServiceSigner), // Add your variant
}
// Add constructor method
impl Signer {
#[cfg(feature = "your_service")]
pub fn from_your_service(
api_key: String,
api_secret: String,
wallet_id: String,
public_key: String,
) -> Result<Self, SignerError> {
Ok(Self::YourService(YourServiceSigner::new(
api_key,
api_secret,
wallet_id,
public_key,
None, // uses default HttpClientConfig
)?))
}
}
// Update trait implementation — 4 match arms
#[async_trait::async_trait]
impl SolanaSigner for Signer {
fn pubkey(&self) -> sdk_adapter::Pubkey {
match self {
// ... existing variants
#[cfg(feature = "your_service")]
Signer::YourService(s) => s.pubkey(),
}
}
async fn sign_transaction(
&self,
tx: &mut sdk_adapter::Transaction,
) -> Result<SignTransactionResult, SignerError> {
match self {
// ... existing variants
#[cfg(feature = "your_service")]
Signer::YourService(s) => s.sign_transaction(tx).await,
}
}
async fn sign_message(
&self,
message: &[u8],
) -> Result<sdk_adapter::Signature, SignerError> {
match self {
// ... existing variants
#[cfg(feature = "your_service")]
Signer::YourService(s) => s.sign_message(message).await,
}
}
async fn is_available(&self) -> bool {
match self {
// ... existing variants
#[cfg(feature = "your_service")]
Signer::YourService(s) => s.is_available().await,
}
}
}If your signer uses reqwest, add your feature to the #[cfg(any(...))] gate on
the From<reqwest::Error> impl in src/error.rs.
Step 8: Add Comprehensive Tests
Add tests to your module (at the bottom of src/your_service/mod.rs):
#[cfg(test)]
mod tests {
use super::*;
use solana_sdk::{signature::Keypair, signer::Signer};
use wiremock::{
matchers::{header, method, path},
Mock, MockServer, ResponseTemplate,
};
#[tokio::test]
async fn test_new() {
let keypair = Keypair::new();
let signer = YourServiceSigner::new(
"test-key".to_string(),
"test-secret".to_string(),
"test-wallet".to_string(),
keypair.pubkey().to_string(),
None,
);
assert!(signer.is_ok());
}
#[tokio::test]
async fn test_sign_message() {
let mock_server = MockServer::start().await;
let keypair = Keypair::new();
let message = b"test message";
let signature = keypair.sign_message(message);
Mock::given(method("POST"))
.and(path("/sign"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"signature": base64::engine::general_purpose::STANDARD.encode(signature.as_ref())
})))
.expect(1)
.mount(&mock_server)
.await;
let mut signer = YourServiceSigner::new(
"test-key".to_string(),
"test-secret".to_string(),
"test-wallet".to_string(),
keypair.pubkey().to_string(),
None,
).unwrap();
signer.api_base_url = mock_server.uri();
let result = signer.sign_message(message).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_sign_unauthorized() {
let mock_server = MockServer::start().await;
let keypair = Keypair::new();
Mock::given(method("POST"))
.and(path("/sign"))
.respond_with(ResponseTemplate::new(401))
.expect(1)
.mount(&mock_server)
.await;
let mut signer = YourServiceSigner::new(
"bad-key".to_string(),
"bad-secret".to_string(),
"test-wallet".to_string(),
keypair.pubkey().to_string(),
None,
).unwrap();
signer.api_base_url = mock_server.uri();
let result = signer.sign_message(b"test").await;
assert!(result.is_err());
}
}Step 9: Update Documentation
Add your signer to the supported backends table in README.md:
| Backend | Use Case | Feature Flag |
|---|---|---|
| Memory | Local keypairs, development, testing | memory |
| Vault | Enterprise key management with HashiCorp Vault | vault |
| Privy | Embedded wallets with Privy infrastructure | privy |
| Turnkey | Non-custodial key management via Turnkey | turnkey |
| YourService | Brief description of your service | your_service |
Add usage example:
use solana_keychain::{Signer, SolanaSigner};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let signer = Signer::from_your_service(
"your-api-key".to_string(),
"your-api-secret".to_string(),
"your-wallet-id".to_string(),
"your-public-key".to_string(),
)?;
let pubkey = signer.pubkey();
println!("Public key: {}", pubkey);
Ok(())
}Testing Your Integration
Run tests for your feature:
# Test only your signer
cargo test --features your_service
# Test with all features
cargo test --all-featuresTypeScript Signer
If you're also adding a TypeScript signer package, create it at
typescript/packages/your-signer/. Key patterns:
- Factory function
createYourSigner()returnsSolanaSigner<TAddress> - Export config interface (
YourSignerConfig) - Enforce HTTPS on
apiBaseUrlconfig fields - Sanitize remote API error text with
sanitizeRemoteErrorResponse()from@solana/keychain-core - Guard against malformed JSON with optional chaining and try/catch
- Use
throwSignerError(SignerErrorCode.*, { cause, message })from@solana/keychain-core - Add
@throwsJSDoc to factory functions listing error codes
Update Umbrella Package
Update typescript/packages/keychain/ — 6 files to modify:
src/types.ts— AddYourSignerConfigtoKeychainSignerConfigdiscriminated unionsrc/create-keychain-signer.ts— Import factory, add switch casesrc/resolve-address.ts— Add to fast-path or fetch-path switch casesrc/index.ts— Add config type, namespace, factory fn, and class exportspackage.json— Add@solana/keychain-your-signer: "workspace:*"dependencytsconfig.json— Add{ "path": "../your-signer" }reference
The switch statements have exhaustive never checks — TypeScript will error if you
add to the union but miss a case.
Submission Checklist
Before submitting your PR:
- Code compiles without warnings (
just build) - All tests pass (
just test) - Code is formatted/linting passes (
just fmt) - No hardcoded values or secrets in code
- Error messages are generic (no raw API response text)
- HTTPS enforced on remote HTTP clients
- HTTP timeouts configured via
HttpClientConfig - Follows Rust naming conventions (snake_case)
- Added to README.md supported backends table
Implementation Tips
Error Handling
Always use the existing SignerError variants. Never use .expect() or .unwrap()
on untrusted API responses:
// Good — uses existing error types with generic messages
return Err(SignerError::RemoteApiError(
format!("YourService API returned status {}", status)
));
// Good — converts from standard errors
let bytes = base64::decode(data)
.map_err(|e| SignerError::SerializationError(format!("Failed to decode: {e}")))?;Security Best Practices
- Never log sensitive data (private keys, API secrets)
- Use
Debugimpl that hides sensitive fields - Validate all inputs (public keys, signatures)
- Use HTTPS for all remote API calls (enforced via
https_only(true)) - Configure request and connect timeouts via
HttpClientConfig - Never expose raw remote API error text in error messages
- Use
Option<Pubkey>(notPubkey::default()) for the public key field beforeinit()
Testing with Mocks
Use wiremock for mocking HTTP APIs. Assert on error type only, not error message text:
#[cfg(test)]
mod tests {
use wiremock::{MockServer, Mock, ResponseTemplate};
#[tokio::test]
async fn test_api_call() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&mock_server)
.await;
// Use mock_server.uri() as your api_base_url
}
}Getting Help
- Review existing signer implementations for patterns:
src/memory/mod.rs— Simple, synchronoussrc/para/mod.rs— Requires initialization (use as pattern for new signers)src/turnkey/mod.rs— Complex signature handlingsrc/vault/mod.rs— External client library
- Key files:
src/traits.rs(trait definition),src/transaction_util.rs(shared helpers),src/http_client_config.rs(timeout config) - Open an issue for design discussions before starting work
Example PR Structure
feat(signer): add YourService signer integration
Adds support for YourService as a signing backend.
- [X] Code compiles without warnings (`just build`)
- [X] Code is formatted/linting passes (`just fmt`)
- [X] Add comprehensive tests with wiremock - All tests pass (`just test`)
- [X] Implemented SolanaSigner trait for YourServiceSigner
- [X] Added feature flag 'your_service'
- [X] HTTPS enforced, HTTP timeouts configured
- [X] Added to README.md supported backends table
Closes #1337