DEV Community

Cover image for MPP TestKit - Rust SDK
MPP TestKit
MPP TestKit

Posted on

MPP TestKit - Rust SDK

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"] }
Enter fullscreen mode Exit fullscreen mode

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}");
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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"})),
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Drop-in fetch (shared wallet)

use mpp_test_sdk::mpp_fetch;

let resp = mpp_fetch("http://localhost:3001/api/data", None).await.unwrap();
Enter fullscreen mode Exit fullscreen mode

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}"),
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Links

Top comments (0)