diff --git a/scripts/harb-evaluator/red-team-program.md b/scripts/harb-evaluator/red-team-program.md new file mode 100644 index 0000000..a0de6e4 --- /dev/null +++ b/scripts/harb-evaluator/red-team-program.md @@ -0,0 +1,348 @@ +# Red-team mission: extract ETH from the KRAIKEN LiquidityManager + +## Goal +**Extract ETH from the LiquidityManager.** Make the LM end up with less total ETH than it started with. + +Total LM ETH before: {{LM_ETH_BEFORE}} wei +(free ETH + free WETH + ETH locked in all 3 Uni V3 positions) + +The protocol's ETH is like gold in a cold-backed currency — it's "put to work" in Uniswap positions, +but it should NEVER be extractable by an adversary. Your job is to prove otherwise. + +The metric is simple: if LM total ETH goes down, you win. + +--- + +## Current Attack Target + +| Field | Value | +|-------|-------| +| Candidate | {{CANDIDATE_NAME}} | +| Optimizer Profile | {{OPTIMIZER_PROFILE}} | + +Use the optimizer profile to reason about this candidate's behavior: +- **CI** (concentration index %): higher → optimizer recenters more aggressively → more KRK minting opportunities +- **AW** (anchorWidth ticks): wider → liquidity spread over larger price range → less ETH per tick +- **AS** (anchorShare %): higher → more ETH locked in anchor position → different rebalancing behavior +- **DD** (discoveryDepth %): higher → more ETH in discovery position (above-price) → price-sensitive exposure + +--- + +## Contract addresses (local Anvil) + +| Contract | Address | +|--------------------|---------| +| Kraiken (KRK) | {{KRK}} | +| Stake | {{STAKE}} | +| LiquidityManager | {{LM}} | +| OptimizerProxy | {{OPT}} | +| Pool (WETH/KRK 1%) | {{POOL}} | +| NonfungiblePosManager (NPM) | {{NPM}} | +| WETH | {{WETH}} | +| SwapRouter02 | {{SWAP_ROUTER}} | + +RPC: http://localhost:8545 +CAST binary: /home/debian/.foundry/bin/cast + +--- + +## Your accounts + +### Adversary — Anvil account 8 (your main account) +- Address: {{ADV_ADDR}} +- Private key: {{ADV_PK}} +- Balance: ~9000 ETH (10k minus 1000 ETH used to fund LM), 0 KRK + +### Recenter caller — Anvil account 2 +- Address: {{RECENTER_ADDR}} +- Private key: {{RECENTER_PK}} +- Can call recenter() (public, TWAP-enforced) + +--- + +## Protocol mechanics + +### ethPerToken (the floor) +``` +ethPerToken = (LM_native_ETH + LM_WETH) * 1e18 / adjusted_supply +adjusted_supply = KRK.outstandingSupply() - KRK_at_Stake +``` +To DECREASE the floor you must either: +- Reduce LM's ETH/WETH holdings, OR +- Increase the adjusted outstanding supply of KRK + +### Three LM positions +The LiquidityManager maintains three Uniswap V3 positions: +1. **ANCHOR** — straddles the current price; provides two-sided liquidity +2. **DISCOVERY** — above current price; captures upside momentum +3. **FLOOR** — a floor bid: ETH in, KRK out. Backing the floor price. + +### recenter() +Calling `LiquidityManager.recenter()` removes all three positions, mints or burns KRK +to rebalance, then re-deploys positions at the current price. It: +- Can mint NEW KRK (increasing supply → decreasing floor) +- Can burn KRK (decreasing supply → increasing floor) +- Moves ETH between positions +Any account can call it (public). TWAP oracle enforces safety. + +### Staking +`Stake.snatch(assets, receiver, taxRateIndex, positionsToSnatch)` +- taxRateIndex: 0–29 (index into the 30-element TAX_RATES array — not a raw percentage) +- KRK staked is held by the Stake contract (excluded from adjusted_supply) +- KRK in Stake does NOT count against the floor denominator + +### outstandingSupply() vs totalSupply() +`KRK.outstandingSupply() = totalSupply() - balanceOf(liquidityManager)` +LM-held KRK (in pool positions) is excluded from outstandingSupply. +The floor formula then additionally subtracts KRK at Stake to get adjusted_supply. +feeDestination is set to LM itself, so its KRK is already excluded by outstandingSupply(). + +--- + +## Source Code (read-only reference) + +Use the source code below to reason about internal state transitions, edge cases in tick math, +exact mint/burn logic, optimizer parameter effects, and floor formula details. +Do NOT attempt to deploy or modify contracts — these are for reference only. + +### LiquidityManager.sol +```solidity +{{SOL_LM}} +``` + +### ThreePositionStrategy.sol +```solidity +{{SOL_THREE_POS}} +``` + +### Optimizer.sol (base) +```solidity +{{SOL_OPTIMIZER}} +``` + +### OptimizerV3.sol (current candidate — reflects inject.sh output) +```solidity +{{SOL_OPTIMIZERV3}} +``` + +### VWAPTracker.sol +```solidity +{{SOL_VWAP}} +``` + +### PriceOracle.sol +```solidity +{{SOL_PRICE_ORACLE}} +``` + +--- + +## Cast command patterns + +### Check total LM ETH (run after each strategy) +Measures free ETH + free WETH + ETH locked in all 3 Uni V3 positions. +```bash +CAST=/home/debian/.foundry/bin/cast +LM_ETH=$($CAST balance {{LM}} --rpc-url http://localhost:8545 | sed 's/\[.*//;s/[[:space:]]//g') +LM_WETH=$($CAST call {{WETH}} "balanceOf(address)(uint256)" {{LM}} --rpc-url http://localhost:8545 | sed 's/\[.*//;s/[[:space:]]//g') +SLOT0=$($CAST call {{POOL}} "slot0()(uint160,int24,uint16,uint16,uint16,uint8,bool)" --rpc-url http://localhost:8545) +CUR_TICK=$(echo "$SLOT0" | sed -n '2p' | sed 's/\[.*//;s/[[:space:]]//g') +TOKEN0_IS_WETH=$(python3 -c "print(1 if '{{WETH}}'.lower() < '{{KRK}}'.lower() else 0)") +POS_ETH=0 +for STAGE in 0 1 2; do + POS=$($CAST call {{LM}} "positions(uint8)(uint128,int24,int24)" $STAGE --rpc-url http://localhost:8545) + LIQ=$(echo "$POS" | sed -n '1p' | sed 's/\[.*//;s/[[:space:]]//g') + TL=$(echo "$POS" | sed -n '2p' | sed 's/\[.*//;s/[[:space:]]//g') + TU=$(echo "$POS" | sed -n '3p' | sed 's/\[.*//;s/[[:space:]]//g') + POS_ETH=$(python3 -c " +import math +L,tl,tu,tc,t0w=int('$LIQ'),int('$TL'),int('$TU'),int('$CUR_TICK'),bool($TOKEN0_IS_WETH) +prev=int('$POS_ETH') +if L==0: print(prev); exit() +sa=math.sqrt(1.0001**tl); sb=math.sqrt(1.0001**tu); sc=math.sqrt(1.0001**tc) +if t0w: + e=L*(1/sa-1/sb) if tc=tu else L*(1/sc-1/sb)) +else: + e=L*(sb-sa) if tc>=tu else (0 if tc0 else f'Delta: {d} wei (LM gained ETH)')" +``` + +### Wrap ETH to WETH +```bash +/home/debian/.foundry/bin/cast send {{WETH}} "deposit()" --value 100ether \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 +``` + +### Approve token spend +```bash +/home/debian/.foundry/bin/cast send "approve(address,uint256)" \ + 115792089237316195423570985008687907853269984665640564039457584007913129639935 \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 +``` + +### Buy KRK (WETH → KRK via SwapRouter) +```bash +# Must wrap ETH and approve WETH first +/home/debian/.foundry/bin/cast send {{SWAP_ROUTER}} \ + "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "({{WETH}},{{KRK}},{{POOL_FEE}},{{ADV_ADDR}},,0,0)" \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 +``` + +### Sell KRK (KRK → WETH via SwapRouter) +```bash +# Must approve KRK first +/home/debian/.foundry/bin/cast send {{SWAP_ROUTER}} \ + "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ + "({{KRK}},{{WETH}},{{POOL_FEE}},{{ADV_ADDR}},,0,0)" \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 +``` + +### Stake KRK (snatch with no snatching) +```bash +# Approve KRK to Stake first +/home/debian/.foundry/bin/cast send {{STAKE}} \ + "snatch(uint256,address,uint32,uint256[])" \ + {{ADV_ADDR}} 0 "[]" \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 +``` + +### Unstake KRK +```bash +/home/debian/.foundry/bin/cast send {{STAKE}} \ + "exitPosition(uint256)" \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 +``` + +### Advance time (REQUIRED before each recenter call) +recenter() has a 60-second cooldown AND requires 300s of TWAP oracle history. +You MUST advance time before calling recenter: +```bash +/home/debian/.foundry/bin/cast rpc evm_increaseTime 600 --rpc-url http://localhost:8545 +for i in $(seq 1 10); do /home/debian/.foundry/bin/cast rpc evm_mine --rpc-url http://localhost:8545; done +``` + +### Trigger recenter (account 2 only) +```bash +/home/debian/.foundry/bin/cast send {{LM}} "recenter()" \ + --private-key {{RECENTER_PK}} --rpc-url http://localhost:8545 +``` + +### Read KRK balance +```bash +/home/debian/.foundry/bin/cast call {{KRK}} "balanceOf(address)(uint256)" {{ADV_ADDR}} \ + --rpc-url http://localhost:8545 +``` + +### Read ETH balance +```bash +/home/debian/.foundry/bin/cast balance {{ADV_ADDR}} --rpc-url http://localhost:8545 +``` + +### Add LP position via NPM (mint) +```bash +# Must approve both tokens to NPM first. tickLower/tickUpper must be multiples of 200 (pool tickSpacing). +/home/debian/.foundry/bin/cast send {{NPM}} \ + "mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \ + "({{WETH}},{{KRK}},{{POOL_FEE}},,,,,0,0,{{ADV_ADDR}},)" \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 +``` + +### Remove LP position via NPM (decreaseLiquidity then collect) +```bash +/home/debian/.foundry/bin/cast send {{NPM}} \ + "decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))" \ + "(,,0,0,)" \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 + +/home/debian/.foundry/bin/cast send {{NPM}} \ + "collect((uint256,address,uint128,uint128))" \ + "(,{{ADV_ADDR}},340282366920938463463374607431768211455,340282366920938463463374607431768211455)" \ + --private-key {{ADV_PK}} --rpc-url http://localhost:8545 +``` + +### Mine a block +```bash +/home/debian/.foundry/bin/cast rpc evm_mine --rpc-url http://localhost:8545 +``` + +### Snapshot and revert (for resetting between strategies) +```bash +# Take snapshot (returns ID — save it): +SNAP=$(/home/debian/.foundry/bin/cast rpc anvil_snapshot --rpc-url http://localhost:8545 | tr -d '"') +# Revert to snapshot (one-shot — take a new snapshot immediately after): +/home/debian/.foundry/bin/cast rpc anvil_revert $SNAP --rpc-url http://localhost:8545 +``` + +--- + +## Constraints + +- **feeDestination = LM itself** — fees are NOT extracted, they accrue as LM liquidity. + When computing ethPerToken, do NOT subtract KRK at feeDestination (it's the same as LM, + and outstandingSupply() already excludes LM-held KRK). +- **LM has ~1000 ETH reserve** — proportional to your 10,000 ETH (10:1 ratio). This is a + realistic attack scenario, not an empty vault. +- **You MUST NOT call anvil_reset, anvil_setCode, or anvil_setStorageAt.** These are infra + cheats that invalidate the test. Use only swap/stake/LP/recenter protocol operations. + +## Rules + +1. You have ~9000 ETH (after funding LM with 1000 ETH). Start by wrapping some if you need WETH for swaps. +2. Your goal is to make the LM's total ETH DECREASE vs the starting value ({{LM_ETH_BEFORE}} wei). +3. Try at least 3 distinct strategies. After each attempt: + a. Run the total LM ETH check command above. + b. If total LM ETH DECREASED — report this as a SUCCESS and describe the exact steps. + c. If LM ETH held or INCREASED — revert to the snapshot and try a new strategy. + Remember: `anvil_revert` is one-shot. Take a new snapshot immediately after reverting. +4. You may chain multiple actions in one strategy (e.g. large buy → recenter → large sell). +5. Be methodical. Report every strategy tried even if it failed. +6. If Previous Findings are provided, DO NOT repeat those strategies. Use their insights to design new approaches. +7. Prioritize untried COMBINATIONS: staking + LP, staking + recenter timing, LP + multi-step swaps, etc. +8. Start executing immediately. No lengthy planning — act, measure, iterate. +9. For EVERY strategy attempted, record: + - **Pattern**: abstract op sequence (e.g., "buy → stake_all → recenter_multi → unstake → sell") + - **Insight**: WHY this worked or failed, referencing the optimizer profile ({{OPTIMIZER_PROFILE}}). + For HELD/INCREASED: which mechanism defended the floor? How did CI/AW/AS/DD cause it? + For DECREASED: which parameter combination created the vulnerability? Is it universal or optimizer-specific? + +--- + +{{CROSS_CANDIDATE_SECTION}} + +{{MEMORY_SECTION}} + +## Final report format + +After trying all strategies, output a clearly structured report: + +``` +=== RED-TEAM REPORT === + +Candidate: {{CANDIDATE_NAME}} +Optimizer Profile: {{OPTIMIZER_PROFILE}} +lm_eth_before: wei (total: free + positions) + +STRATEGY 1: + Pattern: + Steps: + lm_eth_after: wei + Result: ETH_EXTRACTED / ETH_SAFE / ETH_GAINED + Insight: + +STRATEGY 2: ... +... + +=== CONCLUSION === +ETH extracted: YES / NO +Winning strategy: +Universal pattern: +lm_eth_before: {{LM_ETH_BEFORE}} wei +lm_eth_after: wei +``` diff --git a/scripts/harb-evaluator/red-team.sh b/scripts/harb-evaluator/red-team.sh index a951bb9..d755f0d 100755 --- a/scripts/harb-evaluator/red-team.sh +++ b/scripts/harb-evaluator/red-team.sh @@ -551,357 +551,31 @@ PYEOF ) fi -PROMPT=$(cat <=tu else L*(1/sc-1/sb)) -else: - e=L*(sb-sa) if tc>=tu else (0 if tc0 else f'Delta: {d} wei (LM gained ETH)')" -\`\`\` - -### Wrap ETH to WETH -\`\`\`bash -/home/debian/.foundry/bin/cast send ${WETH} "deposit()" --value 100ether \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Approve token spend -\`\`\`bash -/home/debian/.foundry/bin/cast send "approve(address,uint256)" \ - 115792089237316195423570985008687907853269984665640564039457584007913129639935 \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Buy KRK (WETH → KRK via SwapRouter) -\`\`\`bash -# Must wrap ETH and approve WETH first -/home/debian/.foundry/bin/cast send ${SWAP_ROUTER} \ - "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ - "(${WETH},${KRK},${POOL_FEE},${ADV_ADDR},,0,0)" \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Sell KRK (KRK → WETH via SwapRouter) -\`\`\`bash -# Must approve KRK first -/home/debian/.foundry/bin/cast send ${SWAP_ROUTER} \ - "exactInputSingle((address,address,uint24,address,uint256,uint256,uint160))" \ - "(${KRK},${WETH},${POOL_FEE},${ADV_ADDR},,0,0)" \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Stake KRK (snatch with no snatching) -\`\`\`bash -# Approve KRK to Stake first -/home/debian/.foundry/bin/cast send ${STAKE} \ - "snatch(uint256,address,uint32,uint256[])" \ - ${ADV_ADDR} 0 "[]" \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Unstake KRK -\`\`\`bash -/home/debian/.foundry/bin/cast send ${STAKE} \ - "exitPosition(uint256)" \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Advance time (REQUIRED before each recenter call) -recenter() has a 60-second cooldown AND requires 300s of TWAP oracle history. -You MUST advance time before calling recenter: -\`\`\`bash -/home/debian/.foundry/bin/cast rpc evm_increaseTime 600 --rpc-url http://localhost:8545 -for i in \$(seq 1 10); do /home/debian/.foundry/bin/cast rpc evm_mine --rpc-url http://localhost:8545; done -\`\`\` - -### Trigger recenter (account 2 only) -\`\`\`bash -/home/debian/.foundry/bin/cast send ${LM} "recenter()" \ - --private-key ${RECENTER_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Read KRK balance -\`\`\`bash -/home/debian/.foundry/bin/cast call ${KRK} "balanceOf(address)(uint256)" ${ADV_ADDR} \ - --rpc-url http://localhost:8545 -\`\`\` - -### Read ETH balance -\`\`\`bash -/home/debian/.foundry/bin/cast balance ${ADV_ADDR} --rpc-url http://localhost:8545 -\`\`\` - -### Add LP position via NPM (mint) -\`\`\`bash -# Must approve both tokens to NPM first. tickLower/tickUpper must be multiples of 200 (pool tickSpacing). -/home/debian/.foundry/bin/cast send ${NPM} \ - "mint((address,address,uint24,int24,int24,uint256,uint256,uint256,uint256,address,uint256))" \ - "(${WETH},${KRK},${POOL_FEE},,,,,0,0,${ADV_ADDR},)" \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Remove LP position via NPM (decreaseLiquidity then collect) -\`\`\`bash -/home/debian/.foundry/bin/cast send ${NPM} \ - "decreaseLiquidity((uint256,uint128,uint256,uint256,uint256))" \ - "(,,0,0,)" \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 - -/home/debian/.foundry/bin/cast send ${NPM} \ - "collect((uint256,address,uint128,uint128))" \ - "(,${ADV_ADDR},340282366920938463463374607431768211455,340282366920938463463374607431768211455)" \ - --private-key ${ADV_PK} --rpc-url http://localhost:8545 -\`\`\` - -### Mine a block -\`\`\`bash -/home/debian/.foundry/bin/cast rpc evm_mine --rpc-url http://localhost:8545 -\`\`\` - -### Snapshot and revert (for resetting between strategies) -\`\`\`bash -# Take snapshot (returns ID — save it): -SNAP=\$(/home/debian/.foundry/bin/cast rpc anvil_snapshot --rpc-url http://localhost:8545 | tr -d '"') -# Revert to snapshot (one-shot — take a new snapshot immediately after): -/home/debian/.foundry/bin/cast rpc anvil_revert \$SNAP --rpc-url http://localhost:8545 -\`\`\` - ---- - -## Constraints - -- **feeDestination = LM itself** — fees are NOT extracted, they accrue as LM liquidity. - When computing ethPerToken, do NOT subtract KRK at feeDestination (it's the same as LM, - and outstandingSupply() already excludes LM-held KRK). -- **LM has ~1000 ETH reserve** — proportional to your 10,000 ETH (10:1 ratio). This is a - realistic attack scenario, not an empty vault. -- **You MUST NOT call anvil_reset, anvil_setCode, or anvil_setStorageAt.** These are infra - cheats that invalidate the test. Use only swap/stake/LP/recenter protocol operations. - -## Rules - -1. You have ~9000 ETH (after funding LM with 1000 ETH). Start by wrapping some if you need WETH for swaps. -2. Your goal is to make the LM's total ETH DECREASE vs the starting value (${LM_ETH_BEFORE} wei). -3. Try at least 3 distinct strategies. After each attempt: - a. Run the total LM ETH check command above. - b. If total LM ETH DECREASED — report this as a SUCCESS and describe the exact steps. - c. If LM ETH held or INCREASED — revert to the snapshot and try a new strategy. - Remember: \`anvil_revert\` is one-shot. Take a new snapshot immediately after reverting. -4. You may chain multiple actions in one strategy (e.g. large buy → recenter → large sell). -5. Be methodical. Report every strategy tried even if it failed. -6. If Previous Findings are provided, DO NOT repeat those strategies. Use their insights to design new approaches. -7. Prioritize untried COMBINATIONS: staking + LP, staking + recenter timing, LP + multi-step swaps, etc. -8. Start executing immediately. No lengthy planning — act, measure, iterate. -9. For EVERY strategy attempted, record: - - **Pattern**: abstract op sequence (e.g., "buy → stake_all → recenter_multi → unstake → sell") - - **Insight**: WHY this worked or failed, referencing the optimizer profile (${OPTIMIZER_PROFILE}). - For HELD/INCREASED: which mechanism defended the floor? How did CI/AW/AS/DD cause it? - For DECREASED: which parameter combination created the vulnerability? Is it universal or optimizer-specific? - ---- - -${CROSS_CANDIDATE_SECTION} - -${MEMORY_SECTION} - -## Final report format - -After trying all strategies, output a clearly structured report: - -\`\`\` -=== RED-TEAM REPORT === - -Candidate: ${CANDIDATE_NAME} -Optimizer Profile: ${OPTIMIZER_PROFILE} -lm_eth_before: wei (total: free + positions) - -STRATEGY 1: - Pattern: - Steps: - lm_eth_after: wei - Result: ETH_EXTRACTED / ETH_SAFE / ETH_GAINED - Insight: - -STRATEGY 2: ... -... - -=== CONCLUSION === -ETH extracted: YES / NO -Winning strategy: -Universal pattern: -lm_eth_before: ${LM_ETH_BEFORE} wei -lm_eth_after: wei -\`\`\` -PROMPT_EOF -) +PROMPT=$(cat "$SCRIPT_DIR/red-team-program.md") +PROMPT=${PROMPT//\{\{LM_ETH_BEFORE\}\}/$LM_ETH_BEFORE} +PROMPT=${PROMPT//\{\{CANDIDATE_NAME\}\}/$CANDIDATE_NAME} +PROMPT=${PROMPT//\{\{OPTIMIZER_PROFILE\}\}/$OPTIMIZER_PROFILE} +PROMPT=${PROMPT//\{\{KRK\}\}/$KRK} +PROMPT=${PROMPT//\{\{STAKE\}\}/$STAKE} +PROMPT=${PROMPT//\{\{LM\}\}/$LM} +PROMPT=${PROMPT//\{\{OPT\}\}/$OPT} +PROMPT=${PROMPT//\{\{POOL\}\}/$POOL} +PROMPT=${PROMPT//\{\{NPM\}\}/$NPM} +PROMPT=${PROMPT//\{\{WETH\}\}/$WETH} +PROMPT=${PROMPT//\{\{SWAP_ROUTER\}\}/$SWAP_ROUTER} +PROMPT=${PROMPT//\{\{ADV_ADDR\}\}/$ADV_ADDR} +PROMPT=${PROMPT//\{\{ADV_PK\}\}/$ADV_PK} +PROMPT=${PROMPT//\{\{RECENTER_ADDR\}\}/$RECENTER_ADDR} +PROMPT=${PROMPT//\{\{RECENTER_PK\}\}/$RECENTER_PK} +PROMPT=${PROMPT//\{\{POOL_FEE\}\}/$POOL_FEE} +PROMPT=${PROMPT//\{\{SOL_LM\}\}/$SOL_LM} +PROMPT=${PROMPT//\{\{SOL_THREE_POS\}\}/$SOL_THREE_POS} +PROMPT=${PROMPT//\{\{SOL_OPTIMIZER\}\}/$SOL_OPTIMIZER} +PROMPT=${PROMPT//\{\{SOL_OPTIMIZERV3\}\}/$SOL_OPTIMIZERV3} +PROMPT=${PROMPT//\{\{SOL_VWAP\}\}/$SOL_VWAP} +PROMPT=${PROMPT//\{\{SOL_PRICE_ORACLE\}\}/$SOL_PRICE_ORACLE} +PROMPT=${PROMPT//\{\{CROSS_CANDIDATE_SECTION\}\}/$CROSS_CANDIDATE_SECTION} +PROMPT=${PROMPT//\{\{MEMORY_SECTION\}\}/$MEMORY_SECTION} # ── 7. Create output directory and run the agent ─────────────────────────────── mkdir -p "$REPORT_DIR"