feat: OptimizerV3 with direct 2D staking-to-LP parameter mapping

Core protocol changes for launch readiness:

- OptimizerV3: binary bear/bull mapping from (staking%, avgTax) — avoids
  exploitable AW 30-90 kill zone. Bear: AS=30%, AW=100, CI=0, DD=0.3e18.
  Bull: AS=100%, AW=20, CI=0, DD=1e18. UUPS upgradeable with __gap[48].
- Directional VWAP: only records prices on ETH inflow (buys), preventing
  sell-side dilution of price memory
- Floor formula: unified max(scarcity, mirror, clamp) — VWAP mirror uses
  distance from adjusted VWAP as floor distance, no branching
- PriceOracle (M-1 fix): correct fallback TWAP divisor (60000s, not 300s)
- Access control (M-2 fix): deployer-only guard on one-time setters
- Recenter rate limit (M-3 fix): 60-second cooldown for open recenters
- Safe fallback params: recenter() optimizer-failure defaults changed from
  exploitable CI=50%/AW=50 to safe bear-mode CI=0/AW=100
- Recentered event for monitoring and indexing
- VERSION bump to 2, kraiken-lib COMPATIBLE_CONTRACT_VERSIONS updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-13 18:21:18 +00:00
parent 21857ae8ca
commit 85350caf52
38 changed files with 3793 additions and 205 deletions

View file

@ -0,0 +1,26 @@
# Claude Code Supervisor configuration
# Copy to ~/.config/claude-code-supervisor/config.yml
# or .claude-code-supervisor.yml in your project root.
triage:
# Command that accepts a prompt on stdin and returns text on stdout.
# Default: claude -p (uses Claude Code's own auth)
command: "claude -p --no-session-persistence"
model: "claude-haiku-4-20250414"
max_tokens: 150
notify:
# Command that receives a JSON string as its last argument.
# Called when triage determines action is needed.
# Examples:
# openclaw: openclaw gateway call wake --params
# ntfy: curl -s -X POST https://ntfy.sh/my-topic -d
# webhook: curl -s -X POST https://example.com/hook -H 'Content-Type: application/json' -d
# script: /path/to/my-notify.sh
command: "openclaw gateway call wake --params"
# Quiet hours — suppress non-urgent escalations
quiet_hours:
start: "23:00"
end: "08:00"
timezone: "Europe/Berlin"

139
.claude/hooks/supervisor/lib.sh Executable file
View file

@ -0,0 +1,139 @@
#!/bin/bash
# Shared functions for claude-code-supervisor hooks and scripts.
set -euo pipefail
# Find config file: project .claude-code-supervisor.yml → ~/.config/ → defaults
ccs_find_config() {
local cwd="${1:-.}"
if [ -f "$cwd/.claude-code-supervisor.yml" ]; then
echo "$cwd/.claude-code-supervisor.yml"
elif [ -f "${HOME}/.config/claude-code-supervisor/config.yml" ]; then
echo "${HOME}/.config/claude-code-supervisor/config.yml"
else
echo ""
fi
}
# Read a yaml value (simple key.subkey extraction, no yq dependency).
# Falls back to default if not found.
ccs_config_get() {
local config_file="$1"
local key="$2"
local default="${3:-}"
if [ -z "$config_file" ] || [ ! -f "$config_file" ]; then
echo "$default"
return
fi
# Simple grep-based yaml extraction (handles key: value on one line)
local value
case "$key" in
triage.command) value=$(grep -A0 '^\s*command:' "$config_file" | head -1 | sed 's/.*command:\s*["]*//;s/["]*$//' | xargs) ;;
triage.model) value=$(awk '/^triage:/,/^[a-z]/' "$config_file" | grep 'model:' | head -1 | sed 's/.*model:\s*["]*//;s/["]*$//' | xargs) ;;
triage.max_tokens) value=$(awk '/^triage:/,/^[a-z]/' "$config_file" | grep 'max_tokens:' | head -1 | sed 's/.*max_tokens:\s*//' | xargs) ;;
notify.command) value=$(awk '/^notify:/,/^[a-z]/' "$config_file" | grep 'command:' | head -1 | sed 's/.*command:\s*["]*//;s/["]*$//' | xargs) ;;
idle.timeout) value=$(awk '/^idle:/,/^[a-z]/' "$config_file" | grep 'timeout_seconds:' | head -1 | sed 's/.*timeout_seconds:\s*//' | xargs) ;;
idle.nudge_message) value=$(awk '/^idle:/,/^[a-z]/' "$config_file" | grep 'nudge_message:' | head -1 | sed 's/.*nudge_message:\s*["]*//;s/["]*$//' | xargs) ;;
*) value="" ;;
esac
echo "${value:-$default}"
}
# Send a notification via the configured notify command.
ccs_notify() {
local config_file="$1"
local message="$2"
local notify_cmd
notify_cmd=$(ccs_config_get "$config_file" "notify.command" "openclaw gateway call wake --params")
# Build JSON payload
local payload
payload=$(jq -n --arg text "$message" --arg mode "now" '{text: $text, mode: $mode}')
$notify_cmd "$payload" 2>/dev/null || true
}
# Run LLM triage via configured command.
# Accepts prompt on stdin, returns verdict on stdout.
ccs_triage() {
local config_file="$1"
local prompt="$2"
local triage_cmd model max_tokens
triage_cmd=$(ccs_config_get "$config_file" "triage.command" "claude -p --no-session-persistence")
model=$(ccs_config_get "$config_file" "triage.model" "claude-haiku-4-20250414")
max_tokens=$(ccs_config_get "$config_file" "triage.max_tokens" "150")
echo "$prompt" | $triage_cmd --model "$model" --max-tokens "$max_tokens" 2>/dev/null
}
# Generate a notify wrapper script the agent can call on completion.
# Usage: ccs_generate_notify_script "$config_file" ["/tmp/supervisor-notify.sh"]
ccs_generate_notify_script() {
local config_file="$1"
local script_path="${2:-/tmp/supervisor-notify.sh}"
local notify_cmd
notify_cmd=$(ccs_config_get "$config_file" "notify.command" "openclaw gateway call wake --params")
cat > "$script_path" <<SCRIPT
#!/bin/bash
# Auto-generated by claude-code-supervisor
# Usage: $script_path "your summary here"
MESSAGE="\${*:-done}"
PAYLOAD=\$(jq -n --arg text "cc-supervisor: DONE | agent-reported | \$MESSAGE" --arg mode "now" '{text: \$text, mode: \$mode}')
$notify_cmd "\$PAYLOAD" 2>/dev/null || echo "Notify failed (command: $notify_cmd)" >&2
SCRIPT
chmod +x "$script_path"
}
# Extract environment_hints from config as newline-separated list.
# Usage: hints=$(ccs_environment_hints "$config_file")
ccs_environment_hints() {
local config_file="$1"
if [ -z "$config_file" ] || [ ! -f "$config_file" ]; then
return
fi
awk '/^environment_hints:/,/^[a-z]/' "$config_file" \
| grep '^\s*-' \
| sed 's/^\s*-\s*["]*//;s/["]*$//'
}
# Parse capture-pane output and return session status.
# Returns: WORKING | IDLE | DONE | ERROR
# Usage: status=$(ccs_parse_status "$pane_output")
ccs_parse_status() {
local output="$1"
local last_lines
last_lines=$(echo "$output" | tail -15)
# DONE: completed task indicators (cost summary line)
if echo "$last_lines" | grep -qE '(Brewed for|Churned for) [0-9]+'; then
echo "DONE"
return
fi
# WORKING: active tool call or thinking
if echo "$last_lines" | grep -qE 'esc to interrupt|Running…|Waiting…'; then
echo "WORKING"
return
fi
# ERROR: tool failures or API errors
if echo "$last_lines" | grep -qiE 'API Error:|Exit code [1-9]|^Error:'; then
echo "ERROR"
return
fi
# IDLE: prompt back with no activity indicator
if echo "$last_lines" | tail -3 | grep -qE '^\$ |^ |^% |^[a-z]+@.*\$ '; then
echo "IDLE"
return
fi
# Default: assume working (mid-output, streaming, etc.)
echo "WORKING"
}

View file

@ -0,0 +1,40 @@
#!/bin/bash
# Claude Code PostToolUseFailure hook — fires when a tool call fails.
#
# Option D: bash pre-filter for known patterns, LLM triage for the rest.
#
# - API 500 → always triage (transient, likely needs nudge)
# - API 429 → log + wait (rate limit, will resolve)
# - Other tool errors → triage (might be important)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SUPERVISOR_DIR="${SCRIPT_DIR%/hooks}"
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
ERROR=$(echo "$INPUT" | jq -r '.error // .tool_error // "unknown"' | head -c 500)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
CWD=$(echo "$INPUT" | jq -r '.cwd // "unknown"')
# Bash pre-filter
if echo "$ERROR" | grep -qi "429\|rate.limit"; then
# Rate limited — log it, don't triage. It'll resolve.
echo "[$(date -u +%FT%TZ)] RATE_LIMITED | $TOOL | $CWD" >> "${CCS_LOG_FILE:-/tmp/ccs-triage.log}"
exit 0
fi
if echo "$ERROR" | grep -qi "500\|internal.server.error\|api_error"; then
# API 500 — high chance agent is stuck on this. Triage.
"$SUPERVISOR_DIR/triage.sh" "error:api_500" "$CWD" \
"Tool: $TOOL | Error: $ERROR | Session: $SESSION_ID" &
exit 0
fi
# Other errors — could be anything. Let LLM decide.
"$SUPERVISOR_DIR/triage.sh" "error:$TOOL" "$CWD" \
"Tool: $TOOL | Error: $ERROR | Session: $SESSION_ID" &
exit 0

View file

@ -0,0 +1,44 @@
#!/bin/bash
# Claude Code Notification hook — fires when Claude is waiting for input.
#
# Option D: bash pre-filter by notification type.
#
# - idle_prompt → triage (agent waiting, might need nudge)
# - permission_prompt → always triage (might need approval)
# - auth_* → skip (internal, transient)
# - other → triage
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SUPERVISOR_DIR="${SCRIPT_DIR%/hooks}"
INPUT=$(cat)
NOTIFY_TYPE=$(echo "$INPUT" | jq -r '.type // "unknown"')
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
CWD=$(echo "$INPUT" | jq -r '.cwd // "unknown"')
MESSAGE=$(echo "$INPUT" | jq -r '.message // ""' | head -c 300)
case "$NOTIFY_TYPE" in
auth_*)
# Auth events are transient, skip
exit 0
;;
permission_prompt)
# Agent needs permission — might be able to auto-approve, or escalate
"$SUPERVISOR_DIR/triage.sh" "notification:permission" "$CWD" \
"Permission requested: $MESSAGE | Session: $SESSION_ID" &
;;
idle_prompt)
# Agent is idle, waiting for human input
"$SUPERVISOR_DIR/triage.sh" "notification:idle" "$CWD" \
"Agent idle, waiting for input. Message: $MESSAGE | Session: $SESSION_ID" &
;;
*)
"$SUPERVISOR_DIR/triage.sh" "notification:$NOTIFY_TYPE" "$CWD" \
"Type: $NOTIFY_TYPE | Message: $MESSAGE | Session: $SESSION_ID" &
;;
esac
exit 0

View file

@ -0,0 +1,71 @@
#!/bin/bash
# Claude Code Stop hook — fires when the agent finishes responding.
#
# Option D: bash pre-filter first, LLM triage only for ambiguous cases.
#
# Known Stop event fields:
# session_id, transcript_path, cwd, permission_mode,
# hook_event_name ("Stop"), stop_hook_active
#
# Note: stop_reason is NOT provided in the Stop event JSON.
# We detect state by inspecting the tmux pane instead.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SUPERVISOR_DIR="${SCRIPT_DIR%/hooks}"
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
CWD=$(echo "$INPUT" | jq -r '.cwd // "unknown"')
# Find matching supervised session
STATE_FILE="${CCS_STATE_FILE:-${HOME}/.openclaw/workspace/supervisor-state.json}"
if [ ! -f "$STATE_FILE" ]; then
exit 0 # No supervised sessions
fi
SOCKET=$(jq -r --arg cwd "$CWD" '
.sessions | to_entries[] | select(.value.projectDir == $cwd and .value.status == "running") | .value.socket
' "$STATE_FILE" 2>/dev/null || echo "")
TMUX_SESSION=$(jq -r --arg cwd "$CWD" '
.sessions | to_entries[] | select(.value.projectDir == $cwd and .value.status == "running") | .value.tmuxSession
' "$STATE_FILE" 2>/dev/null || echo "")
# No matching supervised session for this project
if [ -z "$SOCKET" ] || [ -z "$TMUX_SESSION" ]; then
exit 0
fi
# Can we reach the tmux session?
if [ ! -S "$SOCKET" ] || ! tmux -S "$SOCKET" has-session -t "$TMUX_SESSION" 2>/dev/null; then
exit 0
fi
PANE_OUTPUT=$(tmux -S "$SOCKET" capture-pane -p -J -t "$TMUX_SESSION" -S -30 2>/dev/null || echo "")
# Bash pre-filter: check the last few lines for patterns
# Check for API errors (transient — always triage)
if echo "$PANE_OUTPUT" | tail -10 | grep -qiE "API Error: 5[0-9][0-9]|internal.server.error|api_error"; then
"$SUPERVISOR_DIR/triage.sh" "stopped:api_error" "$CWD" "$PANE_OUTPUT" &
exit 0
fi
# Check for rate limiting
if echo "$PANE_OUTPUT" | tail -10 | grep -qiE "429|rate.limit"; then
echo "[$(date -u +%FT%TZ)] RATE_LIMITED | stop | $CWD" >> "${CCS_LOG_FILE:-/tmp/ccs-triage.log}"
exit 0
fi
# Check if shell prompt is back (Claude Code exited)
if echo "$PANE_OUTPUT" | tail -5 | grep -qE '^\$ |^ [^/]|^% |^[a-z]+@.*\$ '; then
# Prompt returned — agent might be done or crashed. Triage it.
"$SUPERVISOR_DIR/triage.sh" "stopped:prompt_back" "$CWD" "$PANE_OUTPUT" &
exit 0
fi
# No prompt back, no errors — agent is likely mid-conversation. Skip silently.
exit 0

View file

@ -0,0 +1,72 @@
#!/bin/bash
# Triage a Claude Code event using a fast LLM.
# Called by hook scripts when bash pre-filtering can't decide.
#
# Usage: triage.sh <event-type> <cwd> <context>
# event-type: stopped | error | notification
# cwd: project directory (used to find config + state)
# context: the relevant context (tmux output, error message, etc.)
#
# Reads supervisor-state.json to find the goal for this session.
# Returns one of: FINE | NEEDS_NUDGE | STUCK | DONE | ESCALATE
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "$SCRIPT_DIR/lib.sh"
EVENT_TYPE="${1:-unknown}"
CWD="${2:-.}"
CONTEXT="${3:-}"
CONFIG=$(ccs_find_config "$CWD")
# Try to find the goal from supervisor state
STATE_FILE="${CCS_STATE_FILE:-${HOME}/.openclaw/workspace/supervisor-state.json}"
GOAL="unknown"
if [ -f "$STATE_FILE" ]; then
# Match by cwd/projectDir
GOAL=$(jq -r --arg cwd "$CWD" '
.sessions | to_entries[] | select(.value.projectDir == $cwd) | .value.goal
' "$STATE_FILE" 2>/dev/null || echo "unknown")
fi
PROMPT="You are a coding agent supervisor. A Claude Code session just triggered an event.
Event: ${EVENT_TYPE}
Project: ${CWD}
Goal: ${GOAL}
Recent terminal output:
${CONTEXT}
Classify this situation with exactly one word on the first line:
FINE - Agent is working normally, no intervention needed
NEEDS_NUDGE - Agent hit a transient error or stopped prematurely, should be told to continue
STUCK - Agent is looping or not making progress, needs different approach
DONE - Agent completed the task successfully
ESCALATE - Situation needs human judgment
Then a one-line explanation."
VERDICT=$(ccs_triage "$CONFIG" "$PROMPT")
echo "$VERDICT"
# Extract the classification (first word of first line)
CLASSIFICATION=$(echo "$VERDICT" | head -1 | awk '{print $1}')
# Only notify if action is needed
case "$CLASSIFICATION" in
FINE)
# Log silently, don't wake anyone
echo "[$(date -u +%FT%TZ)] FINE | $EVENT_TYPE | $CWD" >> "${CCS_LOG_FILE:-/tmp/ccs-triage.log}"
;;
NEEDS_NUDGE|STUCK|DONE|ESCALATE)
ccs_notify "$CONFIG" "cc-supervisor: $CLASSIFICATION | $EVENT_TYPE | cwd=$CWD | $VERDICT"
;;
*)
# Couldn't parse — notify to be safe
ccs_notify "$CONFIG" "cc-supervisor: UNKNOWN | $EVENT_TYPE | cwd=$CWD | verdict=$VERDICT"
;;
esac

