The Rust MPP SDK (mpp-test-sdk on crates.io) implements the full Solana HTTP 402 payment flow in pure async Rust. No Solana SDK dependency. Ed25519 signing, base58, and compact-u16 transaction encoding — all from scratch with ed25519-dalek and num-bigint. Framework-agnostic server middleware via a ChargeResult enum.
Why Rust and HTTP 402?
Rust is increasingly the language of high-performance APIs, WebAssembly services, and systems that need both speed and correctness. If you're building something in Rust that serves data — and you want to charge per request without billing infrastructure — HTTP 402 on Solana is the natural fit.
The challenge is that most HTTP 402 / Solana tooling targets TypeScript or Python. The Rust MPP SDK changes that: pure Rust, async-first, no cgo, no Solana SDK, no wallet binary — just tokio, reqwest, ed25519-dalek, and the Solana JSON-RPC.
Installation
[dependencies]
mpp-test-sdk = "1.0"
tokio = { version = "1", features = ["full"] }
Client
use mpp_test_sdk::{create_test_client, TestClientConfig};
#[tokio::main]
async fn main() {
let client = create_test_client(TestClientConfig {
on_step: Some(Box::new(|step| {
println!("[{:?}] {}", step.step_type, step.message);
})),
..Default::default() // network: devnet, timeout: 30s
})
.await
.unwrap();
println!("Wallet: {}", client.address);
let resp = client.fetch("http://localhost:3001/api/data", None).await.unwrap();
let body = resp.text().await.unwrap();
println!("{body}");
}
create_test_client generates an ed25519 keypair, airdrops 2 SOL on devnet (with 1s → 2s → 4s back-off on faucet rate limits), and returns a TestClient whose fetch method handles the entire 402 flow.
Server (framework-agnostic)
The MppServer::charge method returns a ChargeResult enum instead of directly writing to a response. This means it works with any Rust web framework — axum, actix-web, warp, hyper — without a hard dependency on any of them.
use mpp_test_sdk::{create_test_server, ChargeOptions, ChargeResult, TestServerConfig};
let server = create_test_server(TestServerConfig::default()).unwrap();
println!("Recipient: {}", server.recipient_address);
Axum
use axum::{extract::State, http::HeaderMap, response::IntoResponse, Json};
use mpp_test_sdk::{ChargeOptions, ChargeResult, MppServer};
use std::sync::Arc;
async fn data_handler(
State(srv): State<Arc<MppServer>>,
headers: HeaderMap,
) -> impl IntoResponse {
let receipt = headers
.get("payment-receipt")
.and_then(|v| v.to_str().ok());
match srv.charge(receipt, &ChargeOptions { amount: "0.001" }).await {
ChargeResult::NeedsPayment { payment_request_header, body } => (
[(
axum::http::header::HeaderName::from_static("payment-request"),
payment_request_header,
)],
axum::http::StatusCode::PAYMENT_REQUIRED,
Json(body),
)
.into_response(),
ChargeResult::Denied(reason) => {
(axum::http::StatusCode::FORBIDDEN, reason).into_response()
}
ChargeResult::Authorized => {
Json(serde_json::json!({"result": "here is your data"})).into_response()
}
}
}
Actix-web
use actix_web::{web, HttpRequest, HttpResponse};
use mpp_test_sdk::{ChargeOptions, ChargeResult, MppServer};
use std::sync::Arc;
async fn data_handler(
srv: web::Data<Arc<MppServer>>,
req: HttpRequest,
) -> HttpResponse {
let receipt = req
.headers()
.get("payment-receipt")
.and_then(|v| v.to_str().ok());
match srv.charge(receipt, &ChargeOptions { amount: "0.001" }).await {
ChargeResult::NeedsPayment { payment_request_header, body } => HttpResponse::PaymentRequired()
.insert_header(("payment-request", payment_request_header))
.json(body),
ChargeResult::Denied(reason) => HttpResponse::Forbidden().body(reason),
ChargeResult::Authorized => HttpResponse::Ok().json(serde_json::json!({"result": "here is your data"})),
}
}
What happens under the hood
The SDK builds a Solana legacy transaction entirely in safe Rust:
compact-u16(1) // signature count
[64]byte // ed25519 signature (ed25519-dalek)
Message:
[3]byte // header: required_sigs=1, ro_signed=0, ro_unsigned=1
compact-u16(3) // account key count
[32*3]byte // [from, to, system_program]
[32]byte // recent blockhash (fetched via JSON-RPC)
compact-u16(1) // instruction count
u8(2) // program_id_index = system program
compact-u16(2) // account indices
compact-u16(12) // data length
[4]byte LE // SystemInstruction::Transfer = 2
[8]byte LE // lamports (u64)
Base58 is implemented with num-bigint. No Solana SDK. No generated code. No cgo.
Lifecycle events
use mpp_test_sdk::{create_test_client, PaymentStepType, TestClientConfig};
let client = create_test_client(TestClientConfig {
on_step: Some(Box::new(|step| match step.step_type {
PaymentStepType::WalletCreated => println!("Wallet: {}", step.message),
PaymentStepType::Funded => println!("Funded via devnet airdrop"),
PaymentStepType::Payment => println!("Paying: {}", step.message),
PaymentStepType::Success => println!("Done: {}", step.message),
_ => {}
})),
..Default::default()
})
.await
.unwrap();
Steps: WalletCreated, Funded, Request, Payment, Retry, Success, Error.
Mainnet
use mpp_test_sdk::{create_test_client, SolanaNetwork, TestClientConfig};
let client = create_test_client(TestClientConfig {
network: Some(SolanaNetwork::Mainnet),
secret_key: Some(my_keypair_bytes), // 32-byte seed or 64-byte keypair
..Default::default()
})
.await
.unwrap();
Drop-in fetch (shared wallet)
use mpp_test_sdk::mpp_fetch;
let resp = mpp_fetch("http://localhost:3001/api/data", None).await.unwrap();
Uses a lazily-created Arc<TestClient> behind a tokio::sync::Mutex, safe across tasks. Call reset_mpp_fetch().await to discard it.
Error handling
use mpp_test_sdk::Error;
match client.fetch("http://localhost:3001/api/data", None).await {
Ok(resp) => println!("Status: {}", resp.status()),
Err(Error::Faucet(e)) => eprintln!("Airdrop failed for {}", e.address),
Err(Error::Payment(e)) => eprintln!("Payment failed, status: {}", e.status),
Err(Error::Timeout(e)) => eprintln!("Timed out after {}ms", e.timeout_ms),
Err(Error::Network(e)) => eprintln!("Network error: {}", e.network),
Err(Error::Other(msg)) => eprintln!("Error: {msg}"),
}
Testing
#[tokio::test]
async fn test_charges_per_request() {
use mpp_test_sdk::{create_test_client, TestClientConfig};
// Start your axum/actix server in a background task, get port
// ...
let client = create_test_client(TestClientConfig::default()).await.unwrap();
let resp = client.fetch(&format!("http://localhost:{port}/api/data"), None).await.unwrap();
assert_eq!(resp.status().as_u16(), 200);
}
Links
- crates.io: mpp-test-sdk
- GitHub: sdk-rust
- mpptestkit.com — live playground
- GitHub: mpptestkit — organisation
Top comments (0)