DEV Community

KingdomCoder
KingdomCoder

Posted on

Everything You Need for Web3 Infrastructure Can Be Modelled in Elixir

When I set out to build a toy stablecoin orchestration platform — something that mirrors the kind of infrastructure Brale operates — I expected to spend most of my time fighting the language. Blockchain infrastructure feels inherently exotic: multiple chains with different finality guarantees, on-chain/off-chain coordination, financial correctness requirements, and fault isolation between networks that go down independently and unpredictably.

What I found instead was that Elixir already had the right primitive for every hard problem. Not approximately right — exactly right. This post is about three of those mappings, drawn from building StableMint: a working stablecoin issuance platform built on Elixir/Phoenix, the Ash Framework, and PostgreSQL.


The Problem Space

A stablecoin issuance platform has to do several things simultaneously that would be uncomfortable in most languages:

  • Support multiple blockchains, each with different address formats, transaction structures, and finality times
  • Process transactions asynchronously without blocking API callers
  • Maintain financial accounting that is provably correct — not "probably correct"
  • Isolate failures so that one chain going down doesn't take others with it

These aren't edge cases. They're the core requirements. Let's look at how Elixir handles each one.


1. Multi-Chain Abstraction: Behaviours Are Exactly This

The central challenge of multi-chain support is that every blockchain is different in ways that matter. Ethereum addresses start with 0x and are 40 hex characters. Solana addresses are 32–44 character base58 strings. Stellar addresses start with G and are 56 characters total. Ethereum takes ~12 seconds to reach finality. Solana takes ~1 second. Stellar, ~5 seconds.

You need to write code that handles all of these uniformly without turning into a massive case statement scattered across your codebase.

Elixir behaviours are a formal protocol for exactly this. You define the contract once:

defmodule StableMint.Chains.Adapter do
  @type tx_status :: :pending | :confirmed | :failed

  @callback mint(keyword()) :: {:ok, tx_hash()} | {:error, term()}
  @callback burn(keyword()) :: {:ok, tx_hash()} | {:error, term()}
  @callback transfer(keyword()) :: {:ok, tx_hash()} | {:error, term()}
  @callback get_transaction_status(tx_hash()) :: {:ok, tx_status()} | {:error, term()}
  @callback validate_address(address()) :: :ok | {:error, String.t()}
  @callback finality_time() :: pos_integer()
  @callback chain_name() :: String.t()
end
Enter fullscreen mode Exit fullscreen mode

Then each chain implements it:

defmodule StableMint.Chains.EthereumAdapter do
  @behaviour StableMint.Chains.Adapter

  @impl true
  def validate_address(<<"0x", rest::binary>>) when byte_size(rest) == 40, do: :ok
  def validate_address(_), do: {:error, "Invalid Ethereum address: must be 0x + 40 hex chars"}

  @impl true
  def finality_time, do: 12

  # ...
end

defmodule StableMint.Chains.SolanaAdapter do
  @behaviour StableMint.Chains.Adapter

  @impl true
  def validate_address(address) when is_binary(address) and byte_size(address) in 32..44, do: :ok
  def validate_address(_), do: {:error, "Invalid Solana address: must be 32-44 chars (base58)"}

  @impl true
  def finality_time, do: 1

  # ...
end
Enter fullscreen mode Exit fullscreen mode

The finality_time/0 callback is the detail I find most interesting here. It's not configuration — it's a behavioral contract. Each chain declares how long it takes to reach finality as part of implementing the behaviour. The GenServer that polls for confirmations uses this value to schedule its next check:

defp schedule_check(seconds), do: Process.send_after(self(), :check_confirmations, seconds * 1_000)
Enter fullscreen mode Exit fullscreen mode

Adding a new chain means implementing 8 callbacks and registering the module in one place. The rest of the system — the GenServer, the supervisor, the dispatch logic, the API surface — doesn't change. In a platform like Brale that supports 22+ chains, this property isn't nice to have. It's the architecture.


2. Financial Correctness: The Double-Entry Ledger

