X402.Facilitator.Engine (X402 v0.6.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 verify checklist at :full level, returning the POST /verify response (%{"isValid" => true, "payer" => ...} / %{"isValid" => false, "invalidReason" => ..., "payer" => ...}).
  • settle/3re-verifies independently (the exact-EVM scheme's normative requirement), then broadcasts the transferWithAuthorization 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.

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, value 0, and calldata produced exclusively by X402.EIP3009.transfer_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 transferWithAuthorization calldata (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 kind per configured network, no extensions, and the signer's address under the eip155:* family.

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"}],
#     "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.