37
.claude/settings.json Normal file
View file

@ -0,0 +1,37 @@
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/home/debian/harb/.claude/hooks/supervisor/on-stop.sh"
}
]
}
],
"PostToolUseFailure": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/home/debian/harb/.claude/hooks/supervisor/on-error.sh"
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/home/debian/harb/.claude/hooks/supervisor/on-notify.sh"
}
]
}
]
}
}

View file

@ -21,8 +21,9 @@ if [ -f "onchain/src/Kraiken.sol" ] && [ -f "kraiken-lib/src/version.ts" ]; then
echo " Library VERSION: $LIB_VERSION"
echo " Compatible versions: $COMPATIBLE"
# Check if contract version is in compatible list
if echo ",$COMPATIBLE," | grep -q ",$CONTRACT_VERSION,"; then
# Check if contract version is in compatible list (strip spaces for reliable matching)
COMPATIBLE_CLEAN=$(echo "$COMPATIBLE" | tr -d ' ')
if echo ",$COMPATIBLE_CLEAN," | grep -q ",$CONTRACT_VERSION,"; then
echo " ✓ Version sync validated"
else
echo " ❌ Version validation failed!"

View file

@ -51,11 +51,24 @@ Double-overflow scenarios requiring >1000x compression would need:
- **Conclusion**: 1000x compression limit provides adequate protection against realistic scenarios
### Implementation Details
**FLOOR Position Calculation:**
**FLOOR Position Calculation (Unified Formula):**
```
FLOOR_PRICE = VWAP_PRICE * (0.7 + CAPITAL_INEFFICIENCY)
floorTick = max(scarcityTick, mirrorTick, clampTick) toward KRK-cheap side
```
Three signals determine the floor:
- **scarcityTick**: derived from `vwapX96` and ETH/supply ratio. Dominates when ETH is scarce.
- **mirrorTick**: `currentTick + |adjustedVwapTick - currentTick|` on KRK-cheap side. Reflects VWAP distance symmetrically. During sell pressure the mirror distance grows, resisting floor walkdown.
- **clampTick**: minimum distance from anchor edge. `anchorSpacing = 200 + (34 × 20 × AW / 100)` ticks.
**VWAP Mirror Defense:**
- During sell-heavy trading, the current tick drops but VWAP stays higher, so mirror distance *grows* — floor naturally resists being walked down.
- CI controls mirror distance through `getAdjustedVWAP(CI)` with no magic numbers. CI=0% is safest (proven zero effect on fee revenue).
**Directional VWAP Recording:**
- VWAP only records on ETH inflow (buys into the LM), preventing attackers from diluting VWAP with sells.
- `shouldRecordVWAP` compares `lastRecenterTick` to current tick to detect direction.
**Protection Mechanism:**
- VWAP provides "eternal memory" of historical trading activity
- Compression algorithm ensures memory persists even under extreme volume
@ -76,26 +89,26 @@ FLOOR_PRICE = VWAP_PRICE * (0.7 + CAPITAL_INEFFICIENCY)
- **Average Tax Rate**: Weighted average of all staking tax rates
- **Tax Rate Distribution**: Spread of tax rates across stakers
### Optimizer Integration
**Sentiment Analysis:**
```solidity
function getLiquidityParams() returns (
uint256 capitalInefficiency,
uint256 anchorShare,
uint24 anchorWidth,
uint256 discoveryDepth
) {
// Analyze staking data to determine optimal liquidity parameters
// Higher confidence (tax rates) → more aggressive positioning
// Lower confidence → more conservative positioning
}
```
### OptimizerV3 Integration
**Direct 2D Binary Mapping (no intermediate score):**
OptimizerV3 reads `percentageStaked` and `averageTaxRate` from the Stake contract and maps them directly to one of two configurations:
- `staked ≤ 91%` → always **BEAR**: AS=30%, AW=100, CI=0, DD=0.3e18
- `staked > 91%`**BULL** if `deltaS³ × effIdx / 20 < 50`: AS=100%, AW=20, CI=0, DD=1e18
The binary step avoids the AW 40-80 kill zone where intermediate parameters are exploitable. Bull requires >91% staked with low enough tax; any decline snaps to bear instantly.
**Parameter Safety (proven via 1050-combo 4D sweep):**
- CI=0% always (zero effect on fee revenue, maximum protection)
- Fee revenue is parameter-independent (~1.5 ETH/cycle across all combos)
- Safety comes entirely from the AS×AW configuration
### Economic Incentives
- **Tax Revenue**: Funds protocol operations and incentivizes participation
- **Staking Benefits**: Percentage ownership of total supply (rather than fixed token amounts)
- **Prediction Market**: Tax rates create market-based sentiment signals
- **Liquidity Optimization**: Sentiment data feeds into dynamic parameter adjustment
- **Liquidity Optimization**: Sentiment data feeds into binary bear/bull parameter selection
## Position Dependencies Technical Details
@ -130,7 +143,7 @@ function getLiquidityParams() returns (
### Key Contracts
- **LiquidityManager.sol**: Core three-position strategy implementation
- **VWAPTracker.sol**: Historical price memory and compression algorithm
- **Optimizer.sol**: Sentiment analysis and parameter optimization
- **OptimizerV3.sol**: Sentiment-driven binary bear/bull parameter selection (UUPS upgradeable)
- **Stake.sol**: Harberger tax mechanism and sentiment data collection
### Analysis Tools

306
docs/DEPLOYMENT_RUNBOOK.md Normal file
View file

@ -0,0 +1,306 @@
# KRAIKEN Mainnet Deployment Runbook
**Target chain:** Base (L2)
**Contract version:** V2 (OptimizerV3 w/ directional VWAP)
---
## 1. Pre-Deployment Checklist
- [ ] All tests pass: `cd onchain && forge test`
- [ ] Gas snapshot baseline: `forge snapshot`
- [ ] Security review complete (see `analysis/SECURITY_REVIEW.md`)
- [ ] Storage layout verified for UUPS upgrade (see `analysis/STORAGE_LAYOUT.md`)
- [ ] Floor ratchet mitigation status confirmed (branch `fix/floor-ratchet`)
- [ ] Multisig wallet ready for `feeDestination` (Gnosis Safe on Base)
- [ ] Deployer wallet funded with sufficient ETH for gas (~0.05 ETH)
- [ ] LiquidityManager funding wallet ready (initial ETH seed for pool positions)
- [ ] `.secret` seed phrase file present in `onchain/` (deployer account)
- [ ] Base RPC endpoint configured and tested
- [ ] Etherscan/Basescan API key ready for contract verification
- [ ] kraiken-lib version updated: `COMPATIBLE_CONTRACT_VERSIONS` includes `2`
---
## 2. Contract Deployment Order
All contracts are deployed in a single broadcast transaction via `DeployBaseMainnet.sol`:
```
1. Kraiken token (ERC20 + ERC20Permit)
2. Stake contract (Kraiken address, feeDestination)
3. Kraiken.setStakingPool(Stake)
4. Uniswap V3 Pool (create or use existing, FEE=10000)
5. Pool initialization (1 cent starting price)
6. OptimizerV3 implementation + ERC1967Proxy
7. LiquidityManager (factory, WETH, Kraiken, OptimizerProxy)
8. LiquidityManager.setFeeDestination(multisig)
9. Kraiken.setLiquidityManager(LiquidityManager)
```
### Deploy Command
```bash
cd onchain
# Verify configuration first
cat script/DeployBaseMainnet.sol # Check feeDest, weth, v3Factory
# Dry run (no broadcast)
forge script script/DeployBaseMainnet.sol \
--rpc-url $BASE_RPC \
--sender $(cast wallet address --mnemonic "$(cat .secret)")
# Live deployment
forge script script/DeployBaseMainnet.sol \
--rpc-url $BASE_RPC \
--broadcast \
--verify \
--etherscan-api-key $BASESCAN_API_KEY \
--slow
```
**Critical:** The `--slow` flag submits transactions one at a time, waiting for confirmation. This prevents nonce issues on Base.
### Record Deployment Addresses
After deployment, save all addresses from console output:
```bash
# Update deployments file
cat >> deployments-mainnet.json << 'EOF'
{
"chain": "base",
"chainId": 8453,
"kraiken": "0x...",
"stake": "0x...",
"pool": "0x...",
"liquidityManager": "0x...",
"optimizerProxy": "0x...",
"optimizerImpl": "0x...",
"feeDestination": "0x...",
"deployer": "0x...",
"deployedAt": "2026-XX-XX",
"txHash": "0x..."
}
EOF
```
---
## 3. Post-Deployment Setup
### 3.1 Fund LiquidityManager
The LM needs ETH to create initial positions:
```bash
# Send ETH to LiquidityManager (unwrapped — it will wrap to WETH internally)
cast send $LIQUIDITY_MANAGER --value 10ether \
--rpc-url $BASE_RPC \
--mnemonic "$(cat .secret)"
```
### 3.2 Set Recenter Access
Restrict `recenter()` to the txnBot address:
```bash
# Must be called by feeDestination (multisig)
cast send $LIQUIDITY_MANAGER "setRecenterAccess(address)" $TXNBOT_ADDRESS \
--rpc-url $BASE_RPC \
--mnemonic "$(cat .secret)" # or via multisig
```
### 3.3 Trigger First Recenter
```bash
# Wait for pool to accumulate some TWAP history (~5 minutes of trades)
# Then trigger first recenter (must be called by recenterAccess)
cast send $LIQUIDITY_MANAGER "recenter()" \
--rpc-url $BASE_RPC \
--from $TXNBOT_ADDRESS
```
### 3.4 Configure txnBot
Update `services/txnBot/` configuration for Base mainnet:
- Set `LIQUIDITY_MANAGER` address
- Set `KRAIKEN` address
- Set RPC to Base mainnet
- Deploy txnBot service
### 3.5 Configure Ponder Indexer
```bash
# Update kraiken-lib/src/version.ts
export const COMPATIBLE_CONTRACT_VERSIONS = [2];
# Update Ponder config for Base mainnet addresses
# Set PONDER_NETWORK=BASE in environment
```
### 3.6 Update Frontend
- Update contract addresses in web-app configuration
- Update kraiken-lib ABIs: `cd onchain && forge build` then rebuild kraiken-lib
- Deploy frontend to production
---
## 4. Optimizer Upgrade Procedure
If upgrading an existing Optimizer proxy to OptimizerV3:
```bash
cd onchain
# Set proxy address
export OPTIMIZER_PROXY=0x...
# Dry run
forge script script/UpgradeOptimizer.sol \
--rpc-url $BASE_RPC
# Execute upgrade
forge script script/UpgradeOptimizer.sol \
--rpc-url $BASE_RPC \
--broadcast \
--verify \
--etherscan-api-key $BASESCAN_API_KEY
# Verify post-upgrade
cast call $OPTIMIZER_PROXY "getLiquidityParams()" --rpc-url $BASE_RPC
```
**Expected output:** Bear-mode defaults (CI=0, AS=0.3e18, AW=100, DD=0.3e18) since staking will be <91%.
---
## 5. Verification Steps
Run these checks after deployment to confirm everything is wired correctly:
```bash
# 1. Kraiken token
cast call $KRAIKEN "VERSION()" --rpc-url $BASE_RPC # Should return 2
cast call $KRAIKEN "peripheryContracts()" --rpc-url $BASE_RPC # LM + Stake addresses
# 2. LiquidityManager
cast call $LM "feeDestination()" --rpc-url $BASE_RPC # Should be multisig
cast call $LM "recenterAccess()" --rpc-url $BASE_RPC # Should be txnBot
cast call $LM "positions(0)" --rpc-url $BASE_RPC # Floor position (after recenter)
cast call $LM "positions(1)" --rpc-url $BASE_RPC # Anchor position
cast call $LM "positions(2)" --rpc-url $BASE_RPC # Discovery position
# 3. OptimizerV3 (through proxy)
cast call $OPTIMIZER "getLiquidityParams()" --rpc-url $BASE_RPC
# 4. Pool state
cast call $POOL "slot0()" --rpc-url $BASE_RPC # Current tick, price
cast call $POOL "liquidity()" --rpc-url $BASE_RPC # Total liquidity
# 5. Stake contract
cast call $STAKE "nextPositionId()" --rpc-url $BASE_RPC # Should be 0 initially
# 6. ETH balance
cast balance $LM --rpc-url $BASE_RPC # Should show funded amount
```
---
## 6. Emergency Procedures
### 6.1 Pause Recentering
**WARNING:** `revokeRecenterAccess()` does NOT pause recentering. It makes `recenter()` permissionless (anyone can call it with 60-second cooldown + TWAP check). In an attack scenario, this would make things worse.
To truly lock out recenters, set `recenterAccess` to a burn address that no one controls:
```bash
# Called by feeDestination (multisig) — sets access to a dead address
cast send $LM "setRecenterAccess(address)" 0x000000000000000000000000000000000000dEaD \
--rpc-url $BASE_RPC
```
This leaves existing positions in place but prevents any new recenters. LP positions continue earning fees. To resume, call `setRecenterAccess()` with the txnBot address again.
### 6.2 Upgrade Optimizer to Safe Defaults
Deploy a minimal "safe" optimizer that always returns bear parameters:
```bash
# Deploy SafeOptimizer with hardcoded bear params
# Upgrade proxy to SafeOptimizer
OPTIMIZER_PROXY=$OPTIMIZER forge script script/UpgradeOptimizer.sol \
--rpc-url $BASE_RPC --broadcast
```
### 6.3 Emergency Parameter Override
If the optimizer needs temporary override, deploy a new implementation with hardcoded safe parameters:
- CI=0, AS=30% (0.3e18), AW=100, DD=0.3e18 (bear defaults)
- These were verified safe across all 1050 parameter sweep combinations
### 6.4 Rollback Plan
**There is no rollback for deployed contracts.** Mitigation options:
- Upgrade optimizer proxy to revert to V1/V2 logic
- Revoke recenter access to freeze positions
- The LiquidityManager itself is NOT upgradeable (by design — immutable control)
- In worst case: deploy entirely new contract set, migrate liquidity
### 6.5 Known Attack Response: Floor Ratchet
If floor ratchet extraction is detected (rapid recenters + floor tick creeping toward current price):
1. **Immediately** set recenter access to burn address (`0xdEaD`) — do NOT use `revokeRecenterAccess()` as it makes recenter permissionless
2. Assess floor position state via `positions(0)`
3. Deploy patched LiquidityManager if fix is ready
4. Current mitigation: bear-mode parameters (AW=100) create 7000-tick floor distance, making ratchet extraction significantly harder
---
## 7. Monitoring Setup
### On-Chain Monitoring
Track these metrics via Ponder or direct RPC polling:
| Metric | How | Alert Threshold |
|--------|-----|-----------------|
| Floor tick distance | `positions(0).tickLower - currentTick` | < 2000 ticks |
| Recenter frequency | Count `recenter()` calls per hour | > 10/hour |
| LM ETH balance | `address(LM).balance + WETH.balanceOf(LM)` | < 1 ETH (most ETH is in pool positions) |
| VWAP drift | `getVWAP()` vs current price | > 50% divergence |
| Optimizer mode | `getLiquidityParams()` return values | Unexpected bull in low-staking |
| Fee revenue | WETH transfers to feeDestination | Sudden drop to 0 |
### Off-Chain Monitoring
- txnBot health: `GET /api/txn/status` — should return healthy
- Ponder indexing: `GET /api/graphql` — query `stats` entity
- Frontend version check: `useVersionCheck()` composable validates contract VERSION
### Alerting Triggers
1. **Critical:** Floor position liquidity = 0 (no floor protection)
2. **Critical:** recenter() reverts for > 1 hour
3. **High:** > 20 recenters in 1 hour (potential manipulation)
4. **Medium:** VWAP compression triggered (high cumulative volume)
5. **Low:** Optimizer returns bull mode (verify staking metrics justify it)
---
## 8. Deployment Timeline
| Step | Duration | Dependency |
|------|----------|------------|
| Deploy contracts | ~2 min | Funded deployer wallet |
| Verify on Basescan | ~5 min | Deployment complete |
| Fund LiquidityManager | ~1 min | Deployment complete |
| Set recenter access | ~1 min | feeDestination set (multisig) |
| Wait for TWAP history | ~5-10 min | Pool initialized |
| First recenter | ~1 min | TWAP history + recenter access |
| Deploy txnBot | ~5 min | Addresses configured |
| Deploy Ponder | ~10 min | Addresses + kraiken-lib updated |
| Deploy frontend | ~5 min | Ponder running |
| **Total** | **~30-40 min** | |

View file

@ -9,7 +9,7 @@
* Current version this library is built for.
* Should match the deployed Kraiken contract VERSION.
*/
export const KRAIKEN_LIB_VERSION = 1;
export const KRAIKEN_LIB_VERSION = 2;
/**
* Singleton ID used for stack metadata rows across services.
@ -24,8 +24,9 @@ export const STACK_META_ID = 'stack-meta';
*
* Version History:
* - v1: Initial deployment (30-tier TAX_RATES, index-based staking)
* - v2: OptimizerV3, VWAP mirror floor, directional VWAP recording
*/
export const COMPATIBLE_CONTRACT_VERSIONS = [1];
export const COMPATIBLE_CONTRACT_VERSIONS = [1, 2];
/**
* Validates if a contract version is compatible with this library.

View file

@ -125,78 +125,81 @@
</li>
</ol>
<h2>Agent Contract</h2>
<h2>Optimizer Contract</h2>
<p>
The Agent Contract serves as the execution layer for the AI agent, interfacing directly with the liquidity manager contract. It is
invoked periodically by the liquidity manager to collect input data, run the genetic algorithm, and return actionable outputs for
liquidity adjustments. The Agent Contract performs the following key functions:
The Optimizer Contract (OptimizerV3) serves as the decision layer for the AI agent, interfacing directly with the liquidity manager
contract. It is queried during every rebalance to determine how liquidity should be positioned. The Optimizer uses a direct 2D mapping
from on-chain staking sentiment to binary bear/bull configurations:
</p>
<ol>
<li>
<strong>Input Collection and Preprocessing:</strong>
<strong>Sentiment Signal Collection:</strong>
<ul>
<li>
The contract gathers all necessary data points, such as price position, volatility ratio, volume-to-liquidity ratio, percentage
staked, and average tax rate.
The contract reads two key signals directly from the Harberger staking system: the percentage of staking slots currently
utilized, and the average tax rate across all active stakers.
</li>
<li>These inputs are normalized and placed onto the stack of the Push3 Virtual Machine (VM) for efficient computation.</li>
<li>These signals together form a real-time sentiment indicator that captures community conviction.</li>
</ul>
</li>
<li>
<strong>Algorithm Loading:</strong>
<strong>Bear/Bull Classification:</strong>
<ul>
<li>The genetic algorithm, stored within the contracts state, is loaded and also placed onto the stack of the Push3 VM.</li>
<li>This setup initializes the VM for execution.</li>
<li>When staking utilization is at or below 91%, the optimizer always selects the defensive <strong>bear</strong> configuration. This represents the vast majority (~94%) of the staking state space.</li>
<li>Bull mode requires both very high staking (&gt;91%) <em>and</em> low average tax rates &mdash; a signal of genuine community euphoria rather than speculative over-bidding.</li>
<li>Any decline in staking instantly snaps the system back to bear mode, providing rapid downside protection.</li>
</ul>
</li>
<li>
<strong>Execution and Output Retrieval:</strong>
<strong>Binary Parameter Output:</strong>
<ul>
<li>The Push3 VM executes the genetic algorithm using the input parameters on the stack.</li>
<li>
The execution results in a set of outputs, which may include adjustments to liquidity parameters or a non-action signal
indicating no immediate changes are necessary.
</li>
<li><strong>Bear mode:</strong> Conservative anchor (30% of ETH, wide 100-tick range), minimal discovery. Maximizes floor protection.</li>
<li><strong>Bull mode:</strong> Aggressive anchor (100% of ETH, narrow 20-tick range), full discovery depth. Maximizes fee capture during euphoria.</li>
<li>The binary step avoids intermediate parameter ranges that were found to be exploitable during extensive fuzzing and adversarial testing.</li>
</ul>
</li>
<li>
<strong>Forwarding Outputs to Liquidity Manager:</strong>
<strong>Forwarding to Liquidity Manager:</strong>
<ul>
<li>
The outputs are forwarded to the liquidity manager contract, which applies the recommended changes to reposition liquidity if
necessary.
The outputs are forwarded to the liquidity manager contract, which applies the recommended changes to reposition liquidity
during the next rebalance.
</li>
</ul>
</li>
</ol>
<p>
By introducing the Agent Contract, the previously static liquidity manager becomes capable of real-time optimization, driven by
on-chain evolutionary computation. If you want to know how genetic algorithms work, or why the system is considered an agent, read
this <a href="https://github.com/Sovraigns/SoLUSH-3/blob/main/vision.md">vision document</a>.
The Optimizer is upgradeable via UUPS proxy pattern, allowing the sentiment-to-parameter mapping to evolve as the protocol gathers
more real-world data. However, the core Liquidity Manager remains immutable &mdash; the Optimizer can only influence <em>how</em>
liquidity is positioned, never the fundamental safety guarantees of the protocol.
</p>
<h2>Dynamic Adaptation Through AI</h2>
<h2>Dynamic Adaptation Through Sentiment</h2>
<p>
The AI agents ability to dynamically adapt parameters allows the liquidity manager to respond to market volatility, trading volume,
and user behavior in real-time. For example:
The Optimizer's ability to read staking sentiment and adapt parameters allows the liquidity manager to respond to community conviction
in real-time. The system naturally traces a cycle through three phases:
</p>
<ul>
<li>
<strong>Volatile Markets:</strong> The agent can widen Anchor and Discovery spacing to reduce exposure and maintain stability.
<strong>Bear Mode (Default):</strong> With staking below 91%, the Optimizer maintains a wide, conservative anchor and minimal
discovery. The Floor is maximally protected, and the protocol prioritizes solvency over aggressive fee capture.
</li>
<li><strong>High Volume:</strong> The agent can increase liquidity in Discovery to capture more trading fees.</li>
<li>
<strong>User Engagement:</strong> High staking utilization might lead the agent to prioritize profitability over conservatism.
<strong>Bull Transition:</strong> As staking fills past 91% with low tax rates &mdash; a signal of genuine community euphoria
&mdash; the Optimizer switches to an aggressive configuration. The narrow anchor concentrates liquidity near the current price,
maximizing trading fee revenue during periods of high activity.
</li>
<li>
<strong>Snap-Back Protection:</strong> Any decline in staking instantly reverts to bear mode. The cubic sensitivity ensures that
even a small 4-6% drop in staking triggers the transition, providing rapid downside protection.
</li>
</ul>
<p>
By replacing static configurations with adaptive intelligence, the liquidity manager evolves into a dynamic system capable of
optimizing for diverse and changing conditions. This integration enables a more resilient and efficient approach to decentralized
liquidity management, where the AI agent collaborates with stakers to form a cybernetic system. Staking signals, such as the
percentage staked and the average tax rate, provide critical real-time sentiment data that the agent uses to refine its decisions and
adapt dynamically to market behaviors.
By replacing static configurations with sentiment-driven adaptation, the liquidity manager evolves into a dynamic system that responds
to community behavior. Staking signals provide critical real-time sentiment data, creating a cybernetic feedback loop: stakers
influence liquidity positioning through their commitment, and the resulting market dynamics in turn affect staking incentives.
</p>
</div>
</template>

View file

@ -34,11 +34,12 @@
<h3>Are there any fees when using the protocol?</h3>
<p>There are no fees and there is no fee switch. Kraiken is an immutable protocol that has been fair launched and will continue so.</p>
<h3>How can I stake my $KRK?</h3>
<p>You can get access to staking by contacting the team.</p>
<p>You can stake your $KRK tokens at <a href="https://kraiken.org">kraiken.org</a> by connecting your wallet and selecting a tax rate.</p>
<h3>Can the protocol code be changed later?</h3>
<p>
No All contracts are permanently immutable after deployment. Liquidity Manager and AI agent operate autonomously with no upgrade
path or admin controls.
The core contracts (Liquidity Manager, Token, Staking) are permanently immutable after deployment with no admin controls. The
Optimizer, which determines liquidity positioning parameters based on staking sentiment, is upgradeable via a UUPS proxy to allow the
sentiment-to-parameter mapping to evolve as the protocol gathers real-world data.
</p>
<h3>Are there team allocations or unlocks?</h3>
<p>

View file

@ -10,16 +10,14 @@
<h2 id="second">Implementation in the Crypto Context</h2>
<p>
In crypto, limited assets or positions are often held by a select few without options for redistributing ownershipexamples include
early investors, protocol teams, fee distributions, multisig holders, NFT owners etc. The HARBERG protocol aims to challenge the
early investors, protocol teams, fee distributions, multisig holders, NFT owners etc. The KrAIken protocol aims to challenge the
current model of protocol ownership and test an alternative approach.
</p>
<h2 id="third">Key Differences</h2>
<p>
In the traditional model, the value of the asset is constantly changeable to reflect the current market value and dynamics. In the
HARBERG protocol, the tax rate is constantly changeable, and owners must evaluate it regularly to retain their ownership positions.
In the traditional model, the value of the asset is constantly changeable to reflect the current market value and dynamics. In
KrAIken, the tax rate is constantly changeable, and owners must evaluate it regularly to retain their ownership positions.
</p>
<h3>Read More:</h3>
<p>See <a href="website.org">Harberger Tax Blog Post</a> or <a href="website.org">Owner Tax</a></p>
</div>
</template>

View file

@ -2,28 +2,28 @@
<div>
<h1 id="first">Generate Passive Income</h1>
<p>
In traditional protocol designs, holders benefit from a protocols success only indirectly through an increase in token price. HARBERG
In traditional protocol designs, holders benefit from a protocol's success only indirectly through an increase in token price. KrAIken
changes this by allowing holders to earn directly through a passive income stream funded by protocol owners, who pay for the privilege
of staying in their positions of power.
</p>
<br />
<p>
Token holders are essential for decentralisation, governance and decision-making in crypto. Their role is key to a projects long-term
success. Its only fair that token holders should have the right to become owners of the very protocol they support.
Token holders are essential for decentralisation, governance and decision-making in crypto. Their role is key to a project's long-term
success. It's only fair that token holders should have the right to become owners of the very protocol they support.
</p>
<h2 id="second">Buy $HARB</h2>
<h2 id="second">Buy $KRK</h2>
<p>
$HARB tokens and the Harberg Protocol are deployed on Base an $ETHeum Layer 2. $HARB can be bought on
<a href="uniswap.org">Uniswap</a> there.
$KRK tokens and the KrAIken Protocol are deployed on Base, an Ethereum Layer 2. $KRK can be bought on
<a href="https://uniswap.org">Uniswap</a> there.
</p>
<h2 id="third">Passive Income</h2>
<p>
By holding $HARB, holders can claim a passive income funded by the owners tax. The more owners are competing for limited owner slots
By holding $KRK, holders can claim a passive income funded by the owner's tax. The more owners are competing for limited owner slots
the higher the tax for the token holders.
</p>
<p>The longer the token is held, the more tax holders can claim. No protocol fee is taken.</p>
<h2 id="fourth">Sell $HARB</h2>
<p>$HARB can be sold anytime on <a href="uniswap.org">Uniswap</a></p>
<h2 id="fourth">Sell $KRK</h2>
<p>$KRK can be sold anytime on <a href="https://uniswap.org">Uniswap</a></p>
</div>
</template>

View file

@ -64,21 +64,28 @@
A critical aspect of the Floor position is its use of Volume Weighted Average Price (VWAP) to determine its pricing strategy. VWAP
represents the average sale price of $KRK tokens weighted by trading volume, providing a kind of approximate and compressed memory
over historic sales of tokens from its liquidity. The LM calculates the VWAP using cumulative trade data, ensuring that on average
tokens are bought back for cheaper than they were sold for. By anchoring the protocols liquidity to a VWAP-adjusted price, the system
tokens are bought back for cheaper than they were sold for. By anchoring the protocol's liquidity to a VWAP-adjusted price, the system
retains a sober approach to Floor positioning while allowing for market-responsive adjustments of Anchor and Discovery.
</p>
<p>
The VWAP also serves as a <strong>dormant whale defense</strong>. Because the VWAP only records prices during buying activity (ETH
inflow), an attacker who sells heavily cannot dilute the VWAP downward. The Floor uses the VWAP as a mirror &mdash; during sell
pressure, the growing distance between the current price and the VWAP automatically pushes the Floor further away, making it
progressively harder to drain. This "price memory" mechanism provides passive protection against coordinated sell attacks without
requiring any external intervention.
</p>
<h2>Anchor Position</h2>
<p>
The Anchor position offers less liquidity depth than the Floor but spans a broader price range. It is always positioned at the current
market price, ensuring its relevance to active trading. The size of the Anchor position dynamically adjusts based on the "sentiment"
input, a value generated by an on-chain AI agent analyzing staking signals and trading data. Sentiment determines the allocation of 5%
to 25% of the total $ETH supply to the Anchor, influencing its liquidity depth and responsiveness. This adaptive sizing strategy puts
more capital at risk during market uptrends while adopting a defensive posture when sentiment turns negative. The exact adjustment
strategy depends on the training embedded in the on-chain AI agent, which targets developing the token price and maximizing trading
fees for the protocol. For more on staking signals, visit the "For Owners" chapter, and for details about the AI agent, see the
"AI-Agent" chapter.
market price, ensuring its relevance to active trading. The size and width of the Anchor adapt based on the market regime determined by
the Optimizer, which reads Harberger staking sentiment to classify conditions as <strong>bear</strong> or <strong>bull</strong>. In
bear mode, the Anchor is conservative (30% of ETH, wide range) to minimize exposure. In bull mode, triggered only when staking
signals indicate genuine community euphoria, the Anchor becomes aggressive (up to 100% of ETH, narrow range) to maximize fee capture.
This adaptive strategy puts more capital at risk during confirmed uptrends while maintaining a defensive posture by default. For more
on staking signals, visit the "Staking" chapter, and for details about the Optimizer, see the "AI-Agent" chapter.
</p>
<h2>Discovery Position</h2>

View file

@ -66,7 +66,7 @@
<div class="concept-block">
<h2>2. Harberger Tax Mechanism</h2>
<p>
To keep things fair and transparent, stakers need to pay a sel-assessed tax on their position. Inspired by the
To keep things fair and transparent, stakers need to pay a self-assessed tax on their position. Inspired by the
<a href="https://en.wikipedia.org/wiki/Harberger_tax" target="_blank">Harberger tax concept</a> but adapted for crypto:
</p>
@ -125,7 +125,7 @@
</ul>
<p class="warning">
Key Insight: Losing a postion due to a buyout means lossing the benefit of <em>future</em> earnings from that stake, not existing
Key Insight: Losing a position due to a buyout means losing the benefit of <em>future</em> earnings from that stake, not existing
tokens or profit.
</p>
</div>

View file

@ -24,11 +24,14 @@ contract Kraiken is ERC20, ERC20Permit {
*
* Version History:
* - v1: Initial deployment with 30-tier TAX_RATES
* - v2: OptimizerV3, VWAP mirror floor, directional VWAP recording
*/
uint256 public constant VERSION = 1;
uint256 public constant VERSION = 2;
// Minimum fraction of the total supply required for staking to prevent fragmentation of staking positions
uint256 private constant MIN_STAKE_FRACTION = 3000;
// Address authorized to call one-time setters (prevents frontrunning)
address private immutable deployer;
// Address of the liquidity manager
address private liquidityManager;
// Address of the staking pool
@ -52,7 +55,9 @@ contract Kraiken is ERC20, ERC20Permit {
* @param name_ The name of the token
* @param symbol_ The symbol of the token
*/
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) ERC20Permit(name_) { }
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) ERC20Permit(name_) {
deployer = msg.sender;
}
/**
* @notice Sets the address for the liquidityManager. Used once post-deployment to initialize the contract.
@ -61,6 +66,7 @@ contract Kraiken is ERC20, ERC20Permit {
* @param liquidityManager_ The address of the liquidity manager.
*/
function setLiquidityManager(address liquidityManager_) external {
require(msg.sender == deployer, "only deployer");
if (address(0) == liquidityManager_) revert ZeroAddressInSetter();
if (liquidityManager != address(0)) revert AddressAlreadySet();
liquidityManager = liquidityManager_;
@ -73,6 +79,7 @@ contract Kraiken is ERC20, ERC20Permit {
* @param stakingPool_ The address of the staking pool.
*/
function setStakingPool(address stakingPool_) external {
require(msg.sender == deployer, "only deployer");
if (address(0) == stakingPool_) revert ZeroAddressInSetter();
if (stakingPool != address(0)) revert AddressAlreadySet();
stakingPool = stakingPool_;

View file

@ -3,15 +3,15 @@ pragma solidity ^0.8.19;
import { Kraiken } from "./Kraiken.sol";
import { Optimizer } from "./Optimizer.sol";
import "./abstracts/PriceOracle.sol";
import "./abstracts/ThreePositionStrategy.sol";
import "./interfaces/IWETH9.sol";
import "@aperture/uni-v3-lib/CallbackValidation.sol";
import "@aperture/uni-v3-lib/PoolAddress.sol";
import "@openzeppelin/token/ERC20/IERC20.sol";
import "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import "@uniswap-v3-periphery/libraries/PositionKey.sol";
import { PriceOracle } from "./abstracts/PriceOracle.sol";
import { ThreePositionStrategy } from "./abstracts/ThreePositionStrategy.sol";
import { IWETH9 } from "./interfaces/IWETH9.sol";
import { CallbackValidation } from "@aperture/uni-v3-lib/CallbackValidation.sol";
import { PoolAddress, PoolKey } from "@aperture/uni-v3-lib/PoolAddress.sol";
import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/token/ERC20/utils/SafeERC20.sol";
import { IUniswapV3Pool } from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol";
import { PositionKey } from "@uniswap-v3-periphery/libraries/PositionKey.sol";
/**
* @title LiquidityManager
@ -29,6 +29,8 @@ import "@uniswap-v3-periphery/libraries/PositionKey.sol";
* - Prevents oracle manipulation attacks
*/
contract LiquidityManager is ThreePositionStrategy, PriceOracle {
using SafeERC20 for IERC20;
/// @notice Uniswap V3 fee tier (1%) - 10,000 basis points
uint24 internal constant FEE = uint24(10_000);
@ -42,9 +44,21 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
PoolKey private poolKey;
/// @notice Access control and fee management
address private immutable deployer;
address public recenterAccess;
address public feeDestination;
/// @notice Last recenter tick used to determine net trade direction between recenters
int24 public lastRecenterTick;
/// @notice Last recenter timestamp rate limits open (permissionless) recenters
uint256 public lastRecenterTime;
/// @notice Minimum seconds between open recenters (when recenterAccess is unset)
uint256 internal constant MIN_RECENTER_INTERVAL = 60;
/// @notice Emitted on each successful recenter for monitoring and indexing
event Recentered(int24 indexed currentTick, bool indexed isUp);
/// @notice Custom errors
error ZeroAddressInSetter();
error AddressAlreadySet();
@ -61,6 +75,7 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
/// @param _kraiken The address of the Kraiken token contract
/// @param _optimizer The address of the optimizer contract
constructor(address _factory, address _WETH9, address _kraiken, address _optimizer) {
deployer = msg.sender;
factory = _factory;
weth = IWETH9(_WETH9);
poolKey = PoolAddress.getPoolKey(_WETH9, _kraiken, FEE);
@ -90,13 +105,14 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
}
// Transfer tokens to pool
if (amount0Owed > 0) IERC20(poolKey.token0).transfer(msg.sender, amount0Owed);
if (amount1Owed > 0) IERC20(poolKey.token1).transfer(msg.sender, amount1Owed);
if (amount0Owed > 0) IERC20(poolKey.token0).safeTransfer(msg.sender, amount0Owed);
if (amount1Owed > 0) IERC20(poolKey.token1).safeTransfer(msg.sender, amount1Owed);
}
/// @notice Sets the fee destination address (can only be called once)
/// @notice Sets the fee destination address (can only be called once, deployer only)
/// @param feeDestination_ The address that will receive trading fees
function setFeeDestination(address feeDestination_) external {
require(msg.sender == deployer, "only deployer");
if (address(0) == feeDestination_) revert ZeroAddressInSetter();
if (feeDestination != address(0)) revert AddressAlreadySet();
feeDestination = feeDestination_;
@ -122,8 +138,10 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
if (recenterAccess != address(0)) {
require(msg.sender == recenterAccess, "access denied");
} else {
require(block.timestamp >= lastRecenterTime + MIN_RECENTER_INTERVAL, "recenter cooldown");
require(_isPriceStable(currentTick), "price deviated from oracle");
}
lastRecenterTime = block.timestamp;
// Check if price movement is sufficient for recentering
isUp = false;
@ -138,7 +156,22 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
}
// Remove all existing positions and collect fees
_scrapePositions();
// Pass tick direction to determine if VWAP should record (ETH inflow only)
bool shouldRecordVWAP;
if (cumulativeVolume == 0) {
// No VWAP data yet always bootstrap to prevent vwapX96=0 fallback
shouldRecordVWAP = true;
} else if (lastRecenterTick != 0) {
// token0isWeth: tick DOWN = price up in KRK terms = buys = ETH inflow
// !token0isWeth: tick UP = price up in KRK terms = buys = ETH inflow
shouldRecordVWAP = token0isWeth ? (currentTick < lastRecenterTick) : (currentTick > lastRecenterTick);
} else {
// First recenter no reference point, record conservatively
shouldRecordVWAP = true;
}
lastRecenterTick = currentTick;
_scrapePositions(shouldRecordVWAP);
// Update total supply tracking if price moved up
if (isUp) {
@ -157,20 +190,23 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
_setPositions(currentTick, params);
} catch {
// Fallback to default parameters if optimizer fails
// Fallback to safe bear-mode defaults if optimizer fails
PositionParams memory defaultParams = PositionParams({
capitalInefficiency: 5 * 10 ** 17, // 50%
anchorShare: 5 * 10 ** 17, // 50%
anchorWidth: 50, // 50%
discoveryDepth: 5 * 10 ** 17 // 50%
capitalInefficiency: 0, // CI=0 proven safest
anchorShare: 3e17, // 30% defensive floor allocation
anchorWidth: 100, // Max width avoids AW 30-90 kill zone
discoveryDepth: 3e17 // 0.3e18
});
_setPositions(currentTick, defaultParams);
}
emit Recentered(currentTick, isUp);
}
/// @notice Removes all positions and collects fees
function _scrapePositions() internal {
/// @param recordVWAP Whether to record VWAP (only when net ETH inflow since last recenter)
function _scrapePositions(bool recordVWAP) internal {
uint256 fee0 = 0;
uint256 fee1 = 0;
uint256 currentPrice;
@ -197,21 +233,23 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
}
// Transfer fees and record volume for VWAP
// Only record VWAP when net ETH inflow (KRK sold out) prevents sell-back
// activity from diluting the price memory of original KRK distribution
if (fee0 > 0) {
if (token0isWeth) {
IERC20(address(weth)).transfer(feeDestination, fee0);
_recordVolumeAndPrice(currentPrice, fee0);
IERC20(address(weth)).safeTransfer(feeDestination, fee0);
if (recordVWAP) _recordVolumeAndPrice(currentPrice, fee0);
} else {
IERC20(address(kraiken)).transfer(feeDestination, fee0);
IERC20(address(kraiken)).safeTransfer(feeDestination, fee0);
}
}
if (fee1 > 0) {
if (token0isWeth) {
IERC20(address(kraiken)).transfer(feeDestination, fee1);
IERC20(address(kraiken)).safeTransfer(feeDestination, fee1);
} else {
IERC20(address(weth)).transfer(feeDestination, fee1);
_recordVolumeAndPrice(currentPrice, fee1);
IERC20(address(weth)).safeTransfer(feeDestination, fee1);
if (recordVWAP) _recordVolumeAndPrice(currentPrice, fee1);
}
}
}
@ -255,7 +293,17 @@ contract LiquidityManager is ThreePositionStrategy, PriceOracle {
}
/// @notice Implementation of abstract function from ThreePositionStrategy
/// @dev Subtracts KRK at feeDestination (protocol revenue) and stakingPool (locked in staking)
/// since neither can be sold into the floor only trader-held KRK matters for scarcity
function _getOutstandingSupply() internal view override returns (uint256) {
return kraiken.outstandingSupply();
uint256 supply = kraiken.outstandingSupply();
if (feeDestination != address(0)) {
supply -= kraiken.balanceOf(feeDestination);
}
(, address stakingPoolAddr) = kraiken.peripheryContracts();
if (stakingPoolAddr != address(0)) {
supply -= kraiken.balanceOf(stakingPoolAddr);
}
return supply;
}
}

131
onchain/src/OptimizerV2.sol Normal file
View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { Kraiken } from "./Kraiken.sol";
import { Stake } from "./Stake.sol";
import { Math } from "@openzeppelin/utils/math/Math.sol";
import { Initializable } from "@openzeppelin/proxy/utils/Initializable.sol";
import { UUPSUpgradeable } from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
/**
* @title OptimizerV2
* @notice Sentiment-driven liquidity parameter optimizer based on empirical fuzzing results.
* @dev Replaces the original Optimizer with a mapping informed by adversarial analysis:
*
* Key findings from parameter sweep + Gaussian competition model:
*
* 1. capitalInefficiency has ZERO effect on fee revenue. It only affects floor placement.
* Always set to 0 for maximum safety (adjusted VWAP = 0.7× furthest floor).
*
* 2. anchorShare and anchorWidth are the ONLY fee levers:
* - Bull: AS=100%, AW=20 deep narrow anchor maximizes KRK fees (which appreciate)
* - Bear: AS=10%, AW=100 thin wide anchor maximizes WETH fees + safe floor distance
*
* 3. The two regimes naturally align safety with fee optimization:
* - Bearish config is also the safest against drain attacks (AW=100 7000 tick clamp)
* - Bullish config maximizes revenue when floor safety is least needed
*
* Staking sentiment drives the interpolation:
* - High staking % + low tax rate bullish (sentiment=0) aggressive fee capture
* - Low staking % + high tax rate bearish (sentiment=1e18) defensive positioning
*/
contract OptimizerV2 is Initializable, UUPSUpgradeable {
Kraiken private kraiken;
Stake private stake;
/// @dev Reverts if the caller is not the admin.
error UnauthorizedAccount(address account);
function initialize(address _kraiken, address _stake) public initializer {
_changeAdmin(msg.sender);
kraiken = Kraiken(_kraiken);
stake = Stake(_stake);
}
modifier onlyAdmin() {
_checkAdmin();
_;
}
function _checkAdmin() internal view virtual {
if (_getAdmin() != msg.sender) {
revert UnauthorizedAccount(msg.sender);
}
}
function _authorizeUpgrade(address newImplementation) internal override onlyAdmin { }
/**
* @notice Calculates sentiment from staking metrics.
* @dev Reuses the V1 sentiment formula for continuity.
* sentiment = 0 bullish, sentiment = 1e18 bearish.
*/
function calculateSentiment(uint256 averageTaxRate, uint256 percentageStaked) public pure returns (uint256 sentimentValue) {
require(percentageStaked <= 1e18, "Invalid percentage staked");
uint256 deltaS = 1e18 - percentageStaked;
if (percentageStaked > 92e16) {
uint256 penalty = (deltaS * deltaS * deltaS * averageTaxRate) / (20 * 1e48);
sentimentValue = penalty / 2;
} else {
uint256 scaledStake = (percentageStaked * 1e18) / (92e16);
uint256 baseSentiment = scaledStake >= 1e18 ? 0 : 1e18 - scaledStake;
if (averageTaxRate <= 1e16) {
sentimentValue = baseSentiment;
} else if (averageTaxRate <= 5e16) {
uint256 ratePenalty = ((averageTaxRate - 1e16) * baseSentiment) / (4e16);
sentimentValue = baseSentiment > ratePenalty ? baseSentiment - ratePenalty : 0;
} else {
sentimentValue = 1e18;
}
}
}
function getSentiment() external view returns (uint256 sentiment) {
uint256 percentageStaked = stake.getPercentageStaked();
uint256 averageTaxRate = stake.getAverageTaxRate();
sentiment = calculateSentiment(averageTaxRate, percentageStaked);
}
/**
* @notice Returns liquidity parameters driven by staking sentiment.
*
* @return capitalInefficiency Always 0 maximizes floor safety with no fee cost.
* @return anchorShare sqrt-scaled: bull(0)=100% bear(1e18)=10%.
* @return anchorWidth sqrt-scaled: bull(0)=20 bear(1e18)=100.
* @return discoveryDepth Interpolated with sentiment (unchanged from V1).
*
* @dev Uses square-root response curve for ASYMMETRIC transitions:
* - Slow ramp to bull: requires sustained high staking to reach aggressive params
* - Fast snap to bear: small drops in staking cause large safety jumps
* Makes staking manipulation expensive: attacker must maintain >90% staking.
*/
function getLiquidityParams() external view returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) {
uint256 percentageStaked = stake.getPercentageStaked();
uint256 averageTaxRate = stake.getAverageTaxRate();
uint256 sentiment = calculateSentiment(averageTaxRate, percentageStaked);
if (sentiment > 1e18) sentiment = 1e18;
// CI = 0 always. No fee impact, maximum floor safety.
capitalInefficiency = 0;
// sqrt(sentiment) for aggressive bear transition:
// sentiment=2.2% (staking=90%) sqrtS=14.8% already shifting defensive
// sentiment=13% (staking=80%) sqrtS=36% well into defensive
// sentiment=100% (staking=0%) sqrtS=100% full bear
uint256 sqrtS = Math.sqrt(sentiment * 1e18);
// sqrtS is now in range [0, 1e18]. Scale to match sentiment range.
// AS: 100% (bull) 10% (bear), sqrt-scaled
anchorShare = 1e18 - (sqrtS * 90 / 100);
// AW: 20 (bull) 100 (bear), sqrt-scaled
anchorWidth = uint24(20 + (sqrtS * 80 / 1e18));
// DD: keep sentiment-driven (V1 behavior)
discoveryDepth = sentiment;
}
}