Financial systems have a 700-year-old solution to the correctness problem: double-entry bookkeeping. Every transaction produces exactly two entries — a debit and a credit — for the same amount. The global invariant is simple: total debits must always equal total credits. If they don't, something has gone wrong.

In StableMint, every transfer (mint, burn, or movement) goes through RecordLedgerEntries, an Ash change that runs after the transfer is created:

def change(changeset, opts, _context) do
  Ash.Changeset.after_action(changeset, fn _changeset, transfer ->
    with {:ok, debit_account_id, credit_account_id} <- resolve_accounts(transfer, opts[:direction]),
         {:ok, _} <- create_entry(transfer, debit_account_id, :debit),
         {:ok, _} <- create_entry(transfer, credit_account_id, :credit) do
      {:ok, transfer}
    end
  end)
end
Enter fullscreen mode Exit fullscreen mode

The direction of the entries depends on the transfer type:

  • Mint: debit the reserve (representing a new obligation), credit the recipient
  • Burn: debit the source (tokens surrendered), credit the reserve (obligation reduced)
  • Transfer: debit the source, credit the destination

The reserve account is a sentinel UUID (00000000-0000-0000-0000-000000000000). When you mint, the reserve's balance goes negative — intentionally. A negative reserve balance represents the total tokens in circulation: the system's outstanding obligation to back them with real assets.

Each entry carries a balance_after field computed from the previous entry for that account:

defp create_entry(transfer, account_id, entry_type) do
  balance = current_balance(account_id, transfer.currency)

  balance_after =
    case entry_type do
      :debit -> Decimal.sub(balance, transfer.amount)
      :credit -> Decimal.add(balance, transfer.amount)
    end

  # create the entry with this balance_after
end
Enter fullscreen mode Exit fullscreen mode

This creates a sequential chain of balances. Reading an account's current balance is a single row lookup — no summing the entire history.

The global invariant is verified by an audit action that runs a single SQL query across the entire ledger:

action :audit, :map do
  run fn _input, _context ->
    result = Repo.one(
      from e in "ledger_entries",
        select: %{
          total_debits: sum(fragment("CASE WHEN ? = 'debit' THEN ? ELSE 0 END",
                                     e.entry_type, e.amount)),
          total_credits: sum(fragment("CASE WHEN ? = 'credit' THEN ? ELSE 0 END",
                                      e.entry_type, e.amount))
        }
    )

    balanced = Decimal.eq?(result.total_debits, result.total_credits)
    {:ok, %{balanced: balanced, ...}}
  end
end
Enter fullscreen mode Exit fullscreen mode

If balanced is ever false, the ledger is inconsistent. This is the financial equivalent of a checksum — a single query that proves the entire system is correct.

What makes Elixir particularly well-suited here is that Ash changes compose cleanly with database transactions. The ledger entries are written inside the same transaction as the transfer record. Either both commit or neither does. There's no window where a transfer exists without its ledger entries, or where ledger entries exist without a transfer.


3. Fault Isolation: OTP Supervision Maps Directly to Chain Independence

Here's the operational reality of running multi-chain infrastructure: chains go down. RPC providers have outages. Networks halt for upgrades. Transactions get stuck. This is not exceptional — it's routine. When it happens, it must be contained. One chain's problems cannot cascade to others.

OTP supervision trees are designed for exactly this failure model.

In StableMint, each chain runs as its own GenServer under a one_for_one supervisor:

StableMint.Supervisor (one_for_one)
├── Repo
├── PubSub
├── ProcessorRegistry
├── Chains.Supervisor (one_for_one)
│   ├── Processor("ethereum")   ← GenServer, polls every 12s
│   ├── Processor("solana")     ← GenServer, polls every 1s
│   └── Processor("stellar")    ← GenServer, polls every 5s
└── Endpoint
Enter fullscreen mode Exit fullscreen mode

The supervisor strategy is one_for_one: if the Ethereum GenServer crashes, only the Ethereum GenServer restarts. Solana and Stellar continue processing without interruption. This is the correct semantics for chain independence — a direct mapping from an operational requirement to a supervision strategy.

