Skip to content

IMM (Internal Market Maker) ​

IMM is your own market-making desk on the engine: you set the price, and your vault — a real wallet you fund — takes the other side of every trade. Instead of routing through a public pool, a trade moves the user's tokens into your vault and pays them out of it at your rate. It's chain-agnostic: the same endpoints work on Celo and Stellar (and any EVM chain the engine runs).

A tenant builds and runs an IMM entirely with its own API key and secret (X-API-Key / X-API-Secret), exactly as on the dashboard's Markets page — every endpoint below is scoped to your tenant automatically.

▸API ConfigurationNot configured
Every endpoint, payload, and response on this page follows this choice.

Everything on this page is real

Every request moves a real balance, or refuses and tells you why. The engine never mints to cover a trade and never signs with its own relayer for you: a trade your vault can't pay for fails and the user's tokens are returned; a transaction your relayer can't pay gas for is refused before it's sent.

How IMM works ​

PieceWhat it isWhere on the dashboard
RelayerYour own wallet that signs and pays gas. Required — the engine's relayer is never used for a tenant.Wallets → Relayers
Rate tableYour price for each pair (1 base = rate × quote). Kept on CBK's live rates automatically, or set by hand.Markets → Pricing & trades
VaultYour reserve wallet per chain. Trades pay out of it; it must hold the asset being bought.Markets → Pricing & trades → Vault
Exposure & limitsNet position per asset, capped per trade / per day.Markets → Pricing & trades → Risk exposure
AMM poolsOptional Uniswap V2 liquidity from your relayer.Markets → AMM pools
CorridorsMulti-hop treasury routes between your nodes.Markets → Corridors

Pricing. Rates between USDT, USDC, IMC and KES are rewritten every 15 minutes (and whenever the Central Bank of Kenya publishes) from CBK's USD/KES mid, minus the platform margin — see Exchange Rates. IMC is $1, backed by airtime. You can also set any rate yourself; a rate older than 60 minutes is refused as stale.

Build your IMM (tenant quick start) ​

All requests: -H "X-API-Key: $KEY" -H "X-API-Secret: $SECRET". Replace celo with stellar to run on Stellar.

1. Create your relayer ​

bash
curl -X POST $ENGINE/api/v1/admin/wallet-config/relayer/generate \
  -H "Content-Type: application/json" \
  -d '{ "chain": "celo", "feature": "*" }'

feature: "*" covers every IMM operation on that chain; use "imm.vault" or "imm.amm" for a wallet dedicated to one. To bring a key you already hold: POST /api/v1/admin/wallet-config/relayer/import with privateKeyHex (EVM) or secretSeed + "scheme": "stellar". List yours: GET /api/v1/relayers (with live gas and token balances).

Fund the relayer's address with gas (CELO / XLM) and the tokens you'll put in your vault.

2. Check today's pricing ​

bash
curl "$ENGINE/api/v1/admin/imm/pricing"          # CBK USD/KES, margins, vault value, 24h spread earned
curl "$ENGINE/api/v1/admin/imm/rates"            # your rate table

Set or override a rate:

bash
curl -X POST $ENGINE/api/v1/admin/imm/rates \
  -H "Content-Type: application/json" \
  -d '{ "base": "USDT", "quote": "KES", "rate": 127.54 }'
curl -X DELETE "$ENGINE/api/v1/admin/imm/rates?base=USDT&quote=KES"

3. Fund your vault ​

Moves tokens you already hold into your vault on that chain — nothing is minted. from picks the source:

  • "treasury": your treasury for that asset. IMC from booked airtime top-ups sits in the IMC treasury; USDT you sent to your USDT treasury sits there. This is the default for IMC.
  • "relayer": your relayer. This is the default for everything else.
bash
curl -X POST $ENGINE/api/v1/admin/imm/vault/fund \
  -H "Content-Type: application/json" \
  -d '{ "chain": "celo", "asset": "USDT", "amount": 1000, "from": "relayer", "externalId": "fund-2026-09-25-1" }'
curl "$ENGINE/api/v1/admin/imm/vault?chain=celo&asset=USDT"   # on-chain vs ledger balance

To move tokens back out — to your relayer (default) or treasury — withdraw; "all": true empties that asset:

bash
curl -X POST $ENGINE/api/v1/admin/imm/vault/withdraw \
  -H "Content-Type: application/json" \
  -d '{ "chain": "celo", "asset": "IMC", "all": true, "to": "relayer", "externalId": "wd-2026-09-25-1" }'
# → { "status": "success", "result": { "txHash": "0x…", "amount": 57703.39, "asset": "IMC", "chain": "celo" } }

You can also send tokens straight to the vault address (returned by GET /admin/imm/vault). GET /api/v1/wallets/custody lists every treasury, vault and cycle-run wallet with live balances.

4. Quote and trade for your users ​

bash
curl "$ENGINE/api/v1/imm/quote?base=USDT&quote=KES&amountIn=100"
curl -X POST $ENGINE/api/v1/imm/swap \
  -H "Content-Type: application/json" \
  -d '{ "externalUserId": "user-42", "chain": "celo", "base": "USDT", "quote": "IMC", "amountIn": 100, "externalId": "trade-7781" }'

The user's base goes into your vault; quote is paid from it. If the vault can't cover it, the trade fails and the user's tokens are returned. externalId makes the trade idempotent.

5. Watch exposure, rebalance ​

