This document explains how the client SDK integrates with the Substrate blockchain.
The SDK uses subxt for blockchain interactions. The integration is organized in layers:
Wraps subxt's OnlineClient and provides:
- Connection management to substrate nodes
- Signer management (dev accounts and custom keypairs)
- Dynamic extrinsic builders for pallet calls
- Dynamic storage query builders
Provides common functionality:
- HTTP client for provider nodes
- Substrate client access
- Configuration management
- Helper utilities
Each specialized client (ProviderClient, AdminClient, etc.) uses the base client to:
- Submit extrinsics via
chain().api().tx().sign_and_submit_then_watch_default() - Query storage via
chain().api().storage() - Parse events from transaction results
use storage_client::{AdminClient, ClientConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create config
let config = ClientConfig {
chain_ws_url: "ws://localhost:2222".to_string(),
provider_urls: vec!["http://localhost:3333".to_string()],
timeout_secs: 30,
enable_retries: true,
};
// Create client and connect to chain
let mut client = AdminClient::new(config, "5GrwvaEF...".to_string())?;
client.base.connect_chain().await?;
// Set signer (for testing)
client.base = client.base.with_dev_signer("alice")?;
// Now you can make on-chain calls
let bucket_id = client.create_bucket(2).await?;
println!("Created bucket: {}", bucket_id);
Ok(())
}For production, use actual keypairs instead of dev accounts:
use subxt_signer::sr25519::Keypair;
// Load from seed phrase or keystore
let keypair = Keypair::from_uri("//Alice")?;
// Set signer
if let Some(chain_client) = client.base.chain_client.as_mut() {
let client = Arc::make_mut(chain_client);
*client = client.clone().with_signer(keypair);
}The substrate module uses dynamic extrinsics (not requiring compile-time metadata):
// Example: Register provider
pub fn register_provider(multiaddr: Vec<u8>, public_key: Vec<u8>) -> impl Payload {
subxt::dynamic::tx(
"StorageProvider", // Pallet name
"register_provider", // Call name
vec![
subxt::dynamic::Value::from_bytes(multiaddr),
subxt::dynamic::Value::from_bytes(public_key),
],
)
}Benefits:
- No compile-time dependency on runtime metadata
- Works across runtime upgrades (as long as call signatures match)
- Easier to build and distribute
Drawbacks:
- Less type safety (errors at runtime instead of compile time)
- Must manually keep call signatures in sync with pallet
Similarly, storage queries use dynamic access:
pub fn provider_info(account: &AccountId32) -> Address<subxt::dynamic::Value, (), (), ()> {
subxt::dynamic::storage(
"StorageProvider", // Pallet name
"Providers", // Storage item name
vec![subxt::dynamic::Value::from_bytes(account.as_ref())],
)
}-
ProviderClient:
register()- Register as storage provideraccept_agreement()- Accept storage agreementrespond_to_challenge()- Respond to data challenge
-
AdminClient:
create_bucket()- Create new bucketrequest_agreement()- Request storage from provider
-
ChallengerClient:
challenge_checkpoint()- Challenge provider data
Methods marked with // TODO: Submit extrinsic still use placeholder logic but have the infrastructure in place. To complete them:
- Add the extrinsic builder to
src/substrate.rs::extrinsics - Update the client method to call the builder
- Submit and wait for finalization
- Event Parsing: Extract data from transaction events (e.g., bucket IDs, challenge IDs)
- Storage Queries: Implement query methods for reading on-chain state
- Runtime API Calls: Use the custom Runtime API for complex queries
- Error Handling: Map substrate errors to ClientError variants
- Batch Operations: Support submitting multiple extrinsics in one transaction
For production, generate and include runtime metadata:
# Connect to your running node
subxt metadata -f bytes > client/metadata.scaleThen use the codegen macro in substrate.rs:
#[subxt::subxt(runtime_metadata_path = "metadata.scale")]
pub mod runtime {}This provides:
- Compile-time type checking
- Auto-generated types for all pallets
- Better IDE support and documentation
- Never hardcode private keys
- Use keystore files or hardware wallets
- Implement proper key rotation
- Consider using proxy accounts for operations
Current implementation uses basic error mapping. For production:
- Parse specific substrate errors
- Retry transient failures
- Handle nonce issues
- Monitor finalization delays
Current implementation waits for finalization. Consider:
- Using
wait_for_in_block()for faster confirmation - Implementing transaction status callbacks
- Tracking transaction lifetime and expiry
- Handling transaction drops
- Implement reconnection logic
- Handle node upgrades gracefully
- Support multiple endpoint failover
- Monitor connection health
Mock the substrate client for testing business logic:
#[cfg(test)]
mod tests {
#[tokio::test]
async fn test_register_provider() {
// Use testnet or local dev chain
let config = ClientConfig {
chain_ws_url: "ws://localhost:2222".to_string(),
// ...
};
let mut client = ProviderClient::new(config, "5FHne...".to_string())?;
client.base.connect_chain().await?;
client.base = client.base.with_dev_signer("bob")?;
let result = client.register(
"/ip4/127.0.0.1/tcp/3333".to_string(),
vec![0u8; 32],
1_000_000_000_000,
).await;
assert!(result.is_ok());
}
}Run against a local development chain:
# Terminal 1: Start local node
cargo build --release
./target/release/storage-parachain-node --dev
# Terminal 2: Run tests
cd client
cargo test --features integration-testsEnsure you call connect_chain() before making on-chain calls:
client.base.connect_chain().await?;Set a signer before submitting extrinsics:
client.base = client.base.with_dev_signer("alice")?;Check:
- Account has sufficient balance for fees
- Pallet call name matches runtime
- Arguments match pallet call signature
- Nonce is correct (usually handled automatically)
Increase timeout in config:
let config = ClientConfig {
timeout_secs: 60, // Increase from default 30
// ...
};