DEV Community

Cover image for MPP TestKit - NPM SDK
MPP TestKit
MPP TestKit

Posted on

MPP TestKit - NPM SDK

The TypeScript MPP SDK (mpp-test-sdk on npm) lets you build and test pay-per-request APIs on Solana in minutes. One function on the server. One function on the client. No Stripe, no database, no wallet pop-ups.


The problem with API monetisation today

Every paid API you've ever built follows the same playbook: create a Stripe account, issue API keys, build a usage-tracking database, write billing logic, handle failed payments, deal with dunning emails. You spend more time on billing infrastructure than on the API itself.

HTTP 402 was supposed to fix this. It's been reserved in the HTTP spec since 1999, sitting there with the description "Payment Required", waiting for someone to actually use it. The missing piece was always a payment layer — something fast enough to pay per request without a 3-second checkout flow.

Solana makes it possible. Sub-second finality, near-zero fees, and a simple transfer instruction. MPP wires it all together.


What MPP gives you

On the server: a single middleware that wraps any handler. When a request arrives without proof of payment, it returns a 402 with a Payment-Request header describing what to pay and where. When proof arrives, it verifies the on-chain transaction and serves the response.

On the client: a fetch replacement that detects 402, pays automatically on Solana, and retries with a Payment-Receipt header. On devnet it airdrops SOL automatically — no setup required.


Installation

npm install mpp-test-sdk
Enter fullscreen mode Exit fullscreen mode

Server (Express)

import express from "express";
import { createTestServer } from "mpp-test-sdk";

const app = express();

const server = await createTestServer({ network: "devnet" });
console.log("Recipient:", server.recipientAddress);

app.get(
  "/api/data",
  server.charge({ amount: "0.001" })(async (_req, res) => {
    res.json({ result: "here is your data" });
  })
);

app.listen(3001);
Enter fullscreen mode Exit fullscreen mode

That's the whole server. No billing database. No webhook handler. The middleware issues the 402 challenge and verifies the on-chain Solana transaction automatically.


Client

import { createTestClient } from "mpp-test-sdk";

const client = await createTestClient({
  network: "devnet",
  onStep: (step) => console.log(`[${step.type}] ${step.message}`),
});

console.log("Wallet:", client.address);

const res = await client.fetch("http://localhost:3001/api/data");
const data = await res.json();
console.log(data); // { result: "here is your data" }
Enter fullscreen mode Exit fullscreen mode

createTestClient generates a Solana keypair, airdrops 2 SOL on devnet, and returns a client whose fetch method handles the entire 402 flow automatically.


What happens under the hood

Client                          Server
  |                               |
  |── GET /api/data ─────────────>|
  |                               |── no Payment-Receipt header
  |<── 402 Payment-Request ───────|   Payment-Request: solana; amount="0.001"; recipient="9xK..."
  |                               |
  |── [build Solana tx] ──────────|── (client signs and submits on-chain)
  |── [confirm on chain] ─────────|
  |                               |
  |── GET /api/data ─────────────>|   Payment-Receipt: solana; signature="3xK..."; amount="0.001"
  |                               |── [verify tx on-chain]
  |<── 200 OK ────────────────────|
Enter fullscreen mode Exit fullscreen mode

No off-chain coordination. No shared secret. The server verifies the actual Solana transaction — recipient address, amount, confirmation status — directly via the RPC.


Lifecycle callbacks

const client = await createTestClient({
  network: "devnet",
  onStep: (step) => {
    switch (step.type) {
      case "wallet-created": console.log("Wallet:", step.data.address); break;
      case "funded":         console.log("Funded via airdrop"); break;
      case "payment":        console.log("Paying", step.data.amount, "SOL"); break;
      case "success":        console.log("Done:", step.data.status); break;
    }
  },
});
Enter fullscreen mode Exit fullscreen mode

Every stage of the flow emits a typed event: wallet-created, funded, request, payment, retry, success, error.


Mainnet

import { createTestClient } from "mpp-test-sdk";

const client = await createTestClient({
  network: "mainnet",
  secretKey: Uint8Array.from(myKeypairBytes), // 32-byte seed or 64-byte keypair
});
Enter fullscreen mode Exit fullscreen mode

On mainnet, no airdrop is available. Pass a pre-funded keypair. The rest of the flow is identical.


Drop-in fetch (shared wallet)

import { mppFetch } from "mpp-test-sdk";

// Uses a lazily-created shared devnet wallet
const res = await mppFetch("http://localhost:3001/api/data");
Enter fullscreen mode Exit fullscreen mode

mppFetch creates a single client on the first call and reuses it. Call mppFetch.reset() to discard the wallet and force a fresh one.


Error handling

import { MppFaucetError, MppPaymentError, MppTimeoutError } from "mpp-test-sdk";

try {
  const res = await client.fetch("http://localhost:3001/api/data");
} catch (err) {
  if (err instanceof MppFaucetError)   console.error("Airdrop failed:", err.address);
  if (err instanceof MppPaymentError)  console.error("Payment failed:", err.status);
  if (err instanceof MppTimeoutError)  console.error("Timed out after:", err.timeoutMs, "ms");
}
Enter fullscreen mode Exit fullscreen mode

Integration testing

The TypeScript SDK ships with a full integration test harness — spin up a server in-process, run a client against it, assert on the receipts.

import { createTestClient, createTestServer } from "mpp-test-sdk";
import express from "express";

test("charges 0.001 SOL per request", async () => {
  const server = await createTestServer({ network: "devnet" });
  const app = express();
  app.get("/data", server.charge({ amount: "0.001" })((_, res) => res.json({ ok: true })));
  const httpServer = app.listen(0);
  const { port } = httpServer.address() as { port: number };

  const client = await createTestClient({ network: "devnet" });
  const res = await client.fetch(`http://localhost:${port}/data`);
  expect(res.status).toBe(200);

  httpServer.close();
});
Enter fullscreen mode Exit fullscreen mode

What's next

Top comments (0)