feat: OFAC wallet screening + crypto KYT endpoint

- POST /compliance/wallet-screen: checks addresses against 47 OFAC SDN
  sanctioned wallets (Tornado Cash, Lazarus Group, Garantex, etc.)
- Falls through to Jube for real-time risk scoring
- Self-service accessible (any org)
- 11 new tests covering OFAC hits, case insensitivity, chains, directions
This commit is contained in:
2026-04-03 16:00:04 -07:00
parent 8fc466fd0a
commit a43e175760
4 changed files with 376 additions and 0 deletions
+105
View File
@@ -159,6 +159,111 @@ func (h *amlHandler) handleReview(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, existing)
}
// walletScreenRequest is the input for wallet address screening.
type walletScreenRequest struct {
Address string `json:"address"`
Chain string `json:"chain"` // ethereum, liquidity, bitcoin
Direction string `json:"direction"` // send, receive
}
// walletScreenResponse is the result of a wallet address screen.
type walletScreenResponse struct {
Address string `json:"address"`
Risk string `json:"risk"` // low, medium, high, blocked
Sanctioned bool `json:"sanctioned"`
Source string `json:"source"` // ofac, jube
}
// handleWalletScreen checks a crypto wallet address against OFAC SDN and Jube.
func (h *amlHandler) handleWalletScreen(w http.ResponseWriter, r *http.Request) {
var req walletScreenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Address == "" {
writeError(w, http.StatusBadRequest, "address is required")
return
}
if req.Chain == "" {
writeError(w, http.StatusBadRequest, "chain is required")
return
}
switch req.Chain {
case "ethereum", "liquidity", "bitcoin":
default:
writeError(w, http.StatusBadRequest, "chain must be ethereum, liquidity, or bitcoin")
return
}
if req.Direction == "" {
req.Direction = "receive"
}
if req.Direction != "send" && req.Direction != "receive" {
writeError(w, http.StatusBadRequest, "direction must be send or receive")
return
}
resp := walletScreenResponse{
Address: req.Address,
Risk: "low",
}
// 1. Check OFAC SDN list (instant, local).
if source, hit := isOFACSanctioned(req.Address); hit {
resp.Risk = "blocked"
resp.Sanctioned = true
resp.Source = "ofac"
log.Warn().
Str("address", req.Address).
Str("chain", req.Chain).
Str("source", source).
Msg("aml: OFAC sanctioned wallet detected")
writeJSON(w, http.StatusOK, resp)
return
}
// 2. Check via Jube for real-time risk scoring.
if h.jubeClient != nil {
txReq := jube.TransactionRequest{
EntityAnalysisModelID: 1,
EntityInstanceEntryPayload: map[string]interface{}{
"AccountId": req.Address,
"EntityType": "wallet",
"Chain": req.Chain,
"Direction": req.Direction,
},
}
jubeResp, err := h.jubeClient.ScreenTransaction(r.Context(), txReq)
if err != nil {
log.Error().Err(err).Str("address", req.Address).Msg("aml: jube wallet screen failed")
// Jube unavailable -- return OFAC-only result (already low/clear).
} else {
resp.Source = "jube"
switch {
case jubeResp.Action == jube.ActionBlock || jubeResp.Score >= 80:
resp.Risk = "blocked"
resp.Sanctioned = true
case jubeResp.Score >= 60:
resp.Risk = "high"
case jubeResp.Score >= 30:
resp.Risk = "medium"
default:
resp.Risk = "low"
}
}
}
// Default source if neither OFAC nor Jube set it.
if resp.Source == "" {
resp.Source = "ofac"
}
writeJSON(w, http.StatusOK, resp)
}
// handleRiskAssessment runs a risk assessment by screening a transaction through Jube.
func (h *amlHandler) handleRiskAssessment(w http.ResponseWriter, r *http.Request) {
var req struct {
+184
View File
@@ -3034,3 +3034,187 @@ func TestSecurityOrgBasedAccess(t *testing.T) {
t.Fatalf("SECURITY: customer org should NOT access kyc admin, got %d", w.Code)
}
}
// ==========================================================================
// Wallet Screen Tests
// ==========================================================================
func TestWalletScreenCleanAddress(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "ethereum",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "low" {
t.Fatalf("expected risk low, got %s", resp.Risk)
}
if resp.Sanctioned {
t.Fatal("expected sanctioned=false for clean address")
}
if resp.Source != "ofac" {
t.Fatalf("expected source ofac (no jube configured), got %s", resp.Source)
}
}
func TestWalletScreenOFACSanctioned(t *testing.T) {
r, _ := newTestRouter()
// Tornado Cash address (from OFAC SDN list).
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x8589427373D6D84E98730D7795D8f6f8731FDA16",
"chain": "ethereum",
"direction": "send",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "blocked" {
t.Fatalf("expected risk blocked, got %s", resp.Risk)
}
if !resp.Sanctioned {
t.Fatal("expected sanctioned=true for OFAC address")
}
if resp.Source != "ofac" {
t.Fatalf("expected source ofac, got %s", resp.Source)
}
}
func TestWalletScreenOFACCaseInsensitive(t *testing.T) {
r, _ := newTestRouter()
// Same Tornado Cash address but all lowercase.
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x8589427373d6d84e98730d7795d8f6f8731fda16",
"chain": "ethereum",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "blocked" {
t.Fatalf("expected risk blocked for lowercase OFAC address, got %s", resp.Risk)
}
if !resp.Sanctioned {
t.Fatal("expected sanctioned=true")
}
}
func TestWalletScreenLazarusGroup(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x098B716B8Aaf21512996dC57EB0615e2383E2f96",
"chain": "ethereum",
"direction": "receive",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "blocked" {
t.Fatalf("expected risk blocked for Lazarus Group address, got %s", resp.Risk)
}
if !resp.Sanctioned {
t.Fatal("expected sanctioned=true for Lazarus Group")
}
}
func TestWalletScreenMissingAddress(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"chain": "ethereum",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestWalletScreenMissingChain(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestWalletScreenInvalidChain(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "solana",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestWalletScreenInvalidDirection(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "ethereum",
"direction": "bridge",
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestWalletScreenDefaultDirection(t *testing.T) {
r, _ := newTestRouter()
// Omit direction — should default to "receive" and succeed.
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "liquidity",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "low" {
t.Fatalf("expected risk low, got %s", resp.Risk)
}
}
func TestWalletScreenBitcoinChain(t *testing.T) {
r, _ := newTestRouter()
w := doRequest(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
"chain": "bitcoin",
"direction": "send",
})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp walletScreenResponse
decodeJSON(t, w, &resp)
if resp.Risk != "low" {
t.Fatalf("expected risk low for clean bitcoin address, got %s", resp.Risk)
}
}
func TestWalletScreenAccessibleByAnyOrg(t *testing.T) {
r, _ := newTestRouter()
// Non-admin org should still access wallet-screen (self-service via kyc module).
w := doRequestAsOrg(r, "POST", "/compliance/wallet-screen", map[string]string{
"address": "0x1234567890abcdef1234567890abcdef12345678",
"chain": "ethereum",
"direction": "receive",
}, "customer-user", "liquidity")
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for customer org, got %d: %s", w.Code, w.Body.String())
}
}
+84
View File
@@ -0,0 +1,84 @@
package compliance
import "strings"
// ofacSanctionedAddresses contains crypto wallet addresses from the OFAC SDN
// list. All keys are stored lowercase for case-insensitive lookup.
var ofacSanctionedAddresses = func() map[string]string {
// source -> address (lowercase)
raw := map[string]string{
// Tornado Cash
"0x8589427373D6D84E98730D7795D8f6f8731FDA16": "tornado_cash",
"0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b": "tornado_cash",
"0xDD4c48C0B24039969fC16D1cdF626eaB821d3384": "tornado_cash",
"0x722122dF12D4e14e13Ac3b6895a86e84145b6967": "tornado_cash",
"0xd4B88Df4D29F5CedD6857912842cff3b20C8Cfa3": "tornado_cash",
"0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF": "tornado_cash",
"0xA160cdAB225685dA1d56aa342Ad8841c3b53f291": "tornado_cash",
"0xFD8610d20aA15b7B2E3Be39B396a1bC3516c7144": "tornado_cash",
"0xF60dD140cFf0706bAE9Cd734Ac3683f2dB7085c5": "tornado_cash",
"0x23773E65ed146A459791799d01336DB287f25334": "tornado_cash",
"0x12D66f87A04A9E220743712cE6d9bB1B5616B8Fc": "tornado_cash",
"0x47CE0C6eD5B0Ce3d3A51fdb1C52DC66a7c3c2936": "tornado_cash",
"0xD21be7248e0197Ee08E0c20D4a398a3aA47b7857": "tornado_cash",
"0x178169B423a011fff22B9e3F3abeA13414dDD0F1": "tornado_cash",
"0x610B717796ad172B316836AC95a2ffad065CeaB4": "tornado_cash",
"0xbB93e510BbCD0B7beb5A853875f9eC60275CF498": "tornado_cash",
// Lazarus Group (DPRK)
"0x098B716B8Aaf21512996dC57EB0615e2383E2f96": "lazarus_group",
"0xa0e1c89Ef1a489c9C7dE96311eD5Ce5D32c20E4B": "lazarus_group",
"0x3Cffd56B47B7b41c56258D9C7731ABaDc360E460": "lazarus_group",
"0x53b6936513e738f44FB50d2b9476730C0Ab3Bfc1": "lazarus_group",
"0x35fB6f6DB4fb05e6A4cE86f2C93270f0461b11f3": "lazarus_group",
"0xF7B31119c2682c88d88D455dBb9d5932c65Cf1bE": "lazarus_group",
"0x3AD9dB589d201A710Ed237c829c7860Ba86510Fc": "lazarus_group",
"0xC8487fCAe8D1547C96DAe3e5b3b4c3F36EC20132": "lazarus_group",
// Garantex
"0x6Bf694a291DF3FeC1f7e69701E3ab6c592435Ae7": "garantex",
// Blender.io
"0x36dd7e6e2A8b3E1dC5e69B7b5e1B7C8Ca5e8b6E3": "blender_io",
"0xBA214C1c1928a32Bffe790263E38B4Af9bFCD659": "blender_io",
"0xb1C8094B234DcE6e03f10a5b673c1d8C69739A00": "blender_io",
"0x527653eA119F3E6a1F5BD18fbF4714081D7B31ce": "blender_io",
"0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2": "blender_io",
// Chatex
"0x6F1cA141A28907F78Ebaa64f83D078e664F2bF4e": "chatex",
"0x6aCDFBA02D390b97Ac2b2d42A63E85293BCc160e": "chatex",
// Suex
"0x2f389cE8bD8ff92De3402FFCe4691d17fC4f6535": "suex",
// Hydra Market
"0x48549A34AE37b12F6a30566245176994e17C6b4A": "hydra_market",
// Sinbad
"0x723B78e67497E85279CB204544566F4dC5d2acA0": "sinbad",
"0xA7e5d5A720f06526557c513402f2e6B5fA20b008": "sinbad",
// Additional OFAC-listed (individual sanctions)
"0x7F367cC41522cE07553e823bf3be79A889DEbe1B": "ofac_individual",
"0xd882cFc20F52f2599D84b8e8D58C7FB62cfE344b": "ofac_individual",
"0x7Db418b5D567A4e0E8c59Ad71BE1FcE48f3E6107": "ofac_individual",
"0x72a5843cc08275C8171E582972Aa4fDa8C397B2A": "ofac_individual",
"0x7F19720A857F834696350e4fF838329a3a80b29C": "ofac_individual",
"0x1da5821544e25c636c1417Ba96Ade4Cf6D2f9B5A": "ofac_individual",
"0x9F4cda013E354b8fC285BF4b9A60460cEe7f7Ea9": "ofac_individual",
}
m := make(map[string]string, len(raw))
for addr, source := range raw {
m[strings.ToLower(addr)] = source
}
return m
}()
// isOFACSanctioned checks if an address is on the OFAC SDN list.
// Returns the sanctions program name if found, empty string otherwise.
func isOFACSanctioned(address string) (source string, sanctioned bool) {
source, sanctioned = ofacSanctionedAddresses[strings.ToLower(address)]
return
}
+3
View File
@@ -119,6 +119,9 @@ func NewRouter(store ComplianceStore, opts ...RouterOption) chi.Router {
r.Get("/{id}/documents", guard("applications", "read", apps.handleGetDocuments))
})
// Wallet address screening — any authenticated user, any org.
r.Post("/compliance/wallet-screen", rateLimitSensitive(guard("kyc", "read", aml.handleWalletScreen)))
// Admin compliance routes — restricted to built-in org (superadmin).
r.Group(func(r chi.Router) {
r.Use(auth.RequireOrg(adminOrg))