Design Pattern for Program Clients
- Understanding Solana's Client Architecture: A Deep Dive into Rust Type System Usage
Understanding Solana's Client Architecture: A Deep Dive into Rust Type System Usage
Introduction
Solana's client architecture provides an excellent example of leveraging Rust's type system to create a flexible, type-safe, and maintainable design.
In this post, we'll explore how Solana implements different client types for various use cases while maintaining a unified interface.
The Three Client Types
The solana-program-library crate implements three main client types:
ProgramRpcClient: For production RPC interactionsProgramBanksClient: For testing and simulationProgramOfflineClient: For offline transaction signing
Let's take a look at each of these client types.
1. ProgramBanksClient
ProgramBanksClient has two fields:
context: AProgramBanksClientContextenum that can be eitherClientorContextsend: This is of generic typeST, which implements theSendTransactionBanksClientandSimulateTransactionBanksClienttraits to send transactions(seesendmethod) and simulate transactions(seesimulatemethod).
#![allow(unused)] fn main() { pub struct ProgramBanksClient<ST> { context: ProgramBanksClientContext, send: ST, } enum ProgramBanksClientContext { Client(Arc<Mutex<BanksClient>>), Context(Arc<Mutex<ProgramTestContext>>), } #[async_trait] impl<ST> ProgramClient<ST> for ProgramBanksClient<ST> where ST: SendTransactionBanksClient + SimulateTransactionBanksClient + Send + Sync, {} /// Extends basic `SendTransaction` trait with function `send` where client is /// `&mut BanksClient`. Required for `ProgramBanksClient`. pub trait SendTransactionBanksClient: SendTransaction { fn send<'a>( &self, client: &'a mut BanksClient, transaction: Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::Output>>; } /// Extends basic `SimulateTransaction` trait with function `simulation` where /// client is `&mut BanksClient`. Required for `ProgramBanksClient`. pub trait SimulateTransactionBanksClient: SimulateTransaction { fn simulate<'a>( &self, client: &'a mut BanksClient, transaction: Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::SimulationOutput>>; } /// Basic trait for sending transactions to validator. pub trait SendTransaction { type Output; } }
ProgramBanksClient is primarily used for:
- Testing Solana Programs: It enables developers to simulate and send transactions to a Solana program while running tests, allowing for verification of program behavior without the need for an actual on-chain deployment.
- Encapsulation of Client Logic: The client encapsulates the logic required to send and simulate transactions, making it easier to manage and modify as testing needs evolve.
There are two variants of ProgramBanksClientContext:
Client(Arc<Mutex<BanksClient>>): Provides streamlined access for basic transaction processing and simple unit tests. It's lightweight and ideal for single transaction validation and basic program interaction testing.Context(Arc<Mutex<ProgramTestContext>>): Offers a comprehensive testing environment with advanced features like custom genesis configuration, block management, fee payer accounts, and program deployment capabilities. This makes it suitable for integration testing, multi-transaction scenarios, and complex state management testing where full lifecycle control is needed.
According to the documentation, BanksClient is a client for the ledger state, from the perspective of an arbitrary validator. It serves as a client interface for interacting with the Solana blockchain's in-memory state during testing.
2. ProgramRpcClient
The ProgramRpcClient<ST> struct is designed to serve as a client for interacting with the Solana blockchain through the RpcClient.
#![allow(unused)] fn main() { pub struct ProgramRpcClient<ST> { client: Arc<RpcClient>, send: ST, } #[async_trait] impl<ST> ProgramClient<ST> for ProgramRpcClient<ST> where ST: SendTransactionRpc + SimulateTransactionRpc + Send + Sync, {} /// Extends basic `SendTransaction` trait with function `send` where client is /// `&RpcClient`. Required for `ProgramRpcClient`. pub trait SendTransactionRpc: SendTransaction { fn send<'a>( &self, client: &'a RpcClient, transaction: &'a Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::Output>>; } /// Extends basic `SimulateTransaction` trait with function `simulate` where /// client is `&RpcClient`. Required for `ProgramRpcClient`. pub trait SimulateTransactionRpc: SimulateTransaction { fn simulate<'a>( &self, client: &'a RpcClient, transaction: &'a Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::SimulationOutput>>; } }
- Used for regular RPC interactions with Solana nodes
- Wraps the standard
RpcClientfrom solana-client - Communicates with Solana nodes over JSON-RPC protocol
- Suitable for standard production environments
3. ProgramOfflineClient
#![allow(unused)] fn main() { pub struct ProgramOfflineClient<ST> { blockhash: Hash, _send: ST, } #[async_trait] impl<ST> ProgramClient<ST> for ProgramOfflineClient<ST> where ST: SendTransaction<Output = RpcClientResponse> + SimulateTransaction<SimulationOutput = RpcClientResponse> + Send + Sync, {} }
- Designed for offline transaction signing
- Doesn't require network connection
- Limited functionality (can't fetch accounts or rent)
- Useful for cold wallet scenarios
Type System Design
1. Unified Interface Through Traits
The ProgramClient trait serves as the foundational interface that all client implementations must adhere to, defining the core functionality for interacting with Solana programs:
#![allow(unused)] fn main() { #[async_trait] pub trait ProgramClient<ST> where ST: SendTransaction + SimulateTransaction, { async fn get_minimum_balance_for_rent_exemption(&self, data_len: usize) -> ProgramClientResult<u64>; async fn get_latest_blockhash(&self) -> ProgramClientResult<Hash>; async fn send_transaction(&self, transaction: &Transaction) -> ProgramClientResult<ST::Output>; async fn get_account(&self, address: Pubkey) -> ProgramClientResult<Option<Account>>; async fn simulate_transaction(&self, transaction: &Transaction) -> ProgramClientResult<ST::SimulationOutput>; } }
It contains 5 methods:
get_minimum_balance_for_rent_exemption: Get the minimum balance required for rent exemption.get_latest_blockhash: Get the latest blockhash.send_transaction: Send a transaction.get_account: Get an account.simulate_transaction: Simulate a transaction.
2. Transaction Handling Traits
In Solana's client architecture, there are base traits (SendTransaction and SimulateTransaction) that define what operations like sending and simulating transactions are possible.
#![allow(unused)] fn main() { /// Basic trait for sending transactions to validator. pub trait SendTransaction { type Output; } /// Basic trait for simulating transactions in a validator. pub trait SimulateTransaction { type SimulationOutput: SimulationResult; } /// Trait for the output of a simulation pub trait SimulationResult { fn get_compute_units_consumed(&self) -> ProgramClientResult<u64>; } }
However, there are different client types, i.e. Banks(ProgramBanksClient), RPC(ProgramRpcClient), Offline(ProgramOfflineClient). How do they implement these operations differently?
We can define different traits to extend the basic SendTransaction and SimulateTransaction traits and add trait bounds to the generic parameter ST of ProgramClient to ensure that the concrete type ST implements these operations differently.
Here are the extended traits:
SendTransactionBanksClient: ExtendsSendTransactionwith asendmethod that takes a&mut BanksClientand aTransaction.SimulateTransactionBanksClient: ExtendsSimulateTransactionwith asimulatemethod that takes a&mut BanksClientand aTransaction.SendTransactionRpc: ExtendsSendTransactionwith asendmethod that takes a&RpcClientand a&Transaction.SimulateTransactionRpc: ExtendsSimulateTransactionwith asimulatemethod that takes a&RpcClientand a&Transaction.
#![allow(unused)] fn main() { /// Extends basic `SendTransaction` trait with function `send` where client is /// `&RpcClient`. Required for `ProgramRpcClient`. pub trait SendTransactionRpc: SendTransaction { fn send<'a>( &self, client: &'a RpcClient, transaction: &'a Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::Output>>; } // Extends basic `SendTransaction` trait with function `send` where client is /// `&mut BanksClient`. Required for `ProgramBanksClient`. pub trait SendTransactionBanksClient: SendTransaction { fn send<'a>( &self, client: &'a mut BanksClient, transaction: Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::Output>>; } /// Extends basic `SimulateTransaction` trait with function `simulate` where /// client is `&RpcClient`. Required for `ProgramRpcClient`. pub trait SimulateTransactionRpc: SimulateTransaction { fn simulate<'a>( &self, client: &'a RpcClient, transaction: &'a Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::SimulationOutput>>; } /// Extends basic `SimulateTransaction` trait with function `simulation` where /// client is `&mut BanksClient`. Required for `ProgramBanksClient`. pub trait SimulateTransactionBanksClient: SimulateTransaction { fn simulate<'a>( &self, client: &'a mut BanksClient, transaction: Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::SimulationOutput>>; } }
So when implementing ProgramClient for ProgramRpcClient, we add trait bounds to the generic parameter ST to ensure that the concrete type ST implements the SendTransactionRpc and SimulateTransactionRpc traits.
#![allow(unused)] fn main() { impl<ST> ProgramClient<ST> for ProgramRpcClient<ST> where ST: SendTransactionRpc + SimulateTransactionRpc + Send + Sync, {} }
Similarly, when implementing ProgramClient for ProgramBanksClient, we add trait bounds to the generic parameter ST to ensure that the concrete type ST implements the SendTransactionBanksClient and SimulateTransactionBanksClient traits.
#![allow(unused)] fn main() { impl<ST> ProgramClient<ST> for ProgramBanksClient<ST> where ST: SendTransactionBanksClient + SimulateTransactionBanksClient + Send + Sync, {} }
And when implementing ProgramClient for ProgramOfflineClient, we add trait bounds to the generic parameter ST to ensure that the concrete type ST implements the SendTransaction and SimulateTransaction traits.
#![allow(unused)] fn main() { impl<ST> ProgramClient<ST> for ProgramOfflineClient<ST> where ST: SendTransaction<Output = RpcClientResponse> + SimulateTransaction<SimulationOutput = RpcClientResponse> + Send + Sync, {} }
So how to initialize these client types?
For ProgramRpcClient, we can initialize it like this:
#![allow(unused)] fn main() { let rpc_client = Arc::new(RpcClient::new("http://api.mainnet-beta.solana.com")); let program_client = ProgramRpcClient::new( rpc_client, ProgramRpcClientSendTransaction ); }
ProgramRpcClientSendTransaction is a concrete type that implements the SendTransactionRpc trait.
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, Default)] pub struct ProgramRpcClientSendTransaction; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RpcClientResponse { Signature(Signature), Transaction(Transaction), Simulation(RpcSimulateTransactionResult), } impl SendTransaction for ProgramRpcClientSendTransaction { type Output = RpcClientResponse; } impl SendTransactionRpc for ProgramRpcClientSendTransaction { fn send<'a>( &self, client: &'a RpcClient, transaction: &'a Transaction, ) -> BoxFuture<'a, ProgramClientResult<Self::Output>> { Box::pin(async move { if !transaction.is_signed() { return Err("Cannot send transaction: not fully signed".into()); } client .send_and_confirm_transaction(transaction) .await .map(RpcClientResponse::Signature) .map_err(Into::into) }) } } }
We can also define another concrete type like ProgramRpcClientSendTransactionCustom that implements both SendTransaction and SimulateTransaction traits, and use it to initialize ProgramRpcClient. This helps us to customize the send behavior of ProgramRpcClient.
In other words, when you instantiate ProgramRpcClient( ProgramBanksClient or ProgramOfflineClient), you provide a concrete type for ST that implements the required traits. The send field will then be used in methods like send_transaction and simulate_transaction to invoke the appropriate logic for handling those operations.
#![allow(unused)] fn main() { let program_client = ProgramRpcClient::new( rpc_client, ProgramRpcClientSendTransactionCustom ); }
Implementing traits for different concrete types and using them to initialize ProgramRpcClient is a good example of the strategy pattern.
Design Patterns
Now, let's take a look at the design patterns used in the ProgramClient trait.
1. Strategy Pattern
The generic parameter ST implements the strategy pattern:
#![allow(unused)] fn main() { pub struct ProgramRpcClient<ST> { client: Arc<RpcClient>, send: ST, // Strategy for sending transactions } }
2. Adapter Pattern
Each client adapts a specific underlying implementation:
#![allow(unused)] fn main() { #[async_trait] impl<ST> ProgramClient<ST> for ProgramRpcClient<ST> where ST: SendTransactionRpc + SimulateTransactionRpc + Send + Sync, { async fn send_transaction(&self, transaction: &Transaction) -> ProgramClientResult<ST::Output> { self.send.send(&self.client, transaction).await } } #[async_trait] impl<ST> ProgramClient<ST> for ProgramBanksClient<ST> where ST: SendTransactionBanksClient + SimulateTransactionBanksClient + Send + Sync, { async fn send_transaction(&self, transaction: &Transaction) -> ProgramClientResult<ST::Output> { self.run_in_lock(|client| { let transaction = transaction.clone(); self.send.send(client, transaction) }) .await } } #[async_trait] impl<ST> ProgramClient<ST> for ProgramOfflineClient<ST> where ST: SendTransaction<Output = RpcClientResponse> + SimulateTransaction<SimulationOutput = RpcClientResponse> + Send + Sync, { async fn send_transaction(&self, transaction: &Transaction) -> ProgramClientResult<ST::Output> { Ok(RpcClientResponse::Transaction(transaction.clone())) } } }
3. Composition Pattern
The design uses composition over inheritance:
#![allow(unused)] fn main() { /// Program client for `BanksClient` from crate `solana-program-test`. pub struct ProgramBanksClient<ST> { context: ProgramBanksClientContext, send: ST, } /// Program client for `RpcClient` from crate `solana-client`. pub struct ProgramRpcClient<ST> { client: Arc<RpcClient>, send: ST, } /// Program client for offline signing. pub struct ProgramOfflineClient<ST> { blockhash: Hash, _send: ST, } }
The ProgramBanksClient struct composes of a context and a send field. The context field is an enum that can be either Client or Context. The send field is a generic type ST that implements the SendTransactionBanksClient and SimulateTransactionBanksClient traits.
The ProgramRpcClient struct composes of a client and a send field. The client field is an Arc<RpcClient>. The send field is a generic type ST that implements the SendTransactionRpc and SimulateTransactionRpc traits.
The ProgramOfflineClient struct composes of a blockhash and a _send field. The blockhash field is a Hash. The _send field is a generic type ST that implements the SendTransaction and SimulateTransaction traits.
Usage Examples
Banks Client for High Performance
#![allow(unused)] fn main() { let banks_client = Arc::new(Mutex::new(BanksClient::new(...))); let program_client = ProgramBanksClient::new_from_client( banks_client, ProgramBanksClientProcessTransaction ); }
RPC Client for Standard Usage
#![allow(unused)] fn main() { let rpc_client = Arc::new(RpcClient::new("http://api.mainnet-beta.solana.com")); let program_client = ProgramRpcClient::new( rpc_client, ProgramRpcClientSendTransaction ); }
Offline Client for Cold Wallets
#![allow(unused)] fn main() { let program_client = ProgramOfflineClient::new( blockhash, ProgramOfflineTransaction ); }
Benefits of This Design
-
Type Safety
- Compile-time guarantees
- No runtime type errors
- Clear interface contracts
-
Flexibility
- Easy to add new client types
- Customizable transaction handling
- Pluggable components
-
Performance
- Zero-cost abstractions
- Direct banking stage access when needed
- RPC interface when appropriate
-
Maintainability
- Clear separation of concerns
- Modular design
- Easy to test
Conclusion
Solana's client architecture demonstrates sophisticated use of Rust's type system to:
- Support different performance requirements
- Ensure type safety and correct usage
- Allow flexibility in transaction handling
- Maintain a consistent interface across implementations
The design shows how to leverage Rust's type system to create a robust and flexible architecture that can handle different use cases while maintaining type safety and a clean interface.