Skip to content

Python - interface for PayKit #136

@lgalabru

Description

@lgalabru

solana-pay-kit (Python) — SDK Design Guidelines

Working notes for the surface that unifies x402 and MPP behind a
single Pythonic API. Sibling to ruby/DESIGN.md; same model, Python idioms.

Intent

  • One package, one surface, two protocols underneath (x402, MPP). Merchants
    shouldn't have to care which protocol settled a request unless they ask.
  • Type-hint-driven, IDE-friendly. The signature is the contract.
  • Framework-agnostic core. FastAPI, Flask, Django, Starlette are thin shims
    over a pure ASGI/WSGI middleware.

Naming

PyPI name and Python import name use the standard split — same logic as the
Ruby gem-vs-module split:

Surface Name
PyPI package solana-pay-kit
Import path import pay_kit (lowercase, PEP 8)
Distribution dir src/pay_kit/
Submodules pay_kit.fastapi, pay_kit.flask, pay_kit.schemes, …

Rationale: PyPI name carries discoverability (pip install solana-pay-kit,
SEO on PyPI); short import keeps call sites readable (pay_kit.configure(...)
beats solana_pay_kit.configure(...)). Same pattern as
beautifulsoup4bs4, pillowPIL, psycopg2-binarypsycopg2.

Decoupling pay_kit from solana in imports keeps the door open for
non-Solana rails later (pay_kit.schemes.lightning) without breaking
imports.

Inspirations

  • FastAPI — type hints as the API
    surface, Depends() for dependency-injected gating, async-first. The
    FastAPI shim borrows the injection pattern directly.
  • Stripe Python SDK — the
    reference for "payments SDK in Python." We take the exception hierarchy,
    resource-orientation, and the discipline around idempotency.
  • Pydantic v2 — frozen, validated
    data models. Gate, Price, Fee are Pydantic BaseModel subclasses with
    frozen=True. Validation happens at construction, not at use.
  • Flask — minimal decorator-driven
    surface. @require_payment(REPORT) stacks cleanly with @app.route.
  • httpx — sync/async parity as a
    design principle. Every helper ships in both flavors.

Vocabulary

Pick these terms and use them consistently in code, docstrings, and error
messages.

Term Meaning
operator Merchant identity: recipient + signer + fee-payer flag.
signer Ed25519 key source — demo, bytes, file, from_env, gcp, …
gate A protected unit. Has an amount, optional fees, accepted schemes.
amount The base amount a gate charges, before any fee_on_top.
total What the customer pays: amount + sum(fee_on_top). Derived.
price Value object returned by usd(...): number + denom + settlement.
fee_within Fee taken out of the amount. pay_to recipient nets less.
fee_on_top Fee added to the amount. Customer pays more; pay_to nets full.
payment Proof submitted by the client to pass a gate.
scheme Protocol used to prove payment: "x402", "mpp".
accept Ordered preference list — applies to both schemes and stablecoins.
denom The fiat unit a price is quoted in ("USD", "EUR").
settlement The on-chain asset that actually transfers ("USDC", "USDT").

Surface

Boot-time configuration

Function-call form is the Python-idiomatic default. The kwarg names mirror
config sections. Zero-config boots — every field has a working default,
including a hard-coded Signer.demo() so demos and tests run with
import pay_kit; pay_kit.configure(network="solana_localnet") and
nothing else.

import os
import pay_kit

pay_kit.configure(
    network     = "solana_devnet",
    accept      = ["x402", "mpp"],          # preference order
    stablecoins = ["USDC", "USDT"],         # preference order
    rpc_url     = os.environ.get("PAY_KIT_RPC_URL"),  # None → default per network

    operator = pay_kit.Operator(
        recipient = os.environ.get("PAY_KIT_OPERATOR_RECIPIENT"),       # None → signer.pubkey
        signer    = pay_kit.Signer.from_env("PAY_KIT_OPERATOR_KEY"),    # None → Signer.demo()
        fee_payer = True,
    ),

    # OPTIONAL — when set, PayKit POSTs to this URL's /verify and /settle
    # endpoints and never touches the chain itself. When None, PayKit runs
    # x402 locally using rpc_url + operator.signer. See "Chain access" below.
    x402 = pay_kit.X402Config(
        facilitator_url = os.environ.get("PAY_KIT_X402_FACILITATOR_URL"),
        scheme          = "exact",
    ),

    mpp = pay_kit.MppConfig(
        realm                    = "MyApp",
        challenge_binding_secret = os.environ["PAY_KIT_MPP_CHALLENGE_BINDING_SECRET"],
        expires_in               = 300,
    ),
)