bash
curl "$ENGINE/api/v1/admin/imm/exposure?chain=celo&asset=USDT"
curl -X POST $ENGINE/api/v1/admin/imm/rebalance \
  -H "Content-Type: application/json" \
  -d '{ "chain": "celo", "fromAsset": "USDT", "toAsset": "IMC", "amount": 100, "externalId": "rebal-1" }'

6. AMM liquidity (optional) ​

bash
curl "$ENGINE/api/v1/admin/imm/amm"                                   # your signing wallet + every pool and your share
curl -X POST $ENGINE/api/v1/admin/imm/amm/pairs -d '{ "tokenA": "USDT", "tokenB": "IMC" }' -H "Content-Type: application/json"
curl -X POST $ENGINE/api/v1/admin/imm/amm/liquidity -d '{ "tokenA": "USDT", "tokenB": "IMC", "amountA": 500, "amountB": 500 }' -H "Content-Type: application/json"
curl -X POST $ENGINE/api/v1/admin/imm/amm/liquidity/remove -d '{ "tokenA": "USDT", "tokenB": "IMC" }' -H "Content-Type: application/json"

Liquidity comes from, and LP tokens return to, your relayer.

7. Everything is journaled ​

Every trade, fund and rebalance appears in GET /api/v1/journal with its transaction hash and the wallet address it touched.

