SDKs & recipes
Integrating Clearlist is a copy-paste. @clearlist/sdk is a typed, zero-dependency client for Node 18+, Bun, Deno, edge runtimes and browsers; @clearlist/react adds a provider, a hook and a gate that renders your send button only when the wallet screens clean. Every recipe below uses the test fixtures, so it demonstrably works before you have a single real user. The raw HTTP API is documented in the API reference.
Install
npm install @clearlist/sdk
npm install @clearlist/react # optional, for the gateRun the SDK on your backend, in a serverless function or in an edge worker, with a live key in an environment variable. Never ship a live key to a browser: anything in a bundle is public, and the API does not serve CORS headers, so a browser cannot call it directly anyway. The front end talks to a route of yours that holds the key (the first recipe), and @clearlist/react takes that route as its screen function. Test keys are free, unlimited, and only ever produce mode: "test" decisions.
30-second quickstart
import { Clearlist } from "@clearlist/sdk";
const clearlist = new Clearlist({ apiKey: process.env.CLEARLIST_KEY! }); // cl_test_… or cl_live_…
const decision = await clearlist.screenAddress("0x000000000000000000000000000000000000b10c", "base", {
idempotencyKey: withdrawal.id, // a retry returns the same decision
metadata: { user_id: user.id, flow: "withdrawal" }, // shown in the dashboard
});
if (decision.outcome === "block") return reject(decision.user_message); // safe to show
if (decision.outcome === "review") return hold(withdrawal.id, decision.id); // an analyst clears or confirms it
// allow → proceedThree rules: branch on outcome only (allow proceed, review hold, block refuse); show user_message to your end user, it never names the list entry; pass your own id as idempotencyKey so a retry returns the same decision, and your user id in metadata so the dashboard shows who was screened.
Options: new Clearlist({ apiKey, baseUrl?, timeoutMs = 10000, maxRetries = 2, fetch?, headers? }). Every method also takes a trailing { timeoutMs, signal, maxRetries }.
Every method
One client, resource namespaces as properties, every response typed with the same contracts the service uses (copied verbatim into the package).
// Screening
clearlist.screen(subject, { idempotencyKey?, metadata? }) // any Subject → Decision
clearlist.screenAddress(address, chain?, { exposure? }) // chain detected from the format when omitted
clearlist.screenName("Viktor Bout") / screenName({ name, dob, nationality, id_number })
clearlist.screenCountry("IR") / screenCountry({ country?, ip? })
clearlist.screenTransaction({ chain, from, to, amount?, asset?, exposure? })
clearlist.batch(subjects) // up to 100, same order back
// Decisions
clearlist.decisions.list({ outcome?, type?, since?, until?, cursor?, limit? })
for await (const d of clearlist.decisions.iterate({ outcome: "block" })) …
clearlist.decisions.get(id) // + review state
clearlist.decisions.evidence(id) // the evidence file (paid plans)
// Reviews / allowlist / policy
clearlist.reviews.list({ status: "open" }) · reviews.resolve(id, { status: "cleared", resolution, allowlist: true })
clearlist.allowlist.list() · allowlist.add({ subject_type, value, reason, expires_at? }) · allowlist.remove(id)
clearlist.policy.get() · policy.update(policy) // full replacement; version bumped server-side
// Monitors / webhooks (paid plans)
clearlist.monitors.create(subject, { metadata }) · list · iterate · get · pause · resume · delete
clearlist.webhooks.create({ url, events }) // returns the secret once
clearlist.webhooks.list · get · delete · test(id, event?) · deliveries(id, { status? })
// Reference data
clearlist.lists.status() · labels.lookup(address, chain?) · chains() · health() · fixtures()
// Escape hatch
clearlist.request({ method, path, query?, body? })React gate
ClearlistProvider takes a screen function that calls your backend. ScreeningGate screens the connected wallet (or a transaction via subject), renders its children only on allow, shows a compliant message on block or review, and fails closed on error. No styling dependencies; a tiny unstyled default with className hooks.
import { ClearlistProvider, ScreeningGate } from "@clearlist/react";
// Calls YOUR backend (which holds the live key), never Clearlist directly.
const screen = (subject) =>
fetch("/api/screen", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(subject) })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`screen failed: ${r.status}`))));
<ClearlistProvider screen={screen}>
<ScreeningGate
address={address} // from wagmi's useAccount(), wallet-adapter's publicKey, Privy…
chain="base"
loading={<p>Checking wallet…</p>}
onBlock={(d) => analytics.track("wallet_blocked", { decision: d.id })}
>
<SendButton /> {/* rendered only when the outcome is allow */}
</ScreeningGate>
</ClearlistProvider>
// Block and review render decision.user_message by default (unstyled; class hooks
// .clearlist-gate, .clearlist-gate--block, .clearlist-gate--review, .clearlist-gate__message).
// useScreen() gives you { screen, decision, loading, error } for imperative use.// app/api/screen/route.ts — the backend behind the React gate
import { Clearlist } from "@clearlist/sdk";
const clearlist = new Clearlist({ apiKey: process.env.CLEARLIST_KEY! });
export async function POST(req: Request) {
const user = await getSessionUser(req);
if (!user) return Response.json({ error: "unauthenticated" }, { status: 401 });
const decision = await clearlist.screen(await req.json(), { metadata: { user_id: user.id } });
return Response.json(decision, { status: 201 });
}For local development only, <ClearlistProvider apiKey="cl_test_…" baseUrl="/clearlist"> posts straight to the API through a same-origin dev proxy; live keys are refused. wagmi, Solana wallet-adapter and Privy examples are in the recipes below and in the package README.
Test fixtures
Fixtures only fire under a test key (cl_test_…). They never touch the sanctions lists or counterparty exposure, the outcome ignores your policy, and the decision is persisted with mode "test" so it shows in your dashboard. Every reason carries evidence.fixture = true. A transaction whose `to` address is a fixture inherits that fixture.
The same table is served at GET https://clearlist.xyz/api/v1/fixtures (no auth) and exported from the SDK as FIXTURES, FIXTURE_ADDRESSES, FIXTURE_NAMES and FIXTURE_COUNTRIES.
| Subject | Value | Outcome | Reason |
|---|---|---|---|
| address, any EVM | 0x000000000000000000000000000000000000b10c | block | address.sanctioned Listed on OFAC SDN as FIXTURE SANCTIONED ENTITY. Blocks on any EVM chain. |
| address, any EVM | 0x0000000000000000000000000000000000000e41 | review | address.labeled Publicly labeled as a mixer. Opens a review. |
| address, any EVM | 0x000000000000000000000000000000000000a110 | allow | none Clean address. No reasons, no matches. |
| address, solana | C1earListFixtureB1ock11111111111111111111111 | block | address.sanctioned Solana equivalent of the EVM block fixture. |
| address, solana | C1earListFixtureRev1ew1111111111111111111111 | review | address.labeled Solana equivalent of the EVM review fixture (mixer label). |
| address, solana | C1earListFixtureA11ow11111111111111111111111 | allow | none Solana equivalent of the EVM allow fixture. |
| name | FIXTURE BLOCK | block | name.strong_match_corroborated A 100% name match corroborated by a date of birth (uses the dob you send, or a fixture one). |
| name | FIXTURE REVIEW | review | name.strong_match A 100% name match with nothing corroborating it: review, never block on the name alone. |
| name | FIXTURE ALLOW | allow | none Clean name. No reasons, no matches. |
| country | XB | block | country.sanctioned Behaves like a comprehensively sanctioned jurisdiction on your block list. |
| country | XR | review | country.restricted Behaves like a jurisdiction on your review list. |
| transaction | any fixture address as `to` | inherits | Same reason with evidence.side: "to"; exposure is skipped. |
import { Clearlist, FIXTURE_ADDRESSES, FIXTURE_NAMES } from "@clearlist/sdk";
const test = new Clearlist({ apiKey: process.env.CLEARLIST_TEST_KEY! });
expect((await test.screenAddress(FIXTURE_ADDRESSES.evm.block)).outcome).toBe("block");
expect((await test.screenAddress(FIXTURE_ADDRESSES.solana.review, "solana")).outcome).toBe("review");
expect((await test.screenName(FIXTURE_NAMES.allow)).outcome).toBe("allow");
expect((await test.screenCountry("XB")).outcome).toBe("block");
expect((await test.screenTransaction({ chain: "base", from: me, to: FIXTURE_ADDRESSES.evm.block })).outcome).toBe("block");Under a live key the same values are screened for real (and come back allow, since nobody has designated them). A fixture decision looks like this:
{
"outcome": "block",
"reasons": [
{
"code": "address.sanctioned",
"severity": "critical",
"summary": "This address 0x000000…00b10c is listed on the OFAC SDN List as belonging to FIXTURE SANCTIONED ENTITY (program: FIXTURE).",
"evidence": {
"action": "block",
"address": "0x000000000000000000000000000000000000b10c",
"chain": "ethereum",
"list": "OFAC_SDN",
"entity_id": "FIXTURE:sanctioned",
"primary_name": "FIXTURE SANCTIONED ENTITY",
"programs": [
"FIXTURE"
],
"batch_id": "<the live OFAC_SDN batch id>",
"asset_codes": [
"ETH"
],
"fixture": true
}
}
],
"user_message": "We can't process this transaction. Contact support if you believe this is a mistake."
}Webhook verification
Deliveries carry clearlist-signature: t=<unix seconds>,v1=<hex hmac-sha256> over `${t}.${rawBody}`. constructEvent verifies and parses in one step (WebCrypto, so it runs anywhere the SDK does) and throws WebhookSignatureError otherwise. Lower level: verifyWebhookSignature(secret, header, rawBody) returns a boolean.
import { constructEvent, WebhookSignatureError } from "@clearlist/sdk";
export async function POST(req: Request) {
const raw = await req.text(); // verify the raw body, never re-serialised JSON
let event;
try {
event = await constructEvent(process.env.CLEARLIST_WEBHOOK_SECRET!, req.headers.get("clearlist-signature"), raw);
} catch (e) {
if (e instanceof WebhookSignatureError) return new Response("bad signature", { status: 401 });
throw e;
}
if (event.event === "monitor.changed") await freezeIfNeeded(event.data); // the new decision
return new Response("ok");
}Errors & retries
Every non-2xx response throws a ClearlistError (or a subclass) with the API’s stable code, the HTTP status, any details, and requestId. 429 rate_limited, 5xx and connection errors are retried with backoff (honouring retry-after); plan_limit, 402 and other 4xx are not. Transport failures surface as ClearlistConnectionError / ClearlistTimeoutError with status: 0.
import { ClearlistError, PlanLimitError, PlanRequiredError, RateLimitError, ValidationError } from "@clearlist/sdk";
try {
await clearlist.screenAddress(address);
} catch (e) {
if (e instanceof PlanLimitError) … // 429 plan_limit: Free allowance used up. Never retried. e.used / e.limit
else if (e instanceof PlanRequiredError) … // 402: not on your plan. e.feature / e.requiredPlan
else if (e instanceof ValidationError) … // 400: e.issues[] with path + message
else if (e instanceof RateLimitError) … // 429 rate_limited, after retries. e.retryAfterSec
else if (e instanceof ClearlistError) … // anything else: e.code, e.status, e.details, e.requestId
else throw e;
}Recipes
One file plus a README each, under examples/ in the repository. All of them use the fixtures, so a cl_test_ key is enough to see every state.
- Next.js route handlerNext.js App Router
- Supabase Edge FunctionDeno
- Cloudflare WorkerWorkers
- Express middlewareNode
- Privy: screen on loginReact + Privy
- wagmi connect gateReact + wagmi
- Solana wallet-adapter gateReact + wallet-adapter
Next.js route handler (Next.js App Router)
Screens the transfer the signed-in user is about to make, then returns the transaction to sign, a 202 while a review is open, or a 403 with a message that is safe to show. Also the /api/screen backend the React gate talks to.
npm install @clearlist/sdk zod/**
* app/api/send/route.ts — screen the current user's wallet before letting them send.
*
* The live key lives in CLEARLIST_KEY on the server. The browser posts { to, amount } and gets back
* either the signed-off payload or a compliant message to show. The same route doubles as the
* `screen` function for @clearlist/react if you return the decision unchanged (see /api/screen below).
*
* Try it with a test key: CLEARLIST_KEY=cl_test_… and a `to` of 0x000000000000000000000000000000000000b10c
* returns a block; …0e41 a review; …a110 an allow.
*/
import { Clearlist, ClearlistError, PlanLimitError } from "@clearlist/sdk";
import { z } from "zod";
const clearlist = new Clearlist({ apiKey: process.env.CLEARLIST_KEY! });
const Body = z.object({
to: z.string().min(10),
amount: z.string(),
asset: z.string().default("USDC"),
});
export async function POST(req: Request) {
const user = await getSessionUser(req); // your auth
if (!user) return Response.json({ error: "unauthenticated" }, { status: 401 });
const parsed = Body.safeParse(await req.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "invalid_body" }, { status: 400 });
const { to, amount, asset } = parsed.data;
try {
const decision = await clearlist.screenTransaction(
{ chain: "base", from: user.walletAddress, to, amount, asset },
{
// Same key → same decision on a retry; use the id of the thing being sent.
idempotencyKey: `send:${user.id}:${to}:${amount}:${Date.now() / 60_000 | 0}`,
metadata: { user_id: user.id, flow: "send" },
},
);
if (decision.outcome === "block") {
// user_message never names the list entry; safe to show.
return Response.json({ ok: false, decision_id: decision.id, message: decision.user_message }, { status: 403 });
}
if (decision.outcome === "review") {
await holdForReview(user.id, decision.id); // an analyst clears or confirms it in the Clearlist dashboard
return Response.json({ ok: false, decision_id: decision.id, message: decision.user_message }, { status: 202 });
}
const tx = await buildTransfer({ from: user.walletAddress, to, amount, asset });
return Response.json({ ok: true, decision_id: decision.id, tx });
} catch (err) {
if (err instanceof PlanLimitError) {
// Free allowance used up: fail closed, and page someone.
return Response.json({ ok: false, message: "Transfers are temporarily unavailable." }, { status: 503 });
}
if (err instanceof ClearlistError) console.error("clearlist", err.code, err.status, err.requestId);
throw err;
}
}
/* ---- app/api/screen/route.ts: the `screen` function for @clearlist/react, same key, same client ---- */
export async function SCREEN_POST(req: Request) {
const user = await getSessionUser(req);
if (!user) return Response.json({ error: "unauthenticated" }, { status: 401 });
const subject = await req.json(); // trust the shape; Clearlist validates it and returns 400 details
const decision = await clearlist.screen(subject, { metadata: { user_id: user.id } });
return Response.json(decision, { status: 201 });
}
/* ---- stand-ins for your app ---- */
declare function getSessionUser(req: Request): Promise<{ id: string; walletAddress: string } | null>;
declare function holdForReview(userId: string, decisionId: string): Promise<void>;
declare function buildTransfer(input: { from: string; to: string; amount: string; asset: string }): Promise<unknown>;Supabase Edge Function (Deno)
Screens the wallet a user just connected, verifies their Supabase session, stores the verdict in a table your RLS policies can read.
supabase functions new screen-wallet && supabase secrets set CLEARLIST_KEY=cl_test_…// supabase/functions/screen-wallet/index.ts — Deno edge function that screens a wallet for the signed-in user.
//
// Deploy: supabase functions deploy screen-wallet
// Secret: supabase secrets set CLEARLIST_KEY=cl_test_…
// Call: POST https://<project>.supabase.co/functions/v1/screen-wallet { "address": "0x…", "chain": "base" }
// with the user's Supabase JWT in Authorization.
//
// With a test key, address 0x000000000000000000000000000000000000b10c returns a block, …0e41 a review, …a110 an allow.
import { Clearlist, ClearlistError } from "npm:@clearlist/sdk@^0.1.0";
import { createClient } from "npm:@supabase/supabase-js@2";
const clearlist = new Clearlist({ apiKey: Deno.env.get("CLEARLIST_KEY")! });
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
// Who is asking: verify the Supabase JWT the client sent.
const supabase = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, {
global: { headers: { Authorization: req.headers.get("Authorization") ?? "" } },
});
const { data: { user } } = await supabase.auth.getUser();
if (!user) return Response.json({ error: "unauthenticated" }, { status: 401 });
const { address, chain } = await req.json().catch(() => ({}));
if (typeof address !== "string") return Response.json({ error: "address required" }, { status: 400 });
try {
const decision = await clearlist.screenAddress(address, chain, {
idempotencyKey: `wallet:${user.id}:${address.toLowerCase()}`, // one decision per user+wallet
metadata: { user_id: user.id, flow: "connect" },
});
// Persist the verdict next to the user so RLS policies can read it (e.g. block sends when status != 'allow').
await supabase.from("wallet_screens").upsert({ user_id: user.id, address: address.toLowerCase(), outcome: decision.outcome, decision_id: decision.id, screened_at: decision.created_at });
return Response.json({ outcome: decision.outcome, message: decision.user_message, decision_id: decision.id });
} catch (err) {
if (err instanceof ClearlistError) return Response.json({ error: err.code, message: err.message }, { status: err.status || 502 });
throw err;
}
});Cloudflare Worker (Workers)
A screening proxy for your front end (the key stays in a Worker secret, allow results cached in KV) plus a webhook receiver that verifies the signature with constructEvent.
npm install @clearlist/sdk && npm install -D wrangler @cloudflare/workers-types/**
* Cloudflare Worker: a screening proxy for your front end.
*
* POST /screen { "type": "address", "address": "0x…" } → the Clearlist decision (201)
* POST /webhook (from Clearlist) → verified with the signing secret, then handled
*
* The browser talks to this Worker (same origin or your CORS rules), the Worker talks to Clearlist with the
* live key in a secret. Point @clearlist/react's `screen` at `/screen`.
*
* wrangler secret put CLEARLIST_KEY # cl_test_… while developing
* wrangler secret put CLEARLIST_WEBHOOK_SECRET # whsec_… from webhooks.create()
* wrangler dev
*/
import { Clearlist, ClearlistError, constructEvent, WebhookSignatureError, type Decision } from "@clearlist/sdk";
export interface Env {
CLEARLIST_KEY: string;
CLEARLIST_WEBHOOK_SECRET: string;
/** Optional: cache allow decisions per address for an hour. */
SCREENS?: KVNamespace;
}
const ALLOWED_ORIGIN = "https://app.example.com";
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
if (req.method === "OPTIONS") return cors(new Response(null, { status: 204 }));
if (url.pathname === "/screen" && req.method === "POST") return cors(await screen(req, env));
if (url.pathname === "/webhook" && req.method === "POST") return webhook(req, env);
return new Response("not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
async function screen(req: Request, env: Env): Promise<Response> {
const clearlist = new Clearlist({ apiKey: env.CLEARLIST_KEY, timeoutMs: 8_000 });
const subject = await req.json().catch(() => null);
if (!subject || typeof subject !== "object") return Response.json({ error: "invalid_json" }, { status: 400 });
const cacheKey = subject.type === "address" ? `allow:${String(subject.address).toLowerCase()}` : null;
if (cacheKey && env.SCREENS) {
const cached = await env.SCREENS.get(cacheKey, "json");
if (cached) return Response.json(cached, { headers: { "x-screen-cache": "hit" } });
}
try {
const decision: Decision = await clearlist.screen(subject, { metadata: { ip_country: req.headers.get("cf-ipcountry") ?? "" } });
if (cacheKey && env.SCREENS && decision.outcome === "allow") await env.SCREENS.put(cacheKey, JSON.stringify(decision), { expirationTtl: 3600 });
return Response.json(decision, { status: 201 });
} catch (err) {
if (err instanceof ClearlistError) return Response.json({ error: { code: err.code, message: err.message } }, { status: err.status || 502 });
throw err;
}
}
async function webhook(req: Request, env: Env): Promise<Response> {
const raw = await req.text(); // verify the raw bytes, never re-serialised JSON
try {
const event = await constructEvent<Decision>(env.CLEARLIST_WEBHOOK_SECRET, req.headers.get("clearlist-signature"), raw);
if (event.event === "monitor.changed" && event.data.outcome !== "allow") {
// A monitored wallet's outcome changed after a list update: freeze it.
// await freezeWallet(event.data.subject);
}
return new Response("ok");
} catch (err) {
if (err instanceof WebhookSignatureError) return new Response("bad signature", { status: 401 });
throw err;
}
}
function cors(res: Response): Response {
res.headers.set("access-control-allow-origin", ALLOWED_ORIGIN);
res.headers.set("access-control-allow-methods", "POST, OPTIONS");
res.headers.set("access-control-allow-headers", "content-type");
return res;
}Express middleware (Node)
requireClearWallet(): allow continues with req.decision set, review answers 202, block 403, and a screening failure 503 without ever letting the request through.
npm install @clearlist/sdk express && npm install -D tsx @types/express/**
* Express middleware: `requireClearWallet()` screens `req.user.walletAddress` (or a body/param field) and
* refuses the request with the user-safe message unless the outcome is allow.
*
* CLEARLIST_KEY=cl_test_… npx tsx server.ts
* curl -X POST localhost:4000/withdraw -H 'content-type: application/json' \
* -d '{"wallet":"0x000000000000000000000000000000000000b10c","amount":"10"}' # 403 block
* curl -X POST localhost:4000/withdraw -H 'content-type: application/json' \
* -d '{"wallet":"0x0000000000000000000000000000000000000e41","amount":"10"}' # 202 review
* curl -X POST localhost:4000/withdraw -H 'content-type: application/json' \
* -d '{"wallet":"0x000000000000000000000000000000000000a110","amount":"10"}' # 200 allow
*/
import express, { type NextFunction, type Request, type Response } from "express";
import { Clearlist, ClearlistError, type Chain, type Decision } from "@clearlist/sdk";
const clearlist = new Clearlist({ apiKey: process.env.CLEARLIST_KEY ?? "" });
declare module "express-serve-static-core" {
interface Request {
decision?: Decision;
}
}
interface GateOptions {
/** Where the wallet address comes from. Default: req.body.wallet, then req.params.wallet. */
address?: (req: Request) => string | undefined;
chain?: Chain;
/** Hold reviews instead of refusing them (202 + message). Default true. */
acceptReview?: boolean;
}
/** Screen the wallet; on allow, continue with `req.decision` set; otherwise answer with the user-safe message. */
export function requireClearWallet(opts: GateOptions = {}) {
const pick = opts.address ?? ((req: Request) => (req.body?.wallet as string | undefined) ?? req.params.wallet);
return async (req: Request, res: Response, next: NextFunction) => {
const address = pick(req);
if (!address) return res.status(400).json({ error: "wallet address required" });
try {
const decision = await clearlist.screenAddress(address, opts.chain, {
metadata: { route: req.path, ip: req.ip ?? "" },
});
req.decision = decision;
if (decision.outcome === "allow") return next();
if (decision.outcome === "review" && opts.acceptReview !== false) {
return res.status(202).json({ status: "review", decision_id: decision.id, message: decision.user_message });
}
return res.status(403).json({ status: decision.outcome, decision_id: decision.id, message: decision.user_message });
} catch (err) {
if (err instanceof ClearlistError) {
// Fail closed. Log the stable code and request id for support.
console.error("clearlist", err.code, err.status, err.requestId);
return res.status(503).json({ error: "screening_unavailable", message: "Please try again in a moment." });
}
next(err);
}
};
}
const app = express();
app.use(express.json());
app.post("/withdraw", requireClearWallet({ chain: "base" }), (req, res) => {
// Only reached when the wallet screened as allow. req.decision carries the full record.
res.json({ ok: true, decision_id: req.decision!.id, amount: req.body.amount });
});
app.get("/wallets/:wallet/status", requireClearWallet({ acceptReview: false }), (req, res) => {
res.json({ ok: true, decision_id: req.decision!.id });
});
const port = Number(process.env.PORT ?? 4000);
app.listen(port, () => console.log(`listening on http://localhost:${port} (${clearlist.isTest ? "test" : "live"} key)`));Privy: screen on login (React + Privy)
In useLogin({ onComplete }) every linked wallet (embedded and external, EVM and Solana) is screened through your backend; a blocked user is logged straight back out.
npm install @privy-io/react-auth @clearlist/react @clearlist/sdk"use client";
/**
* Screen every wallet a user brings on Privy login, and gate the app on the result.
*
* Privy fires `onComplete` once the user is logged in with their linked accounts. We post every wallet
* address to our own /api/screen route (which holds the live key; see the nextjs-route-handler recipe),
* keep the worst outcome, and render a compliant message instead of the app when it is not allow.
*
* Test it: log in with an embedded wallet, then temporarily push a fixture address into `wallets` below
* (0x000000000000000000000000000000000000b10c → block) while /api/screen uses a cl_test_ key.
*/
import { PrivyProvider, useLogin, usePrivy, type User } from "@privy-io/react-auth";
import { useState } from "react";
import type { ScreenDecision } from "@clearlist/react";
const PRIVY_CHAIN: Record<string, string> = { ethereum: "ethereum", solana: "solana" };
/** Calls OUR backend, never Clearlist directly. */
async function screenAddress(address: string, chain: string, userId: string): Promise<ScreenDecision> {
const res = await fetch("/api/screen", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "address", address, chain, metadata: { user_id: userId, flow: "privy_login" } }),
});
if (!res.ok) throw new Error(`screen failed: ${res.status}`);
return res.json();
}
/** Every wallet Privy knows for this user (embedded + linked), with Clearlist's chain name. */
function walletsOf(user: User): Array<{ address: string; chain: string }> {
return user.linkedAccounts
.filter((a): a is Extract<User["linkedAccounts"][number], { type: "wallet" }> => a.type === "wallet")
.map((w) => ({ address: w.address, chain: PRIVY_CHAIN[w.chainType] ?? "other" }));
}
const RANK = { allow: 0, review: 1, block: 2 } as const;
export function LoginGate({ children }: { children: React.ReactNode }) {
const { ready, authenticated, logout } = usePrivy();
const [verdict, setVerdict] = useState<ScreenDecision | null | "checking">(null);
const { login } = useLogin({
onComplete: async ({ user }) => {
setVerdict("checking");
try {
const decisions = await Promise.all(walletsOf(user).map((w) => screenAddress(w.address, w.chain, user.id)));
// No wallets yet (email-only login): nothing to screen, let them in.
const worst = decisions.sort((a, b) => RANK[b.outcome] - RANK[a.outcome])[0] ?? null;
setVerdict(worst);
if (worst && worst.outcome === "block") await logout(); // do not keep a session for a blocked wallet
} catch {
setVerdict({ id: "", outcome: "review", user_message: "We couldn't complete a required check. Please try again.", reasons: [] });
}
},
});
if (!ready) return null;
if (!authenticated) return <button onClick={() => login()}>Log in</button>;
if (verdict === "checking") return <p>Checking your wallet…</p>;
if (verdict && verdict.outcome !== "allow") {
return (
<div className="clearlist-gate" role="status" data-decision-id={verdict.id}>
<p className="clearlist-gate__message">{verdict.user_message}</p>
</div>
);
}
return <>{children}</>;
}
export function App({ children }: { children: React.ReactNode }) {
return (
<PrivyProvider appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID!} config={{ embeddedWallets: { ethereum: { createOnLogin: "users-without-wallets" } } }}>
<LoginGate>{children}</LoginGate>
</PrivyProvider>
);
}wagmi connect gate (React + wagmi)
useAccount() supplies the address and chain id; <ScreeningGate> renders the send form only when the wallet screens as allow and re-screens when the account changes.
npm install wagmi viem @tanstack/react-query @clearlist/react @clearlist/sdk"use client";
/**
* wagmi: gate the send form on the connected wallet.
*
* `useAccount()` gives the address and numeric chain id; <ScreeningGate> screens it through your backend
* (/api/screen holds the key; see the nextjs-route-handler recipe) and renders the form only on allow.
*
* Test it: while /api/screen uses a cl_test_ key, pass `address="0x000000000000000000000000000000000000b10c"`
* to the gate instead of the wagmi address and you will see the block message.
*/
import { ClearlistProvider, ScreeningGate, type ScreenFn } from "@clearlist/react";
import { useAccount, useConnect, useDisconnect } from "wagmi";
/** Posts the subject to OUR backend, never to Clearlist directly. */
const screen: ScreenFn = async (subject, opts) => {
const res = await fetch("/api/screen", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(subject),
signal: opts?.signal,
});
if (!res.ok) throw new Error(`screen failed: ${res.status}`);
return res.json();
};
/** wagmi chain id → Clearlist chain id. Any EVM chain matches the same designations; this only labels the decision. */
const CHAIN: Record<number, string> = { 1: "ethereum", 8453: "base", 42161: "arbitrum", 10: "optimism", 137: "polygon", 56: "bsc", 43114: "avalanche" };
export function SendPanel() {
const { address, chainId, isConnected } = useAccount();
const { connect, connectors } = useConnect();
const { disconnect } = useDisconnect();
return (
<ClearlistProvider screen={screen}>
<ScreeningGate
address={isConnected ? address : null}
chain={chainId ? CHAIN[chainId] : undefined}
disconnected={
<div>
{connectors.map((c) => (
<button key={c.uid} onClick={() => connect({ connector: c })}>
Connect {c.name}
</button>
))}
</div>
}
loading={<p>Checking wallet…</p>}
fallback={(decision) => (
<div className="clearlist-gate" role="status" data-decision-id={decision.id}>
<p className="clearlist-gate__message">{decision.user_message}</p>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
)}
onBlock={(d) => console.warn("wallet blocked", d.id)}
>
<SendForm from={address!} />
</ScreeningGate>
</ClearlistProvider>
);
}
function SendForm({ from }: { from: string }) {
return (
<form>
<p>Sending from {from}</p>
<input name="to" placeholder="0x…" />
<input name="amount" placeholder="0.00" />
<button type="submit">Send</button>
</form>
);
}Solana wallet-adapter gate (React + wallet-adapter)
Two nested gates: the connected wallet, then the actual USDC transfer once a recipient is typed, so the send button only exists for a clean wallet sending to a clean recipient.
npm install @solana/wallet-adapter-react @solana/wallet-adapter-react-ui @solana/web3.js @clearlist/react @clearlist/sdk"use client";
/**
* Solana wallet-adapter: gate a USDC transfer on the connected wallet AND on the transaction itself.
*
* The wallet is screened as soon as it connects. When the user fills in a recipient, the gate switches
* to a transaction subject so both legs (and counterparty exposure, on paid plans) are checked before
* the transfer is built.
*
* Test it: while /api/screen uses a cl_test_ key, enter C1earListFixtureB1ock11111111111111111111111 as
* the recipient to see a block, C1earListFixtureRev1ew1111111111111111111111 for a review.
*/
import { ClearlistProvider, ScreeningGate, type ScreenFn } from "@clearlist/react";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { useState } from "react";
const screen: ScreenFn = async (subject, opts) => {
const res = await fetch("/api/screen", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(subject),
signal: opts?.signal,
});
if (!res.ok) throw new Error(`screen failed: ${res.status}`);
return res.json();
};
export function Transfer() {
const { publicKey } = useWallet();
const owner = publicKey?.toBase58() ?? null;
const [to, setTo] = useState("");
const [amount, setAmount] = useState("");
const recipientReady = to.length >= 32;
return (
<ClearlistProvider screen={screen}>
{/* 1. The wallet itself. */}
<ScreeningGate address={owner} chain="solana" disconnected={<WalletMultiButton />} loading={<p>Checking wallet…</p>}>
<form onSubmit={(e) => e.preventDefault()}>
<input value={to} onChange={(e) => setTo(e.target.value.trim())} placeholder="Recipient address" />
<input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="USDC amount" />
{/* 2. The transfer, once there is a recipient. Re-screens as the fields change. */}
{recipientReady ? (
<ScreeningGate
subject={{ type: "transaction", chain: "solana", from: owner!, to, amount: amount || undefined, asset: "USDC" }}
loading={<button disabled>Checking…</button>}
fallback={(d) => (
<p className={`clearlist-gate clearlist-gate--${d.outcome}`} role="status">
{d.user_message}
</p>
)}
>
<button type="submit" onClick={() => sendUsdc(owner!, to, amount)}>
Send {amount || "0"} USDC
</button>
</ScreeningGate>
) : null}
</form>
</ScreeningGate>
</ClearlistProvider>
);
}
declare function sendUsdc(from: string, to: string, amount: string): Promise<void>;