X402.Extensions.OfferReceipt (X402 v0.6.0)

Copy Markdown View Source

Offer-and-receipt extension for x402: signed offers and signed receipts.

Implements the offer-and-receipt extension: the resource server cryptographically commits to the payment terms it advertises (a signed offer placed under extensions["offer-receipt"].info.offers[] of the payment requirements) and, after successful payment and delivery, confirms the transaction (a signed receipt under extensions["offer-receipt"].info.receipt of the settlement response). Both artifacts are portable, independently verifiable, and identical for x402 v1 and v2.

Two signature formats are supported, per the specification (§3.1):

  • "eip712" — an EIP-712 typed-data signature with the fixed domain {name: "x402 offer" | "x402 receipt", version: "1", chainId: 1} (chain-agnostic by design, §3.2) and the canonical Offer / Receipt types from §4.3 and §5.3. Signing goes through the X402.Signer behaviour; verification recovers the signer address. Requires the optional ex_keccak (and, for verification, ex_secp256k1) dependencies.
  • "jws" — a compact JWS (header.payload.signature) with ES256K or EdDSA, implemented with OTP :crypto by X402.Extensions.OfferReceipt.JWS. The protected header carries the mandatory alg and kid (a DID URL) fields (§3.3); payloads are JCS-canonicalized (§10).

Server side: issuing offers and receipts

{:ok, signer} = X402.Signer.LocalKey.new(private_key)

{:ok, payload} =
  X402.Extensions.OfferReceipt.offer_payload(
    resource_url: "https://api.example.com/premium-data",
    scheme: "exact",
    network: "eip155:8453",
    asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    pay_to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
    amount: "10000",
    valid_until: System.os_time(:second) + 300
  )

{:ok, offer} = X402.Extensions.OfferReceipt.sign_offer(payload, signer, accept_index: 0)

extensions = %{"offer-receipt" => X402.Extensions.OfferReceipt.build_extension([offer])}

Client side: verifying

{:ok, [offer]} = X402.Extensions.OfferReceipt.fetch_offers(payment_required)

{:ok, %{signer: signer_address, payload: payload}} =
  X402.Extensions.OfferReceipt.verify_offer(offer)

A valid signature only proves which key signed — it does not prove the key was authorized for the offered resource. Verifiers must apply an authorization policy (§4.5.1); the simplest is checking the recovered signer against the offer's payTo address, which verify_offer/2 supports through the :expected_signer option.

Boundaries

  • JWS verification takes an explicit :public_key — this library never resolves kid DID URLs (no network access); resolve the key yourself and check its authorization per §4.5.1.
  • The JWS algorithms are limited to what OTP :crypto provides: ES256K and EdDSA (both spec-named algorithms are covered).

Summary

Signed Offers

Extracts the payload from a signed envelope without verifying it.

Computes the EIP-712 digest of an offer payload (§4.3).

Builds an offer payload (§4.2) in wire shape.

Signs an offer payload as an EIP-712 artifact through an X402.Signer.

Signs an offer payload as a compact JWS artifact.

Verifies a signed offer envelope (§4.5).

Signed Receipts

Computes the EIP-712 digest of a receipt payload (§5.3).

Builds a receipt payload (§5.2) in wire shape.

Signs a receipt payload as an EIP-712 artifact through an X402.Signer.

Signs a receipt payload as a compact JWS artifact.

Verifies a signed receipt envelope (§5.5).

Extension Declaration

Builds the extensions["offer-receipt"] value for payment requirements.

Builds the extensions["offer-receipt"] value for a settlement response.

Fetches and validates the signed offers from a payment-required map.

Fetches and validates the signed receipt from a settlement response map.

Validates the structure of a signed offer envelope (§3.1.1, §4.2).

Validates the structure of a signed receipt envelope (§3.1.1, §5.2).

Types

A signed offer or receipt envelope in wire shape (string keys).

An offer or receipt payload in wire shape (string keys).

t()

A built extensions["offer-receipt"] value (info + schema).

The result of a successful verification.

Functions

Converts a network identifier to CAIP-2 format.

Signed Offers

extract_payload(arg1)

(since 0.6.0)
@spec extract_payload(envelope()) :: {:ok, payload()} | {:error, verify_error()}

Extracts the payload from a signed envelope without verifying it.

For "eip712" the transmitted payload is returned; for "jws" the JWS payload segment is decoded. Use verify_offer/2 / verify_receipt/2 for verified reads.

Examples

iex> X402.Extensions.OfferReceipt.extract_payload(%{
...>   "format" => "eip712",
...>   "payload" => %{"version" => 1},
...>   "signature" => "0xsig"
...> })
{:ok, %{"version" => 1}}