EndpointPurpose
POST /admin/wallet-config/relayer/generate · /importCreate or import your relayer
GET /relayersYour relayers with balances
GET /admin/imm/pricingToday's CBK-based pricing and your spread earned
GET · POST · DELETE /admin/imm/ratesYour rate table
GET /imm/quotePrice a trade (no side effects)
POST /imm/swapExecute a trade against your vault
GET /admin/imm/vault · POST /admin/imm/vault/fundVault balance · fund it from your treasury or relayer
GET /admin/imm/vault/sources?chain=&asset=The vault's, treasury's and relayer's balance of one asset — what you can fund from or move to
POST /admin/imm/vault/withdrawMove vault tokens back to your relayer or treasury
GET /wallets/custodyEvery treasury, vault and cycle-run wallet, with balances
GET /admin/imm/exposureNet position and limits
POST /admin/imm/rebalanceFlatten an imbalance via the AMM
GET /admin/imm/amm · POST /admin/imm/amm/pairs · /liquidity · /liquidity/removeAMM liquidity from your relayer
/admin/corridor/*Treasury corridors (Part 2 below)

Part 1 — Swaps ​

Step 1: Set a real rate ​

Set Rate
curl -X POST "http://localhost:8100/api/v1/admin/imm/rates" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "base": "USDT",
  "quote": "IMC",
  "rate": 1.05
}'
Response200
{
  "status": "success",
  "rate": {
    "tenantId": 1,
    "base": "USDT",
    "quote": "IMC",
    "rate": 1.05,
    "updatedAt": "2026-09-09T09:00:00.000Z"
  }
}

Upserts: 1 base = rate quote. Setting (base, quote) also answers queries for the inverse (quote, base) automatically — you only ever set one direction per pair. Use your own real, measured rate here, not a placeholder — this number is exactly what every quote and swap will use until you change it.

List Rates
curl -X GET "http://localhost:8100/api/v1/admin/imm/rates" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "count": 1,
  "rates": [
    {
      "tenantId": 1,
      "base": "USDT",
      "quote": "IMC",
      "rate": 1.05,
      "updatedAt": "2026-09-09T09:00:00.000Z"
    }
  ]
}

Step 2: Fund the vault ​

Every swap's quote leg pays out of IMM's own real custodial wallet (one per chain) if it holds enough — that's what "vault-backed" means. An unfunded vault can't pay out: IMM never mints to cover a trade, so fund the vault with every asset your users will buy.

Fund Vault (Celo)
curl -X POST "http://localhost:8100/api/v1/admin/imm/vault/fund" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "chain": "celo",
  "asset": "IMC",
  "amount": 5000,
  "externalId": "vault-seed-001"
}'
Response200
{
  "status": "success",
  "txHash": "0x67d440eea5ad5e24c20cf6f85067d94b4a695dc234a170cd0aaf2493b8832ecc"
}

Check what the vault actually holds, next to what the ledger thinks it owes — these should track each other closely, diverging only by trades still settling or transfers made outside the engine:

Check Vault Balance (Celo)
curl -X GET "http://localhost:8100/api/v1/admin/imm/vault?chain=celo&asset=IMC" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "chain": "celo",
  "asset": "IMC",
  "vaultAddress": "0x823ed9603f3bb0bb400502b351b0476e24385142",
  "onChainBalance": 5000,
  "ledgerInventoryBalance": 0
}

Step 3: Get a quote (optional, no side effects) ​

Get Quote
curl -X GET "http://localhost:8100/api/v1/imm/quote?base=USDT&quote=IMC&amountIn=100" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "base": "USDT",
  "quote": "IMC",
  "rate": 1.05,
  "amountIn": 100,
  "amountOut": 105,
  "rateAge": "3m12s"
}

amountIn/amountOut are human-readable amounts, not base units. rateAge reflects how long ago the rate was last set — a quote or swap against a rate older than 60 minutes is refused with imm: rate is stale, refresh it before trading.

Step 4: Execute the swap ​

Execute Swap (Celo)
curl -X POST "http://localhost:8100/api/v1/imm/swap" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "externalUserId": "user-24",
  "chain": "celo",
  "base": "USDT",
  "quote": "IMC",
  "amountIn": 100,
  "externalId": "imm-swap-001"
}'
Response200
{
  "status": "success",
  "chain": "celo",
  "result": {
    "base": "USDT",
    "quote": "IMC",
    "rate": 1.05,
    "amountIn": 100,
    "amountOut": 105,
    "baseTxHash": "0x1a2b...",
    "quoteTxHash": "0x3c4d...",
    "vaultBacked": true
  }
}

What actually happens, in order:

  1. Look up the admin-set rate for (base, quote) — reject if none exists or it's stale.
  2. Check risk limits for both legs (see below).
  3. Base leg — always a real transfer into the vault, never destroyed. amountIn of base moves from the user's wallet into IMM's vault wallet, on-chain.
  4. Quote leg — paid from the vault, or not at all. If the vault holds enough quote, amountIn * rate of it is paid out of the vault to the user, on-chain (vaultBacked: true). If it doesn't, nothing is minted: the trade fails and the base leg is returned to the user automatically.
  5. Journal both legs, plus one unified swap entry (visible in the Journal, tagged venue: "imm", vaultBacked) — the same consolidated feed every AMM swap also writes to.

Automatic rollback on partial failure

The base transfer happens before the quote payout. If the quote leg fails after a successful base transfer, the engine automatically reverses the base transfer (vault → user, signed as the vault) before ever returning an error — the response says exactly that ("...was automatically reversed — your <asset> was not charged") and includes both the original and reversal tx hashes. Only if the reversal itself also fails does this fall back to the old "...needs manual reconciliation" error — a genuine last resort, not the default outcome. Either way, watch server logs for the [imm] CRITICAL line, which only appears in that last-resort case.

Step 5: Check exposure ​

Check Exposure (Celo)
curl -X GET "http://localhost:8100/api/v1/admin/imm/exposure?chain=celo&asset=IMC" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "chain": "celo",
  "asset": "IMC",
  "netPosition": 105,
  "exposureCap": 50000,
  "volume24h": 105,
  "dailyCap": 10000,
  "maxTrade": 1000
}

Step 6: Rebalance when needed ​

If trades have left the vault heavy in one asset and light in another, flatten it via the chain's existing AMM — executed as the vault's own identity, converting the vault's excess of one asset into another. On Celo that's the Uniswap V2 router; on Stellar it's the native protocol-level AMM (see Stellar AMM) — same request/response shape either way:

Rebalance (Celo)
curl -X POST "http://localhost:8100/api/v1/admin/imm/rebalance" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "chain": "celo",
  "fromAsset": "USDT",
  "toAsset": "IMC",
  "amount": 50,
  "externalId": "rebalance-001"
}'
Response200
{
  "status": "success",
  "txHash": "0x5e6f..."
}

This is always available as a manual, admin-triggered action. It can also happen automatically — see below.

Automatic rebalancing (opt-in) ​

A background scheduler checks every configured asset's exposure every 5 minutes and flattens it automatically via the same mechanism as the manual call above, once it crosses a threshold — off by default, per-asset opt-in:

SettingEnv varDefault
Rebalance destinationIMM_REBALANCE_TARGET_<SYMBOL>unset — auto-rebalance disabled for this asset
Trigger thresholdIMM_AUTO_REBALANCE_THRESHOLD_PCT_<SYMBOL>0.8 (80% of the asset's exposure cap)

Setting IMM_REBALANCE_TARGET_IMC=USDT means: once IMC's net exposure reaches 80% of IMM_EXPOSURE_CAP_IMC, the engine automatically swaps the full excess into USDT via the chain's AMM — the exact same call and journal entries as an admin-triggered rebalance, just scheduler-initiated. Leaving the target unset (the default) means nothing here ever fires on its own; an operator decides every time, exactly as before.

Why this doesn't go through Approvals

Approvals requires a human to sign off in real time — there's no one to approve a 3am scheduler tick. Auto-rebalance is bounded a different way: it only ever acts on an asset an operator explicitly named a destination for, in advance, at a threshold they chose. Nothing fires without that prior, deliberate configuration.

Risk limits ​

All three are per-asset, env-overridable, and checked before any on-chain action:

LimitEnv varDefault
Max single tradeIMM_MAX_TRADE_<SYMBOL>1,000 whole units
Rolling 24h volumeIMM_DAILY_CAP_<SYMBOL>10,000 whole units
Net exposure (either direction)IMM_EXPOSURE_CAP_<SYMBOL>50,000 whole units

Exposure is capped in either direction — "IMM has accumulated too much of an asset" and "IMM has minted too much unbacked supply of an asset" are the same underlying risk (an unrebalanced position), so the cap applies to the magnitude regardless of sign.

Part 2 — Treasury (corridors) ​

A corridor is a named, ordered sequence of hops across nodes — real liquidity points, each backed by an actual balance (an on-chain wallet, or an isolated fiat/float ledger account). Running a corridor's cycle walks its hops in order, and at every single hop it re-checks the source node's real, current balance before moving anything. A hop's output is never assumed — it is always amountIn × rate, using a real rate you configured, and if a node can't honestly cover the next hop, the cycle stops right there and reports exactly why. Nothing here ever fabricates progress to make a projected return look real.

This is the mechanism behind things like "buy discounted airtime → liquidate through a mobile-money agent → mint the airtime-backed token → roll the proceeds into the next hop → exit once at the end" — but built so every step is a real, checked balance movement, not a face-value assumption.

Step 1: Create nodes ​

A node is either on-chain (a real custodial wallet on a configured chain/asset) or fiat (a pure ledger float — a mobile-money agent balance, a PSP virtual-card float — that never touches a blockchain):

Create a Fiat Node
curl -X POST "http://localhost:8100/api/v1/admin/corridor/nodes" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "name": "T-Kash Super-Agent",
  "class": "agent_float",
  "kind": "fiat",
  "asset": "KES",
  "targetWeight": 0.1,
  "minBalance": 1000,
  "rebalanceBandPct": 0.2
}'
Response200
{
  "status": "success",
  "node": {
    "id": "6aa12a0f0349585718f06ba8",
    "tenantId": 1,
    "name": "T-Kash Super-Agent",
    "class": "agent_float",
    "kind": "fiat",
    "asset": "KES",
    "targetWeight": 0.1,
    "minBalance": 1000,
    "rebalanceBandPct": 0.2,
    "createdAt": "2026-09-09T09:38:40.732Z",
    "updatedAt": "2026-09-09T09:38:40.732Z"
  }
}
Create an On-Chain Node
curl -X POST "http://localhost:8100/api/v1/admin/corridor/nodes" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "name": "IMC Mint",
  "class": "stablecoin_mint",
  "kind": "chain",
  "chain": "celo",
  "asset": "IMC",
  "targetWeight": 0.1,
  "minBalance": 10,
  "rebalanceBandPct": 0.1
}'
Response200
{
  "status": "success",
  "node": {
    "id": "6aa12a100349585718f06ba9",
    "tenantId": 1,
    "name": "IMC Mint",
    "class": "stablecoin_mint",
    "kind": "chain",
    "chain": "celo",
    "asset": "IMC",
    "targetWeight": 0.1,
    "minBalance": 10,
    "rebalanceBandPct": 0.1,
    "createdAt": "2026-09-09T09:38:41.323Z",
    "updatedAt": "2026-09-09T09:38:41.323Z"
  }
}

minBalance is the hard floor — the single most important field on a node. A hop debiting this node will refuse to run if it would take the real balance below this, no matter what a corridor's configured rate implies should happen next. targetWeight/rebalanceBandPct feed the rebalance check (Step 6) — they don't affect whether a cycle can execute.

Automatic floor recovery — opt-in, one explicit source only

Set refillFromNodeId on a node (same asset, same chain if chain-kind) and the scheduler tops it back up from that one named source automatically whenever it drops below minBalance — up to refillTarget (defaults to 2× minBalance). This is deliberately narrower than IMM's auto-rebalance: the portfolio-level rebalance check below (Step 8) never auto-executes anything, because which node to pull from across a whole portfolio is a real operator judgment call. A refill has no such ambiguity — you named the one source in advance — so it's safe to automate. The refill itself still refuses to run if it would take the source below its own floor, the same honesty guarantee every hop already makes.

Step 2: Seed initial capital ​

Seeding is the one explicit, one-time, admin-triggered funding action — not something a cycle ever does on its own. A chain node is funded by a transfer from your corridor relayer (nothing is minted); a fiat node gets an audited ledger credit:

Seed a Node
curl -X POST "http://localhost:8100/api/v1/admin/corridor/nodes/6aa12a0f0349585718f06ba8/seed" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "amount": 130000,
  "externalId": "seed-kes-001"
}'
Response200
{
  "status": "success",
  "txHash": ""
}

For a fiat node this is a ledger credit to that node's own isolated account. For an on-chain node it's a real mint — and only for assets your engine is actually the honest issuer of (the same Type/Backing == "airtime" gate /tokenize/airtime enforces elsewhere); anything else needs a real external transfer into the node's wallet instead.

Step 3: List nodes (with live balances) ​

List Nodes
curl -X GET "http://localhost:8100/api/v1/admin/corridor/nodes" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "count": 2,
  "nodes": [
    {
      "id": "6aa12a0f0349585718f06ba8",
      "name": "T-Kash Super-Agent",
      "kind": "fiat",
      "asset": "KES",
      "balance": 130000,
      "minBalance": 1000,
      "targetWeight": 0.1
    },
    {
      "id": "6aa12a100349585718f06ba9",
      "name": "IMC Mint",
      "kind": "chain",
      "chain": "celo",
      "asset": "IMC",
      "balance": 0,
      "minBalance": 10,
      "targetWeight": 0.1
    }
  ]
}

Every call to this endpoint re-reads real balances (on-chain AssetBalance, or the node's own ledger account) — nothing here is cached or assumed.

Step 4: Create a corridor ​

Create a Corridor
curl -X POST "http://localhost:8100/api/v1/admin/corridor/corridors" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "name": "KES to IMC corridor",
  "hops": [
    {
      "fromNodeId": "6aa12a0f0349585718f06ba8",
      "toNodeId": "6aa12a100349585718f06ba9",
      "rateType": "fx_edge",
      "rate": 0.00769
    }
  ],
  "cyclesPerDay": 5,
  "rebalanceAt": "02:00",
  "defaultPrincipalAmount": 5000
}'
Response200
{
  "status": "success",
  "corridor": {
    "id": "6aa12a1d0349585718f06bab",
    "tenantId": 1,
    "name": "KES to IMC corridor",
    "hops": [
      {
        "fromNodeId": "6aa12a0f0349585718f06ba8",
        "toNodeId": "6aa12a100349585718f06ba9",
        "rateType": "fx_edge",
        "rate": 0.00769
      }
    ],
    "cyclesPerDay": 5,
    "rebalanceAt": "02:00",
    "defaultPrincipalAmount": 5000,
    "killSwitch": false,
    "createdAt": "2026-09-09T09:42:53.802Z",
    "updatedAt": "2026-09-09T09:42:53.802Z"
  }
}

Every rate here is real and yours to set — rateType is a label for your own audit trail ("procure_discount", "liquidation_spread", "fx_edge", "mint", ...), not something the engine interprets. defaultPrincipalAmount is what the scheduler uses to auto-trigger cycles (Step 7) — leave it at 0 for a corridor you only ever want to run manually.

Step 5: Execute a cycle ​

Execute a Cycle
curl -X POST "http://localhost:8100/api/v1/admin/corridor/corridors/6aa12a1d0349585718f06bab/execute" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "principalAmount": 50000,
  "externalId": "cycle-001"
}'
Response200
{
  "status": "success",
  "cycle": {
    "id": "6aa12a1e0349585718f06bac",
    "corridorId": "6aa12a1d0349585718f06bab",
    "externalId": "cycle-001",
    "startAmount": 50000,
    "endAmount": 384.5,
    "multiplier": 0.00769,
    "hops": [
      {
        "fromNodeId": "6aa12a0f0349585718f06ba8",
        "toNodeId": "6aa12a100349585718f06ba9",
        "rateType": "fx_edge",
        "rate": 0.00769,
        "amountIn": 50000,
        "amountOut": 384.5,
        "synthetic": false
      }
    ],
    "aborted": false
  }
}

What an honest abort looks like

If the source node can't actually cover the next hop, this endpoint returns the aborted cycle instead of pretending it finished:

json
{
  "error": "corridor: cycle aborted: hop 0: T-Kash Super-Agent has 80000.0000 KES, needs 90000.0000 while keeping its 1000.0000 floor — refusing to execute on an assumed balance",
  "cycle": { "aborted": true, "abortReason": "...", "endAmount": 90000, "hops": null }
}

Whatever balance the node had before stays exactly where it was — nothing moves on an abort. Handle this response, not just the success case — a real integration needs to know when a corridor genuinely can't run right now, not just when it can.

Step 6: Kill switches ​

Two independent levels, checked before every single cycle — manual or scheduled:

Halt One Corridor
curl -X POST "http://localhost:8100/api/v1/admin/corridor/corridors/6aa12a1d0349585718f06bab/kill-switch" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "killed": true
}'
Response200
{
  "status": "success",
  "corridor": {
    "id": "6aa12a1d0349585718f06bab",
    "killSwitch": true
  }
}
Halt Every Corridor (this tenant)
curl -X POST "http://localhost:8100/api/v1/admin/corridor/kill-switch" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "enabled": true
}'
Response200
{
  "status": "success",
  "enabled": true
}

The global switch stops everything for the tenant regardless of any individual corridor's own setting — use it as the real emergency stop.

Step 7: The scheduler ​

Once a corridor has defaultPrincipalAmount > 0, it runs on its own — one cycle every 24h / cyclesPerDay, checked every minute, self-correcting after any downtime instead of drifting off a fixed clock. Set rebalanceAt (Africa/Nairobi, "HH:MM") to also get one automatic rebalance check (never an automatic transfer — see below) per day. Nothing here needs a separate process or cron job on your side — it's a background goroutine inside the engine itself, gated by the same kill switches as manual execution.

Step 8: Rebalance checks ​

This tells you which nodes have drifted from their target share of the vault, or breached their floor — it never moves anything on its own, the same way a real treasury desk's monitoring shouldn't silently "fix" a position for you:

Run a Rebalance Check
curl -X POST "http://localhost:8100/api/v1/admin/corridor/rebalance/check" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "status": "success",
  "check": {
    "id": "6aa12dc6b9e8dfb880ea76bf",
    "checkedAt": "2026-09-09T09:58:30.816Z",
    "totalVaultValueUsd": 384.5,
    "unnormalizedNodes": 1,
    "deviations": [
      {
        "nodeId": "6aa12a100349585718f06ba9",
        "nodeName": "IMC Mint",
        "asset": "IMC",
        "balance": 384.5,
        "normalized": true,
        "valueUsd": 384.5,
        "actualWeight": 1,
        "targetWeight": 0.1,
        "deviationPct": 9,
        "outOfBand": true,
        "belowFloor": false
      }
    ],
    "anyOutOfBand": true,
    "anyBelowFloor": true
  }
}

Normalization is honest, not universal

This engine has no price oracle. A node's balance is only converted to USD if it's already USD/USDC/USDT, or if you've set a real IMM rate for (asset, "USD") (Part 1, Step 1). A node with no such rate is reported with normalized: false and excluded from the total — never assigned a fabricated value. Set the rates you actually need before relying on totalVaultValueUsd.

Part 3 — Arbitrage Cycles (KES → Airtime → IMC → USDT) ​

🔒 Super admin or service token. The platform's capital loop. A run starts with seed capital in KES and repeats N cycles (default 5); each cycle's chosen exit is reinvested as the next cycle's opening capital.

ColumnFormula
D Starting capitalseed, then the previous cycle's exit
E Airtime face valueD × (1 + discount) — airtime bought at the gateway
F Discount capturedE − D
G IMC (USD)E ÷ internal rate — drawn from the tenant's IMC treasury, which only airtime top-ups fill (see IMC & Airtime); never minted by the cycle
H USDTG — swapped 1:1 out of the IMM vault (the same vault IMM trades use), after a liquidity check
I Option 1 — KES exitH × CBK rate
J Option 2 — USDT exitH × USDT rate (preferred)
K Net profitexit − D, where exit = the higher of I and J — equivalently USDT when (usdt − internal) > (cbk − internal)

Inputs are set per run and prefilled from the engine: CBK USD/KES, internal rate = CBK × (1 − IMM_STABLE_SPREAD_BPS), USDT rate = the engine's USDT/KES price, discount = AIRTIME_BONUS_PCT. A discount below 3.5% aborts the cycle (discount floor breach). If the IMC treasury can't cover a cycle, the run stops with "book an airtime top-up"; a failed run can be resumed (POST /admin/imm/cycles/:id/resume) from the step that failed — nothing already on-chain is repeated.

The last cycle is not reinvested. Its USDT waits for the operator to withdraw it to a Binance (Celo) deposit address — only on explicit confirmation, since withdrawals can't be reversed.

On testnet the airtime top-ups are simulated and a run executes on its own; drawing IMC from the treasury, the 1:1 swap and settlement are real on-chain transactions. On a live gateway each cycle waits for the operator to confirm the real top-up's gateway reference.

Preview the model (no chain calls) ​

Preview

🔒 Admin operation — always requires a Service Token, regardless of the panel above.

curl -X POST "http://localhost:8100/api/v1/admin/imm/cycles/preview" \
  -H "Content-Type: application/json" \
  -H "X-Service-Token: YOUR_SERVICE_TOKEN" \
  -d '{
  "baseCapitalKes": 3000,
  "cycles": 5,
  "inputs": {
    "internalRate": 128.8525,
    "cbkRate": 129.5,
    "usdtRate": 130.2,
    "discountPct": 5
  }
}'
Response200
{
  "route": "USDT",
  "projection": {
    "totalProfitKes": 1033.27,
    "returnPct": 34.44,
    "cycles": [
      {
        "n": 1,
        "openingKes": 3000,
        "airtimeFaceValue": 3150,
        "discountCapture": 150,
        "imcUnits": 24.446557,
        "usdtReceived": 24.446557,
        "option1Kes": 3165.83,
        "option2Kes": 3182.94,
        "exitPathChosen": "USDT",
        "cyclePnl": 182.94
      }
    ]
  }
}

GET /api/v1/admin/imm/cycles/rates returns today's default inputs and the model projected on them.

Start a run ​

Start

🔒 Admin operation — always requires a Service Token, regardless of the panel above.

curl -X POST "http://localhost:8100/api/v1/admin/imm/cycles" \
  -H "Content-Type: application/json" \
  -H "X-Service-Token: YOUR_SERVICE_TOKEN" \
  -d '{
  "baseCapitalKes": 3000,
  "cycles": 5,
  "inputs": {
    "internalRate": 128.8525,
    "cbkRate": 129.5,
    "usdtRate": 130.2,
    "discountPct": 5
  }
}'
Response202
{
  "id": "cyc_540140990117",
  "status": "running",
  "baseCapitalKes": 3000,
  "totalCycles": 5,
  "simulated": true
}

Omitted inputs take the engine's values. Only one run per tenant can be in progress.

Follow, advance, abort, withdraw ​

EndpointPurpose
GET /api/v1/admin/imm/cyclesRuns, newest first
GET /api/v1/admin/imm/cycles/:idOne run with every cycle, its transactions and totals
POST /api/v1/admin/imm/cycles/:id/advanceLive gateway only — { "gatewayReference": "…" } runs the next cycle
POST /api/v1/admin/imm/cycles/:id/abortStop a running or waiting run
POST /api/v1/admin/imm/cycles/:id/withdraw{ "address": "0x…", "confirm": "WITHDRAW" } — sends the final USDT

Statuses: running, awaiting_topup, awaiting_withdrawal, completed, aborted, failed.

Approvals (Dual Control) ​

Every critical admin action above — funding the vault, setting a swap rate, executing a treasury cycle, flipping a kill switch — normally runs the instant one valid credential calls it. Approvals lets you require N-of-M signed sign-offs before any specific action (or a whole family of them) actually executes, instead of trusting a single API key or admin session alone.

This is not a blockchain multisig wallet, and the engine never custodies a signer's key. A policy names wallet addresses (any EVM or Stellar address you already hold — Metamask, a hardware wallet, whatever); a signer proves they're one of the named approvers by signing an opaque message with that wallet off-chain, and the engine only verifies the signature.

Purely additive

A tenant who never configures a policy sees zero behavior change — every action runs exactly as it did before this existed. Approvals only ever intercepts an action when a matching policy is present.

How it works ​

  1. You configure a policy: which action scope it governs, which signer addresses can approve, and how many of them (the threshold) are required.
  2. When someone calls a governed action, the engine doesn't run it — it creates a pending action and responds 202 Accepted with an approval ID, capturing exactly what was requested.
  3. Each signer independently signs a canonical message over that pending action with their own wallet and posts the signature.
  4. The moment enough valid signatures arrive, the engine runs the original action from the persisted request — identically whether that took one second or happened across a server restart days later.

Step 1: Create a policy ​

actionScope can be an exact scope ("imm.vault.fund"), a prefix wildcard ("imm.*" — every IMM admin action), or "*" (everything gate-able). The most specific policy that matches wins if you configure more than one.

Create a Policy
curl -X POST "http://localhost:8100/api/v1/admin/approvals/policies" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "actionScope": "imm.vault.fund",
  "signers": [
    {
      "label": "Ops Lead",
      "address": "0x1111...",
      "scheme": "evm"
    },
    {
      "label": "CFO",
      "address": "0x2222...",
      "scheme": "evm"
    }
  ],
  "threshold": 2,
  "expiryMinutes": 60
}'
Response200
{
  "status": "success",
  "policy": {
    "tenantId": 1,
    "actionScope": "imm.vault.fund",
    "signers": [
      {
        "label": "Ops Lead",
        "address": "0x1111...",
        "scheme": "evm"
      },
      {
        "label": "CFO",
        "address": "0x2222...",
        "scheme": "evm"
      }
    ],
    "threshold": 2,
    "expiryMinutes": 60
  }
}
List Policies
curl -X GET "http://localhost:8100/api/v1/admin/approvals/policies" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "count": 1,
  "policies": [
    {
      "actionScope": "imm.vault.fund",
      "threshold": 2
    }
  ]
}

What can be gated today ​

Action scopeWhat it governs
imm.vault.fundMinting real reserves into the IMM vault
imm.rates.setChanging an IMM swap rate
imm.vault.rebalanceSwapping the vault's holdings via the chain's AMM
corridor.node.seedMinting into an honestly-mintable corridor node
corridor.cycle.executeRunning a treasury corridor cycle
corridor.killswitch.corridorHalting one corridor
corridor.killswitch.globalHalting every corridor for the tenant

Normal user-facing throughput (IMM quotes/swaps, reading balances, listing nodes/corridors) is never gate-able — dual control is for the admin actions that unilaterally move or reprice real treasury value, not everyday usage.

Step 2: Call a governed action ​

Nothing about the request changes — you call the same endpoint you always would (e.g. POST /api/v1/admin/imm/vault/fund). If a policy governs it, the response looks like this instead of completing immediately:

json
{
  "status": "pending_approval",
  "approvalId": "b3f1...",
  "actionScope": "imm.vault.fund",
  "threshold": 2,
  "expiresAt": "2026-09-10T15:00:00Z"
}

Step 3: Sign and approve ​

Each signer signs the canonical message comet-engine-approval:v1:<approvalId>:<actionScope>:<sha256 of the request body, hex> with their own wallet (EIP-191 personal_sign for an evm signer, a plain ed25519 signature for a stellar signer) and posts it:

Approve
curl -X POST "http://localhost:8100/api/v1/admin/approvals/b3f1.../approve" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "scheme": "evm",
  "address": "0x1111...",
  "signature": "0xabc..."
}'
Response200
{
  "status": "success",
  "pending": {
    "id": "b3f1...",
    "status": "pending",
    "approvals": [
      {
        "address": "0x1111...",
        "scheme": "evm"
      }
    ]
  }
}

The response after the second (threshold-reaching) approval carries "status": "executed" and the real result (e.g. the vault-funding transaction's txHash) — the same shape the action would have returned immediately if no policy governed it.

Check a Pending Action
curl -X GET "http://localhost:8100/api/v1/admin/approvals/b3f1..." \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "id": "b3f1...",
  "actionScope": "imm.vault.fund",
  "status": "executed",
  "threshold": 2,
  "approvals": [
    {
      "address": "0x1111..."
    },
    {
      "address": "0x2222..."
    }
  ],
  "result": {
    "txHash": "0x..."
  }
}
List Pending Actions
curl -X GET "http://localhost:8100/api/v1/admin/approvals" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "count": 1,
  "pending": [
    {
      "id": "b3f1...",
      "status": "pending"
    }
  ]
}

A signature can't be reused

The canonical message binds a signature to one specific pending action's ID, scope, and exact request body — a signer's approval on one action can never be replayed against another, even one with an identical-looking payload.

Rejecting instead of approving ​

Reject
curl -X POST "http://localhost:8100/api/v1/admin/approvals/b3f1.../reject" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "scheme": "evm",
  "address": "0x1111...",
  "signature": "0xabc..."
}'
Response200
{
  "status": "success",
  "pending": {
    "id": "b3f1...",
    "status": "rejected"
  }
}

A rejected or expired (past expiryMinutes with no threshold reached) pending action is terminal — the underlying action never runs, and nothing about the state it would have changed is touched.

Gating an action outside this engine ​

POST /api/v1/admin/approvals/external lets any other service — inside this monorepo or outside it — require the same N-of-M sign-off for one of its own critical actions, without comet-engine needing to know how to execute it. You still need a policy governing the actionScope you pass; the difference is what happens at threshold.

Propose an External Action
curl -X POST "http://localhost:8100/api/v1/admin/approvals/external" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET" \
  -d '{
  "actionScope": "billing.refund.large",
  "payload": {
    "invoiceId": "INV-2201",
    "amountUsd": 15000
  },
  "callbackUrl": "https://your-service.example.com/webhooks/approved"
}'
Response200
{
  "status": "pending_approval",
  "approvalId": "c9a2...",
  "actionScope": "billing.refund.large",
  "threshold": 2,
  "expiresAt": "2026-09-10T15:00:00Z"
}

Once threshold is reached, comet-engine POSTs { approvalId, actionScope, payload, approvals } to callbackUrl, signed the same way an inbound Mamlaka-style callback is verified elsewhere in this platform: X-Timestamp plus X-Signature = HMAC-SHA256(secret, "<timestamp>.<body>"). The one-time callback secret is generated per pending action — there is no long-lived shared secret to rotate.

Risk (Exposure History, VaR, PnL) ​

Read-only risk analytics for everything above, built entirely from data this engine already has: IMM's own admin-set rate table (there is no price oracle anywhere in this engine) and the append-only ledger journal. This is deliberately not a full quant risk engine — no Monte Carlo, no GARCH, no options-style greeks — it's the smallest thing that gives an operator a real, computed number instead of nothing.

Honest about what these numbers are

Every result here says exactly what it measured and from how much data. VaR below a sample-size threshold refuses to answer rather than fabricate a number; PnL is a realized-volume rollup, not a mark-to-market P&L (there's nothing to mark against without a price feed). Read each result's caveats, not just its headline figure.

Exposure history ​

A rolling record of an asset's net USD-normalized IMM exposure, recorded hourly by a background scheduler — the raw material both the VaR estimate below and your own charting use.

Exposure History
curl -X GET "http://localhost:8100/api/v1/admin/risk/history?chain=celo&asset=IMC&days=30" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "count": 47,
  "snapshots": [
    {
      "chain": "celo",
      "asset": "IMC",
      "netPositionUsd": 1250,
      "normalized": true,
      "recordedAt": "2026-09-01T00:00:00.000Z"
    }
  ]
}

A snapshot only exists once an asset has actually carried exposure or volume for a tenant — nothing is recorded for assets you've never traded, so an empty result here usually just means "not used yet," not a fault.

Value at Risk (VaR) ​

A historical-simulation VaR estimate: the confidence-th worst period-over-period move actually observed in recorded exposure snapshots — not a theoretical/parametric model assuming a return distribution.

95% VaR, 30-day lookback
curl -X GET "http://localhost:8100/api/v1/admin/risk/var?chain=celo&asset=IMC&confidence=0.95&days=30" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "chain": "celo",
  "asset": "IMC",
  "confidencePct": 0.95,
  "lookbackFrom": "2026-08-11T00:00:00.000Z",
  "sampleCount": 47,
  "valueAtRiskUsd": 340.5,
  "method": "historical_simulation"
}

sampleCount is reported alongside the estimate for a reason: a technically-computed VaR from 12 snapshots is real, but statistically thin — treat it accordingly until the sample grows. Fewer than 10 snapshots refuses to answer at all:

json
{ "error": "risk: not enough recorded snapshots yet for a meaningful VaR estimate (need at least 10)" }

Sign convention

Unlike a long-only portfolio's VaR, a "loss" here means exposure growing — more unbacked supply accumulating in one direction — since that's the actual risk IMM's exposure caps exist to bound. A window where exposure only shrank reports valueAtRiskUsd: 0, not a negative number.

PnL (realized-volume rollup) ​

7-day PnL rollup
curl -X GET "http://localhost:8100/api/v1/admin/risk/pnl?chain=celo&asset=IMC&sinceDays=7" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Secret: YOUR_API_SECRET"
Response200
{
  "chain": "celo",
  "asset": "IMC",
  "since": "2026-09-03T00:00:00.000Z",
  "mintedUsd": 5200,
  "burnedUsd": 4800,
  "swapVolumeUsd": 12500,
  "netUsd": 400,
  "entryCount": 63,
  "unnormalizedEntryCount": 0
}

Sums every ledger entry tagged as an IMM or corridor movement for (chain, asset) since the given time, converting each to USD via the same rate lookup Exposure uses. netUsd (minted − burned) is the closest honest proxy this engine can compute for "how much unbacked exposure did this asset accumulate" over the window — not a claim about trading profit. unnormalizedEntryCount counts entries that couldn't be converted (no IMM rate configured for that asset against USD) — they're included in entryCount but excluded from every USD total, never assigned a fabricated value.

Production checklist ​

  • [ ] Rates (Part 1, Step 1) reflect real, currently-measured numbers — not a placeholder you'll "fix later."
  • [ ] The vault (Part 1, Step 2) is funded with real reserves for every asset your users will buy.
  • [ ] Every node's minBalance (Part 2, Step 1) is a real operational floor, not 0 — this is what actually prevents a corridor from draining a float to zero mid-cycle.
  • [ ] Your integration handles the aborted-cycle response (Part 2, Step 5), not just the success case.
  • [ ] The global kill switch (Part 2, Step 6) is wired to whatever your team's real incident-response path is — know how to hit it before you need to.
  • [ ] You're polling or alerting on anyBelowFloor/anyOutOfBand from rebalance checks (Part 2, Step 8), not just glancing at the dashboard occasionally.
  • [ ] Fund/rate/rebalance/cycle-execute actions are behind an Approvals policy — a single API key moving real treasury value unilaterally is an operational risk worth closing before real volume, not after.
  • [ ] Every Approvals policy's signer set is real people's own wallets, not addresses the engine (or one person) controls — the whole point is that no single party can act alone.
  • [ ] expiryMinutes on an Approvals policy matches how long you're actually willing to leave a proposed action open, not a placeholder default.
  • [ ] Your integration handles 202 pending_approval as a real, expected response for gated actions — not just the immediate-success shape.
  • [ ] For external Approvals actions, callbackUrl verifies X-Signature before treating the callback as authentic.
  • [ ] Your own relayer exists on every chain you trade on (one * wallet, or imm.vault / imm.amm wallets) and holds gas — the engine refuses tenant operations without one.
  • [ ] You're checking Risk's exposure history/VaR/PnL endpoints on a schedule, not only when something already looks wrong.
  • [ ] You're not relying on VaR before sampleCount has grown past a handful of readings — a fresh deployment needs roughly two weeks of hourly snapshots before the estimate is more than a rough signal.
  • [ ] unnormalizedEntryCount in a PnL response is 0, or you know why it isn't (a rate is missing for one of the assets you're rolling up).

Released under the MIT License.