mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
443 lines
13 KiB
Go
443 lines
13 KiB
Go
// Package custody manages asset custody across multiple custodians (Fireblocks,
|
|
// BitGo, etc.) and self-custody wallets. It maintains the position of record,
|
|
// tracks inter-custodian transfers, and runs reconciliation against provider
|
|
// balances. Required for BD regulatory compliance (SEC Rule 15c3-3).
|
|
package custody
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// CustodianType categorizes the kind of custodial arrangement.
|
|
type CustodianType string
|
|
|
|
const (
|
|
CustodianPrime CustodianType = "prime" // prime broker (e.g., Alpaca, IBKR)
|
|
CustodianCrypto CustodianType = "crypto" // crypto custodian (e.g., Fireblocks, BitGo)
|
|
CustodianSelf CustodianType = "self" // self-custody wallet
|
|
CustodianBank CustodianType = "bank" // fiat bank custody
|
|
)
|
|
|
|
// Custodian represents a registered custody provider.
|
|
type Custodian struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"` // e.g., "fireblocks", "bitgo", "alpaca"
|
|
Type CustodianType `json:"type"`
|
|
Status string `json:"status"` // active, suspended, decommissioned
|
|
Metadata map[string]string `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// Holding is the position of record for an asset at a specific custodian.
|
|
type Holding struct {
|
|
ID string `json:"id"`
|
|
AccountID string `json:"account_id"`
|
|
CustodianID string `json:"custodian_id"`
|
|
Asset string `json:"asset"` // symbol or currency
|
|
Quantity float64 `json:"quantity"`
|
|
CostBasis float64 `json:"cost_basis"` // USD cost basis for tax reporting
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// TransferStatus tracks the state of an inter-custodian transfer.
|
|
type TransferStatus string
|
|
|
|
const (
|
|
TransferPending TransferStatus = "pending"
|
|
TransferConfirmed TransferStatus = "confirmed"
|
|
TransferFailed TransferStatus = "failed"
|
|
TransferCancelled TransferStatus = "cancelled"
|
|
)
|
|
|
|
// Transfer represents movement of assets between custodians.
|
|
type Transfer struct {
|
|
ID string `json:"id"`
|
|
AccountID string `json:"account_id"`
|
|
FromCustodianID string `json:"from_custodian_id"`
|
|
ToCustodianID string `json:"to_custodian_id"`
|
|
Asset string `json:"asset"`
|
|
Quantity float64 `json:"quantity"`
|
|
Status TransferStatus `json:"status"`
|
|
ExternalRef string `json:"external_ref,omitempty"` // provider-side transfer ID
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
|
|
}
|
|
|
|
// ReconciliationResult is the outcome of comparing our records against a custodian.
|
|
type ReconciliationResult struct {
|
|
CustodianID string `json:"custodian_id"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Matched int `json:"matched"`
|
|
Mismatched int `json:"mismatched"`
|
|
Breaks []ReconciliationBreak `json:"breaks,omitempty"`
|
|
}
|
|
|
|
// ReconciliationBreak is a discrepancy between our records and the custodian.
|
|
type ReconciliationBreak struct {
|
|
AccountID string `json:"account_id"`
|
|
Asset string `json:"asset"`
|
|
OurQty float64 `json:"our_qty"`
|
|
TheirQty float64 `json:"their_qty"`
|
|
Delta float64 `json:"delta"`
|
|
}
|
|
|
|
// BalanceFetcher retrieves current balances from a custody provider.
|
|
// Implementations wrap provider-specific APIs (Fireblocks, BitGo, etc.).
|
|
type BalanceFetcher func(ctx context.Context, custodianID string) (map[string]map[string]float64, error)
|
|
|
|
// Returns: accountID -> asset -> quantity
|
|
|
|
// Service manages custody state, inter-custodian transfers, and reconciliation.
|
|
type Service struct {
|
|
mu sync.RWMutex
|
|
custodians map[string]*Custodian // custodianID -> custodian
|
|
holdings map[holdingKey]*Holding // (account, custodian, asset) -> holding
|
|
transfers map[string]*Transfer // transferID -> transfer
|
|
nextID int
|
|
}
|
|
|
|
type holdingKey struct {
|
|
accountID string
|
|
custodianID string
|
|
asset string
|
|
}
|
|
|
|
// NewService creates a custody management service.
|
|
func NewService() *Service {
|
|
return &Service{
|
|
custodians: make(map[string]*Custodian),
|
|
holdings: make(map[holdingKey]*Holding),
|
|
transfers: make(map[string]*Transfer),
|
|
}
|
|
}
|
|
|
|
// RegisterCustodian adds a custodian to the registry.
|
|
func (s *Service) RegisterCustodian(c Custodian) error {
|
|
if c.ID == "" || c.Name == "" {
|
|
return fmt.Errorf("custodian id and name are required")
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if _, exists := s.custodians[c.ID]; exists {
|
|
return fmt.Errorf("custodian %s already registered", c.ID)
|
|
}
|
|
s.custodians[c.ID] = &c
|
|
return nil
|
|
}
|
|
|
|
// GetCustodian returns a registered custodian by ID.
|
|
func (s *Service) GetCustodian(id string) (*Custodian, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
c, ok := s.custodians[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("custodian %s not found", id)
|
|
}
|
|
cp := *c
|
|
return &cp, nil
|
|
}
|
|
|
|
// ListCustodians returns all registered custodians.
|
|
func (s *Service) ListCustodians() []*Custodian {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
result := make([]*Custodian, 0, len(s.custodians))
|
|
for _, c := range s.custodians {
|
|
cp := *c
|
|
result = append(result, &cp)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// RecordHolding updates the position of record for an asset at a custodian.
|
|
// This is the BD's authoritative ledger of what's held where.
|
|
func (s *Service) RecordHolding(accountID, custodianID, asset string, quantity, costBasis float64) error {
|
|
if accountID == "" || custodianID == "" || asset == "" {
|
|
return fmt.Errorf("account_id, custodian_id, and asset are required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, ok := s.custodians[custodianID]; !ok {
|
|
return fmt.Errorf("custodian %s not registered", custodianID)
|
|
}
|
|
|
|
key := holdingKey{accountID: accountID, custodianID: custodianID, asset: asset}
|
|
s.nextID++
|
|
s.holdings[key] = &Holding{
|
|
ID: fmt.Sprintf("hold_%d", s.nextID),
|
|
AccountID: accountID,
|
|
CustodianID: custodianID,
|
|
Asset: asset,
|
|
Quantity: quantity,
|
|
CostBasis: costBasis,
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetHolding returns the position of record for a specific account/custodian/asset.
|
|
func (s *Service) GetHolding(accountID, custodianID, asset string) (*Holding, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
key := holdingKey{accountID: accountID, custodianID: custodianID, asset: asset}
|
|
h, ok := s.holdings[key]
|
|
if !ok {
|
|
return nil, fmt.Errorf("no holding for account %s, custodian %s, asset %s", accountID, custodianID, asset)
|
|
}
|
|
cp := *h
|
|
return &cp, nil
|
|
}
|
|
|
|
// ListHoldings returns all holdings for an account across all custodians.
|
|
func (s *Service) ListHoldings(accountID string) []*Holding {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []*Holding
|
|
for _, h := range s.holdings {
|
|
if h.AccountID == accountID {
|
|
cp := *h
|
|
result = append(result, &cp)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ListHoldingsByCustodian returns all holdings at a specific custodian.
|
|
func (s *Service) ListHoldingsByCustodian(custodianID string) []*Holding {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []*Holding
|
|
for _, h := range s.holdings {
|
|
if h.CustodianID == custodianID {
|
|
cp := *h
|
|
result = append(result, &cp)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// InitiateTransfer creates a pending inter-custodian transfer.
|
|
// The caller is responsible for executing the actual transfer at each provider.
|
|
func (s *Service) InitiateTransfer(accountID, fromCustodianID, toCustodianID, asset string, quantity float64) (*Transfer, error) {
|
|
if accountID == "" || fromCustodianID == "" || toCustodianID == "" || asset == "" {
|
|
return nil, fmt.Errorf("account_id, from_custodian_id, to_custodian_id, and asset are required")
|
|
}
|
|
if quantity <= 0 {
|
|
return nil, fmt.Errorf("quantity must be positive")
|
|
}
|
|
if fromCustodianID == toCustodianID {
|
|
return nil, fmt.Errorf("from and to custodian must differ")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, ok := s.custodians[fromCustodianID]; !ok {
|
|
return nil, fmt.Errorf("source custodian %s not registered", fromCustodianID)
|
|
}
|
|
if _, ok := s.custodians[toCustodianID]; !ok {
|
|
return nil, fmt.Errorf("destination custodian %s not registered", toCustodianID)
|
|
}
|
|
|
|
// Verify sufficient holdings at source.
|
|
key := holdingKey{accountID: accountID, custodianID: fromCustodianID, asset: asset}
|
|
h, ok := s.holdings[key]
|
|
if !ok || h.Quantity < quantity {
|
|
avail := 0.0
|
|
if ok {
|
|
avail = h.Quantity
|
|
}
|
|
return nil, fmt.Errorf("insufficient holdings: need %.8f %s, have %.8f at %s",
|
|
quantity, asset, avail, fromCustodianID)
|
|
}
|
|
|
|
s.nextID++
|
|
t := &Transfer{
|
|
ID: fmt.Sprintf("xfr_%d", s.nextID),
|
|
AccountID: accountID,
|
|
FromCustodianID: fromCustodianID,
|
|
ToCustodianID: toCustodianID,
|
|
Asset: asset,
|
|
Quantity: quantity,
|
|
Status: TransferPending,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
s.transfers[t.ID] = t
|
|
return t, nil
|
|
}
|
|
|
|
// ConfirmTransfer marks a transfer as confirmed and updates holdings.
|
|
// Called when the actual custodian-level transfer completes.
|
|
func (s *Service) ConfirmTransfer(transferID, externalRef string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
t, ok := s.transfers[transferID]
|
|
if !ok {
|
|
return fmt.Errorf("transfer %s not found", transferID)
|
|
}
|
|
if t.Status != TransferPending {
|
|
return fmt.Errorf("transfer %s in status %s, cannot confirm", transferID, t.Status)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
t.Status = TransferConfirmed
|
|
t.ExternalRef = externalRef
|
|
t.ConfirmedAt = &now
|
|
|
|
// Debit source holding.
|
|
fromKey := holdingKey{accountID: t.AccountID, custodianID: t.FromCustodianID, asset: t.Asset}
|
|
if h, ok := s.holdings[fromKey]; ok {
|
|
h.Quantity -= t.Quantity
|
|
h.UpdatedAt = now
|
|
}
|
|
|
|
// Credit destination holding (create if needed).
|
|
toKey := holdingKey{accountID: t.AccountID, custodianID: t.ToCustodianID, asset: t.Asset}
|
|
if h, ok := s.holdings[toKey]; ok {
|
|
h.Quantity += t.Quantity
|
|
h.UpdatedAt = now
|
|
} else {
|
|
s.nextID++
|
|
s.holdings[toKey] = &Holding{
|
|
ID: fmt.Sprintf("hold_%d", s.nextID),
|
|
AccountID: t.AccountID,
|
|
CustodianID: t.ToCustodianID,
|
|
Asset: t.Asset,
|
|
Quantity: t.Quantity,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FailTransfer marks a transfer as failed. No holding changes are made.
|
|
func (s *Service) FailTransfer(transferID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
t, ok := s.transfers[transferID]
|
|
if !ok {
|
|
return fmt.Errorf("transfer %s not found", transferID)
|
|
}
|
|
if t.Status != TransferPending {
|
|
return fmt.Errorf("transfer %s in status %s, cannot fail", transferID, t.Status)
|
|
}
|
|
|
|
t.Status = TransferFailed
|
|
return nil
|
|
}
|
|
|
|
// GetTransfer returns a transfer by ID.
|
|
func (s *Service) GetTransfer(id string) (*Transfer, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
t, ok := s.transfers[id]
|
|
if !ok {
|
|
return nil, fmt.Errorf("transfer %s not found", id)
|
|
}
|
|
cp := *t
|
|
return &cp, nil
|
|
}
|
|
|
|
// ListTransfers returns all transfers for an account.
|
|
func (s *Service) ListTransfers(accountID string) []*Transfer {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var result []*Transfer
|
|
for _, t := range s.transfers {
|
|
if t.AccountID == accountID {
|
|
cp := *t
|
|
result = append(result, &cp)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Reconcile compares our holdings against actual balances from a custodian.
|
|
// The fetchFn retrieves current balances from the custodian API.
|
|
func (s *Service) Reconcile(ctx context.Context, custodianID string, fetchFn BalanceFetcher) (*ReconciliationResult, error) {
|
|
actual, err := fetchFn(ctx, custodianID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch balances from %s: %w", custodianID, err)
|
|
}
|
|
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
result := &ReconciliationResult{
|
|
CustodianID: custodianID,
|
|
Timestamp: time.Now().UTC(),
|
|
}
|
|
|
|
// Build map of our holdings at this custodian.
|
|
ourHoldings := make(map[string]map[string]float64) // accountID -> asset -> qty
|
|
for key, h := range s.holdings {
|
|
if key.custodianID != custodianID {
|
|
continue
|
|
}
|
|
if ourHoldings[h.AccountID] == nil {
|
|
ourHoldings[h.AccountID] = make(map[string]float64)
|
|
}
|
|
ourHoldings[h.AccountID][h.Asset] = h.Quantity
|
|
}
|
|
|
|
// Compare against actual.
|
|
seen := make(map[string]map[string]bool)
|
|
for acct, assets := range actual {
|
|
if seen[acct] == nil {
|
|
seen[acct] = make(map[string]bool)
|
|
}
|
|
for asset, theirQty := range assets {
|
|
seen[acct][asset] = true
|
|
ourQty := 0.0
|
|
if ours, ok := ourHoldings[acct]; ok {
|
|
ourQty = ours[asset]
|
|
}
|
|
if ourQty == theirQty {
|
|
result.Matched++
|
|
} else {
|
|
result.Mismatched++
|
|
result.Breaks = append(result.Breaks, ReconciliationBreak{
|
|
AccountID: acct,
|
|
Asset: asset,
|
|
OurQty: ourQty,
|
|
TheirQty: theirQty,
|
|
Delta: ourQty - theirQty,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for holdings we have that the custodian doesn't.
|
|
for acct, assets := range ourHoldings {
|
|
for asset, ourQty := range assets {
|
|
if ourQty == 0 {
|
|
continue
|
|
}
|
|
if seen[acct] == nil || !seen[acct][asset] {
|
|
result.Mismatched++
|
|
result.Breaks = append(result.Breaks, ReconciliationBreak{
|
|
AccountID: acct,
|
|
Asset: asset,
|
|
OurQty: ourQty,
|
|
TheirQty: 0,
|
|
Delta: ourQty,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|