Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 60 additions & 100 deletions coinbase/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package coinbase
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
Expand All @@ -18,12 +17,9 @@ import (
const (
metricsStructName = "coinbase.client"

// DefaultHost is the host serving the Onramp v1 transaction-status API.
// Override via Client.Host for staging or to pin to a different region.
DefaultHost = "api.developer.coinbase.com"

defaultPageSize = 100
maxPages = 50 // hard cap to avoid runaway pagination
// DefaultHost is the host serving the Onramp API. Override via
// Client.Host for staging or to pin to a different region.
DefaultHost = "api.cdp.coinbase.com"
)

// Client talks to the Coinbase Developer Platform Onramp API. Construct via
Expand Down Expand Up @@ -55,77 +51,29 @@ func NewClient(auth *Authenticator) *Client {
}
}

// GetUserTransactions returns every Onramp transaction Coinbase has recorded
// for the given partnerUserRef under the authenticated CDP project.
// Pagination is handled internally.
func (c *Client) GetUserTransactions(ctx context.Context, partnerUserRef string) ([]*Transaction, error) {
tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetUserTransactions")
defer tracer.End()

if partnerUserRef == "" {
return nil, errors.New("partner user ref is required")
}

var all []*Transaction
var pageKey string
for range maxPages {
path := fmt.Sprintf("/onramp/v1/buy/user/%s/transactions", url.PathEscape(partnerUserRef))
query := url.Values{}
query.Set("page_size", fmt.Sprintf("%d", defaultPageSize))
if pageKey != "" {
query.Set("page_key", pageKey)
}

var resp transactionsResponse
if err := c.get(ctx, path, query, &resp); err != nil {
tracer.OnError(err)
return nil, err
}

for i := range resp.Transactions {
all = append(all, resp.Transactions[i].toTransaction())
}

if resp.NextPageKey == "" {
return all, nil
}
pageKey = resp.NextPageKey
}
err := errors.Errorf("page cap (%d) reached for partner user ref %q", maxPages, partnerUserRef)
tracer.OnError(err)
return nil, err
}