There is no top-level pay_to and no x402.facilitator_secret_key
both cascade from operator. See Operator below.

For env-driven config, a Pydantic BaseSettings is supported as a drop-in
alternative — the canonical Python pattern for 12-factor config:

from pydantic_settings import BaseSettings
import pay_kit

class Settings(BaseSettings):
    network:                    str = "solana_devnet"
    rpc_url:                    str | None = None
    operator_recipient:         str | None = None
    operator_key:               str | None = None       # JSON / base58 / hex; auto-detected
    x402_facilitator_url:       str | None = None
    mpp_challenge_binding_secret: str

    class Config:
        env_prefix = "PAY_KIT_"

pay_kit.configure_from(Settings())

Operator — merchant identity in one place

An operator bundles the three things a merchant brings to the protocol:

  • recipient — where settled funds land. Default pay_to for every gate.
  • signer — the Ed25519 keypair used to sign x402 facilitator challenges,
    and (if fee_payer=True) to pay Solana network fees.
  • fee_payer — whether the operator's signer also pays Solana network
    fees on settlement transactions.
operator = pay_kit.Operator(
    recipient = "Cs2zdfUNonRdRGsiZUQQLdTxzxVvJZmgiX2mpLYKuEqP",
    signer    = pay_kit.Signer.file("/etc/paykit/operator.json"),
    fee_payer = True,
)

Operator is a Pydantic v2 frozen=True BaseModel. The fields default
to None (recipient, signer) or True (fee_payer); a
@model_validator(mode="after") resolves Nones:

  • signerSigner.demo() if None
  • recipientsigner.pubkey if None

This is the Pythonic equivalent of Ruby's "nil-as-no-op setter
convention"
: None-as-default-marker. Pass os.environ.get(...)
directly — if the env var is unset, you get None, the validator fills
in the default, and you didn't need an if ENV[...] guard:

operator = pay_kit.Operator(
    recipient = os.environ.get("PAY_KIT_OPERATOR_RECIPIENT"),   # None ⇒ signer.pubkey
    signer    = pay_kit.Signer.from_env("PAY_KIT_OPERATOR_KEY"), # None ⇒ Signer.demo()
)

Defaults

The operator is optional. If you never pass operator=..., you get:

pay_kit.Operator(
    recipient = None,                     # → derived from signer.pubkey
    signer    = pay_kit.Signer.demo(),    # → hard-coded demo keypair
    fee_payer = True,
)

Signer.demo() returns the package-shipped demo keypair (constant,
base58 pubkey: AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj). The
library calls logging.warning(...) once at boot when the demo signer is
in use, and raises pay_kit.DemoSignerOnMainnetError if combined with
network="solana_mainnet".

Signer factories

Factories split by execution model. Local/in-process signers live as
classmethods on pay_kit.Signer and ship in v1. Remote enclave signers
(KMS, Vault, HSMs) are reserved under pay_kit.kms but not part of
the initial release
— they're sketched here so the namespace shape is
locked in before anyone writes against pay_kit.Signer.gcp_kms(...)
and we have to rename later.

Both halves return objects satisfying the same Signer protocol —
.pubkey, .sign(message), .fee_payer — so the rest of PayKit
doesn't care which side of the split you used.

# === Local — synchronous, no I/O on .sign. Ships in v1. ===

pay_kit.Signer.demo()                                  # hard-coded demo keypair
pay_kit.Signer.bytes([1, 2, 3, ..., 64])               # raw 64-byte secret (list[int])
pay_kit.Signer.json("[1,2,3,...,64]")                  # Solana-CLI JSON-array format
pay_kit.Signer.base58("4PzYg...wuFNQT")                # Phantom / Solflare export string
pay_kit.Signer.hex("abcd1234...")                      # 128-char hex string
pay_kit.Signer.file("/etc/paykit/operator.json")       # 64-byte JSON array on disk
pay_kit.Signer.from_env("PAY_KIT_OPERATOR_KEY")        # env var (auto-detects json/base58/hex)
pay_kit.Signer.generate()                              # fresh ephemeral keypair (tests only)

