mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
466 lines
16 KiB
Go
466 lines
16 KiB
Go
package coinbaseprime
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/go-libs/v5/pkg/types/currency"
|
|
"github.com/hanzo-fi/payments/ee/plugins/coinbaseprime/client"
|
|
"github.com/hanzo-fi/payments/pkg/domain/models"
|
|
)
|
|
|
|
// resolveWallets builds a fresh symbol→walletID map from Coinbase Prime each
|
|
// time FetchNextOrders runs. The plugin should not read Formance storage:
|
|
// Coinbase remains the source of truth for the PSP wallet IDs needed to link
|
|
// orders to account references.
|
|
//
|
|
// Only TRADING wallets are indexed: Coinbase Prime paginates TRADING /
|
|
// VAULT / ONCHAIN / QC / WALLET_TYPE_OTHER separately (see GET /wallets
|
|
// `type` query param — https://docs.cdp.coinbase.com/prime/reference/primerestapi_getportfoliowallets),
|
|
// so a single symbol (e.g. "USD") can back multiple accounts with the same
|
|
// DefaultAsset. Orders debit/credit only the TRADING wallet (see the
|
|
// `source_type` / `destination_type` fields on create-order —
|
|
// https://docs.cdp.coinbase.com/prime/reference/primerestapi_createorder),
|
|
// so non-TRADING rows must be excluded — otherwise a VAULT or ONCHAIN row
|
|
// could stomp the TRADING row in the map and resolution would return the
|
|
// wrong account.
|
|
func (p *Plugin) resolveWallets(ctx context.Context) (map[string]string, error) {
|
|
wallets := make(map[string]string)
|
|
cursor := ""
|
|
for {
|
|
response, err := p.client.GetWallets(ctx, walletTypeTrading, cursor, PAGE_SIZE)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing trading wallets: %w", err)
|
|
}
|
|
|
|
for _, wallet := range response.Wallets {
|
|
symbol := strings.ToUpper(strings.TrimSpace(wallet.Symbol))
|
|
if symbol == "" {
|
|
continue
|
|
}
|
|
wallets[symbol] = wallet.ID
|
|
}
|
|
|
|
if !response.Pagination.HasNext {
|
|
break
|
|
}
|
|
if response.Pagination.NextCursor == "" {
|
|
return nil, fmt.Errorf("listing trading wallets: next cursor missing")
|
|
}
|
|
cursor = response.Pagination.NextCursor
|
|
}
|
|
|
|
return wallets, nil
|
|
}
|
|
|
|
func (p *Plugin) fetchNextOrders(ctx context.Context, req models.FetchNextOrdersRequest) (models.FetchNextOrdersResponse, error) {
|
|
var oldState incrementalState
|
|
if req.State != nil {
|
|
if err := json.Unmarshal(req.State, &oldState); err != nil {
|
|
return models.FetchNextOrdersResponse{}, fmt.Errorf("failed to unmarshal state: %w", err)
|
|
}
|
|
}
|
|
|
|
wallets, err := p.resolveWallets(ctx)
|
|
if err != nil {
|
|
return models.FetchNextOrdersResponse{}, err
|
|
}
|
|
|
|
ordersResp, err := p.client.ListOrders(ctx, oldState.Cursor, req.PageSize)
|
|
if err != nil {
|
|
return models.FetchNextOrdersResponse{}, fmt.Errorf("failed to list orders: %w", err)
|
|
}
|
|
|
|
pspOrders := make([]models.PSPOrder, 0, len(ordersResp.Orders))
|
|
for _, order := range ordersResp.Orders {
|
|
pspOrder, err := p.clientOrderToPSPOrder(ctx, order, wallets)
|
|
if err != nil {
|
|
// Fail the batch when any order in the page references an
|
|
// unresolved wallet (or anything else goes wrong). This is the
|
|
// safe default: the cursor is not advanced, the page is
|
|
// retried, and no order is dropped. This allows Temporal
|
|
// retries, e.g. once a missing account is fetched during
|
|
// fetchNextAccounts.
|
|
return models.FetchNextOrdersResponse{}, fmt.Errorf("failed to convert order %s: %w", order.ID, err)
|
|
}
|
|
pspOrders = append(pspOrders, pspOrder)
|
|
}
|
|
|
|
newState := incrementalState{Cursor: advanceCursor(oldState.Cursor, ordersResp.Pagination.NextCursor)}
|
|
|
|
stateBytes, err := json.Marshal(newState)
|
|
if err != nil {
|
|
return models.FetchNextOrdersResponse{}, fmt.Errorf("failed to marshal state: %w", err)
|
|
}
|
|
|
|
return models.FetchNextOrdersResponse{
|
|
Orders: pspOrders,
|
|
NewState: stateBytes,
|
|
HasMore: ordersResp.Pagination.HasNext,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Plugin) clientOrderToPSPOrder(ctx context.Context, order client.Order, wallets map[string]string) (models.PSPOrder, error) {
|
|
raw, err := json.Marshal(order)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to marshal order raw: %w", err)
|
|
}
|
|
|
|
parts := strings.Split(order.ProductID, "-")
|
|
if len(parts) != 2 {
|
|
return models.PSPOrder{}, fmt.Errorf("invalid product ID: %s", order.ProductID)
|
|
}
|
|
baseSymbol, quoteSymbol := parts[0], parts[1]
|
|
|
|
baseAsset, _, baseOk, err := p.resolveAssetAndPrecision(ctx, baseSymbol)
|
|
if err != nil {
|
|
return models.PSPOrder{}, err
|
|
}
|
|
if !baseOk {
|
|
return models.PSPOrder{}, fmt.Errorf("unsupported base asset: %s", baseSymbol)
|
|
}
|
|
quoteAsset, _, quoteOk, err := p.resolveAssetAndPrecision(ctx, quoteSymbol)
|
|
if err != nil {
|
|
return models.PSPOrder{}, err
|
|
}
|
|
if !quoteOk {
|
|
return models.PSPOrder{}, fmt.Errorf("unsupported quote asset: %s", quoteSymbol)
|
|
}
|
|
|
|
// Pull a single snapshot of currencies to thread through parseOrderQuantity /
|
|
// parseFeeFields. getAssets is cheap on the fresh-cache fast path (the two
|
|
// resolveAssetAndPrecision calls above just refreshed the TTL).
|
|
currencies, _, err := p.getAssets(ctx)
|
|
if err != nil {
|
|
return models.PSPOrder{}, err
|
|
}
|
|
|
|
quoteWallet := wallets[strings.ToUpper(quoteSymbol)]
|
|
baseWallet := wallets[strings.ToUpper(baseSymbol)]
|
|
if quoteWallet == "" {
|
|
return models.PSPOrder{}, fmt.Errorf("unresolved wallet for quote symbol %q on order %s (will retry after next accounts cycle)", quoteSymbol, order.ID)
|
|
}
|
|
if baseWallet == "" {
|
|
return models.PSPOrder{}, fmt.Errorf("unresolved wallet for base symbol %q on order %s (will retry after next accounts cycle)", baseSymbol, order.ID)
|
|
}
|
|
|
|
// BUY BTC-USD: source=USD (spend), target=BTC (receive)
|
|
// SELL BTC-USD: source=BTC (spend), target=USD (receive)
|
|
direction := models.ORDER_DIRECTION_BUY
|
|
sourceAsset, destinationAsset := quoteAsset, baseAsset
|
|
if strings.ToUpper(order.Side) == "SELL" {
|
|
direction = models.ORDER_DIRECTION_SELL
|
|
sourceAsset, destinationAsset = baseAsset, quoteAsset
|
|
}
|
|
|
|
orderType, knownType := mapCoinbaseOrderType(order.Type)
|
|
if !knownType {
|
|
p.logger.Infof("unknown coinbase order type %q for order %s, defaulting to MARKET", order.Type, order.ID)
|
|
}
|
|
status, knownStatus := mapCoinbaseStatus(order.Status, order.BaseQuantity, order.FilledQuantity)
|
|
if !knownStatus {
|
|
p.logger.Infof("unknown coinbase order status %q for order %s, defaulting to PENDING", order.Status, order.ID)
|
|
}
|
|
timeInForce, _ := models.TimeInForceFromString(strings.ToUpper(order.TimeInForce))
|
|
if timeInForce == models.TIME_IN_FORCE_UNKNOWN {
|
|
timeInForce = models.TIME_IN_FORCE_GOOD_UNTIL_CANCELLED
|
|
}
|
|
|
|
baseQuantityOrdered, err := parseOrderQuantity(currencies, order.BaseQuantity, baseSymbol)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to parse base quantity: %w", err)
|
|
}
|
|
|
|
baseQuantityFilled, err := parseOrderQuantity(currencies, order.FilledQuantity, baseSymbol)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to parse filled quantity: %w", err)
|
|
}
|
|
|
|
// Quote amount (filled_value) at quote precision — the exact USD amount
|
|
quoteAmount, err := parseOrderQuantity(currencies, order.FilledValue, quoteSymbol)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to parse filled value: %w", err)
|
|
}
|
|
|
|
// Fee at quote precision
|
|
fee, feeAsset, err := parseFeeFields(currencies, order, quoteSymbol, quoteAsset)
|
|
if err != nil {
|
|
return models.PSPOrder{}, err
|
|
}
|
|
|
|
// Use dynamic precision: max decimal places across all price fields for this
|
|
// order, so all prices are at the same scale and directly comparable.
|
|
pricePrecision := maxPricePrecision(order.LimitPrice, order.StopPrice, order.AverageFilledPrice)
|
|
|
|
limitPrice, err := p.parseOptionalPrice(order.LimitPrice, pricePrecision)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to parse limit price: %w", err)
|
|
}
|
|
|
|
stopPrice, err := p.parseOptionalPrice(order.StopPrice, pricePrecision)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to parse stop price: %w", err)
|
|
}
|
|
|
|
avgFillPrice, err := p.parseOptionalPrice(order.AverageFilledPrice, pricePrecision)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to parse average filled price: %w", err)
|
|
}
|
|
|
|
createdAt, err := time.Parse(time.RFC3339, order.CreatedAt)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to parse order createdAt %q: %w", order.CreatedAt, err)
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if order.ExpiryTime != "" {
|
|
t, err := time.Parse(time.RFC3339, order.ExpiryTime)
|
|
if err != nil {
|
|
return models.PSPOrder{}, fmt.Errorf("failed to parse order expiryTime %q: %w", order.ExpiryTime, err)
|
|
}
|
|
expiresAt = &t
|
|
}
|
|
|
|
metadata := p.buildOrderMetadata(order, baseSymbol, quoteSymbol, pricePrecision, wallets)
|
|
priceAsset := fmt.Sprintf("%s/%d", quoteSymbol, pricePrecision)
|
|
|
|
// Resolve account references from wallet map. Both wallet IDs were
|
|
// validated non-empty at the top of this function, so they are guaranteed
|
|
// to resolve here.
|
|
// BUY: source = quote wallet (USD out), destination = base wallet (crypto in)
|
|
// SELL: source = base wallet (crypto out), destination = quote wallet (USD in)
|
|
var sourceAccountRef, destAccountRef *string
|
|
if direction == models.ORDER_DIRECTION_BUY {
|
|
sourceAccountRef = "eWallet
|
|
destAccountRef = &baseWallet
|
|
} else {
|
|
sourceAccountRef = &baseWallet
|
|
destAccountRef = "eWallet
|
|
}
|
|
|
|
return models.PSPOrder{
|
|
Reference: order.ID,
|
|
ClientOrderID: order.ClientOrderID,
|
|
CreatedAt: createdAt,
|
|
Direction: direction,
|
|
Type: orderType,
|
|
SourceAsset: sourceAsset,
|
|
DestinationAsset: destinationAsset,
|
|
BaseQuantityOrdered: baseQuantityOrdered,
|
|
BaseQuantityFilled: baseQuantityFilled,
|
|
LimitPrice: limitPrice,
|
|
StopPrice: stopPrice,
|
|
QuoteAmount: quoteAmount,
|
|
QuoteAsset: quoteAsset,
|
|
AverageFillPrice: avgFillPrice,
|
|
Fee: fee,
|
|
FeeAsset: feeAsset,
|
|
PriceAsset: &priceAsset,
|
|
SourceAccountReference: sourceAccountRef,
|
|
DestinationAccountReference: destAccountRef,
|
|
Status: status,
|
|
TimeInForce: timeInForce,
|
|
ExpiresAt: expiresAt,
|
|
Metadata: metadata,
|
|
Raw: raw,
|
|
}, nil
|
|
}
|
|
|
|
func mapCoinbaseOrderType(t string) (models.OrderType, bool) {
|
|
switch strings.ToUpper(t) {
|
|
case "MARKET":
|
|
return models.ORDER_TYPE_MARKET, true
|
|
case "LIMIT":
|
|
return models.ORDER_TYPE_LIMIT, true
|
|
case "STOP_LIMIT":
|
|
return models.ORDER_TYPE_STOP_LIMIT, true
|
|
case "TWAP":
|
|
return models.ORDER_TYPE_TWAP, true
|
|
case "VWAP":
|
|
return models.ORDER_TYPE_VWAP, true
|
|
case "PEG":
|
|
return models.ORDER_TYPE_PEG, true
|
|
case "BLOCK":
|
|
return models.ORDER_TYPE_BLOCK, true
|
|
case "RFQ":
|
|
return models.ORDER_TYPE_RFQ, true
|
|
default:
|
|
return models.ORDER_TYPE_MARKET, false
|
|
}
|
|
}
|
|
|
|
func parseFeeFields(currencies map[string]int, order client.Order, quoteSymbol, quoteAsset string) (*big.Int, *string, error) {
|
|
if order.Commission == "" {
|
|
return nil, nil, nil
|
|
}
|
|
fee, err := parseOrderQuantity(currencies, order.Commission, quoteSymbol)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to parse commission: %w", err)
|
|
}
|
|
return fee, "eAsset, nil
|
|
}
|
|
|
|
// parseOptionalPrice parses price fields that may contain float artifacts from
|
|
// the exchange. Uses parseDecimalString (truncating) instead of go-libs (strict)
|
|
// because Coinbase returns prices like "1825.6099999998417653".
|
|
func (p *Plugin) parseOptionalPrice(value string, precision int) (*big.Int, error) {
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
return parseDecimalString(value, precision)
|
|
}
|
|
|
|
// parseDecimalString converts a decimal string to *big.Int using pure string
|
|
// manipulation -- no float64 anywhere -- truncating excess decimals to handle
|
|
// float artifacts from exchange APIs.
|
|
func parseDecimalString(value string, precision int) (*big.Int, error) {
|
|
if value == "" {
|
|
return nil, fmt.Errorf("empty decimal string")
|
|
}
|
|
|
|
// Strip leading/trailing whitespace
|
|
value = strings.TrimSpace(value)
|
|
|
|
parts := strings.SplitN(value, ".", 2)
|
|
intPart := parts[0]
|
|
fracPart := ""
|
|
if len(parts) == 2 {
|
|
fracPart = parts[1]
|
|
}
|
|
|
|
if len(fracPart) > precision {
|
|
fracPart = fracPart[:precision]
|
|
} else {
|
|
fracPart += strings.Repeat("0", precision-len(fracPart))
|
|
}
|
|
|
|
result, ok := new(big.Int).SetString(intPart+fracPart, 10)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid decimal: %s", value)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// decimalPlaces returns the number of digits after the decimal point in a string.
|
|
func decimalPlaces(s string) int {
|
|
if i := strings.IndexByte(s, '.'); i >= 0 {
|
|
return len(s) - i - 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// maxPricePrecision returns the max decimal places across non-empty values,
|
|
// capped at 10. Coinbase returns prices computed with float64, which can
|
|
// produce strings like "1825.6099999998417653" -- the trailing digits past
|
|
// ~10 decimals are float noise, not real precision.
|
|
const maxPricePrecisionCap = 10
|
|
|
|
func maxPricePrecision(values ...string) int {
|
|
m := 0
|
|
for _, v := range values {
|
|
if d := decimalPlaces(v); d > m {
|
|
m = d
|
|
}
|
|
}
|
|
if m > maxPricePrecisionCap {
|
|
m = maxPricePrecisionCap
|
|
}
|
|
return m
|
|
}
|
|
|
|
// buildOrderMetadata captures valuable Coinbase Prime fields that don't map to
|
|
// structured Order fields, preserving data that's useful for reconciliation and debugging.
|
|
func (p *Plugin) buildOrderMetadata(order client.Order, baseSymbol, quoteSymbol string, pricePrecision int, wallets map[string]string) map[string]string {
|
|
m := make(map[string]string)
|
|
|
|
set := func(k, v string) {
|
|
if v != "" {
|
|
m[MetadataPrefix+k] = v
|
|
}
|
|
}
|
|
|
|
set("product_id", order.ProductID)
|
|
set("portfolio_id", order.PortfolioID)
|
|
set("client_order_id", order.ClientOrderID)
|
|
set("quote_value", order.QuoteValue)
|
|
set("filled_value", order.FilledValue)
|
|
set("order_total", order.OrderTotal)
|
|
set("exchange_fee", order.ExchangeFee)
|
|
set("net_average_filled_price", order.NetAverageFilledPrice)
|
|
set("historical_pov", order.HistoricalPov)
|
|
set("quote_currency", quoteSymbol)
|
|
m[MetadataPrefix+"price_asset"] = fmt.Sprintf("%s/%d", quoteSymbol, pricePrecision)
|
|
|
|
set("base_wallet_id", wallets[strings.ToUpper(baseSymbol)])
|
|
set("quote_wallet_id", wallets[strings.ToUpper(quoteSymbol)])
|
|
|
|
if order.CommissionDetail != nil {
|
|
set("commission_total", order.CommissionDetail.TotalCommission)
|
|
set("commission_client", order.CommissionDetail.ClientCommission)
|
|
set("commission_venue", order.CommissionDetail.VenueCommission)
|
|
set("commission_ces", order.CommissionDetail.CesCommission)
|
|
set("commission_financing", order.CommissionDetail.FinancingCommission)
|
|
set("commission_regulatory", order.CommissionDetail.RegulatoryCommission)
|
|
set("commission_clearing", order.CommissionDetail.ClearingCommission)
|
|
}
|
|
|
|
if order.PostOnly {
|
|
m[MetadataPrefix+"post_only"] = "true"
|
|
}
|
|
|
|
return m
|
|
}
|
|
|
|
func mapCoinbaseStatus(cbStatus, baseQuantity, filledQuantity string) (models.OrderStatus, bool) {
|
|
switch strings.ToUpper(cbStatus) {
|
|
case "PENDING":
|
|
return models.ORDER_STATUS_PENDING, true
|
|
case "OPEN":
|
|
if filledQuantity != "" && filledQuantity != "0" {
|
|
filled, ok1 := new(big.Float).SetString(filledQuantity)
|
|
base, ok2 := new(big.Float).SetString(baseQuantity)
|
|
if ok1 && ok2 && filled.Sign() > 0 && filled.Cmp(base) < 0 {
|
|
return models.ORDER_STATUS_PARTIALLY_FILLED, true
|
|
}
|
|
}
|
|
return models.ORDER_STATUS_OPEN, true
|
|
case "FILLED":
|
|
return models.ORDER_STATUS_FILLED, true
|
|
case "CANCELLED":
|
|
return models.ORDER_STATUS_CANCELLED, true
|
|
case "EXPIRED":
|
|
return models.ORDER_STATUS_EXPIRED, true
|
|
case "FAILED":
|
|
return models.ORDER_STATUS_FAILED, true
|
|
default:
|
|
return models.ORDER_STATUS_PENDING, false
|
|
}
|
|
}
|
|
|
|
// parseOrderQuantity converts a string quantity to a big.Int using the
|
|
// precision lookup from the provided currencies snapshot. Unknown assets
|
|
// fall back to a default precision of 8 (matching the pre-refactor
|
|
// behavior). Callers are responsible for sourcing currencies via
|
|
// p.getAssets(ctx) — keeping the parameter explicit avoids the implicit
|
|
// "must-be-fresh" contract the previous p.currencies read had.
|
|
func parseOrderQuantity(currencies map[string]int, quantityStr string, asset string) (*big.Int, error) {
|
|
if quantityStr == "" {
|
|
return big.NewInt(0), nil
|
|
}
|
|
return currency.GetAmountWithPrecisionFromString(quantityStr, precisionFor(currencies, asset))
|
|
}
|
|
|
|
func precisionFor(currencies map[string]int, asset string) int {
|
|
if currencies != nil {
|
|
if precision, ok := currencies[strings.ToUpper(asset)]; ok {
|
|
return precision
|
|
}
|
|
}
|
|
// Default precision for unknown assets
|
|
return 8
|
|
}
|