Files
aml/pkg/chain/chainalysis.go
T
zeekayandHanzo Dev 07e69e3ee2 luxfi/aml — real-time AML/CFT transaction monitoring
A Go AML engine: a configurable rules DSL over expr-lang with weight-of-evidence
scoring, sanctions screening against OFAC/UN/EU/HMT, case management, and scored
alerting. Single binary, embedded SQLite per org, embedded UI.

Surface (all /v1, org-scoped):
  /v1/aml/transactions        ingest and score
  /v1/aml/rules  /rules/test  the rules DSL and its dry-run
  /v1/aml/sanctions/search    list screening
  /v1/aml/cases               investigation workflow
  /v1/aml/health

Build: make build produces one static binary with the UI embedded (embedui tag);
bare Go tooling needs no Node. Third-party license and notice texts are generated
from the module graph the linker actually walks and ship beside every artifact.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 22:51:25 -07:00

181 lines
4.9 KiB
Go

// Package chain provides blockchain analytics integration for AML screening.
// Supports address risk assessment and transaction exposure analysis.
package chain
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client is a Chainalysis KYT (Know Your Transaction) API client.
// API key must be stored in KMS and injected at startup.
type Client struct {
baseURL string
apiKey string
client *http.Client
}
// maxResponseSize limits API response body reads (1 MB).
const maxResponseSize = 1024 * 1024
// NewClient creates a Chainalysis client. apiKey must come from KMS.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// AddressRisk is the risk assessment for a blockchain address.
type AddressRisk struct {
Address string `json:"address"`
Chain string `json:"chain"`
RiskScore float64 `json:"risk_score"` // 0.0 (clean) to 1.0 (high risk)
Category string `json:"category"` // exchange, mixer, darknet, sanctions, unknown
Cluster string `json:"cluster_name,omitempty"`
Exposure []ExposureEntry `json:"exposure,omitempty"`
}
// ExposureEntry describes exposure to a particular category.
type ExposureEntry struct {
Category string `json:"category"`
Percentage float64 `json:"percentage"` // 0-100
Amount float64 `json:"amount,omitempty"`
Currency string `json:"currency,omitempty"`
}
// TxRisk is the risk assessment for a blockchain transaction.
type TxRisk struct {
TxHash string `json:"tx_hash"`
Chain string `json:"chain"`
RiskScore float64 `json:"risk_score"`
DirectExposure []ExposureEntry `json:"direct_exposure,omitempty"`
IndirectExposure []ExposureEntry `json:"indirect_exposure,omitempty"`
Alerts []string `json:"alerts,omitempty"`
}
// ScreenAddress checks a blockchain address against Chainalysis risk data.
func (c *Client) ScreenAddress(ctx context.Context, address, chain string) (*AddressRisk, error) {
body := map[string]string{
"address": address,
"chain": chain,
}
resp, err := c.post(ctx, "/v2/entities", body)
if err != nil {
return nil, fmt.Errorf("screen address: %w", err)
}
var result AddressRisk
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("parse address risk: %w", err)
}
result.Address = address
result.Chain = chain
return &result, nil
}
// ScreenTransaction checks a transaction against Chainalysis.
func (c *Client) ScreenTransaction(ctx context.Context, txHash, chain string) (*TxRisk, error) {
path := fmt.Sprintf("/v2/transfers/%s?chain=%s", txHash, chain)
resp, err := c.get(ctx, path)
if err != nil {
return nil, fmt.Errorf("screen transaction: %w", err)
}
var result TxRisk
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("parse tx risk: %w", err)
}
result.TxHash = txHash
result.Chain = chain
return &result, nil
}
// RegisterWebhook subscribes to Chainalysis alert events.
func (c *Client) RegisterWebhook(ctx context.Context, callbackURL string, events []string) error {
body := map[string]interface{}{
"url": callbackURL,
"events": events,
}
_, err := c.post(ctx, "/v2/webhooks", body)
if err != nil {
return fmt.Errorf("register webhook: %w", err)
}
return nil
}
// IsHighRisk returns true if the address is categorized as high risk.
func (ar *AddressRisk) IsHighRisk() bool {
if ar.RiskScore >= 0.7 {
return true
}
switch ar.Category {
case "mixer", "darknet", "sanctions", "scam", "ransomware", "stolen_funds":
return true
}
return false
}
// IsBlocked returns true if the address is associated with sanctioned entities.
func (ar *AddressRisk) IsBlocked() bool {
return ar.Category == "sanctions"
}
func (c *Client) post(ctx context.Context, path string, body interface{}) ([]byte, error) {
payload, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Token", c.apiKey)
return c.doRequest(req)
}
func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Token", c.apiKey)
return c.doRequest(req)
}
func (c *Client) doRequest(req *http.Request) ([]byte, error) {
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(data))
}
return data, nil
}