iex> X402.Extensions.OfferReceipt.extract_payload(%{"format" => "carrier-pigeon"})
{:error, {:unsupported_format, "carrier-pigeon"}}

offer_digest(payload)

(since 0.6.0)
@spec offer_digest(payload()) ::
  {:ok, <<_::256>>} | {:error, payload_error() | :missing_dependency}

Computes the EIP-712 digest of an offer payload (§4.3).

Returns keccak256(0x19 0x01 || domainSeparator || structHash) where the domain is {name: "x402 offer", version: "1", chainId: 1}. An absent validUntil is hashed as 0. Requires the optional ex_keccak dependency.

offer_payload(opts)

(since 0.6.0)
@spec offer_payload(keyword()) ::
  {:ok, payload()} | {:error, {:unknown_network, String.t()}}

Builds an offer payload (§4.2) in wire shape.

The :network is normalized to CAIP-2 with to_caip2/1, as the specification requires for offer payloads. version is always the current payload schema version (1).

Options

  • :resource_url (String.t/0) - Required. The paid resource URL.

  • :scheme (String.t/0) - Required. Payment scheme identifier (e.g. "exact").

  • :network (String.t/0) - Required. Network identifier — CAIP-2 or an x402 v1 name (converted via to_caip2/1).

  • :asset (String.t/0) - Required. Token contract address or "native".

  • :pay_to (String.t/0) - Required. Recipient wallet address.

  • :amount - Required. Required payment amount in atomic units (encoded as a string).

  • :valid_until (non_neg_integer/0) - Unix timestamp (seconds) when the offer expires. Omit for no expiry.

Examples

iex> X402.Extensions.OfferReceipt.offer_payload(
...>   resource_url: "https://api.example.com/premium-data",
...>   scheme: "exact",
...>   network: "base",
...>   asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
...>   pay_to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
...>   amount: 10_000,
...>   valid_until: 1_703_123_516
...> )
{:ok,
 %{
   "version" => 1,
   "resourceUrl" => "https://api.example.com/premium-data",
   "scheme" => "exact",
   "network" => "eip155:8453",
   "asset" => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
   "payTo" => "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
   "amount" => "10000",
   "validUntil" => 1703123516
 }}

iex> X402.Extensions.OfferReceipt.offer_payload(
...>   resource_url: "https://a.example",
...>   scheme: "exact",
...>   network: "unknown-net",
...>   asset: "native",
...>   pay_to: "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
...>   amount: "1"
...> )
{:error, {:unknown_network, "unknown-net"}}

sign_offer(payload, signer, opts \\ [])

(since 0.6.0)
@spec sign_offer(payload(), X402.Signer.t(), keyword()) ::
  {:ok, envelope()} | {:error, verify_error() | term()}

Signs an offer payload as an EIP-712 artifact through an X402.Signer.

Computes the EIP-712 digest with the fixed offer domain (name: "x402 offer", version: "1", chainId: 1, §3.2) and the canonical Offer type (§4.3), signs it through signer, and returns the transmitted envelope

%{"format" => "eip712", "payload" => payload, "signature" => "0x..."}

Per §4.3, an absent validUntil is signed and transmitted as 0. Requires the optional ex_keccak dependency ({:error, :missing_dependency} without it).

Options

  • :accept_index (non_neg_integer/0) - Index into accepts[] this offer corresponds to. An unsigned convenience field (§4.1.1) — clients match offers by payload fields.

sign_offer_jws(payload, key_opts)

(since 0.6.0)
@spec sign_offer_jws(
  payload(),
  keyword()
) ::
  {:ok, envelope()}
  | {:error, X402.Extensions.OfferReceipt.JWS.sign_error() | payload_error()}

Signs an offer payload as a compact JWS artifact.

The payload travels inside the JWS, so the envelope omits payload (§3.1.1). Optional payload fields stay omitted (no zero-filling — that rule is EIP-712 specific).

key_opts are the X402.Extensions.OfferReceipt.JWS.sign/2 options (:alg, :kid, :key) plus the :accept_index envelope option.

Examples

{:ok, offer} =
  X402.Extensions.OfferReceipt.sign_offer_jws(payload,
    alg: "EdDSA",
    kid: "did:web:api.example.com#key-1",
    key: ed25519_seed,
    accept_index: 0
  )

verify_offer(envelope, opts \\ [])

(since 0.6.0)
@spec verify_offer(
  envelope(),
  keyword()
) :: {:ok, verification()} | {:error, verify_error()}

Verifies a signed offer envelope (§4.5).

