_Docs/
Get StartedModulesPlatformDeployCookbookChangelogReference
_Cookbook
  • Introduction
    • RideShare Tutorial
    • Omnibus Account Management
    • Payment Card Acceptance Processing
    • Card Issuing & Financial Host
    • Stablecoin On-Ramp & Off-Ramp Operations
    • Stablecoin Issuer
    • Neobank (FBO)
    • Marketplace Payouts
    • Crypto Custody
    • BNPL & Lending
  1. Examples
  2. Advanced Recipes
  3. Crypto Custody
Cookbook

Crypto Custody

This example shows how to implement multi-asset crypto custody in Formance using a declarative ledger schema. Each customer holds entitlements across several assets — USD held at FBO banks alongside crypto held in pooled custody — while the platform reconciles those entitlements against the wallet and on-chain balances that back them. Every deposit, withdrawal, and trade is tracked through its confirmation lifecycle so that what customers are owed never drifts from what the platform actually holds.

Common use cases:

  • Custodial exchanges holding customer fiat and crypto across multiple assets and networks
  • Brokerage platforms offering buy/sell against an OTC desk with a spread
  • Wallet providers managing pooled custody with on-chain reconciliation

This is an illustrative example. Adapt the schema to your specific business requirements, regulatory obligations, and financial practices.

Key Concepts#

  • Multi-Asset Entitlements — Each customer holds balances across several assets (USD, BTC, ETH, multiple USDC variants) under a single customers:$customerId namespace. Cash and crypto live in separate sub-trees, each split between funds that are spendable and funds still in flight.
  • Omnibus Custody vs. Per-Customer Entitlements — Customer crypto balances are entitlements: a record of what the platform owes each customer. The actual coins sit pooled in platform:custody accounts — per-network hot wallets and per-custodian omnibus wallets — shared across all customers. The chart deliberately separates the two so a customer's spendable balance is never confused with where the asset physically lives.
  • On-Chain Reconciliation — Because entitlements and custody are tracked separately, the platform can continuously reconcile the sum of customer crypto entitlements against the sum of hot-wallet and omnibus backing, per asset. A divergence means an unbooked deposit, an untracked withdrawal, or a custody discrepancy.
  • Deposit & Withdrawal Confirmation Latency — On-chain deposits are credited as confirming the moment they are observed, then promoted to available once they reach confirmation depth. Fiat deposits move through a pending cash window while the ACH or wire clears. Withdrawals are reserved into per-withdrawal accounts before they settle to chain.
  • In-Flight Tracking — Dedicated accounts isolate every asset that is between systems: cash:pending, crypto:confirming, per-withdrawal pending reserves, in-transit FBO balances, and per-trade conversion accounts. Each gives precise visibility into what is committed but not yet final.

The Complete Schema#

This is the full ledger schema for a multi-asset crypto custody system. The sections below explain each part.

│ ├─ pendingaccount
│ └─ availableaccount
│ ├─ confirmingaccount
│ └─ availableaccount
└─ pendingaccount
├─ settledaccount
└─ inTransitaccount
│ │ └─ {}$network
^[a-zA-Z0-9_-]+$
account
│ └─ omnibusaccount
│ └─ {}$network
^[a-zA-Z0-9_-]+$
account
│ └─ spreadaccount
│ └─ networkFeesaccount
└─ depositsaccount
└─ otcDeskaccount
└─ {}$conversionId
^[a-zA-Z0-9_-]+$
account
├─ achaccount
├─ wireaccount
└─ {}$address
^[a-zA-Z0-9_-]+$
account
Open in Studio

Edit this template in the Formance Studio editor.

Open in Studio

Chart of Accounts#

The chart section defines the customer-facing entitlements, the platform-side custody and operational accounts, and the external endpoints money flows to and from.

Customers#

│ ├─ pendingaccount
│ └─ availableaccount
│ ├─ confirmingaccount
│ └─ availableaccount
└─ pendingaccount

Normal credit accounts — they represent the platform's liabilities to customers. Each customer namespace carries a cash sub-tree (pending while a fiat deposit clears, available once spendable) and a crypto sub-tree (confirming while an on-chain deposit awaits confirmation depth, available once spendable). The withdrawals:$withdrawalId:pending accounts isolate each outbound crypto withdrawal as its own reserve, so reserved funds can't be spent twice and each withdrawal's lifecycle is tracked independently.

FBO#

├─ settledaccount
└─ inTransitaccount

