Skip to content

Golang - interface for PayKit #137

@lgalabru

Description

@lgalabru

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

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

Intent

  • One module, one surface, two protocols underneath (x402, MPP). Callers
    shouldn't have to care which protocol settled a request unless they ask.
  • Feel like it belongs in the stdlib. No DSLs, no reflection magic, no
    package-level mutable state, no surprising types.
  • Middleware is func(http.Handler) http.Handler — works with chi, gorilla,
    std http.ServeMux, anything that respects the stdlib interface.

Naming

Go conventions are tighter than Ruby/Python — module path is the import
path, and the package name comes from the directory. The split we chose:

Surface Name
Repo / org github.com/solana-foundation/pay-kit
Module path github.com/solana-foundation/pay-kit/go
Package name paykit
Subpackages paykit/schemes/x402, paykit/schemes/mpp, …

Rationale: short package name keeps call sites readable
(paykit.Configure(...) beats solanapaykit.Configure(...)), and dropping
the solana prefix in the package name leaves room for non-Solana rails
later without breaking imports. The module path keeps solana-foundation for
discoverability.

Inspirations

  • net/http + chi
    func(http.Handler) http.Handler is the only middleware shape we
    produce. Works with everything. Chi is the spiritual reference for the
    middleware-stacking style.
  • aws-sdk-go-v2Config
    struct with zero-value defaults; New(cfg) (*Client, error); no globals;
    every method takes ctx context.Context first.
  • stripe-go — typed errors with
    Unwrap(); sensible exported names; resource-oriented domain types.
  • log/slog (stdlib) — request-scoped
    data threaded via context.Context with a private key type to avoid
    collisions across packages.
  • uber-go/zap — functional options
    only when the struct gets unwieldy. Default to struct config; reserve
    options for extensibility points.
  • regexp.MustCompile, template.Must (stdlib) — MustXxx
    constructors that panic on bad input, for use in var blocks at package
    init time.

Vocabulary

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

Term Meaning
operator Merchant identity: recipient + signer + fee-payer flag.
signer Ed25519 key source — signer.Demo, signer.FromFile, kms.GCP, …
gate A protected unit. Has an amount, optional fees, accepted schemes.
amount The base amount a gate charges, before any FeeOnTop.
total What the customer pays: Amount + sum(FeeOnTop). Derived.
price Value object returned by USD(...): number + denom + settlement.
feeWithin Fee taken out of the amount. PayTo recipient nets less.
feeOnTop Fee added to the amount. Customer pays more; PayTo nets full.
payment Proof submitted by the client to pass a gate.
scheme Protocol used to prove payment: paykit.X402, paykit.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

Struct config with zero-value defaults; explicit *Client returned.
Mirrors aws-sdk-go-v2. Zero-value Config{} is invalid (network
required), but every other field has a sensible default — including a
hard-coded signer.Demo() so smoke tests boot with one line:

client, err := paykit.New(paykit.Config{Network: paykit.SolanaLocalnet})

Production form:

package main

import (
    "log"
    "os"
    "time"

    "github.com/solana-foundation/pay-kit/go/paykit"
    "github.com/solana-foundation/pay-kit/go/paykit/signer"
)