# === Remote enclave — asynchronous, network I/O on .sign. FUTURE WORK. ===
# Sketched here to reserve the namespace shape. Not in v1.

pay_kit.kms.gcp(key_name="...", pubkey="...")          # not implemented yet
pay_kit.kms.aws(key_id="...", region="...", pubkey="...")  # not implemented yet
pay_kit.kms.vault(addr="...", path="...", pubkey="...")     # not implemented yet

Naming follows Python convention: from_* for parsing constructors
(matches datetime.fromisoformat, Path.from_uri). Class-method
factories on Signer; module-level functions on pay_kit.kms (lowercase
module, sub-namespace).

Signer.from_env(name) contract

pay_kit.Signer.from_env("VAR_NAME") is the env-driven loader. It
returns:

  • A Signer parsed from the env var's contents, auto-detecting the
    encoding (Solana-CLI JSON array, base58 string, or hex string — same
    formats supported by the explicit .json / .base58 / .hex
    classmethods).
  • None if the env var is unset or empty.
  • Raises pay_kit.InvalidKeyError if the var is set but malformed.
    Silent fallback on malformed input would mask bugs.

The None return composes with the Operator model_validator
Operator(signer=Signer.from_env("…")) leaves the default in place when
the env var is unset.

Cascading

The operator is one source of truth for things that used to be
scattered:

Was Now
pay_to operator.recipient
x402.facilitator_secret_key operator.signer (when running x402)
(manual SOL fee management) operator.fee_payer = True

Env-var conventions follow the same rename. The old names keep working
for one minor release with a DeprecationWarning, then go away:

Old New
PAY_KIT_PAY_TO PAY_KIT_OPERATOR_RECIPIENT
PAY_KIT_X402_FACILITATOR_KEY PAY_KIT_OPERATOR_KEY
PAY_KIT_X402_FACILITATOR * PAY_KIT_X402_FACILITATOR_URL
PAY_KIT_MPP_SECRET PAY_KIT_MPP_CHALLENGE_BINDING_SECRET

* The old PAY_KIT_X402_FACILITATOR value in demo configs was actually
a Solana RPC URL, not a facilitator URL — that data should move to the
new PAY_KIT_RPC_URL and the facilitator var should be left unset
unless a real x402 facilitator is being pointed at.

The MPP rename tracks the spec's vocabulary — draft-httpauth-payment-00
§5.1.2.1.1 calls this the "server secret" used for "stateless challenge
binding" (HMAC-SHA256 over challenge parameters).

Gate-level pay_to still overrides the operator's recipient (the
marketplace pattern — operator signs, seller is paid).

Rules

  1. operator.recipient defaults to operator.signer.pubkey when
    None. The signer always has a pubkey, so this is a safe default.
  2. Gate pay_to overrides operator.recipient per gate.
  3. operator.signer is the x402 facilitator key. Setting
    x402.signer = ... is the escape hatch; not advertised in the
    getting-started docs.
  4. operator.fee_payer = True (default) means the operator's signer
    pays Solana network fees on settlement. Flip to False when fees
    come from elsewhere.
  5. MPP secret stays separate. MPP signs HMACs, not Ed25519, so
    mpp.challenge_binding_secret remains explicit — KMS-backed signers
    don't apply (the spec calls this the "server secret" / "shared
    secret" used for "stateless challenge binding"; see
    draft-httpauth-payment-00 §5.1.2.1.1).
  6. Demo signer warns on every boot. Construction refuses to proceed
    if network="solana_mainnet" resolves to the demo signer.

Chain access — rpc_url and x402 modes

Two separate concerns that have been historically confused: how PayKit
talks to Solana
(rpc_url) and whether PayKit handles x402
verification/settlement itself
(x402.facilitator_url). Different
fields, different layers.

rpc_url — Solana RPC URL

The HTTP endpoint of a Solana RPC node. Used by:

  • MPP, always — verifying that a submitted settlement transaction
    landed on-chain with the right recipient and amount.
  • x402 self-hosted mode — same plus tx construction and submission.
  • x402 delegated mode — not used; the facilitator owns the RPC.

