20 KiB
description, globs, alwaysApply
| description | globs | alwaysApply |
|---|---|---|
| Grimoire - A Portable Execution Language for Onchain Strategies | *.ts, *.tsx, *.html, *.css, *.js, *.jsx, *.spell, package.json | false |
Development Rules
First Message
If the user did not give a concrete task in their first message, read README.md, then ask which area to work on (compiler/runtime, venues, CLI, docs, skills). Based on the answer, read the relevant docs or package readmes.
Agent Portability (LLM-agnostic)
- Avoid tool-specific assumptions. Use standard shell commands and Bun scripts.
- Prefer repo-local context. Document workflows in this repo instead of relying on conversation memory.
- Use relative paths.
- Provide manual fallbacks if a workflow is automated.
- Keep secrets out of prompts; use environment variables.
- If a task needs new dependencies, list them and pin versions.
Code Quality
- No
anyunless absolutely necessary. - Avoid Bun-only APIs in
packages/core,packages/venues, andpackages/cli. If you must use Bun APIs, add a Node fallback. - Keep protocol SDKs out of
@grimoirelabs/core(core stays protocol-agnostic). - Prefer deterministic, side-effect-free logic in compiler/runtime.
- Ask before removing functionality or changing DSL semantics.
Commands
- For code changes, run
bun run validateunless the user says otherwise. - Docs-only changes do not require tests.
- Onchain tests are expensive: only run when requested. Use
--dry-runfirst. - Never commit unless the user asks.
Git Rules
- Do not use destructive commands (
git reset --hard,git checkout .,git clean -fd) unless explicitly asked. - Stage only files you changed in this session.
GitHub Issues
When reading issues, read all comments. Use:
gh issue view <number> --json title,body,comments,labels,state
PR Workflow
Work in a feature branch if requested. Do not open PRs unless asked.
Tools
- Bun for install/test/build:
bun install,bun test,bun run ... - Biome for lint/format:
bun run lint,bun run format - TypeScript:
bun run typecheck - Skills validation:
bunx skills-ref validate <skill-dir>(for exampleskills/grimoire,skills/grimoire-aave) - Changesets:
bunx changeset
Style
- Keep answers concise and technical.
- No emojis in commits, issues, or code comments.
Changelog
Location: CHANGELOG.md (root). Format follows Keep a Changelog.
- Add new entries only under
## Unreleased. - Do not edit already-released version sections.
- Use Changesets for normal release notes. Only edit
CHANGELOG.mdwhen asked.
Releasing
- Initial
0.1.0publish is manual (seedocs/how-to/publish.md). - After that, use Changesets + CI. Do not bump versions manually.
Project Overview
Grimoire is a DSL for defining and executing onchain DeFi strategies. Strategies are written in .spell files using a brace-delimited syntax ({ ... }).
Project Structure
packages/
core/ # Core compiler + runtime (protocol-agnostic)
src/
compiler/ # Spell -> IR compilation
grimoire/ # Tokenizer, parser, transformer
expression-parser.ts
ir-generator.ts
validator.ts
runtime/ # IR execution engine
interpreter.ts
context.ts
state-store.ts # StateStore interface + RunRecord types
sqlite-state-store.ts # SQLite-based state persistence
steps/ # Step handlers (compute, conditional, loop, etc.)
venues/ # Adapter registry + types (no SDKs)
types/ # TypeScript type definitions
builders/ # Fluent API for building spells
venues/ # Official adapters (SDK integrations)
src/
aave-v3.ts
uniswap-v3.ts
uniswap-v4.ts
morpho-blue.ts
hyperliquid.ts
across.ts
cli/ # per-venue CLIs
cli/ # Grimoire CLI
sdk/ # (WIP) SDK for external integrations
spells/ # Example spell files
skills/ # Agent skills (core CLI + per-venue metadata workflows)
docs/ # Diataxis docs
Grimoire Syntax
Spells use a brace-delimited syntax ({ ... }):
spell YieldOptimizer {
version: "1.0.0"
description: "Optimizes yield across lending venues"
assets: [USDC, USDT, DAI]
params: {
min_amount: 100
amount: 100000
}
limits: {
max_allocation_per_venue: 50%
}
venues: {
aave_v3: @aave_v3
morpho_blue: @morpho_blue
uniswap_v3: @uniswap_v3
}
skills: {
dex: {
type: swap
adapters: [uniswap_v3]
default_constraints: {
max_slippage: 50
}
}
}
advisors: {
risk: {
model: anthropic:sonnet
timeout: 30
fallback: true
}
}
state: {
persistent: {
counter: 0
}
ephemeral: {
temp: 0
}
}
on hourly: {
for asset in assets {
current_balance = balance(asset)
if current_balance > params.min_amount {
if **gas costs justify the move** via risk {
atomic {
aave_v3.withdraw(asset, params.amount)
morpho_blue.lend(asset, params.amount)
}
}
}
}
}
}
Syntax Reference
| Feature | Syntax | Example |
|---|---|---|
| Spell declaration | spell Name { |
spell YieldOptimizer { |
| Arrays | [item1, item2] |
assets: [USDC, DAI] |
| Venue refs | @name |
@aave_v3 |
| Venue groups | name: [@v1, @v2] |
lending: [@aave_v3, @morpho_blue] |
| Skills | skills: { |
skills: { ... } |
| Advisors | advisors: { |
advisors: { ... } |
| Percentages | N% |
50% (converts to 0.5) |
| Triggers | on trigger: { |
on hourly: {, on daily: {, on manual: { |
| For loops | for x in y { |
for asset in assets { |
| Repeat loops | repeat N { |
repeat 3 { |
| Loop until | loop until cond max N { |
loop until done max 10 { |
| If/elif/else | if cond { |
if x > 0 { |
| Advisory (AI) | advise advisor: "prompt" |
decision = advise risk: "is this safe" { output: { type: boolean }, fallback: true } |
| Advise | x = advise advisor: |
decision = advise risk: "..." |
| Atomic blocks | atomic { |
Transaction grouping |
| Try/catch | try { |
try { ... } catch *: { |
| Parallel | parallel ... { |
parallel join=all: { |
| Pipeline | `expr | map: {` |
| Block/Do | block / do |
block add(a,b) { |
| Comments | # comment |
# Calculate rates |
| Method calls | obj.method(args) |
venue.deposit(asset, amount) |
| Assignment | x = expr |
rate = get_apy("aave_v3", asset) |
| Using skill | using name |
swap(...) using dex |
| Constraints | with k=v or with (k=v, ...) |
with max_slippage=50 or multi-line with (\n slippage=50,\n deadline=300,\n) |
| Logical ops | and, or, not |
if a > 0 and b < 10 { |
| Emit events | emit name(k=v) |
emit done(value=42) |
| Halt execution | halt "reason" |
halt "insufficient balance" |
| Wait | wait N |
wait 3600 (seconds) |
| Trailing commas | allowed in all lists | [USDC, ETH,], f(a, b,), with (k=v,) |
| Multi-line objects | { key: val } across lines |
{ / a: 1, / b: 2, / } |
Example Spells
spells/uniswap-swap-execute.spellspells/aave-supply.spellspells/morpho-blue-lend.spellspells/across-bridge.spellspells/hyperliquid-perps.spell
Compiler Pipeline
Source (.spell) -> Tokenizer -> Parser -> AST -> Transformer -> SpellSource -> IR Generator -> SpellIR -> Type Checker -> Validator
Key files:
packages/core/src/compiler/grimoire/tokenizer.ts- Brace-delimited lexer, emits LBRACE/RBRACE tokenspackages/core/src/compiler/grimoire/parser.ts- Recursive descent parserpackages/core/src/compiler/grimoire/ast.ts- AST node type definitionspackages/core/src/compiler/grimoire/transformer.ts- AST -> SpellSource conversionpackages/core/src/compiler/ir-generator.ts- SpellSource -> SpellIR (executable format)packages/core/src/compiler/type-checker.ts- Compile-time type checker (errors block compilation)packages/core/src/compiler/validator.ts- IR validation (structural checks)
Execution Model (Preview / Commit)
Grimoire uses an embedded runtime with a mandatory preview / commit model for any action that moves value (onchain or offchain). The runtime is the same whether invoked from the CLI or embedded in an agent.
- Preview -- Full execution against simulated or forked state. Produces a typed receipt listing every value-moving action, its parameters, and expected outcomes.
- Commit -- Settlement using an approved receipt. The runtime re-checks drift bounds before submitting transactions. If bounds are violated, commit rejects and requires a fresh preview.
Local computation, state writes, and advisory calls do not cross the irreversibility boundary and therefore do not require the preview/commit cycle.
This model is defined by the Value-Flow Mandate Runtime. The same preview/commit semantics apply to both CLI and embedded runtime usage.
Venues and Adapters
Core is SDK-free. Protocol integrations live in @grimoirelabs/venues and are injected at execution time.
Supported adapters:
aave_v3(AaveKit) - lending/borrowing on Ethereum + Baseuniswap_v3- swaps via SwapRouter02uniswap_v4- swaps via Universal Router + Permit2morpho_blue- isolated lending marketshyperliquid(offchain) - spot + perps via APIacross(bridge) - cross-chain bridgingpendle- hosted SDK convert routespolymarket(offchain) - prediction market CLOB execution on Polygon
Adapters can return multi-transaction plans to handle approvals. Offchain venues implement executeAction.
Aave V3 Amount Format
The @aave/client SDK uses human-readable BigDecimal amounts, not raw token units:
- supply/borrow:
value: "0.1"(human-readable) - withdraw/repay:
value: { exact: "100000" }(raw units inexactwrapper)
The adapter handles this conversion internally using token decimals (USDC=6, WETH=18, etc.).
Morpho Blue Default Markets
The default adapter ships with well-known Base (chain 8453) markets:
- cbBTC/USDC (86% LLTV)
- WETH/USDC (86% LLTV)
When no collateral is specified in a spell, the first matching market by loan token is selected. To target a specific market, specify the collateral token in the action.
Across Bridge Minimums
Across enforces minimum bridge amounts per token. For test spells:
- USDC: minimum about 1.00 (1000000 raw)
- WETH: minimum about 0.002 ETH
State Persistence
Spell state survives across runs via a SQLite-backed store. The execute() function stays pure; persistence is handled by the CLI or caller.
Architecture
CLI: state = await store.load(spellId)
CLI: result = await execute({ ..., persistentState: state })
CLI: await store.save(spellId, result.finalState)
CLI: await store.addRun(spellId, createRunRecord(result))
CLI: await store.saveLedger(spellId, result.runId, result.ledgerEvents)
Key files
packages/core/src/runtime/state-store.ts-StateStoreinterface,RunRecordtype,createRunRecord()helperpackages/core/src/runtime/sqlite-state-store.ts-SqliteStateStoreclass usingbun:sqlitewith a Node fallbackpackages/cli/src/commands/state-helpers.ts-withStatePersistence()wrapper for CLI commands
StateStore interface
interface StateStore {
load(spellId: string): Promise<Record<string, unknown> | null>;
save(spellId: string, state: Record<string, unknown>): Promise<void>;
addRun(spellId: string, run: RunRecord): Promise<void>;
getRunById(runId: string): Promise<RunRecord | null>;
getRuns(spellId: string, limit?: number): Promise<RunRecord[]>;
saveLedger(spellId: string, runId: string, entries: LedgerEntry[]): Promise<void>;
loadLedger(spellId: string, runId: string): Promise<LedgerEntry[] | null>;
listSpells(): Promise<string[]>;
upsertRunTrack(track: RunTrackRecord): Promise<void>;
getRunTracks(runId: string): Promise<RunTrackRecord[]>;
upsertRunHandoff(handoff: RunHandoffRecord): Promise<void>;
getRunHandoffs(runId: string): Promise<RunHandoffRecord[]>;
upsertRunStepResult(step: RunStepResultRecord): Promise<void>;
getRunStepResults(runId: string): Promise<RunStepResultRecord[]>;
}
SQLite schema
Database lives at .grimoire/grimoire.db (configurable via --state-dir). Core tables: spell_state (persistent state), runs (execution history), ledger (event logs per run). Cross-chain tables: run_tracks, run_handoffs, run_step_results. Migrations are additive and versioned via PRAGMA user_version. Old runs are pruned beyond maxRuns (default 100).
CLI flags
--state-dir <dir>- custom directory forgrimoire.db(onsimulate,cast,history,log)--no-state- disable persistence entirely (onsimulate,cast)--advisory-trace-verbose- stream detailed advisory trace (prompt/schema, tool payloads, model text/thinking deltas) in non-JSONsimulate/castruns--destination-spell <spell>- enable cross-chain orchestration by specifying destination spell--destination-chain <id>- destination chain ID for cross-chain mode--handoff-timeout-sec <seconds>- required handoff timeout in cross-chain mode--poll-interval-sec <seconds>- handoff polling interval (default30)--watch- keep process alive to continue after handoff settlement--morpho-market-id <actionRef>=<marketId>/--morpho-market-map <path>- explicit Morpho market identity mapping for cross-chain runs
Action Constraints (Slippage)
Action constraints are resolved at runtime and attached to Action.constraints for adapter use.
max_slippage,min_output,max_input,deadlinemax_price_impact,min_liquidity,require_quote,require_simulation,max_gas
For swaps, always set both max_slippage and min_output to prevent unexpected losses.
Bridge Actions
bridge actions compile from venue.bridge(asset, amount, to_chain) and are supported by the Across adapter. The IR requires a literal chain ID for to_chain.
CLI
grimoire compile <spell>- compile a.spellfile to IRgrimoire compile-all [dir]- compile all.spellfiles in a directorygrimoire validate <spell> [--strict] [--json]- validate a.spellfilegrimoire simulate <spell> [--rpc-url <url>|<chainId>=<url>] [--destination-spell <spell>]- simulate execution (dry run), with state persistencegrimoire cast <spell> [--rpc-url <url>|<chainId>=<url>] [--destination-spell <spell>]- execute a spell onchain, with state persistencegrimoire setup [--chain <id>] [--rpc-url <url>] [--adapter <name>] [--keystore <path>] [--password-env <name>] [--key-env <name>] [--import-key] [--no-save-password-env] [--no-doctor] [--non-interactive] [--json]- guided local execute onboarding (wallet + RPC + readiness checks, with optional.grimoire/setup.envwriting/autoload)grimoire resume <runId> [--watch]- resume a waiting cross-chain orchestration rungrimoire history [spell]- view execution history (all spells or runs for one spell)grimoire log <spell> <runId>- view ledger events for a specific rungrimoire venues- list available venue adaptersgrimoire venue doctor [--chain <id>] [--adapter <name>] [--rpc-url <url>] [--json]- venue env/chain/RPC diagnosticsgrimoire init- initialize a new.grimoiredirectory- Per-venue CLIs in
@grimoirelabs/venues:grimoire-aavegrimoire-uniswapgrimoire-morpho-bluegrimoire-hyperliquidgrimoire-pendlegrimoire-polymarket
Documentation and Skills Maintenance
Docs live in docs/ and follow the Diataxis structure.
Skills live in skills/ and provide LLM-consumable context:
skills/grimoire/- Core CLI commandsskills/grimoire-aave/- Aave V3 venue CLI + amount formatskills/grimoire-uniswap/- Uniswap V3/V4 venue CLIskills/grimoire-morpho-blue/- Morpho Blue venue CLI + default marketsskills/grimoire-hyperliquid/- Hyperliquid venue CLIskills/grimoire-pendle/- Pendle Hosted SDK metadata workflowsskills/grimoire-polymarket/- Polymarket CLOB custom action workflows
Keep docs and skills in sync with code changes:
| Change | Update |
|---|---|
| New/changed CLI command or flag | docs/reference/cli.md, skills/grimoire/SKILL.md, this file |
| New/changed DSL syntax or feature | docs/reference/grimoire-dsl-spec.md, docs/reference/spell-syntax.md |
| New/changed venue adapter | docs/reference/venues.md, matching skills/grimoire-<venue>/SKILL.md |
| New venue adapter added | Create skills/grimoire-<venue>/SKILL.md, update docs/reference/venues.md |
| Test runner changes | docs/how-to/run-tests.md |
| Wallet/keystore changes | docs/reference/cli.md (setup + wallet sections), docs/how-to/use-wallet-commands-end-to-end.md |
| Bridge/amount thresholds | docs/how-to/bridge-with-across.md, docs/reference/venues.md |
| State persistence changes | This file (State Persistence section) |
| New example spells | README.md (Examples section), docs/reference/grimoire-dsl-spec.md |
Runtime Notes (Bun-first, Node-supported)
- Use Bun for local development and tests.
- Packages in
packages/core,packages/venues, andpackages/climust remain Node-compatible. - Prefer standard
fsAPIs overBun.filein shared packages. - If using
bun:sqlite, provide a Node fallback (better-sqlite3).
Testing Conventions
import { describe, test, expect } from "bun:test";
describe("Feature", () => {
test("does something", () => {
expect(result).toBe(expected);
});
});
Test files are named *.test.ts and colocated with source files.
Onchain Test Suite
# Simulate only (no wallet needed):
./scripts/run-onchain-tests.sh
# Dry-run (builds txs, estimates gas, does NOT send):
./scripts/run-onchain-tests.sh --dry-run
# Live execution on Base:
CHAIN=8453 ./scripts/run-onchain-tests.sh --execute
# Resume from a specific phase after failure:
./scripts/run-onchain-tests.sh --execute --start-phase 4
# Recovery mode - return stranded funds to Base:
./scripts/run-onchain-tests.sh --recover
Phases: 0 (compile) -> 1 (simulate pure) -> 2 (simulate features) -> 3 (cast features) -> 4 (cast venues) -> 5 (multi-chain). Phase 5 writes checkpoints to .grimoire/test-suite/checkpoint and skips completed sub-steps on resume. Phase 5 is skipped if earlier phases have failures.
Expression Parser
The expression parser (packages/core/src/compiler/expression-parser.ts) handles:
- Arithmetic:
+,-,*,/,% - Comparison:
==,!=,<,>,<=,>= - Logical:
and,or,not(case-insensitive) - Ternary:
condition ? then : else - Function calls:
max(a, b),min(x, y),abs(n),sum(arr),avg(arr),to_number(n),to_bigint(n) - Query functions:
price(base, quote, source?),balance(asset, address?) - Type conversions:
to_number(bigint_val)→ number,to_bigint(number_val)→ bigint - Property access:
obj.prop,arr[0]
Usage Example
import { compile, execute, SqliteStateStore, createRunRecord } from "@grimoirelabs/core";
import { adapters, createAlchemyQueryProvider } from "@grimoirelabs/venues";
// Compile a spell
const result = compile(spellSource);
if (!result.success) {
console.error(result.errors);
process.exit(1);
}
// Load persisted state
const store = new SqliteStateStore();
const persistentState = (await store.load(result.ir.id)) ?? {};
// Execute the spell
const execResult = await execute({
spell: result.ir,
vault: "0x...",
chain: 1,
params: { amount: 1000 },
persistentState,
adapters,
queryProvider: createAlchemyQueryProvider({
provider,
chainId: 1,
vault: "0x...",
rpcUrl: "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY",
}),
});
// Persist results
await store.save(result.ir.id, execResult.finalState);
await store.addRun(result.ir.id, createRunRecord(execResult));
await store.saveLedger(result.ir.id, execResult.runId, execResult.ledgerEvents);
store.close();