Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Greeting

banner

Welcome, stranger!

This is the Dango Book, all you need to know about the one app for everything DeFi.

What is Dango?

Dango is a DeFi-native Layer-1 blockchain built from the ground up for trading. Where most blockchains are general-purpose platforms that happen to host DeFi apps, Dango inverts this: the chain is purpose-built around a DEX, with every infrastructure decision made to serve traders.

Dango describes itself as “the one app for everything DeFi” — combining spot trading, perpetual futures, vaults, and lending within a single interface and a single unified margin account.

Problems Dango Solves

  1. Capital Inefficiency

    On today’s platforms, collateral is siloed. A trader on Aave must deposit separately from their dYdX position, their Uniswap LP, and so on. Dango’s Unified Margin Account lets a single pool of collateral back spot trades, perpetual positions, and lending simultaneously.

  2. Execution Quality & MEV

    AMMs suffer from slippage and impermanent loss by design. Orders are also vulnerable to MEV — bots that front-run transactions for profit at the user’s expense. Dango’s on-chain Central Limit Order Book (CLOB) with periodic batch auctions eliminates both problems.

  3. Terrible UX

    DeFi onboarding is notoriously difficult: manage private keys, pay gas in native tokens, bridge assets across chains, juggle multiple wallets. Dango introduces Smart Accounts — a keyless system where accounts are secured by passkeys (biometrics) instead of seed phrases. Gas is paid in USDC.

  4. Developer Inflexibility

    EVM and Cosmos SDK give developers limited control over gas mechanics, scheduling, and account logic. Dango’s Grug execution environment gives developers programmable gas fees, on-chain cron jobs, and customizable account logic — without hard forks.

Key Stats

MetricValue
X / Twitter followers~111,000
Testnet unique users180,000+
Testnet transactions1.75M+
Seed funding raised$3.6M
Alpha Mainnet launchJanuary 2026

What Makes Dango Different

Most chains compete on speed (TPS). Dango competes on product design — specifically by building its own execution environment (Grug) co-designed with the application layer. This “app-driven infra development” enables features impossible or prohibitively expensive on EVM chains:

  • On-chain CLOB with sub-second batch settlement
  • Protocol-native cron jobs for automatic funding rate calculation
  • Smart account architecture enabling biometric signing
  • Zero gas fees
  • Unified cross-collateral margin for all trading products

Security Audit Guide

This guide documents the architecture of Grug (the blockchain state machine) and Dango (the smart contract system built on Grug), targeting security auditors with blockchain and DeFi experience. It covers:

  1. Grug Architecture – Database, Jellyfish Merkle Tree, storage layer, the App/ABCI interface, virtual machines, and gas metering.
  2. Smart Contract Semantics – Entry points, context types, message passing, storage abstractions, authentication model, and the testing framework.
  3. Dango Contract System – Each smart contract (bank, accounts, oracle, DEX, perps, taxman, gateway, etc.), their state layout, access control, and inter-contract interactions.
  4. Indexer & Node – The indexer pipeline, SQL schema, GraphQL API, and the CLI that wires everything together.

Repository layout