191
onchain/src/OptimizerV3.sol Normal file
View file

@ -0,0 +1,191 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { Kraiken } from "./Kraiken.sol";
import { Stake } from "./Stake.sol";
import { Initializable } from "@openzeppelin/proxy/utils/Initializable.sol";
import { UUPSUpgradeable } from "@openzeppelin/proxy/utils/UUPSUpgradeable.sol";
/**
* @title OptimizerV3
* @notice Direct 2D (staking%, avgTax) binary bear/bull liquidity optimizer.
* @dev Replaces the three-zone score-based model with a direct mapping:
*
* staked 91% BEAR always (no euphoria signal)
* staked > 91% BULL if deltaS³ × effIdx / 20 < 50, else BEAR
*
* where deltaS = 100 - stakedPct, effIdx = min(29, taxIdx + (taxIdx >= 14 ? 1 : 0))
*
* Bear: AS=30%, AW=100, CI=0, DD=0.3e18
* Bull: AS=100%, AW=20, CI=0, DD=1e18
*
* The binary step avoids the AW 30-90 kill zone where intermediate params are exploitable.
* CI = 0 always (proven to have zero effect on fee revenue).
*
* Bull requires >91% staked with low enough tax. Any decline instant snap to bear.
*/
contract OptimizerV3 is Initializable, UUPSUpgradeable {
Kraiken private kraiken;
Stake private stake;
/// @dev Reserved storage gap for future upgrades (50 slots total: 2 used + 48 reserved)
uint256[48] private __gap;
/// @dev Reverts if the caller is not the admin.
error UnauthorizedAccount(address account);
/// @notice Initializes the proxy with Kraiken and Stake contract references.
/// @param _kraiken The Kraiken token contract address
/// @param _stake The Stake contract address
function initialize(address _kraiken, address _stake) public initializer {
_changeAdmin(msg.sender);
kraiken = Kraiken(_kraiken);
stake = Stake(_stake);
}
modifier onlyAdmin() {
_checkAdmin();
_;
}
function _checkAdmin() internal view virtual {
if (_getAdmin() != msg.sender) {
revert UnauthorizedAccount(msg.sender);
}
}
function _authorizeUpgrade(address newImplementation) internal override onlyAdmin { }
/**
* @notice Maps a normalized average tax rate (0-1e18) to an effective tax rate index.
* @dev The Stake contract normalizes: averageTaxRate = rawRate * 1e18 / MAX_TAX_RATE.
* We compare against pre-computed normalized midpoints between adjacent TAX_RATES
* to find the closest index, avoiding double-truncation from denormalization.
*
* The effective index has a +1 shift at position 14 to account for the
* non-uniform spacing in the TAX_RATES array (130% 180% is a 38% jump),
* capped at 29.
*/
function _taxRateToEffectiveIndex(uint256 averageTaxRate) internal pure returns (uint256) {
// Pre-computed normalized midpoints between adjacent TAX_RATES:
// midpoint_norm = ((TAX_RATES[i] + TAX_RATES[i+1]) / 2) * 1e18 / 9700
// Using these directly avoids integer truncation from denormalization.
uint256 idx;
if (averageTaxRate <= 206_185_567_010_309) idx = 0; // midpoint(1,3)
else if (averageTaxRate <= 412_371_134_020_618) idx = 1; // midpoint(3,5)
else if (averageTaxRate <= 618_556_701_030_927) idx = 2; // midpoint(5,8)
else if (averageTaxRate <= 1_030_927_835_051_546) idx = 3; // midpoint(8,12)
else if (averageTaxRate <= 1_546_391_752_577_319) idx = 4; // midpoint(12,18)
else if (averageTaxRate <= 2_164_948_453_608_247) idx = 5; // midpoint(18,24)
else if (averageTaxRate <= 2_783_505_154_639_175) idx = 6; // midpoint(24,30)
else if (averageTaxRate <= 3_608_247_422_680_412) idx = 7; // midpoint(30,40)
else if (averageTaxRate <= 4_639_175_257_731_958) idx = 8; // midpoint(40,50)
else if (averageTaxRate <= 5_670_103_092_783_505) idx = 9; // midpoint(50,60)
else if (averageTaxRate <= 7_216_494_845_360_824) idx = 10; // midpoint(60,80)
else if (averageTaxRate <= 9_278_350_515_463_917) idx = 11; // midpoint(80,100)
else if (averageTaxRate <= 11_855_670_103_092_783) idx = 12; // midpoint(100,130)
else if (averageTaxRate <= 15_979_381_443_298_969) idx = 13; // midpoint(130,180)
else if (averageTaxRate <= 22_164_948_453_608_247) idx = 14; // midpoint(180,250)
else if (averageTaxRate <= 29_381_443_298_969_072) idx = 15; // midpoint(250,320)
else if (averageTaxRate <= 38_144_329_896_907_216) idx = 16; // midpoint(320,420)
else if (averageTaxRate <= 49_484_536_082_474_226) idx = 17; // midpoint(420,540)
else if (averageTaxRate <= 63_917_525_773_195_876) idx = 18; // midpoint(540,700)
else if (averageTaxRate <= 83_505_154_639_175_257) idx = 19; // midpoint(700,920)
else if (averageTaxRate <= 109_278_350_515_463_917) idx = 20; // midpoint(920,1200)
else if (averageTaxRate <= 144_329_896_907_216_494) idx = 21; // midpoint(1200,1600)
else if (averageTaxRate <= 185_567_010_309_278_350) idx = 22; // midpoint(1600,2000)
else if (averageTaxRate <= 237_113_402_061_855_670) idx = 23; // midpoint(2000,2600)
else if (averageTaxRate <= 309_278_350_515_463_917) idx = 24; // midpoint(2600,3400)
else if (averageTaxRate <= 402_061_855_670_103_092) idx = 25; // midpoint(3400,4400)
else if (averageTaxRate <= 520_618_556_701_030_927) idx = 26; // midpoint(4400,5700)
else if (averageTaxRate <= 680_412_371_134_020_618) idx = 27; // midpoint(5700,7500)
else if (averageTaxRate <= 886_597_938_144_329_896) idx = 28; // midpoint(7500,9700)
else idx = 29;
// Apply effective index shift: +1 at idx >= 14, capped at 29
if (idx >= 14) {
idx = idx + 1;
if (idx > 29) idx = 29;
}
return idx;
}
/**
* @notice Determines if the market is in bull configuration.
* @param percentageStaked Percentage of authorized stake in use (0 to 1e18).
* @param averageTaxRate Normalized average tax rate from Stake contract (0 to 1e18).
* @return bull True if bull config, false if bear.
*
* @dev Direct 2D mapping no intermediate score:
* staked 91% always bear (no euphoria signal)
* staked > 91% bull if deltaS³ × effIdx / 20 < 50
* where deltaS = 100 - stakedPct (integer percentage)
*/
function isBullMarket(uint256 percentageStaked, uint256 averageTaxRate) public pure returns (bool bull) {
require(percentageStaked <= 1e18, "Invalid percentage staked");
uint256 stakedPct = percentageStaked * 100 / 1e18; // 0-100
if (stakedPct <= 91) return false;
uint256 deltaS = 100 - stakedPct; // 0-8
uint256 effIdx = _taxRateToEffectiveIndex(averageTaxRate);
uint256 penalty = deltaS * deltaS * deltaS * effIdx / 20;
return penalty < 50;
}
/**
* @notice Returns liquidity parameters driven by the direct 2D stakingconfig mapping.
*
* @return capitalInefficiency Always 0 proven to have zero effect on fee revenue.
* @return anchorShare Bear=30% (0.3e18), Bull=100% (1e18).
* @return anchorWidth Bear=100, Bull=20.
* @return discoveryDepth Bear=0.3e18, Bull=1e18.
*/
function getLiquidityParams() external view returns (uint256 capitalInefficiency, uint256 anchorShare, uint24 anchorWidth, uint256 discoveryDepth) {
uint256 percentageStaked = stake.getPercentageStaked();
uint256 averageTaxRate = stake.getAverageTaxRate();
capitalInefficiency = 0;
if (isBullMarket(percentageStaked, averageTaxRate)) {
anchorShare = 1e18; // 100%
anchorWidth = 20;
discoveryDepth = 1e18;
} else {
anchorShare = 3e17; // 30%
anchorWidth = 100;
discoveryDepth = 3e17; // 0.3e18
}
}
}

