From 4fe5fce483869719d16860269565b68ad2850b8c Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:26:25 +0100 Subject: [PATCH 01/15] ci: add auto release PR workflow for develop -> main (#2) Triggers on every push to develop and creates a PR to main if none exists and there are new commits. Mirrors the existing workflow from the api repo. --- .github/workflows/auto-release-pr.yaml | 70 ++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/auto-release-pr.yaml 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 From 9ec29612aad57ab555cd902065e8359652cf3676 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:28:27 +0100 Subject: [PATCH 02/15] fix: harden RangeKeeper with 11 bug fixes and safety improvements (#4) - Atomic state file writes via write-to-tmp + renameSync - Band width validation for all bands (not just the last) - NonRetryableError class and retryableErrors filter for retry logic - Wallet balance check before swap execution - BigNumber integer arithmetic in SlippageGuard (replacing parseFloat) - Duplicate pool ID detection in config validation - Size-based history log rotation at 10MB - Median-based gas oracle baseline via ring buffer (replacing EMA) - Directional price parameter in IL tracker (priceIsToken0InToken1) - Constructor validation for Telegram and Discord notifier configs - Auto-recovery for tx-error emergency stops with configurable cooldown --- src/chain/gas-oracle.ts | 15 ++++-- src/config/pool.config.ts | 27 ++++++---- src/core/range-calculator.ts | 13 ++--- src/notification/discord-notifier.ts | 24 ++++++++- src/notification/telegram-notifier.ts | 41 ++++++++++----- src/persistence/history-logger.ts | 19 ++++++- src/persistence/state-store.ts | 10 ++-- src/risk/emergency-stop.ts | 33 ++++++++++-- src/risk/il-tracker.ts | 23 +++++++-- src/risk/slippage-guard.ts | 74 +++++++++++++++++++++++---- src/swap/swap-executor.ts | 42 +++++++++------ src/util/retry.ts | 29 ++++++++++- 12 files changed, 275 insertions(+), 75 deletions(-) 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/config/pool.config.ts b/src/config/pool.config.ts index 70ac075..13331be 100644 --- a/src/config/pool.config.ts +++ b/src/config/pool.config.ts @@ -19,9 +19,12 @@ 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', - }), + 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}$/), }); @@ -85,9 +88,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 +108,17 @@ 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(', ')}`); + } + + return pools; } 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/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/swap-executor.ts b/src/swap/swap-executor.ts index 7ab1b34..cd44968 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; @@ -51,10 +51,16 @@ export class SwapExecutor { 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); @@ -63,15 +69,18 @@ export class SwapExecutor { 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', ); @@ -89,7 +98,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})`); } diff --git a/src/util/retry.ts b/src/util/retry.ts index faf0240..7df7f46 100644 --- a/src/util/retry.ts +++ b/src/util/retry.ts @@ -1,5 +1,12 @@ import { getLogger } from './logger'; +export class NonRetryableError extends Error { + constructor(message: string) { + super(message); + this.name = 'NonRetryableError'; + } +} + export interface RetryOptions { maxRetries: number; baseDelayMs: number; @@ -24,12 +31,32 @@ 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; + } + + 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); } From 82a18e41c892d84ec97e778752daadda324beec2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:52:43 +0100 Subject: [PATCH 03/15] Fix critical bugs in rebalance engine (#5) - Fix initial mint: re-read wallet balance after each band mint to prevent using more tokens than available (was using original balance for all bands) - Fix depeg race condition: await emergencyWithdraw and add rebalanceLock guard to onPriceUpdate to prevent concurrent state corruption - Fix rebalance loss check: compare pre-swap vs post-swap wallet value instead of meaningless wallet dust before/after rebalance cycle - Fix emergency stop auto-recovery: pass 'tx-error' category in handleError so auto-recovery cooldown actually triggers (was defaulting to 'manual') - Add emergencyStop.isStopped() check in onPriceUpdate to catch externally triggered stops between price updates - Pass 'depeg' category to emergencyStop.trigger in checkDepeg --- src/core/rebalance-engine.ts | 195 ++++++++++++++++++++++------------- 1 file changed, 125 insertions(+), 70 deletions(-) diff --git a/src/core/rebalance-engine.ts b/src/core/rebalance-engine.ts index 3f8f74a..160c865 100644 --- a/src/core/rebalance-engine.ts +++ b/src/core/rebalance-engine.ts @@ -20,7 +20,15 @@ import { tickToPrice } 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; @@ -102,7 +110,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 +130,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,9 +164,10 @@ 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; + const bandWidth = + activeBands.length > 1 + ? activeBands[1].tickLower - activeBands[0].tickLower + : activeBands[0].tickUpper - activeBands[0].tickLower; this.bandManager.setBands(activeBands, bandWidth); this.logger.info({ bandCount: activeBands.length }, 'Found existing on-chain positions as bands'); } @@ -164,6 +184,11 @@ export class RebalanceEngine { async onPriceUpdate(poolState: PoolState): Promise { 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 +205,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 +223,45 @@ 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 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; } @@ -264,11 +301,13 @@ export class RebalanceEngine { this.persistState(stateStore, poolEntry.id); } 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` + + `Manual intervention required immediately`, + ) + .catch(() => {}); } finally { this.rebalanceLock = false; } @@ -319,7 +358,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 +381,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, @@ -376,7 +421,13 @@ export class RebalanceEngine { const bal1 = parseFloat(totalBalance1.toString()) / Math.pow(10, 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 +456,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 +483,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,16 +490,24 @@ 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) this.setState('SWAPPING'); @@ -468,6 +518,16 @@ export class RebalanceEngine { token1Contract.balanceOf(wallet.address), ]); + // Pre-swap value: dissolved band tokens + wallet dust (meaningful baseline for loss check) + const preSwapPrice = tickToPrice(poolState.tick); + const preSwapValue = this.estimatePortfolioValue( + balance0, + balance1, + pool.token0.decimals, + pool.token1.decimals, + preSwapPrice, + ); + 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 @@ -490,8 +550,7 @@ export class RebalanceEngine { } // 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 +581,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 = tickToPrice(poolState.tick); + 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 +652,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'); } From 4bb449ebb0f28aedbf7ab6d7349b968fe9eb34ac Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:52:31 +0100 Subject: [PATCH 04/15] Fix auto-recovery, unhandled promise, dead code, failover tracking (#6) * Fix auto-recovery, unhandled promise, dead code, failover tracking - Fix engine ERROR state blocking auto-recovery: when emergency stop clears after tx-error cooldown, engine now transitions back to MONITORING and resets consecutiveErrors counter - Catch unhandled promise rejection in onPriceUpdate event callback to prevent bot crash on unexpected errors - Remove dead code: ratio-calculator.ts was never imported by any source file - Call failoverProvider.recordSuccess() after successful pool state poll to properly reset error counter for intermittent failures * Format codebase with prettier Apply consistent formatting across all source and test files. --- src/chain/evm-provider.ts | 4 +- src/chain/nonce-tracker.ts | 14 +- src/config/env.config.ts | 4 +- src/core/band-manager.ts | 10 +- src/core/dry-run-position-manager.ts | 19 +-- src/core/position-manager.ts | 71 +++++---- src/core/rebalance-engine.ts | 7 + src/main.ts | 22 +-- src/swap/ratio-calculator.ts | 143 ------------------- test/helpers/fixtures.ts | 6 +- test/helpers/mock-context.ts | 6 +- test/integration/checkpoint-recovery.spec.ts | 44 +++--- test/integration/dry-run-e2e.spec.ts | 26 +++- test/integration/emergency-scenarios.spec.ts | 8 +- test/integration/gas-gating.spec.ts | 21 ++- test/integration/notification-flow.spec.ts | 26 +++- test/integration/pool-monitor-events.spec.ts | 21 ++- test/integration/state-persistence.spec.ts | 50 ++++++- test/ratio-calculator.spec.ts | 126 ---------------- 19 files changed, 247 insertions(+), 381 deletions(-) delete mode 100644 src/swap/ratio-calculator.ts delete mode 100644 test/ratio-calculator.spec.ts 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/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..96d510b 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -28,9 +28,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/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..b892238 100644 --- a/src/core/position-manager.ts +++ b/src/core/position-manager.ts @@ -100,19 +100,22 @@ export class PositionManager { 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', ); @@ -124,7 +127,10 @@ export class PositionManager { 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 +159,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,13 +180,16 @@ 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(); @@ -196,12 +208,15 @@ export class PositionManager { const collectNonce = this.nonceTracker ? { nonce: this.nonceTracker.getNextNonce() } : {}; const collectTx: ContractTransaction = await withRetry( () => - nftManager.collect({ - tokenId, - recipient: w.address, - amount0Max: maxUint128, - amount1Max: maxUint128, - }, collectNonce), + nftManager.collect( + { + tokenId, + recipient: w.address, + amount0Max: maxUint128, + amount1Max: maxUint128, + }, + collectNonce, + ), 'collect', ); const collectReceipt = await collectTx.wait(); diff --git a/src/core/rebalance-engine.ts b/src/core/rebalance-engine.ts index 160c865..ff44462 100644 --- a/src/core/rebalance-engine.ts +++ b/src/core/rebalance-engine.ts @@ -182,6 +182,13 @@ 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; diff --git a/src/main.ts b/src/main.ts index de60fdc..2b4b4f1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -66,10 +66,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 +84,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'); @@ -121,9 +120,9 @@ 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()) { @@ -171,7 +170,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(); 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/test/helpers/fixtures.ts b/test/helpers/fixtures.ts index cfc96f7..0940b2e 100644 --- a/test/helpers/fixtures.ts +++ b/test/helpers/fixtures.ts @@ -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..3e92760 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 diff --git a/test/integration/checkpoint-recovery.spec.ts b/test/integration/checkpoint-recovery.spec.ts index c78096f..1f97e75 100644 --- a/test/integration/checkpoint-recovery.spec.ts +++ b/test/integration/checkpoint-recovery.spec.ts @@ -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..e114746 100644 --- a/test/integration/dry-run-e2e.spec.ts +++ b/test/integration/dry-run-e2e.spec.ts @@ -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..ac2d214 100644 --- a/test/integration/emergency-scenarios.spec.ts +++ b/test/integration/emergency-scenarios.spec.ts @@ -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..cdfd4be 100644 --- a/test/integration/gas-gating.spec.ts +++ b/test/integration/gas-gating.spec.ts @@ -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..ac6059d 100644 --- a/test/integration/notification-flow.spec.ts +++ b/test/integration/notification-flow.spec.ts @@ -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..56706d8 100644 --- a/test/integration/pool-monitor-events.spec.ts +++ b/test/integration/pool-monitor-events.spec.ts @@ -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/state-persistence.spec.ts b/test/integration/state-persistence.spec.ts index 6015364..54f13ed 100644 --- a/test/integration/state-persistence.spec.ts +++ b/test/integration/state-persistence.spec.ts @@ -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) }, @@ -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); - } - } - }); -}); From 7b2abb48354dd4c61b20a4719356c6d6396835b9 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:53:49 +0100 Subject: [PATCH 05/15] Fix 5 critical/medium bugs: nonce desync, emergency withdraw, startup, shutdown, swap slippage (#7) * Fix nonce desync, emergency withdraw, startup crash, shutdown persist, swap slippage - Move confirmNonce() before revert-check in position-manager and swap-executor to prevent nonce desync when TX reverts on-chain (nonce is consumed even on revert) - Fix emergency withdraw partial failure: remove bands incrementally from bandManager and persist after each successful removal - Prevent startup notification failure from crashing the bot (add .catch()) - Persist state on graceful shutdown before process.exit() - Compute price-aware amountOutMinimum in swap-executor using current tick and token decimals instead of assuming 1:1 ratio - Fix pre-existing prettier formatting issues * Add missing .catch() on emergency withdraw success notification Prevents a misleading "CRITICAL: Emergency withdraw FAILED" message when the withdraw itself succeeded but the notification delivery failed. --- src/core/position-manager.ts | 8 +-- src/core/rebalance-engine.ts | 57 ++++++++++++++++----- src/main.ts | 9 +++- src/swap/dry-run-swap-executor.ts | 5 ++ src/swap/swap-executor.ts | 83 +++++++++++++++++++++++++++++-- 5 files changed, 141 insertions(+), 21 deletions(-) diff --git a/src/core/position-manager.ts b/src/core/position-manager.ts index b892238..afe4fec 100644 --- a/src/core/position-manager.ts +++ b/src/core/position-manager.ts @@ -120,10 +120,10 @@ export class PositionManager { ); 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) { @@ -193,10 +193,10 @@ export class PositionManager { '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'); @@ -220,10 +220,10 @@ export class PositionManager { 'collect', ); const collectReceipt = await collectTx.wait(); + this.nonceTracker?.confirmNonce(); if (collectReceipt.status === 0) { throw new Error('collect transaction reverted on-chain'); } - 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'); @@ -239,10 +239,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/rebalance-engine.ts b/src/core/rebalance-engine.ts index ff44462..98cc921 100644 --- a/src/core/rebalance-engine.ts +++ b/src/core/rebalance-engine.ts @@ -284,34 +284,59 @@ 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` + + `Removed ${removedCount}/${totalBands} bands before failure\n` + `Manual intervention required immediately`, ) .catch(() => {}); @@ -545,6 +570,10 @@ export class RebalanceEngine { pool.feeTier, balance0, strategy.slippageTolerancePercent, + poolState.tick, + pool.token0.decimals, + pool.token1.decimals, + true, ); } else if (direction === 'upper' && balance1.gt(0)) { swapResult = await swapExecutor.executeSwap( @@ -553,6 +582,10 @@ export class RebalanceEngine { pool.feeTier, balance1, strategy.slippageTolerancePercent, + poolState.tick, + pool.token1.decimals, + pool.token0.decimals, + false, ); } diff --git a/src/main.ts b/src/main.ts index 2b4b4f1..13017d2 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 @@ -190,7 +192,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'); } @@ -206,7 +210,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/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/swap-executor.ts b/src/swap/swap-executor.ts index cd44968..a976f5e 100644 --- a/src/swap/swap-executor.ts +++ b/src/swap/swap-executor.ts @@ -47,6 +47,10 @@ 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; @@ -62,9 +66,15 @@ export class SwapExecutor { ); } - // 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( @@ -85,10 +95,10 @@ export class SwapExecutor { ); 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'; @@ -117,4 +127,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); + } } From 309768f03826b7c1154c9b2bea05845b95e11669 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Sat, 14 Feb 2026 09:38:08 +0100 Subject: [PATCH 06/15] Fix 6 bugs: nonce retry, double failover, approval nonce, swap amount, band width, collect retry (#8) - Make nonce-related TX errors non-retryable in withRetry to prevent dangerous retry of already-mined transactions - Add guard flag to prevent double failover execution when both setInterval and setTimeout fire in RPC failover callback - Pass NonceTracker through ensureApproval so approval TXs don't desync the nonce sequence on first startup - Limit rebalance swap to dissolved band amount instead of entire wallet balance to prevent swapping unrelated funds - Fix bandTickWidth recovery to use individual band width instead of inter-band distance which breaks for non-contiguous bands - Add dedicated collect retry (5 attempts) after decreaseLiquidity succeeds to prevent tokens getting stuck in NFT manager --- src/chain/contracts.ts | 12 ++++++-- src/core/position-manager.ts | 59 +++++++++++++++++++++++------------- src/core/rebalance-engine.ts | 28 +++++++++++------ src/main.ts | 13 +++++--- src/swap/swap-executor.ts | 14 ++++++--- src/util/retry.ts | 16 ++++++++++ 6 files changed, 101 insertions(+), 41 deletions(-) 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/core/position-manager.ts b/src/core/position-manager.ts index afe4fec..4ce1847 100644 --- a/src/core/position-manager.ts +++ b/src/core/position-manager.ts @@ -72,10 +72,16 @@ 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'); } @@ -204,25 +210,36 @@ 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(); - this.nonceTracker?.confirmNonce(); - 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; } const collectEvent = collectReceipt.events?.find((e: { event?: string }) => e.event === 'Collect'); if (!collectEvent?.args) { diff --git a/src/core/rebalance-engine.ts b/src/core/rebalance-engine.ts index 98cc921..d62103b 100644 --- a/src/core/rebalance-engine.ts +++ b/src/core/rebalance-engine.ts @@ -164,12 +164,11 @@ 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'); } } } @@ -542,6 +541,7 @@ export class RebalanceEngine { ); // 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); @@ -550,7 +550,7 @@ export class RebalanceEngine { token1Contract.balanceOf(wallet.address), ]); - // Pre-swap value: dissolved band tokens + wallet dust (meaningful baseline for loss check) + // Pre-swap value: wallet balance (meaningful baseline for loss check) const preSwapPrice = tickToPrice(poolState.tick); const preSwapValue = this.estimatePortfolioValue( balance0, @@ -560,27 +560,35 @@ export class RebalanceEngine { 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, diff --git a/src/main.ts b/src/main.ts index 13017d2..6f04714 100644 --- a/src/main.ts +++ b/src/main.ts @@ -113,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); @@ -130,7 +133,7 @@ async function main(): Promise { 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(); } @@ -138,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(); diff --git a/src/swap/swap-executor.ts b/src/swap/swap-executor.ts index a976f5e..6269933 100644 --- a/src/swap/swap-executor.ts +++ b/src/swap/swap-executor.ts @@ -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'); } diff --git a/src/util/retry.ts b/src/util/retry.ts index 7df7f46..7efbb98 100644 --- a/src/util/retry.ts +++ b/src/util/retry.ts @@ -7,6 +7,15 @@ export class NonRetryableError extends Error { } } +// 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; @@ -35,6 +44,13 @@ export async function withRetry(fn: () => Promise, label: string, opts?: P 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())); From 4dd85b3a0d8a2e6d8007a882e56831f9b50020ef Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:10:05 +0100 Subject: [PATCH 07/15] Fix production safety issues: ETH price fallback, BigNumber precision, token order (#9) - Remove hardcoded ETH_PRICE_USD_FALLBACK (3000 USD); skip USD gas cost check when no live ETH price is available instead of using a stale value - Fix BigNumber precision loss in portfolio value estimation by using ethers utils.formatUnits instead of parseFloat on raw BigNumber strings - Add Uniswap V3 token order validation (token0 < token1) to pool config loader with actionable error message - Fix pools.yaml token order: ZCHF (0xB589...) is token0, USDT (0xdAC1...) is token1 --- config/pools.yaml | 10 +++++----- src/config/pool.config.ts | 10 ++++++++++ src/core/rebalance-engine.ts | 19 +++++++++++-------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/config/pools.yaml b/config/pools.yaml index 6e52da6..52ba625 100644 --- a/config/pools.yaml +++ b/config/pools.yaml @@ -8,13 +8,13 @@ pools: - "${ETHEREUM_BACKUP_RPC_URL}" pool: token0: - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - symbol: "USDT" - decimals: 6 - token1: address: "0xB58906E27d85EFC9DD6f15A0234dF2e2a23e5847" symbol: "ZCHF" decimals: 18 + token1: + address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" + symbol: "USDT" + decimals: 6 feeTier: 100 nftManagerAddress: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88" swapRouterAddress: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45" @@ -24,7 +24,7 @@ pools: minRebalanceIntervalMinutes: 30 maxGasCostUsd: 5.0 slippageTolerancePercent: 0.5 - expectedPriceRatio: 1.0 # expected token0/token1 price (for depeg detection) + 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/config/pool.config.ts b/src/config/pool.config.ts index 13331be..e853ad7 100644 --- a/src/config/pool.config.ts +++ b/src/config/pool.config.ts @@ -120,5 +120,15 @@ export function loadPoolConfigs(configPath?: string): PoolEntry[] { 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/rebalance-engine.ts b/src/core/rebalance-engine.ts index d62103b..0b77f54 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'; @@ -31,7 +31,6 @@ export type RebalanceState = | 'STOPPED'; const REBALANCE_GAS_ESTIMATE = 800_000; -const ETH_PRICE_USD_FALLBACK = 3000; export interface RebalanceContext { poolEntry: PoolEntry; @@ -363,8 +362,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( @@ -448,8 +451,8 @@ export class RebalanceEngine { // 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 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( @@ -762,8 +765,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'); From 1b8a97fda75770283c6a5a4bd17fbd9cb5ac9b8f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:40:37 +0100 Subject: [PATCH 08/15] Fix tick price decimal adjustment and mint slippage for out-of-range bands (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix tick price calculation ignoring token decimals and mint slippage for out-of-range bands tickToPrice() returned raw 1.0001^tick without decimal adjustment, causing immediate emergency stop for pairs with different decimals. Added tickToAdjustedPrice() that applies 10^(decimals0-decimals1) correction. mint() applied slippage to desired amounts instead of actual expected amounts, causing reverts for out-of-range bands where one token amount is 0. Now uses callStatic.mint() to simulate first, matching the removePosition() pattern. * Fix remaining 6-decimal mock values in integration tests Update fee0 (1_000_000 → 1e18) and swap amountOut (50_000_000 → 50e18) to be consistent with test token decimals changed to 18. --- src/core/position-manager.ts | 24 +++++++++++++++++--- src/core/rebalance-engine.ts | 11 +++++---- src/util/tick-math.ts | 4 ++++ test/helpers/fixtures.ts | 10 ++++---- test/helpers/mock-context.ts | 4 ++-- test/integration/band-rebalance.spec.ts | 6 ++--- test/integration/checkpoint-recovery.spec.ts | 6 ++--- test/integration/dry-run-e2e.spec.ts | 2 +- test/integration/emergency-scenarios.spec.ts | 4 ++-- test/integration/gas-gating.spec.ts | 4 ++-- test/integration/notification-flow.spec.ts | 4 ++-- test/integration/pool-monitor-events.spec.ts | 2 +- test/integration/rebalance-lifecycle.spec.ts | 6 ++--- test/integration/state-persistence.spec.ts | 4 ++-- 14 files changed, 57 insertions(+), 34 deletions(-) diff --git a/src/core/position-manager.ts b/src/core/position-manager.ts index 4ce1847..dc02eea 100644 --- a/src/core/position-manager.ts +++ b/src/core/position-manager.ts @@ -87,9 +87,6 @@ export class PositionManager { } 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( @@ -103,6 +100,27 @@ 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( () => diff --git a/src/core/rebalance-engine.ts b/src/core/rebalance-engine.ts index 0b77f54..e7e5899 100644 --- a/src/core/rebalance-engine.ts +++ b/src/core/rebalance-engine.ts @@ -16,7 +16,7 @@ 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'; @@ -234,7 +234,8 @@ export class RebalanceEngine { if (!strategy.expectedPriceRatio) return false; - const currentPrice = tickToPrice(poolState.tick); + 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; @@ -450,7 +451,7 @@ export class RebalanceEngine { this.consecutiveErrors = 0; // Set IL tracker entry and initial portfolio value - const currentPrice = tickToPrice(poolState.tick); + 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); @@ -554,7 +555,7 @@ export class RebalanceEngine { ]); // Pre-swap value: wallet balance (meaningful baseline for loss check) - const preSwapPrice = tickToPrice(poolState.tick); + const preSwapPrice = tickToAdjustedPrice(poolState.tick, pool.token0.decimals, pool.token1.decimals); const preSwapValue = this.estimatePortfolioValue( balance0, balance1, @@ -633,7 +634,7 @@ export class RebalanceEngine { this.consecutiveErrors = 0; // Post-swap value check: compare value before swap (dissolved band) with value after swap - const postSwapPrice = tickToPrice(poolState.tick); + const postSwapPrice = tickToAdjustedPrice(poolState.tick, pool.token0.decimals, pool.token1.decimals); const postSwapValue = this.estimatePortfolioValue( newBal0, newBal1, 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 0940b2e..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', diff --git a/test/helpers/mock-context.ts b/test/helpers/mock-context.ts index 3e92760..c9449a9 100644 --- a/test/helpers/mock-context.ts +++ b/test/helpers/mock-context.ts @@ -121,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', @@ -268,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 1f97e75..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(), diff --git a/test/integration/dry-run-e2e.spec.ts b/test/integration/dry-run-e2e.spec.ts index e114746..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', diff --git a/test/integration/emergency-scenarios.spec.ts b/test/integration/emergency-scenarios.spec.ts index ac2d214..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(), diff --git a/test/integration/gas-gating.spec.ts b/test/integration/gas-gating.spec.ts index cdfd4be..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(), diff --git a/test/integration/notification-flow.spec.ts b/test/integration/notification-flow.spec.ts index ac6059d..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' }, }), diff --git a/test/integration/pool-monitor-events.spec.ts b/test/integration/pool-monitor-events.spec.ts index 56706d8..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', diff --git a/test/integration/rebalance-lifecycle.spec.ts b/test/integration/rebalance-lifecycle.spec.ts index eded0e4..546a087 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(), diff --git a/test/integration/state-persistence.spec.ts b/test/integration/state-persistence.spec.ts index 54f13ed..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', @@ -215,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', From 1ee77e09aca43ca92c58a5896e821f1f48a00c45 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:06:33 +0100 Subject: [PATCH 09/15] Validate persisted bands against on-chain state on startup (#11) * Validate persisted bands against on-chain state on startup If the process crashes during emergency withdraw between removing a position on-chain and persisting the updated state, the state file contains a burned tokenId. On restart, the bot would try to operate with a non-existent position. Now validates each loaded band via getPosition() and drops any that no longer exist on-chain or have zero liquidity. * Handle RPC errors gracefully during band validation on startup Distinguish between Uniswap-specific errors (invalid token ID) and transient RPC errors during on-chain band validation. On RPC failure, abort validation and keep all persisted bands to prevent data loss. --- src/core/rebalance-engine.ts | 46 +++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/core/rebalance-engine.ts b/src/core/rebalance-engine.ts index e7e5899..5fcb38b 100644 --- a/src/core/rebalance-engine.ts +++ b/src/core/rebalance-engine.ts @@ -89,7 +89,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, @@ -97,9 +97,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 From 935bbd60d3df2ec1f70bff11c9bfcbd299efa387 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:05:45 +0100 Subject: [PATCH 10/15] Guard against low band count causing silent inactivity (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The safe zone and trigger zone formulas in BandManager overlap when band count drops below 5. At 3 bands, all indices are "safe" and no rebalance ever triggers — the bot becomes silently inert. Add MIN_OPERATIONAL_BANDS (5) check during initialize(). If band count is above 0 but below the minimum (e.g. after partial emergency withdraw + crash), the engine stops with a CRITICAL notification instead of operating with broken trigger logic. --- src/core/rebalance-engine.ts | 17 ++++++++++++++ test/integration/rebalance-lifecycle.spec.ts | 24 +++++++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/core/rebalance-engine.ts b/src/core/rebalance-engine.ts index 5fcb38b..9dc5ffb 100644 --- a/src/core/rebalance-engine.ts +++ b/src/core/rebalance-engine.ts @@ -31,6 +31,7 @@ export type RebalanceState = | 'STOPPED'; const REBALANCE_GAS_ESTIMATE = 800_000; +const MIN_OPERATIONAL_BANDS = 5; export interface RebalanceContext { poolEntry: PoolEntry; @@ -212,6 +213,22 @@ export class RebalanceEngine { } } + // 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); diff --git a/test/integration/rebalance-lifecycle.spec.ts b/test/integration/rebalance-lifecycle.spec.ts index 546a087..740bf0e 100644 --- a/test/integration/rebalance-lifecycle.spec.ts +++ b/test/integration/rebalance-lifecycle.spec.ts @@ -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 () => { From d9c32a4b659f4170443e7556e85a52ec18fa6987 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:48:23 +0100 Subject: [PATCH 11/15] Add Docker CI/CD workflows and ACI deployment templates (#13) - Add GitHub Actions workflows for DEV (develop) and PRD (main) with Docker Hub push - Add ACI YAML templates for Azure Container Instance deployment (currently disabled) - Load .env from Azure File Share mount with local fallback in env.config.ts --- .github/workflows/rangekeeper-dev.yaml | 99 ++++++++++++++++++++++++++ .github/workflows/rangekeeper-prd.yaml | 99 ++++++++++++++++++++++++++ aci-dev.yaml | 37 ++++++++++ aci-prd.yaml | 37 ++++++++++ src/config/env.config.ts | 3 +- 5 files changed, 274 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/rangekeeper-dev.yaml create mode 100644 .github/workflows/rangekeeper-prd.yaml create mode 100644 aci-dev.yaml create mode 100644 aci-prd.yaml diff --git a/.github/workflows/rangekeeper-dev.yaml b/.github/workflows/rangekeeper-dev.yaml new file mode 100644 index 0000000..17798e0 --- /dev/null +++ b/.github/workflows/rangekeeper-dev.yaml @@ -0,0 +1,99 @@ +name: RangeKeeper DEV CI/CD + +on: + push: + branches: [develop] + workflow_dispatch: + +env: + 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 }} + + # --- Azure deployment steps (disabled until infrastructure is ready) --- + # - name: Log in to Azure + # uses: azure/login@v2 + # with: + # creds: ${{ secrets.DFX_DEV_CREDENTIALS }} + # + # - name: Get Storage Key + # 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 + # 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 + # 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 }} + # + # - name: Stop Azure Container Instance + # run: | + # az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true + # + # - name: Delete Azure Container Instance + # run: | + # az container delete --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --yes || true + # + # - name: Deploy container instance + # run: | + # az container create --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --file ./rendered-aci-dev.yaml + # + # - 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..1aa9715 --- /dev/null +++ b/.github/workflows/rangekeeper-prd.yaml @@ -0,0 +1,99 @@ +name: RangeKeeper PRD CI/CD + +on: + push: + branches: [main] + workflow_dispatch: + +env: + 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 }} + + # --- Azure deployment steps (disabled until infrastructure is ready) --- + # - name: Log in to Azure + # uses: azure/login@v2 + # with: + # creds: ${{ secrets.DFX_PRD_CREDENTIALS }} + # + # - name: Get Storage Key + # 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 + # 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 + # 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 }} + # + # - name: Stop Azure Container Instance + # run: | + # az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true + # + # - name: Delete Azure Container Instance + # run: | + # az container delete --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --yes || true + # + # - name: Deploy container instance + # run: | + # az container create --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --file ./rendered-aci-prd.yaml + # + # - 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..710bbf8 --- /dev/null +++ b/aci-dev.yaml @@ -0,0 +1,37 @@ +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: + - name: DEPLOY_INFO + value: '${DEPLOY_INFO}' + 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..c4ba41c --- /dev/null +++ b/aci-prd.yaml @@ -0,0 +1,37 @@ +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: + - name: DEPLOY_INFO + value: '${DEPLOY_INFO}' + 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/src/config/env.config.ts b/src/config/env.config.ts index 96d510b..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), From f3b09f24b43640866c15f782aafb2c786ed23b9c Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:03:45 +0100 Subject: [PATCH 12/15] Enable Azure ACI deployment with secrets and config (#14) - Activate Azure deployment steps in dev and prd CI/CD workflows - Add environment secrets (private key, RPC URLs, notifications) to ACI templates - Add config variables (LOG_LEVEL, HEALTH_PORT, MAX_TOTAL_LOSS_PERCENT, DRY_RUN) - Remove unused ETHEREUM_BACKUP_RPC_URL from env example and pools config --- .env.example | 1 - .github/workflows/rangekeeper-dev.yaml | 119 +++++++++++++------------ .github/workflows/rangekeeper-prd.yaml | 119 +++++++++++++------------ aci-dev.yaml | 22 +++++ aci-prd.yaml | 22 +++++ config/pools.yaml | 25 +++--- 6 files changed, 180 insertions(+), 128 deletions(-) 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/rangekeeper-dev.yaml b/.github/workflows/rangekeeper-dev.yaml index 17798e0..87883af 100644 --- a/.github/workflows/rangekeeper-dev.yaml +++ b/.github/workflows/rangekeeper-dev.yaml @@ -40,60 +40,65 @@ jobs: push: true tags: ${{ env.DOCKER_TAGS }} - # --- Azure deployment steps (disabled until infrastructure is ready) --- - # - name: Log in to Azure - # uses: azure/login@v2 - # with: - # creds: ${{ secrets.DFX_DEV_CREDENTIALS }} - # - # - name: Get Storage Key - # 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 - # 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 - # 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 }} - # - # - name: Stop Azure Container Instance - # run: | - # az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true - # - # - name: Delete Azure Container Instance - # run: | - # az container delete --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --yes || true - # - # - name: Deploy container instance - # run: | - # az container create --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --file ./rendered-aci-dev.yaml - # - # - name: Logout from Azure - # run: | - # az logout - # if: always() + - name: Log in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.DFX_DEV_CREDENTIALS }} + + - name: Get Storage Key + 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 + 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 + 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 + run: | + az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true + + - name: Delete Azure Container Instance + run: | + az container delete --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --yes || true + + - name: Deploy container instance + run: | + az container create --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --file ./rendered-aci-dev.yaml + + - name: Logout from Azure + run: | + az logout + if: always() diff --git a/.github/workflows/rangekeeper-prd.yaml b/.github/workflows/rangekeeper-prd.yaml index 1aa9715..a5caa22 100644 --- a/.github/workflows/rangekeeper-prd.yaml +++ b/.github/workflows/rangekeeper-prd.yaml @@ -40,60 +40,65 @@ jobs: push: true tags: ${{ env.DOCKER_TAGS }} - # --- Azure deployment steps (disabled until infrastructure is ready) --- - # - name: Log in to Azure - # uses: azure/login@v2 - # with: - # creds: ${{ secrets.DFX_PRD_CREDENTIALS }} - # - # - name: Get Storage Key - # 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 - # 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 - # 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 }} - # - # - name: Stop Azure Container Instance - # run: | - # az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true - # - # - name: Delete Azure Container Instance - # run: | - # az container delete --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --yes || true - # - # - name: Deploy container instance - # run: | - # az container create --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --file ./rendered-aci-prd.yaml - # - # - name: Logout from Azure - # run: | - # az logout - # if: always() + - name: Log in to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.DFX_PRD_CREDENTIALS }} + + - name: Get Storage Key + 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 + 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 + 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 + run: | + az container stop --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} || true + + - name: Delete Azure Container Instance + run: | + az container delete --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --name ${{ env.CONTAINER_NAME }} --yes || true + + - name: Deploy container instance + run: | + az container create --resource-group ${{ env.AZURE_RESOURCE_GROUP }} --file ./rendered-aci-prd.yaml + + - name: Logout from Azure + run: | + az logout + if: always() diff --git a/aci-dev.yaml b/aci-dev.yaml index 710bbf8..74faf75 100644 --- a/aci-dev.yaml +++ b/aci-dev.yaml @@ -15,8 +15,30 @@ properties: 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 diff --git a/aci-prd.yaml b/aci-prd.yaml index c4ba41c..f37cb35 100644 --- a/aci-prd.yaml +++ b/aci-prd.yaml @@ -15,8 +15,30 @@ properties: 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 diff --git a/config/pools.yaml b/config/pools.yaml index 52ba625..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: "0xB58906E27d85EFC9DD6f15A0234dF2e2a23e5847" - symbol: "ZCHF" + address: '0xB58906E27d85EFC9DD6f15A0234dF2e2a23e5847' + symbol: 'ZCHF' decimals: 18 token1: - address: "0xdAC17F958D2ee523a2206206994597C13D831ec7" - symbol: "USDT" + 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 ZCHF/USDT 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 From 6abf91abbcab5166bee8621769928ea121e05851 Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:22:12 +0100 Subject: [PATCH 13/15] Fix EIP-55 address checksum validation in pool config (#15) Normalize all addresses from pools.yaml using ethers.utils.getAddress() during Zod schema validation. This prevents startup crashes caused by addresses with incorrect mixed-case checksum encoding. --- src/config/pool.config.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/config/pool.config.ts b/src/config/pool.config.ts index e853ad7..b1c8f32 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)), symbol: z.string().min(1), decimals: z.number().int().min(0).max(18), }); @@ -25,8 +29,14 @@ const poolSchema = z.object({ .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}$/), + nftManagerAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/) + .transform((addr) => ethers.utils.getAddress(addr)), + swapRouterAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/) + .transform((addr) => ethers.utils.getAddress(addr)), }); const strategySchema = z.object({ From 89c44d437f32b9d3d85e07088ff747c35666434c Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:33:45 +0100 Subject: [PATCH 14/15] Fix address checksum normalization by lowercasing before getAddress (#16) getAddress() throws on addresses with incorrect mixed-case checksum. Lowercasing first converts to an unchecksummed address, which getAddress() then normalizes to a correct EIP-55 checksum. --- src/config/pool.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/pool.config.ts b/src/config/pool.config.ts index b1c8f32..c6697e4 100644 --- a/src/config/pool.config.ts +++ b/src/config/pool.config.ts @@ -8,7 +8,7 @@ const tokenSchema = z.object({ address: z .string() .regex(/^0x[a-fA-F0-9]{40}$/) - .transform((addr) => ethers.utils.getAddress(addr)), + .transform((addr) => ethers.utils.getAddress(addr.toLowerCase())), symbol: z.string().min(1), decimals: z.number().int().min(0).max(18), }); @@ -32,11 +32,11 @@ const poolSchema = z.object({ nftManagerAddress: z .string() .regex(/^0x[a-fA-F0-9]{40}$/) - .transform((addr) => ethers.utils.getAddress(addr)), + .transform((addr) => ethers.utils.getAddress(addr.toLowerCase())), swapRouterAddress: z .string() .regex(/^0x[a-fA-F0-9]{40}$/) - .transform((addr) => ethers.utils.getAddress(addr)), + .transform((addr) => ethers.utils.getAddress(addr.toLowerCase())), }); const strategySchema = z.object({ From 2ca933331dbe43b7875db1699574ef337126c0cf Mon Sep 17 00:00:00 2001 From: bernd2022 <104787072+bernd2022@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:01:42 +0100 Subject: [PATCH 15/15] Add DEPLOYMENT_ENABLED flag to CI/CD workflows (#17) Allow stopping/starting container deployment via a single flag in the workflow files. When set to false, the Docker image is still built and pushed but the container is only stopped, not redeployed. Currently set to false for both DEV and PRD. --- .github/workflows/rangekeeper-dev.yaml | 12 ++++++++++++ .github/workflows/rangekeeper-prd.yaml | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/rangekeeper-dev.yaml b/.github/workflows/rangekeeper-dev.yaml index 87883af..81cb34a 100644 --- a/.github/workflows/rangekeeper-dev.yaml +++ b/.github/workflows/rangekeeper-dev.yaml @@ -6,6 +6,7 @@ on: workflow_dispatch: env: + DEPLOYMENT_ENABLED: false DOCKER_TAGS: dfxswiss/rangekeeper:beta AZURE_RESOURCE_GROUP: rg-dfx-api-dev AZURE_STORAGE_ACCOUNT_NAME: stdfxapidev @@ -46,6 +47,7 @@ jobs: 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 }} \ @@ -54,6 +56,7 @@ jobs: 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 }} \ @@ -68,6 +71,7 @@ jobs: 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 @@ -87,17 +91,25 @@ jobs: 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 diff --git a/.github/workflows/rangekeeper-prd.yaml b/.github/workflows/rangekeeper-prd.yaml index a5caa22..a497af6 100644 --- a/.github/workflows/rangekeeper-prd.yaml +++ b/.github/workflows/rangekeeper-prd.yaml @@ -6,6 +6,7 @@ on: workflow_dispatch: env: + DEPLOYMENT_ENABLED: false DOCKER_TAGS: dfxswiss/rangekeeper:latest AZURE_RESOURCE_GROUP: rg-dfx-api-prd AZURE_STORAGE_ACCOUNT_NAME: stdfxapiprd @@ -46,6 +47,7 @@ jobs: 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 }} \ @@ -54,6 +56,7 @@ jobs: 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 }} \ @@ -68,6 +71,7 @@ jobs: 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 @@ -87,17 +91,25 @@ jobs: 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