Normal debit accounts — the for-benefit-of bank accounts that hold the platform's real fiat. The settled balance is reconciled against the bank's statement; inTransit represents the ACH/wire clearing window before funds settle.

Platform#

│ │ └─ {}$network
^[a-zA-Z0-9_-]+$
account
│ └─ omnibusaccount
│ └─ {}$network
^[a-zA-Z0-9_-]+$
account
│ └─ spreadaccount
│ └─ networkFeesaccount
└─ depositsaccount

Mixed nature accounts — the operational backbone. custody:hot:$network holds per-network hot-wallet backing and custody:$custodian:omnibus holds pooled custodian backing; together they back the crypto entitlements customers hold. treasury:gas:$network tracks gas balances per network, revenue:spread accrues the spread earned on trades, expense:networkFees absorbs on-chain fees, and suspense:deposits parks incoming funds not yet attributed to a customer.

Counterparties#

└─ otcDeskaccount

The otcDesk account represents the OTC desk that fills buy and sell orders — it goes negative under allowing unbounded overdraft while a trade is mid-flight and nets back as the legs settle.

Exchanges#

└─ {}$conversionId
^[a-zA-Z0-9_-]+$
account

Per-trade conversion accounts under conv:$conversionId hold the in-progress legs of a buy or sell while the OTC desk settles. Account metadata records the trade side, the customer, and the trade status. A non-zero balance after settlement is a stranded leg that needs a compensating entry.

External#

├─ achaccount
├─ wireaccount
└─ {}$address
^[a-zA-Z0-9_-]+$
account

The external endpoints money flows to and from: ach and wire for fiat rails, and $network:$address for on-chain destinations.

Fiat Deposits & Withdrawals#

Customer fiat moves through a pending window while the bank rail clears.

Deposit Initiate

FIAT_DEPOSIT_INITIATE — Customer USD arrives by ACH or wire and is credited as pending cash. The FBO inTransit account is allowed to overdraft, since the ledger entry is recorded before the bank statement confirms the funds.

FIAT_DEPOSIT_INITIATE
experimental
account interpolation
Customer USD arrives by ACH or wire and is credited as pending
vars {
  account $customer_id
  account $bank_id
  monetary $amount
  string $deposit_id
}

send $amount (
  source = @fbo:bank:$bank_id:inTransit allowing unbounded overdraft
  destination = @customers:$customer_id:cash:pending
)

set_tx_meta("event_type", "fiat_deposit_initiate")
set_tx_meta("deposit_id", $deposit_id)

Deposit Settle

FIAT_DEPOSIT_SETTLE — The ACH or wire clears at the FBO bank. The settled backing is recognized and the customer's pending cash becomes available.

FIAT_DEPOSIT_SETTLE
experimental
account interpolation
ACH or wire clears at the FBO bank; pending cash becomes available
vars {
  account $customer_id
  account $bank_id
  monetary $amount
  string $deposit_id
}

send $amount (
  source = @fbo:bank:$bank_id:settled allowing unbounded overdraft
  destination = @fbo:bank:$bank_id:inTransit
)

send $amount (
  source = @customers:$customer_id:cash:pending
  destination = @customers:$customer_id:cash:available
)

set_tx_meta("event_type", "fiat_deposit_settle")
set_tx_meta("deposit_id", $deposit_id)

Outbound fiat reserves the customer's available cash, then settles or returns:

Withdrawal Initiate

FIAT_WITHDRAWAL_INITIATE — Reserve customer USD for an outbound ACH or wire by moving it into the FBO inTransit account.

FIAT_WITHDRAWAL_INITIATE
experimental
account interpolation
Reserve customer USD for an outbound ACH or wire
vars {
  account $customer_id
  account $bank_id
  monetary $amount
  string $withdrawal_id
}

send $amount (
  source = @customers:$customer_id:cash:available
  destination = @fbo:bank:$bank_id:inTransit
)

set_tx_meta("event_type", "fiat_withdrawal_initiate")
set_tx_meta("withdrawal_id", $withdrawal_id)

Withdrawal Settle

FIAT_WITHDRAWAL_SETTLE — The outbound transfer clears the FBO bank; in-transit backing moves to settled.

FIAT_WITHDRAWAL_SETTLE
experimental
account interpolation
Outbound ACH or wire clears the FBO bank
vars {
  account $bank_id
  monetary $amount
  string $withdrawal_id
}

send $amount (
  source = @fbo:bank:$bank_id:inTransit
  destination = @fbo:bank:$bank_id:settled
)

set_tx_meta("event_type", "fiat_withdrawal_settle")
set_tx_meta("withdrawal_id", $withdrawal_id)