func main() {
    client, err := paykit.New(paykit.Config{
        Network:     paykit.SolanaDevnet,
        Accept:      []paykit.Scheme{paykit.X402, paykit.MPP},
        Stablecoins: []paykit.Stablecoin{paykit.USDC, paykit.USDT},

        RPCURL: os.Getenv("PAY_KIT_RPC_URL"), // "" → default per Network

        Operator: paykit.Operator{
            Recipient: paykit.Address(os.Getenv("PAY_KIT_OPERATOR_RECIPIENT")), // "" → Signer.Pubkey()
            Signer:    signer.MustFromFile(os.Getenv("PAY_KIT_OPERATOR_KEY_FILE")),
            FeePayer:  true,
        },

        // OPTIONAL — when set, paykit POSTs to this URL's /verify and /settle
        // endpoints and never touches the chain itself. When "", paykit runs
        // x402 locally using RPCURL + Operator.Signer. See "Chain access" below.
        X402: paykit.X402Config{
            FacilitatorURL: os.Getenv("PAY_KIT_X402_FACILITATOR_URL"),
            Scheme:         "exact",
        },
        MPP: paykit.MPPConfig{
            Realm:                  "MyApp",
            ChallengeBindingSecret: []byte(os.Getenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET")),
            ExpiresIn:              5 * time.Minute,
        },
    })
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    // ... wire into HTTP mux ...
}

There is no top-level PayTo and no X402Config.FacilitatorSecretKey
both cascade from Operator. See Operator below.

Typed string constants for domain enums — vet-friendly, IDE-friendly:

type Scheme     string ; const ( X402 Scheme     = "x402";  MPP Scheme   = "mpp" )
type Stablecoin string ; const ( USDC Stablecoin = "USDC";  USDT Stablecoin = "USDT" )
type Network    string ; const ( SolanaMainnet  Network = "solana_mainnet"
                                 SolanaDevnet   Network = "solana_devnet"
                                 SolanaLocalnet Network = "solana_localnet" )
type Address    string

Operator — merchant identity in one place

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

  • Recipient — where settled funds land. Default PayTo for every gate.
  • Signer — the Ed25519 keypair used to sign x402 facilitator
    challenges, and (if FeePayer == true) to pay Solana network fees.
  • FeePayer — whether the operator's signer also pays Solana network
    fees on settlement transactions.
cfg.Operator = paykit.Operator{
    Recipient: "Cs2zdfUNonRdRGsiZUQQLdTxzxVvJZmgiX2mpLYKuEqP",
    Signer:    signer.MustFromFile("/etc/paykit/operator.json"),
    FeePayer:  true,
}

Operator is a value struct, not a pointer-handle. Zero-value semantics
fill in defaults inside paykit.New(cfg):

  • Signer == nil → replaced by signer.Demo()
  • Recipient == "" → replaced by Signer.Pubkey()
  • FeePayer == false is the explicit default; to opt out of fee-paying,
    no action needed since it's already the zero value. To opt in, set
    FeePayer: true (recommended for typical merchant flows).

This is the Go equivalent of Ruby's "nil-as-no-op setter convention":
zero-value-as-no-opinion. Operator{Signer: signer.FromEnv("X")} where
signer.FromEnv returns nil for unset means paykit.New substitutes
the demo signer — no if env != "" guards in user code.

Defaults

If the caller never sets cfg.Operator, they get:

paykit.Operator{
    Recipient: "",                  // → resolved to Signer.Pubkey() at New() time
    Signer:    nil,                 // → resolved to signer.Demo() at New() time
    FeePayer:  true,                // applied by paykit.New() when Signer resolves to Demo
}

signer.Demo() returns the package-shipped demo keypair (constant,
base58 pubkey: AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj). The
library emits slog.Warn(...) once at New() when the demo signer is in
use, and returns ErrDemoSignerOnMainnet if combined with
Network == paykit.SolanaMainnet.

Signer backends

Factories split by execution model. Local/in-process signers live in the
paykit/signer sub-package and ship in v1. Remote enclave signers (KMS,
Vault, HSMs) are reserved under paykit/kms but not part of the
initial release
— they're sketched here so the import path is locked
in before anyone writes against signer.GCPKMS(...) and we have to
rename later.

Both halves return values satisfying the same interface:

type Signer interface {
    Pubkey() Address
    Sign(ctx context.Context, message []byte) ([]byte, error)
    FeePayer() bool
}

Local signers accept but ignore ctx; KMS signers honor it (network
I/O, deadlines, cancellation).

import "github.com/solana-foundation/pay-kit/go/paykit/signer"

// === Local — synchronous, ctx ignored. Ships in v1. ===

signer.Demo()                                      // hard-coded demo keypair
signer.FromBytes([]byte{1, 2, 3, /* ..., 64 */})   // raw 64-byte secret
signer.FromJSON("[1,2,3,...,64]")                  // Solana-CLI JSON-array format
signer.FromBase58("4PzYg...wuFNQT")                // Phantom / Solflare export string
signer.FromHex("abcd1234...")                      // 128-char hex string
signer.FromFile("/etc/paykit/operator.json")       // 64-byte JSON array on disk
signer.FromEnv("PAY_KIT_OPERATOR_KEY")             // env var (auto-detects json/base58/hex)
signer.Generate()                                  // fresh ephemeral keypair (tests only)

// Must* variants panic on error, for var-block use:
var op = paykit.Operator{Signer: signer.MustFromFile("/etc/paykit/operator.json")}

The runtime-error variants all return (Signer, error); the Must* set
covers the formats with non-trivial failure modes: MustFromJSON,
MustFromBase58, MustFromHex, MustFromFile. Demo() and
Generate() can't fail; FromBytes accepts a Go slice where
compile-time checks are already enough; FromEnv has (nil, nil) for
unset semantics that don't fit Must*.

// === Remote enclave — async, network I/O on Sign(ctx, …). FUTURE WORK. ===
// Sketched here to reserve the import path. Not in v1.

import "github.com/solana-foundation/pay-kit/go/paykit/kms"

kms.GCP(kms.GCPConfig{KeyName: "...", Pubkey: "..."})        // not implemented yet
kms.AWS(kms.AWSConfig{KeyID: "...", Region: "...", Pubkey: "..."})  // not implemented yet
kms.Vault(kms.VaultConfig{Addr: "...", Path: "...", Pubkey: "..."}) // not implemented yet

Remote signers take per-backend *Config structs once option counts
exceed two — the "config struct beats positional args" Go convention.
All take an explicit Pubkey field so boot doesn't probe the enclave;
misconfiguration surfaces at the first Sign() call.

signer.FromEnv(name) contract

signer.FromEnv("VAR_NAME") returns:

  • (Signer, nil) — env var set and parsed, auto-detecting the encoding
    (Solana-CLI JSON array, base58, or hex — same formats supported by
    the explicit FromJSON / FromBase58 / FromHex constructors).
  • (nil, nil) — env var is unset or empty.
  • (nil, *signer.InvalidKeyError) — env var set but malformed. Silent
    fallback on malformed input would mask bugs.

The (nil, nil) return composes with Operator zero-value resolution
Operator{Signer: signer.MustFromEnv("…")} is wrong (it would panic
on unset); use plain signer.FromEnv and discard the error if you want
"use demo when unset":

sgn, err := signer.FromEnv("PAY_KIT_OPERATOR_KEY")
if err != nil { log.Fatal(err) } // malformed
cfg.Operator.Signer = sgn          // nil-when-unset; paykit.New fills in Demo()

Cascading

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

Was Now
Config.PayTo Config.Operator.Recipient
X402Config.FacilitatorSecretKey Config.Operator.Signer (when running x402)
(manual SOL fee management) Config.Operator.FeePayer = true

Env-var conventions follow the same rename. Old names are detected at
paykit.New(cfg) time and emit slog.Warn pointing at the new name;
removed after one minor release. Go has no Ruby-style deprecate :foo
macro — boot-time env inspection is the only idiomatic spot.

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 PayTo still overrides the operator's recipient (the
marketplace pattern — operator signs, seller is paid).

