X402.Verify.EVM (X402 v0.6.0)

Copy Markdown View Source

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; payTo recipient equality; exact amount equality; and the validAfter/validBefore window with the reference implementations' 6-second settlement buffer.

  • :signature — everything in :structural, plus EIP-712 digest recomputation and EOA signature recovery (requires the optional ex_keccak and ex_secp256k1 dependencies, 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 configured X402.RPC endpoint (otherwise {:error, :rpc_not_configured}): chain-id cross-check, signature routing by payer bytecode (EOA ecrecover when the payer has no code, strict ERC-1271 isValidSignature when it does — no ECDSA fallback, matching on-chain SignatureChecker semantics), ERC-6492 counterfactual handling, asset bytecode presence, balanceOf funding, and an eth_call simulation of transferWithAuthorization with 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. With simulate: false counterfactual 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()
end

The 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

reason_string(reason)

(since 0.6.0)
@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"

verify(payment_payload, requirements, opts)

(since 0.6.0)
@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. :structural needs nothing, :signature needs the optional crypto dependencies, :full additionally needs :rpc. A level never silently downgrades.

  • :rpc - An X402.RPC configuration. Required for level :full.

  • :simulate - Whether level :full simulates transferWithAuthorization via eth_call. Counterfactual ERC-6492 payments always require simulation and are rejected when it is false; :counterfactual_only skips 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 is true.

  • :verify_chain_id (boolean/0) - Whether level :full cross-checks eth_chainId against 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 is true.

  • :eip6492_allowed_factories (list of String.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

error()

@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.

invalid_reason()

@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.

level()

@type level() :: :structural | :signature | :full

Requested verification depth.

signature_type()

@type signature_type() :: :eoa | :erc1271 | :erc6492_counterfactual

How the payment signature was (or would be) verified.

simulate()

@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.

verification()

@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).