Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4fe5fce
ci: add auto release PR workflow for develop -> main (#2)
TaprootFreak Feb 13, 2026
9ec2961
fix: harden RangeKeeper with 11 bug fixes and safety improvements (#4)
TaprootFreak Feb 13, 2026
82a18e4
Fix critical bugs in rebalance engine (#5)
TaprootFreak Feb 13, 2026
4bb449e
Fix auto-recovery, unhandled promise, dead code, failover tracking (#6)
TaprootFreak Feb 13, 2026
7b2abb4
Fix 5 critical/medium bugs: nonce desync, emergency withdraw, startup…
TaprootFreak Feb 13, 2026
309768f
Fix 6 bugs: nonce retry, double failover, approval nonce, swap amount…
TaprootFreak Feb 14, 2026
4dd85b3
Fix production safety issues: ETH price fallback, BigNumber precision…
TaprootFreak Feb 18, 2026
1b8a97f
Fix tick price decimal adjustment and mint slippage for out-of-range …
TaprootFreak Feb 19, 2026
1ee77e0
Validate persisted bands against on-chain state on startup (#11)
TaprootFreak Feb 19, 2026
935bbd6
Guard against low band count causing silent inactivity (#12)
TaprootFreak Feb 19, 2026
d9c32a4
Add Docker CI/CD workflows and ACI deployment templates (#13)
bernd2022 Feb 26, 2026
f3b09f2
Enable Azure ACI deployment with secrets and config (#14)
bernd2022 Feb 27, 2026
6abf91a
Fix EIP-55 address checksum validation in pool config (#15)
bernd2022 Feb 27, 2026
89c44d4
Fix address checksum normalization by lowercasing before getAddress (…
bernd2022 Feb 27, 2026
2ca9333
Add DEPLOYMENT_ENABLED flag to CI/CD workflows (#17)
bernd2022 Feb 27, 2026
400d4f0
Migrate CI/CD from Azure ACI to SSH deploy (#18)
TaprootFreak Apr 27, 2026
6a51d09
Fix CI/CD: use ARM64 runner and correct secret names (#19)
TaprootFreak Apr 27, 2026
41afcde
Fix ZCHF token address in pool config (#20)
TaprootFreak Apr 27, 2026
a9f10a2
Add svJUSD/WCBTC pool on Citrea mainnet (#21)
TaprootFreak Apr 27, 2026
875ebb4
Add Citrea mainnet chain addresses (JuiceSwap) (#22)
TaprootFreak Apr 27, 2026
1ded56f
Fix ZCHF/USDT expected price ratio and depeg threshold (#23)
TaprootFreak Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/auto-release-pr.yaml
Original file line number Diff line number Diff line change
@@ -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
51 changes: 51 additions & 0 deletions .github/workflows/rangekeeper-dev.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: RangeKeeper DEV CI/CD

on:
push:
branches: [develop]
workflow_dispatch:

env:
DOCKER_TAGS: dfxswiss/rangekeeper:beta
DEPLOY_SERVICE: rangekeeper

jobs:
build-and-deploy:
name: Build and deploy to DEV
runs-on: ubuntu-24.04-arm
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 }}
platforms: linux/arm64

- name: Install cloudflared
run: |
curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2025.4.0/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared

- name: Deploy to server
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_DEV_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "${{ secrets.DEPLOY_DEV_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key \
-o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_DEV_HOST }}" \
${{ secrets.DEPLOY_DEV_USER }}@${{ secrets.DEPLOY_DEV_HOST }} \
"${{ env.DEPLOY_SERVICE }}"
51 changes: 51 additions & 0 deletions .github/workflows/rangekeeper-prd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: RangeKeeper PRD CI/CD

on:
push:
branches: [main]
workflow_dispatch:

env:
DOCKER_TAGS: dfxswiss/rangekeeper:latest
DEPLOY_SERVICE: rangekeeper

jobs:
build-and-deploy:
name: Build and deploy to PRD
runs-on: ubuntu-24.04-arm
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 }}
platforms: linux/arm64

- name: Install cloudflared
run: |
curl -fsSL https://github.com/cloudflare/cloudflared/releases/download/2025.4.0/cloudflared-linux-arm64 -o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared

- name: Deploy to server
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_PRD_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "${{ secrets.DEPLOY_PRD_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
ssh -i ~/.ssh/deploy_key \
-o ProxyCommand="cloudflared access ssh --hostname ${{ secrets.DEPLOY_PRD_HOST }}" \
${{ secrets.DEPLOY_PRD_USER }}@${{ secrets.DEPLOY_PRD_HOST }} \
"${{ env.DEPLOY_SERVICE }}"
56 changes: 42 additions & 14 deletions config/pools.yaml
Original file line number Diff line number Diff line change
@@ -1,30 +1,58 @@
pools:
- id: "usdt-zchf-ethereum"
- id: 'usdt-zchf-ethereum'
chain:
name: "ethereum"
name: 'ethereum'
chainId: 1
rpcUrl: "${ETHEREUM_RPC_URL}"
backupRpcUrls:
- "${ETHEREUM_BACKUP_RPC_URL}"
rpcUrl: '${ETHEREUM_RPC_URL}'
backupRpcUrls: []
pool:
token0:
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7"
symbol: "USDT"
address: '0xb58e61C3098d85632Df34EecfB899A1Ed80921cB'
symbol: 'ZCHF'
decimals: 18
token1:
address: '0xdAC17F958D2ee523a2206206994597C13D831ec7'
symbol: 'USDT'
decimals: 6
feeTier: 100
nftManagerAddress: '0xC36442b4a4522E871399CD717aBDD847Ab11FE88'
swapRouterAddress: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45'
strategy:
rangeWidthPercent: 3.0
rebalanceThresholdPercent: 80
minRebalanceIntervalMinutes: 30
maxGasCostUsd: 5.0
slippageTolerancePercent: 0.5
expectedPriceRatio: 1.27 # ZCHF/USDT price (CHF is stronger than USD)
depegThresholdPercent: 10.0 # CHF/USD fluctuates, needs wider threshold
monitoring:
checkIntervalSeconds: 30

- id: 'svjusd-wcbtc-citrea'
chain:
name: 'citrea'
chainId: 4114
rpcUrl: '${CITREA_RPC_URL}'
backupRpcUrls: []
pool:
token0:
address: '0x1b70ae756b1089cc5948e4f8a2ad498df30e897d'
symbol: 'svJUSD'
decimals: 18
token1:
address: "0xB58906E27d85EFC9DD6f15A0234dF2e2a23e5847"
symbol: "ZCHF"
address: '0x3100000000000000000000000000000000000006'
symbol: 'WCBTC'
decimals: 18
feeTier: 100
nftManagerAddress: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88"
swapRouterAddress: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"
feeTier: 3000
nftManagerAddress: '0x3D3821D358f56395d4053954f98aec0E1F0fa568'
swapRouterAddress: '0x565eD3D57fe40f78A46f348C220121AE093c3cF8'
strategy:
rangeWidthPercent: 3.0
rebalanceThresholdPercent: 80
minRebalanceIntervalMinutes: 30
maxGasCostUsd: 5.0
slippageTolerancePercent: 0.5
expectedPriceRatio: 1.0 # expected token0/token1 price (for depeg detection)
depegThresholdPercent: 5.0 # alert if price deviates more than 5%
expectedPriceRatio: 1.0
depegThresholdPercent: 5.0
monitoring:
checkIntervalSeconds: 30
12 changes: 10 additions & 2 deletions src/chain/contracts.ts
Original file line number Diff line number Diff line change
@@ -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)',
Expand Down Expand Up @@ -61,10 +62,17 @@ export async function ensureApproval(
spender: string,
owner: string,
amount: BigNumber,
nonceTracker?: NonceTracker,
): Promise<void> {
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();
}
}
4 changes: 3 additions & 1 deletion src/chain/evm-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
15 changes: 11 additions & 4 deletions src/chain/gas-oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GasInfo> {
try {
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 9 additions & 5 deletions src/chain/nonce-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ export class NonceTracker {

async initialize(persistedNonce?: number): Promise<void> {
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 {
Expand All @@ -39,6 +40,9 @@ export class NonceTracker {
async syncOnFailover(): Promise<void> {
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',
);
}
}
7 changes: 7 additions & 0 deletions src/config/chain-addresses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ const CHAIN_ADDRESSES: Record<number, ChainAddresses> = {
quoterV2: '0x61fFE014bA17989E743c5F6cB21bF9697530B21e',
factory: '0x1F98431c8aD98523631AE4a59f267346ea31F984',
},
// Citrea Mainnet (JuiceSwap)
4114: {
nftPositionManager: '0x3D3821D358f56395d4053954f98aec0E1F0fa568',
swapRouter02: '0x565eD3D57fe40f78A46f348C220121AE093c3cF8',
quoterV2: '0x428f20dd8926Eabe19653815Ed0BE7D6c36f8425',
factory: '0xd809b1285aDd8eeaF1B1566Bf31B2B4C4Bba8e82',
},
};

export function getChainAddresses(chainId: number): ChainAddresses {
Expand Down
7 changes: 3 additions & 4 deletions src/config/env.config.ts
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -28,9 +29,7 @@ export function loadEnvConfig(): EnvConfig {

const result = envSchema.safeParse(process.env);
if (!result.success) {
const formatted = result.error.issues
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
.join('\n');
const formatted = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
throw new Error(`Environment validation failed:\n${formatted}`);
}

Expand Down
Loading
Loading