DirectoryContents
grug/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, dex, perps, taxman, gateway, vesting, warp, upgrade, types, cli
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                                        │
│   grug/app (ABCI + state transitions)                        │
│   grug/db  (RocksDB persistence)                             │
│   grug/vm/rust (native contract execution, no sandbox)       │
│   grug/jellyfish-merkle (state commitment)                   │
│   dango/* system contracts (bank, taxman, accounts, etc.)    │
│   indexer/* (read-only; cannot affect consensus)             │
└────────────────────── ▼ ─────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│  UNTRUSTED: Third-Party WASM Contracts                       │
│   Executed inside grug/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. The WasmVm path exists for future third-party contract support. Both paths share the same Vm trait interface.

Grug Architecture

Grug 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

Grug 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 (grug/db/disk/src/db.rs):

Column FamilyPurpose
defaultMetadata (latest committed version)
state_commitmentJMT nodes (hashed key-value pairs)
state_storageChain-level state (non-contract keys)
wasm_storageContract internal storage (see below)
preimages (IBC feature)Key-hash to raw-key mapping for ICS-23 proofs

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() {
// grug/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:

Optionwasm_storagestate_storage
Memtable size16 MiB (fewer flushes; contracts are less delete-heavy)2 MiB (frequent flushes; chain state is delete-heavy from cronjobs)
Prefix extractor24 bytes (b"wasm" + 20-byte address)4 bytes (grug 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() {
// grug/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 as PendingData and 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 IncorrectVersion error 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() {
// grug/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() {
// grug/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 (grug/jellyfish-merkle/). The tree provides:

  • Cryptographic state root (SHA-256) included in the ABCI app_hash signed 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

  1. Receive a Batch of prehash key-value operations.
  2. Hash all keys and values with SHA-256.
  3. Sort by key hash.
  4. Recursively update tree nodes (only changed paths are rewritten).
  5. Record orphaned nodes for future pruning.
  6. Return new root hash.

Proof verification

#![allow(unused)]
fn main() {
// grug/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() {
// grug/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

grug/storage/ provides type-safe, namespace-aware abstractions over raw key-value storage.

Abstractions

TypePurposeKey file
Item<T>Single valuestorage/src/item.rs
Map<K, T>Key-value mapping with iterationstorage/src/map.rs
Set<K>Membership setstorage/src/set.rs
Counter<T>Monotonic counterstorage/src/counter.rs
IndexedMap<K, T, I>Map with secondary indexesstorage/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 (grug/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 (grug/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:

  1. taxman.withhold_fee() – Can the sender afford the gas fee?
  2. 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:

  1. 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.
  2. Process transactions (see Transaction lifecycle):
    • taxman.withhold_fee()must succeed (withholds gas fee).
    • sender.authenticate() – If fails, skip to step 5.
    • Execute messages one-by-one, atomically.
    • taxman.finalize_fee()must succeed (settles the fee).
  3. Run cronjobs. Each scheduled cronjob runs in an isolated buffer; failures are silently discarded.
  4. Clean up orphaned codes. Codes not referenced by any contract and older than max_orphan_age are removed.
  5. Flush. db.flush_but_not_commit(batch) – stages all changes, computes root hash, but does not persist to disk yet.
  6. 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() {
// grug/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() {
// grug/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 (grug/app/src/gas/costs.rs):

OperationCost
db_read588 + 2/byte
db_write1176 + 18/byte
db_scan (setup)588
db_next (per iteration)18
secp256k1_verify770,000
secp256r1_verify1,880,000
ed25519_verify410,000
ed25519_batch_verify1,340,000 + 188,000/sig
Hash functions0 base + 5–28/byte (varies)
Wasmer operation1 gas/op

See Gas for benchmark methodology.

5. Virtual Machine Layer

Two VM implementations share the same trait:

#![allow(unused)]
fn main() {
// grug/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() {
// grug/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, taxman, accounts, perps, DEX, 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() {
// grug/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 (grug/vm/wasm/src/gatekeeper.rs): Validates WASM modules at compilation time. Allowed/denied features:

FeatureAllowedRationale
FloatsYesRequired for JSON deserialization
Bulk memory opsYesRequired by Rust 1.87+
Reference typesNoCould enable memory leaks
SIMDNoNon-deterministic floats
ThreadsNoNon-deterministic
Exception handlingNoUnstable 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 (grug/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, ed25519_verify, ed25519_batch_verify, secp256k1_pubkey_recover
  • Hashes: sha2_256, sha2_512, sha3_256, sha3_512, keccak256, blake2s_256, blake2b_512, blake3
  • Cross-contract queries: query_chain
  • Debug logging: debug

Each host function call:

  1. Reads data from WASM linear memory.
  2. Charges gas (based on operation + data size).
  3. Enforces state_mutable – writes rejected during query execution.
  4. 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

  1. 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.

  2. The chain finalizes block 12344 normally. At block 12345, during FinalizeBlock, the App reads NEXT_UPGRADE from state and checks the binary’s cargo version.

  3. 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.

  4. The node operator replaces the binary with version 1.2.3 and restarts.

  5. CometBFT retries FinalizeBlock for block 12345. The App sees the version now matches, runs the upgrade handler (App::upgrade_handler) if one is registered, clears NEXT_UPGRADE, records the upgrade in PAST_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_UPGRADE item 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 Grug 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 PointContextSignaturePurpose
instantiateMutableCtxfn(MutableCtx, M) -> Result<Response>One-time initialization on deploy
executeMutableCtxfn(MutableCtx, M) -> Result<Response>State-mutating operations
queryImmutableCtxfn(ImmutableCtx, M) -> Result<Binary>Read-only queries
migrateSudoCtxfn(SudoCtx, M) -> Result<Response>Code upgrade migration
receiveMutableCtxfn(MutableCtx) -> Result<Response>Receive token transfers
replySudoCtxfn(SudoCtx, M, SubMsgResult) -> Result<Response>Callback after submessage

System entry points

Entry PointContextSignaturePurpose
authenticateAuthCtxfn(AuthCtx, Tx) -> Result<Response>Tx authentication (account contracts)
withhold_feeAuthCtxfn(AuthCtx, Tx) -> Result<Response>Fee withholding (taxman only)
finalize_feeAuthCtxfn(AuthCtx, Tx, TxOutcome) -> Result<Response>Fee settlement (taxman only)
bank_executeSudoCtxfn(SudoCtx, BankMsg) -> Result<Response>Token ops (bank only)
bank_queryImmutableCtxfn(ImmutableCtx, BankQuery) -> Result<BankQueryResponse>Balance queries (bank only)
cron_executeSudoCtxfn(SudoCtx) -> Result<Response>Periodic automation

Entry points are defined using the #[grug::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() {
// grug/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, withhold_fee, finalize_fee)
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_onSubmsg succeedsSubmsg failsSubmsg state on failure
SuccessCall reply()Abort entire txReverted (entire tx)
ErrorDo nothingCall reply()Reverted
AlwaysCall reply()Call reply()Reverted
NeverDo nothingAbort entire txReverted (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

grug/math/ provides overflow-safe fixed-point arithmetic:

TypeDescription
Uint128, Uint256Unsigned integers
Int128, Int256Signed integers
Udec128, Udec256Unsigned decimals (18 decimal places)
Dec128, Dec256Signed 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/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 and DEX contracts:

AliasDimensions (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

Grug 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 (grug/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

Grug uses account abstraction – every user has a dedicated smart contract instance that handles authentication.

Account lifecycle

  1. Registration. User calls the account factory with a signed RegisterUser message.
  2. Account creation. The factory deploys an account contract instance, registers the user’s public key, and optionally activates the account.
  3. Transaction signing. User constructs a SignDoc (sender, messages, nonce, expiry), signs it, and submits a Tx.
  4. 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

TypeCurveUse case
PasskeySecp256r1WebAuthn / browser passkeys
Secp256k1Secp256k1Standard crypto wallets
Eip712Secp256k1Ethereum wallet compatibility

8. FFI Layer

grug/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): Uses Region structs (offset + capacity) to describe buffers in WASM linear memory. allocate and deallocate are auto-provided entry points.

9. Testing Framework

TestSuite

grug/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, taxman, accounts, oracle, DEX, perps) in a single genesis block. This enables end-to-end tests that exercise inter-contract interactions.

10. Procedural Macros

grug/macros/ provides:

  • #[grug::export] – Generates WASM FFI wrappers for entry points. Only needed for WasmVm contracts; RustVm contracts register entry points directly.
  • #[grug::derive(Serde, Borsh)] – Derives standard traits (Serialize, Deserialize, BorshSerialize, BorshDeserialize, Clone, Debug, PartialEq, Eq).
  • #[grug::event("name")] – Registers an event type with a canonical name.
  • #[grug::index_list(PK, T)] – Implements IndexList trait for IndexedMap secondary indexes.

Dango Contract System

Dango is a suite of smart contracts deployed on Grug that together form a perpetual futures exchange, spot DEX, oracle, token ledger, bridge aggregator, and account system. All contracts are first-party and execute natively via RustVm.

1. Shared Types (dango/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 taxman: Addr,
    pub warp: Addr,
}
}

Other shared types include authentication types (Key, Signature, Credential, SignDoc, Metadata), fee types (FeeType), and price types (PrecisionlessPrice, PrecisionedPrice).

2. Bank (dango/bank/)

The bank contract manages all token balances, transfers, mints, and burns.

State layout

StorageKeyValuePurpose
NAMESPACE_OWNERSPart (denom segment)AddrWho can mint/burn tokens under this namespace
METADATASDenomMetadataToken name, symbol, decimals
SUPPLIESDenomUint128Total supply per denom
BALANCES(Addr, Denom)Uint128Account balances
ORPHANED_TRANSFERS(Addr, Addr)CoinsDead-letter transfers to non-existent contracts

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 to ORPHANED_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.

Access control

Namespace ownership is assigned once by the chain owner and cannot be overwritten. For example, the perps contract owns the perp/ namespace, the DEX owns the dex/ namespace.

Security considerations

  • Orphaned transfers: If a contract is instantiated but not yet registered, mints to it become dead letters. Recovery requires an explicit RecoverTransfer call. 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/account-factory/)

Creates and manages user accounts.

State layout

StorageKeyValue
CODE_HASHHash256 (account contract code)
NEXT_USER_INDEXCounter<UserIndex>
NEXT_ACCOUNT_INDEXCounter<AccountIndex>
USERSUserIndexUser { name, accounts, keys }
(Index) by_keyHash256UserIndex (MultiIndex)
(Index) by_accountAddrUserIndex (UniqueIndex)
(Index) by_nameUsernameUserIndex (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

  1. User sends tokens to the account factory (deposit ≥ min_deposit).
  2. User sends a RegisterUser message with a signed RegisterUserData containing the chain ID.
  3. Factory verifies signature, creates a new User record, 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/account/)

Single-signature account contract, one instance per user account.

State

StorageValue
STATUSAccountStatus (Inactive / Active / Frozen)
SEEN_NONCESBTreeSet<Nonce> (last 20 nonces)

Authentication flow

When the host receives a transaction, it calls the sender account’s authenticate():

  1. Deserialize the credential from tx.credential.
  2. Verify the account is Active (or in Simulate mode).
  3. Verify the nonce is valid (not seen, not too far ahead).
  4. Verify the signature against the signing key registered in the factory.
  5. Return Response.

5. Taxman (dango/taxman/)

Handles gas fee collection.

State

StorageKeyValue
CONFIGConfig { fee_denom, fee_rate }
WITHHELD_FEE(Config, Uint128)

Fee flow

  1. withhold_fee(tx) – Called before authentication. Computes gas_limit * fee_rate and reserves the fee from the sender’s balance.
  2. finalize_fee(tx, outcome) – Called after execution. Computes actual fee based on gas_used * fee_rate, refunds the difference, and transfers the fee to the treasury.

6. Oracle (dango/oracle/)

Price feed aggregation for spot and derivatives trading.

State

StorageKeyValue
PRICE_SOURCESDenomPriceSource
PYTH_TRUSTED_SIGNERS[u8] (pubkey)Timestamp (expiry)
PYTH_PRICESPythIdPrecisionlessPrice

Price structure

#![allow(unused)]
fn main() {
pub struct PrecisionlessPrice {
    pub humanized_price: Udec128,  // e.g., 50000.0 for $50k BTC
    pub timestamp: Timestamp,       // Feed age
    pub precision: u8,              // Decimal places
}
}

Trust model

  • The oracle trusts Pyth network signers whose public keys are registered in PYTH_TRUSTED_SIGNERS with 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 (DEX, perps) enforce staleness checks before using prices.

7. Spot DEX (dango/dex/)

AMM + order book hybrid spot trading exchange.

State

StorageKeyValue
PAUSEDbool
PAIRS(Denom, Denom)PairParams
RESERVES(Denom, Denom)CoinPair (pool reserves)
ORDERSOrderKeyOrder (IndexedMap)
NEXT_ORDER_IDCounter<OrderId>
DEPTHSDepthKey(Udec128_6, Udec128_6)

Pool types

  • Standard (XYK): x * y = k constant-product formula.
  • Stable swap: Linear-weighted AMM for pegged assets.

Both types charge a pool fee (to LPs) and a protocol fee (to taxman).

LP tokens

LP token denom: dex/pool/{base_denom}/{quote_denom}. A permanent minimum liquidity lock of 1,000 tokens prevents first-depositor manipulation.

Order types

  • Market orders (IOC – immediate or cancel).
  • Limit orders (GTC, IOC, or Post-Only).
  • Orders matched by price-time priority (best price first, then earliest OrderId).

Oracle integration

The DEX enforces MAX_ORACLE_STALENESS (500ms) before using oracle prices for swaps. Stale oracle prices cause swaps to be rejected.

8. Perpetual Futures DEX (dango/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/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)

  1. Load user state and pair state/params.
  2. Validate: minimum size (or reduce-only exempt), tick alignment, slippage vs oracle (market orders), max open orders.
  3. Decompose order into closing portion (vs existing position) and opening portion (new risk).
  4. For opening portion: check OI constraints (long_oi + size ≤ max_abs_oi) and initial margin (available_margin ≥ required).
  5. Match against resting orders in the order book (which may include orders placed by the vault or by other traders).
  6. For fills: compute trading fee (volume-tiered), apply funding (entry_funding_per_unit = current), settle PnL.
  7. Resting (unfilled) portion: reserve margin, place on book with TP/SL children.
  8. Post-trade validation: available_margin ≥ 0 (reverts entire order otherwise).

Funding (cron.rscore/funding.rs)

  1. Sample order book impact prices (best bid/ask for impact_size notional).
  2. Compute midpoint premium vs oracle price.
  3. Clamp to max_abs_funding_rate per day, scale by elapsed time.
  4. Update pair_state.funding_per_unit += delta.
  5. Funding settles lazily on position close: accrued = size × (current_cumulative - entry_cumulative).

Liquidation (maintain/liquidate.rs)

  1. Compute equity = margin + Σ(unrealized_pnl) - Σ(accrued_funding).
  2. Compute maintenance margin = Σ(|size| × price × mm_ratio).
  3. If equity < maintenance_margin: a. Cancel all resting orders (refund reserved margin). b. Close enough of the user’s positions to restore equity ≥ maintenance_margin (with a buffer controlled by liquidation_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

OperationWho can call
Configure (params)Chain owner only
SubmitOrder, Deposit, WithdrawAny active account
LiquidateAnyone (permissionless)
AddLiquidity, RemoveLiquidityAny active account
cron_executeChain (automatic)

9. Gateway (dango/gateway/)

Bridge aggregator for cross-chain token transfers.

State

StorageKeyValue
ROUTES(Addr, Remote)Denom
REVERSE_ROUTES(Denom, Remote)Addr
RATE_LIMITSBTreeMap<Denom, RateLimit>
WITHDRAWAL_FEES(Denom, Remote)Uint128
OUTBOUND_QUOTASDenomUint128

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.

10. Vesting (dango/vesting/)

Token vesting with linear schedules and optional cliffs.

State

StorageKeyValue
UNLOCKING_SCHEDULESchedule
POSITIONSAddrPosition

11. Upgrade (dango/upgrade/)

Handles state migrations during chain upgrades. Example: migrating PairParam to add new vault skew fields with zero defaults.

12. Inter-Contract Interaction Map

┌──────────────┐  RegisterUser ┌──────────┐  mint   ┌──────┐
│ Account      │◄──────────────│ Account  │────────►│ Bank │
│ (per user)   │               │ Factory  │         │      │
└──────┬───────┘               └────┬─────┘         └──┬───┘
       │ authenticate               │ referral         │
       │                            ▼                  │
       │                      ┌──────────┐             │
       │                      │  Perps   │◄────────────┘ force_transfer
       │                      │  DEX     │  (PnL settlement)
       │                      └────┬─────┘
       │                           │ query prices
       │                           ▼
       │                      ┌──────────┐
       │                      │  Oracle  │
       │                      └──────────┘
       │
       │ withhold/finalize fee
       ▼
┌──────────┐
│  Taxman  │
└──────────┘

Key interaction patterns:

  • Perps ↔ Bank: Force-transfers for margin deposits/withdrawals and PnL settlement.
  • Perps/DEX → Oracle: Price queries with staleness checks.
  • Account Factory → Perps: Referral registration on user creation.
  • Account → Factory: Key and nonce lookups during authentication.

13. Security-Relevant Properties

Invariants to verify

  1. Bank solvency: Σ(BALANCES[addr][denom]) = SUPPLIES[denom] for all denoms.
  2. Perps margin: For any non-liquidatable user, equity ≥ maintenance_margin.
  3. OI balance: pair_state.long_oi - pair_state.short_oi = Σ(positions.size) across all users for each pair.
  4. Vault shares: STATE.vault_share_supply = Σ(user_state.vault_shares).
  5. Reserved margin consistency: user_state.reserved_margin = Σ(resting_order.margin_required) for that user.
  6. Order count: user_state.open_order_count = count of resting orders for that user.

Trust boundaries within Dango

ContractTrustsTrusted by
BankNamespace owners (unconditionally)Everyone (for balance queries)
OraclePyth signers (governance-managed)DEX, Perps (for price feeds)
TaxmanAccounts (for fee handling)
PerpsOracle (prices), Bank (balances)Users (for margin custody)
Account FactoryAccounts (for key lookups)
GatewayHyperlane validatorsBank (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() {
// grug/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 affect app_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)

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:       indexer_cache::Cache,
    pub sql:        indexer_sql::Indexer,
    pub clickhouse: 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 (indexer/sql/)

Schema

TableKey ColumnsPurpose
blocksheight (unique), hash, app_hashBlock headers
transactionshash (unique), block_height, sender, statusTx metadata
messagestransaction_id, contract_addr, method_nameSub-tx messages
eventstransaction_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 (indexer/sql/src/event_cache.rs). Configurable window size. Used for fast GraphQL lookups without DB round-trips.

3. Cache Indexer (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

Optional S3 sync with bitmap tracking of uploaded blocks.

4. Dango-Specific Writes (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 (indexer/httpd/)

Actix-web HTTP server with async-graphql:

ParameterValue
Workers8
Max connections10,000
Backlog8,192
Max blocking threads16

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, BlockEventsDataLoader
  • TransactionEventsDataLoader, TransactionMessagesDataLoader
  • EventTransactionDataLoader, 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 + optional S3 sync)
    ├── 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

  1. Set HTTP shutdown flags (return 503 for new requests).
  2. Wait 100ms for propagation.
  3. Shutdown indexer (wait for async tasks to complete).
  4. 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 (+ optional S3 sync)                     │
│  SqlIndexer → PostgreSQL (generic + Dango tables)      │
│  ClickhouseIndexer → analytics DB                      │
└────────────────── ▼ ───────────────────────────────────┘
                    │
┌───────────────────┴────────────────────────────────────┐
│ Public API (GraphQL/HTTP)                              │
│  Read-only queries over indexed data                   │
│  No state mutation capability                          │
└────────────────────────────────────────────────────────┘

Network exposure

ComponentDefault PortExposure
CometBFT RPC26657Public (read-only)
ABCI26658Localhost only (CometBFT ↔ App)
Dango GraphQL8000Configurable
Metrics8001Internal
PostgreSQL5432Private

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:

TimeAuditorSubjectLinks
2025-09-29SherlockAudit contest on Dango spot DEXcontest
2025-04-07ZellicHyperlanereport
2025-04-02ZellicAccount and authentication systemreport
2024-10-25ZellicJellyfish Merkle Tree (JMT)report
Q4 2024Informal SystemsFormal specification of JMT in Quintblogspec

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:

  1. Values the settlement currency at a fixed $1 per unit (no oracle lookup).
  2. Converts the token amount to USD: .
  3. Increment userState.margin by .

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:

  1. Computes (see §8), clamped to zero.
  2. Ensures the requested amount does not exceed .
  3. Deducts the amount from userState.margin.
  4. Converts USD to settlement currency tokens at the fixed $1 rate (floor-rounded to base units).
  5. 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_slippage relative to the oracle price. Any unfilled remainder after matching is discarded (unless nothing filled at all, which is an error).
  • Limit — specifies a limit_price and a time_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:

FieldDescription
userOwner address
sizeSigned quantity (positive = buy, negative = sell)
reduce_onlyIf true, can only close an existing position
reserved_marginMargin 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 directionCurrent positionClosing sizeOpening size
Buy (+)Short (−)
Sell (−)Long (+)
Same directionAny

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:

  1. — the taker is fully filled.
  2. 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:

  1. The maker (resting) order is cancelled (removed from the book).
  2. The taker’s open_order_count and reserved_margin are decremented.
  3. 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:

RoleRateExample value
Takertaker_fee_rate0.1 %
Makermaker_fee_rate0 %

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:

  1. The order is removed from the book.
  2. reserved_margin is released (subtracted from the user’s total).
  3. open_order_count is decremented.
  4. 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:

  1. Average premium:

  2. Clamp to the configured bounds:

  3. Funding delta — scale by the actual elapsed interval and oracle price:

  4. Accumulate into the pair-level running total:

  5. 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

FieldTypeDescription
funding_periodDurationMinimum time between funding collections.
impact_sizeUsdValueNotional 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_rateFundingRateSymmetric clamp applied to the average premium before scaling to a delta. Prevents runaway rates during prolonged skew.
funding_rate_multiplierDimensionlessScalar 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.

  1. For every open position, compute its MM contribution:

  2. Sort positions by MM contribution descending (largest first).

  3. 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 a market order against the on-chain order book. 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: The fill price at which the liquidated user’s total equity would be exactly zero:

Since equity is typically negative for liquidatable users, the bankruptcy price is worse than the oracle price — the counter-party receives a favourable fill. The counter-party’s resting limit orders are not affected by ADL; only their position is force-reduced.

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:

The fee is deducted from the user’s margin and routed to the insurance fund (not the vault). It is capped at the remaining margin so the fee itself never creates bad debt.

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 margin is negative the absolute value is bad debt. The margin is floored to zero and the bad debt is subtracted from the insurance fund:

The insurance fund may go negative. A negative insurance fund represents unresolved bad debt — future liquidation fees will replenish it.

Note: when positions are fully ADL’d at the bankruptcy price, 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).

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:

ParameterValue
PairBTC / USD
Maintenance-margin ratio (mmr)5 %
Liquidation-fee rate0.1 %
Settlement currencyUSDC at $1

Example 1 — Clean liquidation on book (no bad debt)

Setup

AliceBob (maker)
DirectionLong 1 BTCBid 1 BTC @ $47,500
Entry price$50,000
Margin$3,000$10,000

BTC drops to $47,500

Alice’s account

Close schedule

Alice has one position; the full 1 BTC long is scheduled for closure.

Execution

The long is closed (sold) into Bob’s resting bid at $47,500.

Liquidation fee

Settlement (margin arithmetic)

Alice’s margin starts at $3,000.

Final margin is positive — no bad debt.

Example 2 — ADL at bankruptcy price (no book liquidity)

Setup

CharlieDana
DirectionLong 1 BTCShort 1 BTC
Entry price$50,000$55,000
Margin$3,000$10,000

BTC drops to $46,000

Charlie’s account

Close schedule

Charlie’s full 1 BTC long is scheduled for closure.

Order book matching

No bids on the book — the full 1 BTC is unfilled.

ADL

Bankruptcy price for Charlie’s long:

Dana holds the most profitable short (entry $55,000, current oracle $46,000). Her position is force-closed at $47,000.

Charlie’s PnL at bankruptcy price:

Dana’s PnL at bankruptcy price:

Liquidation fee

No bad debt, no insurance fund impact. Dana receives the full PnL at the bankruptcy price, which is better than the oracle price for her.

Final state

Balance
Charlie$0 (fully liquidated)
Dana$18,000 (profit at bp)
Insurance fundunchanged

Example 3 — Book fill creates bad debt

Setup

CharlieBob (maker)
DirectionLong 1 BTCBid 1 BTC @ $46,000
Entry price$50,000
Margin$3,000$50,000
Insurance fund$500

BTC drops to $46,000

Charlie’s liquidation

Same equity and MM as Example 2. Liquidatable.

Order book matching

The bid at $46,000 fills Charlie’s full 1 BTC sell.

Liquidation fee

Bad debt

Charlie’s margin after PnL: .

The insurance fund goes negative. Future liquidation fees will replenish it.

Final state

Balance
Charlie$0 (fully liquidated)
Insurance fund−$500 (unresolved bad debt)
Vaultunchanged (isolated from losses)

  1. 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

NameValue
Virtual shares1,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:

  1. Bid size decreases (less buying)
  2. Ask size increases (more selling)
  3. Bid spread widens (buys less likely to fill)
  4. Ask spread tightens (sells more likely to fill)

The mirror applies when short.

Per-pair parameters

ParameterRole
initial_margin_ratioUsed to compute margin-constrained size
min_order_sizeMinimum notional to place an order
tick_sizePrice granularity for snapping
vault_half_spreadBase half bid-ask spread around oracle price
vault_liquidity_weightWeight for margin allocation across pairs
vault_max_quote_sizeMaximum base size per side
vault_max_skew_sizePosition size at which skew saturates
vault_size_skew_factorSize skew intensity ()
vault_spread_skew_factorSpread 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:

  1. During account creation — the RegisterUser message on the account factory accepts an optional referrer field. If provided, the factory forwards a SetReferral message to the perps contract.
  2. After account creation — the referee (or an account they own) calls SetReferral directly 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 %:

UserCommission rate ()Fee share ratio ()Referrer
A30 %
B20 %A
C15 %40 %B
DC
E40 %D

Trade. User D trades $10 m taker volume, pays $1,000 in fees. Assume so .

LevelUserReceives
1 (referee D)D
1 (referrer C)C15 %
2B20 %15 %
3A30 %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:

  1. Load the referrer’s latest cumulative referral data; let be its field.

  2. Load the cumulative data at ; let be its field.

  3. Compute the windowed volume:

  4. Walk the map and select the entry with the highest volume threshold .

  5. 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.

FieldTypeDescription
volumeUsdValueUser’s own cumulative trading volume.
commission_shared_by_referrerUsdValueTotal commission shared by this user’s referrer.
referee_countu32Number of direct referees.
referees_volumeUsdValueCumulative trading volume of direct referees.
commission_earned_from_refereesUsdValueTotal commission earned from direct referees’ trades.
cumulative_active_refereesu32Cumulative 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:

FieldTypeDescription
registered_atTimestampWhen the referral was established.
volumeUsdValueReferee’s total trading volume.
commission_earnedUsdValueCommission earned by referrer from this referee.
last_day_activeTimestampLast 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):

FieldTypeDescription
referral_activeboolMaster switch. When false, referral commissions are skipped entirely.
min_referrer_volumeUsdValueMinimum lifetime trading volume to call SetFeeShareRatio. Bypassed for users with a commission rate override.
referrer_commission_ratesRateScheduleVolume-tiered commission rates. base = fallback rate; tiers = map of 30-day referees volume threshold → rate. Highest qualifying tier wins.

Constants:

NameValueDescription
MAX_FEE_SHARE_RATIO50 %Maximum share ratio a referrer can set.
MAX_REFERRAL_CHAIN_DEPTH5Maximum levels of upstream referrers walked during commission distribution.
COMMISSION_LOOKBACK_DAYS30Rolling-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.

1.1 Volatility-based derivation

Start from the asset’s historical daily return distribution:

  1. Collect at least 1 year of daily log-returns.

  2. Compute the 99.5th-percentile absolute daily return .

  3. Apply a liquidation-delay factor (typically 2–3) to account for the time between the price move and the liquidation execution:

  4. 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

AssetHyperliquid max leverageHyperliquid IMRdYdX IMR
BTC40×2.5 %5 %
ETH25×4 %5 %
SOL20×5 %10 %
HYPE10×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):

ParameterRole
maker_fee_rateCharged on limit-order fills; revenue to the vault
taker_fee_rateCharged on market / crossing fills; revenue to the vault
liquidation_fee_rateCharged 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

ExchangeMakerTaker
Hyperliquid0.015%0.045%
dYdX0.01%0.05%
GMX0.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):

ParameterScopeCalibration guidance
funding_periodGlobal1–8 hours. Shorter periods track the premium more tightly but increase gas cost.
max_abs_funding_ratePer-pairSee §3.1.
impact_sizePer-pairSee §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

ParameterCalibration guidance
max_unlocksNumber of concurrent withdrawal requests per user. 5–10 is typical; prevents griefing with many small unlocks.
max_open_ordersMaximum 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:

  1. Collect data — Gather ≥ 1 year of daily and hourly OHLCV data for each asset.

  2. Compute volatility — For each asset, compute (daily 99.5th percentile absolute return) and (hourly return standard deviation).

  3. Set margin ratios — Derive MMR from (§1.1), then IMR as a multiple of MMR. Cross-check against peer benchmarks (§1.2).

  4. Set fees — Choose maker/taker/liquidation fee rates satisfying §2.1 and the invariant in §1.3.

  5. Set funding — Pick funding_period, derive max_abs_funding_rate (§3.1), and calibrate impact_size (§3.2).

  6. Size exposure — Set max_abs_oi from vault equity and tail-risk tolerance (§4.1).

  7. Set order constraints — Choose min_order_size and tick_size (§4.2, §4.3).

  8. Configure vault — Set vault_half_spread, vault_max_quote_size, and vault_liquidity_weight per pair (§5), and vault_cooldown_period globally.

  9. 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.
  10. 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

This chapter documents the complete API for the Dango perpetual futures exchange. All interactions with the chain go through a single GraphQL endpoint that supports queries, mutations, and WebSocket subscriptions.

1. Transport

1.1 HTTP

All queries and mutations use a standard GraphQL POST request.

Endpoint: See §11. Constants.

Headers:

HeaderValue
Content-Typeapplication/json

Request body:

{
  "query": "query { ... }",
  "variables": { ... }
}

Example — query chain status:

curl -X POST https://<host>/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "{ queryStatus { block { blockHeight timestamp } chainId } }"}'

Response:

{
  "data": {
    "queryStatus": {
      "block": {
        "blockHeight": 123456,
        "timestamp": "2026-01-15T12:00:00"
      },
      "chainId": "dango-1"
    }
  }
}

1.2 WebSocket

Subscriptions (real-time data) use WebSocket with the graphql-ws protocol.

Endpoint: See §11. Constants.

Connection handshake:

{
  "type": "connection_init",
  "payload": {}
}

Subscribe:

{
  "id": "1",
  "type": "subscribe",
  "payload": {
    "query": "subscription { perpsTrades(pairId: \"perp/btcusd\") { fillPrice fillSize } }"
  }
}

Messages arrive as:

{
  "id": "1",
  "type": "next",
  "payload": {
    "data": {
      "perpsTrades": { ... }
    }
  }
}

1.3 Pagination

List queries use cursor-based pagination (Relay Connection specification).

ParameterTypeDescription
firstIntReturn the first N items
afterStringCursor — return items after this
lastIntReturn the last N items
beforeStringCursor — return items before this
sortByEnumBLOCK_HEIGHT_ASC or BLOCK_HEIGHT_DESC

Response shape:

{
  "pageInfo": {
    "hasNextPage": true,
    "hasPreviousPage": false,
    "startCursor": "abc...",
    "endCursor": "xyz..."
  },
  "nodes": [ ... ]
}

Use first + after for forward pagination, last + before for backward.

1.4 Multi-query

When you need to fetch multiple pieces of state (e.g. oracle prices and user positions), use the multi-query to execute them atomically within a single block. This is the preferred method over issuing separate GraphQL requests, which may be evaluated at different block heights and return an inconsistent snapshot.

Wrap the individual queries in a multi array:

query {
  queryApp(request: {
    multi: [
      {
        wasm_smart: {
          contract: "ORACLE_CONTRACT",
          msg: { prices: {} }
        }
      },
      {
        wasm_smart: {
          contract: "PERPS_CONTRACT",
          msg: {
            user_state: { user: "0x1234...abcd" }
          }
        }
      }
    ]
  })
}

Response:

{
  "multi": [
    { "Ok": { "wasm_smart": { /* oracle prices */ } } },
    { "Ok": { "wasm_smart": { /* user state */ } } }
  ]
}

