Files
Zach Kelling 098f89edfc billing: retire the Circle Gateway top-up rail
The owner's call, 2026-10-30: USDC through Circle Gateway is Circle's
own SDK (@circle-fin/x402-batching) in Hanzo's x402 gateway in front of
cloud, a deposit into Gateway once and a payment per request. The Go
rail reimplemented that protocol, so it goes now rather than later:
billing/gateway, payment/providers/circle, models/remittance, the three
/v1/billing/crypto/gateway routes, the commerce treasury route, and the
gateway networks in /v1/billing/crypto/options. Crypto top-ups by
authorization pause until the gateway takes them; the deposit-address
rail is unchanged. Hashid kinds 292 and 293 stay taken.

Testing: go build ./... and vet clean; api/billing's crypto tests pass
(TestPlanAtInterval and TestSubscribeTrial_NoChargeCardVaultedTrialing
fail on main without this change too).
Risk: removes a live payment route; cloud must drop its plane ops in
the release that pins this.
How to revert: revert this commit.
2026-10-30 07:44:06 -07:00

800 lines
35 KiB
Go

package billing
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/zap-proto/zip"
"github.com/hanzoai/commerce/billing/depositledger"
"github.com/hanzoai/commerce/billing/depositwatch"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/middleware"
"github.com/hanzoai/commerce/models/cryptopaymentintent"
"github.com/hanzoai/commerce/models/organization"
"github.com/hanzoai/commerce/payment/processor"
jsonhttp "github.com/hanzoai/commerce/util/json/http"
)
// The native crypto top-up rail: a per-payer MPC custody deposit address.
//
// Address generation is the MPC signer's (processor.MPC — Hanzo KMS control
// plane + MPC signing backend); commerce holds NO key material and can only
// REQUEST an address, exactly like the HUSD treasury split. Crediting the
// balance is the chain watcher's job on real confirmations — nothing here
// mints, so a claimed deposit is worth nothing until the chain says otherwise.
//
// The supported chain/token set is served live from the processor
// (GET /v1/billing/crypto/options), so when the MPC service grows a chain
// (TON, XRP, …) it appears on every pay surface with no client change.
// cryptoDepositRequest is the wire body. There is deliberately NO subject
// field: the credited payer is the caller's own billing identity, so a body
// value cannot steer where a deposit lands.
type cryptoDepositRequest struct {
Chain string `json:"chain,omitempty"` // default "ethereum"
Token string `json:"token,omitempty"` // default "usdc"
AmountCents int64 `json:"amountCents,omitempty"`
}
// CryptoDeposit is a payer's destination and what has become of it — where to
// send, what names them when it arrives, and how far the chain has got. Both
// endpoints hand back this same value, so a deposit reads identically over HTTP and
// over the internal plane.
type CryptoDeposit struct {
ID string `json:"id"`
Status string `json:"status"`
Chain string `json:"chain"`
Token string `json:"token"`
DepositAddress string `json:"depositAddress"`
// AddressTag is the routing tag the payer MUST send with the payment on a
// chain where the address is shared (XRPL's destination tag). Absent on
// every chain that mints one address per payer.
//
// omitempty is safe and load-bearing here: the tag is rendered decimal, so
// the first tag ever issued is the string "0", which is not empty and is
// therefore sent. A payment to a pooled address with no tag names nobody
// and is credited to nobody, so dropping "0" on the wire would strand the
// deposit of whoever holds it.
AddressTag string `json:"addressTag,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
// AmountCents is what the payer said they would send, in USD cents, when the
// address was asked for. It is a record of intent, not a quote: what is
// credited is what arrives. Absent when nothing was named.
AmountCents int64 `json:"amountCents,omitempty"`
// MinimumCents is the smallest deposit, in USD cents, that credits anything.
// Less credits nothing: under a cent is dust, and a transfer that does not
// exceed FeeCents does not cover moving it. Absent when nothing is watching
// this asset now, and then nothing sent to it is credited.
MinimumCents int64 `json:"minimumCents,omitempty"`
// FeeCents is deducted from every deposit before it is credited: what it costs
// to sweep the deposit out of the custody address. Absent deducts nothing.
FeeCents int64 `json:"feeCents,omitempty"`
// RequiredConfirmations is how deep the chain must bury a deposit before it is
// credited.
RequiredConfirmations int `json:"requiredConfirmations"`
// Confirmations is how deep the deposit being confirmed is now; zero until one
// is seen.
Confirmations int `json:"confirmations"`
// CreditedCents is what the transfer this intent confirmed credited, in USD
// cents, once it succeeded. A later transfer to the same address is credited
// by its own row and is not summed here.
CreditedCents int64 `json:"creditedCents,omitempty"`
}
// CryptoOptions is the menu of what the crypto deposit rail accepts: the
// chains a payer may send on, the tokens they may send, and the pairs
// themselves. None is ever nil — a picker handed `null` where it expects an
// array is a client-side crash rather than an empty menu.
type CryptoOptions struct {
Chains []string `json:"chains"`
Tokens []string `json:"tokens"`
// Assets is every (chain, token) pair the rail accepts. Chains and Tokens are
// its two axes, and a chain and a token each on offer are not a pair on offer:
// base may be watched for USDC alone.
Assets []CryptoAsset `json:"assets"`
}
// CryptoAsset is one pair the rail accepts: a token on a chain.
type CryptoAsset struct {
Chain string `json:"chain"`
Token string `json:"token"`
}
// depositValidationError marks a request that named an asset this rail cannot
// mint on — the caller's own doing, and fixable by asking for a different one.
// The subscription core states its client-side failures the same way
// (subValidationError).
type depositValidationError struct{ msg string }
func (e depositValidationError) Error() string { return e.msg }
// depositRefusal is the rail itself being shut for an asset: nothing is watching
// it, no custody account is configured, the signer will not mint, a reference
// could not be issued. msg is the sentence the PAYER reads and names another way
// to pay; cause carries the operational detail for the log, and is nil where
// there is no underlying failure to report.
type depositRefusal struct {
msg string
cause error
}
func (e depositRefusal) Error() string { return e.msg }
func (e depositRefusal) Unwrap() error { return e.cause }
// errDepositNotFound is the ONE answer for both an id that names nothing and an
// id that names another payer's intent. They are deliberately the same value:
// distinguishing them would let a caller confirm a foreign intent exists, which
// is the whole reason the read is payer-scoped.
var errDepositNotFound = errors.New("crypto deposit: no such deposit for this payer")
// IsDepositUnsupported reports whether the rail turned down the ASSET that was
// asked for — a chain the custody signer cannot mint an address on. Asking for a
// different one is the fix, which is why this is a separate question from
// IsDepositRefused, and why the endpoint answers it with a 400.
func IsDepositUnsupported(err error) bool {
var e depositValidationError
return errors.As(err, &e)
}
// IsDepositRefused reports whether the rail is SHUT for that asset — nothing is
// watching it, or nothing can mint on it right now. Nothing sent while this
// holds could be credited, so asking differently does not help and only time or
// an operator does; the endpoint answers it with a 503 and Error() is the sentence
// to show the payer.
func IsDepositRefused(err error) bool {
var e depositRefusal
return errors.As(err, &e)
}
// IsDepositNotFound reports the miss. An unknown id and another payer's id are
// one answer here, on purpose: a caller that could tell them apart could confirm
// a foreign deposit exists.
func IsDepositNotFound(err error) bool { return errors.Is(err, errDepositNotFound) }
// cryptoProcessor resolves the ONE crypto custody processor (default MPC,
// override via COMMERCE_DEFAULT_CRYPTO_PROCESSOR upstream in the registry).
// Availability is probed with a short deadline so an unconfigured or wedged
// MPC endpoint answers 503 fast instead of hanging the request.
func cryptoProcessor(ctx context.Context) (processor.CryptoProcessor, error) {
p, err := processor.Get(processor.MPC)
if err != nil {
return nil, err
}
cp, ok := p.(processor.CryptoProcessor)
if !ok {
return nil, fmt.Errorf("processor %s does not support crypto operations", processor.MPC)
}
probe, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if !cp.IsAvailable(probe) {
return nil, fmt.Errorf("crypto custody service unavailable")
}
return cp, nil
}
// GetCryptoOptions lists the assets the rail accepts — which is the set a
// deposit watcher can CREDIT, not the set the MPC signer can mint an address
// for. Those are different questions and only the second one was ever asked
// here.
//
// The processor answers "can I derive an address on this chain?", and it says
// yes to nine of them. Nothing followed a single one of those addresses to the
// customer's balance, so the picker walked buyers toward assets the system
// could receive and never credit — the same defect that stopped this rail, one
// endpoint upstream of it.
//
// Deriving the list from the watcher's configured assets makes that
// unrepresentable: an asset appears here only because something is watching it,
// and it stops appearing the moment that stops being true. An unconfigured
// watcher therefore offers nothing rather than everything, which is the safe
// direction for a list whose entries invite people to send money.
//
// It takes a context and nothing else because that is the whole of what the
// question depends on: the offer is a property of this process — which assets
// its watcher reads, which chains its custody signer will mint on — and not of
// the org asking. A peer that offers the same picker over the internal plane
// asks here rather than keeping a list of its own, since a second list is how a
// buyer comes to be shown an asset the mint path refuses.
func GetCryptoOptions(ctx context.Context) (CryptoOptions, error) {
// The custody signer must still be reachable — an asset nobody can mint an
// address for is no more useful than one nobody can credit.
cp, err := cryptoProcessor(ctx)
var assets []CryptoAsset
if err == nil {
assets = offeredAssets(watchedAssets(), cp.SupportedChains())
}
return menu(assets, err)
}
// menu is the rail's offer: the error when the custody signer cannot be
// reached (the endpoint's 503), else the pairs and their two axes.
func menu(assets []CryptoAsset, custody error) (CryptoOptions, error) {
if custody != nil {
return CryptoOptions{}, custody
}
if assets == nil {
assets = []CryptoAsset{}
}
chains, tokens := axesOf(assets)
return CryptoOptions{Chains: chains, Tokens: tokens, Assets: assets}, nil
}
// GetBillingCryptoOptions is the picker's endpoint for that menu.
//
// GET /v1/billing/crypto/options
func GetBillingCryptoOptions(c *zip.Ctx) error {
out, err := GetCryptoOptions(c.Context())
if err != nil {
return jsonhttp.Fail(c, 503, "Crypto deposits not configured", err)
}
return c.JSON(200, out)
}
// watchedAssets is the set of assets something is actually watching — the ONE
// question both the picker and the mint path ask, so they cannot disagree about
// what is on offer. An unconfigured, disabled or STOPPED watcher yields none,
// which is what makes "offered" and "creditable" the same set.
//
// It asks Running, not Enabled, and the difference is the whole safety property.
// Enabled means "an asset was configured"; Running means "something is reading
// the chain for it". Only the second one implies a deposit reaches a balance.
// Gating on the first would re-create the original bug in a new place: a process
// that constructed the watcher and never started its schedule would hand out
// real custody addresses that nothing on earth will ever look at — which is
// precisely the shape of the failure this rail exists to end, and it would look
// healthy from outside.
//
// That makes the gate self-enforcing rather than ceremonial. Bootstrap no longer
// starts the schedule (it builds the app; it does not run it), so the start now
// lives in the two serving entry points — and a third one added later that
// forgets cannot take money, it can only refuse. Fail closed, by construction,
// with no second thing to remember.
func watchedAssets() []depositwatch.Asset {
if w := depositledger.Default(); w.Running() {
return w.Assets()
}
return nil
}
// pooledAddressFor returns the ONE custody account (chain, token) is deposited
// to, for a chain whose deposits are pooled. Empty when nothing watches that
// asset — which is the only reason it can be empty, since AssetsFromEnv refuses
// to build a pooled asset with no configured account.
//
// It is looked up per (chain, TOKEN), not per chain, and that is the check it
// exists for: handing out the pooled address for a token nothing watches would
// invite a deposit that no scan will ever see. The address itself is per-chain
// — one account holds every currency sent to it — so every token on a pooled
// chain answers the same string.
func pooledAddressFor(assets []depositwatch.Asset, chain, token string) string {
for _, a := range assets {
if strings.EqualFold(a.Chain, chain) && strings.EqualFold(a.Token, token) {
return a.PooledAddress
}
}
return ""
}
// offeredFrom projects the watched assets onto the two lists the picker renders,
// keeping only the chains an address can actually be MINTED on.
//
// Both halves are required, and each one alone has already shipped a dead end.
// This endpoint originally answered from the custody signer — "can I derive an
// address here?" — and so offered nine chains the watcher credited none of.
// Answering from the watcher alone inverts the same defect: Solana is creditable
// the moment it is configured, but the custody fleet derives no Ed25519 key and
// `CreateCryptoDeposit` refuses the chain, so the picker would walk a buyer to a
// 400 after they had chosen an amount. An asset is offered when it can be
// received AND credited, which is the only combination that ends with a
// customer's balance going up.
//
// It is a pure function of its two inputs and not a method on the watcher
// because that is what makes the rule testable: NO assets must yield NO offer,
// and the only way to be sure of that is to be able to ask it directly. Reading
// the process-wide watcher inside the handler would leave the question
// answerable only by whatever the environment happened to configure.
//
// Both lists are deduplicated and sorted, so the picker's order is a property of
// the inputs rather than of map iteration.
func offeredFrom(assets []depositwatch.Asset, mintable []string) (chains, tokens []string) {
return axesOf(offeredAssets(assets, mintable))
}
// offeredAssets is the rule offeredFrom states, as the pairs it keeps: each
// watched asset whose chain an address can be had on, lowercased, deduplicated
// and ordered by chain then token. Never nil.
func offeredAssets(assets []depositwatch.Asset, mintable []string) []CryptoAsset {
canMint := make(map[string]bool, len(mintable))
for _, ch := range mintable {
canMint[strings.ToLower(strings.TrimSpace(ch))] = true
}
seen := map[CryptoAsset]bool{}
out := make([]CryptoAsset, 0, len(assets))
for _, a := range assets {
chain := strings.ToLower(a.Chain)
// The custody signer is asked only about chains it would actually have
// to mint on. A POOLED chain's address is CONFIGURED, not derived, so
// the signer's opinion of it is not just unnecessary but wrong: this
// signer answers "no" for XRPL (it derives no XRPL key, and its chain
// switch falls through to the EVM address), and deferring to that would
// hide a chain the rail can both receive and credit. What makes a
// pooled chain offerable is that its account is configured — and
// AssetsFromEnv refuses to build the asset otherwise, so being watched
// already IS that.
if !a.Pooled() && !canMint[chain] {
continue
}
// A token is named only because some OFFERED chain carries it. Listing
// it from an unofferable chain would put it in the picker with nowhere
// to send it.
p := CryptoAsset{Chain: chain, Token: strings.ToLower(a.Token)}
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Chain != out[j].Chain {
return out[i].Chain < out[j].Chain
}
return out[i].Token < out[j].Token
})
return out
}
// axesOf projects pairs onto the picker's two lists.
func axesOf(pairs []CryptoAsset) (chains, tokens []string) {
cset, tset := map[string]bool{}, map[string]bool{}
for _, p := range pairs {
cset[p.Chain] = true
tset[p.Token] = true
}
return sortedKeys(cset), sortedKeys(tset)
}
// sortedKeys returns the set's members in order, and NEVER nil — a nil slice
// marshals to `null`, and a picker handed `null` where it expects a list is a
// client-side crash rather than an empty menu.
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// creditable reports whether a deposit of this asset can actually reach the
// customer's balance — which is the ONE property that makes taking crypto
// legitimate, and the question a blanket flag could never answer.
//
// This replaces `const cryptoDepositsCanBeCredited = false`. That constant was
// right for what it knew: when it was written NOTHING advanced an intent past
// Pending and no watcher observed DepositAddress, so no asset was creditable and
// one `false` covered every case. All four of its stated lift conditions are now
// met — a watcher observes the address, advances the intent, credits exactly
// once through the ledger, and wallet_id persists so a credit can also be swept.
//
// But flipping that constant to `true` would have reintroduced the ORIGINAL BUG
// for every asset the watcher is not configured for. "Crediting exists" is not
// "crediting exists FOR THIS CHAIN": the watcher is configured per asset from
// CRYPTO_DEPOSIT_*, so an unconfigured chain still mints a real custody address
// that nothing on earth will ever look at. A global boolean cannot express a
// per-asset fact, and the gap between them is exactly where money was lost.
//
// So the gate is now the invariant itself, asked per request: this asset is
// offered only if something is watching it. An operator cannot turn it on from
// outside — configuring the watcher IS turning it on, and that is the same act
// rather than a second one that has to be remembered. Nothing is watched by
// default, so the rail is closed until an asset is deliberately armed.
// It reads watchedAssets(), the SAME accessor GetCryptoOptions projects the
// picker from, so "offered" and "mintable" cannot drift apart: a buyer can only
// be shown an asset this will hand out an address for, and vice versa.
func creditable(assets []depositwatch.Asset, chain, token string) bool {
for _, a := range assets {
if strings.EqualFold(a.Chain, chain) && strings.EqualFold(a.Token, token) {
return true
}
}
return false
}
// openIntentFor returns the payer's live deposit intent for this asset, if
// there is one to reuse.
//
// One payer, one live destination per asset. Reuse is what stops a refreshed
// page from spraying MPC keygens and stranding funds across addresses — and on
// a POOLED chain it is what stops it spraying destination TAGS, which matters
// more: a tag is drawn from a finite space that is never reclaimed, and every
// intent ever minted stays watched forever (depositledger.Watched filters on
// the asset and deliberately not on status). Reuse allocates nothing, so the
// second request through this endpoint costs the sequence nothing.
//
// A reused intent is returned whole rather than as an address, so its tag
// travels with it and the two halves of its identity cannot come from different
// places.
//
// Three conditions, each of which has to hold:
//
// found + PENDING a settled or failed intent is history, not a destination
// not expired expiry governs whether we hand the address out AGAIN; it
// never governs whether we honour what arrives at it
// has an address an intent that never got one is nothing to reuse
//
// Get reports a miss as (false, nil), so the caller must check found and not
// merely err.
func openIntentFor(db *datastore.Datastore, payer, chain, token string) (*cryptopaymentintent.CryptoPaymentIntent, bool) {
existing := cryptopaymentintent.New(db)
found, err := existing.Query().
Filter("CustomerRef=", payer).
Filter("Chain=", chain).
Filter("Token=", token).
Filter("Status=", string(cryptopaymentintent.Pending)).
Get()
if err != nil || !found || existing.IsExpired() || existing.DepositAddress == "" {
return nil, false
}
return existing, true
}
// destinationIsComplete reports whether an intent records a destination a payer
// can actually be sent to — BOTH halves of it, on a chain that has two.
//
// It reads the chain off the INTENT rather than off the request, because the
// stored row is the thing being handed back and it is the row that has to be
// coherent. On every per-payer chain it is trivially true, so nothing about
// those paths changes: the address is the whole destination there.
//
// It exists for one state that this handler cannot produce — a pooled intent
// with no tag, since the tag is allocated and written in the same record as the
// address — but which a hand-edited row, a restore, or an older binary could.
// Such a row is not merely incomplete, it is a trap: the address is real and
// ours, so a payment to it ARRIVES and is credited to nobody.
func destinationIsComplete(in *cryptopaymentintent.CryptoPaymentIntent) bool {
return !depositwatch.Pooled(string(in.Chain)) || in.AddressTag != ""
}
// CreateCryptoDeposit answers where a payer sends and what names them when it
// arrives — the MINT, with no HTTP in it.
//
// It takes values rather than a request because the peer that offers this rail
// over the internal plane holds no datastore, and two implementations of "where
// does this payer send, and may they" is how one endpoint comes to hand out an
// address the other refuses. The asset defaults here rather than at the endpoint for
// the same reason: an unnamed chain means ethereum wherever it is asked from.
//
// The payer is the CALLER's own billing identity, resolved at the endpoint and
// passed in. A deposit lands where the credential says and nowhere else.
//
// An open PENDING intent for the same (payer, chain, token) is reused — one
// payer, one live address per asset — so refreshing the page cannot spray MPC
// keygens, strand funds across addresses, or consume the finite tag space.
//
// The two refusals are separate types because they mean opposite things to
// whoever asked: IsDepositUnsupported says ask for another asset,
// IsDepositRefused says nothing sent now could be credited. Anything else is a
// write that failed.
func CreateCryptoDeposit(ctx context.Context, org *organization.Organization, payer, chain, token string, amountCents int64) (CryptoDeposit, error) {
if org == nil {
return CryptoDeposit{}, errors.New("crypto deposit: no organization")
}
if payer == "" {
return CryptoDeposit{}, errors.New("crypto deposit: no payer")
}
db := datastore.New(org.Namespaced(ctx))
chain = strings.ToLower(strings.TrimSpace(chain))
if chain == "" {
chain = "ethereum"
}
token = strings.ToLower(strings.TrimSpace(token))
if token == "" {
token = "usdc"
}
// THE GATE, and it is the whole reason this rail may take money at all: an
// address is handed out only for an asset something is watching. Asked before
// any keygen, because an address that is never minted is one nobody can send
// to — and asked from the SAME accessor the picker projects from, so a buyer
// can never be offered an asset this would refuse.
//
// Nothing is watched until CRYPTO_DEPOSIT_* names it, so the rail is closed by
// default and arming an asset is one deliberate act rather than two that must
// be done in the right order.
watched := watchedAssets()
if !creditable(watched, chain, token) {
return CryptoDeposit{}, depositRefusal{msg: fmt.Sprintf(
"%s on %s is not accepted yet — nothing is watching that address, so a deposit could not be credited. Use a card, bank transfer or wire.",
strings.ToUpper(token), chain)}
}
// A POOLED chain does not mint anything, so it asks the custody signer
// nothing. Its account is configured (depositwatch.Asset.PooledAddress) and
// what distinguishes one payer's deposit from another's is a tag allocated
// below — so probing the signer here would only add a way for a working
// rail to refuse, and asking it whether it "supports xrpl" would answer no
// and close a chain that works. Every per-payer chain keeps the path it
// had, in the order it had it.
pooled := depositwatch.Pooled(chain)
var cp processor.CryptoProcessor
if !pooled {
var err error
cp, err = cryptoProcessor(ctx)
if err != nil {
return CryptoDeposit{}, depositRefusal{msg: "Crypto deposits not configured", cause: err}
}
if !supportedChain(cp, chain) {
return CryptoDeposit{}, depositValidationError{fmt.Sprintf("unsupported chain %q", chain)}
}
}
// Reuse the payer's open intent for this asset before minting a new one.
if existing, ok := openIntentFor(db, payer, chain, token); ok {
// A pooled destination missing its tag names NOBODY: the payment would
// arrive at an address we own and be credited to no one (recorded as
// unattributed, refundable only by hand). It cannot arise from this
// path — the tag is allocated and written in the same record as the
// address — so a row like it is corruption, and the two tempting
// repairs are both worse than refusing. Handing it over takes money we
// cannot route; minting a replacement leaves the untagged row live and
// sprays the tag space on every refresh.
if !destinationIsComplete(existing) {
return CryptoDeposit{}, depositRefusal{
msg: "Crypto deposits are temporarily unavailable — this deposit reference is incomplete. Contact support or choose another way to pay.",
cause: fmt.Errorf("crypto deposit intent %s on %s has no destination tag; refusing to reuse it", existing.Id(), chain),
}
}
return toCryptoDepositResponse(existing), nil
}
// WHERE the payer sends, and WHAT names them when they arrive. This is the
// only thing the two kinds of chain answer differently; everything after it
// is one path, so a field can never be recorded on one kind of chain and
// quietly forgotten on the other.
var wallet processor.Wallet
var tag string
if pooled {
// The account is configured, never invented — and never asked of the
// custody signer, which would mint a fresh wallet per deposit and
// strand a non-refundable reserve on each one. If nothing is
// configured, nothing is handed out.
addr := pooledAddressFor(watched, chain, token)
if addr == "" {
return CryptoDeposit{}, depositRefusal{msg: fmt.Sprintf(
"Crypto deposits on %s are not configured — no custody account is set for %s.", chain, strings.ToUpper(token))}
}
// The tag is allocated BY THE DATABASE and never chosen here — see
// depositledger.NextTag for what makes it unique across replicas, and
// why a random tag with a uniqueness check would not be. Allocating one
// and not using it costs nothing, since the sequence simply moves on;
// issuing one twice halts the asset for every customer. So this refuses
// rather than falls back.
var err error
if tag, err = depositledger.NextTag(ctx, chain); err != nil {
return CryptoDeposit{}, depositRefusal{
msg: "Crypto deposits are temporarily unavailable — a deposit reference could not be issued. Try again shortly or choose another way to pay.",
cause: fmt.Errorf("crypto deposit tag allocation failed for %q on %s: %w", payer, chain, err),
}
}
// No wallet ID. The custody signer holds no per-deposit wallet here —
// the pooled account IS the wallet, and it is the operator's rather
// than this intent's. Empty is the honest answer, not a missing one.
wallet = processor.Wallet{Address: addr}
} else {
var err error
if wallet, err = cp.GenerateAddress(ctx, payer, chain); err != nil {
return CryptoDeposit{}, depositRefusal{
msg: "Crypto deposits are temporarily unavailable — the custody service is not accepting new addresses. Try again shortly or choose another way to pay.",
cause: fmt.Errorf("crypto deposit address generation failed for %q on %s: %w", payer, chain, err),
}
}
}
intent, err := recordIntent(db, payer, chain, token, amountCents, wallet, tag)
if err != nil {
return CryptoDeposit{}, err
}
return toCryptoDepositResponse(intent), nil
}
// CreateBillingCryptoDeposit is the payer's endpoint for the crypto rail. It
// resolves who is asking, reads the asset off the body, and maps a refusal to
// the code it has always mapped to.
//
// POST /v1/billing/crypto/deposit { chain?, token?, amountCents? }
func CreateBillingCryptoDeposit(c *zip.Ctx) error {
payer := userBillingKey(c)
if payer == "" {
return jsonhttp.Fail(c, 401, "Authentication required", nil)
}
org := middleware.GetOrganization(c)
var req cryptoDepositRequest
if len(c.Body()) > 0 {
if err := json.Unmarshal(c.Body(), &req); err != nil {
return jsonhttp.Fail(c, 400, "invalid request body", err)
}
}
out, err := CreateCryptoDeposit(c.Context(), org, payer, req.Chain, req.Token, req.AmountCents)
switch {
case IsDepositUnsupported(err):
return jsonhttp.Fail(c, 400, err.Error(), nil)
case IsDepositRefused(err):
// 503, NOT 502 — and the difference is what the customer reads.
// Cloudflare REPLACES an origin 502 with its own "Bad gateway"
// interstitial, so the JSON never reaches the browser: measured on
// pay.hanzo.ai, the deposit call returned a text/html CF error page while
// the identical call to the origin returned this message. 503 passes
// through untouched (the wire rail's own "not configured" 503 proves it),
// which is also the honest code: the rail is unavailable, we are not a
// broken gateway.
return jsonhttp.Fail(c, 503, err.Error(), errors.Unwrap(err))
case err != nil:
return jsonhttp.Fail(c, 500, "failed to record deposit intent", err)
}
return c.JSON(200, out)
}
// recordIntent turns a resolved destination into the durable row the watcher
// will later match deposits against.
//
// It is a function and not four lines in the handler for a reason that is not
// tidiness: CreateCryptoDeposit is gated shut (cryptoDepositsCanBeCredited), so
// anything written inside it is UNTESTABLE — a mutant that recorded the pooled
// address and dropped the tag passed the entire suite, because no test can
// reach the statement. Money-carrying assignments do not belong behind a gate
// no test can open.
//
// It REFUSES to write an incomplete destination. That is the same invariant
// destinationIsComplete states on the way out, enforced here on the way IN, so
// a row that traps a payment cannot be created rather than merely being caught
// later: on a pooled chain the address is real and ours, so a payment to it
// arrives whether or not we recorded who it belongs to.
func recordIntent(db *datastore.Datastore, payer, chain, token string, amountCents int64, wallet processor.Wallet, tag string) (*cryptopaymentintent.CryptoPaymentIntent, error) {
intent := cryptopaymentintent.New(db)
intent.Amount = amountCents
intent.Currency = "usd"
intent.Chain = cryptopaymentintent.Chain(chain)
intent.Token = token
intent.DepositAddress = wallet.Address
// The other half of the destination on a pooled chain, and empty on every
// chain where the address is the whole answer. Written in the SAME record
// as the address, because an intent holding one without the other names an
// identity the watcher can never match.
intent.AddressTag = tag
// Recorded at mint time because this is the only moment the signer offers
// it. Without it a deposit can be credited and never swept.
intent.WalletID = wallet.ID
intent.CustomerRef = payer
intent.Status = cryptopaymentintent.Pending
// A top-up address is a standing destination, not a checkout hold — give it
// a day rather than the model's 30-minute checkout default.
intent.ExpiresAt = time.Now().Add(24 * time.Hour)
intent.Defaults()
if intent.DepositAddress == "" {
return nil, fmt.Errorf("refusing to record a %s/%s deposit intent with no address", chain, token)
}
if !destinationIsComplete(intent) {
return nil, fmt.Errorf("refusing to record a %s/%s deposit intent with an address but no destination tag — a payment to it would arrive and be credited to nobody", chain, token)
}
if err := intent.Create(); err != nil {
return nil, err
}
return intent, nil
}
// GetCryptoDeposit reports an intent's state (pending → confirming →
// succeeded) — the READ, with no HTTP in it.
//
// It takes the payer as a value because the read is SCOPED to them and the scope
// is the point: the same lookup answered over the internal plane must refuse a
// foreign id there too, and a second copy of "is this yours" is a copy that can
// be written without the check. An id that names nothing and an id that names
// somebody else return the SAME error, so neither endpoint can be made to confirm
// that a foreign intent exists.
func GetCryptoDeposit(ctx context.Context, org *organization.Organization, payer, id string) (CryptoDeposit, error) {
if org == nil {
return CryptoDeposit{}, errors.New("crypto deposit: no organization")
}
if payer == "" {
return CryptoDeposit{}, errors.New("crypto deposit: no payer")
}
intent := cryptopaymentintent.New(datastore.New(org.Namespaced(ctx)))
if err := intent.GetById(id); err != nil {
return CryptoDeposit{}, errDepositNotFound
}
if intent.CustomerRef != payer {
return CryptoDeposit{}, errDepositNotFound
}
return toCryptoDepositResponse(intent), nil
}
// GetBillingCryptoDeposit is the payer's endpoint for their own deposit.
//
// GET /v1/billing/crypto/deposit/:id
func GetBillingCryptoDeposit(c *zip.Ctx) error {
payer := userBillingKey(c)
if payer == "" {
return jsonhttp.Fail(c, 401, "Authentication required", nil)
}
out, err := GetCryptoDeposit(c.Context(), middleware.GetOrganization(c), payer, c.Param("id"))
if err != nil {
return jsonhttp.Fail(c, 404, "deposit not found", err)
}
return c.JSON(200, out)
}
func toCryptoDepositResponse(i *cryptopaymentintent.CryptoPaymentIntent) CryptoDeposit {
out := CryptoDeposit{
ID: i.Id(),
Status: string(i.Status),
Chain: string(i.Chain),
Token: i.Token,
DepositAddress: i.DepositAddress,
AddressTag: i.AddressTag,
ExpiresAt: i.ExpiresAt.UTC().Format(time.RFC3339),
AmountCents: i.Amount,
// The depth the watcher judges by now, which is the chain's policy and not
// the number stamped on an intent minted under an older one.
RequiredConfirmations: cryptopaymentintent.RequiredConfirmationsForChain(i.Chain),
Confirmations: i.Confirmations,
}
if i.Status == cryptopaymentintent.Succeeded {
out.CreditedCents = i.SettlementAmount
}
if terms, ok := termsFor(watchedAssets(), string(i.Chain), i.Token); ok {
out.FeeCents = terms.FeeCents
out.MinimumCents = minimumCents(terms)
}
return out
}
// termsFor answers what a deposit of (chain, token) has deducted before it is
// credited, from the watched asset — where the deduction is configured
// (CRYPTO_DEPOSIT_FEE_<CHAIN>). ok is false when nothing watches the asset.
func termsFor(assets []depositwatch.Asset, chain, token string) (depositwatch.Terms, bool) {
for _, a := range assets {
if strings.EqualFold(a.Chain, chain) && strings.EqualFold(a.Token, token) {
return a.Terms, true
}
}
return depositwatch.Terms{}, false
}
// minimumCents is the smallest deposit, in USD cents of what was sent, that
// depositwatch.AmountCents credits under t: the haircut leaves it at least one
// cent more than the fee. Anything less is dust or under the fee and credits
// nothing.
func minimumCents(t depositwatch.Terms) int64 {
const bps = 10_000
keep := int64(bps - t.SlippageBps)
return ((t.FeeCents+1)*bps + keep - 1) / keep
}
func supportedChain(cp processor.CryptoProcessor, chain string) bool {
for _, s := range cp.SupportedChains() {
if s == chain {
return true
}
}
return false
}