If the transfer is rejected, FIAT_WITHDRAWAL_RETURN sends the reserved funds back to the customer's available cash and flags the entry as an adjustment.

FIAT_WITHDRAWAL_RETURN
experimental
account interpolation
Outbound ACH or wire is returned; reserved cash goes back to available
vars {
  account $customer_id
  account $bank_id
  monetary $amount
  string $withdrawal_id
  string $original_posting_id
}

send $amount (
  source = @fbo:bank:$bank_id:inTransit
  destination = @customers:$customer_id:cash:available
)

set_tx_meta("event_type", "fiat_withdrawal_return")
set_tx_meta("withdrawal_id", $withdrawal_id)
set_tx_meta("adjustment_flag", "true")
set_tx_meta("adjusted_posting_event_id", $original_posting_id)

Crypto Deposits#

On-chain deposits are credited as soon as they are observed, then promoted once confirmed.

Deposit Detected

CRYPTO_DEPOSIT_DETECTED — An on-chain deposit is observed but has not yet reached confirmation depth. The custodian omnibus backing is drawn down (overdraft permitted) and the customer is credited as confirming.

CRYPTO_DEPOSIT_DETECTED
experimental
account interpolation
On-chain deposit observed but not yet confirmed; credited as confirming
vars {
  account $customer_id
  account $custodian
  monetary $amount
  string $deposit_id
}

send $amount (
  source = @platform:custody:$custodian:omnibus allowing unbounded overdraft
  destination = @customers:$customer_id:crypto:confirming
)

set_tx_meta("event_type", "crypto_deposit_detected")
set_tx_meta("deposit_id", $deposit_id)

Deposit Confirmed

CRYPTO_DEPOSIT_CONFIRMED — The deposit reaches confirmation depth. The customer's confirming balance becomes available and spendable.

CRYPTO_DEPOSIT_CONFIRMED
experimental
account interpolation
On-chain deposit reaches confirmation depth; confirming becomes available
vars {
  account $customer_id
  monetary $amount
  string $deposit_id
}

send $amount (
  source = @customers:$customer_id:crypto:confirming
  destination = @customers:$customer_id:crypto:available
)

set_tx_meta("event_type", "crypto_deposit_confirmed")
set_tx_meta("deposit_id", $deposit_id)

Block confirmations are asynchronous and can take seconds to minutes depending on the network. Crediting deposits as confirming gives customers immediate visibility while keeping unconfirmed funds out of the spendable balance.

Trading (Buy & Sell via OTC Desk)#

Trades route through a per-trade conversion account so each leg is isolated until the OTC desk settles. The platform earns a spread on every fill.

Buy Initiate

BUY_TRADE_INITIATE — Lock the customer's USD (gross of spread) into a per-trade conversion account. Metadata records the side, customer, and a pending status.

BUY_TRADE_INITIATE
experimental
account interpolation
Lock customer USD (gross of spread) into a per-trade conversion account
vars {
  account $customer_id
  account $conversion_id
  monetary $usd_gross
}

send $usd_gross (
  source = @customers:$customer_id:cash:available
  destination = @exchanges:conv:$conversion_id
)

set_account_meta(@exchanges:conv:$conversion_id, "trade_side", "buy")
set_account_meta(@exchanges:conv:$conversion_id, "customer", $customer_id)
set_account_meta(@exchanges:conv:$conversion_id, "status", "pending")
set_tx_meta("event_type", "buy_trade_initiate")
set_tx_meta("conversion_id", $conversion_id)

Buy Settle

BUY_TRADE_SETTLE — The OTC desk delivers crypto. The spread is booked to revenue:spread, the remaining USD goes to the desk, the customer is credited their crypto, and the custodian omnibus backing is replenished from the desk.

BUY_TRADE_SETTLE
experimental
account interpolation
OTC desk delivers crypto; spread booked; customer credited
vars {
  account $customer_id
  account $conversion_id
  account $custodian
  monetary $usd_gross
  monetary $spread
  monetary $crypto_amount
}

send $usd_gross (
  source = @exchanges:conv:$conversion_id
  destination = {
    max $spread to @platform:revenue:spread
    remaining to @counterparties:otcDesk
  }
)

send $crypto_amount (
  source = @counterparties:otcDesk allowing unbounded overdraft
  destination = @exchanges:conv:$conversion_id
)

send $crypto_amount (
  source = @exchanges:conv:$conversion_id
  destination = @customers:$customer_id:crypto:available
)

