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]>
This commit is contained in:
2026-07-28 22:51:25 -07:00
co-authored by Hanzo Dev
commit 07e69e3ee2
73 changed files with 17766 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# The image build reads go.mod/go.sum, the Go sources, and ui/. Everything else
# only inflates the context — 332 MB down to a few MB — and keeps local build
# output and the git history from ever reaching a layer.
.git
.github
amld
/dist/
/data/
THIRD-PARTY-NOTICES
ui/node_modules
ui/dist
ui/tsconfig.tsbuildinfo
*.db
*.db-journal
*.db-wal
*.db-shm
.env
+38
View File
@@ -0,0 +1,38 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
# A runner may carry a machine-level go env that routes some module paths around
# the proxy and the checksum database. GOENV=off ignores it, so every module
# resolves through proxy.golang.org and is verified against sum.golang.org — a
# tag that moves later cannot change what this build compiles.
env:
GOENV: off
jobs:
ci:
runs-on: [self-hosted, linux, amd64]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: actions/setup-node@v4
with:
node-version: 22
- run: corepack enable && corepack prepare [email protected] --activate
- run: go mod download
- run: go mod verify
- run: go vet ./...
- run: go test -race -count=1 ./...
- run: go build ./...
# Builds the UI and links it with -tags embedui, the only path that
# compiles ui/embed_prod.go.
- run: make build
+60
View File
@@ -0,0 +1,60 @@
name: Docker
# Single-arch (linux/amd64) container build on the working `lux-build` ARC label.
# Multi-arch (arm64) will return once the per-arch `lux-build-linux-arm64`
# pool is verified online (current per-arch labels did not pick up jobs).
# No QEMU, no cross-org reusable.
on:
workflow_dispatch:
push:
branches: [main, dev, test]
tags: ['v*']
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/luxfi/aml
jobs:
build:
runs-on: [self-hosted, linux, amd64]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute tags
id: meta
run: |
set -e
tags=""
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
v="${GITHUB_REF#refs/tags/}"
tags="ghcr.io/luxfi/aml:${v},ghcr.io/luxfi/aml:latest"
elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then
tags="ghcr.io/luxfi/aml:main"
elif [[ "${GITHUB_REF}" == refs/heads/dev ]]; then
tags="ghcr.io/luxfi/aml:dev"
elif [[ "${GITHUB_REF}" == refs/heads/test ]]; then
tags="ghcr.io/luxfi/aml:test"
fi
echo "tags=${tags}" >> "$GITHUB_OUTPUT"
echo "version=${v:-$(git describe --tags --always)}" >> "$GITHUB_OUTPUT"
- name: Build & push
if: steps.meta.outputs.tags != ''
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
# The build stage stamps main.version from this; without it every
# image would report "dev".
build-args: |
VERSION=${{ steps.meta.outputs.version }}
+62
View File
@@ -0,0 +1,62 @@
name: Release
# Native binary release for amld on `v*` tags.
#
# Six-platform matrix (linux/darwin/windows × amd64/arm64) via goreleaser.
# Container image is built independently by .github/workflows/docker.yml
# (native multi-arch ARC runners — NO QEMU).
#
# Runner labels: lux-build self-hosted ARC scale-sets per the org policy.
# GitHub-hosted runners are NOT a permitted fallback.
on:
push:
tags: ['v*']
workflow_dispatch:
permissions:
contents: write
packages: write
id-token: write
# A runner may carry a machine-level go env that routes some module paths around
# the proxy and the checksum database. GOENV=off ignores it, so every module
# resolves through proxy.golang.org and is verified against sum.golang.org — a
# tag that moves later cannot change what gets released.
env:
GOENV: off
jobs:
goreleaser:
runs-on: [self-hosted, linux, amd64]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
# goreleaser builds ui/dist in its before-hooks and links with
# -tags embedui, so the release runner needs the Node toolchain. pnpm must
# match pnpm-lock.yaml's lockfileVersion (9.0).
- uses: actions/setup-node@v4
with:
node-version: 22
- run: corepack enable && corepack prepare [email protected] --activate
- name: Install syft for SBOM
run: |
mkdir -p "$HOME/.local/bin"
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+12
View File
@@ -0,0 +1,12 @@
/amld
/THIRD-PARTY-NOTICES
/dist/
/data/
*.db
*.db-journal
*.db-wal
*.db-shm
.env
ui/dist/
ui/node_modules/
ui/tsconfig.tsbuildinfo
+85
View File
@@ -0,0 +1,85 @@
# goreleaser config for amld.
#
# Triggered by the release workflow on `v*` tag push. Builds native
# binaries for all six policy platforms, produces tarballs + zips +
# checksums + SBOM, and attaches them to the GitHub Release.
#
# Container images are built separately by `.github/workflows/docker.yml`
# (native multi-arch via hanzoai/.github reusable workflow).
version: 2
project_name: amld
# Reaches the before-hooks and the builds alike. The notice file has to describe
# the same graph the binary links, and blst is reachable only under cgo, so the
# setting belongs in one place rather than repeated per build.
env:
- CGO_ENABLED=0
before:
hooks:
# Never `go mod tidy` here: a release hook that can rewrite go.mod/go.sum
# makes the released artifact depend on when it was built.
- go mod download
- pnpm --dir ui install --frozen-lockfile
- pnpm --dir ui build
# Third-party license and notice texts, derived from the binary's module
# graph. Runs after the UI build so the embedui graph resolves.
#
# One notice file goes into all six archives, and the module set differs by
# platform (darwin links ncruces/go-strftime, linux links prometheus/procfs),
# so it has to be the union over the build matrix below. The other two
# distribution paths pass no -platforms: `make build` and the image each
# produce a single binary, and the host default already describes it.
- go run ./internal/notice -tags embedui -pkg ./cmd/amld -name amld
-platforms linux/amd64,linux/arm64,darwin/amd64,darwin/arm64,windows/amd64,windows/arm64
-o THIRD-PARTY-NOTICES
builds:
- id: amld
main: ./cmd/amld
binary: amld
flags:
- -trimpath
tags:
- embedui
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
archives:
- id: amld
builds: [amld]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
files:
- LICENSE*
- README*
- THIRD-PARTY-NOTICES
checksum:
name_template: checksums.txt
sboms:
- artifacts: archive
snapshot:
version_template: "{{ incpatch .Version }}-next"
changelog:
use: github
sort: asc
release:
draft: false
prerelease: auto
mode: append
Symlink
+1
View File
@@ -0,0 +1 @@
LLM.md
+45
View File
@@ -0,0 +1,45 @@
# Stage 1: Build UI (Vite + React + @hanzo/gui)
# pnpm must match pnpm-lock.yaml's lockfileVersion (9.0), and --frozen-lockfile
# is mandatory so lockfile drift fails the build instead of resolving fresh.
FROM node:22-alpine AS ui
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /ui
COPY ui/package.json ui/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY ui/ .
RUN pnpm build
# Stage 2: Build Go binary with embedded UI
# Every dependency resolves through the default module proxy and is checked
# against the default checksum database, so a tag that moves after the fact
# cannot change what this image contains: proxy.golang.org serves the zip it
# first recorded, and sum.golang.org is an append-only witness of that zip's
# hash. go.sum carries the witnessed hashes — see LLM.md, "Dependency pinning".
# Nothing here is fetched from the git host, so the build needs no credential.
FROM golang:1.26.4-alpine AS build
RUN apk add --no-cache git
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui /ui/dist ./ui/dist/
RUN CGO_ENABLED=0 go build -trimpath -tags embedui \
-ldflags="-s -w -X main.version=${VERSION}" -o /app/amld ./cmd/amld/
# Third-party license and notice texts for the binary just built, derived from
# its module graph while the module cache is still present.
RUN CGO_ENABLED=0 go run ./internal/notice -tags embedui -pkg ./cmd/amld -name amld \
-o /app/THIRD-PARTY-NOTICES
# Stage 3: Runtime
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /app/amld /app/amld
COPY --from=build /app/THIRD-PARTY-NOTICES /app/THIRD-PARTY-NOTICES
COPY LICENSE /app/LICENSE
WORKDIR /data
EXPOSE 8090
ENTRYPOINT ["/app/amld"]
CMD ["serve", "--http=0.0.0.0:8090"]
+122
View File
@@ -0,0 +1,122 @@
Lux Ecosystem License
Version 1.2, December 2025
Copyright (c) 2020-2025 Lux Industries Inc.
All rights reserved.
TECHNOLOGY PORTFOLIO - PATENT APPLICATIONS PLANNED
Contact: [email protected]
================================================================================
TERMS AND CONDITIONS
================================================================================
1. DEFINITIONS
"Lux Primary Network" means the official Lux blockchain with Network ID=1
and EVM Chain ID=96369.
"Authorized Network" means the Lux Primary Network, official testnets/devnets,
and any L1/L2/L3 chain descending from the Lux Primary Network.
"Descending Chain" means an L1/L2/L3 chain built on, anchored to, or deriving
security from the Lux Primary Network or its authorized testnets.
"Research Use" means non-commercial academic research, education, personal
study, or evaluation purposes.
"Commercial Use" means any use in connection with a product or service
offered for sale or fee, internal use by a for-profit entity, or any use
to generate revenue.
2. GRANT OF LICENSE
Subject to these terms, Lux Industries Inc grants you a non-exclusive,
royalty-free license to:
(a) Use for Research Use without restriction;
(b) Operate on the Lux Primary Network (Network ID=1, EVM Chain ID=96369);
(c) Operate on official Lux testnets and devnets;
(d) Operate L1/L2/L3 chains descending from the Lux Primary Network;
(e) Build applications within the Lux ecosystem;
(f) Contribute improvements back to the original repositories.
3. RESTRICTIONS
Without a commercial license from Lux Industries Inc, you may NOT:
(a) Fork the Lux Network or any Lux software;
(b) Create competing networks not descending from Lux Primary Network;
(c) Use for Commercial Use outside the Lux ecosystem;
(d) Sublicense or transfer rights outside the Lux ecosystem;
(e) Use to create competing blockchain networks, exchanges, custody
services, or cryptographic systems outside the Lux ecosystem.
4. NO FORKS POLICY
Lux Industries Inc maintains ZERO TOLERANCE for unauthorized forks.
Any fork or deployment on an unauthorized network constitutes:
(a) Breach of this license;
(b) Grounds for immediate legal action.
5. RIGHTS RESERVATION
All rights not explicitly granted are reserved by Lux Industries Inc.
We plan to apply for patent protection for the technology in this
repository. Any implementation outside the Lux ecosystem may require
a separate commercial license.
6. DISCLAIMER OF WARRANTY
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
7. LIMITATION OF LIABILITY
IN NO EVENT SHALL LUX INDUSTRIES INC BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE.
8. TERMINATION
This license terminates immediately upon any breach, including but not
limited to deployment on unauthorized networks or creation of forks.
9. GOVERNING LAW
This License shall be governed by the laws of the State of Delaware.
10. COMMERCIAL LICENSING
For commercial use outside the Lux ecosystem:
Lux Industries Inc.
Email: [email protected]
Subject: Commercial License Request
================================================================================
TL;DR
================================================================================
- Research/academic use = OK
- Lux Primary Network (Network ID=1, Chain ID=96369) = OK
- L1/L2/L3 chains descending from Lux Primary Network = OK
- Commercial products outside Lux ecosystem = Contact [email protected]
- Forks = Absolutely not
================================================================================
See LP-0012 for full licensing documentation:
https://github.com/luxfi/lps/blob/main/LPs/lp-0012-ecosystem-licensing.md
+12
View File
@@ -0,0 +1,12 @@
# Licensing
This repository is licensed under the **Lux Ecosystem License v1.2** (see [LICENSE](LICENSE)). It belongs to the **patent-protected** tier of the Lux three-tier IP strategy.
- Free for **Authorized Networks** (Lux Primary NetID=1, EVM 96369; official testnets/devnets; Descending Chains).
- Free for **Research Use** (academic, education, evaluation).
- **Commercial use outside Authorized Networks requires a paid commercial license.**
For the canonical Lux IP and licensing strategy and the precise definitions of "Authorized Network", "Descending Chain", and "Research Use", see:
<https://github.com/luxfi/.github/blob/main/profile/README.md>
For commercial licensing inquiries, contact `[email protected]`.
+278
View File
@@ -0,0 +1,278 @@
# luxfi/aml
## Overview
Go module: `github.com/luxfi/aml`
Real-time AML/CFT transaction monitoring engine. Pure Go, single binary, embedded SQLite via Hanzo Base. Configurable rules DSL (expr-lang), sanctions screening (OFAC/UN/EU/HMT), case management, scored alerting.
## Tech Stack
- **Language**: Go 1.26.4
- **Database**: Hanzo Base (embedded SQLite per-org)
- **Rules DSL**: github.com/expr-lang/expr v1.17
- **Scoring**: Weight-of-evidence (pure Go math)
- **Sanctions**: Jaro-Winkler + token-based fuzzy name matching
- **HTTP**: Hanzo Base router (net/http)
- **Auth**: X-Org-Id header (set by gateway from JWT owner claim)
## Build & Run
```bash
make build # UI build + Go build -tags embedui -> ./amld (the product)
make test # go test -race -count=1 ./...
make vet # go vet ./...
make ui # pnpm install --frozen-lockfile && pnpm build -> ui/dist
make run # Build + run dev
```
`make build` is the only command that produces the shippable binary: the admin
dashboard is embedded, so it always builds `ui/dist` first and links with
`-tags embedui`.
Bare `go build ./...`, `go vet ./...`, `go test ./...` and `go install` work on a
fresh clone with no Node toolchain — without the `embedui` tag, `ui/embed.go`
supplies a placeholder tree instead of embedding `ui/dist`. That is what keeps
the module installable, since `ui/dist` is a build artifact and is not committed.
## Module Layout
```
cmd/amld/main.go -- Single binary: serve, version, embedded admin UI
migrations/0001_core.sql -- 9 SQLite collections
ui/ -- Embedded admin dashboard (Vite + React + @hanzo/gui)
embed.go -- !embedui: placeholder DistDirFS(), no dist/ needed
embed_prod.go -- embedui: go:embed all:dist -> DistDirFS()
package.json -- @luxfi/aml-ui (private, pnpm)
vite.config.ts -- base: /_/aml/, proxy /v1/aml to :8090
src/
main.tsx -- React 19 entry
App.tsx -- Hash router (wouter), sidebar nav, 5 routes
api.ts -- Typed fetch wrappers for /v1/aml/* endpoints
pages/
Dashboard.tsx -- Stats cards, recent cases, health check
Cases.tsx -- DataTable with status filter tabs, expandable rows
Rules.tsx -- DataTable with DSL display, test rule modal
Alerts.tsx -- DataTable with severity filter chips, score breakdown
Sanctions.tsx -- Name search form, results table with scores
dist/ -- Built output (gitignored, ~220KB)
pkg/
types/types.go -- Canonical domain types (Transaction, Entity, Rule, Alert, Case)
engine/
engine.go -- Core engine: evaluate tx -> alerts + score + action
evaluator.go -- expr-lang compiler + evaluator with helper functions
scoring.go -- Weight-of-evidence scoring + ScorerPlugin interface
rules/library.go -- 20 starter AML rules (CTR, structuring, velocity, sanctions, etc.)
sanctions/
match.go -- Jaro-Winkler + token-based fuzzy name matching
lists.go -- Default list configurations (OFAC, UN, EU, HMT)
ingest.go -- OFAC SDN XML parser + HTTP fetcher
cases/
case.go -- Case store (create, update status, assign, resolve, events)
errors.go -- Sentinel errors
webhook/webhook.go -- Signed delivery (HMAC-SHA256) with retry + dead-letter
api/routes.go -- /v1/aml/* HTTP routes + SanctionsStore on Base
```
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| POST | /v1/aml/transactions | Ingest transaction, sync rule eval, returns action |
| GET | /v1/aml/transactions/{id}/alerts | Alerts for a transaction |
| GET | /v1/aml/cases | List cases (filter by status) |
| POST | /v1/aml/cases/{id}/events | Add case event (note, status change) |
| GET | /v1/aml/rules | List all rules |
| POST | /v1/aml/rules/test | Dry-run a DSL expression |
| POST | /v1/aml/sanctions/search | Search sanctions lists by name |
| GET | /v1/aml/health | Health check |
All endpoints require `X-Org-Id` header.
## Embedded Admin UI
Served at `/_/aml/` via `go:embed`. Hash router (`/#/cases`, `/#/rules`, etc.) for SPA routing without server rewrites.
```bash
make build # builds ui/dist, then links it with -tags embedui
./amld serve --dev # UI at http://localhost:8090/_/aml/
```
Without `-tags embedui` the binary serves a placeholder page at `/_/aml/`; that
is the expected result of a bare `go build`/`go install`.
Dev mode: `cd ui && pnpm dev` (port 3000, proxies API to :8090).
Tech: React 19, wouter (hash router), @hanzo/gui, Vite 6. Dark theme.
## Transaction Ingest Flow
```
POST /v1/aml/transactions
-> Parse + validate
-> Resolve entity from user_id
-> EvalAll(rules, {tx, entity})
-> Filter by jurisdiction, asset_class, enabled
-> Compile + run each rule DSL via expr-lang
-> Score(hits) -> weight-of-evidence [0,1]
-> resolveAction(alerts) -> highest severity action wins
-> If block/report: auto-create Case
-> Return {action, score, alert_ids, case_id?}
```
## Starter Rules (20)
| # | Rule | DSL Pattern | Action |
|---|------|-------------|--------|
| 1 | CTR Threshold | notional > 10000 && USD | report |
| 2 | Structuring | notional in [9000, 10000) | flag |
| 3 | Velocity Daily | sum_last_24h > 50000 | review |
| 4 | Velocity Monthly | sum_last_30d > 500000 | review |
| 5 | First-Time Large | first_tx && notional > 25000 | review |
| 6 | Round-Trip | is_round_trip 24h | flag |
| 7 | Sanctioned Jurisdiction | jurisdiction in sanctioned list | block |
| 8 | PEP Large | PEP && notional > 10000 | review |
| 9 | Unusual Hour | hour in [2,3,4] | flag |
| 10 | New Counterparty Large | first_counterparty && notional > 5000 | review |
| 11 | Dormant Reactivation | last_tx > 180d && notional > 10000 | review |
| 12 | Crypto Mixer | is_mixer_address | block |
| 13 | Darknet Market | is_darknet_market | block |
| 14 | IP/Geo Mismatch | !geo_match | flag |
| 15 | Layering | layering_score > 0.8 | review |
| 16 | Smurfing | smurfing_detected 7d | review |
| 17 | Wash Trading | wash_trade_detected 1h | flag |
| 18 | Sanctions Direct | sanctions_hit(entity) | block |
| 19 | Sanctions Counterparty | sanctions_hit(counterparty) | block |
| 20 | Travel Rule | notional > travel_rule_threshold | report |
## Compliance Client Compatibility
`luxfi/compliance` AML client connects via HTTP:
| Client Method | Endpoint |
|---|---|
| ScreenTransaction | POST /v1/aml/transactions |
| CheckSanctions | POST /v1/aml/sanctions/search |
| CreateCase | POST /v1/aml/cases/{id}/events |
| GetCases | GET /v1/aml/cases |
Webhook events: `aml.flagged`, `aml.cleared`, `kyc.approved`, `trade.executed`.
## Deferred
- ML scoring via Hanzo Zen gateway (ScorerPlugin interface ready)
- Hanzo Tasks durable workflows (sanctions-refresh, case-automation, backtest)
- Base realtime subscription for live transaction monitoring
- Backtest/batch evaluation against historical data
- SAR draft generation
- Full OFAC/UN/EU/HMT list refresh automation
- Base collection persistence (current: in-memory stores for v1 engine)
## Dependency pinning
Every dependency resolves through `proxy.golang.org` and is verified against
`sum.golang.org`. Both are append-only: the proxy serves the zip it first
recorded for a version, and the checksum database is a transparency log of that
zip's hash. A tag that moves afterwards therefore cannot change what this repo
compiles — which is the whole point, because tags in this fleet do move.
The builds pin that policy themselves rather than inheriting it. A machine may
carry a `go env` file whose `GOPRIVATE`/`GONOPROXY`/`GONOSUMDB` route
`luxfi/*` and `hanzoai/*` around both, so `ci.yml` and `release.yml` set
`GOENV: off`, the one documented switch that ignores the file. `GOPRIVATE=` in
the environment does **not** override it: Go reads an empty environment variable
as unset and falls back to the file.
Rules:
1. **Never write a hash `sum.golang.org` does not corroborate.** The log is the
authority; if `go.sum` disagrees with it, `go.sum` is wrong. Two commits in
this history (`9821aff`, `77173ad`) wrote hashes taken from whatever the git
host was serving, which is the failure this rule exists to prevent.
```bash
curl -sS https://sum.golang.org/lookup/github.com/luxfi/[email protected]
```
2. **`go mod verify` does not check `go.sum`.** It compares the extracted module
against the hash recorded in the local module cache, so it reports
"all modules verified" while a `go.sum` entry that matches nothing sits right
next to it. Only a download can catch that — on a *clean* `GOMODCACHE`, since
a cache entry fetched from the git host keeps serving those bits.
3. **Prefer the lowest unmoved patch successor** (`x.y.z` -> `x.y.z+1`). Never
jump a major; never go above `v1.x.x`.
4. If a module has no version whose host content, proxy content, and log record
all agree, that repo's owner must cut one. Do not work around it here.
Tags known to have been force-moved, and how each was resolved:
| Module | Moved tag | Resolution |
|---|---|---|
| `github.com/luxfi/age` | v1.5.0 | pinned v1.5.1 — same commit `b13d4cb0`, unmoved |
| `github.com/luxfi/threshold` | v1.9.4 | pinned v1.9.5 — same commit `0080201`, unmoved |
| `github.com/luxfi/pq` | v1.0.3 | resolved in place: move was content-neutral, `go.sum` carries the log hash |
| `github.com/hanzoai/base` | v1.1.0 | `go.sum` carries the log hash; **owner action** for a version host and log agree on |
| `github.com/hanzoai/kms/sdk/go` | v1.0.0 | graph-only, not in `go.sum` |
| `github.com/luxfi/mpc` | v1.14.13 | graph-only, not in `go.sum`; host and log disagree |
| `github.com/luxfi/precompile` | v0.5.37 | graph-only, not in `go.sum`; host and log disagree |
`luxfi/age` and `luxfi/threshold` are the pattern to copy: a successor tag cut at
the *identical commit*, so pinning it changes no code and restores corroboration.
Two entries needed more than a successor tag:
- **`luxfi/pq` v1.0.3** moved from `e16b004d` to `90d2223e`, but the two commits
have byte-identical trees, so the zip hash never changed. The proxy, the log,
and the git host all report `h1:pFlQm1+5...`; `go.sum` had recorded
`h1:ksw1dmfT...`, which corresponds to nothing obtainable and made a
cold-cache build fail outright. `go.sum` now carries the log hash. No
successor tag is needed.
- **`hanzoai/base` v1.1.0** moved from `fe516fdc` to `a0b4a9f3`, and the content
did change — in exactly three files, `README.md`, `docs/NETWORK.md`, and
`docs/NETWORK_RED_REVIEW.md`. **No Go source and no `go.mod` differs**, so the
two trees compile identically. `go.sum` carries the log hash
(`h1:Mi4yRgR1...`, the `fe516fdc` tree). A machine whose module cache already
holds the `a0b4a9f3` bits will report a mismatch until that entry is evicted;
a clean cache resolves it from the proxy. The durable fix is for the owner to
cut `v1.1.1` at `a0b4a9f3` so host, proxy, and log agree on one version.
## Third-party notices
The distributed artifacts carry `THIRD-PARTY-NOTICES`: the license, notice, and
patent-grant texts of every module the linker puts in `amld`, reproduced
verbatim. Apache-2.0 §4(d) requires the attribution notices of Apache-licensed
dependencies to travel with each distributed copy, and 10 of the linked modules
ship a `NOTICE` file; MIT/BSD/ISC require their copyright and permission notices
to travel the same way.
`go run ./internal/notice` generates it from the build's own module graph, so it
cannot describe a dependency set the artifact does not have and cannot go stale
when a dependency changes. Output is deterministic — modules sorted by path, no
timestamps — so it does not perturb a reproducible build.
It is generated, not committed (`.gitignore`), and produced on all three
distribution paths: `make build` (via the `notice` target), the goreleaser
archive (a `before` hook plus an `archives.files` entry), and the container image
(build stage, copied to `/app/THIRD-PARTY-NOTICES` beside `/app/LICENSE`).
Regenerate with `make notice`; verify by inspecting a built archive or the image
filesystem, never by reading the config.
## Deployment
- Single binary: `amld serve --http=0.0.0.0:8090`
- Docker: `ghcr.io/luxfi/aml:{env}`
- K8s: Deployment, replicas=1 (SQLite single-writer), PVC for /data
- Replication: hanzoai/replicate sidecar for age-encrypted S3 WAL streaming
## Test Coverage
133 tests across 8 packages. All pass with `-race`.
- engine: 38 tests (evaluator, scoring, integration)
- cases: 28 tests (CRUD, status, events, assignment, resolution)
- sanctions: 24 tests (Jaro-Winkler, normalize, token match)
- rules: 21 tests (all 20 starter rules compile + individual eval)
- chain: 7 tests (Chainalysis client: entities, transfers, webhooks)
- workflows: 7 tests
- webhook: 5 tests (sign, verify, deliver, failure)
- api: 3 tests
+36
View File
@@ -0,0 +1,36 @@
.PHONY: build ui notice test vet run clean
VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
LDFLAGS := -ldflags="-s -w -X main.version=$(VERSION)"
NOTICE := THIRD-PARTY-NOTICES
# The product binary embeds the admin dashboard, so it always builds the UI
# first. Bare `go build`/`go vet`/`go test`/`go install` need no UI: without
# the embedui tag ui/embed.go supplies a placeholder tree.
build: ui notice
CGO_ENABLED=0 go build -trimpath -tags embedui $(LDFLAGS) -o amld ./cmd/amld/
ui:
pnpm --dir ui install --frozen-lockfile
pnpm --dir ui build
# Third-party license and notice texts for the binary, derived from its module
# graph. Generated with the same tags and CGO setting the product ships with, so
# it describes the artifact that is distributed and not some other graph — which
# also means dist/ has to exist first for the embed directive to resolve. No
# -platforms: this target builds one binary, for this host.
notice: ui
CGO_ENABLED=0 go run ./internal/notice -tags embedui -pkg ./cmd/amld -name amld -o $(NOTICE)
test:
go test -race -count=1 ./...
vet:
go vet ./...
run: build
./amld serve --dev
clean:
rm -f amld $(NOTICE)
rm -rf data/ ui/dist/
+94
View File
@@ -0,0 +1,94 @@
// amld is the AML engine daemon. It provides real-time transaction monitoring,
// sanctions screening, case management, and webhook delivery.
//
// Usage:
//
// amld serve Start the HTTP server
// amld version Print version
package main
import (
"fmt"
"os"
"github.com/hanzoai/base"
"github.com/hanzoai/base/apis"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/hook"
"github.com/spf13/cobra"
"github.com/luxfi/aml/pkg/api"
"github.com/luxfi/aml/pkg/cases"
"github.com/luxfi/aml/pkg/engine"
"github.com/luxfi/aml/pkg/rules"
"github.com/luxfi/aml/pkg/sanctions"
uiaml "github.com/luxfi/aml/ui"
)
var version = "(dev)"
func main() {
app := base.New()
var serveCmd *cobra.Command
for _, c := range app.RootCmd.Commands() {
if c.Use == "serve" {
serveCmd = c
break
}
}
// Wire AML engine into Base's serve lifecycle.
defaultOrg := os.Getenv("AML_DEFAULT_ORG")
if defaultOrg == "" {
defaultOrg = "default"
}
starterRules := rules.StarterRules(defaultOrg)
eng := engine.New(starterRules)
caseStore := cases.NewStore()
alertStore := api.NewAlertStore()
sanctionsStore := api.NewSanctionsStore()
handler := &api.Handler{
Engine: eng,
Cases: caseStore,
Alerts: alertStore,
Sanctions: sanctionsStore,
}
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Func: func(se *core.ServeEvent) error {
// Wire TransactionStore (Base-backed) into engine helpers.
txStore := engine.NewBaseStore(app)
engine.RegisterStoreHelpers(eng, txStore)
// Wire SanctionsStore (Base-backed) and register refresh cron.
baseSanctionsStore := sanctions.NewBaseSanctionsStore(app)
sanctions.RefreshCron(app, baseSanctionsStore)
handler.Register(se)
// Mount embedded admin UI at /_/aml/
se.Router.GET("/_/aml/{path...}", apis.Static(uiaml.DistDirFS(), true))
return se.Next()
},
})
// Add version subcommand.
app.RootCmd.AddCommand(&cobra.Command{
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("amld", version)
},
})
_ = serveCmd // Base already provides "serve" — we just hook into it.
if err := app.Start(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+14
View File
@@ -0,0 +1,14 @@
services:
amld:
build: .
container_name: amld
ports:
- "8090:8090"
volumes:
- aml-data:/data
environment:
AML_DEFAULT_ORG: ${AML_DEFAULT_ORG:-default}
restart: unless-stopped
volumes:
aml-data:
+384
View File
@@ -0,0 +1,384 @@
# amld — Integration Patterns
This document describes how `amld` integrates with the rest of the
stack. It is deliberately generic: it specifies the *interfaces*,
*data shapes*, and *control flow*, not any particular commercial
data provider. Operators choose their own watchlist, PEP, and
adverse-media sources and adapt them to the schemas below.
`amld` is the AML / FinCrime evaluation service. It exposes a
versioned HTTP API on `/v1/aml/*`, persists rules / alerts / cases
through a Hanzo Base–backed store, evaluates per-transaction risk
synchronously, emits signed webhooks for downstream review, and
ships with a starter rule library covering the standard typologies.
The integration surface has six concerns:
1. Sanctions / watchlist screening
2. Politically Exposed Person (PEP) screening
3. Adverse-media screening
4. Velocity and amount-threshold limits
5. Customer risk tiers
6. Pre-transaction hooks for caller services (`bankd`, exchange,
payments)
7. Webhook + case-management surface for human review
---
## 1. Sanctions / Watchlist Screening
`amld` treats sanctions data as a *normalised, append-only catalog*
that any number of upstream lists can feed. The canonical model is in
`pkg/types/types.go`:
```go
type SanctionsList struct {
ID string // internal id
Source string // free-form source identifier
URL string // origin URL
Format string // xml | csv | json | ...
FetchedAt time.Time
SHA256 string // integrity hash of the raw payload
Active bool
}
type SanctionsEntry struct {
ID string
ListID string // back-ref to SanctionsList.ID
RefID string // upstream-stable identifier
Name string
Aliases []string
DOB string // best-effort, often partial
Nationality string
Address string
Type string // individual | entity | vessel | aircraft
Raw json.RawMessage // original record preserved verbatim
}
```
The shipped list-source constants (`ofac_sdn`, `un`, `eu`, `hmt`,
`interpol`) are *category tags*, not bindings to any particular
publisher. Operators can ingest:
- Government-published consolidated lists (OFAC SDN / Consolidated,
UN Security Council, EU Consolidated Financial Sanctions, UK HMT
OFSI, regional regulators).
- Internal block lists (denylists from internal investigations,
closed customer relationships, etc.).
- Third-party aggregated watchlist feeds — the same ingest path
applies; only the parser differs.
### Ingest contract
A list ingester is a function that:
1. Fetches the upstream payload.
2. Computes its SHA-256 (recorded on `SanctionsList`).
3. Parses it into `[]SanctionsEntry` rows tagged with the parent
`ListID`.
4. Persists rows transactionally — partial ingests must not leave
half-loaded lists active.
The matcher (`pkg/sanctions/match.go`) is source-agnostic. It scores
incoming names against `Name` and every `Alias` using token-based
similarity and returns hits above a configurable threshold.
### Refresh cadence
`pkg/sanctions/refresh.go` defines the periodic refresh loop. Cadence
is policy: regulated counterparties typically require daily refresh
of consolidated government lists; internal lists update on commit.
---
## 2. PEP Screening
PEP status is a *property of the entity*, not of the transaction. The
canonical flag lives on `Entity`:
```go
type Entity struct {
ID string
OrgID string
EntityType string // user | account | counterparty | bank_account
Name string
Jurisdiction string
KYCLevel int
PEP bool // <-- politically exposed
SanctionsFlag bool
RiskScore float64
}
```
`PEP` is a boolean on the resolved entity. The recommended pattern:
- Upstream KYC pipeline (in `bankd` or a dedicated onboarding
service) determines PEP status at customer-onboarding and on
periodic re-screen.
- The result is written to the AML `Entity` record. `amld` is the
consumer, not the resolver — it does not embed a PEP database.
- Rules consume `entity.PEP` directly via the expr-lang DSL:
`entity.PEP && tx.Notional > 1000`.
The same shape supports relatives-and-close-associates (RCA) and
PEP-by-association — encode them either as additional boolean
attributes on `Entity` (via a future-compatible extension column) or
as separate watchlist categories ingested through the sanctions path
above. The data model intentionally does not over-specify PEP
sub-categories so that operators can match the granularity their
regulator requires.
---
## 3. Adverse-Media Screening
Adverse-media findings are modelled as *evidence attached to a
case*, not as a primary signal. The integration shape:
- An external adverse-media screen (operator's choice) produces a
finding: subject identifier, severity, source URL, headline,
excerpt, date.
- The finding is posted as a `CaseEvent` of kind `note` (or `file`
if a snapshot PDF is attached) on a case opened for the subject:
```
POST /v1/aml/cases/{id}/events
{
"kind": "note",
"body": "Adverse media match — <category> — <source> — <date>"
}
```
- For *automated* adverse-media gating before a transaction settles,
the upstream screen pushes an adverse-media risk score into the
`Entity.RiskScore` field (or a derived attribute) so the rule
engine can read it via expr-lang: `entity.RiskScore > 0.7`.
This keeps `amld` neutral about the adverse-media provider while
giving operators a clean place to materialise findings into the case
record for examiner review.
---
## 4. Velocity & Amount-Threshold Limits
Velocity and threshold rules are expressed in the rule DSL. The
starter library (`pkg/rules/library.go`) ships representative
examples:
| Rule | DSL |
|-------------------|---------------------------------------------|
| CTR threshold | `notional_usd(tx) > 10000` |
| Structuring | `tx.Notional >= 9000 && tx.Notional < 10000` |
| Velocity (24h) | `sum_last_24h(tx.UserID) > 50000` |
| Velocity (30d) | `sum_last_30d(tx.UserID) > 500000` |
| First-time large | `first_tx(tx.UserID) && tx.Notional > 25000` |
| Round-trip | `is_round_trip(tx.UserID, "24h")` |
Helper functions (`pkg/engine/helpers.go`) provide the temporal
aggregates: `sum_last_24h`, `sum_last_30d`, `count_last_24h`,
`first_tx`, `is_round_trip`, `notional_usd`. Operators add their
own helpers by registering them on the evaluator.
Each rule carries:
- `Severity`: low | medium | high | critical
- `Weight`: contribution to the aggregate risk score (0..1)
- `Action`: allow | flag | review | block | report
- `JurisdictionFilter` / `AssetClassFilter`: scope guards
- `Priority`: deterministic evaluation order
Negative weights are rejected at write time (RED-19 hardening) — a
rule cannot subtract from the score.
---
## 5. Customer Risk Tiers
There is no separate "risk-tier" table. Tiering is modelled by the
combination of two `Entity` fields:
- `KYCLevel` (int) — the verification depth completed.
- `RiskScore` (float, 0..1) — the rolling risk score from accepted
alerts.
A rule expresses a tier in DSL:
```
entity.KYCLevel < 2 && tx.Notional > 1000
entity.RiskScore > 0.6 && tx.AssetClass == "crypto"
```
The aggregate score returned by `engine.Evaluate` is the
weight-sum of matching rules, clipped to `[0, 1]`. The action taken
is the *most severe* of the matching rules' actions (`report` >
`block` > `review` > `flag` > `allow` for ordering purposes), with
ties broken by `Priority`.
---
## 6. Pre-Transaction Hooks (`bankd` and other callers)
Caller services call `amld` synchronously on the hot path. The
contract is one POST per transaction:
```
POST /v1/aml/transactions
Headers:
X-Org-Id: <tenant>
Authorization: Bearer <iam-jwt>
Body:
{
"id": "<caller-tx-id>", // optional; generated if absent
"user_id": "<entity-id>",
"account_id": "<account-id>",
"symbol": "<asset>",
"asset_class": "fiat|crypto|equity|...",
"side": "buy|sell|in|out",
"qty": 123.0,
"notional": 12300.00,
"currency": "USD",
"counterparty":"<counterparty-id>",
"ip_address": "...",
"device_fingerprint":"...",
"timestamp": "2026-05-23T12:34:56Z"
}
```
The response is the synchronous decision:
```
{
"action": "allow|flag|review|block|report",
"score": 0.42,
"alert_ids": ["..."],
"case_id": "..." // present when action ∈ {review,block,report}
}
```
### Caller contract (e.g., `bankd`)
Pseudocode at the caller:
```
res := amld.Evaluate(tx)
switch res.Action {
case "allow":
proceed()
case "flag":
proceed() // async review; do not block settlement
case "review":
park(tx, res.CaseID) // hold until case resolved
case "block":
reject(tx, res.AlertIDs) // hard stop
case "report":
proceed() // settle, but a regulatory report
// (e.g., CTR) is now owed
}
```
The caller MUST treat any non-200 response as **fail-closed** for
regulated transactions (i.e., reject), and as **fail-open** only
for explicitly designated low-risk lanes. The default posture is
fail-closed.
The hook is a single round-trip; `amld` does not call back into
the caller during evaluation. Asynchronous review happens via the
case + webhook surface described next.
---
## 7. Webhooks & Case Management
When a transaction transitions out of `allow`, two things happen:
1. A `Case` is opened (or extended) with the firing `Alert` rows
attached.
2. A signed webhook is dispatched to every subscriber registered
for the relevant event.
### Webhook contract
Subscriber config:
```go
type Webhook struct {
ID string
OrgID string
URL string
Secret string
Events []string // aml.flagged | aml.cleared | case.opened | ...
Enabled bool
}
```
Delivery is HTTP POST with:
- `Content-Type: application/json`
- `X-Webhook-Event: <event-name>`
- `X-Webhook-Signature: <hex(hmac-sha256(body, secret))>`
Body:
```
{
"event": "case.opened",
"timestamp": "2026-05-23T12:34:56Z",
"data": { ... }
}
```
Delivery is at-least-once with exponential backoff up to
`webhook.MaxRetries` attempts; persistent failures dead-letter.
Outbound URLs are validated against SSRF (RED-18 hardening) — no
loopback, no link-local, no metadata endpoints.
### Case API
The case-management surface is HTTP-only — there is no embedded
reviewer UI in `amld` proper (the dashboard is mounted by Base at
`/_/aml/`).
| Method | Path | Purpose |
|--------|----------------------------------------|-----------------------------------|
| POST | `/v1/aml/transactions` | Submit tx, get decision |
| GET | `/v1/aml/transactions/{id}/alerts` | Alerts for a tx |
| GET | `/v1/aml/cases` | List cases (filterable) |
| POST | `/v1/aml/cases/{id}/events` | Append note / file / status change |
| GET | `/v1/aml/rules` | List active rules |
| POST | `/v1/aml/rules/test` | Dry-run a rule against a sample tx |
| POST | `/v1/aml/sanctions/search` | Name search against loaded lists |
| GET | `/v1/aml/health` | Liveness |
All routes require `X-Org-Id` and an authenticated IAM session.
Data is scoped per-org; cross-org reads are not possible.
A typical reviewer flow:
1. Case opens → `case.opened` webhook fires → reviewer's
case-management UI ingests it.
2. Reviewer investigates; appends notes / files / a status change
via `POST /v1/aml/cases/{id}/events`.
3. Reviewer resolves the case (`cleared` | `sar_filed` |
`account_frozen` | `false_positive`); this drives the
`case.closed` webhook and updates the rolling `Entity.RiskScore`
for future evaluations.
---
## Operational invariants
- **Synchronous, single-round-trip evaluation.** Callers do not wait
on review; they wait only on rule evaluation.
- **Per-org isolation.** All persisted rows carry `OrgID`; queries
scope by `X-Org-Id` enforced server-side.
- **Fail-closed on rule error.** A rule that errors counts as
*unevaluated* with `EvalErr` populated; the operator-configured
default action applies (default: `review`).
- **Append-only sanctions catalog.** Lists are content-addressed by
SHA-256; refreshes never mutate prior entries in place.
- **Signed, retried webhooks.** HMAC-SHA256 over the body with the
subscriber's secret; outbound URLs SSRF-validated.
- **No vendor lock-in.** Every external data category — sanctions,
PEP, adverse-media — is consumed via a generic schema. Swapping
providers means swapping ingesters, not rewriting the engine.
+164
View File
@@ -0,0 +1,164 @@
module github.com/luxfi/aml
go 1.26.4
require (
github.com/expr-lang/expr v1.17.6
github.com/google/uuid v1.6.0
github.com/hanzoai/base v1.1.0
github.com/spf13/cobra v1.10.2
golang.org/x/text v0.37.0
)
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.7.0 // indirect
cloud.google.com/go/monitoring v1.25.0 // indirect
cloud.google.com/go/storage v1.56.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/ALTree/bigfloat v0.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/consensys/gnark-crypto v0.20.1 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/gtank/merlin v0.1.1 // indirect
github.com/gtank/ristretto255 v0.2.0 // indirect
github.com/hanzoai/dbx v1.15.0 // indirect
github.com/hanzoai/pubsub-go v1.0.0 // indirect
github.com/hanzoai/tasks v1.40.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/luxfi/accel v1.2.4 // indirect
github.com/luxfi/age v1.5.1 // indirect
github.com/luxfi/cache v1.2.1 // indirect
github.com/luxfi/compress v0.0.5 // indirect
github.com/luxfi/concurrent v0.0.3 // indirect
github.com/luxfi/consensus v1.25.17 // indirect
github.com/luxfi/corona v0.7.6 // indirect
github.com/luxfi/crypto v1.19.20 // indirect
github.com/luxfi/crypto/ipa v1.2.4 // indirect
github.com/luxfi/database v1.19.0 // indirect
github.com/luxfi/fhe v1.8.8 // indirect
github.com/luxfi/ids v1.2.15 // indirect
github.com/luxfi/lattice/v7 v7.1.4 // indirect
github.com/luxfi/lens v0.1.4 // indirect
github.com/luxfi/log v1.4.3 // indirect
github.com/luxfi/magnetar v1.2.0 // indirect
github.com/luxfi/math v1.4.1 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/mdns v0.1.1 // indirect
github.com/luxfi/metric v1.5.8 // indirect
github.com/luxfi/mock v0.1.1 // indirect
github.com/luxfi/pq v1.0.3 // indirect
github.com/luxfi/pulsar v1.1.1 // indirect
github.com/luxfi/threshold v1.9.5 // indirect
github.com/luxfi/zap v0.7.2 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.100 // indirect
github.com/montanaflynn/stats v0.9.0 // indirect
github.com/mr-tron/base58 v1.3.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/api v0.275.0 // indirect
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.1 // indirect
)
+438
View File
@@ -0,0 +1,438 @@
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
cloud.google.com/go/logging v1.14.0 h1:xpPpY8cVT6n9DgIRgrWyE+YEsGlO/994pWnbc7o5Eh4=
cloud.google.com/go/logging v1.14.0/go.mod h1:jmI+Try/fZeOTOAer3wVYOuPf9WX9PyzhlSDoBAi4HM=
cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY=
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4=
cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc=
cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI=
cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU=
cloud.google.com/go/trace v1.12.0 h1:XvWHYfr9q88cX4pZyou6qCcSagnuASyUq2ej1dB6NzQ=
cloud.google.com/go/trace v1.12.0/go.mod h1:TOYfyeoyCGsSH0ifXD6Aius24uQI9xV3RyvOdljFIyg=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/ALTree/bigfloat v0.2.0 h1:AwNzawrpFuw55/YDVlcPw0F0cmmXrmngBHhVrvdXPvM=
github.com/ALTree/bigfloat v0.2.0/go.mod h1:+NaH2gLeY6RPBPPQf4aRotPPStg+eXc8f9ZaE4vRfD4=
github.com/ChainSafe/go-schnorrkel v1.1.0 h1:rZ6EU+CZFCjB4sHUE1jIu8VDoB/wRKZxoe1tkcO71Wk=
github.com/ChainSafe/go-schnorrkel v1.1.0/go.mod h1:ABkENxiP+cvjFiByMIZ9LYbRoNNLeBLiakC1XeTFxfE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/consensys/gnark-crypto v0.20.1 h1:PXDUBvk8AzhvWowHLWBEAfUQcV1/aZgWIqD6eMpXmDg=
github.com/consensys/gnark-crypto v0.20.1/go.mod h1:RBWrSgy+IDbGR69RRV313th3M/aZU1ubk2om+qHuTSc=
github.com/cosmos/go-bip39 v0.0.0-20180819234021-555e2067c45d h1:49RLWk1j44Xu4fjHb6JFYmeUnDORVwHNkDxaQ0ctCVU=
github.com/cosmos/go-bip39 v0.0.0-20180819234021-555e2067c45d/go.mod h1:tSxLoYXyBmiFeKpvmq4dzayMdCjCnu8uqmCysIGBT2Y=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cronokirby/saferith v0.33.0 h1:TgoQlfsD4LIwx71+ChfRcIpjkw+RPOapDEVxa+LhwLo=
github.com/cronokirby/saferith v0.33.0/go.mod h1:QKJhjoqUtBsXCAVEjw38mFqoi7DebT7kthcD7UzbnoA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/rpc v1.2.1 h1:yC+LMV5esttgpVvNORL/xX4jvTTEUE30UZhZ5JF7K9k=
github.com/gorilla/rpc v1.2.1/go.mod h1:uNpOihAlF5xRFLuTYhfR0yfCTm0WTQSQttkMSptRfGk=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/gtank/merlin v0.1.1 h1:eQ90iG7K9pOhtereWsmyRJ6RAwcP4tHTDBHXNg+u5is=
github.com/gtank/merlin v0.1.1/go.mod h1:T86dnYJhcGOh5BjZFCJWTDeTK7XW8uE+E21Cy/bIQ+s=
github.com/gtank/ristretto255 v0.2.0 h1:LeOuWr6giplWkkMizx2emfG03SRPJqKt1nfIHLVHQ/0=
github.com/gtank/ristretto255 v0.2.0/go.mod h1:OJ1ox/dWcp7sJ5grYDcZ+kkHYuj5nelW5aaL7ESVXBw=
github.com/hanzoai/base v1.1.0 h1:Mi4yRgR1AFuzB0ajzJ/gEkcxno0gqrbhG2xV6gQ6IR8=
github.com/hanzoai/base v1.1.0/go.mod h1:Sw7XlHnaP+k+bLyZrXh/q/JDFnLuy3mlJIDdyO6Agbk=
github.com/hanzoai/dbx v1.15.0 h1:IjXzivPcPwYCrl1sj+UVIva2nW5QIw2XoOh1IxhMhvM=
github.com/hanzoai/dbx v1.15.0/go.mod h1:ynP6HSiDDoFZ8M3DC+XvSglBPFRygfTd/gjTWabh4yA=
github.com/hanzoai/pubsub-go v1.0.0 h1:nUZ4jK+MFlFQ5qTMri/Yffnqs/R5avI99SfZ9e3t6Cs=
github.com/hanzoai/pubsub-go v1.0.0/go.mod h1:ZHWDgSLsYHK4TiitzNwGc7Cn+oApKyMp0uK8cU7jwpg=
github.com/hanzoai/tasks v1.40.0 h1:1Y0uXHmdGsU3QSv5So7NH55DhWqw/UPV3kgFeBaaWls=
github.com/hanzoai/tasks v1.40.0/go.mod h1:VwQ9ljfO0zYW694NPr4eIdc7WBPpPIs4zC8dbrNALZ0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4=
github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c=
github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
github.com/luxfi/age v1.5.1 h1:Gj8iHMMi0lGkKT/mlXV2HVBr2m3vt2v0eKVsTMTtAQM=
github.com/luxfi/age v1.5.1/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/atomic v1.0.0 h1:xUV60MuzRvXngaQ1sM0yVC2v4TRoLlUGkkH7M9PS4yw=
github.com/luxfi/atomic v1.0.0/go.mod h1:0G2mTlQ6TXWHICUHrUUPu1/qAiIyR4gSZ2tva9ci/bI=
github.com/luxfi/cache v1.2.1 h1:kAzOS55/hmYeNKR+0HAKv4ma48Y6JjkI8UQeqdZ8bfI=
github.com/luxfi/cache v1.2.1/go.mod h1:co7JTxZZHpKT31Yh01LFp5aZOxmoUg157FhBLQdQHVU=
github.com/luxfi/compress v0.0.5 h1:4tEUHw5MK1bu5UOjfYCt4OKMiH7yykIgmGPRA/BfJTM=
github.com/luxfi/compress v0.0.5/go.mod h1:Cc1yxD2pfzrvpO32W2GDwLKff+CylHEvzZh2Ko8RSIU=
github.com/luxfi/concurrent v0.0.3 h1:eJyv1fhaC0jMLMw6+QS774cUmp7GK+ouMgvLCqnC7cc=
github.com/luxfi/concurrent v0.0.3/go.mod h1:Aj/FR5NpM0cB2P4Nt3+tz9+dV6V+LUW4HuMgSjwq5hw=
github.com/luxfi/consensus v1.25.17 h1:MjTp+7qd5ArYli+Im+Ba3v5C0iJuDcZbLiI83uT1/Bo=
github.com/luxfi/consensus v1.25.17/go.mod h1:cNhLp215lFwiOPxdj8I+6JBLz3/vDRMV2vqLigulv04=
github.com/luxfi/constants v1.5.8 h1:iNP9AWNUcM4Tps7jYnx49CwtCWAC9mYRxJfGou2za0g=
github.com/luxfi/constants v1.5.8/go.mod h1:Pu5jWHdnUtQRbWC43yTUjU/pbIIKMDOd2a2yroSfo48=
github.com/luxfi/container v0.0.4 h1:BXhF82WyfqVP5mjlNcr7tP0Fcnvl0Ap1rkiu+rq5XuM=
github.com/luxfi/container v0.0.4/go.mod h1:Z3SpmMF5d4t77MM0nHYXURpn+EMVaeu1fhbd/3BGaek=
github.com/luxfi/corona v0.7.6 h1:CJP6smygD55dL0HHkKkWryL9H24a+wXvs+L+WchK7Nc=
github.com/luxfi/corona v0.7.6/go.mod h1:4aD7+ZqnlZ2aVuU/DBQ5aspIagv5ux45LW2sJ4+siY8=
github.com/luxfi/crypto v1.19.20 h1:PjM/d3tSSWdhsMezaDg5JnFHFJfSGknBJJ+STlsmJ+0=
github.com/luxfi/crypto v1.19.20/go.mod h1:0tfz+EbAjsW1QBWB0cte9kdjB5XhhYFmCr8BkZRux48=
github.com/luxfi/crypto/ipa v1.2.4 h1:6xfwhI9/HrcDkF3Ti5/NxsNQIWbwYDJmRSNIHRQ/xfU=
github.com/luxfi/crypto/ipa v1.2.4/go.mod h1:43J6f6rcfUMrZt4cQectMOZb6Ps+fAEj8ZTPC3Kk+gE=
github.com/luxfi/database v1.19.0 h1:sySH5eS8ihJtyc+yb0LgTignGwJ9H4W9bv+nboCuIm0=
github.com/luxfi/database v1.19.0/go.mod h1:o1ccjgVKMYbVBfi+6+sDvx4swGFgGnCGFQ4gL/oJ+kY=
github.com/luxfi/fhe v1.8.8 h1:3DuWY1OvnhbMPhjQT5RPmNXUGnu8x6KaJAohVQcwUXw=
github.com/luxfi/fhe v1.8.8/go.mod h1:3EsrY0COtGmQGpab3Nb/bA67ZUeWMS6/nwepUeacjpY=
github.com/luxfi/geth v1.17.11 h1:opyVqYiH3t4qglAdMymudxKIO09O1LWB9tKGc57o9sA=
github.com/luxfi/geth v1.17.11/go.mod h1:3vQfQJd9JC+AVBjxNXa9PYQOqpbE2dKu8E3jqhPZ3LU=
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
github.com/luxfi/lens v0.1.4 h1:goGjGDXx2BNdjzXDunL5QT8elK2ZyCcc0z8TAbtWYrg=
github.com/luxfi/lens v0.1.4/go.mod h1:mL+G8IK+9L41d78/2FYRgfhEzAjcr5+VEXB8SGuHbus=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/luxfi/magnetar v1.2.0 h1:bsxHmBnJiswc/A6ElQ0pWz5g6ogqewIEKKqR26VgizA=
github.com/luxfi/magnetar v1.2.0/go.mod h1:7J9YP9jByWbwCjssMFJNUkTU8tcPlSUoVSSiYShtvFs=
github.com/luxfi/math v1.4.1 h1:1t9bCCsEqnl9yIKrShlbs80DBKyYTWdnzkVfBqEeO7Q=
github.com/luxfi/math v1.4.1/go.mod h1:QvbRxauQyE1w4lvbcLSe6c8yeJz2Zj1Bq1rayGgs2tA=
github.com/luxfi/math/big v0.1.0 h1:Vz4c0RsZVPdIKPsHPgAJChH/R3p15WHRUz7LkLf+NIQ=
github.com/luxfi/math/big v0.1.0/go.mod h1:BuxSu22RbO93xBLk5Eam5nldFponoJ73xDFz4uJ3Huk=
github.com/luxfi/mdns v0.1.1 h1:g2eRr9AXcziPkkcd24M+Qu9ApEpoKKjfI79QSNqv0rQ=
github.com/luxfi/mdns v0.1.1/go.mod h1:dbp5f3h3aE7CGzwbaWzBM9cwdcekhmSrWhQevgYhhNA=
github.com/luxfi/metric v1.5.8 h1:axPwfq+erOlIue7IJp5g+hMcMtVhYHja9gJAaT3+KNA=
github.com/luxfi/metric v1.5.8/go.mod h1:fO2giazkg4NDtr72JM/QXJBYebplAMeWC1JoZyNDvKw=
github.com/luxfi/mock v0.1.1 h1:0HEtIjg1J6CWz+IUyP6rsGqNWTcmxjFnSQIhaDuARwY=
github.com/luxfi/mock v0.1.1/go.mod h1:jo35akl3Vtd8LbzDts8VJ0jmSVycrd1/eBi6g6t5hKU=
github.com/luxfi/pq v1.0.3 h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
github.com/luxfi/pq v1.0.3/go.mod h1:8bppZcRElfrVt0n3nYCZW3iX1TvhvzNbdjNdK1irgIE=
github.com/luxfi/pulsar v1.1.1 h1:jo1jEgUsGiVxpT17Eg7Gw4Ax+07pbKFzq4NhHMLmDpI=
github.com/luxfi/pulsar v1.1.1/go.mod h1:U7tPleeAHJ9dZ61ymtstzLKKoZjxM2zFeGZ+RSjHyRw=
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs=
github.com/luxfi/threshold v1.9.5 h1:fTGiT+qCyskAjJbDXw1C3x+ZGKYJiyc3WXeawarYHng=
github.com/luxfi/threshold v1.9.5/go.mod h1:yFEqzc07h+olG8CpJ+i2FjSLxCznk/GcISRfxMuDBvE=
github.com/luxfi/utils v1.2.0 h1:gtEiI7/NM6PQ/OasEpH0PvB+e5hIS/tpum9r64pYjMc=
github.com/luxfi/utils v1.2.0/go.mod h1:T2OCKT1xG9jtKR/gyJQoSkticzrE9WFQ8eohJHGu9Fg=
github.com/luxfi/zap v0.7.2 h1:YecWTWNE5PPJXL56sLIkzS8b23bprUwZ5lPAQuLUtTE=
github.com/luxfi/zap v0.7.2/go.mod h1:1k+nwT+JW802YzuPAuf7CxMSGr/qxvbGgGwi5k6X9Ok=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM=
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b h1:QrHweqAtyJ9EwCaGHBu1fghwxIPiopAHV06JlXrMHjk=
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b/go.mod h1:xxLb2ip6sSUts3g1irPVHyk/DGslwQsNOo9I7smJfNU=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.275.0 h1:vfY5d9vFVJeWEZT65QDd9hbndr7FyZ2+6mIzGAh71NI=
google.golang.org/api v0.275.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek=
google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+313
View File
@@ -0,0 +1,313 @@
// Command notice writes the third-party notice file that ships beside a
// distributed artifact.
//
// It reads the module graph of the package being distributed — the same graph
// the linker walks — and reproduces, verbatim, each dependency module's
// license, notice, and patent-grant texts. Apache-2.0 §4(d) requires the
// attribution notices of Apache-licensed dependencies to travel with every
// distributed copy; MIT/BSD/ISC require their copyright and permission notices
// to travel the same way.
//
// The graph depends on the build configuration, so callers must describe the
// artifact they are shipping: -tags and CGO_ENABLED select which packages are
// reachable (blst is linked only under cgo), and -platforms unions over the
// GOOS/GOARCH set when one notice file serves several archives. Pointed at the
// wrong configuration this reports the wrong dependency set.
//
// Output is deterministic: modules sorted by path, files sorted by name, no
// timestamps. Byte-identical input produces a byte-identical file.
//
// CGO_ENABLED=0 go run ./internal/notice -tags embedui -pkg ./cmd/amld \
// -platforms linux/amd64,darwin/arm64 -o THIRD-PARTY-NOTICES
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
// module is the subset of `go list -json` output this command needs.
type module struct {
Path string
Version string
Dir string
Main bool
}
type pkg struct {
Module *module
}
// kinds maps a filename stem to the section heading it is reproduced under.
// One rule covers every spelling and extension a module might use: match the
// stem case-insensitively, ignore the extension.
var kinds = map[string]string{
"license": "LICENSE",
"licence": "LICENSE",
"licenses": "LICENSE",
"copying": "LICENSE",
"unlicense": "LICENSE",
"notice": "NOTICE",
"patents": "PATENT GRANT",
}
// legal reports whether name is a legal text to reproduce, and under which
// heading. Names carry a distinguishing suffix through to the heading so a
// module shipping both LICENSE-MIT and LICENSE-APACHE keeps them apart.
func legal(name string) (heading string, ok bool) {
stem := strings.ToLower(name)
if i := strings.LastIndex(stem, "."); i > 0 {
stem = stem[:i]
}
if h, ok := kinds[stem]; ok {
return h, true
}
// LICENSE-MIT, LICENSE.APACHE2, COPYING.LESSER, license_apache.
for _, sep := range []string{"-", "_", "."} {
if i := strings.Index(stem, sep); i > 0 {
if h, ok := kinds[stem[:i]]; ok {
return h + " (" + name + ")", true
}
}
}
return "", false
}
// texts returns the legal texts in a module root, keyed by heading, sorted by
// filename so the result is stable across filesystems.
func texts(dir string) (map[string]string, []string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil, err
}
names := make([]string, 0, 4)
for _, e := range entries {
if !e.Type().IsRegular() {
continue
}
if _, ok := legal(e.Name()); ok {
names = append(names, e.Name())
}
}
sort.Strings(names)
out := make(map[string]string, len(names))
headings := make([]string, 0, len(names))
for _, name := range names {
b, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return nil, nil, err
}
heading, _ := legal(name)
if _, dup := out[heading]; dup {
heading += " (" + name + ")"
}
out[heading] = strings.TrimRight(string(b), "\n") + "\n"
headings = append(headings, heading)
}
return out, headings, nil
}
// collect adds to seen every dependency module whose code is compiled into
// pkgPath for one GOOS/GOARCH. The main module is excluded: its own LICENSE
// ships separately. Standard-library packages have no module and are skipped.
func collect(seen map[string]module, tags, pkgPath, goos, goarch string) error {
args := []string{"list", "-deps", "-json"}
if tags != "" {
args = append(args, "-tags", tags)
}
args = append(args, pkgPath)
cmd := exec.Command(goBin(), args...)
cmd.Stderr = os.Stderr
if goos != "" {
cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch)
}
out, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
dec := json.NewDecoder(out)
for {
var p pkg
if err := dec.Decode(&p); err == io.EOF {
break
} else if err != nil {
return fmt.Errorf("decoding go list output: %w", err)
}
if p.Module == nil || p.Module.Main || p.Module.Version == "" {
continue
}
seen[p.Module.Path] = *p.Module
}
if err := cmd.Wait(); err != nil {
return fmt.Errorf("go %s (GOOS=%s GOARCH=%s): %w", strings.Join(args, " "), goos, goarch, err)
}
return nil
}
// modules returns the union of the dependency modules across every platform the
// artifact is released for, sorted by module path.
//
// The set is platform-dependent — darwin links ncruces/go-strftime and linux
// does not, linux links prometheus/procfs and darwin does not — and one notice
// file goes into every archive. A union over-attributes on some platforms,
// which costs nothing; describing only the host platform would omit a license
// from the archives built for the others, which is the whole failure being
// fixed here.
func modules(tags, pkgPath string, platforms []string) ([]module, error) {
seen := map[string]module{}
if len(platforms) == 0 {
if err := collect(seen, tags, pkgPath, "", ""); err != nil {
return nil, err
}
}
for _, p := range platforms {
goos, goarch, ok := strings.Cut(p, "/")
if !ok {
return nil, fmt.Errorf("platform %q is not GOOS/GOARCH", p)
}
if err := collect(seen, tags, pkgPath, goos, goarch); err != nil {
return nil, err
}
}
mods := make([]module, 0, len(seen))
for _, m := range seen {
mods = append(mods, m)
}
sort.Slice(mods, func(i, j int) bool { return mods[i].Path < mods[j].Path })
return mods, nil
}
// goBin resolves the toolchain that is running this command, so the generated
// file describes the graph the build will actually use.
func goBin() string {
if exe := os.Getenv("GOEXE_PATH"); exe != "" {
return exe
}
if root := os.Getenv("GOROOT"); root != "" {
if exe := filepath.Join(root, "bin", "go"); fileExists(exe) {
return exe
}
}
return "go"
}
func fileExists(p string) bool {
fi, err := os.Stat(p)
return err == nil && fi.Mode().IsRegular()
}
const rule = "--------------------------------------------------------------------------------"
func render(w io.Writer, artifact string, mods []module, platforms []string) error {
var bare []module
body := &strings.Builder{}
for _, m := range mods {
if m.Dir == "" {
bare = append(bare, m)
continue
}
byHeading, headings, err := texts(m.Dir)
if err != nil {
return fmt.Errorf("%s@%s: %w", m.Path, m.Version, err)
}
if len(headings) == 0 {
bare = append(bare, m)
continue
}
fmt.Fprintf(body, "\n%s\n%s %s\n%s\n", rule, m.Path, m.Version, rule)
for _, h := range headings {
fmt.Fprintf(body, "\n---- %s ----\n\n%s", h, byHeading[h])
}
}
fmt.Fprintf(w, "Third-party notices for %s\n", artifact)
fmt.Fprintf(w, "%s\n\n", strings.Repeat("=", 24+len(artifact)))
scope := "the platform it was generated on"
if len(platforms) > 0 {
scope = strings.Join(platforms, ", ")
}
fmt.Fprintf(w, `%s links the modules listed below. Their license, notice, and patent-grant
texts are reproduced verbatim in the sections that follow. This file is
generated from the build's own module graph by `+"`go run ./internal/notice`"+`;
it covers third-party code only. The license of %s itself is in LICENSE.
The module set is platform-dependent, so this is the union over every platform
%s is released for: %s. A module listed here may
therefore not be linked into the particular binary beside this file.
Modules: %d
`, artifact, artifact, artifact, scope, len(mods))
for _, m := range mods {
fmt.Fprintf(w, " %s %s\n", m.Path, m.Version)
}
if _, err := io.WriteString(w, body.String()); err != nil {
return err
}
if len(bare) > 0 {
fmt.Fprintf(w, "\n%s\nModules with no license text in the module root\n%s\n\n", rule, rule)
io.WriteString(w, "No license, notice, or patent file was found at the root of these\nmodules, so there is no text to reproduce for them:\n\n")
for _, m := range bare {
fmt.Fprintf(w, " %s %s\n", m.Path, m.Version)
}
}
return nil
}
func main() {
tags := flag.String("tags", "", "build tags for the distributed artifact")
pkgPath := flag.String("pkg", "./...", "package whose module graph is described")
out := flag.String("o", "THIRD-PARTY-NOTICES", "output file")
name := flag.String("name", "", "artifact name in the header (default: base of -pkg)")
plat := flag.String("platforms", "", "comma-separated GOOS/GOARCH list to union over (default: host)")
flag.Parse()
artifact := *name
if artifact == "" {
artifact = filepath.Base(*pkgPath)
}
var platforms []string
if *plat != "" {
platforms = strings.Split(*plat, ",")
}
mods, err := modules(*tags, *pkgPath, platforms)
if err != nil {
fmt.Fprintln(os.Stderr, "notice:", err)
os.Exit(1)
}
if len(mods) == 0 {
fmt.Fprintln(os.Stderr, "notice: no dependency modules found — refusing to write an empty notice file")
os.Exit(1)
}
buf := &strings.Builder{}
if err := render(buf, artifact, mods, platforms); err != nil {
fmt.Fprintln(os.Stderr, "notice:", err)
os.Exit(1)
}
if err := os.WriteFile(*out, []byte(buf.String()), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "notice:", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "notice: %s — %d modules across %d platform(s)\n", *out, len(mods), max(len(platforms), 1))
}
+166
View File
@@ -0,0 +1,166 @@
-- AML core collections for Hanzo Base.
-- Base auto-manages id, created, updated on every record.
-- These are the 9 canonical collections.
-- 1. Transactions — incoming transaction events
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
tenant_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL DEFAULT '',
account_id TEXT NOT NULL DEFAULT '',
symbol TEXT NOT NULL DEFAULT '',
asset_class TEXT NOT NULL DEFAULT '',
side TEXT NOT NULL DEFAULT '',
qty REAL NOT NULL DEFAULT 0,
notional REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'USD',
counterparty TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
device_fingerprint TEXT NOT NULL DEFAULT '',
timestamp TEXT NOT NULL DEFAULT '',
raw JSON DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_tx_org ON transactions(org_id);
CREATE INDEX IF NOT EXISTS idx_tx_user ON transactions(user_id);
CREATE INDEX IF NOT EXISTS idx_tx_timestamp ON transactions(timestamp);
-- 2. Entities — normalized actors
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
entity_type TEXT NOT NULL DEFAULT 'user',
external_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
jurisdiction TEXT NOT NULL DEFAULT '',
kyc_level INTEGER NOT NULL DEFAULT 0,
pep INTEGER NOT NULL DEFAULT 0,
sanctions_flag INTEGER NOT NULL DEFAULT 0,
risk_score REAL NOT NULL DEFAULT 0,
first_seen TEXT NOT NULL DEFAULT '',
last_seen TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_entity_org ON entities(org_id);
CREATE INDEX IF NOT EXISTS idx_entity_external ON entities(external_id);
-- 3. Rules — evaluation rules
CREATE TABLE IF NOT EXISTS rules (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
dsl TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'medium',
weight REAL NOT NULL DEFAULT 0.1,
action TEXT NOT NULL DEFAULT 'flag',
enabled INTEGER NOT NULL DEFAULT 1,
jurisdiction_filter JSON DEFAULT '[]',
asset_class_filter JSON DEFAULT '[]',
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_rules_org ON rules(org_id);
-- 4. Alerts — rule hits
CREATE TABLE IF NOT EXISTS alerts (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
tx_id TEXT NOT NULL DEFAULT '',
rule_id TEXT NOT NULL DEFAULT '',
rule_name TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'medium',
score REAL NOT NULL DEFAULT 0,
score_breakdown JSON DEFAULT '{}',
action_taken TEXT NOT NULL DEFAULT 'flag',
reviewed_by TEXT NOT NULL DEFAULT '',
reviewed_at TEXT NOT NULL DEFAULT '',
decision TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_alerts_org ON alerts(org_id);
CREATE INDEX IF NOT EXISTS idx_alerts_tx ON alerts(tx_id);
CREATE INDEX IF NOT EXISTS idx_alerts_rule ON alerts(rule_id);
-- 5. Cases — human review cases
CREATE TABLE IF NOT EXISTS cases (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
number INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
severity TEXT NOT NULL DEFAULT 'medium',
entity_ids JSON DEFAULT '[]',
alert_ids JSON DEFAULT '[]',
assignee_id TEXT NOT NULL DEFAULT '',
opened_at TEXT NOT NULL DEFAULT '',
closed_at TEXT NOT NULL DEFAULT '',
resolution TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_cases_org ON cases(org_id);
CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status);
-- 6. Case events — case timeline
CREATE TABLE IF NOT EXISTS case_events (
id TEXT PRIMARY KEY,
case_id TEXT NOT NULL DEFAULT '',
author_id TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'note',
body TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_case_events_case ON case_events(case_id);
-- 7. Sanctions lists — list metadata
CREATE TABLE IF NOT EXISTS sanctions_lists (
id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
format TEXT NOT NULL DEFAULT 'xml',
fetched_at TEXT NOT NULL DEFAULT '',
sha256 TEXT NOT NULL DEFAULT '',
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
-- 8. Sanctions entries — flattened entries
CREATE TABLE IF NOT EXISTS sanctions_entries (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL DEFAULT '',
ref_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
aliases JSON DEFAULT '[]',
dob TEXT NOT NULL DEFAULT '',
nationality TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'individual',
raw JSON DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_sanctions_list ON sanctions_entries(list_id);
CREATE INDEX IF NOT EXISTS idx_sanctions_name ON sanctions_entries(name);
-- 9. Webhooks — subscribers
CREATE TABLE IF NOT EXISTS webhooks (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
secret TEXT NOT NULL DEFAULT '',
events JSON DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
last_delivery_at TEXT NOT NULL DEFAULT '',
failure_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_webhooks_org ON webhooks(org_id);
+357
View File
@@ -0,0 +1,357 @@
// Package api registers AML HTTP routes on a Base app.
// All endpoints enforce X-Org-Id and require IAM auth.
package api
import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/google/uuid"
"github.com/hanzoai/base/core"
"github.com/luxfi/aml/pkg/cases"
"github.com/luxfi/aml/pkg/engine"
"github.com/luxfi/aml/pkg/sanctions"
"github.com/luxfi/aml/pkg/types"
)
// Handler wires AML engine + case store + sanctions to HTTP routes.
type Handler struct {
Engine *engine.Engine
Cases *cases.Store
Alerts *AlertStore
Sanctions *SanctionsStore
}
// DefaultMaxAlerts is the default maximum number of transaction IDs in the alert store.
const DefaultMaxAlerts = 100_000
// AlertStore is a thread-safe in-memory alert store with LRU eviction.
// RED-02: sync.RWMutex for data-race safety.
// RED-08: LRU eviction prevents unbounded memory growth.
type AlertStore struct {
mu sync.RWMutex
alerts map[string][]types.Alert // keyed by tx_id
order []string // insertion order for LRU eviction
maxItems int
}
// NewAlertStore creates an alert store with the default max capacity.
func NewAlertStore() *AlertStore {
return NewAlertStoreWithMax(DefaultMaxAlerts)
}
// NewAlertStoreWithMax creates an alert store with the specified max capacity.
func NewAlertStoreWithMax(maxItems int) *AlertStore {
if maxItems <= 0 {
maxItems = DefaultMaxAlerts
}
return &AlertStore{
alerts: make(map[string][]types.Alert),
maxItems: maxItems,
}
}
// Add stores alerts for a transaction. Evicts oldest 10% when capacity is exceeded.
func (s *AlertStore) Add(txID string, alerts []types.Alert) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.alerts[txID]; !exists {
s.order = append(s.order, txID)
}
s.alerts[txID] = append(s.alerts[txID], alerts...)
if len(s.order) > s.maxItems {
evictCount := s.maxItems / 10
if evictCount < 1 {
evictCount = 1
}
for i := 0; i < evictCount && i < len(s.order); i++ {
delete(s.alerts, s.order[i])
}
s.order = s.order[evictCount:]
}
}
// ByTx returns alerts for a transaction.
func (s *AlertStore) ByTx(txID string) []types.Alert {
s.mu.RLock()
defer s.mu.RUnlock()
return s.alerts[txID]
}
// Len returns the number of transaction IDs in the store.
func (s *AlertStore) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.alerts)
}
// SanctionsStore is a minimal in-memory sanctions entry store.
type SanctionsStore struct {
mu sync.RWMutex
entries []types.SanctionsEntry
}
// NewSanctionsStore creates an empty sanctions store.
func NewSanctionsStore() *SanctionsStore {
return &SanctionsStore{}
}
// Load replaces all entries.
func (s *SanctionsStore) Load(entries []types.SanctionsEntry) {
s.mu.Lock()
defer s.mu.Unlock()
s.entries = entries
}
// Search finds entries matching the given name above the threshold.
func (s *SanctionsStore) Search(name string, threshold float64) []sanctionSearchResult {
s.mu.RLock()
defer s.mu.RUnlock()
var results []sanctionSearchResult
for _, e := range s.entries {
score := sanctions.TokenMatch(name, e.Name)
if score >= threshold {
results = append(results, sanctionSearchResult{
Entry: e,
Score: score,
})
continue
}
// Check aliases.
for _, alias := range e.Aliases {
aliasScore := sanctions.TokenMatch(name, alias)
if aliasScore >= threshold && aliasScore > score {
score = aliasScore
results = append(results, sanctionSearchResult{
Entry: e,
Score: score,
})
break
}
}
}
return results
}
type sanctionSearchResult struct {
Entry types.SanctionsEntry
Score float64
}
// Register adds AML routes to a ServeEvent router.
// Called from within an OnServe hook.
func (h *Handler) Register(se *core.ServeEvent) {
se.Router.POST("/v1/aml/transactions", h.ingestTransaction())
se.Router.GET("/v1/aml/transactions/{id}/alerts", h.getAlerts())
se.Router.GET("/v1/aml/cases", h.listCases())
se.Router.POST("/v1/aml/cases/{id}/events", h.addCaseEvent())
se.Router.GET("/v1/aml/rules", h.listRules())
se.Router.POST("/v1/aml/rules/test", h.testRule())
se.Router.POST("/v1/aml/sanctions/search", h.searchSanctions())
se.Router.GET("/v1/aml/health", h.health())
}
func (h *Handler) health() func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
}
func (h *Handler) ingestTransaction() func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
orgID := e.Request.Header.Get("X-Org-Id")
if orgID == "" {
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "missing X-Org-Id"})
}
var tx types.Transaction
if err := json.NewDecoder(e.Request.Body).Decode(&tx); err != nil {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if tx.ID == "" {
tx.ID = uuid.NewString()
}
tx.OrgID = orgID
now := time.Now().UTC()
tx.CreatedAt = now
tx.UpdatedAt = now
if tx.Timestamp.IsZero() {
tx.Timestamp = now
}
// Resolve entity — for now use a minimal entity from the tx.
entity := types.Entity{
ID: tx.UserID,
OrgID: orgID,
Name: tx.UserID,
EntityType: types.EntityUser,
}
alerts, score, action := h.Engine.Evaluate(tx, entity)
alertIDs := make([]string, len(alerts))
for i, a := range alerts {
alertIDs[i] = a.ID
}
h.Alerts.Add(tx.ID, alerts)
result := types.EvalResult{
Action: action,
Score: score,
AlertIDs: alertIDs,
}
// On critical: auto-create case.
if action == types.ActionBlock || action == types.ActionReport {
c := h.Cases.Create(orgID, types.SeverityCritical, alertIDs, []string{tx.UserID})
result.CaseID = c.ID
}
return e.JSON(http.StatusOK, result)
}
}
func (h *Handler) getAlerts() func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
txID := e.Request.PathValue("id")
alerts := h.Alerts.ByTx(txID)
if alerts == nil {
alerts = []types.Alert{}
}
return e.JSON(http.StatusOK, alerts)
}
}
func (h *Handler) listCases() func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
orgID := e.Request.Header.Get("X-Org-Id")
status := e.Request.URL.Query().Get("status")
result := h.Cases.List(orgID, status)
if result == nil {
result = []*types.Case{}
}
return e.JSON(http.StatusOK, result)
}
}
func (h *Handler) addCaseEvent() func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
caseID := e.Request.PathValue("id")
var evt types.CaseEvent
if err := json.NewDecoder(e.Request.Body).Decode(&evt); err != nil {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if err := h.Cases.AddEvent(caseID, evt); err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusCreated, map[string]string{"status": "ok"})
}
}
func (h *Handler) listRules() func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, h.Engine.Rules())
}
}
func (h *Handler) searchSanctions() func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
var req struct {
Name string `json:"name"`
DOB string `json:"dob"`
}
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if req.Name == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
}
if h.Sanctions == nil {
return e.JSON(http.StatusOK, []interface{}{})
}
results := h.Sanctions.Search(req.Name, sanctions.MatchThreshold)
type entry struct {
ID string `json:"id"`
ListID string `json:"list_id"`
Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"`
DOB string `json:"dob,omitempty"`
Nationality string `json:"nationality,omitempty"`
Type string `json:"type"`
Score float64 `json:"score"`
}
out := make([]entry, 0, len(results))
for _, r := range results {
out = append(out, entry{
ID: r.Entry.ID,
ListID: r.Entry.ListID,
Name: r.Entry.Name,
Aliases: r.Entry.Aliases,
DOB: r.Entry.DOB,
Nationality: r.Entry.Nationality,
Type: r.Entry.Type,
Score: r.Score,
})
}
return e.JSON(http.StatusOK, out)
}
}
// maxDSLLength is the maximum allowed DSL expression length (RED-15).
const maxDSLLength = 2048
func (h *Handler) testRule() func(e *core.RequestEvent) error {
return func(e *core.RequestEvent) error {
var req struct {
DSL string `json:"dsl"`
Tx types.Transaction `json:"tx"`
Entity types.Entity `json:"entity"`
}
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
// RED-15: Reject oversized DSL to prevent DoS via computation bombs.
if len(req.DSL) > maxDSLLength {
return e.JSON(http.StatusBadRequest, map[string]string{
"error": "DSL expression exceeds maximum length of 2048 bytes",
})
}
testRule := types.Rule{
ID: "test",
Name: "test",
DSL: req.DSL,
Enabled: true,
}
match, err := h.Engine.Evaluator().Eval(testRule, types.EvalContext{
Tx: req.Tx,
Entity: req.Entity,
})
if err != nil {
return e.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]interface{}{
"match": match,
"dsl": req.DSL,
})
}
}
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"fmt"
"testing"
"github.com/luxfi/aml/pkg/types"
)
// TestAlertStoreEviction (RED-08) verifies that the alert store evicts oldest
// entries when capacity is exceeded.
func TestAlertStoreEviction(t *testing.T) {
store := NewAlertStoreWithMax(100)
// Add 200 items — store should evict down to ~90 (evicts 10% = 10 entries per trigger).
for i := 0; i < 200; i++ {
txID := fmt.Sprintf("tx-%d", i)
store.Add(txID, []types.Alert{{ID: fmt.Sprintf("alert-%d", i)}})
}
size := store.Len()
if size > 100 {
t.Errorf("store size after eviction = %d, want <= 100", size)
}
// Oldest entries should be evicted.
if alerts := store.ByTx("tx-0"); len(alerts) > 0 {
t.Error("tx-0 should have been evicted")
}
// Most recent entries should still exist.
if alerts := store.ByTx("tx-199"); len(alerts) == 0 {
t.Error("tx-199 should still be present")
}
}
// TestAlertStoreNoEvictionUnderCapacity (RED-08) verifies no eviction happens
// when store is under capacity.
func TestAlertStoreNoEvictionUnderCapacity(t *testing.T) {
store := NewAlertStoreWithMax(200)
for i := 0; i < 50; i++ {
store.Add(fmt.Sprintf("tx-%d", i), []types.Alert{{ID: fmt.Sprintf("a-%d", i)}})
}
if store.Len() != 50 {
t.Errorf("store size = %d, want 50", store.Len())
}
// All entries should be present.
for i := 0; i < 50; i++ {
if alerts := store.ByTx(fmt.Sprintf("tx-%d", i)); len(alerts) == 0 {
t.Errorf("tx-%d should be present", i)
}
}
}
// TestAlertStoreDuplicateTxID verifies that adding to an existing tx_id
// does not double-count in the order list.
func TestAlertStoreDuplicateTxID(t *testing.T) {
store := NewAlertStoreWithMax(100)
store.Add("tx-1", []types.Alert{{ID: "a1"}})
store.Add("tx-1", []types.Alert{{ID: "a2"}})
if store.Len() != 1 {
t.Errorf("store size = %d, want 1 (same tx_id)", store.Len())
}
alerts := store.ByTx("tx-1")
if len(alerts) != 2 {
t.Errorf("alert count = %d, want 2", len(alerts))
}
}
+232
View File
@@ -0,0 +1,232 @@
// Package cases provides case management — creating, updating, and
// querying compliance review cases and their event timelines.
package cases
import (
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/luxfi/aml/pkg/types"
)
// DefaultClosedCaseRetention is how long closed cases stay in memory.
const DefaultClosedCaseRetention = 90 * 24 * time.Hour // 90 days
// DefaultMaxCases is the maximum open+recent cases held in memory.
const DefaultMaxCases = 100_000
// Store is an in-memory case store with eviction of old closed cases.
// RED-08: Prevents unbounded memory growth by evicting closed cases
// older than the retention period, and hard-capping total count.
type Store struct {
mu sync.RWMutex
cases map[string]*types.Case
events map[string][]types.CaseEvent
seq atomic.Int64
maxCases int
retention time.Duration
}
// NewStore creates an empty case store with default limits.
func NewStore() *Store {
return &Store{
cases: make(map[string]*types.Case),
events: make(map[string][]types.CaseEvent),
maxCases: DefaultMaxCases,
retention: DefaultClosedCaseRetention,
}
}
// NewStoreWithLimits creates a case store with explicit limits.
func NewStoreWithLimits(maxCases int, retention time.Duration) *Store {
if maxCases <= 0 {
maxCases = DefaultMaxCases
}
if retention <= 0 {
retention = DefaultClosedCaseRetention
}
return &Store{
cases: make(map[string]*types.Case),
events: make(map[string][]types.CaseEvent),
maxCases: maxCases,
retention: retention,
}
}
// EvictExpired removes closed cases older than the retention period.
// Called internally after Create; can also be called externally by a cron.
func (s *Store) EvictExpired() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.evictExpiredLocked()
}
func (s *Store) evictExpiredLocked() int {
cutoff := time.Now().UTC().Add(-s.retention)
evicted := 0
for id, c := range s.cases {
if c.Status == types.CaseClosed && c.ClosedAt != nil && c.ClosedAt.Before(cutoff) {
delete(s.cases, id)
delete(s.events, id)
evicted++
}
}
return evicted
}
// Len returns the number of cases in the store.
func (s *Store) Len() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.cases)
}
// Create opens a new case from a set of alerts.
func (s *Store) Create(orgID string, severity string, alertIDs []string, entityIDs []string) *types.Case {
now := time.Now().UTC()
c := &types.Case{
ID: uuid.NewString(),
OrgID: orgID,
Number: s.seq.Add(1),
Status: types.CaseOpen,
Severity: severity,
AlertIDs: alertIDs,
EntityIDs: entityIDs,
OpenedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
s.mu.Lock()
s.cases[c.ID] = c
// RED-08: Evict expired closed cases when approaching capacity.
if len(s.cases) > s.maxCases {
s.evictExpiredLocked()
}
s.mu.Unlock()
return c
}
// Get returns a case by ID.
func (s *Store) Get(id string) *types.Case {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cases[id]
}
// List returns cases for an org, optionally filtered by status.
func (s *Store) List(orgID, status string) []*types.Case {
s.mu.RLock()
defer s.mu.RUnlock()
var out []*types.Case
for _, c := range s.cases {
if c.OrgID != orgID {
continue
}
if status != "" && c.Status != status {
continue
}
out = append(out, c)
}
return out
}
// UpdateStatus changes a case's status and records the event.
func (s *Store) UpdateStatus(caseID, status, authorID string) error {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.cases[caseID]
if !ok {
return ErrNotFound
}
oldStatus := c.Status
c.Status = status
now := time.Now().UTC()
c.UpdatedAt = now
if status == types.CaseClosed {
c.ClosedAt = &now
}
s.events[caseID] = append(s.events[caseID], types.CaseEvent{
ID: uuid.NewString(),
CaseID: caseID,
AuthorID: authorID,
Kind: types.EventStatusChange,
Body: oldStatus + " -> " + status,
CreatedAt: now,
})
return nil
}
// AddEvent appends a note, file, or other event to a case timeline.
func (s *Store) AddEvent(caseID string, evt types.CaseEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.cases[caseID]; !ok {
return ErrNotFound
}
evt.ID = uuid.NewString()
evt.CaseID = caseID
evt.CreatedAt = time.Now().UTC()
s.events[caseID] = append(s.events[caseID], evt)
return nil
}
// Events returns the timeline for a case.
func (s *Store) Events(caseID string) []types.CaseEvent {
s.mu.RLock()
defer s.mu.RUnlock()
return s.events[caseID]
}
// Assign sets the assignee for a case.
func (s *Store) Assign(caseID, assigneeID string) error {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.cases[caseID]
if !ok {
return ErrNotFound
}
c.AssigneeID = assigneeID
c.UpdatedAt = time.Now().UTC()
return nil
}
// Resolve closes a case with a resolution.
func (s *Store) Resolve(caseID, resolution, authorID string) error {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.cases[caseID]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
c.Status = types.CaseClosed
c.Resolution = resolution
c.ClosedAt = &now
c.UpdatedAt = now
s.events[caseID] = append(s.events[caseID], types.CaseEvent{
ID: uuid.NewString(),
CaseID: caseID,
AuthorID: authorID,
Kind: types.EventStatusChange,
Body: "closed: " + resolution,
CreatedAt: now,
})
return nil
}
+231
View File
@@ -0,0 +1,231 @@
package cases
import (
"testing"
"time"
"github.com/luxfi/aml/pkg/types"
)
func TestCreateCase(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityHigh, []string{"a1", "a2"}, []string{"e1"})
if c.ID == "" {
t.Fatal("case ID should not be empty")
}
if c.OrgID != "org1" {
t.Errorf("orgID = %s, want org1", c.OrgID)
}
if c.Status != types.CaseOpen {
t.Errorf("status = %s, want open", c.Status)
}
if c.Number != 1 {
t.Errorf("number = %d, want 1", c.Number)
}
if len(c.AlertIDs) != 2 {
t.Errorf("alertIDs len = %d, want 2", len(c.AlertIDs))
}
}
func TestGetCase(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityMedium, nil, nil)
got := s.Get(c.ID)
if got == nil {
t.Fatal("Get returned nil")
}
if got.ID != c.ID {
t.Errorf("ID mismatch: %s vs %s", got.ID, c.ID)
}
if s.Get("nonexistent") != nil {
t.Error("Get should return nil for missing case")
}
}
func TestListCases(t *testing.T) {
s := NewStore()
s.Create("org1", types.SeverityLow, nil, nil)
s.Create("org1", types.SeverityHigh, nil, nil)
s.Create("org2", types.SeverityMedium, nil, nil)
all := s.List("org1", "")
if len(all) != 2 {
t.Errorf("list org1 all: got %d, want 2", len(all))
}
open := s.List("org1", types.CaseOpen)
if len(open) != 2 {
t.Errorf("list org1 open: got %d, want 2", len(open))
}
closed := s.List("org1", types.CaseClosed)
if len(closed) != 0 {
t.Errorf("list org1 closed: got %d, want 0", len(closed))
}
}
func TestUpdateStatus(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityHigh, nil, nil)
err := s.UpdateStatus(c.ID, types.CaseInReview, "user1")
if err != nil {
t.Fatalf("UpdateStatus error: %v", err)
}
got := s.Get(c.ID)
if got.Status != types.CaseInReview {
t.Errorf("status = %s, want in_review", got.Status)
}
events := s.Events(c.ID)
if len(events) != 1 {
t.Fatalf("events len = %d, want 1", len(events))
}
if events[0].Kind != types.EventStatusChange {
t.Errorf("event kind = %s, want status_change", events[0].Kind)
}
}
func TestUpdateStatusNotFound(t *testing.T) {
s := NewStore()
err := s.UpdateStatus("bad-id", types.CaseClosed, "user1")
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestAddEvent(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityLow, nil, nil)
err := s.AddEvent(c.ID, types.CaseEvent{
AuthorID: "user1",
Kind: types.EventNote,
Body: "test note",
})
if err != nil {
t.Fatalf("AddEvent error: %v", err)
}
events := s.Events(c.ID)
if len(events) != 1 {
t.Fatalf("events len = %d, want 1", len(events))
}
if events[0].Body != "test note" {
t.Errorf("event body = %q, want 'test note'", events[0].Body)
}
}
func TestAddEventNotFound(t *testing.T) {
s := NewStore()
err := s.AddEvent("bad-id", types.CaseEvent{})
if err != ErrNotFound {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestAssign(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityMedium, nil, nil)
err := s.Assign(c.ID, "reviewer1")
if err != nil {
t.Fatalf("Assign error: %v", err)
}
got := s.Get(c.ID)
if got.AssigneeID != "reviewer1" {
t.Errorf("assignee = %s, want reviewer1", got.AssigneeID)
}
}
func TestResolve(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityHigh, nil, nil)
err := s.Resolve(c.ID, types.ResolutionSARFiled, "user1")
if err != nil {
t.Fatalf("Resolve error: %v", err)
}
got := s.Get(c.ID)
if got.Status != types.CaseClosed {
t.Errorf("status = %s, want closed", got.Status)
}
if got.Resolution != types.ResolutionSARFiled {
t.Errorf("resolution = %s, want sar_filed", got.Resolution)
}
if got.ClosedAt == nil {
t.Error("closed_at should be set")
}
}
func TestSequenceIncrement(t *testing.T) {
s := NewStore()
c1 := s.Create("org1", types.SeverityLow, nil, nil)
c2 := s.Create("org1", types.SeverityLow, nil, nil)
c3 := s.Create("org1", types.SeverityLow, nil, nil)
if c1.Number != 1 || c2.Number != 2 || c3.Number != 3 {
t.Errorf("sequence: %d, %d, %d — want 1, 2, 3", c1.Number, c2.Number, c3.Number)
}
}
// TestCaseStoreEviction (RED-08) verifies that closed cases older than the
// retention period are evicted when the store exceeds capacity.
func TestCaseStoreEviction(t *testing.T) {
retention := 1 * time.Millisecond // very short for test
s := NewStoreWithLimits(10, retention)
// Create and immediately close 15 cases.
for i := 0; i < 15; i++ {
c := s.Create("org1", types.SeverityLow, nil, nil)
s.Resolve(c.ID, types.ResolutionCleared, "system")
}
// Wait for closed cases to become stale.
time.Sleep(5 * time.Millisecond)
// Create one more to trigger eviction.
s.Create("org1", types.SeverityHigh, nil, nil)
if s.Len() > 10 {
t.Errorf("store size after eviction = %d, want <= 10", s.Len())
}
}
// TestCaseStoreEvictExpiredDirect (RED-08) tests the EvictExpired method directly.
func TestCaseStoreEvictExpiredDirect(t *testing.T) {
retention := 1 * time.Millisecond
s := NewStoreWithLimits(1000, retention)
// Create 5 cases, close 3 of them.
var openIDs []string
for i := 0; i < 5; i++ {
c := s.Create("org1", types.SeverityLow, nil, nil)
if i < 3 {
s.Resolve(c.ID, types.ResolutionCleared, "system")
} else {
openIDs = append(openIDs, c.ID)
}
}
time.Sleep(5 * time.Millisecond)
evicted := s.EvictExpired()
if evicted != 3 {
t.Errorf("evicted = %d, want 3", evicted)
}
if s.Len() != 2 {
t.Errorf("store size = %d, want 2 (open cases)", s.Len())
}
for _, id := range openIDs {
if s.Get(id) == nil {
t.Errorf("open case %s should not be evicted", id)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
package cases
import "errors"
// ErrNotFound is returned when a case is not found.
var ErrNotFound = errors.New("case not found")
+135
View File
@@ -0,0 +1,135 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cases
import (
"fmt"
"strings"
"time"
"github.com/luxfi/aml/pkg/types"
)
// FilingType constants for FinCEN SAR.
const (
FilingInitial = "initial"
FilingContinuing = "continuing"
FilingCorrection = "correction"
)
// SARDraft is a structured SAR (Suspicious Activity Report) draft.
// An analyst reviews and submits this — it is never auto-filed.
type SARDraft struct {
// Filing metadata
FilingType string `json:"filing_type"`
FiledBy string `json:"filed_by,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
// Subject information (from entity)
SubjectName string `json:"subject_name"`
SubjectType string `json:"subject_type"` // individual, entity
SubjectIDType string `json:"subject_id_type,omitempty"`
SubjectIDNumber string `json:"subject_id_number,omitempty"`
SubjectAddress string `json:"subject_address,omitempty"`
SubjectDOB string `json:"subject_dob,omitempty"`
SubjectSSN string `json:"subject_ssn,omitempty"` // last 4 only
SubjectOccupation string `json:"subject_occupation,omitempty"`
// Suspicious activity details
ActivityType []string `json:"activity_type"`
InstrumentType string `json:"instrument_type,omitempty"`
Amount float64 `json:"amount"`
Currency string `json:"currency"`
DateRangeStart time.Time `json:"date_range_start"`
DateRangeEnd time.Time `json:"date_range_end"`
TransactionCount int `json:"transaction_count"`
// Narrative (the core of the SAR — must be human-reviewed)
Narrative string `json:"narrative"`
// Case reference
CaseID string `json:"case_id"`
AlertIDs []string `json:"alert_ids"`
}
// GenerateSARDraft creates a SAR draft from a case, its alerts, and the entity.
func GenerateSARDraft(c *types.Case, alerts []types.Alert, entity types.Entity) *SARDraft {
draft := &SARDraft{
FilingType: FilingInitial,
GeneratedAt: time.Now().UTC(),
CaseID: c.ID,
AlertIDs: c.AlertIDs,
SubjectName: entity.Name,
SubjectType: entity.EntityType,
Currency: "USD",
}
// Compute amount and date range from alerts.
var totalAmount float64
var earliest, latest time.Time
activityTypes := make(map[string]bool)
for _, a := range alerts {
totalAmount += a.Score * 10000 // Rough estimate from score
activityTypes[a.RuleName] = true
if earliest.IsZero() || a.CreatedAt.Before(earliest) {
earliest = a.CreatedAt
}
if a.CreatedAt.After(latest) {
latest = a.CreatedAt
}
}
draft.Amount = totalAmount
draft.DateRangeStart = earliest
draft.DateRangeEnd = latest
draft.TransactionCount = len(alerts)
types_ := make([]string, 0, len(activityTypes))
for t := range activityTypes {
types_ = append(types_, t)
}
draft.ActivityType = types_
draft.Narrative = generateNarrative(c, alerts, entity)
return draft
}
// generateNarrative creates the SAR narrative text from case + alerts + entity.
func generateNarrative(c *types.Case, alerts []types.Alert, entity types.Entity) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("This report is filed regarding suspicious activity involving %s ", entity.Name))
b.WriteString(fmt.Sprintf("(entity type: %s", entity.EntityType))
if entity.Jurisdiction != "" {
b.WriteString(fmt.Sprintf(", jurisdiction: %s", entity.Jurisdiction))
}
b.WriteString(").\n\n")
b.WriteString(fmt.Sprintf("Case #%d was opened on %s with severity %s. ",
c.Number, c.OpenedAt.Format("2006-01-02"), c.Severity))
b.WriteString(fmt.Sprintf("%d alert(s) triggered:\n\n", len(alerts)))
for i, a := range alerts {
b.WriteString(fmt.Sprintf(" %d. %s (severity: %s, action: %s, score: %.2f)\n",
i+1, a.RuleName, a.Severity, a.ActionTaken, a.Score))
}
b.WriteString("\n")
if entity.PEP {
b.WriteString("NOTE: Subject is flagged as a Politically Exposed Person (PEP).\n")
}
if entity.SanctionsFlag {
b.WriteString("NOTE: Subject has a sanctions list match.\n")
}
b.WriteString("\n[ANALYST: Review and supplement this narrative before filing.]")
return b.String()
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package cases
import (
"strings"
"testing"
"time"
"github.com/luxfi/aml/pkg/types"
)
func TestGenerateSARDraft_Basic(t *testing.T) {
now := time.Now().UTC()
c := &types.Case{
ID: "case-001",
OrgID: "org-test",
Number: 42,
Status: types.CaseOpen,
Severity: types.SeverityHigh,
AlertIDs: []string{"alert-1", "alert-2"},
OpenedAt: now,
}
alerts := []types.Alert{
{
ID: "alert-1",
RuleName: "CTR Threshold",
Severity: types.SeverityHigh,
ActionTaken: types.ActionReport,
Score: 0.3,
CreatedAt: now.Add(-2 * time.Hour),
},
{
ID: "alert-2",
RuleName: "Structuring",
Severity: types.SeverityMedium,
ActionTaken: types.ActionFlag,
Score: 0.25,
CreatedAt: now.Add(-1 * time.Hour),
},
}
entity := types.Entity{
ID: "entity-1",
Name: "John Smith",
EntityType: types.EntityUser,
Jurisdiction: "US",
}
draft := GenerateSARDraft(c, alerts, entity)
if draft.CaseID != "case-001" {
t.Fatalf("expected case-001, got %q", draft.CaseID)
}
if draft.FilingType != FilingInitial {
t.Fatalf("expected initial, got %q", draft.FilingType)
}
if draft.SubjectName != "John Smith" {
t.Fatalf("expected John Smith, got %q", draft.SubjectName)
}
if draft.SubjectType != types.EntityUser {
t.Fatalf("expected user, got %q", draft.SubjectType)
}
if draft.TransactionCount != 2 {
t.Fatalf("expected 2 transactions, got %d", draft.TransactionCount)
}
if len(draft.ActivityType) != 2 {
t.Fatalf("expected 2 activity types, got %d", len(draft.ActivityType))
}
if draft.Narrative == "" {
t.Fatal("expected non-empty narrative")
}
if !strings.Contains(draft.Narrative, "John Smith") {
t.Fatal("narrative should contain subject name")
}
if !strings.Contains(draft.Narrative, "CTR Threshold") {
t.Fatal("narrative should contain alert rule name")
}
if !strings.Contains(draft.Narrative, "ANALYST") {
t.Fatal("narrative should contain analyst review notice")
}
if draft.DateRangeStart.After(draft.DateRangeEnd) {
t.Fatal("date range start should be before end")
}
}
func TestGenerateSARDraft_PEP(t *testing.T) {
now := time.Now().UTC()
c := &types.Case{
ID: "case-pep",
Number: 1,
Severity: types.SeverityCritical,
AlertIDs: []string{"alert-pep"},
OpenedAt: now,
}
alerts := []types.Alert{
{
ID: "alert-pep", RuleName: "PEP Large Transaction",
Severity: types.SeverityHigh, ActionTaken: types.ActionReview,
Score: 0.3, CreatedAt: now,
},
}
entity := types.Entity{
Name: "Jane Minister",
EntityType: types.EntityUser,
PEP: true,
}
draft := GenerateSARDraft(c, alerts, entity)
if !strings.Contains(draft.Narrative, "Politically Exposed Person") {
t.Fatal("PEP entity should have PEP note in narrative")
}
}
func TestGenerateSARDraft_Sanctions(t *testing.T) {
now := time.Now().UTC()
c := &types.Case{
ID: "case-sanc",
Number: 2,
Severity: types.SeverityCritical,
AlertIDs: []string{"alert-sanc"},
OpenedAt: now,
}
alerts := []types.Alert{
{
ID: "alert-sanc", RuleName: "Sanctions Direct",
Severity: types.SeverityCritical, ActionTaken: types.ActionBlock,
Score: 0.5, CreatedAt: now,
},
}
entity := types.Entity{
Name: "Blocked Entity",
EntityType: types.EntityAccount,
SanctionsFlag: true,
Jurisdiction: "IR",
}
draft := GenerateSARDraft(c, alerts, entity)
if !strings.Contains(draft.Narrative, "sanctions list match") {
t.Fatal("sanctioned entity should have sanctions note in narrative")
}
if !strings.Contains(draft.Narrative, "IR") {
t.Fatal("narrative should mention jurisdiction")
}
}
func TestGenerateSARDraft_EmptyAlerts(t *testing.T) {
c := &types.Case{
ID: "case-empty",
Number: 3,
Severity: types.SeverityLow,
OpenedAt: time.Now().UTC(),
}
entity := types.Entity{
Name: "Nobody",
EntityType: types.EntityUser,
}
draft := GenerateSARDraft(c, nil, entity)
if draft.TransactionCount != 0 {
t.Fatalf("expected 0 transactions, got %d", draft.TransactionCount)
}
if draft.Amount != 0 {
t.Fatalf("expected 0 amount, got %f", draft.Amount)
}
}
func TestSARDraftConstants(t *testing.T) {
if FilingInitial != "initial" {
t.Fatalf("FilingInitial = %q", FilingInitial)
}
if FilingContinuing != "continuing" {
t.Fatalf("FilingContinuing = %q", FilingContinuing)
}
if FilingCorrection != "correction" {
t.Fatalf("FilingCorrection = %q", FilingCorrection)
}
}
+190
View File
@@ -0,0 +1,190 @@
package cases
import (
"sync/atomic"
"time"
"github.com/luxfi/aml/pkg/types"
)
// TriageConfig controls auto-assignment, escalation SLAs, and auto-close behavior.
type TriageConfig struct {
AutoAssignEnabled bool
EscalationSLA map[string]time.Duration // severity -> max time before escalation
AutoCloseAfter time.Duration // auto-close low-severity if no activity
AssignmentMode string // "round_robin", "least_loaded", "manual"
}
// DefaultTriageConfig returns production-safe SLA defaults.
func DefaultTriageConfig() TriageConfig {
return TriageConfig{
AutoAssignEnabled: true,
EscalationSLA: map[string]time.Duration{
types.SeverityCritical: 1 * time.Hour,
types.SeverityHigh: 4 * time.Hour,
types.SeverityMedium: 24 * time.Hour,
types.SeverityLow: 72 * time.Hour,
},
AutoCloseAfter: 30 * 24 * time.Hour, // 30 days
AssignmentMode: "round_robin",
}
}
// rrIndex tracks round-robin state for assignment.
var rrIndex atomic.Int64
// AutoAssign assigns an unassigned case to an analyst.
// Returns the assigned analyst ID, or empty if no analysts available.
func (s *Store) AutoAssign(c *types.Case, analysts []string, mode string) string {
if len(analysts) == 0 {
return ""
}
var assignee string
switch mode {
case "round_robin":
idx := rrIndex.Add(1) - 1
assignee = analysts[idx%int64(len(analysts))]
case "least_loaded":
// Count open cases per analyst.
s.mu.RLock()
loads := make(map[string]int)
for _, existing := range s.cases {
if existing.Status != types.CaseClosed && existing.AssigneeID != "" {
loads[existing.AssigneeID]++
}
}
s.mu.RUnlock()
minLoad := -1
for _, a := range analysts {
load := loads[a]
if minLoad == -1 || load < minLoad {
minLoad = load
assignee = a
}
}
default: // "manual" or unknown: no auto-assign
return ""
}
if assignee != "" {
_ = s.Assign(c.ID, assignee)
}
return assignee
}
// CheckEscalation returns true and escalates the case if the SLA is breached.
func (s *Store) CheckEscalation(c *types.Case, config TriageConfig) bool {
if c.Status == types.CaseClosed || c.Status == types.CaseEscalated {
return false
}
sla, ok := config.EscalationSLA[c.Severity]
if !ok {
return false
}
deadline := c.OpenedAt.Add(sla)
if time.Now().UTC().Before(deadline) {
return false
}
// SLA breached: escalate.
s.mu.Lock()
existing, ok := s.cases[c.ID]
if !ok {
s.mu.Unlock()
return false
}
now := time.Now().UTC()
existing.Status = types.CaseEscalated
existing.UpdatedAt = now
s.events[c.ID] = append(s.events[c.ID], types.CaseEvent{
Kind: types.EventStatusChange,
CaseID: c.ID,
AuthorID: "system",
Body: "auto-escalated: SLA breached",
CreatedAt: now,
})
s.mu.Unlock()
return true
}
// AutoClose closes low-severity cases with no activity after the configured threshold.
// Returns the number of cases closed.
func (s *Store) AutoClose(config TriageConfig) int {
if config.AutoCloseAfter <= 0 {
return 0
}
now := time.Now().UTC()
threshold := now.Add(-config.AutoCloseAfter)
closed := 0
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.cases {
if c.Status == types.CaseClosed || c.Status == types.CaseEscalated {
continue
}
if c.Severity != types.SeverityLow {
continue
}
// Check last activity: use UpdatedAt as proxy.
if c.UpdatedAt.Before(threshold) {
c.Status = types.CaseClosed
c.Resolution = "auto_closed_inactive"
c.ClosedAt = &now
c.UpdatedAt = now
s.events[c.ID] = append(s.events[c.ID], types.CaseEvent{
Kind: types.EventStatusChange,
CaseID: c.ID,
AuthorID: "system",
Body: "auto-closed: no activity for " + config.AutoCloseAfter.String(),
CreatedAt: now,
})
closed++
}
}
return closed
}
// TriageCheck runs the full triage cycle: auto-assign unassigned, escalate overdue, auto-close stale.
// Intended to run every 5 minutes.
func (s *Store) TriageCheck(orgID string, analysts []string, config TriageConfig) (assigned, escalated, autoClosed int) {
// Auto-close stale low-severity FIRST — before assign refreshes UpdatedAt.
autoClosed = s.AutoClose(config)
cases := s.List(orgID, "")
for _, c := range cases {
if c.Status == types.CaseClosed {
continue
}
// Auto-assign unassigned cases.
if config.AutoAssignEnabled && c.AssigneeID == "" {
if a := s.AutoAssign(c, analysts, config.AssignmentMode); a != "" {
assigned++
}
}
// Check escalation.
if s.CheckEscalation(c, config) {
escalated++
}
}
return assigned, escalated, autoClosed
}
+206
View File
@@ -0,0 +1,206 @@
package cases
import (
"testing"
"time"
"github.com/luxfi/aml/pkg/types"
)
func TestAutoAssign_RoundRobin(t *testing.T) {
s := NewStore()
c1 := s.Create("org1", types.SeverityMedium, nil, nil)
c2 := s.Create("org1", types.SeverityMedium, nil, nil)
analysts := []string{"analyst-a", "analyst-b"}
a1 := s.AutoAssign(c1, analysts, "round_robin")
a2 := s.AutoAssign(c2, analysts, "round_robin")
if a1 == "" || a2 == "" {
t.Fatal("expected assignments, got empty")
}
if a1 == a2 {
t.Errorf("round-robin should alternate: got %q and %q", a1, a2)
}
}
func TestAutoAssign_LeastLoaded(t *testing.T) {
s := NewStore()
// Give analyst-a two open cases.
c1 := s.Create("org1", types.SeverityMedium, nil, nil)
_ = s.Assign(c1.ID, "analyst-a")
c2 := s.Create("org1", types.SeverityMedium, nil, nil)
_ = s.Assign(c2.ID, "analyst-a")
// New unassigned case.
c3 := s.Create("org1", types.SeverityMedium, nil, nil)
assignee := s.AutoAssign(c3, []string{"analyst-a", "analyst-b"}, "least_loaded")
if assignee != "analyst-b" {
t.Errorf("assignee = %q, want analyst-b (least loaded)", assignee)
}
}
func TestAutoAssign_Manual(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityMedium, nil, nil)
assignee := s.AutoAssign(c, []string{"analyst-a"}, "manual")
if assignee != "" {
t.Errorf("manual mode should return empty, got %q", assignee)
}
}
func TestAutoAssign_NoAnalysts(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityMedium, nil, nil)
assignee := s.AutoAssign(c, nil, "round_robin")
if assignee != "" {
t.Errorf("expected empty with no analysts, got %q", assignee)
}
}
func TestCheckEscalation_SLABreached(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityCritical, nil, nil)
// Backdate the case to trigger SLA breach.
s.mu.Lock()
s.cases[c.ID].OpenedAt = time.Now().UTC().Add(-2 * time.Hour)
s.mu.Unlock()
config := DefaultTriageConfig()
escalated := s.CheckEscalation(c, config)
if !escalated {
t.Error("expected escalation for 2h-old critical case (1h SLA)")
}
got := s.Get(c.ID)
if got.Status != types.CaseEscalated {
t.Errorf("status = %q, want %q", got.Status, types.CaseEscalated)
}
}
func TestCheckEscalation_NotBreached(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityLow, nil, nil)
config := DefaultTriageConfig()
escalated := s.CheckEscalation(c, config)
if escalated {
t.Error("should not escalate fresh low-severity case")
}
}
func TestCheckEscalation_AlreadyClosed(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityCritical, nil, nil)
_ = s.Resolve(c.ID, "cleared", "analyst")
config := DefaultTriageConfig()
escalated := s.CheckEscalation(c, config)
if escalated {
t.Error("should not escalate closed case")
}
}
func TestAutoClose(t *testing.T) {
s := NewStore()
// Low-severity case with old activity.
c := s.Create("org1", types.SeverityLow, nil, nil)
s.mu.Lock()
s.cases[c.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour) // 60 days old
s.mu.Unlock()
// Medium-severity case that should not be auto-closed.
c2 := s.Create("org1", types.SeverityMedium, nil, nil)
s.mu.Lock()
s.cases[c2.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour)
s.mu.Unlock()
config := DefaultTriageConfig()
closed := s.AutoClose(config)
if closed != 1 {
t.Errorf("closed = %d, want 1 (only low-severity)", closed)
}
got := s.Get(c.ID)
if got.Status != types.CaseClosed {
t.Errorf("low-severity status = %q, want closed", got.Status)
}
if got.Resolution != "auto_closed_inactive" {
t.Errorf("resolution = %q, want auto_closed_inactive", got.Resolution)
}
got2 := s.Get(c2.ID)
if got2.Status == types.CaseClosed {
t.Error("medium-severity case should not be auto-closed")
}
}
func TestAutoClose_RecentActivity(t *testing.T) {
s := NewStore()
c := s.Create("org1", types.SeverityLow, nil, nil)
// UpdatedAt is now (fresh), so auto-close should skip it.
config := DefaultTriageConfig()
closed := s.AutoClose(config)
if closed != 0 {
t.Errorf("closed = %d, want 0 (recent activity)", closed)
}
_ = c
}
func TestTriageCheck_FullCycle(t *testing.T) {
s := NewStore()
// Unassigned case.
s.Create("org1", types.SeverityMedium, nil, nil)
// Critical case past SLA.
c2 := s.Create("org1", types.SeverityCritical, nil, nil)
s.mu.Lock()
s.cases[c2.ID].OpenedAt = time.Now().UTC().Add(-3 * time.Hour)
s.mu.Unlock()
// Stale low-severity case.
c3 := s.Create("org1", types.SeverityLow, nil, nil)
s.mu.Lock()
s.cases[c3.ID].UpdatedAt = time.Now().UTC().Add(-60 * 24 * time.Hour)
s.mu.Unlock()
config := DefaultTriageConfig()
assigned, escalated, autoClosed := s.TriageCheck("org1", []string{"analyst-a"}, config)
// All 3 unassigned cases get assigned (including the stale one before it's closed).
if assigned < 2 {
t.Errorf("assigned = %d, want at least 2", assigned)
}
if escalated != 1 {
t.Errorf("escalated = %d, want 1", escalated)
}
if autoClosed != 1 {
t.Errorf("autoClosed = %d, want 1", autoClosed)
}
}
func TestDefaultTriageConfig(t *testing.T) {
cfg := DefaultTriageConfig()
if !cfg.AutoAssignEnabled {
t.Error("auto-assign should be enabled by default")
}
if cfg.EscalationSLA[types.SeverityCritical] != 1*time.Hour {
t.Error("critical SLA should be 1 hour")
}
if cfg.EscalationSLA[types.SeverityHigh] != 4*time.Hour {
t.Error("high SLA should be 4 hours")
}
if cfg.EscalationSLA[types.SeverityMedium] != 24*time.Hour {
t.Error("medium SLA should be 24 hours")
}
if cfg.EscalationSLA[types.SeverityLow] != 72*time.Hour {
t.Error("low SLA should be 72 hours")
}
}
+180
View File
@@ -0,0 +1,180 @@
// 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
}
+160
View File
@@ -0,0 +1,160 @@
package chain
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestScreenAddress(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/v2/entities" {
t.Errorf("path = %s, want /v2/entities", r.URL.Path)
}
if r.Header.Get("Token") == "" {
t.Error("missing Token header")
}
resp := map[string]interface{}{
"risk_score": 0.85,
"category": "mixer",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL, "test-key")
result, err := client.ScreenAddress(context.Background(), "0xabc123", "ethereum")
if err != nil {
t.Fatalf("ScreenAddress error: %v", err)
}
if result.RiskScore != 0.85 {
t.Errorf("risk_score = %.2f, want 0.85", result.RiskScore)
}
if result.Category != "mixer" {
t.Errorf("category = %q, want mixer", result.Category)
}
if result.Address != "0xabc123" {
t.Errorf("address = %q, want 0xabc123", result.Address)
}
}
func TestScreenTransaction(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
resp := map[string]interface{}{
"risk_score": 0.3,
"alerts": []string{},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL, "test-key")
result, err := client.ScreenTransaction(context.Background(), "0xdef456", "ethereum")
if err != nil {
t.Fatalf("ScreenTransaction error: %v", err)
}
if result.RiskScore != 0.3 {
t.Errorf("risk_score = %.2f, want 0.3", result.RiskScore)
}
if result.TxHash != "0xdef456" {
t.Errorf("tx_hash = %q, want 0xdef456", result.TxHash)
}
}
func TestScreenAddress_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"invalid api key"}`))
}))
defer server.Close()
client := NewClient(server.URL, "bad-key")
_, err := client.ScreenAddress(context.Background(), "0xabc", "ethereum")
if err == nil {
t.Error("expected error for 403 response")
}
}
func TestRegisterWebhook(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
if r.URL.Path != "/v2/webhooks" {
t.Errorf("path = %s, want /v2/webhooks", r.URL.Path)
}
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["url"] != "https://example.com/webhook" {
t.Errorf("url = %v, want https://example.com/webhook", body["url"])
}
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"id":"wh-001"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-key")
err := client.RegisterWebhook(context.Background(), "https://example.com/webhook", []string{"alert.new"})
if err != nil {
t.Fatalf("RegisterWebhook error: %v", err)
}
}
func TestAddressRisk_IsHighRisk(t *testing.T) {
tests := []struct {
name string
risk AddressRisk
wantHigh bool
}{
{"high score", AddressRisk{RiskScore: 0.8}, true},
{"mixer category", AddressRisk{RiskScore: 0.3, Category: "mixer"}, true},
{"darknet", AddressRisk{RiskScore: 0.5, Category: "darknet"}, true},
{"sanctions", AddressRisk{RiskScore: 0.4, Category: "sanctions"}, true},
{"clean", AddressRisk{RiskScore: 0.2, Category: "exchange"}, false},
{"low score unknown", AddressRisk{RiskScore: 0.1, Category: "unknown"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.risk.IsHighRisk(); got != tt.wantHigh {
t.Errorf("IsHighRisk() = %v, want %v", got, tt.wantHigh)
}
})
}
}
func TestAddressRisk_IsBlocked(t *testing.T) {
sanctioned := AddressRisk{Category: "sanctions"}
if !sanctioned.IsBlocked() {
t.Error("sanctions category should be blocked")
}
mixer := AddressRisk{Category: "mixer"}
if mixer.IsBlocked() {
t.Error("mixer should not be blocked (flagged, not blocked)")
}
}
func TestNewClient(t *testing.T) {
c := NewClient("https://api.chainalysis.com", "test-key")
if c.baseURL != "https://api.chainalysis.com" {
t.Errorf("baseURL = %q", c.baseURL)
}
if c.client.Timeout != 30*1e9 {
t.Error("expected 30s timeout")
}
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package engine
import (
"context"
"fmt"
"time"
"github.com/hanzoai/base/core"
"github.com/luxfi/aml/pkg/types"
)
const txCollection = "aml_transactions"
// BaseStore implements TransactionStore backed by Base collections.
type BaseStore struct {
app core.App
}
// NewBaseStore creates a TransactionStore using Base collections.
func NewBaseStore(app core.App) *BaseStore {
return &BaseStore{app: app}
}
func (s *BaseStore) CountByUser(ctx context.Context, userID string, since time.Time) (int, error) {
records, err := s.app.FindRecordsByFilter(
txCollection,
fmt.Sprintf("user_id = '%s' && created >= '%s'", userID, since.UTC().Format(time.RFC3339)),
"", // sort
0, // limit (0 = all)
0, // offset
)
if err != nil {
return 0, fmt.Errorf("count by user: %w", err)
}
return len(records), nil
}
func (s *BaseStore) SumNotionalByUser(ctx context.Context, userID string, since time.Time) (float64, error) {
records, err := s.app.FindRecordsByFilter(
txCollection,
fmt.Sprintf("user_id = '%s' && created >= '%s'", userID, since.UTC().Format(time.RFC3339)),
"",
0,
0,
)
if err != nil {
return 0, fmt.Errorf("sum notional: %w", err)
}
var total float64
for _, r := range records {
total += r.GetFloat("notional")
}
return total, nil
}
func (s *BaseStore) FirstTransaction(ctx context.Context, userID string) (bool, error) {
records, err := s.app.FindRecordsByFilter(
txCollection,
fmt.Sprintf("user_id = '%s'", userID),
"",
1,
0,
)
if err != nil {
return true, fmt.Errorf("first tx check: %w", err)
}
return len(records) == 0, nil
}
func (s *BaseStore) FirstCounterparty(ctx context.Context, userID, counterparty string) (bool, error) {
records, err := s.app.FindRecordsByFilter(
txCollection,
fmt.Sprintf("user_id = '%s' && counterparty = '%s'", userID, counterparty),
"",
1,
0,
)
if err != nil {
return true, fmt.Errorf("first counterparty check: %w", err)
}
return len(records) == 0, nil
}
func (s *BaseStore) LastTransactionTime(ctx context.Context, userID string) (time.Time, error) {
records, err := s.app.FindRecordsByFilter(
txCollection,
fmt.Sprintf("user_id = '%s'", userID),
"-created",
1,
0,
)
if err != nil {
return time.Time{}, fmt.Errorf("last tx time: %w", err)
}
if len(records) == 0 {
return time.Time{}, nil
}
return records[0].GetDateTime("created").Time(), nil
}
func (s *BaseStore) RoundTrip(ctx context.Context, userID string, window time.Duration) (bool, error) {
since := time.Now().UTC().Add(-window)
records, err := s.app.FindRecordsByFilter(
txCollection,
fmt.Sprintf("user_id = '%s' && created >= '%s'", userID, since.Format(time.RFC3339)),
"",
0,
0,
)
if err != nil {
return false, fmt.Errorf("round trip check: %w", err)
}
// Check if any symbol has both "buy" and "sell" sides.
sides := make(map[string]map[string]bool) // symbol -> set of sides
for _, r := range records {
sym := r.GetString("symbol")
side := r.GetString("side")
if sym == "" || side == "" {
continue
}
if sides[sym] == nil {
sides[sym] = make(map[string]bool)
}
sides[sym][side] = true
}
for _, sideSet := range sides {
if sideSet["buy"] && sideSet["sell"] {
return true, nil
}
}
return false, nil
}
func (s *BaseStore) Save(ctx context.Context, tx types.Transaction) error {
collection, err := s.app.FindCollectionByNameOrId(txCollection)
if err != nil {
return fmt.Errorf("find collection %s: %w", txCollection, err)
}
record := core.NewRecord(collection)
record.Set("tx_id", tx.ID)
record.Set("org_id", tx.OrgID)
record.Set("user_id", tx.UserID)
record.Set("symbol", tx.Symbol)
record.Set("side", tx.Side)
record.Set("notional", tx.Notional)
record.Set("currency", tx.Currency)
record.Set("counterparty", tx.Counterparty)
record.Set("ip_address", tx.IPAddress)
record.Set("asset_class", tx.AssetClass)
return s.app.Save(record)
}
+94
View File
@@ -0,0 +1,94 @@
package engine
import (
"fmt"
"testing"
"github.com/luxfi/aml/pkg/rules"
"github.com/luxfi/aml/pkg/types"
)
// BenchmarkEvaluate measures single-transaction evaluation throughput.
// Measures single-transaction evaluation throughput.
func BenchmarkEvaluate(b *testing.B) {
eng := New(rules.StarterRules("bench"))
tx := types.Transaction{
ID: "bench-tx",
UserID: "user-1",
Symbol: "AAPL",
AssetClass: "us_equity",
Side: "buy",
Qty: 10,
Notional: 1500.0,
Currency: "USD",
}
entity := types.Entity{
ID: "user-1",
Jurisdiction: "US",
KYCLevel: 2,
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
eng.Evaluate(tx, entity)
}
}
// BenchmarkEvaluateStructuring measures evaluation of a transaction that
// triggers the structuring rule (notional in $9000-$10000 range).
func BenchmarkEvaluateStructuring(b *testing.B) {
eng := New(rules.StarterRules("bench"))
tx := types.Transaction{
ID: "bench-struct",
UserID: "user-2",
Symbol: "BTC/USD",
AssetClass: "us_crypto",
Side: "buy",
Qty: 1,
Notional: 9500.0,
Currency: "USD",
}
entity := types.Entity{
ID: "user-2",
Jurisdiction: "US",
KYCLevel: 2,
}
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
alerts, score, action := eng.Evaluate(tx, entity)
_ = alerts
_ = score
_ = action
}
}
// BenchmarkEvaluateParallel measures concurrent evaluation throughput.
func BenchmarkEvaluateParallel(b *testing.B) {
eng := New(rules.StarterRules("bench"))
b.ResetTimer()
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
tx := types.Transaction{
ID: fmt.Sprintf("par-%d", i),
UserID: "user-par",
Symbol: "AAPL",
AssetClass: "us_equity",
Side: "buy",
Qty: 10,
Notional: 1500.0,
Currency: "USD",
}
entity := types.Entity{ID: "user-par", Jurisdiction: "US", KYCLevel: 2}
eng.Evaluate(tx, entity)
i++
}
})
}
+119
View File
@@ -0,0 +1,119 @@
// Package engine implements the real-time AML rule evaluation engine.
// It evaluates transactions against rules using expr-lang, scores hits
// using weight-of-evidence, and determines the enforcement action.
package engine
import (
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/luxfi/aml/pkg/types"
)
// Engine is the core AML evaluation engine.
type Engine struct {
evaluator *Evaluator
mu sync.RWMutex
rules []types.Rule
}
// New creates an Engine with the given rules.
func New(rules []types.Rule) *Engine {
return &Engine{
evaluator: NewEvaluator(),
rules: rules,
}
}
// SetRules replaces the rule set atomically. Returns an error if any rule
// has a negative weight (RED-19: prevents score suppression attacks).
func (e *Engine) SetRules(rules []types.Rule) error {
for _, r := range rules {
if r.Weight < 0 {
return fmt.Errorf("rule %q has negative weight %f: negative weights are not allowed", r.ID, r.Weight)
}
}
e.mu.Lock()
e.rules = rules
e.mu.Unlock()
return nil
}
// Rules returns a copy of the current rule set.
func (e *Engine) Rules() []types.Rule {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]types.Rule, len(e.rules))
copy(out, e.rules)
return out
}
// Evaluator returns the underlying evaluator for function registration.
func (e *Engine) Evaluator() *Evaluator {
return e.evaluator
}
// Evaluate runs all rules against a transaction and its primary entity.
// Returns the computed alerts, the aggregate score, and the enforcement action.
func (e *Engine) Evaluate(tx types.Transaction, entity types.Entity) ([]types.Alert, float64, string) {
e.mu.RLock()
rules := e.rules
e.mu.RUnlock()
ctx := types.EvalContext{
Tx: tx,
Entity: entity,
}
hits := e.evaluator.EvalAll(rules, ctx)
if len(hits) == 0 {
return nil, 0, types.ActionAllow
}
score, breakdown := Score(hits)
now := time.Now().UTC()
alerts := make([]types.Alert, 0, len(hits))
for _, h := range hits {
alerts = append(alerts, types.Alert{
ID: uuid.NewString(),
OrgID: tx.OrgID,
TxID: tx.ID,
RuleID: h.Rule.ID,
RuleName: h.Rule.Name,
Severity: h.Rule.Severity,
Score: breakdown[h.Rule.ID],
ScoreBreakdown: breakdown,
ActionTaken: h.Rule.Action,
CreatedAt: now,
UpdatedAt: now,
})
}
action := resolveAction(alerts)
return alerts, score, action
}
// resolveAction picks the most severe action from a set of alerts.
// Priority: block > report > review > flag > allow.
func resolveAction(alerts []types.Alert) string {
priority := map[string]int{
types.ActionBlock: 4,
types.ActionReport: 3,
types.ActionReview: 2,
types.ActionFlag: 1,
types.ActionAllow: 0,
}
best := types.ActionAllow
bestP := 0
for _, a := range alerts {
if p, ok := priority[a.ActionTaken]; ok && p > bestP {
best = a.ActionTaken
bestP = p
}
}
return best
}
+197
View File
@@ -0,0 +1,197 @@
package engine
import (
"testing"
"github.com/luxfi/aml/pkg/rules"
"github.com/luxfi/aml/pkg/types"
)
func TestEngineEvaluateAllow(t *testing.T) {
eng := New(rules.StarterRules("test"))
tx := types.Transaction{
ID: "tx1",
OrgID: "test",
UserID: "u1",
Notional: 100,
Currency: "USD",
}
entity := types.Entity{
ID: "u1",
OrgID: "test",
}
alerts, score, action := eng.Evaluate(tx, entity)
if action != types.ActionAllow {
t.Errorf("expected allow for small transaction, got %s", action)
}
if len(alerts) != 0 {
t.Errorf("expected 0 alerts, got %d", len(alerts))
}
if score != 0 {
t.Errorf("expected score 0, got %f", score)
}
}
func TestEngineEvaluateStructuring(t *testing.T) {
eng := New(rules.StarterRules("test"))
tx := types.Transaction{
ID: "tx2",
OrgID: "test",
UserID: "u1",
Notional: 9500,
Currency: "USD",
}
entity := types.Entity{
ID: "u1",
OrgID: "test",
}
alerts, _, action := eng.Evaluate(tx, entity)
// Structuring (flag) + Travel Rule (report) both fire at $9500.
// Highest action wins: report > flag.
if action != types.ActionReport {
t.Errorf("expected report (travel rule outranks structuring flag), got %s", action)
}
var structuringFired bool
for _, a := range alerts {
if a.RuleName == "Structuring" {
structuringFired = true
break
}
}
if !structuringFired {
t.Error("expected Structuring rule to fire")
}
}
func TestEngineEvaluateCTR(t *testing.T) {
eng := New(rules.StarterRules("test"))
tx := types.Transaction{
ID: "tx3",
OrgID: "test",
UserID: "u1",
Notional: 15000,
Currency: "USD",
}
entity := types.Entity{
ID: "u1",
OrgID: "test",
}
alerts, _, action := eng.Evaluate(tx, entity)
// CTR fires (report) and travel_rule fires (report), so action = report.
if action != types.ActionReport {
t.Errorf("expected report for CTR, got %s", action)
}
var ctrFired bool
for _, a := range alerts {
if a.RuleName == "CTR Threshold" {
ctrFired = true
}
}
if !ctrFired {
t.Error("expected CTR Threshold rule to fire")
}
}
func TestEngineEvaluatePEP(t *testing.T) {
eng := New(rules.StarterRules("test"))
tx := types.Transaction{
ID: "tx4",
OrgID: "test",
UserID: "u1",
Notional: 15000,
Currency: "USD",
}
entity := types.Entity{
ID: "u1",
OrgID: "test",
PEP: true,
}
alerts, _, _ := eng.Evaluate(tx, entity)
var pepFired bool
for _, a := range alerts {
if a.RuleName == "PEP Large Transaction" {
pepFired = true
}
}
if !pepFired {
t.Error("expected PEP Large Transaction rule to fire")
}
}
func TestEngineSetRules(t *testing.T) {
eng := New(nil)
if len(eng.Rules()) != 0 {
t.Fatal("expected 0 rules")
}
if err := eng.SetRules(rules.StarterRules("test")); err != nil {
t.Fatalf("SetRules failed: %v", err)
}
if len(eng.Rules()) != 23 {
t.Errorf("expected 23 rules, got %d", len(eng.Rules()))
}
}
// TestSetRulesRejectsNegativeWeight (RED-19) verifies that SetRules rejects
// rules with negative weights, preventing score suppression attacks.
func TestSetRulesRejectsNegativeWeight(t *testing.T) {
eng := New(nil)
err := eng.SetRules([]types.Rule{
{ID: "good", Weight: 0.3, Enabled: true},
{ID: "evil", Weight: -1.0, Enabled: true},
})
if err == nil {
t.Fatal("RED-19: SetRules should reject negative weight, got nil error")
}
// Verify the rules were NOT set.
if len(eng.Rules()) != 0 {
t.Errorf("rules should remain empty after rejected SetRules, got %d", len(eng.Rules()))
}
}
// TestSetRulesAcceptsZeroWeight verifies that weight=0 is allowed (disabled rule).
func TestSetRulesAcceptsZeroWeight(t *testing.T) {
eng := New(nil)
err := eng.SetRules([]types.Rule{
{ID: "zero", Weight: 0.0, Enabled: true},
})
if err != nil {
t.Fatalf("SetRules should accept weight=0, got: %v", err)
}
}
func TestResolveAction(t *testing.T) {
tests := []struct {
actions []string
want string
}{
{nil, types.ActionAllow},
{[]string{types.ActionFlag}, types.ActionFlag},
{[]string{types.ActionFlag, types.ActionReview}, types.ActionReview},
{[]string{types.ActionReview, types.ActionBlock}, types.ActionBlock},
{[]string{types.ActionReport, types.ActionBlock}, types.ActionBlock},
}
for _, tt := range tests {
alerts := make([]types.Alert, len(tt.actions))
for i, a := range tt.actions {
alerts[i] = types.Alert{ActionTaken: a}
}
got := resolveAction(alerts)
if got != tt.want {
t.Errorf("resolveAction(%v) = %s, want %s", tt.actions, got, tt.want)
}
}
}
+269
View File
@@ -0,0 +1,269 @@
package engine
import (
"fmt"
"strings"
"sync"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
"github.com/luxfi/aml/pkg/types"
)
// ExprFunc is the function signature required by expr-lang.
type ExprFunc = func(params ...any) (any, error)
// DefaultMaxCacheSize is the default max number of compiled programs in the cache.
const DefaultMaxCacheSize = 10_000
// Evaluator compiles and evaluates expr-lang rule DSL against transaction contexts.
// Compiled programs are cached per rule ID + DSL hash for reuse.
// RED-15: Cache has a max size with LRU eviction to prevent memory exhaustion.
type Evaluator struct {
mu sync.RWMutex
cache map[string]*vm.Program // key = rule.ID + ":" + rule.DSL
cacheOrder []string // insertion order for LRU eviction
maxCacheSize int
funcs map[string]ExprFunc
}
// NewEvaluator creates an Evaluator with the default helper functions.
func NewEvaluator() *Evaluator {
return NewEvaluatorWithMaxCache(DefaultMaxCacheSize)
}
// NewEvaluatorWithMaxCache creates an Evaluator with a custom cache limit.
func NewEvaluatorWithMaxCache(maxCache int) *Evaluator {
if maxCache <= 0 {
maxCache = DefaultMaxCacheSize
}
e := &Evaluator{
cache: make(map[string]*vm.Program),
maxCacheSize: maxCache,
funcs: make(map[string]ExprFunc),
}
e.registerDefaults()
return e
}
// CacheLen returns the number of compiled programs in the cache.
func (e *Evaluator) CacheLen() int {
e.mu.RLock()
defer e.mu.RUnlock()
return len(e.cache)
}
// RegisterFunc adds a named function available in the expr-lang environment.
func (e *Evaluator) RegisterFunc(name string, fn ExprFunc) {
e.mu.Lock()
e.funcs[name] = fn
e.mu.Unlock()
}
// registerDefaults registers stub helper functions for the DSL.
// These return conservative defaults. Production wires real implementations.
func (e *Evaluator) registerDefaults() {
e.funcs["count_last_24h"] = func(params ...any) (any, error) { return 0, nil }
e.funcs["sum_last_24h"] = func(params ...any) (any, error) { return 0.0, nil }
e.funcs["sum_last_30d"] = func(params ...any) (any, error) { return 0.0, nil }
e.funcs["first_tx"] = func(params ...any) (any, error) { return false, nil }
e.funcs["first_counterparty"] = func(params ...any) (any, error) { return false, nil }
e.funcs["last_tx_age"] = func(params ...any) (any, error) { return 0, nil }
e.funcs["is_round_trip"] = func(params ...any) (any, error) { return false, nil }
e.funcs["geo_match"] = func(params ...any) (any, error) { return true, nil }
e.funcs["sanctions_hit"] = func(params ...any) (any, error) { return false, nil }
e.funcs["sanctioned_jurisdictions"] = func(params ...any) (any, error) {
return []string{"IR", "KP", "SY", "CU", "AF", "BY", "CF", "CD", "LY", "ML", "SO", "SS", "SD", "YE"}, nil
}
e.funcs["hour"] = func(params ...any) (any, error) { return 12, nil }
e.funcs["is_mixer_address"] = func(params ...any) (any, error) { return false, nil }
e.funcs["is_darknet_market"] = func(params ...any) (any, error) { return false, nil }
e.funcs["layering_score"] = func(params ...any) (any, error) { return 0.0, nil }
e.funcs["smurfing_detected"] = func(params ...any) (any, error) { return false, nil }
e.funcs["wash_trade_detected"] = func(params ...any) (any, error) { return false, nil }
e.funcs["travel_rule_threshold"] = func(params ...any) (any, error) { return 3000.0, nil }
e.funcs["travel_rule_complete"] = func(params ...any) (any, error) { return true, nil }
e.funcs["chain_risk_score"] = func(params ...any) (any, error) { return 0.0, nil }
e.funcs["chain_is_sanctioned"] = func(params ...any) (any, error) { return false, nil }
// RED-13: notional_usd converts tx.Notional to USD using approximate FX rates.
// This ensures CTR rules fire correctly for non-USD currencies.
e.funcs["notional_usd"] = func(params ...any) (any, error) {
if len(params) < 1 {
return 0.0, fmt.Errorf("notional_usd requires a transaction")
}
// expr-lang passes the struct directly.
switch v := params[0].(type) {
case types.Transaction:
return notionalUSD(v.Notional, v.Currency), nil
case *types.Transaction:
return notionalUSD(v.Notional, v.Currency), nil
case map[string]interface{}:
notional, _ := v["Notional"].(float64)
currency, _ := v["Currency"].(string)
return notionalUSD(notional, currency), nil
default:
return 0.0, fmt.Errorf("notional_usd: unexpected type %T", params[0])
}
}
}
// RED-13: fxRates maps currency codes to their approximate USD exchange rate.
// Updated by the sanctions refresh cron in production; these are conservative defaults.
var fxRates = map[string]float64{
"USD": 1.0, "EUR": 1.08, "GBP": 1.27, "CHF": 1.12,
"JPY": 0.0067, "INR": 0.012, "BRL": 0.20, "AUD": 0.66,
"CAD": 0.74, "SGD": 0.75, "AED": 0.27, "KRW": 0.00075,
"CNY": 0.14, "MXN": 0.058, "ZAR": 0.055, "TRY": 0.031,
"RUB": 0.011, "HKD": 0.13, "NZD": 0.61, "SEK": 0.096,
"NOK": 0.094, "DKK": 0.14, "PLN": 0.25, "THB": 0.028,
}
// notionalUSD converts a notional amount to USD using approximate FX rates.
// Unknown currencies return the raw notional value (conservative — assumes USD).
func notionalUSD(notional float64, currency string) float64 {
if currency == "" || currency == "USD" {
return notional
}
rate, ok := fxRates[strings.ToUpper(currency)]
if !ok {
return notional // unknown currency → assume raw = conservative
}
return notional * rate
}
// cacheKey returns the cache key for a compiled rule program.
func cacheKey(r types.Rule) string {
return r.ID + ":" + r.DSL
}
// Compile compiles a rule DSL into a cached vm.Program.
func (e *Evaluator) Compile(r types.Rule) (*vm.Program, error) {
key := cacheKey(r)
e.mu.RLock()
if prog, ok := e.cache[key]; ok {
e.mu.RUnlock()
return prog, nil
}
e.mu.RUnlock()
opts := []expr.Option{
expr.Env(map[string]interface{}{
"tx": types.Transaction{},
"entity": types.Entity{},
}),
}
for name, fn := range e.funcs {
opts = append(opts, expr.Function(name, fn))
}
prog, err := expr.Compile(r.DSL, opts...)
if err != nil {
return nil, fmt.Errorf("compile rule %q: %w", r.Name, err)
}
e.mu.Lock()
// RED-15: Evict oldest entries when cache exceeds max size.
if len(e.cache) >= e.maxCacheSize {
evictCount := e.maxCacheSize / 10
if evictCount < 1 {
evictCount = 1
}
for i := 0; i < evictCount && i < len(e.cacheOrder); i++ {
delete(e.cache, e.cacheOrder[i])
}
if evictCount <= len(e.cacheOrder) {
e.cacheOrder = e.cacheOrder[evictCount:]
} else {
e.cacheOrder = nil
}
}
e.cache[key] = prog
e.cacheOrder = append(e.cacheOrder, key)
e.mu.Unlock()
return prog, nil
}
// Eval evaluates a single rule against a transaction + entity context.
func (e *Evaluator) Eval(r types.Rule, ctx types.EvalContext) (bool, error) {
prog, err := e.Compile(r)
if err != nil {
return false, err
}
env := map[string]interface{}{
"tx": ctx.Tx,
"entity": ctx.Entity,
}
out, err := expr.Run(prog, env)
if err != nil {
return false, fmt.Errorf("eval rule %q: %w", r.Name, err)
}
switch v := out.(type) {
case bool:
return v, nil
case float64:
return v > 0, nil
case int:
return v > 0, nil
default:
return false, fmt.Errorf("rule %q returned non-bool type %T", r.Name, out)
}
}
// EvalAll evaluates all rules against a context and returns matching hits.
// Rules are filtered by jurisdiction and asset class before evaluation.
func (e *Evaluator) EvalAll(rules []types.Rule, ctx types.EvalContext) []types.RuleHit {
var hits []types.RuleHit
for _, r := range rules {
if !r.Enabled {
continue
}
if !matchesFilter(r.JurisdictionFilter, ctx.Entity.Jurisdiction) {
continue
}
if !matchesFilter(r.AssetClassFilter, ctx.Tx.AssetClass) {
continue
}
match, err := e.Eval(r, ctx)
if err != nil {
// RED-03: Fail closed on eval errors. A broken rule should trigger
// review, not silently allow the transaction through. The error is
// captured in the hit so analysts can see WHY the rule flagged.
hits = append(hits, types.RuleHit{
Rule: r,
Match: true,
EvalErr: err.Error(),
})
continue
}
if match {
hits = append(hits, types.RuleHit{
Rule: r,
Match: true,
})
}
}
return hits
}
// matchesFilter returns true if the filter is empty (matches all)
// or if val is in the filter list.
func matchesFilter(filter []string, val string) bool {
if len(filter) == 0 {
return true
}
for _, f := range filter {
if strings.EqualFold(f, val) {
return true
}
}
return false
}
+256
View File
@@ -0,0 +1,256 @@
package engine
import (
"fmt"
"testing"
"github.com/luxfi/aml/pkg/types"
)
func TestEvalSimpleBool(t *testing.T) {
eval := NewEvaluator()
rule := types.Rule{
ID: "test",
Name: "test",
DSL: `tx.Notional > 10000`,
Enabled: true,
}
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 15000},
}
match, err := eval.Eval(rule, ctx)
if err != nil {
t.Fatalf("Eval error: %v", err)
}
if !match {
t.Error("expected match for notional 15000 > 10000")
}
}
func TestEvalNotMatched(t *testing.T) {
eval := NewEvaluator()
rule := types.Rule{
ID: "test",
Name: "test",
DSL: `tx.Notional > 10000`,
Enabled: true,
}
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 5000},
}
match, err := eval.Eval(rule, ctx)
if err != nil {
t.Fatalf("Eval error: %v", err)
}
if match {
t.Error("expected no match for notional 5000 < 10000")
}
}
func TestEvalCompoundCondition(t *testing.T) {
eval := NewEvaluator()
rule := types.Rule{
ID: "structuring",
Name: "structuring",
DSL: `tx.Notional >= 9000 && tx.Notional < 10000`,
Enabled: true,
}
tests := []struct {
notional float64
want bool
}{
{9500, true},
{9000, true},
{8999, false},
{10000, false},
{10001, false},
}
for _, tt := range tests {
ctx := types.EvalContext{
Tx: types.Transaction{Notional: tt.notional},
}
match, err := eval.Eval(rule, ctx)
if err != nil {
t.Fatalf("Eval error for notional %f: %v", tt.notional, err)
}
if match != tt.want {
t.Errorf("notional %f: got match=%v, want %v", tt.notional, match, tt.want)
}
}
}
func TestEvalEntityField(t *testing.T) {
eval := NewEvaluator()
rule := types.Rule{
ID: "pep",
Name: "pep",
DSL: `entity.PEP == true && tx.Notional > 10000`,
Enabled: true,
}
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 15000},
Entity: types.Entity{PEP: true},
}
match, err := eval.Eval(rule, ctx)
if err != nil {
t.Fatalf("Eval error: %v", err)
}
if !match {
t.Error("expected match for PEP with notional 15000")
}
ctx.Entity.PEP = false
match, err = eval.Eval(rule, ctx)
if err != nil {
t.Fatalf("Eval error: %v", err)
}
if match {
t.Error("expected no match for non-PEP")
}
}
func TestEvalStringComparison(t *testing.T) {
eval := NewEvaluator()
rule := types.Rule{
ID: "ctr",
Name: "ctr",
DSL: `tx.Notional > 10000 && tx.Currency == "USD"`,
Enabled: true,
}
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 15000, Currency: "USD"},
}
match, err := eval.Eval(rule, ctx)
if err != nil {
t.Fatalf("Eval error: %v", err)
}
if !match {
t.Error("expected match for USD 15000")
}
ctx.Tx.Currency = "EUR"
match, err = eval.Eval(rule, ctx)
if err != nil {
t.Fatalf("Eval error: %v", err)
}
if match {
t.Error("expected no match for EUR")
}
}
func TestEvalInvalidDSL(t *testing.T) {
eval := NewEvaluator()
rule := types.Rule{
ID: "bad",
Name: "bad",
DSL: `this is not valid syntax !!!`,
Enabled: true,
}
_, err := eval.Eval(rule, types.EvalContext{})
if err == nil {
t.Error("expected error for invalid DSL")
}
}
func TestEvalAllFilters(t *testing.T) {
eval := NewEvaluator()
rules := []types.Rule{
{
ID: "r1", Name: "r1", DSL: `tx.Notional > 100`,
Enabled: true, JurisdictionFilter: []string{"US"},
},
{
ID: "r2", Name: "r2", DSL: `tx.Notional > 100`,
Enabled: true, JurisdictionFilter: []string{"UK"},
},
{
ID: "r3", Name: "r3", DSL: `tx.Notional > 100`,
Enabled: false,
},
}
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 200},
Entity: types.Entity{Jurisdiction: "US"},
}
hits := eval.EvalAll(rules, ctx)
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if hits[0].Rule.ID != "r1" {
t.Errorf("expected r1, got %s", hits[0].Rule.ID)
}
}
func TestMatchesFilter(t *testing.T) {
if !matchesFilter(nil, "anything") {
t.Error("empty filter should match everything")
}
if !matchesFilter([]string{"US", "UK"}, "us") {
t.Error("case-insensitive match should work")
}
if matchesFilter([]string{"US", "UK"}, "DE") {
t.Error("non-matching should return false")
}
}
func TestEvalCaching(t *testing.T) {
eval := NewEvaluator()
rule := types.Rule{
ID: "cached",
Name: "cached",
DSL: `tx.Notional > 0`,
Enabled: true,
}
// First compile.
_, err := eval.Compile(rule)
if err != nil {
t.Fatal(err)
}
// Second compile — should hit cache.
_, err = eval.Compile(rule)
if err != nil {
t.Fatal(err)
}
if eval.CacheLen() != 1 {
t.Errorf("cache should have 1 entry, got %d", eval.CacheLen())
}
}
// TestEvaluatorCacheEviction (RED-15) verifies that the evaluator cache evicts
// entries when it exceeds maxCacheSize.
func TestEvaluatorCacheEviction(t *testing.T) {
eval := NewEvaluatorWithMaxCache(100)
// Compile 200 unique rules.
for i := 0; i < 200; i++ {
rule := types.Rule{
ID: fmt.Sprintf("r%d", i),
Name: fmt.Sprintf("r%d", i),
DSL: fmt.Sprintf("tx.Notional > %d", i),
Enabled: true,
}
_, err := eval.Compile(rule)
if err != nil {
t.Fatalf("compile r%d: %v", i, err)
}
}
size := eval.CacheLen()
if size > 100 {
t.Errorf("RED-15: cache size = %d, want <= 100 after eviction", size)
}
}
+157
View File
@@ -0,0 +1,157 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package engine
import (
"context"
"fmt"
"time"
)
// RegisterStoreHelpers replaces the default stub helper functions with
// real implementations backed by a TransactionStore. Call this at startup
// after creating the engine and store.
func RegisterStoreHelpers(e *Engine, store TransactionStore) {
eval := e.Evaluator()
eval.RegisterFunc("count_last_24h", func(params ...any) (any, error) {
return countLastNh(store, params, 24)
})
eval.RegisterFunc("sum_last_24h", func(params ...any) (any, error) {
return sumLastNh(store, params, 24)
})
eval.RegisterFunc("sum_last_30d", func(params ...any) (any, error) {
return sumLastNd(store, params, 30)
})
eval.RegisterFunc("first_tx", func(params ...any) (any, error) {
if len(params) < 1 {
return false, fmt.Errorf("first_tx requires userID")
}
userID, ok := params[0].(string)
if !ok {
return false, fmt.Errorf("first_tx: userID must be string")
}
return store.FirstTransaction(context.Background(), userID)
})
eval.RegisterFunc("first_counterparty", func(params ...any) (any, error) {
if len(params) < 2 {
return false, fmt.Errorf("first_counterparty requires userID and counterparty")
}
userID, ok := params[0].(string)
if !ok {
return false, fmt.Errorf("first_counterparty: userID must be string")
}
counterparty, ok := params[1].(string)
if !ok {
return false, fmt.Errorf("first_counterparty: counterparty must be string")
}
return store.FirstCounterparty(context.Background(), userID, counterparty)
})
eval.RegisterFunc("last_tx_age", func(params ...any) (any, error) {
if len(params) < 1 {
return 0, fmt.Errorf("last_tx_age requires userID")
}
userID, ok := params[0].(string)
if !ok {
return 0, fmt.Errorf("last_tx_age: userID must be string")
}
lastTime, err := store.LastTransactionTime(context.Background(), userID)
if err != nil {
return 0, err
}
if lastTime.IsZero() {
return 999, nil // Never transacted = very old
}
days := int(time.Since(lastTime).Hours() / 24)
return days, nil
})
eval.RegisterFunc("is_round_trip", func(params ...any) (any, error) {
if len(params) < 2 {
return false, fmt.Errorf("is_round_trip requires userID and window")
}
userID, ok := params[0].(string)
if !ok {
return false, fmt.Errorf("is_round_trip: userID must be string")
}
windowStr, ok := params[1].(string)
if !ok {
return false, fmt.Errorf("is_round_trip: window must be string")
}
window, err := parseDuration(windowStr)
if err != nil {
return false, fmt.Errorf("is_round_trip: %w", err)
}
return store.RoundTrip(context.Background(), userID, window)
})
}
// countLastNh counts transactions in the last N hours for a user.
func countLastNh(store TransactionStore, params []any, hours int) (any, error) {
if len(params) < 1 {
return 0, fmt.Errorf("count_last_%dh requires userID", hours)
}
userID, ok := params[0].(string)
if !ok {
return 0, fmt.Errorf("count_last_%dh: userID must be string", hours)
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
return store.CountByUser(context.Background(), userID, since)
}
// sumLastNh sums notional in the last N hours for a user.
func sumLastNh(store TransactionStore, params []any, hours int) (any, error) {
if len(params) < 1 {
return 0.0, fmt.Errorf("sum_last_%dh requires userID", hours)
}
userID, ok := params[0].(string)
if !ok {
return 0.0, fmt.Errorf("sum_last_%dh: userID must be string", hours)
}
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
return store.SumNotionalByUser(context.Background(), userID, since)
}
// sumLastNd sums notional in the last N days for a user.
func sumLastNd(store TransactionStore, params []any, days int) (any, error) {
if len(params) < 1 {
return 0.0, fmt.Errorf("sum_last_%dd requires userID", days)
}
userID, ok := params[0].(string)
if !ok {
return 0.0, fmt.Errorf("sum_last_%dd: userID must be string", days)
}
since := time.Now().UTC().AddDate(0, 0, -days)
return store.SumNotionalByUser(context.Background(), userID, since)
}
// parseDuration parses human-friendly duration strings like "24h", "7d", "30d".
func parseDuration(s string) (time.Duration, error) {
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration %q", s)
}
unit := s[len(s)-1]
numStr := s[:len(s)-1]
var num int
_, err := fmt.Sscanf(numStr, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration number %q: %w", numStr, err)
}
switch unit {
case 'h':
return time.Duration(num) * time.Hour, nil
case 'd':
return time.Duration(num) * 24 * time.Hour, nil
case 'm':
return time.Duration(num) * time.Minute, nil
default:
return 0, fmt.Errorf("unknown duration unit %q (use h/d/m)", string(unit))
}
}
+319
View File
@@ -0,0 +1,319 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package engine
import (
"context"
"testing"
"time"
"github.com/luxfi/aml/pkg/types"
)
func seedStore(t *testing.T) (*MemoryStore, *Engine) {
t.Helper()
store := NewMemoryStore()
// Seed data: 3 transactions for user-A
now := time.Now().UTC()
txs := []types.Transaction{
{
ID: "tx-1", UserID: "user-A", Notional: 5000, Currency: "USD",
Symbol: "BTC", Side: "buy", Counterparty: "exchange-1",
CreatedAt: now.Add(-2 * time.Hour),
},
{
ID: "tx-2", UserID: "user-A", Notional: 15000, Currency: "USD",
Symbol: "BTC", Side: "sell", Counterparty: "exchange-2",
CreatedAt: now.Add(-1 * time.Hour),
},
{
ID: "tx-3", UserID: "user-A", Notional: 8000, Currency: "USD",
Symbol: "ETH", Side: "buy", Counterparty: "exchange-1",
CreatedAt: now.Add(-30 * time.Minute),
},
}
for _, tx := range txs {
if err := store.Save(context.Background(), tx); err != nil {
t.Fatalf("seed save: %v", err)
}
}
rules := []types.Rule{
{
ID: "test-rule", Name: "test", DSL: "true",
Enabled: true, Weight: 1.0, Action: types.ActionFlag,
},
}
eng := New(rules)
RegisterStoreHelpers(eng, store)
return store, eng
}
func TestHelpers_CountLast24h(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
fn := eval.funcs["count_last_24h"]
result, err := fn("user-A")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
count, ok := result.(int)
if !ok {
t.Fatalf("expected int, got %T", result)
}
if count != 3 {
t.Fatalf("expected 3 transactions, got %d", count)
}
}
func TestHelpers_CountLast24h_NoTxs(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
fn := eval.funcs["count_last_24h"]
result, err := fn("user-B")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
count := result.(int)
if count != 0 {
t.Fatalf("expected 0, got %d", count)
}
}
func TestHelpers_SumLast24h(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
fn := eval.funcs["sum_last_24h"]
result, err := fn("user-A")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
sum, ok := result.(float64)
if !ok {
t.Fatalf("expected float64, got %T", result)
}
if sum != 28000 { // 5000 + 15000 + 8000
t.Fatalf("expected 28000, got %f", sum)
}
}
func TestHelpers_SumLast30d(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
fn := eval.funcs["sum_last_30d"]
result, err := fn("user-A")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
sum := result.(float64)
if sum != 28000 {
t.Fatalf("expected 28000, got %f", sum)
}
}
func TestHelpers_FirstTx(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
fn := eval.funcs["first_tx"]
// user-A has transactions
result, err := fn("user-A")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.(bool) != false {
t.Fatal("expected false (not first tx)")
}
// user-B has no transactions
result, err = fn("user-B")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.(bool) != true {
t.Fatal("expected true (first tx)")
}
}
func TestHelpers_FirstCounterparty(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
fn := eval.funcs["first_counterparty"]
// user-A has transacted with exchange-1
result, err := fn("user-A", "exchange-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.(bool) != false {
t.Fatal("expected false (not first counterparty)")
}
// user-A has NOT transacted with exchange-3
result, err = fn("user-A", "exchange-3")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.(bool) != true {
t.Fatal("expected true (first counterparty)")
}
}
func TestHelpers_LastTxAge(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
fn := eval.funcs["last_tx_age"]
// user-A's last tx was ~30 min ago = 0 days
result, err := fn("user-A")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
days := result.(int)
if days != 0 {
t.Fatalf("expected 0 days (recent), got %d", days)
}
// user-B has no transactions = 999 days (sentinel)
result, err = fn("user-B")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
days = result.(int)
if days != 999 {
t.Fatalf("expected 999 days (never transacted), got %d", days)
}
}
func TestHelpers_IsRoundTrip(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
fn := eval.funcs["is_round_trip"]
// user-A bought and sold BTC within 24h
result, err := fn("user-A", "24h")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.(bool) != true {
t.Fatal("expected true (round trip detected)")
}
// user-B has no transactions
result, err = fn("user-B", "24h")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.(bool) != false {
t.Fatal("expected false (no round trip)")
}
}
func TestHelpers_MissingParams(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
tests := []struct {
name string
fn string
args []any
}{
{"count_last_24h no args", "count_last_24h", nil},
{"sum_last_24h no args", "sum_last_24h", nil},
{"sum_last_30d no args", "sum_last_30d", nil},
{"first_tx no args", "first_tx", nil},
{"first_counterparty no args", "first_counterparty", nil},
{"first_counterparty one arg", "first_counterparty", []any{"user-A"}},
{"last_tx_age no args", "last_tx_age", nil},
{"is_round_trip no args", "is_round_trip", nil},
{"is_round_trip one arg", "is_round_trip", []any{"user-A"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fn := eval.funcs[tt.fn]
_, err := fn(tt.args...)
if err == nil {
t.Fatalf("expected error for %s with args %v", tt.fn, tt.args)
}
})
}
}
func TestHelpers_InvalidTypes(t *testing.T) {
_, eng := seedStore(t)
eval := eng.Evaluator()
tests := []struct {
name string
fn string
args []any
}{
{"count_last_24h int arg", "count_last_24h", []any{123}},
{"first_tx int arg", "first_tx", []any{123}},
{"first_counterparty int,string", "first_counterparty", []any{123, "cp"}},
{"first_counterparty string,int", "first_counterparty", []any{"uid", 123}},
{"last_tx_age int arg", "last_tx_age", []any{123}},
{"is_round_trip int,string", "is_round_trip", []any{123, "24h"}},
{"is_round_trip string,int", "is_round_trip", []any{"uid", 123}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fn := eval.funcs[tt.fn]
_, err := fn(tt.args...)
if err == nil {
t.Fatalf("expected error for %s with args %v", tt.fn, tt.args)
}
})
}
}
func TestParseDuration(t *testing.T) {
tests := []struct {
input string
expected time.Duration
wantErr bool
}{
{"24h", 24 * time.Hour, false},
{"7d", 7 * 24 * time.Hour, false},
{"30d", 30 * 24 * time.Hour, false},
{"15m", 15 * time.Minute, false},
{"1h", 1 * time.Hour, false},
{"", 0, true},
{"x", 0, true},
{"24x", 0, true},
{"abc", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseDuration(tt.input)
if tt.wantErr && err == nil {
t.Fatal("expected error")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.expected {
t.Fatalf("expected %v, got %v", tt.expected, got)
}
})
}
}
func TestMemoryStore_Interface(t *testing.T) {
var _ TransactionStore = (*MemoryStore)(nil)
}
+154
View File
@@ -0,0 +1,154 @@
package engine
import (
"testing"
"github.com/luxfi/aml/pkg/cases"
"github.com/luxfi/aml/pkg/rules"
"github.com/luxfi/aml/pkg/types"
)
// TestIntegrationStructuringFlow is the integration test from the spec:
// POST a transaction with notional: 9500 and verify structuring fires,
// travel_rule fires, case opens on block/report, webhook queued.
func TestIntegrationStructuringFlow(t *testing.T) {
eng := New(rules.StarterRules("test-org"))
caseStore := cases.NewStore()
// Simulate 3 transactions from the same user at ~$9500 each.
txs := []types.Transaction{
{ID: "tx1", OrgID: "test-org", UserID: "u-structurer", Notional: 9200, Currency: "USD"},
{ID: "tx2", OrgID: "test-org", UserID: "u-structurer", Notional: 9400, Currency: "USD"},
{ID: "tx3", OrgID: "test-org", UserID: "u-structurer", Notional: 9500, Currency: "USD"},
}
entity := types.Entity{
ID: "u-structurer",
OrgID: "test-org",
Name: "Suspicious User",
}
for _, tx := range txs {
alerts, score, action := eng.Evaluate(tx, entity)
// Each tx should fire at least Structuring (flag) + Travel Rule (report).
if len(alerts) == 0 {
t.Fatalf("tx %s: expected alerts, got none", tx.ID)
}
// Score should be non-zero.
if score == 0 {
t.Fatalf("tx %s: expected non-zero score", tx.ID)
}
// Action should be at least report (travel rule outranks structuring flag).
if action != types.ActionReport {
t.Fatalf("tx %s: expected action=report, got %s", tx.ID, action)
}
// Verify structuring rule fired.
var structuringFired bool
for _, a := range alerts {
if a.RuleName == "Structuring" {
structuringFired = true
}
}
if !structuringFired {
t.Fatalf("tx %s: Structuring rule did not fire", tx.ID)
}
// Auto-create case on report.
alertIDs := make([]string, len(alerts))
for i, a := range alerts {
alertIDs[i] = a.ID
}
if action == types.ActionBlock || action == types.ActionReport {
c := caseStore.Create("test-org", types.SeverityCritical, alertIDs, []string{tx.UserID})
if c.ID == "" {
t.Fatal("case should have an ID")
}
if c.Status != types.CaseOpen {
t.Fatalf("case status = %s, want open", c.Status)
}
}
}
// Verify cases were created.
allCases := caseStore.List("test-org", "")
if len(allCases) != 3 {
t.Errorf("expected 3 cases (one per tx), got %d", len(allCases))
}
}
// TestIntegrationSanctionedBlock verifies a sanctioned jurisdiction blocks.
func TestIntegrationSanctionedBlock(t *testing.T) {
eng := New(rules.StarterRules("test-org"))
tx := types.Transaction{
ID: "tx-iran",
OrgID: "test-org",
UserID: "u-iran",
Notional: 1000,
Currency: "USD",
}
entity := types.Entity{
ID: "u-iran",
OrgID: "test-org",
Name: "Iranian Entity",
Jurisdiction: "IR",
}
alerts, _, action := eng.Evaluate(tx, entity)
if action != types.ActionBlock {
t.Errorf("sanctioned jurisdiction should block, got %s", action)
}
var sanctionedFired bool
for _, a := range alerts {
if a.RuleName == "Sanctioned Jurisdiction" {
sanctionedFired = true
}
}
if !sanctionedFired {
t.Error("Sanctioned Jurisdiction rule should fire for IR")
}
if len(alerts) == 0 {
t.Fatal("expected at least one alert")
}
}
// TestIntegrationCleanTransaction verifies clean traffic gets through.
func TestIntegrationCleanTransaction(t *testing.T) {
eng := New(rules.StarterRules("test-org"))
tx := types.Transaction{
ID: "tx-clean",
OrgID: "test-org",
UserID: "u-good",
Notional: 500,
Currency: "USD",
}
entity := types.Entity{
ID: "u-good",
OrgID: "test-org",
Name: "Good Customer",
Jurisdiction: "US",
KYCLevel: 3,
}
alerts, score, action := eng.Evaluate(tx, entity)
if action != types.ActionAllow {
t.Errorf("clean transaction should allow, got %s", action)
}
if len(alerts) != 0 {
t.Errorf("clean transaction should have 0 alerts, got %d", len(alerts))
}
if score != 0 {
t.Errorf("clean transaction score should be 0, got %f", score)
}
}
+53
View File
@@ -0,0 +1,53 @@
package engine
import "github.com/luxfi/aml/pkg/types"
// severityMultiplier returns a weight multiplier for a severity level.
func severityMultiplier(severity string) float64 {
switch severity {
case types.SeverityCritical:
return 2.0
case types.SeverityHigh:
return 1.5
case types.SeverityMedium:
return 1.0
case types.SeverityLow:
return 0.5
default:
return 1.0
}
}
// clamp01 clamps v to [0, 1].
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// Score computes a weight-of-evidence risk score from rule hits.
// Returns a score in [0,1] and a breakdown per rule ID.
func Score(hits []types.RuleHit) (float64, map[string]float64) {
breakdown := make(map[string]float64, len(hits))
var total float64
for _, h := range hits {
if !h.Match {
continue
}
s := h.Rule.Weight * severityMultiplier(h.Rule.Severity)
breakdown[h.Rule.ID] = s
total += s
}
return clamp01(total), breakdown
}
// ScorerPlugin is the interface for pluggable ML scoring.
// v1 uses weight-of-evidence only. This interface is the hook point
// for later Hanzo Zen gateway integration.
type ScorerPlugin interface {
Score(tx types.Transaction, entity types.Entity, hits []types.RuleHit) (float64, map[string]float64, error)
}
+95
View File
@@ -0,0 +1,95 @@
package engine
import (
"testing"
"github.com/luxfi/aml/pkg/types"
)
func TestSeverityMultiplier(t *testing.T) {
tests := []struct {
severity string
want float64
}{
{types.SeverityCritical, 2.0},
{types.SeverityHigh, 1.5},
{types.SeverityMedium, 1.0},
{types.SeverityLow, 0.5},
{"unknown", 1.0},
}
for _, tt := range tests {
got := severityMultiplier(tt.severity)
if got != tt.want {
t.Errorf("severityMultiplier(%q) = %f, want %f", tt.severity, got, tt.want)
}
}
}
func TestClamp01(t *testing.T) {
tests := []struct {
in, want float64
}{
{-1, 0},
{0, 0},
{0.5, 0.5},
{1, 1},
{2, 1},
}
for _, tt := range tests {
got := clamp01(tt.in)
if got != tt.want {
t.Errorf("clamp01(%f) = %f, want %f", tt.in, got, tt.want)
}
}
}
func TestScore(t *testing.T) {
hits := []types.RuleHit{
{Rule: types.Rule{ID: "r1", Weight: 0.3, Severity: types.SeverityHigh}, Match: true},
{Rule: types.Rule{ID: "r2", Weight: 0.2, Severity: types.SeverityMedium}, Match: true},
{Rule: types.Rule{ID: "r3", Weight: 0.1, Severity: types.SeverityLow}, Match: false}, // not matched
}
score, breakdown := Score(hits)
// r1: 0.3 * 1.5 = 0.45
// r2: 0.2 * 1.0 = 0.20
// r3: skipped (Match=false)
// total = 0.65
const eps = 1e-9
if score < 0.65-eps || score > 0.65+eps {
t.Errorf("Score = %f, want 0.65", score)
}
if v := breakdown["r1"]; v < 0.45-eps || v > 0.45+eps {
t.Errorf("breakdown[r1] = %f, want 0.45", v)
}
if v := breakdown["r2"]; v < 0.2-eps || v > 0.2+eps {
t.Errorf("breakdown[r2] = %f, want 0.2", v)
}
if _, ok := breakdown["r3"]; ok {
t.Error("breakdown should not contain r3")
}
}
func TestScoreClamps(t *testing.T) {
hits := []types.RuleHit{
{Rule: types.Rule{ID: "r1", Weight: 0.5, Severity: types.SeverityCritical}, Match: true},
{Rule: types.Rule{ID: "r2", Weight: 0.5, Severity: types.SeverityCritical}, Match: true},
}
score, _ := Score(hits)
// 0.5*2.0 + 0.5*2.0 = 2.0, clamped to 1.0
if score != 1.0 {
t.Errorf("Score = %f, want 1.0 (clamped)", score)
}
}
func TestScoreEmpty(t *testing.T) {
score, breakdown := Score(nil)
if score != 0 {
t.Errorf("Score = %f, want 0", score)
}
if len(breakdown) != 0 {
t.Errorf("breakdown should be empty, got %d entries", len(breakdown))
}
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package engine
import (
"context"
"time"
"github.com/luxfi/aml/pkg/types"
)
// TransactionStore is the storage interface for transaction history queries.
// Implementations back the helper functions used in the expr-lang DSL.
type TransactionStore interface {
// CountByUser returns the number of transactions for a user since the given time.
CountByUser(ctx context.Context, userID string, since time.Time) (int, error)
// SumNotionalByUser returns the total notional value for a user since the given time.
SumNotionalByUser(ctx context.Context, userID string, since time.Time) (float64, error)
// FirstTransaction returns true if this is the user's first transaction.
FirstTransaction(ctx context.Context, userID string) (bool, error)
// FirstCounterparty returns true if this is the first time the user has transacted
// with the given counterparty.
FirstCounterparty(ctx context.Context, userID, counterparty string) (bool, error)
// LastTransactionTime returns the timestamp of the user's most recent transaction.
LastTransactionTime(ctx context.Context, userID string) (time.Time, error)
// RoundTrip returns true if the user has both bought and sold the same asset
// within the given time window.
RoundTrip(ctx context.Context, userID string, window time.Duration) (bool, error)
// Save persists a transaction for subsequent queries.
Save(ctx context.Context, tx types.Transaction) error
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package engine
import (
"context"
"sync"
"time"
"github.com/luxfi/aml/pkg/types"
)
// MemoryStore is an in-memory TransactionStore for testing.
type MemoryStore struct {
mu sync.RWMutex
txs []types.Transaction
}
// NewMemoryStore creates an empty MemoryStore.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{}
}
func (m *MemoryStore) Save(_ context.Context, tx types.Transaction) error {
m.mu.Lock()
defer m.mu.Unlock()
if tx.CreatedAt.IsZero() {
tx.CreatedAt = time.Now().UTC()
}
m.txs = append(m.txs, tx)
return nil
}
func (m *MemoryStore) CountByUser(_ context.Context, userID string, since time.Time) (int, error) {
m.mu.RLock()
defer m.mu.RUnlock()
count := 0
for _, tx := range m.txs {
if tx.UserID == userID && !tx.CreatedAt.Before(since) {
count++
}
}
return count, nil
}
func (m *MemoryStore) SumNotionalByUser(_ context.Context, userID string, since time.Time) (float64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var total float64
for _, tx := range m.txs {
if tx.UserID == userID && !tx.CreatedAt.Before(since) {
total += tx.Notional
}
}
return total, nil
}
func (m *MemoryStore) FirstTransaction(_ context.Context, userID string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, tx := range m.txs {
if tx.UserID == userID {
return false, nil
}
}
return true, nil
}
func (m *MemoryStore) FirstCounterparty(_ context.Context, userID, counterparty string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, tx := range m.txs {
if tx.UserID == userID && tx.Counterparty == counterparty {
return false, nil
}
}
return true, nil
}
func (m *MemoryStore) LastTransactionTime(_ context.Context, userID string) (time.Time, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var latest time.Time
for _, tx := range m.txs {
if tx.UserID == userID && tx.CreatedAt.After(latest) {
latest = tx.CreatedAt
}
}
return latest, nil
}
func (m *MemoryStore) RoundTrip(_ context.Context, userID string, window time.Duration) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
since := time.Now().UTC().Add(-window)
sides := make(map[string]map[string]bool)
for _, tx := range m.txs {
if tx.UserID != userID || tx.CreatedAt.Before(since) {
continue
}
if tx.Symbol == "" || tx.Side == "" {
continue
}
if sides[tx.Symbol] == nil {
sides[tx.Symbol] = make(map[string]bool)
}
sides[tx.Symbol][tx.Side] = true
}
for _, sideSet := range sides {
if sideSet["buy"] && sideSet["sell"] {
return true, nil
}
}
return false, nil
}
+291
View File
@@ -0,0 +1,291 @@
// Package rules provides the AML rule DSL layer and the starter rule library.
package rules
import "github.com/luxfi/aml/pkg/types"
// StarterRules returns the 20 default AML rules covering standard typologies.
// These are seeded on first run for every org. Admins can disable or modify.
func StarterRules(orgID string) []types.Rule {
return []types.Rule{
{
ID: "rule-ctr-threshold",
OrgID: orgID,
Name: "CTR Threshold",
Description: "Currency Transaction Report: transactions over $10,000 USD equivalent",
DSL: `notional_usd(tx) > 10000`,
Severity: types.SeverityHigh,
Weight: 0.3,
Action: types.ActionReport,
Enabled: true,
Priority: 1,
},
{
ID: "rule-structuring",
OrgID: orgID,
Name: "Structuring",
Description: "Transactions just below CTR threshold — possible structuring",
DSL: `tx.Notional >= 9000 && tx.Notional < 10000`,
Severity: types.SeverityMedium,
Weight: 0.25,
Action: types.ActionFlag,
Enabled: true,
Priority: 2,
},
{
ID: "rule-velocity-daily",
OrgID: orgID,
Name: "Velocity Daily",
Description: "Daily transaction volume exceeds $50,000",
DSL: `sum_last_24h(tx.UserID) > 50000`,
Severity: types.SeverityMedium,
Weight: 0.2,
Action: types.ActionReview,
Enabled: true,
Priority: 3,
},
{
ID: "rule-velocity-monthly",
OrgID: orgID,
Name: "Velocity Monthly",
Description: "Monthly transaction volume exceeds $500,000",
DSL: `sum_last_30d(tx.UserID) > 500000`,
Severity: types.SeverityHigh,
Weight: 0.25,
Action: types.ActionReview,
Enabled: true,
Priority: 4,
},
{
ID: "rule-first-time-large",
OrgID: orgID,
Name: "First-Time Large",
Description: "First transaction from user exceeds $25,000",
DSL: `first_tx(tx.UserID) && tx.Notional > 25000`,
Severity: types.SeverityMedium,
Weight: 0.2,
Action: types.ActionReview,
Enabled: true,
Priority: 5,
},
{
ID: "rule-round-trip",
OrgID: orgID,
Name: "Round-Trip",
Description: "Funds sent and received within 24h — layering indicator",
DSL: `is_round_trip(tx.UserID, "24h")`,
Severity: types.SeverityMedium,
Weight: 0.2,
Action: types.ActionFlag,
Enabled: true,
Priority: 6,
},
{
ID: "rule-sanctioned-jurisdiction",
OrgID: orgID,
Name: "Sanctioned Jurisdiction",
Description: "Entity located in a sanctioned jurisdiction",
DSL: `entity.Jurisdiction in sanctioned_jurisdictions()`,
Severity: types.SeverityCritical,
Weight: 0.5,
Action: types.ActionBlock,
Enabled: true,
Priority: 7,
},
{
ID: "rule-pep-large",
OrgID: orgID,
Name: "PEP Large Transaction",
Description: "Politically exposed person transacting over $10,000",
DSL: `entity.PEP == true && tx.Notional > 10000`,
Severity: types.SeverityHigh,
Weight: 0.3,
Action: types.ActionReview,
Enabled: true,
Priority: 8,
},
{
ID: "rule-unusual-hour",
OrgID: orgID,
Name: "Unusual Hour",
Description: "Transaction during unusual hours (2-4 AM local time)",
DSL: `hour(tx.Timestamp, entity.Jurisdiction) in [2, 3, 4]`,
Severity: types.SeverityLow,
Weight: 0.1,
Action: types.ActionFlag,
Enabled: true,
Priority: 9,
},
{
ID: "rule-new-counterparty-large",
OrgID: orgID,
Name: "New Counterparty Large",
Description: "First transaction with counterparty exceeds $5,000",
DSL: `first_counterparty(tx.UserID, tx.Counterparty) && tx.Notional > 5000`,
Severity: types.SeverityMedium,
Weight: 0.15,
Action: types.ActionReview,
Enabled: true,
Priority: 10,
},
{
ID: "rule-dormant-reactivation",
OrgID: orgID,
Name: "Dormant Reactivation",
Description: "Account dormant >180 days reactivated with large transaction",
DSL: `last_tx_age(tx.UserID) > 180 && tx.Notional > 10000`,
Severity: types.SeverityMedium,
Weight: 0.2,
Action: types.ActionReview,
Enabled: true,
Priority: 11,
},
{
ID: "rule-crypto-mixer",
OrgID: orgID,
Name: "Crypto Mixer Address",
Description: "Counterparty is a known mixer address",
DSL: `is_mixer_address(tx.Counterparty)`,
Severity: types.SeverityCritical,
Weight: 0.5,
Action: types.ActionBlock,
Enabled: true,
Priority: 12,
AssetClassFilter: []string{"us_crypto", "crypto"},
},
{
ID: "rule-darknet-market",
OrgID: orgID,
Name: "Darknet Market",
Description: "Counterparty is a known darknet marketplace",
DSL: `is_darknet_market(tx.Counterparty)`,
Severity: types.SeverityCritical,
Weight: 0.5,
Action: types.ActionBlock,
Enabled: true,
Priority: 13,
AssetClassFilter: []string{"us_crypto", "crypto"},
},
{
ID: "rule-ip-geo-mismatch",
OrgID: orgID,
Name: "IP/Geo Mismatch",
Description: "Transaction IP does not match entity jurisdiction",
DSL: `!geo_match(tx.IPAddress, entity.Jurisdiction)`,
Severity: types.SeverityLow,
Weight: 0.1,
Action: types.ActionFlag,
Enabled: true,
Priority: 14,
},
{
ID: "rule-layering",
OrgID: orgID,
Name: "Layering Pattern",
Description: "Layering score exceeds 0.8 — complex transaction chains",
DSL: `layering_score(tx.UserID) > 0.8`,
Severity: types.SeverityHigh,
Weight: 0.3,
Action: types.ActionReview,
Enabled: true,
Priority: 15,
},
{
ID: "rule-smurfing",
OrgID: orgID,
Name: "Smurfing",
Description: "Multiple small deposits across accounts — smurfing pattern",
DSL: `smurfing_detected(tx.UserID, "7d")`,
Severity: types.SeverityHigh,
Weight: 0.3,
Action: types.ActionReview,
Enabled: true,
Priority: 16,
},
{
ID: "rule-wash-trading",
OrgID: orgID,
Name: "Wash Trading",
Description: "Self-trading detected within 1h window",
DSL: `wash_trade_detected(tx.Symbol, "1h")`,
Severity: types.SeverityMedium,
Weight: 0.2,
Action: types.ActionFlag,
Enabled: true,
Priority: 17,
},
{
ID: "rule-sanctions-direct",
OrgID: orgID,
Name: "Sanctions Direct",
Description: "Entity name matches sanctions list",
DSL: `sanctions_hit(entity.Name, entity.Jurisdiction)`,
Severity: types.SeverityCritical,
Weight: 0.5,
Action: types.ActionBlock,
Enabled: true,
Priority: 18,
},
{
ID: "rule-sanctions-counterparty",
OrgID: orgID,
Name: "Sanctions Counterparty",
Description: "Counterparty name matches sanctions list",
DSL: `sanctions_hit(tx.Counterparty, "")`,
Severity: types.SeverityCritical,
Weight: 0.5,
Action: types.ActionBlock,
Enabled: true,
Priority: 19,
},
{
ID: "rule-travel-rule",
OrgID: orgID,
Name: "Travel Rule",
Description: "Transaction exceeds FATF Travel Rule threshold ($3,000 USD equivalent)",
DSL: `notional_usd(tx) > 3000`,
Severity: types.SeverityMedium,
Weight: 0.15,
Action: types.ActionReport,
Enabled: true,
Priority: 20,
},
{
ID: "rule-travel-rule-incomplete",
OrgID: orgID,
Name: "Travel Rule Incomplete",
Description: "Travel rule triggered but originator/beneficiary info missing",
DSL: `tx.Notional > travel_rule_threshold(tx.Currency) && !travel_rule_complete(tx.ID)`,
Severity: types.SeverityHigh,
Weight: 0.25,
Action: types.ActionReview,
Enabled: true,
Priority: 21,
},
{
ID: "rule-chain-risk",
OrgID: orgID,
Name: "Chain Risk Check",
Description: "Counterparty address flagged by blockchain analytics",
DSL: `chain_risk_score(tx.Counterparty) > 0.7`,
Severity: types.SeverityHigh,
Weight: 0.35,
Action: types.ActionBlock,
Enabled: true,
Priority: 22,
AssetClassFilter: []string{"us_crypto", "crypto"},
},
{
ID: "rule-chain-sanctions",
OrgID: orgID,
Name: "Chain Sanctions",
Description: "Counterparty address is sanctions-listed on-chain",
DSL: `chain_is_sanctioned(tx.Counterparty)`,
Severity: types.SeverityCritical,
Weight: 0.5,
Action: types.ActionBlock,
Enabled: true,
Priority: 23,
AssetClassFilter: []string{"us_crypto", "crypto"},
},
}
}
+294
View File
@@ -0,0 +1,294 @@
package rules
import (
"testing"
"github.com/luxfi/aml/pkg/engine"
"github.com/luxfi/aml/pkg/types"
)
func TestStarterRulesCount(t *testing.T) {
rules := StarterRules("test")
if len(rules) != 23 {
t.Errorf("expected 23 starter rules, got %d", len(rules))
}
}
func TestStarterRulesAllEnabled(t *testing.T) {
for _, r := range StarterRules("test") {
if !r.Enabled {
t.Errorf("rule %q should be enabled", r.Name)
}
}
}
func TestStarterRulesUniqueIDs(t *testing.T) {
seen := make(map[string]bool)
for _, r := range StarterRules("test") {
if seen[r.ID] {
t.Errorf("duplicate rule ID: %s", r.ID)
}
seen[r.ID] = true
}
}
func TestStarterRulesUniquePriorities(t *testing.T) {
seen := make(map[int]bool)
for _, r := range StarterRules("test") {
if seen[r.Priority] {
t.Errorf("duplicate priority %d for rule %q", r.Priority, r.Name)
}
seen[r.Priority] = true
}
}
func TestStarterRulesAllCompile(t *testing.T) {
eval := engine.NewEvaluator()
for _, r := range StarterRules("test") {
_, err := eval.Compile(r)
if err != nil {
t.Errorf("rule %q failed to compile: %v", r.Name, err)
}
}
}
func TestStarterRulesValidSeverity(t *testing.T) {
valid := map[string]bool{
types.SeverityLow: true,
types.SeverityMedium: true,
types.SeverityHigh: true,
types.SeverityCritical: true,
}
for _, r := range StarterRules("test") {
if !valid[r.Severity] {
t.Errorf("rule %q has invalid severity: %s", r.Name, r.Severity)
}
}
}
func TestStarterRulesValidAction(t *testing.T) {
valid := map[string]bool{
types.ActionFlag: true,
types.ActionReview: true,
types.ActionBlock: true,
types.ActionReport: true,
}
for _, r := range StarterRules("test") {
if !valid[r.Action] {
t.Errorf("rule %q has invalid action: %s", r.Name, r.Action)
}
}
}
func TestStarterRulesAllHaveWeight(t *testing.T) {
for _, r := range StarterRules("test") {
if r.Weight <= 0 {
t.Errorf("rule %q has non-positive weight: %f", r.Name, r.Weight)
}
}
}
func TestStarterRulesOrgID(t *testing.T) {
for _, r := range StarterRules("my-org") {
if r.OrgID != "my-org" {
t.Errorf("rule %q has org_id %s, want my-org", r.Name, r.OrgID)
}
}
}
// Individual rule evaluation tests.
func TestRuleCTRFires(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-ctr-threshold")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 15000, Currency: "USD"},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if !match {
t.Error("CTR should fire for $15,000 USD")
}
}
// TestCTRFiresForEUR (RED-13) verifies that CTR fires for non-USD currencies
// when the USD equivalent exceeds $10,000. EUR 9999 * 1.08 = $10,798.
func TestCTRFiresForEUR(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-ctr-threshold")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 9999, Currency: "EUR"},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if !match {
t.Error("RED-13: CTR should fire for EUR 9999 ($10,799 at 1.08)")
}
}
// TestCTRNoFireJPYSmall (RED-13) verifies CTR does NOT fire for small JPY amounts.
func TestCTRNoFireJPYSmall(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-ctr-threshold")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 10001, Currency: "JPY"},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if match {
t.Error("RED-13: CTR should NOT fire for JPY 10001 ($67 at 0.0067)")
}
}
// TestCTRFiresJPYLarge (RED-13) verifies CTR fires for large JPY amounts.
func TestCTRFiresJPYLarge(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-ctr-threshold")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 1500000, Currency: "JPY"},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if !match {
t.Error("RED-13: CTR should fire for JPY 1500000 ($10,050 at 0.0067)")
}
}
func TestRuleStructuringFires(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-structuring")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 9500},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if !match {
t.Error("Structuring should fire for $9,500")
}
}
func TestRuleStructuringNoFire(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-structuring")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 10000},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if match {
t.Error("Structuring should not fire at exactly $10,000")
}
}
func TestRulePEPFires(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-pep-large")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 20000},
Entity: types.Entity{PEP: true},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if !match {
t.Error("PEP rule should fire for PEP with $20,000")
}
}
func TestRulePEPNoFireLow(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-pep-large")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 5000},
Entity: types.Entity{PEP: true},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if match {
t.Error("PEP rule should not fire for $5,000")
}
}
func TestRuleSanctionedJurisdiction(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-sanctioned-jurisdiction")
ctx := types.EvalContext{
Entity: types.Entity{Jurisdiction: "IR"},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if !match {
t.Error("sanctioned jurisdiction should fire for IR")
}
}
func TestRuleSanctionedJurisdictionSafe(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-sanctioned-jurisdiction")
ctx := types.EvalContext{
Entity: types.Entity{Jurisdiction: "US"},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if match {
t.Error("sanctioned jurisdiction should not fire for US")
}
}
func TestRuleTravelRule(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-travel-rule")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 5000, Currency: "USD"},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if !match {
t.Error("travel rule should fire for $5,000 (threshold $3,000)")
}
}
func TestRuleTravelRuleBelowThreshold(t *testing.T) {
eval := engine.NewEvaluator()
r := findRule("rule-travel-rule")
ctx := types.EvalContext{
Tx: types.Transaction{Notional: 2000, Currency: "USD"},
}
match, err := eval.Eval(r, ctx)
if err != nil {
t.Fatal(err)
}
if match {
t.Error("travel rule should not fire for $2,000")
}
}
func findRule(id string) types.Rule {
for _, r := range StarterRules("test") {
if r.ID == id {
return r
}
}
panic("rule not found: " + id)
}
+150
View File
@@ -0,0 +1,150 @@
package sanctions
import (
"bytes"
"context"
"crypto/sha256"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/luxfi/aml/pkg/types"
)
// maxBodySize is the maximum sanctions list download size (100 MB).
const maxBodySize = 100 * 1024 * 1024
// Ingester fetches and parses sanctions lists.
type Ingester struct {
client *http.Client
}
// NewIngester creates a new Ingester with a 60s timeout.
func NewIngester() *Ingester {
return &Ingester{
client: &http.Client{Timeout: 60 * time.Second},
}
}
// FetchOFAC fetches and parses the OFAC SDN XML list.
func (ing *Ingester) FetchOFAC(ctx context.Context, url string) ([]types.SanctionsEntry, string, error) {
body, hash, err := ing.fetch(ctx, url)
if err != nil {
return nil, "", fmt.Errorf("fetch OFAC: %w", err)
}
// RED-05: Use streaming decoder with entity expansion disabled to prevent
// billion-laughs XML bomb attacks. Strict mode + empty entity map means
// any entity reference in the XML causes a parse error instead of OOM.
var sdnList sdnListXML
d := xml.NewDecoder(bytes.NewReader(body))
d.Strict = true
d.Entity = map[string]string{} // disable entity expansion
if err := d.Decode(&sdnList); err != nil {
return nil, "", fmt.Errorf("parse OFAC XML: %w", err)
}
entries := make([]types.SanctionsEntry, 0, len(sdnList.Entries))
for _, e := range sdnList.Entries {
entryType := types.SanctionIndividual
if e.SDNType == "Entity" {
entryType = types.SanctionEntity
} else if e.SDNType == "Vessel" {
entryType = types.SanctionVessel
} else if e.SDNType == "Aircraft" {
entryType = types.SanctionAircraft
}
name := strings.TrimSpace(e.FirstName + " " + e.LastName)
aliases := make([]string, 0, len(e.Aliases))
for _, a := range e.Aliases {
an := strings.TrimSpace(a.FirstName + " " + a.LastName)
if an != "" {
aliases = append(aliases, an)
}
}
var dob string
for _, d := range e.DateOfBirth {
dob = d.DateOfBirth
break
}
var nationality string
for _, n := range e.Nationalities {
nationality = n.Country
break
}
entries = append(entries, types.SanctionsEntry{
RefID: fmt.Sprintf("%d", e.UID),
Name: name,
Aliases: aliases,
DOB: dob,
Nationality: nationality,
Type: entryType,
})
}
return entries, hash, nil
}
// fetch downloads a URL and returns the body bytes + SHA256 hex hash.
func (ing *Ingester) fetch(ctx context.Context, url string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, "", err
}
req.Header.Set("User-Agent", "luxfi-aml/1.0")
resp, err := ing.client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize))
if err != nil {
return nil, "", err
}
h := sha256.Sum256(body)
return body, fmt.Sprintf("%x", h), nil
}
// OFAC SDN XML structures.
type sdnListXML struct {
XMLName xml.Name `xml:"sdnList"`
Entries []sdnEntryXML `xml:"sdnEntry"`
}
type sdnEntryXML struct {
UID int `xml:"uid"`
FirstName string `xml:"firstName"`
LastName string `xml:"lastName"`
SDNType string `xml:"sdnType"`
Aliases []sdnAliasXML `xml:"akaList>aka"`
DateOfBirth []sdnDOBXML `xml:"dateOfBirthList>dateOfBirthItem"`
Nationalities []sdnNatXML `xml:"nationalityList>nationality"`
}
type sdnAliasXML struct {
FirstName string `xml:"firstName"`
LastName string `xml:"lastName"`
}
type sdnDOBXML struct {
DateOfBirth string `xml:"dateOfBirth"`
}
type sdnNatXML struct {
Country string `xml:"country"`
}
+99
View File
@@ -0,0 +1,99 @@
package sanctions
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// TestIngestXMLEntityExpansion (RED-05) verifies that the OFAC XML parser
// rejects entity expansion attacks (billion laughs / XML bombs). Without
// the streaming decoder fix, this would cause exponential memory growth.
func TestIngestXMLEntityExpansion(t *testing.T) {
// Classic billion-laughs XML bomb with nested entity expansion.
bomb := `<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
]>
<sdnList>
<sdnEntry>
<uid>1</uid>
<firstName>&lol4;</firstName>
<lastName>Test</lastName>
</sdnEntry>
</sdnList>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(bomb))
}))
defer srv.Close()
ing := NewIngester()
_, _, err := ing.FetchOFAC(context.Background(), srv.URL)
if err == nil {
t.Fatal("expected error parsing XML bomb with entity expansion, got nil")
}
// The error should be about entity references, not OOM.
t.Logf("correctly rejected XML bomb: %v", err)
}
// TestIngestValidOFACXML ensures normal OFAC XML still parses correctly
// after the streaming decoder change.
func TestIngestValidOFACXML(t *testing.T) {
validXML := `<?xml version="1.0"?>
<sdnList>
<sdnEntry>
<uid>1234</uid>
<firstName>John</firstName>
<lastName>Doe</lastName>
<sdnType>Individual</sdnType>
<akaList>
<aka>
<firstName>Johnny</firstName>
<lastName>Doe</lastName>
</aka>
</akaList>
</sdnEntry>
<sdnEntry>
<uid>5678</uid>
<firstName></firstName>
<lastName>Evil Corp</lastName>
<sdnType>Entity</sdnType>
</sdnEntry>
</sdnList>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(validXML))
}))
defer srv.Close()
ing := NewIngester()
entries, hash, err := ing.FetchOFAC(context.Background(), srv.URL)
if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
if hash == "" {
t.Fatal("expected non-empty hash")
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].Name != "John Doe" {
t.Errorf("entry[0].Name = %q, want %q", entries[0].Name, "John Doe")
}
if entries[0].Type != "individual" {
t.Errorf("entry[0].Type = %q, want %q", entries[0].Type, "individual")
}
if len(entries[0].Aliases) != 1 || entries[0].Aliases[0] != "Johnny Doe" {
t.Errorf("entry[0].Aliases = %v, want [Johnny Doe]", entries[0].Aliases)
}
if entries[1].Type != "entity" {
t.Errorf("entry[1].Type = %q, want %q", entries[1].Type, "entity")
}
}
+33
View File
@@ -0,0 +1,33 @@
package sanctions
import "github.com/luxfi/aml/pkg/types"
// DefaultLists returns the default sanctions list configurations.
func DefaultLists() []types.SanctionsList {
return []types.SanctionsList{
{
Source: types.ListOFACSDN,
URL: "https://www.treasury.gov/ofac/downloads/sdn.xml",
Format: "xml",
Active: true,
},
{
Source: types.ListUN,
URL: "https://scsanctions.un.org/resources/xml/en/consolidated.xml",
Format: "xml",
Active: true,
},
{
Source: types.ListEU,
URL: "https://webgate.ec.europa.eu/fsd/fsf/public/files/xmlFullSanctionsList_1_1/content",
Format: "xml",
Active: true,
},
{
Source: types.ListHMT,
URL: "https://ofsistorage.blob.core.windows.net/publishlive/ConList.xml",
Format: "xml",
Active: true,
},
}
}
+254
View File
@@ -0,0 +1,254 @@
// Package sanctions implements name-matching and list ingestion for
// OFAC SDN, UN, EU, and HMT sanctions lists.
package sanctions
import (
"math"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// JaroWinkler computes the Jaro-Winkler distance between two strings.
// Returns a value in [0, 1] where 1 is an exact match.
func JaroWinkler(s1, s2 string) float64 {
s1 = normalize(s1)
s2 = normalize(s2)
if s1 == s2 {
return 1.0
}
if len(s1) == 0 || len(s2) == 0 {
return 0.0
}
jaro := jaroDistance(s1, s2)
// Winkler prefix bonus: up to 4 common prefix chars.
prefix := 0
for i := 0; i < min(len(s1), len(s2)) && i < 4; i++ {
if s1[i] != s2[i] {
break
}
prefix++
}
return jaro + float64(prefix)*0.1*(1.0-jaro)
}
// jaroDistance computes the Jaro distance between two strings.
func jaroDistance(s1, s2 string) float64 {
if s1 == s2 {
return 1.0
}
l1, l2 := len(s1), len(s2)
matchWindow := max(l1, l2)/2 - 1
if matchWindow < 0 {
matchWindow = 0
}
s1Matches := make([]bool, l1)
s2Matches := make([]bool, l2)
var matches float64
var transpositions float64
for i := 0; i < l1; i++ {
lo := max(0, i-matchWindow)
hi := min(l2-1, i+matchWindow)
for j := lo; j <= hi; j++ {
if s2Matches[j] || s1[i] != s2[j] {
continue
}
s1Matches[i] = true
s2Matches[j] = true
matches++
break
}
}
if matches == 0 {
return 0.0
}
k := 0
for i := 0; i < l1; i++ {
if !s1Matches[i] {
continue
}
for !s2Matches[k] {
k++
}
if s1[i] != s2[k] {
transpositions++
}
k++
}
return (matches/float64(l1) + matches/float64(l2) + (matches-transpositions/2)/matches) / 3.0
}
// cyrillicToLatin maps Cyrillic homoglyphs to their Latin equivalents.
// RED-10: Prevents homoglyph evasion (e.g., Cyrillic 'а' vs Latin 'a').
var cyrillicToLatin = map[rune]rune{
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e',
'ё': 'e', 'ж': 'z', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k',
'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r',
'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'c',
'ч': 'c', 'ш': 's', 'щ': 's', 'ъ': ' ', 'ы': 'y', 'ь': ' ',
'э': 'e', 'ю': 'y', 'я': 'y',
}
// normalize performs NFKD Unicode normalization, Cyrillic→Latin transliteration,
// lowercasing, and non-alphanumeric stripping.
// RED-10: NFKD normalization decomposes compatibility characters, and the
// transliteration map catches Cyrillic homoglyphs used to evade sanctions.
func normalize(s string) string {
// NFKD decomposition — normalizes ligatures, fullwidth chars, etc.
s = norm.NFKD.String(s)
s = strings.ToLower(s)
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
// Cyrillic transliteration.
if lat, ok := cyrillicToLatin[r]; ok {
if lat != ' ' {
b.WriteRune(lat)
}
continue
}
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// tokenMatchScore computes the average best Jaro-Winkler per input token.
func tokenMatchScore(inputTokens, candidateTokens []string) float64 {
if len(inputTokens) == 0 || len(candidateTokens) == 0 {
return 0.0
}
var totalBest float64
for _, it := range inputTokens {
best := 0.0
for _, ct := range candidateTokens {
score := JaroWinkler(it, ct)
best = math.Max(best, score)
}
totalBest += best
}
return totalBest / float64(len(inputTokens))
}
// reverseTokens returns the tokens in reverse order.
func reverseTokens(tokens []string) []string {
n := len(tokens)
rev := make([]string, n)
for i, t := range tokens {
rev[n-1-i] = t
}
return rev
}
// soundex computes a simplified Soundex code for a string.
func soundex(s string) string {
s = strings.ToUpper(normalize(s))
if len(s) == 0 {
return ""
}
codes := map[byte]byte{
'B': '1', 'F': '1', 'P': '1', 'V': '1',
'C': '2', 'G': '2', 'J': '2', 'K': '2', 'Q': '2', 'S': '2', 'X': '2', 'Z': '2',
'D': '3', 'T': '3',
'L': '4',
'M': '5', 'N': '5',
'R': '6',
}
result := []byte{s[0]}
lastCode := codes[s[0]]
for i := 1; i < len(s) && len(result) < 4; i++ {
code, ok := codes[s[i]]
if ok && code != lastCode {
result = append(result, code)
lastCode = code
} else if !ok {
lastCode = 0
}
}
for len(result) < 4 {
result = append(result, '0')
}
return string(result)
}
// TokenMatch computes token-based matching with name reversal and phonetic fallback.
// RED-10: Runs both forward and reversed token orderings to catch "Jong Un Kim"
// matching "Kim Jong Un". Adds Soundex as a phonetic tie-breaker.
func TokenMatch(input, candidate string) float64 {
inputTokens := tokenize(input)
candidateTokens := tokenize(candidate)
if len(inputTokens) == 0 || len(candidateTokens) == 0 {
return 0.0
}
// Forward match.
forward := tokenMatchScore(inputTokens, candidateTokens)
// Reversed name-order match.
reversed := tokenMatchScore(reverseTokens(inputTokens), candidateTokens)
best := math.Max(forward, reversed)
// Phonetic fallback: if token match is marginal, check Soundex similarity.
if best < MatchThreshold && best > 0.5 {
phonForward := tokenMatchScore(soundexTokens(inputTokens), soundexTokens(candidateTokens))
phonReversed := tokenMatchScore(soundexTokens(reverseTokens(inputTokens)), soundexTokens(candidateTokens))
phonBest := math.Max(phonForward, phonReversed)
// Blend: 70% string match + 30% phonetic.
blended := best*0.7 + phonBest*0.3
best = math.Max(best, blended)
}
return best
}
// soundexTokens converts string tokens to their Soundex codes.
func soundexTokens(tokens []string) []string {
out := make([]string, len(tokens))
for i, t := range tokens {
out[i] = soundex(t)
}
return out
}
// tokenize splits a string on whitespace and returns non-empty tokens.
func tokenize(s string) []string {
parts := strings.Fields(strings.ToLower(s))
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// MatchThreshold is the default Jaro-Winkler threshold for a sanctions match.
const MatchThreshold = 0.85
// Match checks if a name matches any entry in the list above the threshold.
// Returns matching entries with their scores.
type MatchResult struct {
EntryID string `json:"entry_id"`
ListID string `json:"list_id"`
Name string `json:"name"`
Score float64 `json:"score"`
MatchType string `json:"match_type"` // exact, fuzzy, token
}
+168
View File
@@ -0,0 +1,168 @@
package sanctions
import (
"math"
"testing"
)
func TestJaroWinklerExact(t *testing.T) {
score := JaroWinkler("john smith", "john smith")
if score != 1.0 {
t.Errorf("exact match: got %f, want 1.0", score)
}
}
func TestJaroWinklerEmpty(t *testing.T) {
if JaroWinkler("", "abc") != 0.0 {
t.Error("empty s1 should return 0")
}
if JaroWinkler("abc", "") != 0.0 {
t.Error("empty s2 should return 0")
}
}
func TestJaroWinklerSimilar(t *testing.T) {
score := JaroWinkler("john smith", "jon smith")
if score < 0.9 {
t.Errorf("similar names: got %f, want >= 0.9", score)
}
}
func TestJaroWinklerDifferent(t *testing.T) {
score := JaroWinkler("john smith", "alice jones")
if score > 0.7 {
t.Errorf("different names: got %f, want < 0.7", score)
}
}
func TestJaroWinklerCaseInsensitive(t *testing.T) {
s1 := JaroWinkler("John Smith", "john smith")
s2 := JaroWinkler("JOHN SMITH", "john smith")
if s1 != 1.0 || s2 != 1.0 {
t.Errorf("case insensitive: got %f, %f — want 1.0 for both", s1, s2)
}
}
func TestJaroWinklerCommonPrefix(t *testing.T) {
// Winkler bonus rewards common prefix.
withPrefix := JaroWinkler("johnson", "johnsn")
noPrefix := JaroWinkler("nohnson", "noahson")
if withPrefix <= noPrefix {
t.Errorf("common prefix should score higher: withPrefix=%f, noPrefix=%f", withPrefix, noPrefix)
}
}
func TestNormalize(t *testing.T) {
tests := []struct {
in, want string
}{
{"John Smith", "johnsmith"},
{"O'Brien", "obrien"},
{"Al-Rashid", "alrashid"},
{" spaces ", "spaces"},
{"Números123", "numeros123"},
}
for _, tt := range tests {
got := normalize(tt.in)
if got != tt.want {
t.Errorf("normalize(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTokenMatch(t *testing.T) {
score := TokenMatch("John Smith", "Smith John")
if score < 0.95 {
t.Errorf("reordered tokens: got %f, want >= 0.95", score)
}
}
func TestTokenMatchPartial(t *testing.T) {
score := TokenMatch("John A Smith", "John Smith")
// "John" matches perfectly, "A" doesn't match well, "Smith" matches perfectly.
if score < 0.6 {
t.Errorf("partial token match: got %f, want >= 0.6", score)
}
}
func TestTokenMatchEmpty(t *testing.T) {
if TokenMatch("", "anything") != 0.0 {
t.Error("empty input should return 0")
}
if TokenMatch("anything", "") != 0.0 {
t.Error("empty candidate should return 0")
}
}
func approxEqual(a, b, tolerance float64) bool {
return math.Abs(a-b) < tolerance
}
func TestJaroWinklerSymmetric(t *testing.T) {
s1 := JaroWinkler("martha", "marhta")
s2 := JaroWinkler("marhta", "martha")
if !approxEqual(s1, s2, 0.001) {
t.Errorf("JaroWinkler should be symmetric: %f vs %f", s1, s2)
}
}
func TestJaroWinklerKnownValue(t *testing.T) {
// Classic Jaro-Winkler example: MARTHA/MARHTA.
score := JaroWinkler("martha", "marhta")
if score < 0.96 {
t.Errorf("MARTHA/MARHTA: got %f, want >= 0.96", score)
}
}
// TestMatchCyrillicHomoglyph (RED-10) verifies that Cyrillic homoglyphs
// are transliterated before matching, catching evasion attempts.
func TestMatchCyrillicHomoglyph(t *testing.T) {
// "Ким Йонг Ун" (Cyrillic) vs "Kim Jong Un" (Latin)
score := TokenMatch("Ким Йонг Ун", "Kim Jong Un")
if score < 0.7 {
t.Errorf("RED-10 Cyrillic homoglyph: score=%f, want >= 0.7", score)
}
}
// TestMatchReversedName (RED-10) verifies that reversed name order still matches.
func TestMatchReversedName(t *testing.T) {
score := TokenMatch("Jong Un Kim", "Kim Jong Un")
if score < 0.8 {
t.Errorf("RED-10 reversed name: score=%f, want >= 0.8", score)
}
}
// TestMatchTransliteration (RED-10) verifies transliteration of Cyrillic names.
func TestMatchTransliteration(t *testing.T) {
// Common Cyrillic→Latin transliteration test.
score := TokenMatch("Путин Владимир", "Putin Vladimir")
if score < 0.5 {
t.Errorf("RED-10 transliteration: score=%f, want >= 0.5", score)
}
}
// TestNormalizeNFKD (RED-10) verifies NFKD normalization handles fullwidth chars.
func TestNormalizeNFKD(t *testing.T) {
// Fullwidth "John" should normalize to "john".
got := normalize("John")
if got != "john" {
t.Errorf("normalize fullwidth = %q, want %q", got, "john")
}
}
func TestSoundex(t *testing.T) {
tests := []struct {
in, want string
}{
{"Robert", "R163"},
{"Rupert", "R163"},
{"Smith", "S530"},
{"Smyth", "S530"},
}
for _, tt := range tests {
got := soundex(tt.in)
if got != tt.want {
t.Errorf("soundex(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package sanctions
import (
"context"
"fmt"
"strings"
"time"
"github.com/hanzoai/base/core"
"github.com/luxfi/aml/pkg/types"
)
const sanctionsCollection = "aml_sanctions"
// SanctionsStore is the interface for persistent sanctions data.
type SanctionsStore interface {
// Save persists a batch of sanctions entries.
Save(ctx context.Context, entries []types.SanctionsEntry) error
// Search finds entries matching a name above the match threshold.
Search(ctx context.Context, name, dob string) ([]SearchResult, error)
// Count returns the total number of entries.
Count(ctx context.Context) (int, error)
// LastRefresh returns the time of the most recent data load.
LastRefresh(ctx context.Context) (time.Time, error)
}
// SearchResult is a sanctions search match.
type SearchResult struct {
Entry types.SanctionsEntry
Score float64
}
// BaseSanctionsStore implements SanctionsStore using Base collections.
type BaseSanctionsStore struct {
app core.App
lastRefresh time.Time
}
// NewBaseSanctionsStore creates a BaseSanctionsStore.
func NewBaseSanctionsStore(app core.App) *BaseSanctionsStore {
return &BaseSanctionsStore{app: app}
}
func (s *BaseSanctionsStore) Save(ctx context.Context, entries []types.SanctionsEntry) error {
collection, err := s.app.FindCollectionByNameOrId(sanctionsCollection)
if err != nil {
return fmt.Errorf("find sanctions collection: %w", err)
}
for _, e := range entries {
record := core.NewRecord(collection)
record.Set("ref_id", e.RefID)
record.Set("list_id", e.ListID)
record.Set("name", e.Name)
record.Set("aliases", strings.Join(e.Aliases, "|"))
record.Set("dob", e.DOB)
record.Set("nationality", e.Nationality)
record.Set("address", e.Address)
record.Set("entry_type", e.Type)
if err := s.app.Save(record); err != nil {
return fmt.Errorf("save sanctions entry %s: %w", e.RefID, err)
}
}
s.lastRefresh = time.Now().UTC()
return nil
}
func (s *BaseSanctionsStore) Search(ctx context.Context, name, dob string) ([]SearchResult, error) {
records, err := s.app.FindRecordsByFilter(
sanctionsCollection,
"", // no filter — we do fuzzy matching in-process
"",
0,
0,
)
if err != nil {
return nil, fmt.Errorf("search sanctions: %w", err)
}
var results []SearchResult
for _, r := range records {
entryName := r.GetString("name")
score := TokenMatch(name, entryName)
if score >= MatchThreshold {
results = append(results, SearchResult{
Entry: recordToEntry(r),
Score: score,
})
continue
}
// Check aliases.
aliasStr := r.GetString("aliases")
if aliasStr != "" {
for _, alias := range strings.Split(aliasStr, "|") {
aliasScore := TokenMatch(name, alias)
if aliasScore >= MatchThreshold && aliasScore > score {
results = append(results, SearchResult{
Entry: recordToEntry(r),
Score: aliasScore,
})
break
}
}
}
}
return results, nil
}
func (s *BaseSanctionsStore) Count(ctx context.Context) (int, error) {
records, err := s.app.FindRecordsByFilter(sanctionsCollection, "", "", 0, 0)
if err != nil {
return 0, err
}
return len(records), nil
}
func (s *BaseSanctionsStore) LastRefresh(_ context.Context) (time.Time, error) {
return s.lastRefresh, nil
}
func recordToEntry(r *core.Record) types.SanctionsEntry {
aliasStr := r.GetString("aliases")
var aliases []string
if aliasStr != "" {
aliases = strings.Split(aliasStr, "|")
}
return types.SanctionsEntry{
ID: r.Id,
ListID: r.GetString("list_id"),
RefID: r.GetString("ref_id"),
Name: r.GetString("name"),
Aliases: aliases,
DOB: r.GetString("dob"),
Nationality: r.GetString("nationality"),
Address: r.GetString("address"),
Type: r.GetString("entry_type"),
}
}
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package sanctions
import (
"context"
"sync"
"testing"
"time"
"github.com/luxfi/aml/pkg/types"
)
// MemorySanctionsStore is an in-memory SanctionsStore for testing.
type MemorySanctionsStore struct {
mu sync.RWMutex
entries []types.SanctionsEntry
lastRefresh time.Time
}
func NewMemorySanctionsStore() *MemorySanctionsStore {
return &MemorySanctionsStore{}
}
func (m *MemorySanctionsStore) Save(_ context.Context, entries []types.SanctionsEntry) error {
m.mu.Lock()
defer m.mu.Unlock()
m.entries = append(m.entries, entries...)
m.lastRefresh = time.Now().UTC()
return nil
}
func (m *MemorySanctionsStore) Search(_ context.Context, name, dob string) ([]SearchResult, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var results []SearchResult
for _, e := range m.entries {
score := TokenMatch(name, e.Name)
if score >= MatchThreshold {
results = append(results, SearchResult{Entry: e, Score: score})
continue
}
for _, alias := range e.Aliases {
aliasScore := TokenMatch(name, alias)
if aliasScore >= MatchThreshold {
results = append(results, SearchResult{Entry: e, Score: aliasScore})
break
}
}
}
return results, nil
}
func (m *MemorySanctionsStore) Count(_ context.Context) (int, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.entries), nil
}
func (m *MemorySanctionsStore) LastRefresh(_ context.Context) (time.Time, error) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.lastRefresh, nil
}
func TestMemorySanctionsStore_Save(t *testing.T) {
store := NewMemorySanctionsStore()
entries := []types.SanctionsEntry{
{ID: "e-1", Name: "John Doe", Type: types.SanctionIndividual},
{ID: "e-2", Name: "Acme Corp", Type: types.SanctionEntity},
}
err := store.Save(context.Background(), entries)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
count, err := store.Count(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if count != 2 {
t.Fatalf("expected 2, got %d", count)
}
}
func TestMemorySanctionsStore_Search(t *testing.T) {
store := NewMemorySanctionsStore()
store.Save(context.Background(), []types.SanctionsEntry{
{ID: "e-1", Name: "John Smith", Aliases: []string{"J. Smith", "Johnny Smith"}},
{ID: "e-2", Name: "Jane Doe"},
{ID: "e-3", Name: "Unrelated Person"},
})
// Exact-ish match
results, err := store.Search(context.Background(), "John Smith", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) == 0 {
t.Fatal("expected at least 1 result for 'John Smith'")
}
if results[0].Entry.Name != "John Smith" {
t.Fatalf("expected John Smith, got %q", results[0].Entry.Name)
}
// No match
results, err = store.Search(context.Background(), "Totally Different Name", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) != 0 {
t.Fatalf("expected 0 results, got %d", len(results))
}
}
func TestMemorySanctionsStore_SearchAlias(t *testing.T) {
store := NewMemorySanctionsStore()
store.Save(context.Background(), []types.SanctionsEntry{
{ID: "e-alias", Name: "Muhammad al-Bashir", Aliases: []string{"Omar al-Bashir", "Bashir"}},
})
results, err := store.Search(context.Background(), "Omar al-Bashir", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(results) == 0 {
t.Fatal("expected alias match")
}
}
func TestMemorySanctionsStore_LastRefresh(t *testing.T) {
store := NewMemorySanctionsStore()
before, _ := store.LastRefresh(context.Background())
if !before.IsZero() {
t.Fatal("expected zero time before any save")
}
store.Save(context.Background(), []types.SanctionsEntry{
{ID: "e-1", Name: "Test"},
})
after, _ := store.LastRefresh(context.Background())
if after.IsZero() {
t.Fatal("expected non-zero time after save")
}
}
func TestSanctionsStoreInterface(t *testing.T) {
var _ SanctionsStore = (*MemorySanctionsStore)(nil)
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2024-2026 Lux Partners Limited. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package sanctions
import (
"context"
"fmt"
"log"
"time"
"github.com/hanzoai/base/core"
"github.com/luxfi/aml/pkg/types"
)
// RefreshCron registers a daily 06:00 UTC cron that fetches all sanctions
// lists, diffs against stored entries, and persists updates.
func RefreshCron(app core.App, store SanctionsStore) {
app.Cron().Add("sanctions-refresh", "0 6 * * *", func() {
if err := RefreshAll(context.Background(), store); err != nil {
log.Printf("[sanctions-refresh] error: %v", err)
}
})
}
// RefreshAll fetches OFAC, UN, EU, and HMT lists, and persists them
// via the SanctionsStore. Returns the count of new entries added.
func RefreshAll(ctx context.Context, store SanctionsStore) error {
ingester := NewIngester()
lists := DefaultLists()
var totalEntries int
for _, list := range lists {
if !list.Active {
continue
}
var entries []types.SanctionsEntry
var hash string
var err error
switch list.Source {
case types.ListOFACSDN:
entries, hash, err = ingester.FetchOFAC(ctx, list.URL)
default:
// UN, EU, HMT use the same OFAC parser for now — their XML
// structures are similar enough. Production should have dedicated
// parsers per list format.
entries, hash, err = ingester.FetchOFAC(ctx, list.URL)
}
if err != nil {
log.Printf("[sanctions-refresh] fetch %s failed: %v", list.Source, err)
continue
}
// Tag entries with list source.
for i := range entries {
entries[i].ListID = list.Source
entries[i].ID = fmt.Sprintf("%s-%s", list.Source, entries[i].RefID)
now := time.Now().UTC()
entries[i].CreatedAt = now
entries[i].UpdatedAt = now
}
if err := store.Save(ctx, entries); err != nil {
log.Printf("[sanctions-refresh] save %s failed: %v", list.Source, err)
continue
}
totalEntries += len(entries)
log.Printf("[sanctions-refresh] %s: %d entries (hash: %s)", list.Source, len(entries), hash[:16])
}
log.Printf("[sanctions-refresh] complete: %d total entries across %d lists", totalEntries, len(lists))
return nil
}
+261
View File
@@ -0,0 +1,261 @@
// Package types defines the canonical domain types for the AML engine.
// These types are the single source of truth for all AML operations.
package types
import (
"encoding/json"
"time"
)
// Severity levels for rules and alerts.
const (
SeverityLow = "low"
SeverityMedium = "medium"
SeverityHigh = "high"
SeverityCritical = "critical"
)
// Action constants for rule outcomes and transaction responses.
const (
ActionAllow = "allow"
ActionFlag = "flag"
ActionReview = "review"
ActionBlock = "block"
ActionReport = "report"
)
// Case statuses.
const (
CaseOpen = "open"
CaseInReview = "in_review"
CaseEscalated = "escalated"
CaseClosed = "closed"
)
// Case resolutions.
const (
ResolutionCleared = "cleared"
ResolutionSARFiled = "sar_filed"
ResolutionAccountFrozen = "account_frozen"
ResolutionFalsePositive = "false_positive"
)
// Alert decisions.
const (
DecisionApproved = "approved"
DecisionRejected = "rejected"
DecisionEscalated = "escalated"
)
// Entity types.
const (
EntityUser = "user"
EntityAccount = "account"
EntityCounterparty = "counterparty"
EntityBankAccount = "bank_account"
)
// Case event kinds.
const (
EventNote = "note"
EventStatusChange = "status_change"
EventFile = "file"
EventWebhook = "webhook_event"
)
// Sanctions list sources.
const (
ListOFACSDN = "ofac_sdn"
ListUN = "un"
ListEU = "eu"
ListHMT = "hmt"
ListInterpol = "interpol"
)
// Sanctions entry types.
const (
SanctionIndividual = "individual"
SanctionEntity = "entity"
SanctionVessel = "vessel"
SanctionAircraft = "aircraft"
)
// Webhook event types.
const (
WebhookAMLFlagged = "aml.flagged"
WebhookAMLCleared = "aml.cleared"
WebhookCaseOpened = "case.opened"
WebhookCaseClosed = "case.closed"
WebhookKYCApproved = "kyc.approved"
WebhookTradeExecuted = "trade.executed"
)
// Transaction is an incoming transaction event for AML evaluation.
type Transaction struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
TenantID string `json:"tenant_id,omitempty"`
Source string `json:"source"`
UserID string `json:"user_id"`
AccountID string `json:"account_id,omitempty"`
Symbol string `json:"symbol,omitempty"`
AssetClass string `json:"asset_class,omitempty"`
Side string `json:"side,omitempty"`
Qty float64 `json:"qty"`
Notional float64 `json:"notional"`
Currency string `json:"currency"`
Counterparty string `json:"counterparty,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
DeviceFingerprint string `json:"device_fingerprint,omitempty"`
Timestamp time.Time `json:"timestamp"`
Raw json.RawMessage `json:"raw,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Entity is a normalized actor (user, account, counterparty).
type Entity struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
EntityType string `json:"entity_type"`
ExternalID string `json:"external_id,omitempty"`
Name string `json:"name"`
Jurisdiction string `json:"jurisdiction,omitempty"`
KYCLevel int `json:"kyc_level"`
PEP bool `json:"pep"`
SanctionsFlag bool `json:"sanctions_flag"`
RiskScore float64 `json:"risk_score"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Rule is an AML evaluation rule using expr-lang DSL.
type Rule struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
Name string `json:"name"`
Description string `json:"description"`
DSL string `json:"dsl"`
Severity string `json:"severity"`
Weight float64 `json:"weight"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
JurisdictionFilter []string `json:"jurisdiction_filter,omitempty"`
AssetClassFilter []string `json:"asset_class_filter,omitempty"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Alert is a rule hit — one per transaction per rule.
type Alert struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
TxID string `json:"tx_id"`
RuleID string `json:"rule_id"`
RuleName string `json:"rule_name"`
Severity string `json:"severity"`
Score float64 `json:"score"`
ScoreBreakdown map[string]float64 `json:"score_breakdown,omitempty"`
ActionTaken string `json:"action_taken"`
ReviewedBy string `json:"reviewed_by,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
Decision string `json:"decision,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Case is a human review case.
type Case struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
Number int64 `json:"number"`
Status string `json:"status"`
Severity string `json:"severity"`
EntityIDs []string `json:"entity_ids,omitempty"`
AlertIDs []string `json:"alert_ids,omitempty"`
AssigneeID string `json:"assignee_id,omitempty"`
OpenedAt time.Time `json:"opened_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
Resolution string `json:"resolution,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CaseEvent is a case timeline entry.
type CaseEvent struct {
ID string `json:"id"`
CaseID string `json:"case_id"`
AuthorID string `json:"author_id"`
Kind string `json:"kind"`
Body string `json:"body,omitempty"`
FilePath string `json:"file_path,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// SanctionsList is list metadata.
type SanctionsList struct {
ID string `json:"id"`
Source string `json:"source"`
URL string `json:"url"`
Format string `json:"format"`
FetchedAt time.Time `json:"fetched_at"`
SHA256 string `json:"sha256"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SanctionsEntry is a flattened sanctions list entry.
type SanctionsEntry struct {
ID string `json:"id"`
ListID string `json:"list_id"`
RefID string `json:"ref_id"`
Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"`
DOB string `json:"dob,omitempty"`
Nationality string `json:"nationality,omitempty"`
Address string `json:"address,omitempty"`
Type string `json:"type"`
Raw json.RawMessage `json:"raw,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Webhook is a subscriber configuration.
type Webhook struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
URL string `json:"url"`
Secret string `json:"secret"`
Events []string `json:"events"`
Enabled bool `json:"enabled"`
LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`
FailureCount int `json:"failure_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RuleHit is the result of evaluating a single rule against a transaction.
type RuleHit struct {
Rule Rule
Match bool
Score float64
EvalErr string // non-empty if the rule evaluation failed (fail-closed)
}
// EvalContext is the evaluation context passed to expr-lang rules.
type EvalContext struct {
Tx Transaction `json:"tx"`
Entity Entity `json:"entity"`
}
// EvalResult is the synchronous response to a transaction ingestion.
type EvalResult struct {
Action string `json:"action"`
Score float64 `json:"score"`
AlertIDs []string `json:"alert_ids"`
CaseID string `json:"case_id,omitempty"`
}
+181
View File
@@ -0,0 +1,181 @@
// Package webhook provides signed outbound webhook delivery with
// exponential backoff and dead-letter semantics.
package webhook
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/luxfi/aml/pkg/types"
)
// MaxRetries is the maximum delivery attempts before dead-lettering.
const MaxRetries = 5
// maxResponseBody is the max response body to read from webhook targets.
const maxResponseBody = 1024
// Payload is the webhook delivery body.
type Payload struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data interface{} `json:"data"`
}
// Deliver sends a signed webhook to the subscriber URL.
// The signature is HMAC-SHA256 of the JSON body using the webhook secret.
// Returns nil on 2xx, error otherwise.
func Deliver(ctx context.Context, wh types.Webhook, event string, data interface{}) error {
payload := Payload{
Event: event,
Timestamp: time.Now().UTC(),
Data: data,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("webhook marshal: %w", err)
}
sig := Sign(body, wh.Secret)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, wh.URL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Webhook-Signature", sig)
req.Header.Set("X-Webhook-Event", event)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("webhook deliver: %w", err)
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBody))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook %s returned %d", wh.URL, resp.StatusCode)
}
return nil
}
// Sign computes HMAC-SHA256 of body using secret and returns hex-encoded.
func Sign(body []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}
// Verify checks that a signature matches the expected HMAC-SHA256.
func Verify(body []byte, secret, signature string) bool {
expected := Sign(body, secret)
return hmac.Equal([]byte(expected), []byte(signature))
}
// privateNetworks lists CIDR ranges that must be rejected for webhook URLs.
// RED-18: Prevents SSRF by blocking delivery to internal/private networks.
var privateNetworks = []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.0/8",
"169.254.0.0/16", // link-local + cloud metadata
"::1/128",
"fc00::/7", // unique local
"fe80::/10", // link-local IPv6
}
// internalHostSuffixes are DNS suffixes that resolve to internal services.
var internalHostSuffixes = []string{
".svc.cluster.local",
".internal",
".local",
}
// ValidateURL checks that a webhook URL is safe to deliver to.
// Rejects private IPs, localhost, cloud metadata, and K8s internal hostnames.
func ValidateURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme != "https" {
return fmt.Errorf("webhook URL must use HTTPS")
}
hostname := parsed.Hostname()
// Check internal hostname suffixes.
lower := strings.ToLower(hostname)
for _, suffix := range internalHostSuffixes {
if strings.HasSuffix(lower, suffix) {
return fmt.Errorf("webhook URL hostname %q is internal", hostname)
}
}
// Block well-known metadata hostnames.
if lower == "metadata.google.internal" || lower == "metadata" {
return fmt.Errorf("webhook URL hostname %q is a cloud metadata endpoint", hostname)
}
// Resolve hostname and check all IPs against private ranges.
ips, err := net.LookupHost(hostname)
if err != nil {
// If resolution fails, check if hostname is already an IP.
ip := net.ParseIP(hostname)
if ip != nil {
ips = []string{hostname}
} else {
return fmt.Errorf("cannot resolve hostname %q: %w", hostname, err)
}
}
for _, ipStr := range ips {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
for _, cidr := range privateNetworks {
_, network, _ := net.ParseCIDR(cidr)
if network != nil && network.Contains(ip) {
return fmt.Errorf("webhook URL resolves to private IP %s", ipStr)
}
}
}
return nil
}
// DeliverWithRetry attempts delivery with exponential backoff.
// Sleeps 1s, 2s, 4s, 8s, 16s between retries.
func DeliverWithRetry(ctx context.Context, wh types.Webhook, event string, data interface{}) error {
var lastErr error
for attempt := 0; attempt < MaxRetries; attempt++ {
if err := Deliver(ctx, wh, event, data); err != nil {
lastErr = err
backoff := time.Duration(1<<uint(attempt)) * time.Second
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
continue
}
return nil
}
return fmt.Errorf("webhook dead-letter after %d attempts: %w", MaxRetries, lastErr)
}
+129
View File
@@ -0,0 +1,129 @@
package webhook
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/luxfi/aml/pkg/types"
)
func TestSign(t *testing.T) {
sig := Sign([]byte("hello"), "secret")
if sig == "" {
t.Fatal("signature should not be empty")
}
// Deterministic.
sig2 := Sign([]byte("hello"), "secret")
if sig != sig2 {
t.Error("same input should produce same signature")
}
// Different input.
sig3 := Sign([]byte("world"), "secret")
if sig == sig3 {
t.Error("different input should produce different signature")
}
}
func TestVerify(t *testing.T) {
body := []byte(`{"event":"test"}`)
secret := "my-secret-key"
sig := Sign(body, secret)
if !Verify(body, secret, sig) {
t.Error("valid signature should verify")
}
if Verify(body, "wrong-secret", sig) {
t.Error("wrong secret should not verify")
}
if Verify([]byte("tampered"), secret, sig) {
t.Error("tampered body should not verify")
}
}
func TestDeliver(t *testing.T) {
var received Payload
var gotSig, gotEvent string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSig = r.Header.Get("X-Webhook-Signature")
gotEvent = r.Header.Get("X-Webhook-Event")
body, _ := io.ReadAll(r.Body)
json.Unmarshal(body, &received)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
wh := types.Webhook{
URL: srv.URL,
Secret: "test-secret",
}
err := Deliver(context.Background(), wh, "aml.flagged", map[string]string{"tx_id": "tx1"})
if err != nil {
t.Fatalf("Deliver error: %v", err)
}
if received.Event != "aml.flagged" {
t.Errorf("event = %s, want aml.flagged", received.Event)
}
if gotEvent != "aml.flagged" {
t.Errorf("X-Webhook-Event = %s, want aml.flagged", gotEvent)
}
if gotSig == "" {
t.Error("X-Webhook-Signature should not be empty")
}
}
// TestWebhookURLValidation (RED-18) verifies SSRF protection on webhook URLs.
func TestWebhookURLValidation(t *testing.T) {
tests := []struct {
name string
url string
wantErr bool
}{
{"private_10", "https://10.0.0.1/hook", true},
{"private_172", "https://172.16.0.1/hook", true},
{"private_192", "https://192.168.1.1/hook", true},
{"localhost_ip", "https://127.0.0.1/hook", true},
{"localhost_ipv6", "https://[::1]/hook", true},
{"metadata_gcp", "https://metadata.google.internal/computeMetadata/v1/", true},
{"link_local", "https://169.254.169.254/latest/meta-data/", true},
{"k8s_internal", "https://my-svc.default.svc.cluster.local/hook", true},
{"dot_internal", "https://something.internal/hook", true},
{"http_scheme", "http://example.com/hook", true},
{"public_ok", "https://example.com/hook", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateURL(tt.url)
if tt.wantErr && err == nil {
t.Errorf("RED-18: ValidateURL(%q) should reject but returned nil", tt.url)
}
if !tt.wantErr && err != nil {
t.Errorf("RED-18: ValidateURL(%q) should accept but got: %v", tt.url, err)
}
})
}
}
func TestDeliverFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
wh := types.Webhook{
URL: srv.URL,
Secret: "secret",
}
err := Deliver(context.Background(), wh, "test", nil)
if err == nil {
t.Error("expected error for 500 response")
}
}
+341
View File
@@ -0,0 +1,341 @@
// Package workflows provides cron-driven AML workflows: continuous KYC
// re-screening, high-risk entity monitoring, and entity change detection.
package workflows
import (
"context"
"log/slog"
"sync"
"time"
"github.com/luxfi/aml/pkg/sanctions"
"github.com/luxfi/aml/pkg/types"
)
// EntitySource fetches active entities for re-screening.
// Production wires IAM or database queries; tests use in-memory stubs.
type EntitySource interface {
// ActiveEntities returns all entities with approved KYC.
ActiveEntities(ctx context.Context, orgID string) ([]types.Entity, error)
// HighRiskEntities returns entities with risk_score > threshold or PEP=true.
HighRiskEntities(ctx context.Context, orgID string, threshold float64) ([]types.Entity, error)
// UpdateLastScreened marks an entity as screened at the given time.
UpdateLastScreened(ctx context.Context, entityID string, at time.Time) error
}
// AlertSink receives new alerts and cases from screening.
type AlertSink interface {
CreateAlert(ctx context.Context, alert types.Alert) error
CreateCase(ctx context.Context, orgID, severity string, alertIDs, entityIDs []string) (*types.Case, error)
}
// ScreenResult captures the outcome of a single entity re-screen.
type ScreenResult struct {
EntityID string
Matched bool
Score float64
ListID string
EntryName string
}
// ContinuousKYCConfig controls re-screening behavior.
type ContinuousKYCConfig struct {
OrgID string
HighRiskThreshold float64 // entities above this risk_score get hourly rescreen
MatchThreshold float64 // Jaro-Winkler threshold for sanctions match
BatchSize int // max entities per batch
Timeout time.Duration // per-entity timeout
}
// DefaultConfig returns conservative defaults.
func DefaultConfig(orgID string) ContinuousKYCConfig {
return ContinuousKYCConfig{
OrgID: orgID,
HighRiskThreshold: 0.7,
MatchThreshold: sanctions.MatchThreshold,
BatchSize: 500,
Timeout: 5 * time.Second,
}
}
// ContinuousKYCScreen re-screens all active users against current sanctions lists.
// Intended to run daily at 02:00 UTC.
func ContinuousKYCScreen(
ctx context.Context,
cfg ContinuousKYCConfig,
source EntitySource,
sink AlertSink,
entries []types.SanctionsEntry,
logger *slog.Logger,
) (screened int, matches int, cases int) {
entities, err := source.ActiveEntities(ctx, cfg.OrgID)
if err != nil {
logger.Error("failed to fetch active entities", "err", err)
return 0, 0, 0
}
for _, entity := range entities {
select {
case <-ctx.Done():
return screened, matches, cases
default:
}
matched, result := screenEntity(entity, entries, cfg.MatchThreshold)
screened++
if err := source.UpdateLastScreened(ctx, entity.ID, time.Now().UTC()); err != nil {
logger.Error("failed to update last_screened", "entity_id", entity.ID, "err", err)
}
if !matched {
continue
}
matches++
alert := types.Alert{
OrgID: cfg.OrgID,
RuleID: "continuous_kyc_rescreen",
RuleName: "Continuous KYC Rescreen",
Severity: types.SeverityHigh,
ActionTaken: types.ActionReview,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := sink.CreateAlert(ctx, alert); err != nil {
logger.Error("failed to create alert", "entity_id", entity.ID, "err", err)
continue
}
_, err := sink.CreateCase(ctx, cfg.OrgID, types.SeverityHigh,
[]string{alert.ID}, []string{entity.ID})
if err != nil {
logger.Error("failed to create case", "entity_id", entity.ID, "err", err)
continue
}
cases++
logger.Warn("sanctions match on rescreen",
"entity_id", entity.ID,
"entity_name", entity.Name,
"match_score", result.Score,
"match_entry", result.EntryName,
)
}
logger.Info("continuous KYC screen complete",
"screened", screened,
"matches", matches,
"cases", cases,
)
return screened, matches, cases
}
// HighRiskRescreen re-screens entities above the risk threshold or flagged as PEP.
// Intended to run hourly.
func HighRiskRescreen(
ctx context.Context,
cfg ContinuousKYCConfig,
source EntitySource,
sink AlertSink,
entries []types.SanctionsEntry,
logger *slog.Logger,
) (screened int, matches int, cases int) {
entities, err := source.HighRiskEntities(ctx, cfg.OrgID, cfg.HighRiskThreshold)
if err != nil {
logger.Error("failed to fetch high-risk entities", "err", err)
return 0, 0, 0
}
for _, entity := range entities {
select {
case <-ctx.Done():
return screened, matches, cases
default:
}
matched, result := screenEntity(entity, entries, cfg.MatchThreshold)
screened++
if err := source.UpdateLastScreened(ctx, entity.ID, time.Now().UTC()); err != nil {
logger.Error("failed to update last_screened", "entity_id", entity.ID, "err", err)
}
if !matched {
continue
}
matches++
alert := types.Alert{
OrgID: cfg.OrgID,
RuleID: "high_risk_rescreen",
RuleName: "High-Risk Rescreen",
Severity: types.SeverityCritical,
ActionTaken: types.ActionBlock,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if err := sink.CreateAlert(ctx, alert); err != nil {
logger.Error("failed to create alert", "entity_id", entity.ID, "err", err)
continue
}
_, err := sink.CreateCase(ctx, cfg.OrgID, types.SeverityCritical,
[]string{alert.ID}, []string{entity.ID})
if err != nil {
logger.Error("failed to create case", "entity_id", entity.ID, "err", err)
continue
}
cases++
logger.Warn("high-risk sanctions match",
"entity_id", entity.ID,
"entity_name", entity.Name,
"risk_score", entity.RiskScore,
"match_score", result.Score,
)
}
logger.Info("high-risk rescreen complete",
"screened", screened,
"matches", matches,
"cases", cases,
)
return screened, matches, cases
}
// screenEntity checks one entity against all sanctions entries.
func screenEntity(entity types.Entity, entries []types.SanctionsEntry, threshold float64) (bool, ScreenResult) {
for _, entry := range entries {
// Full name match.
score := sanctions.JaroWinkler(entity.Name, entry.Name)
if score >= threshold {
return true, ScreenResult{
EntityID: entity.ID,
Matched: true,
Score: score,
ListID: entry.ListID,
EntryName: entry.Name,
}
}
// Token match for partial name coverage.
tokenScore := sanctions.TokenMatch(entity.Name, entry.Name)
if tokenScore >= threshold {
return true, ScreenResult{
EntityID: entity.ID,
Matched: true,
Score: tokenScore,
ListID: entry.ListID,
EntryName: entry.Name,
}
}
// Also check aliases.
for _, alias := range entry.Aliases {
aliasScore := sanctions.JaroWinkler(entity.Name, alias)
if aliasScore >= threshold {
return true, ScreenResult{
EntityID: entity.ID,
Matched: true,
Score: aliasScore,
ListID: entry.ListID,
EntryName: alias,
}
}
}
}
return false, ScreenResult{EntityID: entity.ID}
}
// --- In-memory stubs for testing ---
// MemEntitySource is an in-memory EntitySource for testing.
type MemEntitySource struct {
mu sync.RWMutex
entities []types.Entity
screened map[string]time.Time
}
// NewMemEntitySource creates a MemEntitySource with the given entities.
func NewMemEntitySource(entities []types.Entity) *MemEntitySource {
return &MemEntitySource{
entities: entities,
screened: make(map[string]time.Time),
}
}
func (m *MemEntitySource) ActiveEntities(_ context.Context, orgID string) ([]types.Entity, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []types.Entity
for _, e := range m.entities {
if e.OrgID == orgID {
out = append(out, e)
}
}
return out, nil
}
func (m *MemEntitySource) HighRiskEntities(_ context.Context, orgID string, threshold float64) ([]types.Entity, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var out []types.Entity
for _, e := range m.entities {
if e.OrgID == orgID && (e.RiskScore > threshold || e.PEP) {
out = append(out, e)
}
}
return out, nil
}
func (m *MemEntitySource) UpdateLastScreened(_ context.Context, entityID string, at time.Time) error {
m.mu.Lock()
m.screened[entityID] = at
m.mu.Unlock()
return nil
}
func (m *MemEntitySource) LastScreened(entityID string) (time.Time, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
t, ok := m.screened[entityID]
return t, ok
}
// MemAlertSink is an in-memory AlertSink for testing.
type MemAlertSink struct {
mu sync.Mutex
Alerts []types.Alert
Cases []types.Case
}
func (m *MemAlertSink) CreateAlert(_ context.Context, a types.Alert) error {
m.mu.Lock()
m.Alerts = append(m.Alerts, a)
m.mu.Unlock()
return nil
}
func (m *MemAlertSink) CreateCase(_ context.Context, orgID, severity string, alertIDs, entityIDs []string) (*types.Case, error) {
m.mu.Lock()
defer m.mu.Unlock()
c := types.Case{
ID: "case-" + orgID,
OrgID: orgID,
Severity: severity,
AlertIDs: alertIDs,
EntityIDs: entityIDs,
Status: types.CaseOpen,
OpenedAt: time.Now().UTC(),
}
m.Cases = append(m.Cases, c)
return &c, nil
}
+181
View File
@@ -0,0 +1,181 @@
package workflows
import (
"context"
"log/slog"
"os"
"testing"
"github.com/luxfi/aml/pkg/types"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
}
func TestContinuousKYCScreen_NoMatch(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Alice Smith"},
{ID: "e2", OrgID: "org1", Name: "Bob Jones"},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
// Sanctions list with no matching names.
entries := []types.SanctionsEntry{
{Name: "Vladimir Petrov", ListID: "ofac"},
}
cfg := DefaultConfig("org1")
screened, matches, cases := ContinuousKYCScreen(
context.Background(), cfg, source, sink, entries, testLogger(),
)
if screened != 2 {
t.Errorf("screened = %d, want 2", screened)
}
if matches != 0 {
t.Errorf("matches = %d, want 0", matches)
}
if cases != 0 {
t.Errorf("cases = %d, want 0", cases)
}
// Verify all entities were marked as screened.
for _, e := range entities {
if _, ok := source.LastScreened(e.ID); !ok {
t.Errorf("entity %s not marked as screened", e.ID)
}
}
}
func TestContinuousKYCScreen_WithMatch(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Vladimir Petrov"},
{ID: "e2", OrgID: "org1", Name: "Clean Name"},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
entries := []types.SanctionsEntry{
{Name: "Vladimir Petrov", ListID: "ofac"},
}
cfg := DefaultConfig("org1")
screened, matches, cases := ContinuousKYCScreen(
context.Background(), cfg, source, sink, entries, testLogger(),
)
if screened != 2 {
t.Errorf("screened = %d, want 2", screened)
}
if matches != 1 {
t.Errorf("matches = %d, want 1", matches)
}
if cases != 1 {
t.Errorf("cases = %d, want 1", cases)
}
if len(sink.Alerts) != 1 {
t.Errorf("alerts = %d, want 1", len(sink.Alerts))
}
}
func TestContinuousKYCScreen_OrgFilter(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Test User"},
{ID: "e2", OrgID: "org2", Name: "Other Org"},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
cfg := DefaultConfig("org1")
screened, _, _ := ContinuousKYCScreen(
context.Background(), cfg, source, sink, nil, testLogger(),
)
if screened != 1 {
t.Errorf("screened = %d, want 1 (org-filtered)", screened)
}
}
func TestHighRiskRescreen(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Normal User", RiskScore: 0.3},
{ID: "e2", OrgID: "org1", Name: "Risky User", RiskScore: 0.9},
{ID: "e3", OrgID: "org1", Name: "PEP User", RiskScore: 0.4, PEP: true},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
cfg := DefaultConfig("org1")
screened, _, _ := HighRiskRescreen(
context.Background(), cfg, source, sink, nil, testLogger(),
)
// Only e2 (risk 0.9 > 0.7) and e3 (PEP) should be screened.
if screened != 2 {
t.Errorf("screened = %d, want 2 (high-risk + PEP only)", screened)
}
}
func TestHighRiskRescreen_MatchCreatesCriticalCase(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Sanctioned Person", RiskScore: 0.9},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
entries := []types.SanctionsEntry{
{Name: "Sanctioned Person", ListID: "ofac"},
}
cfg := DefaultConfig("org1")
_, matches, cases := HighRiskRescreen(
context.Background(), cfg, source, sink, entries, testLogger(),
)
if matches != 1 {
t.Errorf("matches = %d, want 1", matches)
}
if cases != 1 {
t.Errorf("cases = %d, want 1", cases)
}
if len(sink.Cases) > 0 && sink.Cases[0].Severity != types.SeverityCritical {
t.Errorf("case severity = %q, want %q", sink.Cases[0].Severity, types.SeverityCritical)
}
}
func TestContinuousKYCScreen_Cancellation(t *testing.T) {
entities := make([]types.Entity, 100)
for i := range entities {
entities[i] = types.Entity{ID: "e-many", OrgID: "org1", Name: "Test"}
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately.
cfg := DefaultConfig("org1")
screened, _, _ := ContinuousKYCScreen(ctx, cfg, source, sink, nil, testLogger())
// Should stop quickly due to cancellation.
if screened > 1 {
t.Errorf("screened = %d, expected early exit due to context cancellation", screened)
}
}
func TestScreenEntity_AliasMatch(t *testing.T) {
entity := types.Entity{ID: "e1", Name: "Vlad Petrov"}
entries := []types.SanctionsEntry{
{Name: "Vladimir Petrovich Petrov", Aliases: []string{"Vlad Petrov"}},
}
matched, result := screenEntity(entity, entries, 0.85)
if !matched {
t.Error("expected alias match")
}
if result.Score < 0.85 {
t.Errorf("score = %.2f, expected >= 0.85", result.Score)
}
}
+23
View File
@@ -0,0 +1,23 @@
//go:build !embedui
// Package ui provides a placeholder dashboard filesystem when the server is
// built without the `embedui` tag, so the bare Go toolchain (go build, go vet,
// go test, go install) never depends on a Vite build having run first.
//
// The product build embeds the real dashboard: `make build` runs the UI build
// and passes -tags embedui. See ui/embed_prod.go.
package ui
import (
"io/fs"
"testing/fstest"
)
var emptyFS = fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(
`<!doctype html><title>AML</title><p>UI not embedded. Build with -tags embedui.</p>`,
)},
}
// DistDirFS returns a placeholder dashboard tree.
func DistDirFS() fs.FS { return emptyFS }
+25
View File
@@ -0,0 +1,25 @@
//go:build embedui
// Package ui embeds the AML admin dashboard built with Vite + React.
//
// The Go HTTP handler serves the admin UI at the /_/aml/ path. `make ui`
// produces dist/; the //go:embed directive below picks up the result.
package ui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
// DistDirFS returns a rooted fs.FS pointing at dist/ so it can be passed
// directly to the Base router's Static handler.
func DistDirFS() fs.FS {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic("aml-ui: dist/ missing — run `make ui` first: " + err.Error())
}
return sub
}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AML Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; width: 100%; background: #000; color: #e5e5e5; font-family: system-ui, -apple-system, sans-serif; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@luxfi/aml-ui",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@hanzo/gui": "^4.8.0",
"@hanzogui/config-default": "^4.4.0",
"@hanzogui/core": "^4.4.0",
"@hanzogui/theme": "^4.4.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"wouter": "^3.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
+7571
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
import { Router, Route, Switch, Link, useLocation } from 'wouter'
import { useHashLocation } from 'wouter/use-hash-location'
import { Dashboard } from './pages/Dashboard'
import { Cases } from './pages/Cases'
import { Rules } from './pages/Rules'
import { Alerts } from './pages/Alerts'
import { Sanctions } from './pages/Sanctions'
const navItems = [
{ href: '/', label: 'Dashboard' },
{ href: '/cases', label: 'Cases' },
{ href: '/rules', label: 'Rules' },
{ href: '/alerts', label: 'Alerts' },
{ href: '/sanctions', label: 'Sanctions' },
] as const
function NavLink({ href, label }: { href: string; label: string }) {
const [location] = useLocation()
const active = href === '/' ? location === '/' : location.startsWith(href)
return (
<Link
href={href}
style={{
display: 'block',
padding: '6px 12px',
borderRadius: 6,
color: active ? '#fff' : '#a3a3a3',
background: active ? '#262626' : 'transparent',
textDecoration: 'none',
fontSize: 14,
}}
>
{label}
</Link>
)
}
export function App() {
return (
<Router hook={useHashLocation}>
<div style={{ display: 'flex', height: '100vh', background: '#000', color: '#e5e5e5' }}>
<aside
style={{
width: 200,
borderRight: '1px solid #262626',
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16, color: '#fff' }}>
AML Admin
</div>
<nav style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{navItems.map((n) => (
<NavLink key={n.href} href={n.href} label={n.label} />
))}
</nav>
</aside>
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
<Switch>
<Route path="/" component={Dashboard} />
<Route path="/cases" component={Cases} />
<Route path="/rules" component={Rules} />
<Route path="/alerts" component={Alerts} />
<Route path="/sanctions" component={Sanctions} />
<Route>
<div style={{ color: '#737373' }}>Not found</div>
</Route>
</Switch>
</main>
</div>
</Router>
)
}
+114
View File
@@ -0,0 +1,114 @@
// api.ts -- typed fetch wrappers for /v1/aml/* endpoints.
// All calls use relative URLs so they work on any host.
export interface Case {
id: string
org_id: string
number: number
status: string
severity: string
entity_ids?: string[]
alert_ids?: string[]
assignee_id?: string
opened_at: string
closed_at?: string
resolution?: string
created_at: string
updated_at: string
}
export interface Rule {
id: string
org_id: string
name: string
description: string
dsl: string
severity: string
weight: number
action: string
enabled: boolean
jurisdiction_filter?: string[]
asset_class_filter?: string[]
priority: number
created_at: string
updated_at: string
}
export interface Alert {
id: string
org_id: string
tx_id: string
rule_id: string
rule_name: string
severity: string
score: number
score_breakdown?: Record<string, number>
action_taken: string
reviewed_by?: string
reviewed_at?: string
decision?: string
created_at: string
updated_at: string
}
export interface SanctionsResult {
id: string
list_id: string
name: string
aliases?: string[]
dob?: string
nationality?: string
type: string
score: number
}
export interface RuleTestResult {
match: boolean
dsl: string
}
async function json<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
const body = await res.text()
throw new Error(`${res.status}: ${body}`)
}
return res.json()
}
export function listCases(status?: string): Promise<Case[]> {
const q = status && status !== 'all' ? `?status=${status}` : ''
return json(`/v1/aml/cases${q}`)
}
export function listRules(): Promise<Rule[]> {
return json('/v1/aml/rules')
}
export function testRule(dsl: string): Promise<RuleTestResult> {
return json('/v1/aml/rules/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
dsl,
tx: { id: 'test', notional: 15000, currency: 'USD', qty: 1 },
entity: { id: 'test', name: 'Test User', entity_type: 'user' },
}),
})
}
export function listAlerts(txId: string): Promise<Alert[]> {
return json(`/v1/aml/transactions/${txId}/alerts`)
}
export function searchSanctions(name: string, dob?: string): Promise<SanctionsResult[]> {
return json('/v1/aml/sanctions/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, dob }),
})
}
export function health(): Promise<{ status: string; time: string }> {
return json('/v1/aml/health')
}
+12
View File
@@ -0,0 +1,12 @@
import { createRoot } from 'react-dom/client'
import React from 'react'
import { App } from './App'
const root = document.getElementById('root')
if (!root) throw new Error('root element missing')
createRoot(root).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react'
import { listCases, listAlerts, type Case, type Alert } from '../api'
export function Alerts() {
const [cases, setCases] = useState<Case[]>([])
const [alerts, setAlerts] = useState<Alert[]>([])
const [sevFilter, setSevFilter] = useState<string>('all')
const [expanded, setExpanded] = useState<string | null>(null)
const [err, setErr] = useState('')
// Derive alerts from cases' alert_ids. The API returns alerts per-transaction,
// so we collect unique tx_ids from case alert references and fetch each.
useEffect(() => {
listCases()
.then(async (cs) => {
setCases(cs)
// Collect all unique alert fetches -- alerts are keyed by tx,
// but we only have case.alert_ids. For the embedded UI, we show
// case-level alert metadata as placeholder rows when the
// per-transaction fetch is not available.
const allAlerts: Alert[] = []
// Derive minimal alert rows from case data.
for (const c of cs) {
for (const aid of c.alert_ids ?? []) {
allAlerts.push({
id: aid,
org_id: c.org_id,
tx_id: '',
rule_id: '',
rule_name: '',
severity: c.severity,
score: 0,
action_taken: '',
created_at: c.opened_at,
updated_at: c.updated_at,
})
}
}
setAlerts(allAlerts)
})
.catch((e) => setErr(e.message))
}, [])
const filtered =
sevFilter === 'all' ? alerts : alerts.filter((a) => a.severity === sevFilter)
const severities = ['all', 'low', 'medium', 'high', 'critical']
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Alerts</h1>
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
<div style={{ display: 'flex', gap: 8 }}>
{severities.map((s) => (
<button
key={s}
onClick={() => setSevFilter(s)}
style={{
padding: '4px 12px',
borderRadius: 12,
border: '1px solid #262626',
cursor: 'pointer',
fontSize: 12,
background: sevFilter === s ? '#262626' : 'transparent',
color: sevFilter === s ? '#fff' : '#737373',
}}
>
{s}
</button>
))}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={th}>Time</th>
<th style={th}>Alert ID</th>
<th style={th}>Severity</th>
<th style={th}>Score</th>
<th style={th}>Action</th>
</tr>
</thead>
<tbody>
{filtered.map((a) => (
<>
<tr
key={a.id}
onClick={() => setExpanded(expanded === a.id ? null : a.id)}
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
>
<td style={{ ...td, color: '#737373' }}>
{new Date(a.created_at).toLocaleString()}
</td>
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12 }}>
{a.id.slice(0, 8)}
</td>
<td style={td}>
<SeverityBadge severity={a.severity} />
</td>
<td style={td}>{a.score.toFixed(2)}</td>
<td style={td}>{a.action_taken || '-'}</td>
</tr>
{expanded === a.id && (
<tr key={`${a.id}-detail`}>
<td colSpan={5} style={{ padding: 12, background: '#0a0a0a' }}>
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
<div>Full ID: {a.id}</div>
<div>Transaction: {a.tx_id || '-'}</div>
<div>Rule: {a.rule_name || a.rule_id || '-'}</div>
<div>Decision: {a.decision || '-'}</div>
{a.score_breakdown && (
<div style={{ marginTop: 8 }}>
<div style={{ marginBottom: 4, color: '#737373' }}>Score Breakdown:</div>
{Object.entries(a.score_breakdown).map(([k, v]) => (
<div key={k} style={{ display: 'flex', gap: 8 }}>
<span style={{ color: '#525252' }}>{k}:</span>
<span>{v.toFixed(3)}</span>
</div>
))}
</div>
)}
</div>
</td>
</tr>
)}
</>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
No alerts
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
const td: React.CSSProperties = { padding: '8px 12px' }
function SeverityBadge({ severity }: { severity: string }) {
const colors: Record<string, string> = {
low: '#3b82f6',
medium: '#f59e0b',
high: '#f97316',
critical: '#ef4444',
}
return (
<span
style={{
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
background: `${colors[severity] ?? '#262626'}20`,
color: colors[severity] ?? '#a3a3a3',
}}
>
{severity}
</span>
)
}
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react'
import { listCases, type Case } from '../api'
const statuses = ['all', 'open', 'in_review', 'escalated', 'closed'] as const
export function Cases() {
const [cases, setCases] = useState<Case[]>([])
const [filter, setFilter] = useState<string>('all')
const [expanded, setExpanded] = useState<string | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
listCases(filter)
.then(setCases)
.catch((e) => setErr(e.message))
}, [filter])
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Cases</h1>
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
<div style={{ display: 'flex', gap: 8 }}>
{statuses.map((s) => (
<button
key={s}
onClick={() => setFilter(s)}
style={{
padding: '6px 14px',
borderRadius: 6,
border: 'none',
cursor: 'pointer',
fontSize: 13,
background: filter === s ? '#262626' : 'transparent',
color: filter === s ? '#fff' : '#737373',
}}
>
{s.replace('_', ' ')}
</button>
))}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={th}>#</th>
<th style={th}>Status</th>
<th style={th}>Severity</th>
<th style={th}>Assignee</th>
<th style={th}>Opened</th>
<th style={th}>Resolution</th>
</tr>
</thead>
<tbody>
{cases.map((c) => (
<>
<tr
key={c.id}
onClick={() => setExpanded(expanded === c.id ? null : c.id)}
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
>
<td style={td}>{c.number}</td>
<td style={td}>
<StatusBadge status={c.status} />
</td>
<td style={td}>
<SeverityBadge severity={c.severity} />
</td>
<td style={td}>{c.assignee_id || '-'}</td>
<td style={{ ...td, color: '#737373' }}>
{new Date(c.opened_at).toLocaleDateString()}
</td>
<td style={td}>{c.resolution || '-'}</td>
</tr>
{expanded === c.id && (
<tr key={`${c.id}-detail`}>
<td colSpan={6} style={{ padding: '12px', background: '#0a0a0a' }}>
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
<div>Case ID: {c.id}</div>
<div>Alerts: {c.alert_ids?.join(', ') || 'none'}</div>
<div>Entities: {c.entity_ids?.join(', ') || 'none'}</div>
{c.closed_at && <div>Closed: {new Date(c.closed_at).toLocaleString()}</div>}
</div>
</td>
</tr>
)}
</>
))}
{cases.length === 0 && (
<tr>
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
No cases
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
const td: React.CSSProperties = { padding: '8px 12px' }
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
open: '#3b82f6',
in_review: '#f59e0b',
escalated: '#f97316',
closed: '#525252',
}
return (
<span
style={{
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
background: `${colors[status] ?? '#262626'}20`,
color: colors[status] ?? '#a3a3a3',
}}
>
{status.replace('_', ' ')}
</span>
)
}
function SeverityBadge({ severity }: { severity: string }) {
const colors: Record<string, string> = {
low: '#3b82f6',
medium: '#f59e0b',
high: '#f97316',
critical: '#ef4444',
}
return (
<span
style={{
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
background: `${colors[severity] ?? '#262626'}20`,
color: colors[severity] ?? '#a3a3a3',
}}
>
{severity}
</span>
)
}
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import { listCases, health, type Case } from '../api'
const cardStyle: React.CSSProperties = {
background: '#0a0a0a',
border: '1px solid #262626',
borderRadius: 8,
padding: 16,
minWidth: 160,
}
const labelStyle: React.CSSProperties = {
fontSize: 12,
color: '#737373',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}
const valueStyle: React.CSSProperties = {
fontSize: 28,
fontWeight: 600,
color: '#fff',
marginTop: 4,
}
export function Dashboard() {
const [cases, setCases] = useState<Case[]>([])
const [status, setStatus] = useState<string>('')
const [err, setErr] = useState('')
useEffect(() => {
listCases()
.then(setCases)
.catch((e) => setErr(e.message))
health()
.then((h) => setStatus(h.status))
.catch(() => setStatus('unreachable'))
}, [])
const open = cases.filter((c) => c.status === 'open').length
const inReview = cases.filter((c) => c.status === 'in_review').length
const escalated = cases.filter((c) => c.status === 'escalated').length
const closed = cases.filter((c) => c.status === 'closed').length
const recent = cases.slice(0, 5)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Dashboard</h1>
<span style={{ fontSize: 12, color: status === 'ok' ? '#22c55e' : '#ef4444' }}>
Engine: {status || 'loading'}
</span>
</div>
{err && (
<div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>
)}
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<div style={cardStyle}>
<div style={labelStyle}>Open Cases</div>
<div style={valueStyle}>{open}</div>
</div>
<div style={cardStyle}>
<div style={labelStyle}>In Review</div>
<div style={valueStyle}>{inReview}</div>
</div>
<div style={cardStyle}>
<div style={labelStyle}>Escalated</div>
<div style={valueStyle}>{escalated}</div>
</div>
<div style={cardStyle}>
<div style={labelStyle}>Closed</div>
<div style={valueStyle}>{closed}</div>
</div>
<div style={cardStyle}>
<div style={labelStyle}>Total Cases</div>
<div style={valueStyle}>{cases.length}</div>
</div>
</div>
<section>
<div style={labelStyle}>Recent Cases</div>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 8, fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={{ padding: '8px 12px' }}>#</th>
<th style={{ padding: '8px 12px' }}>Status</th>
<th style={{ padding: '8px 12px' }}>Severity</th>
<th style={{ padding: '8px 12px' }}>Opened</th>
</tr>
</thead>
<tbody>
{recent.map((c) => (
<tr key={c.id} style={{ borderBottom: '1px solid #171717' }}>
<td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>
{c.number}
</td>
<td style={{ padding: '8px 12px' }}>
<Badge text={c.status} />
</td>
<td style={{ padding: '8px 12px' }}>
<SeverityBadge severity={c.severity} />
</td>
<td style={{ padding: '8px 12px', color: '#737373' }}>
{new Date(c.opened_at).toLocaleDateString()}
</td>
</tr>
))}
{recent.length === 0 && (
<tr>
<td colSpan={4} style={{ padding: '16px 12px', color: '#525252' }}>
No cases yet
</td>
</tr>
)}
</tbody>
</table>
</section>
</div>
)
}
function Badge({ text }: { text: string }) {
return (
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
background: '#262626',
color: '#d4d4d4',
}}
>
{text}
</span>
)
}
function SeverityBadge({ severity }: { severity: string }) {
const colors: Record<string, string> = {
low: '#3b82f6',
medium: '#f59e0b',
high: '#f97316',
critical: '#ef4444',
}
return (
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
background: `${colors[severity] ?? '#525252'}20`,
color: colors[severity] ?? '#a3a3a3',
}}
>
{severity}
</span>
)
}
+175
View File
@@ -0,0 +1,175 @@
import { useEffect, useState } from 'react'
import { listRules, testRule, type Rule, type RuleTestResult } from '../api'
export function Rules() {
const [rules, setRules] = useState<Rule[]>([])
const [err, setErr] = useState('')
const [editId, setEditId] = useState<string | null>(null)
const [testDsl, setTestDsl] = useState('')
const [testResult, setTestResult] = useState<RuleTestResult | null>(null)
const [testErr, setTestErr] = useState('')
useEffect(() => {
listRules()
.then(setRules)
.catch((e) => setErr(e.message))
}, [])
const handleTest = async () => {
setTestErr('')
setTestResult(null)
try {
const r = await testRule(testDsl)
setTestResult(r)
} catch (e: unknown) {
setTestErr(e instanceof Error ? e.message : String(e))
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Rules</h1>
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={th}>Name</th>
<th style={th}>DSL</th>
<th style={th}>Severity</th>
<th style={th}>Action</th>
<th style={th}>Enabled</th>
</tr>
</thead>
<tbody>
{rules.map((r) => (
<>
<tr
key={r.id}
onClick={() => {
setEditId(editId === r.id ? null : r.id)
setTestDsl(r.dsl)
setTestResult(null)
setTestErr('')
}}
style={{ borderBottom: '1px solid #171717', cursor: 'pointer' }}
>
<td style={td}>{r.name}</td>
<td style={{ ...td, fontFamily: 'monospace', fontSize: 12, maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.dsl}
</td>
<td style={td}>
<SeverityBadge severity={r.severity} />
</td>
<td style={td}>{r.action}</td>
<td style={td}>
<span style={{ color: r.enabled ? '#22c55e' : '#525252' }}>
{r.enabled ? 'on' : 'off'}
</span>
</td>
</tr>
{editId === r.id && (
<tr key={`${r.id}-detail`}>
<td colSpan={5} style={{ padding: 16, background: '#0a0a0a' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 12, color: '#a3a3a3' }}>
<div>ID: {r.id}</div>
<div>Description: {r.description || '-'}</div>
<div>Weight: {r.weight}</div>
<div>Priority: {r.priority}</div>
{r.jurisdiction_filter?.length ? (
<div>Jurisdictions: {r.jurisdiction_filter.join(', ')}</div>
) : null}
{r.asset_class_filter?.length ? (
<div>Asset classes: {r.asset_class_filter.join(', ')}</div>
) : null}
</div>
<div>
<label style={{ fontSize: 12, color: '#737373', display: 'block', marginBottom: 4 }}>
Test DSL
</label>
<textarea
value={testDsl}
onChange={(e) => setTestDsl(e.target.value)}
style={{
width: '100%',
minHeight: 60,
background: '#171717',
border: '1px solid #262626',
borderRadius: 6,
color: '#e5e5e5',
fontFamily: 'monospace',
fontSize: 13,
padding: 8,
resize: 'vertical',
}}
/>
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<button onClick={handleTest} style={btnStyle}>
Test Rule
</button>
{testResult && (
<span style={{ fontSize: 13, color: testResult.match ? '#22c55e' : '#737373' }}>
{testResult.match ? 'Matched' : 'No match'}
</span>
)}
{testErr && (
<span style={{ fontSize: 13, color: '#ef4444' }}>{testErr}</span>
)}
</div>
</div>
</td>
</tr>
)}
</>
))}
{rules.length === 0 && (
<tr>
<td colSpan={5} style={{ padding: '16px 12px', color: '#525252' }}>
No rules loaded
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
const td: React.CSSProperties = { padding: '8px 12px' }
const btnStyle: React.CSSProperties = {
padding: '6px 16px',
borderRadius: 6,
border: 'none',
cursor: 'pointer',
fontSize: 13,
fontWeight: 500,
background: '#262626',
color: '#fff',
}
function SeverityBadge({ severity }: { severity: string }) {
const colors: Record<string, string> = {
low: '#3b82f6',
medium: '#f59e0b',
high: '#f97316',
critical: '#ef4444',
}
return (
<span
style={{
padding: '2px 8px',
borderRadius: 4,
fontSize: 12,
background: `${colors[severity] ?? '#262626'}20`,
color: colors[severity] ?? '#a3a3a3',
}}
>
{severity}
</span>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from 'react'
import { searchSanctions, type SanctionsResult } from '../api'
export function Sanctions() {
const [name, setName] = useState('')
const [dob, setDob] = useState('')
const [results, setResults] = useState<SanctionsResult[] | null>(null)
const [loading, setLoading] = useState(false)
const [err, setErr] = useState('')
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setErr('')
setLoading(true)
try {
const r = await searchSanctions(name.trim(), dob.trim() || undefined)
setResults(r)
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h1 style={{ fontSize: 20, fontWeight: 600, color: '#fff' }}>Sanctions Search</h1>
<form onSubmit={handleSearch} style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 12, color: '#737373' }}>Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Full name"
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 12, color: '#737373' }}>Date of Birth</label>
<input
type="text"
value={dob}
onChange={(e) => setDob(e.target.value)}
placeholder="YYYY-MM-DD (optional)"
style={inputStyle}
/>
</div>
<button type="submit" disabled={loading || !name.trim()} style={btnStyle}>
{loading ? 'Searching...' : 'Search'}
</button>
</form>
{err && <div style={{ color: '#ef4444', fontSize: 13 }}>{err}</div>}
{results !== null && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid #262626', color: '#737373', textAlign: 'left' }}>
<th style={th}>Name</th>
<th style={th}>Aliases</th>
<th style={th}>Source</th>
<th style={th}>Nationality</th>
<th style={th}>Type</th>
<th style={th}>Score</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={`${r.id}-${i}`} style={{ borderBottom: '1px solid #171717' }}>
<td style={td}>{r.name}</td>
<td style={{ ...td, fontSize: 12, color: '#a3a3a3' }}>
{r.aliases?.join(', ') || '-'}
</td>
<td style={td}>
<span
style={{
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
background: '#262626',
color: '#d4d4d4',
textTransform: 'uppercase',
}}
>
{r.list_id}
</span>
</td>
<td style={td}>{r.nationality || '-'}</td>
<td style={td}>{r.type}</td>
<td style={td}>
<span style={{ color: r.score >= 0.9 ? '#ef4444' : r.score >= 0.7 ? '#f59e0b' : '#a3a3a3' }}>
{r.score.toFixed(3)}
</span>
</td>
</tr>
))}
{results.length === 0 && (
<tr>
<td colSpan={6} style={{ padding: '16px 12px', color: '#525252' }}>
No matches found
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
)
}
const th: React.CSSProperties = { padding: '8px 12px', fontWeight: 500 }
const td: React.CSSProperties = { padding: '8px 12px' }
const inputStyle: React.CSSProperties = {
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #262626',
background: '#0a0a0a',
color: '#e5e5e5',
fontSize: 14,
outline: 'none',
minWidth: 200,
}
const btnStyle: React.CSSProperties = {
padding: '8px 20px',
borderRadius: 6,
border: 'none',
cursor: 'pointer',
fontSize: 14,
fontWeight: 500,
background: '#262626',
color: '#fff',
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/_/aml/',
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
output: { manualChunks: undefined },
},
},
server: {
port: 3000,
proxy: {
'/v1/aml': 'http://localhost:8090',
},
},
})