mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
* fix(coinbaseprime): EN-803 Use wallet balance endpoint * Clean unused coinbase requests
339 lines
11 KiB
Go
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"`
|
|
}
|