Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changelog/kind-bees-pack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
mpp: minor
---

Added a Stripe Shared Payment Token (SPT) example demonstrating the full 402 → challenge → credential → retry flow using Stripe's payment method. Includes a server with SPT proxy endpoint and a headless client using a test card.
5 changes: 5 additions & 0 deletions .changelog/unique-lakes-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
mpp: minor
---

Added Stripe payment method support (`method="stripe"`, `intent="charge"`) with client-side `StripeProvider` for SPT creation, server-side `ChargeMethod` for PaymentIntent verification, and `Mpp::create_stripe()` builder integration. Added `stripe` and `integration-stripe` feature flags backed by `reqwest`.
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ jobs:
- uses: Swatinem/rust-cache@v2
- run: cargo update -p native-tls
- uses: taiki-e/install-action@cargo-hack
- run: cargo test --features tempo,server,client,axum,middleware,tower,utils
- name: Tests
run: cargo test --features tempo,stripe,server,client,axum,middleware,tower,utils,integration-stripe
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
- run: cargo hack check --each-feature --no-dev-deps --skip integration
- name: Check examples
run: find examples -name Cargo.toml -exec cargo check --manifest-path {} \;
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ server = ["tokio", "futures-core", "async-stream"]
# Method implementations
evm = ["alloy", "hex", "rand"]
tempo = ["evm", "tempo-alloy", "tempo-primitives", "uuid"]
stripe = ["dep:reqwest"]

# Utilities
utils = ["hex", "rand"]
Expand All @@ -40,6 +41,7 @@ reqwest-rustls-tls = ["reqwest?/rustls-tls"]

# Integration tests (requires a running Tempo localnet)
integration = ["tempo", "server", "client", "axum"]
integration-stripe = ["stripe", "server", "client", "axum"]

[dependencies]
# Core dependencies (always included)
Expand Down
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ cargo add mpp

## Quick Start

### Server
### Server (Tempo)

```rust
use mpp::server::{Mpp, tempo, TempoConfig};
Expand All @@ -47,7 +47,24 @@ let challenge = mpp.charge("1")?;
let receipt = mpp.verify_credential(&credential).await?;
```

### Client
### Server (Stripe)

```rust
use mpp::server::{Mpp, stripe, StripeConfig};

let mpp = Mpp::create_stripe(stripe(StripeConfig {
secret_key: "sk_test_...",
network_id: "internal",
payment_method_types: &["card"],
currency: "usd",
decimals: 2,
}))?;

let challenge = mpp.stripe_charge("1")?;
let receipt = mpp.verify_credential(&credential).await?;
```

### Client (Tempo)

```rust
use mpp::client::{PaymentMiddleware, TempoProvider};
Expand All @@ -62,13 +79,37 @@ let client = ClientBuilder::new(reqwest::Client::new())
let resp = client.get("https://mpp.dev/api/ping/paid").send().await?;
```

### Client (Stripe)

```rust
use mpp::client::{Fetch, StripeProvider};
use mpp::protocol::methods::stripe::CreateTokenResult;

let provider = StripeProvider::new(|params| {
Box::pin(async move {
// Proxy SPT creation through your backend (requires Stripe secret key)
let resp = reqwest::Client::new()
.post("https://my-server.com/api/create-spt")
.json(&params)
.send().await?.json::<serde_json::Value>().await?;
Ok(CreateTokenResult::from(resp["spt"].as_str().unwrap().to_string()))
})
});

let resp = reqwest::Client::new()
.get("https://api.example.com/paid")
.send_with_payment(&provider)
.await?;
```

## Feature Flags