Rules

  1. Operator.Recipient defaults to Operator.Signer.Pubkey() when
    "". The signer always has a pubkey, so this is a safe default.
  2. Gate PayTo overrides Operator.Recipient per gate.
  3. Operator.Signer is the x402 facilitator key. Setting
    X402Config.Signer directly is the escape hatch; not advertised in
    the getting-started docs.
  4. Operator.FeePayer == true means the operator's signer pays
    Solana network fees on settlement. false when fees come from
    elsewhere.
  5. MPP secret stays separate. MPP signs HMACs, not Ed25519, so
    MPPConfig.ChallengeBindingSecret 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 New(). paykit.New returns
    ErrDemoSignerOnMainnet when the resolved signer is signer.Demo()
    and Network == SolanaMainnet.

Chain access — RPCURL and x402 modes

Two separate concerns that have been historically confused: how paykit
talks to Solana
(Config.RPCURL) and whether paykit handles x402
verification/settlement itself
(X402Config.FacilitatorURL). Different
fields, different layers.

Config.RPCURL — 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.

RPCURL is optional. When "", paykit.New picks a default from
Network:

Network Default RPCURL
paykit.SolanaMainnet https://api.mainnet-beta.solana.com
paykit.SolanaDevnet https://api.devnet.solana.com
paykit.SolanaLocalnet http://localhost:8899

Override with RPCURL: "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; paykit.New
emits slog.Warn when Network == SolanaMainnet and RPCURL resolves
to the public default.

X402Config.FacilitatorURL — 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.
cfg.X402 = paykit.X402Config{FacilitatorURL: "https://facilitator.example.com"}

