Facilitator-role engine for exact payments on Solana (solana:*).
The SVM counterpart of X402.Facilitator.Engine: it assembles the SDK's
local SVM verification (X402.Verify.SVM), Solana JSON-RPC calls
(X402.Solana.RPC), transaction primitives (X402.Solana.Transaction),
and the 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-SVM static-path checklist, returning thePOST /verifyresponse.settle/3— re-verifies independently, co-signs the fee-payer slot with the configured signer, broadcasts viasendTransaction, and pollsgetSignatureStatusesuntil the transaction is confirmed, returning thePOST /settleresponse with the Base58 transaction signature.supported/1— theGET /supportedresponse derived from the configured networks.
Expose the engine over HTTP with X402.Plug.Facilitator (alone, or next
to an EVM X402.Facilitator.Engine via the :engines option), or call it
directly from your own transport.
Fee-payer safety
The facilitator's Ed25519 key co-signs client-built transactions, so what
it signs is strictly constrained by verification: the requirements'
extra.feePayer must be this engine's signer, account 0 must be that fee
payer, the fee payer must not be referenced by any instruction (isolation
— the sponsor's signature can never move the sponsor's funds), the
instruction layout must match the static whitelist, and every other
required signer's Ed25519 signature is verified locally over the message
bytes. Smart-wallet (CPI-wrapped) payments and address-lookup-table
transactions are rejected fail-closed.
Settlement pipeline
- Compute the settlement key — SHA-256 of the transaction's message
bytes — and atomically claim it in the
:settlement_cache(duplicate_settlementwhen already claimed). The claim is released on verify failure, node-side broadcast rejection, and terminal on-chain failure, and kept on success. Onsettlement_pendingthe claim is kept only when the:pending_settlement_storerecorded the broadcast (a retry then reconciles against the recorded signature); without a store it is released, so a retry re-verifies and re-broadcasts the identical wire bytes instead of dead-ending onduplicate_settlement. - Pending-settlement fast path: when a prior settle for this exact transaction broadcast but could not confirm, re-await the recorded signature instead of re-verifying and re-broadcasting (Solana transactions embed an expiring blockhash, so a resend can fail while the original is still perfectly valid).
- Re-verify via
X402.Verify.SVMat:fulllevel, re-simulating by default exactly as the reference facilitators do (see:simulate_in_settle). - Sign the message bytes with the configured signer, splice the
signature into the fee payer's slot 0
(
X402.Solana.Transaction.attach_signature/3), and broadcast withskipPreflight: true. - Poll
getSignatureStatusesuntilconfirmed/finalizedor:confirm_timeout_ms. A broadcast whose confirmation cannot be established returns the spec's non-terminal"settlement_pending"with the transaction signature and records it in the:pending_settlement_storefor the fast path above.
Duplicate-settlement protection
Configure a settlement cache and a pending store
With the default settlement_cache: nil, duplicate-settlement
protection is disabled: concurrent settles of the same payment all
broadcast (the network still collapses them to one transaction id, but
every call burns RPC round-trips and races the confirmation poll).
Production engines should configure both the cache the reference
facilitators use — 120 seconds, roughly twice the blockhash lifetime —
and a :pending_settlement_store. The two interact: on a
settlement_pending verdict the cache claim is kept only when the
store recorded the broadcast, letting the retry reconcile against the
recorded signature; with a cache but no store the claim is released so
the retry can re-broadcast the identical wire bytes (collapsed by the
network to one transaction id) instead of being rejected as
duplicate_settlement.
children = [
{X402.Extensions.PaymentIdentifier.ETSCache,
name: MyApp.SettlementCache, ttl_ms: 120_000},
{X402.Facilitator.PendingSettlementStore.ETS,
name: MyApp.PendingSettlements}
]
settlement_cache:
{X402.Extensions.PaymentIdentifier.ETSCache, MyApp.SettlementCache},
pending_settlement_store:
{X402.Facilitator.PendingSettlementStore.ETS, MyApp.PendingSettlements}Example
{:ok, rpc} = X402.RPC.new(rpc_url: "https://api.devnet.solana.com", finch: MyApp.Finch)
{:ok, signer} = X402.Signer.SolanaKey.new(System.fetch_env!("SOLANA_FEE_PAYER_KEY"))
{:ok, engine} =
X402.Facilitator.SVMEngine.new(
rpc: rpc,
signer: signer,
networks: ["solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"]
)
{:ok, %{"isValid" => true, "payer" => payer}} =
X402.Facilitator.SVMEngine.verify(engine, payment_payload, requirements)
{:ok, %{"success" => true, "transaction" => signature}} =
X402.Facilitator.SVMEngine.settle(engine, payment_payload, requirements)Hooks and telemetry
X402.Hooks callbacks wrap both operations exactly as on
X402.Facilitator.Engine, and the same
[:x402, :facilitator_engine, :verify] / [:x402, :facilitator_engine, :settle] telemetry events are emitted 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, ...} with the
canonical cross-SDK reason string (X402.Verify.SVM.reason_string/1).
{:error, reason} is reserved for infrastructure failures (RPC transport
errors, 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, then co-signs the fee-payer
slot and broadcasts — see the module documentation for the full pipeline,
including duplicate-settlement claims and pending-settlement
reconciliation. 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 signature.
{: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 solana:* family. Each kind advertises the fee payer
under "extra" — the channel through which reference resource servers
discover which extra.feePayer to inject into their 402 challenges
(omitted only when the signer's address is unavailable).
Examples
{:ok, engine} = X402.Facilitator.SVMEngine.new(rpc: rpc, signer: signer, networks: [network])
X402.Facilitator.SVMEngine.supported(engine)
#=> %{
# "kinds" => [
# %{
# "x402Version" => 2,
# "scheme" => "exact",
# "network" => network,
# "extra" => %{"feePayer" => "9hSR..."}
# }
# ],
# "extensions" => [],
# "signers" => %{"solana:*" => ["9hSR..."]}
# }
Types
@type t() :: %X402.Facilitator.SVMEngine{ confirm_interval_ms: pos_integer(), confirm_timeout_ms: pos_integer(), hooks: module(), max_required_signatures: pos_integer() | nil, networks: [String.t()], pending_settlement_store: X402.Facilitator.PendingSettlementStore.adapter() | nil, rpc: X402.RPC.t(), settlement_cache: X402.Extensions.PaymentIdentifier.Cache.adapter() | nil, signer: X402.Signer.t(), simulate: boolean(), simulate_in_settle: 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 pointed at a Solana JSON-RPC node.:signer- Required. The fee-payer signer (a struct implementingX402.Signerwith thesign_ed25519/2callback —X402.Signer.SolanaKeydoes). Its key pays transaction fees and co-signs every settlement.:networks- Required. Non-empty list of CAIP-2 networks this engine serves (solana:<reference>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/3runssimulateTransaction. The default value istrue.:simulate_in_settle(boolean/0) - Whether the independent re-verify insidesettle/3also simulates. On by default, matching the reference facilitators, whose settle always re-simulates: it is the blockhash-freshness and balance guard right before the preflight-skipping broadcast, and the fee payer is charged for a transaction that fails on-chain. Disabling it is an explicit operator optimization that trades that guard for one fewer RPC round-trip per settle. The default value istrue.:settlement_cache- Optional{module, cache}adapter implementingX402.Extensions.PaymentIdentifier.Cache, used as the atomic duplicate-settlement claim (duplicate_settlement).nildisables duplicate protection entirely — see the module documentation for the recommendedETSCacheconfiguration withttl_ms: 120_000, paired with a:pending_settlement_store. The default value isnil.:pending_settlement_store- Optional{module, store}adapter implementingX402.Facilitator.PendingSettlementStore. Lets a retried settle for the same transaction reconcile against the already-broadcast signature instead of re-verifying and re-broadcasting.nildisables reconciliation: asettlement_pendingverdict is returned but not recorded, and the:settlement_cacheclaim is released so the retry can re-broadcast the identical wire bytes rather than dead-end onduplicate_settlement. The default value isnil.:confirm_timeout_ms(pos_integer/0) - How longsettle/3waits for confirmation before returning the non-terminal"settlement_pending"response. The default value is30000.:confirm_interval_ms(pos_integer/0) - Interval betweengetSignatureStatusespolls. The default value is1000.:max_required_signatures- Cap on a transaction's required signature count (every signature adds 5000 lamports of base fee, paid by this engine's key).nildisables the cap. The default value isnil.