The Processor GenServer holds its state — pending transactions awaiting confirmation — in its process memory:

defstruct [:chain, :adapter, :pending_txs]
Enter fullscreen mode Exit fullscreen mode

When a transfer is dispatched to the chain, it's tracked in pending_txs. The GenServer polls for confirmations at each chain's finality_time interval and updates the transfer status accordingly:

def handle_info(:check_confirmations, state) do
  new_pending =
    Enum.reduce(state.pending_txs, state.pending_txs, fn {tx_hash, transfer_id}, acc ->
      case state.adapter.get_transaction_status(tx_hash) do
        {:ok, :confirmed} ->
          # finalize the transfer, remove from pending
          Map.delete(acc, tx_hash)
        {:ok, :failed} ->
          # mark as failed, remove from pending
          Map.delete(acc, tx_hash)
        {:ok, :pending} ->
          acc  # keep waiting
      end
    end)

  schedule_check(state.adapter.finality_time())
  {:noreply, %{state | pending_txs: new_pending}}
end
Enter fullscreen mode Exit fullscreen mode

One subtle but important detail: the chain dispatch happens via after_transaction, not after_action:

defmodule StableMint.Changes.DispatchToChain do
  def change(changeset, _opts, _context) do
    Ash.Changeset.after_transaction(changeset, fn
      _changeset, {:ok, transfer} ->
        StableMint.Chains.Processor.submit(chain, transfer.id)
        {:ok, transfer}
      _changeset, {:error, error} ->
        {:error, error}
    end)
  end
end
Enter fullscreen mode Exit fullscreen mode

after_action runs inside the database transaction. after_transaction runs after it commits. This matters: you don't want to notify the chain GenServer about a transfer that might still be rolled back. By using after_transaction, the GenServer only sees transfers that are durably committed to the database.

The GenServer receives the dispatch as a cast — asynchronous, non-blocking. The API caller gets their response immediately with a pending transfer. The chain submission and confirmation polling happen independently, in the background, at each chain's own pace.


The Pattern

Looking across these three examples, a pattern emerges. Elixir doesn't just handle these problems — its primitives map to the domain concepts almost 1:1:

Domain Requirement Elixir Primitive
Uniform interface across heterogeneous chains Behaviours
Chain-specific timing as a contract finality_time/0 callback
Isolated failure domains per chain one_for_one supervision
Async chain processing without blocking callers GenServer + cast
Atomic ledger writes Ecto transactions + Ash changes
Sequential balance history Append-only ledger entries

This isn't coincidence. Elixir was built for telecommunications — a domain that shares the same core requirements as blockchain infrastructure: heterogeneous systems, independent failure domains, high concurrency, and correctness guarantees. The problems are different but the shape of the solutions is the same.


What's Next

StableMint uses mock chain adapters — they return simulated tx hashes and randomized confirmation statuses. In a real system, each adapter would call actual chain SDKs: ethereumex for Ethereum JSON-RPC, Solana's web3 client, Stellar's Horizon API. The behaviour interface wouldn't change; only the implementations become real.

At higher throughput, the GenServer-per-chain model gives way to Broadway pipelines with configurable concurrency and back-pressure. Each chain becomes a pipeline with chain-specific rate limiting. But the supervision structure — one isolated process tree per chain — stays the same.

The ledger's double-entry invariant extends naturally to cross-chain bridges: a burn on the source chain and a mint on the destination, each producing balanced entries independently, coordinated by a parent bridge record that tracks the two-phase state machine.

The point isn't that StableMint is production-ready. It's that the architecture holds at scale. The same primitives — behaviours, supervision trees, Ash changes, Ecto transactions — compose into production infrastructure without fundamental redesign. That's what makes Elixir unusual for this domain. You're not fighting the language to model the problem. The language already speaks the problem's vocabulary.


StableMint source is on GitHub. The live demo is at stablemint.josboxoffice.com. Inspired by the architecture of Brale.

Top comments (0)