Each element in the response array corresponds to the query at the same index in the request. Individual queries that fail return {"Err": "..."} without aborting the others.

2. Authentication and transactions

2.1 Transaction structure

Every write operation is wrapped in a signed transaction (Tx):

{
  "sender": "0x1234...abcd",
  "gas_limit": 1500000,
  "msgs": [
    {
      "execute": {
        "contract": "PERPS_CONTRACT",
        "msg": { ... },
        "funds": {}
      }
    }
  ],
  "data": { ... },
  "credential": { ... }
}
FieldTypeDescription
senderAddrAccount address sending the transaction
gas_limitu64Maximum gas units for execution
msgs[Message]Non-empty list of messages to execute atomically
dataMetadataAuthentication metadata (see §2.2)
credentialCredentialCryptographic proof of sender authorization

Messages execute atomically — either all succeed or all fail.

2.2 Metadata

The data field contains authentication metadata:

{
  "user_index": 0,
  "chain_id": "dango-1",
  "nonce": 42,
  "expiry": null
}
FieldTypeDescription
user_indexu32The user index that owns the sender account
chain_idStringChain identifier (prevents cross-chain replay)
nonceu32Replay protection nonce
expiryTimestamp | nullOptional expiration; null = no expiry

Nonce semantics: Dango uses unordered nonces with a sliding window of 20, similar to the approach used by Hyperliquid. The account tracks the 20 most recently seen nonces. A transaction is accepted if its nonce is newer than the oldest seen nonce, has not been used before, and not greater than newest seen nonce + 100. This means transactions may arrive out of order without being rejected. SDK implementations should track the next available nonce client-side by querying the account’s seen nonces and choosing the next integer above the maximum.

