Overview
Rujira is a domain library for querying and parsing blockchain protocol data. It provides:
- Protocol modules — query APIs for each DeFi protocol (FIN, Bow, etc.)
- Event parsing — transforms raw chain events into typed structs
- Asset resolution — maps chain denominations to canonical asset types
- Contract queries — CosmWasm smart contract interaction via gRPC
Protocol Module Structure
Each protocol follows the same shape. Resource modules are self-contained: struct + construction + queries. The protocol module is a pure defdelegate facade with zero logic.
Module responsibilities
| Module | Owns |
|---|---|
Protocol.Resource | Struct, new, get, list, load, all queries (including cross-resource), use Memoize |
Protocol | Public API: only defdelegate — no function bodies, no logic |
Protocol.Events | Event parsing pipeline |
A function belongs on the module that owns the return type. list_all_orders/1 returns [Order.t()] → lives on Order, even if it calls Pair.list() internally.
Resource module template
defmodule Rujira.Protocol.Resource do
@moduledoc "Resource for Protocol. Use `Rujira.Protocol` as the public API."
use Memoize
# --- Struct ---
defstruct id: nil,
name: nil,
items: [],
value: 0,
price: Decimal.new(0)
@type t :: %__MODULE__{...}
# --- Construction ---
@spec new(map()) :: {:ok, t()} | {:error, term()}
def new(...), do: ...
# --- Queries ---
@spec get(String.t()) :: {:ok, t()} | {:error, term()}
def get(address), do: Contracts.get({__MODULE__, address})
@spec list() :: {:ok, [t()]} | {:error, term()}
defmemo list, do: ...
# --- Private queries ---
defmemo query(...), do: Contracts.query_state_smart(...)
endFacade module template
defmodule Rujira.Protocol do
@moduledoc "Public API for Protocol. Pure delegation — no function bodies."
defdelegate get_resource(address), to: Resource, as: :get
defdelegate list_resources(), to: Resource, as: :list
defdelegate list_all_resources(address), to: Resource, as: :list_all
endCache invalidation
Cache lives on the resource module that defines defmemo. Invalidate the module that owns the data — never the facade.
Memoize.invalidate(Rujira.Fin.Pair, :list, [])
Memoize.invalidate(Rujira.Fin.Pair, :find_by_denoms, ["rune", "tcy"])
Memoize.invalidate(Rujira.Fin.Book, :query, ["thor1pair", 100])
Memoize.invalidate(Rujira.Fin.Order, :query_list, ["thor1pair", "thor1user", 30])The facade uses defdelegate — it does not cache.
File structure per protocol
lib/rujira/protocol/
├── resource.ex # Struct + construction + queries
├── events.ex # Event parser
└── events/
├── event.ex # Envelope
└── action.ex # Sub-event: new(map()) → {:ok, t()}
lib/rujira/protocol.ex # Public API: defdelegate onlyData Construction
Struct modules are pure data constructors. Always pass maps or structs for pattern matching — never positional fields.
| Constructor | Input | Returns | Use |
|---|---|---|---|
new/1 | map() (string keys) | {:ok, t()} | {:error, term()} | Top-level struct from chain config |
new/2 | (parent_struct, map()) | {:ok, t()} | {:error, term()} | Child struct from parent context + chain query |
new/N | explicit typed args | bare struct | Infallible placeholder |
Rules
new/1for top-level structs: receives a plainmap()(string keys)new/2for child structs: receives parent struct + raw query map- Required fields: pattern match in function head
- Optional fields:
Map.get/2 - Use
Amount.new/1for amounts,Math.to_decimal/1for prices,Math.to_integer/1for ints
Contracts dispatch
Rujira.Contracts.get/1 is the single entry point for live contracts:
config |> Map.put("address", address) |> module.new()If the contract does not exist on chain, the error tuple from query_state_smart/2 is returned as-is. The on-chain contract registry lives in Rujira.Deployments (live Thorchain.Types.Query.Stub.contract_infos/2 resolver) — protocol → module mapping is configured via config :rujira_ex, :protocol_modules.
Event Pipeline
Data flows in one direction — each layer transforms forward, never backward.
raw input → Events.cast/1 → Events.parse/1 → route/1 → Protocol.parse/1 → {:ok, envelope}| Layer | Input | Output | Responsibility |
|---|---|---|---|
Events.cast/1 | BlockEvent proto | %{type, attributes} | Normalize raw input |
Events.parse/1 | %{type, attributes} | {:ok, envelope} | Create %Event{}, route to protocol |
Events.route/1 (private) | %Event{} | {:ok, envelope} | Dispatch by type prefix |
Protocol.Events.parse/1 | %Event{} | {:ok, %ProtocolEvent{}} | Extract fields, match action, delegate to sub-event, wrap |
SubEvent.new/1 | map() (attrs) | {:ok, struct()} | Pure data constructor — decoupled from pipeline |
Consumer API
case Rujira.Events.parse(raw) do
{:ok, %Rujira.Fin.Events.Event{} = e} -> handle_fin(e)
{:ok, %Rujira.Fin.Events.Event{data: %Trade{side: :base}}} -> ...
{:ok, %Rujira.Thorchain.Events.Event{} = e} -> handle_tc(e)
{:ok, %Rujira.Events.Event{}} -> handle_unknown(e)
endAdding a New Protocol
Follow this exact structure. Example: adding Bow protocol.
1. Create the envelope struct
lib/rujira/bow/events/event.ex:
defmodule Rujira.Bow.Events.Event do
defstruct address: nil, data: nil
@type t :: %__MODULE__{address: String.t() | nil, data: struct()}
@spec new(String.t() | nil, struct()) :: t()
def new(address, data), do: %__MODULE__{address: address, data: data}
end- Wasm protocols include
address(from_contract_addressin attrs) - Native chain protocols use
defstruct [:data]only
2. Create sub-event structs
lib/rujira/bow/events/swap.ex:
defmodule Rujira.Bow.Events.Swap do
defstruct pool: nil, offer: nil, return: nil
@type t :: %__MODULE__{pool: String.t(), offer: Amount.t() | nil, return: Amount.t() | nil}
@spec new(map()) :: {:ok, t()} | {:error, term()}
def new(%{"pool" => pool} = attrs) do
with {:ok, offer} <- Amount.new(Map.get(attrs, "offer")),
{:ok, return_amt} <- Amount.new(Map.get(attrs, "return")) do
{:ok, %__MODULE__{pool: pool, offer: offer, return: return_amt}}
end
end
def new(_), do: {:error, :invalid_attrs}
endRules:
new/1receives a plainmap()— never%Event{}Returns
{:ok, struct()} | {:error, term()}- Pattern match required keys in the function head; use
Map.get/2for optional - Always end with a
def new(_), do: {:error, :invalid_attrs}fallback — the routing parser already handles{:error, _} - No alias to
Rujira.Events.Event— sub-events are decoupled
3. Create the protocol parser
lib/rujira/bow/events.ex:
defmodule Rujira.Bow.Events do
alias Rujira.Events.Event
alias Rujira.Bow.Events.Event, as: BowEvent
alias Rujira.Bow.Events.Swap
@spec parse(Event.t()) :: {:ok, BowEvent.t()} | {:error, term()}
def parse(%Event{
type: "wasm-rujira-bow/" <> action,
attributes: %{"_contract_address" => address} = attrs
} = event) do
case new(action, attrs) do
{:ok, data} -> {:ok, BowEvent.new(address, data)}
{:error, _} = err -> err
:pass -> {:ok, BowEvent.new(address, event)}
end
end
def parse(%Event{} = event), do: {:ok, BowEvent.new(nil, event)}
defp new("swap", attrs), do: Swap.new(attrs)
defp new(_, _), do: :pass
end4. Register the route
In lib/rujira/events.ex, add above the catch-all:
defp route(%Event{type: "wasm-rujira-bow/" <> _} = event),
do: Rujira.Bow.Events.parse(event)5. Create the resource module
lib/rujira/bow/pool.ex — struct + new/1 + get/list/load queries (see Resource module template above).
6. Create the facade
lib/rujira/bow.ex — pure defdelegate to Pool (see Facade module template above).
7. Add tests
test/rujira/bow/events_test.exs— test each sub-event viaProtocol.Events.parse/1test/rujira/events_test.exs— add routing tests- Assert envelope shape:
{:ok, %BowEvent{address: "...", data: %Swap{...}}}