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:
2026-05-26 18:11:50 -07:00
parent 2b54eb1527
commit 7b09f3d3d0
7 changed files with 1434 additions and 0 deletions
+83
View File
@@ -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})
}
+468
View File
@@ -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
}
+432
View File
@@ -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
}
+61
View File
@@ -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)
}
+96
View File
@@ -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
}
+107
View File
@@ -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)
}
}
+187
View File
@@ -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
}