2.3 Message format

The primary message type for interacting with contracts is execute:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "submit_order": {
          "pair_id": "perp/btcusd",
          "size": "0.100000",
          "kind": {
            "market": {
              "max_slippage": "0.010000"
            }
          },
          "reduce_only": false
        }
      }
    },
    "funds": {}
  }
}
FieldTypeDescription
contractAddrTarget contract address
msgJSONContract-specific execute message (snake_case keys)
fundsCoinsTokens to send with the message: {"<denom>": "<amount>"} or {} if none

The funds field is a map of denomination to amount string. For example, depositing 1000 USDC:

{
  "funds": {
    "bridge/usdc": "1000000000"
  }
}

USDC uses 6 decimal places in its base unit (1 USDC = 1000000 base units). All bridged tokens use the bridge/ prefix.

2.4 Signing methods

The credential field wraps a StandardCredential or SessionCredential. A StandardCredential identifies the signing key and contains the signature:

Passkey (Secp256r1 / WebAuthn):

{
  "standard": {
    "key_hash": "A1B2C3D4...64HEX",
    "signature": {
      "passkey": {
        "authenticator_data": "<base64>",
        "client_data": "<base64>",
        "sig": "<base64>"
      }
    }
  }
}
  • sig: 64-byte Secp256r1 signature (base64-encoded)
  • client_data: base64-encoded WebAuthn client data JSON (challenge = base64url of SHA-256 of SignDoc)
  • authenticator_data: base64-encoded WebAuthn authenticator data

Secp256k1:

{
  "standard": {
    "key_hash": "A1B2C3D4...64HEX",
    "signature": {
      "secp256k1": "<base64>"
    }
  }
}
  • 64-byte Secp256k1 signature (base64-encoded)

EIP-712 (Ethereum wallets):

{
  "standard": {
    "key_hash": "A1B2C3D4...64HEX",
    "signature": {
      "eip712": {
        "typed_data": "<base64>",
        "sig": "<base64>"
      }
    }
  }
}
  • sig: 65-byte signature (64-byte Secp256k1 + 1-byte recovery ID; base64-encoded)
  • typed_data: base64-encoded JSON of the EIP-712 typed data object

2.5 Session credentials

Session keys allow delegated signing without requiring the master key for every transaction.

{
  "session": {
    "session_info": {
      "session_key": "<base64>",
      "expire_at": "1700000000"
    },
    "session_signature": "<base64>",
    "authorization": {
      "key_hash": "A1B2C3D4...64HEX",
      "signature": { ... }
    }
  }
}
FieldTypeDescription
session_infoSessionInfoSession key public key + expiration
session_signatureByteArray<64>SignDoc signed by the session key (base64-encoded)
authorizationStandardCredentialSessionInfo signed by the user’s master key

2.6 SignDoc

The SignDoc is the data structure that gets signed. It mirrors the transaction but replaces the credential with the structured Metadata:

{
  "data": {
    "chain_id": "dango-1",
    "expiry": null,
    "nonce": 42,
    "user_index": 0
  },
  "gas_limit": 1500000,
  "messages": [ ... ],
  "sender": "0x1234...abcd"
}

Signing process:

  1. Serialize the SignDoc to canonical JSON (fields sorted alphabetically).
  2. Hash the serialized bytes with SHA-256.
  3. Sign the hash with the appropriate key.

For Passkey (WebAuthn), the SHA-256 hash becomes the challenge in the WebAuthn request. For EIP-712, the SignDoc is mapped to an EIP-712 typed data structure and signed via eth_signTypedData_v4.

2.7 Signing flow

The full transaction lifecycle:

  1. Compose messages — build the contract execute message(s).
  2. Fetch metadata — query chain ID, account’s user_index, and next available nonce.
  3. Simulate — send an UnsignedTx to estimate gas (see §2.8).
  4. Set gas limit — use the simulation result, adding ~770,000 for signature verification overhead.
  5. Build SignDoc — assemble {sender, gas_limit, messages, data}.
  6. Sign — sign the SignDoc with the chosen method.
  7. Broadcast — submit the signed Tx via broadcastTxSync (see §2.9).

2.8 Gas estimation

Use the simulate query to dry-run a transaction:

query Simulate($tx: UnsignedTx!) {
  simulate(tx: $tx)
}

Variables:

{
  "tx": {
    "sender": "0x1234...abcd",
    "msgs": [
      {
        "execute": {
          "contract": "PERPS_CONTRACT",
          "msg": {
            "trade": {
              "deposit": {}
            }
          },
          "funds": {
            "bridge/usdc": "1000000000"
          }
        }
      }
    ],
    "data": {
      "user_index": 0,
      "chain_id": "dango-1",
      "nonce": 42,
      "expiry": null
    }
  }
}

Response:

{
  "data": {
    "simulate": {
      "gas_limit": null,
      "gas_used": 750000,
      "result": {
        "ok": [ ... ]
      }
    }
  }
}

Simulation skips signature verification. Add 770,000 gas (Secp256k1 verification cost) to gas_used when setting gas_limit in the final transaction.

2.9 Broadcasting

Submit a signed transaction:

mutation BroadcastTx($tx: Tx!) {
  broadcastTxSync(tx: $tx)
}

Variables:

{
  "tx": {
    "sender": "0x1234...abcd",
    "gas_limit": 1500000,
    "msgs": [ ... ],
    "data": {
      "user_index": 0,
      "chain_id": "dango-1",
      "nonce": 42,
      "expiry": null
    },
    "credential": {
      "standard": {
        "key_hash": "...",
        "signature": { ... }
      }
    }
  }
}

The mutation returns the transaction outcome as JSON.

3. Account management

Dango uses smart accounts instead of externally-owned accounts (EOAs). A user profile is identified by a UserIndex and may own 1 master account and 0-4 subaccounts. Keys are associated with the user profile, not individual accounts.

3.1 Register user

Creating a new user profile is a two-step process:

Step 1 — Register. Call register_user on the account factory: use the the account factory address itself as sender, and null for the data and credential fields.

{
  "sender": "ACCOUNT_FACTORY_CONTRACT",
  "gas_limit": 1500000,
  "msgs": [
    {
      "execute": {
        "contract": "ACCOUNT_FACTORY_CONTRACT",
        "msg": {
          "register_user": {
            "key": {
              "secp256r1": "<base64>"
            },
            "key_hash": "A1B2C3D4...64HEX",
            "seed": 12345,
            "signature": {
              "passkey": {
                "authenticator_data": "<base64>",
                "client_data": "<base64>",
                "sig": "<base64>"
              }
            }
          }
        },
        "funds": {}
      }
    }
  ],
  "data": null,
  "credential": null
}
FieldTypeDescription
keyKeyThe user’s initial public key (see §10.3)
key_hashHash256Client-chosen hash identifying this key
seedu32Arbitrary number for address variety
signatureSignatureSignature over {"chain_id": "dango-1"} proving key ownership

A master account is created in the inactive state (for the purpose of spam prevention). The new account address is returned in the transaction events.

Step 2 — Activate. Send at least the minimum_deposit (10 USDC = 10000000 bridge/usdc on mainnet) to the new master account address. The transfer can either come from an existing Dango account, or from another chain via Hyperlane bridging. Upon receipt, the account activates itself and becomes ready to use.

3.2 Register subaccount

Create an additional account for an existing user (maximum 5 accounts per user):

{
  "execute": {
    "contract": "ACCOUNT_FACTORY_CONTRACT",
    "msg": {
      "register_account": {}
    },
    "funds": {}
  }
}

Must be sent from an existing account owned by the user.

3.3 Account address derivation

3.3.1 Master account

The first account of a new user (§3.1) is derived as:

address := ripemd160(sha256(deployer || code_hash || seed || key_hash || key_tag || key))

where || denotes byte concatenation.

The preimage layout (122 bytes total):

Byte rangeSizeFieldDescription
[0..20)20deployerThe ACCOUNT_FACTORY_CONTRACT address (see §11)
[20..52)32code_hashThe code hash of the Dango single-signature account contract (see §11)
[52..56)4seedUser-chosen u32, big-endian — arbitrary value for frontrunning protection
[56..88)32key_hashClient-chosen 32-byte identifier for the key (see §3.8 for hashing rules)
[88..89)1key_tagKey type: 0 = Secp256r1, 1 = Secp256k1, 2 = Ethereum
[89..122)33keySecp256r1 / Secp256k1: 33-byte compressed public key. Ethereum: 13 zero bytes followed by the 20-byte address

