Block x402 payments to sanctioned addresses
This recipe shows how an app can prevent x402 payments to addresses on a sanctions list, enforced by Privy’s policy engine before anything is signed. It covers Base and Tempo (EVM) and Solana.Privy is infrastructure, not a compliance program. Each app determines its own compliance
obligations and layers the tooling it sees fit on top of Privy. A denylist blocks known addresses.
It does not screen for indirect exposure, nested services, or newly generated addresses. Pair it
with a screening provider such as Chainalysis, TRM, or Blockaid for risk scoring. Nothing in this
guide constitutes compliance or legal advice. Validate the approach with legal and compliance
teams, and keep the source data and review process current.
Where to screen an x402 payment
x402 payments are not ordinary transactions, so screeningeth_sendTransaction alone does not catch them.
- Base and Tempo (EVM): an x402 payment is an EIP-3009
transferWithAuthorizationauthorization. The client signs an EIP-712 typed-data message overeth_signTypedData_v4, and a facilitator submits it onchain and pays gas. The recipient to screen is thetofield inside theTransferWithAuthorizationmessage. - Solana: the payment is a USDC (SPL) transfer signed via
signTransactionorsignAndSendTransaction. The x402 library builds it as aTransferCheckedinstruction, so the recipient to screen isTransferChecked.destination. The policy below also coversTransfer.destinationin case the client emits a plainTransfer.
Why the policy engine is the enforcement point
- DENY takes precedence. If any rule evaluates to DENY, the policy engine denies the request even if another rule would allow it. A denylist can therefore extend an existing policy without reworking its allow rules.
- Unmatched methods default to DENY. If no rule returns an action for a requested method, the policy engine denies the request. Pair the denylist with ALLOW rules for the methods the app legitimately uses.
- Condition sets update independently of the policy. Addresses live in a condition set referenced by ID, so new designations sync without redeploying or re-signing the policy.
- Privy enforces policies per wallet. A policy applies only to the wallets that list it in
policy_ids. Every wallet the app creates must carry the policy from creation.
1. Define the authoritative list and its owner
Use a sanctions-data source approved by the compliance team, for example the U.S. Treasury OFAC SDN list, and establish:- the team responsible for reviewing and approving list updates
- the refresh cadence, at least daily and more often if the risk posture requires it
- an audit trail recording source, fetch time, version or hash, and addresses added or removed
- an escalation path for potential matches and false positives
2. Create the condition set
Create one condition set per chain family, one for EVM and one for Solana. Each policy targets a singlechain_type, and separate sets keep the audit trail per chain clean and allow independent refreshes.
id. The policy references the denylist by ID, not by name.
3. Load the addresses into the condition set
Fetching and batching the addresses is general code, so it is not reproduced here. At a high level:- Pull digital-currency addresses from the machine-readable SDN enhanced XML export on a schedule.
- Branch on address format, not on the asset label. USDC and USDT appear on multiple chains, and one address can carry several asset labels. EVM addresses start with
0x, and Solana addresses are base58. - POST them to the condition set in batches of up to 100 items per request.
4. Create the policy
Base and Tempo (EVM)
The x402 signing path iseth_signTypedData_v4. Screen the to field of the TransferWithAuthorization message against the condition set.
Determine the types map the client sends
The x402 library defines onlyTransferWithAuthorization. Whether EIP712Domain also reaches Privy
depends on how the wallet is wired up:
Both Privy clients hand x402 a viem
LocalAccount, which x402 calls directly, so its types map is
forwarded untouched. A WalletClient instead routes through viem’s signTypedData action, which
inserts an EIP712Domain entry derived from the domain fields.
The example below uses the shape both Privy clients produce. To screen requests that carry
EIP712Domain, add it to the same types map, listing only the domain fields the client actually
sends, in the order name, version, chainId, verifyingContract:
types map it matches exactly, so one rule cannot cover both.
TransferWithAuthorization denies every other message type, which is the intended behavior for a payments-only wallet but breaks apps that also sign permits or logins.
For Tempo, keep the same rule shape and set the domain conditions to Tempo’s chain ID and USDC contract. If the app also moves value through ordinary transactions, add matching DENY rules on eth_sendTransaction and eth_signTransaction that screen ethereum_transaction.to for native transfers and the decoded calldata recipient for ERC-20 transfer and transferFrom.
Solana
The transfer action
Rules are scoped to a method, so the rules above only screen the signing methods they name. An app that also moves funds through the high-level transfer action needs its own rules, screeningdestination.address from the request body.
transfer rules entirely does not leave transfers unscreened: an unmatched
method is denied by default, so every transfer call fails until a rule allows it.
5. Attach the policy to wallets
Attach the policy at creation so no wallet ever exists without the denylist:owner_id, the update must be authorized by that owner:
6. Reject consistently and safely
When the policy denies a request, Privy does not sign it and returns apolicy_violation error. Map it to a stable, client-safe response:
- return a consistent 4xx, either 403 or 422
- use a generic message that does not reveal screening details
- record an internal audit event with policy version, matched address, chain, timestamp, and request identifier
- route the event to the compliance or risk-review workflow
7. Decide behavior when the sanctions source is unavailable
Enforcement does not depend on the sanctions source being reachable at signing time, because the condition set is the versioned local cache. What the app must decide is how the sync job behaves when the source is unavailable, and how stale a list it tolerates. Document one of:- Fail closed: if the list cannot be refreshed within the maximum tolerated age, tighten the policy to deny the affected methods.
- Fail open with alerting: keep enforcing against the last known-good set and alert immediately.
- Recommended: enforce continuously against the condition set, refresh asynchronously, and continue on the last known-good contents up to a defined maximum age, then apply the chosen fail mode.
8. Test before enabling
Cover at least these cases in a non-production environment:
Run the allowed and rejected cases against the same wallet and policy. A DENY rule that never fires looks identical to a correctly configured policy until a listed address is tested explicitly.
Limitations
- Direct recipients only. The engine screens the recipient in the signing request. Funds routed through a bridge, mixer, or intermediary that later reach a sanctioned address are not caught. Use a screening provider for indirect exposure.
- Typed-data screening needs the schema. An
ethereum_typed_data_messagecondition only matches messages whosetypesmap matches the declared one exactly. Screen every message type the app signs over. - Solana SPL destinations are token accounts.
TransferChecked.destinationis an associated token account, not the owner’s wallet address. To screen owners, derive and store the associated token accounts for each sanctioned address and each supported mint. - Solana address lookup tables. Policy evaluation cannot resolve addresses stored in an address lookup table. Keep screened addresses in the transaction’s static account keys.
- Wallets without the policy are unprotected. Enforcement is per wallet. Audit that every wallet carries the policy.

