Tokenomics simulation engine. Real EVM-in-the-loop (forked mainnet via pyrevm), agent-based scheduler, Gymnasium-wrapped for RL.
Status. Private MVP. Phase 5 closed 2026-05-13 (Sprints 5-A
through 5-I — see CHANGELOG.md for the per-sprint
breakdown). Where things stand today:
- 6 EVM chains dispatched by
chain_idviamayavi/chains/registry.py: mainnet (1), Optimism (10), BNB (56), Polygon (137), Base (8453), Arbitrum (42161). Per-chainERC20_BALANCE_SLOTSempirically probed viascripts/probe_balance_slot.py. - 4 protocol adapters: Aave V3 (six chains), SparkLend (mainnet —
thin
SparkAccount(AaveAccount)over the Aave V3 fork), Compound V3 / Comet (mainnet + Base + Polygon + Arbitrum), Curve StableSwap (mainnet). - 18 EVM scenario types registered + 1 SVM (Solana parked); 21
channel-agnostic engine bundles committed under
docs/artifacts/protocols/, each carryingreport.html+eval.jsonfrom a real forked-chain run. - 3 trained RL-agent eval JSONs (Aave borrower PPO, vesting
recipient PPO, Aave liquidator PPO) referenced from
mayavi/report/builder.py. - Deployed dashboard at
app.mayavi.sambhal-labs.com(Next.js on Vercel) backed by a FastAPI service on Modal atmorellato26--mayavi-api-fastapi-app.modal.run. - Phase 5 release gate (
tests/test_phase5_release_gate.py) green: 18 EVM scenario types ≥ floor, 3 trained-RL artifacts present, 21 catalogued bundles ≥ floor.
Repo is private; license decision deferred until launch.
git clone <private-repo-url> mayavi
cd mayavi
uv sync --all-extras --group dev
cp .env.example .env # add your ALCHEMY_RPC_URL
uv run mayavi demomayavi demo forks mainnet at the pinned block, runs the vesting-cliff
scenario, persists the run to DuckDB, and writes a self-contained HTML
report to data/reports/.
For VC analysts doing token due diligence: drop in a token contract, ask "what happens when 30 % of seed unlocks on March 1?", get a defensible HTML report (print-to-PDF) in under a minute. The simulation runs against real vesting and Uniswap V3 contracts on a forked mainnet — not Python re-implementations.
Available demos today:
- Vesting cliff — N heterogeneous recipients liquidate over a horizon via real Uniswap V3 swaps; reports cumulative sell pressure, realized price, per-cohort breakdown, and any rejected attempts (slippage floor / illiquid quote / revert).
- Aave V3 oracle shock — Gym-wrapped borrower env with a deterministic ETH price drop driven through the Aave oracle (via bytecode override at the Chainlink aggregator). PPO baseline + naive baselines included.
- Aave V3 borrower (YAML) — Scheduler-driven multi-borrower scenario
registered as
aave_borrower;uv run mayavi run mayavi/scenarios/aave_borrower.yamlfor the nominal-conditions baseline (no shock), ordepeg_cascade.yamlfor the stablecoin-spike → cascade-liquidation stress test. - Real-launch vesting-cliff replay —
launch_replayscenario type forks at the cliff block of a real token launch and simulates aggregate sell pressure to compare against on-chain truth in the post-cliff window. One concrete instance ships today (ENA, 2025-04-02 first cliff):uv run mayavi run mayavi/scenarios/launch_replay_ena.yaml. Seedocs/validation.md§ Section 3 for the tolerance posture and observed deltas, and § 3b for why W (Wormhole) was scoped out of this iteration.
Side-by-side: naive-dump baseline vs RL-optimized liquidation schedule.
- Not a price prediction tool. Outputs are defensible counterfactuals under explicit assumptions, not forecasts.
- Not a Gauntlet/Chaos Labs replacement (yet). Single-chain (Ethereum mainnet), single-tenant.
- Not financial advice. The HTML reports include a methodology section spelling out what is and isn't modeled (intra-block ordering, MEV, off-chain venues, reflexive participant behavior).
The credibility wall. Every release passes:
- Quoter-bit-exact validation on the Uniswap V3 USDC/WETH 5 bps pool
(5/5 swaps,
delta == 0). - Named-incident replay — EIGEN Season 1 transferability (2024-10-01).
See
docs/validation.md. - Fork cache integrity — pyrevm storage matches fresh
eth_getStorageAtfor four (address, slot) triples at the pinned block. - Determinism — same seed + same scenario → byte-identical run output.
- Gym contract —
gymnasium.utils.env_checker.check_envon every env.
Reproduce locally: uv run mayavi validate and uv run pytest -m fork -v.
CLAUDE.md— engineering standards, layer map, common commands.CHANGELOG.md— per-PR record + sprint summaries.docs/validation.md— running ledger of sim-to-real validations.docs/architecture.md— layer overview + key invariants.
uv run pytest -m "not fork and not slow and not modal" # fast tests (every push)
uv run pytest -m fork # fork tests (needs RPC)
uv run ruff check . --fix && uv run ruff format .
uv run mypy mayavi/CI gates: lint, type, unit on every push; fork nightly. Coverage
floor 80 % on mayavi/sim/ and mayavi/agents/ (workflow env vars +
sanity guard so a misspelled include can't fake-pass).
The canonical training default is the local GPU (GTX 1650, 4 GB VRAM).
Modal A10G is the opt-in escape hatch for distributed training, large
nets, and hyperparameter sweeps. The dispatch indirection lives in
mayavi/rl/runner.py:train_via_runner (landed Sprint 3-E2).
# Default: local GPU (or CPU if no CUDA visible). No Modal credentials needed.
uv run mayavi train --env aave --timesteps 1000
# Same, explicit:
MAYAVI_RL_BACKEND=local uv run mayavi train --env aave --timesteps 1000
# Modal A10G escape hatch (env-var form):
MAYAVI_RL_BACKEND=modal uv run mayavi train --env aave --timesteps 50000
# Modal A10G escape hatch (CLI-flag form; takes precedence over the env var):
uv run mayavi train --env aave --timesteps 50000 --remote modalThe local backend pre-aborts with a typed RuntimeError if a CUDA
device is visible but its total VRAM is far below
MAYAVI_LOCAL_GPU_MEM_GB_CEILING (default 4.0 GB). To run on a
smaller card, lower the ceiling explicitly:
MAYAVI_LOCAL_GPU_MEM_GB_CEILING=2.0 uv run mayavi train --env aave --timesteps 1000Sprint 3-E3 added parallel rollout via Ray RLlib's EnvRunner. The
default num_env_runners is auto-detected from os.cpu_count()
clamped to a cap of 2. The cap is 2 (not the originally-planned 8)
because each env runner is its own subprocess with its own forked-EVM
client, and 8 simultaneous fork-resets reproducibly trip the free
Alchemy tier's HTTP 429 "compute units per second" rate limit. Users
on paid Alchemy tiers (or with a higher CU/s ceiling) can override
explicitly:
# Paid-Alchemy-tier host: 8 parallel rollout workers (overrides cap=2).
MAYAVI_RL_NUM_ENV_RUNNERS=8 uv run mayavi train --env aave --timesteps 50000
# Memory-constrained host (< 16 GB RAM): force serial regardless of cores.
MAYAVI_RL_NUM_ENV_RUNNERS=0 uv run mayavi train --env aave --timesteps 1000Each runner ≈ one forked EVM client (~600 MB RAM). On hosts with
< 16 GB RAM, the cap=2 default already keeps the footprint reasonable;
dial down to =0 (strictly-serial pre-3-E3 behavior, useful for
debugging) if you need every byte. The _dispatch_local runner
hop (mayavi/rl/runner.py) chdirs to a neutral tmp directory before
Ray init so the per-worker uv-virtualenv-creation doesn't fight the
project's pyproject.toml and exhaust tmpfs.
See docs/architecture.md § "RL backend
(Phase 3)" and CLAUDE.md § "RL backend" for the
full env-var contract.
Sprint 3-E4a added a frozen-fork snapshot architecture for the Aave
borrower env: pre-fetch every storage slot the env reads into a JSON
file once, then train against the file (zero RPC during the inner
loop). Sprint 3-E4b extended the warmup to cover Pool.repay()'s
SLOAD trace; Sprint 3-E5 wired the snapshot path through the Modal
backend so RPC-free training works on Modal A10G too.
# Local GPU, RPC-free (the default Phase-3 path):
uv run mayavi train --env aave --timesteps 50000 \
--snapshot data/snapshots/aave_v3_19M.json
# Modal A10G, RPC-free (3-E5; snapshot is image-baked into the container):
uv run mayavi train --env aave --timesteps 50000 --remote modal \
--snapshot data/snapshots/aave_v3_19M.json
# Regenerate the snapshot (one-shot RPC burn against live Alchemy):
uv run mayavi cache snapshot --env aave --fork-block 19000000 \
--out data/snapshots/aave_v3_19M.jsonThe committed data/snapshots/aave_v3_19M.json (29 accounts, 71
storage slots, ~0.5 MB) is built into the Modal image at
/root/snapshots/; the runner translates the local path to a
"default" sentinel that the Modal local-entrypoint resolves. Custom
snapshot paths (different fork block, different env) require either
regenerating the image with the new file or using a Modal volume
mount.
Cloud GPU training runs through MAYAVI_RL_BACKEND=modal uv run mayavi train --env aave
(or the equivalent --remote modal flag). Every Modal entrypoint reads the
env var MAYAVI_MODAL_COST_CEIL_USD (default $10) at job start and aborts
with ModalCostCeilingError if the worst-case cost — timeout_hours * $0.60/hr (A10G) — exceeds the ceiling. The current 4-hour timeout caps a
single run at ~$2.40, well under the default. To allow a longer run, raise
the ceiling explicitly:
MAYAVI_MODAL_COST_CEIL_USD=15 uv run mayavi train --env aave --remote modalPhase 3-B routes Solana scenarios through Surfpool
(local mainnet-forking validator). Surfpool itself runs locally and is free;
the cost lives in the upstream Solana mainnet RPC the validator forks from.
The free public endpoint (api.mainnet-beta.solana.com) is rate-limited; deep
historical fork tests need a paid provider (Helius, Alchemy, Triton).
The Surfpool cost guard at mayavi/svm/cost_guard.py reads
MAYAVI_SURFPOOL_COST_CEIL_USD (default $5) and aborts with
SurfpoolCostCeilingError if the worst-case spend — timeout_hours * $0.50/hr (Helius sustained-fork tier) — exceeds the ceiling. The 2-hour
default timeout caps a single session at ~$1.00, well under the default.
To allow a longer run, raise the ceiling explicitly:
MAYAVI_SURFPOOL_COST_CEIL_USD=20 uv run mayavi run mayavi/scenarios/svm_*.yamlPhase 3-C stands up a FastAPI service at mayavi/api/. Sprint 3-C0 landed the
scaffold (/healthz + authenticated root); 3-C1 wires up the run-submission
flow (POST /runs + GET /runs/{id} + GET /runs).
export MAYAVI_API_KEY=<some-long-random-string>
uv run uvicorn mayavi.api.main:app
curl http://127.0.0.1:8000/healthz # 200, unauthenticated
curl -H "Authorization: Bearer $MAYAVI_API_KEY" http://127.0.0.1:8000/ # 200
curl http://127.0.0.1:8000/ # 401Auth contract: every non-/healthz route requires
Authorization: Bearer <MAYAVI_API_KEY>. If the env var is unset on the
server, the dependency fails closed (every authenticated request returns
401). /healthz is exempt by design — Modal's liveness probe hits it.
# Submit an Aave-borrower run; returns 202 + run_id + status="queued"
curl -H "Authorization: Bearer $MAYAVI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"scenario": "aave_borrower",
"name": "demo-via-api",
"fork_block": 19000000,
"horizon_steps": 4,
"seed": 42,
"params": {"n_borrowers": 1}
}' \
http://127.0.0.1:8000/runs
# Poll a single run by id
curl -H "Authorization: Bearer $MAYAVI_API_KEY" \
http://127.0.0.1:8000/runs/<run_id>
# Paginate run history (newest first)
curl -H "Authorization: Bearer $MAYAVI_API_KEY" \
"http://127.0.0.1:8000/runs?limit=20&offset=0"Lifecycle column (runs.status in the DuckDB file):
queued → running → complete | failed. The submission handler writes
the queued row synchronously and spawns a BackgroundTask to execute the
scenario; the task UPSERTs the row to complete on success (with timing
fields populated) or transitions to failed with the error string captured.
Server restart sweep (3-C2). A row left in status='running' after a
process restart (Modal scale-down, container OOM, kill signal) is by
definition orphaned — in-flight tasks cannot survive a process death. The
FastAPI app's lifespan startup unconditionally transitions every
status='running' row to status='failed' with error='server restart',
so dashboards never see indefinitely-stuck rows. If you need to sweep
mid-lifetime (rare; mostly Phase 4 territory once Modal autoscaling
lands), the helper accepts a max_age_seconds cutoff to skip in-flight
rows. Manual ops workaround (e.g. you bypassed the API and want to
clean up by hand):
-- Mark stuck-running rows as failed; adjust cutoff_timestamp as appropriate.
UPDATE runs
SET status = 'failed',
error = 'server restart',
finished_at = CURRENT_TIMESTAMP
WHERE status = 'running'
AND created_at < <cutoff_timestamp>;DB path override: set MAYAVI_API_RUNS_DB to point the API at a non-default
DuckDB file (the CLI's mayavi run shares data/runs/runs.duckdb by default,
so submitted runs and CLI-invoked runs co-exist in the same history table).
The OpenAPI schema is auto-served at http://127.0.0.1:8000/docs.
Two endpoints serve the existing HTML report builder + a Playwright-rendered PDF for VC handoff:
# Self-contained HTML report (Plotly via CDN; charts render in any browser)
curl -H "Authorization: Bearer $MAYAVI_API_KEY" \
-o report.html \
http://127.0.0.1:8000/runs/<run_id>/report
# PDF rendered server-side via headless Chromium
curl -H "Authorization: Bearer $MAYAVI_API_KEY" \
-o report.pdf \
http://127.0.0.1:8000/runs/<run_id>/report.pdfBoth endpoints require the run to be in status='complete' (404 if the
run_id is missing; 409 if it's still queued / running / failed).
Chromium install for the PDF endpoint. Playwright's Python wheel ships
with uv sync, but the actual Chromium binary is a separate ~150 MB
download. Run once after install:
uv run playwright install chromiumWithout it, the PDF endpoint returns 503 Service Unavailable with a clear
"run playwright install chromium" message in the response body. The HTML
endpoint works without Chromium (no JS-execution dependency on the server
side; the browser executes the embedded Plotly script tag at view time).
Cold-start cost flag (Modal). The first PDF request after a Modal
scale-down launches Chromium (~10-30s); subsequent PDFs in the same instance
are fast. For pitch demos, warm a single Modal instance pre-call by hitting
/healthz first; if cold-start becomes a recurring complaint, switch to
Modal's keep_warm=1 for the PDF endpoint or precompute PDFs on run
completion.
Sprint 3-C3 stood up a Next.js (App Router) frontend at web/
that consumes the FastAPI service. The 3-C3 surface is the scaffold:
design tokens inherited from landing/styles.css, a typed FastAPI client
(web/lib/api.ts), a RunStatusBadge component for the four lifecycle
states, plus the Vitest + ESLint + Next-build pipelines wired into a
separate web-ci.yml workflow.
Run-submission + history pages land in 3-C4; embedded report view + PDF
download in 3-C5; Vercel deploy at app.mayavi.run in 3-C6. See
web/README.md for local-dev + test commands.
cd web
pnpm install # ~12s; pnpm 10
# 3-C5 added a server-side report proxy; pass the key as both
# MAYAVI_API_KEY (server-only, used by /api/runs/[id]/report*
# route handlers for iframe+PDF auth) and NEXT_PUBLIC_API_KEY
# (inlined into client bundle, used by direct fetches).
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000 \
NEXT_PUBLIC_API_KEY=$MAYAVI_API_KEY \
MAYAVI_API_URL=http://127.0.0.1:8000 \
MAYAVI_API_KEY=$MAYAVI_API_KEY \
pnpm dev # next dev on :3000Private. License decision deferred until launch.