3.3.2 Subaccount

address := ripemd160(sha256(deployer || code_hash || account_index))

The preimage layout (56 bytes total):

Byte rangeSizeFieldDescription
[0..20)20deployerThe ACCOUNT_FACTORY_CONTRACT address (see §11)
[20..52)32code_hashThe code hash of the Dango single-signature account contract (see §11)
[52..56)4account_indexGlobal account index, u32, big-endian

The global account index is a chain-wide monotonic counter maintained by the account factory; it is incremented for every account created across all users, so every account has a unique index.

3.4 Update key

Associate or disassociate a key with the user profile.

Add a key:

{
  "execute": {
    "contract": "ACCOUNT_FACTORY_CONTRACT",
    "msg": {
      "update_key": {
        "key_hash": "A1B2C3D4...64HEX",
        "key": {
          "insert": {
            "secp256k1": "<base64>"
          }
        }
      }
    },
    "funds": {}
  }
}

Remove a key:

{
  "execute": {
    "contract": "ACCOUNT_FACTORY_CONTRACT",
    "msg": {
      "update_key": {
        "key_hash": "A1B2C3D4...64HEX",
        "key": "delete"
      }
    },
    "funds": {}
  }
}

3.5 Update username

Set the user’s human-readable username (one-time operation):

{
  "execute": {
    "contract": "ACCOUNT_FACTORY_CONTRACT",
    "msg": {
      "update_username": "alice"
    },
    "funds": {}
  }
}

Username rules: 1–15 characters, lowercase a-z, digits 0-9, and underscore _ only.

The username is cosmetic only — used for human-readable display on the frontend. It is not used in any business logic of the exchange.

3.6 Query user

query {
  user(userIndex: 0) {
    userIndex
    createdBlockHeight
    createdAt
    publicKeys {
      keyHash
      publicKey
      keyType
      createdBlockHeight
      createdAt
    }
    accounts {
      accountIndex
      address
      createdBlockHeight
      createdAt
    }
  }
}

The keyType enum values are: SECP256R1, SECP256K1, ETHEREUM.

3.7 Query user by username

Look up a user by their username via a smart contract query against the account factory:

query {
  queryApp(request: {
    wasm_smart: {
      contract: "ACCOUNT_FACTORY_CONTRACT",
      msg: {
        user: {
          name: "alice"
        }
      }
    }
  })
}

Response:

{
  "index": 42,
  "name": "alice",
  "accounts": {
    "100": "0xabcd...1234"
  },
  "keys": {
    "A1B2C3...": {
      "ethereum": "0x1234...abcd"
    }
  }
}

You can also look up by index: { "user": { "index": 42 } }.

3.8 Query users by key

Search for users by public key or key hash. Useful when you know a user’s key but not their index or username.

query {
  users(publicKeyHash: "A1B2C3D4...64HEX", first: 10) {
    nodes {
      userIndex
      createdBlockHeight
      createdAt
      publicKeys {
        keyHash
        publicKey
        keyType
        createdBlockHeight
        createdAt
      }
      accounts {
        accountIndex
        address
        createdBlockHeight
        createdTxHash
        createdAt
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Filter by publicKeyHash or by publicKey (the raw key value). The key hash is computed differently depending on key type:

Key typeInput to SHA-256
ETHEREUMUTF-8 bytes of the lowercase hex address (with 0x prefix)
SECP256K1Compressed public key bytes (33 bytes)
SECP256R1WebAuthn credential ID bytes

The resulting hash is hex-encoded in uppercase.

3.9 Query accounts

query {
  accounts(userIndex: 0, first: 10) {
    nodes {
      accountIndex
      address
      createdBlockHeight
      createdTxHash
      createdAt
      users {
        userIndex
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Filter by userIndex to get all accounts for a specific user, or by address for a specific account.

4. Market data

4.1 Global parameters

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        param: {}
      }
    }
  })
}

Response:

{
  "max_unlocks": 5,
  "max_open_orders": 50,
  "max_action_batch_size": 5,
  "maker_fee_rates": {
    "base": "0.000000",
    "tiers": {}
  },
  "taker_fee_rates": {
    "base": "0.001000",
    "tiers": {}
  },
  "protocol_fee_rate": "0.100000",
  "liquidation_fee_rate": "0.010000",
  "liquidation_buffer_ratio": "0.000000",
  "funding_period": "3600",
  "vault_total_weight": "10.000000",
  "vault_cooldown_period": "604800",
  "referral_active": true,
  "min_referrer_volume": "0.000000",
  "referrer_commission_rates": {
    "base": "0.000000",
    "tiers": {}
  }
}
FieldTypeDescription
max_unlocksusizeMax concurrent vault unlock requests per user
max_open_ordersusizeMax resting limit orders per user (all pairs)
max_action_batch_sizeusizeMax actions in a single batch_update_orders message
maker_fee_ratesRateScheduleVolume-tiered maker fee rates
taker_fee_ratesRateScheduleVolume-tiered taker fee rates
protocol_fee_rateDimensionlessFraction of trading fees routed to treasury
liquidation_fee_rateDimensionlessInsurance fund fee on liquidations
liquidation_buffer_ratioDimensionlessPost-liquidation equity buffer above maintenance margin
funding_periodDurationInterval between funding collections
vault_total_weightDimensionlessSum of all pairs’ vault liquidity weights
vault_cooldown_periodDurationWaiting time before vault withdrawal release
referral_activeboolWhether the referral commission system is active
min_referrer_volumeUsdValueMinimum lifetime volume to become a referrer
referrer_commission_ratesRateScheduleVolume-tiered referrer commission rates

A RateSchedule has two fields: base (the default rate) and tiers (a map of volume threshold to rate; highest qualifying tier wins).

For fee mechanics, see Order matching §8.

4.2 Global state

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        state: {}
      }
    }
  })
}

Response:

{
  "last_funding_time": "1700000000.123456789",
  "vault_share_supply": "500000000",
  "insurance_fund": "25000.000000",
  "treasury": "12000.000000"
}
FieldTypeDescription
last_funding_timeTimestampLast funding collection time
vault_share_supplyUint128Total vault share tokens
insurance_fundUsdValueInsurance fund balance
treasuryUsdValueAccumulated protocol fees

4.3 Pair parameters

All pairs:

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        pair_params: {
          start_after: null,
          limit: 30
        }
      }
    }
  })
}

Single pair:

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        pair_param: {
          pair_id: "perp/btcusd"
        }
      }
    }
  })
}

Response (single pair):

{
  "tick_size": "1.000000",
  "min_order_size": "10.000000",
  "max_abs_oi": "1000000.000000",
  "max_abs_funding_rate": "0.000500",
  "initial_margin_ratio": "0.050000",
  "maintenance_margin_ratio": "0.025000",
  "impact_size": "10000.000000",
  "vault_liquidity_weight": "1.000000",
  "vault_half_spread": "0.001000",
  "vault_max_quote_size": "50000.000000",
  "max_limit_price_deviation": "0.100000",
  "max_market_slippage": "0.100000",
  "bucket_sizes": ["1.000000", "5.000000", "10.000000"]
}
FieldTypeDescription
tick_sizeUsdPriceMinimum price increment for limit orders
min_order_sizeUsdValueMinimum notional value (reduce-only exempt)
max_abs_oiQuantityMaximum open interest per side
max_abs_funding_rateFundingRateDaily funding rate cap
initial_margin_ratioDimensionlessMargin to open (e.g. 0.05 = 20x max leverage)
maintenance_margin_ratioDimensionlessMargin to stay open (liquidation threshold)
impact_sizeUsdValueNotional for impact price calculation
vault_liquidity_weightDimensionlessVault allocation weight for this pair
vault_half_spreadDimensionlessHalf the vault’s bid-ask spread
vault_max_quote_sizeQuantityMaximum vault resting size per side
max_limit_price_deviationDimensionlessMax symmetric deviation of a limit price from oracle at submission
max_market_slippageDimensionlessMax max_slippage a user may set on a market or TP/SL child order
bucket_sizes[UsdPrice]Price bucket granularities for depth queries

For the relationship between margin ratios and leverage, see Risk §2.

4.4 Pair state

All pairs:

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        pair_states: {
          start_after: null,
          limit: 30
        }
      }
    }
  })
}

Single pair:

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        pair_state: {
          pair_id: "perp/btcusd"
        }
      }
    }
  })
}

Response:

{
  "long_oi": "12500.000000",
  "short_oi": "10300.000000",
  "funding_per_unit": "0.000123",
  "funding_rate": "0.000050"
}
FieldTypeDescription
long_oiQuantityTotal long open interest
short_oiQuantityTotal short open interest
funding_per_unitFundingPerUnitCumulative funding accumulator
funding_rateFundingRateCurrent per-day funding rate (positive = longs pay)

For funding mechanics, see Funding.

4.5 Order book depth

Query aggregated order book depth at a given price bucket granularity:

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        liquidity_depth: {
          pair_id: "perp/btcusd",
          bucket_size: "10.000000",
          limit: 20
        }
      }
    }
  })
}
ParameterTypeDescription
pair_idPairIdTrading pair
bucket_sizeUsdPricePrice aggregation granularity (must be in bucket_sizes)
limitu32?Max number of price levels per side

Response:

{
  "bids": {
    "64990.000000": {
      "size": "12.500000",
      "notional": "812375.000000"
    },
    "64980.000000": {
      "size": "8.200000",
      "notional": "532836.000000"
    }
  },
  "asks": {
    "65010.000000": {
      "size": "10.000000",
      "notional": "650100.000000"
    },
    "65020.000000": {
      "size": "5.500000",
      "notional": "357610.000000"
    }
  }
}

Each level contains:

FieldTypeDescription
sizeQuantityAbsolute order size in the bucket
notionalUsdValueUSD notional (size × price)

4.6 Pair statistics

All pairs:

query {
  allPerpsPairStats {
    pairId
    currentPrice
    price24HAgo
    volume24H
    priceChange24H
  }
}

Single pair:

query {
  perpsPairStats(pairId: "perp/btcusd") {
    pairId
    currentPrice
    price24HAgo
    volume24H
    priceChange24H
  }
}
FieldTypeDescription
pairIdString!Pair identifier
currentPriceBigDecimalCurrent price (nullable)
price24HAgoBigDecimalPrice 24 hours ago (nullable)
volume24HBigDecimal!24h trading volume in USD
priceChange24HBigDecimal24h price change percentage (e.g. 5.25 = +5.25%)

4.7 Historical candles