For "eip712" artifacts, recomputes the EIP-712 digest from the payload exactly as transmitted and recovers the signer address (requires the optional ex_secp256k1 / ex_keccak dependencies). For "jws" artifacts, verifies the compact JWS against the :public_key option.

Returns {:ok, %{format: ..., payload: ...}} with :signer (EIP-712, the recovered address) or :header (JWS, the protected header with alg and kid).

Signature validity is not authorization

A valid signature proves which key signed, not that the key was authorized for payload.resourceUrl (§4.5.1). Check the signer against your authorization policy — for the common payTo-signs deployment, pass expected_signer: payload["payTo"].

Options

  • :expected_signer (String.t/0) - EIP-712 only: EVM address the recovered signer must equal (case-insensitive), e.g. the offer's payTo. When the recovered signer differs, verification fails with {:error, :unauthorized_signer}.

  • :public_key (String.t/0) - JWS only (required for "jws" artifacts): raw public key bytes — a 32-byte Ed25519 key for EdDSA, a SEC1 secp256k1 point for ES256K.

  • :algs - JWS only: accepted algorithms. The default value is ["ES256K", "EdDSA"].

Signed Receipts

receipt_digest(payload)

(since 0.6.0)
@spec receipt_digest(payload()) ::
  {:ok, <<_::256>>} | {:error, payload_error() | :missing_dependency}

Computes the EIP-712 digest of a receipt payload (§5.3).

The domain is {name: "x402 receipt", version: "1", chainId: 1}; an absent transaction is hashed as "". Requires the optional ex_keccak dependency.

receipt_payload(opts)

(since 0.6.0)
@spec receipt_payload(keyword()) ::
  {:ok, payload()} | {:error, {:unknown_network, String.t()}}

Builds a receipt payload (§5.2) in wire shape.

Receipts are privacy-minimal by default: transaction is only included when passed. :issued_at defaults to the current Unix time.

Options

  • :resource_url (String.t/0) - Required. The paid resource URL.

  • :network (String.t/0) - Required. Network identifier — CAIP-2 or an x402 v1 name (converted via to_caip2/1).

  • :payer (String.t/0) - Required. Payer identifier (commonly a wallet address).

  • :issued_at (non_neg_integer/0) - Unix timestamp (seconds) the receipt was issued. Defaults to the current time.

  • :transaction (String.t/0) - Blockchain transaction hash. Optional — receipts are privacy-minimal by default; include it when verifiability matters more than privacy.

Examples

iex> X402.Extensions.OfferReceipt.receipt_payload(
...>   resource_url: "https://api.example.com/premium-data",
...>   network: "eip155:8453",
...>   payer: "0x857b06519E91e3A54538791bDbb0E22373e36b66",
...>   issued_at: 1_703_123_456
...> )
{:ok,
 %{
   "version" => 1,
   "network" => "eip155:8453",
   "resourceUrl" => "https://api.example.com/premium-data",
   "payer" => "0x857b06519E91e3A54538791bDbb0E22373e36b66",
   "issuedAt" => 1703123456
 }}

sign_receipt(payload, signer)

(since 0.6.0)
@spec sign_receipt(payload(), X402.Signer.t()) :: {:ok, envelope()} | {:error, term()}

Signs a receipt payload as an EIP-712 artifact through an X402.Signer.

Uses the fixed receipt domain (name: "x402 receipt", version: "1", chainId: 1, §3.2) and the canonical Receipt type (§5.3). Per §5.3, an absent transaction is signed and transmitted as "".

sign_receipt_jws(payload, key_opts)

(since 0.6.0)
@spec sign_receipt_jws(
  payload(),
  keyword()
) ::
  {:ok, envelope()}
  | {:error, X402.Extensions.OfferReceipt.JWS.sign_error() | payload_error()}

Signs a receipt payload as a compact JWS artifact.

See sign_offer_jws/2; key_opts are :alg, :kid, and :key.

verify_receipt(envelope, opts \\ [])

(since 0.6.0)
@spec verify_receipt(
  envelope(),
  keyword()
) :: {:ok, verification()} | {:error, verify_error()}

Verifies a signed receipt envelope (§5.5).

Same contract as verify_offer/2, with the receipt domain and types. issuedAt policy checks (freshness) and on-chain transaction checks are the caller's responsibility.

Extension Declaration

build_extension(offers)

(since 0.6.0)
@spec build_extension([envelope()]) :: t()

Builds the extensions["offer-receipt"] value for payment requirements.

Takes the signed offer envelopes for the response's accepts[] entries and returns the %{"info" => %{"offers" => offers}, "schema" => schema} declaration (§4.1, §6.1/§6.3). All offers must share one signature format — the specification's schemas are format-specific and servers use one format consistently (§6). Raises ArgumentError for empty, mixed-format, or structurally invalid offers (programmer errors).

