Add SCHEMA.md documenting the JSONL attack file format with all operation definitions, field types, and the burn_lp tokenId convention divergence between AttackRunner (.positionIndex) and FitnessEvaluator (.tokenId). Add schema-version header comments to all existing attack files and teach both consumers to skip comment lines starting with //. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
6.2 KiB
Attack File Schema (v1)
Attack files use JSON Lines format (.jsonl): one JSON
object per line, each representing a single operation executed sequentially against
a forked chain.
Schema version
Schema version is tracked in this document and referenced by a comment line at the top of each attack file:
// schema-version: 1
JSONL parsers that do not tolerate comment lines should skip lines starting with //.
Consumers
Two independent consumers execute attack files:
| Consumer | Location | Context |
|---|---|---|
| AttackRunner | onchain/script/backtesting/AttackRunner.s.sol |
Foundry script (forge script), runs against a live Anvil fork |
| FitnessEvaluator | onchain/test/FitnessEvaluator.t.sol |
Foundry test (forge test), runs in-process against a fork |
Both consumers read the same .jsonl files from this directory and interpret
operations identically, with one exception noted under burn_lp below.
Operations
buy
Swap WETH → KRK via SwapRouter.
| Field | Type | Description |
|---|---|---|
op |
"buy" |
Operation identifier |
amount |
string (wei) | WETH amount to swap |
token |
string | Ignored (WETH assumed). Present for readability. |
{"op":"buy","amount":"100000000000000000000","token":"WETH"}
sell
Swap KRK → WETH via SwapRouter.
| Field | Type | Description |
|---|---|---|
op |
"sell" |
Operation identifier |
amount |
string (wei) or "all" |
KRK amount to swap; "all" sells entire balance |
token |
string | Ignored. Present for readability. |
{"op":"sell","amount":"all","token":"KRK"}
recenter
Call LiquidityManager.recenter() via the authorized recenter account.
No additional fields. Emits a snapshot in AttackRunner.
{"op":"recenter"}
buy_recenter_loop
Batch N × (buy → recenter) cycles in a single op. Avoids per-step forge overhead for high-cycle attacks.
| Field | Type | Description |
|---|---|---|
op |
"buy_recenter_loop" |
Operation identifier |
count |
uint | Number of buy→recenter cycles |
amount |
string (wei) | WETH amount per buy |
{"op":"buy_recenter_loop","count":80,"amount":"100000000000000000000"}
stake
Call Stake.snatch() to create a staking position.
| Field | Type | Description |
|---|---|---|
op |
"stake" |
Operation identifier |
amount |
string (wei) | KRK amount to stake |
taxRateIndex |
uint | Raw tax-rate value passed to Stake.snatch() |
{"op":"stake","amount":"1000000000000000000000","taxRateIndex":0}
unstake
Call Stake.exitPosition().
| Field | Type | Description |
|---|---|---|
op |
"unstake" |
Operation identifier |
positionId |
uint | 1-based index into staking positions created by prior stake ops in this run |
{"op":"unstake","positionId":1}
mint_lp
Add a Uniswap V3 LP position via the NonfungiblePositionManager (NPM).
The minted NPM tokenId is stored internally (in insertion order) and can
be referenced by subsequent burn_lp ops.
| Field | Type | Description |
|---|---|---|
op |
"mint_lp" |
Operation identifier |
tickLower |
int | Lower tick boundary |
tickUpper |
int | Upper tick boundary |
amount0 |
string (wei) | token0 amount |
amount1 |
string (wei) | token1 amount |
{"op":"mint_lp","tickLower":-100,"tickUpper":100,"amount0":"1000000","amount1":"1000000"}
burn_lp
Remove a Uniswap V3 LP position (decreaseLiquidity + collect).
tokenId convention: The position to burn is identified by a 1-based index
into the list of NPM tokenIds created by prior mint_lp ops in this attack run
(index 1 = first mint_lp, index 2 = second, etc.). This indirection exists because
raw NPM tokenIds vary depending on the fork block tip and would make attack files
non-portable across fork blocks (see issue #614).
Field-name divergence (consumer-specific):
Consumer JSON field Semantics AttackRunner .positionIndex1-based index into _mintedLpTokenIdsFitnessEvaluator .tokenId1-based index into _mintedNpmTokenIdsBoth fields carry the same semantic value (1-based mint_lp index), but the JSON key differs. Attack files targeting AttackRunner must use
positionIndex; files targeting FitnessEvaluator must usetokenId.Migration note: A future unification should standardise on
positionIndex(the more descriptive name) and update FitnessEvaluator to match. Until then, include both fields in attack files that need to work with both consumers:{"op":"burn_lp","positionIndex":1,"tokenId":1}
mine
Advance the block number (Anvil/VM only).
| Field | Type | Description |
|---|---|---|
op |
"mine" |
Operation identifier |
blocks |
uint | Number of blocks to advance |
{"op":"mine","blocks":10}
Snapshot schema (AttackRunner output)
AttackRunner emits a JSON-line snapshot to stdout after each recenter op:
| Field | Description |
|---|---|
seq |
Sequence counter |
tick |
Current pool tick |
lm_eth_free |
Free ETH in LiquidityManager |
lm_weth_free |
Free WETH in LiquidityManager |
lm_eth_total |
Total ETH in LiquidityManager |
positions |
Object with floor, anchor, discovery sub-objects |
vwap_x96 |
VWAP in X96 format |
vwap_tick |
VWAP as tick |
outstanding_supply |
Outstanding KRK supply |
total_supply |
Total KRK supply |
optimizer_output |
Current optimizer parameters |
adversary_eth |
Adversary ETH balance |
adversary_krk |
Adversary KRK balance |
Regenerating attack files
When regenerating attack files (e.g. via the red-team agent):
- Always include the
// schema-version: 1header as the first line. - For
burn_lpops, use 1-basedmint_lpindexes — never raw NPM tokenIds. - If the file must work with both AttackRunner and FitnessEvaluator, include
both
positionIndexandtokenIdfields with the same value. - Test against both consumers after regeneration to catch field-name mismatches.