X402.Facilitator.Engine (X402 v0.9.0)

Copy Markdown View Source

Facilitator-role engine: verify and settle x402 payments yourself.

While X402.Facilitator is the client of a remote facilitator, this module is the facilitator itself. It assembles the SDK's local verification core (X402.Verify.EVM), JSON-RPC client (X402.RPC), transaction encoder (X402.Transaction), and signer behaviour (X402.Signer) into the engine behind the facilitator API's three operations, speaking the same wire shapes as the reference facilitators:

  • verify/3 — the full exact-EVM / upto-EVM verify checklist at :full level, returning the POST /verify response (%{"isValid" => true, "payer" => ...} / %{"isValid" => false, "invalidReason" => ..., "payer" => ...}).
  • settle/3 — re-verifies independently (the schemes' normative requirement), then broadcasts the settlement transaction and awaits its receipt, returning the POST /settle response (%{"success" => true, "transaction" => ..., ...} / %{"success" => false, "errorReason" => ..., ...}).
  • supported/1 — the GET /supported response derived from the configured networks.

Expose the engine over HTTP with X402.Plug.Facilitator, or call it directly from your own transport.

Payment kinds

The engine settles every EVM flow X402.Verify.EVM verifies:

Schemeextra.assetTransferMethodSettlement transaction
exactabsent / "eip3009"asset.transferWithAuthorization(...)
exact"permit2"x402ExactPermit2Proxy.settle(...)
upto—x402UptoPermit2Proxy.settle(...) for amount

The requirements select the authorization used throughout verification, settlement, payer reporting, pending lookup, and receipt checking. Extra unsigned authorization objects do not change that selection; a missing selected authorization never falls back to another transfer method.

For upto the requirements' extra.facilitatorAddress must be this engine's signer address (the proxy only accepts the witness facilitator as sender); other facilitators' payments are rejected with upto_facilitator_mismatch. The settled amount is the requirements' amount, which re-verification proves to be at most the signed permitted.amount.

Fee-payer safety

The facilitator's signing key pays gas, so what it signs is structurally constrained: settlement transactions are always built by this module with to set to the verified requirements' asset (EIP-3009) or the fixed x402 Permit2 proxy address (Permit2), value 0, and calldata produced exclusively by X402.EIP3009.transfer_calldata/3 / X402.Permit2.exact_settle_calldata/2 / X402.Permit2.upto_settle_calldata/3 from the authorization fields the signature verification just proved. The single exception is ERC-6492 counterfactual settlement: the engine signs caller-supplied calldata ONLY toward explicitly allowlisted factory addresses (:eip6492_allowed_factories), capped by :max_deploy_gas_limit — with the default empty allowlist it never does, and counterfactual payments are rejected fail-closed at verify and at settle's re-verify. Deployed ERC-1271 smart wallets are fully supported either way.

Settlement pipeline

  1. Reconcile: with a :pending_settlement_store configured, a payload whose earlier attempt already broadcast a transaction is re-awaited instead of re-verified and re-broadcast.
  2. Re-verify via X402.Verify.EVM at :full level (transfer simulation off by default — verify already simulated; see :simulate_in_settle — while the atomic ERC-6492 counterfactual simulation always runs, being the only possible proof of a counterfactual signature).
  3. For a verified counterfactual payment whose wallet is still undeployed, broadcast the wrapper's factory calldata (allowlist-gated, :max_deploy_gas_limit-capped) and require a successful deploy receipt first.
  4. Build the settlement calldata — transferWithAuthorization or the proxy's settle (shared with verification's simulation encoding).
  5. One batched RPC round-trip: eth_estimateGas (with a safety margin), eth_maxPriorityFeePerGas + eth_feeHistory (falling back to eth_gasPrice on nodes without EIP-1559 fee APIs), and eth_getTransactionCount (pending).
  6. Encode the EIP-1559 transaction (X402.Transaction), sign its keccak digest through the configured X402.Signer (the signer must support raw digest signing — X402.Signer.LocalKey does; the 27/28 recovery id it returns is normalized to the EIP-1559 yParity), and broadcast via eth_sendRawTransaction.
  7. Poll eth_getTransactionReceipt until confirmation or timeout. A confirmed receipt must also carry the matching ERC-20 Transfer event before success is reported. A broadcast whose confirmation cannot be established returns the spec's non-terminal "settlement_pending" with the transaction hash so callers can reconcile on chain, recording the attempt in the :pending_settlement_store when one is configured.

