Files
Quentin-David-24andGitHub 3aa04ad50f fix(coinbaseprime): EN-803 Use wallet balance endpoint (#672)
* fix(coinbaseprime): EN-803 Use wallet balance endpoint

* Clean unused coinbase requests
2026-03-05 16:03:35 +00:00

339 lines
11 KiB
Go

package client
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"github.com/formancehq/payments/internal/connectors/httpwrapper"
"github.com/formancehq/payments/internal/connectors/metrics"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
//go:generate mockgen -source client.go -destination client_generated.go -package client . Client
type Client interface {
GetPortfolio(ctx context.Context) (*PortfolioResponse, error)
GetAssets(ctx context.Context, entityID string) (*AssetsResponse, error)
GetWallets(ctx context.Context, cursor string, pageSize int) (*WalletsResponse, error)
GetBalanceForWallet(ctx context.Context, walletID string) (*WalletBalanceResponse, error)
GetTransactions(ctx context.Context, cursor string, pageSize int) (*TransactionsResponse, error)
}
const defaultBaseURL = "https://api.prime.coinbase.com"
type client struct {
httpClient httpwrapper.Client
baseURL string
apiKey string
apiSecret string
passphrase string
portfolioID string
}
func New(connectorName, apiKey, apiSecret, passphrase, portfolioID string) Client {
return NewWithBaseURL(connectorName, apiKey, apiSecret, passphrase, portfolioID, defaultBaseURL)
}
func NewWithBaseURL(connectorName, apiKey, apiSecret, passphrase, portfolioID, baseURL string) Client {
c := &client{
baseURL: baseURL,
apiKey: apiKey,
apiSecret: apiSecret,
passphrase: passphrase,
portfolioID: portfolioID,
}
config := &httpwrapper.Config{
Transport: metrics.NewTransport(connectorName, metrics.TransportOpts{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}),
}
c.httpClient = httpwrapper.NewClient(config)
return c
}
func (c *client) signRequest(req *http.Request, body string) error {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
// Coinbase Prime signature must include only the path (no query params).
// See: https://docs.cdp.coinbase.com/prime/rest-api/requests
message := timestamp + req.Method + req.URL.Path + body
// Coinbase Prime expects the signing key to be used as raw string bytes
// for the HMAC computation, NOT base64-decoded.
h := hmac.New(sha256.New, []byte(c.apiSecret))
h.Write([]byte(message))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
req.Header.Set("X-CB-ACCESS-KEY", c.apiKey)
req.Header.Set("X-CB-ACCESS-SIGNATURE", signature)
req.Header.Set("X-CB-ACCESS-TIMESTAMP", timestamp)
req.Header.Set("X-CB-ACCESS-PASSPHRASE", c.passphrase)
req.Header.Set("Content-Type", "application/json")
return nil
}
func (c *client) GetPortfolio(ctx context.Context) (*PortfolioResponse, error) {
endpoint := fmt.Sprintf("%s/v1/portfolios/%s", c.baseURL, c.portfolioID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if err := c.signRequest(req, ""); err != nil {
return nil, err
}
var response PortfolioResponse
var errorResponse ErrorResponse
statusCode, err := c.httpClient.Do(ctx, req, &response, &errorResponse)
if err != nil {
return nil, fmt.Errorf("failed to get portfolio (status %d, message: %s): %w", statusCode, errorResponse.Message, err)
}
return &response, nil
}
func (c *client) GetAssets(ctx context.Context, entityID string) (*AssetsResponse, error) {
endpoint := fmt.Sprintf("%s/v1/entities/%s/assets", c.baseURL, entityID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if err := c.signRequest(req, ""); err != nil {
return nil, err
}
var response AssetsResponse
var errorResponse ErrorResponse
statusCode, err := c.httpClient.Do(ctx, req, &response, &errorResponse)
if err != nil {
return nil, fmt.Errorf("failed to get assets (status %d, message: %s): %w", statusCode, errorResponse.Message, err)
}
return &response, nil
}
func (c *client) GetWallets(ctx context.Context, cursor string, pageSize int) (*WalletsResponse, error) {
endpoint, err := c.buildPortfolioEndpoint("wallets", cursor, pageSize)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if err := c.signRequest(req, ""); err != nil {
return nil, err
}
var response WalletsResponse
var errorResponse ErrorResponse
statusCode, err := c.httpClient.Do(ctx, req, &response, &errorResponse)
if err != nil {
return nil, fmt.Errorf("failed to get wallets (status %d, message: %s): %w", statusCode, errorResponse.Message, err)
}
return &response, nil
}
func (c *client) GetBalanceForWallet(ctx context.Context, walletID string) (*WalletBalanceResponse, error) {
if walletID == "" {
return nil, fmt.Errorf("missing wallet ID for balance")
}
endpoint := fmt.Sprintf("%s/v1/portfolios/%s/wallets/%s/balance", c.baseURL, c.portfolioID, url.PathEscape(walletID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if err := c.signRequest(req, ""); err != nil {
return nil, err
}
var response WalletBalanceResponse
var errorResponse ErrorResponse
statusCode, err := c.httpClient.Do(ctx, req, &response, &errorResponse)
if err != nil {
return nil, fmt.Errorf("failed to get wallet balance (status %d, message: %s): %w", statusCode, errorResponse.Message, err)
}
return &response, nil
}
func (c *client) GetTransactions(ctx context.Context, cursor string, pageSize int) (*TransactionsResponse, error) {
endpoint, err := c.buildPortfolioEndpoint("transactions", cursor, pageSize)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
if err := c.signRequest(req, ""); err != nil {
return nil, err
}
var response TransactionsResponse
var errorResponse ErrorResponse
statusCode, err := c.httpClient.Do(ctx, req, &response, &errorResponse)
if err != nil {
return nil, fmt.Errorf("failed to get transactions (status %d, message: %s): %w", statusCode, errorResponse.Message, err)
}
return &response, nil
}
func (c *client) buildPortfolioEndpoint(resource, cursor string, pageSize int) (string, error) {
endpoint, err := url.Parse(fmt.Sprintf("%s/v1/portfolios/%s/%s", c.baseURL, c.portfolioID, resource))
if err != nil {
return "", fmt.Errorf("failed to parse endpoint: %w", err)
}
query := endpoint.Query()
query.Set("limit", strconv.Itoa(pageSize))
query.Set("sort_direction", "ASC")
if cursor != "" {
query.Set("cursor", cursor)
}
endpoint.RawQuery = query.Encode()
return endpoint.String(), nil
}
// Portfolio represents a Coinbase Prime portfolio.
type Portfolio struct {
ID string `json:"id"`
Name string `json:"name"`
EntityID string `json:"entity_id"`
OrganizationID string `json:"organization_id"`
}
// PortfolioResponse wraps a portfolio.
type PortfolioResponse struct {
Portfolio Portfolio `json:"portfolio"`
}
// NetworkInfo describes a blockchain network identity.
type NetworkInfo struct {
ID string `json:"id"`
Type string `json:"type"`
}
// Network represents a network-scoped asset entry (e.g. USDC on Base → "BASEUSDC").
type Network struct {
Network NetworkInfo `json:"network"`
Name string `json:"name"`
NetworkScopedSymbol string `json:"network_scoped_symbol"`
MaxDecimals string `json:"max_decimals"`
}
// Asset represents a Coinbase Prime asset with its decimal precision.
type Asset struct {
Name string `json:"name"`
Symbol string `json:"symbol"`
DecimalPrecision string `json:"decimal_precision"`
TradingSupported bool `json:"trading_supported"`
Networks []Network `json:"networks"`
}
// AssetsResponse wraps assets.
type AssetsResponse struct {
Assets []Asset `json:"assets"`
}
// Wallet represents a Coinbase Prime wallet.
type Wallet struct {
ID string `json:"id"`
Name string `json:"name"`
Symbol string `json:"symbol"`
Type string `json:"type"` // TRADING, VAULT, ONCHAIN
CreatedAt time.Time `json:"created_at"`
}
// Balance represents a Coinbase Prime portfolio balance.
type Balance struct {
Symbol string `json:"symbol"`
Amount string `json:"amount"`
Holds string `json:"holds"`
WithdrawableAmount string `json:"withdrawable_amount"`
FiatAmount string `json:"fiat_amount"`
}
// TransferEndpoint represents a source or destination in a Coinbase Prime transaction.
type TransferEndpoint struct {
Type string `json:"type"` // WALLET, PAYMENT_METHOD, ...
Value string `json:"value"` // wallet ID or payment method ID
Address string `json:"address"`
AccountIdentifier string `json:"account_identifier"`
}
// Transaction represents a Coinbase Prime transaction.
type Transaction struct {
ID string `json:"id"`
WalletID string `json:"wallet_id"`
PortfolioID string `json:"portfolio_id"`
Type string `json:"type"` // DEPOSIT, WITHDRAWAL, INTERNAL_TRANSFER, CONVERSION, ...
Status string `json:"status"` // TRANSACTION_PENDING, TRANSACTION_DONE, TRANSACTION_IMPORT_PENDING, ...
Symbol string `json:"symbol"`
Amount string `json:"amount"`
Fees string `json:"fees"`
FeeSymbol string `json:"fee_symbol"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
TransferFrom *TransferEndpoint `json:"transfer_from"`
TransferTo *TransferEndpoint `json:"transfer_to"`
NetworkFees string `json:"network_fees"`
Network string `json:"network"`
BlockchainIDs []string `json:"blockchain_ids"`
DepositAddress string `json:"deposit_address"`
SourceAddress string `json:"source_address"`
ExternalTxID string `json:"external_tx_id"`
TransactionID string `json:"transaction_id"`
}
// Pagination represents cursor-based pagination from Coinbase Prime.
type Pagination struct {
NextCursor string `json:"next_cursor"`
SortDirection string `json:"sort_direction"`
HasNext bool `json:"has_next"`
}
// WalletsResponse wraps wallets with pagination.
type WalletsResponse struct {
Wallets []Wallet `json:"wallets"`
Pagination Pagination `json:"pagination"`
}
// WalletBalanceResponse is the response for GET /v1/portfolios/{id}/wallets/{wallet_id}/balance.
type WalletBalanceResponse struct {
Balance Balance `json:"balance"`
}
// TransactionsResponse wraps transactions with pagination.
type TransactionsResponse struct {
Transactions []Transaction `json:"transactions"`
Pagination Pagination `json:"pagination"`
}
// ErrorResponse represents an API error.
type ErrorResponse struct {
Message string `json:"message"`
}