Generic EVM payment method — verifies on-chain ERC-20 or native ETH transfers.
Works on any EVM chain (Ethereum, Base, Polygon, Arbitrum, etc.) by configuring the appropriate RPC endpoint and chain ID. The client broadcasts a transaction, then sends the transaction hash as a credential. The server fetches the receipt via RPC and verifies the transfer matches the charge intent.
Configuration
Pass EVM-specific config via :method_config in MPP.Plug opts:
plug MPP.Plug,
secret_key: "hmac-secret",
realm: "api.example.com",
method: MPP.Methods.EVM,
amount: "1000000",
currency: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
method_config: %{
"rpc_url" => "https://mainnet.infura.io/v3/YOUR_KEY",
"chain_id" => 1
}Config Keys
"rpc_url"— (required) JSON-RPC endpoint URL for the target EVM chain"chain_id"— (required) EIP-155 chain ID of the target network, advertised aschainIdin challengemethodDetails(e.g.1for Ethereum mainnet,8453for Base,11155111for Sepolia). Clients reject challenges whosechainIdthey do not support."permit2_address"— (optional) Permit2 contract advertised aspermit2Addressin challengemethodDetails. Defaults to the canonical deployment0x000000000022D473030F116dDEE9F6B43aC78BA3"store"— (optional) replay-dedup store, on by default (see "Replay protection"): when absent, the app-startedMPP.Tempo.ConCacheStoreenforces single-use of each on-chain transaction hash out of the box. Pass a module implementingMPP.Tempo.Store(Redis/Postgres for multi-node) or{MPP.Tempo.ConCacheStore, opts}; a configured store MUST implement the atomiccheck_and_mark/2. Passstore: falseto opt out (not recommended)"private_key"— (required fortype="authorization") server-only secp256k1 key used to submittransferWithAuthorization(pays gas)"authorization"— (optional) EIP-712 domain%{"name" => ..., "version" => ...}for a custom EIP-3009 token. Known Circle USDC/EURC contracts are resolved automatically"max_fee_per_gas"/"max_priority_fee_per_gas"— (optional) EIP-1559 fees for authorization settlement, as wei integers or{n, :gwei}tuples"req_options"— (optional) merged into theOnchain.RPCcall as:req_options(e.g.[plug: {Req.Test, MyMod}]) for testing stubs
Replay protection
This method verifies an already-broadcast transaction by hash and matches it
against the charge's token/to/amount. That match alone does not bind the
proof to a single use, so without a dedup store one settled transfer can satisfy
unlimited future charges on a static-price route (replay).
Configure "store" to make each transaction hash single-use: the hash is checked
before verification and atomically committed after, reusing the same
MPP.Tempo.Store behaviour (and MPP.Tempo.ConCacheStore) as the Tempo method.
The store's TTL must be ≥ your challenge expires_in (a good default is 2×) so a
hash cannot be evicted and replayed while its challenge is still valid.
Residual limitation of type="hash": a store makes each transaction
single-use, but the hash-pointer credential carries no on-chain binding to a
specific challenge — so on its first presentation, any unrelated transfer
that happens to match token/to/amount is accepted. Prefer
type="authorization" when the token implements EIP-3009: the nonce is the
challengeHash, so the authorization can settle only that challenge.
Credential Payload
Two charge payload types are accepted:
type="hash"(or an untyped"hash"field) — client-broadcast transaction hash. The server fetches the receipt and matchestoken/to/amount.type="authorization"— EIP-3009transferWithAuthorizationfor tokens that implement it (Circle USDC/EURC). The client signs off-chain; the server submits the authorization and pays gas. The EIP-3009 nonce MUST bekeccak256(challenge.id <> challenge.realm)(challengeHash). Advertised only when the currency is a known EIP-3009 token (or"authorization" => %{"name" => ..., "version" => ...}is configured) and"private_key"is set for settlement.
Currency Conventions
- ERC-20 tokens: use the token contract address as currency
(e.g.,
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"for USDC on Ethereum) - Native ETH: use
"ETH"or the zero address ("0x0000000000000000000000000000000000000000")
Dependencies
Requires the onchain package for address validation and Transfer event parsing.
API Functions
| Function | Arity | Description | Param Kinds |
|---|---|---|---|
challenge_method_details | 1 | Return EVM-specific fields (chainId, credentialTypes, permit2Address) for the 402 challenge. | charge: value |
verify | 2 | Verify an EVM credential by checking on-chain settlement via transaction receipt. | payload: value, charge: value |
validate_config! | 1 | Validate EVM method_config at init time. Raises on missing rpc_url or chain_id. | config: value |
credential_types | 0 | Return the EVM charge payload types currently implemented: authorization and hash. | - |
method_name | 0 | Return the payment method identifier for EVM. | - |
Summary
Functions
Return EVM-specific fields (chainId, credentialTypes, permit2Address) for the 402 challenge.
Return the EVM charge payload types currently implemented: authorization and hash.
Return the payment method identifier for EVM.
Validate EVM method_config at init time. Raises on missing rpc_url or chain_id.
Verify an EVM credential by checking on-chain settlement via transaction receipt.
Functions
@spec challenge_method_details(MPP.Method.intent()) :: map() | nil
@spec challenge_method_details(MPP.Intents.Charge.t()) :: map()
Return EVM-specific fields (chainId, credentialTypes, permit2Address) for the 402 challenge.
Parameters
charge- Charge struct with method_details containing requiredchain_idand optionalpermit2_address(value)
Returns
Map with required chainId (EIP-155), credentialTypes, and permit2Address (map)
# descripex:contract
%{
params: %{
charge: %{
description: "Charge struct with method_details containing required `chain_id` and optional `permit2_address`",
kind: :value
}
},
returns: %{
type: :map,
description: "Map with required `chainId` (EIP-155), `credentialTypes`, and `permit2Address`"
}
}
Return the EVM charge payload types currently implemented: authorization and hash.
# descripex:contract
%{}
@spec method_name() :: String.t()
Return the payment method identifier for EVM.
# descripex:contract
%{}
Validate EVM method_config at init time. Raises on missing rpc_url or chain_id.
Parameters
config- method_config map to validate (value)
Returns
:ok on success, raises ArgumentError on missing keys (atom)
# descripex:contract
%{
params: %{
config: %{description: "method_config map to validate", kind: :value}
},
returns: %{
type: :atom,
description: "`:ok` on success, raises `ArgumentError` on missing keys"
}
}
@spec verify(map(), MPP.Intents.Charge.t()) :: {:ok, MPP.Receipt.t()} | {:error, MPP.Errors.t()}
Verify an EVM credential by checking on-chain settlement via transaction receipt.
Parameters
payload- Credential payload map:"hash"(0x-prefixed transaction hash) ortype="authorization"EIP-3009 fields (value)charge- Charge intent struct with amount, currency, recipient, and method_details (includingrpc_url) (value)
Returns
{:ok, receipt} on success, {:error, error} on failure (tagged_tuple)
Errors
:invalid_payload:verification_failed
# descripex:contract
%{
params: %{
payload: %{
description: "Credential payload map: `\"hash\"` (0x-prefixed transaction hash) or `type=\"authorization\"` EIP-3009 fields",
kind: :value
},
charge: %{
description: "Charge intent struct with amount, currency, recipient, and method_details (including `rpc_url`)",
kind: :value
}
},
errors: [:invalid_payload, :verification_failed],
returns: %{
type: :tagged_tuple,
description: "`{:ok, receipt}` on success, `{:error, error}` on failure"
}
}