query {
  perpsCandles(
    pairId: "perp/btcusd",
    interval: ONE_HOUR,
    laterThan: "2026-01-01T00:00:00Z",
    earlierThan: "2026-01-02T00:00:00Z",
    first: 24
  ) {
    nodes {
      pairId
      interval
      open
      high
      low
      close
      volume
      volumeUsd
      timeStart
      timeStartUnix
      timeEnd
      timeEndUnix
      minBlockHeight
      maxBlockHeight
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
ParameterTypeDescription
pairIdString!Trading pair (e.g. "perp/btcusd")
intervalCandleInterval!Candle interval
laterThanDateTimeCandles after this time (inclusive)
earlierThanDateTimeCandles before this time (exclusive)

CandleInterval values: ONE_SECOND, ONE_MINUTE, FIVE_MINUTES, FIFTEEN_MINUTES, ONE_HOUR, FOUR_HOURS, ONE_DAY, ONE_WEEK.

PerpsCandle fields:

FieldTypeDescription
openBigDecimalOpening price
highBigDecimalHighest price
lowBigDecimalLowest price
closeBigDecimalClosing price
volumeBigDecimalVolume in base units
volumeUsdBigDecimalVolume in USD
timeStartStringPeriod start (ISO 8601)
timeStartUnixIntPeriod start (Unix timestamp)
timeEndStringPeriod end (ISO 8601)
timeEndUnixIntPeriod end (Unix timestamp)
minBlockHeightIntFirst block in this candle
maxBlockHeightIntLast block in this candle

4.8 Fees and revenue

query {
  perpsFeesAndRevenue(
    from: "2026-01-01T00:00:00Z",
    to: "2026-02-01T00:00:00Z"
  ) {
    from
    to
    feeEventsCount
    protocolFee
    vaultFee
    refereeRebate
    referrerPayout
    volumeUsd
  }
}
ParameterTypeDescription
fromDateTime!Window lower bound (inclusive)
toDateTime!Window upper bound (inclusive)

from must be less than or equal to to.

Resolution. Windows shorter than 3 days are served from per-block rows with microsecond-precise bounds. Windows of 3 days or more are served from the perps_fees_hourly materialized view; bounds are snapped to the enclosing hours, so a request that overlaps partial hours at either end includes those hours’ full aggregates.

PerpsFeesAndRevenue fields:

FieldTypeDescription
fromString!Lower bound echoed back as ISO 8601
toString!Upper bound echoed back as ISO 8601
feeEventsCountInt!Number of FeeDistributed events aggregated in the window
protocolFeeBigDecimal!Protocol fee accrued (USD)
vaultFeeBigDecimal!Vault fee accrued (USD), already net of referral commissions
refereeRebateBigDecimal!Referral commissions paid back to referees (USD)
referrerPayoutBigDecimal!Referral commissions paid out to referrers (USD)
volumeUsdBigDecimal!USD notional volume from OrderFilled and Deleveraged events

Total protocol revenue over the window is protocolFee + vaultFee. The total fee paid by users is protocolFee + vaultFee + refereeRebate + referrerPayout; refereeRebate and referrerPayout are informational totals of referral commissions distributed.

This query backs the Dango entry on DefiLlama: https://defillama.com/protocol/dango.

5. User state and orders

5.1 User state

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        user_state: {
          user: "0x1234...abcd"
        }
      }
    }
  })
}

Response:

{
  "margin": "10000.000000",
  "vault_shares": "0",
  "positions": {
    "perp/btcusd": {
      "size": "0.500000",
      "entry_price": "64500.000000",
      "entry_funding_per_unit": "0.000100",
      "conditional_order_above": {
        "order_id": "55",
        "size": "-0.500000",
        "trigger_price": "70000.000000",
        "max_slippage": "0.020000"
      },
      "conditional_order_below": null
    }
  },
  "unlocks": [],
  "reserved_margin": "500.000000",
  "open_order_count": 2
}
FieldTypeDescription
marginUsdValueDeposited margin (USD)
vault_sharesUint128Vault liquidity shares owned
positionsMap<PairId, Position>Open positions by pair
unlocks[Unlock]Pending vault withdrawals
reserved_marginUsdValueMargin reserved for resting limit orders
open_order_countusizeNumber of resting limit orders

Position:

FieldTypeDescription
sizeQuantityPosition size (positive = long, negative = short)
entry_priceUsdPriceAverage entry price
entry_funding_per_unitFundingPerUnitFunding accumulator at last modification
conditional_order_aboveConditionalOrder|nullTP/SL that triggers when oracle >= trigger_price
conditional_order_belowConditionalOrder|nullTP/SL that triggers when oracle <= trigger_price

ConditionalOrder (embedded in Position):

FieldTypeDescription
order_idOrderIdInternal ID for price-time priority
sizeQuantity|nullSize to close (sign opposes position); null closes entire position
trigger_priceUsdPriceOracle price that activates this order
max_slippageDimensionlessSlippage tolerance for the market order at trigger

Unlock:

FieldTypeDescription
end_timeTimestampWhen cooldown completes
amount_to_releaseUsdValueUSD value to release

Returns null if the user has no state.

Enumerate all user states (paginated):

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        user_states: {
          start_after: null,
          limit: 10
        }
      }
    }
  })
}

Returns: { "<address>": <UserState>, ... }

5.2 Open orders

Query all resting limit orders for a user:

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        orders_by_user: {
          user: "0x1234...abcd"
        }
      }
    }
  })
}

Response:

{
  "42": {
    "pair_id": "perp/btcusd",
    "size": "0.500000",
    "limit_price": "63000.000000",
    "reduce_only": false,
    "reserved_margin": "1575.000000",
    "created_at": "1700000000"
  }
}

The response is a map of OrderId → order details. This query returns only resting limit orders. Conditional (TP/SL) orders are stored on the position itself and can be queried via user_state (see §5.1, conditional_order_above / conditional_order_below fields).

FieldTypeDescription
pair_idPairIdTrading pair
sizeQuantityOrder size (positive = buy, negative = sell)
limit_priceUsdPriceLimit price
reduce_onlyboolWhether the order only reduces an existing position
reserved_marginUsdValueMargin reserved for this order
created_atTimestampCreation time

5.3 Single order

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        order: {
          order_id: "42"
        }
      }
    }
  })
}

Response:

{
  "user": "0x1234...abcd",
  "pair_id": "perp/btcusd",
  "size": "0.500000",
  "limit_price": "63000.000000",
  "reduce_only": false,
  "reserved_margin": "1575.000000",
  "created_at": "1700000000"
}
FieldTypeDescription
userAddrOrder owner
pair_idPairIdTrading pair
sizeQuantityOrder size (positive = buy, negative = sell)
limit_priceUsdPriceLimit price
reduce_onlyboolWhether the order only reduces an existing position
reserved_marginUsdValueMargin reserved for this order
created_atTimestampCreation time

Returns null if the order does not exist.

5.4 Trading volume

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        volume: {
          user: "0x1234...abcd",
          since: null
        }
      }
    }
  })
}
ParameterTypeDescription
userAddrAccount address
sinceTimestamp?Start time; null for lifetime volume

Returns a UsdValue string (e.g. "1250000.000000").

5.5 Trade history

Query historical perps events such as fills, liquidations, and order lifecycle:

query {
  perpsEvents(
    userAddr: "0x1234...abcd",
    eventType: "order_filled",
    pairId: "perp/btcusd",
    first: 50,
    sortBy: BLOCK_HEIGHT_DESC
  ) {
    nodes {
      idx
      blockHeight
      txHash
      eventType
      userAddr
      pairId
      data
      createdAt
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
ParameterTypeDescription
userAddrStringFilter by user address
eventTypeStringFilter by event type (see §9)
pairIdStringFilter by trading pair
blockHeightIntFilter by block height

The data field contains the event-specific payload as JSON. For example, an order_filled event:

{
  "order_id": "42",
  "pair_id": "perp/btcusd",
  "user": "0x1234...abcd",
  "fill_price": "65000.000000",
  "fill_size": "0.100000",
  "closing_size": "0.000000",
  "opening_size": "0.100000",
  "realized_pnl": "0.000000",
  "realized_funding": "0.000000",
  "fee": "6.500000",
  "client_order_id": "42",
  "fill_id": "17",
  "is_maker": false
}

5.6 Extended user state

Query user state with additional computed fields (equity, available margin, maintenance margin, and per-position unrealized PnL/funding):

query {
  queryApp(request: {
    wasm_smart: {
      contract: "PERPS_CONTRACT",
      msg: {
        user_state_extended: {
          user: "0x1234...abcd",
          include_equity: true,
          include_available_margin: true,
          include_maintenance_margin: true,
          include_unrealized_pnl: true,
          include_unrealized_funding: true,
          include_liquidation_price: true
        }
      }
    }
  })
}
ParameterTypeDescription
userAddrAccount address
include_equityboolCompute and return the user’s equity
include_available_marginboolCompute and return the user’s free margin
include_maintenance_marginboolCompute and return the user’s maintenance margin
include_unrealized_pnlboolCompute and return per-position unrealized PnL
include_unrealized_fundingboolCompute and return per-position unrealized funding costs
include_liquidation_priceboolCompute and return per-position liquidation price

Response:

{
  "margin": "10000.000000",
  "vault_shares": "0",
  "unlocks": [],
  "reserved_margin": "500.000000",
  "open_order_count": 2,
  "equity": "10250.000000",
  "available_margin": "8625.000000",
  "maintenance_margin": "1875.000000",
  "positions": {
    "perp/ethusd": {
      "size": "5.000000",
      "entry_price": "2000.000000",
      "entry_funding_per_unit": "0.000000",
      "conditional_order_above": null,
      "conditional_order_below": null,
      "unrealized_pnl": "250.000000",
      "unrealized_funding": "0.000000",
      "liquidation_price": "1052.631578"
    }
  }
}
FieldTypeDescription
marginUsdValueThe user’s deposited margin
vault_sharesUint128Vault shares owned by this user
unlocks[Unlock]Pending vault withdrawal cooldowns
reserved_marginUsdValueMargin reserved for resting limit orders
open_order_countusizeNumber of resting limit orders
equityUsdValue|nullmargin + unrealized PnL − unrealized funding; null if not requested
available_marginUsdValue|nullmargin − initial margin requirements − reserved margin; null if not requested
maintenance_marginUsdValue|nullsum of |size| * oracle_price * maintenance_margin_ratio across all positions; null if not requested
positionsMap<PairId, PositionExtended>Open positions with optional computed data (see below)

PositionExtended:

FieldTypeDescription
sizeQuantityPositive = long, negative = short
entry_priceUsdPriceAverage entry price
entry_funding_per_unitFundingPerUnitFunding accumulator at last update
conditional_order_aboveConditionalOrder|nullTP/SL that triggers when oracle >= trigger_price
conditional_order_belowConditionalOrder|nullTP/SL that triggers when oracle <= trigger_price
unrealized_pnlUsdValue|nullsize * (oracle_price - entry_price); positive = profit; null if not requested
unrealized_fundingUsdValue|nullsize * (current_funding_per_unit - entry_funding_per_unit); positive = cost; null if not requested
liquidation_priceUsdPrice|nullOracle price that triggers account liquidation (other prices held constant); null if not requested or no valid price exists

equity reflects the total account value including unrealized positions. available_margin is the amount the user can withdraw or use for new orders. maintenance_margin is the minimum equity required to keep positions open — if equity falls below this threshold the account becomes liquidatable.

6. Trading operations

Each message is wrapped in a Tx as described in §2 and broadcast via broadcastTxSync.

6.1 Deposit margin

Deposit USDC into the trading margin account:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "deposit": {}
      }
    },
    "funds": {
      "bridge/usdc": "1000000000"
    }
  }
}

The deposited USDC is converted to USD at a fixed rate of $1 per USDC and credited to user_state.margin. In this example, 1000000000 base units = 1,000 USDC = $1,000.

6.2 Withdraw margin

Withdraw USD from the trading margin account:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "withdraw": {
          "amount": "500.000000"
        }
      }
    },
    "funds": {}
  }
}
FieldTypeDescription
amountUsdValueUSD amount to withdraw

The USD amount is converted to USDC at the fixed rate of $1 per USDC (floor-rounded) and transferred to the sender.

6.3 Submit market order