build_receipt_extension(receipt)

(since 0.6.0)
@spec build_receipt_extension(envelope()) :: t()

Builds the extensions["offer-receipt"] value for a settlement response.

Takes the signed receipt envelope and returns the %{"info" => %{"receipt" => receipt}, "schema" => schema} declaration (§5.1, §6.5/§6.7). Raises ArgumentError for a structurally invalid receipt (programmer error).

fetch_offers(map)

(since 0.6.0)
@spec fetch_offers(map()) ::
  {:ok, [envelope()]}
  | {:error, :extension_not_present | {:invalid_extension, term()}}

Fetches and validates the signed offers from a payment-required map.

Accepts either the full decoded PAYMENT-REQUIRED map (looks under "extensions") or the extensions map itself. Validation is fail-closed: every offer envelope must be structurally valid per §3.1.1 ("eip712" offers carry a payload and a 65-byte hex signature; "jws" offers carry a three-part compact JWS and no payload).

Examples

iex> X402.Extensions.OfferReceipt.fetch_offers(%{"accepts" => []})
{:error, :extension_not_present}

fetch_receipt(map)

(since 0.6.0)
@spec fetch_receipt(map()) ::
  {:ok, envelope()}
  | {:error, :extension_not_present | {:invalid_extension, term()}}

Fetches and validates the signed receipt from a settlement response map.

Accepts either the full decoded settlement response (looks under "extensions") or the extensions map itself.

validate_offer(envelope)

(since 0.6.0)
@spec validate_offer(envelope()) :: :ok | {:error, verify_error()}

Validates the structure of a signed offer envelope (§3.1.1, §4.2).

Examples

iex> X402.Extensions.OfferReceipt.validate_offer(%{
...>   "format" => "jws",
...>   "signature" => "eyJhbGciOiJFUzI1NksiLCJraWQiOiJrIn0.eyJ2ZXJzaW9uIjoxfQ.c2ln"
...> })
:ok

iex> X402.Extensions.OfferReceipt.validate_offer(%{
...>   "format" => "jws",
...>   "payload" => %{"version" => 1},
...>   "signature" => "a.b.c"
...> })
{:error, {:invalid_field, "payload"}}

validate_receipt(envelope)

(since 0.6.0)
@spec validate_receipt(envelope()) :: :ok | {:error, verify_error()}

Validates the structure of a signed receipt envelope (§3.1.1, §5.2).

Types

envelope()

@type envelope() :: %{optional(String.t()) => term()}

A signed offer or receipt envelope in wire shape (string keys).

payload()

@type payload() :: %{optional(String.t()) => term()}

An offer or receipt payload in wire shape (string keys).

payload_error()

@type payload_error() ::
  {:missing_field, String.t()}
  | {:invalid_field, String.t()}
  | {:unsupported_payload_version, term()}
  | {:unknown_network, String.t()}

t()

@type t() :: %{optional(String.t()) => map()}

A built extensions["offer-receipt"] value (info + schema).

verification()

@type verification() :: %{
  optional(:signer) => String.t(),
  optional(:header) => map(),
  format: String.t(),
  payload: payload()
}

The result of a successful verification.

verify_error()

@type verify_error() ::
  payload_error()
  | X402.Extensions.OfferReceipt.JWS.verify_error()
  | {:unsupported_format, term()}
  | :invalid_envelope
  | :missing_public_key
  | :unauthorized_signer
  | :missing_dependency
  | :invalid_signature

Functions

to_caip2(network)

(since 0.6.0)
@spec to_caip2(String.t()) ::
  {:ok, String.t()} | {:error, {:unknown_network, String.t()}}

Converts a network identifier to CAIP-2 format.

Offer and receipt payloads must carry CAIP-2 identifiers even in x402 v1 flows (§4.2, §5.2). Strings that already contain a : are passed through; x402 v1 names are mapped ("base""eip155:8453", "solana" → its CAIP-2 chain reference); anything else is {:error, {:unknown_network, network}}.

Examples

iex> X402.Extensions.OfferReceipt.to_caip2("eip155:8453")
{:ok, "eip155:8453"}

iex> X402.Extensions.OfferReceipt.to_caip2("base-sepolia")
{:ok, "eip155:84532"}

iex> X402.Extensions.OfferReceipt.to_caip2("solana")
{:ok, "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"}

iex> X402.Extensions.OfferReceipt.to_caip2("mystery-chain")
{:error, {:unknown_network, "mystery-chain"}}