// Self-hosted: paykit runs verification and settlement itself.
// FacilitatorURL left "" (the zero value); RPCURL + Operator.Signer do the work.
cfg.X402 = paykit.X402Config{}   // the default

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

  • RPCURL 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.FeePayer is ignored by x402 — the facilitator handles
    fees per its own policy.

The previous demo's X402Config.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 RPCURL set to that URL is the
correct shape; the facilitator field stays "" in the demo until a
real facilitator is available to point at.

Money helpers — denomination vs. settlement

Two functions per fiat: the error-returning form for runtime, the Must form
for boot-time var blocks. Variadic stablecoins follow the splat pattern:

p, err := paykit.ParseUSD("0.10")                               // runtime
p     := paykit.MustParseUSD("0.10")                            // boot-time
p     := paykit.MustParseUSD("0.10", paykit.USDC)                    // narrow
p     := paykit.MustParseUSD("0.10", paykit.USDC, paykit.USDT)       // preference order
p     := paykit.MustParseUSD("0.10", cfg.Stablecoins...)             // splat slice

Internally Price carries a decimal.Decimal — never float64. The
helpers reject float inputs at compile time (amount string parameter).
Future-proofed: paykit.ParseEUR("0.20", paykit.EURC) and its MustParseEUR
sibling slot in identically.

Gates — package-level var blocks

Idiomatic Go: declare gates at package scope, import where needed. Same
pattern as Python's module-level constants, and the same departure from
Ruby's Pricing class. The import "myapp/pricing" line is the registry
lookup.

// pricing/pricing.go
package pricing

import "github.com/solana-foundation/pay-kit/go/paykit"

const (
    SELLER   paykit.Address = "..."
    PLATFORM paykit.Address = "..."
    GATEWAY  paykit.Address = "..."
)

var (
    Report = paykit.Gate{
        Amount: paykit.MustParseUSD("0.10"),
        Desc:   "Premium report",
    }

    APICall = paykit.Gate{
        Amount: paykit.MustParseUSD("0.001", paykit.USDC),
        Accept: []paykit.Scheme{paykit.X402},
    }

    Paywall = paykit.Gate{
        Amount: paykit.MustParseUSD("0.50"),
        Accept: []paykit.Scheme{paykit.MPP, paykit.X402}, // MPP preferred
    }

    // Inclusive fee — taken out of the amount. MPP only (x402 auto-disabled).
    Sale = paykit.Gate{
        Amount: paykit.MustParseUSD("10.00"),
        PayTo:  SELLER,
        FeeWithin: paykit.Fees{
            PLATFORM: paykit.MustParseUSD("0.30"),
        },
    }

    // Surcharge — added on top of the amount.
    Ticket = paykit.Gate{
        Amount: paykit.MustParseUSD("10.00"),
        PayTo:  SELLER,
        FeeOnTop: paykit.Fees{
            PLATFORM: paykit.MustParseUSD("0.50"),
        },
    }
)

paykit.Fees is type Fees = map[Address]Price — a type alias that keeps
the literal short without inventing a new domain concept.

Amount and fees

A gate has one Amount (the base it charges) and zero or more fees. Two
fields cover every real-world case; each is a map[Address]Price, so one
or many recipients use the same syntax:

  • FeeWithin: paykit.Fees{...} — taken out of the amount. Customer
    pays the amount; the PayTo recipient nets less.
  • FeeOnTop: paykit.Fees{...} — added on top of the amount. Customer
    pays Amount + fee; the PayTo recipient nets the full amount.
// Simple, no fees:
Report := paykit.Gate{
    Amount: paykit.MustParseUSD("0.10"),
    PayTo:  ALICE,
}
// Customer pays $0.10 ▸ ALICE nets $0.10

// FeeWithin (Stripe Connect "application_fee" pattern):
Sale := paykit.Gate{
    Amount:    paykit.MustParseUSD("10.00"),
    PayTo:     SELLER,
    FeeWithin: paykit.Fees{PLATFORM: paykit.MustParseUSD("0.30")},
}
// Customer pays $10.00 ▸ SELLER nets $9.70 ▸ PLATFORM $0.30

// FeeOnTop (surcharge / cardholder-pays):
Ticket := paykit.Gate{
    Amount:   paykit.MustParseUSD("10.00"),
    PayTo:    SELLER,
    FeeOnTop: paykit.Fees{PLATFORM: paykit.MustParseUSD("0.50")},
}
// Customer pays $10.50 ▸ SELLER nets $10.00 ▸ PLATFORM $0.50

