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:fulllevel, returning thePOST /verifyresponse (%{"isValid" => true, "payer" => ...}/%{"isValid" => false, "invalidReason" => ..., "payer" => ...}).settle/3— re-verifies independently (the exact-EVM scheme's normative requirement), then broadcasts thetransferWithAuthorizationtransaction and awaits its receipt, returning thePOST /settleresponse (%{"success" => true, "transaction" => ..., ...}/%{"success" => false, "errorReason" => ..., ...}).supported/1— theGET /supportedresponse 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
- Reconcile: with a
:pending_settlement_storeconfigured, a payload whose earlier attempt already broadcast a transaction is re-awaited instead of re-verified and re-broadcast. - Re-verify via
X402.Verify.EVMat:fulllevel (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). - 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. - Build
transferWithAuthorizationcalldata (shared with verification's simulation encoding). - One batched RPC round-trip:
eth_estimateGas(with a safety margin),eth_maxPriorityFeePerGas+eth_feeHistory(falling back toeth_gasPriceon nodes without EIP-1559 fee APIs), andeth_getTransactionCount(pending). - Encode the EIP-1559 transaction (
X402.Transaction), sign its keccak digest through the configuredX402.Signer(the signer must support raw digest signing —X402.Signer.LocalKeydoes; the 27/28 recovery id it returns is normalized to the EIP-1559yParity), and broadcast viaeth_sendRawTransaction. - Poll
eth_getTransactionReceiptuntil confirmation or timeout. A confirmed receipt must also carry the matching ERC-20Transferevent 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_storewhen 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
A facilitator wire response (/verify or /settle shape).
Payment Verification
@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
@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
@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
@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.
A facilitator wire response (/verify or /settle shape).
Functions
@spec new(keyword()) :: {:ok, t()} | {:error, NimbleOptions.ValidationError.t()}
Builds a validated engine configuration.
Options
:rpc- Required. AnX402.RPCconfiguration for the served network.:signer- Required. The fee-payer signer (a struct implementingX402.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 (currentlyeip155:<chainId>only). Verify and settle requests for other networks are rejected withinvalid_network.:hooks- Lifecycle hook module implementingX402.Hooks. The default value isX402.Hooks.Default.:simulate(boolean/0) - Whetherverify/3simulatestransferWithAuthorizationviaeth_call. Even whenfalse, 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 istrue.:simulate_in_settle(boolean/0) - Whether the independent re-verify insidesettle/3also simulates. Off by default, matching the reference facilitators — verify already simulated, andeth_estimateGasre-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 isfalse.:verify_chain_id(boolean/0) - Whether verification cross-checkseth_chainIdagainst the CAIP-2 network. The default value istrue.:gas_limit_margin_percent(non_neg_integer/0) - Safety margin added toeth_estimateGas(percent). The default value is20.:receipt_timeout_ms(pos_integer/0) - How longsettle/3waits for the transaction receipt before returning the non-terminal"settlement_pending"response. The default value is60000.:receipt_interval_ms(pos_integer/0) - Interval betweeneth_getTransactionReceiptpolls. The default value is1000.:nonce_manager- OptionalX402.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 isnil.:max_gas_limit(pos_integer/0) - Absolute gas ceiling per settlement transaction (margin included). A legitimatetransferWithAuthorizationcosts 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 is200000.:eip6492_allowed_factories(list ofString.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 is600000.:pending_settlement_store- Optional{module, store}adapter implementingX402.Facilitator.PendingSettlementStore. When configured,settle/3records 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 isnil.