MPP.Methods.EVM (mpp v0.14.0)

Copy Markdown View Source

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 as chainId in challenge methodDetails (e.g. 1 for Ethereum mainnet, 8453 for Base, 11155111 for Sepolia). Clients reject challenges whose chainId they do not support.
  • "permit2_address" — (optional) Permit2 contract advertised as permit2Address in challenge methodDetails. Defaults to the canonical deployment 0x000000000022D473030F116dDEE9F6B43aC78BA3
  • "store" — (optional) replay-dedup store, on by default (see "Replay protection"): when absent, the app-started MPP.Tempo.ConCacheStore enforces single-use of each on-chain transaction hash out of the box. Pass a module implementing MPP.Tempo.Store (Redis/Postgres for multi-node) or {MPP.Tempo.ConCacheStore, opts}; a configured store MUST implement the atomic check_and_mark/2. Pass store: false to opt out (not recommended)
  • "private_key" — (required for type="authorization") server-only secp256k1 key used to submit transferWithAuthorization (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 the Onchain.RPC call 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 matches token/to/amount.
  • type="authorization" — EIP-3009 transferWithAuthorization for 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 be keccak256(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

FunctionArityDescriptionParam Kinds
challenge_method_details1Return EVM-specific fields (chainId, credentialTypes, permit2Address) for the 402 challenge.charge: value
verify2Verify an EVM credential by checking on-chain settlement via transaction receipt.payload: value, charge: value
validate_config!1Validate EVM method_config at init time. Raises on missing rpc_url or chain_id.config: value
credential_types0Return the EVM charge payload types currently implemented: authorization and hash.-
method_name0Return 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

challenge_method_details(charge)

@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 required chain_id and optional permit2_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`"
  }
}

credential_types()

@spec credential_types() :: [String.t()]
@spec credential_types() :: [String.t()]

Return the EVM charge payload types currently implemented: authorization and hash.

# descripex:contract
%{}

method_name()

@spec method_name() :: String.t()

Return the payment method identifier for EVM.

# descripex:contract
%{}

validate_config!(config)

@spec validate_config!(map()) :: :ok
@spec validate_config!(map()) :: :ok

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"
  }
}

verify(payload, charge)

@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) or type="authorization" EIP-3009 fields (value)
  • charge - Charge intent struct with amount, currency, recipient, and method_details (including rpc_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"
  }
}