// Multiple recipients, same kind — just add map entries:
Ticket := paykit.Gate{
    Amount: paykit.MustParseUSD("10.00"),
    PayTo:  SELLER,
    FeeOnTop: paykit.Fees{
        PLATFORM: paykit.MustParseUSD("0.30"),
        GATEWAY:  paykit.MustParseUSD("0.20"),
    },
}

// Mixed kinds — both fields:
Complex := paykit.Gate{
    Amount:    paykit.MustParseUSD("100.00"),
    PayTo:     SELLER,
    FeeWithin: paykit.Fees{PLATFORM: paykit.MustParseUSD("3.00")},
    FeeOnTop:  paykit.Fees{GATEWAY:  paykit.MustParseUSD("0.50")},
}
// Customer pays $100.50 ▸ SELLER nets $97.00 ▸ PLATFORM $3.00 ▸ GATEWAY $0.50

Methods exposed on *Gate:

func (g *Gate) Amount()         Price            // declared base
func (g *Gate) Total()          Price            // Amount + sum(FeeOnTop). Advertised in the 402 challenge.
func (g *Gate) Payout(Address) (Price, bool)     // what this recipient nets
func (g *Gate) Validate()       error            // returns nil or a typed *GateError

Rules (validated by Gate.Validate(), called automatically at
client.Require(gate)):

  1. Fixed amounts only. No basis points, no percentages, no rounding policy.
  2. PayTo is optional and defaults to Config.Operator.Recipient.
    Most gates omit it; marketplace gates set it to route to a seller.
    Fees route to their own recipients via the map fields.
  3. All amounts share one denomination. Mix USD(...) and EUR(...)
    prices in the same gate and Validate() returns ErrMixedDenoms.
  4. sum(FeeWithin values) <= Amount. Validated at registration time —
    the PayTo recipient can't end up with a negative payout.
  5. x402 is automatically disabled when fees are 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: []Scheme{X402} on a gate with fees fails Validate()
    with ErrSchemeIncompatible.
  6. Stablecoin preference is gate- or config-level, not per-fee. Per-
    recipient override via the amount form (MustParseUSD("3.00", USDC)) is the
    escape hatch.

Middleware

Standard func(http.Handler) http.Handler shape — composes with anything:

client, _ := paykit.New(cfg)

// std http.ServeMux:
mux := http.NewServeMux()
mux.Handle("/report", client.Require(pricing.Report)(http.HandlerFunc(reportHandler)))

// chi:
r := chi.NewRouter()
r.With(client.Require(pricing.Report)).Get("/report", reportHandler)

// Inline gate, no pricing package entry:
mux.Handle("/oneoff", client.Require(paykit.Gate{
    Amount: paykit.MustParseUSD("0.25"),
    Desc:   "One-off",
})(http.HandlerFunc(handler)))

The middleware extracts the Authorization: Payment header, verifies the
proof, and stuffs the resulting *Payment into the request context.

Handler access — context-scoped trio

Mirrors Ruby's require_payment! / paid? / payment and Python's
require_payment / is_paid / get_payment, adapted to Go's
context-passing convention:

func reportHandler(w http.ResponseWriter, r *http.Request) {
    pmt, _ := paykit.PaymentFrom(r.Context())  // guaranteed non-nil here
    fmt.Fprintf(w, "paid by %s", pmt.Scheme)
}

func statsHandler(w http.ResponseWriter, r *http.Request) {
    pmt, ok := paykit.PaymentFrom(r.Context())  // comma-ok for opportunistic
    if ok && pmt.Gate == pricing.BulkReport.Name {
        // premium content
    }
}
Function Returns On failure
client.Require(Gate) func(...) http.Handler middleware writes 402 to response
paykit.PaymentFrom(ctx) (*Payment, bool) (nil, false)
paykit.IsPaid(ctx) bool never
paykit.IsPaidFor(ctx, gate) bool never

The context key is unexported (type ctxKey struct{}), per log/slog /
context package guidance — prevents cross-package collisions and accidental
overwrites.

Dynamic gates

For prices that depend on the request, use a GateFunc:

type GateFunc func(*http.Request) (Gate, error)

tiered := func(r *http.Request) (paykit.Gate, error) {
    switch r.URL.Query().Get("tier") {
    case "premium":
        return paykit.Gate{Amount: paykit.MustParseUSD("5.00")}, nil
    case "basic":
        return paykit.Gate{Amount: paykit.MustParseUSD("0.10")}, nil
    default:
        return paykit.Gate{}, fmt.Errorf("unknown tier")
    }
}

