On-chain sanctions oracle

built and tested; testnet deployment pending

A contract that answers one question, isSanctioned(address), for free, from inside any transaction. It mirrors the EVM-format addresses on the government sanctions lists Clearlist ingests (OFAC SDN and Consolidated, EU, UK OFSI, UN) and a keeper keeps it equal to those lists. The logic is immutable; a new deployment is a new version.

The one-line check

The interface is intentionally the same as the Chainalysis oracle’s, selector included, so a contract that already integrates one can point at the other by changing an address. Nothing else is required: no token, no fee, no registration. Chainalysis deprecated its oracle on 18 March 2026; the migration guide covers what changes and what does not.

Solidity
// Same selector as the Chainalysis oracle (isSanctioned(address) = 0xdf592f7d):
// swap the address and nothing else changes.
interface ISanctionsOracle {
    function isSanctioned(address addr) external view returns (bool);
}

contract Vault {
    ISanctionsOracle constant ORACLE = ISanctionsOracle(0x0000000000000000000000000000000000000000); // see Deployments

    function withdraw(uint256 amount) external {
        require(!ORACLE.isSanctioned(msg.sender), "sanctioned");
        // …
    }
}

The guard modifier

SanctionsGuard is an abstract contract with a notSanctioned(address) modifier. Inherit it, pass the oracle address, put the modifier on the functions that move value. It costs one external call and one cold storage read (about 2,600 gas) per check. _sanctionsOracle() is virtual for integrators who want to rotate the oracle later.

Solidity
import {SanctionsGuard} from "clearlist/contracts/src/SanctionsGuard.sol";

contract Vault is SanctionsGuard {
    constructor(address oracle) SanctionsGuard(oracle) {}

    // Reverts with SanctionedAddress(who) when the oracle lists the address.
    function deposit() external payable notSanctioned(msg.sender) { /* … */ }
    function transfer(address to, uint256 amount) external notSanctioned(msg.sender) notSanctioned(to) { /* … */ }
}

Deployments

One contract per chain, same bytecode, same address format. Testnets first; mainnets follow as deployments are funded, across the 32 EVM chains in the registry. Live list versions and timestamps come from GET /api/v1/oracle; the table below is the static deployment record.

ChainChain idContractStatus
Base Sepolia testnet84532not deployedpending
Ethereum mainnet1not deployedpending
Base mainnet8453not deployedpending
Arbitrum One mainnet42161not deployedpending
OP Mainnet mainnet10not deployedpending
Polygon mainnet137not deployedpending
BNB Smart Chain mainnet56not deployedpending
Avalanche C-Chain mainnet43114not deployedpending
Arc mainnet5042not deployedpending

Deploying is one command from contracts/: ORACLE_DEPLOYER_KEY=0x… forge script script/Deploy.s.sol --rpc-url $RPC --broadcast. The deployer becomes owner and updater unless ORACLE_OWNER / ORACLE_UPDATER are set.

GET/api/v1/oracleno auth; deployments with live listVersion / lastUpdated / sourceListBatch

Provenance and verification

The on-chain set is derived from the same rows the API screens against, and every piece needed to check it is public. The snapshot endpoint returns the full EVM set, the source batch ids it was built from, and a Merkle root (keccak256 leaves, sorted pairs). The contract stores sourceListBatch = keccak256 of the snapshot’s source_list_batch_id, so anyone can confirm which list versions a given on-chain state mirrors, and listVersion increments on every change, with the full address array in the event, so the history can be rebuilt from logs alone.

GET/api/v1/oracle/snapshotno auth; addresses, batches, source_list_batch, merkle.root
GET/api/v1/oracle/proof/:addressno auth; Merkle inclusion proof, 404 not_listed when absent
verify with cast
# 1. The set the oracle should mirror, with the batch ids it came from and a Merkle root
curl -s https://clearlist.xyz/api/v1/oracle/snapshot | jq '{count, source_list_batch_id, source_list_batch, root: .merkle.root}'

# 2. What the contract says it mirrors (keccak256 of source_list_batch_id)
cast call $ORACLE "sourceListBatch()(bytes32)" --rpc-url $RPC
cast call $ORACLE "listVersion()(uint256)"     --rpc-url $RPC

