Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
485 changes: 208 additions & 277 deletions README.md

Large diffs are not rendered by default.

247 changes: 247 additions & 0 deletions internal/circle/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package circle

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

const (
baseURLSandbox = "https://api-sandbox.circle.com/v1"
baseURLProduction = "https://api.circle.com/v1"
)

// Client is the Circle API client.
type Client struct {
apiKey string
baseURL string
http *http.Client
}

func New(apiKey string, sandbox bool) *Client {
base := baseURLProduction
if sandbox {
base = baseURLSandbox
}
return &Client{
apiKey: apiKey,
baseURL: base,
http: &http.Client{Timeout: 30 * time.Second},
}
}

// --- Wallet ---

type CreateWalletRequest struct {
IdempotencyKey string `json:"idempotencyKey"`
Description string `json:"description,omitempty"`
}

type Wallet struct {
WalletID string `json:"walletId"`
EntityID string `json:"entityId"`
Type string `json:"type"`
Description string `json:"description"`
Balances []Balance `json:"balances"`
}

type Balance struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}

// CreateWallet provisions a new programmable wallet for a user.
func (c *Client) CreateWallet(ctx context.Context, idempotencyKey, description string) (*Wallet, error) {
body := CreateWalletRequest{
IdempotencyKey: idempotencyKey,
Description: description,
}

var result struct {
Data Wallet `json:"data"`
}

if err := c.post(ctx, "/wallets", body, &result); err != nil {
return nil, fmt.Errorf("circle CreateWallet: %w", err)
}

return &result.Data, nil
}

// GetWallet retrieves a wallet by ID.
func (c *Client) GetWallet(ctx context.Context, walletID string) (*Wallet, error) {
var result struct {
Data Wallet `json:"data"`
}

if err := c.get(ctx, fmt.Sprintf("/wallets/%s", walletID), &result); err != nil {
return nil, fmt.Errorf("circle GetWallet: %w", err)
}

return &result.Data, nil
}

// GetBalance returns the USDC balance for a given wallet.
func (c *Client) GetBalance(ctx context.Context, walletID string) (string, error) {
wallet, err := c.GetWallet(ctx, walletID)
if err != nil {
return "0", err
}

for _, b := range wallet.Balances {
if b.Currency == "USDC" {
return b.Amount, nil
}
}

return "0", nil
}

// --- Transfers (micropayment settlement) ---

type CreateTransferRequest struct {
IdempotencyKey string `json:"idempotencyKey"`
Source TransferAccount `json:"source"`
Destination TransferAccount `json:"destination"`
Amount TransferAmount `json:"amount"`
}

type TransferAccount struct {
Type string `json:"type"`
ID string `json:"id"`
Chain string `json:"chain,omitempty"`
Address string `json:"address,omitempty"`
}

type TransferAmount struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}

type Transfer struct {
ID string `json:"id"`
Source TransferAccount `json:"source"`
Destination TransferAccount `json:"destination"`
Amount TransferAmount `json:"amount"`
Status string `json:"status"`
CreateDate string `json:"createDate"`
}

// SendUSDC transfers USDC from one wallet to another (agent execution fee settlement).
func (c *Client) SendUSDC(ctx context.Context, idempotencyKey, fromWalletID, toWalletID, amount string) (*Transfer, error) {
body := CreateTransferRequest{
IdempotencyKey: idempotencyKey,
Source: TransferAccount{
Type: "wallet",
ID: fromWalletID,
},
Destination: TransferAccount{
Type: "wallet",
ID: toWalletID,
},
Amount: TransferAmount{
Amount: amount,
Currency: "USDC",
},
}

var result struct {
Data Transfer `json:"data"`
}

if err := c.post(ctx, "/transfers", body, &result); err != nil {
return nil, fmt.Errorf("circle SendUSDC: %w", err)
}

return &result.Data, nil
}

// --- CCTP (Cross-Chain Transfer Protocol simulation) ---

type CCTPBurnRequest struct {
IdempotencyKey string `json:"idempotencyKey"`
Amount string `json:"amount"`
SourceWalletID string `json:"sourceWalletId"`
DestinationChain string `json:"destinationChain"`
DestinationAddress string `json:"destinationAddress"`
}