Buy or sell at the best available prices with a slippage tolerance:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "submit_order": {
          "pair_id": "perp/btcusd",
          "size": "0.100000",
          "kind": {
            "market": {
              "max_slippage": "0.010000"
            }
          },
          "reduce_only": false
        }
      }
    },
    "funds": {}
  }
}
FieldTypeDescription
pair_idPairIdTrading pair (e.g. "perp/btcusd")
sizeQuantityContract size — positive = buy, negative = sell
max_slippageDimensionlessMaximum slippage as a fraction of oracle price (0.01 = 1%)
reduce_onlyboolIf true, only the position-closing portion executes
tpChildOrder?Optional take-profit child order (see below)
slChildOrder?Optional stop-loss child order (see below)

Market orders execute immediately (IOC behavior). Any unfilled remainder is discarded. If nothing fills, the transaction reverts.

Child orders (TP/SL): When tp or sl is provided, a conditional order is automatically attached to the resulting position after fill. See ChildOrder in the types reference.

{
  "tp": {
    "trigger_price": "70000.000000",
    "max_slippage": "0.020000",
    "size": null
  },
  "sl": {
    "trigger_price": "60000.000000",
    "max_slippage": "0.020000",
    "size": null
  }
}
FieldTypeDescription
trigger_priceUsdPriceOracle price that activates this order
max_slippageDimensionlessSlippage tolerance for the market order at trigger
sizeQuantity|nullSize to close (sign opposes position); null closes entire position

For order matching mechanics, see Order matching.

6.4 Submit limit order

Place a resting order on the book:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "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
        }
      }
    },
    "funds": {}
  }
}
FieldTypeDescription
limit_priceUsdPriceLimit price — must be aligned to tick_size
time_in_forceTimeInForce"GTC" (default), "IOC", or "POST" — see below
client_order_idClientOrderId?Optional caller-assigned id for in-flight cancel — see below
reduce_onlyboolIf true, only position-closing portion is kept
tpChildOrder?Optional take-profit child order (see §6.3)
slChildOrder?Optional stop-loss child order (see §6.3)

Time-in-force options:

  • GTC (Good-Til-Canceled, default): the matching portion fills immediately; any unfilled remainder is stored on the book. Margin is reserved for the unfilled portion.
  • IOC (Immediate-Or-Cancel): fills as much as possible against the book, then discards any unfilled remainder. Errors if nothing fills at all.
  • POST (Post-Only): the entire order is placed on the book without matching. Rejected if the limit price would cross the best offer on the opposite side.

Client order id:

client_order_id is a caller-assigned Uint64 that lets an algo trader cancel an order in the same block it was submitted, without round-tripping through the server response to learn the system-assigned OrderId. Cancel via CancelOrderRequest::OneByClientOrderId.

  • Uniqueness scope: per-sender, across the sender’s active (resting) limit orders only. The contract does not remember client order ids of orders that have been canceled or filled, so they can be reused freely.
  • Submitting a second order with a client_order_id that the sender already has on the book fails with duplicate_data.
  • Not allowed with time_in_force: "IOC" — IOC never enters the book, so the alias would be unreachable. Submission is rejected with a clear error.

6.5 Cancel order

Cancel a single order by system-assigned OrderId:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "cancel_order": {
          "one": "42"
        }
      }
    },
    "funds": {}
  }
}

Cancel a single order by caller-assigned ClientOrderId:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "cancel_order": {
          "one_by_client_order_id": "42"
        }
      }
    },
    "funds": {}
  }
}

This resolves to the active order owned by the sender that carries the given client_order_id (see §6.4). It bails if no such order exists. The lookup is per-sender, so two traders can independently use the same client_order_id value without colliding.

Pattern: an algo trader can submit and cancel in the same block by reusing the client_order_id they assigned at submission, without waiting for the server response.

Cancel all orders:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "cancel_order": "all"
      }
    },
    "funds": {}
  }
}

Cancellation releases reserved margin and decrements open_order_count.

6.6 Batch update orders

Apply a sequence of submit and cancel actions atomically. Actions execute in order; later actions observe the state written by earlier ones. If any action fails, the whole message reverts and no partial state is persisted.

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "batch_update_orders": [
          { "cancel": "all" },
          {
            "submit": {
              "pair_id": "perp/btcusd",
              "size": "0.100000",
              "kind": {
                "limit": {
                  "limit_price": "64000.000000",
                  "time_in_force": "POST",
                  "client_order_id": "1"
                }
              },
              "reduce_only": false
            }
          },
          {
            "submit": {
              "pair_id": "perp/btcusd",
              "size": "-0.100000",
              "kind": {
                "limit": {
                  "limit_price": "66000.000000",
                  "time_in_force": "POST",
                  "client_order_id": "2"
                }
              },
              "reduce_only": false
            }
          }
        ]
      }
    },
    "funds": {}
  }
}

The payload is a JSON array of SubmitOrCancelOrderRequest values. Each entry is one of:

  • { "submit": { … } } — same shape as submit_order (every field of SubmitOrderRequest).
  • { "cancel": <CancelOrderRequest> } — any variant of CancelOrderRequest: { "one": "..." }, { "one_by_client_order_id": "..." }, or the string "all".

Constraints:

Atomicity: every storage write the earlier actions made — including realized fills that mutated counterparties’ state — is discarded if a later action fails. Events are emitted only for the successful-batch case; a reverting batch surfaces just the top-level transaction failure.

Example use case — atomic book replacement:

An algo trader refreshing quotes at a new reference price can send a single batch_update_orders carrying { "cancel": "all" } followed by the new submit entries. The old orders are canceled (freeing reserved margin) before the new submits run their margin checks, and if any new submit would fail (margin check, price band, OI cap, …) the old orders are restored along with the rest of the batch.

Reusing a client_order_id within one batch: a { "cancel": { "one_by_client_order_id": "X" } } entry releases the id before a later { "submit": { … "client_order_id": "X" } } runs, so the same id can be rebound to a new order within a single message.

6.7 Submit conditional order (TP/SL)

Place a take-profit or stop-loss order that triggers when the oracle price crosses a threshold:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "submit_conditional_order": {
          "pair_id": "perp/btcusd",
          "size": "-0.100000",
          "trigger_price": "70000.000000",
          "trigger_direction": "above",
          "max_slippage": "0.020000"
        }
      }
    },
    "funds": {}
  }
}
FieldTypeDescription
pair_idPairIdTrading pair
sizeQuantitySize to close — sign must oppose the position
trigger_priceUsdPriceOracle price that activates this order
trigger_directionTriggerDirection"above" or "below" (see below)
max_slippageDimensionlessSlippage tolerance for the market order at trigger

Trigger direction:

DirectionTriggers whenUse case
aboveoracle_price >= trigger_priceTake-profit for longs, stop-loss for shorts
beloworacle_price <= trigger_priceStop-loss for longs, take-profit for shorts

Conditional orders are always reduce-only with zero reserved margin. When triggered, they execute as market orders.

6.8 Cancel conditional order

Conditional orders are identified by (pair_id, trigger_direction), not by order ID.

Cancel a single conditional order:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "cancel_conditional_order": {
          "one": {
            "pair_id": "perp/btcusd",
            "trigger_direction": "above"
          }
        }
      }
    },
    "funds": {}
  }
}

Cancel all conditional orders for a specific pair:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "cancel_conditional_order": {
          "all_for_pair": {
            "pair_id": "perp/btcusd"
          }
        }
      }
    },
    "funds": {}
  }
}

Cancel all conditional orders:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "trade": {
        "cancel_conditional_order": "all"
      }
    },
    "funds": {}
  }
}

6.9 Liquidate (permissionless)

Force-close all positions of an undercollateralized user. This message can be sent by anyone (liquidation bots):

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "maintain": {
        "liquidate": {
          "user": "0x5678...ef01"
        }
      }
    },
    "funds": {}
  }
}

The transaction reverts if the user is not below the maintenance margin. Unfilled positions are ADL’d against counter-parties at the bankruptcy price. For mechanics, see Liquidation & ADL.

7. Vault operations

The counterparty vault provides liquidity for the exchange. Users can deposit margin into the vault to earn trading fees, and withdraw with a cooldown period.

7.1 Add liquidity

Transfer margin from the trading account to the vault:

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "vault": {
        "add_liquidity": {
          "amount": "1000.000000",
          "min_shares_to_mint": "900000"
        }
      }
    },
    "funds": {}
  }
}
FieldTypeDescription
amountUsdValueUSD margin amount to transfer to the vault
min_shares_to_mintUint128?Revert if fewer shares are minted (slippage guard)

Shares are minted proportionally to the vault’s current NAV. For vault mechanics, see Vault.

7.2 Remove liquidity

Request a withdrawal from the vault (initiates cooldown):

{
  "execute": {
    "contract": "PERPS_CONTRACT",
    "msg": {
      "vault": {
        "remove_liquidity": {
          "shares_to_burn": "500000"
        }
      }
    },
    "funds": {}
  }
}
FieldTypeDescription
shares_to_burnUint128Number of shares to burn

Shares are burned immediately. The corresponding USD value enters a cooldown queue. After vault_cooldown_period elapses, funds are automatically credited back to the user’s trading margin.

8. Real-time subscriptions

All subscriptions use the WebSocket transport described in §1.2.

8.1 Perps candles

Stream OHLCV candlestick data for a perpetual pair:

subscription {
  perpsCandles(pairId: "perp/btcusd", interval: ONE_MINUTE) {
    pairId
    interval
    open
    high
    low
    close
    volume
    volumeUsd
    timeStart
    timeStartUnix
    timeEnd
    timeEndUnix
    minBlockHeight
    maxBlockHeight
  }
}

Pushes updated candle data as new trades occur. Fields match the PerpsCandle type in §4.7.

8.2 Perps trades

Stream real-time trade fills for a pair:

subscription {
  perpsTrades(pairId: "perp/btcusd") {
    orderId
    pairId
    user
    fillPrice
    fillSize
    closingSize
    openingSize
    realizedPnl
    fee
    createdAt
    blockHeight
    tradeIdx
    isMaker
  }
}

Behavior: On connection, cached recent trades are replayed first, then new trades stream in real-time.

FieldTypeDescription
orderIdStringOrder ID that produced this fill
pairIdStringTrading pair
userStringAccount address
fillPriceStringExecution price
fillSizeStringFilled size (positive = buy, negative = sell)
closingSizeStringPortion that closed existing position
openingSizeStringPortion that opened new position
realizedPnlStringPnL realized on this fill, including funding settled on the pre-existing position; excludes trading fees
feeStringTrading fee charged
createdAtStringTimestamp (ISO 8601)
blockHeightIntBlock in which the trade occurred
tradeIdxIntIndex within the block
isMakerBoolean?True for the maker side of a match, false for the taker side; null for trades executed before v0.16.0

8.3 Contract query polling

Poll any contract query at a regular block interval:

subscription {
  queryApp(
    request: {
      wasm_smart: {
        contract: "PERPS_CONTRACT",
        msg: {
          user_state: {
            user: "0x1234...abcd"
          }
        }
      }
    },
    blockInterval: 5
  ) {
    response
    blockHeight
  }
}
ParameterTypeDefaultDescription
requestGrugQueryInputAny valid queryApp request
blockIntervalInt10Push updates every N blocks

Common use cases:

  • User state — monitor margin, positions, and order counts.
  • Order book depth — track bid/ask levels.
  • Pair states — monitor open interest and funding.

8.4 Block stream

Subscribe to new blocks as they are finalized:

subscription {
  block {
    blockHeight
    hash
    appHash
    createdAt
  }
}

8.5 Event stream

Subscribe to events with optional filtering:

