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"andtype="permit2") server-only secp256k1 key used to submit settlement (pays gas)"permit2"— (optional)trueto advertise and settletype="permit2""transaction"— (optional)trueto accept and advertisetype="transaction"for ERC-20 charges without splits. The client signs an EIP-1559 transfer and the server broadcasts it; the client pays gas. Without the flag, the type is rejected: EVM challenges always populatecredentialTypes, so the draft's omitted-list MUST-accept default does not apply"splits"— (optional) ordered extra%{"recipient" => ..., "amount" => ...}legs; advertised and settled only via Permit2. Sum must be strictly less than the charge amount (primary recipient gets the remainder)"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 and Permit2 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
Four 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="transaction"— client-signed EIP-1559 ERC-20transfer. The server validates chain, token, recipient, amount, and challenge expiry, then broadcasts. Accepted and advertised only when"transaction" => trueand the charge is an ERC-20 without splits. Splits are rejected.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.type="permit2"— off-chain Permit2 witness authorization. Enable with"permit2" => trueand"private_key". The server pays gas and settles primary and split transfers atomically. UseMPP.Methods.EVM.Permit2.sign/5with the server's spender address; the payer needs an ERC-20 approval."splits"contains ordered recipient/amount maps, subtracted from the total. Split charges accept only Permit2 credentials.
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 implemented EVM charge payload types: permit2, authorization, transaction, 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 implemented EVM charge payload types: permit2, authorization, transaction, 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 implemented EVM charge payload types: permit2, authorization, transaction, 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),type="transaction"signed EIP-1559 transfer,type="authorization"EIP-3009 fields, ortype="permit2"Permit2 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: %{
charge: %{
description: "Charge intent struct with amount, currency, recipient, and method_details (including `rpc_url`)",
kind: :value
},
payload: %{
description: "Credential payload map: `\"hash\"` (0x-prefixed transaction hash), `type=\"transaction\"` signed EIP-1559 transfer, `type=\"authorization\"` EIP-3009 fields, or `type=\"permit2\"` Permit2 fields",
kind: :value
}
},
errors: [:invalid_payload, :verification_failed],
returns: %{
type: :tagged_tuple,
description: "`{:ok, receipt}` on success, `{:error, error}` on failure"
}
}