# 3. Spot-check: every snapshot address must be listed on chain
cast call $ORACLE "isSanctioned(address)(bool)" 0x098B716B8Aaf21512996dC57EB0615e2383E2f96 --rpc-url $RPC
# → true (Lazarus Group, OFAC SDN)
GET /api/v1/oracle/proof/0x098b…2f96 (abridged)
{
  "address": "0x098b716b8aaf21512996dc57eb0615e2383e2f96",
  "listed": true,
  "leaf": "0x2861e94a7c7d561cbfa5e20f6bfc1f43e38e06af52564511bf9aa36c5e5897d8",
  "proof": [
    "0x…",
    "0x…",
    "0x…",
    "0x…",
    "0x…",
    "0x…",
    "0x…"
  ],
  "root": "0x…",
  "depth": 7,
  "count": 124,
  "source_list_batch_id": "OFAC_CONS=batch_…;OFAC_SDN=batch_…",
  "source_list_batch": "0x…",
  "sources": [
    "OFAC_SDN"
  ],
  "entity_ids": [
    "OFAC_SDN:…"
  ]
}

Keeper cadence

Lists refresh daily at 06:40 UTC; the keeper runs at 07:00 UTC and whenever an operator triggers it. It computes target minus on-chain, verifies against the contract with isSanctionedBatch before sending anything, and submits addToList / removeFromList in chunks of 200 (about 4.7M gas per full chunk). Every confirmed chunk is recorded in oracle_sync, then setSourceListBatch stamps the new provenance. Dry-run is the default; only the updater key can write, and the owner can pause it.

shell
# Dry run (default): the diff between the DB set and each configured contract, nothing sent
npm run oracle:sync
npm run oracle:sync -- --chain base-sepolia

# Send addToList / removeFromList in chunks of 200 from ORACLE_UPDATER_KEY
npm run oracle:sync -- --execute

# The same, as a cron route (runs daily after the list refresh)
curl -X POST -H "x-cron-secret: $CRON_SECRET" https://clearlist.xyz/api/cron/oracle-sync
POST/api/cron/oracle-syncCRON_SECRET; ?chain=… to limit, ?dry=1 to report only

Limits

  • EVM addresses only. Bitcoin, Tron, Solana and the other formats on the lists are screened by the API, not mirrored here (see the Solana design below).
  • Government lists only. Public labels (mixers, hacks, darknet markets), counterparty exposure, your policy and your allowlist are API features; the oracle answers “is this exact address designated” and nothing else.
  • A listed address is listed on every chain the oracle is deployed to, because an EVM address is valid on all of them.
  • Sync lag is the keeper cadence (daily, plus manual runs), on top of the publishers’ own update schedules.
  • No upgradeability. Bugs or new features mean a new deployment and a new address; the old one keeps working and keeps being synced until retired.
  • The contract is a convenience for on-chain enforcement, not an evidence record. Decisions you need to show a bank or an auditor come from the API.

Contract reference

SanctionsOracle, Solidity 0.8.30, source at contracts/src/SanctionsOracle.sol. One storage mapping; every function below is tested with Foundry (forge test in contracts/).

ABI summary
// Reads (free, no auth)
function isSanctioned(address addr) external view returns (bool);           // 2,566 gas
function isSanctionedBatch(address[] calldata addrs) external view returns (bool[] memory);
function listVersion() external view returns (uint256);   // +1 on every list change; 0 = never synced
function lastUpdated() external view returns (uint64);    // block timestamp of the last change
function sourceListBatch() external view returns (bytes32); // keccak256(source_list_batch_id)
function owner() / pendingOwner() / updater() / paused()

// Updater (the keeper)
function addToList(address[] calldata addrs) external;      // skips duplicates, reverts NoChange if none
function removeFromList(address[] calldata addrs) external;
function setSourceListBatch(bytes32 batch) external;        // provenance only; does not bump listVersion

// Owner
function setUpdater(address) / pause() / unpause()
function transferOwnership(address) / acceptOwnership()      // two-step

// Events
event SanctionedAddressesAdded(address[] addrs, uint256 version);
event SanctionedAddressesRemoved(address[] addrs, uint256 version);
event SourceListBatchUpdated(bytes32 sourceListBatch, uint256 version);
event UpdaterChanged(address indexed previousUpdater, address indexed newUpdater);
event OwnershipTransferStarted / OwnershipTransferred / Paused / Unpaused

// Errors
NotOwner, NotPendingOwner, NotUpdater, ZeroAddress, EnforcedPause, ExpectedPause, EmptyBatch, NoChange

Solana design

On Solana the oracle is a program holding one Merkle root per list version (the same root the snapshot endpoint publishes), updated by the keeper with a single set_root instruction, and a verify(address, proof) helper other programs can CPI into or inline. Proofs come from GET /api/v1/oracle/proof/:address, which is live today. The program itself is designed in contracts/solana/DESIGN.md and not yet built: the Anchor and Solana toolchains were not available where this was written. The design says so plainly and lists the build steps.