Files
zeekay 7b09f3d3d0 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]>
2026-05-26 18:11:50 -07:00

84 lines
2.0 KiB
Go

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})
}