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
: AProgramBanksClientContext
enum that can be eitherClient
orContext
send
: This is of generic typeST
, which implements theSendTransactionBanksClient
andSimulateTransactionBanksClient
traits to send transactions(seesend
method) and simulate transactions(seesimulate
method).
#![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
RpcClient
from 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
: ExtendsSendTransaction
with asend
method that takes a&mut BanksClient
and aTransaction
.SimulateTransactionBanksClient
: ExtendsSimulateTransaction
with asimulate
method that takes a&mut BanksClient
and aTransaction
.SendTransactionRpc
: ExtendsSendTransaction
with asend
method that takes a&RpcClient
and a&Transaction
.SimulateTransactionRpc
: ExtendsSimulateTransaction
with asimulate
method that takes a&RpcClient
and 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.