Skip to main content

lib/rujira/fin/range.ex

defmodule Rujira.Fin.Range do
  @moduledoc """
  Concentrated liquidity position (range) for the FIN protocol.

  Struct, construction, and queries. Use `Rujira.Fin` as the public API.
  """

  alias Rujira.Amount
  alias Rujira.Assets
  alias Rujira.Contracts
  alias Rujira.Math
  alias Rujira.Prices

  use Memoize

  defstruct id: nil,
            idx: nil,
            pair: nil,
            owner: nil,
            high: 0,
            low: 0,
            skew: 0,
            spread: 0,
            fee: 0,
            base: 0,
            quote: 0,
            price: 0,
            ask: 0,
            bid: 0,
            fees_base: 0,
            fees_quote: 0,
            value_usd: 0

  @type t :: %__MODULE__{
          id: String.t(),
          idx: integer(),
          pair: String.t(),
          owner: String.t(),
          high: Decimal.t(),
          low: Decimal.t(),
          skew: Decimal.t(),
          spread: Decimal.t(),
          fee: Decimal.t(),
          base: Amount.t(),
          quote: Amount.t(),
          price: Decimal.t(),
          ask: Decimal.t(),
          bid: Decimal.t(),
          fees_base: Amount.t(),
          fees_quote: Amount.t(),
          value_usd: Amount.t()
        }

  # --- Construction ---

  @doc """
  Parses a range from a contract query response.

  When called with a string address and integer index, creates a minimal
  range struct for subscription edge responses.
  """
  @spec new(Rujira.Fin.Pair.t(), map()) :: {:ok, t()} | {:error, term()}
  def new(
        %{address: address, token_quote: token_quote, token_base: token_base},
        %{
          "idx" => idx,
          "owner" => owner,
          "high" => high,
          "low" => low,
          "skew" => skew,
          "spread" => spread,
          "fee" => fee,
          "base" => base_amount,
          "quote" => quote_amount,
          "price" => price,
          "ask" => ask,
          "bid" => bid,
          "fees" => [fees_base, fees_quote]
        }
      ) do
    with {:ok, asset_quote} <- Assets.from_denom(token_quote),
         {:ok, asset_base} <- Assets.from_denom(token_base),
         {:ok, ask} <- Math.to_decimal(ask),
         {:ok, bid} <- Math.to_decimal(bid),
         {:ok, base} <- Amount.new(base_amount),
         {:ok, quote_} <- Amount.new(quote_amount),
         {:ok, fees_base} <- Amount.new(fees_base),
         {:ok, fees_quote} <- Amount.new(fees_quote),
         {:ok, price} <- Math.to_decimal(price),
         {:ok, high} <- Math.to_decimal(high),
         {:ok, low} <- Math.to_decimal(low),
         {:ok, skew} <- Math.to_decimal(skew),
         {:ok, spread} <- Math.to_decimal(spread),
         {:ok, fee} <- Math.to_decimal(fee),
         {:ok, idx} <- Math.to_integer(idx) do
      {:ok,
       %__MODULE__{
         id: "#{address}/#{idx}",
         idx: idx,
         pair: address,
         owner: owner,
         high: high,
         low: low,
         skew: skew,
         spread: spread,
         fee: fee,
         base: base,
         quote: quote_,
         price: price,
         ask: ask,
         bid: bid,
         fees_base: fees_base,
         fees_quote: fees_quote,
         value_usd:
           Prices.value_usd(asset_base.symbol, base + fees_base) +
             Prices.value_usd(asset_quote.symbol, quote_ + fees_quote)
       }}
    end
  end

  defp placeholder(address, idx) do
    %__MODULE__{id: "#{address}/#{idx}", idx: idx, pair: address}
  end

  # --- Queries ---

  @spec list(Rujira.Fin.Pair.t(), String.t() | nil, keyword()) ::
          {:ok, [t()]} | {:error, term()}
  def list(pair, address \\ nil, opts \\ [])

  def list(pair, address, opts) do
    case query_list(pair.address, address, opts) do
      {:ok, ranges} when is_list(ranges) ->
        Rujira.Enum.reduce_while_ok(ranges, [], &new(pair, &1))

      err ->
        err
    end
  end

  @spec load(Rujira.Fin.Pair.t(), integer()) :: {:ok, t()} | {:error, term()}
  def load(%{address: address} = pair, idx) do
    case query(address, idx) do
      {:ok, range} ->
        new(pair, range)

      {:error, %GRPC.RPCError{status: 2, message: "NotFound: query wasm contract failed"}} ->
        {:ok, placeholder(address, idx)}

      err ->
        err
    end
  end

  @spec list_all(String.t() | nil, [String.t()] | nil) :: {:ok, [t()]} | {:error, term()}
  def list_all(address \\ nil, contracts \\ nil)

  def list_all(address, nil) do
    with {:ok, pairs} <- Rujira.Fin.Pair.list() do
      collect(pairs, address)
    end
  end

  def list_all(address, contracts) when is_list(contracts) do
    with {:ok, pairs} <- Rujira.Enum.reduce_while_ok(contracts, [], &Rujira.Fin.Pair.get/1) do
      collect(pairs, address)
    end
  end

  @spec from_id(String.t()) :: {:ok, t()} | {:error, term()}
  def from_id(id) do
    with [pair_address, idx] <- String.split(id, "/"),
         {:ok, idx} <- Math.to_integer(idx),
         {:ok, pair} <- Rujira.Fin.Pair.get(pair_address) do
      load(pair, idx)
    else
      {:error, err} -> {:error, err}
      _ -> {:error, :invalid_id}
    end
  end

  @spec tvl(Rujira.Fin.Pair.t()) :: {:ok, non_neg_integer()} | {:error, term()}
  def tvl(pair) do
    case list(pair) do
      {:ok, ranges} -> {:ok, Enum.reduce(ranges, 0, fn r, acc -> acc + r.value_usd end)}
      {:error, _} = err -> err
    end
  end

  @spec total_tvl() :: {:ok, non_neg_integer()} | {:error, term()}
  def total_tvl do
    with {:ok, pairs} <- Rujira.Fin.Pair.list(),
         {:ok, tvls} <-
           Rujira.Enum.reduce_async_while_ok(
             pairs,
             fn pair ->
               case tvl(pair) do
                 {:ok, _} = ok -> ok
                 {:error, _} -> {:ok, 0}
               end
             end,
             timeout: 30_000
           ) do
      {:ok, Enum.sum(tvls)}
    end
  end

  # --- Private ---

  defp collect(pairs, address) do
    with {:ok, ranges} <-
           Rujira.Enum.reduce_async_while_ok(pairs, &list(&1, address), timeout: 15_000) do
      {:ok, List.flatten(ranges)}
    end
  end

  defmemo query_list(contract, address, opts, cursor \\ nil, limit \\ 30) do
    Contracts.query_state_smart(
      contract,
      %{ranges: %{owner: address, cursor: cursor, limit: limit}},
      opts
    )
    |> Contracts.paginate("ranges", limit, fn ranges ->
      query_list(contract, address, opts, List.last(ranges)["idx"], limit)
    end)
  end

  defmemop query(address, idx) do
    Contracts.query_state_smart(
      address,
      %{range: Kernel.to_string(idx)}
    )
  end
end