Files
commerce/payment/x402/facilitator.go
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

330 lines
10 KiB
Go

package x402
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
"github.com/hanzoai/commerce/models/types/currency"
"github.com/hanzoai/commerce/payment/processor"
)
// Facilitator handles payment verification and settlement for x402 transactions.
// It accepts a signed ERC-3009 transferWithAuthorization, verifies the payer's
// signature, checks on-chain allowance/balance, and executes the transfer.
type Facilitator struct {
// registry provides access to payment processors (MPC, etc.)
registry *processor.Registry
// httpClient for calling external facilitator service (if remote)
httpClient *http.Client
// remoteURL is the URL of a remote facilitator service (optional).
// If empty, the facilitator handles settlement locally via the processor registry.
remoteURL string
// tokenName is the ERC-20 token name for EIP-712 domain (e.g., "USD Coin").
tokenName string
// tokenVersion is the ERC-20 token version for EIP-712 domain (e.g., "2").
tokenVersion string
}
// FacilitatorConfig holds configuration for creating a Facilitator.
type FacilitatorConfig struct {
// Registry is the processor registry for executing payments.
Registry *processor.Registry
// RemoteURL is the URL of a remote facilitator service.
// If set, settlement is delegated to this service.
RemoteURL string
// TokenName is the ERC-20 token name (default: "USD Coin").
TokenName string
// TokenVersion is the ERC-20 token version (default: "2").
TokenVersion string
}
// NewFacilitator creates a new Facilitator.
func NewFacilitator(cfg FacilitatorConfig) *Facilitator {
tokenName := cfg.TokenName
if tokenName == "" {
tokenName = "USD Coin"
}
tokenVersion := cfg.TokenVersion
if tokenVersion == "" {
tokenVersion = "2"
}
return &Facilitator{
registry: cfg.Registry,
httpClient: &http.Client{Timeout: 30 * time.Second},
remoteURL: strings.TrimRight(cfg.RemoteURL, "/"),
tokenName: tokenName,
tokenVersion: tokenVersion,
}
}
// Settle verifies and executes a payment authorization.
// It validates the ERC-3009 signature, checks that the authorization matches
// the payment request, and either settles locally via MPC or delegates to
// a remote facilitator service.
func (f *Facilitator) Settle(ctx context.Context, req *PaymentRequest, auth *PaymentAuthorization) (*PaymentReceipt, error) {
// Validate the authorization matches the request.
if auth.Value != req.Amount {
return nil, fmt.Errorf("x402: payment amount mismatch: auth=%s, request=%s", auth.Value, req.Amount)
}
// Verify time bounds.
now := time.Now().Unix()
if now > auth.ValidBefore {
return nil, fmt.Errorf("x402: authorization expired")
}
if now < auth.ValidAfter {
return nil, fmt.Errorf("x402: authorization not yet valid")
}
// If a remote facilitator is configured, delegate to it.
if f.remoteURL != "" {
return f.settleRemote(ctx, req, auth)
}
// Local settlement: verify signature and execute via processor registry.
return f.settleLocal(ctx, req, auth)
}
// settleLocal handles settlement using the local processor registry.
func (f *Facilitator) settleLocal(ctx context.Context, req *PaymentRequest, auth *PaymentAuthorization) (*PaymentReceipt, error) {
// Verify the ERC-3009 signature.
if err := f.verifyAuthorization(req, auth); err != nil {
return nil, fmt.Errorf("x402: signature verification failed: %w", err)
}
// Execute the payment via the MPC crypto processor.
if f.registry == nil {
return nil, fmt.Errorf("x402: no processor registry configured")
}
cryptoProc, err := f.registry.GetCrypto(processor.MPC)
if err != nil {
return nil, fmt.Errorf("x402: MPC processor not available: %w", err)
}
// auth.Value is already in USDC's own minor units, and USDC is a 6-decimal
// currency in the table, so it IS the minor-unit amount — no rescale.
// This used to divide by 10,000 to force the value into two-decimal cents,
// which threw away every fraction of a cent and, now that the table carries
// USDC's real scale, would under-report the charge by 1e4.
amount, ok := new(big.Int).SetString(auth.Value, 10)
if !ok {
return nil, fmt.Errorf("x402: invalid payment amount: %s", auth.Value)
}
if !amount.IsInt64() {
return nil, fmt.Errorf("x402: payment amount out of range: %s", auth.Value)
}
// Create a charge request through the MPC processor.
result, err := cryptoProc.Charge(ctx, processor.PaymentRequest{
Amount: currency.Cents(amount.Int64()),
Currency: currency.USDC,
CustomerID: auth.From,
Address: req.Payee,
Chain: chainFromNetwork(req.Network, req.ChainID),
Metadata: map[string]interface{}{
"x402": true,
"nonce": auth.Nonce,
"resource": req.Resource,
"signature": auth.Signature,
},
})
if err != nil {
return nil, fmt.Errorf("x402: payment execution failed: %w", err)
}
txHash := ""
if result.Metadata != nil {
if h, ok := result.Metadata["txHash"].(string); ok {
txHash = h
}
}
// Compute the request hash for the receipt.
requestHash := computeRequestHash(req)
return &PaymentReceipt{
RequestHash: requestHash,
Payer: auth.From,
Payee: req.Payee,
Amount: req.Amount,
TxHash: txHash,
Timestamp: time.Now().Unix(),
Success: result.Success,
}, nil
}
// settleRemote delegates settlement to a remote facilitator service.
func (f *Facilitator) settleRemote(ctx context.Context, req *PaymentRequest, auth *PaymentAuthorization) (*PaymentReceipt, error) {
payload := struct {
Request *PaymentRequest `json:"request"`
Authorization *PaymentAuthorization `json:"authorization"`
}{
Request: req,
Authorization: auth,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("x402: marshal settlement request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, f.remoteURL+"/api/v1/settle", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("x402: build settlement request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := f.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("x402: settlement request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("x402: read settlement response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("x402: facilitator returned %d: %s", resp.StatusCode, string(respBody))
}
var receipt PaymentReceipt
if err := json.Unmarshal(respBody, &receipt); err != nil {
return nil, fmt.Errorf("x402: decode settlement response: %w", err)
}
return &receipt, nil
}
// verifyAuthorization verifies the ERC-3009 transferWithAuthorization signature.
func (f *Facilitator) verifyAuthorization(req *PaymentRequest, auth *PaymentAuthorization) error {
// Strip 0x prefix from signature.
sigHex := auth.Signature
if strings.HasPrefix(sigHex, "0x") || strings.HasPrefix(sigHex, "0X") {
sigHex = sigHex[2:]
}
sigBytes, err := hex.DecodeString(sigHex)
if err != nil {
return fmt.Errorf("invalid signature hex: %w", err)
}
// Compute the EIP-712 digest for transferWithAuthorization.
digest, err := f.computeEIP712Digest(req, auth)
if err != nil {
return fmt.Errorf("compute EIP-712 digest: %w", err)
}
// Verify that the recovered signer matches auth.From.
return VerifySignature(digest, sigBytes, auth.From)
}
// computeEIP712Digest computes the EIP-712 typed data hash for an ERC-3009
// transferWithAuthorization.
func (f *Facilitator) computeEIP712Digest(req *PaymentRequest, auth *PaymentAuthorization) ([32]byte, error) {
// Domain separator
domainSep := EIP712DomainSeparator(f.tokenName, f.tokenVersion, req.ChainID, req.Token)
// Struct hash: TransferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce)
typeHash := Keccak256([]byte(
"TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)",
))
value, ok := new(big.Int).SetString(auth.Value, 10)
if !ok {
return [32]byte{}, fmt.Errorf("invalid value: %s", auth.Value)
}
nonceHex := auth.Nonce
if strings.HasPrefix(nonceHex, "0x") || strings.HasPrefix(nonceHex, "0X") {
nonceHex = nonceHex[2:]
}
nonceBytes, err := hex.DecodeString(nonceHex)
if err != nil {
return [32]byte{}, fmt.Errorf("invalid nonce hex: %w", err)
}
var nonce [32]byte
copy(nonce[32-len(nonceBytes):], nonceBytes)
// ABI-encode the struct fields
encoded := make([]byte, 0, 224) // 7 * 32 bytes
encoded = append(encoded, typeHash[:]...)
encoded = append(encoded, padLeft(addressToBytes(auth.From), 32)...)
encoded = append(encoded, padLeft(addressToBytes(auth.To), 32)...)
encoded = append(encoded, padLeft(value.Bytes(), 32)...)
encoded = append(encoded, padLeft(big.NewInt(auth.ValidAfter).Bytes(), 32)...)
encoded = append(encoded, padLeft(big.NewInt(auth.ValidBefore).Bytes(), 32)...)
encoded = append(encoded, nonce[:]...)
structHash := Keccak256(encoded)
// Final EIP-712 digest: keccak256("\x19\x01" || domainSeparator || structHash)
digestInput := make([]byte, 0, 66)
digestInput = append(digestInput, 0x19, 0x01)
digestInput = append(digestInput, domainSep[:]...)
digestInput = append(digestInput, structHash[:]...)
return Keccak256(digestInput), nil
}
// computeRequestHash computes a hash of the payment request for receipt tracking.
func computeRequestHash(req *PaymentRequest) string {
data, _ := json.Marshal(req)
hash := Keccak256(data)
return fmt.Sprintf("0x%x", hash[:])
}
// chainFromNetwork maps a network name and chain ID to a chain string for the processor.
func chainFromNetwork(network string, chainID int64) string {
switch network {
case "lux":
return "lux"
case "ethereum":
return "ethereum"
case "polygon":
return "polygon"
case "arbitrum":
return "arbitrum"
case "optimism":
return "optimism"
case "base":
return "base"
default:
// Fall back to chain ID mapping.
switch chainID {
case 1:
return "ethereum"
case 137:
return "polygon"
case 42161:
return "arbitrum"
case 10:
return "optimism"
case 8453:
return "base"
case 96369:
return "lux"
default:
return "ethereum"
}
}
}