mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
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]>
97 lines
3.0 KiB
Go
97 lines
3.0 KiB
Go
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
|
|
}
|