// GetUserTransactionByOrderID looks up a single transaction by order ID
// within the partnerUserRef's namespace. Returns ErrTransactionNotFound if
// the order isn't found. The orderID alone is not queryable — callers must
// pair it with the partnerUserRef the order was created under.
func (c *Client) GetUserTransactionByOrderID(ctx context.Context, partnerUserRef, orderID string) (*Transaction, error) {
tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetUserTransactionByOrderID")
// GetOrder returns the Onramp order with the given order ID. Returns
// ErrOrderNotFound if the order isn't present in the authenticated CDP
// project.
func (c *Client) GetOrder(ctx context.Context, orderID string) (*Order, error) {
tracer := metrics.TraceMethodCall(ctx, metricsStructName, "GetOrder")
defer tracer.End()

if orderID == "" {
return nil, errors.New("order id is required")
}

txns, err := c.GetUserTransactions(ctx, partnerUserRef)
if err != nil {
path := "/platform/v2/onramp/orders/" + url.PathEscape(orderID)

var resp orderResponse
if err := c.get(ctx, path, &resp); err != nil {
tracer.OnError(err)
return nil, err
}
for _, t := range txns {
if t.OrderID == orderID {
return t, nil
}
}
return nil, ErrTransactionNotFound
return resp.Order.toOrder(), nil
}

func (c *Client) get(ctx context.Context, path string, query url.Values, out any) error {
func (c *Client) get(ctx context.Context, path string, out any) error {
fullURL := "https://" + c.Host + path
if len(query) > 0 {
fullURL += "?" + query.Encode()
}

var lastErr error
_, err := c.retrier.Retry(func() error {
Expand Down Expand Up @@ -153,7 +101,7 @@ func (c *Client) get(ctx context.Context, path string, query url.Values, out any
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
lastErr = ErrTransactionNotFound
lastErr = ErrOrderNotFound
return nil // do not retry
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
Expand All @@ -178,46 +126,58 @@ func (c *Client) get(ctx context.Context, path string, query url.Values, out any
return lastErr
}

// transactionsResponse mirrors the JSON shape of the v1 transactions
// endpoint. Field names match Coinbase's snake_case payload.
type transactionsResponse struct {
Transactions []transactionPayload `json:"transactions"`
NextPageKey string `json:"next_page_key"`
TotalCount string `json:"total_count"`
// orderResponse mirrors the JSON envelope of the v2 order endpoint.
type orderResponse struct {
Order orderPayload `json:"order"`
}

type transactionPayload struct {
Status string `json:"status"`
TxHash string `json:"tx_hash"`
WalletAddress string `json:"wallet_address"`
PurchaseAmount amountPayload `json:"purchase_amount"`
PurchaseCurrency string `json:"purchase_currency"`
PaymentTotal *amountPayload `json:"payment_total,omitempty"`
PartnerUserRef string `json:"partner_user_ref"`
PartnerUserID string `json:"partner_user_id"` // legacy field name
UserID string `json:"user_id"`
TransactionID string `json:"transaction_id"` // == orderId UUID
CreatedAt time.Time `json:"created_at"`
type orderPayload struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
PaymentTotal string `json:"paymentTotal"`
PaymentSubtotal string `json:"paymentSubtotal"`
PaymentCurrency string `json:"paymentCurrency"`
PaymentMethod string `json:"paymentMethod"`
PurchaseAmount string `json:"purchaseAmount"`
PurchaseCurrency string `json:"purchaseCurrency"`
Fees []feePayload `json:"fees"`
ExchangeRate string `json:"exchangeRate"`
DestinationAddress string `json:"destinationAddress"`
DestinationNetwork string `json:"destinationNetwork"`
TxHash string `json:"txHash"`
PartnerUserRef string `json:"partnerUserRef"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

type amountPayload struct {
Value string `json:"value"`
type feePayload struct {
Type string `json:"type"`
Amount string `json:"amount"`
Currency string `json:"currency"`
}

func (p transactionPayload) toTransaction() *Transaction {
ref := p.PartnerUserRef
if ref == "" {
ref = p.PartnerUserID
func (p orderPayload) toOrder() *Order {
fees := make([]Fee, 0, len(p.Fees))
for _, f := range p.Fees {
fees = append(fees, Fee{
Type: FeeType(f.Type),
Amount: Amount{Value: f.Amount, Currency: f.Currency},
})
}
return &Transaction{
OrderID: p.TransactionID,
PartnerUserRef: ref,
Status: TransactionStatus(p.Status),
TxHash: p.TxHash,
WalletAddress: p.WalletAddress,
PurchaseAmount: Amount{Value: p.PurchaseAmount.Value, Currency: p.PurchaseAmount.Currency},
PurchaseAssetID: p.PurchaseCurrency,
CreatedAt: p.CreatedAt,
return &Order{
OrderID: p.OrderID,
Status: OrderStatus(p.Status),
PaymentTotal: Amount{Value: p.PaymentTotal, Currency: p.PaymentCurrency},
PaymentSubtotal: Amount{Value: p.PaymentSubtotal, Currency: p.PaymentCurrency},
PaymentMethod: PaymentMethod(p.PaymentMethod),
PurchaseAmount: Amount{Value: p.PurchaseAmount, Currency: p.PurchaseCurrency},
Fees: fees,
ExchangeRate: p.ExchangeRate,
DestinationAddress: p.DestinationAddress,
DestinationNetwork: p.DestinationNetwork,
TxHash: p.TxHash,
PartnerUserRef: p.PartnerUserRef,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
68 changes: 68 additions & 0 deletions coinbase/order.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Package coinbase provides a client for Coinbase Developer Platform APIs,
// scoped to the surfaces this repo currently consumes (Onramp order
// status). Authentication uses CDP API keys signed with Ed25519 (EdDSA),
// regenerated per request — see auth.go.
package coinbase

import (
"errors"
"time"
)

type OrderStatus string

const (
OrderStatusUnknown OrderStatus = ""
OrderStatusPendingAuth OrderStatus = "ONRAMP_ORDER_STATUS_PENDING_AUTH"
OrderStatusPendingPayment OrderStatus = "ONRAMP_ORDER_STATUS_PENDING_PAYMENT"
OrderStatusProcessing OrderStatus = "ONRAMP_ORDER_STATUS_PROCESSING"
OrderStatusCompleted OrderStatus = "ONRAMP_ORDER_STATUS_COMPLETED"
OrderStatusFailed OrderStatus = "ONRAMP_ORDER_STATUS_FAILED"
)

type PaymentMethod string

const (
PaymentMethodApplePay PaymentMethod = "GUEST_CHECKOUT_APPLE_PAY"
PaymentMethodGooglePay PaymentMethod = "GUEST_CHECKOUT_GOOGLE_PAY"
)

type FeeType string

const (
FeeTypeNetwork FeeType = "FEE_TYPE_NETWORK"
FeeTypeExchange FeeType = "FEE_TYPE_EXCHANGE"
)

// Order is a Coinbase Onramp order as returned by the v2 API.
type Order struct {
OrderID string // UUID assigned by Coinbase
Status OrderStatus
PaymentTotal Amount // Total charged to the buyer, including fees
PaymentSubtotal Amount // Charge before fees
PaymentMethod PaymentMethod
PurchaseAmount Amount // Amount of the purchased asset delivered
Fees []Fee
ExchangeRate string
DestinationAddress string
DestinationNetwork string
TxHash string // Empty until on-chain settlement
PartnerUserRef string // Stable per-user reference supplied by us at widget init
CreatedAt time.Time
UpdatedAt time.Time
}

// Amount mirrors Coinbase's value/currency object.
type Amount struct {
Value string // Decimal string — preserve precision
Currency string
}

type Fee struct {
Type FeeType
Amount Amount
}

// ErrOrderNotFound is returned when the requested order ID isn't present in
// the authenticated CDP project.
var ErrOrderNotFound = errors.New("coinbase order not found")
45 changes: 0 additions & 45 deletions coinbase/transaction.go

This file was deleted.

Loading