From c615740990f48c795d0265cc53fcd6b1844d02ba Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Tue, 28 Apr 2026 10:10:02 -0400 Subject: [PATCH] Add Coinbase client for getting onramp orders --- coinbase/auth.go | 93 +++++++++++++++++ coinbase/client.go | 223 ++++++++++++++++++++++++++++++++++++++++ coinbase/transaction.go | 45 ++++++++ 3 files changed, 361 insertions(+) create mode 100644 coinbase/auth.go create mode 100644 coinbase/client.go create mode 100644 coinbase/transaction.go diff --git a/coinbase/auth.go b/coinbase/auth.go new file mode 100644 index 0000000..0d15e7b --- /dev/null +++ b/coinbase/auth.go @@ -0,0 +1,93 @@ +package coinbase + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "strconv" + "time" + + "github.com/pkg/errors" +) + +// jwtTTL is the lifetime CDP allows for a request JWT. CDP rejects anything +// longer than 2 minutes, and there is no benefit to caching across requests +// because the `uri` claim is per-endpoint. +const jwtTTL = 120 * time.Second + +// Authenticator mints per-request CDP JWTs. Construct one per process and +// share it — there is no per-request state. +type Authenticator struct { + keyID string + privateKey ed25519.PrivateKey +} + +// NewAuthenticator constructs an Authenticator from a CDP key ID and an +// Ed25519 private key. The private key must be a 64-byte ed25519 seed+public +// key (the form returned by ed25519.GenerateKey / ed25519.NewKeyFromSeed). +func NewAuthenticator(keyID string, privateKey ed25519.PrivateKey) (*Authenticator, error) { + if keyID == "" { + return nil, errors.New("key id is required") + } + if len(privateKey) != ed25519.PrivateKeySize { + return nil, errors.Errorf("private key must be %d bytes, got %d", ed25519.PrivateKeySize, len(privateKey)) + } + return &Authenticator{ + keyID: keyID, + privateKey: privateKey, + }, nil +} + +// JWT returns a freshly signed bearer token for the given request. The host +// must not include a scheme (e.g. "api.developer.coinbase.com"), and path +// must include the leading slash. Per CDP's spec the `uri` claim binds the +// JWT to a single METHOD+HOST+PATH combination, so reuse across requests +// will fail. +func (a *Authenticator) JWT(method, host, path string) (string, error) { + header := map[string]any{ + "alg": "EdDSA", + "typ": "JWT", + "kid": a.keyID, + // nonce defends against replay if a token leaks during its 2-min TTL. + "nonce": randomNonce(), + } + + now := time.Now() + payload := map[string]any{ + "sub": a.keyID, + "iss": "cdp", + "aud": []string{"cdp_service"}, + "nbf": now.Unix(), + "exp": now.Add(jwtTTL).Unix(), + "uri": method + " " + host + path, + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", errors.Wrap(err, "error marshalling jwt header") + } + payloadJSON, err := json.Marshal(payload) + if err != nil { + return "", errors.Wrap(err, "error marshalling jwt payload") + } + + signingInput := base64URL(headerJSON) + "." + base64URL(payloadJSON) + signature := ed25519.Sign(a.privateKey, []byte(signingInput)) + return signingInput + "." + base64URL(signature), nil +} + +func base64URL(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} + +func randomNonce() string { + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + // crypto/rand failure is effectively unrecoverable; fall back to a + // timestamp so we still produce a unique-ish nonce rather than + // blocking auth entirely. + return strconv.FormatInt(time.Now().UnixNano(), 10) + } + return base64URL(buf[:]) +} diff --git a/coinbase/client.go b/coinbase/client.go new file mode 100644 index 0000000..36eb659 --- /dev/null +++ b/coinbase/client.go @@ -0,0 +1,223 @@ +package coinbase + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/pkg/errors" + + "github.com/code-payments/ocp-server/metrics" + "github.com/code-payments/ocp-server/retry" + "github.com/code-payments/ocp-server/retry/backoff" +) + +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 +) + +// Client talks to the Coinbase Developer Platform Onramp API. Construct via +// NewClient and share across goroutines — http.Client and the Authenticator +// are both safe for concurrent use. +type Client struct { + httpClient *http.Client + auth *Authenticator + retrier retry.Retrier + + // Host is the API host (no scheme, no trailing slash). Defaults to + // DefaultHost. The same host is used for the JWT `uri` claim, so changing + // it must match the actual server. + Host string +} + +func NewClient(auth *Authenticator) *Client { + return &Client{ + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + auth: auth, + retrier: retry.NewRetrier( + retry.NonRetriableErrors(context.Canceled), + retry.Limit(3), + retry.BackoffWithJitter(backoff.BinaryExponential(time.Second), 10*time.Second, 0.1), + ), + Host: DefaultHost, + } +} + +// 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") + defer tracer.End() + + if orderID == "" { + return nil, errors.New("order id is required") + } + + txns, err := c.GetUserTransactions(ctx, partnerUserRef) + if err != nil { + tracer.OnError(err) + return nil, err + } + for _, t := range txns { + if t.OrderID == orderID { + return t, nil + } + } + return nil, ErrTransactionNotFound +} + +func (c *Client) get(ctx context.Context, path string, query url.Values, out any) error { + fullURL := "https://" + c.Host + path + if len(query) > 0 { + fullURL += "?" + query.Encode() + } + + var lastErr error + _, err := c.retrier.Retry(func() error { + // JWTs are bound to METHOD+HOST+PATH and expire in 2 minutes, so + // mint a fresh one per attempt rather than per call. + jwt, err := c.auth.JWT(http.MethodGet, c.Host, path) + if err != nil { + lastErr = errors.Wrap(err, "error generating jwt") + return lastErr + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, http.NoBody) + if err != nil { + lastErr = errors.Wrap(err, "error building request") + return lastErr + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = errors.Wrap(err, "error sending request") + return lastErr + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + lastErr = ErrTransactionNotFound + return nil // do not retry + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + lastErr = errors.Errorf("coinbase api returned status %d", resp.StatusCode) + // Retry 5xx; 4xx is the caller's problem. + if resp.StatusCode >= 500 { + return lastErr + } + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + lastErr = errors.Wrap(err, "error decoding response") + return lastErr + } + lastErr = nil + return nil + }) + if err != nil { + return err + } + 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"` +} + +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 amountPayload struct { + Value string `json:"value"` + Currency string `json:"currency"` +} + +func (p transactionPayload) toTransaction() *Transaction { + ref := p.PartnerUserRef + if ref == "" { + ref = p.PartnerUserID + } + 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, + } +} diff --git a/coinbase/transaction.go b/coinbase/transaction.go new file mode 100644 index 0000000..4ffe63d --- /dev/null +++ b/coinbase/transaction.go @@ -0,0 +1,45 @@ +// Package coinbase provides a client for Coinbase Developer Platform APIs, +// scoped to the surfaces this repo currently consumes (Onramp transaction +// status). Authentication uses CDP API keys signed with Ed25519 (EdDSA), +// regenerated per request — see auth.go. +package coinbase + +import ( + "errors" + "time" +) + +type TransactionStatus string + +const ( + TransactionStatusUnknown TransactionStatus = "" + TransactionStatusCreated TransactionStatus = "ONRAMP_TRANSACTION_STATUS_CREATED" + TransactionStatusInProgress TransactionStatus = "ONRAMP_TRANSACTION_STATUS_IN_PROGRESS" + TransactionStatusSuccess TransactionStatus = "ONRAMP_TRANSACTION_STATUS_SUCCESS" + TransactionStatusFailed TransactionStatus = "ONRAMP_TRANSACTION_STATUS_FAILED" +) + +// Transaction is the subset of the Coinbase Onramp transaction record this +// repo cares about. The full payload is larger; fields not used by callers +// are intentionally omitted. +type Transaction struct { + OrderID string // UUID assigned by Coinbase + PartnerUserRef string // Stable per-user reference supplied by us at widget init + Status TransactionStatus + TxHash string // Empty until on-chain settlement + WalletAddress string // Destination wallet on the target chain + PurchaseAmount Amount // Amount in the purchased asset + PurchaseAssetID string // Coinbase asset identifier (e.g. "USDC") + CreatedAt time.Time +} + +// Amount mirrors Coinbase's value/currency object. +type Amount struct { + Value string // Decimal string — preserve precision + Currency string +} + +// ErrTransactionNotFound is returned when an order ID isn't present under the +// given partnerUserRef (either it doesn't exist in our project, or the +// partnerUserRef is wrong). +var ErrTransactionNotFound = errors.New("coinbase transaction not found")