rpc_url is optional. When None, PayKit picks a default from
network:

network Default rpc_url
"solana_mainnet" https://api.mainnet-beta.solana.com
"solana_devnet" https://api.devnet.solana.com
"solana_localnet" http://localhost:8899

Override with rpc_url="https://my-helius-endpoint.example.com" (or
any custom RPC — Helius, QuickNode, private validator). The public
Solana RPCs are rate-limited and unsuitable for production traffic; a
warning fires at boot if network="solana_mainnet" and rpc_url
resolves to the public default.

x402.facilitator_url — optional delegation

Per the x402 spec, a facilitator is a server that facilitates
verification and execution of payments for one or many networks

(coinbase/x402 README). It exposes /verify and /settle HTTP
endpoints; the resource server (merchant) POSTs payment payloads and
the facilitator handles the RPC, gas/fees, and on-chain submission. The
merchant never sees the chain.

Two modes:

# Delegated: PayKit POSTs to the facilitator. No RPC needed for x402.
x402 = pay_kit.X402Config(facilitator_url="https://facilitator.example.com")

# Self-hosted: PayKit runs verification and settlement itself.
# facilitator_url left None; rpc_url + operator.signer do the work.
x402 = pay_kit.X402Config(facilitator_url=None)   # the default

Default is self-hosted (None). Setting facilitator_url to any
URL opts into delegation. When delegated:

  • rpc_url is unused by x402 (still used by MPP if enabled).
  • operator.signer is still used to authenticate to the facilitator if
    the facilitator requires merchant identity (some don't; depends on
    the facilitator's auth scheme).
  • operator.fee_payer is ignored by x402 — the facilitator handles
    fees per its own policy.

The previous demo's x402.facilitator = "https://402.surfnet.dev:8899"
was incorrect: that URL is a Solana validator RPC (port 8899), not an
x402 facilitator. Self-hosted mode with rpc_url set to that URL is
the correct shape; the facilitator field stays None in the demo until
a real facilitator is available to point at.

Price helper — denomination vs. settlement

from pay_kit import usd

usd("0.10")                     # denom USD, settled in configured defaults
usd("0.10", "USDC")             # narrow to one stablecoin
usd("0.10", "USDC", "USDT")     # ordered preference: USDC first, USDT fallback
usd("0.10", *config.stablecoins)  # dynamic

Future-proofed: eur("0.20", "EURC"), gbp(...), etc. slot in identically.
Amounts use decimal.Decimal internally — never float. (usd accepts
str | Decimal | int; float is rejected to avoid the standard FP trap.)

Gates — module-level constants

Python convention is to define gates as module-level Gate objects, imported
where needed. No central registry class — this is a deliberate departure
from the Ruby Pricing < PayKit::Pricing pattern, because Python's import
system already gives you one source of truth per module:

# app/pricing.py
from pay_kit import Gate, usd

REPORT    = Gate(amount=usd("0.10"),         description="Premium report")
API_CALL  = Gate(amount=usd("0.001", "USDC"), accept=["x402"])
PAYWALL   = Gate(amount=usd("0.50"),         accept=["mpp", "x402"])  # MPP preferred

# Inclusive fee — taken out of the amount. MPP only (x402 auto-disabled).
SALE = Gate(
    amount     = usd("10.00"),
    pay_to     = SELLER_WALLET,
    fee_within = {PLATFORM_WALLET: usd("1.00")},
)

# Surcharge — added on top of the amount.
TICKET = Gate(
    amount     = usd("10.00"),
    pay_to     = SELLER_WALLET,
    fee_on_top = {PLATFORM_WALLET: usd("0.50")},
)

For dynamic pricing (price depends on the request), decorate a callable:

from pay_kit import gate

@gate.dynamic
def tiered(req) -> Gate:
    tier = req.query_params["tier"]
    match tier:
        case "basic":   return Gate(amount=usd("0.10"))
        case "premium": return Gate(amount=usd("5.00"))

Amount and fees

A gate has one amount (the base it charges) and zero or more fees. Two
kwargs cover every real-world case; each takes a {recipient: price} dict,
so one or many recipients use the same syntax:

  • fee_within: {...} — taken out of the amount. Customer pays the
    amount; the pay_to recipient nets less.
  • fee_on_top: {...} — added on top of the amount. Customer pays
    amount + fee; the pay_to recipient nets the full amount.
# Simple, no fees:
REPORT = Gate(amount=usd("0.10"), pay_to=ALICE)
# Customer pays $0.10 ▸ ALICE nets $0.10

# fee_within (Stripe Connect "application_fee" pattern):
SALE = Gate(
    amount     = usd("10.00"),
    pay_to     = SELLER,
    fee_within = {PLATFORM: usd("0.30")},
)
# Customer pays $10.00 ▸ SELLER nets $9.70 ▸ PLATFORM $0.30

# fee_on_top (surcharge / cardholder-pays):
TICKET = Gate(
    amount     = usd("10.00"),
    pay_to     = SELLER,
    fee_on_top = {PLATFORM: usd("0.50")},
)
# Customer pays $10.50 ▸ SELLER nets $10.00 ▸ PLATFORM $0.50

# Multiple recipients, same kind — just add dict entries:
TICKET = Gate(
    amount     = usd("10.00"),
    pay_to     = SELLER,
    fee_on_top = {PLATFORM: usd("0.30"), GATEWAY: usd("0.20")},
)

# Mixed kinds — both kwargs:
COMPLEX = Gate(
    amount     = usd("100.00"),
    pay_to     = SELLER,
    fee_within = {PLATFORM: usd("3.00")},
    fee_on_top = {GATEWAY:  usd("0.50")},
)
# Customer pays $100.50 ▸ SELLER nets $97.00 ▸ PLATFORM $3.00 ▸ GATEWAY $0.50

Properties exposed on the Gate model:

  • gate.amount — declared base
  • gate.total — what the customer pays (amount + sum(fee_on_top)).
    Advertised in the 402 challenge.
  • gate.payout(to=X) — what recipient X nets after all fees applied.

Rules:

  1. Fixed amounts only. No basis points, no percentages, no rounding policy.
  2. pay_to is optional and defaults to operator.recipient. Most
    gates omit it; marketplace gates set it to route to a seller. Fees
    route to their own recipients via the dict kwargs.
  3. All amounts share one denomination. Mix usd(...) and eur(...) and
    the Gate constructor raises ValidationError at import time.
  4. sum(fee_within values) <= amount. Pydantic validator at construction
    — the pay_to recipient can't end up with a negative payout.
  5. x402 is automatically disabled when fee_within or fee_on_top is
    present.
    Stock x402 facilitators settle to a single address; multi-
    recipient settlement is MPP-only. The resolver strips "x402" from
    accept silently. Explicitly setting accept=["x402"] on a gate with
    fees raises at construction.
  6. Stablecoin preference is gate- or config-level, not per-fee.
    Per-recipient override via the amount form (usd("3.00", "USDC")) is the
    escape hatch.

FastAPI — dependency injection

The killer FastAPI pattern: Depends(require_payment(GATE)) injects a
typed Payment into your handler. Free routes can opportunistically observe
the payment via Depends(get_payment) returning Payment | None:

from fastapi import FastAPI, Depends
from pay_kit.fastapi import require_payment, get_payment, Payment
from app.pricing import REPORT, BULK_REPORT

app = FastAPI()

@app.get("/report")
async def report(payment: Payment = Depends(require_payment(REPORT))):
    return {"ok": True, "paid_by": payment.scheme}

@app.get("/stats")
async def stats(payment: Payment | None = Depends(get_payment)):
    return {"ok": True, "premium": payment is not None}

# Inline form, no `pricing.py` entry:
@app.get("/oneoff")
async def oneoff(
    payment: Payment = Depends(require_payment(Gate(amount=usd("0.25")))),
):
    return {"ok": True}

Flask — decorator

Stacks naturally above @app.route. The payment is on flask.g:

from flask import Flask, jsonify, g
from pay_kit.flask import require_payment, is_paid
from app.pricing import REPORT, BULK_REPORT

app = Flask(__name__)

@app.route("/report")
@require_payment(REPORT)
def report():
    return jsonify(ok=True, paid_by=g.payment.scheme)

@app.route("/stats")
def stats():
    return jsonify(ok=True, premium=is_paid(BULK_REPORT))

Django — decorator + middleware

# settings.py
MIDDLEWARE = [
    # ...
    "pay_kit.django.PaymentMiddleware",
]

# views.py
from pay_kit.django import require_payment, is_paid
from app.pricing import REPORT

@require_payment(REPORT)
def report(request):
    return JsonResponse({"ok": True, "paid_by": request.payment.scheme})

Starlette — pure ASGI middleware

The lowest layer. Framework shims wrap this:

from starlette.applications import Starlette
from pay_kit.starlette import PaymentMiddleware

app = Starlette(middleware=[Middleware(PaymentMiddleware)])

The trio — same names, framework-agnostic semantics

Mirrors Ruby's require_payment! / paid? / payment. Python has no ? /
! suffixes, so prefixes carry the meaning:

Function Returns On failure
require_payment(GATE) Payment raises 402
is_paid(GATE) bool never
get_payment() Payment | None never

Errors

Exception hierarchy modeled on Stripe's — base class plus typed leaves so
apps can except precisely:

class PayKitError(Exception): ...
class PaymentRequired(PayKitError): ...       # → 402
class InvalidProof(PayKitError): ...          # → 400 or 402 with detail
class ChallengeExpired(InvalidProof): ...
class SchemeNotSupported(PayKitError): ...    # → 406

Framework shims convert these to the appropriate response — FastAPI to
HTTPException, Flask to abort(...), Django to a JsonResponse. Apps
can register their own handlers to customize the response body.

Layers

pay_kit.starlette       # ASGI middleware — protocol-level
pay_kit.fastapi         # Depends() helpers, Payment dependency
pay_kit.flask           # @require_payment decorator, g.payment
pay_kit.django          # decorator + middleware, request.payment
pay_kit.gate            # Gate Pydantic model
pay_kit.price           # Price Pydantic model (returned by usd, eur, …)
pay_kit.fee             # Fee Pydantic model (internal)
pay_kit.operator        # Operator Pydantic model
pay_kit.signer          # Signer class + classmethod factories (demo/bytes/file/...)
pay_kit.kms             # Remote-enclave signer factories (FUTURE — reserved namespace)
pay_kit.schemes.x402    # x402 adapter
pay_kit.schemes.mpp     # MPP adapter

Design rules — locked in

  1. A gate is the unit. Amount alone undersells it — gates carry policy
    (accepted schemes), metadata (description), and optionally dynamic logic
    via @gate.dynamic.
  2. Strings expand against config defaults; objects carry overrides.
    Same hybrid on both axes — schemes (["x402"]) and stablecoins
    (["USDC"]). Strings cover ~90%.
  3. Order is semantic everywhere. accept and stablecoin lists are
    preference order, not sets. The 402 challenge advertises in this order.
  4. Frozen value objects. Gate, Price, Fee are Pydantic v2 models
    with model_config = {"frozen": True}. Mutation raises. Validation at
    construction.
  5. Denomination and settlement are separate. usd("0.10", "USDC", "USDT")
    means "$0.10 USD, settle in USDC or USDT." Merchants think fiat.
  6. ASGI first, framework shims on top. FastAPI/Starlette/Flask/Django
    pick up for free if the core is pure middleware. No framework is
    special
    — Flask is a first-class citizen, not a documentation
    afterthought. Anything FastAPI does (DI lookup, predicate, accessor) must
    work identically in Flask and Django.
  7. require_payment raises, is_paid returns bool, get_payment returns
    Payment | None.
    Mirrors Clearance's bang/predicate/accessor split,
    adapted to Python verb-prefix convention.
  8. Errors are typed exceptions in a hierarchy, not 402-shaped dicts.
    Apps except PaymentRequired to customize the response.
  9. One source of truth per axis. Stablecoin mints live in config with
    sensible mainnet/devnet defaults — apps shouldn't have to look up
    addresses.
  10. Amount + fees, never splits. Multi-recipient gates are modelled as
    one amount plus fee_within / fee_on_top dict kwargs. Fixed amounts
    only, MPP only. x402 auto-disabled on any gate with fees because stock
    x402 facilitators settle to a single address.
  11. No central registry class. Module-level Gate(...) constants in a
    pricing.py are the Python idiom. The from app.pricing import REPORT
    line is the registry lookup.
  12. Sync/async parity. Every public helper ships in both flavors, or is
    detected and dispatched correctly. FastAPI handlers can be async; Flask
    handlers are sync. The middleware lives once.
  13. Decimal, never float. All amount construction goes through
    helpers that reject float inputs. Floats are a known footgun for
    money; refuse them.
  14. Operator is the merchant identity, one source of truth. Recipient,
    Ed25519 signer, and fee-payer flag live together on Operator.
    Gate-level pay_to and X402Config.signer are escape hatches, not
    the primary surface. Zero config boots on the in-memory demo signer
    with a visible warning; mainnet refuses to start under the demo key.
  15. None-as-default-marker on optional fields. Operator(recipient= os.environ.get("…")) leaves the field as None when the env var is
    unset, and a Pydantic model_validator(mode="after") resolves Nones
    to the actual default. No if ENV[...] guards in user code.
  16. Signer factories use from_* for parsing constructors. Matches
    datetime.fromisoformat / Path.from_uri convention. Signer.demo(),
    Signer.bytes(...), Signer.json(...), Signer.base58(...),
    Signer.from_env(...). Remote enclave signers (KMS, Vault) live under
    pay_kit.kms — separate sub-module to give async backends room to
    grow without crowding the local cases. KMS is future work, not
    in v1.

Style — Python micro-rules

  • Type hints on all public surfaces. Gate, Payment, Price are
    exported with __all__.
  • Literal types for enums. accept: list[Literal["x402", "mpp"]].
    Gives autocomplete in IDEs without runtime cost.
  • Stablecoin strings uppercase: "USDC", not "usdc". Tickers, not
    variable names.
  • Scheme strings lowercase: "x402", "mpp", "x402_exact".
  • Network strings lowercase + underscore: "solana_devnet". Validated
    with a Literal at configure time.
  • Pydantic v2 BaseModel with frozen=True for all value objects.
  • from __future__ import annotations at the top of every module — PEP
    563 deferred evaluation.
  • Ruff + Pyright in strict mode. Lint and type-check are part of CI.
  • No __init__.py re-exports beyond the public API. Internal modules
    use leading underscore (_resolver.py).

Open questions

Things to decide before we cut code:

  1. Inline gate form for one-offs. Currently both
    require_payment(REPORT) and require_payment(Gate(amount=usd(...)))
    work. Cleaner to support both — same call site, just different
    arguments. Worth keeping the inline path?
  2. @gate.dynamic vs callable in the Gate constructor. Decorator is
    cleaner; Gate(amount=lambda req: ...) would also work. Decorator wins
    on readability but adds an import. Vote needed.
  3. configure() vs configure_from(Settings) vs Pydantic-only. All
    three are supported in the current sketch. If most users want
    env-driven, configure_from could be the only path. Cost is one extra
    import; benefit is fewer ways to do the same thing.
  4. Async-only middleware, with sync shims — or true dual-stack? FastAPI
    and Django 4+ are async-capable; Flask 3 has async views; Django sync
    views are common. Probably easiest to ship async middleware and let
    sync shims block on it via asgiref.sync.async_to_sync. Confirm.
  5. Should Gate be hashable? Module-level constants used as dict keys
    in user code is plausible. Pydantic v2 frozen models are hashable by
    default — keep that, document it.

What we are not building (yet)

  • Subscription / recurring billing.
  • Custodial wallet management.
  • Non-USD-pegged settlement (BTC/SOL-denominated pricing).
  • Chargeback / refund flows.
  • A CLI — solana-pay-kit stays library-only; CLI is a separate package
    if needed.

If a feature request lands in one of these buckets, push back hard before
expanding scope.

Future work (post-v1)

Reserved here so the design has a place to grow without breaking changes:

  • pay_kit.kms module — remote enclave signers. gcp, aws,
    vault. Async on .sign (network I/O); share the Signer protocol
    contract so call sites don't change. Module name is locked in now;
    implementation lands after v1.
  • Additional fiat denoms. eur(...), gbp(...) slot into the
    existing usd(...) shape.
  • Additional remote-signer backends (Turnkey, Privy, Fireblocks,
    HSMs) under pay_kit.kms.*.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions