開發者平臺
主題

Go SDK 参考#

Go SDK 参考(适用于 exactexact + permit2uptoaggr_deferred#

模块 / 包#

所有导入路径都以 github.com/okx/payments/go/x402/... 为前缀。下表按 Go module / package 组织。

包路径描述
github.com/okx/payments/go/x402核心:资源服务端 X402ResourceServer、facilitator 客户端接口、hooks、错误类型、Network / Price
github.com/okx/payments/go/x402/types线格式类型(v1/v2):PaymentRequirementsPaymentPayloadPaymentRequiredSupportedKind
github.com/okx/payments/go/x402/httpHTTP 资源服务端 HTTPServer、路由配置 RoutesConfigHTTPFacilitatorClient / OKXFacilitatorClient
github.com/okx/payments/go/x402/http/nethttpnet/http 中间件
github.com/okx/payments/go/x402/http/ginGin 中间件
github.com/okx/payments/go/x402/http/echoEcho 中间件
github.com/okx/payments/go/x402/mechanisms/evmEVM 共享原语:payload 类型、Permit2 / upto 常量、AssetInfo / NetworkConfig
github.com/okx/payments/go/x402/mechanisms/evm/exact/serverexact(EIP-3009 / Permit2)卖方 scheme
github.com/okx/payments/go/x402/mechanisms/evm/upto/serverupto(cap + override)卖方 scheme
github.com/okx/payments/go/x402/mechanisms/evm/deferred/serveraggr_deferred(TEE 聚合)卖方 scheme
github.com/okx/payments/go/x402/adapters多协议入口适配器 X402Adapter(与 MPP 等统一调度时使用)

Go SDK 当前主要提供服务端(卖方)和 facilitator 客户端能力。mechanisms/evm/exact/clientupto/clientdeferred/client 等买方包存在,但本参考聚焦卖方侧;买方支付签名能力以这些 client 包为准,本文不展开。


核心类型#

Network / Price / AssetAmount#

Network 是具名字符串类型并带通配符匹配方法,Price 是空接口(可传 string、数字或 AssetAmount)。

go
// Network 是 CAIP-2 格式的链标识,例如 "eip155:196"。
type Network string

func ParseNetwork(s string) Network
func (n Network) Match(pattern Network) bool                  // "eip155:1" 匹配 "eip155:*"
func (n Network) Parse() (namespace, reference string, err error)

// Price 可以是 "$0.01" / "0.01" / 数字 / AssetAmount。
type Price interface{}

type AssetAmount struct {
	Asset  string                 `json:"asset"`  // 代币合约地址
	Amount string                 `json:"amount"` // 最小单位金额
	Extra  map[string]interface{} `json:"extra,omitempty"`
}

包级网络辅助函数:

go
func IsWildcardNetwork(network Network) bool
func MatchesNetwork(pattern Network, network Network) bool

ResourceInfo#

go
type ResourceInfo struct {
	URL         string `json:"url"`
	Description string `json:"description,omitempty"`
	MimeType    string `json:"mimeType,omitempty"`
}

PaymentRequirements#

go
type PaymentRequirements struct {
	Scheme            string                 `json:"scheme"`  // "exact" | "aggr_deferred" | "upto"
	Network           string                 `json:"network"` // CAIP-2
	Asset             string                 `json:"asset"`   // 代币合约地址
	Amount            string                 `json:"amount"`  // 价格(upto 时为 cap),最小单位
	PayTo             string                 `json:"payTo"`   // 收款地址
	MaxTimeoutSeconds int                    `json:"maxTimeoutSeconds"`
	Extra             map[string]interface{} `json:"extra,omitempty"` // scheme 专属数据
}

Extra 中常见的字段:

keyscheme含义
assetTransferMethodexact / upto"eip3009"(默认)或 "permit2";upto server 始终写 "permit2"
facilitatorAddressuptoupto proxy 强制 witness.facilitator == msg.sender,由 UptoEvmScheme.EnhancePaymentRequirementssupportedKind.Extra 自动注入
name / versionexact(EIP-3009 路径)EIP-712 domain,供客户端签名

mechanisms/evm/upto/server 包里这两个 key 还有具名常量:AssetTransferMethodKey = "assetTransferMethod"ExtraFacilitatorAddressKey

PaymentRequired#

402 响应体(v2)。

go
type PaymentRequired struct {
	X402Version int                    `json:"x402Version"`
	Error       string                 `json:"error,omitempty"`
	Resource    *ResourceInfo          `json:"resource,omitempty"`
	Accepts     []PaymentRequirements  `json:"accepts"`
	Extensions  map[string]interface{} `json:"extensions,omitempty"`
}

PaymentPayload#

客户端的签名支付(v2)。Payload 内容随 scheme 不同而不同(EIP-3009 / Permit2 / upto Permit2)。

go
type PaymentPayload struct {
	X402Version int                    `json:"x402Version"`
	Payload     map[string]interface{} `json:"payload"`  // 详见下方 EVM Payload 一节
	Accepted    PaymentRequirements    `json:"accepted"`
	Resource    *ResourceInfo          `json:"resource,omitempty"`
	Extensions  map[string]interface{} `json:"extensions,omitempty"`
}

func (p PaymentPayload) GetVersion() int
func (p PaymentPayload) GetScheme() string
func (p PaymentPayload) GetNetwork() string
func (p PaymentPayload) GetPayload() map[string]interface{}

Go SDK 显式保留了 v1 类型(PaymentRequirementsV1 / PaymentPayloadV1 / PaymentRequiredV1 / SupportedKindV1),并通过 PaymentRequirementsView / PaymentPayloadView 两个接口让 hooks 做到版本无关。新接入默认用 v2。


Facilitator 类型#

Go SDK 的 facilitator 接口在网络边界传 []byte(由 SDK 内部按版本路由),verify/settle 的入参不是 typed 的 VerifyRequest / SettleRequest,而是 payload + requirements 的原始字节。响应类型仍然是 typed 的。

VerifyResponse / SettleResponse#

go
type VerifyResponse struct {
	IsValid        bool   `json:"isValid"`
	InvalidReason  string `json:"invalidReason,omitempty"`
	InvalidMessage string `json:"invalidMessage,omitempty"`
	Payer          string `json:"payer,omitempty"`
}

type SettleResponse struct {
	Success      bool    `json:"success"`
	ErrorReason  string  `json:"errorReason,omitempty"`
	ErrorMessage string  `json:"errorMessage,omitempty"`
	Payer        string  `json:"payer,omitempty"`
	Transaction  string  `json:"transaction"`        // 交易哈希(aggr_deferred 为空)
	Network      Network `json:"network"`
	Status       string  `json:"status,omitempty"`   // OKX 扩展: "pending" | "success" | "timeout"
	// upto 等场景下实际结算金额可能严格小于签名 cap。
	Amount       string  `json:"amount,omitempty"`
}

sync/async 结算开关放在 facilitator 客户端配置 上(OKXFacilitatorConfig.SyncSettle,见下文),而不是每次 settle 调用或每条路由上。

SupportedKind / SupportedResponse#

go
type SupportedKind struct {
	X402Version int                    `json:"x402Version"`
	Scheme      string                 `json:"scheme"`
	Network     string                 `json:"network"`
	// upto: facilitator 地址通过 extra.facilitatorAddress 暴露;卖方 scheme
	// 会在 EnhancePaymentRequirements 时把它注入 challenge 的 extra。
	Extra       map[string]interface{} `json:"extra,omitempty"`
}

type SupportedResponse struct {
	Kinds      []SupportedKind     `json:"kinds"`
	Extensions []string            `json:"extensions"`
	Signers    map[string][]string `json:"signers"` // CAIP family → signer 地址
}

SettleStatusResponse#

go
type SettleStatusResponse struct {
	Success      bool    `json:"success"`
	Status       string  `json:"status,omitempty"` // "pending" | "success" | "failed"
	ErrorReason  string  `json:"errorReason,omitempty"`
	ErrorMessage string  `json:"errorMessage,omitempty"`
	Payer        string  `json:"payer,omitempty"`
	Transaction  string  `json:"transaction,omitempty"`
	Network      Network `json:"network,omitempty"`
}

接口#

SchemeNetworkServer#

服务端 scheme 实现。exact / aggr_deferred / upto 都实现该接口。

go
type SchemeNetworkServer interface {
	Scheme() string
	ParsePrice(price Price, network Network) (AssetAmount, error)
	EnhancePaymentRequirements(
		ctx context.Context,
		requirements types.PaymentRequirements,
		supportedKind types.SupportedKind,
		extensions []string,
	) (types.PaymentRequirements, error)
}

FacilitatorClient#

与远程 facilitator 通信的网络边界。注意入参是字节,版本由 SDK 内部检测。

go
type FacilitatorClient interface {
	Verify(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*VerifyResponse, error)
	Settle(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*SettleResponse, error)
	GetSupported(ctx context.Context) (SupportedResponse, error)
}

// 可选接口:支持按交易哈希查询结算状态(用于 timeout 恢复轮询)。
type SettleStatusChecker interface {
	GetSettleStatus(ctx context.Context, txHash string) (*SettleStatusResponse, error)
}

ResourceServerExtension / FacilitatorExtension#

Go 的扩展接口精简如下:

go
// 在 types 包。
type ResourceServerExtension interface {
	Key() string
	EnrichDeclaration(declaration interface{}, transportContext interface{}) interface{}
}

// 在 x402 包,facilitator 侧扩展。
type FacilitatorExtension interface {
	Key() string
}
func NewFacilitatorExtension(key string) FacilitatorExtension

verify/settle extension 的富化通过一组 hooksBeforeVerifyHook / AfterVerifyHook / OnVerifyFailureHook / BeforeSettleHook / AfterSettleHook / OnSettleFailureHook)实现,通过 WithBeforeVerifyHook(...)ResourceServerOption 注册(见下)。