Hooks

X402.Hooks callbacks wrap both operations, mirroring the reference facilitator's lifecycle hooks: before_* returning {:halt, reason} turns into a rejected wire response (not an exception), after_* runs on successful results and may replace them, and on_*_failure runs for both rejected wire responses and infrastructure errors and may {:recover, result} with a replacement response. The internal re-verify inside settle/3 runs without hooks — the settle hooks already wrap it.

Example

{:ok, rpc} = X402.RPC.new(rpc_url: "https://sepolia.base.org", finch: MyApp.Finch)
{:ok, signer} = X402.Signer.LocalKey.new(System.fetch_env!("PRIVATE_KEY"))

{:ok, engine} =
  X402.Facilitator.Engine.new(
    rpc: rpc,
    signer: signer,
    networks: ["eip155:84532"]
  )

{:ok, %{"isValid" => true, "payer" => payer}} =
  X402.Facilitator.Engine.verify(engine, payment_payload, requirements)

{:ok, %{"success" => true, "transaction" => tx_hash}} =
  X402.Facilitator.Engine.settle(engine, payment_payload, requirements)

Telemetry

Emits [:x402, :facilitator_engine, :verify] and [:x402, :facilitator_engine, :settle] with :status metadata.

Summary

Payment Verification

Verifies a payment payload against requirements, returning the POST /verify wire response.

Payment Settlement

Settles a payment, returning the POST /settle wire response.

Facilitator Discovery

Returns the GET /supported wire response for this engine.

Types

t()

A validated engine configuration built by new/1.

A facilitator wire response (/verify or /settle shape).

Functions

Builds a validated engine configuration.

Payment Verification

verify(engine, payment_payload, requirements)

(since 0.6.0)
@spec verify(t(), map(), map()) :: {:ok, wire_response()} | {:error, term()}

Verifies a payment payload against requirements, returning the POST /verify wire response.

Returns {:ok, response} for every protocol-level outcome — including rejected payments, which come back as %{"isValid" => false, "invalidReason" => reason, "payer" => payer} with the canonical cross-SDK reason string (X402.Verify.EVM.reason_string/1). {:error, reason} is reserved for infrastructure failures (RPC transport errors, missing crypto dependencies, hook crashes) where no verdict about the payment exists; transports should map it to an opaque 500.

Payment Settlement

settle(engine, payment_payload, requirements)

(since 0.6.0)
@spec settle(t(), map(), map()) :: {:ok, wire_response()} | {:error, term()}

Settles a payment, returning the POST /settle wire response.

Independently re-verifies the payment first (normative for the exact-EVM scheme), then broadcasts transferWithAuthorization and awaits the receipt — see the module documentation for the full pipeline. Rejected or failed settlements come back as {:ok, %{"success" => false, "errorReason" => reason, ...}}; a broadcast whose confirmation could not be established returns the non-terminal "settlement_pending" reason with the transaction hash. {:error, reason} is reserved for infrastructure failures where nothing was broadcast.

Facilitator Discovery

supported(engine)

(since 0.6.0)
@spec supported(t()) :: wire_response()

Returns the GET /supported wire response for this engine.

One exact and one upto kind per configured network, no extensions, and the signer's address under the eip155:* family. Resource servers advertising upto requirements forward that signer address as extra.facilitatorAddress — the engine only settles upto payments whose witness binds it.

Examples

{:ok, engine} = X402.Facilitator.Engine.new(rpc: rpc, signer: signer, networks: ["eip155:84532"])
X402.Facilitator.Engine.supported(engine)
#=> %{
#     "kinds" => [
#       %{"x402Version" => 2, "scheme" => "exact", "network" => "eip155:84532"},
#       %{"x402Version" => 2, "scheme" => "upto", "network" => "eip155:84532"}
#     ],
#     "extensions" => [],
#     "signers" => %{"eip155:*" => ["0x..."]}
#   }

Types

t()

