mppx-solana

March 18, 2026 · View on GitHub

Solana payment method for the Machine Payments Protocol. Accept SOL and SPL token payments on any HTTP endpoint.

Built on top of mppx — the official MPP SDK.

Install

bun add mppx-solana mppx @solana/web3.js @solana/spl-token viem
npm install mppx-solana mppx @solana/web3.js @solana/spl-token viem
pnpm add mppx-solana mppx @solana/web3.js @solana/spl-token viem

How it works

Client                          Server
  │                               │
  │  GET /api/resource            │
  │ ────────────────────────────► │
  │                               │
  │  402 + Challenge              │
  │  (amount, recipient, mint)    │
  │ ◄──────────────────────────── │
  │                               │
  │  Signs & sends Solana tx      │
  │  Retries with tx signature    │
  │ ────────────────────────────► │
  │                               │
  │  Verifies tx on-chain         │
  │  200 + Resource + Receipt     │
  │ ◄──────────────────────────── │
  1. Client hits a paid endpoint → server returns 402 with a Solana payment challenge
  2. Client builds, signs, and submits a Solana transaction
  3. Client retries the request with the transaction signature as proof
  4. Server verifies the transaction on-chain and returns the resource with a receipt

Server

Gate any endpoint behind a Solana payment. Works with Bun.serve, Hono, Express, and Next.js.

Bun

import { Mppx } from "mppx/server";
import { server as solanaServer, NATIVE_SOL_CURRENCY } from "mppx-solana";

const mppx = Mppx.create({
  methods: [
    solanaServer({
      recipient: "YourWalletPublicKeyBase58",
      currency: NATIVE_SOL_CURRENCY,
      cluster: "mainnet-beta",
      decimals: 9,
    }),
  ],
  secretKey: process.env.MPP_SECRET_KEY!,
});

Bun.serve({
  async fetch(request) {
    const result = await mppx.charge({
      amount: "1000000", // 0.001 SOL in lamports
      description: "API access",
    })(request);

    if (result.status === 402) return result.challenge;

    return result.withReceipt(
      Response.json({ data: "your paid content here" }),
    );
  },
});

Hono

import { Hono } from "hono";
import { Mppx } from "mppx/hono";
import { server as solanaServer, NATIVE_SOL_CURRENCY } from "mppx-solana";

const app = new Hono();

const mppx = Mppx.create({
  methods: [
    solanaServer({
      recipient: "YourWalletPublicKeyBase58",
      currency: NATIVE_SOL_CURRENCY,
      cluster: "mainnet-beta",
      decimals: 9,
    }),
  ],
  secretKey: process.env.MPP_SECRET_KEY!,
});

app.get(
  "/api/resource",
  mppx.charge({ amount: "1000000", description: "API access" }),
  async (c) => c.json({ data: "your paid content here" }),
);

Express

import express from "express";
import { Mppx } from "mppx/express";
import { server as solanaServer, NATIVE_SOL_CURRENCY } from "mppx-solana";

const app = express();

const mppx = Mppx.create({
  methods: [
    solanaServer({
      recipient: "YourWalletPublicKeyBase58",
      currency: NATIVE_SOL_CURRENCY,
      cluster: "mainnet-beta",
      decimals: 9,
    }),
  ],
  secretKey: process.env.MPP_SECRET_KEY!,
});

app.get(
  "/api/resource",
  mppx.charge({ amount: "1000000", description: "API access" }),
  async (req, res) => res.json({ data: "your paid content here" }),
);

Next.js

// app/api/resource/route.ts
import { Mppx } from "mppx/nextjs";
import { server as solanaServer, NATIVE_SOL_CURRENCY } from "mppx-solana";

const mppx = Mppx.create({
  methods: [
    solanaServer({
      recipient: "YourWalletPublicKeyBase58",
      currency: NATIVE_SOL_CURRENCY,
      cluster: "mainnet-beta",
      decimals: 9,
    }),
  ],
  secretKey: process.env.MPP_SECRET_KEY!,
});

export const GET = mppx.charge({
  amount: "1000000",
  description: "API access",
})(async () => Response.json({ data: "your paid content here" }));

