Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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/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
6 changes: 6 additions & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,9 @@ pub use tempo::session::{channel_ops, TempoSessionProvider};
pub use tempo::{AutoswapConfig, TempoClientError, TempoProvider};
#[cfg(feature = "tempo")]
pub use tempo_alloy::TempoNetwork;

// Re-export Stripe types at client level for convenience
#[cfg(feature = "stripe")]
pub mod stripe;
#[cfg(feature = "stripe")]
pub use stripe::StripeProvider;
8 changes: 8 additions & 0 deletions src/client/stripe/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//! Stripe-specific client implementations.
//!
//! Provides [`StripeProvider`] which implements [`PaymentProvider`] for
//! Stripe charge challenges using Shared Payment Tokens (SPTs).

mod provider;

pub use provider::StripeProvider;
163 changes: 163 additions & 0 deletions src/client/stripe/provider.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//! Stripe payment provider for client-side credential creation.

use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;

use crate::client::PaymentProvider;
use crate::error::{MppError, ResultExt};
use crate::protocol::core::{PaymentChallenge, PaymentCredential};
use crate::protocol::intents::ChargeRequest;
use crate::protocol::methods::stripe::types::CreateTokenResult;
use crate::protocol::methods::stripe::{
StripeCredentialPayload, StripeMethodDetails, INTENT_CHARGE, METHOD_NAME,
};

/// Parameters passed to the `create_token` callback.
///
/// Matches the mppx `OnChallengeParameters` shape.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CreateTokenParams {
/// Payment amount in smallest currency unit.
pub amount: String,
/// Three-letter ISO currency code.
pub currency: String,
/// Stripe Business Network profile ID.
pub network_id: String,
/// SPT expiration as Unix timestamp (seconds).
pub expires_at: u64,
/// Optional metadata from the challenge's methodDetails.
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<std::collections::HashMap<String, String>>,
/// The full challenge as JSON, for advanced use cases.
#[serde(skip)]
pub challenge: serde_json::Value,
}

/// Client-side Stripe payment provider.
///
/// Handles 402 challenges with `method="stripe"` by creating an SPT via
/// the user-provided `create_token` callback and returning a credential.
///
/// # Example
///
/// ```ignore
/// use mpp::client::stripe::StripeProvider;
/// use mpp::protocol::methods::stripe::CreateTokenResult;
///
/// let provider = StripeProvider::new(|params| {
/// Box::pin(async move {
/// let resp = reqwest::Client::new()
/// .post("https://my-server.com/api/create-spt")
/// .json(&params)
/// .send().await.map_err(|e| mpp::MppError::Http(e.to_string()))?
/// .json::<serde_json::Value>().await
/// .map_err(|e| mpp::MppError::Http(e.to_string()))?;
/// Ok(CreateTokenResult {
/// spt: resp["spt"].as_str().unwrap().to_string(),
/// external_id: None,
/// })
/// })
/// });
/// ```
type CreateTokenFn = dyn Fn(
CreateTokenParams,
) -> Pin<Box<dyn Future<Output = Result<CreateTokenResult, MppError>> + Send>>
+ Send
+ Sync;

#[derive(Clone)]
pub struct StripeProvider {
create_token: Arc<CreateTokenFn>,
}

impl StripeProvider {
/// Create a new Stripe provider with the given SPT creation callback.
///
/// The callback receives [`CreateTokenParams`] and should return a
/// [`CreateTokenResult`] containing the SPT and optional external ID.
pub fn new<F>(create_token: F) -> Self
where
F: Fn(
CreateTokenParams,
)
-> Pin<Box<dyn Future<Output = Result<CreateTokenResult, MppError>> + Send>>
+ Send
+ Sync
+ 'static,
{
Self {
create_token: Arc::new(create_token),
}
}
}

impl PaymentProvider for StripeProvider {
fn supports(&self, method: &str, intent: &str) -> bool {
method == METHOD_NAME && intent == INTENT_CHARGE
}

async fn pay(&self, challenge: &PaymentChallenge) -> Result<PaymentCredential, MppError> {
let request: ChargeRequest = challenge
.request
.decode()
.mpp_config("failed to decode challenge request")?;

let details: StripeMethodDetails = request
.method_details
.as_ref()
.map(|v| serde_json::from_value(v.clone()))
.transpose()
.mpp_config("invalid methodDetails")?
.unwrap_or_default();

let expires_at = challenge
.expires
.as_ref()
.and_then(|e| {
time::OffsetDateTime::parse(e, &time::format_description::well_known::Rfc3339).ok()
})
.map(|dt| dt.unix_timestamp() as u64)
.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600
});

let params = CreateTokenParams {
amount: request.amount,
currency: request.currency,
network_id: details.network_id,
expires_at,
metadata: details.metadata,
challenge: serde_json::to_value(challenge).unwrap_or_default(),
};

let result = (self.create_token)(params).await?;

let payload = StripeCredentialPayload {
spt: result.spt,
external_id: result.external_id,
};

Ok(PaymentCredential::new(challenge.to_echo(), payload))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_supports() {
let provider = StripeProvider::new(|_| {
Box::pin(async { Ok(CreateTokenResult::from("spt_test".to_string())) })
});

assert!(provider.supports("stripe", "charge"));
assert!(!provider.supports("tempo", "charge"));
assert!(!provider.supports("stripe", "session"));
}
}
6 changes: 6 additions & 0 deletions src/protocol/intents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ pub mod payment_request;
pub mod session;

pub use charge::ChargeRequest;

/// Intent identifier for one-time payments.
pub const INTENT_CHARGE: &str = "charge";

/// Intent identifier for pay-as-you-go sessions.
pub const INTENT_SESSION: &str = "session";
pub use payment_request::{
deserialize as deserialize_request, deserialize_typed as deserialize_request_typed,
from_challenge as request_from_challenge, from_challenge_typed as request_from_challenge_typed,
Expand Down
13 changes: 10 additions & 3 deletions src/protocol/methods/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,24 @@
//! # Available Methods
//!
//! - [`tempo`]: Tempo blockchain (requires `tempo` feature)
//! - [`stripe`]: Stripe payments via SPTs (requires `stripe` feature)
//!
//! # Architecture
//!
//! ```text
//! methods/
//! └── tempo/ # Tempo-specific (chain_id=42431, TIP-20, 2D nonces)
//! ├── types.rs # TempoMethodDetails
//! └── charge.rs # TempoChargeExt trait
//! ├── tempo/ # Tempo-specific (chain_id=42431, TIP-20, 2D nonces)
//! │ ├── types.rs # TempoMethodDetails
//! │ └── charge.rs # TempoChargeExt trait
//! └── stripe/ # Stripe SPT-based payments
//! ├── types.rs # StripeChargeRequest, StripeCredentialPayload
//! └── method.rs # ChargeMethod impl
//! ```
//!
//! Shared EVM utilities (Address, U256, parsing) are in the top-level `evm` module.

#[cfg(feature = "tempo")]
pub mod tempo;

#[cfg(feature = "stripe")]
pub mod stripe;
Loading
Loading