diff --git a/.env.example b/.env.example index 812292f..7786f7a 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ PRIVATE_KEY=0x... ETHEREUM_RPC_URL=https://... -ETHEREUM_BACKUP_RPC_URL=https://... POLYGON_RPC_URL=https://... TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= diff --git a/.github/workflows/auto-release-pr.yaml b/.github/workflows/auto-release-pr.yaml new file mode 100644 index 0000000..cbb705b --- /dev/null +++ b/.github/workflows/auto-release-pr.yaml @@ -0,0 +1,70 @@ +name: Auto Release PR + +on: + push: + branches: [develop] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: auto-release-pr + cancel-in-progress: false + +jobs: + create-release-pr: + name: Create Release PR + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch main branch + run: git fetch origin main + + - name: Check for existing PR + id: check-pr + run: | + PR_COUNT=$(gh pr list --base main --head develop --state open --json number --jq 'length') + echo "pr_exists=$([[ $PR_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "::notice::Open PRs from develop to main: $PR_COUNT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Check for differences + id: check-diff + if: steps.check-pr.outputs.pr_exists == 'false' + run: | + DIFF_COUNT=$(git rev-list --count origin/main..origin/develop) + echo "has_changes=$([[ $DIFF_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + echo "commit_count=$DIFF_COUNT" >> $GITHUB_OUTPUT + echo "::notice::Commits ahead of main: $DIFF_COUNT" + + - name: Create Release PR + if: steps.check-pr.outputs.pr_exists == 'false' && steps.check-diff.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_COUNT: ${{ steps.check-diff.outputs.commit_count }} + run: | + printf '%s\n' \ + "## Automatic Release PR" \ + "" \ + "This PR was automatically created after changes were pushed to develop." \ + "" \ + "**Commits:** ${COMMIT_COUNT} new commit(s)" \ + "" \ + "### Checklist" \ + "- [ ] Review all changes" \ + "- [ ] Verify CI passes" \ + "- [ ] Approve and merge when ready for production" \ + > /tmp/pr-body.md + + gh pr create \ + --base main \ + --head develop \ + --title "Release: develop -> main" \ + --body-file /tmp/pr-body.md diff --git a/.github/workflows/rangekeeper-dev.yaml b/.github/workflows/rangekeeper-dev.yaml new file mode 100644 index 0000000..81cb34a --- /dev/null +++ b/.github/workflows/rangekeeper-dev.yaml @@ -0,0 +1,116 @@ +name: RangeKeeper DEV CI/CD + +on: + push: + branches: [develop] + workflow_dispatch: + +env: + DEPLOYMENT_ENABLED: false + DOCKER_TAGS: dfxswiss/rangekeeper:beta + AZURE_RESOURCE_GROUP: rg-dfx-api-dev + AZURE_STORAGE_ACCOUNT_NAME: stdfxapidev + AZURE_WORKSPACE_NAME: log-dfx-api-dev + CONTAINER_NAME: ci-dfx-rk-dev + DEPLOY_INFO: ${{ github.ref_name }}-${{ github.sha }} + +jobs: + build-and-deploy: + name: Build, test and deploy to DEV + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ env.DOCKER_TAGS }} + + - name: Log in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.DFX_DEV_CREDENTIALS }} + + - name: Get Storage Key + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + STORAGE_KEY=$(az storage account keys list \ + --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \ + --account-name ${{ env.AZURE_STORAGE_ACCOUNT_NAME }} \ + --query '[0].value' -o tsv) + echo "STORAGE_KEY=$STORAGE_KEY" >> $GITHUB_ENV + + - name: Get Log Analytics Info + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + LOG_WORKSPACE_ID=$(az monitor log-analytics workspace show \ + --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \ + --workspace-name ${{ env.AZURE_WORKSPACE_NAME }} \ + --query customerId -o tsv) + + LOG_WORKSPACE_KEY=$(az monitor log-analytics workspace get-shared-keys \ + --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \ + --workspace-name ${{ env.AZURE_WORKSPACE_NAME }} \ + --query primarySharedKey -o tsv) + echo "LOG_WORKSPACE_ID=$LOG_WORKSPACE_ID" >> $GITHUB_ENV + echo "LOG_WORKSPACE_KEY=$LOG_WORKSPACE_KEY" >> $GITHUB_ENV + + - name: Render deployment YAML + if: env.DEPLOYMENT_ENABLED == 'true' + uses: nowactions/envsubst@v1 + with: + input: ./aci-dev.yaml + output: ./rendered-aci-dev.yaml + env: + DEPLOY_INFO: ${{ env.DEPLOY_INFO }} + STORAGE_KEY: ${{ env.STORAGE_KEY }} + LOG_WORKSPACE_ID: ${{ env.LOG_WORKSPACE_ID }} + LOG_WORKSPACE_KEY: ${{ env.LOG_WORKSPACE_KEY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + PRIVATE_KEY: ${{ secrets.RK_DEV_PRIVATE_KEY }} + ETHEREUM_RPC_URL: ${{ secrets.RK_DEV_ETHEREUM_RPC_URL }} + POLYGON_RPC_URL: ${{ secrets.RK_DEV_POLYGON_RPC_URL }} + TELEGRAM_BOT_TOKEN: ${{ secrets.RK_DEV_TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.RK_DEV_TELEGRAM_CHAT_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.RK_DEV_DISCORD_WEBHOOK_URL }} + + - name: Stop Azure Container Instance + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true + + - name: Delete Azure Container Instance + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + az container delete --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --yes || true + + - name: Deploy container instance + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + az container create --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --file ./rendered-aci-dev.yaml + + - name: Stop container (disabled) + if: env.DEPLOYMENT_ENABLED != 'true' + run: | + az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true + + - name: Logout from Azure + run: | + az logout + if: always() diff --git a/.github/workflows/rangekeeper-prd.yaml b/.github/workflows/rangekeeper-prd.yaml new file mode 100644 index 0000000..a497af6 --- /dev/null +++ b/.github/workflows/rangekeeper-prd.yaml @@ -0,0 +1,116 @@ +name: RangeKeeper PRD CI/CD + +on: + push: + branches: [main] + workflow_dispatch: + +env: + DEPLOYMENT_ENABLED: false + DOCKER_TAGS: dfxswiss/rangekeeper:latest + AZURE_RESOURCE_GROUP: rg-dfx-api-prd + AZURE_STORAGE_ACCOUNT_NAME: stdfxapiprd + AZURE_WORKSPACE_NAME: log-dfx-api-prd + CONTAINER_NAME: ci-dfx-rk-prd + DEPLOY_INFO: ${{ github.ref_name }}-${{ github.sha }} + +jobs: + build-and-deploy: + name: Build, test and deploy to PRD + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ env.DOCKER_TAGS }} + + - name: Log in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.DFX_PRD_CREDENTIALS }} + + - name: Get Storage Key + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + STORAGE_KEY=$(az storage account keys list \ + --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \ + --account-name ${{ env.AZURE_STORAGE_ACCOUNT_NAME }} \ + --query '[0].value' -o tsv) + echo "STORAGE_KEY=$STORAGE_KEY" >> $GITHUB_ENV + + - name: Get Log Analytics Info + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + LOG_WORKSPACE_ID=$(az monitor log-analytics workspace show \ + --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \ + --workspace-name ${{ env.AZURE_WORKSPACE_NAME }} \ + --query customerId -o tsv) + + LOG_WORKSPACE_KEY=$(az monitor log-analytics workspace get-shared-keys \ + --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \ + --workspace-name ${{ env.AZURE_WORKSPACE_NAME }} \ + --query primarySharedKey -o tsv) + echo "LOG_WORKSPACE_ID=$LOG_WORKSPACE_ID" >> $GITHUB_ENV + echo "LOG_WORKSPACE_KEY=$LOG_WORKSPACE_KEY" >> $GITHUB_ENV + + - name: Render deployment YAML + if: env.DEPLOYMENT_ENABLED == 'true' + uses: nowactions/envsubst@v1 + with: + input: ./aci-prd.yaml + output: ./rendered-aci-prd.yaml + env: + DEPLOY_INFO: ${{ env.DEPLOY_INFO }} + STORAGE_KEY: ${{ env.STORAGE_KEY }} + LOG_WORKSPACE_ID: ${{ env.LOG_WORKSPACE_ID }} + LOG_WORKSPACE_KEY: ${{ env.LOG_WORKSPACE_KEY }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + PRIVATE_KEY: ${{ secrets.RK_PRD_PRIVATE_KEY }} + ETHEREUM_RPC_URL: ${{ secrets.RK_PRD_ETHEREUM_RPC_URL }} + POLYGON_RPC_URL: ${{ secrets.RK_PRD_POLYGON_RPC_URL }} + TELEGRAM_BOT_TOKEN: ${{ secrets.RK_PRD_TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.RK_PRD_TELEGRAM_CHAT_ID }} + DISCORD_WEBHOOK_URL: ${{ secrets.RK_PRD_DISCORD_WEBHOOK_URL }} + + - name: Stop Azure Container Instance + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true + + - name: Delete Azure Container Instance + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + az container delete --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --yes || true + + - name: Deploy container instance + if: env.DEPLOYMENT_ENABLED == 'true' + run: | + az container create --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --file ./rendered-aci-prd.yaml + + - name: Stop container (disabled) + if: env.DEPLOYMENT_ENABLED != 'true' + run: | + az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true + + - name: Logout from Azure + run: | + az logout + if: always() diff --git a/aci-dev.yaml b/aci-dev.yaml new file mode 100644 index 0000000..74faf75 --- /dev/null +++ b/aci-dev.yaml @@ -0,0 +1,59 @@ +apiVersion: 2023-05-01 +location: westeurope +name: ci-dfx-rk-dev +type: Microsoft.ContainerInstance/containerGroups +properties: + osType: Linux + containers: + - name: rk + properties: + image: dfxswiss/rangekeeper:beta + resources: + requests: + cpu: 0.5 + memoryInGb: 1.0 + ports: + - port: 3000 + environmentVariables: + # Secrets (from GitHub Secrets) + - name: PRIVATE_KEY + secureValue: '${PRIVATE_KEY}' + - name: ETHEREUM_RPC_URL + secureValue: '${ETHEREUM_RPC_URL}' + - name: POLYGON_RPC_URL + secureValue: '${POLYGON_RPC_URL}' + - name: TELEGRAM_BOT_TOKEN + secureValue: '${TELEGRAM_BOT_TOKEN}' + - name: TELEGRAM_CHAT_ID + secureValue: '${TELEGRAM_CHAT_ID}' + - name: DISCORD_WEBHOOK_URL + secureValue: '${DISCORD_WEBHOOK_URL}' + # Config (changeable) + - name: DEPLOY_INFO + value: '${DEPLOY_INFO}' + - name: LOG_LEVEL + value: 'info' + - name: HEALTH_PORT + value: '3000' + - name: MAX_TOTAL_LOSS_PERCENT + value: '10' + - name: DRY_RUN + value: 'false' + volumeMounts: + - name: volume + mountPath: /app/data + volumes: + - name: volume + azureFile: + shareName: ci-rk + storageAccountName: stdfxapidev + storageAccountKey: '${STORAGE_KEY}' + restartPolicy: Always + imageRegistryCredentials: + - server: index.docker.io + username: '${DOCKER_USERNAME}' + password: '${DOCKER_PASSWORD}' + diagnostics: + logAnalytics: + workspaceId: '${LOG_WORKSPACE_ID}' + workspaceKey: '${LOG_WORKSPACE_KEY}' diff --git a/aci-prd.yaml b/aci-prd.yaml new file mode 100644 index 0000000..f37cb35 --- /dev/null +++ b/aci-prd.yaml @@ -0,0 +1,59 @@ +apiVersion: 2023-05-01 +location: westeurope +name: ci-dfx-rk-prd +type: Microsoft.ContainerInstance/containerGroups +properties: + osType: Linux + containers: + - name: rk + properties: + image: dfxswiss/rangekeeper:latest + resources: + requests: + cpu: 0.5 + memoryInGb: 1.0 + ports: + - port: 3000 + environmentVariables: + # Secrets (from GitHub Secrets) + - name: PRIVATE_KEY + secureValue: '${PRIVATE_KEY}' + - name: ETHEREUM_RPC_URL + secureValue: '${ETHEREUM_RPC_URL}' + - name: POLYGON_RPC_URL + secureValue: '${POLYGON_RPC_URL}' + - name: TELEGRAM_BOT_TOKEN + secureValue: '${TELEGRAM_BOT_TOKEN}' + - name: TELEGRAM_CHAT_ID + secureValue: '${TELEGRAM_CHAT_ID}' + - name: DISCORD_WEBHOOK_URL + secureValue: '${DISCORD_WEBHOOK_URL}' + # Config (changeable) + - name: DEPLOY_INFO + value: '${DEPLOY_INFO}' + - name: LOG_LEVEL + value: 'info' + - name: HEALTH_PORT + value: '3000' + - name: MAX_TOTAL_LOSS_PERCENT + value: '10' + - name: DRY_RUN + value: 'false' + volumeMounts: + - name: volume + mountPath: /app/data + volumes: + - name: volume + azureFile: + shareName: ci-rk + storageAccountName: stdfxapiprd + storageAccountKey: '${STORAGE_KEY}' + restartPolicy: Always + imageRegistryCredentials: + - server: index.docker.io + username: '${DOCKER_USERNAME}' + password: '${DOCKER_PASSWORD}' + diagnostics: + logAnalytics: + workspaceId: '${LOG_WORKSPACE_ID}' + workspaceKey: '${LOG_WORKSPACE_KEY}' diff --git a/config/pools.yaml b/config/pools.yaml index 6e52da6..ebb5fc1 100644 --- a/config/pools.yaml +++ b/config/pools.yaml @@ -1,30 +1,29 @@ pools: - - id: "usdt-zchf-ethereum" + - id: 'usdt-zchf-ethereum' chain: - name: "ethereum" + name: 'ethereum' chainId: 1 - rpcUrl: "${ETHEREUM_RPC_URL}" - backupRpcUrls: - - "${ETHEREUM_BACKUP_RPC_URL}" + rpcUrl: '${ETHEREUM_RPC_URL}' + backupRpcUrls: [] pool: token0: - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - symbol: "USDT" - decimals: 6 - token1: - address: "0xB58906E27d85EFC9DD6f15A0234dF2e2a23e5847" - symbol: "ZCHF" + address: '0xB58906E27d85EFC9DD6f15A0234dF2e2a23e5847' + symbol: 'ZCHF' decimals: 18 + token1: + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + symbol: 'USDT' + decimals: 6 feeTier: 100 - nftManagerAddress: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88" - swapRouterAddress: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45" + nftManagerAddress: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88' + swapRouterAddress: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45' strategy: rangeWidthPercent: 3.0 rebalanceThresholdPercent: 80 minRebalanceIntervalMinutes: 30 maxGasCostUsd: 5.0 slippageTolerancePercent: 0.5 - expectedPriceRatio: 1.0 # expected token0/token1 price (for depeg detection) - depegThresholdPercent: 5.0 # alert if price deviates more than 5% + expectedPriceRatio: 1.0 # expected ZCHF/USDT price (for depeg detection) + depegThresholdPercent: 5.0 # alert if price deviates more than 5% monitoring: checkIntervalSeconds: 30 diff --git a/src/chain/contracts.ts b/src/chain/contracts.ts index a69b7fd..01c442b 100644 --- a/src/chain/contracts.ts +++ b/src/chain/contracts.ts @@ -1,4 +1,5 @@ -import { Contract, Wallet, BigNumber } from 'ethers'; +import { Contract, Wallet, BigNumber, ContractTransaction } from 'ethers'; +import { NonceTracker } from './nonce-tracker'; const ERC20_ABI = [ 'function balanceOf(address owner) view returns (uint256)', @@ -61,10 +62,17 @@ export async function ensureApproval( spender: string, owner: string, amount: BigNumber, + nonceTracker?: NonceTracker, ): Promise { const allowance: BigNumber = await tokenContract.allowance(owner, spender); if (allowance.lt(amount)) { - const tx = await tokenContract.approve(spender, BigNumber.from(2).pow(256).sub(1)); + const nonceOverride = nonceTracker ? { nonce: nonceTracker.getNextNonce() } : {}; + const tx: ContractTransaction = await tokenContract.approve( + spender, + BigNumber.from(2).pow(256).sub(1), + nonceOverride, + ); await tx.wait(); + nonceTracker?.confirmNonce(); } } diff --git a/src/chain/evm-provider.ts b/src/chain/evm-provider.ts index 18ea413..94bc8de 100644 --- a/src/chain/evm-provider.ts +++ b/src/chain/evm-provider.ts @@ -90,7 +90,9 @@ export function getWallet(privateKey: string, provider: providers.JsonRpcProvide return new ethers.Wallet(privateKey, provider); } -export async function verifyConnection(provider: providers.JsonRpcProvider): Promise<{ chainId: number; blockNumber: number }> { +export async function verifyConnection( + provider: providers.JsonRpcProvider, +): Promise<{ chainId: number; blockNumber: number }> { const logger = getLogger(); const [network, blockNumber] = await Promise.all([provider.getNetwork(), provider.getBlockNumber()]); logger.info({ chainId: network.chainId, blockNumber }, 'Connected to chain'); diff --git a/src/chain/gas-oracle.ts b/src/chain/gas-oracle.ts index e60f740..82f43fb 100644 --- a/src/chain/gas-oracle.ts +++ b/src/chain/gas-oracle.ts @@ -8,9 +8,12 @@ export interface GasInfo { isEip1559: boolean; } +const RING_BUFFER_SIZE = 20; + export class GasOracle { private readonly logger = getLogger(); private baselineGasPrice: number | undefined; + private readonly gasPriceBuffer: number[] = []; async getGasInfo(provider: providers.JsonRpcProvider): Promise { try { @@ -41,11 +44,15 @@ export class GasOracle { } private updateBaseline(currentGwei: number): void { - if (!this.baselineGasPrice) { - this.baselineGasPrice = currentGwei; - } else { - this.baselineGasPrice = this.baselineGasPrice * 0.95 + currentGwei * 0.05; + this.gasPriceBuffer.push(currentGwei); + if (this.gasPriceBuffer.length > RING_BUFFER_SIZE) { + this.gasPriceBuffer.shift(); } + + // Use median of the ring buffer as baseline + const sorted = [...this.gasPriceBuffer].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + this.baselineGasPrice = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } isGasSpike(currentGwei: number, multiplier = 10): boolean { diff --git a/src/chain/nonce-tracker.ts b/src/chain/nonce-tracker.ts index 198eefc..4662521 100644 --- a/src/chain/nonce-tracker.ts +++ b/src/chain/nonce-tracker.ts @@ -12,10 +12,11 @@ export class NonceTracker { async initialize(persistedNonce?: number): Promise { const onChainNonce = await this.getProvider().getTransactionCount(this.walletAddress, 'latest'); - this.currentNonce = persistedNonce !== undefined - ? Math.max(persistedNonce, onChainNonce) - : onChainNonce; - this.logger.info({ walletAddress: this.walletAddress, nonce: this.currentNonce, persistedNonce, onChainNonce }, 'Nonce tracker initialized'); + this.currentNonce = persistedNonce !== undefined ? Math.max(persistedNonce, onChainNonce) : onChainNonce; + this.logger.info( + { walletAddress: this.walletAddress, nonce: this.currentNonce, persistedNonce, onChainNonce }, + 'Nonce tracker initialized', + ); } getNextNonce(): number { @@ -39,6 +40,9 @@ export class NonceTracker { async syncOnFailover(): Promise { const onChainNonce = await this.getProvider().getTransactionCount(this.walletAddress, 'latest'); this.currentNonce = Math.max(this.currentNonce ?? 0, onChainNonce); - this.logger.info({ walletAddress: this.walletAddress, nonce: this.currentNonce, onChainNonce }, 'Nonce synced on failover'); + this.logger.info( + { walletAddress: this.walletAddress, nonce: this.currentNonce, onChainNonce }, + 'Nonce synced on failover', + ); } } diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 6cdaa9c..065c490 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; import dotenv from 'dotenv'; -dotenv.config(); +dotenv.config({ path: '/app/data/.env' }); // Azure File Share +dotenv.config(); // Fallback: .env in project root (local dev) const envSchema = z.object({ PRIVATE_KEY: z.string().startsWith('0x').min(66), @@ -28,9 +29,7 @@ export function loadEnvConfig(): EnvConfig { const result = envSchema.safeParse(process.env); if (!result.success) { - const formatted = result.error.issues - .map((i) => ` ${i.path.join('.')}: ${i.message}`) - .join('\n'); + const formatted = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); throw new Error(`Environment validation failed:\n${formatted}`); } diff --git a/src/config/pool.config.ts b/src/config/pool.config.ts index 70ac075..c6697e4 100644 --- a/src/config/pool.config.ts +++ b/src/config/pool.config.ts @@ -1,10 +1,14 @@ import { readFileSync } from 'fs'; import { parse } from 'yaml'; import { z } from 'zod'; +import { ethers } from 'ethers'; import path from 'path'; const tokenSchema = z.object({ - address: z.string().regex(/^0x[a-fA-F0-9]{40}$/), + address: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/) + .transform((addr) => ethers.utils.getAddress(addr.toLowerCase())), symbol: z.string().min(1), decimals: z.number().int().min(0).max(18), }); @@ -19,11 +23,20 @@ const chainSchema = z.object({ const poolSchema = z.object({ token0: tokenSchema, token1: tokenSchema, - feeTier: z.number().int().refine((v) => [100, 500, 3000, 10000].includes(v), { - message: 'feeTier must be one of: 100, 500, 3000, 10000', - }), - nftManagerAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/), - swapRouterAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/), + feeTier: z + .number() + .int() + .refine((v) => [100, 500, 3000, 10000].includes(v), { + message: 'feeTier must be one of: 100, 500, 3000, 10000', + }), + nftManagerAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/) + .transform((addr) => ethers.utils.getAddress(addr.toLowerCase())), + swapRouterAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/) + .transform((addr) => ethers.utils.getAddress(addr.toLowerCase())), }); const strategySchema = z.object({ @@ -85,9 +98,7 @@ function deepResolveEnvVars(obj: unknown, parentKey?: string): unknown { return result === UNRESOLVED ? undefined : result; } if (Array.isArray(obj)) { - return obj - .map((item) => deepResolveEnvVars(item, parentKey)) - .filter((item) => item !== undefined); + return obj.map((item) => deepResolveEnvVars(item, parentKey)).filter((item) => item !== undefined); } if (obj !== null && typeof obj === 'object') { const result: Record = {}; @@ -107,11 +118,27 @@ export function loadPoolConfigs(configPath?: string): PoolEntry[] { const result = poolsFileSchema.safeParse(resolved); if (!result.success) { - const formatted = result.error.issues - .map((i) => ` ${i.path.join('.')}: ${i.message}`) - .join('\n'); + const formatted = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n'); throw new Error(`Pool config validation failed:\n${formatted}`); } - return result.data.pools; + const pools = result.data.pools; + + const ids = pools.map((p) => p.id); + const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i); + if (duplicates.length > 0) { + throw new Error(`Duplicate pool IDs found: ${[...new Set(duplicates)].join(', ')}`); + } + + for (const p of pools) { + if (p.pool.token0.address.toLowerCase() >= p.pool.token1.address.toLowerCase()) { + throw new Error( + `Pool "${p.id}": token0 address must be less than token1 address (Uniswap V3 requirement). ` + + `Got token0=${p.pool.token0.address} (${p.pool.token0.symbol}), token1=${p.pool.token1.address} (${p.pool.token1.symbol}). ` + + `Swap them in pools.yaml.`, + ); + } + } + + return pools; } diff --git a/src/core/band-manager.ts b/src/core/band-manager.ts index b9d08af..7e8e1bb 100644 --- a/src/core/band-manager.ts +++ b/src/core/band-manager.ts @@ -44,7 +44,7 @@ export class BandManager { // Safe zone: middle bands (index 2, 3, 4 for 7 bands) const count = this.bands.length; const safeStart = Math.floor(count / 2) - 1; // 2 - const safeEnd = Math.floor(count / 2) + 1; // 4 + const safeEnd = Math.floor(count / 2) + 1; // 4 return idx >= safeStart && idx <= safeEnd; } @@ -91,7 +91,9 @@ export class BandManager { removeBand(tokenId: BigNumber): void { this.bands = this.bands.filter((b) => !b.tokenId.eq(tokenId)); // Re-index - this.bands.forEach((b, i) => { b.index = i; }); + this.bands.forEach((b, i) => { + b.index = i; + }); } addBand(band: Omit, position: 'start' | 'end'): void { @@ -101,7 +103,9 @@ export class BandManager { this.bands.push({ ...band, index: this.bands.length }); } // Re-index - this.bands.forEach((b, i) => { b.index = i; }); + this.bands.forEach((b, i) => { + b.index = i; + }); } getOverallRange(): { tickLower: number; tickUpper: number } | undefined { diff --git a/src/core/dry-run-position-manager.ts b/src/core/dry-run-position-manager.ts index b21316e..523812a 100644 --- a/src/core/dry-run-position-manager.ts +++ b/src/core/dry-run-position-manager.ts @@ -1,6 +1,13 @@ import { BigNumber } from 'ethers'; import { getLogger } from '../util/logger'; -import { PositionManager, MintParams, MintResult, RemoveResult, PositionInfo, WalletProvider } from './position-manager'; +import { + PositionManager, + MintParams, + MintResult, + RemoveResult, + PositionInfo, + WalletProvider, +} from './position-manager'; interface VirtualPosition { tokenId: BigNumber; @@ -74,10 +81,7 @@ export class DryRunPositionManager extends PositionManager { if (virtualPos) { this.virtualPositions.delete(key); - this.dryLogger.info( - { tokenId: key }, - '[DRY RUN] Virtual position removed', - ); + this.dryLogger.info({ tokenId: key }, '[DRY RUN] Virtual position removed'); return { amount0: virtualPos.amount0, @@ -93,10 +97,7 @@ export class DryRunPositionManager extends PositionManager { } // On-chain position — simulate removal by reading its state - this.dryLogger.info( - { tokenId: key }, - '[DRY RUN] Simulating removal of on-chain position', - ); + this.dryLogger.info({ tokenId: key }, '[DRY RUN] Simulating removal of on-chain position'); const pos = await super.getPosition(tokenId); return { diff --git a/src/core/position-manager.ts b/src/core/position-manager.ts index ff3b6f9..dc02eea 100644 --- a/src/core/position-manager.ts +++ b/src/core/position-manager.ts @@ -72,18 +72,21 @@ export class PositionManager { const token0 = getErc20Contract(token0Address, w); const token1 = getErc20Contract(token1Address, w); - await Promise.all([ - ensureApproval(token0, this.nftManagerAddress, w.address, constants.MaxUint256), - ensureApproval(token1, this.nftManagerAddress, w.address, constants.MaxUint256), - ]); + // Run approvals sequentially when using NonceTracker to avoid nonce conflicts + if (this.nonceTracker) { + await ensureApproval(token0, this.nftManagerAddress, w.address, constants.MaxUint256, this.nonceTracker); + await ensureApproval(token1, this.nftManagerAddress, w.address, constants.MaxUint256, this.nonceTracker); + } else { + await Promise.all([ + ensureApproval(token0, this.nftManagerAddress, w.address, constants.MaxUint256), + ensureApproval(token1, this.nftManagerAddress, w.address, constants.MaxUint256), + ]); + } this.logger.info('Token approvals confirmed for NFT Manager'); } async mint(params: MintParams): Promise { - const slippageMul = 1 - params.slippagePercent / 100; - const amount0Min = params.amount0Desired.mul(Math.floor(slippageMul * 10000)).div(10000); - const amount1Min = params.amount1Desired.mul(Math.floor(slippageMul * 10000)).div(10000); const deadline = Math.floor(Date.now() / 1000) + 300; // 5 min this.logger.info( @@ -97,34 +100,61 @@ export class PositionManager { ); const nftManager = this.nftManager; + + // Simulate mint to get actual expected amounts (handles out-of-range bands correctly) + const simulated = await nftManager.callStatic.mint({ + token0: params.token0, + token1: params.token1, + fee: params.fee, + tickLower: params.tickLower, + tickUpper: params.tickUpper, + amount0Desired: params.amount0Desired, + amount1Desired: params.amount1Desired, + amount0Min: 0, + amount1Min: 0, + recipient: params.recipient, + deadline, + }); + + // Apply slippage to simulated amounts (not desired amounts) + const slippageMul = Math.floor((1 - params.slippagePercent / 100) * 10000); + const amount0Min = BigNumber.from(simulated.amount0).mul(slippageMul).div(10000); + const amount1Min = BigNumber.from(simulated.amount1).mul(slippageMul).div(10000); + const nonceOverride = this.nonceTracker ? { nonce: this.nonceTracker.getNextNonce() } : {}; const tx: ContractTransaction = await withRetry( () => - nftManager.mint({ - token0: params.token0, - token1: params.token1, - fee: params.fee, - tickLower: params.tickLower, - tickUpper: params.tickUpper, - amount0Desired: params.amount0Desired, - amount1Desired: params.amount1Desired, - amount0Min, - amount1Min, - recipient: params.recipient, - deadline, - }, nonceOverride), + nftManager.mint( + { + token0: params.token0, + token1: params.token1, + fee: params.fee, + tickLower: params.tickLower, + tickUpper: params.tickUpper, + amount0Desired: params.amount0Desired, + amount1Desired: params.amount1Desired, + amount0Min, + amount1Min, + recipient: params.recipient, + deadline, + }, + nonceOverride, + ), 'mint', ); const receipt = await tx.wait(); + this.nonceTracker?.confirmNonce(); if (receipt.status === 0) { throw new Error('Mint transaction reverted on-chain'); } - this.nonceTracker?.confirmNonce(); const event = receipt.events?.find((e: { event?: string }) => e.event === 'IncreaseLiquidity'); if (!event?.args) { - this.logger.error({ txHash: receipt.transactionHash, logs: receipt.logs?.length }, 'IncreaseLiquidity event not found in mint receipt'); + this.logger.error( + { txHash: receipt.transactionHash, logs: receipt.logs?.length }, + 'IncreaseLiquidity event not found in mint receipt', + ); throw new Error(`Mint succeeded but IncreaseLiquidity event not found (tx: ${receipt.transactionHash})`); } @@ -153,7 +183,10 @@ export class PositionManager { const w = this.wallet; const nftManager = this.nftManager; - this.logger.info({ tokenId: tokenId.toString(), liquidity: liquidity.toString(), slippagePercent }, 'Removing position'); + this.logger.info( + { tokenId: tokenId.toString(), liquidity: liquidity.toString(), slippagePercent }, + 'Removing position', + ); // Query expected amounts to calculate slippage-protected minimums const amounts = await nftManager.callStatic.decreaseLiquidity({ @@ -171,20 +204,23 @@ export class PositionManager { const decreaseNonce = this.nonceTracker ? { nonce: this.nonceTracker.getNextNonce() } : {}; const decreaseTx: ContractTransaction = await withRetry( () => - nftManager.decreaseLiquidity({ - tokenId, - liquidity, - amount0Min, - amount1Min, - deadline, - }, decreaseNonce), + nftManager.decreaseLiquidity( + { + tokenId, + liquidity, + amount0Min, + amount1Min, + deadline, + }, + decreaseNonce, + ), 'decreaseLiquidity', ); const decreaseReceipt = await decreaseTx.wait(); + this.nonceTracker?.confirmNonce(); if (decreaseReceipt.status === 0) { throw new Error('decreaseLiquidity transaction reverted on-chain'); } - this.nonceTracker?.confirmNonce(); const decreaseEvent = decreaseReceipt.events?.find((e: { event?: string }) => e.event === 'DecreaseLiquidity'); if (!decreaseEvent?.args) { this.logger.error({ txHash: decreaseReceipt.transactionHash }, 'DecreaseLiquidity event not found'); @@ -192,23 +228,37 @@ export class PositionManager { } // Step 2: Collect all tokens (including fees) + // Use higher retry count since tokens are stuck in NFT manager if collect fails after decreaseLiquidity const maxUint128 = BigNumber.from(2).pow(128).sub(1); const collectNonce = this.nonceTracker ? { nonce: this.nonceTracker.getNextNonce() } : {}; - const collectTx: ContractTransaction = await withRetry( - () => - nftManager.collect({ - tokenId, - recipient: w.address, - amount0Max: maxUint128, - amount1Max: maxUint128, - }, collectNonce), - 'collect', - ); - const collectReceipt = await collectTx.wait(); - if (collectReceipt.status === 0) { - throw new Error('collect transaction reverted on-chain'); + let collectReceipt; + try { + const collectTx: ContractTransaction = await withRetry( + () => + nftManager.collect( + { + tokenId, + recipient: w.address, + amount0Max: maxUint128, + amount1Max: maxUint128, + }, + collectNonce, + ), + 'collect', + { maxRetries: 5, baseDelayMs: 2000, maxDelayMs: 30000 }, + ); + collectReceipt = await collectTx.wait(); + this.nonceTracker?.confirmNonce(); + if (collectReceipt.status === 0) { + throw new Error('collect transaction reverted on-chain'); + } + } catch (collectErr) { + this.logger.error( + { tokenId: tokenId.toString(), err: collectErr }, + 'CRITICAL: collect failed after decreaseLiquidity — tokens may be stuck in NFT manager. Manual collect required.', + ); + throw collectErr; } - this.nonceTracker?.confirmNonce(); const collectEvent = collectReceipt.events?.find((e: { event?: string }) => e.event === 'Collect'); if (!collectEvent?.args) { this.logger.error({ txHash: collectReceipt.transactionHash }, 'Collect event not found'); @@ -224,10 +274,10 @@ export class PositionManager { const burnNonce = this.nonceTracker ? { nonce: this.nonceTracker.getNextNonce() } : {}; const burnTx: ContractTransaction = await withRetry(() => nftManager.burn(tokenId, burnNonce), 'burn'); const burnReceipt = await burnTx.wait(); + this.nonceTracker?.confirmNonce(); if (burnReceipt.status === 0) { throw new Error('burn transaction reverted on-chain'); } - this.nonceTracker?.confirmNonce(); const result: RemoveResult = { amount0: principalAmount0, diff --git a/src/core/range-calculator.ts b/src/core/range-calculator.ts index ba97c12..618b42f 100644 --- a/src/core/range-calculator.ts +++ b/src/core/range-calculator.ts @@ -81,10 +81,7 @@ export function calculateBands( const totalTickRange = tickOffset * 2; const rawBandWidth = Math.floor(totalTickRange / bandCount); - const bandTickWidth = Math.max( - Math.floor(rawBandWidth / tickSpacing) * tickSpacing, - tickSpacing, - ); + const bandTickWidth = Math.max(Math.floor(rawBandWidth / tickSpacing) * tickSpacing, tickSpacing); // Center band (index 3 for 7 bands) should contain centerTick const centerBandIndex = Math.floor(bandCount / 2); @@ -102,8 +99,12 @@ export function calculateBands( bands.push({ index: i, tickLower, tickUpper }); } - if (bands[bands.length - 1].tickLower >= bands[bands.length - 1].tickUpper) { - throw new Error('Band calculation produced invalid range'); + for (const band of bands) { + if (band.tickLower >= band.tickUpper) { + throw new Error( + `Band ${band.index} has invalid range: tickLower ${band.tickLower} >= tickUpper ${band.tickUpper}`, + ); + } } return { diff --git a/src/core/rebalance-engine.ts b/src/core/rebalance-engine.ts index 3f8f74a..9dc5ffb 100644 --- a/src/core/rebalance-engine.ts +++ b/src/core/rebalance-engine.ts @@ -1,4 +1,4 @@ -import { BigNumber, providers } from 'ethers'; +import { BigNumber, providers, utils } from 'ethers'; import { getLogger } from '../util/logger'; import { PoolMonitor, PoolState, PositionRange } from './pool-monitor'; import { PositionManager, RemoveResult } from './position-manager'; @@ -16,14 +16,22 @@ import { PoolEntry } from '../config'; import { getErc20Contract } from '../chain/contracts'; import { GasOracle, estimateGasCostUsd } from '../chain/gas-oracle'; import { NonceTracker } from '../chain/nonce-tracker'; -import { tickToPrice } from '../util/tick-math'; +import { tickToAdjustedPrice } from '../util/tick-math'; import { Wallet } from 'ethers'; import { BandManager, Band, TriggerDirection } from './band-manager'; -export type RebalanceState = 'IDLE' | 'MONITORING' | 'EVALUATING' | 'WITHDRAWING' | 'SWAPPING' | 'MINTING' | 'ERROR' | 'STOPPED'; +export type RebalanceState = + | 'IDLE' + | 'MONITORING' + | 'EVALUATING' + | 'WITHDRAWING' + | 'SWAPPING' + | 'MINTING' + | 'ERROR' + | 'STOPPED'; const REBALANCE_GAS_ESTIMATE = 800_000; -const ETH_PRICE_USD_FALLBACK = 3000; +const MIN_OPERATIONAL_BANDS = 5; export interface RebalanceContext { poolEntry: PoolEntry; @@ -82,7 +90,7 @@ export class RebalanceEngine { const savedState = stateStore.getPoolState(poolEntry.id); - // Load band state from persistence + // Load band state from persistence and validate against on-chain if (savedState?.bands?.length) { const bands: Band[] = savedState.bands.map((b, i) => ({ index: i, @@ -90,9 +98,49 @@ export class RebalanceEngine { tickLower: b.tickLower, tickUpper: b.tickUpper, })); - this.bandManager.setBands(bands, savedState.bandTickWidth ?? 0); + + // Validate each band exists on-chain (protects against crash during emergency withdraw) + const validBands: Band[] = []; + let validationAborted = false; + for (const band of bands) { + try { + const pos = await positionManager.getPosition(band.tokenId); + if (!pos.liquidity.isZero()) { + validBands.push(band); + } else { + this.logger.warn({ tokenId: band.tokenId.toString() }, 'Dropping band with zero liquidity from state'); + } + } catch (err) { + const msg = err instanceof Error ? err.message.toLowerCase() : ''; + if (msg.includes('invalid token id') || msg.includes('nonexistent token')) { + this.logger.warn({ tokenId: band.tokenId.toString() }, 'Dropping orphaned band not found on-chain'); + } else { + // RPC or transient error — abort validation to prevent data loss + this.logger.error({ err }, 'Band validation failed due to RPC error, keeping all bands from state'); + validationAborted = true; + break; + } + } + } + + if (validationAborted) { + this.bandManager.setBands(bands, savedState.bandTickWidth ?? 0); + this.logger.info({ bandCount: bands.length }, 'Restored band state from disk (validation skipped)'); + } else { + if (validBands.length !== bands.length) { + this.logger.warn( + { loaded: bands.length, valid: validBands.length }, + 'Removed stale bands during on-chain validation', + ); + validBands.forEach((b, i) => (b.index = i)); + } + if (validBands.length > 0) { + this.bandManager.setBands(validBands, savedState.bandTickWidth ?? 0); + this.persistState(stateStore, poolEntry.id); + } + this.logger.info({ bandCount: validBands.length }, 'Restored band state from disk'); + } this.lastRebalanceTime = savedState.lastRebalanceTime ?? 0; - this.logger.info({ bandCount: bands.length }, 'Restored band state from disk'); } // Verify pending TXs from previous run @@ -102,7 +150,10 @@ export class RebalanceEngine { try { const receipt = await provider.getTransactionReceipt(hash); if (receipt) { - this.logger.info({ txHash: hash, status: receipt.status }, receipt.status === 1 ? 'Pending TX confirmed' : 'Pending TX reverted'); + this.logger.info( + { txHash: hash, status: receipt.status }, + receipt.status === 1 ? 'Pending TX confirmed' : 'Pending TX reverted', + ); } else { this.logger.warn({ txHash: hash }, 'Pending TX not found on-chain'); } @@ -119,9 +170,17 @@ export class RebalanceEngine { // Recover from incomplete rebalance if (savedState?.rebalanceStage) { - this.logger.warn({ poolId: poolEntry.id, stage: savedState.rebalanceStage }, 'Recovering from incomplete rebalance'); + this.logger.warn( + { poolId: poolEntry.id, stage: savedState.rebalanceStage }, + 'Recovering from incomplete rebalance', + ); this.bandManager.setBands([], 0); - stateStore.updatePoolState(poolEntry.id, { rebalanceStage: undefined, pendingTxHashes: undefined, bands: undefined, bandTickWidth: undefined }); + stateStore.updatePoolState(poolEntry.id, { + rebalanceStage: undefined, + pendingTxHashes: undefined, + bands: undefined, + bandTickWidth: undefined, + }); stateStore.save(); await notifier.notify(`RECOVERY: ${poolEntry.id} recovering from stage ${savedState.rebalanceStage}`); } @@ -145,15 +204,31 @@ export class RebalanceEngine { tickUpper: p.tickUpper, })); if (activeBands.length > 0) { - const bandWidth = activeBands.length > 1 - ? activeBands[1].tickLower - activeBands[0].tickLower - : activeBands[0].tickUpper - activeBands[0].tickLower; + // Use individual band width (tickUpper - tickLower) instead of inter-band distance, + // which would be wrong if bands are non-contiguous after partial emergency withdraw + const bandWidth = activeBands[0].tickUpper - activeBands[0].tickLower; this.bandManager.setBands(activeBands, bandWidth); - this.logger.info({ bandCount: activeBands.length }, 'Found existing on-chain positions as bands'); + this.logger.info({ bandCount: activeBands.length, bandWidth }, 'Found existing on-chain positions as bands'); } } } + // Guard: band count too low for correct trigger logic (safe zone overlaps trigger zone) + const loadedBandCount = this.bandManager.getBandCount(); + if (loadedBandCount > 0 && loadedBandCount < MIN_OPERATIONAL_BANDS) { + this.logger.error( + { bandCount: loadedBandCount, minRequired: MIN_OPERATIONAL_BANDS }, + 'Band count below minimum for safe trigger logic — manual intervention required', + ); + await notifier.notify( + `CRITICAL: Only ${loadedBandCount} bands remaining (minimum ${MIN_OPERATIONAL_BANDS} needed). ` + + 'Engine stopped to prevent silent inactivity. Manual intervention required.', + ); + this.ctx.emergencyStop.trigger(`Band count ${loadedBandCount} below minimum ${MIN_OPERATIONAL_BANDS}`, 'manual'); + this.setState('STOPPED'); + return; + } + // Ensure token approvals for both NFT manager and swap router await positionManager.approveTokens(pool.token0.address, pool.token1.address); await this.ctx.swapExecutor.approveTokens(pool.token0.address, pool.token1.address); @@ -162,8 +237,20 @@ export class RebalanceEngine { } async onPriceUpdate(poolState: PoolState): Promise { + // Auto-recovery: if emergency stop has cleared, transition back to monitoring + if ((this.state === 'ERROR' || this.state === 'STOPPED') && !this.ctx.emergencyStop.isStopped()) { + this.logger.info({ previousState: this.state }, 'Auto-recovered after emergency stop cooldown'); + this.consecutiveErrors = 0; + this.setState('MONITORING'); + } + if (this.state === 'STOPPED' || this.state === 'ERROR') return; if (this.state !== 'MONITORING' && this.state !== 'IDLE') return; + if (this.rebalanceLock) return; + if (this.ctx.emergencyStop.isStopped()) { + this.setState('STOPPED'); + return; + } const { poolEntry } = this.ctx; @@ -180,7 +267,7 @@ export class RebalanceEngine { }); // Check depeg - if (this.checkDepeg(poolState)) return; + if (await this.checkDepeg(poolState)) return; // No bands yet → mint initial bands if (this.bandManager.getBandCount() === 0) { @@ -198,33 +285,46 @@ export class RebalanceEngine { } } - private checkDepeg(poolState: PoolState): boolean { + private async checkDepeg(poolState: PoolState): Promise { const { poolEntry, emergencyStop, notifier } = this.ctx; const { strategy } = poolEntry; if (!strategy.expectedPriceRatio) return false; - const currentPrice = tickToPrice(poolState.tick); - const deviation = Math.abs(currentPrice - strategy.expectedPriceRatio) / strategy.expectedPriceRatio * 100; + const { pool } = poolEntry; + const currentPrice = tickToAdjustedPrice(poolState.tick, pool.token0.decimals, pool.token1.decimals); + const deviation = (Math.abs(currentPrice - strategy.expectedPriceRatio) / strategy.expectedPriceRatio) * 100; const threshold = strategy.depegThresholdPercent ?? 5; if (deviation > threshold) { this.logger.error( - { poolId: poolEntry.id, currentPrice, expectedPrice: strategy.expectedPriceRatio, deviation: deviation.toFixed(2) }, + { + poolId: poolEntry.id, + currentPrice, + expectedPrice: strategy.expectedPriceRatio, + deviation: deviation.toFixed(2), + }, 'TOKEN DEPEG DETECTED', ); - emergencyStop.trigger(`Token depeg: price ${currentPrice.toFixed(6)} deviates ${deviation.toFixed(2)}% from expected ${strategy.expectedPriceRatio}`); - notifier.notify( - `ALERT: DEPEG detected for ${poolEntry.id}!\n` + - `Current price: ${currentPrice.toFixed(6)}\n` + - `Expected: ${strategy.expectedPriceRatio}\n` + - `Deviation: ${deviation.toFixed(2)}%\n` + - `Action: closing all bands and stopping bot`, - ).catch(() => {}); - - this.emergencyWithdraw().catch((err) => { + emergencyStop.trigger( + `Token depeg: price ${currentPrice.toFixed(6)} deviates ${deviation.toFixed(2)}% from expected ${strategy.expectedPriceRatio}`, + 'depeg', + ); + notifier + .notify( + `ALERT: DEPEG detected for ${poolEntry.id}!\n` + + `Current price: ${currentPrice.toFixed(6)}\n` + + `Expected: ${strategy.expectedPriceRatio}\n` + + `Deviation: ${deviation.toFixed(2)}%\n` + + `Action: closing all bands and stopping bot`, + ) + .catch(() => {}); + + try { + await this.emergencyWithdraw(); + } catch (err) { this.logger.error({ err }, 'Failed emergency withdraw on depeg'); - }); + } return true; } @@ -240,35 +340,62 @@ export class RebalanceEngine { this.rebalanceLock = true; this.setState('WITHDRAWING'); + const totalBands = bands.length; + let removedCount = 0; try { for (const band of bands) { - const pos = await positionManager.getPosition(band.tokenId); - if (!pos.liquidity.isZero()) { - await positionManager.removePosition(band.tokenId, pos.liquidity, strategy.slippageTolerancePercent); + try { + const pos = await positionManager.getPosition(band.tokenId); + if (!pos.liquidity.isZero()) { + await positionManager.removePosition(band.tokenId, pos.liquidity, strategy.slippageTolerancePercent); + } + this.bandManager.removeBand(band.tokenId); + removedCount++; + this.persistState(stateStore, poolEntry.id); + } catch (bandErr) { + this.logger.error( + { tokenId: band.tokenId.toString(), err: bandErr }, + 'Failed to remove band during emergency withdraw, skipping', + ); } } historyLogger.log({ type: OperationType.EMERGENCY_STOP, poolId: poolEntry.id, - bandCount: bands.length, + bandCount: totalBands, + removedCount, }); - await notifier.notify( - `EMERGENCY: All ${bands.length} bands closed for ${poolEntry.id}\n` + - `Reason: ${this.ctx.emergencyStop.getReason() ?? 'unknown'}\n` + - `Action: bot stopped, manual intervention required`, - ); - - this.bandManager.setBands([], 0); - this.persistState(stateStore, poolEntry.id); + if (removedCount < totalBands) { + const remaining = this.bandManager.getBandCount(); + await notifier + .notify( + `CRITICAL: Emergency withdraw PARTIAL for ${poolEntry.id}!\n` + + `Removed ${removedCount}/${totalBands} bands, ${remaining} bands still on-chain\n` + + `Reason: ${this.ctx.emergencyStop.getReason() ?? 'unknown'}\n` + + `Manual intervention required immediately`, + ) + .catch(() => {}); + } else { + await notifier + .notify( + `EMERGENCY: All ${totalBands} bands closed for ${poolEntry.id}\n` + + `Reason: ${this.ctx.emergencyStop.getReason() ?? 'unknown'}\n` + + `Action: bot stopped, manual intervention required`, + ) + .catch(() => {}); + } } catch (err) { this.logger.error({ err }, 'Emergency withdraw failed'); - await notifier.notify( - `CRITICAL: Emergency withdraw FAILED for ${poolEntry.id}!\n` + - `Error: ${err instanceof Error ? err.message : String(err)}\n` + - `Manual intervention required immediately`, - ).catch(() => {}); + await notifier + .notify( + `CRITICAL: Emergency withdraw FAILED for ${poolEntry.id}!\n` + + `Error: ${err instanceof Error ? err.message : String(err)}\n` + + `Removed ${removedCount}/${totalBands} bands before failure\n` + + `Manual intervention required immediately`, + ) + .catch(() => {}); } finally { this.rebalanceLock = false; } @@ -293,8 +420,12 @@ export class RebalanceEngine { this.logger.warn('Gas spike but position is out of range, proceeding anyway'); } - const ethPrice = this.ctx.ethPriceUsd ?? ETH_PRICE_USD_FALLBACK; - const estimatedCostUsd = estimateGasCostUsd(REBALANCE_GAS_ESTIMATE, gasInfo.gasPriceGwei, ethPrice); + if (!this.ctx.ethPriceUsd) { + this.logger.warn('No ETH price available, skipping USD gas cost check'); + return true; + } + + const estimatedCostUsd = estimateGasCostUsd(REBALANCE_GAS_ESTIMATE, gasInfo.gasPriceGwei, this.ctx.ethPriceUsd); if (estimatedCostUsd > strategy.maxGasCostUsd && !isOutOfRange) { this.logger.info( @@ -319,7 +450,8 @@ export class RebalanceEngine { } private async mintInitialBands(poolState: PoolState): Promise { - const { poolEntry, wallet, positionManager, balanceTracker, ilTracker, stateStore, historyLogger, notifier } = this.ctx; + const { poolEntry, wallet, positionManager, balanceTracker, ilTracker, stateStore, historyLogger, notifier } = + this.ctx; const { pool, strategy } = poolEntry; this.rebalanceLock = true; @@ -341,11 +473,16 @@ export class RebalanceEngine { for (let i = 0; i < bandCount; i++) { const bandConfig = layout.bands[i]; - // Equal share per band - const amount0 = totalBalance0.div(bandCount - i); - const amount1 = totalBalance1.div(bandCount - i); + const remainingBands = bandCount - i; + + // Re-read actual remaining wallet balance after each mint + const [remaining0, remaining1] = await Promise.all([ + token0Contract.balanceOf(wallet.address), + token1Contract.balanceOf(wallet.address), + ]); + const amount0 = remaining0.div(remainingBands); + const amount1 = remaining1.div(remainingBands); - // Recalculate remaining for next iteration const result = await positionManager.mint({ token0: pool.token0.address, token1: pool.token1.address, @@ -371,12 +508,18 @@ export class RebalanceEngine { this.consecutiveErrors = 0; // Set IL tracker entry and initial portfolio value - const currentPrice = tickToPrice(poolState.tick); - const bal0 = parseFloat(totalBalance0.toString()) / Math.pow(10, pool.token0.decimals); - const bal1 = parseFloat(totalBalance1.toString()) / Math.pow(10, pool.token1.decimals); + const currentPrice = tickToAdjustedPrice(poolState.tick, pool.token0.decimals, pool.token1.decimals); + const bal0 = parseFloat(utils.formatUnits(totalBalance0, pool.token0.decimals)); + const bal1 = parseFloat(utils.formatUnits(totalBalance1, pool.token1.decimals)); ilTracker.setEntry(bal0, bal1, currentPrice); - const initialValue = this.estimatePortfolioValue(totalBalance0, totalBalance1, pool.token0.decimals, pool.token1.decimals, currentPrice); + const initialValue = this.estimatePortfolioValue( + totalBalance0, + totalBalance1, + pool.token0.decimals, + pool.token1.decimals, + currentPrice, + ); balanceTracker.setInitialValue(initialValue); this.logger.info({ initialValueUsd: initialValue.toFixed(2) }, 'Initial portfolio value set'); @@ -405,7 +548,8 @@ export class RebalanceEngine { } private async executeBandRebalance(poolState: PoolState, direction: TriggerDirection): Promise { - const { poolEntry, wallet, positionManager, swapExecutor, emergencyStop, balanceTracker, stateStore, historyLogger, notifier } = this.ctx; + const { poolEntry, wallet, positionManager, swapExecutor, emergencyStop, stateStore, historyLogger, notifier } = + this.ctx; const { pool, strategy } = poolEntry; // Check min interval @@ -431,16 +575,6 @@ export class RebalanceEngine { this.logger.info({ poolId: poolEntry.id, tick: poolState.tick, direction }, 'Starting band rebalance'); try { - // Pre-rebalance value estimation - const preToken0 = getErc20Contract(pool.token0.address, wallet); - const preToken1 = getErc20Contract(pool.token1.address, wallet); - const [preBal0, preBal1] = await Promise.all([ - preToken0.balanceOf(wallet.address), - preToken1.balanceOf(wallet.address), - ]); - const prePrice = tickToPrice(poolState.tick); - const preValue = this.estimatePortfolioValue(preBal0, preBal1, pool.token0.decimals, pool.token1.decimals, prePrice); - // STEP 1: Dissolve the opposite band this.setState('WITHDRAWING'); const bandToDissolve = this.bandManager.getBandToDissolve(direction); @@ -448,18 +582,27 @@ export class RebalanceEngine { const pos = await positionManager.getPosition(bandToDissolve.tokenId); if (!pos.liquidity.isZero()) { - removeResult = await positionManager.removePosition(bandToDissolve.tokenId, pos.liquidity, strategy.slippageTolerancePercent); + removeResult = await positionManager.removePosition( + bandToDissolve.tokenId, + pos.liquidity, + strategy.slippageTolerancePercent, + ); } this.bandManager.removeBand(bandToDissolve.tokenId); // Checkpoint: band dissolved, funds in wallet - this.persistCheckpoint(stateStore, poolEntry.id, 'WITHDRAWN', + this.persistCheckpoint( + stateStore, + poolEntry.id, + 'WITHDRAWN', removeResult?.txHashes ? [removeResult.txHashes.decreaseLiquidity, removeResult.txHashes.collect, removeResult.txHashes.burn] - : []); + : [], + ); // STEP 2: Swap through own pool (6 remaining bands provide liquidity) + // Only swap the tokens received from the dissolved band, not the entire wallet balance this.setState('SWAPPING'); const token0Contract = getErc20Contract(pool.token0.address, wallet); const token1Contract = getErc20Contract(pool.token1.address, wallet); @@ -468,30 +611,55 @@ export class RebalanceEngine { token1Contract.balanceOf(wallet.address), ]); + // Pre-swap value: wallet balance (meaningful baseline for loss check) + const preSwapPrice = tickToAdjustedPrice(poolState.tick, pool.token0.decimals, pool.token1.decimals); + const preSwapValue = this.estimatePortfolioValue( + balance0, + balance1, + pool.token0.decimals, + pool.token1.decimals, + preSwapPrice, + ); + + // Determine swap amount from dissolved band (principal + fees) + const dissolvedAmount0 = removeResult ? removeResult.amount0.add(removeResult.fee0) : BigNumber.from(0); + const dissolvedAmount1 = removeResult ? removeResult.amount1.add(removeResult.fee1) : BigNumber.from(0); + let swapResult: SwapResult | undefined; // When price goes lower: dissolved top band yields token0, we need token1 for new bottom band // When price goes upper: dissolved bottom band yields token1, we need token0 for new top band - if (direction === 'lower' && balance0.gt(0)) { + if (direction === 'lower' && dissolvedAmount0.gt(0)) { + // Cap at wallet balance in case of rounding + const swapAmount = dissolvedAmount0.gt(balance0) ? balance0 : dissolvedAmount0; swapResult = await swapExecutor.executeSwap( pool.token0.address, pool.token1.address, pool.feeTier, - balance0, + swapAmount, strategy.slippageTolerancePercent, + poolState.tick, + pool.token0.decimals, + pool.token1.decimals, + true, ); - } else if (direction === 'upper' && balance1.gt(0)) { + } else if (direction === 'upper' && dissolvedAmount1.gt(0)) { + // Cap at wallet balance in case of rounding + const swapAmount = dissolvedAmount1.gt(balance1) ? balance1 : dissolvedAmount1; swapResult = await swapExecutor.executeSwap( pool.token1.address, pool.token0.address, pool.feeTier, - balance1, + swapAmount, strategy.slippageTolerancePercent, + poolState.tick, + pool.token1.decimals, + pool.token0.decimals, + false, ); } // Checkpoint: swap completed - this.persistCheckpoint(stateStore, poolEntry.id, 'SWAPPED', - swapResult ? [swapResult.txHash] : []); + this.persistCheckpoint(stateStore, poolEntry.id, 'SWAPPED', swapResult ? [swapResult.txHash] : []); // STEP 3: Mint new band at the opposite end this.setState('MINTING'); @@ -522,33 +690,27 @@ export class RebalanceEngine { this.lastRebalanceTime = Date.now(); this.consecutiveErrors = 0; - // Post-rebalance value check - const currentPrice = tickToPrice(poolState.tick); - const postValue = this.estimatePortfolioValue(newBal0, newBal1, pool.token0.decimals, pool.token1.decimals, currentPrice); + // Post-swap value check: compare value before swap (dissolved band) with value after swap + const postSwapPrice = tickToAdjustedPrice(poolState.tick, pool.token0.decimals, pool.token1.decimals); + const postSwapValue = this.estimatePortfolioValue( + newBal0, + newBal1, + pool.token0.decimals, + pool.token1.decimals, + postSwapPrice, + ); - if (preValue > 0 && postValue > 0 && emergencyStop.checkRebalanceLoss(preValue, postValue)) { + if (preSwapValue > 0 && postSwapValue > 0 && emergencyStop.checkRebalanceLoss(preSwapValue, postSwapValue)) { await notifier.notify( - `ALERT: Rebalance loss too high for ${poolEntry.id}!\n` + - `Pre: $${preValue.toFixed(2)} → Post: $${postValue.toFixed(2)}\n` + - `Loss: ${(((preValue - postValue) / preValue) * 100).toFixed(2)}%\n` + + `ALERT: Rebalance swap loss too high for ${poolEntry.id}!\n` + + `Pre-swap: $${preSwapValue.toFixed(2)} → Post-swap: $${postSwapValue.toFixed(2)}\n` + + `Loss: ${(((preSwapValue - postSwapValue) / preSwapValue) * 100).toFixed(2)}%\n` + `Action: pausing bot`, ); this.setState('STOPPED'); return; } - const initialValue = balanceTracker.getInitialValue(); - if (initialValue && emergencyStop.checkPortfolioLoss(postValue, initialValue, this.ctx.maxTotalLossPercent)) { - await this.emergencyWithdraw(); - await notifier.notify( - `ALERT: Portfolio loss limit reached for ${poolEntry.id}!\n` + - `Initial: $${initialValue.toFixed(2)} → Current: $${postValue.toFixed(2)}\n` + - `Loss: ${(((initialValue - postValue) / initialValue) * 100).toFixed(2)}%\n` + - `Action: all bands closed, bot stopped`, - ); - return; - } - this.persistState(stateStore, poolEntry.id); historyLogger.log({ type: OperationType.REBALANCE, @@ -599,8 +761,10 @@ export class RebalanceEngine { if (this.consecutiveErrors >= 3) { this.setState('ERROR'); - this.ctx.emergencyStop.trigger(`${this.consecutiveErrors} consecutive errors: ${message}`); - this.ctx.notifier.notify(`ALERT: ${this.ctx.poolEntry.id} stopped after ${this.consecutiveErrors} errors: ${message}`).catch(() => {}); + this.ctx.emergencyStop.trigger(`${this.consecutiveErrors} consecutive errors: ${message}`, 'tx-error'); + this.ctx.notifier + .notify(`ALERT: ${this.ctx.poolEntry.id} stopped after ${this.consecutiveErrors} errors: ${message}`) + .catch(() => {}); } else { this.setState('MONITORING'); } @@ -659,8 +823,8 @@ export class RebalanceEngine { this.logger.error({ price }, 'Invalid price for portfolio estimation, returning 0'); return 0; } - const bal0 = parseFloat(balance0.toString()) / Math.pow(10, decimals0); - const bal1 = parseFloat(balance1.toString()) / Math.pow(10, decimals1); + const bal0 = parseFloat(utils.formatUnits(balance0, decimals0)); + const bal1 = parseFloat(utils.formatUnits(balance1, decimals1)); const value = bal0 * price + bal1; if (!Number.isFinite(value)) { this.logger.error({ bal0, bal1, price, value }, 'Portfolio value calculation produced non-finite result'); diff --git a/src/main.ts b/src/main.ts index de60fdc..6f04714 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,6 +24,7 @@ import { NonceTracker } from './chain/nonce-tracker'; import { startHealthServer, setDryRunMode } from './health/health-server'; const engines: RebalanceEngine[] = []; +let stateStoreRef: StateStore | undefined; async function main(): Promise { const env = loadEnvConfig(); @@ -57,6 +58,7 @@ async function main(): Promise { // Persistence const dataDir = path.resolve(process.cwd(), 'data'); const stateStore = new StateStore(path.join(dataDir, 'state.json')); + stateStoreRef = stateStore; const historyLogger = new HistoryLogger(path.join(dataDir, 'history.jsonl')); // Load pool configs @@ -66,10 +68,7 @@ async function main(): Promise { for (const poolEntry of pools) { try { // Create failover provider with backup RPCs - const failoverProvider = createFailoverProvider( - poolEntry.chain.rpcUrl, - poolEntry.chain.backupRpcUrls ?? [], - ); + const failoverProvider = createFailoverProvider(poolEntry.chain.rpcUrl, poolEntry.chain.backupRpcUrls ?? []); const provider = failoverProvider.getProvider(); let wallet = getWallet(env.PRIVATE_KEY, provider); @@ -87,7 +86,9 @@ async function main(): Promise { ); if (poolAddress === '0x0000000000000000000000000000000000000000') { - throw new Error(`Pool not found for ${poolEntry.pool.token0.symbol}/${poolEntry.pool.token1.symbol} fee=${poolEntry.pool.feeTier}`); + throw new Error( + `Pool not found for ${poolEntry.pool.token0.symbol}/${poolEntry.pool.token1.symbol} fee=${poolEntry.pool.feeTier}`, + ); } logger.info({ poolId: poolEntry.id, poolAddress }, 'Pool resolved'); @@ -112,7 +113,10 @@ async function main(): Promise { // Register failover callback to rebuild contracts with new provider // Defers if a rebalance is in progress to avoid mixed-provider state failoverProvider.setFailoverCallback((fromUrl, toUrl, newProvider) => { + let failoverApplied = false; const applyFailover = () => { + if (failoverApplied) return; + failoverApplied = true; logger.warn({ poolId: poolEntry.id, from: fromUrl, to: toUrl }, 'RPC failover: reconnecting contracts'); wallet = getWallet(env.PRIVATE_KEY, newProvider); poolContract = getPoolContract(poolAddress, wallet); @@ -121,15 +125,15 @@ async function main(): Promise { nonceTracker?.syncOnFailover().catch((err) => { logger.error({ poolId: poolEntry.id, err }, 'Failed to sync nonce on failover'); }); - notifier.notify( - `ALERT: RPC failover for ${poolEntry.id}\nSwitched from ${fromUrl} to ${toUrl}`, - ).catch(() => {}); + notifier + .notify(`ALERT: RPC failover for ${poolEntry.id}\nSwitched from ${fromUrl} to ${toUrl}`) + .catch(() => {}); }; if (engine.isRebalancing()) { logger.warn({ poolId: poolEntry.id }, 'RPC failover deferred: rebalance in progress'); const deferInterval = setInterval(() => { - if (!engine.isRebalancing()) { + if (!engine.isRebalancing() && !failoverApplied) { clearInterval(deferInterval); applyFailover(); } @@ -137,10 +141,12 @@ async function main(): Promise { // Safety: don't defer forever (30s max) setTimeout(() => { clearInterval(deferInterval); - if (engine.isRebalancing()) { - logger.error({ poolId: poolEntry.id }, 'RPC failover forced after 30s defer timeout'); + if (!failoverApplied) { + if (engine.isRebalancing()) { + logger.error({ poolId: poolEntry.id }, 'RPC failover forced after 30s defer timeout'); + } + applyFailover(); } - applyFailover(); }, 30_000); } else { applyFailover(); @@ -171,7 +177,12 @@ async function main(): Promise { await engine.initialize(); // Wire up events - poolMonitor.on('priceUpdate', (state) => engine.onPriceUpdate(state)); + poolMonitor.on('priceUpdate', (state) => { + failoverProvider.recordSuccess(); + engine.onPriceUpdate(state).catch((err) => { + logger.error({ poolId: poolEntry.id, err }, 'Unhandled error in onPriceUpdate'); + }); + }); poolMonitor.on('error', (err) => { logger.error({ poolId: poolEntry.id, err }, 'Pool monitor error'); failoverProvider.recordError(); @@ -186,7 +197,9 @@ async function main(): Promise { } } - await notifier.notify(`RangeKeeper started with ${engines.length} pool(s)`); + await notifier.notify(`RangeKeeper started with ${engines.length} pool(s)`).catch((err) => { + logger.warn({ err }, 'Failed to send startup notification'); + }); logger.info({ activeEngines: engines.length }, 'RangeKeeper is running'); } @@ -202,7 +215,8 @@ function setupShutdownHandlers(): void { await engine.stop(); } - logger.info('All engines stopped, exiting'); + stateStoreRef?.save(); + logger.info('All engines stopped, state persisted, exiting'); process.exit(0); }; diff --git a/src/notification/discord-notifier.ts b/src/notification/discord-notifier.ts index 4e77e33..96eafbd 100644 --- a/src/notification/discord-notifier.ts +++ b/src/notification/discord-notifier.ts @@ -6,7 +6,29 @@ import { getLogger } from '../util/logger'; export class DiscordNotifier implements Notifier { private readonly logger = getLogger(); - constructor(private readonly webhookUrl: string) {} + constructor(private readonly webhookUrl: string) { + this.validate(); + } + + private validate(): void { + if (!this.webhookUrl) { + throw new Error('Discord webhookUrl is required'); + } + try { + const parsed = new URL(this.webhookUrl); + if (parsed.protocol !== 'https:') { + throw new Error('Discord webhook URL must use HTTPS'); + } + if (!parsed.hostname.endsWith('discord.com')) { + throw new Error(`Discord webhook URL has unexpected hostname: ${parsed.hostname}`); + } + } catch (err) { + if (err instanceof TypeError) { + throw new Error(`Invalid Discord webhook URL: ${this.webhookUrl}`); + } + throw err; + } + } async notify(message: string): Promise { if (!this.webhookUrl) return; diff --git a/src/notification/telegram-notifier.ts b/src/notification/telegram-notifier.ts index b34f851..806fb78 100644 --- a/src/notification/telegram-notifier.ts +++ b/src/notification/telegram-notifier.ts @@ -8,7 +8,18 @@ export class TelegramNotifier implements Notifier { constructor( private readonly botToken: string, private readonly chatId: string, - ) {} + ) { + this.validate(); + } + + private validate(): void { + if (!this.botToken || !/^\d+:[A-Za-z0-9_-]{35,}$/.test(this.botToken)) { + throw new Error(`Invalid Telegram botToken format: expected ":"`); + } + if (!this.chatId || !/^-?\d+$/.test(this.chatId)) { + throw new Error(`Invalid Telegram chatId format: expected numeric ID, got "${this.chatId}"`); + } + } async notify(message: string): Promise { if (!this.botToken || !this.chatId) return; @@ -21,18 +32,22 @@ export class TelegramNotifier implements Notifier { }); return new Promise((resolve) => { - const req = https.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: 10_000 }, (res) => { - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => { - if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { - resolve(); - } else { - this.logger.warn({ statusCode: res.statusCode, response: data }, 'Telegram notification failed'); - resolve(); - } - }); - }); + const req = https.request( + url, + { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: 10_000 }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + this.logger.warn({ statusCode: res.statusCode, response: data }, 'Telegram notification failed'); + resolve(); + } + }); + }, + ); req.on('timeout', () => { this.logger.warn('Telegram notification timed out after 10s'); diff --git a/src/persistence/history-logger.ts b/src/persistence/history-logger.ts index 0efacf8..60f8c02 100644 --- a/src/persistence/history-logger.ts +++ b/src/persistence/history-logger.ts @@ -1,7 +1,9 @@ -import { appendFileSync, existsSync, mkdirSync } from 'fs'; +import { appendFileSync, existsSync, mkdirSync, statSync, renameSync } from 'fs'; import path from 'path'; import { getLogger } from '../util/logger'; +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB + export enum OperationType { MINT = 'MINT', REBALANCE = 'REBALANCE', @@ -43,10 +45,25 @@ export class HistoryLogger { }; try { + this.rotateIfNeeded(); appendFileSync(this.filePath, JSON.stringify(record) + '\n', 'utf-8'); this.logger.debug({ type: entry.type, poolId: entry.poolId }, 'History entry logged'); } catch (err) { this.logger.error({ err }, 'Failed to write history log'); } } + + private rotateIfNeeded(): void { + try { + if (!existsSync(this.filePath)) return; + const stats = statSync(this.filePath); + if (stats.size > MAX_FILE_SIZE) { + const rotatedPath = this.filePath + '.1'; + renameSync(this.filePath, rotatedPath); + this.logger.info({ rotatedPath, sizeMb: (stats.size / 1024 / 1024).toFixed(1) }, 'History log rotated'); + } + } catch (err) { + this.logger.warn({ err }, 'Failed to rotate history log'); + } + } } diff --git a/src/persistence/state-store.ts b/src/persistence/state-store.ts index aa8c19d..1cf4fd3 100644 --- a/src/persistence/state-store.ts +++ b/src/persistence/state-store.ts @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync } from 'fs'; import path from 'path'; import { getLogger } from '../util/logger'; @@ -65,7 +65,9 @@ export class StateStore { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8'); + const tmpPath = this.filePath + '.tmp'; + writeFileSync(tmpPath, JSON.stringify(this.state, null, 2), 'utf-8'); + renameSync(tmpPath, this.filePath); } catch (err) { this.logger.error({ err }, 'Failed to save state'); } @@ -76,7 +78,9 @@ export class StateStore { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(this.filePath, JSON.stringify(this.state, null, 2), 'utf-8'); + const tmpPath = this.filePath + '.tmp'; + writeFileSync(tmpPath, JSON.stringify(this.state, null, 2), 'utf-8'); + renameSync(tmpPath, this.filePath); } getPoolState(poolId: string): PoolState | undefined { diff --git a/src/risk/emergency-stop.ts b/src/risk/emergency-stop.ts index 17c3db6..31399d3 100644 --- a/src/risk/emergency-stop.ts +++ b/src/risk/emergency-stop.ts @@ -1,18 +1,39 @@ import { getLogger } from '../util/logger'; +export type StopCategory = 'tx-error' | 'portfolio-loss' | 'rebalance-loss' | 'depeg' | 'manual'; + export class EmergencyStop { private readonly logger = getLogger(); private stopped = false; private reason?: string; + private category?: StopCategory; private consecutiveTxErrors = 0; + private stoppedAt?: number; + private autoRecoveryCooldownMs: number; + + constructor(autoRecoveryCooldownMs = 5 * 60 * 1000) { + this.autoRecoveryCooldownMs = autoRecoveryCooldownMs; + } - trigger(reason: string): void { + trigger(reason: string, category: StopCategory = 'manual'): void { this.stopped = true; this.reason = reason; - this.logger.error({ reason }, 'EMERGENCY STOP TRIGGERED'); + this.category = category; + this.stoppedAt = Date.now(); + this.logger.error({ reason, category }, 'EMERGENCY STOP TRIGGERED'); } isStopped(): boolean { + if (this.stopped && this.category === 'tx-error' && this.stoppedAt) { + if (Date.now() - this.stoppedAt >= this.autoRecoveryCooldownMs) { + this.logger.info( + { cooldownMs: this.autoRecoveryCooldownMs, reason: this.reason }, + 'Auto-recovering from tx-error emergency stop after cooldown', + ); + this.reset(); + return false; + } + } return this.stopped; } @@ -23,6 +44,8 @@ export class EmergencyStop { reset(): void { this.stopped = false; this.reason = undefined; + this.category = undefined; + this.stoppedAt = undefined; this.consecutiveTxErrors = 0; this.logger.info('Emergency stop reset'); } @@ -30,7 +53,7 @@ export class EmergencyStop { recordTxError(): number { this.consecutiveTxErrors++; if (this.consecutiveTxErrors > 3) { - this.trigger(`${this.consecutiveTxErrors} consecutive transaction errors`); + this.trigger(`${this.consecutiveTxErrors} consecutive transaction errors`, 'tx-error'); } return this.consecutiveTxErrors; } @@ -42,7 +65,7 @@ export class EmergencyStop { checkPortfolioLoss(currentValueUsd: number, initialValueUsd: number, maxLossPercent: number): boolean { const lossPct = ((initialValueUsd - currentValueUsd) / initialValueUsd) * 100; if (lossPct > maxLossPercent) { - this.trigger(`Portfolio loss ${lossPct.toFixed(2)}% exceeds max ${maxLossPercent}%`); + this.trigger(`Portfolio loss ${lossPct.toFixed(2)}% exceeds max ${maxLossPercent}%`, 'portfolio-loss'); return true; } return false; @@ -51,7 +74,7 @@ export class EmergencyStop { checkRebalanceLoss(preValueUsd: number, postValueUsd: number, maxLossPercent = 2): boolean { const lossPct = ((preValueUsd - postValueUsd) / preValueUsd) * 100; if (lossPct > maxLossPercent) { - this.trigger(`Rebalance loss ${lossPct.toFixed(2)}% exceeds max ${maxLossPercent}%`); + this.trigger(`Rebalance loss ${lossPct.toFixed(2)}% exceeds max ${maxLossPercent}%`, 'rebalance-loss'); return true; } return false; diff --git a/src/risk/il-tracker.ts b/src/risk/il-tracker.ts index d6d679e..4bd818e 100644 --- a/src/risk/il-tracker.ts +++ b/src/risk/il-tracker.ts @@ -24,19 +24,32 @@ export class ILTracker { this.logger.info({ token0Amount, token1Amount, price }, 'IL tracker entry set'); } - calculate(currentToken0: number, currentToken1: number, currentPrice: number): ILSnapshot | null { + calculate( + currentToken0: number, + currentToken1: number, + currentPrice: number, + priceIsToken0InToken1 = true, + ): ILSnapshot | null { if (!this.entryToken0Amount || !this.entryToken1Amount || !this.entryPrice) { return null; } // Hold value: what the original tokens would be worth now - const holdValueUsd = this.entryToken0Amount * currentPrice + this.entryToken1Amount; + // priceIsToken0InToken1=true: price = token0 per token1 → value = token0 * price + token1 + // priceIsToken0InToken1=false: price = token1 per token0 → value = token0 + token1 * price + let holdValueUsd: number; + let positionValueUsd: number; - // Position value: current token amounts at current price - const positionValueUsd = currentToken0 * currentPrice + currentToken1; + if (priceIsToken0InToken1) { + holdValueUsd = this.entryToken0Amount * currentPrice + this.entryToken1Amount; + positionValueUsd = currentToken0 * currentPrice + currentToken1; + } else { + holdValueUsd = this.entryToken0Amount + this.entryToken1Amount * currentPrice; + positionValueUsd = currentToken0 + currentToken1 * currentPrice; + } // IL = (positionValue / holdValue - 1) * 100 - const ilPercent = holdValueUsd > 0 ? ((positionValueUsd / holdValueUsd) - 1) * 100 : 0; + const ilPercent = holdValueUsd > 0 ? (positionValueUsd / holdValueUsd - 1) * 100 : 0; const snapshot: ILSnapshot = { timestamp: Date.now(), diff --git a/src/risk/slippage-guard.ts b/src/risk/slippage-guard.ts index a77c52e..bad8ace 100644 --- a/src/risk/slippage-guard.ts +++ b/src/risk/slippage-guard.ts @@ -7,20 +7,72 @@ export class SlippageGuard { constructor(private readonly maxSlippagePercent: number) {} calculateMinOut(amountIn: BigNumber, expectedPrice: number, decimalsIn: number, decimalsOut: number): BigNumber { - const amountInNorm = parseFloat(amountIn.toString()) / Math.pow(10, decimalsIn); - const expectedOut = amountInNorm * expectedPrice; - const minOut = expectedOut * (1 - this.maxSlippagePercent / 100); - return BigNumber.from(Math.floor(minOut * Math.pow(10, decimalsOut)).toString()); + // Use integer arithmetic to avoid parseFloat precision loss on large BigNumbers. + // expectedPrice is expressed as a ratio (e.g. 1.0 for stablecoins). + // We scale the price to an integer numerator/denominator with 12 digits of precision. + const PRICE_SCALE = 1_000_000_000_000; // 1e12 + const priceNumerator = BigNumber.from(Math.round(expectedPrice * PRICE_SCALE)); + const slippageNumerator = BigNumber.from(Math.round((1 - this.maxSlippagePercent / 100) * PRICE_SCALE)); + + const decimalDiff = decimalsOut - decimalsIn; + let result: BigNumber; + if (decimalDiff >= 0) { + result = amountIn + .mul(priceNumerator) + .mul(slippageNumerator) + .mul(BigNumber.from(10).pow(decimalDiff)) + .div(BigNumber.from(PRICE_SCALE)) + .div(BigNumber.from(PRICE_SCALE)); + } else { + result = amountIn + .mul(priceNumerator) + .mul(slippageNumerator) + .div(BigNumber.from(10).pow(-decimalDiff)) + .div(BigNumber.from(PRICE_SCALE)) + .div(BigNumber.from(PRICE_SCALE)); + } + + return result; } - checkSlippage(amountIn: BigNumber, amountOut: BigNumber, decimalsIn: number, decimalsOut: number, expectedPrice: number): boolean { - const inNorm = parseFloat(amountIn.toString()) / Math.pow(10, decimalsIn); - const outNorm = parseFloat(amountOut.toString()) / Math.pow(10, decimalsOut); - const actualPrice = outNorm / inNorm; - const slippage = Math.abs(actualPrice - expectedPrice) / expectedPrice * 100; + checkSlippage( + amountIn: BigNumber, + amountOut: BigNumber, + decimalsIn: number, + decimalsOut: number, + expectedPrice: number, + ): boolean { + // Use integer cross-multiplication to avoid parseFloat precision loss. + // actualPrice = (amountOut / 10^decimalsOut) / (amountIn / 10^decimalsIn) + // slippage% = |actualPrice - expectedPrice| / expectedPrice * 100 + // Rewritten in integers: compare amountOut * 10^decimalsIn vs amountIn * 10^decimalsOut * expectedPrice + const PRICE_SCALE = 1_000_000_000_000; + const priceScaled = BigNumber.from(Math.round(expectedPrice * PRICE_SCALE)); + + const decimalDiff = decimalsOut - decimalsIn; + // actual_scaled = amountOut * PRICE_SCALE (represents actual price * amountIn * 10^(decimalsOut-decimalsIn) * PRICE_SCALE) + // expected_scaled = amountIn * priceScaled * 10^(decimalsOut-decimalsIn) + let actualScaled: BigNumber; + let expectedScaled: BigNumber; + + if (decimalDiff >= 0) { + actualScaled = amountOut.mul(PRICE_SCALE); + expectedScaled = amountIn.mul(priceScaled).mul(BigNumber.from(10).pow(decimalDiff)); + } else { + actualScaled = amountOut.mul(PRICE_SCALE).mul(BigNumber.from(10).pow(-decimalDiff)); + expectedScaled = amountIn.mul(priceScaled); + } + + if (expectedScaled.isZero()) return true; + + // slippage = |actual - expected| / expected * 100 + const diff = actualScaled.gt(expectedScaled) ? actualScaled.sub(expectedScaled) : expectedScaled.sub(actualScaled); + // slippageBps = diff * 10000 / expected (basis points) + const slippageBps = diff.mul(10000).div(expectedScaled); + const maxSlippageBps = Math.round(this.maxSlippagePercent * 100); - if (slippage > this.maxSlippagePercent) { - this.logger.warn({ actualPrice, expectedPrice, slippage: slippage.toFixed(4) }, 'Slippage exceeds threshold'); + if (slippageBps.gt(maxSlippageBps)) { + this.logger.warn({ slippageBps: slippageBps.toNumber(), maxSlippageBps }, 'Slippage exceeds threshold'); return false; } diff --git a/src/swap/dry-run-swap-executor.ts b/src/swap/dry-run-swap-executor.ts index 381f888..4a70f2e 100644 --- a/src/swap/dry-run-swap-executor.ts +++ b/src/swap/dry-run-swap-executor.ts @@ -19,7 +19,12 @@ export class DryRunSwapExecutor extends SwapExecutor { feeTier: number, amountIn: BigNumber, _slippagePercent: number, + _currentTick?: number, + _decimalsIn?: number, + _decimalsOut?: number, + _tokenInIsToken0?: boolean, ): Promise { + // Dry run: simulate swap output with fee deduction (assumes ~1:1 for simplicity) const amountOut = amountIn.mul(1_000_000 - feeTier).div(1_000_000); this.dryLogger.info( diff --git a/src/swap/ratio-calculator.ts b/src/swap/ratio-calculator.ts deleted file mode 100644 index c50fb87..0000000 --- a/src/swap/ratio-calculator.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { BigNumber } from 'ethers'; -import JSBI from 'jsbi'; -import { TickMath, SqrtPriceMath } from '@uniswap/v3-sdk'; - -export interface SwapPlan { - tokenIn: string; - tokenOut: string; - amountIn: BigNumber; - direction: 'token0to1' | 'token1to0'; -} - -/** - * Calculate swap needed to achieve correct token ratio for a given tick range. - * - * For concentrated liquidity, the ratio of token0:token1 depends on where the - * current price sits within the range. We compute the ideal ratio and determine - * how much to swap. - */ -export function calculateSwap( - balance0: BigNumber, - balance1: BigNumber, - decimals0: number, - decimals1: number, - currentTick: number, - tickLower: number, - tickUpper: number, - feeTier: number, - token0Address?: string, - token1Address?: string, -): SwapPlan | null { - // Get sqrt prices for range boundaries - const sqrtRatioA = TickMath.getSqrtRatioAtTick(tickLower); - const sqrtRatioB = TickMath.getSqrtRatioAtTick(tickUpper); - const sqrtRatioCurrent = TickMath.getSqrtRatioAtTick(currentTick); - - // Calculate amounts for 1 unit of liquidity to get the ratio - const testLiquidity = JSBI.BigInt('1000000000000000000'); // 1e18 - - let amount0Needed: JSBI; - let amount1Needed: JSBI; - - if (currentTick < tickLower) { - // Price below range: only token0 needed - amount0Needed = SqrtPriceMath.getAmount0Delta(sqrtRatioA, sqrtRatioB, testLiquidity, true); - amount1Needed = JSBI.BigInt(0); - } else if (currentTick >= tickUpper) { - // Price above range: only token1 needed - amount0Needed = JSBI.BigInt(0); - amount1Needed = SqrtPriceMath.getAmount1Delta(sqrtRatioA, sqrtRatioB, testLiquidity, true); - } else { - // Price in range: both tokens needed - amount0Needed = SqrtPriceMath.getAmount0Delta(sqrtRatioCurrent, sqrtRatioB, testLiquidity, true); - amount1Needed = SqrtPriceMath.getAmount1Delta(sqrtRatioA, sqrtRatioCurrent, testLiquidity, true); - } - - // If one side is 0, all tokens should be on the other side - const a0 = JSBI.toNumber(amount0Needed); - const a1 = JSBI.toNumber(amount1Needed); - - if (a0 === 0 && a1 === 0) return null; - - // Normalize balances to a comparable scale - const bal0Normalized = parseFloat(balance0.toString()) / Math.pow(10, decimals0); - const bal1Normalized = parseFloat(balance1.toString()) / Math.pow(10, decimals1); - - if (a0 === 0) { - // Need all token1 → swap all token0 to token1 - if (balance0.gt(0)) { - return { - tokenIn: token0Address ?? '', - tokenOut: token1Address ?? '', - amountIn: balance0, - direction: 'token0to1', - }; - } - return null; - } - - if (a1 === 0) { - // Need all token0 → swap all token1 to token0 - if (balance1.gt(0)) { - return { - tokenIn: token1Address ?? '', - tokenOut: token0Address ?? '', - amountIn: balance1, - direction: 'token1to0', - }; - } - return null; - } - - // Calculate ideal ratio: what fraction of total value should be token0 - // Use price to convert to common unit - const price = Math.pow(1.0001, currentTick) * Math.pow(10, decimals0 - decimals1); - const totalValue = bal0Normalized + bal1Normalized * price; - - if (totalValue === 0) return null; - - // The ideal amount0 (in token0 units) based on the ratio - const a0Norm = a0 / Math.pow(10, decimals0); - const a1Norm = a1 / Math.pow(10, decimals1); - const idealRatio0 = a0Norm / (a0Norm + a1Norm * price); - - const currentRatio0 = bal0Normalized / totalValue; - - const diff = currentRatio0 - idealRatio0; - - // threshold: only swap if more than 1% difference - if (Math.abs(diff) < 0.01) return null; - - if (diff > 0) { - // Too much token0 → swap some to token1 - const swapAmount0 = diff * totalValue; - const swapAmountRaw = BigNumber.from( - Math.floor(swapAmount0 * Math.pow(10, decimals0)).toString(), - ); - // Don't swap more than balance - const cappedAmount = swapAmountRaw.gt(balance0) ? balance0 : swapAmountRaw; - if (cappedAmount.lte(0)) return null; - - return { - tokenIn: token0Address ?? '', - tokenOut: token1Address ?? '', - amountIn: cappedAmount, - direction: 'token0to1', - }; - } else { - // Too much token1 → swap some to token0 - const swapAmount1 = Math.abs(diff) * totalValue / price; - const swapAmountRaw = BigNumber.from( - Math.floor(swapAmount1 * Math.pow(10, decimals1)).toString(), - ); - const cappedAmount = swapAmountRaw.gt(balance1) ? balance1 : swapAmountRaw; - if (cappedAmount.lte(0)) return null; - - return { - tokenIn: token1Address ?? '', - tokenOut: token0Address ?? '', - amountIn: cappedAmount, - direction: 'token1to0', - }; - } -} diff --git a/src/swap/swap-executor.ts b/src/swap/swap-executor.ts index 7ab1b34..6269933 100644 --- a/src/swap/swap-executor.ts +++ b/src/swap/swap-executor.ts @@ -1,7 +1,7 @@ import { Contract, BigNumber, Wallet, ContractTransaction, constants } from 'ethers'; import { getLogger } from '../util/logger'; import { getSwapRouterContract, getErc20Contract, ensureApproval } from '../chain/contracts'; -import { withRetry } from '../util/retry'; +import { withRetry, NonRetryableError } from '../util/retry'; import { NonceTracker } from '../chain/nonce-tracker'; export type WalletProvider = () => Wallet; @@ -33,10 +33,16 @@ export class SwapExecutor { const token0 = getErc20Contract(token0Address, w); const token1 = getErc20Contract(token1Address, w); - await Promise.all([ - ensureApproval(token0, this.swapRouterAddress, w.address, constants.MaxUint256), - ensureApproval(token1, this.swapRouterAddress, w.address, constants.MaxUint256), - ]); + // Run approvals sequentially when using NonceTracker to avoid nonce conflicts + if (this.nonceTracker) { + await ensureApproval(token0, this.swapRouterAddress, w.address, constants.MaxUint256, this.nonceTracker); + await ensureApproval(token1, this.swapRouterAddress, w.address, constants.MaxUint256, this.nonceTracker); + } else { + await Promise.all([ + ensureApproval(token0, this.swapRouterAddress, w.address, constants.MaxUint256), + ensureApproval(token1, this.swapRouterAddress, w.address, constants.MaxUint256), + ]); + } this.logger.info('Token approvals confirmed for Swap Router'); } @@ -47,39 +53,58 @@ export class SwapExecutor { feeTier: number, amountIn: BigNumber, slippagePercent: number, + currentTick?: number, + decimalsIn?: number, + decimalsOut?: number, + tokenInIsToken0?: boolean, ): Promise { const w = this.wallet; const router = this.router; - this.logger.info( - { tokenIn, tokenOut, feeTier, amountIn: amountIn.toString(), slippagePercent }, - 'Executing swap', - ); + this.logger.info({ tokenIn, tokenOut, feeTier, amountIn: amountIn.toString(), slippagePercent }, 'Executing swap'); + + // Verify wallet has sufficient balance before submitting swap + const tokenInContract = getErc20Contract(tokenIn, w); + const balance: BigNumber = await tokenInContract.balanceOf(w.address); + if (balance.lt(amountIn)) { + throw new NonRetryableError( + `Insufficient balance for swap: have ${balance.toString()} but need ${amountIn.toString()} of ${tokenIn}`, + ); + } - // For stablecoin pairs, we expect ~1:1 ratio, so min out is based on slippage const slippageMul = Math.floor((1 - slippagePercent / 100) * 10000); - const amountOutMinimum = amountIn.mul(slippageMul).div(10000); + const amountOutMinimum = this.computeAmountOutMinimum( + amountIn, + slippageMul, + currentTick, + decimalsIn, + decimalsOut, + tokenInIsToken0, + ); const nonceOverride = this.nonceTracker ? { nonce: this.nonceTracker.getNextNonce() } : {}; const tx: ContractTransaction = await withRetry( () => - router.exactInputSingle({ - tokenIn, - tokenOut, - fee: feeTier, - recipient: w.address, - amountIn, - amountOutMinimum, - sqrtPriceLimitX96: 0, - }, nonceOverride), + router.exactInputSingle( + { + tokenIn, + tokenOut, + fee: feeTier, + recipient: w.address, + amountIn, + amountOutMinimum, + sqrtPriceLimitX96: 0, + }, + nonceOverride, + ), 'swap', ); const receipt = await tx.wait(); + this.nonceTracker?.confirmNonce(); if (receipt.status === 0) { throw new Error('Swap transaction reverted on-chain'); } - this.nonceTracker?.confirmNonce(); // Parse Transfer event from output token to get amountOut const transferTopic = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; @@ -89,7 +114,10 @@ export class SwapExecutor { ); if (!transferLog) { - this.logger.error({ txHash: receipt.transactionHash, logsCount: receipt.logs?.length }, 'Transfer event not found in swap receipt'); + this.logger.error( + { txHash: receipt.transactionHash, logsCount: receipt.logs?.length }, + 'Transfer event not found in swap receipt', + ); throw new Error(`Swap succeeded but Transfer event not found for output token (tx: ${receipt.transactionHash})`); } @@ -105,4 +133,71 @@ export class SwapExecutor { return { amountOut, txHash: receipt.transactionHash }; } + + protected computeAmountOutMinimum( + amountIn: BigNumber, + slippageMul: number, + currentTick?: number, + decimalsIn?: number, + decimalsOut?: number, + tokenInIsToken0?: boolean, + ): BigNumber { + // If price info is provided, compute price-aware minimum + if ( + currentTick !== undefined && + decimalsIn !== undefined && + decimalsOut !== undefined && + tokenInIsToken0 !== undefined + ) { + try { + const price = Math.pow(1.0001, currentTick); + if (!Number.isFinite(price) || price <= 0) { + throw new Error(`Invalid price from tick ${currentTick}`); + } + + // Use scaled integer arithmetic: price scaled by 10^15 + const PRICE_PRECISION = 1e15; + const priceScaled = Math.round(price * PRICE_PRECISION); + if (!Number.isFinite(priceScaled) || priceScaled <= 0) { + throw new Error(`Price scaling overflow for tick ${currentTick}`); + } + const priceBN = BigNumber.from(Math.floor(priceScaled).toString()); + const precisionBN = BigNumber.from(Math.floor(PRICE_PRECISION).toString()); + + const absDiff = Math.abs(decimalsOut - decimalsIn); + const decimalAdjust = absDiff > 0 ? BigNumber.from(10).pow(absDiff) : BigNumber.from(1); + + let expectedOut: BigNumber; + if (tokenInIsToken0) { + // token0→token1: expectedOut = amountIn * price * 10^(decOut-decIn) + if (decimalsOut >= decimalsIn) { + expectedOut = amountIn.mul(priceBN).mul(decimalAdjust).div(precisionBN); + } else { + expectedOut = amountIn.mul(priceBN).div(precisionBN).div(decimalAdjust); + } + } else { + // token1→token0: expectedOut = amountIn / price * 10^(decOut-decIn) + if (decimalsOut >= decimalsIn) { + expectedOut = amountIn.mul(precisionBN).mul(decimalAdjust).div(priceBN); + } else { + expectedOut = amountIn.mul(precisionBN).div(priceBN).div(decimalAdjust); + } + } + + const result = expectedOut.mul(slippageMul).div(10000); + if (result.gt(0)) { + this.logger.debug( + { expectedOut: expectedOut.toString(), amountOutMinimum: result.toString(), currentTick }, + 'Price-aware amountOutMinimum computed', + ); + return result; + } + } catch (err) { + this.logger.warn({ err, currentTick }, 'Failed to compute price-aware amountOutMinimum, using 1:1 fallback'); + } + } + + // Fallback: assume 1:1 ratio (safe for same-decimal stablecoin pairs) + return amountIn.mul(slippageMul).div(10000); + } } diff --git a/src/util/retry.ts b/src/util/retry.ts index faf0240..7efbb98 100644 --- a/src/util/retry.ts +++ b/src/util/retry.ts @@ -1,5 +1,21 @@ import { getLogger } from './logger'; +export class NonRetryableError extends Error { + constructor(message: string) { + super(message); + this.name = 'NonRetryableError'; + } +} + +// Nonce-related errors indicate a TX was already submitted/mined — retrying would be dangerous +export const NON_RETRYABLE_TX_PATTERNS = [ + 'nonce too low', + 'nonce has already been used', + 'replacement transaction underpriced', + 'already known', + 'transaction already imported', +]; + export interface RetryOptions { maxRetries: number; baseDelayMs: number; @@ -24,12 +40,39 @@ export async function withRetry(fn: () => Promise, label: string, opts?: P } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); + if (lastError instanceof NonRetryableError) { + throw lastError; + } + + // Check for nonce-related errors that should never be retried + const msgLower = lastError.message.toLowerCase(); + if (NON_RETRYABLE_TX_PATTERNS.some((p) => msgLower.includes(p))) { + logger.warn({ error: lastError.message }, `${label}: non-retryable TX error detected, aborting retries`); + throw lastError; + } + + if (options.retryableErrors && options.retryableErrors.length > 0) { + const msg = lastError.message.toLowerCase(); + const isRetryable = options.retryableErrors.some((re) => msg.includes(re.toLowerCase())); + if (!isRetryable) { + throw lastError; + } + } + if (attempt === options.maxRetries) break; const delay = Math.min(options.baseDelayMs * Math.pow(2, attempt), options.maxDelayMs); const jitter = delay * 0.1 * Math.random(); - logger.warn({ attempt: attempt + 1, maxRetries: options.maxRetries, delay: Math.round(delay + jitter), error: lastError.message }, `${label}: retrying after error`); + logger.warn( + { + attempt: attempt + 1, + maxRetries: options.maxRetries, + delay: Math.round(delay + jitter), + error: lastError.message, + }, + `${label}: retrying after error`, + ); await sleep(delay + jitter); } diff --git a/src/util/tick-math.ts b/src/util/tick-math.ts index ea762a7..3d6efbb 100644 --- a/src/util/tick-math.ts +++ b/src/util/tick-math.ts @@ -5,6 +5,10 @@ export function tickToPrice(tick: number): number { return Math.pow(1.0001, tick); } +export function tickToAdjustedPrice(tick: number, decimals0: number, decimals1: number): number { + return Math.pow(1.0001, tick) * Math.pow(10, decimals0 - decimals1); +} + export function priceToTick(price: number): number { return Math.floor(Math.log(price) / Math.log(1.0001)); } diff --git a/test/helpers/fixtures.ts b/test/helpers/fixtures.ts index cfc96f7..f3b5e60 100644 --- a/test/helpers/fixtures.ts +++ b/test/helpers/fixtures.ts @@ -13,14 +13,14 @@ export const SWAP_ROUTER_ADDRESS = '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45'; export const WALLET_ADDRESS = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; // ---- Token decimals ---- -export const USDT_DECIMALS = 6; +export const USDT_DECIMALS = 18; export const ZCHF_DECIMALS = 18; // ---- BigNumber amounts ---- -export const AMOUNT_100_USDT = BigNumber.from(100_000_000); // 100 * 10^6 +export const AMOUNT_100_USDT = BigNumber.from('100000000000000000000'); // 100 * 10^18 export const AMOUNT_100_ZCHF = BigNumber.from('100000000000000000000'); // 100 * 10^18 -export const AMOUNT_50_USDT = BigNumber.from(50_000_000); -export const AMOUNT_50_ZCHF = BigNumber.from('50000000000000000000'); +export const AMOUNT_50_USDT = BigNumber.from('50000000000000000000'); // 50 * 10^18 +export const AMOUNT_50_ZCHF = BigNumber.from('50000000000000000000'); // 50 * 10^18 // ---- Factory functions ---- @@ -89,7 +89,7 @@ export function createRemoveResult(): RemoveResult { return { amount0: AMOUNT_100_USDT, amount1: AMOUNT_100_ZCHF, - fee0: BigNumber.from(1_000_000), // 1 USDT fee + fee0: BigNumber.from('1000000000000000000'), // 1 USDT fee (18 decimals) fee1: BigNumber.from('1000000000000000000'), // 1 ZCHF fee txHashes: { decreaseLiquidity: '0xmock-decrease-hash', @@ -99,11 +99,7 @@ export function createRemoveResult(): RemoveResult { }; } -export function createPositionInfo( - tokenId: number, - tickLower: number, - tickUpper: number, -): PositionInfo { +export function createPositionInfo(tokenId: number, tickLower: number, tickUpper: number): PositionInfo { return { tokenId: BigNumber.from(tokenId), token0: USDT_ADDRESS, diff --git a/test/helpers/mock-context.ts b/test/helpers/mock-context.ts index 96983de..c9449a9 100644 --- a/test/helpers/mock-context.ts +++ b/test/helpers/mock-context.ts @@ -3,11 +3,7 @@ import { RebalanceContext } from '../../src/core/rebalance-engine'; import { EmergencyStop } from '../../src/risk/emergency-stop'; import { SlippageGuard } from '../../src/risk/slippage-guard'; import { ILTracker } from '../../src/risk/il-tracker'; -import { - createPoolEntry, - AMOUNT_100_USDT, - AMOUNT_100_ZCHF, -} from './fixtures'; +import { createPoolEntry, AMOUNT_100_USDT, AMOUNT_100_ZCHF } from './fixtures'; import { PoolEntry } from '../../src/config'; // Module-level mock for getErc20Contract @@ -125,7 +121,7 @@ export function createMockContext(poolEntryOverrides?: Partial): Mock const removePosition = jest.fn().mockResolvedValue({ amount0: AMOUNT_100_USDT, amount1: AMOUNT_100_ZCHF, - fee0: BigNumber.from(1_000_000), + fee0: BigNumber.from('1000000000000000000'), fee1: BigNumber.from('1000000000000000000'), txHashes: { decreaseLiquidity: '0xmock-decrease-hash', @@ -272,5 +268,5 @@ export function createMockContext(poolEntryOverrides?: Partial): Mock } function AMOUNT_50_USDT(): BigNumber { - return BigNumber.from(50_000_000); + return BigNumber.from('50000000000000000000'); } diff --git a/test/integration/band-rebalance.spec.ts b/test/integration/band-rebalance.spec.ts index a729854..016688e 100644 --- a/test/integration/band-rebalance.spec.ts +++ b/test/integration/band-rebalance.spec.ts @@ -33,7 +33,7 @@ function buildContext(overrides: Record = {}) { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', @@ -71,7 +71,7 @@ function buildContext(overrides: Record = {}) { removePosition: jest.fn().mockResolvedValue({ amount0: AMOUNT_100_USDT, amount1: AMOUNT_100_ZCHF, - fee0: BigNumber.from(1_000_000), + fee0: BigNumber.from('1000000000000000000'), fee1: BigNumber.from('1000000000000000000'), txHashes: { decreaseLiquidity: '0xmock-decrease-hash', collect: '0xmock-collect-hash', burn: '0xmock-burn-hash' }, }), @@ -83,7 +83,7 @@ function buildContext(overrides: Record = {}) { }), findExistingPositions: jest.fn().mockResolvedValue([]), approveTokensSE: jest.fn().mockResolvedValue(undefined), - executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(50_000_000), txHash: '0xmock-swap-hash' }), + executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from('50000000000000000000'), txHash: '0xmock-swap-hash' }), setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn(), diff --git a/test/integration/checkpoint-recovery.spec.ts b/test/integration/checkpoint-recovery.spec.ts index c78096f..2c4e457 100644 --- a/test/integration/checkpoint-recovery.spec.ts +++ b/test/integration/checkpoint-recovery.spec.ts @@ -32,7 +32,7 @@ function buildContext(overrides: Record = {}) { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', @@ -71,7 +71,7 @@ function buildContext(overrides: Record = {}) { removePosition: jest.fn().mockResolvedValue({ amount0: AMOUNT_100_USDT, amount1: AMOUNT_100_ZCHF, - fee0: BigNumber.from(1_000_000), + fee0: BigNumber.from('1000000000000000000'), fee1: BigNumber.from('1000000000000000000'), txHashes: { decreaseLiquidity: '0xmock-decrease-hash', @@ -87,7 +87,7 @@ function buildContext(overrides: Record = {}) { }), findExistingPositions: jest.fn().mockResolvedValue([]), approveTokensSE: jest.fn().mockResolvedValue(undefined), - executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(50_000_000), txHash: '0xmock-swap-hash' }), + executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from('50000000000000000000'), txHash: '0xmock-swap-hash' }), setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn(), @@ -188,12 +188,15 @@ describe('Checkpoint Recovery Integration', () => { await engine.initialize(); // Should have cleared the stage - expect(mocks.updatePoolState).toHaveBeenCalledWith('USDT-ZCHF-100', expect.objectContaining({ - rebalanceStage: undefined, - pendingTxHashes: undefined, - bands: undefined, - bandTickWidth: undefined, - })); + expect(mocks.updatePoolState).toHaveBeenCalledWith( + 'USDT-ZCHF-100', + expect.objectContaining({ + rebalanceStage: undefined, + pendingTxHashes: undefined, + bands: undefined, + bandTickWidth: undefined, + }), + ); expect(mocks.save).toHaveBeenCalled(); // Should have sent recovery notification @@ -208,9 +211,7 @@ describe('Checkpoint Recovery Integration', () => { it('recovery from SWAPPED stage clears bands and sends notification', async () => { const { ctx, mocks } = buildContext(); mocks.getPoolState.mockReturnValue({ - bands: [ - { tokenId: '201', tickLower: -150, tickUpper: -107 }, - ], + bands: [{ tokenId: '201', tickLower: -150, tickUpper: -107 }], bandTickWidth: 43, lastRebalanceTime: Date.now() - 60000, rebalanceStage: 'SWAPPED', @@ -220,10 +221,13 @@ describe('Checkpoint Recovery Integration', () => { const engine = new RebalanceEngine(ctx); await engine.initialize(); - expect(mocks.updatePoolState).toHaveBeenCalledWith('USDT-ZCHF-100', expect.objectContaining({ - rebalanceStage: undefined, - pendingTxHashes: undefined, - })); + expect(mocks.updatePoolState).toHaveBeenCalledWith( + 'USDT-ZCHF-100', + expect.objectContaining({ + rebalanceStage: undefined, + pendingTxHashes: undefined, + }), + ); expect(mocks.notify).toHaveBeenCalledWith(expect.stringContaining('SWAPPED')); expect(engine.getBands()).toHaveLength(0); expect(engine.getState()).toBe('MONITORING'); @@ -259,17 +263,15 @@ describe('Checkpoint Recovery Integration', () => { it('pending TX verification checks receipts on startup', async () => { const { ctx, mocks } = buildContext(); mocks.getPoolState.mockReturnValue({ - bands: [ - { tokenId: '201', tickLower: -150, tickUpper: -107 }, - ], + bands: [{ tokenId: '201', tickLower: -150, tickUpper: -107 }], bandTickWidth: 43, pendingTxHashes: ['0xconfirmed', '0xreverted', '0xnotfound'], }); mocks.getTransactionReceipt - .mockResolvedValueOnce({ status: 1 }) // confirmed - .mockResolvedValueOnce({ status: 0 }) // reverted - .mockResolvedValueOnce(null); // not found + .mockResolvedValueOnce({ status: 1 }) // confirmed + .mockResolvedValueOnce({ status: 0 }) // reverted + .mockResolvedValueOnce(null); // not found const engine = new RebalanceEngine(ctx); await engine.initialize(); @@ -283,9 +285,7 @@ describe('Checkpoint Recovery Integration', () => { it('recovery allows minting new bands on next price update', async () => { const { ctx, mocks } = buildContext(); mocks.getPoolState.mockReturnValue({ - bands: [ - { tokenId: '201', tickLower: -150, tickUpper: -107 }, - ], + bands: [{ tokenId: '201', tickLower: -150, tickUpper: -107 }], bandTickWidth: 43, lastRebalanceTime: Date.now() - 60000, rebalanceStage: 'WITHDRAWN', diff --git a/test/integration/dry-run-e2e.spec.ts b/test/integration/dry-run-e2e.spec.ts index 0b517a2..cb52bf5 100644 --- a/test/integration/dry-run-e2e.spec.ts +++ b/test/integration/dry-run-e2e.spec.ts @@ -60,7 +60,7 @@ describe('Dry Run E2E Integration', () => { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xNFT', @@ -79,15 +79,33 @@ describe('Dry Run E2E Integration', () => { return { poolEntry, wallet: mockWallet, - poolMonitor: { fetchPoolState: jest.fn().mockResolvedValue(createPoolState(200)), startMonitoring: jest.fn(), stopMonitoring: jest.fn(), on: jest.fn() }, + poolMonitor: { + fetchPoolState: jest.fn().mockResolvedValue(createPoolState(200)), + startMonitoring: jest.fn(), + stopMonitoring: jest.fn(), + on: jest.fn(), + }, positionManager: dryPM, swapExecutor: drySE, emergencyStop: new EmergencyStop(), slippageGuard: new SlippageGuard(0.5), ilTracker: new ILTracker(), - balanceTracker: { setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn() }, - gasOracle: { getGasInfo: jest.fn().mockResolvedValue({ gasPriceGwei: 20, isEip1559: false }), isGasSpike: jest.fn().mockReturnValue(false) }, - stateStore: { getPoolState: jest.fn().mockReturnValue(undefined), updatePoolState: jest.fn(), save: jest.fn(), saveOrThrow: jest.fn(), getState: jest.fn() }, + balanceTracker: { + setInitialValue: jest.fn(), + getInitialValue: jest.fn().mockReturnValue(undefined), + getLossPercent: jest.fn(), + }, + gasOracle: { + getGasInfo: jest.fn().mockResolvedValue({ gasPriceGwei: 20, isEip1559: false }), + isGasSpike: jest.fn().mockReturnValue(false), + }, + stateStore: { + getPoolState: jest.fn().mockReturnValue(undefined), + updatePoolState: jest.fn(), + save: jest.fn(), + saveOrThrow: jest.fn(), + getState: jest.fn(), + }, historyLogger: { log: jest.fn() }, notifier: { notify: jest.fn().mockResolvedValue(undefined) }, maxTotalLossPercent: 10, diff --git a/test/integration/emergency-scenarios.spec.ts b/test/integration/emergency-scenarios.spec.ts index 1ea305f..8f05b6c 100644 --- a/test/integration/emergency-scenarios.spec.ts +++ b/test/integration/emergency-scenarios.spec.ts @@ -32,7 +32,7 @@ function buildContext(overrides: Record = {}) { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', @@ -82,7 +82,7 @@ function buildContext(overrides: Record = {}) { }), findExistingPositions: jest.fn().mockResolvedValue([]), approveTokensSE: jest.fn().mockResolvedValue(undefined), - executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(50_000_000), txHash: '0xmock-swap-hash' }), + executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from('50000000000000000000'), txHash: '0xmock-swap-hash' }), setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn(), @@ -136,7 +136,13 @@ function buildContext(overrides: Record = {}) { getLossPercent: mocks.getLossPercent, }, gasOracle: { getGasInfo: mocks.getGasInfo, isGasSpike: mocks.isGasSpike }, - stateStore: { getPoolState: mocks.getPoolState, updatePoolState: mocks.updatePoolState, save: mocks.save, saveOrThrow: jest.fn(), getState: mocks.getState }, + stateStore: { + getPoolState: mocks.getPoolState, + updatePoolState: mocks.updatePoolState, + save: mocks.save, + saveOrThrow: jest.fn(), + getState: mocks.getState, + }, historyLogger: { log: mocks.log }, notifier: { notify: mocks.notify }, maxTotalLossPercent: 10, diff --git a/test/integration/gas-gating.spec.ts b/test/integration/gas-gating.spec.ts index 88a1906..1e475bf 100644 --- a/test/integration/gas-gating.spec.ts +++ b/test/integration/gas-gating.spec.ts @@ -31,7 +31,7 @@ function buildContext() { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', @@ -79,7 +79,7 @@ function buildContext() { }), findExistingPositions: jest.fn().mockResolvedValue([]), approveTokensSE: jest.fn().mockResolvedValue(undefined), - executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(50_000_000), txHash: '0xmock-swap-hash' }), + executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from('50000000000000000000'), txHash: '0xmock-swap-hash' }), setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn(), @@ -104,7 +104,12 @@ function buildContext() { const ctx = { poolEntry, wallet, - poolMonitor: { fetchPoolState: mocks.fetchPoolState, startMonitoring: jest.fn(), stopMonitoring: jest.fn(), on: jest.fn() }, + poolMonitor: { + fetchPoolState: mocks.fetchPoolState, + startMonitoring: jest.fn(), + stopMonitoring: jest.fn(), + on: jest.fn(), + }, positionManager: { approveTokens: mocks.approveTokensPM, mint: mocks.mint, @@ -116,9 +121,19 @@ function buildContext() { emergencyStop: new EmergencyStop(), slippageGuard: new SlippageGuard(0.5), ilTracker: new ILTracker(), - balanceTracker: { setInitialValue: mocks.setInitialValue, getInitialValue: mocks.getInitialValue, getLossPercent: mocks.getLossPercent }, + balanceTracker: { + setInitialValue: mocks.setInitialValue, + getInitialValue: mocks.getInitialValue, + getLossPercent: mocks.getLossPercent, + }, gasOracle: { getGasInfo: mocks.getGasInfo, isGasSpike: mocks.isGasSpike }, - stateStore: { getPoolState: mocks.getPoolState, updatePoolState: mocks.updatePoolState, save: mocks.save, saveOrThrow: jest.fn(), getState: jest.fn() }, + stateStore: { + getPoolState: mocks.getPoolState, + updatePoolState: mocks.updatePoolState, + save: mocks.save, + saveOrThrow: jest.fn(), + getState: jest.fn(), + }, historyLogger: { log: mocks.log }, notifier: { notify: mocks.notify }, maxTotalLossPercent: 10, diff --git a/test/integration/notification-flow.spec.ts b/test/integration/notification-flow.spec.ts index d75dfe2..9672aa0 100644 --- a/test/integration/notification-flow.spec.ts +++ b/test/integration/notification-flow.spec.ts @@ -33,7 +33,7 @@ function buildContext(overrides: Record = {}) { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xNFT', @@ -77,7 +77,7 @@ function buildContext(overrides: Record = {}) { removePosition: jest.fn().mockResolvedValue({ amount0: AMOUNT_100_USDT, amount1: AMOUNT_100_ZCHF, - fee0: BigNumber.from(1_000_000), + fee0: BigNumber.from('1000000000000000000'), fee1: BigNumber.from('1000000000000000000'), txHashes: { decreaseLiquidity: '0xmock-decrease-hash', collect: '0xmock-collect-hash', burn: '0xmock-burn-hash' }, }), @@ -93,7 +93,12 @@ function buildContext(overrides: Record = {}) { const ctx = { poolEntry: overrides.poolEntry ?? poolEntry, wallet, - poolMonitor: { fetchPoolState: mocks.fetchPoolState, startMonitoring: jest.fn(), stopMonitoring: jest.fn(), on: jest.fn() }, + poolMonitor: { + fetchPoolState: mocks.fetchPoolState, + startMonitoring: jest.fn(), + stopMonitoring: jest.fn(), + on: jest.fn(), + }, positionManager: { approveTokens: jest.fn().mockResolvedValue(undefined), mint: mocks.mint, @@ -101,13 +106,26 @@ function buildContext(overrides: Record = {}) { getPosition: mocks.getPosition, findExistingPositions: mocks.findExistingPositions, }, - swapExecutor: { approveTokens: jest.fn().mockResolvedValue(undefined), executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(0), txHash: '0xmock-swap-hash' }) }, + swapExecutor: { + approveTokens: jest.fn().mockResolvedValue(undefined), + executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(0), txHash: '0xmock-swap-hash' }), + }, emergencyStop: new EmergencyStop(), slippageGuard: new SlippageGuard(0.5), ilTracker: new ILTracker(), - balanceTracker: { setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn() }, + balanceTracker: { + setInitialValue: jest.fn(), + getInitialValue: jest.fn().mockReturnValue(undefined), + getLossPercent: jest.fn(), + }, gasOracle: { getGasInfo: mocks.getGasInfo, isGasSpike: mocks.isGasSpike }, - stateStore: { getPoolState: jest.fn().mockReturnValue(undefined), updatePoolState: jest.fn(), save: jest.fn(), saveOrThrow: jest.fn(), getState: jest.fn() }, + stateStore: { + getPoolState: jest.fn().mockReturnValue(undefined), + updatePoolState: jest.fn(), + save: jest.fn(), + saveOrThrow: jest.fn(), + getState: jest.fn(), + }, historyLogger: { log: jest.fn() }, notifier: { notify: mocks.notify }, maxTotalLossPercent: 10, diff --git a/test/integration/pool-monitor-events.spec.ts b/test/integration/pool-monitor-events.spec.ts index f8ea810..06c5d45 100644 --- a/test/integration/pool-monitor-events.spec.ts +++ b/test/integration/pool-monitor-events.spec.ts @@ -31,7 +31,7 @@ function buildContext() { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xNFT', @@ -90,7 +90,12 @@ function buildContext() { const ctx = { poolEntry, wallet, - poolMonitor: { fetchPoolState: mocks.fetchPoolState, startMonitoring: jest.fn(), stopMonitoring: jest.fn(), on: jest.fn() }, + poolMonitor: { + fetchPoolState: mocks.fetchPoolState, + startMonitoring: jest.fn(), + stopMonitoring: jest.fn(), + on: jest.fn(), + }, positionManager: { approveTokens: jest.fn().mockResolvedValue(undefined), mint: mocks.mint, @@ -102,9 +107,19 @@ function buildContext() { emergencyStop: new EmergencyStop(), slippageGuard: new SlippageGuard(0.5), ilTracker: new ILTracker(), - balanceTracker: { setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn() }, + balanceTracker: { + setInitialValue: jest.fn(), + getInitialValue: jest.fn().mockReturnValue(undefined), + getLossPercent: jest.fn(), + }, gasOracle: { getGasInfo: mocks.getGasInfo, isGasSpike: mocks.isGasSpike }, - stateStore: { getPoolState: jest.fn().mockReturnValue(undefined), updatePoolState: jest.fn(), save: jest.fn(), saveOrThrow: jest.fn(), getState: jest.fn() }, + stateStore: { + getPoolState: jest.fn().mockReturnValue(undefined), + updatePoolState: jest.fn(), + save: jest.fn(), + saveOrThrow: jest.fn(), + getState: jest.fn(), + }, historyLogger: { log: jest.fn() }, notifier: { notify: mocks.notify }, maxTotalLossPercent: 10, diff --git a/test/integration/rebalance-lifecycle.spec.ts b/test/integration/rebalance-lifecycle.spec.ts index eded0e4..740bf0e 100644 --- a/test/integration/rebalance-lifecycle.spec.ts +++ b/test/integration/rebalance-lifecycle.spec.ts @@ -32,7 +32,7 @@ function buildContext(overrides: Record = {}) { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88', @@ -73,7 +73,7 @@ function buildContext(overrides: Record = {}) { removePosition: jest.fn().mockResolvedValue({ amount0: AMOUNT_100_USDT, amount1: AMOUNT_100_ZCHF, - fee0: BigNumber.from(1_000_000), + fee0: BigNumber.from('1000000000000000000'), fee1: BigNumber.from('1000000000000000000'), txHashes: { decreaseLiquidity: '0xmock-decrease-hash', collect: '0xmock-collect-hash', burn: '0xmock-burn-hash' }, }), @@ -85,7 +85,7 @@ function buildContext(overrides: Record = {}) { }), findExistingPositions: jest.fn().mockResolvedValue([]), approveTokensSE: jest.fn().mockResolvedValue(undefined), - executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(50_000_000), txHash: '0xmock-swap-hash' }), + executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from('50000000000000000000'), txHash: '0xmock-swap-hash' }), setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn(), @@ -208,6 +208,25 @@ describe('Rebalance Lifecycle Integration', () => { }); it('initialize() finds on-chain positions and sets bands from chain', async () => { + const { ctx, mocks } = buildContext(); + mocks.findExistingPositions.mockResolvedValue( + Array.from({ length: 5 }, (_, i) => ({ + tokenId: BigNumber.from(789 + i), + tickLower: -100 + i * 40, + tickUpper: -60 + i * 40, + liquidity: BigNumber.from('5000000'), + })), + ); + + const engine = new RebalanceEngine(ctx); + await engine.initialize(); + + expect(engine.getState()).toBe('MONITORING'); + expect(engine.getBands()).toHaveLength(5); + expect(engine.getBands()[0].tokenId.eq(789)).toBe(true); + }); + + it('initialize() stops engine when on-chain positions are below minimum band count', async () => { const { ctx, mocks } = buildContext(); mocks.findExistingPositions.mockResolvedValue([ { @@ -221,9 +240,8 @@ describe('Rebalance Lifecycle Integration', () => { const engine = new RebalanceEngine(ctx); await engine.initialize(); - expect(engine.getState()).toBe('MONITORING'); - expect(engine.getBands()).toHaveLength(1); - expect(engine.getBands()[0].tokenId.eq(789)).toBe(true); + expect(engine.getState()).toBe('STOPPED'); + expect(mocks.notify).toHaveBeenCalledWith(expect.stringContaining('CRITICAL')); }); it('onPriceUpdate with no bands mints initial 7 bands', async () => { diff --git a/test/integration/state-persistence.spec.ts b/test/integration/state-persistence.spec.ts index 6015364..9996ce4 100644 --- a/test/integration/state-persistence.spec.ts +++ b/test/integration/state-persistence.spec.ts @@ -100,7 +100,7 @@ describe('State Persistence Integration', () => { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xNFT', @@ -129,16 +129,32 @@ describe('State Persistence Integration', () => { txHash: `0xmock-mint-hash-${mintCallCount}`, }; }), - removePosition: jest.fn().mockResolvedValue({ amount0: AMOUNT_100_USDT, amount1: AMOUNT_100_ZCHF, fee0: BigNumber.from(0), fee1: BigNumber.from(0), txHashes: { decreaseLiquidity: '0xmock-decrease-hash', collect: '0xmock-collect-hash', burn: '0xmock-burn-hash' } }), + removePosition: jest.fn().mockResolvedValue({ + amount0: AMOUNT_100_USDT, + amount1: AMOUNT_100_ZCHF, + fee0: BigNumber.from(0), + fee1: BigNumber.from(0), + txHashes: { + decreaseLiquidity: '0xmock-decrease-hash', + collect: '0xmock-collect-hash', + burn: '0xmock-burn-hash', + }, + }), getPosition: jest.fn().mockResolvedValue({ liquidity: BigNumber.from('1000') }), findExistingPositions: jest.fn().mockResolvedValue([]), }, - swapExecutor: { approveTokens: jest.fn().mockResolvedValue(undefined), executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(0), txHash: '0xmock-swap-hash' }) }, + swapExecutor: { + approveTokens: jest.fn().mockResolvedValue(undefined), + executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(0), txHash: '0xmock-swap-hash' }), + }, emergencyStop: new EmergencyStop(), slippageGuard: new SlippageGuard(0.5), ilTracker: new ILTracker(), balanceTracker: { setInitialValue: jest.fn(), getInitialValue: jest.fn(), getLossPercent: jest.fn() }, - gasOracle: { getGasInfo: jest.fn().mockResolvedValue({ gasPriceGwei: 20, isEip1559: false }), isGasSpike: jest.fn().mockReturnValue(false) }, + gasOracle: { + getGasInfo: jest.fn().mockResolvedValue({ gasPriceGwei: 20, isEip1559: false }), + isGasSpike: jest.fn().mockReturnValue(false), + }, stateStore, historyLogger: { log: jest.fn() }, notifier: { notify: jest.fn().mockResolvedValue(undefined) }, @@ -199,7 +215,7 @@ describe('State Persistence Integration', () => { id: 'USDT-ZCHF-100', chain: { name: 'ethereum', chainId: 1, rpcUrl: 'http://localhost:8545', backupRpcUrls: [] }, pool: { - token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 6 }, + token0: { address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', symbol: 'USDT', decimals: 18 }, token1: { address: '0xB58E61C3098d85632Df34EecfB899A1Ed80921cB', symbol: 'ZCHF', decimals: 18 }, feeTier: 100, nftManagerAddress: '0xNFT', @@ -219,16 +235,36 @@ describe('State Persistence Integration', () => { positionManager: { approveTokens: jest.fn().mockResolvedValue(undefined), mint, - removePosition: jest.fn().mockResolvedValue({ amount0: AMOUNT_100_USDT, amount1: AMOUNT_100_ZCHF, fee0: BigNumber.from(0), fee1: BigNumber.from(0), txHashes: { decreaseLiquidity: '0xmock-decrease-hash', collect: '0xmock-collect-hash', burn: '0xmock-burn-hash' } }), + removePosition: jest.fn().mockResolvedValue({ + amount0: AMOUNT_100_USDT, + amount1: AMOUNT_100_ZCHF, + fee0: BigNumber.from(0), + fee1: BigNumber.from(0), + txHashes: { + decreaseLiquidity: '0xmock-decrease-hash', + collect: '0xmock-collect-hash', + burn: '0xmock-burn-hash', + }, + }), getPosition: jest.fn().mockResolvedValue({ liquidity: BigNumber.from('1000') }), findExistingPositions: jest.fn().mockResolvedValue([]), }, - swapExecutor: { approveTokens: jest.fn().mockResolvedValue(undefined), executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(0), txHash: '0xmock-swap-hash' }) }, + swapExecutor: { + approveTokens: jest.fn().mockResolvedValue(undefined), + executeSwap: jest.fn().mockResolvedValue({ amountOut: BigNumber.from(0), txHash: '0xmock-swap-hash' }), + }, emergencyStop: new EmergencyStop(), slippageGuard: new SlippageGuard(0.5), ilTracker: new ILTracker(), - balanceTracker: { setInitialValue: jest.fn(), getInitialValue: jest.fn().mockReturnValue(undefined), getLossPercent: jest.fn() }, - gasOracle: { getGasInfo: jest.fn().mockResolvedValue({ gasPriceGwei: 20, isEip1559: false }), isGasSpike: jest.fn().mockReturnValue(false) }, + balanceTracker: { + setInitialValue: jest.fn(), + getInitialValue: jest.fn().mockReturnValue(undefined), + getLossPercent: jest.fn(), + }, + gasOracle: { + getGasInfo: jest.fn().mockResolvedValue({ gasPriceGwei: 20, isEip1559: false }), + isGasSpike: jest.fn().mockReturnValue(false), + }, stateStore, historyLogger: { log: jest.fn() }, notifier: { notify: jest.fn().mockResolvedValue(undefined) }, diff --git a/test/ratio-calculator.spec.ts b/test/ratio-calculator.spec.ts deleted file mode 100644 index 471027b..0000000 --- a/test/ratio-calculator.spec.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { BigNumber } from 'ethers'; -import { calculateSwap } from '../src/swap/ratio-calculator'; - -describe('calculateSwap', () => { - const USDT = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; - const ZCHF = '0xB58906E27d85EFC9DD6f15A0234dF2e2a23e5847'; - const decimals0 = 6; - const decimals1 = 18; - const feeTier = 100; - - it('should return null when balances are zero', () => { - const result = calculateSwap( - BigNumber.from(0), - BigNumber.from(0), - decimals0, - decimals1, - 0, - -100, - 100, - feeTier, - USDT, - ZCHF, - ); - expect(result).toBeNull(); - }); - - it('should return swap when price is below range (need all token0)', () => { - const balance0 = BigNumber.from(0); - const balance1 = BigNumber.from('100000000000000000000'); // 100 token1 - - const result = calculateSwap( - balance0, - balance1, - decimals0, - decimals1, - -200, // current tick below range - -100, - 100, - feeTier, - USDT, - ZCHF, - ); - - // Price below range → need all token0, so swap token1 → token0 - expect(result).not.toBeNull(); - expect(result!.direction).toBe('token1to0'); - expect(result!.amountIn.gt(0)).toBe(true); - }); - - it('should return swap when price is above range (need all token1)', () => { - const balance0 = BigNumber.from('100000000'); // 100 USDT - const balance1 = BigNumber.from(0); - - const result = calculateSwap( - balance0, - balance1, - decimals0, - decimals1, - 200, // current tick above range - -100, - 100, - feeTier, - USDT, - ZCHF, - ); - - // Price above range → need all token1, so swap token0 → token1 - expect(result).not.toBeNull(); - expect(result!.direction).toBe('token0to1'); - expect(result!.amountIn.gt(0)).toBe(true); - }); - - it('should return null when ratio difference is small', () => { - // When already balanced, should return null - const balance0 = BigNumber.from('50000000'); // 50 USDT - const balance1 = BigNumber.from('50000000000000000000'); // 50 ZCHF - - const result = calculateSwap( - balance0, - balance1, - decimals0, - decimals1, - 0, // tick=0, price ~1, centered in range - -150, - 150, - feeTier, - USDT, - ZCHF, - ); - - // At tick=0 with symmetric range, 50/50 split is approximately correct - // Either null or very small swap - if (result !== null) { - // If there's a swap, it should be relatively small - const amountNorm = parseFloat(result.amountIn.toString()); - const balance0Norm = 50000000; - expect(amountNorm / balance0Norm).toBeLessThan(0.1); // less than 10% - } - }); - - it('should not swap more than available balance', () => { - const balance0 = BigNumber.from('10000000'); // 10 USDT - const balance1 = BigNumber.from('1000000000000000000000'); // 1000 ZCHF - - const result = calculateSwap( - balance0, - balance1, - decimals0, - decimals1, - 0, - -100, - 100, - feeTier, - USDT, - ZCHF, - ); - - if (result) { - if (result.direction === 'token0to1') { - expect(result.amountIn.lte(balance0)).toBe(true); - } else { - expect(result.amountIn.lte(balance1)).toBe(true); - } - } - }); -});