This is a Turborepo monorepo with the following structure:
compound-security/
├── apps/
│ └── portal/ # Next.js web app
├── packages/
│ ├── decoder/ # Proposal decoder CLI tool
│ └── simulator/ # Proposal simulator CLI tool (Tenderly)
├── vendor/
│ └── comet/ # Git submodule with deployment configs
├── compound-config.json # Shared config (RPC URLs, API keys, addresses)
├── compound-config.json.example # Template for compound-config.json
├── package.json # Workspace root
├── pnpm-workspace.yaml
├── turbo.json
└── tsconfig.base.json
All configuration is consolidated in compound-config.json at the monorepo root. This includes:
etherscanApiKey: Etherscan V2 API key (used by decoder)chains: Chain-specific configurationrpcUrl: General RPC URL for reading chain data (used by decoder)simulatorRpcUrl: Tenderly virtual testnet URL (used by simulator)directory: Comet deployments directory name- Contract addresses (governor, timelock, bridge, receiver, etc.)
defaults: Default values for simulation (gas, robinhood address, COMP address)
Setup: Copy compound-config.json.example to compound-config.json and fill in your API keys and RPC URLs.
Note: compound-config.json is gitignored since it contains sensitive API keys.
pnpm decode <proposal>- Run decoder from rootpnpm simulate <proposal>- Run simulator from rootpnpm build- Build all packagespnpm typecheck- Type check all packagespnpm dev- Start all dev servers with hot reload (recommended for development)docker build -f packages/decoder/Dockerfile . -t proposal-decoder- Build decoder Docker image (run from monorepo root)docker build -f packages/simulator/Dockerfile . -t proposal-simulator- Build simulator Docker image (run from monorepo root)docker build -f apps/portal/Dockerfile . -t proposal-portal- Build portal Docker image (run from monorepo root)docker run -p 3000:3000 proposal-portal- Run portal Docker containerpnpm test- Run all tests (decoder + simulator)pnpm --filter @compound-security/decoder test- Run decoder testspnpm --filter @compound-security/simulator test- Run simulator testspnpm --filter @compound-security/portal test:e2e- Run portal Playwright tests
Always use pnpm dev from the monorepo root for development with hot reload:
pnpm devThis runs Turbo which starts all dev servers concurrently:
- decoder:
tsup --watch- rebuilds dist on source changes - simulator:
tsup --watch- rebuilds dist on source changes - portal:
next dev- watches node_modules for changes
The workflow is:
- You edit
packages/decoder/src/*.ts - tsup detects the change and rebuilds
packages/decoder/dist/ - Next.js detects the dist change and hot reloads
Common mistake: Running only the portal dev server (cd apps/portal && pnpm dev) skips the decoder/simulator watch modes, so changes to packages require manual rebuilds.
The portal depends on decoder and simulator via workspace links:
"dependencies": {
"@compound-security/decoder": "workspace:*",
"@compound-security/simulator": "workspace:*"
}These packages export their dist/ files (not source), so the watch modes are essential.
The simulator requires packages/simulator/proposal.config.json to exist (create from .example). Without it:
- CLI commands fail with "ENOENT: no such file or directory"
- Portal API routes that import from
@compound-security/simulatorreturn 500 errors
Required setup:
cp packages/simulator/proposal.config.json.example packages/simulator/proposal.config.json
# Then update with valid Tenderly virtual testnet RPC URLsSee packages/simulator/CLAUDE.md for full config structure and required fields.
Snapshots are stored in .snapshots/ at the monorepo root, shared between both the CLI and portal. This follows the same pattern as compound-config.json.
-
turbo prune doesn't include everything: Files like
tsconfig.base.jsonandvendor/are not included in the prune output. Copy them explicitly from the pruner stage:COPY --from=pruner /app/vendor ./vendor COPY --from=pruner /app/tsconfig.base.json ./tsconfig.base.json
-
Don't install turbo globally with pnpm in Docker:
pnpm add -g turbofails with "Unable to find the global bin directory". Usenpx turboinstead. -
Docker build context: Always run
docker build -f packages/decoder/Dockerfile .from the monorepo root, not from the package directory. -
Portal requires config at build time: Next.js "Collecting page data" phase imports API routes which depend on
compound-config.json. The config file must be copied in the builder stage before runningpnpm turbo build:# In builder stage, before RUN pnpm turbo build COPY --from=pruner /app/compound-config.json* ./
Also copy in the runner stage for runtime access. The wildcard copies both
compound-config.jsonandcompound-config.json.exampleif they exist.
Problem: TypeScript path aliases (@/* → src/*) work at compile time but Node.js ESM doesn't resolve them at runtime.
Failed approaches:
tscalone: Outputs files with unresolved@/importstsc-alias: Resolves aliases but doesn't add.jsextensions required by ESM
Solution: Use tsup with packages: "external":
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/main.ts", "src/decoder.ts"],
format: ["esm"],
target: "node20",
outDir: "dist",
clean: true,
sourcemap: true,
dts: true,
splitting: false,
bundle: true,
packages: "external", // Don't bundle node_modules
});Why packages: "external": Without this, tsup bundles dependencies like dotenv which use CommonJS require('fs'), causing "Dynamic require of 'fs' is not supported" errors in ESM.
Source files that reference paths relative to process.cwd() break when the package moves. Use import.meta.url instead:
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = join(__dirname, "..");
const MONOREPO_ROOT = join(PACKAGE_ROOT, "..", "..");Files that needed this update:
packages/decoder/src/local-abi.tspackages/decoder/src/ethers.tspackages/decoder/src/handlers/address-verification-handler.tspackages/decoder/src/lib/comet-metadata.tspackages/simulator/src/main.ts
After modifying any package.json, run pnpm install locally before Docker build. Docker uses --frozen-lockfile which fails if the lockfile is outdated.
Problem: JSON imports with import config from "./config.json" with { type: "json" } break when bundling with tsup because the JSON file path changes relative to the output.
Solution: Use fs.readFileSync with import.meta.url for runtime config loading:
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = join(__dirname, "..");
const config = JSON.parse(readFileSync(join(PACKAGE_ROOT, "config.json"), "utf-8"));This approach:
- Works with tsup bundling (JSON stays outside the bundle)
- Resolves paths correctly regardless of where the script is run from
- Allows the config to be modified without rebuilding
Setup: Vitest for decoder/simulator unit tests, Playwright for portal E2E tests.
Key files:
packages/decoder/vitest.config.ts- 2min timeout for RPC callspackages/simulator/vitest.config.ts- 5min timeout for simulationsapps/portal/playwright.config.ts- Webserver auto-start config
Test proposals: 518 (high gas), 519 (high value), 524 (CCIP/Ronin), 528 (baseline)
Decoder test considerations:
- Not all contracts are verified on Etherscan - test "most calls decoded" (≥50%) rather than "all calls decoded"
- Some L2 chains may not have RPC URLs configured - decoder handles this gracefully with warnings
Simulator test considerations:
- Tenderly virtual testnet state persists - some proposals may fail due to state changes
- Use
SKIP_TENDERLY_TESTS=trueenv var to skip simulator tests when Tenderly unavailable - Consolidate assertions into single test to avoid
undefinedresult issues when simulation fails
Portal Playwright tests:
- Add
data-testidattributes to components for reliable selectors - Use long timeouts (120s+) for pages that make RPC calls
- Key test IDs:
proposal-id-input,decode-submit-button,proposal-overview,action-0,simulation-results,simulation-status
Problem: Configuration was scattered across multiple locations - .env files for decoder, proposal.config.json for simulator, hardcoded values in source files. This made it difficult to maintain and required dotenv loading in multiple places.
Solution: Create a single compound-config.json at the monorepo root that both packages read. Each package has a src/config.ts loader that:
- Reads from
MONOREPO_ROOT/compound-config.json - Transforms the data as needed (e.g., simulator uses
simulatorRpcUrl, decoder usesrpcUrl) - Caches the parsed config for performance
- Exports typed helper functions (
getRpcUrl(),getEtherscanApiKey(), etc.)
Key insight: Separate rpcUrl (general read-only RPC for decoder) from simulatorRpcUrl (Tenderly virtual testnet for state manipulation) since they serve different purposes. Docker builds must copy compound-config.json from the pruner stage.
Problem: Using import.meta.url to resolve file paths in Next.js API routes doesn't work reliably because API routes are bundled differently than standalone Node.js scripts.
Solution: Use process.cwd() instead, which points to the app directory (apps/portal) when running the Next.js dev server:
import { join } from "node:path";
// process.cwd() is the portal directory (apps/portal), go up 2 levels to monorepo root
const MONOREPO_ROOT = join(process.cwd(), "..", "..");
const CONFIG_PATH = join(MONOREPO_ROOT, "compound-config.json");Key insight: The path resolution strategy differs between contexts:
- Standalone packages (decoder, simulator): Use
import.meta.urlsince tsup bundles them and__dirnamepoints todist/ - Next.js API routes: Use
process.cwd()sinceimport.meta.urldoesn't resolve correctly in bundled API routes
The portal includes a /config page for managing compound-config.json:
- API route:
apps/portal/src/app/api/config/route.ts- GET/POST for reading/writing config - Page:
apps/portal/src/app/config/page.tsx- Main configuration UI - Components:
apps/portal/src/components/config/- Form components with validation - Types:
apps/portal/src/types/config.ts- TypeScript interfaces
Features:
- Bootstraps from
compound-config.json.exampleif no config exists - Warning system for placeholder values, missing required fields
- Secret input masking with reveal toggle
- Add/remove chains with validation