ETS-backed spend tracking for agent payment operations.
Tracks all payments made by an agent with timestamps, enabling sliding-window session limits and lifetime totals. One Ledger GenServer runs per agent (or shared across agents if desired).
Usage
{:ok, ledger} = Ledger.start_link(name: :my_ledger)
:ok = Ledger.record_spend(ledger, "agent_1", Decimal.new("0.05"), %{
domain: "api.example.com",
protocol: :x402,
tx_hash: "0x..."
})
case Ledger.check_budget(ledger, "agent_1", Decimal.new("0.10"), policy) do
:ok -> # proceed with payment
{:over_limit, :per_request} -> # amount too high
{:over_limit, :session} -> # session window exhausted
end
Summary
Functions
Check if a payment amount fits within the spending policy.
Returns a specification to start this module under a supervisor.
Drop the reservation tag for intent_id WITHOUT releasing budget.
Freeze the ledger. While frozen, every try_spend / check_budget call
returns {:over_limit, :frozen} regardless of policy caps.
Return the current freeze state.
Get spend history for an agent.
Get aggregate totals for an agent.
Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_info/2.
Record a completed payment.
Release (refund) a previously reserved amount.
Idempotently release the reservation tagged for intent_id.
Number of live reservation tags. For observability and tests.
Subscribe the calling process to new ledger entries.
Drop reservation tags older than the TTL and enforce the size cap, returning the count that remains.
Tag a settled reservation with the on-chain intent (or transfer) it funded.
Spawn a printer that prints every ledger entry to :stdio (or another device).
Atomically check budget and record spend in a single operation.
Release the freeze flag. See freeze/1.
Types
Functions
@spec check_budget( GenServer.server(), term(), Decimal.t(), Raxol.Payments.SpendingPolicy.t() ) :: :ok | {:over_limit, atom()}
Check if a payment amount fits within the spending policy.
Returns :ok or {:over_limit, limit_type} where limit_type is
:per_request, :session, :lifetime, or :invalid_amount (a zero,
negative, or non-finite amount).
Note: For concurrent use, prefer try_spend/5 which atomically checks
and records to prevent TOCTOU races.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec forget_reservation(GenServer.server(), term(), String.t()) :: :ok
Drop the reservation tag for intent_id WITHOUT releasing budget.
Used when an intent reaches a terminal status whose funds are kept on the books (completed, or a failure whose origin pull may already have moved funds): the spend stands, and only the tag is cleared so the reservation map stays bounded to in-flight intents.
@spec freeze(GenServer.server()) :: :ok
Freeze the ledger. While frozen, every try_spend / check_budget call
returns {:over_limit, :frozen} regardless of policy caps.
This is the kill switch for a runaway agent: flip the flag and the gate
refuses all further spending without killing the process. unfreeze/1
restores normal operation.
@spec frozen?(GenServer.server()) :: boolean()
Return the current freeze state.
@spec get_history(GenServer.server(), term(), keyword()) :: [entry()]
Get spend history for an agent.
@spec get_totals(GenServer.server(), term(), Raxol.Payments.SpendingPolicy.t()) :: %{ session: Decimal.t(), lifetime: Decimal.t() }
Get aggregate totals for an agent.
Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_info/2.
@spec record_spend(GenServer.server(), term(), Decimal.t(), map()) :: :ok
Record a completed payment.
@spec release(GenServer.server(), term(), Decimal.t(), map()) :: :ok
Release (refund) a previously reserved amount.
Records a compensating entry of -amount so a spend that was reserved via
try_spend/5 but then failed downstream (e.g. an intent execution error
after signing was authorized) does not permanently consume budget. Session
and lifetime totals net back out; per-request caps are unaffected since they
are evaluated per call, not on the running sum.
@spec release_by_intent(GenServer.server(), term(), String.t()) :: :released | :noop
Idempotently release the reservation tagged for intent_id.
Inserts a single compensating -amount entry the FIRST time it is called for a
tagged intent, then drops the tag; any later call for the same intent id is a
no-op. This is what makes a refund safe to observe more than once (e.g. a
re-poll of an already-refunded intent) without double-releasing budget, which
would under-count spend and hand back real headroom.
Returns :released when it reversed the reservation, :noop when there was no
live tag (already released, forgotten, or never tagged). A freeze does not block
a release -- refunding budget on returned funds is not a spend.
@spec reservation_count(GenServer.server()) :: non_neg_integer()
Number of live reservation tags. For observability and tests.
@spec subscribe( GenServer.server(), keyword() ) :: :ok
Subscribe the calling process to new ledger entries.
Every successful try_spend or record_spend sends {:ledger_entry, entry}
to the subscriber. The subscription is monitored, so the Ledger cleans up
when the subscriber dies.
Optional :agent_id filters to entries for one agent.
@spec sweep_reservations(GenServer.server()) :: non_neg_integer()
Drop reservation tags older than the TTL and enforce the size cap, returning the count that remains.
Runs automatically on a timer; exposed so an operator (or a test) can force it. Releasing a still-tagged intent stays possible right up until it is swept.
@spec tag_reservation(GenServer.server(), term(), String.t(), Decimal.t()) :: :ok
Tag a settled reservation with the on-chain intent (or transfer) it funded.
Recorded once execution dispatches, when both the reserved amount and the
intent id are known (the reservation itself is made earlier, before the id
exists). It lets a later terminal outcome release exactly that amount by intent
id via release_by_intent/3, without the caller re-threading the amount. A
no-op tag (nil id or non-positive amount) is ignored.
@spec tail( GenServer.server(), keyword() ) :: pid()
Spawn a printer that prints every ledger entry to :stdio (or another device).
Returns the watcher pid. Process.exit(pid, :kill) to stop, or let the
Ledger's death take it down via the monitor.
iex> {:ok, ledger} = Raxol.Payments.Ledger.start_link()
iex> Raxol.Payments.Ledger.tail(ledger)
@spec try_spend( GenServer.server(), term(), Decimal.t(), Raxol.Payments.SpendingPolicy.t(), map() ) :: :ok | {:over_limit, atom()}
Atomically check budget and record spend in a single operation.
Prevents TOCTOU races where concurrent requests both pass check_budget
before either calls record_spend.
Returns :ok or {:over_limit, limit_type}.
@spec unfreeze(GenServer.server()) :: :ok
Release the freeze flag. See freeze/1.