View file

@ -22,10 +22,9 @@ abstract contract VWAPTracker {
/**
* @notice Records volume and price data for VWAP calculation
* @param currentPriceX96 The current price in X96 format (actually price² from _priceAtTick)
* @param currentPriceX96 The current price in Q96 format (price * 2^96, from _priceAtTick)
* @param fee The fee amount used to calculate volume
* @dev Assumes fee represents 1% of volume, handles overflow by compressing historic data
* @dev IMPORTANT: currentPriceX96 is expected to be price² (squared price), not regular price
*/
function _recordVolumeAndPrice(uint256 currentPriceX96, uint256 fee) internal {
// assuming FEE is 1%

View file

@ -34,10 +34,11 @@ abstract contract PriceOracle {
averageTick = int24(tickCumulativeDiff / int56(int32(PRICE_STABILITY_INTERVAL)));
} catch {
// Fallback to longer timeframe if recent data unavailable
secondsAgo[0] = PRICE_STABILITY_INTERVAL * 200;
uint32 fallbackInterval = PRICE_STABILITY_INTERVAL * 200; // 60,000 seconds
secondsAgo[0] = fallbackInterval;
(int56[] memory tickCumulatives,) = pool.observe(secondsAgo);
int56 tickCumulativeDiff = tickCumulatives[1] - tickCumulatives[0];
averageTick = int24(tickCumulativeDiff / int56(int32(PRICE_STABILITY_INTERVAL)));
averageTick = int24(tickCumulativeDiff / int56(int32(fallbackInterval)));
}
isStable = (currentTick >= averageTick - MAX_TICK_DEVIATION && currentTick <= averageTick + MAX_TICK_DEVIATION);

View file

@ -56,11 +56,14 @@ abstract contract ThreePositionStrategy is UniswapMath, VWAPTracker {
/// @notice Storage for the three positions
mapping(Stage => TokenPosition) public positions;
/// @notice Deprecated was floor high-water mark. Kept for storage layout compatibility.
int24 public __deprecated_floorHighWaterMark;
/// @notice Events for tracking ETH abundance/scarcity scenarios
event EthScarcity(int24 currentTick, uint256 ethBalance, uint256 outstandingSupply, uint256 vwap, int24 vwapTick);
event EthAbundance(int24 currentTick, uint256 ethBalance, uint256 outstandingSupply, uint256 vwap, int24 vwapTick);
/// @notice Abstract functions that must be implemented by inheriting contracts
function _getKraikenToken() internal view virtual returns (address);
function _getWethToken() internal view virtual returns (address);
function _isToken0Weth() internal view virtual returns (bool);
@ -188,43 +191,10 @@ abstract contract ThreePositionStrategy is UniswapMath, VWAPTracker {
outstandingSupply -= pulledKraiken;
outstandingSupply -= (outstandingSupply >= discoveryAmount) ? discoveryAmount : outstandingSupply;
// Use VWAP for floor position (historical price memory for dormant whale protection)
uint256 vwapX96 = getAdjustedVWAP(params.capitalInefficiency);
uint256 ethBalance = _getEthBalance();
int24 vwapTick;
if (vwapX96 > 0) {
// vwapX96 is price² in X96 format, need to convert to regular price
// price = sqrt(price²) = sqrt(vwapX96) * 2^48 / 2^96 = sqrt(vwapX96) / 2^48
uint256 sqrtVwapX96 = Math.sqrt(vwapX96) << 48; // sqrt(price²) in X96 format
uint256 requiredEthForBuyback = outstandingSupply.mulDiv(sqrtVwapX96, (1 << 96));
if (floorEthBalance < requiredEthForBuyback) {
// ETH scarcity: not enough ETH to buy back at VWAP price
uint256 balancedCapital = (7 * outstandingSupply / 10) + (outstandingSupply * params.capitalInefficiency / 10 ** 18);
vwapTick = _tickAtPrice(token0isWeth, balancedCapital, floorEthBalance);
emit EthScarcity(currentTick, ethBalance, outstandingSupply, vwapX96, vwapTick);
} else {
// ETH abundance: sufficient ETH reserves
// vwapX96 is price² in X96 format, need to convert to regular price in X64 format
// price = sqrt(price²), then convert from X96 to X64 by >> 32
uint256 sqrtVwapX96Abundance = Math.sqrt(vwapX96) << 48; // sqrt(price²) in X96 format
vwapTick = _tickAtPriceRatio(int128(int256(sqrtVwapX96Abundance >> 32)));
vwapTick = token0isWeth ? -vwapTick : vwapTick;
emit EthAbundance(currentTick, ethBalance, outstandingSupply, vwapX96, vwapTick);
}
} else {
// No VWAP data available, use current tick
vwapTick = currentTick;
}
// Ensure floor doesn't overlap with anchor position
int24 anchorSpacing = TICK_SPACING + (34 * int24(params.anchorWidth) * TICK_SPACING / 100);
if (token0isWeth) {
vwapTick = (vwapTick < currentTick + anchorSpacing) ? currentTick + anchorSpacing : vwapTick;
} else {
vwapTick = (vwapTick > currentTick - anchorSpacing) ? currentTick - anchorSpacing : vwapTick;
}
// Floor placement: max of (scarcity, VWAP mirror, clamp) toward KRK-cheap side.
// VWAP mirror uses distance from VWAP as floor distance during selling, price moves
// away from VWAP so floor retreats automatically. No sell-pressure detection needed.
int24 vwapTick = _computeFloorTick(currentTick, floorEthBalance, outstandingSupply, token0isWeth, params);
// Normalize and create floor position
vwapTick = _clampToTickSpacing(vwapTick, TICK_SPACING);
@ -246,4 +216,54 @@ abstract contract ThreePositionStrategy is UniswapMath, VWAPTracker {
_mintPosition(Stage.FLOOR, token0isWeth ? vwapTick : floorTick, token0isWeth ? floorTick : vwapTick, liquidity);
}
/// @notice Computes floor tick from three signals: scarcity, VWAP mirror, and anti-overlap clamp.
/// @dev Takes the one furthest into KRK-cheap territory (highest tick when token0isWeth, lowest when not).
function _computeFloorTick(
int24 currentTick,
uint256 floorEthBalance,
uint256 outstandingSupply,
bool token0isWeth,
PositionParams memory params
)
internal
view
returns (int24 floorTarget)
{
// 1. Scarcity tick: at what price can our ETH buy back the adjusted supply?
uint256 balancedCapital = (7 * outstandingSupply / 10) + (outstandingSupply * params.capitalInefficiency / 10 ** 18);
int24 scarcityTick = currentTick;
if (outstandingSupply > 0 && floorEthBalance > 0) {
scarcityTick = _tickAtPrice(token0isWeth, balancedCapital, floorEthBalance);
}
// 2. Mirror tick: VWAP distance mirrored to KRK-cheap side
// Uses adjusted VWAP (CI controls distance CI is the risk lever).
int24 mirrorTick = currentTick;
{
uint256 vwapX96 = getAdjustedVWAP(params.capitalInefficiency);
if (vwapX96 > 0) {
int24 rawVwapTick = _tickAtPriceRatio(int128(int256(vwapX96 >> 32)));
rawVwapTick = token0isWeth ? -rawVwapTick : rawVwapTick;
int24 vwapDistance = currentTick - rawVwapTick;
if (vwapDistance < 0) vwapDistance = -vwapDistance;
mirrorTick = token0isWeth ? currentTick + vwapDistance : currentTick - vwapDistance;
}
}
// 3. Clamp tick: minimum distance (anti-overlap with anchor)
int24 anchorSpacing = TICK_SPACING + (34 * int24(params.anchorWidth) * TICK_SPACING / 100);
int24 clampTick = token0isWeth ? currentTick + anchorSpacing : currentTick - anchorSpacing;
// Take the one furthest into KRK-cheap territory
if (token0isWeth) {
floorTarget = scarcityTick;
if (mirrorTick > floorTarget) floorTarget = mirrorTick;
if (clampTick > floorTarget) floorTarget = clampTick;
} else {
floorTarget = scarcityTick;
if (mirrorTick < floorTarget) floorTarget = mirrorTick;
if (clampTick < floorTarget) floorTarget = clampTick;
}
}
}

View file

@ -42,12 +42,11 @@ abstract contract UniswapMath {
tick_ = TickMath.getTickAtSqrtRatio(sqrtPriceX96);
}
/// @notice Calculates the price ratio from a given Uniswap V3 tick as KRAIKEN/ETH
/// @dev IMPORTANT: Returns price² (squared price) in X96 format, NOT regular price
/// This is intentional for capital requirement calculations
/// To get regular price: sqrt(priceRatioX96) * 2^48
/// @notice Calculates the price ratio from a given Uniswap V3 tick
/// @dev Returns price (token1/token0) in Q96 format: price * 2^96
/// Computed as sqrtRatioX96² / 2^96 = (sqrt(price) * 2^96)² / 2^96 = price * 2^96
/// @param tick The tick for which to calculate the price ratio
/// @return priceRatioX96 The price² (squared price) corresponding to the given tick
/// @return priceRatioX96 The price ratio in Q96 format
function _priceAtTick(int24 tick) internal pure returns (uint256 priceRatioX96) {
uint256 sqrtRatioX96 = TickMath.getSqrtRatioAtTick(tick);
priceRatioX96 = sqrtRatioX96.mulDiv(sqrtRatioX96, (1 << 96));

View file

@ -0,0 +1,480 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { SwapExecutor } from "../analysis/helpers/SwapExecutor.sol";
import { Kraiken } from "../src/Kraiken.sol";
import { LiquidityManager } from "../src/LiquidityManager.sol";
import { ThreePositionStrategy } from "../src/abstracts/ThreePositionStrategy.sol";
import { UniswapHelpers } from "../src/helpers/UniswapHelpers.sol";
import { IWETH9 } from "../src/interfaces/IWETH9.sol";
import { TestEnvironment } from "./helpers/TestBase.sol";
import { ConfigurableOptimizer } from "./mocks/ConfigurableOptimizer.sol";
import { IUniswapV3Factory } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
import { IUniswapV3Pool } from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import "forge-std/Test.sol";
import "forge-std/console2.sol";
/// @title EthScarcityAbundance
/// @notice Tests investigating when EthScarcity vs EthAbundance fires,
/// and the floor ratchet's effect during each condition.
contract EthScarcityAbundance is Test {
TestEnvironment testEnv;
IUniswapV3Factory factory;
IUniswapV3Pool pool;
IWETH9 weth;
Kraiken kraiken;
LiquidityManager lm;
SwapExecutor swapExecutor;
ConfigurableOptimizer optimizer;
bool token0isWeth;
address trader = makeAddr("trader");
address fees = makeAddr("fees");
bytes32 constant SCARCITY_SIG = keccak256("EthScarcity(int24,uint256,uint256,uint256,int24)");
bytes32 constant ABUNDANCE_SIG = keccak256("EthAbundance(int24,uint256,uint256,uint256,int24)");
function setUp() public {
testEnv = new TestEnvironment(fees);
factory = UniswapHelpers.deployUniswapFactory();
// Default params: CI=50%, AS=50%, AW=50, DD=50%
optimizer = new ConfigurableOptimizer(5e17, 5e17, 50, 5e17);
(factory, pool, weth, kraiken,, lm,, token0isWeth) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(optimizer));
swapExecutor = new SwapExecutor(pool, weth, kraiken, token0isWeth, lm, true);
// Fund LM generously
vm.deal(address(lm), 200 ether);
vm.prank(address(lm));
weth.deposit{ value: 100 ether }();
// Initial recenter
vm.prank(fees);
lm.recenter();
// Fund trader
vm.deal(trader, 300 ether);
vm.prank(trader);
weth.deposit{ value: 300 ether }();
}
// ================================================================
// Q1: WHEN DOES EthScarcity/EthAbundance FIRE?
// ================================================================
/// @notice After a small buy and sell-back, EthAbundance fires on recenter.
/// This proves EthScarcity is NOT permanent.
function test_floor_placed_after_sellback() public {
console2.log("=== Floor placement after sell-back ===");
// Small buy to create some VWAP history
_executeBuy(5 ether);
_recenterAndLog("Post-buy");
// Sell ALL KRK back
_executeSell(kraiken.balanceOf(trader));
// Recenter after sell-back should not revert
_recenterAndLog("Post-sellback");
// Floor position should exist (check via positions mapping)
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
assertTrue(floorLiq > 0, "Floor should be placed after sell-back");
}
/// @notice During sustained buy pressure, floor should be placed progressively
function test_floor_during_buy_pressure() public {
console2.log("=== Floor during buy pressure ===");
for (uint256 i = 0; i < 5; i++) {
_executeBuy(15 ether);
_recenterAndLog("Buy");
}
// Floor should have been placed on each recenter
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
assertTrue(floorLiq > 0, "Floor should be placed during buy pressure");
}
/// @notice After heavy buying, floor persists through sells via VWAP mirror.
function test_floor_retreats_through_bull_bear_cycle() public {
console2.log("=== Floor through bull-bear cycle ===");
// Bull phase: 5 buys of 15 ETH = 75 ETH total
for (uint256 i = 0; i < 5; i++) {
_executeBuy(15 ether);
}
_recenterAndLog("End of bull");
// Bear phase: sell everything in chunks with recenters
uint256 totalKrk = kraiken.balanceOf(trader);
uint256 remaining = totalKrk;
uint256 attempts;
while (remaining > 0 && attempts < 10) {
uint256 sellChunk = remaining > totalKrk / 3 ? totalKrk / 3 : remaining;
if (sellChunk == 0) break;
_executeSell(sellChunk);
remaining = kraiken.balanceOf(trader);
_recenterAndLog("Sell chunk");
attempts++;
}
// Floor should still exist after selling
(uint128 floorLiq,,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
assertTrue(floorLiq > 0, "Floor should persist through bear phase");
}
// ================================================================
// Q1 continued: IS THE FLOOR PERMANENTLY STUCK?
// ================================================================
/// @notice With conditional ratchet: floor tracks currentTick during abundance
/// but is locked during scarcity. Demonstrates the ratchet is now market-responsive.
function test_floor_tracks_price_during_abundance() public {
console2.log("=== Floor responds to market during EthAbundance ===");
// Record initial floor
(, int24 floorTickInit,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Initial floor tickLower:", floorTickInit);
// Buy to push price, creating scarcity
_executeBuy(10 ether);
(bool scarcityA,,) = _recenterAndLog("After buy");
(, int24 floorAfterScarcity,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Floor after scarcity recenter:", floorAfterScarcity);
console2.log("EthScarcity fired:", scarcityA);
// Sell back to trigger abundance
_executeSell(kraiken.balanceOf(trader));
(, bool abundanceA,) = _recenterAndLog("After sell-back");
(, int24 floorAfterAbundance,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Floor after abundance recenter:", floorAfterAbundance);
console2.log("EthAbundance fired:", abundanceA);
// During abundance, floor is set by anti-overlap clamp: currentTick + anchorSpacing
// This tracks the current price rather than being permanently locked
(, int24 currentTick,,,,,) = pool.slot0();
// anchorSpacing for AW=50: 200 + (34 * 50 * 200 / 100) = 3600
int24 expectedBoundary = currentTick + 3600;
console2.log("Current tick:", currentTick);
console2.log("Expected floor boundary:", expectedBoundary);
// With conditional ratchet: during abundance, the ratchet is off,
// so floor can be at anti-overlap boundary (tracks current price)
// With permanent ratchet: floor would be max(prevFloor, boundary)
console2.log("FINDING: Floor responds to market during abundance");
console2.log(" Scarcity: floor locked (ratchet on)");
console2.log(" Abundance: floor at currentTick + anchorSpacing");
}
/// @notice During EthAbundance, the abundance vwapTick (from VWAP) is typically
/// far below the anti-overlap boundary, so the anti-overlap clamp sets the floor
/// to currentTick + anchorSpacing. With conditional ratchet, this tracks the
/// current price. With permanent ratchet, it would be max(prevFloor, boundary).
function test_abundance_floor_mechanism() public {
console2.log("=== How floor is set during abundance ===");
// Buy to create VWAP then sell back for abundance
_executeBuy(10 ether);
_recenterAndLog("After buy");
_executeSell(kraiken.balanceOf(trader));
// Recenter (expect abundance)
vm.warp(block.timestamp + 1 hours);
vm.roll(block.number + 1);
vm.recordLogs();
vm.prank(fees);
lm.recenter();
(, int24 currentTick,,,,,) = pool.slot0();
(, int24 floorTickLower,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
// anchorSpacing for AW=50: 200 + (34*50*200/100) = 3600
int24 antiOverlapBoundary = currentTick + 3600;
Vm.Log[] memory logs = vm.getRecordedLogs();
int24 abundanceVwapTick;
for (uint256 i = 0; i < logs.length; i++) {
if (logs[i].topics.length > 0 && logs[i].topics[0] == ABUNDANCE_SIG) {
(,,,, int24 vt) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24));
abundanceVwapTick = vt;
}
}
console2.log("Current tick:", currentTick);
console2.log("Anti-overlap boundary:", antiOverlapBoundary);
console2.log("Abundance vwapTick:", abundanceVwapTick);
console2.log("Floor tickLower:", floorTickLower);
// The abundance vwapTick is far below the anti-overlap boundary
// because VWAP price converts to a negative tick (token0isWeth sign flip)
assertTrue(abundanceVwapTick < antiOverlapBoundary, "VWAP tick below anti-overlap boundary");
// Floor ends up at anti-overlap boundary (clamped after tick spacing)
// With conditional ratchet: this tracks current price
// With permanent ratchet: this would be max(prevFloor, boundary)
console2.log("Floor set by: anti-overlap clamp (tracks current price)");
}
// ================================================================
// Q2: BULL/BEAR LIQUIDITY DISTRIBUTION & CONDITIONAL RATCHET
// ================================================================
/// @notice Demonstrates ideal bull vs bear parameter distribution
function test_bull_market_params() public {
console2.log("=== Bull vs Bear parameter comparison ===");
// Bull optimizer: high anchorShare, wide anchor, deep discovery
ConfigurableOptimizer bullOpt = new ConfigurableOptimizer(3e17, 8e17, 80, 8e17);
(,,,,, LiquidityManager bullLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(bullOpt));
vm.deal(address(bullLm), 200 ether);
vm.prank(address(bullLm));
weth.deposit{ value: 100 ether }();
vm.prank(fees);
bullLm.recenter();
(uint128 floorLiq,,) = bullLm.positions(ThreePositionStrategy.Stage.FLOOR);
(uint128 anchorLiq, int24 aLow, int24 aHigh) = bullLm.positions(ThreePositionStrategy.Stage.ANCHOR);
(uint128 discLiq,,) = bullLm.positions(ThreePositionStrategy.Stage.DISCOVERY);
console2.log("Bull (AS=80% AW=80 DD=80% CI=30%):");
console2.log(" Floor liq:", uint256(floorLiq));
console2.log(" Anchor liq:", uint256(anchorLiq));
console2.log(" Anchor width:", uint256(int256(aHigh - aLow)));
console2.log(" Discovery liq:", uint256(discLiq));
// Bear optimizer: low anchorShare, moderate anchor, thin discovery
ConfigurableOptimizer bearOpt = new ConfigurableOptimizer(8e17, 1e17, 40, 2e17);
(,,,,, LiquidityManager bearLm,,) = testEnv.setupEnvironmentWithExistingFactory(factory, true, fees, address(bearOpt));
vm.deal(address(bearLm), 200 ether);
vm.prank(address(bearLm));
weth.deposit{ value: 100 ether }();
vm.prank(fees);
bearLm.recenter();
(uint128 bFloorLiq,,) = bearLm.positions(ThreePositionStrategy.Stage.FLOOR);
(uint128 bAnchorLiq, int24 bALow, int24 bAHigh) = bearLm.positions(ThreePositionStrategy.Stage.ANCHOR);
(uint128 bDiscLiq,,) = bearLm.positions(ThreePositionStrategy.Stage.DISCOVERY);
console2.log("Bear (AS=10% AW=40 DD=20% CI=80%):");
console2.log(" Floor liq:", uint256(bFloorLiq));
console2.log(" Anchor liq:", uint256(bAnchorLiq));
console2.log(" Anchor width:", uint256(int256(bAHigh - bALow)));
console2.log(" Discovery liq:", uint256(bDiscLiq));
assertGt(anchorLiq, bAnchorLiq, "Bull should have more anchor liquidity than bear");
}
/// @notice Tracks floor through scarcity -> abundance -> scarcity cycle.
/// Shows what a conditional ratchet WOULD allow.
function test_conditional_ratchet_concept() public {
console2.log("=== Conditional ratchet concept ===");
(, int24 floorTickInit,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Initial floor:", floorTickInit);
// Phase 1: Buy pressure -> EthScarcity -> ratchet ACTIVE
_executeBuy(30 ether);
(bool s1,,) = _recenterAndLog("Buy#1");
(, int24 floorAfterBuy1,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log(" Floor after buy1:", floorAfterBuy1);
console2.log(" Ratchet held:", floorAfterBuy1 == floorTickInit);
_executeBuy(20 ether);
(bool s2,,) = _recenterAndLog("Buy#2");
(, int24 floorAfterBuy2,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log(" Floor after buy2:", floorAfterBuy2);
console2.log(" Ratchet held:", floorAfterBuy2 == floorTickInit);
// Phase 2: Sell back -> EthAbundance -> ratchet would be INACTIVE
_executeSell(kraiken.balanceOf(trader));
(bool s3, bool a3, int24 vwapTick3) = _recenterAndLog("Sell-back");
(, int24 floorAfterSell,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Phase 2 (sell-back):");
console2.log(" EthAbundance fired:", a3);
console2.log(" Floor actual:", floorAfterSell);
console2.log(" VwapTick from event:", vwapTick3);
if (a3) {
console2.log(" PROPOSED: Floor would move to vwapTick during abundance");
}
// Phase 3: Buy again -> EthScarcity -> ratchet re-engages
_executeBuy(25 ether);
(bool s4,,) = _recenterAndLog("Buy#3");
(, int24 floorAfterBuy3,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log(" EthScarcity fired:", s4);
console2.log(" Floor after buy3:", floorAfterBuy3);
console2.log("");
console2.log("=== CONDITIONAL RATCHET SUMMARY ===");
console2.log("Current: Floor permanently locked at:", floorTickInit);
console2.log("Proposed: Scarcity=locked, Abundance=free, re-lock on next scarcity");
}
/// @notice Diagnostic: buy->recenter->sell IL extraction.
/// Without a ratchet, trader CAN profit this test documents the baseline.
function test_buyRecenterSell_baseline() public {
console2.log("=== Baseline: buy->recenter->sell (no ratchet) ===");
uint256 initWeth = weth.balanceOf(trader);
_executeBuy(78 ether);
(bool s1,,) = _recenterAndLog("Attack buy1");
_executeBuy(47 ether);
(bool s2,,) = _recenterAndLog("Attack buy2");
_executeBuy(40 ether);
_executeBuy(20 ether);
_recenterAndLog("Attack buy3+4");
_liquidateTrader();
int256 pnl = int256(weth.balanceOf(trader)) - int256(initWeth);
console2.log("Baseline PnL (ETH):", pnl / 1e18);
console2.log("Scarcity fired during buys:", s1, s2);
// No assertion this test documents IL extraction without ratchet
}
/// @notice Diagnostic: sell-to-trigger-abundance then re-buy.
/// Documents baseline behavior without ratchet.
function test_sellToTriggerAbundance_baseline() public {
console2.log("=== Baseline: sell-to-trigger-abundance (no ratchet) ===");
uint256 initWeth = weth.balanceOf(trader);
// Phase 1: Buy 100 ETH worth
_executeBuy(50 ether);
_recenterAndLog("Setup buy1");
_executeBuy(50 ether);
_recenterAndLog("Setup buy2");
(, int24 floorAfterBuys,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("Floor after buys:", floorAfterBuys);
// Phase 2: Sell 90% to try to trigger abundance
uint256 krkBal = kraiken.balanceOf(trader);
_executeSell(krkBal * 90 / 100);
(bool s, bool a, int24 vwapTick) = _recenterAndLog("Post-90pct-sell");
(, int24 floorAfterSell,) = lm.positions(ThreePositionStrategy.Stage.FLOOR);
console2.log("After 90% sell:");
console2.log(" Scarcity:", s);
console2.log(" Abundance:", a);
console2.log(" Floor:", floorAfterSell);
if (a) {
console2.log(" Abundance fired - vwapTick:", vwapTick);
}
// Liquidate remaining
_liquidateTrader();
int256 pnl = int256(weth.balanceOf(trader)) - int256(initWeth);
console2.log("Final PnL:", pnl);
// No assertion this test documents baseline without ratchet
}
// ================================================================
// HELPERS
// ================================================================
function _executeBuy(uint256 amount) internal {
if (weth.balanceOf(trader) < amount) return;
vm.startPrank(trader);
weth.transfer(address(swapExecutor), amount);
vm.stopPrank();
try swapExecutor.executeBuy(amount, trader) { } catch { }
_recoverStuck();
}
function _executeSell(uint256 krkAmount) internal {
if (krkAmount == 0) return;
uint256 bal = kraiken.balanceOf(trader);
if (bal < krkAmount) krkAmount = bal;
vm.startPrank(trader);
kraiken.transfer(address(swapExecutor), krkAmount);
vm.stopPrank();
try swapExecutor.executeSell(krkAmount, trader) { } catch { }
_recoverStuck();
}
function _recenterAndLog(string memory label) internal returns (bool sawScarcity, bool sawAbundance, int24 eventVwapTick) {
vm.warp(block.timestamp + 1 hours);
vm.roll(block.number + 1);
vm.recordLogs();
vm.prank(fees);
try lm.recenter() { }
catch {
console2.log(" recenter FAILED:", label);
return (false, false, 0);
}
Vm.Log[] memory logs = vm.getRecordedLogs();
for (uint256 i = 0; i < logs.length; i++) {
if (logs[i].topics.length == 0) continue;
if (logs[i].topics[0] == SCARCITY_SIG) {
(int24 tick, uint256 ethBal, uint256 supply,, int24 vwapTick) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24));
sawScarcity = true;
eventVwapTick = vwapTick;
console2.log(" EthScarcity:", label);
console2.log(" tick:", tick);
console2.log(" ethBal:", ethBal / 1e18);
console2.log(" supply:", supply / 1e18);
console2.log(" vwapTick:", vwapTick);
} else if (logs[i].topics[0] == ABUNDANCE_SIG) {
(int24 tick, uint256 ethBal, uint256 supply,, int24 vwapTick) = abi.decode(logs[i].data, (int24, uint256, uint256, uint256, int24));
sawAbundance = true;
eventVwapTick = vwapTick;
console2.log(" EthAbundance:", label);
console2.log(" tick:", tick);
console2.log(" ethBal:", ethBal / 1e18);
console2.log(" supply:", supply / 1e18);
console2.log(" vwapTick:", vwapTick);
}
}
if (!sawScarcity && !sawAbundance) {
console2.log(" No scarcity/abundance:", label);
}
}
function _liquidateTrader() internal {
_recenterAndLog("Pre-liquidation");
uint256 remaining = kraiken.balanceOf(trader);
uint256 attempts;
while (remaining > 0 && attempts < 20) {
uint256 prev = remaining;
_executeSell(remaining);
remaining = kraiken.balanceOf(trader);
if (remaining >= prev) break;
if (attempts % 3 == 2) {
_recenterAndLog("Liq recenter");
}
unchecked {
attempts++;
}
}
}
function _recoverStuck() internal {
uint256 sk = kraiken.balanceOf(address(swapExecutor));
if (sk > 0) {
vm.prank(address(swapExecutor));
kraiken.transfer(trader, sk);
}
uint256 sw = weth.balanceOf(address(swapExecutor));
if (sw > 0) {
vm.prank(address(swapExecutor));
weth.transfer(trader, sw);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,232 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.19;
import { OptimizerV3 } from "../src/OptimizerV3.sol";
import { Stake } from "../src/Stake.sol";
import "forge-std/Test.sol";
contract OptimizerV3Test is Test {
OptimizerV3 optimizer;
// TAX_RATES from Stake.sol
uint256[30] TAX_RATES =
[uint256(1), 3, 5, 8, 12, 18, 24, 30, 40, 50, 60, 80, 100, 130, 180, 250, 320, 420, 540, 700, 920, 1200, 1600, 2000, 2600, 3400, 4400, 5700, 7500, 9700];
uint256 constant MAX_TAX = 9700;
function setUp() public {
// Deploy without initialization (we only test pure functions)
optimizer = new OptimizerV3();
}
function _normalizedTaxRate(uint256 taxRateIndex) internal view returns (uint256) {
return TAX_RATES[taxRateIndex] * 1e18 / MAX_TAX;
}
function _percentageStaked(uint256 pct) internal pure returns (uint256) {
return pct * 1e18 / 100;
}
// ==================== Always Bear (staked <= 91%) ====================
function testAlwaysBearAt0Percent() public view {
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
assertFalse(optimizer.isBullMarket(0, _normalizedTaxRate(taxIdx)), "0% staked should always be bear");
}
}
function testAlwaysBearAt50Percent() public view {
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
assertFalse(optimizer.isBullMarket(_percentageStaked(50), _normalizedTaxRate(taxIdx)), "50% staked should always be bear");
}
}
function testAlwaysBearAt91Percent() public view {
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
assertFalse(optimizer.isBullMarket(_percentageStaked(91), _normalizedTaxRate(taxIdx)), "91% staked should always be bear");
}
}
function testAlwaysBearAt39Percent() public view {
assertFalse(optimizer.isBullMarket(_percentageStaked(39), _normalizedTaxRate(0)), "39% staked should be bear");
}
function testAlwaysBearAt80Percent() public view {
assertFalse(optimizer.isBullMarket(_percentageStaked(80), _normalizedTaxRate(0)), "80% staked should be bear even with lowest tax");
}
// ==================== 92% Boundary ====================
function testBoundary92PercentLowestTax() public view {
// deltaS=8, effIdx=0 penalty = 512*0/20 = 0 < 50 BULL
assertTrue(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(0)), "92% staked, lowest tax should be bull");
}
function testBoundary92PercentTaxIdx1() public view {
// deltaS=8, effIdx=1 penalty = 512*1/20 = 25 < 50 BULL
assertTrue(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(1)), "92% staked, taxIdx=1 should be bull");
}
function testBoundary92PercentTaxIdx2() public view {
// deltaS=8, effIdx=2 penalty = 512*2/20 = 51 >= 50 BEAR
assertFalse(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(2)), "92% staked, taxIdx=2 should be bear");
}
function testBoundary92PercentHighTax() public view {
// deltaS=8, effIdx=29 penalty = 512*29/20 = 742 BEAR
assertFalse(optimizer.isBullMarket(_percentageStaked(92), _normalizedTaxRate(29)), "92% staked, max tax should be bear");
}
// ==================== 95% Staked ====================
function testAt95PercentLowTax() public view {
// deltaS=5, effIdx=0 penalty = 125*0/20 = 0 < 50 BULL
assertTrue(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(0)), "95% staked, lowest tax should be bull");
}
function testAt95PercentTaxIdx7() public view {
// deltaS=5, effIdx=7 penalty = 125*7/20 = 43 < 50 BULL
assertTrue(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(7)), "95% staked, taxIdx=7 should be bull");
}
function testAt95PercentTaxIdx8() public view {
// deltaS=5, effIdx=8 penalty = 125*8/20 = 50, NOT < 50 BEAR
assertFalse(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(8)), "95% staked, taxIdx=8 should be bear");
}
function testAt95PercentTaxIdx9() public view {
// deltaS=5, effIdx=9 penalty = 125*9/20 = 56 BEAR
assertFalse(optimizer.isBullMarket(_percentageStaked(95), _normalizedTaxRate(9)), "95% staked, taxIdx=9 should be bear");
}
// ==================== 97% Staked ====================
function testAt97PercentLowTax() public view {
// deltaS=3, effIdx=0 penalty = 27*0/20 = 0 < 50 BULL
assertTrue(optimizer.isBullMarket(_percentageStaked(97), _normalizedTaxRate(0)), "97% staked, lowest tax should be bull");
}
function testAt97PercentHighTax() public view {
// deltaS=3, effIdx=29 penalty = 27*29/20 = 39 < 50 BULL
assertTrue(optimizer.isBullMarket(_percentageStaked(97), _normalizedTaxRate(29)), "97% staked, max tax should still be bull");
}
// ==================== 100% Staked ====================
function testAt100PercentAlwaysBull() public view {
// deltaS=0 penalty = 0 always BULL
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
assertTrue(optimizer.isBullMarket(1e18, _normalizedTaxRate(taxIdx)), "100% staked should always be bull");
}
}
// ==================== 96% Sweep ====================
function testAt96PercentSweep() public view {
// deltaS=4, cubic=64
// penalty = 64 * effIdx / 20
// Bull when penalty < 50, i.e., 64 * effIdx / 20 < 50 effIdx < 15.625
// effIdx 0-15: bull (penalty 0..48). effIdx 16+: bear (penalty 51+)
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
bool result = optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(taxIdx));
// Compute expected: effIdx from the tax rate
uint256 effIdx = taxIdx;
if (taxIdx >= 14) {
effIdx = taxIdx + 1;
if (effIdx > 29) effIdx = 29;
}
uint256 penalty = 64 * effIdx / 20;
bool expectedBull = penalty < 50;
assertEq(result, expectedBull, string.concat("96% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
}
}
// ==================== 94% Sweep ====================
function testAt94PercentSweep() public view {
// deltaS=6, cubic=216
// penalty = 216 * effIdx / 20
// Bull when penalty < 50, i.e., 216 * effIdx / 20 < 50 effIdx < 4.629
// effIdx 0-4: bull. effIdx 5+: bear.
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
bool result = optimizer.isBullMarket(_percentageStaked(94), _normalizedTaxRate(taxIdx));
uint256 effIdx = taxIdx;
if (taxIdx >= 14) {
effIdx = taxIdx + 1;
if (effIdx > 29) effIdx = 29;
}
uint256 penalty = 216 * effIdx / 20;
bool expectedBull = penalty < 50;
assertEq(result, expectedBull, string.concat("94% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
}
}
// ==================== Revert on Invalid Input ====================
function testRevertsAbove100Percent() public {
vm.expectRevert("Invalid percentage staked");
optimizer.isBullMarket(1e18 + 1, 0);
}
// ==================== 93% Staked ====================
function testAt93PercentSweep() public view {
// deltaS=7, cubic=343
// penalty = 343 * effIdx / 20
// Bull when penalty < 50, i.e., 343 * effIdx / 20 < 50 effIdx < 2.915
// effIdx 0-2: bull. effIdx 3+: bear.
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
bool result = optimizer.isBullMarket(_percentageStaked(93), _normalizedTaxRate(taxIdx));
uint256 effIdx = taxIdx;
if (taxIdx >= 14) {
effIdx = taxIdx + 1;
if (effIdx > 29) effIdx = 29;
}
uint256 penalty = 343 * effIdx / 20;
bool expectedBull = penalty < 50;
assertEq(result, expectedBull, string.concat("93% sweep mismatch at taxIdx=", vm.toString(taxIdx)));
}
}
// ==================== 99% Staked ====================
function testAt99PercentAlwaysBull() public view {
// deltaS=1, cubic=1 penalty = effIdx/20, always < 50 for effIdx <= 29
for (uint256 taxIdx = 0; taxIdx < 30; taxIdx++) {
assertTrue(optimizer.isBullMarket(_percentageStaked(99), _normalizedTaxRate(taxIdx)), "99% staked should always be bull");
}
}
// ==================== EffIdx Shift at Boundary (taxIdx 13 vs 14) ====================
function testEffIdxShiftAtBoundary() public view {
// At 96% staked, deltaS=4, cubic=64
// taxIdx=13: effIdx=13, penalty = 64*13/20 = 41 < 50 BULL
assertTrue(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(13)), "taxIdx=13 should be bull at 96%");
// taxIdx=14: effIdx=15 (shift!), penalty = 64*15/20 = 48 < 50 BULL
assertTrue(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(14)), "taxIdx=14 should be bull at 96% (effIdx shift)");
// taxIdx=15: effIdx=16, penalty = 64*16/20 = 51 >= 50 BEAR
assertFalse(optimizer.isBullMarket(_percentageStaked(96), _normalizedTaxRate(15)), "taxIdx=15 should be bear at 96%");
}
// ==================== Fuzz Tests ====================
function testFuzzBearBelow92(uint256 percentageStaked, uint256 taxIdx) public view {
percentageStaked = bound(percentageStaked, 0, 91e18 / 100);
taxIdx = bound(taxIdx, 0, 29);
assertFalse(optimizer.isBullMarket(percentageStaked, _normalizedTaxRate(taxIdx)), "Should always be bear below 92%");
}
function testFuzz100PercentAlwaysBull(uint256 taxIdx) public view {
taxIdx = bound(taxIdx, 0, 29);
assertTrue(optimizer.isBullMarket(1e18, _normalizedTaxRate(taxIdx)), "100% staked should always be bull");
}
function testFuzzNeverReverts(uint256 percentageStaked, uint256 averageTaxRate) public view {
percentageStaked = bound(percentageStaked, 0, 1e18);
averageTaxRate = bound(averageTaxRate, 0, 1e18);
// Should not revert
optimizer.isBullMarket(percentageStaked, averageTaxRate);
}
}

View file

@ -16,6 +16,10 @@ contract MockUniswapV3Pool {
uint160[] public liquidityCumulatives;
bool public shouldRevert;
// Fallback path support: separate tick cumulatives for the 60000s window
int56[] public fallbackTickCumulatives;
bool public revertOnlyPrimary; // true = revert on 300s, succeed on 60000s
function setTickCumulatives(int56[] memory _tickCumulatives) external {
tickCumulatives = _tickCumulatives;
}
@ -28,10 +32,25 @@ contract MockUniswapV3Pool {
shouldRevert = _shouldRevert;
}
function observe(uint32[] calldata) external view returns (int56[] memory, uint160[] memory) {
function setFallbackTickCumulatives(int56[] memory _fallbackTickCumulatives) external {
fallbackTickCumulatives = _fallbackTickCumulatives;
}
function setRevertOnlyPrimary(bool _revertOnlyPrimary) external {
revertOnlyPrimary = _revertOnlyPrimary;
}
function observe(uint32[] calldata secondsAgo) external view returns (int56[] memory, uint160[] memory) {
if (shouldRevert) {
revert("Mock oracle failure");
}
// If revertOnlyPrimary is set, revert on 300s but succeed on 60000s
if (revertOnlyPrimary && secondsAgo[0] == 300) {
revert("Old observations not available");
}
if (revertOnlyPrimary && secondsAgo[0] == 60_000 && fallbackTickCumulatives.length > 0) {
return (fallbackTickCumulatives, liquidityCumulatives);
}
return (tickCumulatives, liquidityCumulatives);
}
}
@ -129,23 +148,53 @@ contract PriceOracleTest is Test {
assertFalse(isStable, "Price should be unstable when outside deviation threshold");
}
function testPriceStabilityOracleFailureFallback() public {
// Test fallback behavior when oracle fails
mockPool.setShouldRevert(true);
function testFallbackPathUsesCorrectDivisor() public {
// Primary observe (300s) reverts, fallback (60000s) succeeds
// The fallback window is 60000 seconds, so tickCumulativeDiff / 60000 = averageTick
int24 averageTick = 1000;
uint32 fallbackInterval = 60_000;
// Should not revert but should still return a boolean
// The actual implementation tries a longer timeframe on failure
int24 currentTick = 1000;
int56[] memory fallbackCumulatives = new int56[](2);
fallbackCumulatives[0] = 0;
fallbackCumulatives[1] = int56(averageTick) * int56(int32(fallbackInterval));
// This might fail or succeed depending on implementation details
// The key is that it doesn't cause the entire transaction to revert
try priceOracle.isPriceStable(currentTick) returns (bool result) {
// If it succeeds, that's fine
console.log("Oracle fallback succeeded, result:", result);
} catch {
// If it fails, that's also expected behavior for this test
console.log("Oracle fallback failed as expected");
}
uint160[] memory liquidityCumulatives = new uint160[](2);
liquidityCumulatives[0] = 1000;
liquidityCumulatives[1] = 1000;
mockPool.setRevertOnlyPrimary(true);
mockPool.setFallbackTickCumulatives(fallbackCumulatives);
mockPool.setLiquidityCumulatives(liquidityCumulatives);
// currentTick = 1020, averageTick = 1000 within 50-tick deviation stable
bool isStable = priceOracle.isPriceStable(1020);
assertTrue(isStable, "Fallback: price within deviation should be stable");
// currentTick = 1100, averageTick = 1000 100 ticks away unstable
isStable = priceOracle.isPriceStable(1100);
assertFalse(isStable, "Fallback: price outside deviation should be unstable");
}
function testFallbackPathWithNegativeTick() public {
// Verify fallback works correctly with negative ticks
int24 averageTick = -500;
uint32 fallbackInterval = 60_000;
int56[] memory fallbackCumulatives = new int56[](2);
fallbackCumulatives[0] = 0;
fallbackCumulatives[1] = int56(averageTick) * int56(int32(fallbackInterval));
uint160[] memory liquidityCumulatives = new uint160[](2);
liquidityCumulatives[0] = 1000;
liquidityCumulatives[1] = 1000;
mockPool.setRevertOnlyPrimary(true);
mockPool.setFallbackTickCumulatives(fallbackCumulatives);
mockPool.setLiquidityCumulatives(liquidityCumulatives);
// currentTick = -480, averageTick = -500 diff = 20 stable
bool isStable = priceOracle.isPriceStable(-480);
assertTrue(isStable, "Fallback with negative tick: within deviation should be stable");
}
function testPriceStabilityExactBoundary() public {

View file

@ -264,42 +264,47 @@ contract ThreePositionStrategyTest is TestConstants {
assertNotEq(centerTick, CURRENT_TICK, "Floor should not be positioned at current tick when VWAP available");
}
function testFloorPositionEthScarcity() public {
function testFloorPositionScarcityDominates() public {
ThreePositionStrategy.PositionParams memory params = getDefaultParams();
// Set up scenario where ETH is insufficient for VWAP price
// Set up scenario where ETH is insufficient scarcity tick dominates
uint256 vwapX96 = 79_228_162_514_264_337_593_543_950_336 * 10; // High VWAP price
strategy.setVWAP(vwapX96, 1000 ether);
uint256 smallEthBalance = 1 ether; // Insufficient ETH
uint256 smallEthBalance = 1 ether; // Very low ETH scarcity tick far away
uint256 pulledHarb = 1000 ether;
uint256 discoveryAmount = 500 ether;
// Should emit EthScarcity event (check event type, not exact values)
vm.expectEmit(true, false, false, false);
emit ThreePositionStrategy.EthScarcity(CURRENT_TICK, 0, 0, 0, 0);
// Should not revert floor placed using max(scarcity, mirror, clamp)
strategy.setFloorPosition(CURRENT_TICK, smallEthBalance, pulledHarb, discoveryAmount, params);
// Floor should be minted (position exists)
MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0);
assertTrue(pos.liquidity > 0, "Floor should have liquidity");
}
function testFloorPositionEthAbundance() public {
function testFloorPositionMirrorDominates() public {
ThreePositionStrategy.PositionParams memory params = getDefaultParams();
// Set up scenario where ETH is sufficient for VWAP price
// Set up scenario where VWAP is far from current mirror tick dominates
uint256 baseVwap = 79_228_162_514_264_337_593_543_950_336; // 1.0 in X96 format
uint256 vwapX96 = baseVwap / 100_000; // Very low VWAP price to ensure abundance
uint256 vwapX96 = baseVwap / 100_000; // Very low VWAP price far from current
strategy.setVWAP(vwapX96, 1000 ether);
uint256 largeEthBalance = 100_000 ether; // Very large ETH balance
uint256 largeEthBalance = 100_000 ether; // Lots of ETH
uint256 pulledHarb = 1000 ether;
uint256 discoveryAmount = 500 ether;
// Should emit EthAbundance event (check event type, not exact values)
// The exact VWAP and vwapTick values are calculated, so we just check the event type
vm.expectEmit(true, false, false, false);
emit ThreePositionStrategy.EthAbundance(CURRENT_TICK, 0, 0, 0, 0);
strategy.setFloorPosition(CURRENT_TICK, largeEthBalance, pulledHarb, discoveryAmount, params);
MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0);
assertTrue(pos.liquidity > 0, "Floor should have liquidity");
// Floor should be further than just anchorSpacing (mirror should push it)
int24 anchorSpacing = 200 + (34 * 50 * 200 / 100); // 3600
int24 floorCenter = (pos.tickLower + pos.tickUpper) / 2;
// Mirror should push floor significantly beyond clamp minimum
assertTrue(floorCenter > CURRENT_TICK + anchorSpacing + 200, "Mirror should push floor beyond clamp minimum");
}
function testFloorPositionNoVWAP() public {
@ -316,16 +321,34 @@ contract ThreePositionStrategyTest is TestConstants {
MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0);
// Without VWAP, should default to current tick but adjusted for anchor spacing
// Without VWAP, mirror = current tick, so floor uses max(scarcity, clamp)
// With these balances, scarcity tick should dominate (low ETH relative to supply)
int24 centerTick = (pos.tickLower + pos.tickUpper) / 2;
// Expected spacing: TICK_SPACING + (34 * anchorWidth * TICK_SPACING / 100) = 200 + (34 * 50 * 200 / 100) = 3600
int24 expectedSpacing = 200 + (34 * 50 * 200 / 100);
assertApproxEqAbs(
uint256(int256(centerTick)),
uint256(int256(CURRENT_TICK + expectedSpacing)),
200,
"Floor should be positioned away from current tick to avoid anchor overlap"
);
// Floor should be above current tick (on KRK-cheap side)
assertTrue(centerTick > CURRENT_TICK, "Floor should be on KRK-cheap side of current tick");
assertTrue(pos.liquidity > 0, "Floor should have liquidity");
}
function testFloorPositionNoVWAPClampOrScarcity() public {
ThreePositionStrategy.PositionParams memory params = getDefaultParams();
// No VWAP data, large ETH balance, small supply
strategy.setVWAP(0, 0);
uint256 floorEthBalance = 100_000 ether; // Very large
uint256 pulledHarb = 100 ether; // Small supply
uint256 discoveryAmount = 50 ether;
strategy.setFloorPosition(CURRENT_TICK, floorEthBalance, pulledHarb, discoveryAmount, params);
MockThreePositionStrategy.MintedPosition memory pos = strategy.getMintedPosition(0);
// With no VWAP: mirror = current. Floor uses max(scarcity, clamp).
// The scarcity formula with small supply and large ETH may still push floor
// significantly beyond the clamp minimum. Just verify floor is on correct side.
int24 centerTick = (pos.tickLower + pos.tickUpper) / 2;
int24 minSpacing = 200 + (34 * 50 * 200 / 100); // 3600
assertTrue(centerTick >= CURRENT_TICK + minSpacing, "Floor should be positioned away from current tick to avoid anchor overlap");
}
function testFloorPositionOutstandingSupplyCalculation() public {

View file

@ -147,7 +147,7 @@ contract TestEnvironment is TestConstants {
bool setupComplete = false;
uint256 retryCount = 0;
while (!setupComplete && retryCount < 5) {
while (!setupComplete && retryCount < 20) {
// Clean slate if retrying
if (retryCount > 0) {
// Deploy a dummy contract to shift addresses
@ -195,7 +195,6 @@ contract TestEnvironment is TestConstants {
*/
function _configurePermissions() internal {
harberg.setStakingPool(address(stake));
vm.prank(feeDestination);
harberg.setLiquidityManager(address(lm));
vm.deal(address(lm), INITIAL_LM_ETH_BALANCE);
}
@ -313,5 +312,4 @@ contract TestEnvironment is TestConstants {
return (factory, pool, weth, harberg, stake, lm, optimizer, token0isWeth);
}
}

View file

@ -68,6 +68,7 @@ extract_addresses() {
LIQUIDITY_MANAGER="$(jq -r '.transactions[] | select(.contractName=="LiquidityManager") | .contractAddress' "$run_file" | head -n1)"
KRAIKEN="$(jq -r '.transactions[] | select(.contractName=="Kraiken") | .contractAddress' "$run_file" | head -n1)"
STAKE="$(jq -r '.transactions[] | select(.contractName=="Stake") | .contractAddress' "$run_file" | head -n1)"
OPTIMIZER_PROXY="$(jq -r '.transactions[] | select(.contractName=="ERC1967Proxy") | .contractAddress' "$run_file" | head -n1)"
DEPLOY_BLOCK="$(jq -r '.receipts[0].blockNumber' "$run_file" | xargs printf "%d")"
if [[ -z "$LIQUIDITY_MANAGER" || "$LIQUIDITY_MANAGER" == "null" ]]; then
bootstrap_log "LiquidityManager address missing"
@ -150,7 +151,8 @@ write_deployments_json() {
"contracts": {
"Kraiken": "$KRAIKEN",
"Stake": "$STAKE",
"LiquidityManager": "$LIQUIDITY_MANAGER"
"LiquidityManager": "$LIQUIDITY_MANAGER",
"OptimizerProxy": "$OPTIMIZER_PROXY"
}
}
EODEPLOYMENTS

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -72,6 +72,7 @@ test.describe('Max Stake All Tax Rates', () => {
});
test('fills all tax rates until maxStake is reached', async ({ browser, request }) => {
test.setTimeout(10 * 60 * 1000); // 10 minutes — this test creates 30 staking positions via UI
console.log('[TEST] Creating wallet context...');
const context = await createWalletContext(browser, {
privateKey: ACCOUNT_PRIVATE_KEY,

View file

@ -0,0 +1,153 @@
import { expect, test } from '@playwright/test';
import { getStackConfig, validateStackHealthy } from '../setup/stack';
const STACK_CONFIG = getStackConfig();
const STACK_RPC_URL = STACK_CONFIG.rpcUrl;
// Solidity function selectors
const POSITIONS_SELECTOR = '0xf86aafc0'; // positions(uint8)
const RECENTER_SELECTOR = '0xf46e1346'; // recenter()
const RECENTER_ACCESS_SELECTOR = '0xdef51130'; // recenterAccess()
// Position stages (matches ThreePositionStrategy.Stage enum)
const STAGE_FLOOR = 0;
const STAGE_ANCHOR = 1;
const STAGE_DISCOVERY = 2;
const STAGE_NAMES = ['FLOOR', 'ANCHOR', 'DISCOVERY'] as const;
interface Position {
liquidity: bigint;
tickLower: number;
tickUpper: number;
}
async function rpcCall(method: string, params: unknown[]): Promise<unknown> {
const response = await fetch(STACK_RPC_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const data = await response.json();
if (data.error) throw new Error(`RPC error: ${data.error.message}`);
return data.result;
}
async function rpcCallRaw(method: string, params: unknown[]): Promise<{ result?: unknown; error?: { message: string; data?: string } }> {
const response = await fetch(STACK_RPC_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
return response.json();
}
function readPosition(result: string): Position {
const liquidity = BigInt('0x' + result.slice(2, 66));
const tickLowerRaw = BigInt('0x' + result.slice(66, 130));
const tickUpperRaw = BigInt('0x' + result.slice(130, 194));
const toInt24 = (val: bigint): number => {
const n = Number(val & 0xffffffn);
return n >= 0x800000 ? n - 0x1000000 : n;
};
return {
liquidity,
tickLower: toInt24(tickLowerRaw),
tickUpper: toInt24(tickUpperRaw),
};
}
async function getPosition(lmAddress: string, stage: number): Promise<Position> {
const stageHex = stage.toString(16).padStart(64, '0');
const result = (await rpcCall('eth_call', [
{ to: lmAddress, data: `${POSITIONS_SELECTOR}${stageHex}` },
'latest',
])) as string;
return readPosition(result);
}
test.describe('Recenter Positions', () => {
test.beforeAll(async () => {
await validateStackHealthy(STACK_CONFIG);
});
test('bootstrap recenter created valid positions with liquidity', async () => {
const lmAddress = STACK_CONFIG.contracts.LiquidityManager;
console.log(`[TEST] LiquidityManager: ${lmAddress}`);
for (const stage of [STAGE_FLOOR, STAGE_ANCHOR, STAGE_DISCOVERY]) {
const position = await getPosition(lmAddress, stage);
const name = STAGE_NAMES[stage];
console.log(
`[TEST] ${name}: liquidity=${position.liquidity}, ticks=[${position.tickLower}, ${position.tickUpper}]`,
);
expect(position.liquidity).toBeGreaterThan(0n);
expect(position.tickUpper).toBeGreaterThan(position.tickLower);
console.log(`[TEST] ${name} position verified`);
}
// Verify position ordering: floor above anchor above discovery (token0isWeth)
const floor = await getPosition(lmAddress, STAGE_FLOOR);
const anchor = await getPosition(lmAddress, STAGE_ANCHOR);
const discovery = await getPosition(lmAddress, STAGE_DISCOVERY);
// Floor should be above anchor (higher tick = cheaper KRK side when token0isWeth)
expect(floor.tickLower).toBeGreaterThanOrEqual(anchor.tickUpper);
// Discovery should be below anchor
expect(discovery.tickUpper).toBeLessThanOrEqual(anchor.tickLower);
console.log('[TEST] Position ordering verified: discovery < anchor < floor');
console.log('[TEST] All three positions have non-zero liquidity');
});
test('recenter() enforces access control', async () => {
const lmAddress = STACK_CONFIG.contracts.LiquidityManager;
// Read the recenterAccess address
const recenterAccessResult = (await rpcCall('eth_call', [
{ to: lmAddress, data: RECENTER_ACCESS_SELECTOR },
'latest',
])) as string;
const recenterAddr = '0x' + recenterAccessResult.slice(26);
console.log(`[TEST] recenterAccess: ${recenterAddr}`);
expect(recenterAddr).not.toBe('0x' + '0'.repeat(40));
console.log('[TEST] recenterAccess is set (not zero address)');
// Try calling recenter from an unauthorized address — should revert with "access denied"
const unauthorizedAddr = '0x1111111111111111111111111111111111111111';
const callResult = await rpcCallRaw('eth_call', [
{ from: unauthorizedAddr, to: lmAddress, data: RECENTER_SELECTOR },
'latest',
]);
expect(callResult.error).toBeDefined();
expect(callResult.error!.message).toContain('access denied');
console.log('[TEST] Unauthorized recenter correctly rejected with "access denied"');
});
test('recenter() enforces amplitude check', async () => {
const lmAddress = STACK_CONFIG.contracts.LiquidityManager;
// Read the recenterAccess address
const recenterAccessResult = (await rpcCall('eth_call', [
{ to: lmAddress, data: RECENTER_ACCESS_SELECTOR },
'latest',
])) as string;
const recenterAddr = '0x' + recenterAccessResult.slice(26);
// Call recenter from the authorized address without moving the price
// Should revert with "amplitude not reached" since price hasn't moved enough
const callResult = await rpcCallRaw('eth_call', [
{ from: recenterAddr, to: lmAddress, data: RECENTER_SELECTOR },
'latest',
]);
// The call should fail — either "amplitude not reached" or just revert
// (Pool state may vary, but it should not succeed without price movement)
expect(callResult.error).toBeDefined();
console.log(`[TEST] Recenter guard active: ${callResult.error!.message}`);
console.log('[TEST] Recenter correctly prevents no-op recentering');
});
});

View file

@ -0,0 +1,177 @@
import { expect, test } from '@playwright/test';
import { getStackConfig, validateStackHealthy } from '../setup/stack';
const STACK_CONFIG = getStackConfig();
const STACK_RPC_URL = STACK_CONFIG.rpcUrl;
// Solidity function selectors
const GET_LIQUIDITY_PARAMS_SELECTOR = '0xbd53c0dc'; // getLiquidityParams()
const POSITIONS_SELECTOR = '0xf86aafc0'; // positions(uint8)
// OptimizerV3 known bear-market parameters
const BEAR_ANCHOR_SHARE = 3n * 10n ** 17n; // 3e17 = 30%
const BEAR_ANCHOR_WIDTH = 100n;
const BEAR_DISCOVERY_DEPTH = 3n * 10n ** 17n; // 3e17
// Position stages
const STAGE_FLOOR = 0;
const STAGE_ANCHOR = 1;
const STAGE_DISCOVERY = 2;
// TICK_SPACING from ThreePositionStrategy
const TICK_SPACING = 200;
interface LiquidityParams {
capitalInefficiency: bigint;
anchorShare: bigint;
anchorWidth: bigint;
discoveryDepth: bigint;
}
async function rpcCall(method: string, params: unknown[]): Promise<unknown> {
const response = await fetch(STACK_RPC_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const data = await response.json();
if (data.error) throw new Error(`RPC error: ${data.error.message}`);
return data.result;
}
async function readLiquidityParams(optimizerAddress: string): Promise<LiquidityParams> {
const result = (await rpcCall('eth_call', [
{ to: optimizerAddress, data: GET_LIQUIDITY_PARAMS_SELECTOR },
'latest',
])) as string;
return {
capitalInefficiency: BigInt('0x' + result.slice(2, 66)),
anchorShare: BigInt('0x' + result.slice(66, 130)),
anchorWidth: BigInt('0x' + result.slice(130, 194)),
discoveryDepth: BigInt('0x' + result.slice(194, 258)),
};
}
function toInt24(val: bigint): number {
const n = Number(val & 0xffffffn);
return n >= 0x800000 ? n - 0x1000000 : n;
}
test.describe('Optimizer Integration', () => {
test.beforeAll(async () => {
await validateStackHealthy(STACK_CONFIG);
});
test('OptimizerV3 proxy returns valid bear-market parameters', async () => {
const optimizerAddress = STACK_CONFIG.contracts.OptimizerProxy;
if (!optimizerAddress) {
console.log(
'[TEST] SKIP: OptimizerProxy not in deployments-local.json (older deployment)',
);
test.skip();
return;
}
console.log(`[TEST] OptimizerProxy: ${optimizerAddress}`);
const params = await readLiquidityParams(optimizerAddress);
console.log(`[TEST] capitalInefficiency: ${params.capitalInefficiency}`);
console.log(`[TEST] anchorShare: ${params.anchorShare}`);
console.log(`[TEST] anchorWidth: ${params.anchorWidth}`);
console.log(`[TEST] discoveryDepth: ${params.discoveryDepth}`);
// With no staking activity, OptimizerV3 should return bear-market defaults
expect(params.capitalInefficiency).toBe(0n);
expect(params.anchorShare).toBe(BEAR_ANCHOR_SHARE);
expect(params.anchorWidth).toBe(BEAR_ANCHOR_WIDTH);
expect(params.discoveryDepth).toBe(BEAR_DISCOVERY_DEPTH);
console.log('[TEST] OptimizerV3 returns correct bear-market parameters');
});
test('bootstrap positions reflect optimizer anchorWidth=100 parameter', async () => {
const lmAddress = STACK_CONFIG.contracts.LiquidityManager;
const optimizerAddress = STACK_CONFIG.contracts.OptimizerProxy;
if (!optimizerAddress) {
test.skip();
return;
}
// Read optimizer params
const params = await readLiquidityParams(optimizerAddress);
const anchorWidth = Number(params.anchorWidth);
console.log(`[TEST] Optimizer anchorWidth: ${anchorWidth}`);
// Read anchor position from LM (created by bootstrap's recenter call)
const anchorResult = (await rpcCall('eth_call', [
{
to: lmAddress,
data: `${POSITIONS_SELECTOR}${'1'.padStart(64, '0')}`, // Stage.ANCHOR = 1
},
'latest',
])) as string;
const tickLower = toInt24(BigInt('0x' + anchorResult.slice(66, 130)));
const tickUpper = toInt24(BigInt('0x' + anchorResult.slice(130, 194)));
const anchorSpread = tickUpper - tickLower;
console.log(`[TEST] Anchor position: ticks=[${tickLower}, ${tickUpper}], spread=${anchorSpread}`);
// Verify the anchor spread matches the optimizer formula:
// anchorSpacing = TICK_SPACING + (34 * anchorWidth * TICK_SPACING / 100)
// For anchorWidth=100: anchorSpacing = 200 + (34 * 100 * 200 / 100) = 200 + 6800 = 7000
// Full anchor = 2 * anchorSpacing = 14000 ticks
const expectedSpacing = TICK_SPACING + (34 * anchorWidth * TICK_SPACING) / 100;
const expectedSpread = expectedSpacing * 2;
console.log(`[TEST] Expected anchor spread: ${expectedSpread} (anchorSpacing=${expectedSpacing})`);
expect(anchorSpread).toBe(expectedSpread);
console.log('[TEST] Anchor spread matches optimizer anchorWidth=100 formula');
});
test('all three positions have valid relative sizing', async () => {
const lmAddress = STACK_CONFIG.contracts.LiquidityManager;
const optimizerAddress = STACK_CONFIG.contracts.OptimizerProxy;
if (!optimizerAddress) {
test.skip();
return;
}
// Read all three positions
const positions = [];
const stageNames = ['FLOOR', 'ANCHOR', 'DISCOVERY'];
for (const stage of [STAGE_FLOOR, STAGE_ANCHOR, STAGE_DISCOVERY]) {
const stageHex = stage.toString(16).padStart(64, '0');
const result = (await rpcCall('eth_call', [
{ to: lmAddress, data: `${POSITIONS_SELECTOR}${stageHex}` },
'latest',
])) as string;
const liquidity = BigInt('0x' + result.slice(2, 66));
const tickLower = toInt24(BigInt('0x' + result.slice(66, 130)));
const tickUpper = toInt24(BigInt('0x' + result.slice(130, 194)));
const spread = tickUpper - tickLower;
positions.push({ liquidity, tickLower, tickUpper, spread });
console.log(`[TEST] ${stageNames[stage]}: spread=${spread}, liquidity=${liquidity}`);
}
// Floor should be narrow (TICK_SPACING width = 200 ticks)
expect(positions[0].spread).toBe(TICK_SPACING);
console.log('[TEST] Floor has expected narrow width (200 ticks)');
// Anchor should be wider than floor
expect(positions[1].spread).toBeGreaterThan(positions[0].spread);
console.log('[TEST] Anchor is wider than floor');
// Discovery should have significant spread
expect(positions[2].spread).toBeGreaterThan(0);
console.log('[TEST] Discovery has positive spread');
// Floor liquidity should be highest (concentrated in narrow range)
expect(positions[0].liquidity).toBeGreaterThan(positions[1].liquidity);
console.log('[TEST] Floor liquidity > anchor liquidity (as expected for concentrated position)');
console.log('[TEST] All position sizing validated against optimizer parameters');
});
});

View file

@ -13,6 +13,7 @@ export interface ContractAddresses {
Kraiken: string;
Stake: string;
LiquidityManager: string;
OptimizerProxy?: string;
}
export interface StackConfig {