Local verification of EVM exact/eip3009 payment payloads.
Runs the full facilitator verify checklist from the exact-EVM scheme
specification locally, so an Elixir resource server can cryptographically
verify payments instead of trusting a remote facilitator's verify
endpoint. The checks mirror the reference TypeScript/Go/Python facilitator
engines check for check.
Verification levels
The :level option is required and explicit — a level whose capabilities
are unavailable returns an error instead of silently downgrading:
:structural— pure checks, no cryptography and no RPC: scheme, network, and EIP-712 domain requirements; payload shape;payTorecipient equality; exact amount equality; and thevalidAfter/validBeforewindow with the reference implementations' 6-second settlement buffer.:signature— everything in:structural, plus EIP-712 digest recomputation and EOA signature recovery (requires the optionalex_keccakandex_secp256k1dependencies, otherwise{:error, :missing_dependency}). Smart-wallet signatures (ERC-1271 / ERC-6492) cannot be proven without RPC and are rejected with{:error, {:invalid, :smart_wallet_requires_rpc}}— fail closed, never assume.:full— everything in:signature, plus on-chain checks over a configuredX402.RPCendpoint (otherwise{:error, :rpc_not_configured}): chain-id cross-check, signature routing by payer bytecode (EOAecrecoverwhen the payer has no code, strict ERC-1271isValidSignaturewhen it does — no ECDSA fallback, matching on-chainSignatureCheckersemantics), ERC-6492 counterfactual handling, asset bytecode presence,balanceOffunding, and aneth_callsimulation oftransferWithAuthorizationwith failure diagnosis (nonce already used, insufficient balance, token domain mismatch, ...).
ERC-6492 counterfactual signatures (fail-closed)
A wrapped signature from an undeployed wallet is never accepted on the strength of the wrapper alone (the reference Go design):
- the deployment factory must appear in
:eip6492_allowed_factories(default[]— all counterfactual payments are rejected with{:invalid, :eip6492_factory_not_allowed}until factories are explicitly trusted), and - validity is proven only by an atomic Multicall3 simulation that deploys
the wallet and executes the transfer in a single
eth_call. Withsimulate: falsecounterfactual payments are rejected with{:invalid, :undeployed_smart_wallet}.
Example
{:ok, payload} = X402.PaymentSignature.decode_and_validate(header, requirements)
{:ok, rpc} = X402.RPC.new(rpc_url: "https://sepolia.base.org", finch: MyApp.Finch)
case X402.Verify.EVM.verify(payload, requirements, level: :full, rpc: rpc) do
{:ok, %{payer: payer}} -> grant_access(payer)
{:error, _reason} -> deny_access()
endThe result never silently downgrades: {:ok, result} means the payment
passed every check the stated :level includes, and result.level echoes
that level.
Structural-only verification is pure and needs no optional dependency:
iex> requirements = %{
...> "scheme" => "exact",
...> "network" => "eip155:84532",
...> "amount" => "10000",
...> "asset" => "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
...> "payTo" => "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
...> "maxTimeoutSeconds" => 60,
...> "extra" => %{"name" => "USDC", "version" => "2"}
...> }
iex> payload = %{
...> "x402Version" => 2,
...> "accepted" => requirements,
...> "payload" => %{
...> "signature" => "0x" <> String.duplicate("11", 65),
...> "authorization" => %{
...> "from" => "0x857b06519E91e3A54538791bDbb0E22373e36b66",
...> "to" => "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
...> "value" => "10000",
...> "validAfter" => "0",
...> "validBefore" => "32503680000",
...> "nonce" => "0x" <> String.duplicate("ab", 32)
...> }
...> }
...> }
iex> {:ok, result} = X402.Verify.EVM.verify(payload, requirements, level: :structural)
iex> {result.level, result.payer}
{:structural, "0x857b06519e91e3a54538791bdbb0e22373e36b66"}
Summary
Payment Verification
Maps an invalid reason atom to the canonical cross-SDK invalidReason
string used by the reference facilitators.
Verifies a decoded v2 PaymentPayload against payment requirements.
Types
Verification errors.
Why a payment was rejected.
Requested verification depth.
How the payment signature was (or would be) verified.
Simulation mode for level :full.
A successful verification.
Payment Verification
@spec reason_string(invalid_reason()) :: String.t()
Maps an invalid reason atom to the canonical cross-SDK invalidReason
string used by the reference facilitators.
Local-only reasons without a canonical wire equivalent fall back to their atom name.
Examples
iex> X402.Verify.EVM.reason_string(:recipient_mismatch)
"invalid_exact_evm_recipient_mismatch"
iex> X402.Verify.EVM.reason_string(:nonce_already_used)
"invalid_exact_evm_nonce_already_used"
iex> X402.Verify.EVM.reason_string(:invalid_payload)
"invalid_payload"
@spec verify(map(), map(), keyword()) :: {:ok, verification()} | {:error, error()}
Verifies a decoded v2 PaymentPayload against payment requirements.
payment_payload is the decoded v2 envelope (as returned by
X402.PaymentSignature.decode_and_validate/2) and requirements the
matched PaymentRequirements object. Both accept string or atom keys.
Returns {:ok, verification} when the payment passes every check the
stated :level includes, {:error, {:invalid, reason}} when a check
fails, and a capability error (:missing_dependency,
:rpc_not_configured) when the level cannot run — never a silent
downgrade. RPC transport failures return {:error, {:rpc_error, reason}}
(fail closed: the payment is not proven valid).
Options
:level- Required. Verification depth.:structuralneeds nothing,:signatureneeds the optional crypto dependencies,:fulladditionally needs:rpc. A level never silently downgrades.:rpc- AnX402.RPCconfiguration. Required for level:full.:simulate- Whether level:fullsimulatestransferWithAuthorizationviaeth_call. Counterfactual ERC-6492 payments always require simulation and are rejected when it isfalse;:counterfactual_onlyskips the EOA/ERC-1271 transfer simulation but keeps the atomic counterfactual deploy-and-transfer simulation, which is the only possible proof of a counterfactual signature. The default value istrue.:verify_chain_id(boolean/0) - Whether level:fullcross-checkseth_chainIdagainst the CAIP-2 network in the requirements, guarding against a misconfigured RPC endpoint. Adds no extra round-trip (batched with the other reads). The default value istrue.:eip6492_allowed_factories(list ofString.t/0) - Factory contract addresses trusted to deploy counterfactual ERC-6492 smart wallets (case-insensitive). The default empty list rejects every counterfactual payment. The default value is[].:multicall_address(String.t/0) - The Multicall3 contract used for atomic ERC-6492 deploy-and-transfer simulation. The default value is"0xcA11bde05977b3631167028862bE2a173976CA11".
Examples
iex> X402.Verify.EVM.verify(%{"x402Version" => 2}, %{}, level: :structural)
{:error, {:invalid, :invalid_payload}}
Types
@type error() :: {:invalid, invalid_reason()} | :missing_dependency | :rpc_not_configured | {:rpc_error, X402.RPC.error()} | {:chain_id_mismatch, non_neg_integer(), non_neg_integer()}
Verification errors.
@type invalid_reason() ::
:invalid_payload
| :invalid_authorization
| :invalid_requirements
| :scheme_mismatch
| :unsupported_transfer_method
| :unsupported_network
| :network_mismatch
| :missing_eip712_domain
| :recipient_mismatch
| :value_mismatch
| :valid_before_expired
| :valid_after_in_future
| :invalid_signature
| :smart_wallet_requires_rpc
| :undeployed_smart_wallet
| :eip6492_factory_not_allowed
| :asset_not_deployed_contract
| :balance_check_failed
| :insufficient_balance
| :eip3009_not_supported
| :nonce_already_used
| :token_name_mismatch
| :token_version_mismatch
| :simulation_failed
Why a payment was rejected.
Reasons map onto the canonical cross-SDK invalidReason strings via
reason_string/1 where an equivalent exists.
@type level() :: :structural | :signature | :full
Requested verification depth.
@type signature_type() :: :eoa | :erc1271 | :erc6492_counterfactual
How the payment signature was (or would be) verified.
@type simulate() :: boolean() | :counterfactual_only
Simulation mode for level :full.
:counterfactual_only skips the EOA/ERC-1271 transfer simulation like
false, but keeps the atomic ERC-6492 deploy-and-transfer simulation —
the only possible signature proof for an undeployed wallet.
@type verification() :: %{ payer: String.t(), level: level(), signature_type: signature_type() | nil }
A successful verification.
payer is the lowercase authorization from address. level echoes the
level that was run. signature_type is nil at :structural (no
signature classification happens without cryptography).