Raxol.Payments.SettlementLedger (Raxol Payments v0.2.0)

Copy Markdown View Source

ETS-backed post-settlement P&L ledger: one entry per completed cross-chain fill, recording the fee collected (in the fee token) against the native gas the fill burned, so a corridor's margin and a chain's native-token drain can be measured after the fact.

This exists because a small cross-chain fill can quote as solvable yet lose money once destination gas lands -- e.g. a $1 fill to Ethereum L1 pays far more in ETH gas than it earns in USDC spread, and the loss is denominated in ETH (a one-way drain), not USDC. can_solve catches this only at quote time; this ledger quantifies it after settlement and feeds Raxol.Payments.RebalanceAdvisor.

Mirrors Raxol.Payments.Ledger (BaseManager + ETS, caller-started). One deliberate difference: the table is a :set keyed by intent_id (Ledger uses a :duplicate_bag keyed by agent_id) so :ets.insert_new/2 gives O(1) idempotency -- a re-poll of the same settlement must not double-record. Do not "align" this back to a :duplicate_bag.

There is no price oracle in raxol_payments, so gas (native units) and fee (fee token) are stored raw; USD margin is computed only in the aggregations, and only when an injectable :price_fn (and :usdc_price) is supplied. Without a price, aggregations still return raw fee_by_currency / gas_by_chain totals and a usd_margin of nil.

Usage

{:ok, ledger} = SettlementLedger.start_link(name: :my_settlements)

{:ok, :recorded} =
  SettlementLedger.record_settlement(ledger, %{
    intent_id: "xi_abc",
    from_chain_id: 8453,
    to_chain_id: 1,
    token_symbol: "USDC",
    fee_collected: "2205",
    fee_currency: "USDC",
    fee_decimals: 6,
    gas_native: 107_675_364_531_212,
    gas_chain_id: 1,
    gas_symbol: "ETH",
    gas_status: :confirmed,
    tx_hash: "0x...",
    settlement_type: :public
  })

SettlementLedger.report(ledger, price_fn: fn "ETH" -> Decimal.new("1700") end)

Summary

Types

Aggregated margin over a set of entries.

Functions

Backfill gas_native for an intent whose receipt mined after the entry was recorded (the :pending path). Fills only when the stored gas_native is nil; otherwise a no-op. opts may carry :gas_status (default :confirmed).

Returns a specification to start this module under a supervisor.

One aggregate over all (filtered) settlements. A negative usd_margin is the cumulative subsidy the solver has spent keeping those corridors live.

Fetch one settlement by intent id.

List settlements, newest last. opts filters: :from_chain_id, :to_chain_id, :settlement_type, :since_ms.

Margin aggregate per {from_chain_id, to_chain_id} corridor. Pricing opts (:price_fn, :usdc_price) flow into USD fields; filter opts flow to list_settlements/2.

Margin aggregate per destination chain (to_chain_id).

Total native-token drain (wei) per chain the solver filled on.

Record a completed fill. Idempotent by intent_id: a second record for the same intent returns {:ok, :duplicate} and does not overwrite or re-emit telemetry.

A structured margin report: corridors, destinations, native drain, and totals.

Types

aggregate()

@type aggregate() :: %{
  count: non_neg_integer(),
  gas_unknown_count: non_neg_integer(),
  fee_by_currency: %{required(String.t()) => Decimal.t()},
  gas_by_chain: %{required(pos_integer()) => Decimal.t()},
  usd_revenue: Decimal.t() | nil,
  usd_fee: Decimal.t() | nil,
  usd_gas: Decimal.t() | nil,
  usd_margin: Decimal.t() | nil
}

Aggregated margin over a set of entries.

entry()

@type entry() :: %{
  intent_id: String.t(),
  from_chain_id: pos_integer() | nil,
  to_chain_id: pos_integer() | nil,
  token_symbol: String.t() | nil,
  token_address: String.t() | nil,
  fee_collected: Decimal.t(),
  fee_currency: String.t(),
  fee_decimals: pos_integer(),
  from_amount: Decimal.t() | nil,
  from_symbol: String.t() | nil,
  from_decimals: pos_integer() | nil,
  to_amount: Decimal.t() | nil,
  to_symbol: String.t() | nil,
  to_decimals: pos_integer() | nil,
  gas_native: Decimal.t() | nil,
  gas_chain_id: pos_integer() | nil,
  gas_symbol: String.t() | nil,
  gas_decimals: pos_integer(),
  gas_status: gas_status(),
  estimated_gas_cost: String.t() | nil,
  tx_hash: String.t() | nil,
  settlement_type: atom() | nil,
  timestamp_ms: integer(),
  usd_margin: Decimal.t() | nil,
  metadata: map()
}

gas_status()

@type gas_status() :: :confirmed | :pending | :no_public_tx | :error

Functions

amend_gas(server, intent_id, gas_native, opts \\ [])

@spec amend_gas(GenServer.server(), String.t(), integer() | Decimal.t(), keyword()) ::
  {:ok, :amended | :noop} | :error

Backfill gas_native for an intent whose receipt mined after the entry was recorded (the :pending path). Fills only when the stored gas_native is nil; otherwise a no-op. opts may carry :gas_status (default :confirmed).

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

cumulative_subsidy(server, opts \\ [])

@spec cumulative_subsidy(
  GenServer.server(),
  keyword()
) :: aggregate()

One aggregate over all (filtered) settlements. A negative usd_margin is the cumulative subsidy the solver has spent keeping those corridors live.

get_settlement(server, intent_id)

@spec get_settlement(GenServer.server(), String.t()) :: {:ok, entry()} | :error

Fetch one settlement by intent id.

handle_manager_cast(msg, state)

Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_cast/2.

handle_manager_info(msg, state)

Callback implementation for Raxol.Core.Behaviours.BaseManager.handle_manager_info/2.

list_settlements(server, opts \\ [])

@spec list_settlements(
  GenServer.server(),
  keyword()
) :: [entry()]

List settlements, newest last. opts filters: :from_chain_id, :to_chain_id, :settlement_type, :since_ms.

margin_by_corridor(server, opts \\ [])

@spec margin_by_corridor(
  GenServer.server(),
  keyword()
) :: %{required({pos_integer(), pos_integer()}) => aggregate()}

Margin aggregate per {from_chain_id, to_chain_id} corridor. Pricing opts (:price_fn, :usdc_price) flow into USD fields; filter opts flow to list_settlements/2.

margin_by_destination(server, opts \\ [])

@spec margin_by_destination(
  GenServer.server(),
  keyword()
) :: %{required(pos_integer()) => aggregate()}

Margin aggregate per destination chain (to_chain_id).

native_drain_by_chain(server, opts \\ [])

@spec native_drain_by_chain(
  GenServer.server(),
  keyword()
) :: %{required(pos_integer()) => Decimal.t()}

Total native-token drain (wei) per chain the solver filled on.

record_settlement(server, input)

@spec record_settlement(GenServer.server(), map()) :: {:ok, :recorded | :duplicate}

Record a completed fill. Idempotent by intent_id: a second record for the same intent returns {:ok, :duplicate} and does not overwrite or re-emit telemetry.

report(server, opts \\ [])

@spec report(
  GenServer.server(),
  keyword()
) :: %{corridors: map(), destinations: map(), drain: map(), totals: aggregate()}

A structured margin report: corridors, destinations, native drain, and totals.

start_link(init_opts \\ [])