Greeting
Welcome, stranger!
This is the Dango Book, all you need to know about the one app for everything DeFi.
Security Audit Guide
This guide documents the architecture of Dango – both the blockchain state machine and the smart contract system built on top of it, targeting security auditors with blockchain and DeFi experience. It covers:
- Architecture – Database, Jellyfish Merkle Tree, storage layer, the App/ABCI interface, virtual machines, and gas metering.
- Smart Contract Semantics – Entry points, context types, message passing, storage abstractions, authentication model, and the testing framework.
- Dango Contract System – Each smart contract (bank, accounts, oracle, perps, gateway, etc.), their state layout, access control, and inter-contract interactions.
- Indexer & Node – The indexer pipeline, SQL schema, GraphQL API, and the CLI that wires everything together.
Repository layout
| Directory | Contents |
|---|---|
dango/core/ | State machine: app, db/disk, db/memory, vm/rust, vm/wasm, types, storage, jellyfish-merkle, ffi, macros, crypto, math, std, testing |
dango/ | Smart contracts: bank, account, account-factory, auth, oracle, perps, gateway, vesting, warp, upgrade, types, cli |
dango/indexer/ | Indexing: hooked, sql, sql-migration, cache, httpd, client |
ui/ | TypeScript frontend (out of scope for this guide) |
deploy/ | Ansible playbooks (out of scope) |
Trust model at a glance
┌──────────────────────────────────────────────────────────────┐
│ TRUSTED: Node Binary │
│ dango/core/app (ABCI + state transitions) │
│ dango/core/db (RocksDB persistence) │
│ dango/core/vm/rust (native contract execution, no sandbox) │
│ dango/core/jellyfish-merkle (state commitment) │
│ dango/* system contracts (bank, accounts, etc.) │
│ dango/indexer/* (read-only; cannot affect consensus) │
└────────────────────── ▼ ─────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ UNTRUSTED: Third-Party WASM Contracts │
│ Executed inside dango/core/vm/wasm (Wasmer sandbox) │
│ All storage access namespaced via StorageProvider │
│ All operations metered via gas tracker │
│ Host function calls go through Gatekeeper middleware │
└──────────────────────────────────────────────────────────────┘
Note: In the current Dango deployment, all contracts are first-party and executed natively via
RustVm. TheWasmVmpath exists for future third-party contract support. Both paths share the sameVmtrait interface.
Architecture
Dango is a custom blockchain state machine that runs on top of CometBFT consensus. It is inspired by CosmWasm but differs in several key ways: native Rust contract execution, account abstraction at the protocol level, a dual-storage model (ADR-065 style), and simplified gas metering.
1. Database Layer
Dango separates storage into two independent stores following the Cosmos SDK ADR-065 pattern:
- State Storage (SS): Flat key-value store for raw, prehashed data. This is what contracts read and write.
- State Commitment (SC): Merkle-tree-backed store for cryptographic state proofs. Keys and values are hashed before insertion.
Both stores are backed by a single RocksDB instance using separate column families
(dango/core/db/disk/src/db.rs):
| Column Family | Purpose |
|---|---|
default | Metadata (latest committed version) |
state_commitment | JMT nodes (hashed key-value pairs) |
state_storage | Chain-level state (non-contract keys) |
wasm_storage | Contract internal storage (see below) |
state_storage and wasm_storage together form the logical “state storage” layer.
They share the same Batch of pending writes; the DB routes each key to the correct
CF based on its prefix:
#![allow(unused)]
fn main() {
// dango/core/db/disk/src/db.rs
fn is_wasm_key(key: &[u8]) -> bool {
key.starts_with(CONTRACT_NAMESPACE) && key.len() >= WASM_PREFIX_LEN
}
}
A contract key has the format b"wasm" | address (20 bytes) | sub_key, giving a
fixed 24-byte prefix (WASM_PREFIX_LEN). Everything else goes to state_storage.
The two CFs exist so that each can have specialized RocksDB options:
| Option | wasm_storage | state_storage |
|---|---|---|
| Memtable size | 16 MiB (fewer flushes; contracts are less delete-heavy) | 2 MiB (frequent flushes; chain state is delete-heavy from cronjobs) |
| Prefix extractor | 24 bytes (b"wasm" + 20-byte address) | 4 bytes (namespace length) |
Both CFs share a common base configuration: 256 MiB LRU block cache, bloom filters (10 bits/key), L0 filter/index pinning, and level-style compaction.
During iteration, the DB detects whether the scan range falls entirely within the
wasm range, entirely outside it, or spans both. In the spanning case, it creates a
merged iterator over both CFs, preserving key ordering. When min/max share the
same 24-byte contract prefix, RocksDB’s prefix_same_as_start mode is enabled for
faster prefix-scoped iteration.
DiskDb
#![allow(unused)]
fn main() {
// dango/core/db/disk/src/db.rs
pub struct DiskDb<T> {
data: Arc<RwLock<Data>>, // RocksDB handle + priority data
pending: Arc<RwLock<Option<PendingData>>>, // Staged but uncommitted writes
_commitment: PhantomData<T>, // MerkleTree or SimpleCommitment
}
}
Key properties:
- Two-phase commit.
flush_but_not_commit()stages a write batch in memory asPendingDataand returns the new version + root hash.commit()atomically persists the staged batch to RocksDB. If the node crashes between these two calls, all changes are discarded on restart. - Versioning. Each committed batch increments a monotonic version counter. The
version must match the expected block height – the
IncorrectVersionerror prevents out-of-order mutations. - Pruning. Old versions can be pruned via
prune(up_to_version)to reclaim disk space. Pruned versions can no longer produce Merkle proofs.
MemDb (testing)
#![allow(unused)]
fn main() {
// dango/core/db/memory/src/db.rs
pub struct MemDb<T = SimpleCommitment> {
inner: Shared<MemDbInner>,
_commitment: PhantomData<T>,
}
}
An in-memory implementation using BTreeMaps. Only maintains the latest version.
Supports snapshot/recovery via dump() and recover() for mainnet forking in tests.
Db trait
#![allow(unused)]
fn main() {
// dango/core/app/src/traits/db.rs
pub trait Db {
type StateStorage: Storage + Clone + 'static;
type StateCommitment: Storage + Clone + 'static;
type Proof: BorshSerialize + BorshDeserialize;
fn state_commitment(&self) -> Self::StateCommitment;
fn state_storage_with_comment(&self, version: Option<u64>, comment: &'static str)
-> Result<Self::StateStorage, Self::Error>;
fn latest_version(&self) -> Option<u64>;
fn root_hash(&self, version: Option<u64>) -> Result<Option<Hash256>, Self::Error>;
fn prove(&self, key: &[u8], version: Option<u64>) -> Result<Self::Proof, Self::Error>;
fn flush_but_not_commit(&self, batch: Batch) -> Result<(u64, Option<Hash256>), Self::Error>;
fn commit(&self) -> Result<u64, Self::Error>;
fn prune(&self, up_to_version: u64) -> Result<(), Self::Error>;
}
}
2. Jellyfish Merkle Tree (JMT)
State commitment uses a binary Jellyfish Merkle Tree adapted from Diem
(dango/core/jellyfish-merkle/). The tree provides:
- Cryptographic state root (SHA-256) included in the ABCI
app_hashsigned by validators. - Membership proofs (a key exists with a given value) and non-membership proofs (a key does not exist).
- Versioned nodes enabling proofs at historical heights.
Node types
#![allow(unused)]
fn main() {
// Internal node: branches left and right
pub struct InternalNode {
left_hash: Option<Hash256>,
right_hash: Option<Hash256>,
}
// Leaf node: actual key-value entry
pub struct LeafNode {
key_hash: Hash256,
value_hash: Hash256,
}
}
Apply algorithm
- Receive a
Batchof prehash key-value operations. - Hash all keys and values with SHA-256.
- Sort by key hash.
- Recursively update tree nodes (only changed paths are rewritten).
- Record orphaned nodes for future pruning.
- Return new root hash.
Proof verification
#![allow(unused)]
fn main() {
// dango/core/jellyfish-merkle/src/proof.rs
pub fn verify_membership_proof(
root_hash: Hash256,
key_hash: Hash256,
value_hash: Hash256,
proof: &MembershipProof, // Vec of sibling hashes along the path
) -> Result<(), ProofError>;
pub fn verify_non_membership_proof(
root_hash: Hash256,
key_hash: Hash256,
proof: &NonMembershipProof,
) -> Result<(), ProofError>;
}
Commitment trait
#![allow(unused)]
fn main() {
// dango/core/app/src/traits/commitment.rs
pub trait Commitment {
type Proof;
fn root_hash(storage: &dyn Storage, version: u64) -> StdResult<Option<Hash256>>;
fn apply(storage: &mut dyn Storage, old_version: u64, new_version: u64, batch: &Batch)
-> StdResult<Option<Hash256>>;
fn prove(storage: &dyn Storage, key_hash: Hash256, version: u64) -> StdResult<Self::Proof>;
fn prune(storage: &mut dyn Storage, up_to_version: u64) -> StdResult<()>;
}
}
Two implementations: MerkleTree (production, full JMT) and SimpleCommitment
(testing fallback, SHA-256 of batch).
3. Storage Layer
dango/core/storage/ provides type-safe, namespace-aware abstractions over raw key-value
storage.
Abstractions
| Type | Purpose | Key file |
|---|---|---|
Item<T> | Single value | storage/src/item.rs |
Map<K, T> | Key-value mapping with iteration | storage/src/map.rs |
Set<K> | Membership set | storage/src/set.rs |
Counter<T> | Monotonic counter | storage/src/counter.rs |
IndexedMap<K, T, I> | Map with secondary indexes | storage/src/index/map.rs |
Usage example:
#![allow(unused)]
fn main() {
const CONFIG: Item<Config> = Item::new("config");
const BALANCES: Map<Addr, Uint128> = Map::new("balances");
const ADMINS: Set<Addr> = Set::new("admins");
}
Key encoding
Keys implement the PrimaryKey trait which serializes composite keys with length
delimiters for unambiguous parsing. Tuple keys like (Addr, u64) are encoded as
[len(Addr) | Addr bytes | u64 bytes]. Values are serialized with Borsh by default.
Contract storage isolation
Each contract’s storage is wrapped in a StorageProvider (dango/core/app/src/providers/storage.rs):
#![allow(unused)]
fn main() {
pub struct StorageProvider {
storage: Box<dyn Storage>,
namespace: Vec<u8>, // "wasm" + contract_address
}
}
Every read, write, scan, and remove operation is automatically prefixed with the
contract’s namespace. Scans are bounded to [namespace, namespace_increment).
Security guarantee: A contract cannot access another contract’s storage through
any combination of key manipulation. The StorageProvider is opaque to contract code.
4. The App (ABCI Interface)
The App struct (dango/core/app/src/app.rs) is the state machine’s entry point. It
connects the database, VM, indexer, and proposal preparer:
#![allow(unused)]
fn main() {
pub struct App<DB, VM, PP = NaiveProposalPreparer, ID = NullIndexer> {
pub db: DB,
vm: VM,
pp: PP,
pub indexer: ID,
query_gas_limit: u64,
upgrade_handler: Option<UpgradeHandler<VM>>,
cargo_version: String,
}
}
ABCI lifecycle
CometBFT drives the state machine through these ABCI methods:
InitChain → [PrepareProposal → CheckTx* → FinalizeBlock → Commit]*
InitChain
Initializes genesis state: stores the chain config, deploys system contracts, executes genesis messages. The first version is 0.
CheckTx
Lightweight mempool validation. Only runs:
- Fee withholding – Can the sender afford the gas fee?
sender.authenticate()– Is the credential (signature, nonce) valid?
State changes from CheckTx are discarded. A failing CheckTx causes the transaction to be rejected from the mempool.
FinalizeBlock
Full transaction processing:
- Upgrade check. If the current height matches a scheduled upgrade and the binary version matches, run the upgrade handler. If the version mismatches, halt the chain intentionally.
- Process transactions (see Transaction lifecycle):
- Withhold the gas fee (
gas_limit * gas_fee_rateofgas_token, credited to the owner) – must succeed. Senders ingas_exemptionspay nothing. sender.authenticate()– If fails, the withheld fee is still charged.- Execute messages one-by-one, atomically.
- Commit the withheld fee. There is no refund of unused gas.
- Withhold the gas fee (
- Run cronjobs. Each scheduled cronjob runs in an isolated buffer; failures are silently discarded.
- Clean up orphaned codes. Codes not referenced by any contract and older than
max_orphan_ageare removed. - Flush.
db.flush_but_not_commit(batch)– stages all changes, computes root hash, but does not persist to disk yet. - Index. The indexer receives the block and outcomes.
Commit
db.commit() atomically persists the staged changes to RocksDB. If this fails, the
chain panics (conservative: prevents state corruption).
Buffer pattern (rollback)
State changes are accumulated in nested Buffer<S> layers:
#![allow(unused)]
fn main() {
// dango/core/types/src/buffer.rs
pub struct Buffer<S> {
base: S,
pending: Batch, // BTreeMap<Vec<u8>, Op<Vec<u8>>>
}
}
- Block-level buffer: Wraps the DB’s state storage.
- Transaction-level buffer: Wraps the block buffer. On tx success, merged up; on failure, discarded.
- Submessage buffer: Each submessage gets its own buffer for granular rollback.
Reads check pending first (most recent write wins), then fall through to base.
Gas metering
#![allow(unused)]
fn main() {
// dango/core/app/src/gas/tracker.rs
pub struct GasTracker {
inner: Shared<GasTrackerInner>, // Shared<T> = Arc<RwLock<T>>
}
struct GasTrackerInner {
limit: Option<u64>, // None = unlimited (genesis, cronjobs)
used: u64,
}
}
Gas is consumed on every operation. Exceeding the limit returns StdError::OutOfGas
and aborts execution (state changes discarded, fee still collected).
Gas costs (dango/core/app/src/gas/costs.rs):
| Operation | Cost |
|---|---|
db_read | 588 + 2/byte |
db_write | 1176 + 18/byte |
db_scan (setup) | 588 |
db_next (per iteration) | 18 |
secp256k1_verify | 770,000 |
secp256r1_verify | 1,880,000 |
| Hash functions | 0 base + 5–28/byte (varies) |
| Wasmer operation | 1 gas/op |
See Gas for benchmark methodology.
5. Virtual Machine Layer
Two VM implementations share the same trait:
#![allow(unused)]
fn main() {
// dango/core/app/src/traits/vm.rs
pub trait Vm: Sized {
type Instance: Instance;
fn build_instance(
&mut self,
code: &[u8],
code_hash: Hash256,
storage: StorageProvider,
state_mutable: bool,
querier: Box<dyn QuerierProvider>,
query_depth: usize,
gas_tracker: GasTracker,
) -> Result<Self::Instance, Self::Error>;
}
pub trait Instance {
fn call_in_0_out_1(self, name: &'static str, ctx: &Context) -> Result<Vec<u8>>;
fn call_in_1_out_1<P>(self, name: &'static str, ctx: &Context, param: &P) -> Result<Vec<u8>>;
fn call_in_2_out_1<P1, P2>(self, name: &'static str, ctx: &Context, p1: &P1, p2: &P2) -> Result<Vec<u8>>;
}
}
Note: The Instance is consumed (self, not &self) on each call, preventing
state leakage between invocations.
RustVm (native execution)
#![allow(unused)]
fn main() {
// dango/core/vm/rust/src/vm.rs
pub struct RustVm;
}
Executes contracts compiled directly into the node binary. No sandboxing, no gas metering overhead. Used for all first-party system contracts (bank, accounts, perps, oracle, etc.).
Security implication: Code running in RustVm has the same trust level as the
node binary itself. A bug in a system contract is indistinguishable from a bug in
the state machine.
WasmVm (sandboxed execution)
#![allow(unused)]
fn main() {
// dango/core/vm/wasm/src/vm.rs
pub struct WasmVm {
cache: Option<Cache>, // LRU cache of compiled Wasmer modules
}
}
Executes third-party WASM bytecode via the Wasmer runtime. Key protections:
Gatekeeper middleware (dango/core/vm/wasm/src/gatekeeper.rs): Validates WASM
modules at compilation time. Allowed/denied features:
| Feature | Allowed | Rationale |
|---|---|---|
| Floats | Yes | Required for JSON deserialization |
| Bulk memory ops | Yes | Required by Rust 1.87+ |
| Reference types | No | Could enable memory leaks |
| SIMD | No | Non-deterministic floats |
| Threads | No | Non-deterministic |
| Exception handling | No | Unstable WASM proposal |
Metering middleware: Injects gas tracking into every WASM operation (1 gas per Wasmer op).
Memory limits: 32 MiB per instance (512 WASM pages).
Query depth limit: Maximum 3 levels of nested cross-contract queries.
Host functions (dango/core/vm/wasm/src/imports.rs): The WASM guest can call these
host-provided functions:
- Storage:
db_read,db_write,db_remove,db_scan,db_next - Crypto:
secp256k1_verify,secp256r1_verify,secp256k1_pubkey_recover - Hashes:
sha2_256,sha2_512,sha3_256,sha3_512,keccak256 - Cross-contract queries:
query_chain - Debug logging:
debug
Each host function call:
- Reads data from WASM linear memory.
- Charges gas (based on operation + data size).
- Enforces
state_mutable– writes rejected during query execution. - Invalidates all iterators on write (preventing use-after-mutation bugs).
6. Chain Upgrades
There are three dimensions in which a change can be breaking:
- Consensus-breaking: Given the same state and block, old and new software produce different results, causing a consensus failure.
- State-breaking: The format of data stored in the DB changes.
- API-breaking: The transaction or query API changes.
Any breaking change requires a coordinated upgrade: all validators halt at the same block height, upgrade, and resume together.
Upgrade procedure
-
The chain owner sends a
Message::Upgrade:{ "upgrade": { "height": 12345, "cargo_version": "1.2.3", "git_tag": "v1.2.3", "url": "https://github.com/left-curve/left-curve/releases/v1.2.3" } }This signals the upgrade height and target version. Node operators should not upgrade yet.
-
The chain finalizes block 12344 normally. At block 12345, during
FinalizeBlock, the App readsNEXT_UPGRADEfrom state and checks the binary’s cargo version. -
Version mismatch → intentional halt. The App returns an error in
FinalizeBlockResponse. Block 12345 is not finalized; no state changes are committed. This is safer than risking a fork. -
The node operator replaces the binary with version
1.2.3and restarts. -
CometBFT retries
FinalizeBlockfor block 12345. The App sees the version now matches, runs the upgrade handler (App::upgrade_handler) if one is registered, clearsNEXT_UPGRADE, records the upgrade inPAST_UPGRADES, and resumes normal block processing.
Upgrade handler
#![allow(unused)]
fn main() {
type UpgradeHandler<VM> = fn(Box<dyn Storage>, VM, BlockInfo) -> AppResult<()>;
}
The handler receives mutable storage access and can perform arbitrary state migrations: adding fields to stored structs, rewriting storage layouts, deploying new contracts, or updating configuration. It runs exactly once at the upgrade height.
Security considerations
- The upgrade height and version are stored on-chain (
NEXT_UPGRADEitem in app state). Only the chain owner can schedule an upgrade. - A mismatch between the running binary and the scheduled version causes an intentional halt rather than a silent fork – this is the conservative choice.
- There is no automated upgrade tool (like Cosmos SDK’s cosmovisor) yet; operators must manually replace the binary.
Smart Contract Semantics
This chapter documents the programming model for Dango smart contracts: entry points, context types, message passing, storage, authentication, and the testing framework.
1. Entry Points
Contracts export functions that the host calls at specific points in the transaction lifecycle. Each entry point receives a typed context and returns a typed response.
Basic entry points
| Entry Point | Context | Signature | Purpose |
|---|---|---|---|
instantiate | MutableCtx | fn(MutableCtx, M) -> Result<Response> | One-time initialization on deploy |
execute | MutableCtx | fn(MutableCtx, M) -> Result<Response> | State-mutating operations |
query | ImmutableCtx | fn(ImmutableCtx, M) -> Result<Binary> | Read-only queries |
migrate | SudoCtx | fn(SudoCtx, M) -> Result<Response> | Code upgrade migration |
receive | MutableCtx | fn(MutableCtx) -> Result<Response> | Receive token transfers |
reply | SudoCtx | fn(SudoCtx, M, SubMsgResult) -> Result<Response> | Callback after submessage |
System entry points
| Entry Point | Context | Signature | Purpose |
|---|---|---|---|
authenticate | AuthCtx | fn(AuthCtx, Tx) -> Result<Response> | Tx authentication (account contracts) |
bank_execute | SudoCtx | fn(SudoCtx, BankMsg) -> Result<Response> | Token ops (bank only) |
bank_query | ImmutableCtx | fn(ImmutableCtx, BankQuery) -> Result<BankQueryResponse> | Balance queries (bank only) |
cron_execute | SudoCtx | fn(SudoCtx) -> Result<Response> | Periodic automation |
Entry points are defined using the #[dango_ffi::export] attribute macro, which generates
the WASM FFI boilerplate (extern C functions, memory marshaling via Region structs).
This macro is only necessary when building contracts for the WasmVm. Contracts
targeting the RustVm (all first-party Dango contracts) do not need it – they
register their entry points directly as Rust function pointers.
2. Context Types
Each entry point receives a context that controls what the contract can do.
#![allow(unused)]
fn main() {
// dango/core/types/src/context.rs
// Read-only access (queries)
pub struct ImmutableCtx<'a> {
pub storage: &'a dyn Storage,
pub api: &'a dyn Api,
pub querier: QuerierWrapper<'a>,
pub chain_id: String,
pub block: BlockInfo,
pub contract: Addr,
}
// Read-write access with sender and funds info (execute, instantiate)
pub struct MutableCtx<'a> {
pub storage: &'a mut dyn Storage,
pub api: &'a dyn Api,
pub querier: QuerierWrapper<'a>,
pub chain_id: String,
pub block: BlockInfo,
pub contract: Addr,
pub sender: Addr,
pub funds: Coins,
}
// Read-write, chain-initiated (migrate, reply, cron_execute, bank)
pub struct SudoCtx<'a> {
pub storage: &'a mut dyn Storage,
pub api: &'a dyn Api,
pub querier: QuerierWrapper<'a>,
pub chain_id: String,
pub block: BlockInfo,
pub contract: Addr,
}
// Authentication context (authenticate)
pub struct AuthCtx<'a> {
pub storage: &'a mut dyn Storage,
pub api: &'a dyn Api,
pub querier: QuerierWrapper<'a>,
pub chain_id: String,
pub block: BlockInfo,
pub contract: Addr,
pub mode: AuthMode,
}
pub enum AuthMode {
Simulate, // Gas estimation -- tx is unsigned (sig verify skipped)
Check, // CheckTx phase
Finalize, // FinalizeBlock phase
}
}
Security note: MutableCtx is the only context with sender and funds. A
SudoCtx entry point is called by the chain (no user sender). An AuthCtx entry
point knows which ABCI phase it’s in, allowing it to skip signature verification
during simulation (the tx is not yet signed at that point – the user needs the gas
estimate before they can sign).
3. Messages and Responses
Transaction messages
A transaction contains a vector of Message variants:
#![allow(unused)]
fn main() {
pub enum Message {
Configure(MsgConfigure),
Upgrade(MsgUpgrade),
Transfer(MsgTransfer),
Upload(MsgUpload),
Instantiate(MsgInstantiate),
Execute(MsgExecute),
Migrate(MsgMigrate),
}
pub struct MsgExecute {
pub contract: Addr,
pub msg: Json,
pub funds: Coins,
}
}
Contract responses
#![allow(unused)]
fn main() {
pub struct Response {
pub submsgs: Vec<SubMessage>,
pub subevents: Vec<ContractEvent>,
}
}
Submessages and replies
Contracts can emit submessages – nested calls that execute after the current entry point returns:
#![allow(unused)]
fn main() {
pub struct SubMessage {
pub msg: Message,
pub reply_on: ReplyOn,
}
pub enum ReplyOn {
Success(Json), // Reply only on success (payload passed to reply())
Error(Json), // Reply only on failure
Always(Json), // Reply regardless
Never, // No reply callback
}
pub type SubMsgResult = Result<Event, String>;
}
Execution semantics:
reply_on | Submsg succeeds | Submsg fails | Submsg state on failure |
|---|---|---|---|
Success | Call reply() | Abort entire tx | Reverted (entire tx) |
Error | Do nothing | Call reply() | Reverted |
Always | Call reply() | Call reply() | Reverted |
Never | Do nothing | Abort entire tx | Reverted (entire tx) |
Each submessage executes in its own Buffer. On success, the buffer is committed to
the parent. On failure, the submessage’s state changes are always reverted (its
buffer is discarded). If reply_on is Error or Always, the parent continues and
reply() is called; otherwise, the entire transaction is aborted.
Security implication: A failed submessage can never leave behind partial state changes. If no reply handler catches the failure, the entire transaction is aborted, preventing contracts from silently ignoring errors.
4. Core Types
Addresses
#![allow(unused)]
fn main() {
pub type Addr = EncodedBytes<[u8; 20], AddrEncoder>; // 20-byte, 0x-prefixed hex
// Deterministic address derivation
// address = ripemd160(sha256(deployer_addr || code_hash || salt))
}
All Addr fields are validated during deserialization. Invalid hex or wrong length is
rejected before contract code runs.
Coins
#![allow(unused)]
fn main() {
pub type Coins = BTreeMap<Denom, Uint128>;
// Ordered, deduplicated, non-zero amounts enforced
}
Math types
dango/core/math/ provides overflow-safe fixed-point arithmetic:
| Type | Description |
|---|---|
Uint128, Uint256 | Unsigned integers |
Int128, Int256 | Signed integers |
Udec128, Udec256 | Unsigned decimals (18 decimal places) |
Dec128, Dec256 | Signed decimals (18 decimal places) |
All arithmetic is checked. Overflow/underflow returns StdError instead of panicking.
Dimensional Number type
Dango extends the base math types with Number<Q, U, D>
(dango/exchange/types/src/typed_number.rs), a dimensionally-typed signed fixed-point
decimal (Dec128_6 – 6 decimal places). The three type parameters encode physical
dimensions using typenum integers:
- Q – quantity (asset units)
- U – USD value
- D – time duration (days)
Multiplication and division propagate dimensions at the type level, so the compiler rejects nonsensical operations (e.g., adding a price to a quantity):
#![allow(unused)]
fn main() {
// price × quantity = USD value (Q: -1+1=0, U: 1+0=1, D: 0+0=0)
fn checked_mul<Q1, U1, D1>(self, rhs: Number<Q1, U1, D1>)
-> MathResult<Number<Q + Q1, U + U1, D + D1>>;
// USD value / price = quantity (Q: 0-(-1)=1, U: 1-1=0, D: 0-0=0)
fn checked_div<Q1, U1, D1>(self, rhs: Number<Q1, U1, D1>)
-> MathResult<Number<Q - Q1, U - U1, D - D1>>;
}
Key type aliases used throughout the perps contracts:
| Alias | Dimensions (Q, U, D) | Meaning |
|---|---|---|
Dimensionless | (0, 0, 0) | Pure scalar (ratios, rates) |
Quantity | (1, 0, 0) | Asset amount in human units |
UsdValue | (0, 1, 0) | Dollar amount |
UsdPrice | (-1, 1, 0) | Price (USD per unit of asset) |
FundingPerUnit | (-1, 1, 0) | Cumulative funding accumulator |
FundingRate | (0, 0, -1) | Funding rate (per day) |
Days | (0, 0, 1) | Time duration in days |
This type system is a key defense against unit-confusion bugs in margin, PnL, and funding calculations. A mismatched dimension is a compile-time error, not a runtime surprise.
Bounded types
Dango encourages declarative validation via Bounded<T, B> and LengthBounded<T>:
#![allow(unused)]
fn main() {
struct FeeRateBounds;
impl Bounds<Udec256> for FeeRateBounds {
const MIN: Bound<Udec256> = Bound::Inclusive(Udec256::ZERO);
const MAX: Bound<Udec256> = Bound::Exclusive(Udec256::ONE);
}
type FeeRate = Bounded<Udec256, FeeRateBounds>;
// Length bounds
pub type Label = LengthBounded<String, 1, 128>;
pub type Salt = LengthBounded<Binary, 1, 82>;
}
Bounds are enforced during deserialization – contracts never see out-of-bounds data.
5. Storage Abstractions
Item (single value)
#![allow(unused)]
fn main() {
const CONFIG: Item<Config> = Item::new("config");
CONFIG.save(storage, &value)?;
let v = CONFIG.load(storage)?;
let v = CONFIG.may_load(storage)?; // Option<T>
}
Map (key-value)
#![allow(unused)]
fn main() {
const BALANCES: Map<Addr, Uint128> = Map::new("balances");
BALANCES.save(storage, addr, &amount)?;
let amt = BALANCES.load(storage, addr)?;
BALANCES.has(storage, addr);
BALANCES.remove(storage, addr);
// Iteration
for (key, value) in BALANCES.range(storage, None, None, Order::Ascending)? {
// ...
}
}
Set (membership)
#![allow(unused)]
fn main() {
const WHITELIST: Set<Addr> = Set::new("whitelist");
WHITELIST.insert(storage, addr)?;
WHITELIST.has(storage, addr);
WHITELIST.remove(storage, addr);
}
Counter
#![allow(unused)]
fn main() {
const NONCE: Counter<u32> = Counter::new("nonce", 0, 1); // base=0, step=1
let (old, new) = NONCE.increment(storage)?;
}
IndexedMap
For queryable maps with secondary indexes:
#![allow(unused)]
fn main() {
const USERS: IndexedMap<UserIndex, User, UserIndexes> = IndexedMap::new("user", indexes);
// Primary key access
USERS.save(storage, user_idx, &user)?;
let user = USERS.load(storage, user_idx)?;
// Secondary index queries
USERS.idx.by_account.prefix(addr).range(...)?;
USERS.idx.by_name.prefix(name).range(...)?;
}
Index types:
MultiIndex<PK, IK, T>– one primary key can map to many index keys (one-to-many).UniqueIndex<PK, IK, T>– one primary key maps to exactly one unique index key.
6. Cross-Contract Communication
Queries
Contracts can query other contracts or chain state via the QuerierWrapper:
#![allow(unused)]
fn main() {
// Query another contract's custom endpoint (invokes the target's query() entry point)
let result: R::Response = ctx.querier.query_wasm_smart(contract_addr, query_msg)?;
// Query raw storage of another contract (direct KV lookup, no entry point call)
let raw: Option<Binary> = ctx.querier.query_wasm_raw(contract_addr, key)?;
// Query bank balances
let balance: Coin = ctx.querier.query_balance(addr, denom)?;
let all: Coins = ctx.querier.query_balances(addr)?;
}
There is also StorageQuerier::query_wasm_path (dango/core/storage/src/querier.rs), which
combines the low gas cost of query_wasm_raw with the ergonomics of query_wasm_smart.
It takes a typed storage Path (produced by Item::path() or Map::path(key)),
performs a raw KV lookup, and automatically deserializes the result using the storage
item’s codec:
#![allow(unused)]
fn main() {
// Read another contract's CONFIG item -- raw lookup, typed result, no entry point call
let cfg: Config = ctx.querier.query_wasm_path(other_contract, CONFIG.path())?;
// Read a specific key from another contract's Map
let user: User = ctx.querier.query_wasm_path(factory, &USERS.path(user_index))?;
// Optional variant (returns None instead of error if key is missing)
let maybe: Option<User> = ctx.querier.may_query_wasm_path(factory, &USERS.path(idx))?;
}
This is the preferred query method in Dango’s inter-contract calls (e.g., the auth
module reading user data from the account factory, or the oracle querier reading Pyth
prices) because it avoids the overhead of invoking the target contract’s query()
entry point entirely.
Queries are read-only and gas-metered. They cannot mutate state. Recursive queries are limited to depth 3 to prevent stack overflow.
Submessages (state-mutating calls)
To call another contract with state mutation, return submessages in the Response:
#![allow(unused)]
fn main() {
let msg = Message::execute(target_addr, &call_msg, coins)?;
let response = Response::new()
.add_message(msg) // reply_on: Never
.add_submessage(SubMessage::reply_on_success(msg, &data)?); // reply_on: Success
}
7. Authentication and Account Model
Dango uses account abstraction – every user has a dedicated smart contract instance that handles authentication.
Account lifecycle
- Registration. User calls the account factory with a signed
RegisterUsermessage. - Account creation. The factory deploys an account contract instance, registers the user’s public key, and optionally activates the account.
- Transaction signing. User constructs a
SignDoc(sender, messages, nonce, expiry), signs it, and submits aTx. - Authentication. The host calls the account contract’s
authenticate()entry point. The contract verifies the signature, nonce, and account status.
Nonce management
A naively incrementing nonce forces strict transaction ordering: if a user sends nonces 11 and 12 concurrently and 12 arrives first, 12 is rejected (the account expects 11). This is poor UX for high-frequency use cases like canceling multiple limit orders.
Instead, Dango tracks the most recent 20 nonces seen (SEEN_NONCES). A new tx
is accepted if:
- Its nonce is not already in
SEEN_NONCES. - Its nonce is greater than the smallest nonce in
SEEN_NONCES. - Its nonce does not jump more than 100 from the current maximum (prevents a DoS where an attacker fills the set with very large values).
When a new nonce is inserted and the set exceeds 20 entries, the smallest is evicted.
This means nonces older than the 20th-most-recent are permanently rejected, which
also serves as an implicit transaction expiry (supplemented by an explicit
expiry timestamp in the tx metadata).
This design allows concurrent, unordered transaction submission while still preventing replay attacks.
Account status
#![allow(unused)]
fn main() {
pub enum AccountStatus {
Inactive, // Not yet funded or activated
Active, // Can send transactions
Frozen, // Blocked (e.g., by governance)
}
}
Inactive accounts are activated on sufficient deposit (≥ min_deposit from app config).
Signature types
| Type | Curve | Use case |
|---|---|---|
Passkey | Secp256r1 | WebAuthn / browser passkeys |
Secp256k1 | Secp256k1 | Standard crypto wallets |
Eip712 | Secp256k1 | Ethereum wallet compatibility |
8. FFI Layer
dango/core/ffi/ bridges WASM guests and the host:
- Exports (
ffi/src/exports.rs):do_instantiate,do_execute,do_query, etc. These deserialize context and message from WASM memory, call the contract function, and serialize the result back. - Imports (
ffi/src/imports.rs):db_read,db_write,secp256k1_verify, etc. These are extern C functions the guest calls to invoke host capabilities. - Memory (
ffi/src/memory.rs): UsesRegionstructs (offset + capacity) to describe buffers in WASM linear memory.allocateanddeallocateare auto-provided entry points.
9. Testing Framework
TestSuite
dango/core/testing/ provides a high-level integration test harness:
#![allow(unused)]
fn main() {
let suite = TestBuilder::new()
.with_chain_id("test-chain")
.with_block_time(Duration::from_secs(5))
.with_genesis_state(genesis)?
.build()?;
// Upload and deploy a contract
suite.upload(wasm_code)?;
let addr = suite.instantiate(code_hash, &msg, None)?;
// Execute and query
let outcome = suite.execute(addr, &execute_msg, &funds)?;
let result: QueryResponse = suite.query(addr, &query_msg)?;
// Advance blocks
suite.make_block()?;
}
Test helpers
outcome.should_succeed()/outcome.should_fail()– Assert tx result.outcome.should_fail_with_error("msg")– Assert specific error.- Event inspection via
outcome.events.
Testing with MemDb + RustVm
Tests use MemDb (in-memory, no disk I/O) and RustVm (native execution, no WASM
compilation). This makes tests fast and deterministic while exercising the same
storage and execution paths as production.
Dango-specific test suite
dango/testing/ extends the base suite with helpers for deploying the full Dango
contract system (bank, accounts, oracle, perps) in a single genesis block. This
enables end-to-end tests that exercise inter-contract interactions.
10. Procedural Macros
dango/core/macros/ provides:
#[dango_ffi::export]– Generates WASM FFI wrappers for entry points. Only needed for WasmVm contracts; RustVm contracts register entry points directly.#[dango_primitives::derive(Serde, Borsh)]– Derives standard traits (Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq).#[dango_primitives::event("name")]– Registers an event type with a canonical name.#[dango_storage::index_list(PK, T)]– ImplementsIndexListtrait for IndexedMap secondary indexes.
Dango Contract System
Dango is a suite of smart contracts that together form a perpetual
futures exchange, oracle, token ledger, bridge aggregator, and account system. All
contracts are first-party and execute natively via RustVm.
1. Shared Types (dango/exchange/types/)
All contracts reference a central AppConfig that stores addresses of every system
contract:
#![allow(unused)]
fn main() {
pub struct AppAddresses {
pub account_factory: Addr,
pub dex: Addr,
pub gateway: Addr,
pub hyperlane: Hyperlane<Addr>,
pub oracle: Addr,
pub perps: Addr,
pub warp: Addr,
}
}
Other shared types include authentication types (Key, Signature, Credential,
SignDoc, Metadata) and price types
(PrecisionlessPrice, PrecisionedPrice).
2. Bank (dango/exchange/bank/)
The bank contract manages all token balances, transfers, mints, and burns.
State layout
| Storage | Key | Value | Purpose |
|---|---|---|---|
NAMESPACE_OWNERS | Part (denom segment) | Addr | Who can mint/burn tokens under this namespace |
METADATAS | Denom | Metadata | Token name, symbol, decimals |
SUPPLIES | Denom | Uint128 | Total supply per denom |
BALANCES | (Addr, Denom) | Uint128 | Account balances |
ORPHANED_TRANSFERS | (Addr, Addr) | Coins | Dead-letter transfers to non-existent contracts |
TRANSFERS_ENABLED | – | bool | Whether transfers are allowed; absent = allowed |
Operations
- Transfer. Moves coins between accounts. This is implemented at the host level
via
BankMsg::Transfer, not as a contract execute message. - Mint.
Mint { to, coins }– caller must be the namespace owner for each denom. If the recipient contract doesn’t exist, coins go toORPHANED_TRANSFERS. - Burn.
Burn { from, coins }– caller must be the namespace owner. - Force transfer.
ForceTransfer { from, to, coins }– namespace owner can move funds arbitrarily. Used by the perps contract to settle PnL. - Set transfers enabled.
SetTransfersEnabled(bool)– chain owner only.
Disabling transfers
Every balance movement caused by a transfer passes through bank_execute: the
Transfer message, the funds attached to an Execute or Instantiate message,
and the gas fee withheld by the state machine. That makes it the one place where
transfers can be switched off, which the wind-down does.
While disabled, a transfer goes through only if it satisfies both conditions:
- The sender or the recipient is the gateway contract, or the recipient is the chain owner. The first two cover bridge deposits, withdrawals, and refunds; the third covers the gas fee.
- The recipient exists. No new orphaned transfer may be created, even on an allowed leg, or a bridge deposit to an account that was never created would strand tokens in the bank again.
mint carries the same recipient check, since it has its own orphan branch.
burn and RecoverTransfer are not gated: neither can strand funds once the
map is empty and no new entry can be created.
An absent TRANSFERS_ENABLED means enabled, so a chain created before the
wind-down needs no genesis change and no migration of this item.
Access control
Namespace ownership is assigned once by the chain owner and cannot be overwritten.
For example, the perps contract owns the perp/ namespace.
Security considerations
- Orphaned transfers: If a contract is instantiated but not yet registered, mints
to it become dead letters. Recovery requires an explicit
RecoverTransfercall. There is no automatic expiry or governance recovery. - Trust in namespace owners: The bank unconditionally trusts namespace owners for
mint/burn/force-transfer. A bug in the perps contract could allow unlimited minting
of
perp/*tokens.
3. Account Factory (dango/exchange/account-factory/)
Creates and manages user accounts.
State layout
| Storage | Key | Value |
|---|---|---|
CODE_HASH | – | Hash256 (account contract code) |
NEXT_USER_INDEX | – | Counter<UserIndex> |
NEXT_ACCOUNT_INDEX | – | Counter<AccountIndex> |
USERS | UserIndex | User { name, accounts, keys } |
(Index) by_key | Hash256 | → UserIndex (MultiIndex) |
(Index) by_account | Addr | → UserIndex (UniqueIndex) |
(Index) by_name | Username | → UserIndex (UniqueIndex) |
User structure
#![allow(unused)]
fn main() {
pub struct User {
pub name: Option<Username>, // Immutable once set
pub accounts: BTreeMap<AccountIndex, Addr>, // Max 5 accounts
pub keys: BTreeMap<Hash256, Key>, // All signing keys
}
}
Registration flow
- User sends tokens to the account factory (deposit ≥
min_deposit). - User sends a
RegisterUsermessage with a signedRegisterUserDatacontaining the chain ID. - Factory verifies signature, creates a new
Userrecord, deploys an account contract, and optionally registers a referrer with the perps contract.
Constraints:
- Exactly one message per registration tx (prevents batching attacks).
- Username is immutable after being set.
- Maximum 5 accounts per user.
- Nonce jump limited to 100 (prevents DoS on the nonce set).
4. Account (dango/exchange/account/)
Single-signature account contract, one instance per user account.
State
| Storage | Value |
|---|---|
STATUS | AccountStatus (Inactive / Active / Frozen) |
SEEN_NONCES | BTreeSet<Nonce> (last 20 nonces) |
Authentication flow
When the host receives a transaction, it calls the sender account’s authenticate():
- Deserialize the credential from
tx.credential. - Verify the account is Active (or in Simulate mode).
- Verify the nonce is valid (not seen, not too far ahead).
- Verify the signature against the signing key registered in the factory.
- Return
Response.
Operations
-
Force withdrawal.
ForceWithdrawal { denom, remote, recipient }– chain owner only. Sends the account’s entire balance ofdenomtorecipienton the remote chain, by calling the gateway’sTransferRemotewith the balance attached.This exists for the wind-down: balances left behind by users who don’t withdraw before the shutdown are returned to the address they deposited from, which the owner supplies as
recipient. It carries no privilege inside the gateway – routes, reserves, fees, personal quotas, and rate limits all apply as they would to a withdrawal the user makes themselves, and the resulting withdrawal request still needs the guardian’s or the owner’s approval.It works on an
Inactiveaccount. The account is not the transaction sender, soauthenticateis never involved, and funds attached to anExecutemessage skip the recipient’sreceivehook.
5. Gas fees
Gas fees are handled directly by the state machine; there is no fee contract. The
parameters live in the chain Config:
| Field | Meaning |
|---|---|
gas_token | The denom in which fees are paid. |
gas_fee_rate | The amount of gas_token charged per unit of gas. |
gas_exemptions | Senders that pay no fee (e.g. the oracle and the account factory, which submit protocol-level transactions). |
Before a transaction is authenticated, the state machine withholds ceil(gas_limit * gas_fee_rate)
of gas_token from the sender and credits it to the chain owner.
The fee is charged even if authentication or message execution fails. There is no
refund of unused gas.
6. Oracle (dango/exchange/oracle/)
Price feed aggregation for derivatives trading.
State
| Storage | Key | Value |
|---|---|---|
PRICE_SOURCES | Denom | PriceSource |
PYTH_TRUSTED_SIGNERS | [u8] (pubkey) | Timestamp (expiry) |
PYTH_PRICES | PythId | Price |
Price structure
#![allow(unused)]
fn main() {
pub struct Price {
pub humanized_price: UsdPrice, // e.g., 50000.0 for $50k BTC
pub timestamp: Timestamp, // Feed age
}
}
Trust model
- The oracle trusts Pyth network signers whose public keys are registered in
PYTH_TRUSTED_SIGNERSwith expiry timestamps. - The chain owner (governance) controls which signers are trusted.
- There is no automated slashing or removal of malicious signers – governance intervention is required.
- Consuming contracts (perps) enforce staleness checks before using prices.
7. Perpetual Futures Exchange (dango/exchange/perps/)
The primary audit target. A leveraged perpetual futures exchange with a vault-based counterparty (market maker).
Note: Detailed mechanism design is documented separately in the Perps section of this book. This chapter focuses on the smart contract implementation details relevant to security auditing.
Source files
dango/exchange/perps/src/
├── lib.rs # Entry points (instantiate, execute, query, cron_execute)
├── state.rs # All storage definitions
├── query.rs # Query implementations
├── cron.rs # Scheduled tasks (funding, conditional orders)
├── core/ # Pure business logic
│ ├── margin.rs # Equity, maintenance margin, available margin
│ ├── funding.rs # Funding rate computation, impact prices
│ ├── fees.rs # Trading fee calculations (volume-tiered)
│ ├── closure.rs # Liquidation eligibility, closeout calculations
│ ├── vault.rs # Vault quoting (bid/ask sizes and prices)
│ ├── fill.rs # Order fill execution
│ ├── oi.rs # Open interest constraints
│ ├── liq_price.rs # Liquidation price computation
│ ├── target_price.rs # Price constraints for orders
│ ├── min_size.rs # Minimum order size validation
│ └── decompose.rs # Decomposing fills into open/close portions
├── trade/ # State mutations for trading
│ ├── submit_order.rs
│ ├── submit_conditional_order.rs
│ ├── cancel_order.rs
│ ├── cancel_conditional_order.rs
│ ├── deposit.rs
│ └── withdraw.rs
├── vault/ # Vault (LP) operations
│ ├── add_liquidity.rs
│ ├── remove_liquidity.rs
│ └── refresh.rs # Vault market-making order placement
├── maintain/ # Maintenance operations
│ ├── configure.rs # Parameter updates (owner-only)
│ └── liquidate.rs # Forced position closeout
├── referral/ # Referral system
├── volume.rs # Trading volume accumulation
├── position_index.rs # Position tracking by entry price
└── liquidity_depth.rs # Order book depth aggregation
State layout
Global state:
#![allow(unused)]
fn main() {
STATE: Item<State> {
last_funding_time: Timestamp,
vault_share_supply: Uint128,
insurance_fund: UsdValue, // Covers bad debt from liquidations
treasury: UsdValue, // Accumulated protocol fees
}
PARAM: Item<Param> {
max_unlocks: u32,
max_open_orders: u32,
maker_fee_rates: RateSchedule, // Volume-tiered schedule
taker_fee_rates: RateSchedule,
protocol_fee_rate: Udec128, // Fraction of fees → treasury
liquidation_fee_rate: Udec128,
liquidation_buffer_ratio: Udec128,
funding_period: Duration,
vault_total_weight: Udec128,
vault_cooldown_period: Duration,
referral_active: bool,
min_referrer_volume: UsdValue,
referrer_commission_rates: RateSchedule,
vault_deposit_cap: Option<UsdValue>,
}
}
Per-pair state:
#![allow(unused)]
fn main() {
PAIR_PARAMS: Map<&PairId, PairParam> {
tick_size, min_order_size, max_abs_oi,
max_abs_funding_rate,
initial_margin_ratio, // 1/leverage (e.g., 0.1 = 10x)
maintenance_margin_ratio, // Liquidation trigger
impact_size, // Notional for impact price sampling
vault_liquidity_weight, // Fraction of vault margin allocated
vault_half_spread, // Base bid-ask spread around oracle
vault_max_quote_size, // Max single-side vault order size
vault_size_skew_factor, // Inventory skew → size tilt
vault_spread_skew_factor, // Inventory skew → spread tilt
vault_max_skew_size, // Skew saturation point
bucket_sizes: BTreeSet<UsdPrice>, // Liquidity depth granularities
}
PAIR_STATES: Map<&PairId, PairState> {
long_oi, // Total long open interest
short_oi, // Total short open interest (abs)
funding_per_unit, // Cumulative funding accumulator
funding_rate, // Current per-day rate (clamped)
}
}
Per-user state:
#![allow(unused)]
fn main() {
USER_STATES: IndexedMap<Addr, UserState> {
margin: UsdValue, // Deposited collateral (USDC)
vault_shares: Uint128, // LP shares owned
positions: BTreeMap<PairId, Position>,
unlocks: VecDeque<Unlock>, // Pending vault withdrawals
reserved_margin: UsdValue, // Collateral reserved for resting orders
open_order_count: u32, // Resting limit order count
}
Position {
size: Int128, // Positive=long, negative=short
entry_price: UsdPrice,
entry_funding_per_unit: Dec128,
conditional_order_above: Option<ConditionalOrder>,
conditional_order_below: Option<ConditionalOrder>,
}
}
Order book:
#![allow(unused)]
fn main() {
BIDS: IndexedMap<OrderKey, LimitOrder> // OrderKey = (PairId, Price, OrderId)
ASKS: IndexedMap<OrderKey, LimitOrder>
// ADL position tracking (sorted by entry price for selection)
LONGS: Set<(PairId, UsdPrice, Addr)>
SHORTS: Set<(PairId, UsdPrice, Addr)>
}
Other state:
#![allow(unused)]
fn main() {
VOLUMES: Map<(Addr, Timestamp), UsdValue> // Per-user per-day volume
REFEREE_TO_REFERRER: Map<UserIndex, UserIndex>
FEE_SHARE_RATIO: Map<UserIndex, FeeShareRatio>
COMMISSION_RATE_OVERRIDES: Map<UserIndex, CommissionRate>
}
Critical flows
Order submission (trade/submit_order.rs)
- Load user state and pair state/params.
- Validate: minimum size (or reduce-only exempt), tick alignment, slippage vs oracle (market orders), max open orders.
- Decompose order into closing portion (vs existing position) and opening portion (new risk).
- For opening portion: check OI constraints (
long_oi + size ≤ max_abs_oi) and initial margin (available_margin ≥ required). - Match against resting orders in the order book (which may include orders placed by the vault or by other traders).
- For fills: compute trading fee (volume-tiered), apply funding
(
entry_funding_per_unit = current), settle PnL. - Resting (unfilled) portion: reserve margin, place on book with TP/SL children.
- Post-trade validation:
available_margin ≥ 0(reverts entire order otherwise).
Funding (cron.rs → core/funding.rs)
- Sample order book impact prices (best bid/ask for
impact_sizenotional). - Compute midpoint premium vs oracle price.
- Clamp to
max_abs_funding_rateper day, scale by elapsed time. - Update
pair_state.funding_per_unit += delta. - Funding settles lazily on position close:
accrued = size × (current_cumulative - entry_cumulative).
Liquidation (maintain/liquidate.rs)
- Compute equity =
margin + Σ(unrealized_pnl) - Σ(accrued_funding). - Compute maintenance margin =
Σ(|size| × price × mm_ratio). - If
equity < maintenance_margin: a. Cancel all resting orders (refund reserved margin). b. Close enough of the user’s positions to restoreequity ≥ maintenance_margin(with a buffer controlled byliquidation_buffer_ratio). Not all positions are necessarily closed. c. Positions are closed against resting orders in the book at the target price. Only if there is insufficient book liquidity within the target price does the engine resort to auto-deleveraging (ADL) against profitable counter-parties. d. Collect liquidation fee → insurance fund. e. Cover any remaining bad debt from insurance fund.
Vault (LP) system (vault/)
The vault acts as a passive market maker, placing orders around the oracle price:
- Share price:
vault_equity / vault_shares + VIRTUAL_ASSETS / VIRTUAL_SHARES(ERC-4626-style virtual shares prevent share inflation attacks). - Add liquidity: Mint shares at current share price. Slippage-protected via
min_shares_to_mint. - Remove liquidity: Burn shares, queue withdrawal for
vault_cooldown_period. - Quoting: Inventory-based skew tilts bid/ask sizes and spreads to manage directional exposure.
bid_price = oracle × (1 - half_spread × (1 - skew × spread_skew_factor))
ask_price = oracle × (1 + half_spread × (1 + skew × spread_skew_factor))
skew = vault_inventory / vault_max_skew_size [clamped to [-1, 1]]
Access control
| Operation | Who can call |
|---|---|
Configure (params) | Chain owner only |
SubmitOrder, Deposit, Withdraw | Any active account |
Liquidate | Anyone (permissionless) |
AddLiquidity, RemoveLiquidity | Any active account |
cron_execute | Chain (automatic) |
8. Gateway (dango/exchange/gateway/)
Bridge aggregator for cross-chain token transfers.
State
| Storage | Key | Value |
|---|---|---|
ROUTES | (Addr, Remote) | Denom |
REVERSE_ROUTES | (Denom, Remote) | Addr |
RATE_LIMITS | – | BTreeMap<Denom, RateLimit> |
WITHDRAWAL_FEES | (Denom, Remote) | Uint128 |
OUTBOUND_QUOTAS | Denom | Uint128 |
Cross-chain flow
Inbound: Remote bridge delivers tokens → gateway mints wrapped tokens → transfers to recipient (or orphaned transfer if contract not deployed).
Outbound: User sends tokens to gateway → rate limit check → withdrawal fee deducted → local tokens burned → cross-chain message sent.
Rate limiting
#![allow(unused)]
fn main() {
RateLimit = Bounded<Udec128, ZeroInclusiveOneExclusive>
// e.g., 0.1 = max 10% of supply per period
}
Trust model
Trusts Hyperlane validators/ISM. Governance controls bridge configuration, fees, and rate limits.
9. Vesting (dango/exchange/vesting/)
Token vesting with linear schedules and optional cliffs.
State
| Storage | Key | Value |
|---|---|---|
UNLOCKING_SCHEDULE | – | Schedule |
POSITIONS | Addr | Position |
10. Upgrade (dango/exchange/upgrade/)
Handles state migrations during chain upgrades. Example: migrating PairParam to add
new vault skew fields with zero defaults.
11. Inter-Contract Interaction Map
┌──────────────┐ RegisterUser ┌──────────┐ mint ┌──────┐
│ Account │◄──────────────│ Account │────────►│ Bank │
│ (per user) │ │ Factory │ │ │
└──────────────┘ └────┬─────┘ └──┬───┘
│ referral │
▼ │
┌──────────┐ │
│ Perps │◄────────────┘ force_transfer
│ │ (PnL settlement)
└────┬─────┘
│ query prices
▼
┌──────────┐
│ Oracle │
└──────────┘
Key interaction patterns:
- Perps ↔ Bank: Force-transfers for margin deposits/withdrawals and PnL settlement.
- Perps → Oracle: Price queries with staleness checks.
- Account Factory → Perps: Referral registration on user creation.
- Account → Factory: Key and nonce lookups during authentication.
12. Security-Relevant Properties
Invariants to verify
- Bank solvency:
Σ(BALANCES[addr][denom]) = SUPPLIES[denom]for all denoms. - Perps margin: For any non-liquidatable user,
equity ≥ maintenance_margin. - OI balance:
pair_state.long_oi - pair_state.short_oi = Σ(positions.size)across all users for each pair. - Vault shares:
STATE.vault_share_supply = Σ(user_state.vault_shares). - Reserved margin consistency:
user_state.reserved_margin = Σ(resting_order.margin_required)for that user. - Order count:
user_state.open_order_count =count of resting orders for that user.
Trust boundaries within Dango
| Contract | Trusts | Trusted by |
|---|---|---|
| Bank | Namespace owners (unconditionally) | Everyone (for balance queries) |
| Oracle | Pyth signers (governance-managed) | Perps (for price feeds) |
| Perps | Oracle (prices), Bank (balances) | Users (for margin custody) |
| Account Factory | – | Accounts (for key lookups) |
| Gateway | Hyperlane validators | Bank (for mint/burn) |
Indexer and Node Architecture
This chapter covers the indexer pipeline, SQL schema, GraphQL API, and the Dango CLI that wires everything together.
1. Indexer Design
The indexer is a read-only, non-consensus component that observes state transitions and writes structured data to external databases. It cannot affect consensus – its operations run after transaction execution and are never on the critical path for state commitment.
Indexer trait
#![allow(unused)]
fn main() {
// dango/core/app/src/traits/indexer.rs
#[async_trait]
pub trait Indexer: Send + Sync {
async fn start(&mut self, storage: &dyn Storage) -> IndexerResult<()>;
async fn shutdown(&mut self) -> IndexerResult<()>;
async fn pre_indexing(&self, block_height: u64) -> IndexerResult<()>;
async fn index_block(&self, block: &Block, outcome: &BlockOutcome) -> IndexerResult<()>;
async fn post_indexing(&self, block_height: u64, cfg: Config, app_cfg: Json) -> IndexerResult<()>;
async fn wait_for_finish(&self) -> IndexerResult<()>;
async fn last_indexed_block_height(&self) -> IndexerResult<Option<u64>>;
}
}
Call sequence in FinalizeBlock
1. pre_indexing() ← BEFORE transaction execution
2. Execute all txs
3. Execute cronjobs
4. Remove orphaned codes
5. db.flush_but_not_commit() ← State root computed
6. index_block() ← AFTER execution, BEFORE commit
7. [Commit happens separately in do_commit()]
8. post_indexing() ← AFTER commit, spawned as async task
Security properties:
- Pre-indexing runs before any state mutation – indexer cannot influence tx execution.
- State root is computed before
index_block()– indexer cannot affectapp_hash. - Post-indexing is async and non-blocking – indexer errors don’t halt the chain.
- Pre-indexing and index_block errors are fatal (halt block processing).
HookedIndexer (composition)
dango/indexer/hooked/ is the single Indexer impl that the chain wires into App. It owns the three production indexer components by value and orchestrates their per-block work:
#![allow(unused)]
fn main() {
pub struct HookedIndexer {
pub file: dango_indexer_cache::Cache,
pub sql: dango_indexer_sql::Indexer,
pub clickhouse: dango_indexer_clickhouse::Indexer,
// …plus an `is_running` flag and a per-block `post_indexing` task map.
}
}
The data flow is expressed through typed method arguments: Cache::post_indexing returns a BlockAndBlockOutcomeWithHttpDetails payload, which HookedIndexer then hands to SqlIndexer::post_indexing and ClickhouseIndexer::post_indexing in sequence. Each block’s post_indexing runs on its own tokio task so SQL and Clickhouse writes do not block consensus; wait_for_finish drains the task map before shutdown.
The “Hooked” name is historical — earlier revisions held a dynamic Arc<RwLock<Vec<Box<dyn Indexer>>>> and passed data between entries through an opaque http::Extensions-based context. The current shape is the three concrete fields above, but the crate and struct name are kept so deploy scripts and imports do not need to churn.
2. SQL Indexer (dango/indexer/sql/)
Schema
| Table | Key Columns | Purpose |
|---|---|---|
blocks | height (unique), hash, app_hash | Block headers |
transactions | hash (unique), block_height, sender, status | Tx metadata |
messages | transaction_id, contract_addr, method_name | Sub-tx messages |
events | transaction_id, block_height, event_type, data (JSON) | Emitted events |
Indexes exist on block_height, hash, sender, contract_addr, and events.data
(JSON).
HTTP request tracking
Each transaction records the HTTP peer that submitted it:
#![allow(unused)]
fn main() {
pub struct HttpRequestDetails {
pub remote_ip: Option<String>,
pub peer_ip: Option<String>,
pub created_at: u64,
}
}
Persistence properties
- Idempotent:
save_block()checks if the block already exists before inserting (safe for crash recovery). - Atomic: All table writes within a single database transaction.
- Batch-safe: Inserts batched to 2,048 rows to respect PostgreSQL argument limits.
Event cache
An in-memory ring buffer of recent block events (dango/indexer/sql/src/event_cache.rs).
Configurable window size. Used for fast GraphQL lookups without DB round-trips.
3. Cache Indexer (dango/indexer/cache/)
Persists complete block + outcome data to disk for recovery:
~/.dango/indexer/blocks/{height}.json -- Serialized block data
~/.dango/indexer/last_block.json -- Latest block height
4. Dango-Specific Writes (dango/indexer/sql/src/write/)
The SQL indexer crate also performs Dango-specific data extraction in the same post_indexing pass, after the generic block/tx/message/event rows have been written:
#![allow(unused)]
fn main() {
// Runs in SqlIndexer::post_indexing (async, non-blocking)
let (transfers, accounts, perps) = tokio::join!(
crate::write::transfers::save_transfers(&self.context, block_height),
crate::write::accounts::save_accounts(&self.context, block, app_cfg.clone()),
crate::write::perps_events::save_perps_events(&self.context, block, app_cfg),
);
}
Two sea-orm migration tables are kept side by side in the same database (grug_seaql_migrations and dango_seaql_migrations) so existing prod data does not need to be migrated.
Extracts:
- Account events:
UserRegistered,AccountRegistered,KeyOwned,KeyDisowned→ accounts, users, public_keys tables. - Transfer events: Bank transfer events → transfers table.
- Perps events: Trade execution, funding, liquidation → perps_events table.
Only processes committed events from successful transactions.
5. GraphQL / HTTP Server (dango/indexer/httpd/)
Actix-web HTTP server with async-graphql:
| Parameter | Value |
|---|---|
| Workers | 8 |
| Max connections | 10,000 |
| Backlog | 8,192 |
| Max blocking threads | 16 |
Query types
block(height)/blocks(first, after)– Block headers with nested transactions and events.transaction(hash)/transactions(first, after)– Tx metadata with nested messages and events.events(filter)– Event queries with JSON data filtering.
Subscriptions
Real-time subscriptions via PostgreSQL LISTEN/NOTIFY:
blockMinted– New blocks.transactionProcessed– New transactions.eventEmitted– New events.
Data loaders
N+1 query prevention via dataloaders:
BlockTransactionsDataLoader,BlockEventsDataLoaderTransactionEventsDataLoader,TransactionMessagesDataLoaderEventTransactionDataLoader,FileTransactionDataLoader
6. Node Startup (dango/cli/)
The dango start command initializes and runs the full node:
1. Parse CLI args and config
2. Initialize telemetry (Sentry + OpenTelemetry)
3. Initialize metrics (Prometheus)
4. Open DiskDb (RocksDB)
5. Create RustVm
6. Create base App
7. Setup indexer stack (HookedIndexer with three components):
├── Cache (disk persistence)
├── SqlIndexer (PostgreSQL — generic + Dango-specific tables)
└── ClickhouseIndexer (analytics)
8. Run DB migrations + catch-up reindexing
9. Spawn:
├── Dango HTTP server (GraphQL)
├── Metrics HTTP server (Prometheus)
└── ABCI server (CometBFT connection)
10. Signal handlers (SIGINT, SIGTERM)
ABCI server split
The app is split into four ABCI service components:
- Consensus: FinalizeBlock, Commit
- Mempool: CheckTx
- Snapshot: State sync
- Info: Query, simulation
Graceful shutdown
- Set HTTP shutdown flags (return 503 for new requests).
- Wait 100ms for propagation.
- Shutdown indexer (wait for async tasks to complete).
- Flush telemetry (Sentry, OpenTelemetry).
7. Security Analysis
Trust boundaries
┌────────────────────────────────────────────────────────┐
│ Consensus-Critical (ABCI) │
│ App + DB + VM │
│ Indexer trait called but read-only │
│ State root determined before indexer writes │
└────────────────── ▼ ───────────────────────────────────┘
│ Block + Outcome
┌───────────────────┴────────────────────────────────────┐
│ Non-Consensus (Indexer Stack) │
│ Cache → disk │
│ SqlIndexer → PostgreSQL (generic + Dango tables) │
│ ClickhouseIndexer → analytics DB │
└────────────────── ▼ ───────────────────────────────────┘
│
┌───────────────────┴────────────────────────────────────┐
│ Public API (GraphQL/HTTP) │
│ Read-only queries over indexed data │
│ No state mutation capability │
└────────────────────────────────────────────────────────┘
Network exposure
| Component | Default Port | Exposure |
|---|---|---|
| CometBFT RPC | 26657 | Public (read-only) |
| ABCI | 26658 | Localhost only (CometBFT ↔ App) |
| Dango GraphQL | 8000 | Configurable |
| Metrics | 8001 | Internal |
| PostgreSQL | 5432 | Private |
Known gaps
- No GraphQL query complexity limits. Deeply nested or expensive queries could DoS the HTTP server.
- No HTTP rate limiting. Any client can issue unlimited queries.
- Event JSON size unbounded. Malicious contracts could emit large events, inflating the database.
- IP logging without TTL. Transaction origin IPs stored indefinitely.
Previous Audits
A list of audits we have completed so far:
| Time | Auditor | Subject | Links |
|---|---|---|---|
| 2025-09-29 | Sherlock | Audit contest on Dango spot DEX | contest |
| 2025-04-07 | Zellic | Hyperlane | report |
| 2025-04-02 | Zellic | Account and authentication system | report |
| 2024-10-25 | Zellic | Jellyfish Merkle Tree (JMT) | report |
| Q4 2024 | Informal Systems | Formal specification of JMT in Quint | blog • spec |
Margin
1. Overview
All trader margin is held internally in the perps contract as a USD value on each user’s userState.
Internal logics of the perps contract use USD amounts exclusively. Token conversion only happens at two boundaries:
- Deposit — the user sends settlement currency (USDC) to the perps contract; the oracle price converts the token amount to USD and credits
userState.margin. - Withdraw — the user requests a USD amount; the oracle price converts it to settlement currency tokens (floor-rounded) and transfers them out.
2. Trader Deposit
The user sends settlement currency as attached funds. The perps contract:
- Values the settlement currency at a fixed $1 per unit (no oracle lookup).
- Converts the token amount to USD: .
- Increment
userState.marginby .
The tokens remain in the perps contract’s bank balance.
3. Trader Withdraw
The user specifies how much USD margin to withdraw. The perps contract:
- Computes (see §8), clamped to zero.
- Ensures the requested amount does not exceed .
- Deducts the amount from
userState.margin. - Converts USD to settlement currency tokens at the fixed $1 rate (floor-rounded to base units).
- Transfers the tokens to the user.
4. Equity
A user’s equity (net account value) is:
where is the USD value of the user’s deposited margin (userState.margin).
Per-position unrealised PnL is:
and accrued funding is:
Positive accrued funding is a cost to the trader (subtracted from equity). Refer to Funding for details on the funding rate.
5. Initial margin (IM)
where is the per-pair initial margin ratio. IM is the minimum equity required to open or hold positions. It is used in two places:
- Pre-match margin check — verifies the taker can afford the worst-case 100 % fill (see Order matching §5).
- Available margin calculation — determines how much can be withdrawn or committed to new limit orders (see §8 below).
When checking a new order the IM is computed with a projected size: the user’s current position in that pair is replaced by the hypothetical post-fill position (). Positions in other pairs use their actual sizes.
6. Maintenance margin (MM)
where is the per-pair maintenance margin ratio (always ). A user becomes eligible for liquidation when:
See Liquidation for details.
7. Reserved margin
When a GTC limit order is placed, margin is reserved for the worst-case scenario (the entire order is opening):
The user’s total is the sum across all resting orders. Reserved margin is released proportionally as orders fill and fully released on cancellation. Reduce-only orders reserve zero margin (they can only close).
See Order matching §10 for when reservation occurs.
8. Available margin
where is the IM of current positions (§5 formula applied to actual sizes, without any projection). This determines how much can be withdrawn (§3) or committed to new limit orders.
Order Matching
This chapter describes how orders are submitted, matched, filled, and settled in the on-chain perpetual futures order book.
1. Order types
An order can be order:
- Market — immediate-or-cancel (IOC). Specifies a
max_slippagerelative to the oracle price. Any unfilled remainder after matching is discarded (unless nothing filled at all, which is an error). - Limit — specifies a
limit_priceand atime_in_force:- GTC (default): any unfilled remainder is stored as a resting order on the book.
- IOC: fills as much as possible, then discards the unfilled remainder. Errors if nothing fills.
- Post-only: the order is to be inserted into the book without entering the matching engine. Reject if it would cross the best price on the opposite side.
Resting orders on the book are stored as:
| Field | Description |
|---|---|
user | Owner address |
size | Signed quantity (positive = buy, negative = sell) |
reduce_only | If true, can only close an existing position |
reserved_margin | Margin locked for this order |
The pair ID, order ID, and limit price are part of the storage key.
2. Order decomposition
Before matching, every fill is decomposed into a closing and an opening portion based on the user’s current position:
| Order direction | Current position | Closing size | Opening size |
|---|---|---|---|
| Buy (+) | Short (−) | ||
| Sell (−) | Long (+) | ||
| Same direction | Any |
Both closing and opening carry the same sign as the original order size (or are zero). For reduce-only orders, the opening portion is forced to zero — if the resulting fillable size is zero, the transaction is rejected.
3. Target price
The target price defines the worst acceptable execution price for the taker:
Market orders (bid/buy):
Market orders (ask/sell):
Limit orders: (oracle price is ignored).
The user-supplied on a market order is bounded by the per-pair cap max_market_slippage — see §3b.
A price constraint is violated when:
- Bid:
- Ask:
3a. Price banding for limit orders
Every limit order (GTC, IOC, or post-only) must have a limit_price within a per-pair symmetric deviation of the oracle price at submission. Concretely:
where is a per-pair parameter in . Equivalently, the limit price must fall inside
An order whose price falls outside this band is rejected at submission, before matching begins. The check is applied identically to GTC, IOC, and post-only limit orders.
3b. Market-order slippage cap
Each market order must have a max_slippage within a per-pair max_market_slippage constraint at submission:
The same cap applies to max_slippage on TP/SL child orders (attached to a parent submit order or placed as standalone conditional orders), which become market orders when triggered.
Conditional-order staleness. It is possible that when a conditional order is submitted, its max_slippage falls within the max_market_slippage constraint, but when triggered, governance has tightened the constaint such that it is no longer compliant. In this case, the conditional order is canceled with reason = SlippageCapTightened.
4. Matching engine
The matching engine iterates the opposite side of the book in price-time priority:
- A bid (buy) walks the asks in ascending price order (cheapest first).
- An ask (sell) walks the bids in descending price order (most expensive first). Bids are stored with bitwise-NOT inverted prices so that ascending iteration over storage keys yields descending real prices.
At each resting order the engine checks two termination conditions:
- — the taker is fully filled.
- The resting order’s price violates the taker’s price constraint.
If neither condition is met, the fill size is:
After each fill the maker order is updated: reserved margin is released proportionally, and if fully filled the order is removed from the book and open_order_count is decremented.
5. Pre-match margin check
Before matching begins, the taker’s margin is verified (skipped for reduce-only orders). The check ensures the user can afford the worst case — a 100 % fill:
where is the initial margin assuming the full order fills (see Margin §5) and is
This prevents a taker from submitting orders they cannot collateralise.
Maker order re-checks
When a maker order with an eligible price is encountered, the matching engine performs two check before executing filling:
6a. Self-trade prevention
The exchange uses EXPIRE_MAKER mode. When the taker encounters their own resting order on the opposite side:
- The maker (resting) order is cancelled (removed from the book).
- The taker’s
open_order_countandreserved_marginare decremented. - The taker continues matching deeper in the book — no fill occurs for the self-matched order.
6b. Price-banding
The submission-time band (§3a) only inspects the price at the moment of placement. Between placement and matching, the oracle may move far enough that a previously in-band resting order is now outside the band relative to the current oracle.
To address this, the matching engine applies a band re-check on every
resting maker it walks. For each maker encountered during the walk,
the engine evaluates the §3a band
against the current oracle price. If the maker’s resting price is outside
the band, it is canceled with reason = PriceBandViolation.
7. Fill execution
Each fill between taker and maker is executed as follows:
7a. Funding settlement
Accrued funding is settled on the user’s existing position before the fill:
The negated accrued funding is added to the user’s PnL (positive accrued funding is a cost to longs).
7b. Closing PnL
For the closing portion of the fill:
Long closing (selling to close):
Short closing (buying to close):
The position size is reduced by the closing amount. If the position is fully closed, it is removed from state.
7c. Opening position
For the opening portion of the fill:
- New position: entry price is set to the fill price.
- Existing position (same direction): entry price is blended as a weighted average:
7d. OI update
Open interest is updated per side:
- Closing a long:
- Closing a short:
- Opening a long:
- Opening a short:
8. Trading fees
Fees are charged on every fill:
The fee rate differs by role:
| Role | Rate | Example value |
|---|---|---|
| Taker | taker_fee_rate | 0.1 % |
| Maker | maker_fee_rate | 0 % |
Fees are always positive (absolute value of fill size is used). They are routed to the vault via the settlement loop described below.
9. PnL settlement
After all fills in an order are complete, PnLs and fees are settled atomically as in-place USD margin adjustments. No token conversions occur during settlement — all values are pure UsdValue arithmetic.
9a. Fee loop
For each non-vault user with a non-zero fee:
Fees from the vault to itself are skipped (no-op). Processing fees first ensures collected fees augment before any vault losses are absorbed.
9b. PnL loop
Non-vault users:
A user’s margin can go negative temporarily — the outer function handles bad debt (see Liquidation).
Vault:
A negative represents a deficit (bad debt not yet recovered via ADL).
10. Unfilled remainder
After matching completes:
- Market orders and IOC limit orders: the unfilled remainder is silently discarded. If nothing was filled at all, the transaction reverts with “no liquidity at acceptable price”.
- GTC limit orders: the unfilled remainder is stored as a resting order. Storage requires:
open_order_count<max_open_orders- Price is aligned to the pair’s tick size ()
- Sufficient available margin (skipped for reduce-only orders) — see below
Margin reservation (non-reduce-only):
The unfilled portion’s margin requirement is computed and checked against available margin (see Margin §7–§8):
If the check passes, reserved_margin is increased by and open_order_count is incremented. This is the 0 %-fill scenario check — it ensures the user can afford the order even if nothing fills immediately.
Post-only limit orders take a fast path that bypasses the matching engine entirely. They are rejected if they would cross the best price on the opposite side:
- Buy:
- Sell:
If the opposite book is empty, the order always succeeds.
11. Open interest constraint
Each pair has a parameter enforcing a per-side cap:
- Long opening:
- Short opening:
The constraint is checked before matching and does not apply to reduce-only orders (which have zero opening size). Long and short OI limits are independent but share the same parameter.
12. Order cancellation
Single cancel
A user can cancel any individual resting order by its order ID.
On cancellation:
- The order is removed from the book.
reserved_marginis released (subtracted from the user’s total).open_order_countis decremented.- If the user state is now empty (no positions, no open orders, no pending unlocks), it is deleted from storage.
Bulk cancel
A user can cancel all of their resting orders across both sides of the book in a single transaction. The contract iterates the user’s resting orders, removing each one and releasing margin. The same cleanup logic applies — if the user state becomes empty after all orders are removed, it is deleted.
Funding
Fundings are periodic payments between longs and shorts that anchor the perpetual contract price to the oracle. When the market trades above the oracle, longs pay shorts; when below, shorts pay longs. This mechanism discourages persistent deviations from the spot price without requiring contract expiry.
1. Premium
Each funding cycle begins with measuring how far the on-chain book has drifted from the oracle. The contract computes two impact prices by walking the book, takes their midpoint, and compares it to the oracle:
- Impact bid — the volume-weighted average price (VWAP) obtained by selling worth of base asset into the bid side.
- Impact ask — the VWAP obtained by buying worth from the ask side.
The premium is then:
If a side of the book has less than of depth, the walk returns the VWAP of whatever depth is available. If a side has no depth at all, the sample is skipped for that cycle rather than a one-sided mid being computed. In steady state both sides are always populated by the vault.
2. Sampling
A cron job runs frequently (e.g. every minute). Each invocation samples the premium for every active pair and accumulates it into the pair’s state:
Sampling at a cadence close to the block rate gives each observation roughly equal weight. A resting order that momentarily drags the mid can only influence the average in proportion to how long it sits on the book relative to the full funding period.
3. Collection
When has elapsed since the last collection, the same cron invocation finalises the funding rate:
-
Average premium:
-
Clamp to the configured bounds:
-
Funding delta — scale by the actual elapsed interval and oracle price:
-
Accumulate into the pair-level running total:
-
Reset accumulators: , , .
4. Position-level settlement
Accrued funding is settled on a position whenever it is touched — during a fill, liquidation, or ADL event:
After settlement the entry point is reset:
Sign convention: positive accrued funding is a cost to the holder (longs pay when the rate is positive, shorts pay when it is negative). The negated accrued funding is added to the user’s realised PnL. See Order matching §7a and Vault §4 for how this integrates with fill execution and vault accounting.
5. Parameters
| Field | Type | Description |
|---|---|---|
funding_period | Duration | Minimum time between funding collections. |
impact_size | UsdValue | Notional depth walked on each side of the book to compute impact prices. A larger value dilutes the influence of any single resting order on the premium in proportion to the fraction of the walk it occupies. |
max_abs_funding_rate | FundingRate | Symmetric clamp applied to the average premium before scaling to a delta. Prevents runaway rates during prolonged skew. |
funding_rate_multiplier | Dimensionless | Scalar applied to the vault-driven premium so governance can tune funding independently of the vault’s quoting (see §6). Bounds: . is identity; disables funding for the pair. |
6. Discussions
Vault being the sole maker
As of today, the protocol-owned vault is the dominant maker in Dango’s markets. The vault’s inventory-skew-aware quoting policy causes the book mid to drift from the oracle whenever the vault holds inventory:
Suppose the vault is literally the only maker in the entire market, we can substitute the vault’s bid and ask into the formula:
and therefore
Positive skew (vault long, because sell flow has dominated) produces a negative premium, so longs receive funding from shorts — which credits the vault-as-long for absorbed inventory. Symmetric when short. The sign is economically correct by construction.
The closed-form also tethers funding to the vault’s quoting parameters: tightening spreads to compete for flow would otherwise shrink funding by the same factor. is a per-pair governance knob that decouples these two — admins can dial funding up or down (e.g. in response to persistent one-sided skew) without touching or and therefore without changing the vault’s quoted prices. is the identity and matches the pre-multiplier formulation; disables funding entirely.
Comparison with other exchanges
The “book mid minus oracle” premium is the dominant on-chain perpetual-funding pattern — see Drift (bid/ask TWAP mid vs oracle TWAP), Vertex (mark vs spot index), Paradex (Fair Basis from mark), and MCDEX v2 (AMM mid vs index). Dango’s formulation differs in reading impact prices (depth-walked VWAPs) rather than top-of-book, which bakes depth distribution into the primitive and forces any book-level manipulation to commit notional proportional to .
Liquidation & Auto-Deleveraging (ADL)
This document describes how the perpetual futures exchange protects itself from under-collateralised accounts and socialises losses via auto-deleveraging and the insurance fund.
1. Liquidation trigger
Every account has an equity and a maintenance margin (MM):
where is the per-pair maintenance-margin ratio. An account becomes liquidatable when
Strict inequality: an account whose equity exactly equals its MM is still safe. An account with no open positions is never liquidatable regardless of its equity.
2. Close schedule
When an account is liquidatable, the system computes the minimum set of position closures needed to restore it above maintenance margin.
-
For every open position, compute its MM contribution:
-
Sort positions by MM contribution descending (largest first).
-
Walk the sorted list and close just enough to cover the deficit:
-
where is the global
liquidation_buffer_ratio(default 0). When , positions are closed slightly beyond the maintenance boundary so the user’s post-liquidation equity exceeds their remaining MM by a factor of , preventing repeated small liquidations from minor adverse price movements. -
For each position:
- If : stop
-
This produces a vector of entries. Each has the opposite sign of the existing position (a long is closed with a sell, a short with a buy). Only positions that contribute to the deficit are touched and they may be partially closed when the deficit is small relative to the position.
3. Position closure
Each entry in the close schedule is executed in two phases:
3a. Order book matching
The close is submitted as an immediate-or-cancel (IOC) limit order against the on-chain order book. The order’s limit price is the bankruptcy price (defined in §3b) when the account is solvent (), or the oracle price when insolvent. It matches resting limit orders at price-time priority. Any filled amount is settled normally (mark-to-market PnL between the entry price and the fill price).
3b. Auto-deleveraging (ADL)
If any quantity remains unfilled after the order book is exhausted, the system automatically deleverages against counter-parties. The unfilled remainder is closed against the most profitable counter-positions at the liquidated user’s bankruptcy price.
Counter-party selection: Positions are indexed by the tuple . For a long being liquidated (selling), the system finds shorts with the highest entry price (most profitable) first. For a short being liquidated (buying), it finds longs with the lowest entry price first.1
Bankruptcy price: A position’s bankruptcy price (BP) is the fill price at which, if the entire position were closed at it, the user’s total account equity would be exactly zero:
The divisor is always the position’s full current size, even when the close schedule closes only part of the position. An ADL fill at this price therefore moves exactly per unit closed from the user to the counter-party:
- If the user is solvent (), the BP sits slightly on the favourable side of the oracle for the counter-party (below oracle when closing a long, above when closing a short). The counter-party receives the user’s per-unit equity share as compensation for the forced close; the user keeps the equity attributable to the unclosed remainder, and never goes negative.
- If the user is insolvent (), the BP overshoots the oracle in the user’s favour (above oracle when closing a long, below when closing a short). The counter-party is force-closed at a worse-than-oracle price — it absorbs what would otherwise be bad debt. The close schedule fully closes every position of an insolvent account, so a pure-ADL liquidation leaves the account at exactly zero equity.
ADL does not fill the counter-party’s resting limit orders; their position is force-reduced directly. The shrunken position can, however, cause their resting reduce-only orders to be resized or cancelled, maintaining the invariant that the total size of a user’s reduce-only orders never exceeds their position size.
Liquidation fills (both order-book and ADL) carry zero trading fees for both taker and maker.
Order-book fills during liquidation emit order_filled events with a fill_id just like regular matches (see the events reference); ADL fills do not — they use the separate deleveraged and liquidated events, which carry no fill_id, because ADL is a position transfer at the bankruptcy price rather than an order-book match.
4. Liquidation fee
After all positions in the schedule are closed, a one-time liquidation fee is charged:
where is the account’s equity once the scheduled closes are settled — i.e. the post-close margin plus the unrealised PnL of any positions left open.
The fee is deducted from the user’s margin and routed to the insurance fund (not the vault). It is capped at the remaining equity — not margin alone — so the fee itself never drives equity below zero and therefore never creates bad debt. The cap matters precisely when margin and equity diverge: an account can reach liquidation with negative margin but positive equity (its open positions hold unrealised profit), and capping at margin would skip a fee the account can clearly afford; conversely, capping at margin when remaining positions are underwater could charge a fee that pushes equity negative.
5. PnL settlement
All PnL from the liquidation fills (user, book makers, ADL counter-parties) is settled atomically as in-place USD margin adjustments — no token transfers occur. Both user and maker PnL are applied via the same settlement logic described in Order matching §8.
6. Bad debt
After PnL and fee settlement, if the user’s equity is negative the absolute value is bad debt. The account is topped up to exactly zero equity — the bad debt is credited to the user’s margin — and the same amount is subtracted from the insurance fund:
Bad debt is a negative-equity condition, not merely a negative margin balance. A cross-margined account can carry negative margin while still solvent when its remaining positions hold unrealised profit (see margin §3); recognising bad debt off the margin sign in that case would transfer insurance-fund value to a solvent user. When the account is fully closed — the normal insolvent path — no positions remain, so equity equals margin and crediting is identical to flooring margin to zero.
The insurance fund may go negative. A negative insurance fund represents unresolved bad debt — future liquidation fees will replenish it.
Note: when an insolvent account’s positions are fully ADL’d at their bankruptcy prices — each computed from the account’s equity at the moment that position is processed — the user’s equity is zeroed by construction. Bad debt from ADL fills is therefore zero. Bad debt arises only from book fills at prices worse than the bankruptcy price (e.g., thin order books with deep bids/asks far from oracle); see Example 4.
7. Insurance fund
The insurance fund is a separate pool from the vault that absorbs bad debt and is funded by liquidation fees.
Funding: Every liquidation fee (§4) is credited to the insurance fund.
Usage: Every bad debt event (§6) is debited from the insurance fund.
Negative balance: The insurance fund may go negative when accumulated bad debt exceeds accumulated fees. This is the simplest approach — no special trigger or intervention is needed. Future liquidation fees will naturally replenish the fund.
Other users’ bad debt and liquidation fees never touch the vault’s margin — this isolates liquidity providers from external liquidation losses. However, the vault itself is subject to liquidation like any other account. If the vault’s equity falls below its maintenance margin, its positions are closed following the same procedure described above. The vault’s own liquidation fee goes to the insurance fund, and any bad debt is absorbed by it.
Examples
All examples use:
| Parameter | Value |
|---|---|
| Pairs | ETH / USD (1–6); plus BTC / USD (7–8) |
| Maintenance-margin ratio (mmr) | 5 % — both pairs |
| Liquidation-fee rate | 0.1 % |
| Liquidation buffer ratio () | 0 |
| Settlement currency | USDC at $1 |
Cast:
- Alice is the account being liquidated.
- Bob holds the exact opposite position(s), opened against Alice at her entry price — being the most profitable counter-position, he is the ADL counter-party.
- Carol is a third-party maker who supplies order-book liquidity where stated.
Examples 1–6 cover a single position, ordered from the most ideal situation to the least: Alice solvent (1–3) then insolvent (4–6), with the order book absorbing all (1, 4), part (2, 5), or none (3, 6) of the close. Examples 7–8 cover an account with two positions. A final example covers the cross-margin edge case where the account reaches liquidation with negative margin but positive equity — the case the equity caps in §4 and §6 exist for.
All eight numbered examples — plus mirrored variants with the sides flipped — are implemented as end-to-end tests in dango/testing/tests/perps/liquidation_spec.rs, asserting every figure below exactly. The negative-margin edge case is likewise covered by an end-to-end test there.
Example 1 — Solvent; close fully absorbed by the book
Setup
| Alice | Bob | Carol | |
|---|---|---|---|
| Position | Long 10 ETH | Short 10 ETH | Bid 8 ETH @ $1,800 |
| Entry price | $2,000 | $2,000 | — |
| Margin | $2,180 | $10,000 | — |
ETH drops to $1,800
Alice’s account
Close schedule
Bankruptcy price
Alice is solvent, so the close order’s limit price is the BP.
Execution
Carol’s bid at $1,800 is above the $1,782 limit, so the entire 8 ETH close fills at $1,800. No ADL.
Liquidation fee
Final state
| Position | Margin / balance | |
|---|---|---|
| Alice | Long 2 ETH @ $2,000 | $2,180 − $1,600 − $14.40 = $565.60 |
| Bob | Short 10 ETH (untouched) | $10,000 |
| Carol | Long 8 ETH @ $1,800 | — |
| Insurance fund | — | +$14.40 |
No ADL, no bad debt. Alice keeps 2 ETH and equity of $565.60 − 2 × $200 = $165.60.
Example 2 — Solvent; close partially absorbed by the book, remainder ADL’d
Same as Example 1, except Carol’s bid is only 5 ETH @ $1,800.
Execution
- Book: 5 ETH fill Carol’s bid at $1,800.
- ADL: the remaining 3 ETH close against Bob — the most profitable short — at the bankruptcy price, $1,782.
Closing 3 ETH at the oracle would have realized for Bob; the extra $54 is Alice’s per-unit equity concession, .
Liquidation fee
Same closed notional as Example 1 (8 ETH valued at the $1,800 oracle) → fee $14.40.
Final state
| Position | Margin / balance | |
|---|---|---|
| Alice | Long 2 ETH @ $2,000 | $2,180 − $1,654 − $14.40 = $511.60 |
| Bob | Short 7 ETH @ $2,000 | $10,000 + $654 = $10,654 |
| Carol | Long 5 ETH @ $1,800 | — |
| Insurance fund | — | +$14.40 |
No bad debt.
Example 3 — Solvent; book empty, whole close ADL’d
Same as Example 1, but the order book is empty.
Execution
The entire 8 ETH close is ADL’d against Bob at the bankruptcy price, $1,782.
Liquidation fee
$14.40 as before.
Final state
| Position | Margin / balance | |
|---|---|---|
| Alice | Long 2 ETH @ $2,000 | $2,180 − $1,744 − $14.40 = $421.60 |
| Bob | Short 2 ETH @ $2,000 | $10,000 + $1,744 = $11,744 |
| Insurance fund | — | +$14.40 |
No bad debt: Alice concedes of her $180 equity to Bob and keeps the rest.
Example 4 — Insolvent; close fully absorbed by the book
Setup
| Alice | Bob | Carol | |
|---|---|---|---|
| Position | Long 10 ETH | Short 10 ETH | Bid 10 ETH @ $1,700 |
| Entry price | $2,000 | $2,000 | — |
| Margin | $2,800 | $10,000 | — |
ETH drops to $1,700
Alice’s account
Equity is negative — Alice is liquidatable and insolvent.
Close schedule
Bankruptcy price
Alice is insolvent, so the close order’s limit price is the oracle price ($1,700), not the BP.
Execution
Carol’s bid at $1,700 fills the entire 10 ETH — at a price lower than the BP. No ADL.
Liquidation fee
Bad debt
Equivalently: each of the 10 ETH filled $20 below the $1,720 BP. Alice’s margin is floored to zero and the insurance fund covers the $200.
Final state
| Position | Margin / balance | |
|---|---|---|
| Alice | — (fully liquidated) | $0 |
| Bob | Short 10 ETH (untouched) | $10,000 |
| Carol | Long 10 ETH @ $1,700 | — |
| Insurance fund | — | −$200 |
Example 5 — Insolvent; close partially absorbed by the book, remainder ADL’d
Same as Example 4, except Carol’s bid is only 4 ETH @ $1,700.
Execution
- Book: 4 ETH fill at $1,700 (below the $1,720 BP).
- ADL: the remaining 6 ETH close against Bob at the BP, $1,720.
Bob is force-closed $20 above oracle on 6 ETH — he absorbs $120 of Alice’s insolvency that would otherwise become bad debt.
Bad debt
The $80 equals the book-filled portion’s shortfall from the BP: . The fee is $0 (no remaining margin).
Final state
| Position | Margin / balance | |
|---|---|---|
| Alice | — (fully liquidated) | $0 |
| Bob | Short 4 ETH @ $2,000 | $10,000 + $1,680 = $11,680 |
| Carol | Long 4 ETH @ $1,700 | — |
| Insurance fund | — | −$80 |
Example 6 — Insolvent; book empty, whole close ADL’d
Same as Example 4, but the order book is empty.
Execution
All 10 ETH are ADL’d against Bob at the BP, $1,720.
Alice’s equity is zeroed by construction — no bad debt, despite her insolvency. Bob absorbs the whole $200 shortfall by buying back 10 ETH at $20 above oracle:
($200 less than the $3,000 he would realize closing at the oracle.) The fee is $0.
Final state
| Position | Margin / balance | |
|---|---|---|
| Alice | — (fully liquidated) | $0 |
| Bob | — (fully ADL’d) | $10,000 + $2,800 = $12,800 |
| Insurance fund | — | unchanged |
Example 7 — Two positions, solvent
Alice now holds two longs; Bob holds the exact opposite shorts. The order book is empty in this example and the next, so all closes go to ADL.
Setup
| Alice | Bob | |
|---|---|---|
| Positions | Long 10 ETH, long 1 BTC | Short 10 ETH, short 1 BTC |
| Entry price | ETH $2,000; BTC $50,000 | ETH $2,000; BTC $50,000 |
| Margin | $7,065 | $12,000 |
ETH drops to $1,900; BTC drops to $47,000
Alice’s account
Close schedule
Positions are processed in descending order of MM contribution: BTC ($2,350) before ETH ($950).
Closing 0.1 BTC removes of MM — the deficit is fully covered, so the ETH position is not scheduled at all.
Bankruptcy price (BTC)
The numerator is the whole-account equity — including the ETH position’s unrealized PnL — divided by the BTC position’s full size (1 BTC).
Execution
0.1 BTC is ADL’d against Bob at $43,935.
Liquidation fee
Final state
| Positions | Margin / balance | |
|---|---|---|
| Alice | Long 10 ETH, long 0.9 BTC | $7,065 − $606.50 − $4.70 = $6,453.80 |
| Bob | Short 10 ETH, short 0.9 BTC | $12,000 + $606.50 = $12,606.50 |
| Insurance fund | — | +$4.70 |
No bad debt. Alice’s equity is $6,453.80 − $1,000 − 0.9 × $3,000 = $2,753.80.
Example 8 — Two positions, insolvent
Same positions and margins as Example 7; prices fall further.
ETH drops to $1,800; BTC drops to $44,000
Alice’s account
Close schedule
Entry 1: BTC
The full 1 BTC is ADL’d against Bob at $44,935:
Alice’s equity is now — the first position’s ADL absorbed the entire account shortfall.
Entry 2: ETH
The BP is recomputed from the account’s current equity, which is now zero:
All 10 ETH are ADL’d against Bob at $1,800:
Liquidation fee and bad debt
Remaining margin is $0, so the fee is $0; the margin is exactly zero, so there is no bad debt.
Final state
| Positions | Margin / balance | |
|---|---|---|
| Alice | — (fully liquidated) | $0 |
| Bob | — (fully ADL’d) | $12,000 + $5,065 + $2,000 = $19,065 |
| Insurance fund | — | unchanged |
Bob’s two ADL fills realize on BTC and on ETH. Alice’s $935 shortfall is absorbed entirely by the BTC fill’s above-oracle premium.
Edge case — negative margin, positive equity
Every example above reaches the fee and bad-debt steps with margin equal to equity: a solvent account is only partially closed but its margin stays positive, and an insolvent account is fully closed, leaving no positions so that margin equals equity. Neither the §4 fee cap nor the §6 bad-debt check can tell margin and equity apart in those cases.
A cross-margined account can, however, reach liquidation with negative margin but positive equity. The cash margin balance goes negative — while the account stays solvent — when the trader extracts an open position’s unrealised profit before it is realised: by withdrawing against it (margin §3), or by realising a loss on a different position against it. The account is still solvent because that position’s unrealised profit is part of equity. This is the case the equity caps exist for.
Setup
| Alice | |
|---|---|
| Position | Long 10 ETH |
| Entry price | $2,000 |
| Margin | −$1,450 |
Carol rests a 5-ETH bid at the $2,200 oracle.
ETH at $2,200
Close schedule
Execution
Alice is solvent, so the close order’s limit price is the bankruptcy price . Carol’s bid at $2,200 is above that limit, so the 5-ETH close fills on the book at $2,200 (no ADL):
Closing at the oracle realises no concession, so equity is unchanged at $550.
Liquidation fee
The margin-based cap would instead have used and skipped the fee entirely.
Bad debt
The margin-based check would instead have seen , paid $461 from the insurance fund, and floored Alice’s margin to zero — handing $461 of insurance-fund value to a solvent account and inflating her equity by the same amount.
Final state
| Position | Margin / balance | |
|---|---|---|
| Alice | Long 5 ETH @ $2,000 | −$450 − $11 = −$461 |
| Carol | Long 5 ETH @ $2,200 | — |
| Insurance fund | — | +$11.00 (fee only; no bad debt) |
Alice keeps 5 ETH and equity of −$461 + 5 × $200 = $539, still solvent. Her margin stays negative — a valid cross-margin state, backed by the open position’s unrealised profit, that resolves itself when she eventually closes the position and realises that profit into margin.
-
This does not perfectly rank by total PnL since it ignores accumulated funding fees, but is a reasonable and efficient approximation. ↩
Vault
1. Overview
The vault is the passive market maker for the perpetual futures exchange. It continuously quotes bid/ask orders around the oracle price on every pair, earning the spread.
Liquidity providers (LPs) deposit settlement currency into the vault and receive vault shares credited to their account.
2. Liquidity provision
Adding liquidity follows an ERC-4626 virtual shares pattern to prevent the first depositor inflation attack.
Constants
| Name | Value |
|---|---|
| Virtual shares | 1,000,000 |
| Virtual assets | $1 |
Share minting
The LP specifies a USD margin amount to transfer from their trading margin to the vault.
Floor rounding protects the vault from rounding exploitation. A minimum-shares parameter lets depositors revert if slippage is too high.
First depositor protection
The virtual terms dominate when real supply and equity are small. An attacker cannot inflate the share price to steal from subsequent depositors because the initial share price is effectively per share.
3. Liquidity withdrawal
The LP specifies how many vault shares to burn. The USD value to release is computed:
The fund is not released immediately. A cooldown is initiated, with the ending time computed as:
Once is reached, the contract credits the released USD value back to the LP’s trading margin.
4. Vault equity
The vault has its own user state (positions acquired from market-making fills). Its equity follows the same formula as any user:
where is the vault’s internal USD margin (updated in-place during settlement), and the sums run over all of the vault’s open positions.
If is non-positive the vault is in catastrophic loss and both deposits and withdrawals are disabled.
5. Market making policy
The vault uses its margin to market make in the order book. Each block, after the oracle update, the vault cancels all existing quotes and recomputes bid/ask orders for every pair.
The strategy uses inventory skew to reduce the vault’s exposure to directional price movements. When the vault accumulates a position in one direction, it tilts both order sizes and spreads to encourage trades that unwind that position.
Margin allocation
Total vault available margin is split across pairs by weight:
where and is the sum of initial margin across all vault positions (see Margin §8).
Skew ratio
For each pair, compute a skew ratio from the vault’s current position:
where is the vault’s signed position (positive = long, negative = short) and is the position size at which skew saturates.
At zero position, and quoting is symmetric. At maximum long, . At maximum short, .
Quote size
Each side receives half the allocated margin, capped by a per-pair maximum, then tilted by the skew:
where is the initial margin ratio and controls skew intensity.
When the vault is long (), bid size decreases and ask size increases — the vault offers more on the sell side to unwind. Total quoted size () is preserved.
Bid price
Snap down to the nearest tick:
Book-crossing prevention: if , clamp to .
Skip if or notional is below the minimum order size.
When the vault is long, the bid spread widens (less likely to accumulate more).
Ask price
Snap up to the nearest tick (ceiling):
Book-crossing prevention: if , clamp to .
Skip if notional is below the minimum order size.
When the vault is long, the ask spread tightens (more attractive to takers who buy from the vault).
Combined effect
When the vault is long, all four levers push toward unwinding:
- Bid size decreases (less buying)
- Ask size increases (more selling)
- Bid spread widens (buys less likely to fill)
- Ask spread tightens (sells more likely to fill)
The mirror applies when short.
Per-pair parameters
| Parameter | Role |
|---|---|
initial_margin_ratio | Used to compute margin-constrained size |
min_order_size | Minimum notional to place an order |
tick_size | Price granularity for snapping |
vault_half_spread | Base half bid-ask spread around oracle price |
vault_liquidity_weight | Weight for margin allocation across pairs |
vault_max_quote_size | Maximum base size per side |
vault_max_skew_size | Position size at which skew saturates |
vault_size_skew_factor | Size skew intensity () |
vault_spread_skew_factor | Spread skew intensity () |
If any of vault_half_spread, vault_max_quote_size, vault_liquidity_weight, tick_size, or the allocated margin is zero, the vault skips quoting for that pair.
Choosing parameters
vault_max_skew_size — the position size at which skew reaches its maximum. A natural starting point is vault_max_quote_size (the existing per-side cap). This means: once the vault has accumulated one full order’s worth of directional exposure, skew is fully engaged. For gentler unwinding, use 2x vault_max_quote_size.
vault_size_skew_factor — how aggressively to tilt order sizes. Start with 0.5: at maximum skew, the heavier side quotes 1.5x and the lighter side 0.5x. A value of 1.0 fully shuts off quoting on one side at max position, which may be too aggressive for a vault that should always provide some liquidity. Range 0.5 to 0.8 is recommended.
vault_spread_skew_factor — how aggressively to tilt spreads. Start with 0.3: at maximum skew, the tightened side has 70% of normal spread and the widened side has 130%. Keep this below vault_size_skew_factor — size adjustment is the primary lever, spread adjustment is the fine-tuning. Range 0.3 to 0.5 is recommended. Values above 1.0 are permitted and cause the tightened side to cross the oracle price at maximum skew (an aggressive-unwind posture, useful for quickly deleveraging a large directional position); the invariant bid < ask still holds since ask - bid = 2 × oracle × vault_half_spread. The effective upper bound is governed by the cross-field invariant vault_half_spread × (1 + vault_spread_skew_factor) < 1, which ensures the bid stays positive at max skew.
General tuning principle: start conservative (size 0.5, spread 0.3), observe PnL and position behavior, increase if the vault still accumulates too much directional exposure.
Referral
The referral system lets existing traders recruit new users and earn a share of the trading fees generated by their referrals. When a referred user trades, a portion of the fee — after the protocol treasury has taken its cut — is distributed to the direct referrer and up to four additional upstream referrers in the referral chain.
1. Overview
Three roles participate in a referral commission:
- Referee — the user who was referred and is paying trading fees.
- Direct referrer (level 1) — the user who referred the referee. Earns a commission on the referee’s fees and may share a portion of it back with the referee.
- Upstream referrers (levels 2–5) — referrers further up the chain. Each receives only the marginal increase in commission rate beyond what lower levels already captured.
Commissions are taken from the trading fee after the protocol treasury has claimed its share. The system can be disabled globally by setting in the referral parameters, which causes the commission pass to be skipped entirely.
Referral commissions are applied whenever an order is filled and trading fees are collected. Exception: liquidation fills (both for the taker and the maker) use zero trading fees, so no referral commissions occur during liquidation.
2. Key concepts
Two rates govern how referral fees are distributed:
-
Commission rate () — the fraction of the post-protocol-cut fee that the referral system distributes at a given level. This rate is tiered: it increases as the referrer’s direct referees accumulate more 30-day rolling trading volume (see §6b). The chain owner can also set a per-user override (see §6a).
-
Fee share ratio () — the fraction of the level-1 commission that the direct referrer gives back to the referee as a rebate. For example, if the commission rate is 20 % and the share ratio is 50 %, the referee receives 10 % and the referrer keeps 10 %. The share ratio is capped at 50 % and can only increase once set.
3. Registration
3a. Becoming a referrer
A user opts in as a referrer by calling SetFeeShareRatio with a value. The share ratio determines what fraction of the level-1 commission the referrer gives back to the referee (see §5a).
Eligibility: the user must have accumulated enough lifetime trading volume:
Users who have a commission rate override (see §6a) bypass this volume requirement.
Constraints:
- — the maximum share ratio a referrer can set.
- The share ratio can only increase once set. A subsequent call must supply a value the current ratio.
3b. Registering a referee
A referee is linked to a referrer through one of two paths:
- During account creation — the
RegisterUsermessage on the account factory accepts an optionalreferrerfield. If provided, the factory forwards aSetReferralmessage to the perps contract. - After account creation — the referee (or an account they own) calls
SetReferraldirectly on the perps contract.
Constraints:
- A user cannot refer themselves ().
- The referrer must already have a fee share ratio set (i.e. has opted in as a referrer).
- The referral relationship is immutable once stored — a referee can never change or remove their referrer.
When a referral is registered, a per-referee statistics record is initialised for the (referrer, referee) pair, and the referrer’s is incremented in today’s cumulative data bucket.
4. Fee split recap
For every fill, a trading fee is computed per Order matching §8:
The fee is then split between the protocol treasury and the vault:
The protocol fee is routed to the treasury and is not affected by referrals. Referral commissions are computed against — i.e. the remainder of the fee after the protocol has taken its cut.
5. Commission distribution
After PnL settlement and fee collection, the contract distributes referral commissions for every fee-paying user who has a referrer. Commissions are drawn from the post-protocol-cut fee and credited to the recipients’ margins.
5a. Level 1 — direct referrer
Let be the commission rate of the direct referrer (see §6) and be that referrer’s fee share ratio.
The referee (fee payer) receives:
The direct referrer receives:
Equivalently, the total level-1 commission is , split between referee and referrer by the share ratio.
5b. Levels 2–5 — upstream referrers
The algorithm walks up the referral chain from the direct referrer. At each level (), let be the commission rate of the -th referrer and be the maximum commission rate seen at any prior level (initialised to ).
After computing , update:
If , the referrer at level receives nothing. The chain walk stops early if a referrer at level has no referrer of their own, or after level 5.
Upstream referrers do not use a share ratio — the entire marginal commission goes to the upstream referrer.
5c. Vault deduction
After processing all fee-paying users, the total of all commissions is deducted from the fee that would otherwise have accrued to the vault:
5d. Worked example
Setup. Five users form a referral chain, each with a commission rate override. User C has a fee share ratio of 40 %:
| User | Commission rate () | Fee share ratio () | Referrer |
|---|---|---|---|
| A | 30 % | — | — |
| B | 20 % | — | A |
| C | 15 % | 40 % | B |
| D | — | — | C |
| E | 40 % | — | D |
Trade. User D trades $10 m taker volume, pays $1,000 in fees. Assume so .
| Level | User | Receives | ||
|---|---|---|---|---|
| 1 (referee D) | D | — | — | |
| 1 (referrer C) | C | 15 % | — | |
| 2 | B | 20 % | 15 % | |
| 3 | A | 30 % | 20 % |
Total referral commissions = $300, equal to the highest commission rate in the chain (A’s 30 %) applied to the fee after the protocol cut.
Counter-example. Now User F signs up under User E (40 % commission rate) and trades. Since E’s 40 % exceeds every upstream referrer, no upstream commissions are paid — only E and F split the level-1 commission of .
6. Commission rate
The commission rate for a referrer determines the fraction of the post-protocol-cut fee that the referral system distributes at that level.
6a. Override
The chain owner can set (or remove) a per-user override via SetCommissionRateOverride. When present, this value is used directly, bypassing the volume-tiered lookup. Users with an override also bypass the requirement when calling SetFeeShareRatio.
6b. Volume-tiered lookup
When no override exists, is derived from the referrer’s direct referees’ 30-day rolling trading volume:
-
Load the referrer’s latest cumulative referral data; let be its field.
-
Load the cumulative data at ; let be its field.
-
Compute the windowed volume:
-
Walk the map and select the entry with the highest volume threshold .
-
If no tier qualifies, use .
Cumulative data is bucketed by day (see §7a), so the lookup loads the nearest bucket at or before the start of the window.
7. Data tracking
7a. Cumulative daily buckets
Each user has a UserReferralData record keyed by (user, day). The day is the block timestamp rounded down to midnight. Fields are cumulative (monotonically increasing), so a rolling window is computed by differencing two buckets.
| Field | Type | Description |
|---|---|---|
volume | UsdValue | User’s own cumulative trading volume. |
commission_shared_by_referrer | UsdValue | Total commission shared by this user’s referrer. |
referee_count | u32 | Number of direct referees. |
referees_volume | UsdValue | Cumulative trading volume of direct referees. |
commission_earned_from_referees | UsdValue | Total commission earned from direct referees’ trades. |
cumulative_active_referees | u32 | Cumulative count of daily active direct referees. Difference two buckets to get a windowed count. |
When a referred user trades:
- The referee’s bucket: and increment.
- The direct referrer’s bucket: and increment.
- Upstream referrers: only increments (and only if they received a non-zero commission).
7b. Per-referee statistics
For every (referrer, referee) pair, a RefereeStats record tracks:
| Field | Type | Description |
|---|---|---|
registered_at | Timestamp | When the referral was established. |
volume | UsdValue | Referee’s total trading volume. |
commission_earned | UsdValue | Commission earned by referrer from this referee. |
last_day_active | Timestamp | Last day (rounded to midnight) the referee traded. |
These records are multi-indexed for sorted queries by registered_at, volume, or commission_earned.
7c. Daily active direct referees
On the first trade of each day by a given direct referee, the referrer’s field in today’s cumulative bucket is incremented. Subsequent trades by the same referee on the same day do not increment it again. This is tracked via the field on RefereeStats: if , it is a new active day.
8. Parameters
These fields are part of the top-level Param struct (not a separate nested struct):
| Field | Type | Description |
|---|---|---|
referral_active | bool | Master switch. When false, referral commissions are skipped entirely. |
min_referrer_volume | UsdValue | Minimum lifetime trading volume to call SetFeeShareRatio. Bypassed for users with a commission rate override. |
referrer_commission_rates | RateSchedule | Volume-tiered commission rates. base = fallback rate; tiers = map of 30-day referees volume threshold → rate. Highest qualifying tier wins. |
Constants:
| Name | Value | Description |
|---|---|---|
MAX_FEE_SHARE_RATIO | 50 % | Maximum share ratio a referrer can set. |
MAX_REFERRAL_CHAIN_DEPTH | 5 | Maximum levels of upstream referrers walked during commission distribution. |
COMMISSION_LOOKBACK_DAYS | 30 | Rolling-window length (days) for the volume-tiered commission lookup. |
Risk Parameters
This chapter describes how to choose the risk parameters that govern the perpetual futures exchange — the global Param fields and per-pair PairParam fields defined in the perps contract. The goal is a systematic, reproducible calibration workflow that balances capital efficiency against tail-risk protection.
1. Margin ratios
The initial margin ratio (IMR) sets maximum leverage (). The maintenance margin ratio (MMR) sets the liquidation threshold. Both are per-pair.
The IMR also bounds closed-session index drift: while a market is closed, the index price can move at most from the last regular-session oracle price, so a thin off-hours book cannot walk the mark far from the true price.
1.1 Volatility-based derivation
Start from the asset’s historical daily return distribution:
-
Collect at least 1 year of daily log-returns.
-
Compute the 99.5th-percentile absolute daily return .
-
Apply a liquidation-delay factor (typically 2–3) to account for the time between the price move and the liquidation execution:
-
Set IMR as a multiple of MMR:
A higher gives more buffer between position entry and liquidation, reducing bad-debt risk at the cost of lower leverage.
1.2 Peer benchmarks
| Asset | Hyperliquid max leverage | Hyperliquid IMR | dYdX IMR |
|---|---|---|---|
| BTC | 40× | 2.5 % | 5 % |
| ETH | 25× | 4 % | 5 % |
| SOL | 20× | 5 % | 10 % |
| HYPE | 10× | 10 % | — |
1.3 Invariants
The following must hold for every pair:
The second constraint ensures a liquidated position can always cover the taker fee and liquidation fee from the maintenance margin cushion.
2. Fee rates
Three fee rates apply globally (not per-pair):
| Parameter | Role |
|---|---|
maker_fee_rate | Charged on limit-order fills; revenue to the vault |
taker_fee_rate | Charged on market / crossing fills; revenue to the vault |
liquidation_fee_rate | Charged on liquidation notional; revenue to insurance fund |
2.1 Sizing principles
- Taker fee should exceed the typical half-spread of the most liquid pair so the vault earns positive expected value on every fill against a taker.
- Maker fee can be zero, slightly positive, or negative (rebate). A zero maker fee attracts resting liquidity; a negative maker fee pays the maker on every fill. The absolute value of the maker fee rate must not exceed the taker fee rate, otherwise the exchange loses money on each trade.
- Liquidation fee must satisfy the invariant in §1.3. It should be large enough to fund the insurance pool but small enough that a liquidated user retains some margin when possible.
2.2 Industry benchmarks
| Exchange | Maker | Taker |
|---|---|---|
| Hyperliquid | 0.015% | 0.045% |
| dYdX | 0.01% | 0.05% |
| GMX | 0.05% | 0.07% |
3. Funding parameters
Funding anchors the perp price to the oracle. Two per-pair parameters and one global parameter control its behaviour (see Funding for mechanics):
| Parameter | Scope | Calibration guidance |
|---|---|---|
funding_period | Global | 1–8 hours. Shorter periods track the premium more tightly but increase gas cost. |
max_abs_funding_rate | Per-pair | See §3.1. |
impact_size | Per-pair | See §3.2. |
3.1 Max funding rate
The max daily funding rate limits how much a position can be charged per day. A useful rule of thumb:
where is the number of days it should take sustained max-rate funding to liquidate a fully leveraged position. For days and :
3.2 Impact size
The impact_size determines how deep the order book is walked to compute the premium. Set it to a representative trade size — large enough that the premium reflects real depth, small enough that thin books don’t produce zero premiums too often. A good starting point is 1–5% of the target max OI.
4. Capacity parameters
4.1 Max open interest
The maximum OI per side caps the exchange’s aggregate exposure:
where is the pair’s weight fraction and (2–5) is a safety multiplier reflecting how many times maintenance margin the vault could lose in a tail event.
Start conservatively — it is easy to raise OI caps but dangerous to lower them (existing positions above the cap cannot be force-closed).
4.2 Min order size
Prevents dust orders. Set to a notional value that covers at least 2× the gas cost of processing the order. Typical values: $10–$100.
4.3 Tick size
The minimum price increment. Too small increases book fragmentation; too large creates implicit spread. Rule of thumb:
For BTC at $60,000: tick sizes of $1–$10 are reasonable.
5. Vault parameters
The vault’s market-making policy is controlled by three per-pair parameters and two global parameters (see Vault for mechanics):
5.1 Half-spread
The half-spread should be calibrated to short-term intraday volatility so the vault earns a positive edge:
where is the standard deviation of intra-block price changes. A larger spread protects against adverse selection but reduces fill probability.
5.2 Max quote size
Caps the vault’s resting order size per side per pair. Should be consistent with max_abs_oi — the vault should not be able to accumulate more exposure than the system can handle:
5.3 Liquidity weight
Determines what fraction of total vault margin is allocated to each pair. Higher-volume, lower-risk pairs should receive higher weights. The sum of all weights equals vault_total_weight.
5.4 Cooldown period
Prevents LPs from front-running known losses. Should exceed the funding period and be long enough that vault positions cannot be manipulated by short-term deposit/withdraw cycles. Typical values: 7–14 days.
6. Operational limits
| Parameter | Calibration guidance |
|---|---|
max_unlocks | Number of concurrent withdrawal requests per user. 5–10 is typical; prevents griefing with many small unlocks. |
max_open_orders | Maximum resting limit orders per user across all pairs. 50–200; prevents order-book spam. |
7. Calibration workflow
The following checklist produces a complete parameter set from scratch:
-
Collect data — Gather ≥ 1 year of daily and hourly OHLCV data for each asset.
-
Compute volatility — For each asset, compute (daily 99.5th percentile absolute return) and (hourly return standard deviation).
-
Set margin ratios — Derive MMR from (§1.1), then IMR as a multiple of MMR. Cross-check against peer benchmarks (§1.2).
-
Set fees — Choose maker/taker/liquidation fee rates satisfying §2.1 and the invariant in §1.3.
-
Set funding — Pick
funding_period, derivemax_abs_funding_rate(§3.1), and calibrateimpact_size(§3.2). -
Size exposure — Set
max_abs_oifrom vault equity and tail-risk tolerance (§4.1). -
Set order constraints — Choose
min_order_sizeandtick_size(§4.2, §4.3). -
Configure vault — Set
vault_half_spread,vault_max_quote_size, andvault_liquidity_weightper pair (§5), andvault_cooldown_periodglobally. -
Backtest — Replay historical price data through the parameter set. Verify:
- Liquidations occur before bad debt in > 99% of cases.
- Vault PnL is positive over the test period.
- Funding rates do not hit the clamp for more than 5% of periods.
-
Deploy conservatively — Launch with the conservative profile (lower leverage, higher fees, lower OI caps). Tighten parameters toward the aggressive profile as the system proves stable and liquidity deepens.
API Reference
Every Dango deployment exposes its data and trading surface over two HTTP servers:
- the Live API — the consensus node, which talks to CometBFT, performs state transitions, and serves the latest chain state, transaction broadcasting, and real-time streaming; and
- the Archive API — the archive node, which ingests finalized blocks, analyzes them, and serves structured historical data backed by SQL databases.
The Live API speaks REST and WebSocket; the Archive API speaks REST. This chapter is a guide to both. The exhaustive, always-current endpoint reference lives in each server’s interactive documentation (see §1.2); this chapter covers the concepts that reference cannot express — the two-server model, the transaction lifecycle and signing, the WebSocket protocol, and the perps-specific semantics — and points to the interactive docs for the mechanical detail.
Deprecation notice. Dango nodes historically exposed a single GraphQL endpoint, which backed the frontend and most trading bots. GraphQL is being phased out in favor of the REST + WebSocket surface documented here, and the monolithic server is being split into the two servers above. New integrations should target REST + WebSocket. The GraphQL endpoint remains available during the transition but is unmaintained and will be removed; it is not documented here.
1. Overview
1.1 The two APIs
| I want to… | Use | Transport |
|---|---|---|
| Read the latest chain state (prices, my positions, order book, parameters) | Live API | REST POST /query, GET /perps/*, GET /account/* |
| Submit a transaction (trade, deposit, vault, account ops) | Live API | REST POST /simulate + POST /broadcast |
| Stream real-time data (fills, blocks, order book) | Live API | WebSocket GET /ws |
| Read structured history (past fills, transactions, events) | Archive API | REST feeds (/transactions/*, /events/*) |
| Read a full block at any height | Archive API | REST GET /blocks/{height} |
The division reflects where the data lives. The Live API answers from the node’s in-memory and latest-committed state — it is always current but keeps only a shallow window of history. The Archive API answers from SQL databases populated by analyzing every block — it reaches back to genesis but trails the chain tip slightly (see §6.1).
A quick mental model for readers coming from other venues: POST /query is the universal read (Dango’s analogue of Hyperliquid’s /info), and the GET /perps/* and GET /account/* routes are typed shortcuts over it; POST /broadcast is the universal write (the analogue of Hyperliquid’s /exchange).
1.2 Interactive reference
Both servers auto-generate an OpenAPI specification and a Swagger UI from the handlers themselves, served at /docs/ (with the raw spec at /openapi.json). This is the source of truth for paths, HTTP methods, query and path parameters, and status codes — it can never drift from the code, because it is generated from it. Hitting the base path of either server (GET /) redirects to its docs.
| API | Interactive docs |
|---|---|
| Live | https://<live-host>/docs/ |
| Archive | https://<archive-host>/docs/ |
See Constants for the concrete hostnames.
One caveat shapes how this chapter divides labor with Swagger: the Live API’s read handlers return their contract responses as opaque JSON, so Swagger documents their parameters but not their response shapes. Those response shapes are documented here, in the Types reference. The Archive API’s feeds return typed objects, so Swagger documents their responses in full; this chapter describes them only in outline. And OpenAPI cannot model WebSocket traffic at all, so the entire WebSocket section lives here.
1.3 Base URLs
See Constants for the mainnet and testnet hostnames of both servers, the WebSocket URL, and the testnet faucet.
2. Conventions
These conventions apply across both servers. Reading them first keeps the rest of the chapter terse.
2.1 Data types and encoding
All requests and responses are JSON. Contract-specific message keys are snake_case.
The perps exchange’s numeric types are signed fixed-point decimals with 6 decimal places, built on dango_types::Number. They are serialized as strings to avoid floating-point loss:
| Type alias | Dimension | Example usage | Example value |
|---|---|---|---|
Dimensionless | (pure scalar) | Fee rates, margin ratios, slippage | "0.050000" |
Quantity | quantity | Position size, order size, open interest | "-0.500000" |
UsdValue | usd | Margin, PnL, notional, fees | "10000.000000" |
UsdPrice | usd / quantity | Oracle price, limit price, entry price | "65000.000000" |
FundingPerUnit | usd / quantity | Cumulative funding accumulator | "0.000123" |
FundingRate | per day | Funding rate, funding rate cap | "0.000500" |
Additional scalar types:
| Type | Encoding | Description |
|---|---|---|
Uint128 | string | Large integer (e.g. vault shares) |
u64 | number or string | Gas limit, block height |
u32 | number | User index, account index, nonce |
Timestamp | string | Seconds since the Unix epoch, fixed-point decimal with up to 9 fractional digits (nanosecond precision), trailing zeros elided — so "1700000000", "1700000000.5", and "1700000000.123456789" are all valid. Some feeds instead render time as an RFC 3339 / ISO 8601 string; each is noted where it appears. |
Duration | string | Seconds, same fixed-point encoding as Timestamp |
2.2 Identifiers
| Type | Format | Example |
|---|---|---|
PairId | perp/<base><quote> | "perp/btcusd", "perp/ethusd" |
OrderId | Uint64 (string), system-assigned | "42" |
ClientOrderId | Uint64 (string), caller-assigned | "42" |
FillId | Uint64 (string), per-match | "17" |
Addr | lowercase hex, 0x-prefixed | "0x1234…abcd" |
Hash256 | 64-char uppercase hex, no 0x prefix | "A1B2C3D4…" |
UserIndex | u32 | 0 |
AccountIndex | u32 | 1 |
Username | 1–15 chars, [a-z0-9_] | "alice" |
Addr and Hash256 use different text dialects — an address is lowercase and 0x-prefixed, a hash is bare uppercase. EVM tooling typically displays hashes (e.g. Hyperlane message IDs) as 0x-prefixed lowercase; strip the prefix and uppercase the rest before passing one to Dango, or the request fails to deserialize.
2.3 Pagination
The two servers paginate differently, reflecting their backing stores.
Live API — contract-native paging. The enumerating GET /perps/* and GET /account/* reads forward the contract’s own start_after / limit scheme: iteration begins after the start_after key (a PairId, Addr, or Denom, exclusive) and returns at most limit entries. Omit both to start from the beginning with the contract’s default page size. To page, pass the last key of one response as the next start_after.
Archive API — keyset paging. Every Archive feed is newest-first and keyset-paginated on first (page size, max 50, default 50) and after (an opaque cursor). The response is an envelope:
{
"items": [ /* … */ ],
"pageInfo": {
"hasNextPage": true,
"endCursor": "6f7b…"
}
}
Roll the page’s endCursor back in as the next request’s after to fetch the following page; stop when hasNextPage is false. The cursor is opaque — treat it as a token, do not parse it.
2.4 Errors
Live API REST. Errors map to HTTP status codes: 400 (malformed body or failed query), 404 (single-item lookup found nothing), 503 (a contract address could not be resolved yet — the chain has not committed its genesis state; retry), 500 (transport failure to the consensus node). The body carries the error message.
Archive API REST. Errors are a JSON envelope with the matching status: 400 (malformed argument or cursor), 404 (absent resource), 500 (internal). For example, a bad cursor:
{ "error": "invalid cursor: …" }
WebSocket. Errors ride the socket as error-keyed frames; see §5.5 for the frame shape and the code catalog.
2.5 Casing
URL path segments are kebab-case (/perps/liquidity-depth, /perps/order/by-user). Query parameters keep the snake_case spelling and the wire encoding of the contract fields they forward to — so a numeric grug type stays string-encoded in the query string (bucket_size=10 parses as a UsdPrice), while a plain integer is unquoted (limit=20).
3. Live API — reading state
All reads in this section answer from the latest finalized state and require no authentication.
3.1 The universal query
POST /query runs any read-only query against the latest state. The body is a raw grug Query object; the response is the raw QueryResponse. This is the lowest-level, most general read — every typed shortcut in §3.2 desugars to one of these.
Example — query a contract:
curl -X POST https://<live-host>/query \
-H 'Content-Type: application/json' \
-d '{"wasm_smart": {"contract": "PERPS_CONTRACT", "msg": {"state": {}}}}'
The response is keyed by the request variant:
{ "wasm_smart": { /* the contract's State object */ } }
The body accepts any Query variant — for example {"app_config": {}}, {"balance": {"address": "0x…", "denom": "bridge/usdc"}}, or a smart-contract query as above. Some perps reads have no typed shortcut and are reached only this way — for instance a user’s cumulative trading volume:
{ "wasm_smart": { "contract": "PERPS_CONTRACT", "msg": { "volume": { "user": "0x…", "since": null } } } }
Multi-query. To fetch several pieces of state as one atomic snapshot at a single block height, wrap them in multi. This is the correct way to read, say, oracle prices and a user’s positions together — issuing two separate requests may straddle a block boundary and return an inconsistent pair.
{
"multi": [
{ "wasm_smart": { "contract": "ORACLE_CONTRACT", "msg": { "prices": {} } } },
{ "wasm_smart": { "contract": "PERPS_CONTRACT", "msg": { "user_state": { "user": "0x…" } } } }
]
}
The response is an array of results positionally matching the requests; each is {"Ok": …} or {"Err": "…"}, and one failure does not abort the others.
3.2 Typed read shortcuts
Typed GET routes wrap the most common perps and account reads: the parameters are validated and documented, the target contract address is resolved server-side (clients never pass it), and the response is the contract’s response object verbatim. Use the interactive docs for the exhaustive parameter detail; the response shapes are documented in the Types reference.
Perps reads — all resolve to a query against the perps contract:
| Route | Returns | Notes |
|---|---|---|
GET /perps/param | Param | Global parameters |
GET /perps/state | State | Global state |
GET /perps/pair-param?pair_id= | PairParam | One pair; 404 if unknown |
GET /perps/pair-params?start_after=&limit= | map of PairId → PairParam | All pairs, paginated |
GET /perps/pair-state?pair_id= | PairState | One pair; 404 if unknown |
GET /perps/pair-states?start_after=&limit= | map of PairId → PairState | All pairs, paginated |
GET /perps/liquidity-depth?pair_id=&bucket_size=&limit= | LiquidityDepthResponse | Order book depth (worked below) |
GET /perps/user-state?user=&include_*= | UserStateExtended | One user’s margin, positions, orders |
GET /perps/order/by-user?user= | map of OrderId → order | A user’s resting limit orders |
GET /perps/order/by-client-order-id?user=&client_order_id= | order | Resolve a client order id to its OrderId; 404 if none |
GET /perps/order/{order_id} | order | One resting limit order; 404 if none |
Each of the four one-item lookups (pair-param, pair-state, order/{id}, order/by-client-order-id) responds 404 when the item does not exist, rather than 200 with a null body.
Account reads:
| Route | Returns | Target |
|---|---|---|
GET /account/{address} | Account (its index + owning user index) | Account factory |
GET /account/{address}/user | User (index, username, keys, all accounts) | Account factory |
GET /account/{address}/seen-nonces | array of seen nonces | The account contract itself |
GET /account/{address}/session-seen-nonces?session_key= | array of seen nonces | The account contract itself |
GET /account/{address}/balances?start_after=&limit= | map of denom → amount | Chain-level (any address) |
The seen-nonces routes back nonce selection when building a transaction — see §4.2. URL-encode the session_key, as its base64 form may contain +, /, and =.
Worked example — order book depth
This one read is worked in full as the template for the rest. It also has a WebSocket twin (§5.3): same parameters, same response.
Server Live · Auth none · State latest finalized
REST — GET /perps/liquidity-depth
| Parameter | Type | Required | Description |
|---|---|---|---|
pair_id | PairId | yes | Trading pair, e.g. perp/ethusd |
bucket_size | UsdPrice | yes | Price-bucket granularity. Must be one of the pair’s configured bucket_sizes (see PairParam). |
limit | u32 | no | Max buckets per side; the contract’s default when omitted. |
curl 'https://<live-host>/perps/liquidity-depth?pair_id=perp/ethusd&bucket_size=10&limit=20'
WebSocket twin — channel perpsLiquidityDepth, same parameters plus interval (blocks between refreshes; ≥ 1, default 10; use 1 for per-block updates):
{"method":"subscribe","id":1,"subscription":{"type":"perpsLiquidityDepth","pair_id":"perp/ethusd","bucket_size":"10","limit":20,"interval":1}}
Response — both forms return the same object (the WebSocket frame wraps it as {blockHeight, response}):
{
"bids": {
"2999.000000": { "size": "12.500000", "notional": "37487.500000" },
"2998.000000": { "size": "8.200000", "notional": "24583.600000" }
},
"asks": {
"3001.000000": { "size": "10.000000", "notional": "30010.000000" }
}
}
bids and asks are maps from bucket price to that bucket’s aggregated size (absolute contracts) and notional (USD). Bids read best (highest) first in descending key order; asks best (lowest) first in ascending key order.
Errors — 400 unknown pair, or bucket_size not one of the pair’s configured sizes. 503 the chain has not committed genesis yet (retry).
See also — Order matching for how the book forms; PairParam for the valid bucket_sizes.
Note. A handful of GraphQL-only reads — searching users by public key, and enumerating a user’s accounts — have no REST twin yet. They remain on the deprecated GraphQL endpoint and will be replaced; the account address forms above cover the common cases in the meantime.
3.3 Blocks and node status
The Live API serves the recent tail of blocks from the node’s on-disk cache. For deep history, use the Archive API (§6.2).
| Route | Returns |
|---|---|
GET /block/info · GET /block/info/{height} | Block metadata + transactions (a Block) |
GET /block/result · GET /block/result/{height} | The block’s execution outcome (a BlockOutcome) |
GET /block/full · GET /block/full/{height} | Both together (a FullBlock, {block, outcome}) |
GET /block/full/range?from=&to= | A gap-free run of full blocks, capped at 20 per request |
GET /up | Liveness + indexing status |
The /block/full/{height} shape matches the WebSocket fullBlock channel (§5.2). /up proves the chain is answering and the indexer database is reachable: is_running is whether the latest finalized block is younger than 30 seconds, and indexed_block_height is the highest block the indexer has written.
4. Live API — transactions
Every write — trading, margin, vault, and account operations — is a signed transaction (Tx) broadcast to the Live API. This section covers the lifecycle, the signing scheme, and the catalog of messages. The mechanics behind each operation live in the dedicated chapters (Order matching, Vault, …); here we document the wire format.
4.1 Transaction lifecycle
graph LR
A[Compose<br/>messages] --> B[Fetch metadata<br/>chain_id, user_index, nonce]
B --> C[Simulate<br/>POST /simulate]
C --> D[Set gas limit]
D --> E[Build SignDoc]
E --> F[Sign]
F --> G[Broadcast<br/>POST /broadcast]
- Compose messages — build the contract execute message(s) (§4.7–§4.9).
- Fetch metadata — the chain ID, the sender’s
user_index, and the next nonce (see §4.2). - Simulate — dry-run to estimate gas (§4.5).
- Set gas limit — the simulation’s
gas_used, plus ~770,000 for signature-verification overhead. - Build the SignDoc — assemble
{sender, gas_limit, messages, data}(§4.3). - Sign — with the chosen key.
- Broadcast — submit the signed
Tx(§4.6).
4.2 Transaction structure and nonces
A transaction wraps one or more messages with authentication metadata and a credential:
{
"sender": "0x1234…abcd",
"gas_limit": 1500000,
"msgs": [
{ "execute": { "contract": "PERPS_CONTRACT", "msg": { /* … */ }, "funds": {} } }
],
"data": { "user_index": 0, "chain_id": "dango-1", "nonce": 42, "expiry": null },
"credential": { /* … */ }
}
| Field | Type | Description |
|---|---|---|
sender | Addr | Account sending the transaction |
gas_limit | u64 | Maximum gas units |
msgs | [Message] | Non-empty list, executed atomically — all succeed or all fail |
data | Metadata | {user_index, chain_id, nonce, expiry} (see below) |
credential | Credential | Cryptographic proof of authorization (§4.3) |
The primary message is execute, targeting a contract with a snake_case msg and optional funds (a map of denom → amount string, {} for none):
{ "execute": { "contract": "PERPS_CONTRACT", "msg": { "trade": { "deposit": {} } }, "funds": { "bridge/usdc": "1000000000" } } }
USDC uses 6 decimals (1 USDC = 1000000 base units); all bridged tokens use the bridge/ prefix.
Nonces. Dango uses unordered nonces with a sliding window, similar to Hyperliquid’s scheme. Nonces are tracked per signer, in two namespaces: a standard credential (master key) draws from one account-wide window; a session credential draws from its own window, keyed by the session public key — so several clients (e.g. one bot per session key) can drive one account concurrently without colliding. Within a window, the account keeps the 20 most recently seen nonces; a transaction is accepted if its nonce is unused, newer than the oldest in the window, and no greater than the newest seen plus 100.
Pick the next nonce client-side by querying the relevant window against the sender’s own account contract (§3.2):
- A standard signer reads
GET /account/{address}/seen-noncesand usesmax + 1(or0if empty). - A session signer reads
GET /account/{address}/session-seen-nonces?session_key=<base64>and usesmax + 1of that array; if that window is empty, it falls back to the standard window’smax + 1, or0if the account has never transacted.
4.3 Signing
The credential wraps a StandardCredential (a key identifier + signature) or a SessionCredential (§4.4). Three signature schemes are supported:
Passkey (Secp256r1 / WebAuthn):
{ "standard": { "key_hash": "A1B2…", "signature": { "passkey": { "authenticator_data": "<base64>", "client_data": "<base64>", "sig": "<base64>" } } } }
sig is a 64-byte Secp256r1 signature; client_data is the base64-encoded WebAuthn client-data JSON (its challenge is the base64url of the SHA-256 of the SignDoc); authenticator_data is the base64-encoded authenticator data.
Secp256k1:
{ "standard": { "key_hash": "A1B2…", "signature": { "secp256k1": "<base64>" } } }
A 64-byte Secp256k1 signature, base64-encoded.
EIP-712 (Ethereum wallets):
{ "standard": { "key_hash": "A1B2…", "signature": { "eip712": { "typed_data": "<base64>", "sig": "<base64>" } } } }
sig is a 65-byte signature (64-byte Secp256k1 + 1-byte recovery id); typed_data is the base64-encoded EIP-712 typed-data JSON.
The SignDoc. The signed payload mirrors the transaction but replaces credential with the structured data:
{
"sender": "0x1234…abcd",
"gas_limit": 1500000,
"messages": [ /* … */ ],
"data": { "chain_id": "dango-1", "expiry": null, "nonce": 42, "user_index": 0 }
}
To sign: serialize the SignDoc to canonical JSON (keys sorted alphabetically), hash with SHA-256, and sign the hash. For Passkey, that hash is the WebAuthn challenge; for EIP-712, the SignDoc is mapped to a typed-data structure and signed via eth_signTypedData_v4.
4.4 Session keys
Session keys allow delegated signing without the master key on every transaction. A SessionCredential carries the session key, its expiry, a SignDoc signature by the session key, and an authorization — the SessionInfo signed by the master key:
{
"session": {
"session_info": { "session_key": "<base64>", "expire_at": "1700000000" },
"session_signature": "<base64>",
"authorization": { "key_hash": "A1B2…", "signature": { /* standard signature */ } }
}
}
4.5 Simulating and gas
POST /simulate dry-runs an UnsignedTx (the transaction without a credential) and returns its TxOutcome:
curl -X POST https://<live-host>/simulate \
-H 'Content-Type: application/json' \
-d '{"sender": "0x1234…abcd", "msgs": [ /* … */ ], "data": {"user_index": 0, "chain_id": "dango-1", "nonce": 42, "expiry": null}}'
{ "gas_limit": 100000000, "gas_used": 750000, "result": { "Ok": null }, "events": { /* … */ } }
Simulation skips signature verification, so add 770,000 gas (the Secp256k1 verification cost) to gas_used when setting the final gas_limit. result is {"Ok": null} on success or {"Err": {"error": "…"}} on failure; the reported gas_limit is the simulation ceiling, not the value to use — use gas_used.
4.6 Broadcasting
POST /broadcast submits a signed Tx to the mempool and returns a BroadcastTxOutcome. This is a mempool receipt, not block inclusion:
curl -X POST https://<live-host>/broadcast \
-H 'Content-Type: application/json' \
-d '{"sender": "0x1234…abcd", "gas_limit": 1500000, "msgs": [ /* … */ ], "data": { /* … */ }, "credential": { /* … */ }}'
{ "tx_hash": "…", "check_tx": { "gas_limit": 1500000, "gas_used": 12000, "result": { "Ok": null }, "events": { /* … */ } } }
An accepted transaction returns 200 with check_tx.result = {"Ok": null}; a mempool-rejected transaction also returns 200, but with check_tx.result an {"Err": …} (it never entered a block). Only a transport failure to the consensus node returns 500. To confirm block inclusion, poll the transaction hash, or watch the event stream. A client already holding a WebSocket connection can broadcast over it instead (§5.4).
4.7 Account and key messages
New users, subaccounts, and key changes go through the account factory contract (ACCOUNT_FACTORY_CONTRACT), not the perps contract.
Register a user — a two-step process. First call register_user on the factory, using the factory address itself as sender and null for data and credential:
{
"sender": "ACCOUNT_FACTORY_CONTRACT",
"gas_limit": 1500000,
"msgs": [
{
"execute": {
"contract": "ACCOUNT_FACTORY_CONTRACT",
"msg": {
"register_user": {
"key": { "secp256r1": "<base64>" },
"key_hash": "A1B2…",
"seed": 12345,
"signature": { "passkey": { "authenticator_data": "<base64>", "client_data": "<base64>", "sig": "<base64>" } }
}
},
"funds": {}
}
}
],
"data": null,
"credential": null
}
The master account is created inactive (spam prevention); the new address is returned in the transaction events. Second, send it at least the minimum_deposit (10 USDC = 10000000 bridge/usdc on mainnet), from an existing Dango account or bridged in via Hyperlane, and the account activates on receipt. To confirm a bridged deposit arrived, query the mailbox’s delivered method with the Hyperlane message id via POST /query (Hash256 uppercase, no 0x); a true result is permanent and means the funds are spendable.
Register a subaccount — from an existing account of the user (max 5 accounts per user):
{ "execute": { "contract": "ACCOUNT_FACTORY_CONTRACT", "msg": { "register_account": {} }, "funds": {} } }
Update a key — add or remove a key on the user profile:
{ "execute": { "contract": "ACCOUNT_FACTORY_CONTRACT", "msg": { "update_key": { "key_hash": "A1B2…", "key": { "insert": { "secp256k1": "<base64>" } } } }, "funds": {} } }
Use "key": "delete" to remove.
Set the username — a one-time, cosmetic label (1–15 chars, [a-z0-9_]), not used in any business logic:
{ "execute": { "contract": "ACCOUNT_FACTORY_CONTRACT", "msg": { "update_username": "alice" }, "funds": {} } }
Address derivation. A master account’s address is ripemd160(sha256(deployer ‖ code_hash ‖ seed ‖ key_hash ‖ key_tag ‖ key)) (122-byte preimage); a subaccount’s is ripemd160(sha256(deployer ‖ code_hash ‖ account_index)) (56-byte preimage). See Constants for deployer (the factory address) and the account code_hash; key_tag is 0 Secp256r1, 1 Secp256k1, 2 Ethereum.
Testnet faucet. On testnet, in place of the activating deposit, call the public faucet to mint test tokens to a fresh account: GET https://<faucet-host>/mint/{address}. See Constants for the host. It mints USDC, ETH, BTC, SOL, and XRP, and the account activates on receipt. There is no faucet on mainnet.
4.8 Trading messages
All trading messages target the perps contract under the trade key: {"execute": {"contract": "PERPS_CONTRACT", "msg": {"trade": {…}}, "funds": {…}}}. Only the inner trade object is shown below.
Deposit margin — attach USDC as funds; it is credited to user_state.margin at $1 per USDC. An optional to routes the deposit to another perp account (defaults to the sender):
{ "deposit": {} }
Withdraw margin — converts USD back to USDC (floor-rounded) and transfers it to the sender:
{ "withdraw": { "amount": "500.000000" } }
Submit a market order — fills immediately against the book (IOC behavior); any unfilled remainder is discarded, and the transaction reverts if nothing fills. size is signed (positive = buy, negative = sell):
{ "submit_order": { "pair_id": "perp/btcusd", "size": "0.100000", "kind": { "market": { "max_slippage": "0.010000" } }, "reduce_only": false } }
Submit a limit order — rests on the book. time_in_force is "GTC" (default), "IOC", or "POST"; client_order_id is an optional caller-assigned id (unique among the sender’s resting orders) that enables same-block cancel:
{ "submit_order": { "pair_id": "perp/btcusd", "size": "-0.500000", "kind": { "limit": { "limit_price": "65000.000000", "time_in_force": "GTC", "client_order_id": "42" } }, "reduce_only": false } }
Both order forms accept optional tp / sl child orders (take-profit / stop-loss), each {trigger_price, max_slippage, size} with size: null closing the whole position — attached to the resulting position after fill. For time-in-force and matching mechanics, see Order matching.
Cancel an order — by system id, by client id, or all:
{ "cancel_order": { "one": "42" } }
{ "cancel_order": { "one_by_client_order_id": "42" } }
{ "cancel_order": "all" }
Batch update — apply a non-empty list of submit/cancel actions atomically; later actions observe earlier ones, and any failure reverts the whole batch. The list length must not exceed Param.max_action_batch_size; conditional orders are not allowed in a batch. Useful for atomic quote replacement (cancel: all then re-submits):
{ "batch_update_orders": [ { "cancel": "all" }, { "submit": { /* SubmitOrderRequest */ } } ] }
Submit a conditional order (TP/SL) — always reduce-only, executed as a market order when the oracle crosses trigger_price. trigger_direction is "above" (oracle ≥ trigger) or "below" (oracle ≤ trigger); size: null closes the whole position:
{ "submit_conditional_order": { "pair_id": "perp/btcusd", "size": "-0.100000", "trigger_price": "70000.000000", "trigger_direction": "above", "max_slippage": "0.020000" } }
Cancel a conditional order — by (pair_id, trigger_direction), all for a pair, or all:
{ "cancel_conditional_order": { "one": { "pair_id": "perp/btcusd", "trigger_direction": "above" } } }
{ "cancel_conditional_order": { "all_for_pair": { "pair_id": "perp/btcusd" } } }
{ "cancel_conditional_order": "all" }
Liquidate — permissionless; force-closes all positions of an under-margined user. Reverts unless the target is below maintenance margin. Sent under the maintain key, not trade:
{ "execute": { "contract": "PERPS_CONTRACT", "msg": { "maintain": { "liquidate": { "user": "0x5678…ef01" } } }, "funds": {} } }
For liquidation and ADL mechanics, see Liquidation & ADL.
4.9 Vault messages
The counterparty vault provides liquidity and earns trading fees. Messages target the perps contract under the vault key.
Add liquidity — transfer margin from the trading account into the vault, minting shares at the vault’s current NAV. min_shares_to_mint is an optional slippage guard:
{ "execute": { "contract": "PERPS_CONTRACT", "msg": { "vault": { "add_liquidity": { "amount": "1000.000000", "min_shares_to_mint": "900000" } } }, "funds": {} } }
Remove liquidity — burn shares immediately; the USD value enters a cooldown queue and is credited back to trading margin after Param.vault_cooldown_period:
{ "execute": { "contract": "PERPS_CONTRACT", "msg": { "vault": { "remove_liquidity": { "shares_to_burn": "500000" } } }, "funds": {} } }
For vault mechanics, see Vault.
5. Live API — real-time WebSocket
The Live API serves real-time data over a single multiplexed WebSocket at GET /ws. One socket carries any number of subscriptions and one-shot requests. This section is the authoritative reference for the protocol, because OpenAPI cannot model WebSocket traffic — the interactive docs list the endpoint but cannot describe its frames.
5.1 Protocol
Client messages are tagged by method; server messages are tagged by channel. A subscribe carries a client-chosen integer id — the subscription handle, echoed on the acknowledgement and on every frame the subscription produces, and used to unsubscribe. So one socket can carry several subscriptions (e.g. multiple perpsEvents feeds with different filters), demultiplexed by id.
Client → server:
{"method": "subscribe", "id": 1, "subscription": {"type": "perpsEvents", "pairIds": ["perp/btcusd"]}}
{"method": "subscribe", "id": 2, "subscription": {"type": "blockInfo"}}
{"method": "subscribe", "id": 5, "subscription": {"type": "query", "query": {"app_config": {}}, "interval": 5}}
{"method": "unsubscribe", "id": 1}
{"method": "broadcast", "id": 7, "tx": { /* signed Tx */ }}
{"method": "query", "id": 8, "query": {"balance": {"address": "0x…", "denom": "bridge/usdc"}}}
{"method": "ping", "id": 9}
method | Description |
|---|---|
subscribe / unsubscribe | Open / close a subscription by id |
broadcast | Submit a signed Tx over the socket (§5.4) |
query | Run a one-shot read over the socket (§5.4) |
ping | Application heartbeat (id optional) |
Server → client:
{"channel": "subscriptionResponse", "id": 1, "data": {"method": "subscribe", "type": "perpsEvents"}}
{"channel": "perpsEvents", "id": 1, "data": { /* … */ }}
{"channel": "perpsEvents", "id": 1, "error": {"code": "resync", "message": "…"}}
{"channel": "query", "id": 5, "data": {"blockHeight": 100001, "response": { /* … */ }}}
{"channel": "pong", "id": 9}
{"channel": "error", "error": {"code": "badRequest", "message": "…"}}
Every frame on a subscription’s channel carries either a data payload or an error (co-located so a feed’s failure arrives on the same channel its data does — see §5.5); a client branches on which key is present. A connection-level problem with no subscription to attribute it to (an unparseable frame, or an unsubscribe for an unknown id) uses the dedicated error channel.
Heartbeat. The server pings every 20 seconds and closes a socket it has heard nothing from for 60 seconds. Let your WebSocket stack answer those pings, or send {"method": "ping"} yourself.
5.2 Streaming channels
Four channels stream one frame per finalized block. All are served from an in-memory window of recent blocks, so they are not for deep history — backfill from the Archive API (§6). Each accepts an optional since (replay retained blocks from that height on connect; omit for live-only).
type / channel | Frame data | Description |
|---|---|---|
perpsEvents | {blockHeight, createdAt, events[]} | The block’s perps-contract events (order lifecycle, fills, liquidations, deleveraging), filterable |
blockInfo | {height, timestamp, hash} | Block metadata — the lightest way to follow the tip |
block | {info, txs} | A block without its execution outcome (matches GET /block/info/{height}) |
fullBlock | {block, outcome} | A block in full (matches GET /block/full/{height}) |
perpsEvents filters. Five optional filters — eventTypes, pairIds, users, orderIds, clientOrderIds — AND together. Omitting a filter matches everything on that field; passing an empty array matches nothing. Values match the event’s canonical string form (pass the same 0x-address / decimal-id forms the API returns elsewhere). A client_order_id is unique only per sender, so combine clientOrderIds with users to single out one trader’s order. Only blocks with at least one matching event are delivered:
{"method":"subscribe","id":1,"subscription":{"type":"perpsEvents","since":100000,"eventTypes":["order_filled","liquidated"],"pairIds":["perp/btcusd"],"users":["0x1234…abcd"]}}
{"channel":"perpsEvents","id":1,"data":{"blockHeight":100001,"createdAt":"2026-06-18T00:00:00Z","events":[{"idx":0,"eventType":"order_filled","user":"0x1234…abcd","pairId":"perp/btcusd","orderId":"100","clientOrderId":"42","data":{ /* … */ }}]}}
Each event carries its ordinal idx, its eventType, the indexed user / pairId / orderId / clientOrderId (when present), and the raw data payload (same shapes as the Events reference).
5.3 Standing-query channels
A standing query re-runs a read once per block whose height is a multiple of interval (default 10; use 1 for every block), streaming {blockHeight, response} frames. The initial snapshot arrives immediately, then ticks align absolutely (height % interval == 0), so identical subscriptions share one execution per tick.
The generic form takes any grug Query:
{"method":"subscribe","id":5,"subscription":{"type":"query","query":{"wasm_smart":{"contract":"PERPS_CONTRACT","msg":{"user_state":{"user":"0x…"}}}},"interval":5}}
Four typed aliases are the WebSocket twins of the GET /perps/* reads — same snake_case parameters, plus interval, with the contract address resolved server-side. Each frame’s response is the raw contract response, exactly what the REST twin returns:
type / channel | REST twin |
|---|---|
perpsPairState | GET /perps/pair-state |
perpsUserState | GET /perps/user-state |
perpsOrdersByUser | GET /perps/order/by-user |
perpsLiquidityDepth | GET /perps/liquidity-depth |
{"method":"subscribe","id":6,"subscription":{"type":"perpsUserState","user":"0x…","include_all":true,"interval":1}}
{"channel":"perpsUserState","id":6,"data":{"blockHeight":100005,"response":{ /* UserStateExtended */ }}}
Standing queries are live-only — historical state cannot be re-queried, so there is no since replay; on reconnect, resubscribe and take the fresh snapshot. For incremental order updates prefer the push-based perpsEvents feed over polling perpsOrdersByUser.
5.4 One-shot requests over WebSocket
broadcast and query also ride the socket as one-shot request/response, so a client already holding a connection needn’t open a separate HTTP request. Each is answered by a single frame on its own channel, tagged with the request id.
query returns the raw QueryResponse (same shapes as POST /query). Success is a data frame; a failed query is an error frame with code queryFailed — QueryResponse is success-only, so any failure is an error (a failed one-shot query ends nothing and its id is free to reuse):
{"channel":"query","id":8,"data":{"balance":{"denom":"bridge/usdc","amount":"12345"}}}
broadcast returns the BroadcastTxOutcome (same shape as POST /broadcast). Note the asymmetry with query: a mempool-rejected tx is still a data frame (its rejection rides check_tx.result); only a transport failure to the consensus node is an error frame with code broadcastFailed.
5.5 Reconnect and errors
The block-backed channels (perpsEvents, blockInfo, block, fullBlock) carry a block height on every frame. Track the last height you saw and, on reconnect, resubscribe with since set to that height plus one. Standing query subscriptions are live-only (resubscribe for a fresh snapshot). Subscriptions are not persisted across reconnects — resend your subscribe messages.
A subscription-scoped error rides that subscription’s own channel and id; a connection-level error uses the error channel. Error codes:
code | Meaning |
|---|---|
resync | since predates the retained window, or the feed lagged past it. The subscription ends; reconnect with a newer since and backfill the gap from the Archive API or the /block/* REST routes. |
queryFailed | A standing or one-shot query failed (unknown contract, contract error, …). |
broadcastFailed | Transport failure to the consensus node on a broadcast. |
tooManyRequests | The server’s subscription limit was reached. |
badRequest | The message could not be parsed, or the id is already in use. |
unknownSubscription | An unsubscribe referenced an id with no open subscription. |
{"channel":"perpsEvents","id":1,"error":{"code":"resync","message":"resync required: requested from block 100 but the oldest retained block is 900"}}
6. Archive API — structured history
The Archive API serves deep history from SQL databases populated by analyzing every finalized block. It is REST-only.
6.1 Feed model
Every feed is newest-first and keyset-paginated (first / after / endCursor — see §2.3). A feed returns lightweight indexed columns plus, hydrated from the block store, the heavy payloads (a transaction’s full tx / outcome, an event’s decoded data). Because the archive ingests blocks after they finalize, its frontier trails the chain tip slightly; for the live tip, use the Live API.
Feed response schemas are fully documented in the Archive interactive docs (the handlers return typed objects, so OpenAPI captures them); this section gives the routes and their meaning.
6.2 Blocks
| Route | Returns |
|---|---|
GET /blocks/{height} | The full block at height as {block, outcome} (same shape as the Live API’s /block/full/{height}); 404 if the store does not hold it |
GET /blocks/latest | The block at the store’s contiguous frontier — the highest H with every height in [1, H] stored, i.e. the newest block servable together with all history below it |
During a backfill, /blocks/latest climbs from the bottom and trails the chain tip; once the store is gap-free, it is the tip.
6.3 Activity feeds
Transactions:
| Route | Returns |
|---|---|
GET /transactions/{hash} | Every unit whose transaction bytes hash to hash, newest-first, un-paginated (the hash is not unique — byte-identical resubmissions can recur in later blocks) |
GET /transactions/involving/{address}?role=&kind= | Units the address sent or participated in (the union by default), newest-first, paginated. role (sender / participant) and kind (transaction / cron) narrow it |
Events:
| Route | Returns |
|---|---|
GET /events?type=&involved= | Events filtered by type (a comma-separated list) and/or involved (a participant address). At least one is required — an unfiltered feed has no index anchor |
GET /events/contract?contract=&user=&names= | The contract events of one emitting contract (required), optionally narrowed to a participant user and/or a comma-separated names list |
GET /events/perps?user=&names= | Shortcut for /events/contract pre-bound to the deployment’s perps address — the go-to feed for deep perps history (past fills, liquidations, order lifecycle). Same user / names filters |
Use /events/perps to backfill the gap after a WebSocket perpsEvents resync (the two surface the same perps events; the WebSocket feed is the live window, this feed is the durable history). Event data payloads follow the Events reference.
6.4 Perps market history
Candlestick (OHLCV) data, per-pair 24h statistics, recent trades, and fee/revenue aggregates are not yet available over REST or WebSocket. These historical analytics currently exist only on the deprecated GraphQL endpoint; where they will live (Live API vs. Archive API) and their exact shape are undecided, and they will be replaced by a future method. This note will be updated when that lands.
7. Events reference
The perps contract emits the following events. Stream them live over the WebSocket perpsEvents channel (§5.2) or read their history from the Archive /events/perps feed (§6.3). Field names are the event’s raw payload keys.
Margin:
| Event | Fields | Description |
|---|---|---|
deposited | user, amount | Margin deposited |
withdrew | user, amount | Margin withdrawn |
Vault:
| Event | Fields | Description |
|---|---|---|
liquidity_added | user, amount, shares_minted | Deposited to the vault |
liquidity_unlocking | user, amount, shares_burned, end_time | Withdrawal initiated (cooldown) |
liquidity_released | user, amount | Cooldown completed, funds released |
Orders:
| Event | Fields | Description |
|---|---|---|
order_filled | order_id, pair_id, user, fill_price, fill_size, closing_size, opening_size, realized_pnl, realized_funding?, fee, client_order_id?, fill_id?, is_maker?, remaining_order_size?, remaining_position_size? | Order partially or fully filled |
order_persisted | order_id, pair_id, user, limit_price, size, client_order_id? | Limit order placed on the book |
order_resized | order_id, pair_id, user, old_size, new_size, client_order_id? | Reduce-only order shrunk in place |
order_removed | order_id, pair_id, user, reason, client_order_id? | Order removed from the book |
Conditional orders:
| Event | Fields | Description |
|---|---|---|
conditional_order_placed | pair_id, user, trigger_price, trigger_direction, size, max_slippage | TP/SL created |
conditional_order_triggered | pair_id, user, trigger_price, trigger_direction, oracle_price | TP/SL triggered by a price move |
conditional_order_removed | pair_id, user, trigger_direction, reason | TP/SL removed |
Liquidation:
| Event | Fields | Description |
|---|---|---|
liquidated | user, pair_id, adl_size, adl_price, adl_realized_pnl, adl_realized_funding?, remaining_position_size? | Position liquidated in a pair |
deleveraged | user, pair_id, closing_size, fill_price, realized_pnl, realized_funding?, remaining_position_size? | Counter-party hit by ADL |
bad_debt_covered | liquidated_user, amount, insurance_fund_remaining | Insurance fund absorbed bad debt |
Referral:
| Event | Fields | Description |
|---|---|---|
fee_distributed | payer, payer_addr, protocol_fee, vault_fee, commissions[] | Trading fee split across protocol, vault, and the referral chain |
referral_set | referrer, referee | Referral relationship registered |
Notes on the order/liquidation fields.
- Fields marked
?are optional and may benullon events emitted by older node versions (realized_fundingbefore v0.17.0,fill_idbefore v0.15.0,is_makerbefore v0.16.0,remaining_order_size/remaining_position_sizebefore v0.26.0). A consumer must tolerate their absence. fill_idgroups the two sides of one order-book match: a taker crossing a resting maker emits twoorder_filledevents sharing onefill_id, one withis_maker: trueand one withis_maker: false.realized_pnlreports the closing PnL on the fill (price movement on the closed portion). Funding settled on the pre-existing position is reported separately asrealized_funding(from v0.17.0). Trading fees are separate again, infee; ADL and deleverage fills incur no fee.remaining_position_sizeis the affected position’s size after the event (positive long, negative short, zero if closed) — track a position’s live size directly instead of accumulatingclosing_size/opening_sizedeltas.remaining_order_sizeis the order’s unfilled remainder after the fill.order_removed.reasonis aReasonForOrderRemoval:filled,canceled,position_closed,self_trade_prevention,liquidated,deleveraged,slippage_exceeded,price_band_violation, orslippage_cap_tightened.
For liquidation and ADL mechanics, see Liquidation & ADL; for fee splits, see Order matching §8 and Referral.
8. Types reference
The response objects of the perps read shortcuts mirror the contract types in dango/exchange/types/src/perps.rs, the authoritative source. The consumer-facing fields are documented below; the global-parameter structs also carry vault-market-making and governance knobs, elided here and marked in the source.
Param (global parameters) — trading-relevant fields:
| Field | Type | Description |
|---|---|---|
max_open_orders | usize | Max resting limit orders per user, across all pairs |
max_action_batch_size | usize | Max actions in one batch_update_orders |
maker_fee_rates / taker_fee_rates | RateSchedule | Volume-tiered fee rates ({base, tiers}; highest qualifying tier wins) |
protocol_fee_rate | Dimensionless | Fraction of each fee routed to the treasury |
liquidation_fee_rate | Dimensionless | Insurance-fund fee on liquidations |
funding_period | Duration | Interval between funding collections |
vault_cooldown_period | Duration | Vault-withdrawal cooldown |
vault_deposit_cap | UsdValue | null | Max total vault margin (null = uncapped) |
trading_enabled | bool | When false, order placement, margin deposits, and vault deposits are rejected; withdrawals, cancellations, and liquidations still work |
referral_active | bool | Whether referral commissions are active |
Plus max_unlocks, liquidation_buffer_ratio, vault_total_weight, min_referrer_volume, and referrer_commission_rates — see the source.
PairParam (per-pair parameters) — trading-relevant fields:
| Field | Type | Description |
|---|---|---|
tick_size | UsdPrice | Minimum price increment for limit orders |
min_order_size | UsdValue | Minimum notional (reduce-only exempt) |
max_abs_oi | Quantity | Max open interest per side |
max_abs_funding_rate | FundingRate | Daily funding-rate cap |
initial_margin_ratio | Dimensionless | Margin to open (e.g. 0.05 = 20× max leverage) |
maintenance_margin_ratio | Dimensionless | Margin to stay open (liquidation threshold) |
max_limit_price_deviation | Dimensionless | Max deviation of a limit price from oracle at submission |
max_market_slippage | Dimensionless | Max max_slippage on a market or TP/SL order |
impact_size | UsdValue | Notional used for impact-price computation |
bucket_sizes | [UsdPrice] | Valid granularities for liquidity-depth queries |
Plus the vault market-making knobs (vault_liquidity_weight, vault_half_spread, vault_max_quote_size, the skew factors, funding_rate_multiplier) — see the source. For margin and leverage, see Risk.
| Field | Type | Description |
|---|---|---|
last_funding_time | Timestamp | Last funding collection |
vault_share_supply | Uint128 | Total vault shares |
insurance_fund | UsdValue | Insurance fund balance (may be negative) |
treasury | UsdValue | Accumulated protocol fees |
| Field | Type | Description |
|---|---|---|
long_oi / short_oi | Quantity | Total long / short open interest |
funding_per_unit | FundingPerUnit | Cumulative funding accumulator |
funding_rate | FundingRate | Current per-day rate (positive = longs pay) |
index_price | UsdPrice | Mark for margin, PnL, funding, liquidation; bounded to ±initial_margin_ratio of oracle_price while the market is closed |
last_index_time | Timestamp | When index_price was last updated |
oracle_price | UsdPrice | Last regular-session oracle price; anchors the order price band and the off-hours index bound |
last_oracle_time | Timestamp | When oracle_price was last updated |
For funding, see Funding.
UserState / UserStateExtended (one user). The base fields (from user-state without any include_*):
| Field | Type | Description |
|---|---|---|
margin | UsdValue | Deposited margin |
vault_shares | Uint128 | Vault shares owned |
positions | map of PairId → Position | Open positions |
unlocks | [Unlock] | Pending vault withdrawals ({end_time, amount_to_release}) |
reserved_margin | UsdValue | Margin reserved for resting limit orders |
open_order_count | usize | Number of resting limit orders |
The include_* flags (include_equity, include_available_margin, include_maintenance_margin, include_unrealized_pnl, include_unrealized_funding, include_liquidation_price, or include_all) add computed fields — top-level equity, available_margin, maintenance_margin, and per-position unrealized_pnl, unrealized_funding, liquidation_price. Any field not requested is null.
| Field | Type | Description |
|---|---|---|
size | Quantity | Positive = long, negative = short |
entry_price | UsdPrice | Average entry price |
entry_funding_per_unit | FundingPerUnit | Funding accumulator at last modification |
conditional_order_above | ConditionalOrder | null | TP/SL triggering when oracle ≥ trigger_price |
conditional_order_below | ConditionalOrder | null | TP/SL triggering when oracle ≤ trigger_price |
A ConditionalOrder is {order_id, size, trigger_price, max_slippage}, with size: null meaning close the whole position.
LiquidityDepthResponse — {bids, asks}, each a map of UsdPrice → {size, notional}; see the worked example.
Order responses — the resting-limit-order reads (order/*) share the fields pair_id, size, limit_price, reduce_only, reserved_margin, created_at, and the optional tp / sl child orders. They differ at the edges: order/{order_id} also carries user and client_order_id; the by-user items carry client_order_id (and are already keyed by order_id in the map); the by-client-order-id response carries the resolved order_id.
Enums. OrderKind is {"market": {"max_slippage": "…"}} or {"limit": {"limit_price": "…", "time_in_force": "…", "client_order_id": "…"}}. TimeInForce is "GTC" | "IOC" | "POST". TriggerDirection is "above" | "below". Key types are {"secp256r1": "<base64>"}, {"secp256k1": "<base64>"}, or {"ethereum": "0x…"}.
Constants
This page collects the constants of Dango’s mainnet and testnet deployments: API endpoints, chain IDs, contract addresses, and the Hyperlane bridge contracts on EVM chains.
Endpoints
Each network runs two API servers: the Live API (consensus node — latest chain state, transaction broadcasting, real-time WebSocket) and the Archive API (archive node — structured historical data). See the API reference for which server serves what. Each server’s interactive documentation (Swagger UI) is at /docs/, with the raw OpenAPI spec at /openapi.json.
| API | Network | HTTP | WebSocket |
|---|---|---|---|
| Live | Mainnet | https://api-mainnet.dango.zone | wss://api-mainnet.dango.zone/ws |
| Live | Testnet | https://api-testnet.dango.zone | wss://api-testnet.dango.zone/ws |
| Archive | Mainnet | https://api-archive-mainnet.dango.zone | — |
| Archive | Testnet | https://api-archive-testnet.dango.zone | — |
The GraphQL endpoint previously served at …/graphql on the same hosts is deprecated (see the API reference); prefer the REST + WebSocket surface above.
The testnet faucet is served separately at https://faucet-testnet.dango.zone/mint (see the API reference). There is no faucet on mainnet.
Chain IDs
| Network | CometBFT Chain ID | EIP-155 Chain ID | Hyperlane Domain ID |
|---|---|---|---|
| Dango | dango-1 | - | 88888888 |
| Dango Testnet | dango-testnet-1 | - | 88888887 |
| Ethereum | - | 1 | 1 |
| Sepolia | - | 11155111 | 11155111 |
| Arbitrum | - | 42161 | 42161 |
| Arbitrum Sepolia | - | 421614 | 421614 |
Dango contract addresses
| Name | Mainnet | Testnet |
|---|---|---|
ACCOUNT_FACTORY_CONTRACT | 0x18d28bafcdf9d4574f920ea004dea2d13ec16f6b | 0x18d28bafcdf9d4574f920ea004dea2d13ec16f6b |
MAILBOX_CONTRACT | 0x974e57564ed3ed7d8f99d0c359fd03f3d78259c7 | 0x974e57564ed3ed7d8f99d0c359fd03f3d78259c7 |
ORACLE_CONTRACT | 0xcedc5f73cbb963a48471b849c3650e6e34cd3b6d | 0xcedc5f73cbb963a48471b849c3650e6e34cd3b6d |
PERPS_CONTRACT | 0x90bc84df68d1aa59a857e04ed529e9a26edbea4f | 0xf6344c5e2792e8f9202c58a2d88fbbde4cd3142f |
Code hashes
| Name | Value |
|---|---|
| Single-signature account | d86e8112f3c4c4442126f8e9f44f16867da487f29052bf91b810457db34209a4 |
The code hash is the same on mainnet and testnet.
Hyperlane deployments
Dango bridges assets to and from EVM chains via Hyperlane warp routes. The tables below list the relevant contracts on each chain: Ethereum and Arbitrum serve Dango mainnet; Sepolia and Arbitrum Sepolia serve Dango testnet. The first three rows of each table are contracts deployed by other parties (Circle, Hyperlane), included for reference; the rest are deployed by us.
Ethereum
| Contract | Description | Address |
|---|---|---|
FiatTokenProxy | USDC token | 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB481 |
Mailbox | Hyperlane mailbox | 0xc005dc82818d67AF737725bD4bf75435d065D2392 |
StaticMessageIdMultisigIsmFactory | Hyperlane multisig ISM factory | 0xfA21D9628ADce86531854C2B7ef00F07394B0B693 |
TransparentUpgradeableProxy | Warp-route proxy (USDC) | 0xd05909852aE07118857f9D071781671D12c0f36c |
HypERC20Collateral | Warp-route implementation (USDC) | 0xE071653043828C9923c79B04B077358D94Fc84f9 |
TransparentUpgradeableProxy | Warp-route proxy (ETH) | 0x9d259aa1eC7324C7433b89d2935b08C30f3154cB |
HypNative | Warp-route implementation (ETH) | 0x9d0ea335355dA17eE89E50DF43AB823416Cf73d4 |
ProxyAdmin | Proxy administrator | 0x613942eff27c6886bb2a33a172cdaf03a009e601 |
TimelockController | Timelock (48 hr) | 0xdEc7A9906d143288cD412C0e627bA3B9a91fC8A1 |
SafeProxy | Dango team multisig | 0x94115077A1Dbe2944935186625D57e2e10Fb807D |
Sepolia
| Contract | Description | Address |
|---|---|---|
FiatTokenProxy | USDC token | 0x1c7D4B196Cb0C7B01d743Fbc6116a902379C72381 |
Mailbox | Hyperlane mailbox | 0xfFAEF09B3cd11D9b20d1a19bECca54EEC28847664 |
StaticMessageIdMultisigIsmFactory | Hyperlane multisig ISM factory | 0xFEb9585b2f948c1eD74034205a7439261a9d27DD5 |
TransparentUpgradeableProxy | Warp-route proxy (USDC) | 0x0d8c3516Df20cfF940E479Ea2d8C7d1Dd0A706ac |
HypERC20Collateral | Warp-route implementation (USDC) | 0x26BC0E68467D88cedB5A3793618C8F6586512706 |
TransparentUpgradeableProxy | Warp-route proxy (ETH) | 0xE3109F83BeF36AecE35870ee1B2e07A5DD12CFA9 |
HypNative | Warp-route implementation (ETH) | 0xb4513d39e6839bf7C1f01a65e294bAB8B16b5887 |
ProxyAdmin | Proxy administrator | 0x59cf4f33ce42afa957b93e68031f07bf6d299d60 |
TimelockController | Timelock (5 min) | 0x256363b42F874D08A92ab857622753053006D4b3 |
SafeProxy | Dango team multisig | 0x94115077A1Dbe2944935186625D57e2e10Fb807D |
Arbitrum
| Contract | Description | Address |
|---|---|---|
FiatTokenProxy | USDC token | 0xaf88d065e77c8cC2239327C5EDb3A432268e58311 |
Mailbox | Hyperlane mailbox | 0x979Ca5202784112f4738403dBec5D0F3B9daabB96 |
StaticMessageIdMultisigIsmFactory | Hyperlane multisig ISM factory | 0x12Df53079d399a47e9E730df095b712B0FDFA7917 |
TransparentUpgradeableProxy | Warp-route proxy (USDC) | 0x9d0ea335355dA17eE89E50DF43AB823416Cf73d4 |
HypERC20Collateral | Warp-route implementation (USDC) | 0x34DC3F292fC04e3Dcc2830AC69bb5d4cd5E8F654 |
ProxyAdmin | Proxy administrator | 0x947303E34C1a2B97fB00C68C1cC4cA97B3361fE6 |
TimelockController | Timelock (48 hr) | 0x2e289c81537C8B096432042d549992e56f4feFF4 |
SafeProxy | Dango team multisig | 0x94115077A1Dbe2944935186625D57e2e10Fb807D |
Arbitrum Sepolia
| Contract | Description | Address |
|---|---|---|
FiatTokenProxy | USDC token | 0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d1 |
Mailbox | Hyperlane mailbox | 0x598facE78a4302f11E3de0bee1894Da0b2Cb71F88 |
StaticMessageIdMultisigIsmFactory | Hyperlane multisig ISM factory | 0xF7F0DaB0BECE4498dAc7eb616e288809D44993719 |
TransparentUpgradeableProxy | Warp-route proxy (USDC) | 0x9d0ea335355dA17eE89E50DF43AB823416Cf73d4 |
HypERC20Collateral | Warp-route implementation (USDC) | 0x34DC3F292fC04e3Dcc2830AC69bb5d4cd5E8F654 |
ProxyAdmin | Proxy administrator | 0x947303E34C1a2B97fB00C68C1cC4cA97B3361fE6 |
TimelockController | Timelock (5 min) | 0x8CD37a701a61fcEa0eb02460431275dD2Fc54bCE |
Ownership chain
The ownership structure is identical on every chain (except Arbitrum Sepolia, where an EOA fills the SafeProxy role, because the Safe frontend doesn’t support that chain):
graph TD
7[SafeProxy] -->|proposer| 6[TimelockController]
6 -->|owns| 1[Proxy USDC]
6 -->|owns| 3[Proxy ETH]
6 -->|owns| 5[ProxyAdmin]
5 -->|administers| 1
5 -->|administers| 3
1 -->|delegatecalls| 2[HypERC20Collateral]
3 -->|delegatecalls| 4[HypNative]