OFAC sanctions screening for ERC-20 transfers on any chain — including chains Chainalysis doesn't deploy on.
The Chainalysis on-chain sanctions oracle is keyless and free, but it's only
deployed on a handful of mainnets — Ethereum, Arbitrum, Polygon, BSC,
Avalanche, Optimism, Celo. It is not on Base. Or Linea. Or Scroll. Or any
L3. Or any testnet. On those chains, a contract can't reach Chainalysis with a
plain staticcall.
This example uses a Lit Action to bridge that gap. The action runs an
eth_call against the Chainalysis oracle on Ethereum mainnet, then signs an
authorization that the CompliantToken contract — deployed wherever you want
— can verify with ecrecover. No bridge, no API key, no minted PKP, no
encrypted secrets. The default target is Base Sepolia; you can swap it for any
EVM chain Hardhat can talk to.
user wallet Lit Action Alchemy + Chainalysis CompliantToken
(Ethereum mainnet) (Base)
│ │ │ │
│ js_params │ │ │
├──────────────────►│ │ │
│ │ check URL host │ │
│ │ matches alchemy regex │ │
│ │ │ │
│ │ eth_call( │ │
│ │ chainalysis, │ │
│ │ isSanctioned(to)) │ │
│ ├────────────────────────►│ │
│ │◄────────────────────────┤ │
│ │ bool │ │
│ sig, returnData │ if !sanctioned: sign │ │
│◄──────────────────┤ with │ │
│ │ getLitActionPrivateKey()│ │
│ │ │
│ transferWithAuth(to, amount, nonce, deadline, sig) ────────────►│
│ │ ecrecover(sig) │
│ │ == oracle ✓ │
│ │ _transfer() │
You could deploy CompliantToken on Ethereum, where Chainalysis lives, and
call isSanctioned directly from a _beforeTokenTransfer hook — no Lit
needed. That's exactly the right answer there.
But if your token is on Base, or Solana (once non-EVM PKPs are in play), or a custom L2, or testnets — there is no on-chain Chainalysis. Your options become: (a) operate a server that holds an API key and trust it; (b) build a bridge; or (c) put a Lit Action in front of the oracle, sign an attestation, and let your contract verify it. This is option (c).
The signature comes from Lit.Actions.getLitActionPrivateKey() — a key
derived deterministically from the action's IPFS CID. The deployed
CompliantToken pins the address of that key. Edit the action by a byte and
the CID changes, the key changes, the address changes — and the contract
stops trusting the modified action.
| Path | Purpose |
|---|---|
action/complianceGate.js |
The Lit Action: probes screening RPC for chain id, eth_calls Chainalysis isSanctioned, signs the authorization. |
contracts/CompliantToken.sol |
ERC-20 with transferWithAuth that verifies the action's signature against the pinned oracle address. |
scripts/setup.js |
One-shot setup: computes the action CID, derives the action's wallet address, creates and wires the group, deploys the contract. Idempotent. |
scripts/deploy.js |
Hardhat deploy script; pins the action's derived wallet address as the on-chain oracle (also called by setup). |
scripts/transfer.js |
End-to-end client that calls the Lit Action, then submits the on-chain transfer. |
scripts/_env.js |
Tiny shared helper: reads .env and upserts new lines into it. |
.env.example |
All the env vars you'll fill in. |
cp .env.example .env
npm installEdit .env and set:
LIT_API_KEY— your account-level (master) API key from the Chipotle dashboard, not a scoped usage key. Setup calls management endpoints (/add_action,/add_group) that revertNotMasterAccounton scoped keys.DEPLOYER_PRIVATE_KEY— an EOA with gas on Base Sepolia (or your target chain)SENDER_PRIVATE_KEY— an EOA holding tokens (typically the same as deployer for testing)SCREENING_RPC_URL— an Ethereum mainnet Alchemy URL of the formhttps://eth-mainnet.g.alchemy.com/v2/<your-api-key>. Free tier at https://dashboard.alchemy.com works. The action hardcodes a hostname whitelist requiringeth-mainnet.g.alchemy.com(see the "Trust model" section below).
No Chainalysis signup, no encryption keys. To target a different token
chain (where CompliantToken lives), change DEPLOY_NETWORK (Hardhat
network name) and CHAIN_ID + RPC_URL. The screening chain stays
on Ethereum mainnet unless you also edit the action.
A naïve version of this action might take both a screeningRpcUrl and a
screeningChainId and check that eth_chainId reports the expected value.
That check is theater: anyone calling the action supplies both fields, so
they can lie consistently — pair a malicious RPC with a matching chain id
and the gate passes.
The real anchor is the hostname. The action checks
new URL(screeningRpcUrl).hostname against an anchored regex
(/^eth-mainnet\.g\.alchemy\.com$/). The check passes only when TLS
delivers data from Alchemy's actual servers — which is something nobody
short of a CA-level compromise can fake. Trust shifts to "Alchemy is
honest about Ethereum mainnet state," which is the same assumption almost
every dapp already makes.
To swap providers, edit ALLOWED_SCREENING_HOST in
action/complianceGate.js. For Infura use
/^mainnet\.infura\.io$/i, for QuickNode use
/^[a-z0-9-]+\.quiknode\.pro$/i, etc. Any edit changes the action's
IPFS CID and therefore its signer address — the existing on-chain
contract will refuse signatures from the modified action, so you'd need
to redeploy (or wire a rotate-oracle setter behind a multisig).
npm run setupThis walks through seven steps, printing each one as it goes:
- Compute the action's IPFS CID.
- Create a permission group with a wildcard action allowlist
(
cid_hashes_permitted: ["0"]→U256(0)in the on-chaincidHashset, which the contract reads as "any action allowed here"). Wildcard is what makes the one-shot deriver action in step 4 executable — its inline CID isn't registered anywhere. - Create a scoped usage API key with
execute_in_groups: [groupId], saved asLIT_USAGE_API_KEYin.env. Both the deriver in step 4 andtransfer.jslater call/lit_actionwith this key — the master key can't execute actions in your own groups (the contract'scanExecuteActiononly consults theusageApiKeys[...]mapping). - Derive the action's wallet address from its CID — this is what the
contract will pin as
complianceOracle. Runs via/lit_actionwith the scoped usage key. - Register the action against your account (metadata only).
- Add the specific action CID to the group (audit trail; technically
redundant given the wildcard but lets you later tighten the group
by removing the wildcard via
update_group). - Deploy
CompliantTokenwith the action's wallet address as the oracle.
Re-running npm run setup does a fresh setup top-to-bottom: every step
creates new on-chain state and overwrites the corresponding key in
.env. The previously-minted group / usage key / contract become
orphaned. That's intentional — a production app would manage upgrades,
but here "run setup again" is the simplest reset.
# Clean address — passes
npm run transfer -- --to 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --amount 100
# Chainalysis's documented test sanctioned address — fails closed
npm run transfer -- --to 0x7F367cC41522cE07553e823bf3be79A889DEbe1B --amount 100Expected output for a clean recipient:
Recipient cleared sanctions screening. Submitting transfer...
tx: 0x...
mined in block 12345678
Expected output for the sanctioned test address:
Lit Action denied the transfer: {
authorized: false,
reason: 'Recipient is on the Chainalysis sanctions oracle'
}
The example defaults to deploying CompliantToken on Base Sepolia. To use a
different chain:
- Add a network to
hardhat.config.js(the file already includesbaseandbaseSepolia). - Set
DEPLOY_NETWORKto the Hardhat network name. - Set
CHAIN_IDandRPC_URLto match — these are what the Lit Action and transfer.js use to talk to your deployed contract. - Leave
SCREENING_RPC_URLandSCREENING_CHAIN_IDalone — the action always reads Chainalysis on Ethereum mainnet (or whatever screening chain you point it at), independent of where the token lives.
This is the cross-chain bit: the screening chain and the token chain are decoupled.
The current action trusts one provider (Alchemy). If Alchemy itself ever lied
about an isSanctioned reading — buggy upgrade, hijacked endpoint, deliberate
fraud — the gate would happily sign a malicious transfer. To eliminate that
single point of failure, apply the multi-source consensus pattern: fan the
eth_call out to two or three independently-hostnamed providers (Alchemy +
Infura + QuickNode) and only sign when they all return the same bool. Edit
the action to keep three regexes instead of one and require all three URLs
in js_params.
- Replay protection. The contract stores
(from, nonce) → used. The client picks a random 32-byte nonce per call; switch to a counter if you prefer deterministic nonces. - Deadline. The action sets
deadline = now + 10 min. Tighten or relax to match your settlement window. - Oracle freshness. Chainalysis publishes new sanctioned addresses to their oracle on roughly a daily cadence. There is a window — measured in minutes to hours — where the OFAC SDN list contains an address that the on-chain oracle has not yet acknowledged. Decide whether your product needs to bridge that gap (e.g. by combining with a paid API).
- Policy upgrades. Because the oracle address is derived from the
action's CID, any change to the action source produces a new oracle
address. Old
CompliantTokendeployments will refuse signatures from the new action; the upgrade path is either (a) redeploy the token with the new oracle, or (b) add a setter behind a multisig that rotatescomplianceOracleto the new derived address. - Coverage caveat. Chainalysis's on-chain oracle only screens against OFAC SDN and similar lists. It will not flag a wallet that's known to be a drainer or mixer counterparty unless that wallet is also on a sanctions list. For richer screening, swap the on-chain lookup for a paid provider (TRM Labs, GetBlock, MetaSleuth, Chainalysis KYT) — the pattern (encrypt API key → decrypt in TEE → fetch → sign) is shown in other examples.