subscription {
  events(
    sinceBlockHeight: 100000,
    filter: [
      {
        type: "order_filled",
        data: [
          {
            path: ["user"],
            checkMode: EQUAL,
            value: ["0x1234...abcd"]
          }
        ]
      }
    ]
  ) {
    type
    method
    eventStatus
    data
    blockHeight
    createdAt
  }
}
Filter fieldTypeDescription
typeStringEvent type name
data[FilterData]Conditions on the event’s JSON data
path[String]JSON path to the field
checkModeCheckValueEQUAL (exact match) or CONTAINS (substring)
value[JSON]Values to match against

9. Events reference

The perps contract emits the following events. These can be queried via perpsEvents (§5.5) or streamed via the events subscription (§8.5).

Margin events

EventFieldsDescription
depositeduser, amountMargin deposited
withdrewuser, amountMargin withdrawn

Vault events

EventFieldsDescription
liquidity_addeduser, amount, shares_mintedLP deposited to vault
liquidity_unlockinguser, amount, shares_burned, end_timeLP withdrawal initiated (cooldown)
liquidity_releaseduser, amountCooldown completed, funds released

Order events

EventFieldsDescription
order_filledorder_id, pair_id, user, fill_price, fill_size, closing_size, opening_size, realized_pnl, realized_funding?, fee, client_order_id?, fill_id?, is_maker?Order partially or fully filled
order_persistedorder_id, pair_id, user, limit_price, size, client_order_id?Limit order placed on book
order_removedorder_id, pair_id, user, reason, client_order_id?Order removed from book

client_order_id is null if the order was submitted without one. Off-chain consumers can use it to correlate fills, persistence, and removal with the originally-submitted client id.

fill_id groups the two sides of a single order-book match. When a taker crosses a resting maker, two order_filled events are emitted — one for each side — and both carry the same fill_id. Successive matches use consecutive ids (strictly increasing), so a taker that crosses two makers in the same transaction produces four events with two distinct fill_id values. fill_id is null for trades executed before v0.15.0 — fill IDs were not assigned prior to that release. Not emitted for ADL fills, which use the deleveraged and liquidated events instead.

is_maker is true for the maker side of a match and false for the taker side. Within a single match’s pair of order_filled events (sharing one fill_id), exactly one carries is_maker = true and one carries is_maker = false. is_maker is null for trades executed before v0.16.0 — the maker/taker flag was not recorded prior to that release.

realized_pnl on order_filled and deleveraged (and adl_realized_pnl on liquidated) reports the closing PnL on the fill — price movement on the closed portion. The funding settled on the user’s pre-existing position immediately before the fill is reported separately as realized_funding (or adl_realized_funding on liquidated).

realized_funding is null for events emitted before v0.17.0 — funding was bundled into realized_pnl (and adl_realized_pnl) prior to that release. From v0.17.0 onward the field is always present and realized_pnl + realized_funding equals the pre-v0.17.0 lump sum.

Trading fees are reported separately in the fee field on order_filled; ADL and deleverage fills incur no trading fees.

Conditional order events

EventFieldsDescription
conditional_order_placedpair_id, user, trigger_price, trigger_direction, size, max_slippageTP/SL order created
conditional_order_triggeredpair_id, user, trigger_price, trigger_direction, oracle_priceTP/SL triggered by price move
conditional_order_removedpair_id, user, trigger_direction, reasonTP/SL removed

Liquidation events

EventFieldsDescription
liquidateduser, pair_id, adl_size, adl_price, adl_realized_pnl, adl_realized_funding?Position liquidated in a pair
deleverageduser, pair_id, closing_size, fill_price, realized_pnl, realized_funding?Counter-party hit by ADL
bad_debt_coveredliquidated_user, amount, insurance_fund_remainingInsurance fund absorbed bad debt

ReasonForOrderRemoval

ValueDescription
filledOrder fully filled
canceledUser voluntarily canceled
position_closedPosition was closed (conditional orders only)
self_trade_preventionOrder crossed user’s own order on the opposite side
liquidatedUser was liquidated
deleveragedUser was hit by auto-deleveraging
slippage_exceededConditional order triggered but could not fill within slippage
price_band_violationResting price drifted outside the per-pair band before match
slippage_cap_tightenedConditional order’s stored max_slippage now exceeds the pair’s tightened cap

For liquidation and ADL mechanics, see Liquidation & ADL.

10. Types reference

10.1 Numeric types

All numeric types are signed fixed-point decimals with 6 decimal places, built on dango_types::Number. They are serialized as strings:

Type aliasDimensionExample usageExample value
Dimensionless(pure scalar)Fee rates, margin ratios, slippage"0.050000"
QuantityquantityPosition size, order size, OI"-0.500000"
UsdValueusdMargin, PnL, notional, fees"10000.000000"
UsdPriceusd / quantityOracle price, limit price, entry price"65000.000000"
FundingPerUnitusd / quantityCumulative funding accumulator"0.000123"
FundingRateper dayFunding rate cap"0.000500"

Additional integer types:

TypeEncodingDescription
Uint128StringLarge integer (e.g. vault shares)
u64Number or StringGas limit, timestamps
u32NumberUser index, account index, nonce

10.2 Identifiers

TypeFormatExample
PairIdperp/<base><quote>"perp/btcusd", "perp/ethusd"
OrderIdUint64 (string)"42"
ConditionalOrderIdUint64 (shared counter)"43"
ClientOrderIdUint64 (caller-assigned)"42"
FillIdUint64 (per-match identifier)"17"
AddrHex address"0x1234...abcd"
Hash25664-char uppercase hex"A1B2C3D4E5F6..."
UserIndexu320
AccountIndexu321
Username1–15 chars, [a-z0-9_]"alice"
TimestampSeconds since epoch (decimal)"1700000000.123456789"
DurationSeconds (decimal)"3600" (1 hour)

Timestamp and Duration are encoded as fixed-point decimal strings with up to 9 fractional digits (nanosecond precision); trailing zeros are elided. So "1700000000", "1700000000.5", and "1700000000.123456789" are all valid Timestamp values.

10.3 Enums

OrderKind:

{
  "market": {
    "max_slippage": "0.010000"
  }
}
{
  "limit": {
    "limit_price": "65000.000000",
    "time_in_force": "GTC",
    "client_order_id": "42"
  }
}

client_order_id is optional. Defaults to null when omitted; not allowed with time_in_force: "IOC".

TimeInForce: "GTC" | "IOC" | "POST" (defaults to "GTC" if omitted)

TriggerDirection:

"above"
"below"

CancelOrderRequest:

{
  "one": "42"
}
{
  "one_by_client_order_id": "42"
}
"all"

SubmitOrderRequest:

{
  "pair_id": "perp/btcusd",
  "size": "-0.500000",
  "kind": {
    "limit": {
      "limit_price": "65000.000000",
      "time_in_force": "GTC",
      "client_order_id": "42"
    }
  },
  "reduce_only": false,
  "tp": null,
  "sl": null
}

Same shape used by submit_order and each submit entry in batch_update_orders.

SubmitOrCancelOrderRequest:

{ "submit": { /* SubmitOrderRequest */ } }
{ "cancel": { "one": "42" } }
{ "cancel": "all" }

One action inside a batch_update_orders list. Conditional (TP/SL) orders are not supported.

CancelConditionalOrderRequest:

{
  "one": {
    "pair_id": "perp/btcusd",
    "trigger_direction": "above"
  }
}
{
  "all_for_pair": {
    "pair_id": "perp/btcusd"
  }
}
"all"

Key:

{
  "secp256r1": "<base64>"
}
{
  "secp256k1": "<base64>"
}
{
  "ethereum": "0x1234...abcd"
}

Credential:

{
  "standard": {
    "key_hash": "...",
    "signature": { ... }
  }
}
{
  "session": {
    "session_info": { ... },
    "session_signature": "...",
    "authorization": { ... }
  }
}

CandleInterval (GraphQL enum):

ONE_SECOND | ONE_MINUTE | FIVE_MINUTES | FIFTEEN_MINUTES | ONE_HOUR | FOUR_HOURS | ONE_DAY | ONE_WEEK

10.4 Response types

Param (global parameters) — see §4.1 for all fields.

PairParam (per-pair parameters) — see §4.3 for all fields.

PairState:

FieldTypeDescription
long_oiQuantityTotal long open interest
short_oiQuantityTotal short open interest
funding_per_unitFundingPerUnitCumulative funding accumulator
funding_rateFundingRateCurrent per-day funding rate (positive = longs pay)

State (global state) — see §4.2 for all fields.

UserState — see §5.1 for all fields.

UserStateExtended — see §5.6 for all fields.

Position:

FieldTypeDescription
sizeQuantityPositive = long, negative = short
entry_priceUsdPriceAverage entry price
entry_funding_per_unitFundingPerUnitFunding accumulator at last update
conditional_order_aboveConditionalOrder|nullTP/SL that triggers when oracle >= trigger_price
conditional_order_belowConditionalOrder|nullTP/SL that triggers when oracle <= trigger_price

ConditionalOrder (embedded in Position):

FieldTypeDescription
order_idOrderIdInternal ID for price-time priority
sizeQuantity|nullSize to close (sign opposes position); null closes entire position
trigger_priceUsdPriceOracle price that activates this order
max_slippageDimensionlessSlippage tolerance for the market order at trigger

ChildOrder (TP/SL attached to a parent order):

FieldTypeDescription
trigger_priceUsdPriceOracle price that activates this order
max_slippageDimensionlessSlippage tolerance for the market order at trigger
sizeQuantity|nullSize to close (sign opposes position); null closes entire position

Unlock:

FieldTypeDescription
end_timeTimestampWhen cooldown completes
amount_to_releaseUsdValueUSD value to release

QueryOrderResponse:

FieldTypeDescription
userAddrOrder owner
pair_idPairIdTrading pair
sizeQuantityOrder size
limit_priceUsdPriceLimit price
reduce_onlyboolWhether the order only reduces an existing position
reserved_marginUsdValueMargin reserved for this order
created_atTimestampCreation time

LiquidityDepthResponse:

FieldTypeDescription
bidsMap<UsdPrice, LiquidityDepth>Bid-side depth by price
asksMap<UsdPrice, LiquidityDepth>Ask-side depth by price

LiquidityDepth:

FieldTypeDescription
sizeQuantityAbsolute order size in bucket
notionalUsdValueUSD notional (size × price)

User (account factory):

FieldTypeDescription
indexUserIndexUser’s numerical index
nameUsernameUser’s username
accountsMap<AccountIndex, Addr>Accounts owned (index → address)
keysMap<Hash256, Key>Associated keys (hash → key)

Account:

FieldTypeDescription
indexAccountIndexAccount’s unique index
ownerUserIndexOwning user’s index

11. Constants

Endpoints

NetworkHTTPWebSocket
Mainnethttps://api-mainnet.dango.zone/graphqlwss://api-mainnet.dango.zone/graphql
Testnethttps://api-testnet.dango.zone/graphqlwss://api-testnet.dango.zone/graphql

Chain IDs

NetworkChain ID
Mainnetdango-1
Testnetdango-testnet-1

Contract addresses

NameMainnetTestnet
ACCOUNT_FACTORY_CONTRACT0x18d28bafcdf9d4574f920ea004dea2d13ec16f6b0x18d28bafcdf9d4574f920ea004dea2d13ec16f6b
ORACLE_CONTRACT0xcedc5f73cbb963a48471b849c3650e6e34cd3b6d0xcedc5f73cbb963a48471b849c3650e6e34cd3b6d
PERPS_CONTRACT0x90bc84df68d1aa59a857e04ed529e9a26edbea4f0xf6344c5e2792e8f9202c58a2d88fbbde4cd3142f

Code hashes

NameValue
Single-signature accountd86e8112f3c4c4442126f8e9f44f16867da487f29052bf91b810457db34209a4

The code hash is the same on mainnet and testnet.