send $crypto_amount (
  source = @platform:custody:$custodian:omnibus allowing unbounded overdraft
  destination = @counterparties:otcDesk
)

set_account_meta(@exchanges:conv:$conversion_id, "status", "settled")
set_tx_meta("event_type", "buy_trade_settle")
set_tx_meta("conversion_id", $conversion_id)

The sell flow mirrors the buy flow, exchanging crypto for USD:

Sell Initiate

SELL_TRADE_INITIATE — Lock the customer's crypto into a per-trade conversion account.

SELL_TRADE_INITIATE
experimental
account interpolation
Lock customer crypto into a per-trade conversion account
vars {
  account $customer_id
  account $conversion_id
  monetary $crypto_amount
}

send $crypto_amount (
  source = @customers:$customer_id:crypto:available
  destination = @exchanges:conv:$conversion_id
)

set_account_meta(@exchanges:conv:$conversion_id, "trade_side", "sell")
set_account_meta(@exchanges:conv:$conversion_id, "customer", $customer_id)
set_account_meta(@exchanges:conv:$conversion_id, "status", "pending")
set_tx_meta("event_type", "sell_trade_initiate")
set_tx_meta("conversion_id", $conversion_id)

Sell Settle

SELL_TRADE_SETTLE — The OTC desk delivers USD. The crypto goes to the desk and replenishes omnibus backing, the spread is booked, and the remaining USD is credited to the customer's available cash.

SELL_TRADE_SETTLE
experimental
account interpolation
OTC desk delivers USD; spread booked; customer credited
vars {
  account $customer_id
  account $conversion_id
  account $custodian
  monetary $crypto_amount
  monetary $usd_gross
  monetary $spread
}

send $crypto_amount (
  source = @exchanges:conv:$conversion_id
  destination = @counterparties:otcDesk
)

send $crypto_amount (
  source = @counterparties:otcDesk allowing unbounded overdraft
  destination = @platform:custody:$custodian:omnibus
)

send $usd_gross (
  source = @counterparties:otcDesk allowing unbounded overdraft
  destination = @exchanges:conv:$conversion_id
)

send $usd_gross (
  source = @exchanges:conv:$conversion_id
  destination = {
    max $spread to @platform:revenue:spread
    remaining to @customers:$customer_id:cash:available
  }
)

set_account_meta(@exchanges:conv:$conversion_id, "status", "settled")
set_tx_meta("event_type", "sell_trade_settle")
set_tx_meta("conversion_id", $conversion_id)

If a conversion leg is left stranded — a settlement never completes — CONVERSION_COMPENSATE reverses the held balance back to the customer and flags the entry as an adjustment.

CONVERSION_COMPENSATE
experimental
account interpolation
Reverse a stranded conversion leg back to the customer
vars {
  account $conversion_id
  account $return_account
  monetary $amount
  string $original_posting_id
}

send $amount (
  source = @exchanges:conv:$conversion_id
  destination = $return_account
)

set_account_meta(@exchanges:conv:$conversion_id, "status", "compensated")
set_tx_meta("event_type", "conversion_compensate")
set_tx_meta("conversion_id", $conversion_id)
set_tx_meta("adjustment_flag", "true")
set_tx_meta("adjusted_posting_event_id", $original_posting_id)

Crypto Withdrawals#

Outbound crypto is reserved into a per-withdrawal account before it is sent on-chain.

Withdrawal Initiate

CRYPTO_WITHDRAWAL_INITIATE — Reserve the customer's crypto for an outbound on-chain withdrawal, moving it from available into a per-withdrawal pending reserve.

CRYPTO_WITHDRAWAL_INITIATE
experimental
account interpolation
Reserve customer crypto for an outbound on-chain withdrawal
vars {
  account $customer_id
  account $withdrawal_id
  monetary $amount
}

send $amount (
  source = @customers:$customer_id:crypto:available
  destination = @customers:$customer_id:withdrawals:$withdrawal_id:pending
)

set_tx_meta("event_type", "crypto_withdrawal_initiate")
set_tx_meta("withdrawal_id", $withdrawal_id)

Withdrawal Settle

CRYPTO_WITHDRAWAL_SETTLE — Send the crypto out of the per-network hot wallet to chain. The platform absorbs the network fee, booking it from expense:networkFees into the network's gas balance.

CRYPTO_WITHDRAWAL_SETTLE
experimental
account interpolation
Burn customer crypto from the hot wallet to chain; absorb network fee
vars {
  account $customer_id
  account $withdrawal_id
  account $network
  monetary $amount
  monetary $network_fee
}

