mirror of
https://github.com/hanzoai/commerce.git
synced 2026-11-02 08:46:45 +00:00
Red's review of the rail, fixed: - A payer holds at most $1,000 of credit Gateway has only locked (remittance.Hold, a set swapped per payer); past that a payment is credited when Gateway confirms it. Confirmed releases the hold; only completed settles. - The credit is owed before the ledger is asked, landed after, and a reversal re-lands before it claws back, so a credited payment Gateway fails is always taken back, and a row fails only once its books are right. - A nonce Gateway holds for a different payment fails the row for good. - The reconciler takes accepted, then pending, then refused rows, due first, with a doubling backoff, so stuck rows cannot starve new ones; refused rows are looked up by nonce until they expire; an unsent row expires. - Gateway's network table is read single-flight with a 30 s failure cache, and the options menu reads only the warm table.
298 lines
9.6 KiB
Go
298 lines
9.6 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 {
|
|
if nets = readNetworks(kinds); 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
|
|
}
|