mirror of
https://github.com/hanzo-fi/payments.git
synced 2026-10-30 20:31:20 +00:00
- go.mod module path + all self-imports: github.com/formancehq/payments -> github.com/hanzo-fi/payments (1590 .go files / 3152 refs; 20 modules: root, pkg/client, pkg/domain, 18 ce/plugins). External dep kept: formancehq/go-libs. - plugins: each ce/plugins/* consumed a *published* pkg/domain; added local 'replace .../pkg/domain => ../../../pkg/domain' (consistent with root) so every standalone plugin module builds in-monorepo. compile-plugin.sh MOD var rebranded too. - branding: README, LICENSE (dual MIT+EE; upstream copyright retained + Hanzo added), goreleaser (release images -> registry.hanzo.ai/hanzo-fi, OCI labels, homepage), proto go_package, renovate, speakeasy gen.yaml, connector-template *.gotpl. - CI: removed Numary/Formance workflows + composite action; added canonical hanzo.yml + cicd.yml (hanzoai/ci, gated HANZO_CI_ENABLED) + secretless ci.yml (go build/vet). - kept as data/compat keys: wire capability enums ALLOW_FORMANCE_*, generated SDK type client.Formance, domain field FormanceRedirectURL, OTEL_SERVICE_NAME, upstream base image. - verified: go build ./... (default + -tags ee) + all 20 nested modules green.
425 lines
15 KiB
Go
425 lines
15 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/hanzo-fi/payments/pkg/domain/httpwrapper"
|
|
"github.com/hanzo-fi/payments/pkg/domain/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, walletType, cursor string, pageSize int) (*WalletsResponse, error)
|
|
GetBalanceForWallet(ctx context.Context, walletID string) (*WalletBalanceResponse, error)
|
|
GetTransactions(ctx context.Context, cursor string, pageSize int, types ...string) (*TransactionsResponse, error)
|
|
ListOrders(ctx context.Context, cursor string, pageSize int) (*OrdersResponse, 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, walletType, cursor string, pageSize int) (*WalletsResponse, error) {
|
|
extra := url.Values{}
|
|
if walletType != "" {
|
|
extra.Set("type", walletType)
|
|
}
|
|
endpoint, err := c.buildPortfolioEndpoint("wallets", cursor, pageSize, extra)
|
|
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, types ...string) (*TransactionsResponse, error) {
|
|
extra := url.Values{}
|
|
for _, t := range types {
|
|
extra.Add("types", t)
|
|
}
|
|
endpoint, err := c.buildPortfolioEndpoint("transactions", cursor, pageSize, extra)
|
|
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) ListOrders(ctx context.Context, cursor string, pageSize int) (*OrdersResponse, error) {
|
|
endpoint, err := c.buildPortfolioEndpoint("orders", cursor, pageSize, nil)
|
|
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 OrdersResponse
|
|
var errorResponse ErrorResponse
|
|
statusCode, err := c.httpClient.Do(ctx, req, &response, &errorResponse)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list orders (status %d, message: %s): %w", statusCode, errorResponse.Message, err)
|
|
}
|
|
|
|
return &response, nil
|
|
}
|
|
|
|
func (c *client) buildPortfolioEndpoint(resource, cursor string, pageSize int, extra url.Values) (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)
|
|
}
|
|
for k, vs := range extra {
|
|
for _, v := range vs {
|
|
query.Add(k, v)
|
|
}
|
|
}
|
|
|
|
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"`
|
|
DestinationSymbol string `json:"destination_symbol"`
|
|
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"`
|
|
}
|
|
|
|
// Order represents a Coinbase Prime order.
|
|
// See: https://docs.cdp.coinbase.com/prime/reference/primerestapi_getorders
|
|
type Order struct {
|
|
ID string `json:"id"`
|
|
PortfolioID string `json:"portfolio_id"`
|
|
ProductID string `json:"product_id"`
|
|
Side string `json:"side"`
|
|
Type string `json:"type"`
|
|
BaseQuantity string `json:"base_quantity"`
|
|
QuoteValue string `json:"quote_value"`
|
|
LimitPrice string `json:"limit_price"`
|
|
StopPrice string `json:"stop_price"`
|
|
FilledQuantity string `json:"filled_quantity"`
|
|
FilledValue string `json:"filled_value"`
|
|
AverageFilledPrice string `json:"average_filled_price"`
|
|
Commission string `json:"commission"`
|
|
ExchangeFee string `json:"exchange_fee"`
|
|
OrderTotal string `json:"order_total"`
|
|
Status string `json:"status"`
|
|
TimeInForce string `json:"time_in_force"`
|
|
CreatedAt string `json:"created_at"`
|
|
ExpiryTime string `json:"expiry_time"`
|
|
ClientOrderID string `json:"client_order_id"`
|
|
PostOnly bool `json:"post_only"`
|
|
NetAverageFilledPrice string `json:"net_average_filled_price"`
|
|
HistoricalPov string `json:"historical_pov"`
|
|
CommissionDetail *CommissionDetail `json:"commission_detail_total,omitempty"`
|
|
}
|
|
|
|
// CommissionDetail contains a breakdown of all commission charges for an order.
|
|
type CommissionDetail struct {
|
|
TotalCommission string `json:"total_commission,omitempty"`
|
|
ClientCommission string `json:"client_commission,omitempty"`
|
|
VenueCommission string `json:"venue_commission,omitempty"`
|
|
CesCommission string `json:"ces_commission,omitempty"`
|
|
FinancingCommission string `json:"financing_commission,omitempty"`
|
|
RegulatoryCommission string `json:"regulatory_commission,omitempty"`
|
|
ClearingCommission string `json:"clearing_commission,omitempty"`
|
|
}
|
|
|
|
// OrdersResponse wraps orders with pagination.
|
|
type OrdersResponse struct {
|
|
Orders []Order `json:"orders"`
|
|
Pagination Pagination `json:"pagination"`
|
|
}
|
|
|
|
// ErrorResponse represents an API error.
|
|
type ErrorResponse struct {
|
|
Message string `json:"message"`
|
|
}
|