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-v2 — Config
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
Operator.Recipient defaults to Operator.Signer.Pubkey() when
"". The signer always has a pubkey, so this is a safe default.
- Gate
PayTo overrides Operator.Recipient per gate.
Operator.Signer is the x402 facilitator key. Setting
X402Config.Signer directly is the escape hatch; not advertised in
the getting-started docs.
Operator.FeePayer == true means the operator's signer pays
Solana network fees on settlement. false when fees come from
elsewhere.
- 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).
- 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)):
- Fixed amounts only. No basis points, no percentages, no rounding policy.
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.
- All amounts share one denomination. Mix
USD(...) and EUR(...)
prices in the same gate and Validate() returns ErrMixedDenoms.
sum(FeeWithin values) <= Amount. Validated at registration time —
the PayTo recipient can't end up with a negative payout.
- 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.
- 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
- A gate is the unit. Amount alone undersells it — gates carry policy
(accepted schemes), metadata (description), and optionally dynamic logic
via GateFunc.
- 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.
- 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.
- 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.
- Denomination and settlement are separate.
USD("0.10", USDC, USDT)
means "$0.10 USD, settle in USDC or USDT." Callers think fiat.
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.
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.
- Errors are typed values with sentinel roots. Apps
errors.Is(err, paykit.ErrInvalidProof) for stable handling, errors.As for metadata.
- One source of truth per axis. Stablecoin mints live in
paykit
with sensible mainnet/devnet defaults — apps shouldn't have to look up
addresses.
- 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.
- 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.
context.Context first arg on every public method that does I/O.
Payment data in ctx via private key type. Cancellation respected.
decimal.Decimal, never float64. All amount construction goes
through helpers that accept string. Floats are a known footgun for
money; they're literally uncallable.
- 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.
- 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.
- 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:
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.
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.
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.
- 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.
Client.Close() semantics. Drains in-flight challenges? Cancels
replay-store flushes? Define before shipping.
- 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.
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.mdandpython/DESIGN.md;same model, Go idioms.
Intent
shouldn't have to care which protocol settled a request unless they ask.
package-level mutable state, no surprising types.
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:
github.com/solana-foundation/pay-kitgithub.com/solana-foundation/pay-kit/gopaykitpaykit/schemes/x402,paykit/schemes/mpp, …Rationale: short package name keeps call sites readable
(
paykit.Configure(...)beatssolanapaykit.Configure(...)), and droppingthe
solanaprefix in the package name leaves room for non-Solana railslater without breaking imports. The module path keeps
solana-foundationfordiscoverability.
Inspirations
net/http+chi—
func(http.Handler) http.Handleris the only middleware shape weproduce. Works with everything. Chi is the spiritual reference for the
middleware-stacking style.
aws-sdk-go-v2—Configstruct with zero-value defaults;
New(cfg) (*Client, error); no globals;every method takes
ctx context.Contextfirst.stripe-go— typed errors withUnwrap(); sensible exported names; resource-oriented domain types.log/slog(stdlib) — request-scopeddata threaded via
context.Contextwith a private key type to avoidcollisions across packages.
uber-go/zap— functional optionsonly when the struct gets unwieldy. Default to struct config; reserve
options for extensibility points.
regexp.MustCompile,template.Must(stdlib) —MustXxxconstructors that panic on bad input, for use in
varblocks at packageinit time.
Vocabulary
Pick these terms and use them consistently in code, doc comments, and error
messages.
signer.Demo,signer.FromFile,kms.GCP, …FeeOnTop.Amount + sum(FeeOnTop). Derived.USD(...): number + denom + settlement.PayTorecipient nets less.PayTonets full.paykit.X402,paykit.MPP.USD,EUR.USDC,USDT.Surface
Boot-time configuration
Struct config with zero-value defaults; explicit
*Clientreturned.Mirrors
aws-sdk-go-v2. Zero-valueConfig{}is invalid (networkrequired), but every other field has a sensible default — including a
hard-coded
signer.Demo()so smoke tests boot with one line:Production form:
There is no top-level
PayToand noX402Config.FacilitatorSecretKey—both cascade from
Operator. See Operator below.Typed string constants for domain enums — vet-friendly, IDE-friendly:
Operator — merchant identity in one place
An operator bundles the three things a merchant brings to the protocol:
Recipient— where settled funds land. DefaultPayTofor every gate.Signer— the Ed25519 keypair used to sign x402 facilitatorchallenges, and (if
FeePayer == true) to pay Solana network fees.FeePayer— whether the operator's signer also pays Solana networkfees on settlement transactions.
Operatoris a value struct, not a pointer-handle. Zero-value semanticsfill in defaults inside
paykit.New(cfg):Signer == nil→ replaced bysigner.Demo()Recipient == ""→ replaced bySigner.Pubkey()FeePayer == falseis 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")}wheresigner.FromEnvreturnsnilfor unset meanspaykit.Newsubstitutesthe demo signer — no
if env != ""guards in user code.Defaults
If the caller never sets
cfg.Operator, they get:signer.Demo()returns the package-shipped demo keypair (constant,base58 pubkey:
AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj). Thelibrary emits
slog.Warn(...)once atNew()when the demo signer is inuse, and returns
ErrDemoSignerOnMainnetif combined withNetwork == paykit.SolanaMainnet.Signer backends
Factories split by execution model. Local/in-process signers live in the
paykit/signersub-package and ship in v1. Remote enclave signers (KMS,Vault, HSMs) are reserved under
paykit/kmsbut not part of theinitial release — they're sketched here so the import path is locked
in before anyone writes against
signer.GCPKMS(...)and we have torename later.
Both halves return values satisfying the same interface:
Local signers accept but ignore
ctx; KMS signers honor it (networkI/O, deadlines, cancellation).
The runtime-error variants all return
(Signer, error); theMust*setcovers the formats with non-trivial failure modes:
MustFromJSON,MustFromBase58,MustFromHex,MustFromFile.Demo()andGenerate()can't fail;FromBytesaccepts a Go slice wherecompile-time checks are already enough;
FromEnvhas(nil, nil)forunset semantics that don't fit
Must*.Remote signers take per-backend
*Configstructs once option countsexceed two — the "config struct beats positional args" Go convention.
All take an explicit
Pubkeyfield so boot doesn't probe the enclave;misconfiguration surfaces at the first
Sign()call.signer.FromEnv(name)contractsigner.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/FromHexconstructors).(nil, nil)— env var is unset or empty.(nil, *signer.InvalidKeyError)— env var set but malformed. Silentfallback on malformed input would mask bugs.
The
(nil, nil)return composes withOperatorzero-value resolution—
Operator{Signer: signer.MustFromEnv("…")}is wrong (it would panicon unset); use plain
signer.FromEnvand discard the error if you want"use demo when unset":
Cascading
The operator is one source of truth for things that used to be scattered:
Config.PayToConfig.Operator.RecipientX402Config.FacilitatorSecretKeyConfig.Operator.Signer(when running x402)Config.Operator.FeePayer = trueEnv-var conventions follow the same rename. Old names are detected at
paykit.New(cfg)time and emitslog.Warnpointing at the new name;removed after one minor release. Go has no Ruby-style
deprecate :foomacro — boot-time env inspection is the only idiomatic spot.
PAY_KIT_PAY_TOPAY_KIT_OPERATOR_RECIPIENTPAY_KIT_X402_FACILITATOR_KEYPAY_KIT_OPERATOR_KEYPAY_KIT_X402_FACILITATOR*PAY_KIT_X402_FACILITATOR_URLPAY_KIT_MPP_SECRETPAY_KIT_MPP_CHALLENGE_BINDING_SECRET* The old
PAY_KIT_X402_FACILITATORvalue in demo configs was actuallya Solana RPC URL, not a facilitator URL — that data should move to the
new
PAY_KIT_RPC_URLand the facilitator var should be left unsetunless 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
PayTostill overrides the operator's recipient (themarketplace pattern — operator signs, seller is paid).
Rules
Operator.Recipientdefaults toOperator.Signer.Pubkey()when"". The signer always has a pubkey, so this is a safe default.PayTooverridesOperator.Recipientper gate.Operator.Signeris the x402 facilitator key. SettingX402Config.Signerdirectly is the escape hatch; not advertised inthe getting-started docs.
Operator.FeePayer == truemeans the operator's signer paysSolana network fees on settlement.
falsewhen fees come fromelsewhere.
MPPConfig.ChallengeBindingSecretremains explicit — KMS-backedsigners 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).New().paykit.NewreturnsErrDemoSignerOnMainnetwhen the resolved signer issigner.Demo()and
Network == SolanaMainnet.Chain access —
RPCURLand x402 modesTwo separate concerns that have been historically confused: how paykit
talks to Solana (
Config.RPCURL) and whether paykit handles x402verification/settlement itself (
X402Config.FacilitatorURL). Differentfields, different layers.
Config.RPCURL— Solana RPC URLThe HTTP endpoint of a Solana RPC node. Used by:
landed on-chain with the right recipient and amount.
RPCURLis optional. When"",paykit.Newpicks a default fromNetwork:NetworkRPCURLpaykit.SolanaMainnethttps://api.mainnet-beta.solana.compaykit.SolanaDevnethttps://api.devnet.solana.compaykit.SolanaLocalnethttp://localhost:8899Override with
RPCURL: "https://my-helius-endpoint.example.com"(or anycustom RPC — Helius, QuickNode, private validator). The public Solana
RPCs are rate-limited and unsuitable for production traffic;
paykit.Newemits
slog.WarnwhenNetwork == SolanaMainnetandRPCURLresolvesto the public default.
X402Config.FacilitatorURL— optional delegationPer 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
/verifyand/settleHTTPendpoints; 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:
Default is self-hosted (
""). SettingFacilitatorURLto any URLopts into delegation. When delegated:
RPCURLis unused by x402 (still used by MPP if enabled).Operator.Signeris still used to authenticate to the facilitator ifthe facilitator requires merchant identity (some don't; depends on
the facilitator's auth scheme).
Operator.FeePayeris ignored by x402 — the facilitator handlesfees 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
RPCURLset to that URL is thecorrect shape; the facilitator field stays
""in the demo until areal facilitator is available to point at.
Money helpers — denomination vs. settlement
Two functions per fiat: the error-returning form for runtime, the
Mustformfor boot-time
varblocks. Variadic stablecoins follow the splat pattern:Internally
Pricecarries adecimal.Decimal— neverfloat64. Thehelpers reject
floatinputs at compile time (amount stringparameter).Future-proofed:
paykit.ParseEUR("0.20", paykit.EURC)and itsMustParseEURsibling slot in identically.
Gates — package-level
varblocksIdiomatic Go: declare gates at package scope, import where needed. Same
pattern as Python's module-level constants, and the same departure from
Ruby's
Pricingclass. Theimport "myapp/pricing"line is the registrylookup.
paykit.Feesistype Fees = map[Address]Price— a type alias that keepsthe 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. Twofields cover every real-world case; each is a
map[Address]Price, so oneor many recipients use the same syntax:
FeeWithin: paykit.Fees{...}— taken out of the amount. Customerpays the amount; the
PayTorecipient nets less.FeeOnTop: paykit.Fees{...}— added on top of the amount. Customerpays
Amount + fee; thePayTorecipient nets the full amount.Methods exposed on
*Gate:Rules (validated by
Gate.Validate(), called automatically atclient.Require(gate)):PayTois optional and defaults toConfig.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.
USD(...)andEUR(...)prices in the same gate and
Validate()returnsErrMixedDenoms.sum(FeeWithin values) <= Amount. Validated at registration time —the
PayTorecipient can't end up with a negative payout.facilitators settle to a single address; multi-recipient settlement is
MPP-only. The resolver strips
X402fromAcceptsilently. Explicitlysetting
Accept: []Scheme{X402}on a gate with fees failsValidate()with
ErrSchemeIncompatible.recipient override via the amount form (
MustParseUSD("3.00", USDC)) is theescape hatch.
Middleware
Standard
func(http.Handler) http.Handlershape — composes with anything:The middleware extracts the
Authorization: Paymentheader, verifies theproof, and stuffs the resulting
*Paymentinto the request context.Handler access — context-scoped trio
Mirrors Ruby's
require_payment!/paid?/paymentand Python'srequire_payment/is_paid/get_payment, adapted to Go'scontext-passing convention:
client.Require(Gate) func(...) http.Handlerpaykit.PaymentFrom(ctx)(*Payment, bool)(nil, false)paykit.IsPaid(ctx)boolpaykit.IsPaidFor(ctx, gate)boolThe context key is unexported (
type ctxKey struct{}), perlog/slog/contextpackage guidance — prevents cross-package collisions and accidentaloverwrites.
Dynamic gates
For prices that depend on the request, use a
GateFunc:Errors
Typed-error hierarchy with sentinel roots, mirroring stripe-go and stdlib
patterns. Apps use
errors.Isfor stable comparisons anderrors.Asformetadata:
Apps can override the 402 response writer:
Layers
No
paykit/chi,paykit/gin,paykit/fibersubpackages — the middlewareis pure
func(http.Handler) http.Handler, so the entire ecosystem alreadyworks. If a framework demands a non-stdlib middleware shape (gin's
gin.HandlerFunc), we provide an adapter in user docs, not in the corepackage.
Design rules — locked in
(accepted schemes), metadata (description), and optionally dynamic logic
via
GateFunc.Scheme,Stablecoin,Network,Addressare named types. The compiler catches misspellings;go vetcatches non-exhaustive switches with the right linter.
AcceptandStablecoinsslices arepreference order, not sets. Maps (
Fees) are unordered — recipientidentity is what matters there, not order.
paykit.Config{}is only invalid becauseNetworkis required. The zero values forAccept,Stablecoins,RPCURL,Operator, and the scheme sub-configs all do sensible things— apply defaults, don't crash.
Operator{}resolves tosigner.Demo()with the demo pubkey as recipient.
USD("0.10", USDC, USDT)means "$0.10 USD, settle in USDC or USDT." Callers think fiat.
net/httpfirst, framework adapters never. Anything that wrapspaykit.Client.Requireshould be one-line user code, not a vendor-specific package we maintain.
Require(gate)raises 402,IsPaid(ctx)returns bool,PaymentFrom(ctx)is comma-ok. Mirrors the bang/predicate/accessorsplit from Ruby and the prefix verbs from Python.
errors.Is(err, paykit.ErrInvalidProof)for stable handling,errors.Asfor metadata.paykitwith sensible mainnet/devnet defaults — apps shouldn't have to look up
addresses.
one
AmountplusFeeWithin/FeeOnTopmap fields. Fixed amountsonly, MPP only. x402 auto-disabled on any gate with fees because stock
x402 facilitators settle to a single address.
*paykit.Clientis explicit; youcreate one and pass it through. No
paykit.SetGlobalClient(...). Nopaykit.Configure(...)that hides a singleton.context.Contextfirst arg on every public method that does I/O.Payment data in
ctxvia private key type. Cancellation respected.decimal.Decimal, neverfloat64. All amount construction goesthrough helpers that accept
string. Floats are a known footgun formoney; they're literally uncallable.
Ed25519 signer, and fee-payer flag live together on
Operator.Gate-level
PayToandX402Config.Signerare escape hatches, notthe primary surface. Zero config boots on the in-memory demo signer
with a visible
slog.Warn; mainnet returnsErrDemoSignerOnMainnet.Operator{Signer: signer.FromEnv("X")}whereFromEnvreturnsnilfor unset meanspaykit.Newfills insigner.Demo(). Noif env != ""guards.paykit.New(cfg)normalizes zero values to resolved defaultsinternally.
paykit/signersub-package. Package-levelfunctions (
signer.FromFile,signer.FromEnv, …) withMustXxxvariants for boot-time
varblocks. Remote enclave signers (KMS,Vault) live under
paykit/kms— separate sub-package so asyncbackends 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+goimportsrequired. No exceptions.go vet+staticcheckin CI; treat as compile errors.golangci-lintwith conservative defaults; document any disabledlinters in the project root.
// Gate represents …. Rungo docmentally before naming.erroris always the last return value. No mixing.context.Contextis always the first parameter on public methodsthat do I/O.
MustParseUSD("0.10", USDC, USDT)); acceptcfg.Stablecoins...for dynamic.MustXxxfor boot-time literals,Xxxreturns(T, error)forruntime. Both exported; choose based on where the call lives.
type ctxKey struct{}, neverstring.time.Durationfor durations,time.Timefor timestamps,decimal.Decimalfor money.interface{}/anyon public APIs unless genuinely needed.io.Reader/io.Writerwhen streaming bodies. Not relevant for thecore surface but applies to any request/response codec.
paykit.New(cfg), not as wrapper shims. When arenamed env var is seen, log via
slog.Warnwith a pointer at the newname. Removed after one minor release. Go has no Ruby-style
deprecatemacro — boot-time detection is the only idiomatic spot.
Open questions
Things to decide before we cut code:
Gate.Validate()— when to call? Options: (a) lazily on firstclient.Require(gate), (b) eagerly viapaykit.MustGate(g)wrapper invarblocks, (c) both. Recommendation: (a) is enough; gates are staticand middleware registration happens at startup. Confirm.
Addressunderlying type.string(current sketch) vs. a structcarrying 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.
decimal.Decimalimport.github.com/shopspring/decimalis thecommunity default but not stdlib. Alternative: roll a minimal internal
paykit.Decimalovermath/big.Rat. Lean toward shopspring for ecosystemfamiliarity; revisit if we want zero non-stdlib deps.
Payment.Gate. Currently a stringNamefield. Alternative:
*Gatepointer comparison. Pointer is faster butcouples runtime state to the registry; string handle is loosely coupled
and idiomatic for context-stuffed values. Lean string.
Client.Close()semantics. Drains in-flight challenges? Cancelsreplay-store flushes? Define before shipping.
paykitwithschemes/x402andschemes/mppsubpackages. 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/kmssub-package — remote enclave signers.kms.GCP,kms.AWS,kms.Vault, each taking a backend-specific*Configstruct. Honor
ctx context.ContextonSign(ctx, …); share thesigner.Signerinterface so call sites don't change. Import path islocked in now; implementation lands after v1.
ParseEUR(...),ParseGBP(...)slotinto the existing
ParseUSD(...)shape.HSMs) under
paykit/kms.