mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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.
207 lines
7.0 KiB
Go
207 lines
7.0 KiB
Go
// Copyright (c) 2014-present Hanzo AI, Inc.
|
|
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
|
|
|
|
package circle
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
)
|
|
|
|
// The unified-balance half of Gateway (gateway.yaml): what a depositor holds,
|
|
// what is still arriving, and what a cross-chain transfer out of it costs and
|
|
// became. The commerce rail reads the first two for the treasury view; the
|
|
// last two are the outbound path's reads (LLM.md, "Circle Gateway").
|
|
|
|
// USDC is the only token the commerce rail credits: it is pegged 1:1 to the
|
|
// dollar the ledger counts in, so no price is involved. EURC and cirBTC are
|
|
// Gateway tokens too and would need one.
|
|
const USDC = "USDC"
|
|
|
|
// Info is GET /v1/info: the domains Gateway serves and their contracts.
|
|
type Info struct {
|
|
Version int `json:"version"`
|
|
Domains []Chain `json:"domains"`
|
|
}
|
|
|
|
// Chain is one Gateway domain. Domain is Circle's identifier (0 Ethereum,
|
|
// 6 Base, …), not an EVM chain id.
|
|
type Chain struct {
|
|
Chain string `json:"chain"`
|
|
Network string `json:"network"`
|
|
Domain uint32 `json:"domain"`
|
|
Wallet *Contract `json:"walletContract,omitempty"`
|
|
Minter *Contract `json:"minterContract,omitempty"`
|
|
ProcessedHeight string `json:"processedHeight"`
|
|
ExpirationHeight string `json:"burnIntentExpirationHeight"`
|
|
}
|
|
|
|
// Contract is a Gateway contract on one domain and the tokens it handles.
|
|
type Contract struct {
|
|
Address string `json:"address"`
|
|
Tokens []string `json:"supportedTokens"`
|
|
}
|
|
|
|
// Info reads the domains and contracts Gateway serves.
|
|
func (c *Client) Info(ctx context.Context) (Info, error) {
|
|
var out Info
|
|
err := c.do(ctx, http.MethodGet, "/v1/info", nil, nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// source names a depositor; with no domain Gateway answers for every domain
|
|
// the address is valid on.
|
|
type source struct {
|
|
Depositor string `json:"depositor"`
|
|
}
|
|
|
|
type sources struct {
|
|
Token string `json:"token"`
|
|
Sources []source `json:"sources"`
|
|
}
|
|
|
|
// Balance is a depositor's AVAILABLE balance on one domain, as Gateway writes
|
|
// it: decimal token units ("12.500000"), not base units.
|
|
type Balance struct {
|
|
Domain uint32 `json:"domain"`
|
|
Depositor string `json:"depositor"`
|
|
Balance string `json:"balance"`
|
|
}
|
|
|
|
// Balances reads depositor's available balance of token on every domain.
|
|
func (c *Client) Balances(ctx context.Context, token, depositor string) ([]Balance, error) {
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
Balances []Balance `json:"balances"`
|
|
}
|
|
err := c.do(ctx, http.MethodPost, "/v1/balances", nil, sources{Token: token, Sources: []source{{Depositor: depositor}}}, &out)
|
|
return out.Balances, err
|
|
}
|
|
|
|
// Deposit is an onchain deposit into a Gateway Wallet that Gateway has seen
|
|
// and not yet finalized. Amount is in BASE units.
|
|
type Deposit struct {
|
|
Depositor string `json:"depositor"`
|
|
Domain uint32 `json:"domain"`
|
|
TransactionHash string `json:"transactionHash"`
|
|
Amount string `json:"amount"`
|
|
Status string `json:"status"`
|
|
BlockHeight string `json:"blockHeight"`
|
|
BlockHash string `json:"blockHash"`
|
|
BlockTimestamp string `json:"blockTimestamp"`
|
|
}
|
|
|
|
// Deposits reads depositor's pending deposits of token on every domain.
|
|
func (c *Client) Deposits(ctx context.Context, token, depositor string) ([]Deposit, error) {
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
Deposits []Deposit `json:"deposits"`
|
|
}
|
|
err := c.do(ctx, http.MethodPost, "/v1/deposits", nil, sources{Token: token, Sources: []source{{Depositor: depositor}}}, &out)
|
|
return out.Deposits, err
|
|
}
|
|
|
|
// Spec is a TransferSpec: one burn on a source domain and the mint it pays for
|
|
// on a destination domain. Addresses are bytes32 hex when submitted; Gateway
|
|
// answers them in canonical form.
|
|
type Spec struct {
|
|
Version uint32 `json:"version"`
|
|
SourceDomain uint32 `json:"sourceDomain"`
|
|
DestinationDomain uint32 `json:"destinationDomain"`
|
|
SourceContract string `json:"sourceContract"`
|
|
DestinationContract string `json:"destinationContract"`
|
|
SourceToken string `json:"sourceToken"`
|
|
DestinationToken string `json:"destinationToken"`
|
|
SourceDepositor string `json:"sourceDepositor"`
|
|
DestinationRecipient string `json:"destinationRecipient"`
|
|
SourceSigner string `json:"sourceSigner"`
|
|
DestinationCaller string `json:"destinationCaller"`
|
|
Value string `json:"value"`
|
|
Salt string `json:"salt"`
|
|
HookData string `json:"hookData,omitempty"`
|
|
}
|
|
|
|
// Intent is a burn intent. MaxBlockHeight and MaxFee may be empty on an
|
|
// estimate; Gateway fills them.
|
|
type Intent struct {
|
|
MaxBlockHeight string `json:"maxBlockHeight,omitempty"`
|
|
MaxFee string `json:"maxFee,omitempty"`
|
|
Spec Spec `json:"spec"`
|
|
}
|
|
|
|
// Fee is one intent's share of a transfer's fees, in decimal token units.
|
|
type Fee struct {
|
|
TransferSpecHash string `json:"transferSpecHash"`
|
|
Domain uint32 `json:"domain"`
|
|
BaseFee string `json:"baseFee"`
|
|
TransferFee string `json:"transferFee,omitempty"`
|
|
}
|
|
|
|
// Fees is what a transfer costs, in decimal token units.
|
|
type Fees struct {
|
|
Total string `json:"total"`
|
|
Token string `json:"token"`
|
|
PerIntent []Fee `json:"perIntent"`
|
|
ForwardingFee string `json:"forwardingFee,omitempty"`
|
|
}
|
|
|
|
// Estimate is POST /v1/estimate's answer: the intents with fees and expiry
|
|
// heights filled in.
|
|
type Estimate struct {
|
|
Body []struct {
|
|
BurnIntent Intent `json:"burnIntent"`
|
|
} `json:"body"`
|
|
Fees Fees `json:"fees"`
|
|
}
|
|
|
|
// Estimate prices burn intents without a signature and without moving money.
|
|
func (c *Client) Estimate(ctx context.Context, intents []Intent) (Estimate, error) {
|
|
var out Estimate
|
|
err := c.do(ctx, http.MethodPost, "/v1/estimate", nil, intents, &out)
|
|
return out, err
|
|
}
|
|
|
|
// Transfer is GET /v1/transfer/{id}: a cross-chain transfer created from
|
|
// signed burn intents, and how far its destination mint has got.
|
|
type Transfer struct {
|
|
DestinationDomain uint32 `json:"destinationDomain"`
|
|
Status string `json:"status"` // pending | confirmed | finalized | failed | expired
|
|
TransactionHash string `json:"transactionHash,omitempty"`
|
|
Fees Fees `json:"fees"`
|
|
}
|
|
|
|
// Transfer reads one cross-chain transfer by its id.
|
|
func (c *Client) Transfer(ctx context.Context, id string) (Transfer, error) {
|
|
var out Transfer
|
|
if !uuid(id) {
|
|
return out, fmt.Errorf("circle gateway: transfer id %q is not a UUID", id)
|
|
}
|
|
err := c.do(ctx, http.MethodGet, "/v1/transfer/"+url.PathEscape(id), nil, nil, &out)
|
|
return out, err
|
|
}
|
|
|
|
// uuid reports whether s is a canonical 8-4-4-4-12 hex UUID. Ids are checked
|
|
// before they enter a path so a caller-supplied string can never steer a
|
|
// request to another route.
|
|
func uuid(s string) bool {
|
|
if len(s) != 36 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
switch i {
|
|
case 8, 13, 18, 23:
|
|
if c != '-' {
|
|
return false
|
|
}
|
|
default:
|
|
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|