Skip to content

Repository files navigation

Mayavi

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_id via mayavi/chains/registry.py: mainnet (1), Optimism (10), BNB (56), Polygon (137), Base (8453), Arbitrum (42161). Per-chain ERC20_BALANCE_SLOTS empirically probed via scripts/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 carrying report.html + eval.json from 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 at morellato26--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.

Quickstart

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 demo

mayavi 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/.

What this is

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.yaml for the nominal-conditions baseline (no shock), or depeg_cascade.yaml for the stablecoin-spike → cascade-liquidation stress test.
  • Real-launch vesting-cliff replaylaunch_replay scenario 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. See docs/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.

What this is NOT

  • 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).

Sim-to-real validation

The credibility wall. Every release passes:

  1. Quoter-bit-exact validation on the Uniswap V3 USDC/WETH 5 bps pool (5/5 swaps, delta == 0).
  2. Named-incident replay — EIGEN Season 1 transferability (2024-10-01). See docs/validation.md.
  3. Fork cache integrity — pyrevm storage matches fresh eth_getStorageAt for four (address, slot) triples at the pinned block.
  4. Determinism — same seed + same scenario → byte-identical run output.
  5. Gym contractgymnasium.utils.env_checker.check_env on every env.

Reproduce locally: uv run mayavi validate and uv run pytest -m fork -v.

Development

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

RL training (local + Modal)

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 modal

The 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 1000

Sprint 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 1000

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

Snapshot training (RPC-free, local + Modal)

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

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

Modal training (cost guard)

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 modal

Surfpool / Solana RPC cost guard

Phase 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_*.yaml

API

Phase 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/                                                 # 401

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

Submitting and polling runs (3-C1)

# 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): queuedrunningcomplete | 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.

Report HTML + PDF endpoints (3-C2)

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

Both 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 chromium

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

Web app (Phase 3-C dashboard)

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 :3000

License

Private. License decision deferred until launch.

About

Forked-mainnet simulation. Bit-exact with the chain you're stress-testing

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages