mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
refactor(lx): rip compliance/KYC from permissionless DEX engine
The public permissionless DEX must be provably KYC-free. Compliance/KYC belongs in lux-private (dex/compliance/sovereign), never in luxfi/dex. - delete compliance.go + compliance_types.go: EligibilityChecker.CanTrade pre-trade gate, StandardEligibilityChecker (KYC/AML/accreditation/ jurisdiction enforcement), InvestorProfile/InvestorStatus, PEPStatus, TransferRestriction, ExemptionType, ComplianceError + codes. - delete market_config.go: the SecuritiesMarket wrapper existed solely to gate AddOrder on EligibilityChecker.CanTrade. Zero production consumers — the permissionless engine uses AdvancedOrderBook directly. - funds.go: remove preCheck compliance hook + AML/sanctions/compliance fields + ComplianceGates; permissionless default is FundsStatusApproved. - reconciliation.go: relocate neutral AssetClass + ReconciliationMode types here (cross-venue metadata, not gates); drop CEXComplianceID / ReportedToRegulator / RegulatoryReportID fields. - tests updated to match. Green (CGO off): go build ./..., go vet + go test ./pkg/lx ./pkg/dchain.
This commit is contained in:
@@ -1,174 +0,0 @@
|
||||
package lx
|
||||
|
||||
import "fmt"
|
||||
|
||||
// EligibilityChecker is called before every order placement to verify that
|
||||
// the trader is allowed to trade the given security. ATS operators implement
|
||||
// this interface with their own KYC/AML/accreditation backend.
|
||||
type EligibilityChecker interface {
|
||||
// CanTrade returns nil if the user is eligible, or an error describing
|
||||
// why the trade is rejected.
|
||||
CanTrade(userID string, symbol string, side Side, size float64, price float64) error
|
||||
}
|
||||
|
||||
// SettlementHandler is called after each trade execution. ATS operators
|
||||
// implement this to notify transfer agents, update cap tables, trigger
|
||||
// on-chain token transfers, or enqueue escrow workflows.
|
||||
type SettlementHandler interface {
|
||||
// OnTrade is called for each executed trade. Returning an error does not
|
||||
// reverse the match — settlement failures are handled asynchronously.
|
||||
OnTrade(trade *Trade, market *MarketConfig) error
|
||||
}
|
||||
|
||||
// ComplianceErrorCode identifies the category of a compliance rejection.
|
||||
type ComplianceErrorCode uint8
|
||||
|
||||
const (
|
||||
ErrNotAccredited ComplianceErrorCode = iota + 1 // Investor not accredited
|
||||
ErrJurisdiction // Investor jurisdiction blocked
|
||||
ErrHoldingPeriod // Holding period not elapsed
|
||||
ErrMaxHolders // Would exceed max holder count
|
||||
ErrKYCRequired // KYC verification missing
|
||||
ErrAMLRequired // AML clearance missing
|
||||
ErrMinNotional // Below minimum notional value
|
||||
ErrMinSize // Below minimum order size
|
||||
ErrMaxSize // Exceeds maximum order size
|
||||
ErrROFRPending // Right of first refusal pending
|
||||
ErrBoardApproval // Board approval required
|
||||
ErrTransferRestricted // Generic transfer restriction
|
||||
ErrPEPReviewRequired // PEP requires enhanced due diligence
|
||||
ErrSourceOfFundsRequired // Source of funds verification needed
|
||||
ErrAdverseMedia // Adverse media screening flagged
|
||||
ErrHighRiskCountry // FATF high-risk jurisdiction
|
||||
ErrInstitutionalOnly // Restricted to institutional investors
|
||||
ErrLockupPeriod // Asset is within lockup period
|
||||
ErrCollateralInsufficient // Insufficient collateral ratio
|
||||
)
|
||||
|
||||
// ComplianceError is returned when a pre-trade check fails.
|
||||
type ComplianceError struct {
|
||||
Code ComplianceErrorCode
|
||||
Message string
|
||||
Detail interface{} // Optional context (threshold value, expiry date, etc.)
|
||||
}
|
||||
|
||||
func (e *ComplianceError) Error() string {
|
||||
if e.Detail != nil {
|
||||
return fmt.Sprintf("compliance: %s (detail: %v)", e.Message, e.Detail)
|
||||
}
|
||||
return fmt.Sprintf("compliance: %s", e.Message)
|
||||
}
|
||||
|
||||
// StandardEligibilityChecker provides a reusable implementation that enforces
|
||||
// TransferRestriction rules using an InvestorProfile lookup function.
|
||||
// ATS operators can use this directly or wrap it with additional checks.
|
||||
type StandardEligibilityChecker struct {
|
||||
// GetProfile returns the investor profile for a user ID.
|
||||
// This is the only function the operator must provide.
|
||||
GetProfile func(userID string) (*InvestorProfile, error)
|
||||
|
||||
// GetHolderCount returns the current number of distinct holders for a symbol.
|
||||
GetHolderCount func(symbol string) (int, error)
|
||||
|
||||
// Restrictions are the transfer restrictions for the market.
|
||||
// Usually copied from MarketConfig.Restrictions.
|
||||
Restrictions *TransferRestriction
|
||||
}
|
||||
|
||||
// CanTrade checks all configured restrictions against the investor profile.
|
||||
func (c *StandardEligibilityChecker) CanTrade(userID string, symbol string, side Side, size float64, price float64) error {
|
||||
if c.Restrictions == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
profile, err := c.GetProfile(userID)
|
||||
if err != nil {
|
||||
return &ComplianceError{Code: ErrTransferRestricted, Message: "unable to verify investor profile"}
|
||||
}
|
||||
if profile == nil {
|
||||
return &ComplianceError{Code: ErrKYCRequired, Message: "investor profile not found"}
|
||||
}
|
||||
|
||||
// KYC/AML
|
||||
if !profile.KYCVerified {
|
||||
return &ComplianceError{Code: ErrKYCRequired, Message: "KYC verification required"}
|
||||
}
|
||||
if !profile.AMLCleared {
|
||||
return &ComplianceError{Code: ErrAMLRequired, Message: "AML clearance required"}
|
||||
}
|
||||
|
||||
r := c.Restrictions
|
||||
|
||||
// Investor status check (accreditation)
|
||||
if profile.Status < r.MinInvestorStatus {
|
||||
return &ComplianceError{
|
||||
Code: ErrNotAccredited,
|
||||
Message: "investor does not meet minimum status requirement",
|
||||
Detail: r.MinInvestorStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// Jurisdiction whitelist
|
||||
if len(r.JurisdictionWhitelist) > 0 {
|
||||
allowed := false
|
||||
for _, j := range r.JurisdictionWhitelist {
|
||||
if j == profile.Jurisdiction {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return &ComplianceError{
|
||||
Code: ErrJurisdiction,
|
||||
Message: "investor jurisdiction not in whitelist",
|
||||
Detail: profile.Jurisdiction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Jurisdiction blacklist
|
||||
for _, j := range r.JurisdictionBlacklist {
|
||||
if j == profile.Jurisdiction {
|
||||
return &ComplianceError{
|
||||
Code: ErrJurisdiction,
|
||||
Message: "investor jurisdiction is restricted",
|
||||
Detail: profile.Jurisdiction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Max holders (only check for buy side — new holders)
|
||||
if side == Buy && r.MaxHolders > 0 && c.GetHolderCount != nil {
|
||||
// Only check if this would be a NEW holder
|
||||
currentHolding := profile.Holdings[symbol]
|
||||
if currentHolding == 0 {
|
||||
count, err := c.GetHolderCount(symbol)
|
||||
if err == nil && count >= r.MaxHolders {
|
||||
return &ComplianceError{
|
||||
Code: ErrMaxHolders,
|
||||
Message: "maximum holder count reached",
|
||||
Detail: r.MaxHolders,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ROFR / board approval (these would typically be checked via an async
|
||||
// workflow, but we reject synchronously if the flag is set and no
|
||||
// pre-approval exists. The operator's GetProfile should set these
|
||||
// flags based on prior approval status.)
|
||||
if side == Buy && r.ROFRRequired {
|
||||
return &ComplianceError{
|
||||
Code: ErrROFRPending,
|
||||
Message: "right of first refusal review required",
|
||||
}
|
||||
}
|
||||
if r.BoardApprovalRequired {
|
||||
return &ComplianceError{
|
||||
Code: ErrBoardApproval,
|
||||
Message: "board approval required for transfer",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,515 +0,0 @@
|
||||
package lx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func accreditedProfile(userID string) *InvestorProfile {
|
||||
return &InvestorProfile{
|
||||
UserID: userID,
|
||||
Status: InvestorAccredited,
|
||||
Jurisdiction: "US",
|
||||
AccreditedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||
KYCVerified: true,
|
||||
AMLCleared: true,
|
||||
Holdings: map[string]float64{},
|
||||
}
|
||||
}
|
||||
|
||||
func retailProfile(userID string) *InvestorProfile {
|
||||
return &InvestorProfile{
|
||||
UserID: userID,
|
||||
Status: InvestorRetail,
|
||||
Jurisdiction: "US",
|
||||
KYCVerified: true,
|
||||
AMLCleared: true,
|
||||
Holdings: map[string]float64{},
|
||||
}
|
||||
}
|
||||
|
||||
func regD506cMarket() MarketConfig {
|
||||
return MarketConfig{
|
||||
Symbol: "ACME-USD",
|
||||
AssetClass: AssetClassEquity,
|
||||
Exemption: ExemptionRegD506c,
|
||||
ShareClass: "Series A Preferred",
|
||||
TickSize: 0.01,
|
||||
LotSize: 1.0,
|
||||
MinNotional: 25000.0,
|
||||
Restrictions: &TransferRestriction{
|
||||
HoldingPeriod: 365 * 24 * time.Hour,
|
||||
MaxHolders: 2000,
|
||||
MinInvestorStatus: InvestorAccredited,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- StandardEligibilityChecker tests ---
|
||||
|
||||
func TestAccreditedInvestorCanTrade(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil
|
||||
},
|
||||
GetHolderCount: func(symbol string) (int, error) { return 10, nil },
|
||||
Restrictions: &TransferRestriction{
|
||||
MinInvestorStatus: InvestorAccredited,
|
||||
MaxHolders: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 100, 50.0)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRetailInvestorRejectedForRegD(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return retailProfile(userID), nil
|
||||
},
|
||||
Restrictions: &TransferRestriction{
|
||||
MinInvestorStatus: InvestorAccredited,
|
||||
},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 100, 50.0)
|
||||
require.Error(t, err)
|
||||
|
||||
ce, ok := err.(*ComplianceError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, ErrNotAccredited, ce.Code)
|
||||
}
|
||||
|
||||
func TestKYCRequired(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
p := accreditedProfile(userID)
|
||||
p.KYCVerified = false
|
||||
return p, nil
|
||||
},
|
||||
Restrictions: &TransferRestriction{},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrKYCRequired, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestAMLRequired(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
p := accreditedProfile(userID)
|
||||
p.AMLCleared = false
|
||||
return p, nil
|
||||
},
|
||||
Restrictions: &TransferRestriction{},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrAMLRequired, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestJurisdictionWhitelist(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
p := accreditedProfile(userID)
|
||||
p.Jurisdiction = "CN"
|
||||
return p, nil
|
||||
},
|
||||
Restrictions: &TransferRestriction{
|
||||
JurisdictionWhitelist: []string{"US", "CA", "GB"},
|
||||
},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrJurisdiction, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestJurisdictionWhitelistAllowed(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil
|
||||
},
|
||||
Restrictions: &TransferRestriction{
|
||||
JurisdictionWhitelist: []string{"US", "CA", "GB"},
|
||||
},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestJurisdictionBlacklist(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
p := accreditedProfile(userID)
|
||||
p.Jurisdiction = "KP"
|
||||
return p, nil
|
||||
},
|
||||
Restrictions: &TransferRestriction{
|
||||
JurisdictionBlacklist: []string{"KP", "IR", "CU"},
|
||||
},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrJurisdiction, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestMaxHoldersBuyRejected(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil // Holdings empty = new holder
|
||||
},
|
||||
GetHolderCount: func(symbol string) (int, error) { return 2000, nil },
|
||||
Restrictions: &TransferRestriction{
|
||||
MaxHolders: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrMaxHolders, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestMaxHoldersExistingHolderOK(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
p := accreditedProfile(userID)
|
||||
p.Holdings["ACME-USD"] = 50.0 // Already a holder
|
||||
return p, nil
|
||||
},
|
||||
GetHolderCount: func(symbol string) (int, error) { return 2000, nil },
|
||||
Restrictions: &TransferRestriction{
|
||||
MaxHolders: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMaxHoldersSellAlwaysOK(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil
|
||||
},
|
||||
GetHolderCount: func(symbol string) (int, error) { return 2000, nil },
|
||||
Restrictions: &TransferRestriction{
|
||||
MaxHolders: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
// Sell side should never be blocked by max holders
|
||||
err := checker.CanTrade("user1", "ACME-USD", Sell, 10, 100.0)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestROFRRequired(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil
|
||||
},
|
||||
Restrictions: &TransferRestriction{
|
||||
ROFRRequired: true,
|
||||
},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrROFRPending, err.(*ComplianceError).Code)
|
||||
|
||||
// Sell side should also work (ROFR only blocks buy)
|
||||
// Actually, ROFR is on buy side in our impl
|
||||
err = checker.CanTrade("user1", "ACME-USD", Sell, 10, 100.0)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNoRestrictionsPassesAll(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil
|
||||
},
|
||||
Restrictions: nil,
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "BTC-USD", Buy, 1, 50000.0)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestProfileLookupError(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return nil, fmt.Errorf("database unavailable")
|
||||
},
|
||||
Restrictions: &TransferRestriction{},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("user1", "ACME-USD", Buy, 10, 100.0)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrTransferRestricted, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestProfileNotFound(t *testing.T) {
|
||||
checker := &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return nil, nil
|
||||
},
|
||||
Restrictions: &TransferRestriction{},
|
||||
}
|
||||
|
||||
err := checker.CanTrade("unknown", "ACME-USD", Buy, 10, 100.0)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrKYCRequired, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
// --- SecuritiesMarket tests ---
|
||||
|
||||
func TestSecuritiesMarketMinNotional(t *testing.T) {
|
||||
config := regD506cMarket()
|
||||
config.EligibilityChecker = &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil
|
||||
},
|
||||
GetHolderCount: func(symbol string) (int, error) { return 10, nil },
|
||||
Restrictions: config.Restrictions,
|
||||
}
|
||||
|
||||
sm := NewSecuritiesMarket(config)
|
||||
|
||||
// Order notional = 10 * 100 = $1,000 < $25,000 minimum
|
||||
order := &AdvancedOrder{
|
||||
ID: 1,
|
||||
UserID: "user1",
|
||||
Symbol: "ACME-USD",
|
||||
Side: Buy,
|
||||
Type: Limit,
|
||||
Price: 100.0,
|
||||
Size: 10.0,
|
||||
}
|
||||
|
||||
_, err := sm.AddOrder(order)
|
||||
require.Error(t, err)
|
||||
ce, ok := err.(*ComplianceError)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, ErrMinNotional, ce.Code)
|
||||
}
|
||||
|
||||
func TestSecuritiesMarketMinNotionalPasses(t *testing.T) {
|
||||
config := regD506cMarket()
|
||||
config.EligibilityChecker = &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil
|
||||
},
|
||||
GetHolderCount: func(symbol string) (int, error) { return 10, nil },
|
||||
Restrictions: config.Restrictions,
|
||||
}
|
||||
|
||||
sm := NewSecuritiesMarket(config)
|
||||
|
||||
// Order notional = 500 * 100 = $50,000 >= $25,000 minimum
|
||||
order := &AdvancedOrder{
|
||||
ID: 1,
|
||||
UserID: "user1",
|
||||
Symbol: "ACME-USD",
|
||||
Side: Buy,
|
||||
Type: Limit,
|
||||
Price: 100.0,
|
||||
Size: 500.0,
|
||||
}
|
||||
|
||||
_, err := sm.AddOrder(order)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSecuritiesMarketRejectsRetailInvestor(t *testing.T) {
|
||||
config := regD506cMarket()
|
||||
config.EligibilityChecker = &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return retailProfile(userID), nil
|
||||
},
|
||||
Restrictions: config.Restrictions,
|
||||
}
|
||||
|
||||
sm := NewSecuritiesMarket(config)
|
||||
|
||||
order := &AdvancedOrder{
|
||||
ID: 1,
|
||||
UserID: "retail_user",
|
||||
Symbol: "ACME-USD",
|
||||
Side: Buy,
|
||||
Type: Limit,
|
||||
Price: 100.0,
|
||||
Size: 500.0,
|
||||
}
|
||||
|
||||
_, err := sm.AddOrder(order)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrNotAccredited, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestSecuritiesMarketMatchWithSettlement(t *testing.T) {
|
||||
var settledTrades []Trade
|
||||
|
||||
config := regD506cMarket()
|
||||
config.EligibilityChecker = &StandardEligibilityChecker{
|
||||
GetProfile: func(userID string) (*InvestorProfile, error) {
|
||||
return accreditedProfile(userID), nil
|
||||
},
|
||||
GetHolderCount: func(symbol string) (int, error) { return 10, nil },
|
||||
Restrictions: config.Restrictions,
|
||||
}
|
||||
config.SettlementHandler = &testSettlementHandler{
|
||||
onTrade: func(trade *Trade, mc *MarketConfig) error {
|
||||
settledTrades = append(settledTrades, *trade)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
sm := NewSecuritiesMarket(config)
|
||||
|
||||
// Place a sell order
|
||||
sell := &AdvancedOrder{
|
||||
ID: 1,
|
||||
UserID: "seller1",
|
||||
Symbol: "ACME-USD",
|
||||
Side: Sell,
|
||||
Type: Limit,
|
||||
Price: 100.0,
|
||||
Size: 500.0,
|
||||
}
|
||||
_, err := sm.AddOrder(sell)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Place a matching buy order
|
||||
buy := &AdvancedOrder{
|
||||
ID: 2,
|
||||
UserID: "buyer1",
|
||||
Symbol: "ACME-USD",
|
||||
Side: Buy,
|
||||
Type: Limit,
|
||||
Price: 100.0,
|
||||
Size: 500.0,
|
||||
}
|
||||
trades, err := sm.AddOrder(buy)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, trades, 1)
|
||||
|
||||
// Verify settlement was called
|
||||
require.Len(t, settledTrades, 1)
|
||||
assert.Equal(t, 100.0, settledTrades[0].Price)
|
||||
assert.Equal(t, 500.0, settledTrades[0].Size)
|
||||
}
|
||||
|
||||
func TestSecuritiesMarketSingleMatchMode(t *testing.T) {
|
||||
config := regD506cMarket()
|
||||
config.SingleMatchMode = true
|
||||
config.MinNotional = 0 // Disable for this test
|
||||
config.EligibilityChecker = nil
|
||||
config.Restrictions = nil
|
||||
|
||||
sm := NewSecuritiesMarket(config)
|
||||
|
||||
// Place one sell order for 50 shares only
|
||||
sell1 := &AdvancedOrder{
|
||||
ID: 1, UserID: "s1", Symbol: "ACME-USD",
|
||||
Side: Sell, Type: Limit, Price: 100.0, Size: 50.0,
|
||||
}
|
||||
sm.AdvancedOrderBook.AddOrder(sell1)
|
||||
|
||||
// Buy 200 shares — only 50 available, so 150 remain after match.
|
||||
// In single-match mode, remainder should be canceled.
|
||||
buy := &AdvancedOrder{
|
||||
ID: 2, UserID: "b1", Symbol: "ACME-USD",
|
||||
Side: Buy, Type: Limit, Price: 100.0, Size: 200.0,
|
||||
}
|
||||
trades, err := sm.AddOrder(buy)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, trades, 1)
|
||||
assert.Equal(t, 50.0, trades[0].Size)
|
||||
|
||||
// Single-match mode: remaining 150 should be canceled
|
||||
assert.Equal(t, StatusCanceled, buy.Status)
|
||||
assert.Equal(t, 0.0, buy.RemainingSize)
|
||||
}
|
||||
|
||||
func TestSecuritiesMarketMaxSize(t *testing.T) {
|
||||
config := DefaultMarketConfig()
|
||||
config.Symbol = "TEST-USD"
|
||||
config.MaxSize = 1000.0
|
||||
|
||||
sm := NewSecuritiesMarket(config)
|
||||
|
||||
order := &AdvancedOrder{
|
||||
ID: 1, UserID: "user1", Symbol: "TEST-USD",
|
||||
Side: Buy, Type: Limit, Price: 10.0, Size: 2000.0,
|
||||
}
|
||||
_, err := sm.AddOrder(order)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrMaxSize, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestSecuritiesMarketMinSize(t *testing.T) {
|
||||
config := DefaultMarketConfig()
|
||||
config.Symbol = "TEST-USD"
|
||||
config.MinSize = 100.0
|
||||
config.LotSize = 1.0
|
||||
|
||||
sm := NewSecuritiesMarket(config)
|
||||
|
||||
order := &AdvancedOrder{
|
||||
ID: 1, UserID: "user1", Symbol: "TEST-USD",
|
||||
Side: Buy, Type: Limit, Price: 10.0, Size: 50.0,
|
||||
}
|
||||
_, err := sm.AddOrder(order)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrMinSize, err.(*ComplianceError).Code)
|
||||
}
|
||||
|
||||
func TestDefaultMarketConfigIsUnregulated(t *testing.T) {
|
||||
config := DefaultMarketConfig()
|
||||
assert.False(t, config.IsRegulated())
|
||||
assert.False(t, config.HasRestrictions())
|
||||
}
|
||||
|
||||
func TestRegDMarketConfigIsRegulated(t *testing.T) {
|
||||
config := regD506cMarket()
|
||||
assert.True(t, config.IsRegulated())
|
||||
assert.True(t, config.HasRestrictions())
|
||||
}
|
||||
|
||||
func TestComplianceErrorMessage(t *testing.T) {
|
||||
e := &ComplianceError{
|
||||
Code: ErrNotAccredited,
|
||||
Message: "investor not accredited",
|
||||
Detail: InvestorAccredited,
|
||||
}
|
||||
assert.Contains(t, e.Error(), "investor not accredited")
|
||||
assert.Contains(t, e.Error(), "compliance:")
|
||||
|
||||
e2 := &ComplianceError{
|
||||
Code: ErrKYCRequired,
|
||||
Message: "KYC required",
|
||||
}
|
||||
assert.Contains(t, e2.Error(), "KYC required")
|
||||
assert.NotContains(t, e2.Error(), "detail")
|
||||
}
|
||||
|
||||
// --- test helpers ---
|
||||
|
||||
type testSettlementHandler struct {
|
||||
onTrade func(trade *Trade, mc *MarketConfig) error
|
||||
}
|
||||
|
||||
func (h *testSettlementHandler) OnTrade(trade *Trade, mc *MarketConfig) error {
|
||||
return h.onTrade(trade, mc)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
package lx
|
||||
|
||||
import "time"
|
||||
|
||||
// AssetClass represents the classification of a traded asset.
|
||||
type AssetClass uint8
|
||||
|
||||
const (
|
||||
AssetClassCrypto AssetClass = iota // Unregulated digital asset
|
||||
AssetClassEquity // Common/preferred stock (domestic)
|
||||
AssetClassDebt // Bonds, notes, convertible notes
|
||||
AssetClassFund // LP interests, fund shares
|
||||
AssetClassSAFE // Simple Agreement for Future Equity
|
||||
AssetClassCommodity // Physical or digital commodity
|
||||
AssetClassDerivative // Options, futures, swaps
|
||||
|
||||
// Equities — granular
|
||||
AssetClassIntlEquity // International equity (non-US)
|
||||
|
||||
// FX
|
||||
AssetClassForex // Foreign exchange pairs
|
||||
|
||||
// Derivatives — granular
|
||||
AssetClassOptions // Options contracts
|
||||
AssetClassFutures // Futures contracts
|
||||
|
||||
// Fixed income / credit
|
||||
AssetClassFixedIncome // Bonds, notes, treasuries
|
||||
AssetClassMunicipal // Municipal bonds
|
||||
AssetClassStructured // ABS, MBS, CDO, CLO
|
||||
AssetClassCorporateDebt // Corporate loans, credit facilities
|
||||
AssetClassConsumerDebt // Personal loans, credit card ABS
|
||||
|
||||
// Real assets
|
||||
AssetClassRealEstate // REIT, fractional RE, RE tokens
|
||||
AssetClassPreciousMetals // XAU, XAG, XPT, XPD spot
|
||||
|
||||
// Alternative investments
|
||||
AssetClassPrivateEquity // PE fund tokens, SPV shares
|
||||
AssetClassVenture // VC fund shares, SAFE tokens
|
||||
AssetClassPrivateCredit // Direct lending, mezzanine
|
||||
|
||||
// DeFi-native (bridged)
|
||||
AssetClassCDP // Collateralized debt positions
|
||||
AssetClassLP // Liquidity pool tokens
|
||||
)
|
||||
|
||||
// ExemptionType identifies the registration exemption or regulatory framework
|
||||
// under which a security is offered and traded.
|
||||
type ExemptionType uint8
|
||||
|
||||
const (
|
||||
ExemptionNone ExemptionType = iota // No exemption (registered or crypto)
|
||||
|
||||
// United States (SEC)
|
||||
ExemptionRegD506b // Reg D Rule 506(b) — up to 35 non-accredited
|
||||
ExemptionRegD506c // Reg D Rule 506(c) — accredited only, general solicitation OK
|
||||
ExemptionRegA1 // Reg A Tier 1 — up to $20M/year
|
||||
ExemptionRegA2 // Reg A Tier 2 — up to $75M/year
|
||||
ExemptionRegCF // Regulation Crowdfunding — up to $5M/year
|
||||
ExemptionRegS // Regulation S — offshore only
|
||||
ExemptionRule144 // Rule 144 resale of restricted securities
|
||||
ExemptionSection4a7 // Section 4(a)(7) — private resales to accredited
|
||||
|
||||
// United Kingdom (FCA)
|
||||
ExemptionFCAPrivate // FCA private placement (no prospectus required)
|
||||
ExemptionFCAProspectus // FCA prospectus-exempt (qualified investors only)
|
||||
|
||||
// Singapore (MAS)
|
||||
ExemptionMASSIP // MAS Small Offers (S$5M/12 months, <=50 persons)
|
||||
ExemptionMASPrivate // MAS Private Placement (<=50 persons in 12 months)
|
||||
ExemptionMASInstitutional // MAS Institutional Investor exemption
|
||||
|
||||
// European Union (MiFID II / Prospectus Regulation)
|
||||
ExemptionEUQualifiedInvestor // Qualified investor exemption (Art 1(4)(a))
|
||||
ExemptionEUSmallOffering // < EUR 8M (Art 3(2))
|
||||
ExemptionEUCrowdfunding // ECSP Regulation
|
||||
|
||||
// Hong Kong (SFO)
|
||||
ExemptionHKProfessionalInvestor // Professional investor (Cap 571D)
|
||||
ExemptionHKSmallOffering // Offers to <50 persons
|
||||
|
||||
// Japan (FIEA)
|
||||
ExemptionJPQII // Qualified Institutional Investor
|
||||
|
||||
// Australia (Corporations Act)
|
||||
ExemptionAUSophisticated // Sophisticated investor (s708(8))
|
||||
ExemptionAUSmallScale // Small scale offering (s708(1))
|
||||
|
||||
// UAE (DFSA / ADGM)
|
||||
ExemptionUAEExemptOffer // Exempt offer under DFSA Rules
|
||||
ExemptionUAEQualifiedInvestor // Qualified investor under ADGM
|
||||
|
||||
// Switzerland (FinSA)
|
||||
ExemptionCHQualifiedInvestor // Qualified investor exemption
|
||||
)
|
||||
|
||||
// InvestorStatus represents the regulatory classification of an investor.
|
||||
// Values are ordered by privilege level — higher values have more access.
|
||||
type InvestorStatus uint8
|
||||
|
||||
const (
|
||||
InvestorRetail InvestorStatus = iota // Non-accredited retail
|
||||
InvestorAccredited // US: SEC accredited investor
|
||||
InvestorSophisticated // UK: FCA certified sophisticated investor
|
||||
InvestorProfessional // UK: FCA professional client / SG: MAS accredited
|
||||
InvestorQualifiedPurchaser // US: Qualified purchaser ($5M+ investments)
|
||||
InvestorQualifiedClient // US: Qualified client ($1.1M+ net worth)
|
||||
InvestorInstitutional // Institutional investor (bank, fund, etc.)
|
||||
)
|
||||
|
||||
// TransferRestriction defines a constraint on the transferability of a security.
|
||||
type TransferRestriction struct {
|
||||
// HoldingPeriod is the minimum duration a buyer must hold before resale.
|
||||
// Zero means no holding period restriction.
|
||||
HoldingPeriod time.Duration
|
||||
|
||||
// MaxHolders is the maximum number of beneficial owners allowed.
|
||||
// Zero means unlimited.
|
||||
MaxHolders int
|
||||
|
||||
// MinInvestorStatus is the minimum investor classification required to trade.
|
||||
MinInvestorStatus InvestorStatus
|
||||
|
||||
// JurisdictionWhitelist limits trading to investors in these jurisdictions.
|
||||
// Empty means all jurisdictions allowed.
|
||||
JurisdictionWhitelist []string
|
||||
|
||||
// JurisdictionBlacklist prohibits trading by investors in these jurisdictions.
|
||||
JurisdictionBlacklist []string
|
||||
|
||||
// ROFRRequired indicates the company has a right of first refusal on transfers.
|
||||
ROFRRequired bool
|
||||
|
||||
// BoardApprovalRequired indicates transfers require board/GP approval.
|
||||
BoardApprovalRequired bool
|
||||
}
|
||||
|
||||
// PEPStatus indicates politically exposed person classification.
|
||||
type PEPStatus uint8
|
||||
|
||||
const (
|
||||
PEPNone PEPStatus = iota // Not a PEP
|
||||
PEPDirect // Directly a PEP (head of state, senior official, etc.)
|
||||
PEPRelated // Close associate or family member of a PEP
|
||||
PEPFormer // Former PEP (cooling-off period)
|
||||
)
|
||||
|
||||
// InvestorProfile holds the compliance-relevant attributes of a trader.
|
||||
// Provided by the EligibilityChecker implementation, not stored in the DEX.
|
||||
type InvestorProfile struct {
|
||||
UserID string
|
||||
Status InvestorStatus
|
||||
Jurisdiction string // ISO 3166-1 alpha-2 country code
|
||||
AccreditedAt time.Time
|
||||
KYCVerified bool
|
||||
AMLCleared bool
|
||||
|
||||
// Holdings tracks current share count per symbol for max-holder checks.
|
||||
// The checker implementation is responsible for populating this.
|
||||
Holdings map[string]float64
|
||||
|
||||
// PEP / EDD (Enhanced Due Diligence)
|
||||
PEP PEPStatus // Politically exposed person status
|
||||
EDDCompleted bool // Enhanced due diligence completed
|
||||
SourceOfFunds string // Declared source of funds
|
||||
SOFVerified bool // Source of funds verified
|
||||
AdverseMedia bool // Adverse media screening flagged
|
||||
HighRiskCountry bool // Jurisdiction is FATF high-risk
|
||||
TaxResidency string // Tax residency country code
|
||||
EntityID string // Legal entity identifier (LEI for institutions)
|
||||
}
|
||||
+10
-48
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// VenueKind distinguishes the fundamental type of trading venue.
|
||||
// CEX and DEX have completely different deposit/withdrawal mechanics,
|
||||
// risk profiles, compliance gates, and settlement times.
|
||||
// risk profiles, and settlement times.
|
||||
type VenueKind uint8
|
||||
|
||||
const (
|
||||
@@ -44,15 +44,13 @@ type FundsStatus uint8
|
||||
|
||||
const (
|
||||
FundsStatusPending FundsStatus = iota // Created, awaiting processing
|
||||
FundsStatusCompliance // Undergoing compliance review
|
||||
FundsStatusApproved // Compliance approved, ready to execute
|
||||
FundsStatusApproved // Validated, ready to execute
|
||||
FundsStatusProcessing // Execution in progress
|
||||
FundsStatusConfirming // Awaiting on-chain confirmations (DEX)
|
||||
FundsStatusSettling // Settlement in progress (CEX T+1/T+2)
|
||||
FundsStatusCompleted // Fully settled
|
||||
FundsStatusFailed // Failed (insufficient funds, compliance, etc.)
|
||||
FundsStatusFailed // Failed (insufficient funds, limit exceeded, etc.)
|
||||
FundsStatusCancelled // Cancelled by user or system
|
||||
FundsStatusFrozen // Frozen by compliance (AML hold)
|
||||
)
|
||||
|
||||
// RiskLevel classifies the risk of a funds movement.
|
||||
@@ -61,8 +59,8 @@ type RiskLevel uint8
|
||||
const (
|
||||
RiskLow RiskLevel = iota // Internal, small amounts, known counterparty
|
||||
RiskMedium // Cross-venue, moderate amounts
|
||||
RiskHigh // Large amounts, new counterparty, high-risk jurisdiction
|
||||
RiskCritical // Requires manual review (PEP, sanctions, etc.)
|
||||
RiskHigh // Large amounts, cross-venue/external
|
||||
RiskCritical // Requires manual review
|
||||
)
|
||||
|
||||
// FundsMovement is the universal representation of any deposit, withdrawal,
|
||||
@@ -95,13 +93,6 @@ type FundsMovement struct {
|
||||
UpdatedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
|
||||
// Compliance
|
||||
ComplianceCheckID string // Pre-movement compliance check
|
||||
ComplianceStatus string // approved, pending_review, rejected, frozen
|
||||
AMLFlag bool // Flagged by AML screening
|
||||
SanctionsFlag bool // Flagged by sanctions check
|
||||
ReviewerID string // Manual reviewer (if required)
|
||||
|
||||
// On-chain (DEX movements)
|
||||
SourceTxHash string // Source chain transaction
|
||||
DestTxHash string // Destination chain transaction
|
||||
@@ -127,7 +118,6 @@ type RiskProfile struct {
|
||||
CustodialRisk bool // True for CEX movements
|
||||
BridgeRisk bool // True for cross-chain
|
||||
SettlementTime string // "instant", "~15min", "T+1", "T+2", "1-5 business days"
|
||||
ComplianceGates []string
|
||||
TypicalFees string // Human-readable fee description
|
||||
}
|
||||
|
||||
@@ -138,7 +128,6 @@ var routeRiskProfiles = map[FundsRoute]RiskProfile{
|
||||
CounterpartyRisk: "none",
|
||||
SmartContractRisk: true,
|
||||
SettlementTime: "instant",
|
||||
ComplianceGates: []string{},
|
||||
TypicalFees: "gas only",
|
||||
},
|
||||
RouteCEXInternal: {
|
||||
@@ -146,7 +135,6 @@ var routeRiskProfiles = map[FundsRoute]RiskProfile{
|
||||
CounterpartyRisk: "custodial",
|
||||
CustodialRisk: true,
|
||||
SettlementTime: "instant",
|
||||
ComplianceGates: []string{"account_verification"},
|
||||
TypicalFees: "none",
|
||||
},
|
||||
RouteDEXToDEX: {
|
||||
@@ -155,7 +143,6 @@ var routeRiskProfiles = map[FundsRoute]RiskProfile{
|
||||
SmartContractRisk: true,
|
||||
BridgeRisk: true,
|
||||
SettlementTime: "~15min (bridge confirmations)",
|
||||
ComplianceGates: []string{},
|
||||
TypicalFees: "bridge fee (0.1-0.3%) + gas",
|
||||
},
|
||||
RouteCEXToCEX: {
|
||||
@@ -163,7 +150,6 @@ var routeRiskProfiles = map[FundsRoute]RiskProfile{
|
||||
CounterpartyRisk: "custodial",
|
||||
CustodialRisk: true,
|
||||
SettlementTime: "T+1 to T+2",
|
||||
ComplianceGates: []string{"kyc", "aml", "withdrawal_limit"},
|
||||
TypicalFees: "broker transfer fee",
|
||||
},
|
||||
RouteDEXToCEX: {
|
||||
@@ -172,7 +158,6 @@ var routeRiskProfiles = map[FundsRoute]RiskProfile{
|
||||
SmartContractRisk: true,
|
||||
CustodialRisk: true,
|
||||
SettlementTime: "~30min (on-chain confirm + CEX credit)",
|
||||
ComplianceGates: []string{"kyc", "aml", "source_verification", "address_whitelist"},
|
||||
TypicalFees: "gas + CEX deposit fee (often free)",
|
||||
},
|
||||
RouteCEXToDEX: {
|
||||
@@ -181,7 +166,6 @@ var routeRiskProfiles = map[FundsRoute]RiskProfile{
|
||||
CustodialRisk: true,
|
||||
SmartContractRisk: true,
|
||||
SettlementTime: "~15min (CEX processing + on-chain confirm)",
|
||||
ComplianceGates: []string{"kyc", "aml", "withdrawal_limit", "address_whitelist", "2fa"},
|
||||
TypicalFees: "CEX withdrawal fee + gas",
|
||||
},
|
||||
RouteExternalIn: {
|
||||
@@ -189,7 +173,6 @@ var routeRiskProfiles = map[FundsRoute]RiskProfile{
|
||||
CounterpartyRisk: "banking",
|
||||
CustodialRisk: true,
|
||||
SettlementTime: "1-5 business days (wire/ACH)",
|
||||
ComplianceGates: []string{"kyc", "aml", "source_of_funds", "bank_verification"},
|
||||
TypicalFees: "wire fee ($25-50) or free (ACH)",
|
||||
},
|
||||
RouteExternalOut: {
|
||||
@@ -197,7 +180,6 @@ var routeRiskProfiles = map[FundsRoute]RiskProfile{
|
||||
CounterpartyRisk: "banking",
|
||||
CustodialRisk: true,
|
||||
SettlementTime: "1-5 business days (wire/ACH)",
|
||||
ComplianceGates: []string{"kyc", "aml", "withdrawal_limit", "bank_verification", "travel_rule"},
|
||||
TypicalFees: "wire fee ($25-50) or free (ACH)",
|
||||
},
|
||||
}
|
||||
@@ -223,8 +205,7 @@ type FundsManager struct {
|
||||
lastReset time.Time
|
||||
|
||||
// Hooks
|
||||
preCheck func(m *FundsMovement) error // Pre-movement compliance check
|
||||
onComplete func(m *FundsMovement) // Post-settlement callback
|
||||
onComplete func(m *FundsMovement) // Post-settlement callback
|
||||
onFail func(m *FundsMovement, reason string)
|
||||
|
||||
// Bridge for cross-chain movements
|
||||
@@ -245,13 +226,11 @@ func NewFundsManager(bridge *EnhancedBridge) *FundsManager {
|
||||
|
||||
// SetHooks sets lifecycle callbacks.
|
||||
func (fm *FundsManager) SetHooks(
|
||||
preCheck func(*FundsMovement) error,
|
||||
onComplete func(*FundsMovement),
|
||||
onFail func(*FundsMovement, string),
|
||||
) {
|
||||
fm.mu.Lock()
|
||||
defer fm.mu.Unlock()
|
||||
fm.preCheck = preCheck
|
||||
fm.onComplete = onComplete
|
||||
fm.onFail = onFail
|
||||
}
|
||||
@@ -306,17 +285,9 @@ func (fm *FundsManager) Initiate(m *FundsMovement) (*FundsMovement, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Run pre-check hook (compliance)
|
||||
if fm.preCheck != nil {
|
||||
m.Status = FundsStatusCompliance
|
||||
if err := fm.preCheck(m); err != nil {
|
||||
m.Status = FundsStatusFailed
|
||||
m.FailureReason = err.Error()
|
||||
fm.movements[m.ID] = m
|
||||
return m, err
|
||||
}
|
||||
}
|
||||
|
||||
// Permissionless: a validated movement is approved. There is no
|
||||
// eligibility/KYC gate — only the funds checks above (positive amount,
|
||||
// asset/source present, daily withdrawal limit) can reject a movement.
|
||||
m.Status = FundsStatusApproved
|
||||
|
||||
// Store
|
||||
@@ -418,11 +389,6 @@ func (fm *FundsManager) GetAccountMovements(accountID string) []*FundsMovement {
|
||||
// --- Risk classification ---
|
||||
|
||||
func (fm *FundsManager) classifyRisk(m *FundsMovement) RiskLevel {
|
||||
// Sanctions or AML flag = critical
|
||||
if m.SanctionsFlag || m.AMLFlag {
|
||||
return RiskCritical
|
||||
}
|
||||
|
||||
// Large amounts = high risk (threshold: 10,000 USD equivalent)
|
||||
threshold := new(big.Int).Mul(big.NewInt(10000), big.NewInt(1e6)) // 10K in 6-decimal units
|
||||
if m.Amount.Cmp(threshold) > 0 {
|
||||
@@ -514,18 +480,16 @@ func (fm *FundsManager) executeDEXToCEX(m *FundsMovement) error {
|
||||
// 2. Send to CEX deposit address
|
||||
// 3. Wait for CEX to credit (confirmations required)
|
||||
// Risk: smart contract (step 1) + custodial (step 3)
|
||||
// Compliance: CEX requires address whitelisting, AML check on deposit
|
||||
// Settlement: ~30 min (on-chain confirms + CEX processing)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CEX→DEX: custodial withdrawal + on-chain deposit — mixed risk, ~15 min.
|
||||
func (fm *FundsManager) executeCEXToDEX(m *FundsMovement) error {
|
||||
// 1. CEX processes withdrawal (compliance checks, 2FA, withdrawal limits)
|
||||
// 1. CEX processes withdrawal (2FA, withdrawal limits)
|
||||
// 2. CEX sends on-chain transaction
|
||||
// 3. DEX contract receives deposit
|
||||
// Risk: custodial (step 1) + smart contract (step 3)
|
||||
// Compliance: CEX withdrawal compliance (KYC, AML, travel rule, limits)
|
||||
// Settlement: ~15 min
|
||||
return nil
|
||||
}
|
||||
@@ -534,7 +498,6 @@ func (fm *FundsManager) executeCEXToDEX(m *FundsMovement) error {
|
||||
func (fm *FundsManager) executeExternalIn(m *FundsMovement) error {
|
||||
// Fiat deposit via wire transfer, ACH, or card
|
||||
// Risk: banking counterparty
|
||||
// Compliance: source of funds verification, bank account verification
|
||||
// Settlement: 1-5 business days (wire), 3-5 days (ACH), instant (card, with limits)
|
||||
return nil
|
||||
}
|
||||
@@ -543,7 +506,6 @@ func (fm *FundsManager) executeExternalIn(m *FundsMovement) error {
|
||||
func (fm *FundsManager) executeExternalOut(m *FundsMovement) error {
|
||||
// Fiat withdrawal via wire transfer or ACH
|
||||
// Risk: banking counterparty
|
||||
// Compliance: KYC, AML, travel rule, withdrawal limits, tax reporting
|
||||
// Settlement: 1-5 business days
|
||||
return nil
|
||||
}
|
||||
|
||||
+7
-79
@@ -17,7 +17,6 @@ func TestRiskProfileDEXInternal(t *testing.T) {
|
||||
assert.False(t, rp.CustodialRisk)
|
||||
assert.False(t, rp.BridgeRisk)
|
||||
assert.Equal(t, "instant", rp.SettlementTime)
|
||||
assert.Empty(t, rp.ComplianceGates)
|
||||
}
|
||||
|
||||
func TestRiskProfileCEXInternal(t *testing.T) {
|
||||
@@ -39,37 +38,28 @@ func TestRiskProfileCEXToCEX(t *testing.T) {
|
||||
rp := GetRiskProfile(RouteCEXToCEX)
|
||||
assert.True(t, rp.CustodialRisk)
|
||||
assert.Contains(t, rp.SettlementTime, "T+1")
|
||||
assert.Contains(t, rp.ComplianceGates, "kyc")
|
||||
assert.Contains(t, rp.ComplianceGates, "aml")
|
||||
}
|
||||
|
||||
func TestRiskProfileDEXToCEX(t *testing.T) {
|
||||
rp := GetRiskProfile(RouteDEXToCEX)
|
||||
assert.True(t, rp.SmartContractRisk)
|
||||
assert.True(t, rp.CustodialRisk)
|
||||
assert.Contains(t, rp.ComplianceGates, "source_verification")
|
||||
assert.Contains(t, rp.ComplianceGates, "address_whitelist")
|
||||
}
|
||||
|
||||
func TestRiskProfileCEXToDEX(t *testing.T) {
|
||||
rp := GetRiskProfile(RouteCEXToDEX)
|
||||
assert.True(t, rp.CustodialRisk)
|
||||
assert.True(t, rp.SmartContractRisk)
|
||||
assert.Contains(t, rp.ComplianceGates, "2fa")
|
||||
assert.Contains(t, rp.ComplianceGates, "withdrawal_limit")
|
||||
}
|
||||
|
||||
func TestRiskProfileExternalIn(t *testing.T) {
|
||||
rp := GetRiskProfile(RouteExternalIn)
|
||||
assert.Equal(t, "banking", rp.CounterpartyRisk)
|
||||
assert.Contains(t, rp.ComplianceGates, "source_of_funds")
|
||||
assert.Contains(t, rp.ComplianceGates, "bank_verification")
|
||||
}
|
||||
|
||||
func TestRiskProfileExternalOut(t *testing.T) {
|
||||
rp := GetRiskProfile(RouteExternalOut)
|
||||
assert.Equal(t, "banking", rp.CounterpartyRisk)
|
||||
assert.Contains(t, rp.ComplianceGates, "travel_rule")
|
||||
}
|
||||
|
||||
// --- FundsManager tests ---
|
||||
@@ -191,18 +181,11 @@ func TestFundsManagerNoLimitNoRestriction(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestFundsManagerPreCheckHook(t *testing.T) {
|
||||
func TestFundsManagerInitiateApprovesWithoutGate(t *testing.T) {
|
||||
// Permissionless: every validated movement is approved — there is no
|
||||
// eligibility/KYC pre-check hook to reject it.
|
||||
fm := NewFundsManager(nil)
|
||||
|
||||
complianceChecked := false
|
||||
fm.SetHooks(
|
||||
func(m *FundsMovement) error {
|
||||
complianceChecked = true
|
||||
return nil
|
||||
},
|
||||
nil, nil,
|
||||
)
|
||||
|
||||
m := &FundsMovement{
|
||||
Direction: FundsDeposit,
|
||||
Route: RouteDEXInternal,
|
||||
@@ -210,41 +193,16 @@ func TestFundsManagerPreCheckHook(t *testing.T) {
|
||||
Asset: "ETH",
|
||||
Amount: big.NewInt(1000),
|
||||
}
|
||||
_, err := fm.Initiate(m)
|
||||
result, err := fm.Initiate(m)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, complianceChecked)
|
||||
}
|
||||
|
||||
func TestFundsManagerPreCheckRejection(t *testing.T) {
|
||||
fm := NewFundsManager(nil)
|
||||
|
||||
fm.SetHooks(
|
||||
func(m *FundsMovement) error {
|
||||
if m.AMLFlag {
|
||||
return assert.AnError
|
||||
}
|
||||
return nil
|
||||
},
|
||||
nil, nil,
|
||||
)
|
||||
|
||||
m := &FundsMovement{
|
||||
Direction: FundsDeposit,
|
||||
Route: RouteDEXInternal,
|
||||
SourceID: "0xabc",
|
||||
Asset: "ETH",
|
||||
Amount: big.NewInt(1000),
|
||||
AMLFlag: true,
|
||||
}
|
||||
_, err := fm.Initiate(m)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, FundsStatusApproved, result.Status)
|
||||
}
|
||||
|
||||
func TestFundsManagerExecute(t *testing.T) {
|
||||
fm := NewFundsManager(nil)
|
||||
|
||||
var completed bool
|
||||
fm.SetHooks(nil,
|
||||
fm.SetHooks(
|
||||
func(m *FundsMovement) { completed = true },
|
||||
nil,
|
||||
)
|
||||
@@ -302,7 +260,7 @@ func TestFundsManagerDEXToDEXRequiresBridge(t *testing.T) {
|
||||
fm := NewFundsManager(nil) // nil bridge
|
||||
|
||||
var failReason string
|
||||
fm.SetHooks(nil, nil,
|
||||
fm.SetHooks(nil,
|
||||
func(m *FundsMovement, reason string) { failReason = reason },
|
||||
)
|
||||
|
||||
@@ -344,36 +302,6 @@ func TestFundsManagerGetAccountMovements(t *testing.T) {
|
||||
|
||||
// --- Risk classification tests ---
|
||||
|
||||
func TestRiskClassificationSanctionsIsCritical(t *testing.T) {
|
||||
fm := NewFundsManager(nil)
|
||||
|
||||
m := &FundsMovement{
|
||||
Direction: FundsDeposit,
|
||||
Route: RouteDEXInternal,
|
||||
SourceID: "0xabc",
|
||||
Asset: "USDC",
|
||||
Amount: big.NewInt(100),
|
||||
SanctionsFlag: true,
|
||||
}
|
||||
result, _ := fm.Initiate(m)
|
||||
assert.Equal(t, RiskCritical, result.Risk)
|
||||
}
|
||||
|
||||
func TestRiskClassificationAMLIsCritical(t *testing.T) {
|
||||
fm := NewFundsManager(nil)
|
||||
|
||||
m := &FundsMovement{
|
||||
Direction: FundsDeposit,
|
||||
Route: RouteDEXInternal,
|
||||
SourceID: "0xabc",
|
||||
Asset: "USDC",
|
||||
Amount: big.NewInt(100),
|
||||
AMLFlag: true,
|
||||
}
|
||||
result, _ := fm.Initiate(m)
|
||||
assert.Equal(t, RiskCritical, result.Risk)
|
||||
}
|
||||
|
||||
func TestRiskClassificationLargeAmountCrossVenue(t *testing.T) {
|
||||
fm := NewFundsManager(nil)
|
||||
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
package lx
|
||||
|
||||
// MarketConfig defines the trading rules and regulatory constraints for a market.
|
||||
// For unregulated crypto markets, use DefaultMarketConfig().
|
||||
type MarketConfig struct {
|
||||
Symbol string
|
||||
|
||||
// Asset classification
|
||||
AssetClass AssetClass
|
||||
Exemption ExemptionType
|
||||
ShareClass string // e.g. "Series A Preferred", "Class B Common"
|
||||
|
||||
// Transfer restrictions (nil = no restrictions)
|
||||
Restrictions *TransferRestriction
|
||||
|
||||
// Trading constraints
|
||||
MinNotional float64 // Minimum trade notional value (price * size)
|
||||
MinSize float64 // Minimum order size in base units
|
||||
MaxSize float64 // Maximum order size (0 = unlimited)
|
||||
TickSize float64 // Price tick size
|
||||
LotSize float64 // Size lot size
|
||||
|
||||
// Matching behavior
|
||||
SingleMatchMode bool // If true, remove both sides after first match (private secondaries)
|
||||
|
||||
// Compliance hooks (nil = no checks)
|
||||
EligibilityChecker EligibilityChecker
|
||||
SettlementHandler SettlementHandler
|
||||
|
||||
// Offering / Issuer metadata — populated for exempt securities
|
||||
OfferingType string // reg_cf, reg_d_506c, reg_a, reg_s, public
|
||||
IssuerID string
|
||||
IssuerName string
|
||||
|
||||
// Structured product metadata — credit, real estate, private offerings
|
||||
ProductType string // abs, mbs, cdo, clo, reit, cdp, pe_fund, vc_fund
|
||||
CreditRating string // AAA, AA, A, BBB, BB, B, CCC, NR
|
||||
CollateralType string // residential, commercial, auto, student, mixed
|
||||
MaturityDate string // ISO 8601 date
|
||||
CouponRate float64 // Annual coupon rate as decimal
|
||||
YieldToMaturity float64 // Current YTM
|
||||
Leverage float64 // Max leverage for the product
|
||||
CollateralRatio float64 // Min collateral ratio (e.g. 1.50 = 150%)
|
||||
LiquidationRatio float64 // Ratio below which liquidation triggers
|
||||
MinInvestment float64 // Minimum investment amount
|
||||
LockupPeriod string // e.g. "180d", "1y"
|
||||
AccreditedOnly bool // Requires accredited/professional investor
|
||||
InstitutionalOnly bool // Institutions only (QIB, etc.)
|
||||
|
||||
// CEX reconciliation — links this DEX market to a CEX venue
|
||||
CEXSymbol string // Corresponding CEX symbol (if reconciled)
|
||||
CEXVenue string // CEX venue identifier (e.g. "lux-cex")
|
||||
ReconciliationMode ReconciliationMode // How cross-venue settlement works
|
||||
}
|
||||
|
||||
// ReconciliationMode determines how assets are settled across CEX and DEX.
|
||||
type ReconciliationMode uint8
|
||||
|
||||
const (
|
||||
ReconciliationNone ReconciliationMode = iota // No cross-venue reconciliation
|
||||
ReconciliationAtomicSwap // On-chain atomic swap between venues
|
||||
ReconciliationBridge // Bridge-based transfer (lock/mint)
|
||||
ReconciliationCustodial // Custodial settlement via broker
|
||||
ReconciliationHybrid // On-chain proof + off-chain settlement
|
||||
)
|
||||
|
||||
// DefaultMarketConfig returns a config for an unregulated crypto market.
|
||||
func DefaultMarketConfig() MarketConfig {
|
||||
return MarketConfig{
|
||||
AssetClass: AssetClassCrypto,
|
||||
Exemption: ExemptionNone,
|
||||
TickSize: 0.01,
|
||||
LotSize: 0.001,
|
||||
}
|
||||
}
|
||||
|
||||
// FixedIncomeMarketConfig returns a config for bond/note markets.
|
||||
func FixedIncomeMarketConfig(symbol string) MarketConfig {
|
||||
return MarketConfig{
|
||||
Symbol: symbol,
|
||||
AssetClass: AssetClassFixedIncome,
|
||||
TickSize: 0.001,
|
||||
LotSize: 1000, // Standard bond lot
|
||||
MinNotional: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
// StructuredProductMarketConfig returns a config for ABS/MBS/CDO markets.
|
||||
func StructuredProductMarketConfig(symbol string) MarketConfig {
|
||||
return MarketConfig{
|
||||
Symbol: symbol,
|
||||
AssetClass: AssetClassStructured,
|
||||
TickSize: 0.01,
|
||||
LotSize: 10000,
|
||||
MinNotional: 25000,
|
||||
AccreditedOnly: true,
|
||||
Restrictions: &TransferRestriction{
|
||||
MinInvestorStatus: InvestorAccredited,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PreciousMetalsMarketConfig returns a config for XAU/XAG/XPT/XPD spot.
|
||||
func PreciousMetalsMarketConfig(symbol string) MarketConfig {
|
||||
return MarketConfig{
|
||||
Symbol: symbol,
|
||||
AssetClass: AssetClassPreciousMetals,
|
||||
TickSize: 0.01,
|
||||
LotSize: 0.001,
|
||||
}
|
||||
}
|
||||
|
||||
// RealEstateMarketConfig returns a config for tokenized real estate.
|
||||
func RealEstateMarketConfig(symbol string) MarketConfig {
|
||||
return MarketConfig{
|
||||
Symbol: symbol,
|
||||
AssetClass: AssetClassRealEstate,
|
||||
TickSize: 0.01,
|
||||
LotSize: 1,
|
||||
AccreditedOnly: true,
|
||||
Restrictions: &TransferRestriction{
|
||||
MinInvestorStatus: InvestorAccredited,
|
||||
HoldingPeriod: 365 * 24 * 3600000000000, // 1 year in nanoseconds
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// PrivateEquityMarketConfig returns a config for PE/VC fund tokens.
|
||||
func PrivateEquityMarketConfig(symbol string) MarketConfig {
|
||||
return MarketConfig{
|
||||
Symbol: symbol,
|
||||
AssetClass: AssetClassPrivateEquity,
|
||||
TickSize: 0.01,
|
||||
LotSize: 1,
|
||||
AccreditedOnly: true,
|
||||
InstitutionalOnly: false,
|
||||
SingleMatchMode: true, // Private secondaries
|
||||
Restrictions: &TransferRestriction{
|
||||
MinInvestorStatus: InvestorQualifiedPurchaser,
|
||||
ROFRRequired: true,
|
||||
BoardApprovalRequired: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CDPMarketConfig returns a config for collateralized debt positions.
|
||||
func CDPMarketConfig(symbol string) MarketConfig {
|
||||
return MarketConfig{
|
||||
Symbol: symbol,
|
||||
AssetClass: AssetClassCDP,
|
||||
TickSize: 0.01,
|
||||
LotSize: 0.001,
|
||||
}
|
||||
}
|
||||
|
||||
// LPTokenMarketConfig returns a config for liquidity pool tokens.
|
||||
func LPTokenMarketConfig(symbol string) MarketConfig {
|
||||
return MarketConfig{
|
||||
Symbol: symbol,
|
||||
AssetClass: AssetClassLP,
|
||||
TickSize: 0.000001,
|
||||
LotSize: 0.000001,
|
||||
}
|
||||
}
|
||||
|
||||
// IsRegulated returns true if this market trades a regulated security.
|
||||
func (mc *MarketConfig) IsRegulated() bool {
|
||||
return mc.Exemption != ExemptionNone
|
||||
}
|
||||
|
||||
// HasRestrictions returns true if transfer restrictions are configured.
|
||||
func (mc *MarketConfig) HasRestrictions() bool {
|
||||
return mc.Restrictions != nil
|
||||
}
|
||||
|
||||
// SecuritiesMarket is an AdvancedOrderBook with regulatory configuration.
|
||||
type SecuritiesMarket struct {
|
||||
*AdvancedOrderBook
|
||||
Config MarketConfig
|
||||
}
|
||||
|
||||
// NewSecuritiesMarket creates a market with full securities compliance support.
|
||||
func NewSecuritiesMarket(config MarketConfig) *SecuritiesMarket {
|
||||
book := NewAdvancedOrderBook(config.Symbol)
|
||||
book.tickSize = config.TickSize
|
||||
book.lotSize = config.LotSize
|
||||
|
||||
if config.LotSize == 0 {
|
||||
book.lotSize = 0.001
|
||||
}
|
||||
if config.TickSize == 0 {
|
||||
book.tickSize = 0.01
|
||||
}
|
||||
|
||||
// Always enable self-trade prevention for regulated markets
|
||||
if config.IsRegulated() {
|
||||
book.enableSelfTrade = true
|
||||
}
|
||||
|
||||
return &SecuritiesMarket{
|
||||
AdvancedOrderBook: book,
|
||||
Config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// AddOrder validates compliance before placing an order.
|
||||
func (sm *SecuritiesMarket) AddOrder(order *AdvancedOrder) ([]Trade, error) {
|
||||
// Pre-trade compliance check
|
||||
if sm.Config.EligibilityChecker != nil {
|
||||
if err := sm.Config.EligibilityChecker.CanTrade(
|
||||
order.UserID,
|
||||
sm.Config.Symbol,
|
||||
order.Side,
|
||||
order.Size,
|
||||
order.Price,
|
||||
); err != nil {
|
||||
order.Status = StatusRejected
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Min notional check
|
||||
if sm.Config.MinNotional > 0 && order.Type == Limit {
|
||||
notional := order.Price * order.Size
|
||||
if notional < sm.Config.MinNotional {
|
||||
order.Status = StatusRejected
|
||||
return nil, &ComplianceError{
|
||||
Code: ErrMinNotional,
|
||||
Message: "order notional below minimum",
|
||||
Detail: sm.Config.MinNotional,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Max size check
|
||||
if sm.Config.MaxSize > 0 && order.Size > sm.Config.MaxSize {
|
||||
order.Status = StatusRejected
|
||||
return nil, &ComplianceError{
|
||||
Code: ErrMaxSize,
|
||||
Message: "order size exceeds maximum",
|
||||
Detail: sm.Config.MaxSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Min size check
|
||||
if sm.Config.MinSize > 0 && order.Size < sm.Config.MinSize {
|
||||
order.Status = StatusRejected
|
||||
return nil, &ComplianceError{
|
||||
Code: ErrMinSize,
|
||||
Message: "order size below minimum",
|
||||
Detail: sm.Config.MinSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Single-match mode: use IOC so unmatched qty is auto-cancelled
|
||||
if sm.Config.SingleMatchMode && order.TimeInForce != TIF_FOK {
|
||||
order.TimeInForce = TIF_IOC
|
||||
}
|
||||
|
||||
// Delegate to the underlying order book
|
||||
trades, err := sm.AdvancedOrderBook.AddOrder(order)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Post-trade settlement
|
||||
if sm.Config.SettlementHandler != nil && len(trades) > 0 {
|
||||
for i := range trades {
|
||||
if sErr := sm.Config.SettlementHandler.OnTrade(&trades[i], &sm.Config); sErr != nil {
|
||||
// Log but don't fail the match — trade already happened.
|
||||
// Settlement errors are handled asynchronously.
|
||||
_ = sErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-match mode: IOC already cancelled the remainder via the engine.
|
||||
// Ensure status reflects cancellation if there was a partial fill.
|
||||
if sm.Config.SingleMatchMode && len(trades) > 0 && order.ExecutedSize < order.Size {
|
||||
order.Status = StatusCanceled
|
||||
order.RemainingSize = 0
|
||||
}
|
||||
|
||||
return trades, nil
|
||||
}
|
||||
+67
-14
@@ -6,6 +6,63 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AssetClass represents the classification of a traded asset. It is metadata
|
||||
// used to map a cross-venue trade between the DEX and a counterpart CEX venue;
|
||||
// it is NOT a trading gate — the permissionless engine matches every class.
|
||||
type AssetClass uint8
|
||||
|
||||
const (
|
||||
AssetClassCrypto AssetClass = iota // Digital asset
|
||||
AssetClassEquity // Common/preferred stock (domestic)
|
||||
AssetClassDebt // Bonds, notes, convertible notes
|
||||
AssetClassFund // LP interests, fund shares
|
||||
AssetClassSAFE // Simple Agreement for Future Equity
|
||||
AssetClassCommodity // Physical or digital commodity
|
||||
AssetClassDerivative // Options, futures, swaps
|
||||
|
||||
// Equities — granular
|
||||
AssetClassIntlEquity // International equity (non-US)
|
||||
|
||||
// FX
|
||||
AssetClassForex // Foreign exchange pairs
|
||||
|
||||
// Derivatives — granular
|
||||
AssetClassOptions // Options contracts
|
||||
AssetClassFutures // Futures contracts
|
||||
|
||||
// Fixed income / credit
|
||||
AssetClassFixedIncome // Bonds, notes, treasuries
|
||||
AssetClassMunicipal // Municipal bonds
|
||||
AssetClassStructured // ABS, MBS, CDO, CLO
|
||||
AssetClassCorporateDebt // Corporate loans, credit facilities
|
||||
AssetClassConsumerDebt // Personal loans, credit card ABS
|
||||
|
||||
// Real assets
|
||||
AssetClassRealEstate // REIT, fractional RE, RE tokens
|
||||
AssetClassPreciousMetals // XAU, XAG, XPT, XPD spot
|
||||
|
||||
// Alternative investments
|
||||
AssetClassPrivateEquity // PE fund tokens, SPV shares
|
||||
AssetClassVenture // VC fund shares, SAFE tokens
|
||||
AssetClassPrivateCredit // Direct lending, mezzanine
|
||||
|
||||
// DeFi-native (bridged)
|
||||
AssetClassCDP // Collateralized debt positions
|
||||
AssetClassLP // Liquidity pool tokens
|
||||
)
|
||||
|
||||
// ReconciliationMode determines how a matched cross-venue trade is settled
|
||||
// between the DEX and a counterpart CEX.
|
||||
type ReconciliationMode uint8
|
||||
|
||||
const (
|
||||
ReconciliationNone ReconciliationMode = iota // No cross-venue reconciliation
|
||||
ReconciliationAtomicSwap // On-chain atomic swap between venues
|
||||
ReconciliationBridge // Bridge-based transfer (lock/mint)
|
||||
ReconciliationCustodial // Custodial settlement via broker
|
||||
ReconciliationHybrid // On-chain proof + off-chain settlement
|
||||
)
|
||||
|
||||
// VenueType identifies where a trade was executed.
|
||||
type VenueType uint8
|
||||
|
||||
@@ -40,11 +97,10 @@ type CrossVenueTrade struct {
|
||||
DEXTxHash string // on-chain transaction hash
|
||||
|
||||
// CEX side
|
||||
CEXOrderID string // CEX uses string UUIDs
|
||||
CEXTradeID string
|
||||
CEXSymbol string
|
||||
CEXVenue string // execution venue on CEX
|
||||
CEXComplianceID string
|
||||
CEXOrderID string // CEX uses string UUIDs
|
||||
CEXTradeID string
|
||||
CEXSymbol string
|
||||
CEXVenue string // execution venue on CEX
|
||||
|
||||
// Common fields
|
||||
AssetClass AssetClass
|
||||
@@ -60,10 +116,8 @@ type CrossVenueTrade struct {
|
||||
SettledAt *time.Time
|
||||
FailureReason string
|
||||
|
||||
// Regulatory
|
||||
Jurisdiction string // Governing jurisdiction
|
||||
ReportedToRegulator bool
|
||||
RegulatoryReportID string
|
||||
// Governing jurisdiction of the counterpart CEX venue (metadata only).
|
||||
Jurisdiction string
|
||||
}
|
||||
|
||||
// dexToCEXMap maps DEX asset classes to CEX string equivalents.
|
||||
@@ -274,11 +328,10 @@ func (r *Reconciler) mergeTrades(dex, cex *CrossVenueTrade) *CrossVenueTrade {
|
||||
DEXTxHash: dex.DEXTxHash,
|
||||
|
||||
// CEX side
|
||||
CEXOrderID: cex.CEXOrderID,
|
||||
CEXTradeID: cex.CEXTradeID,
|
||||
CEXSymbol: cex.CEXSymbol,
|
||||
CEXVenue: cex.CEXVenue,
|
||||
CEXComplianceID: cex.CEXComplianceID,
|
||||
CEXOrderID: cex.CEXOrderID,
|
||||
CEXTradeID: cex.CEXTradeID,
|
||||
CEXSymbol: cex.CEXSymbol,
|
||||
CEXVenue: cex.CEXVenue,
|
||||
|
||||
// Common
|
||||
AssetClass: dex.AssetClass,
|
||||
|
||||
+10
-154
@@ -120,17 +120,16 @@ func TestReconcilerMatchDEXFirst(t *testing.T) {
|
||||
|
||||
// Now submit matching CEX trade
|
||||
cexTrade := &CrossVenueTrade{
|
||||
CEXOrderID: "uuid-1",
|
||||
CEXTradeID: "uuid-2",
|
||||
CEXSymbol: "BTC-USD",
|
||||
CEXVenue: "lux-cex",
|
||||
CEXComplianceID: "comp-1",
|
||||
DEXSymbol: "BTC-USD",
|
||||
Side: Buy,
|
||||
Price: 50000.0,
|
||||
Size: 1.0,
|
||||
Jurisdiction: "US",
|
||||
Timestamp: time.Now(),
|
||||
CEXOrderID: "uuid-1",
|
||||
CEXTradeID: "uuid-2",
|
||||
CEXSymbol: "BTC-USD",
|
||||
CEXVenue: "lux-cex",
|
||||
DEXSymbol: "BTC-USD",
|
||||
Side: Buy,
|
||||
Price: 50000.0,
|
||||
Size: 1.0,
|
||||
Jurisdiction: "US",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
result, err = recon.SubmitCEXTrade(cexTrade)
|
||||
@@ -382,115 +381,6 @@ func TestNewAssetClassesAreDefined(t *testing.T) {
|
||||
assert.Equal(t, 23, len(seen), "expected 23 unique asset classes")
|
||||
}
|
||||
|
||||
func TestNewExemptionTypesAreDefined(t *testing.T) {
|
||||
// Verify new jurisdiction exemptions are distinct
|
||||
exemptions := []ExemptionType{
|
||||
ExemptionNone,
|
||||
ExemptionRegD506b, ExemptionRegD506c, ExemptionRegA1, ExemptionRegA2,
|
||||
ExemptionRegCF, ExemptionRegS, ExemptionRule144, ExemptionSection4a7,
|
||||
ExemptionFCAPrivate, ExemptionFCAProspectus,
|
||||
ExemptionMASSIP, ExemptionMASPrivate, ExemptionMASInstitutional,
|
||||
ExemptionEUQualifiedInvestor, ExemptionEUSmallOffering, ExemptionEUCrowdfunding,
|
||||
ExemptionHKProfessionalInvestor, ExemptionHKSmallOffering,
|
||||
ExemptionJPQII,
|
||||
ExemptionAUSophisticated, ExemptionAUSmallScale,
|
||||
ExemptionUAEExemptOffer, ExemptionUAEQualifiedInvestor,
|
||||
ExemptionCHQualifiedInvestor,
|
||||
}
|
||||
|
||||
seen := make(map[ExemptionType]bool)
|
||||
for _, e := range exemptions {
|
||||
assert.False(t, seen[e], "duplicate exemption value: %d", e)
|
||||
seen[e] = true
|
||||
}
|
||||
assert.Equal(t, 25, len(seen))
|
||||
}
|
||||
|
||||
// --- Market config factory tests ---
|
||||
|
||||
func TestFixedIncomeMarketConfig(t *testing.T) {
|
||||
mc := FixedIncomeMarketConfig("UST-10Y")
|
||||
assert.Equal(t, AssetClassFixedIncome, mc.AssetClass)
|
||||
assert.Equal(t, float64(1000), mc.LotSize)
|
||||
assert.Equal(t, "UST-10Y", mc.Symbol)
|
||||
}
|
||||
|
||||
func TestStructuredProductMarketConfig(t *testing.T) {
|
||||
mc := StructuredProductMarketConfig("MBS-POOL-A")
|
||||
assert.Equal(t, AssetClassStructured, mc.AssetClass)
|
||||
assert.True(t, mc.AccreditedOnly)
|
||||
assert.NotNil(t, mc.Restrictions)
|
||||
assert.Equal(t, InvestorAccredited, mc.Restrictions.MinInvestorStatus)
|
||||
}
|
||||
|
||||
func TestPreciousMetalsMarketConfig(t *testing.T) {
|
||||
mc := PreciousMetalsMarketConfig("XAU-USD")
|
||||
assert.Equal(t, AssetClassPreciousMetals, mc.AssetClass)
|
||||
assert.Equal(t, 0.001, mc.LotSize)
|
||||
}
|
||||
|
||||
func TestRealEstateMarketConfig(t *testing.T) {
|
||||
mc := RealEstateMarketConfig("REIT-ALPHA")
|
||||
assert.Equal(t, AssetClassRealEstate, mc.AssetClass)
|
||||
assert.True(t, mc.AccreditedOnly)
|
||||
assert.NotNil(t, mc.Restrictions)
|
||||
}
|
||||
|
||||
func TestPrivateEquityMarketConfig(t *testing.T) {
|
||||
mc := PrivateEquityMarketConfig("PE-FUND-I")
|
||||
assert.Equal(t, AssetClassPrivateEquity, mc.AssetClass)
|
||||
assert.True(t, mc.SingleMatchMode)
|
||||
assert.True(t, mc.AccreditedOnly)
|
||||
assert.True(t, mc.Restrictions.ROFRRequired)
|
||||
assert.True(t, mc.Restrictions.BoardApprovalRequired)
|
||||
assert.Equal(t, InvestorQualifiedPurchaser, mc.Restrictions.MinInvestorStatus)
|
||||
}
|
||||
|
||||
func TestCDPMarketConfig(t *testing.T) {
|
||||
mc := CDPMarketConfig("DAI-CDP")
|
||||
assert.Equal(t, AssetClassCDP, mc.AssetClass)
|
||||
}
|
||||
|
||||
func TestLPTokenMarketConfig(t *testing.T) {
|
||||
mc := LPTokenMarketConfig("UNI-V3-ETH-USDC")
|
||||
assert.Equal(t, AssetClassLP, mc.AssetClass)
|
||||
assert.Equal(t, 0.000001, mc.LotSize)
|
||||
}
|
||||
|
||||
// --- PEP / EDD tests ---
|
||||
|
||||
func TestPEPStatusValues(t *testing.T) {
|
||||
assert.Equal(t, PEPStatus(0), PEPNone)
|
||||
assert.Equal(t, PEPStatus(1), PEPDirect)
|
||||
assert.Equal(t, PEPStatus(2), PEPRelated)
|
||||
assert.Equal(t, PEPStatus(3), PEPFormer)
|
||||
}
|
||||
|
||||
func TestInvestorProfilePEPFields(t *testing.T) {
|
||||
profile := &InvestorProfile{
|
||||
UserID: "pep-user",
|
||||
Status: InvestorAccredited,
|
||||
Jurisdiction: "US",
|
||||
KYCVerified: true,
|
||||
AMLCleared: true,
|
||||
Holdings: map[string]float64{},
|
||||
PEP: PEPDirect,
|
||||
EDDCompleted: true,
|
||||
SourceOfFunds: "salary",
|
||||
SOFVerified: true,
|
||||
AdverseMedia: false,
|
||||
HighRiskCountry: false,
|
||||
TaxResidency: "US",
|
||||
EntityID: "LEI-12345",
|
||||
}
|
||||
|
||||
assert.Equal(t, PEPDirect, profile.PEP)
|
||||
assert.True(t, profile.EDDCompleted)
|
||||
assert.Equal(t, "salary", profile.SourceOfFunds)
|
||||
assert.True(t, profile.SOFVerified)
|
||||
assert.Equal(t, "LEI-12345", profile.EntityID)
|
||||
}
|
||||
|
||||
// --- Reconciliation mode tests ---
|
||||
|
||||
func TestReconciliationModeValues(t *testing.T) {
|
||||
@@ -500,37 +390,3 @@ func TestReconciliationModeValues(t *testing.T) {
|
||||
assert.Equal(t, ReconciliationMode(3), ReconciliationCustodial)
|
||||
assert.Equal(t, ReconciliationMode(4), ReconciliationHybrid)
|
||||
}
|
||||
|
||||
func TestMarketConfigWithReconciliation(t *testing.T) {
|
||||
mc := MarketConfig{
|
||||
Symbol: "BTC-USD",
|
||||
AssetClass: AssetClassCrypto,
|
||||
CEXSymbol: "BTC-USD",
|
||||
CEXVenue: "lux-cex",
|
||||
ReconciliationMode: ReconciliationAtomicSwap,
|
||||
}
|
||||
|
||||
assert.Equal(t, "lux-cex", mc.CEXVenue)
|
||||
assert.Equal(t, ReconciliationAtomicSwap, mc.ReconciliationMode)
|
||||
}
|
||||
|
||||
// --- New compliance error codes ---
|
||||
|
||||
func TestNewComplianceErrorCodes(t *testing.T) {
|
||||
codes := []ComplianceErrorCode{
|
||||
ErrPEPReviewRequired,
|
||||
ErrSourceOfFundsRequired,
|
||||
ErrAdverseMedia,
|
||||
ErrHighRiskCountry,
|
||||
ErrInstitutionalOnly,
|
||||
ErrLockupPeriod,
|
||||
ErrCollateralInsufficient,
|
||||
}
|
||||
|
||||
seen := make(map[ComplianceErrorCode]bool)
|
||||
for _, c := range codes {
|
||||
assert.False(t, seen[c], "duplicate compliance error code: %d", c)
|
||||
seen[c] = true
|
||||
assert.True(t, c > ErrTransferRestricted, "new codes should be after ErrTransferRestricted")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user