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
121 changes: 121 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,127 @@ after the owner agrees: delegation persists even if the payment fails. The
extension returns signed data; it does not broadcast a transaction. Availability
depends on the InFlow environment's sponsorship endpoint and supported networks.

## Accept x402 payments with FastAPI

Create an InFlow **Seller** account and an API key in its dashboard:
[Sandbox](https://sandbox.inflowpay.ai) for testing or
[Production](https://app.inflowpay.ai) for live payments. Use the matching
`environment` in `ClientOptions`.

```shell
pip install 'inflowpay[x402,fastapi]' uvicorn
export INFLOW_API_KEY='your-seller-api-key'
```

`Seller` reads your configured wallets, assets, and payment methods and converts
prices into upstream x402 route options. `Facilitator` sends verification and
settlement requests to InFlow. The upstream FastAPI middleware challenges the
buyer, verifies the payment before calling your handler, and settles after a
successful handler response.

```python
import asyncio
import os

import uvicorn
from fastapi import FastAPI
from x402 import x402ResourceServer
from x402.http.middleware.fastapi import payment_middleware
from x402.http.types import RouteConfig

from inflowpay import ClientOptions
from inflowpay.x402.facilitator import Facilitator
from inflowpay.x402.seller import Seller


async def serve() -> None:
options = ClientOptions(environment="sandbox", api_key=os.environ["INFLOW_API_KEY"])
async with (
await Seller.create(options) as seller,
await Facilitator.create(options) as facilitator,
):
resource_server = x402ResourceServer(facilitator)
for registration in await seller.scheme_registrations():
resource_server.register(registration["network"], registration["server"])

routes = {
"GET /report": RouteConfig(accepts=await seller.offers("$0.01")),
}
app = FastAPI()
app.middleware("http")(payment_middleware(routes, resource_server))

@app.get("/report")
async def report() -> dict[str, str]:
return {"report": "Your paid report"}

await uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=8000)).serve()


asyncio.run(serve())
```

Both clients own their HTTP connections and close them when their context exits.
`Seller.create()` requires an API key and preloads configuration and capabilities.
Configuration reads are cached for one hour; `await seller.config(refresh=True)`
forces a refresh. `await seller.get_supported(refresh=True)` refreshes the Seller's
capability lookup, not a running Facilitator or resource server. Values returned
by these methods can be modified without changing the client's cache.

### Prices, payment methods, and metering

`seller.offers()` accepts `"$0.01"`, `"0.01 USDC"`, or `"0.01"` with
`currency="USDC"`. Dollar prices select all stablecoin currencies in your Seller
configuration. An explicit `currency` overrides the currency in the price string.
Conversion uses decimal digits rather than floating-point arithmetic and rejects
prices that cannot be represented in an asset's smallest unit. Price strings
allow up to eight fractional digits.

Use `schemes=["balance"]` or `networks=["eip155:8453"]` to restrict offers.
Both filters apply when supplied together. Routes default to fixed-price offers
and a 300-second payment timeout; `max_timeout_seconds` changes the timeout.
An empty offers list means the selected configuration and filters produced no
payment option. Check it before exposing the route.

Metered `upto` payments require `inflowpay[evm]` and explicit `schemes=["upto"]`
on both `seller.offers()` and `seller.scheme_registrations()`. Your environment
must advertise metered Permit2 support for the asset. The route price is the
maximum authorized charge. In your FastAPI handler, call the upstream helper
`set_settlement_overrides(response, {"amount": "250000"})` to settle the actual
amount in atomic asset units. The buyer's signed maximum remains unchanged.
Without an override, settlement uses the route's maximum amount.

`await seller.route("0.01 USDC", schemes=["exact"], permit2=True)` selects
compatible Permit2 offers and adds a sponsorship declaration only when the token
and facilitator advertise support. It prefers EIP-2612 over InFlow EIP-7702.
Install `inflowpay[evm]` for EIP-2612 declarations. Balance offers are unaffected
by `permit2=True`; filter to `exact` for an on-chain-only route. InFlow-managed
buyers cannot sign Permit2 payments; these offers require external wallets.

### Settlement and framework behavior

The facilitator preserves a valid buyer payment identifier or derives one from
the signed payment material. Verification and settlement use the same identifier
without modifying the caller's payload. HTTP 412 `permit2_allowance_required`
is returned as an invalid verification result. Settlement retries only an HTTP
409 `idempotency_pending` response, at most five attempts and at most five seconds
between attempts. Other HTTP or transport failures are not retried automatically:
the payment may already have completed. Cancelling the task stops pending waits.

The upstream FastAPI middleware buffers the handler's successful response before
settlement; it does not stream the response to the buyer during settlement. A
rejected verification prevents the handler from running. A handler error prevents
after-handler settlement; a settlement failure replaces the successful handler
response with a payment failure. Avoid irreversible business side effects in a
handler without your own reconciliation design. InFlow does not roll back the
handler's work.

The clients are framework-independent and do not import FastAPI. Use them with
other upstream asynchronous adapters where appropriate. For anonymous external
on-chain facilitation, explicitly use
`await Facilitator.create(ClientOptions(environment="sandbox"), anonymous=True)`.
Anonymous setup rejects credentials; Seller configuration and InFlow balance
settlement require a Seller account.

## x402 facilitator capabilities

Facilitator capabilities describe the payment schemes, networks, and extensions
Expand Down
16 changes: 16 additions & 0 deletions scripts/verify_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ async def check_seller():
entry = payment_identifier_entry(declare_payment_identifier(), generate_payment_id())
assert entry is not None and entry['info']['required'] is False
from inflowpay.x402.buyer import Buyer
from inflowpay.x402.facilitator import Facilitator
from inflowpay.x402.seller import Seller
from inflowpay import ClientOptions
import asyncio
import httpx
Expand All @@ -79,6 +81,20 @@ async def check_buyer():
async with await Buyer.create(ClientOptions(transport=transport)) as buyer:
assert (await buyer.get_supported()).kinds == []
asyncio.run(check_buyer())
async def check_seller():
config = {'sellerId': 'seller', 'assets': [], 'wallets': [],
'paymentMethods': [], 'supported': []}
transport = httpx.MockTransport(lambda request: httpx.Response(200, json=
config if request.url.path.endswith('/config') else {'kinds': []}))
options = ClientOptions(api_key='test-key', transport=transport)
async with await Seller.create(options) as seller:
assert await seller.offers('$1') == []
assert await seller.scheme_registrations() == []
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={'kinds': []}))
options = ClientOptions(transport=transport)
async with await Facilitator.create(options, anonymous=True) as facilitator:
assert facilitator.get_supported().kinds == []
asyncio.run(check_seller())
for name in ('mpp', 'mcp', 'web3', 'solana', 'fastapi', 'rfc8785'):
assert util.find_spec(name) is None, name
"""
Expand Down
172 changes: 172 additions & 0 deletions src/inflowpay/x402/_seller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
from __future__ import annotations

import re
from collections.abc import Sequence
from copy import deepcopy
from typing import Any

from pydantic import Field
from x402.http.types import PaymentOption
from x402.schemas import AssetAmount, SupportedKind
from x402.schemas.base import BaseX402Model

PERMIT2_PROXY = "0x402085c248EeA27D92E8b30b2C58ed07f9E20001"


class Asset(BaseX402Model):
asset_id: str
asset_name: str
currency: str
blockchain: str
network: str
decimals: int = Field(ge=0)
asset_transfer_method: str | None = None
permit2_proxy: str | None = None
token_name: str | None = None
token_version: str | None = None
supports_eip2612: bool = False
supports_eip7702: bool = False


class Wallet(BaseX402Model):
address: str
blockchain: str
fee_payer: str | None = None


class PaymentMethod(BaseX402Model):
scheme: str
network: str
pay_to: str
decimals: int = Field(ge=0)
extra: dict[str, Any] = Field(default_factory=dict)


class SellerConfig(BaseX402Model):
seller_id: str
assets: list[Asset]
wallets: list[Wallet]
payment_methods: list[PaymentMethod]
supported: list[SupportedKind]


def supports_permit2(asset: Asset) -> bool:
return (
asset.network.startswith("eip155:")
and (asset.permit2_proxy or "").lower() == PERMIT2_PROXY.lower()
)


def upto_kind(config: SellerConfig, asset: Asset) -> SupportedKind | None:
if not asset.network.startswith("eip155:") or not asset.permit2_proxy:
return None
for kind in config.supported:
extra = kind.extra or {}
if (
kind.x402_version == 2
and kind.scheme == "upto"
and kind.network == asset.network
and extra.get("assetTransferMethod") == "permit2"
and isinstance(extra.get("facilitatorAddress"), str)
and extra["facilitatorAddress"]
and isinstance(extra.get("permit2Proxy"), str)
and extra["permit2Proxy"]
):
return kind
return None


def _price(value: str, currency: str | None) -> tuple[str, str, str]:
matched = re.fullmatch(r"(\$?)([0-9]+)(?:\.([0-9]{1,8}))?(?:\s+([A-Z][A-Z0-9_]*))?", value)
if matched is None or (matched[1] and matched[4]):
raise ValueError("Price must be '$1.00', '1.00 USDC', or a plain amount with currency")
selected = currency if currency is not None else ("USD" if matched[1] else matched[4])
if not selected:
raise ValueError("A currency is required for a plain amount")
return matched[2], matched[3] or "", selected


def _atomic(integer: str, fraction: str, decimals: int) -> str:
if fraction[decimals:].strip("0"):
raise ValueError("Price cannot be represented in the asset's decimal precision")
return (integer + fraction[:decimals].ljust(decimals, "0")).lstrip("0") or "0"


def build_offers(
config: SellerConfig,
price: str,
*,
currency: str | None = None,
schemes: Sequence[str] | None = None,
networks: Sequence[str] | None = None,
max_timeout_seconds: int = 300,
permit2: bool = False,
) -> list[PaymentOption]:
integer, fraction, selected = _price(price, currency)

def include(scheme: str, network: str) -> bool:
return (schemes is None or scheme in schemes) and (networks is None or network in networks)

result: list[PaymentOption] = []
for wallet in config.wallets:
for asset in config.assets:
if asset.blockchain != wallet.blockchain or selected not in ("USD", asset.currency):
continue
methods: list[tuple[str, str | None, dict[str, Any]]] = []
if not permit2 or supports_permit2(asset):
methods.append(("exact", "permit2" if permit2 else asset.asset_transfer_method, {}))
kind = upto_kind(config, asset) if schemes is not None and "upto" in schemes else None
if kind is not None:
methods.append(("upto", "permit2", kind.extra or {}))
for scheme, method, kind_extra in methods:
if not include(scheme, asset.network):
continue
extra: dict[str, Any] = {"assetName": asset.asset_name}
for key, value in (
("name", asset.token_name),
("version", asset.token_version),
("assetTransferMethod", method),
("feePayer", wallet.fee_payer),
):
if value is not None:
extra[key] = value
if method == "permit2":
if asset.permit2_proxy is not None:
extra["permit2Proxy"] = asset.permit2_proxy
if asset.supports_eip2612:
extra["supportsEip2612"] = True
if asset.supports_eip7702:
extra["supportsEip7702"] = True
result.append(
PaymentOption(
scheme=scheme,
network=asset.network,
pay_to=wallet.address,
price=AssetAmount(
asset=asset.asset_id, amount=_atomic(integer, fraction, asset.decimals)
),
max_timeout_seconds=max_timeout_seconds,
extra={**extra, **deepcopy(kind_extra)},
)
)
currencies = (
list(dict.fromkeys(asset.currency for asset in config.assets))
if selected == "USD"
else [selected]
)
for method_info in config.payment_methods:
if include(method_info.scheme, method_info.network):
for item in currencies:
result.append(
PaymentOption(
scheme=method_info.scheme,
network=method_info.network,
pay_to=method_info.pay_to,
price=AssetAmount(
asset=item, amount=_atomic(integer, fraction, method_info.decimals)
),
max_timeout_seconds=max_timeout_seconds,
extra={**deepcopy(method_info.extra), "assetName": item},
)
)
return result
Loading
Loading