mux.Handle("/tiered", client.RequireFunc(tiered)(handler))

Errors

Typed-error hierarchy with sentinel roots, mirroring stripe-go and stdlib
patterns. Apps use errors.Is for stable comparisons and errors.As for
metadata:

var (
    ErrPaymentRequired      = errors.New("paykit: payment required")
    ErrInvalidProof         = errors.New("paykit: invalid proof")
    ErrChallengeExpired     = errors.New("paykit: challenge expired")
    ErrSchemeNotSupported   = errors.New("paykit: scheme not supported")
    ErrMixedDenoms          = errors.New("paykit: mixed denominations in gate")
    ErrSchemeIncompatible   = errors.New("paykit: x402 incompatible with multi-recipient gates")
    ErrDemoSignerOnMainnet  = errors.New("paykit: demo signer cannot be used on solana_mainnet")
)

type PaymentError struct {
    Code    string
    Gate    *Gate
    Schemes []Scheme
    err     error
}

func (e *PaymentError) Error() string { return ... }
func (e *PaymentError) Unwrap() error { return e.err }

Apps can override the 402 response writer:

client.SetErrorHandler(func(w http.ResponseWriter, r *http.Request, err error) {
    switch {
    case errors.Is(err, paykit.ErrChallengeExpired):
        // custom JSON body, different status, whatever
    default:
        paykit.DefaultErrorHandler(w, r, err)
    }
})

Layers

paykit                       // top-level: Config, Client, Gate, Price, Payment, Operator, errors
paykit/signer                // Signer interface + local factories (Demo, FromBytes, FromFile, …)
paykit/kms                   // Remote-enclave signer factories (FUTURE — reserved import path)
paykit/schemes/x402          // X402 adapter
paykit/schemes/mpp           // MPP adapter
paykit/internal/challenge    // package-private challenge issuer/verifier
paykit/internal/resolver     // gate validation, scheme/stablecoin resolution

No paykit/chi, paykit/gin, paykit/fiber subpackages — the middleware
is pure func(http.Handler) http.Handler, so the entire ecosystem already
works. If a framework demands a non-stdlib middleware shape (gin's
gin.HandlerFunc), we provide an adapter in user docs, not in the core
package.

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 GateFunc.
  2. Typed constants over strings. Scheme, Stablecoin, Network,
    Address are named types. The compiler catches misspellings; go vet
    catches non-exhaustive switches with the right linter.
  3. Order is semantic everywhere. Accept and Stablecoins slices are
    preference order, not sets. Maps (Fees) are unordered — recipient
    identity is what matters there, not order.
  4. Zero-value Config works. paykit.Config{} is only invalid because
    Network is required. The zero values for Accept, Stablecoins,
    RPCURL, Operator, and the scheme sub-configs all do sensible things
    — apply defaults, don't crash. Operator{} resolves to signer.Demo()
    with the demo pubkey as recipient.
  5. Denomination and settlement are separate. USD("0.10", USDC, USDT)
    means "$0.10 USD, settle in USDC or USDT." Callers think fiat.
  6. net/http first, framework adapters never. Anything that wraps
    paykit.Client.Require should be one-line user code, not a vendor-
    specific package we maintain.
  7. Require(gate) raises 402, IsPaid(ctx) returns bool,
    PaymentFrom(ctx) is comma-ok.
    Mirrors the bang/predicate/accessor
    split from Ruby and the prefix verbs from Python.
  8. Errors are typed values with sentinel roots. Apps errors.Is(err, paykit.ErrInvalidProof) for stable handling, errors.As for metadata.
  9. One source of truth per axis. Stablecoin mints live in paykit
    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 FeeWithin / FeeOnTop map fields. Fixed amounts
    only, MPP only. x402 auto-disabled on any gate with fees because stock
    x402 facilitators settle to a single address.
  11. No package-level mutable state. *paykit.Client is explicit; you
    create one and pass it through. No paykit.SetGlobalClient(...). No
    paykit.Configure(...) that hides a singleton.
  12. context.Context first arg on every public method that does I/O.
    Payment data in ctx via private key type. Cancellation respected.
  13. decimal.Decimal, never float64. All amount construction goes
    through helpers that accept string. Floats are a known footgun for
    money; they're literally uncallable.
  14. Operator is the merchant identity, one source of truth. Recipient,
    Ed25519 signer, and fee-payer flag live together on Operator.
    Gate-level PayTo and X402Config.Signer are escape hatches, not
    the primary surface. Zero config boots on the in-memory demo signer
    with a visible slog.Warn; mainnet returns ErrDemoSignerOnMainnet.
  15. Zero-value-as-no-opinion on optional fields. Operator{Signer: signer.FromEnv("X")} where FromEnv returns nil for unset means
    paykit.New fills in signer.Demo(). No if env != "" guards.
    paykit.New(cfg) normalizes zero values to resolved defaults
    internally.
  16. Signer factories live in paykit/signer sub-package. Package-level
    functions (signer.FromFile, signer.FromEnv, …) with MustXxx
    variants for boot-time var blocks. Remote enclave signers (KMS,
    Vault) live under paykit/kms — separate sub-package so async
    backends get room to grow (per-call timeouts, retry budgets,
    signature caching) without crowding the local cases. KMS is future
    work
    , not in v1.

