Skip to main content

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 screening eth_sendTransaction alone does not catch them.
  • Base and Tempo (EVM): an x402 payment is an EIP-3009 transferWithAuthorization authorization. The client signs an EIP-712 typed-data message over eth_signTypedData_v4, and a facilitator submits it onchain and pays gas. The recipient to screen is the to field inside the TransferWithAuthorization message.
  • Solana: the payment is a USDC (SPL) transfer signed via signTransaction or signAndSendTransaction. The x402 library builds it as a TransferChecked instruction, so the recipient to screen is TransferChecked.destination. The policy below also covers Transfer.destination in case the client emits a plain Transfer.
The policy below enforces that the payment recipient is not on the sanctions list at the moment Privy signs, inside the secure enclave, so a modified client cannot bypass it.

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.
An app-side pre-check can improve the user experience, but clients can be modified and server paths can be bypassed. Keep the Privy policy as the authoritative control and treat any pre-check as advisory.

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
Store the chain family alongside every address. An address should never be treated as globally sanctioned without chain context. Create an owner for the condition set and the policy. Condition sets require an owner, and updates to both require an authorization signature. Without an owner, the app secret alone can modify the denylist.

2. Create the condition set

Create one condition set per chain family, one for EVM and one for Solana. Each policy targets a single chain_type, and separate sets keep the audit trail per chain clean and allow independent refreshes.
Keep the returned 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.
The only Privy-specific call is adding items:
For the signing conditions in this guide, the policy engine compares EVM addresses case-insensitively, so each address needs a single entry in either checksummed or lowercase form. Solana base58 addresses are matched exactly, so store those verbatim. The high-level transfer action is also matched exactly, even for EVM addresses. See the transfer action.

4. Create the policy

Base and Tempo (EVM)

The x402 signing path is eth_signTypedData_v4. Screen the to field of the TransferWithAuthorization message against the condition set.
An ethereum_typed_data_message condition only evaluates when the types map declared in the policy matches the types map in the signing request exactly, including every type the client sends and the field order within each type. On a mismatch the condition evaluates to false, which means a DENY rule never fires and a permissive ALLOW rule signs the request anyway.

Determine the types map the client sends

The x402 library defines only TransferWithAuthorization. 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:
If an app signs through more than one of these paths, give each shape its own DENY rule. A rule only screens requests whose types map it matches exactly, so one rule cannot cover both.
Confirm the shape against a real request rather than inferring it. Send one payment and inspect the typed_data.types the app submits, then mirror it exactly. If the policy declares a map the client does not send, both rules stop matching and the request is denied by default, so the symptom is every payment failing rather than a sanctioned payment slipping through. EIP712Domain also lists only the domain fields actually present, so a client that omits name or version produces a different map again.
Add one ALLOW rule per typed-data message shape the app signs. A rule pinned to 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

Solana evaluation requires every instruction in the transaction to be allowed by at least one rule. An x402 payment usually carries more than the token transfer, such as a compute budget instruction, so narrowing the ALLOW rule to instructionName in ['Transfer', 'TransferChecked'] rejects the whole transaction. Narrow the ALLOW rule only after confirming which instructions the x402 client actually emits.
The policy engine evaluates every instruction in a Solana transaction, so a DENY on any single instruction rejects the whole transaction.

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, screening destination.address from the request body.
Unlike the signing conditions above, action_request_body comparisons are exact. A denylist holding a checksummed address does not match the same address sent in lowercase, and the DENY rule silently fails to fire. Normalize destination addresses to a single form before calling transfer, or store both forms in the condition set.
A policy that omits 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:
For existing wallets, patch them. If the wallet has an owner_id, the update must be authorized by that owner:
To apply different denylists to different signers on the same wallet, attach the policy as an override policy on the signer instead. See conditional signer policies.
When a third party creates wallets on an app’s behalf, the policy ID must reach that integration. Pass it alongside the app ID, app secret, and authorization key so wallets are created with policy_ids already set.

6. Reject consistently and safely

When the policy denies a request, Privy does not sign it and returns a policy_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
Do not echo the matched address, condition set ID, or rule name to end users. Detailed rejection reasons let a caller enumerate the denylist by probing.

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.
Refresh the list in place. The policy never changes, so this requires no redeploy and no re-signature:
If a condition set is deleted, every condition referencing it evaluates to false. The denylist silently stops blocking while ALLOW rules keep passing traffic. Give the set an owner, and alert on an unexpectedly empty, shrunken, or missing set.

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_message condition only matches messages whose types map matches the declared one exactly. Screen every message type the app signs over.
  • Solana SPL destinations are token accounts. TransferChecked.destination is 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.

Further reading