X402.Facilitator.PendingSettlementStore behaviour (X402 v0.6.0)

Copy Markdown View Source

Behaviour for pending-settlement reconciliation stores.

When X402.Facilitator.Engine.settle/3 broadcasts a transaction but cannot establish its confirmation — the receipt poll times out, or the transport fails mid-broadcast — it returns the spec's non-terminal "settlement_pending" response. Without a store that knowledge is lost: a client retrying the identical payment would re-verify and re-broadcast a second transaction instead of reconciling against the one already in flight.

A pending-settlement store closes that loop, mirroring the reference SDKs' PendingSettlementStore: before verifying, settle/3 looks the payment up here and, on a hit, deletes the entry and re-awaits the already-broadcast transaction instead of broadcasting a new one. The delete-before-reconcile ordering is load-bearing — a concurrent retry of the same payload misses the store and falls through to the normal path, where the chain itself rejects the duplicate (the EIP-3009 authorization nonce can only be consumed once).

Adapters are configured as {module, store} tuples where module implements this behaviour and store is adapter-specific runtime state (a pid, a registered name, a connection handle). The bundled adapter is X402.Facilitator.PendingSettlementStore.ETS; multi-instance facilitators should supply a shared-store adapter (Redis, database) instead — entries must be visible to whichever instance receives the retry.

Entries

An entry records what is needed to reconcile:

  • :transaction — the 0x-prefixed transaction hash.
  • :provenance:node_acknowledged when the hash was returned by eth_sendRawTransaction, or :local_hash when the transport failed mid-broadcast and the hash was computed locally from the signed transaction (the node may never have seen it).
  • :raw_transaction — the raw signed transaction bytes for :local_hash entries (nil otherwise), kept so operators can inspect or manually rebroadcast a transaction the node may have missed. The engine itself never rebroadcasts.

Entries must expire on their own (the bundled adapter defaults to five minutes) — an entry that outlives its transaction's relevance only delays the terminal verdict of a retry.

Writing a distributed adapter

defmodule MyApp.RedisPendingStore do
  @behaviour X402.Facilitator.PendingSettlementStore

  @ttl_ms :timer.minutes(5)

  @impl true
  def put(conn, key, entry) do
    encoded = Base.encode64(:erlang.term_to_binary(entry))

    case Redix.command(conn, ["SET", "x402:pending:" <> key, encoded, "PX", @ttl_ms]) do
      {:ok, "OK"} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  @impl true
  def get(conn, key) do
    case Redix.command(conn, ["GET", "x402:pending:" <> key]) do
      {:ok, nil} -> :miss
      {:ok, encoded} -> {:hit, decode(encoded)}
      {:error, reason} -> {:error, reason}
    end
  end

  @impl true
  def delete(conn, key) do
    case Redix.command(conn, ["DEL", "x402:pending:" <> key]) do
      {:ok, _count} -> :ok
      {:error, reason} -> {:error, reason}
    end
  end

  defp decode(encoded) do
    # `:safe` refuses unknown atoms; entry maps only contain literals
    # this module wrote, so decoding failures indicate store corruption.
    Base.decode64!(encoded) |> :erlang.binary_to_term([:safe])
  end
end

Summary

Types

Adapter tuple accepted by X402.Facilitator.Engine.

A recorded pending settlement.

Result returned by get/2.

Key identifying a payment's settlement attempt (a lowercase hex digest).

Result returned by write/delete operations.

Callbacks

Deletes the entry for key. Deleting an absent key returns :ok.

Reads the entry stored for key, or :miss when absent or expired.

Unconditionally stores entry for key, resetting its TTL.

Functions

Deletes key through the {module, store} adapter.

Reads key through the {module, store} adapter.

Writes entry under key through the {module, store} adapter.

Validates a {module, store} adapter tuple for use as a NimbleOptions {:custom, ...} validator.

Types

adapter()

@type adapter() :: {module(), term()}

Adapter tuple accepted by X402.Facilitator.Engine.

entry()

@type entry() :: %{
  transaction: String.t(),
  provenance: :node_acknowledged | :local_hash,
  raw_transaction: binary() | nil
}

A recorded pending settlement.

get_result()

@type get_result() :: {:hit, entry()} | :miss | {:error, term()}

Result returned by get/2.

key()

@type key() :: String.t()

Key identifying a payment's settlement attempt (a lowercase hex digest).

write_result()

@type write_result() :: :ok | {:error, term()}

Result returned by write/delete operations.

Callbacks

delete(store, key)

@callback delete(store :: term(), key()) :: write_result()

Deletes the entry for key. Deleting an absent key returns :ok.

get(store, key)

@callback get(store :: term(), key()) :: get_result()

Reads the entry stored for key, or :miss when absent or expired.

put(store, key, entry)

@callback put(store :: term(), key(), entry()) :: write_result()

Unconditionally stores entry for key, resetting its TTL.

Functions

delete(arg, key)

(since 0.6.0)
@spec delete(adapter(), key()) :: write_result()

Deletes key through the {module, store} adapter.

Adapter crashes never propagate: a raise, throw, or exit from the adapter is caught and returned as {:error, {:store_unavailable, reason}}.

get(arg, key)

(since 0.6.0)
@spec get(adapter(), key()) :: get_result()

Reads key through the {module, store} adapter.

Adapter crashes never propagate: a raise, throw, or exit from the adapter (a dead, restarting, or overloaded store process, for example) is caught and returned as {:error, {:store_unavailable, reason}}, so callers can apply their documented store-failure handling — X402.Facilitator.Engine.settle/3 treats a failed read as a miss and falls through to a normal broadcast.

put(arg, key, entry)

(since 0.6.0)
@spec put(adapter(), key(), entry()) :: write_result()

Writes entry under key through the {module, store} adapter.

Adapter crashes never propagate: a raise, throw, or exit from the adapter is caught and returned as {:error, {:store_unavailable, reason}}X402.Facilitator.Engine.settle/3 downgrades a failed write to a terminal response that keeps the broadcast transaction hash.

validate_adapter(adapter)

(since 0.6.0)
@spec validate_adapter(term()) :: {:ok, adapter()} | {:error, String.t()}

Validates a {module, store} adapter tuple for use as a NimbleOptions {:custom, ...} validator.

Examples

iex> X402.Facilitator.PendingSettlementStore.validate_adapter(
...>   {X402.Facilitator.PendingSettlementStore.ETS, MyStore}
...> )
{:ok, {X402.Facilitator.PendingSettlementStore.ETS, MyStore}}

iex> X402.Facilitator.PendingSettlementStore.validate_adapter(:not_a_store)
{:error, "expected {module, store} with module implementing " <>
  "X402.Facilitator.PendingSettlementStore"}