| Feature | Description |
|---------|-------------|
| `client` | Client-side payment providers (`PaymentProvider` trait, `Fetch` extension) |
| `server` | Server-side payment verification (`ChargeMethod` trait) |
| `tempo` | [Tempo](https://tempo.xyz) blockchain support (includes `evm`) |
| `stripe` | [Stripe](https://stripe.com) payment support via SPTs |
| `evm` | Shared EVM utilities (Address, U256, parsing) |
| `middleware` | reqwest-middleware support with `PaymentMiddleware` (implies `client`) |
| `tower` | Tower middleware for server-side integration |
Expand All @@ -77,7 +118,7 @@ let resp = client.get("https://mpp.dev/api/ping/paid").send().await?;

## Payment Methods

MPP supports multiple [payment methods](https://mpp.dev/payment-methods/) through one protocol — [Tempo](https://mpp.dev/payment-methods/tempo/), [Stripe](https://mpp.dev/payment-methods/stripe/), [Lightning](https://mpp.dev/payment-methods/lightning/), [Card](https://mpp.dev/payment-methods/card/), and [custom methods](https://mpp.dev/payment-methods/custom). The server advertises which methods it accepts, and the client chooses which one to pay with. This SDK currently implements Tempo (charge and session intents).
MPP supports multiple [payment methods](https://mpp.dev/payment-methods/) through one protocol — [Tempo](https://mpp.dev/payment-methods/tempo/), [Stripe](https://mpp.dev/payment-methods/stripe/), [Lightning](https://mpp.dev/payment-methods/lightning/), [Card](https://mpp.dev/payment-methods/card/), and [custom methods](https://mpp.dev/payment-methods/custom). The server advertises which methods it accepts, and the client chooses which one to pay with. This SDK implements Tempo (charge and session intents) and Stripe (charge intent via Shared Payment Tokens).

## Protocol

Expand Down
10 changes: 8 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow.

| Example | Description |
|---------|-------------|
| [basic](./basic/) | Payment-gated Fortune Teller API |
| [basic](./basic/) | Payment-gated Fortune Teller API (Tempo) |
| [stripe](./stripe/) | Payment-gated Fortune Teller API (Stripe SPT) |
| [axum-extractor](./axum-extractor/) | Axum extractors with per-route pricing (`MppCharge<C>`) |
| [session/multi-fetch](./session/multi-fetch/) | Multiple paid requests over a single payment channel |
| [session/sse](./session/sse/) | Pay-per-token LLM streaming with SSE |
Expand All @@ -16,11 +17,16 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow.
Each example is a standalone Cargo crate with a server and client binary.

```bash
# Basic example
# Basic example (Tempo)
cd examples/basic
cargo run --bin basic-server # Terminal 1
cargo run --bin basic-client # Terminal 2

# Stripe example
cd examples/stripe
STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server # Terminal 1
cargo run --bin stripe-client # Terminal 2

# Axum extractor (per-route pricing)
cd examples/axum-extractor
cargo run --bin axum-server # Terminal 1
Expand Down
23 changes: 23 additions & 0 deletions examples/stripe/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "stripe-example"
version = "0.1.0"
edition = "2021"
publish = false

[[bin]]
name = "stripe-server"
path = "src/server.rs"

[[bin]]
name = "stripe-client"
path = "src/client.rs"

[dependencies]
mpp = { path = "../..", features = ["server", "client", "stripe"] }
axum = "0.7"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
rand = "0.9"
base64 = "0.22"
76 changes: 76 additions & 0 deletions examples/stripe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Stripe Example

A pay-per-fortune API using Stripe's Shared Payment Token (SPT) flow.

## What This Demonstrates

- Server-side payment protection with `Mpp::create_stripe()` and the Stripe method
- SPT proxy endpoint (secret key stays server-side)
- Headless client using a test card (`pm_card_visa`)
- Full 402 → challenge → credential → retry flow

## Prerequisites

- Rust 1.80+
- A Stripe test-mode secret key (`sk_test_...`)

## Running

**Start the server:**

```bash
export STRIPE_SECRET_KEY=sk_test_...
cargo run --bin stripe-server
```

The server starts at http://localhost:3000.

**Run the client** (in another terminal):

```bash
cargo run --bin stripe-client
# 🥠 A smooth long journey! Great expectations.
# Payment receipt: pi_3Q...
```

## Testing Manually

**Without payment** (returns 402):

```bash
curl -i http://localhost:3000/api/fortune
# HTTP/1.1 402 Payment Required
# WWW-Authenticate: Payment ...
```

## How It Works

```
Client Server Stripe
│ │ │
│ GET /api/fortune │ │
├──────────────────────────────> │ │
│ │ │
│ 402 + WWW-Authenticate │ │
│<────────────────────────────── │ │
│ │ │
│ POST /api/create-spt │ │
├──────────────────────────────> │ Create SPT (test helper) │
│ ├─────────────────────────────> │
│ spt_... │ │
│<────────────────────────────── │<───────────────────────────── │
│ │ │
│ GET /api/fortune │ │
│ Authorization: Payment <cred> │ │
├──────────────────────────────> │ PaymentIntent (SPT + confirm)│
│ ├─────────────────────────────> │
│ │ pi_... succeeded │
│ 200 + fortune + receipt │<───────────────────────────── │
│<────────────────────────────── │ │
```

1. Client requests the fortune → server returns 402 with a payment challenge
2. mpp client calls `create_token` → POSTs to `/api/create-spt` → server creates SPT via Stripe
3. Client retries with a credential containing the SPT
4. Server creates a PaymentIntent with `shared_payment_granted_token` and `confirm=true`
5. On success, returns the fortune with a receipt
100 changes: 100 additions & 0 deletions examples/stripe/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! # Stripe Fortune Teller CLI Client
//!
//! A CLI client that fetches a fortune from the payment-gated Fortune Teller API
//! using Stripe's Shared Payment Token (SPT) flow.
//!
//! Uses a test card (`pm_card_visa`) for headless operation — no browser needed.
//!
//! ## Running
//!
//! ```bash
//! # First start the server:
//! STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server
//!
//! # Then in another terminal:
//! cargo run --bin stripe-client
//!
//! # Or target a different server:
//! cargo run --bin stripe-client -- --server http://localhost:8000
//! ```

use mpp::client::{Fetch, StripeProvider};
use mpp::protocol::methods::stripe::CreateTokenResult;
use mpp::{parse_receipt, MppError};
use reqwest::Client;

#[tokio::main]
async fn main() {
let server_url = std::env::args()
.skip_while(|a| a != "--server")
.nth(1)
.unwrap_or_else(|| "http://localhost:3000".to_string());

let server_base = server_url.trim_end_matches('/').to_string();
let spt_url = format!("{server_base}/api/create-spt");

let provider = StripeProvider::new(move |params| {
let spt_url = spt_url.clone();
Box::pin(async move {
let resp = Client::new()
.post(&spt_url)
.json(&serde_json::json!({
"paymentMethod": "pm_card_visa",
"amount": params.amount,
"currency": params.currency,
"expiresAt": params.expires_at,
}))
.send()
.await
.map_err(|e| MppError::Http(e.to_string()))?;

if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(MppError::Http(format!("SPT creation failed: {body}")));
}

let json: serde_json::Value = resp
.json()
.await
.map_err(|e| MppError::Http(e.to_string()))?;

let spt = json["spt"]
.as_str()
.ok_or_else(|| MppError::Http("missing spt in response".to_string()))?
.to_string();

Ok(CreateTokenResult::from(spt))
})
});

let fortune_url = format!("{server_base}/api/fortune");
println!("Fetching {fortune_url} ...");

let resp = Client::new()
.get(&fortune_url)
.send_with_payment(&provider)
.await
.expect("request failed");

println!("Status: {}", resp.status());

if let Some(receipt_hdr) = resp.headers().get("payment-receipt") {
if let Ok(receipt_str) = receipt_hdr.to_str() {
if let Ok(receipt) = parse_receipt(receipt_str) {
println!("Payment receipt: {}", receipt.reference);
}
}
}

let body = resp.text().await.expect("failed to read response body");

if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
if let Some(fortune) = json.get("fortune").and_then(|v| v.as_str()) {
println!("\nFortune: {fortune}");
} else {
println!("\nResponse: {json}");
}
} else {
println!("\nResponse: {body}");
}
}
Loading
Loading