X402.Client.SIWX (X402 v0.9.0)

Copy Markdown View Source

Client side of the sign-in-with-x extension: answering a challenge.

When a server advertises a Sign-In-With-X challenge under PaymentRequired.extensions["sign-in-with-x"], a client that has already paid for the resource can prove control of its wallet instead of paying again. authenticate/4 picks the chain to sign for, refuses challenges that are not bound to the resource's origin, signs the CAIP-122 message with X402.Extensions.SIWX.sign/3, and returns the SIGN-IN-WITH-X header value. X402.Client.Finch and X402.MCP.Client drive the full flow through their :siwx option; use this module directly with any other transport.

Options

The :siwx option of the drivers is a keyword list:

  • :chain_id - Required. CAIP-2 chain to sign for, or :auto to pick the first advertised supportedChains entry the signer can sign (EVM signers map to eip155:*, Solana signers to solana:*).

  • :address - Address placed in the proof; the signer's address when omitted. The default value is nil.

  • :signature_scheme - Optional signatureScheme hint copied into the proof. The default value is nil.

  • :domain - Expected challenge domain. Required for transports without a resource URL (MCP); for HTTP it defaults to the resource URL's host. The default value is nil.

  • :uri - Exact expected challenge URI, including path and query. Required for automatic MCP proofs, alongside :domain. Configure it independently of the challenge and bind the transport to that trusted server. The default value is nil.

Summary

Types

Validated :siwx driver options.

A signed challenge, ready to send.

Errors returned by authenticate/4, wrapped as {:siwx, reason}.

Functions

Signs the challenge advertised in a PaymentRequired map.

Fetches the advertised challenge from a PaymentRequired map.

Validates a driver's :siwx option.

Types

opts()

@type opts() :: [
  chain_id: String.t() | :auto,
  address: String.t() | nil,
  signature_scheme: String.t() | nil,
  domain: String.t() | nil,
  uri: String.t() | nil
]

Validated :siwx driver options.

proof()

@type proof() :: %{header: String.t(), chain_id: String.t(), address: String.t()}

A signed challenge, ready to send.

reason()

@type reason() ::
  :invalid_challenge
  | :domain_mismatch
  | :uri_mismatch
  | :unsupported_chain
  | :payment_cancelled
  | X402.Extensions.SIWX.sign_error()
  | X402.Extensions.SIWX.signed_encode_error()

Errors returned by authenticate/4, wrapped as {:siwx, reason}.

Functions

authenticate(payment_required, signer, siwx_opts, opts \\ [])

(since 0.9.0)
@spec authenticate(map(), X402.Signer.t(), keyword(), keyword()) ::
  {:ok, proof()} | :none | {:error, {:siwx, reason()}}

Signs the challenge advertised in a PaymentRequired map.

Returns :none when the server advertised no sign-in-with-x challenge, {:ok, proof} with the SIGN-IN-WITH-X header value once signed, or {:error, {:siwx, reason}}.

Before signing, the challenge is checked against the resource it was issued for, as the spec requires: its info.domain must equal (ignoring case) siwx_opts[:domain] when given, otherwise the host (optionally with port) of :resource_url; and, when :resource_url is given, the origin (scheme, host, port) of info.uri must equal the URL's origin. Failures are reported as :domain_mismatch / :uri_mismatch. With neither a trusted domain nor a resource URL, signing fails with :domain_mismatch. Never derive the expected domain from the untrusted challenge.

siwx_opts[:uri] additionally requires an exact URI match, including path and query, and a domain consistent with its host/port. The pin must be an absolute HTTP(S) URL without userinfo or fragment. It is mandatory for transport: :mcp; a domain alone is insufficient for automatic MCP proofs.

With chain_id: :auto the first entry of supportedChains whose family the signer can sign is used (eip155:* needs X402.Signer.sign_message/2, solana:* needs X402.Signer.sign_ed25519/2); no match yields :unsupported_chain. Signing errors from X402.Extensions.SIWX.sign/3 are returned as they are.

Emits [:x402, :client, :siwx] with status: :error, :reason, :transport, and :chain_id on failure; the drivers emit the :ok event once they know the outcome. The chain is the selected signing chain, or nil if origin validation or automatic selection failed before selecting one. It is never :auto.

Options

  • :resource_url — the URL that returned the 402, for the origin check.
  • :transport — atom identifying the caller in telemetry (:http or :mcp for the built-in drivers); defaults to nil for direct calls.
  • :before_sign — optional zero-arity consent callback, invoked after origin/URI checks and chain selection but before wallet signing. Only :ok allows signing; other returns yield {:siwx, :payment_cancelled}. It is not called when no challenge is present.

Examples

iex> {:ok, signer} = X402.Signer.SolanaKey.new(:binary.copy(<<1>>, 32))
iex> challenge = X402.Extensions.SIWX.challenge(
...>   domain: "api.example.com",
...>   uri: "https://api.example.com",
...>   supported_chains: [%{chain_id: "eip155:8453"}, %{chain_id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"}]
...> )
iex> payment_required = %{"x402Version" => 2, "accepts" => [], "extensions" => %{"sign-in-with-x" => challenge}}
iex> {:ok, proof} = X402.Client.SIWX.authenticate(payment_required, signer, [chain_id: :auto],
...>   resource_url: "https://api.example.com/premium")
iex> {proof.chain_id, proof.address}
{"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "AKnL4NNf3DGWZJS6cPknBuEGnVsV4A4m5tgebLHaRSZ9"}
iex> {:ok, {:spec, fields}} = X402.Extensions.SIWX.decode_signed(proof.header)
iex> fields["nonce"] == challenge["info"]["nonce"]
true

iex> X402.Client.SIWX.authenticate(%{"x402Version" => 2, "accepts" => []}, :signer, chain_id: :auto)
:none

fetch_challenge(payment_required)

(since 0.9.0)
@spec fetch_challenge(map()) :: {:ok, map()} | :error

Fetches the advertised challenge from a PaymentRequired map.

Examples

iex> challenge = %{"info" => %{"domain" => "api.example.com"}, "supportedChains" => []}
iex> payment_required = %{"x402Version" => 2, "accepts" => [], "extensions" => %{"sign-in-with-x" => challenge}}
iex> X402.Client.SIWX.fetch_challenge(payment_required)
{:ok, challenge}

iex> X402.Client.SIWX.fetch_challenge(%{"x402Version" => 2, "accepts" => []})
:error

validate_opts(disabled)

(since 0.9.0)
@spec validate_opts(term()) :: {:ok, opts() | nil} | {:error, String.t()}

Validates a driver's :siwx option.

nil and false disable automatic Sign-In-With-X; a keyword list is validated against the options above. Designed for NimbleOptions custom validation.

Examples

iex> X402.Client.SIWX.validate_opts(false)
{:ok, nil}

iex> {:ok, opts} = X402.Client.SIWX.validate_opts(chain_id: :auto)
iex> Enum.sort(opts)
[address: nil, chain_id: :auto, domain: nil, signature_scheme: nil, uri: nil]

iex> {:error, message} = X402.Client.SIWX.validate_opts(address: "0xabc")
iex> message =~ ":chain_id"
true