mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
The owner redirected the design: payments go through Circle Gateway Nanopayments with Circle's own SDK, and Hanzo runs no relayer, no Base RPC watcher and no settlement of its own. This takes main back tof9c668c02, the Circle Gateway rail cloud pins at v1.50.181. v1.50.182, which carried the relayer, is pinned by nothing and is not to be. Revertsd041f32c3,99a20bb37and8b8fe3cd5. Testing: the tree is byte-identical tof9c668c02. Risk: none; restores what production runs. How to revert: revert this commit.
330 lines
10 KiB
Go
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"
|
|
}
|
|
}
|
|
}
|