Client

Pay for any MPP-gated endpoint automatically. The SDK handles the 402 → pay → retry flow.

import { Connection, Keypair } from "@solana/web3.js";
import { Mppx } from "mppx/client";
import { client as solanaClient } from "mppx-solana";

const mppx = Mppx.create({
  methods: [
    solanaClient({
      connection: new Connection("https://api.mainnet-beta.solana.com"),
      signer: Keypair.fromSecretKey(/* your key */),
    }),
  ],
  polyfill: false,
});

// Automatically pays when the server returns 402
const response = await mppx.fetch("https://api.example.com/resource");
const data = await response.json();

Custom signer

Any object with publicKey and signTransaction works — use this to integrate wallet adapters.

solanaClient({
  connection,
  signer: {
    publicKey: wallet.publicKey,
    signTransaction: (tx) => wallet.signTransaction(tx),
  },
});

Dynamic connection

Use getConnection when you need per-request RPC routing:

solanaClient({
  signer: myKeypair,
  getConnection: (cluster) => {
    if (cluster === "devnet") return new Connection("https://api.devnet.solana.com");
    return new Connection("https://my-rpc.example.com");
  },
});

Payment options

Native SOL

solanaServer({
  currency: NATIVE_SOL_CURRENCY, // "solana:native"
  decimals: 9,
  recipient: "YourWalletPublicKeyBase58",
  cluster: "mainnet-beta",
})

// amount is in lamports: "1000000" = 0.001 SOL

SPL tokens (USDC, USDT, etc.)

solanaServer({
  currency: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mint
  decimals: 6,
  recipient: "YourWalletPublicKeyBase58",
  cluster: "mainnet-beta",
})

// amount is in smallest unit: "10000" = 0.01 USDC

The client automatically creates the recipient's associated token account if it doesn't exist.

Memo verification

Attach a memo to bind payments to specific invoices or orders:

mppx.charge({
  amount: "1000000",
  memo: "invoice-abc-123",
})

The server will reject transactions that don't contain the expected memo.

Enable gasless transactions so users only need USDC (or any SPL token) — no SOL required. The server sponsors the Solana transaction fee and the user reimburses the server in the payment token.

Client                          Server
  │                               │
  │  GET /api/resource            │
  │ ────────────────────────────► │
  │                               │
  │  402 + Challenge              │
  │  (sponsored=true, sponsorPath)│
  │ ◄──────────────────────────── │
  │                               │
  │  POST /sponsor                │
  │  { publicKey, request }       │
  │ ────────────────────────────► │
  │                               │
  │  Partially signed tx          │
  │  (feePayer=server)            │
  │ ◄──────────────────────────── │
  │                               │
  │  Co-signs & sends tx          │
  │  Retries with tx signature    │
  │ ────────────────────────────► │
  │                               │
  │  Verifies tx on-chain         │
  │  200 + Resource + Receipt     │
  │ ◄──────────────────────────── │

How it works

  1. Server returns a 402 challenge with sponsored: true and a sponsorPath
  2. Client POSTs its public key to the sponsor endpoint
  3. Server builds the transaction with itself as feePayer, includes the payment transfer + a small fee reimbursement transfer from user → server
  4. Server partially signs as fee payer and returns the serialized transaction
  5. Client co-signs as the payment authority and submits
  6. Server verifies the payment on-chain as usual

The user pays the API cost + a small fee surcharge (e.g., 0.01 USDC) in the same token. The server pays the Solana network fee in SOL and receives the token reimbursement.

Server setup

import { Keypair } from "@solana/web3.js";
import { Mppx } from "mppx/server";
import {
  createSponsorHandler,
  server as solanaServer,
} from "mppx-solana";

const sponsorKeypair = Keypair.fromSecretKey(/* server fee payer key */);

const mppx = Mppx.create({
  methods: [
    solanaServer({
      recipient: "MerchantWalletPublicKeyBase58",
      currency: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC
      decimals: 6,
      cluster: "mainnet-beta",
      sponsor: {
        feePayer: sponsorKeypair,
        feeTokenAmount: "10000", // 0.01 USDC reimbursement
        sponsorPath: "/sponsor",
      },
    }),
  ],
  realm: "api.example.com",
  secretKey: process.env.MPP_SECRET_KEY!,
});

