Files
zeekay b2bae41e21 gateway: a settled payment is credited after an outage, and production takes only Circle's own USDC
Four gaps between "Circle says completed" and "the customer's balance moved".

A remittance Circle reports as completed is marked settled and then
credited. If the ledger is down at that moment the row is settled and
not landed, and the reconciler never looked at settled rows again — the
payment was final at Circle and credited nowhere, forever. Settled rows
that have not landed are now swept, and later() reschedules them, so the
only write left (the idempotent ledger entry) is retried until it
succeeds. Pinned by TestCompletedPaymentRetriesCreditAfterLedgerOutage.

Production offers only the USDC contracts Circle itself issues, keyed by
CAIP-2 and compared against what /v1/x402/supported reports. A testnet
pair, a same-symbol substitute or a network nobody reviewed is dropped
rather than offered, so a signature can never be for something that is
not Circle USDC. Pinned by TestProductionRefusesTestnetAndSubstituteUSDC.

Circle renders base-unit amounts with leading zeroes, and a string
compare of "00010000" against "10000" failed to match a payment the
payer had already made — the remittance stayed open and was never
settled. Amounts now compare as unsigned integers; signs, decimals and
exponents do not parse. Pinned by TestAmountsCompareAsUnsignedBaseUnits.

An unconfigured Circle Gateway no longer stops commerce from starting:
the error is logged and the rail stays closed, while card, wire and the
custody deposit rail keep serving. Billing going down because one
optional processor has no credential is a bigger outage than the rail
being unavailable.

The rig signs against Base mainnet now, matching production.
2026-10-29 14:49:50 -07:00

326 lines
11 KiB
Go

