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.
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
| Piece | What it is | Where on the dashboard |
|---|---|---|
| Relayer | Your own wallet that signs and pays gas. Required — the engine's relayer is never used for a tenant. | Wallets → Relayers |
| Rate table | Your price for each pair (1 base = rate × quote). Kept on CBK's live rates automatically, or set by hand. | Markets → Pricing & trades |
| Vault | Your reserve wallet per chain. Trades pay out of it; it must hold the asset being bought. | Markets → Pricing & trades → Vault |
| Exposure & limits | Net position per asset, capped per trade / per day. | Markets → Pricing & trades → Risk exposure |
| AMM pools | Optional Uniswap V2 liquidity from your relayer. | Markets → AMM pools |
| Corridors | Multi-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
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
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 tableSet or override a rate:
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"e=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.
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 balanceTo move tokens back out — to your relayer (default) or treasury — withdraw; "all": true empties that asset:
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
curl "$ENGINE/api/v1/imm/quote?base=USDT"e=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
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)
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.
| Endpoint | Purpose |
|---|---|
POST /admin/wallet-config/relayer/generate · /import | Create or import your relayer |
GET /relayers | Your relayers with balances |
GET /admin/imm/pricing | Today's CBK-based pricing and your spread earned |
GET · POST · DELETE /admin/imm/rates | Your rate table |
GET /imm/quote | Price a trade (no side effects) |
POST /imm/swap | Execute a trade against your vault |
GET /admin/imm/vault · POST /admin/imm/vault/fund | Vault 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/withdraw | Move vault tokens back to your relayer or treasury |
GET /wallets/custody | Every treasury, vault and cycle-run wallet, with balances |
GET /admin/imm/exposure | Net position and limits |
POST /admin/imm/rebalance | Flatten an imbalance via the AMM |
GET /admin/imm/amm · POST /admin/imm/amm/pairs · /liquidity · /liquidity/remove | AMM liquidity from your relayer |
/admin/corridor/* | Treasury corridors (Part 2 below) |
Part 1 — Swaps
Step 1: Set a real 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
}'{
"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.
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"{
"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.
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"
}'{
"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:
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"{
"chain": "celo",
"asset": "IMC",
"vaultAddress": "0x823ed9603f3bb0bb400502b351b0476e24385142",
"onChainBalance": 5000,
"ledgerInventoryBalance": 0
}Step 3: Get a quote (optional, no side effects)
curl -X GET "http://localhost:8100/api/v1/imm/quote?base=USDT"e=IMC&amountIn=100" \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-API-Secret: YOUR_API_SECRET"{
"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
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"
}'{
"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:
- Look up the admin-set rate for
(base, quote)— reject if none exists or it's stale. - Check risk limits for both legs (see below).
- Base leg — always a real transfer into the vault, never destroyed.
amountInofbasemoves from the user's wallet into IMM's vault wallet, on-chain. - Quote leg — paid from the vault, or not at all. If the vault holds enough
quote,amountIn * rateof 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. - Journal both legs, plus one unified
swapentry (visible in the Journal, taggedvenue: "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
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"{
"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:
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"
}'{
"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:
| Setting | Env var | Default |
|---|---|---|
| Rebalance destination | IMM_REBALANCE_TARGET_<SYMBOL> | unset — auto-rebalance disabled for this asset |
| Trigger threshold | IMM_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:
| Limit | Env var | Default |
|---|---|---|
| Max single trade | IMM_MAX_TRADE_<SYMBOL> | 1,000 whole units |
| Rolling 24h volume | IMM_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):
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
}'{
"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"
}
}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
}'{
"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:
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"
}'{
"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)
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"{
"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
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
}'{
"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
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"
}'{
"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:
{
"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:
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
}'{
"status": "success",
"corridor": {
"id": "6aa12a1d0349585718f06bab",
"killSwitch": true
}
}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
}'{
"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:
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"{
"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.
| Column | Formula |
|---|---|
| D Starting capital | seed, then the previous cycle's exit |
| E Airtime face value | D × (1 + discount) — airtime bought at the gateway |
| F Discount captured | E − 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 USDT | G — swapped 1:1 out of the IMM vault (the same vault IMM trades use), after a liquidity check |
| I Option 1 — KES exit | H × CBK rate |
| J Option 2 — USDT exit | H × USDT rate (preferred) |
| K Net profit | exit − 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)
🔒 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
}
}'{
"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
🔒 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
}
}'{
"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
| Endpoint | Purpose |
|---|---|
GET /api/v1/admin/imm/cycles | Runs, newest first |
GET /api/v1/admin/imm/cycles/:id | One run with every cycle, its transactions and totals |
POST /api/v1/admin/imm/cycles/:id/advance | Live gateway only — { "gatewayReference": "…" } runs the next cycle |
POST /api/v1/admin/imm/cycles/:id/abort | Stop 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
- You configure a policy: which action scope it governs, which signer addresses can approve, and how many of them (the threshold) are required.
- When someone calls a governed action, the engine doesn't run it — it creates a pending action and responds
202 Acceptedwith an approval ID, capturing exactly what was requested. - Each signer independently signs a canonical message over that pending action with their own wallet and posts the signature.
- 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.
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
}'{
"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
}
}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"{
"count": 1,
"policies": [
{
"actionScope": "imm.vault.fund",
"threshold": 2
}
]
}What can be gated today
| Action scope | What it governs |
|---|---|
imm.vault.fund | Minting real reserves into the IMM vault |
imm.rates.set | Changing an IMM swap rate |
imm.vault.rebalance | Swapping the vault's holdings via the chain's AMM |
corridor.node.seed | Minting into an honestly-mintable corridor node |
corridor.cycle.execute | Running a treasury corridor cycle |
corridor.killswitch.corridor | Halting one corridor |
corridor.killswitch.global | Halting 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:
{
"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:
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..."
}'{
"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.
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"{
"id": "b3f1...",
"actionScope": "imm.vault.fund",
"status": "executed",
"threshold": 2,
"approvals": [
{
"address": "0x1111..."
},
{
"address": "0x2222..."
}
],
"result": {
"txHash": "0x..."
}
}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"{
"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
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..."
}'{
"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.
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"
}'{
"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.
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"{
"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.
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"{
"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:
{ "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)
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"{
"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, not0— 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/anyOutOfBandfrom 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.
- [ ]
expiryMinuteson 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_approvalas a real, expected response for gated actions — not just the immediate-success shape. - [ ] For external Approvals actions,
callbackUrlverifiesX-Signaturebefore treating the callback as authentic. - [ ] Your own relayer exists on every chain you trade on (one
*wallet, orimm.vault/imm.ammwallets) 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
sampleCounthas 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. - [ ]
unnormalizedEntryCountin a PnL response is0, or you know why it isn't (a rate is missing for one of the assets you're rolling up).