服务端 API(X402ResourceServer#

构造与注册#

构造用函数式选项(opts ...ResourceServerOption),scheme 通过链式 Register(network, scheme) 直接注册——同一 network 上多个 scheme 可共存,路由侧按 scheme 名挑选。

go
import (
	"github.com/okx/payments/go/x402"
	exact "github.com/okx/payments/go/x402/mechanisms/evm/exact/server"
	deferred "github.com/okx/payments/go/x402/mechanisms/evm/deferred/server"
	uptoserver "github.com/okx/payments/go/x402/mechanisms/evm/upto/server"
)

server := x402.Newx402ResourceServer(
	x402.WithFacilitatorClient(fac),
).
	Register("eip155:196", exact.NewExactEvmScheme()).        // exact (EIP-3009 / Permit2)
	Register("eip155:196", deferred.NewAggrDeferredEvmScheme()). // aggr_deferred
	Register("eip155:196", uptoserver.NewUptoEvmScheme())     // upto (cap + override)

构造选项:

go
type ResourceServerOption func(*x402ResourceServer)

func WithFacilitatorClient(client FacilitatorClient) ResourceServerOption
func WithSchemeServer(network Network, schemeServer SchemeNetworkServer) ResourceServerOption // 等价于链式 Register
func WithCacheTTL(ttl time.Duration) ResourceServerOption
func WithBeforeVerifyHook(hook BeforeVerifyHook) ResourceServerOption
func WithAfterVerifyHook(hook AfterVerifyHook) ResourceServerOption
func WithOnVerifyFailureHook(hook OnVerifyFailureHook) ResourceServerOption
func WithBeforeSettleHook(hook BeforeSettleHook) ResourceServerOption
func WithAfterSettleHook(hook AfterSettleHook) ResourceServerOption
func WithOnSettleFailureHook(hook OnSettleFailureHook) ResourceServerOption

方法#

go
func Newx402ResourceServer(opts ...ResourceServerOption) *x402ResourceServer

func (s *x402ResourceServer) Register(network Network, schemeServer SchemeNetworkServer) *x402ResourceServer
func (s *x402ResourceServer) RegisterExtension(extension types.ResourceServerExtension) *x402ResourceServer

// 拉取 facilitator 的 supported kinds 并缓存。initialize 不是可选的:
// HTTP 层会在中间件挂载/首次请求时驱动它(见下)。
func (s *x402ResourceServer) Initialize(ctx context.Context) error

func (s *x402ResourceServer) HasRegisteredScheme(network Network, scheme string) bool
func (s *x402ResourceServer) HasFacilitatorSupport(network Network, scheme string) bool
func (s *x402ResourceServer) GetFacilitatorClient(network Network, scheme string) FacilitatorClient

// 用一份 ResourceConfig + supportedKind 生成 challenge(内部会调度对应 scheme 的
// ParsePrice + EnhancePaymentRequirements)。
func (s *x402ResourceServer) BuildPaymentRequirements(
	ctx context.Context,
	config ResourceConfig,
	supportedKind types.SupportedKind,
	extensions []string,
) (types.PaymentRequirements, error)

func (s *x402ResourceServer) VerifyPayment(
	ctx context.Context,
	payload types.PaymentPayload,
	requirements types.PaymentRequirements,
) (*VerifyResponse, error)

// overrides: upto scheme 用——业务 handler 决定本次实际扣款金额(≤ cap),
// 通过响应头透传给中间件,中间件解析为 *SettlementOverrides 后调本接口。
func (s *x402ResourceServer) SettlePayment(
	ctx context.Context,
	payload types.PaymentPayload,
	requirements types.PaymentRequirements,
	overrides *SettlementOverrides,
) (*SettleResponse, error)

func (s *x402ResourceServer) FindMatchingRequirements(
	available []types.PaymentRequirements,
	payload types.PaymentPayload,
) *types.PaymentRequirements

轮询结算状态没有独立的核心方法;逻辑收进 HTTP 层(HTTPServer.SetPollDeadline + OnSettlementTimeout hook + facilitator 的 GetSettleStatus,见下)。PollResult 类型本身存在:

go
type PollResult string
const (
	PollResultSuccess PollResult = "success"
	PollResultFailed  PollResult = "failed"
	PollResultTimeout PollResult = "timeout"
)

ResourceConfig / SettlementOverrides#

go
type ResourceConfig struct {
	Scheme            string                 `json:"scheme"`
	PayTo             string                 `json:"payTo"`
	Price             Price                  `json:"price"`
	Network           Network                `json:"network"`
	MaxTimeoutSeconds int                    `json:"maxTimeoutSeconds,omitempty"`
	Extra             map[string]interface{} `json:"extra,omitempty"`
}

// 由业务 handler 通过响应头设置;中间件读出后在 settle 时应用。
type SettlementOverrides struct {
	// 实际结算金额(原子单位),必须 <= 授权 cap。
	Amount string `json:"amount,omitempty"`
}

Amount 字段被记为原子单位整数字符串(must be <= authorized max)。


OKX Facilitator 客户端(OKXFacilitatorClient#

go
import x402http "github.com/okx/payments/go/x402/http"

syncSettle := true
fac, err := x402http.NewOKXFacilitatorClient(&x402http.OKXFacilitatorConfig{
	Auth: x402http.OKXAuthConfig{
		APIKey:     apiKey,
		SecretKey:  secretKey,
		Passphrase: passphrase,
		// BaseURL / BasePath 可选
	},
	BaseURL:    "https://web3.okx.com", // 默认值;sandbox/staging 可覆盖
	SyncSettle: &syncSettle,            // 默认 true:等链上确认后返回(exact)
})
go
type OKXAuthConfig struct {
	APIKey     string
	SecretKey  string
	Passphrase string
	BaseURL    string // 默认 "https://web3.okx.com"
	BasePath   string // 例如 "/api/v6/x402"
}

type OKXFacilitatorConfig struct {
	Auth       OKXAuthConfig
	BaseURL    string        // 默认 "https://web3.okx.com"
	SyncSettle *bool         // 默认 true
	HTTPClient *http.Client
	Timeout    time.Duration
}

func NewOKXFacilitatorClient(config *OKXFacilitatorConfig) (*OKXFacilitatorClient, error)

func (c *OKXFacilitatorClient) GetSupported(ctx context.Context) (x402.SupportedResponse, error)
func (c *OKXFacilitatorClient) Verify(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*x402.VerifyResponse, error)
func (c *OKXFacilitatorClient) Settle(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*x402.SettleResponse, error)
func (c *OKXFacilitatorClient) GetSettleStatus(ctx context.Context, txHash string) (*x402.SettleStatusResponse, error)

NewOKXFacilitatorClient 在缺少 APIKey / SecretKey / Passphrase 时返回错误。客户端自动给请求加 HMAC-SHA256 OKX 认证头,并自动解包 {"code":0,"data":{...},"msg":""} 信封。

sync vs async:sync/async 由 OKXFacilitatorConfig.SyncSettle(per-client,默认 true)控制。HTTP 的 RouteConfig 没有 SyncSettle 字段。

通用 HTTP facilitator 客户端#

非 OKX 的 facilitator 用 HTTPFacilitatorClient

go
type FacilitatorConfig struct {
	URL          string
	HTTPClient   *http.Client
	AuthProvider AuthProvider
	Timeout      time.Duration // 默认 30s
	Identifier   string
}

func NewHTTPFacilitatorClient(config *FacilitatorConfig) *HTTPFacilitatorClient
func NewFacilitatorClient(config *FacilitatorConfig) *HTTPFacilitatorClient // 别名

const DefaultFacilitatorURL = "https://x402.org/facilitator"

HMAC 认证#

Go 把认证封装进了 OKXFacilitatorClient 内部,对外暴露的是一个低层签名函数和认证抽象接口:

go
// 计算 OKX 签名: Base64(HMAC-SHA256(secret, timestamp + METHOD + path + body))。
func ComputeSignature(secretKey, timestamp, method, path, body string) string

type AuthProvider interface {
	GetAuthHeaders(ctx context.Context) (AuthHeaders, error)
}

type AuthHeaders struct {
	// 各端点的认证头集合
}

没有"给任意请求一键加全套 OK-ACCESS-* 头"的公开函数;具体头部由 OKXFacilitatorClient 内部基于 ComputeSignature 组装。


HTTP 工具#

请求头编/解码#

Go SDK 当前没有把 header 编解码函数作为公开 API 暴露——PAYMENT-SIGNATURE / PAYMENT-REQUIRED / PAYMENT-RESPONSE 的 base64 编解码由 HTTPServer.ProcessHTTPRequest / 中间件内部完成。这几个头名在源码里是字符串字面量,不是导出常量。

常量#

go
// http 包
const DefaultPollInterval = 1 * time.Second
const DefaultPollDeadline = 5 * time.Second
const SettlementOverridesHeader = "settlement-overrides"

const (
	ResultNoPaymentRequired = "no-payment-required"
	ResultPaymentVerified   = "payment-verified"
	ResultPaymentError      = "payment-error"
)

Go 只把 SettlementOverridesHeader 提升为导出常量,PAYMENT-SIGNATURE / PAYMENT-REQUIRED / PAYMENT-RESPONSE 三个头名在内部使用。


路由配置#

RoutesConfigmap[string]RouteConfig,key 是 "GET /path" 形式。每个 accept 是 PaymentOption

go
type RoutesConfig map[string]RouteConfig

type RouteConfig struct {
	Accepts PaymentOptions `json:"accepts"`

	Resource          string                 `json:"resource,omitempty"`    // 手动钉死 ResourceInfo.url;为空时按请求自动拼
	Description       string                 `json:"description,omitempty"`
	MimeType          string                 `json:"mimeType,omitempty"`
	CustomPaywallHTML string                 `json:"customPaywallHtml,omitempty"`
	Extensions        map[string]interface{} `json:"extensions,omitempty"`
	AcceptedDomains   []string               `json:"acceptedDomains,omitempty"` // payload.resource.url host 白名单(容忍反代/CDN 改写 Host)
	UnpaidResponseBody UnpaidResponseBodyFunc `json:"-"`                         // 自定义未付费响应体
}

type PaymentOptions = []PaymentOption

type PaymentOption struct {
	Scheme            string                 `json:"scheme"`            // "exact" | "aggr_deferred" | "upto"
	PayTo             interface{}            `json:"payTo"`             // string 或 DynamicPayToFunc
	Price             interface{}            `json:"price"`             // x402.Price 或 DynamicPriceFunc
	Network           x402.Network           `json:"network"`
	MaxTimeoutSeconds int                    `json:"maxTimeoutSeconds,omitempty"`
	Extra             map[string]interface{} `json:"extra,omitempty"`
}

// PayTo / Price 支持运行时动态求值。
type DynamicPayToFunc func(context.Context, HTTPRequestContext) (string, error)
type DynamicPriceFunc func(context.Context, HTTPRequestContext) (x402.Price, error)

两点说明:(1) sync/async 由 facilitator 客户端 OKXFacilitatorConfig.SyncSettle 控制,不在 RouteConfig 上;(2) PayTo / Priceinterface{},既能传静态值,也能传 Dynamic*Func 动态解析回调。

示例(多 scheme 共存)#

go
routes := x402http.RoutesConfig{
	"GET /api/data": {
		Accepts: x402http.PaymentOptions{
			// 1) 默认 exact + EIP-3009(X Layer 上的 USD₮0)
			{
				Scheme:  "exact",
				Price:   "$0.01",
				Network: "eip155:196",
				PayTo:   "0xSeller",
			},
			// 2) exact + Permit2(通用 ERC-20)
			{
				Scheme:  "exact",
				Price:   "$0.01",
				Network: "eip155:196",
				PayTo:   "0xSeller",
				Extra:   map[string]any{"assetTransferMethod": "permit2"},
			},
			// 3) aggr_deferred(TEE 聚合)
			{
				Scheme:  "aggr_deferred",
				Price:   "$0.001",
				Network: "eip155:196",
				PayTo:   "0xSeller",
			},
		},
		Description: "Premium data",
		MimeType:    "application/json",
	},
}

exact + permit2 就是 exact scheme + 路由 Extra: {"assetTransferMethod":"permit2"}upto 路由通常不需要手填 Extra.facilitatorAddress——UptoEvmScheme.EnhancePaymentRequirements 会从 facilitator /supported 流自动注入。


中间件#

net/http / Gin / Echo 各提供一个中间件包。三者 API 形状一致:都有 X402Payment(Config)PaymentMiddleware(...)PaymentMiddlewareFromConfig(...)PaymentMiddlewareFromHTTPServer(...)SimpleX402Payment(...)

net/http#

go
import (
	nethttpmw "github.com/okx/payments/go/x402/http/nethttp"
	evm "github.com/okx/payments/go/x402/mechanisms/evm/exact/server"
)

mux := http.NewServeMux()
mux.HandleFunc("/api/data", handler)

handler := nethttpmw.X402Payment(nethttpmw.Config{
	Routes:      routes,
	Facilitator: fac,
	Schemes: []nethttpmw.SchemeConfig{
		{Network: "eip155:*", Server: evm.NewExactEvmScheme()},
	},
	SyncFacilitatorOnStart: true,
	Timeout:                30 * time.Second,
})(mux)
go
func X402Payment(config Config) func(http.Handler) http.Handler

type Config struct {
	Routes                 x402http.RoutesConfig
	Facilitator            x402.FacilitatorClient   // 用 Facilitator 或 Facilitators,二选一
	Facilitators           []x402.FacilitatorClient
	Schemes                []SchemeConfig
	PaywallConfig          *x402http.PaywallConfig
	SyncFacilitatorOnStart bool                     // 默认 true:启动时拉 supported kinds
	Timeout                time.Duration            // 默认 30s
	ErrorHandler           func(w http.ResponseWriter, r *http.Request, err error)
	SettlementHandler      func(w http.ResponseWriter, r *http.Request, resp *x402.SettleResponse)
}

type SchemeConfig struct {
	Network x402.Network
	Server  x402.SchemeNetworkServer
}

// 用预构造好的 server / HTTPServer(便于先挂 hook):
func PaymentMiddleware(routes x402http.RoutesConfig, server *x402.X402ResourceServer, opts ...MiddlewareOption) func(http.Handler) http.Handler
func PaymentMiddlewareFromHTTPServer(httpServer *x402http.HTTPServer, opts ...MiddlewareOption) func(http.Handler) http.Handler
func PaymentMiddlewareFromConfig(routes x402http.RoutesConfig, opts ...MiddlewareOption) func(http.Handler) http.Handler

// 最小配置版(单路由 + 单 facilitator URL):
func SimpleX402Payment(payTo string, price string, network x402.Network, facilitatorURL string) func(http.Handler) http.Handler

// 从 handler 内部取已验证的 payload / requirements:
func PayloadFromContext(ctx context.Context) (*types.PaymentPayload, bool)
func RequirementsFromContext(ctx context.Context) (*types.PaymentRequirements, bool)

MiddlewareOption 形式(与 Config 等价):WithFacilitatorClient / WithScheme(network, server) / WithPaywallConfig / WithSyncFacilitatorOnStart / WithTimeout / WithErrorHandler / WithSettlementHandler

Gin#

go
import ginmw "github.com/okx/payments/go/x402/http/gin"

r.Use(ginmw.X402Payment(ginmw.Config{
	Routes:      routes,
	Facilitator: fac,
	Schemes: []ginmw.SchemeConfig{
		{Network: "eip155:*", Server: evm.NewExactEvmScheme()},
	},
	SyncFacilitatorOnStart: true,
	Timeout:                30 * time.Second,
}))

三个框架都提供 upto 局部结算辅助 SetSettlementOverrides——业务 handler 通过它把本次实际扣款金额回传给中间件(中间件在 settle 前读出,并把响应头从客户端响应里剥掉):

go
// 各框架同名函数,签名随框架的 request/response 句柄变化:
func ginmw.SetSettlementOverrides(c *gin.Context, overrides *x402.SettlementOverrides)
func echomw.SetSettlementOverrides(c echo.Context, overrides *x402.SettlementOverrides)
func nethttpmw.SetSettlementOverrides(w http.ResponseWriter, overrides *x402.SettlementOverrides)
go
// handler 内(upto)——以 Gin 为例:
ginmw.SetSettlementOverrides(c, &x402.SettlementOverrides{Amount: "1234000"})

Echo#

echo 包与上面同构:X402Payment(Config) echo.MiddlewareFuncPaymentMiddleware*SimpleX402PaymentConfig / SchemeConfig / MiddlewareOption 字段一致(回调签名换成 func(echo.Context, ...))。

中间件流程#

  1. 匹配请求路由 cfg;没命中 → 透传给 inner handler
  2. PAYMENT-SIGNATURE 请求头 → 返回 402 + PAYMENT-REQUIRED(浏览器请求则渲染 paywall HTML)
  3. 解码并验证支付 payload,与路由 accepts 匹配
  4. 经 facilitator 验证(Verify
  5. 调内部 handler 并缓冲响应
  6. 若 handler 设置了 settlement override(upto,用 SetSettlementOverrides),中间件解析为 *SettlementOverrides
  7. 经 facilitator 结算(Settle
  8. 异步(status:"pending" / "timeout")→ 在 pollDeadline 内轮询 GetSettleStatus
  9. 仍超时 → 调用 OnSettlementTimeout hook(若配置)
  10. 给响应加 PAYMENT-RESPONSE

HTTPServer 上可挂的 hook / 设置#

预构造 HTTPServer 后用 PaymentMiddlewareFromHTTPServer 挂载,可链式注册:

go
httpServer := x402http.Wrappedx402HTTPResourceServer(routes, resourceServer).
	OnProtectedRequest(requestHook).
	SetPollDeadline(8 * time.Second).
	OnSettlementTimeout(func(ctx context.Context, txHash, network string) (confirmed bool, err error) {
		// 超时观测:链上二次确认 / 日志 / 上报指标
		return false, nil
	})
go
func (s *HTTPServer) OnProtectedRequest(hook ProtectedRequestHook) *HTTPServer
func (s *HTTPServer) SetPollDeadline(deadline time.Duration) *HTTPServer
func (s *HTTPServer) OnSettlementTimeout(hook OnSettlementTimeoutHook) *HTTPServer
func (s *HTTPServer) RegisterPaywallProvider(provider PaywallProvider) *HTTPServer
func (s *HTTPServer) AddRoutes(routes RoutesConfig) *HTTPServer
func (s *HTTPServer) Initialize(ctx context.Context) error // 拉 supported kinds + 校验路由配置

// 超时恢复 hook:参数是 (ctx, txHash, network),返回 confirmed 决定是否交付资源。
type OnSettlementTimeoutHook func(ctx context.Context, txHash string, network string) (confirmed bool, err error)

OnSettlementTimeoutHook 的参数顺序是 (ctx, txHash, network),返回 (confirmed bool, err error)


EVM 机制(mechanisms/evm + scheme server 子包)#

ExactEvmScheme#

go
import exact "github.com/okx/payments/go/x402/mechanisms/evm/exact/server"

scheme := exact.NewExactEvmScheme()
scheme.Scheme() // "exact"

负责:价格解析("$0.01" / "0.01" / AssetAmount);按代币 decimals 换算原子单位;按 network 查默认资产;向 Extra 注入 EIP-712 domain(name / version)供 EIP-3009 签名;买家在 Extra.assetTransferMethod="permit2" 时走 Permit2 流程。

go
func NewExactEvmScheme() *ExactEvmScheme
func (s *ExactEvmScheme) Scheme() string
func (s *ExactEvmScheme) ParsePrice(price x402.Price, network x402.Network) (x402.AssetAmount, error)
func (s *ExactEvmScheme) EnhancePaymentRequirements(ctx context.Context, requirements types.PaymentRequirements, supportedKind types.SupportedKind, extensionKeys []string) (types.PaymentRequirements, error)
func (s *ExactEvmScheme) ConvertToTokenAmount(decimalAmount string, network string) (string, error)
func (s *ExactEvmScheme) ConvertFromTokenAmount(tokenAmount string, network string) (string, error)
func (s *ExactEvmScheme) GetDisplayAmount(amount string, network string, asset string) (string, error)
func (s *ExactEvmScheme) GetSupportedNetworks() []string
func (s *ExactEvmScheme) ValidatePaymentRequirements(requirements x402.PaymentRequirements) error
func (s *ExactEvmScheme) RegisterMoneyParser(parser x402.MoneyParser) *ExactEvmScheme // 自定义价格→资产换算链

AggrDeferredEvmScheme#

go
import deferred "github.com/okx/payments/go/x402/mechanisms/evm/deferred/server"

scheme := deferred.NewAggrDeferredEvmScheme()
scheme.Scheme() // "aggr_deferred"

价格 / 需求逻辑全部委托给 ExactEvmScheme;卖家配置与 exact 完全相同,链上结算由 facilitator TEE 聚合(Facilitator 将 session-key 签名转换为 EOA 签名并批量上链)。

go
func NewAggrDeferredEvmScheme() *AggrDeferredEvmScheme
func (s *AggrDeferredEvmScheme) Scheme() string // "aggr_deferred"
func (s *AggrDeferredEvmScheme) ParsePrice(price x402.Price, network x402.Network) (x402.AssetAmount, error)
func (s *AggrDeferredEvmScheme) EnhancePaymentRequirements(ctx context.Context, requirements types.PaymentRequirements, supportedKind types.SupportedKind, extensions []string) (types.PaymentRequirements, error)

UptoEvmScheme#

go
import uptoserver "github.com/okx/payments/go/x402/mechanisms/evm/upto/server"

scheme := uptoserver.NewUptoEvmScheme()
scheme.Scheme() // "upto"

uptoPermit2-only 的 cap-and-override 模式:

  • PaymentRequirements.Amount上限(cap),不是实际扣款
  • EnhancePaymentRequirements 始终写 Extra.assetTransferMethod = "permit2"
  • 自动从 supportedKind.Extra.facilitatorAddress 注入到 challenge,让买家把 facilitator 地址钉进 witness.facilitator(合约层强制 msg.sender == witness.facilitator
  • 业务 handler 通过 SetSettlementOverrides(net/http / Gin / Echo 都有)决定实际扣款,中间件读出后用 overrides 调 SettlePayment,余额按实际用量扣
go
func NewUptoEvmScheme() *UptoEvmScheme
func (s *UptoEvmScheme) Scheme() string // "upto"
func (s *UptoEvmScheme) ParsePrice(price x402.Price, network x402.Network) (x402.AssetAmount, error)
func (s *UptoEvmScheme) EnhancePaymentRequirements(ctx context.Context, requirements types.PaymentRequirements, supportedKind types.SupportedKind, extensionKeys []string) (types.PaymentRequirements, error)
func (s *UptoEvmScheme) RegisterMoneyParser(parser x402.MoneyParser) *UptoEvmScheme
// ... ConvertToTokenAmount / ConvertFromTokenAmount / GetDisplayAmount / GetSupportedNetworks / ValidatePaymentRequirements 同 exact

// 服务端结构 / 跨字段校验(在 payload 转发给 facilitator 前运行):
func ValidateUptoPayload(payload types.PaymentPayload, requirements types.PaymentRequirements) error

// Extra key 常量
const AssetTransferMethodKey = "assetTransferMethod"
const ExtraFacilitatorAddressKey = /* re-export from upto/client */

ValidateUptoPayload 依次校验:scheme=="upto"、network 一致、payload 结构是 upto Permit2(以 witness.facilitator 为判别)、spender 是 X402UptoPermit2ProxyAddress、witness to==PayTo、witness facilitator==Extra.facilitatorAddress、token==Assetpermitted.amount==Amount(签名 cap)、deadline 足够、签名能 recover 到 from。这是 facilitator-free 的结构校验;链上模拟 + 签名验证仍在 facilitator 侧。

自托管 facilitator scheme(exact/facilitatorupto/facilitator#

若不用 OKX 托管 facilitator(OKXFacilitatorClient),而是自己跑 facilitator(持签名器、自行 verify + 上链),用这两个包。它们实现 x402.SchemeNetworkFacilitator,构造时注入 evm.FacilitatorEvmSigner(提供签名地址 + RPC 原语)+ 可选 config。

go
import (
	exactfac "github.com/okx/payments/go/x402/mechanisms/evm/exact/facilitator"
	uptofac "github.com/okx/payments/go/x402/mechanisms/evm/upto/facilitator"
)

// exact:SimulateInSettle 是普通 bool(零值 false,不重跑)。
type exactfac.ExactEvmSchemeConfig struct {
	DeployERC4337WithEIP6492 bool // 自动部署 ERC-4337 智能钱包(EIP-6492)
	SimulateInSettle         bool // settle 时重跑 eth_call 预演(verify 始终预演)
}
func exactfac.NewExactEvmScheme(signer evm.FacilitatorEvmSigner, config *ExactEvmSchemeConfig) *ExactEvmScheme

// upto:SimulateInSettle 是 *bool —— nil ⇒ 默认 true。
type uptofac.UptoEvmSchemeConfig struct {
	SimulateInSettle *bool // settle 时重跑 eth_call 预演;nil ⇒ true,传 *false 关闭
}
func uptofac.NewUptoEvmScheme(signer evm.FacilitatorEvmSigner, config *UptoEvmSchemeConfig) *UptoEvmScheme
// config = nil 时全用默认(SimulateInSettle 默认 true)。

// 底层 Permit2 结算层 config(被 UptoEvmScheme 透传):
type uptofac.UptoPermit2FacilitatorConfig struct {
	SimulateInSettle *bool // 同上,nil ⇒ true
}

两个 facilitator 的 SimulateInSettle 语义不同:exact 是普通 bool(零值 false,不重跑);upto*bool(nil ⇒ 默认 true)——因为 upto 实际扣款可能严格 < cap,settle 前重跑模拟更稳妥。

EVM Payload 类型(mechanisms/evm#

go
type AssetTransferMethod string
const (
	AssetTransferMethodEIP3009 AssetTransferMethod = "eip3009"
	AssetTransferMethodPermit2 AssetTransferMethod = "permit2"
)

// ---- EIP-3009 (default) ----
type ExactEIP3009Authorization struct {
	From        string `json:"from"`
	To          string `json:"to"`
	Value       string `json:"value"`
	ValidAfter  string `json:"validAfter"`
	ValidBefore string `json:"validBefore"`
	Nonce       string `json:"nonce"`
}

type ExactEIP3009Payload struct {
	Signature     string                    `json:"signature,omitempty"`
	Authorization ExactEIP3009Authorization `json:"authorization"`
}
func PayloadFromMap(data map[string]interface{}) (*ExactEIP3009Payload, error)
func (p *ExactEIP3009Payload) ToMap() map[string]interface{}

// v1/v2 在 Go 里是同一结构的别名:
type ExactEvmPayloadV1 = ExactEIP3009Payload
type ExactEvmPayloadV2 = ExactEIP3009Payload

// ---- Exact + Permit2 ----
type Permit2TokenPermissions struct {
	Token  string `json:"token"`
	Amount string `json:"amount"`
}
type Permit2Witness struct {
	To         string `json:"to"`
	ValidAfter string `json:"validAfter"`
}
func (w Permit2Witness) WitnessTypeString() string
type Permit2Authorization struct {
	From      string                  `json:"from"`
	Permitted Permit2TokenPermissions `json:"permitted"`
	Spender   string                  `json:"spender"`
	Nonce     string                  `json:"nonce"`
	Deadline  string                  `json:"deadline"`
	Witness   Permit2Witness          `json:"witness"`
}
func (a Permit2Authorization) WitnessTypeString() string
type ExactPermit2Payload struct {
	Signature            string               `json:"signature"`
	Permit2Authorization Permit2Authorization `json:"permit2Authorization"`
}
func Permit2PayloadFromMap(data map[string]interface{}) (*ExactPermit2Payload, error)
func (p *ExactPermit2Payload) ToMap() map[string]interface{}

// ---- Upto + Permit2 (cap mode) ----
// upto witness 多了 facilitator,让 upto proxy 链上强制 msg.sender == witness.facilitator。
type UptoPermit2Witness struct {
	To          string `json:"to"`
	Facilitator string `json:"facilitator"`
	ValidAfter  string `json:"validAfter"`
}
func (w UptoPermit2Witness) WitnessTypeString() string
type UptoPermit2Authorization struct {
	From      string                  `json:"from"`
	Permitted Permit2TokenPermissions `json:"permitted"` // permitted.amount 是 cap
	Spender   string                  `json:"spender"`
	Nonce     string                  `json:"nonce"`
	Deadline  string                  `json:"deadline"`
	Witness   UptoPermit2Witness      `json:"witness"`
}
func (a UptoPermit2Authorization) WitnessTypeString() string
type UptoPermit2Payload struct {
	Signature            string                   `json:"signature"`
	Permit2Authorization UptoPermit2Authorization `json:"permit2Authorization"`
}
func UptoPermit2PayloadFromMap(data map[string]interface{}) (*UptoPermit2Payload, error)
func (p *UptoPermit2Payload) ToMap() map[string]interface{}

// payload 形状判别:
func IsEIP3009Payload(data map[string]interface{}) bool
func IsPermit2Payload(data map[string]interface{}) bool
func IsUptoPermit2Payload(data map[string]interface{}) bool

payload 用 map[string]interface{} 表示,通过 IsEIP3009Payload / IsPermit2Payload / IsUptoPermit2Payload 三个判别函数区分 EIP3009 / Permit2 / upto Permit2。

Permit2 / Upto 常量(mechanisms/evm#

go
// Permit2 是 CREATE2-vanity 部署,每条 EVM 链地址相同。
const PERMIT2Address = "0x000000000022D473030F116dDEE9F6B43aC78BA3"
const MULTICALL3Address = "0xcA11bde05977b3631167028862bE2a173976CA11"

// x402 部署的 Permit2 proxy 合约(vanity 地址)。
const X402ExactPermit2ProxyAddress = "0x402085c248EeA27D92E8b30b2C58ed07f9E20001"
const X402UptoPermit2ProxyAddress  = "0x4020e7393B728A3939659E5732F87fdd8e680002"

// Permit2 witness typehash 字符串——字段顺序 ABI-significant。
const Permit2ExactWitnessTypeString = "Witness(address to,uint256 validAfter)"
const Permit2UptoWitnessTypeString  = "Witness(address to,address facilitator,uint256 validAfter)"

// scheme 名 / 默认参数
const SchemeExact        = "exact"
const SchemeUpto         = "upto"
const SchemeAggrDeferred = "aggr_deferred"
const DefaultDecimals    = 6
const DefaultValidityPeriod = 3600 // 秒

资产 / 链配置(mechanisms/evm#

资产信息用 AssetInfo + GetAssetInfo,链信息在 NetworkConfig + GetNetworkConfig,并预置一个 NetworkConfigs map。

go
type AssetInfo struct {
	Address             string
	Name                string              // EIP-712 domain name(USD₮0 用 U+20AE)
	Version             string
	Decimals            int
	AssetTransferMethod AssetTransferMethod // 强制 "permit2"
	SupportsEip2612     bool
}
func GetAssetInfo(network string, assetSymbolOrAddress string) (*AssetInfo, error)

type NetworkConfig struct {
	ChainID      *big.Int
	DefaultAsset AssetInfo
}
func GetNetworkConfig(network string) (*NetworkConfig, error)
func GetEvmChainId(network string) (*big.Int, error)

// 预置 chain ID(含 X Layer 主网 eip155:196 与测试网 eip155:1952):
var ChainIDXLayer        = big.NewInt(196)
var ChainIDXLayerTestnet = big.NewInt(1952)
// 以及 ChainIDBase / ChainIDBaseSepolia / ChainIDStable / ChainIDMonad / ... 见 NetworkConfigs

错误类型#

x402 包的错误是一组具体错误类型 + 字符串错误码常量。

go
// 通用支付错误
type PaymentError struct {
	Code    string                 `json:"code"`
	Message string                 `json:"message"`
	Details map[string]interface{} `json:"details,omitempty"`
}
func NewPaymentError(code, message string, details map[string]interface{}) *PaymentError
func (e *PaymentError) Error() string

// 验证失败
type VerifyError struct {
	InvalidReason  string
	Payer          string
	InvalidMessage string
}
func NewVerifyError(reason, payer, message string) *VerifyError

// 结算失败
type SettleError struct {
	ErrorReason  string
	Payer        string
	Network      Network
	Transaction  string
	ErrorMessage string
}
func NewSettleError(reason, payer string, network Network, transaction, message string) *SettleError

// facilitator 返回了畸形成功体
type FacilitatorResponseError struct { /* 未导出字段 */ } // 在 http 包
func (e *FacilitatorResponseError) Error() string
func (e *FacilitatorResponseError) Unwrap() error

// 路由配置校验错误(HTTPServer.Initialize 返回)
type RouteConfigurationError struct { Errors []RouteValidationError }
type RouteValidationError struct {
	RoutePattern string
	Scheme       string
	Network      x402.Network
	Reason       string // "missing_scheme" | "missing_facilitator"
	Message      string
}

错误码常量(x402 包):

go
const (
	ErrCodeInvalidPayment     = "invalid_payment"
	ErrCodePaymentRequired    = "payment_required"
	ErrCodeInsufficientFunds  = "insufficient_funds"
	ErrCodeNetworkMismatch    = "network_mismatch"
	ErrCodeSchemeMismatch     = "scheme_mismatch"
	ErrCodeSignatureInvalid   = "signature_invalid"
	ErrCodePaymentExpired     = "payment_expired"
	ErrCodeSettlementFailed   = "settlement_failed"
	ErrCodeUnsupportedScheme  = "unsupported_scheme"
	ErrCodeUnsupportedNetwork = "unsupported_network"
)

工具函数(x402 包)#

x402 包暴露的工具函数较少(部分 find 辅助是未导出的泛型函数):

go
func DeepEqual(a, b interface{}) bool
func IsWildcardNetwork(network Network) bool
func MatchesNetwork(pattern Network, network Network) bool

// payload.resource.url 与请求 URL 的匹配(可选 host 白名单容忍反代改写)。
func ResourceMatches(payloadURL, requestURL string, acceptedDomains []string) bool

没有导出的 base64 编解码函数(base64 编解码是 HTTP 层内部细节)。findSchemesByNetwork / findByNetworkAndScheme 是未导出的泛型辅助,不属于公开 API。


Schema 验证(x402 包)#

x402 包提供包级校验函数 ValidatePaymentRequirements / ValidatePaymentPayload,且 payload 校验是版本感知的(同时处理 v1/v2);没有针对 PaymentRequired 的导出校验函数。

go
func ValidatePaymentRequirements(r PaymentRequirements) error
func ValidatePaymentPayload(p PaymentPayload) error // 版本感知:v1/v2 通吃


Go SDK 参考(适用于 chargesession#

本节覆盖 MPP(charge / session)的卖方实现。几个关键设计点:

  • module path:按 module path 引用,下面用 Go module / package 表 组织。
  • challenge 生成 + 校验收敛进高层协调器 server.MppCharge / SessionChallenge / VerifyCredential / VerifySession),配合 framework middleware。
  • builder 风格:charge 用链式 WithXxx,session 用一个 config struct(evm.EVMSessionMethodConfig)。
  • 泛型 storestore.Store[T] / store.FileStore[T] / store.MemoryStore[T] 是 Go 泛型,channel 状态用 store.ChannelState 实例化。
  • ResourceURL 新字段(本分支新增):per-endpoint 营收聚合标签,仅 Charge 模式,透传到 challenge.request 交给 SA。

Go module / package#

Go modulepackage(import path)描述
github.com/okx/payments/go/mpp.../mpp/server高层协调器 server.MppCharge / SessionChallenge / VerifyCredential / VerifySessionEVMConfigChargeRouteConfig / SessionRouteConfigParseDollarAmount
.../mpp/evmEVM charge / session method:EVMChargeMethod(builder)、EVMSessionMethod + EVMSessionMethodConfig、EIP-712 签名、Signer / PrivateKeySignerSplit / SessionSplitEVMMethodDetails(含 ResourceURL)、常量
.../mpp/protocol协议层:PaymentChallenge / PaymentCredential / ReceiptChargeVerifier / SessionVerifier interface、challenge/credential codec、HMAC ComputeChallengeIDVerificationError
.../mpp/saclientSA-API client:SAClient interface、默认实现 OKXSAClient、测试用 MockSAClient、请求/响应类型、SAErrorCode
.../mpp/store泛型 store:Store[T]MemoryStore[T]FileStore[T]ChannelStateDeductFromChannel
.../mpp/errors稳定错误码:MppErrorMppErrorCode(含新增 InvalidSplit)、RFC 9457 PaymentErrorDetails
.../mpp/http/nethttp .../mpp/http/gindrop-in middleware:ChargeMiddleware / SessionMiddleware / GetReceipt
.../mpp/adapters双协议路由用的 MPP adapter:MppAdapter + MppRouteConfig
github.com/okx/payments/go/paymentrouter.../paymentrouter双协议(MPP + x402)路由核心:ProtocolAdapter interface、RouteConfigConfig
.../paymentrouter/nethttpnet/http drop-in PaymentGateNew(...).For(cfg)(handler)
github.com/okx/payments/go/x402.../x402/adaptersx402 协议 adapter:X402Adapter(包进 paymentrouter

各 package 直接按上面的 import path 引用。


常量#

go
// X Layer mainnet chain ID。
const evm.XLayerChainID uint64 = 196

// X Layer mainnet escrow 合约地址(不传 EscrowContract 时的兜底)。
const evm.DefaultEscrowContract = "0x5E550002e64FaF79B41D89fE8439eEb1be66CE3b"

// EIP-712 voucher 签名 domain(不显式覆盖时的默认值)。
const evm.DefaultDomainName    = "EVM Payment Channel"
const evm.DefaultDomainVersion = "1"

// challenge 默认有效期(分钟)。
const evm.DefaultExpiresMinutes = 5

// method / intent 名。
const evm.MethodNameEVM = "evm"

// session action 常量。
const evm.ActionOpen, evm.ActionTopUp, evm.ActionVoucher, evm.ActionClose, evm.ActionSettle = "open", "topUp", "voucher", "close", "settle"

// session receipt status 常量。
const evm.StatusOpen, evm.StatusClosed = "open", "closed"

// 最大分账路数。
const evm.MaxSplits = 10

核心类型(mpp/evmmpp/saclient#

SAResponse#

SA-API 统一响应包装;client 自动解包 data

go
type saclient.SAResponse struct {
    Code int             `json:"code"`
    Msg  string          `json:"msg"`
    Data json.RawMessage `json:"data"`
}

SAResponsejson.RawMessage 延迟解码,由各端点方法解到具体类型。

EVMMethodDetails / Split#

Charge challenge 的 methodDetails(base64url 编进 request)。

go
type evm.EVMMethodDetails struct {
    ChainID  *uint64 `json:"chainId,omitempty"`
    FeePayer *bool   `json:"feePayer,omitempty"`   // server pays gas(transaction 模式)
    Memo     *string `json:"memo,omitempty"`
    Splits   []Split `json:"splits,omitempty"`
    // ResourceURL —— 本 charge 保护的 endpoint URL(如 "https://api.shop.com/photo")。
    // 原样透传到 challenge.request 交给 SA,让商户按 URL 聚合交易量 / 营收。
    // 商户持有;SDK 不自动填。仅 Charge 模式;Session 模式不支持(一个 session
    // 可能横跨多个 URL)。
    ResourceURL *string `json:"resourceUrl,omitempty"`
}

func (d *EVMMethodDetails) IsFeePayer() bool
func evm.ParseEVMMethodDetails(methodDetails json.RawMessage) (*EVMMethodDetails, error)

// Constraints: sum(splits[].amount) < totalAmount;primary recipient 必须留正余额;splits ≤ MaxSplits。
type evm.Split struct {
    Amount    string  `json:"amount"`              // base-units integer string
    Memo      *string `json:"memo,omitempty"`
    Recipient string  `json:"recipient"`           // 40-hex 地址
}

charge 的分账类型是 evm.SplitResourceURL 是本分支新增字段。

EVMSessionMethodDetails / SessionSplit#

go
type evm.EVMSessionMethodDetails struct {
    EscrowContract  string         `json:"escrowContract"`
    ChannelID       *string        `json:"channelId,omitempty"`
    MinVoucherDelta *string        `json:"minVoucherDelta,omitempty"`
    ChainID         *uint64        `json:"chainId,omitempty"`
    FeePayer        *bool          `json:"feePayer,omitempty"`
    Splits          []SessionSplit `json:"splits,omitempty"`
}

func (d *EVMSessionMethodDetails) IsFeePayer() bool
func evm.ParseEVMSessionMethodDetails(methodDetails json.RawMessage) (*EVMSessionMethodDetails, error)

// Constraints: bps in [1, 9999];sum(splits[].bps) < 10000。
type evm.SessionSplit struct {
    Recipient string  `json:"recipient"`
    Bps       uint32  `json:"bps"`
    Memo      *string `json:"memo,omitempty"`
}

Eip3009Authorization#

Charge payload.authorization 形状(client 签 EIP-3009 后填进去)。分账时 Splits 各路独立一份 EIP-3009。

go
type saclient.Eip3009Authorization struct {
    Type        string                 `json:"type"`       // always "eip-3009"
    From        string                 `json:"from"`
    To          string                 `json:"to"`
    Value       string                 `json:"value"`
    ValidAfter  string                 `json:"validAfter"`
    ValidBefore string                 `json:"validBefore"`
    Nonce       string                 `json:"nonce"`
    Signature   string                 `json:"signature,omitempty"`
    Splits      []Eip3009Authorization `json:"splits,omitempty"`
}

分账直接用 Eip3009Authorization 自嵌套 Splits,每路独立一份 EIP-3009 授权。

ChargeReceipt / SessionReceipt / SessionStatus#

go
type saclient.ChargeReceipt struct {
    Method      string `json:"method"`
    Reference   string `json:"reference"`     // on-chain tx hash
    Status      string `json:"status"`
    Timestamp   string `json:"timestamp"`
    ChainID     uint64 `json:"chainId"`
    ChallengeID string `json:"challengeId"`
    ExternalID  string `json:"externalId"`
}

type saclient.SessionReceipt struct {
    Method    string `json:"method"`
    Intent    string `json:"intent"`
    Status    string `json:"status"`
    Timestamp string `json:"timestamp"`
    ChannelID string `json:"channelId"`
    ChainID   uint64 `json:"chainId"`
    Reference string `json:"reference"`
    Deposit   string `json:"deposit"`          // 当前 on-chain known deposit
}

// GET /session/status 的响应。
type saclient.SessionStatus struct {
    ChannelID        string `json:"channelId"`
    Payer            string `json:"payer"`
    Payee            string `json:"payee"`
    Token            string `json:"token"`
    Deposit          string `json:"deposit"`
    CumulativeAmount string `json:"cumulativeAmount"`
    SettledOnChain   string `json:"settledOnChain"`
    RemainingBalance string `json:"remainingBalance"`
    SessionStatus    string `json:"sessionStatus"`   // OPEN, CLOSING, CLOSED
}

evm package 另有一份面向 receipt header 的 evm.SessionReceiptChannelID/CumulativeAmount/EscrowContract/Status/Reference + ToBaseReceipt),用于把 settlement 编进 protocol.Receipt;上面的 saclient.SessionReceipt 才是 SA-API 端点返回体。

Session 请求 payload(SA-API)#

SDK 主动调 /session/settle / /session/close 的 body(扁平,不带 challenge wrapper);统一包在泛型信封 saclient.CredentialRequest[P] 里。

go
type saclient.CredentialRequest[P any] struct {
    Challenge *protocol.ChallengeEcho `json:"challenge,omitempty"`
    Payload   P                       `json:"payload"`
    Source    string                  `json:"source,omitempty"`
}

type saclient.SessionSettlePayload struct {
    Action           string `json:"action,omitempty"`   // "settle"
    ChannelID        string `json:"channelId"`
    CumulativeAmount string `json:"cumulativeAmount"`
    VoucherSignature string `json:"voucherSignature"`   // payer 65-byte r‖s‖v hex
    PayeeSignature   string `json:"payeeSignature"`      // payee 65-byte r‖s‖v hex
    Nonce            string `json:"nonce"`
    Deadline         string `json:"deadline"`
}

type saclient.SessionClosePayload struct {
    Action           string `json:"action,omitempty"`   // "close"
    ChannelID        string `json:"channelId"`
    CumulativeAmount string `json:"cumulativeAmount"`
    VoucherSignature string `json:"voucherSignature"`   // waiver 分支为 ""
    PayeeSignature   string `json:"payeeSignature"`
    Nonce            string `json:"nonce"`
    Deadline         string `json:"deadline"`
}

// 类型别名:各端点用具体 payload 实例化信封。
type saclient.SessionSettleRequest = CredentialRequest[SessionSettlePayload]
type saclient.SessionCloseRequest  = CredentialRequest[SessionClosePayload]
type saclient.SessionOpenRequest   = CredentialRequest[SessionOpenPayload]
type saclient.SessionTopUpRequest  = CredentialRequest[SessionTopUpPayload]
type saclient.ChargeSettleRequest     = CredentialRequest[ChargeTransactionPayload]
type saclient.ChargeVerifyHashRequest = CredentialRequest[ChargeHashPayload]

settle / close 的 body 分别是 SessionSettlePayload / SessionClosePayload,再用泛型 CredentialRequest[P] 包成 request。


SAClient interface#

可插拔 SA-API client 接口,默认实现 OKXSAClient,测试用 MockSAClient

go
type saclient.SAClient interface {
    // Charge(client-facing —— 透传 credential)
    Settle(ctx context.Context, req *ChargeSettleRequest) (*ChargeReceipt, error)
    VerifyHash(ctx context.Context, req *ChargeVerifyHashRequest) (*ChargeReceipt, error)

    // Session(client-facing —— 透传 credential)
    SessionOpen(ctx context.Context, req *SessionOpenRequest) (*SessionReceipt, error)
    SessionTopUp(ctx context.Context, req *SessionTopUpRequest) (*SessionReceipt, error)

    // Session(merchant-facing —— server 构造 request)
    SessionSettle(ctx context.Context, req *SessionSettleRequest) (*SessionReceipt, error)
    SessionClose(ctx context.Context, req *SessionCloseRequest) (*SessionReceipt, error)

    // Session(read-only)
    SessionStatus(ctx context.Context, channelID string) (*SessionStatus, error)
}

charge 的 settle / verify-hash 对应 Settle / VerifyHash。Go 没有 /session/voucher 端点 —— voucher 在 SDK 本地处理(EVMSessionMethod.VerifySession 的 voucher 分支)。


OKX SA-API 客户端(saclient.OKXSAClient#

go
type saclient.OKXSAClient struct { /* private */ }

func saclient.NewOKXSAClient(baseURL, apiKey, secretKey, passphrase string, opts ...Option) *OKXSAClient

// functional option。
type saclient.Option func(*OKXSAClient)
func saclient.WithHTTPClient(c *http.Client) Option   // 替换默认 http.Client

实现 SAClient;每个请求自动加 OKX API key + HMAC-SHA256 签名 + passphrase 认证头。

构造统一走单个 NewOKXSAClient(baseURL, ...)baseURL 显式必传(生产传 https://web3.okx.com,sandbox/staging 传对应 URL)。

端点#

SAClient 方法OKX 路径
Settle()POST /api/v6/pay/mpp/charge/settle
VerifyHash()POST /api/v6/pay/mpp/charge/verifyHash
SessionOpen()POST /api/v6/pay/mpp/session/open
SessionTopUp()POST /api/v6/pay/mpp/session/topUp
SessionSettle()POST /api/v6/pay/mpp/session/settle
SessionClose()POST /api/v6/pay/mpp/session/close
SessionStatus(channelID)GET /api/v6/pay/mpp/session/status?channelId=...

OKX 响应包在 {"code": 0, "data": {...}, "msg": ""},client 自动解包。

测试用 MockSAClient:接受所有 credential、不做链上校验、返合成 receipt。

go
func saclient.NewMockSAClient(chainID uint64) *MockSAClient

Charge — evm.EVMChargeMethod#

实现 protocol.ChargeVerifier,把 credential 透传给 SA-API。用链式 builder 配置。

go
type evm.EVMChargeMethod struct { /* private */ }

h
func evm.NewEVMChargeMethod() *EVMChargeMethod

func (m *EVMChargeMethod) WithChainID(chainID uint64) *EVMChargeMethod
func (m *EVMChargeMethod) WithRecipient(recipient string) *EVMChargeMethod
func (m *EVMChargeMethod) WithSAClient(c saclient.SAClient) *EVMChargeMethod  // nil → local-only 模式
func (m *EVMChargeMethod) WithFeePayer(feePayer bool) *EVMChargeMethod        // true → transaction 模式

// protocol.ChargeVerifier 实现:
func (m *EVMChargeMethod) Method() string
func (m *EVMChargeMethod) ChallengeMethodDetails() *EVMMethodDetails
func (m *EVMChargeMethod) PrepareRequest(request protocol.ChargeRequest, _ *protocol.PaymentCredential) protocol.ChargeRequest
func (m *EVMChargeMethod) Verify(ctx context.Context, cred *protocol.PaymentCredential, request *protocol.ChargeRequest) (*protocol.Receipt, error)

payload.type 路由(在 Verify 内部):

  • "transaction"SAClient.Settle(SA-API 链上 broadcast transferWithAuthorization
  • "hash"SAClient.VerifyHash(client 已自行 broadcast,SA-API 验证 tx hash)

Splits 透传 payload.authorization.splits[],SA-API 拥有 split 校验权。

EVMChargeMethod 只管「校验」(ChargeVerifier),challenge 生成交给高层 server.Mpp.Charge(...),配置走 server.EVMConfig + server.ChargeRouteConfig(见下)。

构造 EVMSessionMethod#

go
type evm.EVMSessionMethod struct { /* private */ }

func evm.NewEVMSessionMethod(cfg EVMSessionMethodConfig) (*EVMSessionMethod, error)

type evm.EVMSessionMethodConfig struct {
    // 必填。
    Recipient string            // payee 钱包地址
    SAClient  saclient.SAClient // SA-API client

    // 可选(零值 = 默认)。
    ChainID         uint64                          // 默认 196 (X Layer)
    EscrowContract  string                          // 默认 DefaultEscrowContract
    Signer          Signer                          // payee 签名器;nil → settle/close 关闭
    Store           store.Store[store.ChannelState] // 默认 in-memory store
    PerRequestCost  *big.Int                        // 每次请求扣费额
    MinVoucherDelta *big.Int                        // voucher 最小递增量
    NonceProvider   NonceProvider                   // 默认 UuidNonceProvider
    Deadline        *big.Int                        // 默认 U256 MAX(永不过期)
    DomainName      string                          // 默认 "EVM Payment Channel"
    DomainVersion   string                          // 默认 "1"
    FeePayer        bool                            // payee 是否代付 gas
}

配置收敛成单个 EVMSessionMethodConfig struct,由 NewEVMSessionMethod 一次性校验必填字段并返回 error


Session — evm.EVMSessionMethod#

实现 protocol.SessionVerifier。维护本地 channel state、voucher 本地验签 + 累计扣费、商户主动 settle/close。

protocol.SessionVerifier 实现#

go
func (m *EVMSessionMethod) Method() string
func (m *EVMSessionMethod) ChallengeMethodDetails() json.RawMessage  // 无 escrow 配置时返 nil
func (m *EVMSessionMethod) VerifySession(ctx context.Context, cred *protocol.PaymentCredential, request *protocol.SessionRequest) (*protocol.Receipt, error)
func (m *EVMSessionMethod) Respond(cred *protocol.PaymentCredential, receipt *protocol.Receipt) any

Respond 对 open/topUp/close 返管理响应、对 voucher(serve-resource)返 nil。

业务方法#

go
// 底层 channel store。
func (m *EVMSessionMethod) ChannelStore() store.Store[store.ChannelState]

// 原子扣费:available = highestVoucher - spent;不足返 insufficient-balance 错误。
func (m *EVMSessionMethod) DeductFromSession(ctx context.Context, channelID string, amount *big.Int) (*store.ChannelState, error)

// 取本地 highest voucher → 签 SettleAuthorization → 调 /session/settle。
func (m *EVMSessionMethod) SettleWithAuthorization(ctx context.Context, channelID string) (*saclient.SessionReceipt, error)

// 签 CloseAuthorization → 调 /session/close → 成功后从 store 移除。
func (m *EVMSessionMethod) CloseWithAuthorization(ctx context.Context, channelID string) (*saclient.SessionReceipt, error)

voucher 验签内联进 VerifySession 的 voucher 分支,仅暴露 DeductFromSessionCloseWithAuthorization 只收 channelID(waiver 与否由 store 里有无 voucher 决定)。channel 状态查询走 saclient.SAClient.SessionStatus,不在 EVMSessionMethod 上单列。

Session action 路由(VerifySession 内部,按 payload.action#

action行为
"open"payee 校验 → SA SessionOpen → 写本地 store
"voucher"本地验签 + 升 highest voucher → 扣费(DeductFromSession
"topUp"SA SessionTopUp → 累加本地 deposit
"close"取 payer 提供的 voucher → 本地 close 流程

session payload 类型(credential 内部,按 action 分):evm.OpenPayload / evm.VoucherPayload / evm.TopUpPayload / evm.ClosePayload,各有 Validate() error


Session — store.Store[T] interface#

Go 的 store 是泛型 key-value 接口。channel 状态用 store.ChannelState 实例化。

go
type store.Store[T any] interface {
    Get(ctx context.Context, key string) (*T, error)   // 不存在返 (nil, nil)
    Put(ctx context.Context, key string, value *T) error
    Delete(ctx context.Context, key string) error
}

// 原子扣费 helper:
// 读 channel → 检查约束 → 更新 Spent → 写回。
func store.DeductFromChannel(ctx context.Context, s Store[ChannelState], channelID string, amount *big.Int) (*ChannelState, error)

ChannelState#

go
type store.ChannelState struct {
    ChannelID               string   `json:"channelId"`
    ChainID                 uint64   `json:"chainId"`
    EscrowContract          string   `json:"escrowContract"`
    Payer                   string   `json:"payer"`
    Payee                   string   `json:"payee"`
    Token                   string   `json:"token"`
    AuthorizedSigner        string   `json:"authorizedSigner"`   // open 时 address(0) → payer
    Deposit                 *big.Int `json:"deposit"`
    HighestVoucherAmount    *big.Int `json:"highestVoucherAmount"`
    HighestVoucherSignature []byte   `json:"highestVoucherSignature,omitempty"`
    MinVoucherDelta         *big.Int `json:"minVoucherDelta,omitempty"`   // nil 关闭节流
    Spent                   *big.Int `json:"spent"`               // invariant: spent ≤ highestVoucherAmount
    Units                   uint64   `json:"units"`               // deduct 次数
    Finalized               bool     `json:"finalized"`
    CloseRequestedAt        uint64   `json:"closeRequestedAt"`
    CreatedAt               string   `json:"createdAt"`
}

// 链上视图(SA 拉回的 on-chain channel)。
type store.OnChainChannel struct {
    Payer            string   `json:"payer"`
    Payee            string   `json:"payee"`
    Token            string   `json:"token"`
    AuthorizedSigner string   `json:"authorizedSigner"`
    Deposit          *big.Int `json:"deposit"`
    Settled          *big.Int `json:"settled"`
    CloseRequestedAt uint64   `json:"closeRequestedAt"`
    Finalized        bool     `json:"finalized"`
}

channel 状态用 store.ChannelState 表示;金额字段用 *big.Int,签名等可选字节字段用 []byte(nil 表示缺省)。

实现 — MemoryStore[T] / FileStore[T]#

go
// 默认:进程内 map,值经 JSON round-trip 深拷贝防止 caller 篡改。
type store.MemoryStore[T any] struct { /* private */ }
func store.NewMemoryStore[T any]() *MemoryStore[T]

// 文件持久化:每个 key 一个 JSON 文件,per-key mutex 保证同 key 并发安全。
type store.FileStore[T any] struct { /* private */ }
func store.NewFileStore[T any](dir string) (*FileStore[T], error)  // 自动创建目录

channel 场景固定用 store.ChannelState 实例化,例如:

go
chStore, err := store.NewFileStore[store.ChannelState]("/var/lib/mpp/channels")

MemoryStore 的两个 caveat:

  • 重启即丢:进程重启 / crash 丢失所有 channel state。长期 channel / 多实例 HA / 热重载场景请换 FileStore,或自实现持久化 store(SQLite / Redis / Postgres / ...)实现 Store[ChannelState] 注入 EVMSessionMethodConfig.Store
  • abandoned channel 累积:payer 不调 close 时记录会一直留 —— session lifecycle 通用问题,商户应有 cleanup / TTL 策略。

NonceProvider interface#

go
type evm.NonceProvider interface {
    Allocate(payee common.Address, channelID [32]byte) (*big.Int, error)
}

// 默认实现:UUID v4 → big.Int(128-bit random,stateless,跨多实例 / 重启安全)。
type evm.UuidNonceProvider struct{}
func evm.NewUuidNonceProvider() *UuidNonceProvider
func (p *UuidNonceProvider) Allocate(_ common.Address, _ [32]byte) (*big.Int, error)

合约层 nonce 已用集 key = (payee, channelId, nonce),重复使用以 NonceAlreadyUsed revert。SDK 只负责分配「大概率没用过」的 nonce,不追踪已用集。


EIP-712 签名(mpp/evm#

Signer interface 与默认实现#

go
type evm.Signer interface {
    Sign(hash []byte) ([]byte, error)
    SignTypedData(typedData apitypes.TypedData) ([]byte, error)
    Address() common.Address
}

type evm.PrivateKeySigner struct { /* private */ }
func evm.NewPrivateKeySigner(key *ecdsa.PrivateKey) *PrivateKeySigner
func evm.NewPrivateKeySignerFromHex(hexKey string) (*PrivateKeySigner, error)  // 带或不带 "0x"

Go 定义自己的 evm.Signer interface,默认 PrivateKeySigner;远程 / KMS 签名器实现这三个方法即可注入。

Voucher 签名 / 验签#

go
// EIP-712 voucher 结构 1:1 对应合约的 Voucher{ bytes32 channelId; uint128 cumulativeAmount }。
func evm.SignVoucher(
    signer Signer,
    channelID [32]byte,
    cumulativeAmount *big.Int,
    escrowContract common.Address,
    chainID uint64,
    domainName, domainVersion string,   // 空 → DefaultDomainName / DefaultDomainVersion
) ([]byte, error)                       // 65-byte 签名,v 编为 27/28

// 1) len == 65  2) low-s precheck  3) EIP-712 digest  4) ecrecover + 严格地址比较。
func evm.VerifyVoucher(
    escrowContract common.Address,
    chainID uint64,
    channelID [32]byte,
    cumulativeAmount *big.Int,
    sig []byte,
    expectedSigner common.Address,
    domainName, domainVersion string,
) bool                                  // v 接受 27/28 或 0/1

func evm.ValidateVoucherSignature(sig []byte) error   // 65 字节 + low-s 检查

VerifyVoucherbool,预校验单独走 ValidateVoucherSignature(sig) error

SettleAuthorization / CloseAuthorization 签名#

go
type evm.SignedAuthorization struct {
    ChannelID        [32]byte
    CumulativeAmount *big.Int
    Nonce            *big.Int
    Deadline         *big.Int
    Signature        []byte    // 65-byte r||s||v
}

// primaryType 选 "SettleAuthorization" 或 "CloseAuthorization"。
func evm.SignAuthorization(
    signer Signer,
    primaryType string,
    channelID [32]byte,
    cumulativeAmount *big.Int,
    nonce *big.Int,
    deadline *big.Int,
    escrowContract common.Address,
    chainID uint64,
    domainName string,
    domainVersion string,
) (*SignedAuthorization, error)

// 确定性 channelId = keccak256(abi.encode(payer, payee, token, salt, authorizedSigner, escrowContract, chainID))。
func evm.ComputeChannelID(
    payer, payee, token common.Address,
    salt [32]byte,
    authorizedSigner, escrowContract common.Address,
    chainID uint64,
) [32]byte

settle / close 授权签名合并为单个 SignAuthorization,靠 primaryType 选 settle / close。


解码 challenge.request#

protocol package 用一组顶层 codec 函数,从 PaymentChallenge.Request(base64url JSON)解出 typed request。

go
// 从 challenge 解 request。
func protocol.RequestFromChallenge(c *PaymentChallenge) (json.RawMessage, error)
func protocol.RequestFromChallengeTyped(c *PaymentChallenge, v interface{}) error

// 直接对 base64url 串解码。
func protocol.DeserializeRequest(encoded string) (json.RawMessage, error)
func protocol.DeserializeRequestTyped(encoded string, v interface{}) error

// 反向:把 typed request 编成 base64url 串。
func protocol.SerializeRequest(v interface{}) (string, error)

// 用法:
var req protocol.SessionRequest
if err := protocol.RequestFromChallengeTyped(ch, &req); err != nil { /* ... */ }

protocol.ChargeRequest / protocol.SessionRequest 还带 WithBaseUnits()(decimal → base units)、ValidateMaxAmount(max) 等 helper。


Drop-in middleware(mpp/http/nethttpmpp/http/gin#

Go 提供 net/http 与 gin 两套 framework middleware,直接包业务 handler;payment 逻辑收敛在 server.Mpp

go
import mpphttp "github.com/okx/payments/go/mpp/http/nethttp"

// net/http:
func nethttp.ChargeMiddleware(m *server.Mpp, cfg server.ChargeRouteConfig) func(http.Handler) http.Handler
func nethttp.SessionMiddleware(m *server.Mpp, cfg server.SessionRouteConfig) func(http.Handler) http.Handler
func nethttp.GetReceipt(r *http.Request) *protocol.Receipt   // 校验成功后从 ctx 取 receipt

// gin(同名函数,签名换成 gin):
import mppgin "github.com/okx/payments/go/mpp/http/gin"
func gin.ChargeMiddleware(m *server.Mpp, cfg server.ChargeRouteConfig) gin.HandlerFunc
func gin.SessionMiddleware(m *server.Mpp, cfg server.SessionRouteConfig) gin.HandlerFunc
func gin.GetReceipt(c *gin.Context) *protocol.Receipt

校验失败按 protocol.VerificationError.HTTPStatus() 自动映射 HTTP 状态码(400 payload/format、410 channel-not-found/closed、其余默认 402)。


高层协调器 server.Mpp#

charge 与 session 的 challenge 生成统一到 server.Mpp:注入一份 EVMConfig + charge/session verifier,对外暴露「生成 challenge」与「校验 credential」两组方法。

go
type server.EVMConfig struct {
    ChainID   uint64   // 期望链 ID(196 = X Layer)
    Recipient string   // 期望收款地址(带/不带 0x)
    SecretKey string   // challenge ID 的 HMAC 密钥;空 = 空 key(确定但不认证)
    Realm     string   // WWW-Authenticate realm;空默认 "mpp"
}

type server.Mpp struct { /* private */ }
func server.NewMpp(cfg EVMConfig, charge protocol.ChargeVerifier, session protocol.SessionVerifier) *Mpp
// 两个 verifier 任一可为 nil(只用一种 intent 时)。

// 生成 challenge(返 WWW-Authenticate header 值):
func (m *Mpp) Charge(ctx context.Context, cfg ChargeRouteConfig) (string, error)
func (m *Mpp) SessionChallenge(ctx context.Context, cfg SessionRouteConfig) (string, error)

// 校验 credential:
func (m *Mpp) VerifyCredential(ctx context.Context, challengeHeader, authHeader string) (*protocol.Receipt, error)
func (m *Mpp) VerifySession(ctx context.Context, challengeHeader, authHeader string) (*protocol.SessionVerifyResult, error)

// 低层变体(自带 request / options):
func (m *Mpp) ChargeWithOptions(ctx context.Context, req protocol.ChargeRequest, opts ChargeOptions) (string, error)
func (m *Mpp) SessionChallengeWithDetails(ctx context.Context, req protocol.SessionRequest, opts SessionChallengeOptions) (string, error)

Route config#

per-route 参数。注意 ResourceURL 是本分支新增(仅 Charge)。

go
type server.ChargeRouteConfig struct {
    Amount      string      // human-readable decimal,如 "0.01"
    Currency    string      // ERC-20 合约地址
    Decimals    uint32      // 代币精度(USDC = 6)
    Description string
    ExternalID  string      // caller 自定义 reference
    Splits      []evm.Split // 次级收款方,主收款方拿 total - sum(splits);≤ 10
    ResourceURL string      // 【新增】本 charge 保护的 endpoint URL,透传给 SA 按 URL 聚合营收;空则不上报。仅 Charge 模式。
}

type server.SessionRouteConfig struct {
    Amount           string  // human-readable decimal,如 "0.001"
    Currency         string  // ERC-20 合约地址
    Decimals         uint32
    Description      string
    ExternalID       string
    UnitType         string  // 计费单位:"request" / "second" / "byte" ...
    SuggestedDeposit string  // 建议初始 deposit(base units)
}

server package 另有 ParseDollarAmount(amount string, decimals uint32) (string, error):human-readable decimal → 整数 base-units(如 ParseDollarAmount("1.50", 6) → "1500000")。


错误类型#

Go 用三层错误:protocol.VerificationError(verifier 层)、saclient.SAErrorCode(SA-API 业务码)、errors.MppError / MppErrorCode(稳定的字符串码 + RFC 9457 problem details;OKXSAClientSAErrorCode 映射进它)。

go
type protocol.VerificationError struct {
    Message   string    `json:"message"`
    Code      ErrorCode `json:"code,omitempty"`
    Retryable bool      `json:"retryable"`
}
func (e *VerificationError) Error() string
func (e *VerificationError) HTTPStatus() int       // 400 / 410 / 402(默认)
func (e *VerificationError) WithRetryable() *VerificationError

// 机器可读错误码(字符串),含 .SpecCode() / .String():
type protocol.ErrorCode string
const (
    ErrorCodeExpired, ErrorCodeInvalidAmount, ErrorCodeInvalidRecipient,
    ErrorCodeTransactionFailed, ErrorCodeNotFound, ErrorCodeInvalidCredential,
    ErrorCodeNetworkError, ErrorCodeChainIdMismatch, ErrorCodeCredentialMismatch,
    ErrorCodeChannelNotFound, ErrorCodeChannelClosed, ErrorCodeInsufficientBalance,
    ErrorCodeInvalidPayload, ErrorCodeInvalidSignature, ErrorCodeAmountExceedsDeposit,
    ErrorCodeDeltaTooSmall ErrorCode = /* ... */
)
// 配套构造器:protocol.ErrSig / ErrAmount / ErrChannelNotFound / ErrInsufficientBalance / ...
// 以及 VerificationErrorXxx(msg) 一组。

SA-API 业务错误码映射(saclient.SAErrorCode#

go
type saclient.SAErrorCode int
const (
    SACodeSuccess              = 0
    SACodeInvalidParams        = 70000   // 缺必填字段或格式错误
    SACodeUnsupportedChain     = 70001   // 链不在支持列表
    SACodePayerBlocked         = 70002   // 付款方在黑名单
    SACodeInvalidCredential    = 70003   // source 缺失 / feePayer=true 不支持 hash 模式 / txHash 已用
    SACodeInvalidSignature     = 70004   // 签名验证失败
    SACodeSplitSumExceedsTotal = 70005   // 分账总额 ≥ 主金额
    SACodeSplitCountExceeded   = 70006   // 分账数量 > 10
    SACodeTxNotConfirmed       = 70007   // 交易未在链上确认
    SACodeChannelClosed        = 70008   // 链上 channel 已关闭
    SACodeChallengeInvalid     = 70009   // challenge 不存在或已过期
    SACodeChannelNotFound      = 70010   // channelId 不存在
    SACodeGracePeriodTooShort  = 70011   // escrow grace period < 10 分钟,拒绝开通
    SACodeAmountExceedsDeposit = 70012   // cumulativeAmount 超 deposit 余额
    SACodeVoucherDeltaTooSmall = 70013   // voucher 递增量低于 minVoucherDelta
    SACodeChannelClosing       = 70014   // channel CLOSING 状态,不收新 voucher
    SACodeInternalError        = 8000    // API 服务内部错误
)

本地账户余额不足扣费(available < amount)由 verifier 层的 protocol.ErrorCodeInsufficientBalance / ErrInsufficientBalance(...) 表达,不在 SAErrorCode 常量集里(本地扣费由 DeductFromSession / store.DeductFromChannel 检查,不是 SA-API 业务码)。

errors package —— MppError / MppErrorCode#

saclient.OKXSAClient 把 SA-API 业务码(SAErrorCode)映射成稳定的字符串错误码 errors.MppErrorCode,封进 *errors.MppError 返回。

go
type errors.MppErrorCode string

type errors.MppError struct {
    Code    MppErrorCode `json:"code"`
    Message string       `json:"message"`
    Reason  string       `json:"reason,omitempty"`
}
func (e *MppError) Error() string
func (e *MppError) ToProblemDetails(challengeID string) *PaymentErrorDetails // RFC 9457

MppErrorCode 取值(节选,含本分支新增 InvalidSplit):

Code含义
MalformedCredentialcredential 畸形
InvalidChallengechallenge 非法
InvalidSignature签名验证失败
InvalidSplit【新增】分账非法(总额超主金额 / 路数超 10)
InsufficientBalance余额不足
AmountExceedsDeposit金额超 deposit
DeltaTooSmallvoucher 递增量过小
ChannelNotFoundchannel 不存在
ChannelClosedchannel 已关闭
SignerMismatch签名者不匹配
BadRequest请求参数错误
Internal内部错误

完整集还含 AmountExceedsMax / InvalidAmount / InvalidConfig / Http / ChainIdMismatch / Json / HexDecode / Base64Decode / UnsupportedPaymentMethod / MissingHeader / InvalidBase64Url / VerificationFailed / PaymentExpired / PaymentRequired / InvalidPayload / Io / InvalidUtf8 / SystemTime

SA-API 业务码 → MppErrorCode 映射(OKXSAClient.mapSAError#

SA code含义映射 MppErrorCode
70000缺必填字段 / 格式错误BadRequest
70001链不在支持列表Internal
70002付款方在黑名单MalformedCredential
70003source 缺失 / feePayer+hash 不兼容 / txHash 已用MalformedCredential
70004签名验证失败InvalidSignature
70005分账总额 ≥ 主金额InvalidSplit
70006分账数量 > 10InvalidSplit
70007交易未链上确认Internal
70008channel 已关闭ChannelClosed
70009challenge 不存在 / 已过期InvalidChallenge
70010channelId 不存在ChannelNotFound
70011escrow grace period 不满足Internal
70012cumulativeAmount 超 deposit 余额AmountExceedsDeposit
70013voucher 递增量 < minVoucherDeltaDeltaTooSmall
70014channel CLOSING 状态ChannelClosed
8000API 内部错误Internal

默认分支(未列出的码)→ Internal


双协议路由(paymentrouter + mpp/adapters + x402/adapters#

让一个 net/http app 同时接 MPP + x402,业务 handler 协议无关。Go 用 adapter pattern + PaymentGate middleware。

Adapter interface#

go
type paymentrouter.ProtocolAdapter interface {
    Name() string                                                       // "mpp" | "x402" | 自定义
    Priority() int                                                      // 越小越先 Detect(MPP < x402)
    Detect(r *http.Request) bool                                        // 看请求头是否属于本协议
    GetChallenge(ctx context.Context, r *http.Request, cfg any) (http.Header, error)  // 生成本协议 402 challenge header
    Handle(w http.ResponseWriter, r *http.Request, cfg any) error       // 校验 + 写 receipt / 错误
}

GetChallenge 直接传 cfg any,adapter 内部对自己的 config struct 做 type-assert。中间件用闭包包装,无需额外的注册 hook。

内置 adapter#

go
import (
    mppadapters "github.com/okx/payments/go/mpp/adapters"
    x402adapters "github.com/okx/payments/go/x402/adapters"
)

// MppAdapter 接 *server.Mpp(用 server.NewMpp(...) 拼出)。
func mppadapters.NewMppAdapter(mpp *server.Mpp) *MppAdapter
func (a *MppAdapter) Name() string
func (a *MppAdapter) Priority() int
func (a *MppAdapter) Detect(r *http.Request) bool
func (a *MppAdapter) GetChallenge(ctx context.Context, r *http.Request, cfg any) (http.Header, error)
func (a *MppAdapter) Handle(w http.ResponseWriter, r *http.Request, cfg any) error

// X402Adapter 接 *x402http.HTTPServer(routes 可传 nil,由路由 config 懒注册)。
func x402adapters.NewX402Adapter(server *x402http.HTTPServer) *X402Adapter
func (a *X402Adapter) Name() string
func (a *X402Adapter) Priority() int
func (a *X402Adapter) Detect(r *http.Request) bool
func (a *X402Adapter) GetChallenge(ctx context.Context, r *http.Request, cfg any) (http.Header, error)
func (a *X402Adapter) Handle(w http.ResponseWriter, r *http.Request, cfg any) error

NewX402Adapter 一步到位,poll deadline / settlement hook 等行为配在传入的 *x402http.HTTPServer / facilitator 客户端上。MppAdapter 的 priority 内置固定,不可调。

每 adapter 的类型化路由配置#

go
// MPP per-route config(MppAdapter 内部 type-assert 到这个类型)。
type mppadapters.MppRouteConfig struct {
    Intent           string `json:"intent"`          // "charge" 或 "session"(空 → "charge")
    Amount           string `json:"amount"`          // base-units integer string
    Currency         string `json:"currency"`
    Decimals         uint32 `json:"decimals"`
    Description      string `json:"description,omitempty"`
    ExternalID       string `json:"externalId,omitempty"`     // charge only
    Realm            string `json:"realm,omitempty"`
    UnitType         string `json:"unitType,omitempty"`        // session only
    SuggestedDeposit string `json:"suggestedDeposit,omitempty"` // session only
}

// x402 per-route config(X402Adapter 内部 type-assert 到这个类型)。
type x402http.RouteConfig struct {
    Accepts           x402http.PaymentOptions `json:"accepts"`
    Resource          string                  `json:"resource,omitempty"`
    Description       string                  `json:"description,omitempty"`
    MimeType          string                  `json:"mimeType,omitempty"`
    CustomPaywallHTML string                  `json:"customPaywallHtml,omitempty"`
    AcceptedDomains   []string                `json:"acceptedDomains,omitempty"`
    // ... 见 x402 参考
}

MppRouteConfigDecimals / Realm 字段;x402 侧直接复用 x402 SDK 的 x402http.RouteConfig

Router 配置#

go
import (
    pr "github.com/okx/payments/go/paymentrouter"
    prhttp "github.com/okx/payments/go/paymentrouter/nethttp"
)

// RouteConfig 是 map[adapterName]config —— 没列的 adapter 在本 route 上不启用。
type pr.RouteConfig map[string]any

// 核心 config(用于底层 CompiledRouter / Detect / MergeChallenges)。
type pr.Config struct {
    Routes    []RouteEntry
    Protocols []ProtocolAdapter
    OnError   func(err error, phase, protocol string)
}
type pr.RouteEntry struct {
    Pattern string        // "GET /path" 或 "/path"
    Config  RouteConfig
}

const (
    pr.PhaseDetect    = "detect"
    pr.PhaseChallenge = "challenge"
    pr.PhaseHandle    = "handle"
)

// 启动期校验:route 引用了未注册的 adapter Name → panic(尽早暴露配置错误)。
func pr.ValidateRouteKeys(routes []RouteEntry, protocols []ProtocolAdapter)

// net/http drop-in gate:
type prhttp.PaymentGate struct { /* private */ }
func prhttp.New(protocols []pr.ProtocolAdapter, opts ...Option) *PaymentGate
func (g *PaymentGate) For(cfg pr.RouteConfig) func(http.Handler) http.Handler   // 单 route 的 middleware

type prhttp.Option func(*PaymentGate)
func prhttp.WithOnError(fn func(err error, phase, protocol string)) Option

prhttp.New(protocols, ...) 建一个 PaymentGate,再对每条 route 调 .For(cfg)(handler) 显式挂到 mux —— 路由匹配交给 net/httpServeMux,不在 router 内部维护 pattern 列表。错误回调签名是 func(err error, phase, protocol string)phasepr.PhaseDetect / PhaseChallenge / PhaseHandle

端到端拼装示例#

go
package main

import (
    "net/http"

    mppadapters "github.com/okx/payments/go/mpp/adapters"
    "github.com/okx/payments/go/mpp/server"
    pr "github.com/okx/payments/go/paymentrouter"
    prhttp "github.com/okx/payments/go/paymentrouter/nethttp"
    x402adapters "github.com/okx/payments/go/x402/adapters"
    x402http "github.com/okx/payments/go/x402/http"
)

func main() {
    // mpp *server.Mpp 与 x402Server *x402http.HTTPServer 按各自参考构造(略)。
    var mpp *server.Mpp
    var x402Server *x402http.HTTPServer

    gate := prhttp.New(
        []pr.ProtocolAdapter{
            mppadapters.NewMppAdapter(mpp),
            x402adapters.NewX402Adapter(x402Server),
        },
        prhttp.WithOnError(func(err error, phase, protocol string) {
            // 记录 detect / challenge / handle 各阶段的协议错误
        }),
    )

    routeCfg := pr.RouteConfig{
        "mpp": mppadapters.MppRouteConfig{
            Intent:   "charge",
            Amount:   "100",
            Currency: "0x...",
            Decimals: 6,
            Description: "photo",
        },
        "x402": x402http.RouteConfig{
            Description: "photo",
            MimeType:    "image/png",
            // Accepts: ... 见 x402 参考
        },
    }

    mux := http.NewServeMux()
    mux.Handle("GET /photo", gate.For(routeCfg)(photoHandler()))
    http.ListenAndServe(":8080", mux)
}

func photoHandler() http.Handler { /* 协议无关的业务 handler */ return nil }
目錄