send $amount (
  source = @customers:$customer_id:withdrawals:$withdrawal_id:pending
  destination = @platform:custody:hot:$network
)

send $network_fee (
  source = @platform:expense:networkFees allowing unbounded overdraft
  destination = @platform:treasury:gas:$network
)

set_tx_meta("event_type", "crypto_withdrawal_settle")
set_tx_meta("withdrawal_id", $withdrawal_id)

If a reserved withdrawal is abandoned before it settles, CRYPTO_WITHDRAWAL_CANCEL releases the reserve back to the customer's available crypto and flags the entry as an adjustment.

CRYPTO_WITHDRAWAL_CANCEL
experimental
account interpolation
Release a reserved crypto withdrawal back to available
vars {
  account $customer_id
  account $withdrawal_id
  monetary $amount
  string $original_posting_id
}

send $amount (
  source = @customers:$customer_id:withdrawals:$withdrawal_id:pending
  destination = @customers:$customer_id:crypto:available
)

set_tx_meta("event_type", "crypto_withdrawal_cancel")
set_tx_meta("withdrawal_id", $withdrawal_id)
set_tx_meta("adjustment_flag", "true")
set_tx_meta("adjusted_posting_event_id", $original_posting_id)

To keep the hot wallet funded for outbound withdrawals, CUSTODIAN_REFILL moves crypto from a custodian into the hot wallet on the same network.

CUSTODIAN_REFILL
experimental
account interpolation
Move crypto from a custodian to the hot wallet on the same network
vars {
  account $custodian
  account $network
  monetary $amount
  string $refill_id
}

send $amount (
  source = @platform:custody:hot:$network allowing unbounded overdraft
  destination = @platform:custody:$custodian:omnibus
)

set_tx_meta("event_type", "custodian_refill")
set_tx_meta("refill_id", $refill_id)

The Reconciliation Invariant#

Because customer entitlements and platform custody are tracked in separate account trees, the system maintains a continuous invariant: for every asset, the sum of all customer crypto entitlements must equal the sum of all platform backing (hot wallets plus custodian omnibus wallets).

Entitlements (customers::crypto:available) are what the platform owes customers; backing (platform:custody::omnibus and platform:custody:hot:) is what the platform actually holds. These two totals should match per asset at all times. A divergence points to an unbooked deposit, an untracked withdrawal, or a custody discrepancy — surface it before it compounds.

Queries#

The queries section defines reusable lookups for monitoring, reconciliation, and audit:

  • custody_vs_entitlement_parity — total customer crypto entitlement per asset, the entitlement side of the reconciliation check
  • hot_wallet_backing — backing held across all per-network hot wallets
  • per_customer_multi_asset_position — every account under one customer with its per-asset balance: the customer's full statement
  • total_customer_entitlement_per_asset — every customer's spendable cash and crypto summed per asset: the platform's total liability to customers
  • total_custody_backing_per_asset — all platform crypto backing across every custodian and hot wallet, summed per asset
  • crypto_reserved_for_pending_withdrawals — per-withdrawal reserves still holding a balance: crypto reserved but not yet settled or cancelled
  • funds_in_flight_pending_and_confirming — customer fiat in the clearing window and crypto awaiting confirmation: credited but not yet spendable
  • stuck_conversion_aging — per-trade conversion accounts with a non-zero balance: stranded legs needing a compensating entry
  • unattributed_deposit_suspense — incoming funds received but not yet attributed to a customer
  • daily_spread_revenue — volume into the spread revenue account over the daily window
  • daily_absorbed_network_fees — on-chain fees the platform absorbed over the daily window, read per native asset
  • per_customer_crypto_throughput — volume in and out of one customer's spendable crypto balance over a period
  • per_customer_transaction_audit — every transaction touching any account under one customer, for audit and dispute investigation
  • fbo_bank_statement_reconciliation — settled and in-transit USD backing at the FBO bank, matched against the bank's daily statement

These leverage the hierarchical account structure — filtering on customers::crypto:available matches across all customers, and platform:custody:hot: matches every per-network hot wallet.

Marketplace PayoutsBNPL & Lending
On This Page
  • Key Concepts
  • The Complete Schema
  • Chart of Accounts
  • Customers
  • FBO
  • Platform
  • Counterparties
  • Exchanges
  • External
  • Fiat Deposits & Withdrawals
  • Crypto Deposits
  • Trading (Buy & Sell via OTC Desk)
  • Crypto Withdrawals
  • The Reconciliation Invariant
  • Queries