X402.Client.Budget (X402 v0.9.0)

Copy Markdown View Source

A session spend budget shared by payer clients.

A budget tracks the atomic-unit total a client has committed to paying across requests, with a hard :limit and optional :per_asset limits. X402.Client.Finch.request/3 and X402.MCP.Client.call/3 take a :budget and reserve the selected amount before a payment leaves the process, so concurrent requests cannot collectively overspend.

Accounting

reserve/3 atomically adds an amount to the spent total (and to the asset's total) and fails with {:error, {:budget_exceeded, details}} without changing anything when either limit would be exceeded. The drivers reserve after the payload is built and before the paid retry is sent, and release/3 the reservation when the payment is not accepted:

  • a transport error on the paid retry,
  • an HTTP retry that answers with a non-2xx status and no successful PAYMENT-RESPONSE receipt,
  • an MCP retry that answers with another payment-required result and no successful _meta["x402/payment-response"] receipt.

Everything else — a 2xx response, a tool result, or any response carrying a success: true settlement receipt — counts as spent, whether or not the facilitator actually settled: the budget is a safety cap on what the client has authorized, not a ledger of on-chain transfers.

Auth-capture is deliberately more conservative: once a paid request is dispatched, its entire maximum remains reserved for every outcome, including errors and renewed challenges. A hold or charge may already exist. Only application reconciliation against trusted payment state should release it.

Amounts are atomic units: non-negative integers or integer strings, as in PaymentRequirements.amount. Assets are compared case-insensitively.

Example

{:ok, budget} = X402.Client.Budget.start_link(limit: "5000000", per_asset: %{usdc => "1000000"})

X402.Client.Finch.request(MyApp.Finch, url, signer: signer, budget: budget)

X402.Client.Budget.spent(budget)
#=> %{total: 10000, per_asset: %{"0x036cbd53842c5426634e7929541ec2318f3dcf7e" => 10000}}

Summary

Types

An atomic-unit amount: a non-negative integer or integer string.

A budget process reference.

Why a reservation was refused.

Functions

Returns a specification to start this module under a supervisor.

Releases a previous reservation of amount of asset.

Reserves amount of asset against the budget.

Whether a scheme's exposure survives every dispatched-request outcome.

Returns the amounts currently reserved or spent.

Starts a budget process.

Types

amount()

@type amount() :: non_neg_integer() | String.t()

An atomic-unit amount: a non-negative integer or integer string.

budget()

@type budget() :: GenServer.server()

A budget process reference.

exceeded()

@type exceeded() :: %{
  scope: :total | :asset,
  asset: String.t(),
  amount: non_neg_integer(),
  limit: non_neg_integer(),
  spent: non_neg_integer()
}

Why a reservation was refused.

reserve_error()

@type reserve_error() :: {:budget_exceeded, exceeded()} | :invalid_amount

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

release(budget, asset, amount)

(since 0.9.0)
@spec release(budget(), String.t(), amount()) :: :ok | {:error, :invalid_amount}

Releases a previous reservation of amount of asset.

Releases at most the amount held for this asset, reducing both its bucket and the total by the same amount. Other assets' reservations are unaffected; totals never go below zero.

Examples

iex> {:ok, budget} = X402.Client.Budget.start_link(limit: 100)
iex> :ok = X402.Client.Budget.reserve(budget, "usdc", 60)
iex> X402.Client.Budget.release(budget, "usdc", 60)
:ok
iex> X402.Client.Budget.spent(budget)
%{total: 0, per_asset: %{"usdc" => 0}}

reserve(budget, asset, amount)

(since 0.9.0)
@spec reserve(budget(), String.t(), amount()) :: :ok | {:error, reserve_error()}

Reserves amount of asset against the budget.

Returns {:error, {:budget_exceeded, details}} — with details.scope telling whether the total or the asset limit was hit — and leaves the budget unchanged when the reservation does not fit.

Examples

iex> {:ok, budget} = X402.Client.Budget.start_link(limit: 100)
iex> X402.Client.Budget.reserve(budget, "usdc", "60")
:ok
iex> X402.Client.Budget.reserve(budget, "usdc", 50)
{:error, {:budget_exceeded, %{scope: :total, asset: "usdc", amount: 50, limit: 100, spent: 60}}}
iex> X402.Client.Budget.reserve(budget, "usdc", "0.5")
{:error, :invalid_amount}

retain_after_dispatch?(payload)

(since 0.9.0)
@spec retain_after_dispatch?(map()) :: boolean()

Whether a scheme's exposure survives every dispatched-request outcome.

Examples

iex> X402.Client.Budget.retain_after_dispatch?(%{"accepted" => %{"scheme" => "auth-capture"}})
true

iex> X402.Client.Budget.retain_after_dispatch?(%{"accepted" => %{"scheme" => "exact"}})
false

spent(budget)

(since 0.9.0)
@spec spent(budget()) :: %{
  total: non_neg_integer(),
  per_asset: %{required(String.t()) => non_neg_integer()}
}

Returns the amounts currently reserved or spent.

Asset keys are lowercased.

Examples

iex> {:ok, budget} = X402.Client.Budget.start_link(limit: 100, per_asset: %{"USDC" => 80})
iex> :ok = X402.Client.Budget.reserve(budget, "USDC", 30)
iex> X402.Client.Budget.spent(budget)
%{total: 30, per_asset: %{"usdc" => 30}}

start_link(opts)

(since 0.9.0)
@spec start_link(keyword()) :: GenServer.on_start()

Starts a budget process.

Options

  • :name (term/0) - Optional GenServer name to register the budget under.

  • :limit - Required. Total spend limit in atomic units (integer or integer string).

  • :per_asset - Per-asset spend limits in atomic units, keyed by asset identifier. The default value is %{}.