@type t() :: %X402.Facilitator.Engine{
  eip6492_allowed_factories: [String.t()],
  gas_limit_margin_percent: non_neg_integer(),
  hooks: module(),
  max_deploy_gas_limit: pos_integer(),
  max_gas_limit: pos_integer(),
  networks: [String.t()],
  nonce_manager: GenServer.server() | nil,
  pending_settlement_store:
    X402.Facilitator.PendingSettlementStore.adapter() | nil,
  receipt_interval_ms: pos_integer(),
  receipt_timeout_ms: pos_integer(),
  rpc: X402.RPC.t(),
  signer: X402.Signer.t(),
  simulate: boolean(),
  simulate_in_settle: boolean(),
  verify_chain_id: boolean()
}

A validated engine configuration built by new/1.

wire_response()

@type wire_response() :: %{optional(String.t()) => term()}

A facilitator wire response (/verify or /settle shape).

Functions

new(opts)

(since 0.6.0)
@spec new(keyword()) :: {:ok, t()} | {:error, NimbleOptions.ValidationError.t()}

Builds a validated engine configuration.

Options

  • :rpc - Required. An X402.RPC configuration for the served network.

  • :signer - Required. The fee-payer signer (a struct implementing X402.Signer). Its key pays settlement gas and must support signing raw 32-byte digests.

  • :networks - Required. Non-empty list of CAIP-2 networks this engine serves (currently eip155:<chainId> only). Verify and settle requests for other networks are rejected with invalid_network.

  • :hooks - Lifecycle hook module implementing X402.Hooks. The default value is X402.Hooks.Default.

  • :simulate (boolean/0) - Whether verify/3 simulates transferWithAuthorization via eth_call. Even when false, verify keeps the atomic ERC-6492 counterfactual simulation — the only possible proof of a counterfactual signature — so verify predicts settle, whose re-verify always keeps that proof too. The default value is true.

  • :simulate_in_settle (boolean/0) - Whether the independent re-verify inside settle/3 also simulates. Off by default, matching the reference facilitators — verify already simulated, and eth_estimateGas re-simulates right before broadcast. Even when off, the re-verify keeps the atomic ERC-6492 counterfactual simulation, the only possible proof of a counterfactual signature. The default value is false.

  • :verify_chain_id (boolean/0) - Whether verification cross-checks eth_chainId against the CAIP-2 network. The default value is true.

  • :gas_limit_margin_percent (non_neg_integer/0) - Safety margin added to eth_estimateGas (percent). The default value is 20.

  • :receipt_timeout_ms (pos_integer/0) - How long settle/3 waits for the transaction receipt before returning the non-terminal "settlement_pending" response. The default value is 60000.

  • :receipt_interval_ms (pos_integer/0) - Interval between eth_getTransactionReceipt polls. The default value is 1000.

  • :nonce_manager - Optional X402.Facilitator.NonceManager (pid/name) serializing fee-payer nonces. Without it, each settlement reads the pending nonce from the node, which races under concurrent settles — configure the manager for any deployment that settles concurrently. The default value is nil.

  • :max_gas_limit (pos_integer/0) - Absolute gas ceiling per settlement transaction (margin included). A legitimate transferWithAuthorization costs well under 100k gas; an estimate above this ceiling means the asset contract is burning the fee payer's gas and the settlement is refused — the fee payer never broadcasts unbounded-gas transactions against unvetted bytecode. The default value is 200000.

  • :eip6492_allowed_factories (list of String.t/0) - Factory contract addresses (case-insensitive) trusted to deploy counterfactual ERC-6492 smart wallets during settlement. Threaded into verification so verify predicts settle. The default empty list keeps the fail-closed behavior: every counterfactual payment is rejected and the engine never signs caller-supplied factory calldata. The default value is [].

  • :max_deploy_gas_limit (pos_integer/0) - Absolute gas ceiling for the ERC-6492 factory deployment transaction (margin included). Smart-account deployments legitimately cost far more than a transfer (~300k gas), so the deployment carries its own ceiling; the transfer keeps :max_gas_limit. The default value is 600000.

  • :pending_settlement_store - Optional {module, store} adapter implementing X402.Facilitator.PendingSettlementStore. When configured, settle/3 records broadcasts whose confirmation could not be established and reconciles a retried payload against the already-broadcast transaction instead of broadcasting a second one. The default value is nil.