Files
zeekay 1943f1c093 Hanzo Commerce
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans,
usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0.

Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
2026-09-23 21:52:16 -07:00

907 lines
40 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.
// Package depositwatch is the other half of the crypto top-up rail: the part
// that watches the addresses api/billing/crypto_deposit.go hands out and turns
// money that actually arrived into spendable balance.
//
// Without it the rail is a hole — an address is minted, a customer sends real
// USDC to it, and nothing in the process ever looks at the chain, so the intent
// sits Pending forever and the money is simply gone. That is why the rail is
// switched off (cryptoDepositsCanBeCredited), and this package is what earns the
// right to switch it on.
//
// It is DECOMPLECTED the same way billing/husdindex is, and for the same reason:
//
// - this package is pure policy over small interfaces — which blocks to scan,
// how deep is deep enough, what a token amount is worth, which address
// belongs to whom. No datastore, no HTTP handler, no cgo, so every money
// decision in it is unit-tested against fakes with no chain and no database.
// - billing/depositledger is the I/O half: the per-org intent store, the
// idempotent ledger write, the persisted cursor, and the schedule.
//
// The chain READS come from one read client per chain family:
// billing/husdindex.Client for the EVM (this repo's one ERC-20 JSON-RPC read
// client, named for its first caller and not for a restriction),
// billing/solanarpc.Client for Solana, billing/tonrpc.Client for TON and
// billing/xrplrpc.Client for XRPL. All satisfy Reader, so adding a chain adds a
// reader and touches none of the policy here.
//
// SCOPE: dollar-pegged tokens on chains this package can read. Everything
// outside that is refused rather than approximated — see pegCents,
// creditableTokens and chainFamily below.
package depositwatch
import (
"context"
"errors"
"fmt"
"math/big"
"sort"
"strconv"
"strings"
"github.com/hanzoai/commerce/models/cryptopaymentintent"
)
// Asset is ONE watched (chain, token) pair: where the token is defined on chain
// and what one whole token is worth. Decimals are deliberately NOT here — they
// are read from the chain at scan time (Watcher.verify), because a decimals
// constant is a number no code can check and a wrong one credits 10^12 times
// too much.
type Asset struct {
Chain string // "ethereum", "base", "solana", "ton", "xrpl" (lowercase; matches the intent's Chain)
Token string // "usdc", "usdt", "rlusd" (lowercase; matches the intent's Token)
// Contract is what DEFINES the token on its chain, written the way that
// chain writes it:
//
// EVM the ERC-20 contract address, lowercased hex
// Solana the SPL mint account, base58 exactly as given
// TON the jetton master address, base64url exactly as given
// XRPL <CURRENCY>.<ISSUER> — because on XRPL neither half is a token on
// its own: an issuer issues many currencies, and a currency code is
// a name anybody may issue
//
// Normalising it any other way would break the comparison it exists for.
Contract string
RPCURL string // JSON-RPC endpoint for Chain
// PooledAddress is the ONE custody account this chain's deposits all land
// in, for a chain where deposits are POOLED (see Pooled). Empty everywhere
// else, and it must stay empty there: on a per-payer chain the address is
// minted per deposit by the custody signer, and a configured one would hand
// every payer the same address.
//
// It is CONFIGURED (CRYPTO_DEPOSIT_ADDRESS_<CHAIN>) rather than minted, and
// that is the point rather than a shortcut. Asking the custody signer for an
// XRPL address per deposit is precisely the thing the pooled model exists to
// avoid — every one of those accounts would strand a non-refundable base
// reserve — and this repo's signer would not even answer the question: it
// derives no XRPL key and its GenerateAddress falls through to the EVM
// default, so a "minted" XRPL address would be an 0x string nobody can pay.
//
// It is per-CHAIN and not per-token, because the account is: one r-address
// holds every issued currency sent to it. Two assets on a pooled chain
// therefore carry the same string here, read from the same variable.
//
// AssetsFromEnv refuses a pooled chain that has no address, so an asset that
// EXISTS is an asset that can be paid to. That is what lets the mint path
// treat a missing address as impossible rather than as a case to handle.
PooledAddress string
// Terms is what this asset deducts before crediting. Zero — nothing deducted
// — is the default and is right for a dollar-pegged token on a cheap chain.
//
// It is per-ASSET rather than global because the two costs it covers are:
// sweeping USDC on Ethereum costs gas that sweeping it on Base does not, and
// a market-priced coin carries a price move that a stablecoin does not.
Terms Terms
}
// Key identifies an asset for cursors and maps.
func (a Asset) Key() string { return a.Chain + ":" + a.Token }
// Family is HOW a chain is read, how it writes an address, and whether one
// address belongs to one payer. It is the one place a per-chain difference is
// allowed to live in this package; everything downstream — the confirmation
// rule, the amount arithmetic, the dedup key — is written once and applies to
// every family.
//
// It is ONE enum rather than a set of IsX() predicates so that every question a
// caller asks about a chain is answered from the same table, and so that adding
// a family makes the switch that builds readers fail to compile rather than
// fall to an EVM default.
type Family int
const (
FamilyEVM Family = iota
FamilySolana
FamilyTON
FamilyXRPL
// FamilyBitcoin is the rail's first UTXO chain. It is its own family because
// a deposit there is an OUTPUT rather than an account movement, and one
// transaction may pay us twice — see billing/bitcoinrpc.
FamilyBitcoin
)
// chainFamily is the set of chains this rail can READ, keyed by the intent
// model's own chain constants so the two lists cannot drift apart.
//
// A chain that is not here is REFUSED at configuration time rather than handed
// an EVM reader on the assumption that everything is an EVM. That assumption is
// how a Solana endpoint gets asked for eth_getLogs, answers an error every 30
// seconds, and watches nothing while looking configured.
var chainFamily = map[cryptopaymentintent.Chain]Family{
cryptopaymentintent.Ethereum: FamilyEVM,
cryptopaymentintent.Base: FamilyEVM,
cryptopaymentintent.Polygon: FamilyEVM,
cryptopaymentintent.Arbitrum: FamilyEVM,
cryptopaymentintent.Optimism: FamilyEVM,
cryptopaymentintent.Avalanche: FamilyEVM,
cryptopaymentintent.BSC: FamilyEVM,
cryptopaymentintent.Lux: FamilyEVM,
cryptopaymentintent.Zoo: FamilyEVM,
cryptopaymentintent.Solana: FamilySolana,
cryptopaymentintent.TON: FamilyTON,
cryptopaymentintent.XRPL: FamilyXRPL,
cryptopaymentintent.Bitcoin: FamilyBitcoin,
}
// Family reports how this asset's chain is read and written. It is the one
// question the I/O half must ask to build the right reader, and asking it here
// keeps the chain→family table in a single place.
func (a Asset) Family() Family { return chainFamily[cryptopaymentintent.Chain(a.Chain)] }
// Pooled reports whether this chain shares ONE deposit address across payers,
// so that the address alone cannot say whose money arrived.
//
// It is true for XRPL and false everywhere else, and the reason is economic
// rather than technical: XRPL charges a NON-REFUNDABLE base reserve in XRP for
// every funded account, so minting a fresh address per payer would strand that
// reserve on every deposit, forever. The model the whole ledger uses instead is
// one custody account plus a per-deposit DESTINATION TAG — which is why the
// thing that identifies an intent here is Identity and not Address.
//
// Everywhere else an address is per-payer and free to mint (an EVM address
// costs nothing to derive, a Solana ATA is created by the first deposit itself,
// a TON jetton wallet likewise), so there is nothing to pool and no tag.
func (a Asset) Pooled() bool { return Pooled(a.Chain) }
// Pooled reports whether CHAIN shares one deposit address across payers.
//
// It takes a chain rather than an asset because the MINT side must ask before
// it has one: the question "is this chain's address configured or minted?"
// decides which path a deposit request goes through, and at that moment there
// is a chain and a token and no Asset yet. The pooling fact is per-chain
// anyway — the account is shared by every currency sent to it — so this is the
// primitive and Asset.Pooled is the convenience.
//
// Keeping both on one line of one function is deliberate: the read side and the
// write side must never be able to disagree about which chains are pooled,
// because a chain the writer thinks is per-payer and the reader thinks is
// pooled is a deposit matched against an identity nobody issued.
func Pooled(chain string) bool {
return chainFamily[cryptopaymentintent.Chain(strings.ToLower(strings.TrimSpace(chain)))] == FamilyXRPL
}
// Fold normalises a chain-native identifier — a deposit address, a transaction
// id — for comparison and for keying.
//
// It is ONE function rather than one per kind of identifier because it encodes
// one fact: whether that chain's textual encoding is case-significant.
//
// EVM hex, case-INsensitive. The custody service hands back EIP-55
// checksummed addresses and node logs are lowercase; comparing them
// literally would miss every deposit, so both sides are lowercased.
// Solana base58, case-SIGNIFICANT. `aB…` and `Ab…` are different accounts and
// different signatures, so nothing is folded. Lowercasing here would
// merge distinct addresses into one map entry — and while the collision
// is vanishingly unlikely, "unlikely" is not a property to hang a
// custody address on when exactness is free.
// TON base64url, case-SIGNIFICANT. Same reasoning as Solana.
// XRPL base58 (a DIFFERENT alphabet from Solana's, same property) and, for
// a currency code, case-significant too: XRPL compares codes as 160-bit
// values, so "usd" and "USD" are two tokens.
//
// The non-EVM families all leave identifiers alone, which is only safe because
// their READERS canonicalise before returning: tonrpc and xrplrpc both render a
// transaction id as lowercase hex whatever the endpoint answered, and both echo
// back the caller's own spelling of a watched address rather than the node's.
// That keeps the dedup key a function of the EVENT and not of an endpoint's
// rendering choice.
//
// A chain added to chainFamily without a considered answer here gets the EVM
// fold, which is the safe default in the only direction that matters: folding
// too much makes two addresses ambiguous, which fails the pass closed, while
// folding too little makes a real deposit invisible.
func (a Asset) Fold(s string) string {
s = strings.TrimSpace(s)
switch a.Family() {
case FamilySolana, FamilyTON, FamilyXRPL:
return s
default:
return strings.ToLower(s)
}
}
// Identity is WHAT NAMES THE INTENT a transfer belongs to on this chain.
//
// It exists because "the deposit address" is not that thing everywhere, and
// bending the address field until it was would have been the bug: on XRPL every
// payer shares ONE custody account (see Pooled) and only the destination tag
// says whose money arrived, so matching on the address alone would credit ten
// thousand customers' deposits to whichever intent the map happened to keep.
//
// EVM / Solana / TON the folded address. One payer, one address.
// XRPL the folded address AND the destination tag.
//
// Both sides of every comparison — the minted intent and the observed transfer
// — go through this one function, so the two halves of the match cannot drift.
// The separator cannot occur in any chain's address or tag encoding, so no
// (address, tag) pair can be spelled two ways or collide with another.
func (a Asset) Identity(address, tag string) string {
addr := a.Fold(address)
if !a.Pooled() {
return addr
}
return addr + "#" + strings.TrimSpace(tag)
}
// PegCents is what one whole token of this asset is worth in USD cents.
func (a Asset) PegCents() int64 { return pegCents[a.Token] }
// pegCents is THE list of tokens this rail will credit, and what one whole token
// is worth.
//
// A deposit is credited at a FIXED dollar peg and never at a market rate,
// because commerce has no price oracle and inventing one to value a customer's
// money would be guessing. That is not a gap to fill later with a price feed
// bolted onto this file; it is the reason the creditable set is stablecoins:
//
// ETH / MATIC / AVAX / BNB / SOL — a native coin cannot be priced, and on the
// EVM it cannot even be OBSERVED here (a native transfer emits no log, so
// eth_getLogs cannot see it). Not watched at all.
// BTC — needs a price feed. Not this rail. It is the chain that genuinely
// cannot be added without an oracle, and adding one to credit it would put
// a guess about a customer's money on this path.
// XRP / TON — native coins, same problem as ETH: unpriceable here.
// Anything else pegged — add it here WITH its peg, deliberately.
//
// Reaching a new chain therefore needs a Reader and NOT a price oracle, which
// is precisely why TON and XRPL could be added: TON carries jetton USDT and
// XRPL carries issued USDC and RLUSD, all at a dollar.
//
// The known, bounded, deliberate risk this accepts: a depegged stablecoin is
// credited above its market value. That is the standard bargain every payment
// rail makes for stablecoins, and it is stated here rather than hidden in a
// rate table nobody reads.
var pegCents = map[string]int64{
"usdc": 100,
"usdt": 100,
// Ripple USD, the XRPL-native dollar-pegged stablecoin. Added with its peg
// deliberately, exactly as the paragraph above requires.
"rlusd": 100,
}
// marketPriced is the other way a token is creditable: a live rate rather than a
// fixed peg, keyed by the id the oracle knows it by ("ripple", not "xrp" — its
// vocabulary, and translating twice is how a map goes stale).
//
// A token is in EXACTLY ONE of the two tables. Both would mean two answers to
// what a coin is worth and no way to say which a receipt used.
var marketPriced = map[string]string{
"btc": "bitcoin",
"xrp": "ripple",
"ton": "the-open-network",
}
// nativeChain pins each coin to the one chain it is native to. The same ticker
// elsewhere is a WRAPPED asset — "btc on ethereum" is wBTC, with an issuer and a
// de-peg risk — and crediting it at the bitcoin price values it as something it
// is not. A wrapped asset is welcome the day someone adds it deliberately.
var nativeChain = map[string]string{
"btc": "bitcoin",
"xrp": "xrpl",
"ton": "ton",
}
// MarketPriced reports whether this asset is credited at a live rate. One
// question, so the two tables are never consulted in different orders.
func (a Asset) MarketPriced() bool { _, ok := marketPriced[a.Token]; return ok }
// PriceID is the oracle's name for this asset, empty for a pegged token.
func (a Asset) PriceID() string { return marketPriced[a.Token] }
// PegRate renders an asset's peg the way the intent records an exchange rate
// ("1.00" for a dollar-pegged token). It is meaningful only for a pegged asset;
// a market-priced one has no peg and its rate is whatever RateString was handed
// at credit time.
func (a Asset) PegRate() string {
p := a.PegCents()
return fmt.Sprintf("%d.%02d", p/100, p%100)
}
// RateString renders the rate ACTUALLY USED, for the record a customer reads.
//
// Eight decimals always, including for a peg ("1.00000000"), because one format
// for both is one thing to read. A market credit has to be answerable months
// later — "what did you value my BTC at" is a question a fixed peg never has to
// answer, and rendering 65194.99500000 as "65194.99" would throw away the digits
// that make the arithmetic reproducible.
func RateString(microCents int64) string {
const perDollar = 100 * RateScale
return fmt.Sprintf("%d.%08d", microCents/perDollar, microCents%perDollar)
}
// envRPCPrefix and envTokenPrefix are the environment (KMS-injected) keys the
// watch table is read from:
//
// CRYPTO_DEPOSIT_RPC_<CHAIN> read endpoint for that chain
// CRYPTO_DEPOSIT_TOKEN_<CHAIN>_<TOKEN> what defines that token there — an
// ERC-20 contract, an SPL mint, a jetton
// master, or <CURRENCY>.<ISSUER>
//
// e.g. CRYPTO_DEPOSIT_RPC_BASE + CRYPTO_DEPOSIT_TOKEN_BASE_USDC,
// CRYPTO_DEPOSIT_RPC_SOLANA + CRYPTO_DEPOSIT_TOKEN_SOLANA_USDC,
// CRYPTO_DEPOSIT_RPC_TON + CRYPTO_DEPOSIT_TOKEN_TON_USDT, or
// CRYPTO_DEPOSIT_RPC_XRPL + CRYPTO_DEPOSIT_TOKEN_XRPL_RLUSD.
//
// "Endpoint" is deliberately looser than "JSON-RPC URL": the EVM, Solana and
// XRPL are read over JSON-RPC, while TON is read over the TON Index HTTP API,
// because TON has no per-account transaction index in its node RPC at all. One
// key per chain either way.
//
// This mirrors util/husd exactly, including the part that matters: a token
// address has NO DEFAULT. An unset deploy watches nothing instead of guessing at
// an address, and a token's address is per-chain anyway (USDC on BSC is a
// different contract with different decimals than USDC on Ethereum), so a
// built-in table would be a list of constants nobody can verify from here.
// A third key configures the POOLED custody account, for the chains that have
// one:
//
// CRYPTO_DEPOSIT_ADDRESS_<CHAIN> the one account every payer sends to
//
// e.g. CRYPTO_DEPOSIT_ADDRESS_XRPL=rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De. It has
// no default and no fallback for the same reason a token address does not: an
// address is where money goes, and inventing one is inventing a destination.
const (
envRPCPrefix = "CRYPTO_DEPOSIT_RPC_"
envTokenPrefix = "CRYPTO_DEPOSIT_TOKEN_"
envAddressPrefix = "CRYPTO_DEPOSIT_ADDRESS_"
// The deductions, per CHAIN, both optional and both defaulting to nothing:
//
// CRYPTO_DEPOSIT_FEE_<CHAIN> whole cents, what it costs US to sweep
// CRYPTO_DEPOSIT_SLIPPAGE_<CHAIN> basis points, only for a market-priced coin
//
// Per chain and not per token because both costs are the chain's: a sweep on
// Ethereum costs Ethereum gas whether it is moving USDC or USDT, and the
// price move belongs to how long that chain takes to confirm.
envFeePrefix = "CRYPTO_DEPOSIT_FEE_"
envSlippagePrefix = "CRYPTO_DEPOSIT_SLIPPAGE_"
)
// AssetsFromEnv builds the watch table from environ (as returned by os.Environ:
// "KEY=VALUE" strings). Passing environ in rather than reading os keeps this
// package pure and lets the tests state a whole deployment in one literal.
//
// It fails CLOSED on an incoherent table — a token configured on a chain with no
// RPC endpoint, or a token this rail cannot price — because a half-configured
// money rail that silently watches less than the operator asked for is exactly
// the failure this whole package exists to end.
func AssetsFromEnv(environ []string) ([]Asset, error) {
rpc := map[string]string{}
pooledAddr := map[string]string{}
feeCents := map[string]int64{}
slippageBps := map[string]int32{}
type tok struct{ chain, token, contract string }
var toks []tok
for _, kv := range environ {
eq := strings.IndexByte(kv, '=')
if eq <= 0 {
continue
}
key, val := kv[:eq], strings.TrimSpace(kv[eq+1:])
if val == "" {
continue
}
switch {
case strings.HasPrefix(key, envTokenPrefix):
// <CHAIN>_<TOKEN>: the token is the last segment, so a chain name is
// free to contain an underscore even though none of ours does.
rest := key[len(envTokenPrefix):]
us := strings.LastIndexByte(rest, '_')
if us <= 0 || us == len(rest)-1 {
return nil, fmt.Errorf("depositwatch: %s is not %s<CHAIN>_<TOKEN>", key, envTokenPrefix)
}
// The token ADDRESS is not lowercased here. Its case may be
// significant (base58 on Solana), so it is normalised by the chain's
// own fold once the chain is known, below.
toks = append(toks, tok{
chain: strings.ToLower(rest[:us]),
token: strings.ToLower(rest[us+1:]),
contract: val,
})
case strings.HasPrefix(key, envAddressPrefix):
// NOT lowercased: an r-address is base58 and case-significant. It is
// normalised, like a token address, by the chain's own fold below.
pooledAddr[strings.ToLower(key[len(envAddressPrefix):])] = val
case strings.HasPrefix(key, envRPCPrefix):
rpc[strings.ToLower(key[len(envRPCPrefix):])] = val
case strings.HasPrefix(key, envFeePrefix):
// Refused rather than defaulted: an unreadable fee that fell back to
// zero would silently make us pay every sweep, which is the exact
// cost this setting exists to stop paying.
n, err := strconv.ParseInt(val, 10, 64)
if err != nil || n < 0 {
return nil, fmt.Errorf("depositwatch: %s=%q is not a whole number of cents", key, val)
}
feeCents[strings.ToLower(key[len(envFeePrefix):])] = n
case strings.HasPrefix(key, envSlippagePrefix):
n, err := strconv.ParseInt(val, 10, 32)
if err != nil || n < 0 || n >= 10_000 {
return nil, fmt.Errorf("depositwatch: %s=%q is not a haircut in basis points (0-9999)", key, val)
}
slippageBps[strings.ToLower(key[len(envSlippagePrefix):])] = int32(n)
}
}
// A pooled address on a chain that mints per payer is a misunderstanding
// with a very bad ending — every payer handed the same address on a chain
// where the address is the ONLY thing that says whose money it is — so it
// is refused here rather than ignored. Refusing an unknown chain too keeps
// a typo'd variable from silently configuring nothing.
for chain := range pooledAddr {
if _, ok := chainFamily[cryptopaymentintent.Chain(chain)]; !ok {
return nil, fmt.Errorf("depositwatch: %s%s configures chain %q, which this rail has no reader for; the readable chains are %s",
envAddressPrefix, strings.ToUpper(chain), chain, strings.Join(readableChains(), ", "))
}
if !Pooled(chain) {
return nil, fmt.Errorf("depositwatch: %s%s sets a shared deposit address on chain %q, which mints one address per payer — a shared address there would hand every payer the same destination and lose track of whose money is whose",
envAddressPrefix, strings.ToUpper(chain), chain)
}
}
assets := make([]Asset, 0, len(toks))
seen := map[string]bool{}
for _, t := range toks {
_, pegged := pegCents[t.token]
_, market := marketPriced[t.token]
if pegged && market {
// Two answers to what a customer's coin is worth, and no way for a
// receipt to say which was used. Refused rather than preferring one.
return nil, fmt.Errorf("depositwatch: token %q is both pegged and market-priced — it must be exactly one", t.token)
}
if !pegged && !market {
return nil, fmt.Errorf("depositwatch: %s%s_%s configures token %q, which this rail cannot value — it credits dollar-pegged tokens (%s) and market-priced coins (%s); remove it or add it to one of those tables deliberately",
envTokenPrefix, strings.ToUpper(t.chain), strings.ToUpper(t.token), t.token,
strings.Join(creditableTokens(), ", "), strings.Join(marketTokens(), ", "))
}
if _, ok := chainFamily[cryptopaymentintent.Chain(t.chain)]; !ok {
return nil, fmt.Errorf("depositwatch: %s%s_%s configures chain %q, which this rail has no reader for — a chain nobody can read is money nobody is watching; the readable chains are %s",
envTokenPrefix, strings.ToUpper(t.chain), strings.ToUpper(t.token), t.chain, strings.Join(readableChains(), ", "))
}
a := Asset{
Chain: t.chain,
Token: t.token,
// Absent is zero, and zero deducts nothing — so an operator who
// configures neither gets exactly the behaviour this rail had before
// these existed.
Terms: Terms{
SlippageBps: slippageBps[t.chain],
FeeCents: feeCents[t.chain],
},
}
// A stablecoin is credited at a FIXED peg, so there is no price move to
// protect against and a haircut on one is a fee wearing the word
// slippage. Refused rather than ignored: silently dropping it would
// leave an operator believing a deduction is in force that is not.
if want, ok := nativeChain[t.token]; ok && want != t.chain {
return nil, fmt.Errorf("depositwatch: %s%s_%s configures %s on %s, but %s is native only on %s — the same ticker elsewhere is a WRAPPED asset with its own issuer and de-peg risk, and crediting it at the %s price would value it as something it is not",
envTokenPrefix, strings.ToUpper(t.chain), strings.ToUpper(t.token),
t.token, t.chain, t.token, want, t.token)
}
if a.Terms.SlippageBps > 0 && pegged {
return nil, fmt.Errorf("depositwatch: %s%s sets slippage on %s, which is credited at a fixed peg — there is no market move to hedge; use %s%s if the intent is a fee",
envSlippagePrefix, strings.ToUpper(t.chain), t.token, envFeePrefix, strings.ToUpper(t.chain))
}
a.Contract = a.Fold(t.contract)
if err := a.validateContract(); err != nil {
return nil, fmt.Errorf("depositwatch: %s%s_%s %w",
envTokenPrefix, strings.ToUpper(t.chain), strings.ToUpper(t.token), err)
}
url, ok := rpc[t.chain]
if !ok {
return nil, fmt.Errorf("depositwatch: token %s is configured on chain %q but %s%s is unset — a watched token with no endpoint is money nobody is watching",
t.token, t.chain, envRPCPrefix, strings.ToUpper(t.chain))
}
a.RPCURL = url
// A pooled chain with no configured account is money with nowhere to
// go: the mint side has no address to hand out and no way to invent
// one. That is an incoherent table exactly like a token with no
// endpoint, so it fails the same way — at boot, loudly — rather than
// at the first customer who picks XRPL.
if a.Pooled() {
addr, ok := pooledAddr[t.chain]
if !ok {
return nil, fmt.Errorf("depositwatch: token %s is configured on chain %q, which shares ONE deposit account across payers, but %s%s is unset — there is no address to hand out and none may be invented",
t.token, t.chain, envAddressPrefix, strings.ToUpper(t.chain))
}
a.PooledAddress = a.Fold(addr)
if err := a.validatePooledAddress(); err != nil {
return nil, fmt.Errorf("depositwatch: %s%s %w", envAddressPrefix, strings.ToUpper(t.chain), err)
}
}
if seen[a.Key()] {
continue
}
seen[a.Key()] = true
assets = append(assets, a)
}
// Deterministic order so a boot log, a status read and a test all agree.
sort.Slice(assets, func(i, j int) bool { return assets[i].Key() < assets[j].Key() })
return assets, nil
}
// validateContract checks the configured token address is written the way its
// chain writes one.
//
// This is a SHAPE check and nothing more — it cannot tell USDC's mint from any
// other 32-byte account. What proves the address is the token it is labelled as
// is the chain read in Watcher.verify, which asks the token itself. This one
// exists so an address pasted from the wrong chain entirely fails at boot,
// loudly, instead of at the first deposit.
func (a Asset) validateContract() error {
// A native coin has no contract. The key names it: BITCOIN_BTC is bitcoin.
if a.MarketPriced() {
return nil
}
switch a.Family() {
case FamilySolana:
if !isBase58Account(a.Contract) {
return fmt.Errorf("is not a base58 32-byte SPL mint address: %q", a.Contract)
}
case FamilyTON:
if !isTONAddress(a.Contract) {
return fmt.Errorf("is not a TON jetton master address — expected the 48-character user-friendly form (EQ…/UQ…) or raw <workchain>:<64 hex>: %q", a.Contract)
}
case FamilyXRPL:
// The full parse lives in billing/xrplrpc, which also checksums the
// issuer; this is the cheap boundary check that keeps a bare address or
// a bare currency code out of a slot that needs both.
if !isXRPLIssued(a.Contract) {
return fmt.Errorf("is not an XRPL issued token — expected <CURRENCY>.<ISSUER>, e.g. RLUSD.rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De: %q", a.Contract)
}
default:
if !isHexAddress(a.Contract) {
return fmt.Errorf("is not a 20-byte hex address: %q", a.Contract)
}
}
return nil
}
// validatePooledAddress checks the configured custody account is written the
// way its chain writes an ACCOUNT — which is not how it writes a token.
//
// The same SHAPE-check reasoning as validateContract, and the same limit: this
// cannot tell our account from anyone else's. What it stops is the paste that
// would otherwise be discovered by a customer — the issuer half of a
// <CURRENCY>.<ISSUER> pair, an EVM address, an empty string — landing in the
// slot that decides where money is sent.
func (a Asset) validatePooledAddress() error {
switch a.Family() {
case FamilyXRPL:
if !isXRPLAddress(a.PooledAddress) {
return fmt.Errorf("is not an XRPL account address — expected the classic r-address form, e.g. rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De: %q", a.PooledAddress)
}
default:
// Unreachable: only pooled chains get here, and XRPL is the only one.
// It is a refusal rather than a nil so that pooling a second chain
// fails here instead of accepting whatever that chain calls an address.
return fmt.Errorf("chain %q is pooled but has no address form defined here", a.Chain)
}
return nil
}
// creditableTokens lists the tokens with a declared peg, sorted.
func creditableTokens() []string {
out := make([]string, 0, len(pegCents))
for t := range pegCents {
out = append(out, t)
}
sort.Strings(out)
return out
}
// marketTokens lists the tokens credited at a live rate, sorted.
func marketTokens() []string {
out := make([]string, 0, len(marketPriced))
for t := range marketPriced {
out = append(out, t)
}
sort.Strings(out)
return out
}
// readableChains lists the chains this rail has a reader for, sorted.
func readableChains() []string {
out := make([]string, 0, len(chainFamily))
for c := range chainFamily {
out = append(out, string(c))
}
sort.Strings(out)
return out
}
// isHexAddress reports whether s is a 0x-prefixed 20-byte hex address.
func isHexAddress(s string) bool {
if !strings.HasPrefix(s, "0x") || len(s) != 42 {
return false
}
for _, r := range s[2:] {
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') {
return false
}
}
return true
}
// base58Alphabet is Bitcoin/Solana base58: no 0, O, I or l, so the characters a
// human is most likely to confuse cannot both be valid.
const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
// isBase58Account reports whether s could be a 32-byte Solana address.
//
// The length is checked in CHARACTERS because base58 carries no length of its
// own: 32 bytes encodes to 32–44 characters depending on how many leading zero
// bytes it has. Decoding it properly is billing/solanarpc's job and it does
// refuse anything that is not exactly 32 bytes; this is the cheap boundary
// check that keeps an EVM address out of a Solana slot at boot.
func isBase58Account(s string) bool {
if len(s) < 32 || len(s) > 44 {
return false
}
for _, r := range s {
if !strings.ContainsRune(base58Alphabet, r) {
return false
}
}
return true
}
// isTONAddress reports whether s could be a TON account address, in either of
// the two forms TON publishes: the 48-character user-friendly base64url form
// (36 bytes: flags, workchain, 32-byte hash, CRC-16) that every explorer and
// token page shows, or the raw <workchain>:<64 hex> form the indexer answers
// in. Both are accepted because both are pasted, and billing/tonrpc parses both
// with the same function — including the CRC that catches a typo.
func isTONAddress(s string) bool {
if wc, hash, ok := strings.Cut(s, ":"); ok {
if wc == "" || len(hash) != 64 {
return false
}
if wc[0] == '-' {
wc = wc[1:]
}
return wc != "" && allASCIIDigits(wc) && isHex(hash)
}
if len(s) != 48 {
return false
}
for _, r := range s {
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '-' || r == '_' || r == '+' || r == '/' || r == '=') {
return false
}
}
return true
}
// isXRPLIssued reports whether s could be an XRPL <CURRENCY>.<ISSUER> pair: a
// 3-character or 40-hex currency code, and a classic r-address.
func isXRPLIssued(s string) bool {
cur, issuer, ok := strings.Cut(s, ".")
if !ok {
return false
}
// A currency code is 3 ASCII characters, a ticker of up to 20, or the raw
// 40-hex form. billing/xrplrpc turns all three into the same 160 bits.
if cur == "" || len(cur) > 40 || (len(cur) > 20 && !(len(cur) == 40 && isHex(cur))) {
return false
}
return isXRPLAddress(issuer)
}
// isXRPLAddress reports whether s could be an XRPL classic account address: an
// r-prefixed base58 string in XRPL's own alphabet, 25–35 characters.
//
// It is ONE function because an XRPL account is an XRPL account whether it
// appears as a token's issuer or as the custody account deposits are sent to.
// Two copies of this rule would be two chances for the issuer half of a token
// to be accepted somewhere the account half is not.
func isXRPLAddress(s string) bool {
if len(s) < 25 || len(s) > 35 || s[0] != 'r' {
return false
}
for _, r := range s {
if !strings.ContainsRune(rippleAlphabet, r) {
return false
}
}
return true
}
// rippleAlphabet is XRPL's base58 alphabet — the same 58 characters as
// Bitcoin's in a DIFFERENT order, which is why an XRPL address must never be
// checked against base58Alphabet above.
const rippleAlphabet = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"
func isHex(s string) bool {
for _, r := range s {
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f' || r >= 'A' && r <= 'F') {
return false
}
}
return len(s) > 0
}
func allASCIIDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return len(s) > 0
}
// ErrDust is returned by AmountCents for a transfer worth less than one cent.
// It is not a failure: sub-cent dust is a real transfer that credits nothing.
var ErrDust = errors.New("depositwatch: transfer is worth less than one cent")
// ErrUnderFee is a transfer worth something but less than Terms.FeeCents.
// Separate from ErrDust because the customer needs different words: dust is "you
// sent almost nothing", this is "it costs more than that to move it".
var ErrUnderFee = errors.New("depositwatch: transfer does not cover the network fee")
// Terms are what the rail deducts before crediting. Zero deducts nothing.
type Terms struct {
// SlippageBps is a haircut against the price moving between the rate quoted
// and the rate credited — a deposit is priced at CONFIRMATION, and BTC
// confirms about an hour after it is sent. Market-priced assets only: on a
// fixed peg there is no move, so a haircut there is a fee by another name.
SlippageBps int32
// FeeCents is what it costs US to sweep the deposit out of the custody
// address. NOT the sender's own network fee — they already paid that, and
// deducting it again charges twice for one transfer. On Bitcoin a sweep can
// cost more than a small deposit is worth.
FeeCents int64
}
// Deducts reports whether these terms take anything at all, so a caller can say
// "no deductions" rather than "0 and 0" and a receipt can omit the rows.
func (t Terms) Deducts() bool { return t.SlippageBps > 0 || t.FeeCents > 0 }
// RateResolver answers what one whole unit is worth, in cents × RateScale.
//
// Whether a rate may be BELIEVED — how many venues agreed, how far apart — is
// the oracle's judgement, not this package's. An error credits nothing and
// retries: the coin is in custody, and a wrong rate is wrong permanently.
//
// Consulted per CREDIT, not per pass: a rate reused across a long scan prices a
// deposit at a moment that had passed.
type RateResolver interface {
MicroCents(ctx context.Context, priceID string) (int64, error)
}
// RateScale matches the oracle's. Whole cents under-credit a sub-dollar coin:
// XRP at $1.04295 becomes $1.04, 0.28% of every deposit.
const RateScale = 1_000_000
// TermsResolver answers what one ORG is charged on a chain, overriding the
// asset's platform default. Deductions are commercial terms, not platform facts:
// an RPC endpoint is the same for everybody, what a customer pays to sweep is
// agreed with that customer.
//
// ok=false means "no opinion, use the default" — distinct from zero Terms, which
// means "this org pays nothing". An org negotiated to nothing must not look like
// an org nobody configured.
//
// Per CHAIN, because both costs are the chain's: a sweep on Ethereum costs
// Ethereum gas whether it moves USDC or USDT.
type TermsResolver interface {
TermsFor(ctx context.Context, org, chain string) (t Terms, ok bool, err error)
}
// resolveTerms applies the override, failing CLOSED. An error does NOT fall back
// to the default: the default is usually cheaper, so falling back would credit
// an org on terms it is not on, invisibly.
func resolveTerms(ctx context.Context, r TermsResolver, org, chain string, def Terms) (Terms, error) {
if r == nil {
return def, nil
}
t, ok, err := r.TermsFor(ctx, org, chain)
if err != nil {
return Terms{}, fmt.Errorf("depositwatch: terms for org %q on %s: %w", org, chain, err)
}
if !ok {
return def, nil
}
return t, nil
}
// AmountCents converts a raw on-chain amount into the USD cents to credit:
//
// gross = units × rateMicroCents / (10^decimals × RateScale)
// net = gross × (10000 - SlippageBps) / 10000 − FeeCents
//
// The rate is in MICRO-CENTS so a pegged token and a market-priced one take the
// SAME path: a dollar peg is 100 × RateScale, and the oracle's quote arrives at
// that precision already. Whole cents would under-credit a sub-dollar coin by a
// fixed fraction of every deposit (XRP at $1.04295 rounds to $1.04, 0.28% off).
//
// Truncating, always DOWN. A sub-cent remainder is dropped rather than rounded
// up, so the rail can never credit value that was not sent; the dust stays on
// chain in the custody address where it can still be swept.
//
// THE DEDUCTIONS ARE APPLIED IN ONE DIVISION, not three. Multiplying out first
// and dividing once keeps the truncation to a single place; taking the peg,
// then the haircut, then the fee as three roundings would lose up to three
// cents on every deposit and lose them silently.
//
// Zero Terms is the historical behaviour EXACTLY — no haircut, no fee — so a
// dollar-pegged token on a cheap chain is unaffected by this existing at all.
//
// decimals comes from the token contract (Client.Decimals), never from config.
// This is the arithmetic that turns a 6-decimal token read as 18 into a credit
// 10^12 too large, so it refuses every input it cannot justify rather than
// producing a number: a nil or negative amount, an implausible decimals, an
// unpegged token, nonsensical terms, and a result that does not fit in the
// ledger's int64 cents.
func AmountCents(units *big.Int, decimals int, rateMicroCents int64, terms Terms) (int64, error) {
if units == nil || units.Sign() < 0 {
return 0, errors.New("depositwatch: transfer amount must be non-negative")
}
// A token with fewer than 2 decimals cannot express a cent, and one with
// more than 36 is outside anything the ERC-20 world has produced — either is
// a contract we do not understand well enough to credit.
if decimals < 2 || decimals > 36 {
return 0, fmt.Errorf("depositwatch: unusable token decimals %d", decimals)
}
if rateMicroCents <= 0 {
return 0, fmt.Errorf("depositwatch: token has no USD rate")
}
// A haircut at or over 100% credits nothing however much was sent, and a
// negative one credits MORE than arrived. Both are configuration nobody meant
// to write, and both are silent once the arithmetic runs.
if terms.SlippageBps < 0 || terms.SlippageBps >= 10_000 {
return 0, fmt.Errorf("depositwatch: slippage of %d bps is not a haircut", terms.SlippageBps)
}
if terms.FeeCents < 0 {
return 0, fmt.Errorf("depositwatch: fee of %d cents would credit more than was sent", terms.FeeCents)
}
// ONE division, at the end. units × peg × (10000-bps) on top, 10^decimals ×
// 10000 underneath, so the only rounding is the final truncation down.
const bpsScale = 10_000
num := new(big.Int).Mul(units, big.NewInt(rateMicroCents))
num.Mul(num, big.NewInt(int64(bpsScale-terms.SlippageBps)))
den := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)
den.Mul(den, big.NewInt(bpsScale))
den.Mul(den, big.NewInt(RateScale))
cents := new(big.Int).Quo(num, den)
if !cents.IsInt64() {
return 0, fmt.Errorf("depositwatch: %s base units is %s cents, which overflows the ledger", units, cents)
}
if cents.Sign() == 0 {
return 0, ErrDust
}
// The fee comes off LAST and in whole cents, because it is a cost we pay in
// dollars rather than a proportion of what arrived.
net := cents.Int64() - terms.FeeCents
if net <= 0 {
// Worth something, but not enough to move. Distinct from dust: this
// customer sent real money, and the honest thing is a different word.
return 0, fmt.Errorf("%w: %d cents arrived, fee is %d", ErrUnderFee, cents.Int64(), terms.FeeCents)
}
return net, nil
}