Style — Go micro-rules

  • gofmt + goimports required. No exceptions.
  • go vet + staticcheck in CI; treat as compile errors.
  • golangci-lint with conservative defaults; document any disabled
    linters in the project root.
  • Exported names get doc comments// Gate represents …. Run
    go doc mentally before naming.
  • error is always the last return value. No mixing.
  • context.Context is always the first parameter on public methods
    that do I/O.
  • Variadic over slice-arg for ergonomic callers (MustParseUSD("0.10", USDC, USDT)); accept cfg.Stablecoins... for dynamic.
  • MustXxx for boot-time literals, Xxx returns (T, error) for
    runtime. Both exported; choose based on where the call lives.
  • Private context keys. type ctxKey struct{}, never string.
  • time.Duration for durations, time.Time for timestamps,
    decimal.Decimal for money.
  • No interface{} / any on public APIs unless genuinely needed.
  • io.Reader/io.Writer when streaming bodies. Not relevant for the
    core surface but applies to any request/response codec.
  • Deprecations land in paykit.New(cfg), not as wrapper shims. When a
    renamed env var is seen, log via slog.Warn with a pointer at the new
    name. Removed after one minor release. Go has no Ruby-style deprecate
    macro — boot-time detection is the only idiomatic spot.

Open questions

Things to decide before we cut code:

  1. Gate.Validate() — when to call? Options: (a) lazily on first
    client.Require(gate), (b) eagerly via paykit.MustGate(g) wrapper in
    var blocks, (c) both. Recommendation: (a) is enough; gates are static
    and middleware registration happens at startup. Confirm.
  2. Address underlying type. string (current sketch) vs. a struct
    carrying network + base58 form. String is simpler now; struct opens room
    for cross-chain. Solana-only for now, so string wins; revisit if
    non-Solana rails ship.
  3. decimal.Decimal import. github.com/shopspring/decimal is the
    community default but not stdlib. Alternative: roll a minimal internal
    paykit.Decimal over math/big.Rat. Lean toward shopspring for ecosystem
    familiarity; revisit if we want zero non-stdlib deps.
  4. Gate identification in Payment.Gate. Currently a string Name
    field. Alternative: *Gate pointer comparison. Pointer is faster but
    couples runtime state to the registry; string handle is loosely coupled
    and idiomatic for context-stuffed values. Lean string.
  5. Client.Close() semantics. Drains in-flight challenges? Cancels
    replay-store flushes? Define before shipping.
  6. Subpackages or flat? Currently sketched as flat paykit with
    schemes/x402 and schemes/mpp subpackages. Alternative: pure-flat,
    with all schemes registered via init. Flat is simpler but loses tree-
    shake potential for callers who only want one scheme.

Future work (post-v1)

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

  • paykit/kms sub-package — remote enclave signers. kms.GCP,
    kms.AWS, kms.Vault, each taking a backend-specific *Config
    struct. Honor ctx context.Context on Sign(ctx, …); share the
    signer.Signer interface so call sites don't change. Import path is
    locked in now; implementation lands after v1.
  • Additional fiat denoms. ParseEUR(...), ParseGBP(...) slot
    into the existing ParseUSD(...) shape.
  • Additional remote-signer backends (Turnkey, Privy, Fireblocks,
    HSMs) under paykit/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