// Copyright (c) 2014-present Hanzo AI, Inc.
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
package gateway
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"sort"
"strconv"
"strings"
"time"
"github.com/luxfi/crypto"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/payment/providers/circle"
)
// network is one chain the rail accepts USDC on, as Gateway states it live.
type network struct {
id string // CAIP-2, "eip155:8453"
asset string // USDC contract, lowercase 0x hex
decimals int // always 6 (readNetworks refuses anything else)
min time.Duration
domain circle.Domain
}
// readNetworks keeps the Gateway networks a USDC payment to the treasury can
// be signed and verified on, from GET /v1/x402/supported.
//
// A network is kept only when every fact the rail relies on is present and is
// the one this package signs for: the exact scheme, an eip155 chain id, the
// GatewayWalletBatched domain at version 1, a readable GatewayWallet contract,
// and USDC with 6 decimals that accepts a version-1 authorization. A network
// Gateway describes any other way is not offered: signing for a domain this
// package does not implement would take payments it cannot verify.
//
// It is a pure function of Gateway's answer so the rule is testable without
// a network.
func readNetworks(kinds []circle.Kind) []network {
var out []network
for _, k := range kinds {
if k.Scheme != circle.Scheme || k.Extra.Name != circle.Name || k.Extra.Version != circle.Release {
continue
}
chain, ok := chainID(k.Network)
if !ok {
continue
}
contract, err := circle.Address(k.Extra.VerifyingContract)
if err != nil {
continue
}
min := time.Duration(k.Extra.MinValidity) * time.Second
if min <= 0 {
min = fallbackValidity
}
for _, a := range k.Extra.Assets {
if a.Symbol != circle.USDC || a.Decimals != 6 || !has(a.Versions, circle.Release) {
continue
}
addr, err := circle.Address(a.Address)
if err != nil {
continue
}
out = append(out, network{
id: k.Network,
asset: circle.Hex(addr[:]),
decimals: a.Decimals,
min: min,
domain: circle.Domain{
Name: circle.Name, Version: circle.Release, ChainID: chain,
VerifyingContract: circle.Hex(contract[:]),
},
})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].domain.ChainID < out[j].domain.ChainID })
return out
}
// chainID reads "eip155:<id>" with a positive decimal id and nothing else.
func chainID(caip string) (int64, bool) {
ref, ok := strings.CutPrefix(caip, "eip155:")
if !ok || ref == "" || ref[0] == '0' {
return 0, false
}
for i := 0; i < len(ref); i++ {
if ref[i] < '0' || ref[i] > '9' {
return 0, false
}
}
id, err := strconv.ParseInt(ref, 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
func has(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// networks is Gateway's network table, read live and kept for termsAge.
//
// ONE read is in flight at a time, whoever asks: the rest wait for its answer
// rather than each asking Gateway, and a failed read is remembered for
// failAge, so a slow or failing Gateway costs one call per window rather than
// one per request. A failure is an error, never an empty table that looks
// like "no networks".
func (s *Service) networks(ctx context.Context) ([]network, error) {
for {
s.mu.Lock()
now := s.now()
switch {
case s.nets != nil && now.Sub(s.read) < termsAge:
nets := s.nets
s.mu.Unlock()
return nets, nil
case s.ferr != nil && now.Sub(s.failed) < failAge:
err := s.ferr
s.mu.Unlock()
return nil, err
case s.flight != nil:
ch := s.flight
s.mu.Unlock()
select {
case <-ch:
continue // the read in flight answered: look again
case <-ctx.Done():
return nil, shut(ReasonUnavailable, "Circle Gateway is not answering; try again shortly or use a card, bank transfer or wire.", ctx.Err())
}
}
ch := make(chan struct{})
s.flight = ch
s.mu.Unlock()
// Read on a context of its own: the request that happened to start it
// may be cancelled, and every waiter depends on its answer. The
// client's own timeout bounds it.
kinds, err := s.client.Supported(context.WithoutCancel(ctx))
var nets []network
if err == nil {
nets = readNetworks(kinds)
if s.cfg.Network == circle.Mainnet {
allowed := nets[:0]
for _, n := range nets {
if mainnetUSDC[n.id] == n.asset {
allowed = append(allowed, n)
}
}
nets = allowed
}
if len(nets) == 0 {
err = fmt.Errorf("x402/supported: %d kinds, none usable", len(kinds))
}
}
s.mu.Lock()
s.flight = nil
if err != nil {
s.ferr, s.failed = shut(ReasonUnavailable, "Circle Gateway is not answering, or lists no network this rail can accept USDC on; try again shortly or use a card, bank transfer or wire.", err), s.now()
} else {
s.nets, s.read, s.ferr = nets, s.now(), nil
}
close(ch)
s.mu.Unlock()
}
}
func lookup(nets []network, id string) (network, bool) {
for _, n := range nets {
if n.id == id {
return n, true
}
}
return network{}, false
}
// Networks lists the CAIP-2 networks the rail accepts USDC on, for the crypto
// options menu, from the table already in hand and never from Gateway: the
// menu also serves the deposit-address rail, and a slow Gateway must not slow
// that. The reconciler keeps the table warm. Nil when the rail is shut or no
// table has been read in the last hour.
func (s *Service) Networks() []string {
if !s.Armed() {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.nets == nil || s.now().Sub(s.read) > time.Hour {
return nil
}
out := make([]string, 0, len(s.nets))
for _, n := range s.nets {
out = append(out, n.id)
}
return out
}
// Terms is everything a payer needs to sign a top-up, x402-style: where to
// pay, the networks and token, the EIP-712 domain per network, the types, the
// limits, and the nonce to sign.
//
// The payer signs, for one network of Accepts:
//
// domain Accepts[i].Domain
// types Types (EIP712Domain + TransferWithAuthorization)
// primaryType TransferWithAuthorization
// message {from: <payer>, to: PayTo, value: <base units>,
// validAfter: <unix s, not after now>, validBefore: <unix s,
// between now+MinValiditySeconds and now+MaxValiditySeconds>,
// nonce: Nonce}
//
// and POSTs {network, salt: Salt, authorization: message, signature}.
type Terms struct {
Version int `json:"x402Version"`
Scheme string `json:"scheme"`
PayTo string `json:"payTo"`
Nonce string `json:"nonce"`
Salt string `json:"salt"`
MinCents int64 `json:"minCents"`
MaxCents int64 `json:"maxCents"`
MaxValidity int64 `json:"maxValiditySeconds"`
Primary string `json:"primaryType"`
Types map[string][]circle.Field `json:"types"`
Accepts []Accept `json:"accepts"`
}
// Accept is one network the payment may be signed on.
type Accept struct {
Network string `json:"network"`
Asset string `json:"asset"`
Symbol string `json:"symbol"`
Decimals int `json:"decimals"`
MinAmount string `json:"minAmount"` // base units
MaxAmount string `json:"maxAmount"` // base units
MinValidity int64 `json:"minValiditySeconds"`
Domain circle.Domain `json:"domain"`
}
// Terms answers the payment terms for this caller, with a nonce issued to
// them. Every call issues a fresh nonce; any of them may be signed.
func (s *Service) Terms(ctx context.Context, org *organization.Organization, payer string) (Terms, error) {
if err := s.caller(org, payer); err != nil {
return Terms{}, err
}
nets, err := s.networks(ctx)
if err != nil {
return Terms{}, err
}
var salt [32]byte
if _, err := rand.Read(salt[:]); err != nil {
return Terms{}, fmt.Errorf("gateway: salt: %w", err)
}
nonce := bind(org.Name, payer, salt)
t := Terms{
Version: circle.Version, Scheme: circle.Scheme, PayTo: s.cfg.Treasury,
Nonce: circle.Hex(nonce[:]), Salt: circle.Hex(salt[:]),
MinCents: MinCents, MaxCents: MaxCents, MaxValidity: int64(MaxValidity / time.Second),
Primary: circle.Primary, Types: circle.Types(),
}
for _, n := range nets {
unit := centUnit(n.decimals)
t.Accepts = append(t.Accepts, Accept{
Network: n.id, Asset: n.asset, Symbol: circle.USDC, Decimals: n.decimals,
MinAmount: new(big.Int).Mul(big.NewInt(MinCents), unit).String(),
MaxAmount: new(big.Int).Mul(big.NewInt(MaxCents), unit).String(),
MinValidity: int64(n.min / time.Second),
Domain: n.domain,
})
}
return t, nil
}
// centUnit is how many base units make one cent: 10^(decimals-2). USDC has 6
// decimals, so 10,000.
func centUnit(decimals int) *big.Int {
return new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals-2)), nil)
}
// bind is the nonce issued to one caller:
//
// keccak256("hanzo/gateway/remittance" ‖ 0x00 ‖ org ‖ 0x00 ‖ payer ‖ 0x00 ‖ salt)
//
// The payer signs it, so the signature commits to WHO is credited as well as
// to what is paid. A payment presented by a caller it was not issued to fails
// the check in verify, because no salt makes another caller's preimage hash to
// it. It needs no stored state and no secret: the salt travels with the
// payment, and keccak's preimage resistance is the whole argument. Org names
// and billing subjects carry no NUL byte, so the separators are unambiguous.
func bind(org, payer string, salt [32]byte) [32]byte {
var out [32]byte
copy(out[:], crypto.Keccak256(
[]byte("hanzo/gateway/remittance\x00"), []byte(org), []byte{0},
[]byte(payer), []byte{0}, salt[:],
))
return out
}
// Circle-issued native USDC only, never a testnet token or a same-symbol
// substitute. Unknown networks fail closed until reviewed against Circle's
// canonical contract list: https://developers.circle.com/stablecoins/usdc-contract-addresses
var mainnetUSDC = map[string]string{
"eip155:1": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"eip155:8453": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
"eip155:43114": "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e",
"eip155:42161": "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
"eip155:10": "0x0b2c639c533813f4aa9d7837caf62653d097ff85",
"eip155:137": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359",
"eip155:130": "0x078d782b760474a361dda0af3839290b0ef57ad6",
"eip155:146": "0x29219dd400f2bf60e5a23d13be72b486d4038894",
"eip155:480": "0x79a02482a880bce3f13e09da970dc34db4cd24d1",
"eip155:1329": "0xe15fc38f6d8c56af07bbcbe3baf5708a2bf42392",
"eip155:999": "0xb88339cb7199b77e23db6e890353e22632ba630f",
"eip155:5042": "0x3600000000000000000000000000000000000000",
}