type CCTPTransfer struct {
ID string `json:"id"`
Amount string `json:"amount"`
Status string `json:"status"`
SourceChain string `json:"sourceChain"`
DestinationChain string `json:"destinationChain"`
DestinationAddress string `json:"destinationAddress"`
}

// SimulateCCTPTransfer simulates a cross-chain USDC transfer via CCTP.
// In V0 this is a demo endpoint — it calls Circle's CCTP burn-and-mint flow.
func (c *Client) SimulateCCTPTransfer(ctx context.Context, req CCTPBurnRequest) (*CCTPTransfer, error) {
var result struct {
Data CCTPTransfer `json:"data"`
}

if err := c.post(ctx, "/transfers/cctp", req, &result); err != nil {
return nil, fmt.Errorf("circle SimulateCCTPTransfer: %w", err)
}

return &result.Data, nil
}

// --- HTTP helpers ---

func (c *Client) post(ctx context.Context, path string, body, dest interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data))
if err != nil {
return err
}

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)

return c.do(req, dest)
}

func (c *Client) get(ctx context.Context, path string, dest interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}

req.Header.Set("Authorization", "Bearer "+c.apiKey)

return c.do(req, dest)
}

func (c *Client) do(req *http.Request, dest interface{}) error {
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

b, err := io.ReadAll(resp.Body)
if err != nil {
return err
}

if resp.StatusCode >= 400 {
return fmt.Errorf("circle API error %d: %s", resp.StatusCode, string(b))
}

if dest != nil {
return json.Unmarshal(b, dest)
}

return nil
}
101 changes: 101 additions & 0 deletions internal/handlers/circle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package handlers

import (
"encoding/json"
"net/http"

"github.com/go-chi/chi/v5"
"github.com/google/uuid"

"github.com/Cookit-labs/Backend/internal/circle"
)

// circleClient returns a Circle client from the handler config.
// In production, this should be injected; for now it reads from the handler's cfg field.
func (h *Handler) circleClient() *circle.Client {
return h.circle
}

// POST /api/v1/wallets
func (h *Handler) CreateWallet(w http.ResponseWriter, r *http.Request) {
var req struct {
UserWallet string `json:"user_wallet"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}

// Use the user's wallet address as idempotency key so we never double-create
idempotencyKey := uuid.NewSHA1(uuid.NameSpaceURL, []byte("wallet:"+req.UserWallet)).String()

wallet, err := h.circleClient().CreateWallet(r.Context(), idempotencyKey, req.Description)
if err != nil {
respondError(w, http.StatusBadGateway, "failed to create Circle wallet: "+err.Error())
return
}

respondJSON(w, http.StatusCreated, wallet)
}

// GET /api/v1/wallets/{walletID}/balance
func (h *Handler) GetWalletBalance(w http.ResponseWriter, r *http.Request) {
walletID := chi.URLParam(r, "walletID")

balance, err := h.circleClient().GetBalance(r.Context(), walletID)
if err != nil {
respondError(w, http.StatusBadGateway, "failed to fetch balance: "+err.Error())
return
}

respondJSON(w, http.StatusOK, map[string]string{
"wallet_id": walletID,
"amount": balance,
"currency": "USDC",
})
}

// POST /api/v1/wallets/transfer
func (h *Handler) TransferUSDC(w http.ResponseWriter, r *http.Request) {
var req struct {
FromWalletID string `json:"from_wallet_id"`
ToWalletID string `json:"to_wallet_id"`
Amount string `json:"amount"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}

idempotencyKey := uuid.New().String()

transfer, err := h.circleClient().SendUSDC(r.Context(), idempotencyKey, req.FromWalletID, req.ToWalletID, req.Amount)
if err != nil {
respondError(w, http.StatusBadGateway, "USDC transfer failed: "+err.Error())
return
}

respondJSON(w, http.StatusCreated, transfer)
}

// POST /api/v1/wallets/cctp — cross-chain routing demo
func (h *Handler) CCTPSimulate(w http.ResponseWriter, r *http.Request) {
var req circle.CCTPBurnRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}

if req.IdempotencyKey == "" {
req.IdempotencyKey = uuid.New().String()
}

transfer, err := h.circleClient().SimulateCCTPTransfer(r.Context(), req)
if err != nil {
respondError(w, http.StatusBadGateway, "CCTP simulation failed: "+err.Error())
return
}

respondJSON(w, http.StatusCreated, transfer)
}
Loading
Loading