X402.Facilitator.SVMEngine (X402 v0.6.0)

Copy Markdown View Source

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 the POST /verify response.
  • settle/3re-verifies independently, co-signs the fee-payer slot with the configured signer, broadcasts via sendTransaction, and polls getSignatureStatuses until the transaction is confirmed, returning the POST /settle response with the Base58 transaction signature.
  • supported/1 — the GET /supported response 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

  1. Compute the settlement key — SHA-256 of the transaction's message bytes — and atomically claim it in the :settlement_cache (duplicate_settlement when already claimed). The claim is released on verify failure, node-side broadcast rejection, and terminal on-chain failure, and kept on success. On settlement_pending the claim is kept only when the :pending_settlement_store recorded 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 on duplicate_settlement.
  2. 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).
  3. Re-verify via X402.Verify.SVM at :full level, re-simulating by default exactly as the reference facilitators do (see :simulate_in_settle).
  4. 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 with skipPreflight: true.
  5. Poll getSignatureStatuses until confirmed/finalized or :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_store for 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

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, ...} 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

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, 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

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

t()

@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.

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 pointed at a Solana JSON-RPC node.

  • :signer - Required. The fee-payer signer (a struct implementing X402.Signer with the sign_ed25519/2 callback — X402.Signer.SolanaKey does). 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 with invalid_network.

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

  • :simulate (boolean/0) - Whether verify/3 runs simulateTransaction. The default value is true.

  • :simulate_in_settle (boolean/0) - Whether the independent re-verify inside settle/3 also 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 is true.

  • :settlement_cache - Optional {module, cache} adapter implementing X402.Extensions.PaymentIdentifier.Cache, used as the atomic duplicate-settlement claim (duplicate_settlement). nil disables duplicate protection entirely — see the module documentation for the recommended ETSCache configuration with ttl_ms: 120_000, paired with a :pending_settlement_store. The default value is nil.

  • :pending_settlement_store - Optional {module, store} adapter implementing X402.Facilitator.PendingSettlementStore. Lets a retried settle for the same transaction reconcile against the already-broadcast signature instead of re-verifying and re-broadcasting. nil disables reconciliation: a settlement_pending verdict is returned but not recorded, and the :settlement_cache claim is released so the retry can re-broadcast the identical wire bytes rather than dead-end on duplicate_settlement. The default value is nil.

  • :confirm_timeout_ms (pos_integer/0) - How long settle/3 waits for confirmation before returning the non-terminal "settlement_pending" response. The default value is 30000.

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

  • :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). nil disables the cap. The default value is nil.