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 SolanaSigner trait (3 async methods + pubkey())
  • Add a feature flag in Cargo.toml
  • Update the Signer enum in src/lib.rs (4 match arms)
  • Update src/error.rs reqwest From impl 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 all

Step 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:

BackendUse CaseFeature Flag
MemoryLocal keypairs, development, testingmemory
VaultEnterprise key management with HashiCorp Vaultvault
PrivyEmbedded wallets with Privy infrastructureprivy
TurnkeyNon-custodial key management via Turnkeyturnkey
YourServiceBrief description of your serviceyour_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-features

TypeScript Signer

If you're also adding a TypeScript signer package, create it at typescript/packages/your-signer/. Key patterns:

  • Factory function createYourSigner() returns SolanaSigner<TAddress>
  • Export config interface (YourSignerConfig)
  • Enforce HTTPS on apiBaseUrl config 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 @throws JSDoc to factory functions listing error codes

Update Umbrella Package

Update typescript/packages/keychain/ — 6 files to modify:

  1. src/types.ts — Add YourSignerConfig to KeychainSignerConfig discriminated union
  2. src/create-keychain-signer.ts — Import factory, add switch case
  3. src/resolve-address.ts — Add to fast-path or fetch-path switch case
  4. src/index.ts — Add config type, namespace, factory fn, and class exports
  5. package.json — Add @solana/keychain-your-signer: "workspace:*" dependency
  6. tsconfig.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 Debug impl 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> (not Pubkey::default()) for the public key field before init()

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, synchronous
    • src/para/mod.rs — Requires initialization (use as pattern for new signers)
    • src/turnkey/mod.rs — Complex signature handling
    • src/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