const handleSponsor = createSponsorHandler({
  feePayer: sponsorKeypair,
  feeTokenAmount: "10000",
  connection: new Connection("https://api.mainnet-beta.solana.com"),
});

Bun.serve({
  routes: {
    "/api/resource": {
      GET: async (request) => {
        const result = await mppx.charge({
          amount: "100000", // 0.10 USDC
        })(request);

        if (result.status === 402) return result.challenge;
        return result.withReceipt(Response.json({ data: "paid content" }));
      },
    },
    "/sponsor": {
      POST: handleSponsor,
    },
  },
});

Client

No changes needed — the client automatically detects sponsored: true in the challenge and uses the sponsor endpoint.

import { Connection, Keypair } from "@solana/web3.js";
import { Mppx } from "mppx/client";
import { client as solanaClient } from "mppx-solana";

const mppx = Mppx.create({
  methods: [
    solanaClient({
      connection: new Connection("https://api.mainnet-beta.solana.com"),
      signer: Keypair.fromSecretKey(/* user key */),
    }),
  ],
  polyfill: false,
});

// User pays 0.11 USDC (0.10 payment + 0.01 fee) — zero SOL needed
const response = await mppx.fetch("https://api.example.com/api/resource");

Cost breakdown

For a 0.10 USDC API call with sponsored fees:

Without sponsorshipWith sponsorship
User pays (USDC)0.100.11 (0.10 + 0.01 fee)
User pays (SOL)~0.002 SOL (tx fee + ATA rent)0
Server pays (SOL)0~0.004 SOL (tx fee + ATA rent)
Server receives (USDC)00.01 (fee reimbursement)

The server accumulates USDC reimbursements and needs to periodically swap USDC → SOL to stay funded. Initial SOL seed required (~0.1 SOL ≈ 20,000+ transactions).

ParameterTypeRequiredDescription
sponsor.feePayerKeypairYesServer keypair that pays Solana tx fees
sponsor.feeTokenAmountstringYesAmount in token smallest unit to reimburse (e.g., "10000" = 0.01 USDC)
sponsor.sponsorPathstringYesHTTP path for the sponsor endpoint (e.g., "/sponsor")

Server configuration

ParameterTypeRequiredDescription
recipientstringYesWallet address receiving payments
currencystringYes"solana:native" for SOL, or SPL mint address
decimalsnumberYesToken decimals (9 for SOL, 6 for USDC)
clusterstringNo"mainnet-beta", "devnet", "testnet", "localnet"
commitmentstringNo"confirmed" (default) or "finalized"
connectionConnectionNoCustom RPC connection
getConnectionfunctionNoDynamic connection factory
memostringNoRequired memo string on transactions
descriptionstringNoHuman-readable description
externalIdstringNoExternal reference ID for receipts
sponsorSolanaSponsorConfigNoEnable sponsored/gasless transactions

Client configuration

ParameterTypeRequiredDescription
signerKeypair | SolanaSignerYesSigns transactions
connectionConnectionNoRPC connection
getConnectionfunctionNoDynamic connection factory

Running the examples

Full end-to-end examples using a local Solana validator are included. This project uses Solforge as the local validator.

# Install solforge
bun add -g solforge

# Start the local validator (picks up sf.config.json automatically)
solforge

# In another terminal, run the e2e tests
bun run example:e2e                  # SOL payment
bun run example:usdc-e2e             # USDC payment
bun run example:sponsored-e2e        # Sponsored SOL payment
bun run example:sponsored-usdc-e2e   # Sponsored USDC payment (gasless)

Or run server and client separately:

# Terminal 1 — server
MPP_SECRET_KEY=dev-secret \
EXAMPLE_RECIPIENT=<wallet> \
EXAMPLE_CLUSTER=localnet \
bun run example:server

# Terminal 2 — client
EXAMPLE_PAYER_SECRET_KEY='[1,2,3,...]' \
EXAMPLE_CLUSTER=localnet \
bun run example:client

Type checking

bun run check

License

MIT