Files
commerce/models/cryptopaymentintent/cryptopaymentintent.go
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

286 lines
10 KiB
Go

package cryptopaymentintent
import (
"fmt"
"time"
"github.com/hanzoai/commerce/datastore"
"github.com/hanzoai/commerce/models/mixin"
"github.com/hanzoai/orm"
. "github.com/hanzoai/commerce/types"
)
// Status represents the lifecycle state of a crypto payment.
type Status string
const (
Pending Status = "pending" // Awaiting deposit
Confirming Status = "confirming" // Deposit detected, awaiting confirmations
Succeeded Status = "succeeded" // Fully confirmed + settled
Expired Status = "expired" // No deposit received before timeout
Failed Status = "failed" // Deposit failed (reorg, etc.)
Refunded Status = "refunded" // Refund issued on-chain
)
// Chain represents a supported blockchain.
type Chain string
const (
Ethereum Chain = "ethereum"
Solana Chain = "solana"
Base Chain = "base"
Polygon Chain = "polygon"
Arbitrum Chain = "arbitrum"
Optimism Chain = "optimism"
Avalanche Chain = "avalanche"
BSC Chain = "bsc"
Lux Chain = "lux"
Zoo Chain = "zoo"
TON Chain = "ton"
XRPL Chain = "xrpl"
// Bitcoin is the rail's only UTXO chain. It has no accounts and no
// contracts: a deposit is an OUTPUT paying our address, which is why
// billing/bitcoinrpc is its own reader rather than a variant of another.
Bitcoin Chain = "bitcoin"
)
func init() { orm.Register[CryptoPaymentIntent]("crypto-payment-intent") }
// CryptoPaymentIntent represents a custodial crypto/stablecoin payment flow.
type CryptoPaymentIntent struct {
mixin.Model[CryptoPaymentIntent]
// Amount in smallest unit of settlement currency (cents for USD)
Amount int64 `json:"amount"`
// Settlement currency (fiat, e.g., "usd")
Currency string `json:"currency" orm:"default:usd"`
// Blockchain where deposit is expected
Chain Chain `json:"chain"`
// Token on the chain (e.g., "usdc", "usdt", "eth")
Token string `json:"token"`
// Per-payment deposit address (generated by custody service)
DepositAddress string `json:"depositAddress"`
// AddressTag is the routing tag that, WITH DepositAddress, says this
// payment is ours — on a chain where the address alone cannot.
//
// It exists for XRPL and is empty everywhere else. XRPL charges a
// NON-REFUNDABLE base reserve in XRP for every funded account, so a fresh
// address per payer strands that reserve on every deposit; the model the
// ledger actually uses is one custody account shared by everybody plus a
// per-deposit DESTINATION TAG. So on XRPL, DepositAddress is the SAME
// string on every intent and this is the part that differs.
//
// A payment that reaches the shared address carrying no tag, or a tag that
// matches no intent, belongs to nobody: it is recorded as an unattributed
// deposit and credited to no one. See billing/depositwatch.Unattributed.
AddressTag string `json:"addressTag,omitempty"`
// The custody signer's OWN handle for the wallet behind DepositAddress.
//
// An address says where funds land; this says which wallet the signer must
// be asked to sign a spend from. Without it a deposit can be credited and
// never SWEPT — the coins sit in a wallet we can identify only by
// reconciling against the node's records — so it is recorded at mint time,
// which is the only moment it is offered. Empty for providers that have no
// such concept (they address wallets by the address itself).
WalletID string `json:"walletId,omitempty"`
// Customer reference
CustomerRef string `json:"customerRef,omitempty"`
// Current status
Status Status `json:"status" orm:"default:pending"`
// Number of confirmations received
Confirmations int `json:"confirmations"`
// Number of confirmations required
RequiredConfirmations int `json:"requiredConfirmations"`
// On-chain transaction hash (once detected)
TxHash string `json:"txHash,omitempty"`
// Block number where deposit was included
BlockNumber int64 `json:"blockNumber,omitempty"`
// Payment expiry (typically 30-60 minutes)
ExpiresAt time.Time `json:"expiresAt"`
// Fiat-equivalent amount after conversion
SettlementAmount int64 `json:"settlementAmount"`
// Settlement currency
SettlementCurrency string `json:"settlementCurrency" orm:"default:usd"`
// Exchange rate at time of settlement
ExchangeRate string `json:"exchangeRate,omitempty"`
// Crypto amount in token's smallest unit (wei, lamports, etc.)
CryptoAmount string `json:"cryptoAmount,omitempty"`
// Refund transaction hash (if refunded)
RefundTxHash string `json:"refundTxHash,omitempty"`
// OFAC screening result
ScreeningStatus string `json:"screeningStatus,omitempty"` // clear, flagged, blocked
Metadata Map `json:"metadata,omitempty" orm:"default:{}"`
}
// Defaults sets runtime-computed defaults that cannot be expressed as struct tags.
func (cpi *CryptoPaymentIntent) Defaults() {
if cpi.ExpiresAt.IsZero() {
cpi.ExpiresAt = time.Now().Add(30 * time.Minute)
}
if cpi.RequiredConfirmations == 0 && cpi.Chain != "" {
cpi.RequiredConfirmations = RequiredConfirmationsForChain(cpi.Chain)
}
}
// MarkConfirming transitions to confirming state when deposit is detected.
func (cpi *CryptoPaymentIntent) MarkConfirming(txHash string, blockNumber int64) error {
if cpi.Status != Pending {
return fmt.Errorf("cannot mark confirming: current status %s", cpi.Status)
}
cpi.Status = Confirming
cpi.TxHash = txHash
cpi.BlockNumber = blockNumber
return nil
}
// ClearSighting returns a confirming intent to pending because the transaction
// it was confirming is no longer on the canonical chain. It is the reorg
// counterpart of MarkConfirming, and it exists so that "a deposit vanished" is a
// transition on this state machine rather than a watcher reaching in and setting
// fields.
//
// It is reachable ONLY from Confirming, which is the safety property: a
// succeeded intent has already been credited, and a reorg deep enough to undo a
// fully-confirmed deposit is not something to silently paper over here.
func (cpi *CryptoPaymentIntent) ClearSighting() error {
if cpi.Status != Confirming {
return fmt.Errorf("cannot clear sighting: current status %s", cpi.Status)
}
cpi.Status = Pending
cpi.TxHash = ""
cpi.BlockNumber = 0
cpi.Confirmations = 0
return nil
}
// AddConfirmation records a new block confirmation.
func (cpi *CryptoPaymentIntent) AddConfirmation() {
cpi.Confirmations++
}
// IsFullyConfirmed returns true if required confirmations are met.
func (cpi *CryptoPaymentIntent) IsFullyConfirmed() bool {
return cpi.Confirmations >= cpi.RequiredConfirmations
}
// MarkSucceeded transitions to succeeded after full confirmation.
func (cpi *CryptoPaymentIntent) MarkSucceeded(settlementAmount int64, exchangeRate string) error {
if cpi.Status != Confirming {
return fmt.Errorf("cannot mark succeeded: current status %s", cpi.Status)
}
if !cpi.IsFullyConfirmed() {
return fmt.Errorf("insufficient confirmations: %d/%d", cpi.Confirmations, cpi.RequiredConfirmations)
}
cpi.Status = Succeeded
cpi.SettlementAmount = settlementAmount
cpi.ExchangeRate = exchangeRate
return nil
}
// MarkExpired transitions to expired if no deposit received.
func (cpi *CryptoPaymentIntent) MarkExpired() error {
if cpi.Status != Pending {
return fmt.Errorf("cannot expire: current status %s", cpi.Status)
}
cpi.Status = Expired
return nil
}
// MarkFailed transitions to failed (e.g., reorg caused loss).
func (cpi *CryptoPaymentIntent) MarkFailed(reason string) error {
if cpi.Status == Succeeded || cpi.Status == Refunded {
return fmt.Errorf("cannot fail: current status %s", cpi.Status)
}
cpi.Status = Failed
return nil
}
// IsExpired returns true if the payment window has closed.
func (cpi *CryptoPaymentIntent) IsExpired() bool {
return !cpi.ExpiresAt.IsZero() && time.Now().After(cpi.ExpiresAt)
}
// RequiredConfirmationsForChain returns how deep a deposit must be buried before
// it may be credited. It is the ONE confirmation policy in this codebase — the
// deposit watcher (billing/depositwatch) asks this and nothing else, so the
// depth a customer's money is judged by lives beside the state machine that
// spends it rather than in a scanner's private table.
//
// Every chain we accept is named explicitly. The default exists for a chain that
// is added to the address rail before it is added here, and it is the STRICTEST
// value, not a shrug: an unknown chain gets Ethereum's depth rather than a
// guess biased toward crediting sooner.
func RequiredConfirmationsForChain(chain Chain) int {
switch chain {
case Ethereum:
return 12
case Base, Polygon, Arbitrum, Optimism:
// Rollups and Polygon PoS: cheap blocks, so depth costs the customer
// seconds, and Polygon PoS in particular has reorged tens of blocks.
return 20
case BSC:
// BSC has produced multi-block reorgs; 15 is the depth its own
// documentation and the major exchanges settle on.
return 15
case Avalanche, Lux, Zoo:
// Sub-second, single-slot finality (Snowman / Quasar). 12 is far past
// final and costs ~25s — margin we take rather than argue about.
return 12
case Solana:
return 32
case TON:
// TON masterchain blocks are FAST — measured at 0.39s apiece against
// mainnet — so a depth that looks large is about thirty seconds of
// waiting, in line with every other chain here. It is margin and not
// safety: a TON transaction is final when the masterchain block
// referencing its shard block commits, and this client reads nothing
// else (billing/tonrpc.BlockNumber), so what it can SEE is already
// final.
return 80
case XRPL:
// XRPL ledgers close every 3.81 seconds — measured against mainnet —
// and are FINAL on validation:
// consensus declares a ledger validated, it is not buried under
// competing work — and this client reads only validated ledgers
// (billing/xrplrpc). Eight is ~30 seconds of margin inside
// already-final territory, kept so there is one confirmation policy
// across chains rather than an XRPL-shaped exception.
return 8
default:
return 12
}
}
func New(db *datastore.Datastore) *CryptoPaymentIntent {
cpi := new(CryptoPaymentIntent)
cpi.Init(db)
cpi.Parent = db.NewKey("synckey", "", 1, nil)
cpi.Defaults()
return cpi
}
func Query(db *datastore.Datastore) datastore.Query {
return db.Query("crypto-payment-intent")
}