mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
feat(pretrade): G-37 synchronous pre-trade compliance gate
New pkg/pretrade implementing Gap G-37 (Stage 6.5): synchronous gate
at order-entry that fetches state from cap-table, transfer, AML, KYC,
and on-chain ERC-3643 compliance providers and decides allow / deny /
escalate. Each predicate is a composable internal function; the
Decision carries every actionable reason in a single round.
Wired into the broker order path via Wrap[Req, Resp] generic
middleware so existing router signatures stay untouched (additive,
forwards-only). GateError exposes the Decision to callers that surface
required actions to the investor.
Audit is delegated through AuditSink; canonical AuditLogSink bridges
to broker/pkg/audit. PII (ClientIP) is masked to /24 / /48 in the
audit row.
Test coverage: happy path, every hard-deny predicate (kyc_not_verified,
kyc_stale, not_accredited, accreditation_stale_{income,third_party},
suitability_{missing,stale}, aml_not_clean, open_sar,
ofac_screen_stale, lockup_active, rule144_holding_period,
country_restricted, max_holders, transfer_restricted,
chain_compliance_refused), every escalate predicate
(suitability_ambiguous, kyc_near_expiry, accreditation_near_expiry),
upstream-provider error propagation, input validation, max-holder
sell-side exemption, max-holder existing-holder exemption, PII
redaction. 41 tests passing under -race.
Source-of-design: Lux-Prior-IP
Source-ref: f7a8d7a
Clean-Team-Member: dev-agent (P0 G-37 implementation)
Signed-off-by: dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
package pretrade
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/luxfi/broker/pkg/audit"
|
||||
)
|
||||
|
||||
// AuditLogSink adapts a *audit.Log into the local AuditSink. Use this
|
||||
// in production wiring; in tests pass a SliceSink.
|
||||
type AuditLogSink struct {
|
||||
Log *audit.Log
|
||||
}
|
||||
|
||||
// RecordPreTrade writes a single audit entry per evaluation. The action
|
||||
// is the existing risk-decision channel ("route.decision") so existing
|
||||
// SIEM consumers see pre-trade decisions through one stream; the
|
||||
// Metadata map carries the structured decision payload.
|
||||
func (s *AuditLogSink) RecordPreTrade(d *Decision, o *Order) {
|
||||
if s == nil || s.Log == nil || d == nil {
|
||||
return
|
||||
}
|
||||
entry := audit.Entry{
|
||||
Action: audit.ActionRouteDecision,
|
||||
AccountID: o.AccountID,
|
||||
Symbol: o.Symbol,
|
||||
Side: o.Side,
|
||||
Qty: o.Qty,
|
||||
Price: o.Price,
|
||||
RequestID: o.RequestID,
|
||||
ClientIP: o.ClientIP,
|
||||
Status: statusFor(d),
|
||||
Metadata: map[string]interface{}{
|
||||
"investor_id": o.InvestorID,
|
||||
"offering_id": o.OfferingID,
|
||||
"token_address": o.TokenAddress,
|
||||
"country_code": o.CountryCode,
|
||||
"decision": marshal(d),
|
||||
"required_actions": d.RequiredActions,
|
||||
"latency_ns": d.LatencyNs,
|
||||
},
|
||||
}
|
||||
s.Log.Record(entry)
|
||||
}
|
||||
|
||||
func statusFor(d *Decision) string {
|
||||
switch {
|
||||
case d.Allow:
|
||||
return "allow"
|
||||
case d.Deny:
|
||||
return "deny"
|
||||
case d.Escalate:
|
||||
return "escalate"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func marshal(v interface{}) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// SliceSink collects decisions into a slice for unit tests. Not safe
|
||||
// for concurrent producers — use a Mutex if a concurrent test ever
|
||||
// needs it.
|
||||
type SliceSink struct {
|
||||
Entries []SliceEntry
|
||||
}
|
||||
|
||||
// SliceEntry is one captured pre-trade decision.
|
||||
type SliceEntry struct {
|
||||
Decision *Decision
|
||||
Order *Order
|
||||
}
|
||||
|
||||
// RecordPreTrade satisfies the AuditSink interface.
|
||||
func (s *SliceSink) RecordPreTrade(d *Decision, o *Order) {
|
||||
s.Entries = append(s.Entries, SliceEntry{Decision: d, Order: o})
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package pretrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config controls the freshness windows and policy knobs of the gate.
|
||||
// Defaults match the regulatory baseline; offering-specific policies
|
||||
// can be supplied per call (not yet wired — for the v1 a single Config
|
||||
// is sufficient as every freshness window is industry-standard).
|
||||
type Config struct {
|
||||
// AccreditationIncomeMaxAge — Reg D 506(b)/(c) self-cert via income
|
||||
// or net worth must be renewed at least annually.
|
||||
AccreditationIncomeMaxAge time.Duration
|
||||
// AccreditationThirdPartyMaxAge — 506(c) third-party verification
|
||||
// must be re-performed every 90 days.
|
||||
AccreditationThirdPartyMaxAge time.Duration
|
||||
// SuitabilityMaxAge — suitability questionnaire freshness window.
|
||||
SuitabilityMaxAge time.Duration
|
||||
// OFACScreenMaxAge — periodic OFAC / SDN re-screen cadence
|
||||
// (monthly is the industry baseline).
|
||||
OFACScreenMaxAge time.Duration
|
||||
// KYCMaxAge — KYC refresh cadence. 24 months is the FinCEN /
|
||||
// FinCEN-CDD baseline; offering policy may shorten.
|
||||
KYCMaxAge time.Duration
|
||||
// NearExpiryWindow — soft-deny threshold; if a credential will
|
||||
// expire within this window, decision is escalate rather than allow.
|
||||
NearExpiryWindow time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig is the recommended regulatory baseline.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
AccreditationIncomeMaxAge: 365 * 24 * time.Hour,
|
||||
AccreditationThirdPartyMaxAge: 90 * 24 * time.Hour,
|
||||
SuitabilityMaxAge: 365 * 24 * time.Hour,
|
||||
OFACScreenMaxAge: 30 * 24 * time.Hour,
|
||||
KYCMaxAge: 2 * 365 * 24 * time.Hour,
|
||||
NearExpiryWindow: 14 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// Gate is the synchronous pre-trade compliance check (Gap G-37). Compose
|
||||
// one per broker instance and call Check before submitting any order to
|
||||
// a provider.
|
||||
type Gate struct {
|
||||
cap CapTableProvider
|
||||
tx TransferProvider
|
||||
aml AMLProvider
|
||||
kyc KYCProvider
|
||||
chain ChainComplianceProvider
|
||||
audit AuditSink
|
||||
cfg Config
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New constructs a Gate with all six providers wired. Audit may be nil
|
||||
// (decisions then drop on the floor — strongly discouraged for prod).
|
||||
// ChainCompliance may be nil if no on-chain securities flow through
|
||||
// this gate; the on-chain predicate is skipped when the order's
|
||||
// TokenAddress is empty regardless.
|
||||
//
|
||||
// Returns a non-nil error if any of the required providers (cap, tx,
|
||||
// aml, kyc) is nil.
|
||||
func New(cap CapTableProvider, tx TransferProvider, aml AMLProvider, kyc KYCProvider, chain ChainComplianceProvider, audit AuditSink, cfg Config) (*Gate, error) {
|
||||
if cap == nil {
|
||||
return nil, errors.New("pretrade: cap-table provider is required")
|
||||
}
|
||||
if tx == nil {
|
||||
return nil, errors.New("pretrade: transfer provider is required")
|
||||
}
|
||||
if aml == nil {
|
||||
return nil, errors.New("pretrade: aml provider is required")
|
||||
}
|
||||
if kyc == nil {
|
||||
return nil, errors.New("pretrade: kyc provider is required")
|
||||
}
|
||||
if cfg == (Config{}) {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
return &Gate{
|
||||
cap: cap,
|
||||
tx: tx,
|
||||
aml: aml,
|
||||
kyc: kyc,
|
||||
chain: chain,
|
||||
audit: audit,
|
||||
cfg: cfg,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Check evaluates the pre-trade compliance gate for one order.
|
||||
//
|
||||
// Predicate order is deterministic and chosen to short-circuit on the
|
||||
// cheapest predicates first when possible, but every applicable
|
||||
// predicate is evaluated so that the Decision carries every actionable
|
||||
// reason in a single round (avoiding the "fix one thing, get rejected
|
||||
// for the next" UX).
|
||||
//
|
||||
// The returned Decision is never nil. A non-nil error indicates an
|
||||
// upstream-provider failure that prevented evaluation (e.g., AML service
|
||||
// unreachable); the caller must hold the order and surface the error,
|
||||
// because trading under an unknown compliance state is the failure mode
|
||||
// G-37 exists to prevent.
|
||||
func (g *Gate) Check(ctx context.Context, o *Order) (*Decision, error) {
|
||||
if o == nil {
|
||||
return nil, errors.New("pretrade: nil order")
|
||||
}
|
||||
if o.InvestorID == "" {
|
||||
return nil, errors.New("pretrade: order.InvestorID is required")
|
||||
}
|
||||
if o.OfferingID == "" {
|
||||
return nil, errors.New("pretrade: order.OfferingID is required")
|
||||
}
|
||||
start := g.now()
|
||||
dec := &Decision{EvaluatedAt: start}
|
||||
|
||||
// --- Predicate 1: KYC freshness ---
|
||||
if reason, err := g.checkKYC(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("kyc: %w", err)
|
||||
} else if reason != nil {
|
||||
appendReason(dec, *reason)
|
||||
}
|
||||
|
||||
// --- Predicate 2: Accreditation currency ---
|
||||
if reason, err := g.checkAccreditation(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("accreditation: %w", err)
|
||||
} else if reason != nil {
|
||||
appendReason(dec, *reason)
|
||||
}
|
||||
|
||||
// --- Predicate 3: Suitability freshness ---
|
||||
if reason, err := g.checkSuitability(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("suitability: %w", err)
|
||||
} else if reason != nil {
|
||||
appendReason(dec, *reason)
|
||||
}
|
||||
|
||||
// --- Predicate 4: AML status + OFAC re-screen cadence ---
|
||||
if reason, err := g.checkAML(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("aml: %w", err)
|
||||
} else if reason != nil {
|
||||
appendReason(dec, *reason)
|
||||
}
|
||||
|
||||
// --- Predicate 5: Restriction-check (lockup / Rule 144 / country /
|
||||
// max-holder / transfer-restriction agreement) ---
|
||||
if reason, err := g.checkRestrictions(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("restrictions: %w", err)
|
||||
} else if reason != nil {
|
||||
appendReason(dec, *reason)
|
||||
}
|
||||
|
||||
// --- Predicate 6: On-chain ERC-3643 compliance ---
|
||||
if reason, err := g.checkChain(ctx, o); err != nil {
|
||||
return nil, fmt.Errorf("chain: %w", err)
|
||||
} else if reason != nil {
|
||||
appendReason(dec, *reason)
|
||||
}
|
||||
|
||||
finalize(dec)
|
||||
dec.LatencyNs = g.now().Sub(start).Nanoseconds()
|
||||
if g.audit != nil {
|
||||
g.audit.RecordPreTrade(dec, redact(o))
|
||||
}
|
||||
return dec, nil
|
||||
}
|
||||
|
||||
// --- Predicates ---
|
||||
|
||||
func (g *Gate) checkKYC(ctx context.Context, o *Order) (*Reason, error) {
|
||||
s, err := g.kyc.Status(ctx, o.InvestorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.Verified {
|
||||
return &Reason{Code: "kyc_not_verified", Severity: SeverityDeny, Message: "investor has not completed KYC"}, nil
|
||||
}
|
||||
age := g.now().Sub(s.VerifiedAt)
|
||||
if age > g.cfg.KYCMaxAge {
|
||||
return &Reason{
|
||||
Code: "kyc_stale",
|
||||
Severity: SeverityDeny,
|
||||
Message: fmt.Sprintf("kyc last verified %s ago (max %s)", age.Round(24*time.Hour), g.cfg.KYCMaxAge),
|
||||
}, nil
|
||||
}
|
||||
if age > g.cfg.KYCMaxAge-g.cfg.NearExpiryWindow {
|
||||
return &Reason{
|
||||
Code: "kyc_near_expiry",
|
||||
Severity: SeverityEscalate,
|
||||
Message: fmt.Sprintf("kyc expires within %s", g.cfg.NearExpiryWindow),
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g *Gate) checkAccreditation(ctx context.Context, o *Order) (*Reason, error) {
|
||||
a, err := g.cap.Accreditation(ctx, o.InvestorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !a.Verified {
|
||||
return &Reason{Code: "not_accredited", Severity: SeverityDeny, Message: "investor is not accredited"}, nil
|
||||
}
|
||||
window := g.cfg.AccreditationIncomeMaxAge
|
||||
if strings.EqualFold(a.Method, "third_party") {
|
||||
window = g.cfg.AccreditationThirdPartyMaxAge
|
||||
}
|
||||
age := g.now().Sub(a.VerifiedAt)
|
||||
if age > window {
|
||||
return &Reason{
|
||||
Code: "accreditation_stale",
|
||||
Severity: SeverityDeny,
|
||||
Message: fmt.Sprintf("%s accreditation verified %s ago (max %s)", a.Method, age.Round(24*time.Hour), window),
|
||||
}, nil
|
||||
}
|
||||
if age > window-g.cfg.NearExpiryWindow {
|
||||
return &Reason{
|
||||
Code: "accreditation_near_expiry",
|
||||
Severity: SeverityEscalate,
|
||||
Message: fmt.Sprintf("%s accreditation expires within %s", a.Method, g.cfg.NearExpiryWindow),
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g *Gate) checkSuitability(ctx context.Context, o *Order) (*Reason, error) {
|
||||
s, err := g.cap.Suitability(ctx, o.InvestorID, o.OfferingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.OnFile {
|
||||
return &Reason{Code: "suitability_missing", Severity: SeverityDeny, Message: "no suitability questionnaire on file"}, nil
|
||||
}
|
||||
if s.Ambiguous {
|
||||
return &Reason{
|
||||
Code: "suitability_ambiguous",
|
||||
Severity: SeverityEscalate,
|
||||
Message: "suitability response pattern requires human review",
|
||||
}, nil
|
||||
}
|
||||
age := g.now().Sub(s.LastUpdatedAt)
|
||||
if age > g.cfg.SuitabilityMaxAge {
|
||||
return &Reason{
|
||||
Code: "suitability_stale",
|
||||
Severity: SeverityDeny,
|
||||
Message: fmt.Sprintf("suitability last updated %s ago (max %s)", age.Round(24*time.Hour), g.cfg.SuitabilityMaxAge),
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g *Gate) checkAML(ctx context.Context, o *Order) (*Reason, error) {
|
||||
s, err := g.aml.Status(ctx, o.InvestorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.Clean {
|
||||
return &Reason{Code: "aml_not_clean", Severity: SeverityDeny, Message: "open aml finding"}, nil
|
||||
}
|
||||
if s.HasOpenSAR {
|
||||
return &Reason{Code: "open_sar", Severity: SeverityDeny, Message: "open suspicious activity report"}, nil
|
||||
}
|
||||
age := g.now().Sub(s.OFACLastScreenedAt)
|
||||
if age > g.cfg.OFACScreenMaxAge {
|
||||
return &Reason{
|
||||
Code: "ofac_screen_stale",
|
||||
Severity: SeverityDeny,
|
||||
Message: fmt.Sprintf("ofac re-screen %s ago (max %s)", age.Round(24*time.Hour), g.cfg.OFACScreenMaxAge),
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g *Gate) checkRestrictions(ctx context.Context, o *Order) (*Reason, error) {
|
||||
r, err := g.cap.Restrictions(ctx, o.InvestorID, o.OfferingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.LockupRemainingDays > 0 {
|
||||
return &Reason{
|
||||
Code: "lockup_active",
|
||||
Severity: SeverityDeny,
|
||||
Message: fmt.Sprintf("lockup remaining: %d days", r.LockupRemainingDays),
|
||||
}, nil
|
||||
}
|
||||
if !r.Rule144EligibleAt.IsZero() && r.Rule144EligibleAt.After(g.now()) {
|
||||
return &Reason{
|
||||
Code: "rule144_holding_period",
|
||||
Severity: SeverityDeny,
|
||||
Message: fmt.Sprintf("rule 144 eligibility on %s", r.Rule144EligibleAt.Format("2006-01-02")),
|
||||
}, nil
|
||||
}
|
||||
if o.CountryCode != "" {
|
||||
for _, c := range r.RestrictedCountries {
|
||||
if strings.EqualFold(c, o.CountryCode) {
|
||||
return &Reason{
|
||||
Code: "country_restricted",
|
||||
Severity: SeverityDeny,
|
||||
Message: fmt.Sprintf("country %s restricted on offering", o.CountryCode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(o.Side, "buy") && r.MaxHolders > 0 && !r.InvestorAlreadyHolds {
|
||||
if r.CurrentHolders >= r.MaxHolders {
|
||||
return &Reason{
|
||||
Code: "max_holders",
|
||||
Severity: SeverityDeny,
|
||||
Message: fmt.Sprintf("offering at max holders (%d)", r.MaxHolders),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
// Cross-check the cap-table-side restriction view against the
|
||||
// transfer-of-record service. The two should agree; any disagreement
|
||||
// is a hard deny because we cannot tell which view is canonical at
|
||||
// order-entry time and either way the order is unsafe.
|
||||
qty, _ := strconv.ParseInt(strings.Split(o.Qty, ".")[0], 10, 64)
|
||||
if qty <= 0 {
|
||||
// Quantity may legitimately be a sub-unit decimal (fractional);
|
||||
// pass-through with a sentinel 1 so the transfer service still
|
||||
// gets the chance to deny based on per-holder per-class state.
|
||||
qty = 1
|
||||
}
|
||||
allowed, violations, err := g.tx.AllowTransfer(ctx, o.InvestorID, o.OfferingID, o.Side, qty)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !allowed {
|
||||
return &Reason{
|
||||
Code: "transfer_restricted",
|
||||
Severity: SeverityDeny,
|
||||
Message: "transfer-of-record service refused: " + strings.Join(violations, "; "),
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g *Gate) checkChain(ctx context.Context, o *Order) (*Reason, error) {
|
||||
// On-chain compliance is only relevant for tokenised securities; off-
|
||||
// chain offerings carry no token address and skip this predicate.
|
||||
if o.TokenAddress == "" || g.chain == nil {
|
||||
return nil, nil
|
||||
}
|
||||
qty, _ := strconv.ParseInt(strings.Split(o.Qty, ".")[0], 10, 64)
|
||||
if qty <= 0 {
|
||||
qty = 1
|
||||
}
|
||||
// For buy: counter-party (seller) view is "from" — we do not know it
|
||||
// here; the on-chain module is symmetric over the investor/token pair
|
||||
// so we pass the investor as the receiving side. For sell: investor
|
||||
// is the sending side. The provider implementation must accept the
|
||||
// "" placeholder when the counter-party is not known at this stage.
|
||||
from, to := o.InvestorID, ""
|
||||
if strings.EqualFold(o.Side, "buy") {
|
||||
from, to = "", o.InvestorID
|
||||
}
|
||||
s, err := g.chain.CanTransfer(ctx, o.TokenAddress, from, to, qty)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.CanTransfer {
|
||||
msg := "erc-3643 compliance module refused"
|
||||
if s.Reason != "" {
|
||||
msg += ": " + s.Reason
|
||||
}
|
||||
return &Reason{Code: "chain_compliance_refused", Severity: SeverityDeny, Message: msg}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func appendReason(dec *Decision, r Reason) {
|
||||
dec.Reasons = append(dec.Reasons, r)
|
||||
if action := requiredActionFor(r.Code); action != "" {
|
||||
dec.RequiredActions = append(dec.RequiredActions, action)
|
||||
}
|
||||
}
|
||||
|
||||
func finalize(dec *Decision) {
|
||||
hasDeny := false
|
||||
hasEscalate := false
|
||||
for _, r := range dec.Reasons {
|
||||
switch r.Severity {
|
||||
case SeverityDeny:
|
||||
hasDeny = true
|
||||
case SeverityEscalate:
|
||||
hasEscalate = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case hasDeny:
|
||||
dec.Deny = true
|
||||
case hasEscalate:
|
||||
dec.Escalate = true
|
||||
default:
|
||||
dec.Allow = true
|
||||
}
|
||||
}
|
||||
|
||||
// requiredActionFor maps a reason code to the operational remediation
|
||||
// the caller can present to the investor / operator.
|
||||
func requiredActionFor(code string) string {
|
||||
switch code {
|
||||
case "kyc_not_verified", "kyc_stale", "kyc_near_expiry":
|
||||
return "complete_kyc"
|
||||
case "not_accredited", "accreditation_stale", "accreditation_near_expiry":
|
||||
return "renew_accreditation"
|
||||
case "suitability_missing", "suitability_stale":
|
||||
return "submit_suitability"
|
||||
case "suitability_ambiguous":
|
||||
return "review_suitability"
|
||||
case "aml_not_clean", "open_sar":
|
||||
return "contact_compliance"
|
||||
case "ofac_screen_stale":
|
||||
return "rescreen_ofac"
|
||||
case "lockup_active":
|
||||
return "wait_lockup_expiry"
|
||||
case "rule144_holding_period":
|
||||
return "wait_rule144_eligibility"
|
||||
case "country_restricted":
|
||||
return "ineligible_country"
|
||||
case "max_holders":
|
||||
return "offering_capacity"
|
||||
case "transfer_restricted", "chain_compliance_refused":
|
||||
return "review_restrictions"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// redact returns a shallow copy of the order with PII stripped for the
|
||||
// audit trail. AccountID and InvestorID are stable identifiers (not
|
||||
// PII) — they're retained; ClientIP is masked to /24 (IPv4) per audit-
|
||||
// minimisation best-practice.
|
||||
func redact(o *Order) *Order {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *o
|
||||
cp.ClientIP = maskIP(cp.ClientIP)
|
||||
return &cp
|
||||
}
|
||||
|
||||
func maskIP(ip string) string {
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
// IPv4: keep first three octets.
|
||||
if i := strings.LastIndex(ip, "."); i > 0 && strings.Count(ip, ".") == 3 {
|
||||
return ip[:i] + ".0"
|
||||
}
|
||||
// IPv6: keep first /48 (three groups), zero the rest.
|
||||
parts := strings.Split(ip, ":")
|
||||
if len(parts) >= 4 {
|
||||
for i := 3; i < len(parts); i++ {
|
||||
parts[i] = "0"
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
return ip
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package pretrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- mocks ---
|
||||
|
||||
type mockCap struct {
|
||||
acc AccreditationStatus
|
||||
accErr error
|
||||
suit SuitabilityStatus
|
||||
suitErr error
|
||||
rst RestrictionStatus
|
||||
rstErr error
|
||||
}
|
||||
|
||||
func (m *mockCap) Accreditation(context.Context, string) (AccreditationStatus, error) {
|
||||
return m.acc, m.accErr
|
||||
}
|
||||
func (m *mockCap) Suitability(context.Context, string, string) (SuitabilityStatus, error) {
|
||||
return m.suit, m.suitErr
|
||||
}
|
||||
func (m *mockCap) Restrictions(context.Context, string, string) (RestrictionStatus, error) {
|
||||
return m.rst, m.rstErr
|
||||
}
|
||||
|
||||
type mockTx struct {
|
||||
allow bool
|
||||
violations []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockTx) AllowTransfer(context.Context, string, string, string, int64) (bool, []string, error) {
|
||||
return m.allow, m.violations, m.err
|
||||
}
|
||||
|
||||
type mockAML struct {
|
||||
s AMLStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockAML) Status(context.Context, string) (AMLStatus, error) { return m.s, m.err }
|
||||
|
||||
type mockKYC struct {
|
||||
s KYCStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockKYC) Status(context.Context, string) (KYCStatus, error) { return m.s, m.err }
|
||||
|
||||
type mockChain struct {
|
||||
s ChainComplianceStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockChain) CanTransfer(context.Context, string, string, string, int64) (ChainComplianceStatus, error) {
|
||||
return m.s, m.err
|
||||
}
|
||||
|
||||
func fixedNow() time.Time { return time.Date(2026, 5, 25, 12, 0, 0, 0, time.UTC) }
|
||||
|
||||
func happyOrder() *Order {
|
||||
return &Order{
|
||||
AccountID: "acct1",
|
||||
InvestorID: "inv1",
|
||||
OfferingID: "off1",
|
||||
Symbol: "OSAGE",
|
||||
Side: "buy",
|
||||
Qty: "10",
|
||||
Price: "100",
|
||||
OrderType: "limit",
|
||||
CountryCode: "US",
|
||||
}
|
||||
}
|
||||
|
||||
func happyProviders() (*mockCap, *mockTx, *mockAML, *mockKYC, *mockChain) {
|
||||
now := fixedNow()
|
||||
return &mockCap{
|
||||
acc: AccreditationStatus{
|
||||
Verified: true,
|
||||
Method: "income",
|
||||
VerifiedAt: now.Add(-60 * 24 * time.Hour),
|
||||
},
|
||||
suit: SuitabilityStatus{OnFile: true, LastUpdatedAt: now.Add(-30 * 24 * time.Hour)},
|
||||
rst: RestrictionStatus{},
|
||||
},
|
||||
&mockTx{allow: true},
|
||||
&mockAML{s: AMLStatus{Clean: true, OFACLastScreenedAt: now.Add(-7 * 24 * time.Hour)}},
|
||||
&mockKYC{s: KYCStatus{Verified: true, VerifiedAt: now.Add(-90 * 24 * time.Hour)}},
|
||||
&mockChain{s: ChainComplianceStatus{CanTransfer: true}}
|
||||
}
|
||||
|
||||
func newGateT(t *testing.T, cap CapTableProvider, tx TransferProvider, aml AMLProvider, kyc KYCProvider, chain ChainComplianceProvider) (*Gate, *SliceSink) {
|
||||
t.Helper()
|
||||
sink := &SliceSink{}
|
||||
g, err := New(cap, tx, aml, kyc, chain, sink, DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
g.now = fixedNow
|
||||
return g, sink
|
||||
}
|
||||
|
||||
// --- happy path ---
|
||||
|
||||
func TestCheck_HappyPath(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
g, sink := newGateT(t, c, tx, a, k, ch)
|
||||
d, err := g.Check(context.Background(), happyOrder())
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if !d.Allow || d.Deny || d.Escalate {
|
||||
t.Fatalf("expected allow, got %+v", d)
|
||||
}
|
||||
if len(d.Reasons) != 0 {
|
||||
t.Fatalf("expected no reasons, got %v", d.Reasons)
|
||||
}
|
||||
if len(sink.Entries) != 1 {
|
||||
t.Fatalf("audit: expected 1 entry, got %d", len(sink.Entries))
|
||||
}
|
||||
if d.LatencyNs < 0 {
|
||||
t.Fatalf("latency must be >= 0, got %d", d.LatencyNs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- deny paths ---
|
||||
|
||||
type denyCase struct {
|
||||
name string
|
||||
code string
|
||||
// mutate happy state into a failing state for this predicate.
|
||||
mut func(*mockCap, *mockTx, *mockAML, *mockKYC, *mockChain)
|
||||
}
|
||||
|
||||
func denyCases() []denyCase {
|
||||
return []denyCase{
|
||||
{"kyc_not_verified", "kyc_not_verified", func(_ *mockCap, _ *mockTx, _ *mockAML, k *mockKYC, _ *mockChain) {
|
||||
k.s.Verified = false
|
||||
}},
|
||||
{"kyc_stale", "kyc_stale", func(_ *mockCap, _ *mockTx, _ *mockAML, k *mockKYC, _ *mockChain) {
|
||||
k.s.VerifiedAt = fixedNow().Add(-3 * 365 * 24 * time.Hour)
|
||||
}},
|
||||
{"not_accredited", "not_accredited", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.acc.Verified = false
|
||||
}},
|
||||
{"accreditation_stale_income", "accreditation_stale", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.acc.VerifiedAt = fixedNow().Add(-400 * 24 * time.Hour)
|
||||
}},
|
||||
{"accreditation_stale_third_party", "accreditation_stale", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.acc.Method = "third_party"
|
||||
c.acc.VerifiedAt = fixedNow().Add(-120 * 24 * time.Hour)
|
||||
}},
|
||||
{"suitability_missing", "suitability_missing", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.suit.OnFile = false
|
||||
}},
|
||||
{"suitability_stale", "suitability_stale", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.suit.LastUpdatedAt = fixedNow().Add(-400 * 24 * time.Hour)
|
||||
}},
|
||||
{"aml_not_clean", "aml_not_clean", func(_ *mockCap, _ *mockTx, a *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
a.s.Clean = false
|
||||
}},
|
||||
{"open_sar", "open_sar", func(_ *mockCap, _ *mockTx, a *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
a.s.HasOpenSAR = true
|
||||
}},
|
||||
{"ofac_screen_stale", "ofac_screen_stale", func(_ *mockCap, _ *mockTx, a *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
a.s.OFACLastScreenedAt = fixedNow().Add(-60 * 24 * time.Hour)
|
||||
}},
|
||||
{"lockup_active", "lockup_active", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.rst.LockupRemainingDays = 45
|
||||
}},
|
||||
{"rule144_holding_period", "rule144_holding_period", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.rst.Rule144EligibleAt = fixedNow().Add(15 * 24 * time.Hour)
|
||||
}},
|
||||
{"country_restricted", "country_restricted", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.rst.RestrictedCountries = []string{"IR", "KP", "US"}
|
||||
}},
|
||||
{"max_holders", "max_holders", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.rst.MaxHolders = 1000
|
||||
c.rst.CurrentHolders = 1000
|
||||
c.rst.InvestorAlreadyHolds = false
|
||||
}},
|
||||
{"transfer_restricted", "transfer_restricted", func(_ *mockCap, tx *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
tx.allow = false
|
||||
tx.violations = []string{"affiliate restriction"}
|
||||
}},
|
||||
{"chain_compliance_refused", "chain_compliance_refused", func(_ *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, ch *mockChain) {
|
||||
ch.s = ChainComplianceStatus{CanTransfer: false, Reason: "module blocked"}
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_DenyPaths(t *testing.T) {
|
||||
for _, tc := range denyCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
tc.mut(c, tx, a, k, ch)
|
||||
g, sink := newGateT(t, c, tx, a, k, ch)
|
||||
o := happyOrder()
|
||||
// chain check only runs when token address is set
|
||||
if tc.name == "chain_compliance_refused" {
|
||||
o.TokenAddress = "0xdeadbeef"
|
||||
}
|
||||
d, err := g.Check(context.Background(), o)
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if !d.Deny {
|
||||
t.Fatalf("expected deny, got %+v", d)
|
||||
}
|
||||
if !hasReason(d, tc.code) {
|
||||
t.Fatalf("expected reason %q, got %+v", tc.code, d.Reasons)
|
||||
}
|
||||
if len(sink.Entries) != 1 || sink.Entries[0].Decision != d {
|
||||
t.Fatalf("audit: missing or wrong entry")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- escalate paths ---
|
||||
|
||||
func TestCheck_EscalatePaths(t *testing.T) {
|
||||
now := fixedNow()
|
||||
cases := []struct {
|
||||
name string
|
||||
code string
|
||||
mut func(*mockCap, *mockTx, *mockAML, *mockKYC, *mockChain)
|
||||
}{
|
||||
{"suitability_ambiguous", "suitability_ambiguous", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
c.suit.Ambiguous = true
|
||||
}},
|
||||
{"kyc_near_expiry", "kyc_near_expiry", func(_ *mockCap, _ *mockTx, _ *mockAML, k *mockKYC, _ *mockChain) {
|
||||
// KYC max = 24 months; near-expiry window = 14 days; place
|
||||
// last verification just inside the near-expiry window.
|
||||
k.s.VerifiedAt = now.Add(-(2*365*24*time.Hour - 7*24*time.Hour))
|
||||
}},
|
||||
{"accreditation_near_expiry", "accreditation_near_expiry", func(c *mockCap, _ *mockTx, _ *mockAML, _ *mockKYC, _ *mockChain) {
|
||||
// income window = 365d; near-expiry = 14d.
|
||||
c.acc.VerifiedAt = now.Add(-(365*24*time.Hour - 7*24*time.Hour))
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
tc.mut(c, tx, a, k, ch)
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
d, err := g.Check(context.Background(), happyOrder())
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if !d.Escalate {
|
||||
t.Fatalf("expected escalate, got %+v", d)
|
||||
}
|
||||
if !hasReason(d, tc.code) {
|
||||
t.Fatalf("expected reason %q, got %+v", tc.code, d.Reasons)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- upstream-error propagation ---
|
||||
|
||||
func TestCheck_UpstreamError(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
a.err = errors.New("aml service down")
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
d, err := g.Check(context.Background(), happyOrder())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got decision %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// --- input validation ---
|
||||
|
||||
func TestCheck_NilOrder(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
if _, err := g.Check(context.Background(), nil); err == nil {
|
||||
t.Fatal("expected error on nil order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_MissingInvestorID(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
o := happyOrder()
|
||||
o.InvestorID = ""
|
||||
if _, err := g.Check(context.Background(), o); err == nil {
|
||||
t.Fatal("expected error on missing investor id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_MissingOfferingID(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
o := happyOrder()
|
||||
o.OfferingID = ""
|
||||
if _, err := g.Check(context.Background(), o); err == nil {
|
||||
t.Fatal("expected error on missing offering id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_NilProviders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
c CapTableProvider
|
||||
tx TransferProvider
|
||||
a AMLProvider
|
||||
k KYCProvider
|
||||
}{
|
||||
{"nil_cap", nil, &mockTx{}, &mockAML{}, &mockKYC{}},
|
||||
{"nil_tx", &mockCap{}, nil, &mockAML{}, &mockKYC{}},
|
||||
{"nil_aml", &mockCap{}, &mockTx{}, nil, &mockKYC{}},
|
||||
{"nil_kyc", &mockCap{}, &mockTx{}, &mockAML{}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := New(tc.c, tc.tx, tc.a, tc.k, nil, nil, DefaultConfig()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- chain check skipped when no token address ---
|
||||
|
||||
func TestCheck_ChainSkippedWithoutToken(t *testing.T) {
|
||||
c, tx, a, k, _ := happyProviders()
|
||||
ch := &mockChain{err: errors.New("should not be called")}
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
d, err := g.Check(context.Background(), happyOrder())
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if !d.Allow {
|
||||
t.Fatalf("expected allow, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// --- holder check skipped for sells and for existing holders ---
|
||||
|
||||
func TestCheck_MaxHoldersIgnoredForSell(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
c.rst.MaxHolders = 10
|
||||
c.rst.CurrentHolders = 10
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
o := happyOrder()
|
||||
o.Side = "sell"
|
||||
d, err := g.Check(context.Background(), o)
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if !d.Allow {
|
||||
t.Fatalf("expected allow for sell, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_MaxHoldersIgnoredForExistingHolder(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
c.rst.MaxHolders = 10
|
||||
c.rst.CurrentHolders = 10
|
||||
c.rst.InvestorAlreadyHolds = true
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
d, err := g.Check(context.Background(), happyOrder())
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if !d.Allow {
|
||||
t.Fatalf("expected allow for existing holder, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// --- redaction ---
|
||||
|
||||
func TestRedact_IPv4(t *testing.T) {
|
||||
got := maskIP("192.168.1.42")
|
||||
if got != "192.168.1.0" {
|
||||
t.Fatalf("got %q, want %q", got, "192.168.1.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact_IPv6(t *testing.T) {
|
||||
got := maskIP("2001:db8:abcd:1234:5678:9abc:def0:1111")
|
||||
if !strings.HasPrefix(got, "2001:db8:abcd:") {
|
||||
t.Fatalf("got %q, want prefix 2001:db8:abcd:", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, ":0:0:0:0:0") {
|
||||
t.Fatalf("got %q, want suffix :0:0:0:0:0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact_Empty(t *testing.T) {
|
||||
if maskIP("") != "" {
|
||||
t.Fatal("empty stays empty")
|
||||
}
|
||||
}
|
||||
|
||||
// --- multi-reason decisions ---
|
||||
|
||||
func TestCheck_MultipleReasons(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
c.acc.Verified = false
|
||||
c.suit.OnFile = false
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
d, err := g.Check(context.Background(), happyOrder())
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v", err)
|
||||
}
|
||||
if !d.Deny {
|
||||
t.Fatalf("expected deny, got %+v", d)
|
||||
}
|
||||
if len(d.Reasons) < 2 {
|
||||
t.Fatalf("expected >=2 reasons, got %d", len(d.Reasons))
|
||||
}
|
||||
}
|
||||
|
||||
// --- helper ---
|
||||
|
||||
func hasReason(d *Decision, code string) bool {
|
||||
for _, r := range d.Reasons {
|
||||
if r.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package pretrade
|
||||
|
||||
import "context"
|
||||
|
||||
// CapTableProvider returns accreditation, suitability, and offering-level
|
||||
// restriction state for an investor / offering pair. Backed in production
|
||||
// by github.com/luxfi/captable. Defined here so the gate can be
|
||||
// constructed with mocks in tests and so the consumed surface is
|
||||
// minimal and stable.
|
||||
type CapTableProvider interface {
|
||||
// Accreditation returns the investor's accreditation snapshot, or an
|
||||
// error if the investor is unknown.
|
||||
Accreditation(ctx context.Context, investorID string) (AccreditationStatus, error)
|
||||
// Suitability returns the investor's suitability snapshot for the
|
||||
// offering, or an error if the investor is unknown.
|
||||
Suitability(ctx context.Context, investorID, offeringID string) (SuitabilityStatus, error)
|
||||
// Restrictions returns the offering-level restriction snapshot for
|
||||
// the investor.
|
||||
Restrictions(ctx context.Context, investorID, offeringID string) (RestrictionStatus, error)
|
||||
}
|
||||
|
||||
// TransferProvider exposes the canonical transfer-restriction Check for
|
||||
// the cap-table-of-record. Backed in production by github.com/luxfi/
|
||||
// transfer/pkg/restrictions. The gate uses this as a cross-check against
|
||||
// the offering-level RestrictionStatus: any disagreement is a hard deny.
|
||||
type TransferProvider interface {
|
||||
// AllowTransfer answers whether the proposed quantity of an offering
|
||||
// can be transferred to or from the investor (depending on side).
|
||||
AllowTransfer(ctx context.Context, investorID, offeringID, side string, qty int64) (bool, []string, error)
|
||||
}
|
||||
|
||||
// AMLProvider returns the AML / OFAC snapshot for an investor.
|
||||
type AMLProvider interface {
|
||||
// Status returns the AML snapshot or an error if the investor is
|
||||
// unknown to the AML service.
|
||||
Status(ctx context.Context, investorID string) (AMLStatus, error)
|
||||
}
|
||||
|
||||
// KYCProvider returns the KYC snapshot for an investor.
|
||||
type KYCProvider interface {
|
||||
// Status returns the KYC snapshot or an error if the investor is
|
||||
// unknown.
|
||||
Status(ctx context.Context, investorID string) (KYCStatus, error)
|
||||
}
|
||||
|
||||
// ChainComplianceProvider delegates the on-chain ERC-3643 modular
|
||||
// compliance check. Backed in production by github.com/luxfi/securities/
|
||||
// erc3643/compliance.go. The provider is queried only when the order
|
||||
// carries a non-empty TokenAddress.
|
||||
type ChainComplianceProvider interface {
|
||||
// CanTransfer asks the on-chain compliance module whether the
|
||||
// transfer is permitted under the token's attached modules.
|
||||
CanTransfer(ctx context.Context, tokenAddr, fromInvestorID, toInvestorID string, qty int64) (ChainComplianceStatus, error)
|
||||
}
|
||||
|
||||
// AuditSink receives a Decision for every Check evaluation. Implementations
|
||||
// must be non-blocking and concurrency-safe. The broker wires this to the
|
||||
// canonical broker/pkg/audit log; tests pass a slice-collector.
|
||||
type AuditSink interface {
|
||||
RecordPreTrade(decision *Decision, order *Order)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package pretrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// OrderEnricher maps a raw broker-side order (whatever shape the caller
|
||||
// uses) into the pretrade.Order shape. Wiring into broker/pkg/router is
|
||||
// done by the consumer because the router does not know about the
|
||||
// investor / offering binding — that lives in the cap-table.
|
||||
type OrderEnricher func(ctx context.Context, accountID string, raw any) (*Order, error)
|
||||
|
||||
// SubmitFunc is the underlying order-placement function the broker
|
||||
// already exposes (e.g., Router.SmartOrder). The middleware wraps it.
|
||||
type SubmitFunc[Req any, Resp any] func(ctx context.Context, accountID string, req Req) (Resp, error)
|
||||
|
||||
// GateError is returned when the pre-trade gate denies or escalates an
|
||||
// order. It carries the full Decision so callers can surface the
|
||||
// reasons + RequiredActions to the user.
|
||||
type GateError struct {
|
||||
Decision *Decision
|
||||
}
|
||||
|
||||
// Error satisfies the error interface.
|
||||
func (e *GateError) Error() string {
|
||||
if e == nil || e.Decision == nil {
|
||||
return "pretrade: gate denied"
|
||||
}
|
||||
if e.Decision.Deny {
|
||||
return fmt.Sprintf("pretrade: denied — %s", joinCodes(e.Decision.Reasons))
|
||||
}
|
||||
if e.Decision.Escalate {
|
||||
return fmt.Sprintf("pretrade: escalate — %s", joinCodes(e.Decision.Reasons))
|
||||
}
|
||||
return "pretrade: unknown gate outcome"
|
||||
}
|
||||
|
||||
// IsDeny reports whether the underlying decision is a hard deny.
|
||||
func (e *GateError) IsDeny() bool { return e != nil && e.Decision != nil && e.Decision.Deny }
|
||||
|
||||
// IsEscalate reports whether the underlying decision is an escalation.
|
||||
func (e *GateError) IsEscalate() bool {
|
||||
return e != nil && e.Decision != nil && e.Decision.Escalate
|
||||
}
|
||||
|
||||
// IsGateError unwraps an error that may wrap a GateError.
|
||||
func IsGateError(err error) (*GateError, bool) {
|
||||
var ge *GateError
|
||||
if errors.As(err, &ge) {
|
||||
return ge, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Wrap returns a SubmitFunc that runs the pretrade gate before delegating
|
||||
// to inner. If the gate denies or escalates, inner is not called and a
|
||||
// *GateError is returned. If the gate's upstream provider errors, the
|
||||
// upstream error is propagated and inner is not called.
|
||||
//
|
||||
// Generics keep the wrapper type-safe over whatever concrete order /
|
||||
// response shapes the caller uses, without locking pretrade into the
|
||||
// broker/pkg/types surface.
|
||||
func Wrap[Req any, Resp any](g *Gate, enricher OrderEnricher, inner SubmitFunc[Req, Resp]) SubmitFunc[Req, Resp] {
|
||||
return func(ctx context.Context, accountID string, req Req) (Resp, error) {
|
||||
var zero Resp
|
||||
o, err := enricher(ctx, accountID, req)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("pretrade enrich: %w", err)
|
||||
}
|
||||
dec, err := g.Check(ctx, o)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("pretrade check: %w", err)
|
||||
}
|
||||
if !dec.Allow {
|
||||
return zero, &GateError{Decision: dec}
|
||||
}
|
||||
return inner(ctx, accountID, req)
|
||||
}
|
||||
}
|
||||
|
||||
func joinCodes(rs []Reason) string {
|
||||
parts := make([]string, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
parts = append(parts, r.Code)
|
||||
}
|
||||
out := ""
|
||||
for i, p := range parts {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += p
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pretrade
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeReq struct {
|
||||
accountID string
|
||||
investorID string
|
||||
offeringID string
|
||||
side string
|
||||
qty string
|
||||
}
|
||||
|
||||
type fakeResp struct {
|
||||
id string
|
||||
}
|
||||
|
||||
func enrich(_ context.Context, accountID string, raw any) (*Order, error) {
|
||||
r, ok := raw.(*fakeReq)
|
||||
if !ok {
|
||||
return nil, errors.New("bad req")
|
||||
}
|
||||
return &Order{
|
||||
AccountID: accountID,
|
||||
InvestorID: r.investorID,
|
||||
OfferingID: r.offeringID,
|
||||
Side: r.side,
|
||||
Qty: r.qty,
|
||||
Symbol: "OSAGE",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestWrap_AllowDelegates(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
called := false
|
||||
inner := SubmitFunc[*fakeReq, *fakeResp](func(_ context.Context, _ string, _ *fakeReq) (*fakeResp, error) {
|
||||
called = true
|
||||
return &fakeResp{id: "abc"}, nil
|
||||
})
|
||||
w := Wrap(g, enrich, inner)
|
||||
resp, err := w(context.Background(), "acct1", &fakeReq{investorID: "inv1", offeringID: "off1", side: "buy", qty: "1"})
|
||||
if err != nil {
|
||||
t.Fatalf("Wrap: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("inner was not called")
|
||||
}
|
||||
if resp == nil || resp.id != "abc" {
|
||||
t.Fatalf("resp = %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_DenyShortCircuits(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
k.s.Verified = false
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
called := false
|
||||
inner := SubmitFunc[*fakeReq, *fakeResp](func(_ context.Context, _ string, _ *fakeReq) (*fakeResp, error) {
|
||||
called = true
|
||||
return &fakeResp{id: "abc"}, nil
|
||||
})
|
||||
w := Wrap(g, enrich, inner)
|
||||
_, err := w(context.Background(), "acct1", &fakeReq{investorID: "inv1", offeringID: "off1", side: "buy", qty: "1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("inner should not have been called")
|
||||
}
|
||||
ge, ok := IsGateError(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected GateError, got %v", err)
|
||||
}
|
||||
if !ge.IsDeny() {
|
||||
t.Fatalf("expected deny, got %+v", ge.Decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrap_EscalateShortCircuits(t *testing.T) {
|
||||
c, tx, a, k, ch := happyProviders()
|
||||
c.suit.Ambiguous = true
|
||||
g, _ := newGateT(t, c, tx, a, k, ch)
|
||||
called := false
|
||||
inner := SubmitFunc[*fakeReq, *fakeResp](func(_ context.Context, _ string, _ *fakeReq) (*fakeResp, error) {
|
||||
called = true
|
||||
return &fakeResp{id: "abc"}, nil
|
||||
})
|
||||
w := Wrap(g, enrich, inner)
|
||||
_, err := w(context.Background(), "acct1", &fakeReq{investorID: "inv1", offeringID: "off1", side: "buy", qty: "1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("inner should not have been called")
|
||||
}
|
||||
ge, ok := IsGateError(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected GateError, got %v", err)
|
||||
}
|
||||
if !ge.IsEscalate() {
|
||||
t.Fatalf("expected escalate, got %+v", ge.Decision)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package pretrade implements the synchronous pre-trade compliance gate
|
||||
// required at order-entry time (Gap G-37, Stage 6.5).
|
||||
//
|
||||
// The gate fetches state from the consumed providers (cap-table, transfer-
|
||||
// restrictions, AML, KYC, on-chain ERC-3643 compliance) and decides
|
||||
// allow / deny / escalate. Without this gate, a trade can settle and
|
||||
// only then fail compliance — creating a rescission obligation.
|
||||
//
|
||||
// Each predicate is composable: a Gate runs the bundle in order and
|
||||
// short-circuits on the first hard deny; soft-deny conditions become
|
||||
// escalate decisions for human review.
|
||||
package pretrade
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Order is the minimal pre-trade view of an order. The router supplies
|
||||
// this from its native types.CreateOrderRequest; we keep the gate
|
||||
// dependency-light so it does not have to import the order surface and
|
||||
// can be unit-tested with fixtures.
|
||||
type Order struct {
|
||||
// AccountID identifies the brokerage account placing the order.
|
||||
AccountID string
|
||||
// InvestorID identifies the natural / legal person behind the account
|
||||
// for KYC / suitability / accreditation purposes.
|
||||
InvestorID string
|
||||
// OfferingID identifies the security being traded — drives the
|
||||
// restriction, max-holder, lockup, and per-offering policy lookups.
|
||||
OfferingID string
|
||||
// Symbol is the human-readable security identifier.
|
||||
Symbol string
|
||||
// Side: "buy" or "sell".
|
||||
Side string
|
||||
// Qty is the order quantity as a decimal string.
|
||||
Qty string
|
||||
// Price is the estimated fill price as a decimal string (optional for
|
||||
// market orders).
|
||||
Price string
|
||||
// OrderType: "market", "limit", "stop", etc.
|
||||
OrderType string
|
||||
// TokenAddress is the ERC-3643 token contract address (optional —
|
||||
// only set for on-chain securities; gate skips the on-chain check
|
||||
// when empty).
|
||||
TokenAddress string
|
||||
// CountryCode is the investor's country of residence (ISO 3166-1
|
||||
// alpha-2). Drives country-restriction predicate.
|
||||
CountryCode string
|
||||
// ClientIP is captured for the audit log.
|
||||
ClientIP string
|
||||
// RequestID is the caller-supplied request id for audit correlation.
|
||||
RequestID string
|
||||
}
|
||||
|
||||
// Decision is the outcome of the pre-trade compliance gate.
|
||||
type Decision struct {
|
||||
// Allow is true iff every predicate cleared. Mutually exclusive with
|
||||
// Deny and Escalate.
|
||||
Allow bool `json:"allow"`
|
||||
// Deny is true iff at least one hard-deny predicate fired. Hard deny
|
||||
// is irrecoverable in the current order — the caller must reject.
|
||||
Deny bool `json:"deny"`
|
||||
// Escalate is true iff at least one soft-deny predicate fired and no
|
||||
// hard-deny did. The caller must hold the order and route it to a
|
||||
// human reviewer.
|
||||
Escalate bool `json:"escalate"`
|
||||
// Reasons enumerates every predicate that did not clear, with code,
|
||||
// severity, and a human-readable message.
|
||||
Reasons []Reason `json:"reasons,omitempty"`
|
||||
// RequiredActions enumerates the operational steps that would allow
|
||||
// the order to clear (e.g., "renew_accreditation", "complete_kyc").
|
||||
RequiredActions []string `json:"required_actions,omitempty"`
|
||||
// EvaluatedAt is the timestamp at which the gate ran.
|
||||
EvaluatedAt time.Time `json:"evaluated_at"`
|
||||
// LatencyNs is the wall-clock latency of the gate evaluation.
|
||||
LatencyNs int64 `json:"latency_ns"`
|
||||
}
|
||||
|
||||
// Reason is a single predicate outcome.
|
||||
type Reason struct {
|
||||
// Code is a stable machine-readable identifier (e.g.,
|
||||
// "accreditation_stale", "ofac_screening_stale").
|
||||
Code string `json:"code"`
|
||||
// Severity is one of "deny" (hard) or "escalate" (soft).
|
||||
Severity Severity `json:"severity"`
|
||||
// Message is the human-readable explanation, PII-redacted.
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Severity captures whether a predicate result is recoverable.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// SeverityDeny is a hard deny — the order cannot proceed under the
|
||||
// current state, and no human review can override at order-entry
|
||||
// time (operational remediation required first).
|
||||
SeverityDeny Severity = "deny"
|
||||
// SeverityEscalate is a soft deny — the order is held and routed to
|
||||
// a human reviewer (e.g., suitability questionnaire ambiguity, near-
|
||||
// expiry forms).
|
||||
SeverityEscalate Severity = "escalate"
|
||||
)
|
||||
|
||||
// AccreditationStatus is the investor's accreditation snapshot returned
|
||||
// by the cap-table provider.
|
||||
type AccreditationStatus struct {
|
||||
// Verified is true iff a verification method completed successfully.
|
||||
Verified bool
|
||||
// Method is the verification method used (drives the freshness window):
|
||||
// "income" — Reg D 506(b)/(c) self-cert via income
|
||||
// "networth" — Reg D 506(b)/(c) self-cert via net worth
|
||||
// "third_party"— 506(c) third-party verification (90-day window)
|
||||
// "qualified_purchaser" — 3(c)(7) qualified purchaser certification
|
||||
Method string
|
||||
// VerifiedAt is the timestamp of the most recent verification event.
|
||||
VerifiedAt time.Time
|
||||
}
|
||||
|
||||
// SuitabilityStatus is the investor's suitability snapshot.
|
||||
type SuitabilityStatus struct {
|
||||
// OnFile is true iff a suitability questionnaire has ever been
|
||||
// submitted for the investor.
|
||||
OnFile bool
|
||||
// LastUpdatedAt is the timestamp of the most recent questionnaire
|
||||
// version on file.
|
||||
LastUpdatedAt time.Time
|
||||
// Ambiguous is set when the suitability service marks the response
|
||||
// pattern as requiring human review (e.g., investor flags risk
|
||||
// tolerance "low" + product flagged "high risk").
|
||||
Ambiguous bool
|
||||
}
|
||||
|
||||
// RestrictionStatus is the offering-level restriction snapshot.
|
||||
type RestrictionStatus struct {
|
||||
// LockupRemainingDays is the number of days remaining on any active
|
||||
// lockup affecting the investor's position. Zero means no lockup.
|
||||
LockupRemainingDays int
|
||||
// Rule144EligibleAt is the earliest date the investor's holdings are
|
||||
// Rule 144 resale-eligible. Zero value means the investor is already
|
||||
// eligible (or Rule 144 does not apply to this offering).
|
||||
Rule144EligibleAt time.Time
|
||||
// RestrictedCountries is the per-offering list of ISO 3166-1 alpha-2
|
||||
// country codes blocked from holding this security.
|
||||
RestrictedCountries []string
|
||||
// MaxHolders is the per-offering cap on holder-of-record count (e.g.,
|
||||
// 1933 Act §12(g) thresholds). Zero means no cap.
|
||||
MaxHolders int
|
||||
// CurrentHolders is the current holder-of-record count for the
|
||||
// offering. Compared against MaxHolders only for buy-side orders that
|
||||
// would add a new holder.
|
||||
CurrentHolders int
|
||||
// InvestorAlreadyHolds is true iff the investor is already a holder
|
||||
// of record on the offering (so a buy-side order does not change the
|
||||
// holder count).
|
||||
InvestorAlreadyHolds bool
|
||||
}
|
||||
|
||||
// AMLStatus is the AML / OFAC snapshot.
|
||||
type AMLStatus struct {
|
||||
// Clean is true iff there are no open AML findings.
|
||||
Clean bool
|
||||
// OFACLastScreenedAt is the timestamp of the most recent OFAC / SDN
|
||||
// re-screen.
|
||||
OFACLastScreenedAt time.Time
|
||||
// HasOpenSAR is true iff a Suspicious Activity Report is open.
|
||||
HasOpenSAR bool
|
||||
}
|
||||
|
||||
// KYCStatus is the KYC freshness snapshot.
|
||||
type KYCStatus struct {
|
||||
// Verified is true iff the investor has cleared KYC.
|
||||
Verified bool
|
||||
// VerifiedAt is the timestamp of the most recent KYC pass.
|
||||
VerifiedAt time.Time
|
||||
}
|
||||
|
||||
// ChainComplianceStatus is the on-chain ERC-3643 compliance module
|
||||
// response. The compliance module is delegated-to: this gate forwards
|
||||
// the order parameters to the on-chain canTransfer check and collects
|
||||
// the result.
|
||||
type ChainComplianceStatus struct {
|
||||
// CanTransfer is the modular compliance module's allow/deny answer.
|
||||
CanTransfer bool
|
||||
// Reason captures the contract revert reason (or off-chain
|
||||
// short-circuit reason) when CanTransfer is false.
|
||||
Reason string
|
||||
}
|
||||
Reference in New Issue
Block a user