Files
zeekay 7d727874d5 chore: rebrand Formance Payments -> Hanzo Payments (hanzo-fi)
- 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.
2026-07-05 13:08:55 -07:00

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"`
}