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-RESPONSEreceipt, - 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
@type amount() :: non_neg_integer() | String.t()
An atomic-unit amount: a non-negative integer or integer string.
@type budget() :: GenServer.server()
A budget process reference.
@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.
@type reserve_error() :: {:budget_exceeded, exceeded()} | :invalid_amount
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
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}}
@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}
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
@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}}
@spec start_link(keyword()) :: GenServer.on_start()
Starts a budget process.