mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
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
286 lines
10 KiB
Go
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")
|
|
}
|