X402.Verify.SVM (X402 v0.6.0)

Copy Markdown View Source

Local verification of SVM exact payment payloads.

Runs the facilitator verify checklist from the exact-SVM scheme specification's static verification path (Path 1), mirroring the reference TypeScript facilitator check for check: scheme/network match, fee-payer requirements, transaction decoding, local Ed25519 verification of every required signer except the fee payer, the static instruction whitelist (via X402.Scheme.ExactSVM's pre-checks), and — at :full — an RPC simulateTransaction round-trip.

Smart-wallet (CPI-wrapped) payments — the spec's opt-in Path 2 — are out of scope and fail the static checks, and transactions using address lookup tables are rejected fail-closed (their account set cannot be verified without table resolution).

Verification levels

The :level option is required and explicit — a level whose capabilities are unavailable returns an error instead of silently downgrading:

  • :structural — no RPC: scheme, network, and fee-payer requirements; transaction decoding; Ed25519 signature verification of the required signers (pure :crypto, no optional dependency); the address-lookup- table rejection; fail-closed validation of the requirements' amount, asset, and payTo (an uninterpretable field is rejected, never skipped); and the static instruction-layout checks against the requirements (amount, mint, destination ATA, memo, compute budget bounds, fee-payer isolation).

  • :full — everything in :structural, plus simulateTransaction over a configured X402.RPC endpoint (otherwise {:error, :rpc_not_configured}). The simulation runs with sigVerify: false — the fee-payer slot is unsigned until settlement — which is exactly why the local signature checks above are mandatory at every level.

Example

{:ok, payload} = X402.PaymentSignature.decode_and_validate(header, requirements)

{:ok, rpc} = X402.RPC.new(rpc_url: "https://api.devnet.solana.com", finch: MyApp.Finch)

case X402.Verify.SVM.verify(payload, requirements,
       level: :full,
       rpc: rpc,
       fee_payer: facilitator_address
     ) do
  {:ok, %{payer: payer}} -> grant_access(payer)
  {:error, _reason} -> deny_access()
end

payer is the Base58 authority of the TransferChecked instruction — the account whose tokens move. The result never silently downgrades: {:ok, result} means the payment passed every check the stated :level includes, and result.level echoes that level.

Summary

Payment Verification

Maps an invalid reason atom to the canonical cross-SDK invalidReason string used by the reference facilitators.

Verifies a decoded v2 PaymentPayload against payment requirements.

Types

Verification errors.

Why a payment was rejected.

Requested verification depth.

A successful verification.

Payment Verification

reason_string(reason)

(since 0.6.0)
@spec reason_string(invalid_reason()) :: String.t()

Maps an invalid reason atom to the canonical cross-SDK invalidReason string used by the reference facilitators.

The vocabulary is the TypeScript reference's invalid_exact_svm_* set — the strings the hosted facilitator emits. (The Go SDK currently diverges with an invalid_exact_solana_* prefix despite its cross-SDK parity comment; TypeScript is authoritative here.) Local-only reasons without a canonical wire equivalent fall back to their atom name.

Examples

iex> X402.Verify.SVM.reason_string(:amount_mismatch)
"invalid_exact_svm_payload_amount_mismatch"

iex> X402.Verify.SVM.reason_string(:fee_payer_mismatch)
"invalid_exact_svm_fee_payer_mismatch"

iex> X402.Verify.SVM.reason_string(:invalid_payload)
"invalid_payload"

verify(payment_payload, requirements, opts)

(since 0.6.0)
@spec verify(map(), map(), keyword()) :: {:ok, verification()} | {:error, error()}

Verifies a decoded v2 PaymentPayload against payment requirements.

payment_payload is the decoded v2 envelope (as returned by X402.PaymentSignature.decode_and_validate/2) and requirements the matched PaymentRequirements object. Both accept string or atom keys.

Returns {:ok, verification} when the payment passes every check the stated :level includes, {:error, {:invalid, reason}} when a check fails, and {:error, :rpc_not_configured} when the level cannot run — never a silent downgrade. RPC transport failures return {:error, {:rpc_error, reason}} (fail closed: the payment is not proven valid).

Options

  • :level - Required. Verification depth. :structural needs no RPC; :full additionally simulates the transaction (requires :rpc unless simulate: false). A level never silently downgrades.

  • :fee_payer (String.t/0) - Required. The facilitator-managed fee-payer address (Base58). The requirements' extra.feePayer must equal it — a facilitator must never co-sign a transaction whose fee payer it does not control.

  • :rpc - An X402.RPC configuration. Required for level :full with simulation.

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

  • :max_required_signatures - Cap on the transaction's required signature count (every signature adds 5000 lamports of base fee, paid by the facilitator). nil disables the cap. A typical x402 payment needs two. The default value is nil.

  • :commitment (String.t/0) - Commitment level for the simulation. The default value is "confirmed".

Examples

iex> X402.Verify.SVM.verify(%{"x402Version" => 2}, %{},
...>   level: :structural,
...>   fee_payer: "9hSR6S7WPtxmTojgo6GG3k4yDPecgJY292j7xrsUGWBu"
...> )
{:error, {:invalid, :invalid_payload}}

Types

error()

@type error() ::
  {:invalid, invalid_reason()}
  | :rpc_not_configured
  | {:rpc_error, X402.RPC.error()}

Verification errors.

invalid_reason()

@type invalid_reason() ::
  :invalid_payload
  | :unsupported_scheme
  | :network_mismatch
  | :missing_fee_payer
  | :fee_payer_not_managed
  | :transaction_could_not_be_decoded
  | :fee_payer_mismatch
  | :excessive_signers
  | :signature_invalid
  | :alt_resolution_not_available
  | :invalid_requirements_amount
  | :invalid_requirements_asset
  | :invalid_requirements_pay_to
  | :instruction_count
  | :invalid_compute_limit_instruction
  | :invalid_compute_price_instruction
  | :compute_price_too_high
  | :missing_transfer_instruction
  | :fee_payer_not_isolated
  | :amount_mismatch
  | :mint_mismatch
  | :recipient_mismatch
  | :unknown_optional_instruction
  | :memo_count
  | :memo_mismatch
  | :verification_failed
  | :simulation_failed

Why a payment was rejected.

Reasons map onto the canonical cross-SDK invalidReason strings via reason_string/1 where an equivalent exists.

level()

@type level() :: :structural | :full

Requested verification depth.

verification()

@type verification() :: %{payer: String.t(), level: level()}

A successful verification.

payer is the Base58 TransferChecked authority. level echoes the level that was run.