Files
zeekay b798da77d1 billing: take USDC top-ups through Circle Gateway
An agent or a person signs one EIP-3009 authorization against its Circle
Gateway balance, made out to the treasury, and is credited the moment
Gateway locks it. No human, no subscription, cards untouched.

  GET  /v1/billing/crypto/gateway      terms + a nonce issued to the caller
  POST /v1/billing/crypto/gateway      the signed payment
  GET  /v1/billing/crypto/gateway/:id  pending, accepted, settled, failed
  GET  /v1/commerce/treasury           superadmin: Gateway position

payment/providers/circle replaces the Payments API v1 provider with a
permissionless Gateway client and the GatewayWalletBatched EIP-712
encoding, proven against eth-account and Circle's testnet verifier.

billing/gateway checks a payment before Gateway sees it (treasury, live
network, whole cents in $1..$1,000, window, low-s signature from `from`,
nonce bound to the caller), settles it through /v1/x402/settle, credits
the host ledger keyed on the remittance, and reconciles by polling
/v1/x402/transfers: Gateway's webhooks report no batched payment.

Armed by CIRCLE_GATEWAY_TREASURY with CIRCLE_ENVIRONMENT; unset is inert.
2026-10-26 20:23:50 -07:00

331 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package circle
import (
"encoding/hex"
"errors"
"fmt"
"math/big"
"strings"
"github.com/luxfi/crypto"
)
// A batched Gateway payment is an EIP-3009 TransferWithAuthorization signed as
// EIP-712 typed data under Gateway's OWN domain — not the token's:
//
// name "GatewayWalletBatched"
// version "1" (USDC; "2" adds the token as salt and is not used here)
// chainId the network's EVM chain id (never Gateway's domain number)
// verifyingContract the GatewayWallet contract on that network
//
// Source: @circle-fin/x402-batching 4.0.0, BatchEvmScheme.signAuthorization
// (constants CIRCLE_BATCHING_NAME / CIRCLE_BATCHING_VERSION) and Circle's
// "Sign EIP-3009 payment authorizations" guide; the live values are served per
// network by GET /v1/x402/supported. The encoding is pinned in tests against a
// digest and signature eth-account produced independently.
// Name and Release are the domain name and version of a USDC authorization.
const (
Name = "GatewayWalletBatched"
Release = "1"
)
// Domain is an EIP-712 domain, in the shape viem and ethers sign with.
type Domain struct {
Name string `json:"name"`
Version string `json:"version"`
ChainID int64 `json:"chainId"`
VerifyingContract string `json:"verifyingContract"`
}
// Field is one member of an EIP-712 struct type.
type Field struct {
Name string `json:"name"`
Type string `json:"type"`
}
// types is the EIP-712 type table a payer signs with. The digest below is
// computed FROM it, so the types a payer is handed (Types) and the types a
// signature is verified against are one definition.
var types = map[string][]Field{
"EIP712Domain": {
{"name", "string"},
{"version", "string"},
{"chainId", "uint256"},
{"verifyingContract", "address"},
},
"TransferWithAuthorization": {
{"from", "address"},
{"to", "address"},
{"value", "uint256"},
{"validAfter", "uint256"},
{"validBefore", "uint256"},
{"nonce", "bytes32"},
},
}
// Types is a copy of the type table, to hand a payer. A copy, because the
// table is what every digest is computed from and a caller holding the
// original could change what this process verifies.
func Types() map[string][]Field {
out := make(map[string][]Field, len(types))
for k, v := range types {
out[k] = append([]Field(nil), v...)
}
return out
}
// Primary is the type a payer signs.
const Primary = "TransferWithAuthorization"
// Authorization is an EIP-3009 TransferWithAuthorization as it travels: every
// field a string, exactly as the payer signed it and as Gateway reads it.
type Authorization 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"`
}
// Parsed is an Authorization whose every field has been read strictly. A
// field that does not parse is an error, never a zero: an address that failed
// to decode and hashed as zero would verify against the zero address.
type Parsed struct {
From [20]byte
To [20]byte
Value *big.Int
ValidAfter uint64
ValidBefore uint64
Nonce [32]byte
}
// Parse reads a strictly: addresses are 0x + 40 hex, value is decimal digits
// below 2^256, the window is decimal unix seconds, and the nonce is exactly 32
// bytes (0x + 64 hex). Exactly 32, not "up to": a shorter nonce left-pads to
// the same bytes32, so "0x01" and "0x0001" would be two spellings of one
// signature and two keys for one payment.
func (a Authorization) Parse() (Parsed, error) {
var p Parsed
var err error
if p.From, err = Address(a.From); err != nil {
return p, fmt.Errorf("from: %w", err)
}
if p.To, err = Address(a.To); err != nil {
return p, fmt.Errorf("to: %w", err)
}
if p.Value, err = amount(a.Value); err != nil {
return p, fmt.Errorf("value: %w", err)
}
if p.ValidAfter, err = seconds(a.ValidAfter); err != nil {
return p, fmt.Errorf("validAfter: %w", err)
}
if p.ValidBefore, err = seconds(a.ValidBefore); err != nil {
return p, fmt.Errorf("validBefore: %w", err)
}
if p.Nonce, err = Nonce(a.Nonce); err != nil {
return p, fmt.Errorf("nonce: %w", err)
}
return p, nil
}
// Digest is the EIP-712 hash a payer signs for p under d.
func (d Domain) Digest(p Parsed) ([32]byte, error) {
contract, err := Address(d.VerifyingContract)
if err != nil {
return [32]byte{}, fmt.Errorf("verifyingContract: %w", err)
}
if d.ChainID <= 0 {
return [32]byte{}, fmt.Errorf("chainId %d", d.ChainID)
}
var dom []byte
dom = append(dom, typeHash("EIP712Domain")...)
dom = append(dom, crypto.Keccak256([]byte(d.Name))...)
dom = append(dom, crypto.Keccak256([]byte(d.Version))...)
dom = append(dom, word(new(big.Int).SetInt64(d.ChainID))...)
dom = append(dom, pad(contract[:])...)
var msg []byte
msg = append(msg, typeHash(Primary)...)
msg = append(msg, pad(p.From[:])...)
msg = append(msg, pad(p.To[:])...)
msg = append(msg, word(p.Value)...)
msg = append(msg, word(new(big.Int).SetUint64(p.ValidAfter))...)
msg = append(msg, word(new(big.Int).SetUint64(p.ValidBefore))...)
msg = append(msg, p.Nonce[:]...)
var out [32]byte
copy(out[:], crypto.Keccak256([]byte{0x19, 0x01}, crypto.Keccak256(dom), crypto.Keccak256(msg)))
return out, nil
}
// encoding is the EIP-712 encodeType of a struct with no struct members, read
// off Types: "TransferWithAuthorization(address from,address to,…)".
func encoding(name string) string {
parts := make([]string, 0, len(types[name]))
for _, f := range types[name] {
parts = append(parts, f.Type+" "+f.Name)
}
return name + "(" + strings.Join(parts, ",") + ")"
}
func typeHash(name string) []byte { return crypto.Keccak256([]byte(encoding(name))) }
// Recover returns the address that produced sig over digest.
//
// sig is 65 bytes r‖s‖v in hex, v in {0, 1, 27, 28}. A high-s signature is
// refused: (r, n−s) is the same signature in the other half of the curve, and
// the OpenZeppelin ECDSA library Gateway's contracts use refuses it, so
// accepting it here would accept a payment Gateway cannot settle.
func Recover(digest [32]byte, sig string) ([20]byte, error) {
var out [20]byte
b, err := hexBytes(sig)
if err != nil {
return out, err
}
if len(b) != 65 {
return out, fmt.Errorf("signature is %d bytes, want 65", len(b))
}
v := b[64]
if v >= 27 {
v -= 27
}
r, s := new(big.Int).SetBytes(b[:32]), new(big.Int).SetBytes(b[32:64])
if !crypto.ValidateSignatureValues(v, r, s, true) {
return out, errors.New("signature values out of range (r, s, v, or high s)")
}
norm := make([]byte, 65)
copy(norm, b)
norm[64] = v
pub, err := crypto.SigToPub(digest[:], norm)
if err != nil {
return out, fmt.Errorf("recover: %w", err)
}
copy(out[:], crypto.PubkeyToAddress(*pub).Bytes())
return out, nil
}
// Canonical is sig's one spelling: lowercase 0x hex, v as 27 or 28 — the
// form OpenZeppelin's ECDSA.recover, which Gateway's contracts use, accepts.
// It assumes Recover has already accepted sig.
func Canonical(sig string) (string, error) {
b, err := hexBytes(sig)
if err != nil {
return "", err
}
if len(b) != 65 {
return "", fmt.Errorf("signature is %d bytes, want 65", len(b))
}
if b[64] < 27 {
b[64] += 27
}
return Hex(b), nil
}
// Address reads a 0x-prefixed 20-byte hex address, any case. A checksum is
// not required, so it is not checked: EIP-55 protects a human typing an
// address, and none is typed here.
func Address(s string) ([20]byte, error) {
var out [20]byte
b, err := hexBytes(s)
if err != nil {
return out, err
}
if len(b) != 20 {
return out, fmt.Errorf("address %q is %d bytes, want 20", s, len(b))
}
copy(out[:], b)
return out, nil
}
// Nonce reads a 0x-prefixed 32-byte hex nonce, exactly 64 hex digits.
func Nonce(s string) ([32]byte, error) {
var out [32]byte
b, err := hexBytes(s)
if err != nil {
return out, err
}
if len(b) != 32 {
return out, fmt.Errorf("nonce is %d bytes, want exactly 32", len(b))
}
copy(out[:], b)
return out, nil
}
// Hex renders bytes as lowercase 0x hex: the ONE spelling stored and compared.
func Hex(b []byte) string { return "0x" + hex.EncodeToString(b) }
// hexBytes decodes 0x-prefixed hex. The prefix is required: a bare hex string
// is ambiguous with a decimal one in every field that carries both.
func hexBytes(s string) ([]byte, error) {
if len(s) < 2 || s[0] != '0' || (s[1] != 'x' && s[1] != 'X') {
return nil, fmt.Errorf("%q is not 0x-prefixed hex", s)
}
b, err := hex.DecodeString(s[2:])
if err != nil {
return nil, fmt.Errorf("%q is not hex: %w", s, err)
}
return b, nil
}
// maxWord is 2^256 − 1, the largest uint256.
var maxWord = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1))
// amount reads a uint256 in decimal: digits only, no sign, no leading zero.
func amount(s string) (*big.Int, error) {
if !digits(s) || len(s) > 78 {
return nil, fmt.Errorf("%q is not a decimal uint256", s)
}
v, ok := new(big.Int).SetString(s, 10)
if !ok || v.Cmp(maxWord) > 0 {
return nil, fmt.Errorf("%q is not a uint256", s)
}
return v, nil
}
// seconds reads unix seconds in decimal. A uint256 past 2^64 is refused rather
// than read: no window this rail accepts reaches it.
func seconds(s string) (uint64, error) {
if !digits(s) || len(s) > 20 {
return 0, fmt.Errorf("%q is not decimal unix seconds", s)
}
v, ok := new(big.Int).SetString(s, 10)
if !ok || !v.IsUint64() {
return 0, fmt.Errorf("%q is out of range", s)
}
return v.Uint64(), nil
}
// digits reports a canonical decimal: [0-9]+ with no leading zero but "0"
// itself, so one number has one spelling.
func digits(s string) bool {
if s == "" || (len(s) > 1 && s[0] == '0') {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// pad left-pads b to a 32-byte word.
func pad(b []byte) []byte {
out := make([]byte, 32)
copy(out[32-len(b):], b)
return out
}
// word is v as a 32-byte big-endian word. v is never negative and never past
// 2^256 here: amount and seconds refused anything else.
func word(v *big.Int) []byte {
out := make([]byte, 32)
v.FillBytes(out)
return out
}