Uniswap v3 Rebalancer Agent with WaaP CLI
What are we cooking?
An autonomous Node.js agent that manages a Uniswap v3 concentrated-liquidity position using the WaaP CLI. The agent monitors the pool price relative to the position’s tick range and triggers a rebalance when the price drifts out of bounds — all without ever exposing a raw private key.
Concentrated liquidity on Uniswap v3 earns significantly higher swap fees than full-range positions, but only while the price stays within your chosen tick range. When the price drifts out of range, your position stops earning fees entirely. This agent automates the tedious cycle of monitoring, withdrawing, and re-deploying liquidity around the current price.
The agent will:
- Read the current pool state (price tick, position range) on-chain via
viem. - Compare the current tick to the position’s lower and upper ticks.
- If out of range: remove all liquidity with slippage protection via
waap-cli send-tx. - Collect accrued swap fees.
- Drain tokens back to the operator wallet for manual re-deployment (Phase 1) or auto-mint at a new range (Phase 2).
Key Components
- WaaP CLI — Signs and broadcasts on-chain transactions through a 2PC-MPC enclave. No raw private key in your
.env. - Uniswap v3 NonfungiblePositionManager — The on-chain contract that tracks LP positions as NFTs.
- viem — Lightweight TypeScript client for on-chain reads (position state, pool slot0).
- Base network — The target chain (Uniswap v3 is deployed on Base, Ethereum mainnet, Arbitrum, and others).
Prerequisites
Before starting, you need:
- A WaaP wallet — created via
waap-cli signup - ETH on Base — for gas fees (rebalances cost roughly $1-3 per cycle on Base)
- An existing Uniswap v3 position — the NFT token ID from the NonfungiblePositionManager
- Pool tokens — whatever pair your position tracks (e.g., ETH and USDC)
Project Setup
mkdir waap-uniswap-rebalancer && cd waap-uniswap-rebalancer
npm init -y
npm install viem dotenv execa
npm install -g @human.tech/waap-cli@latestSet Up a WaaP Wallet for Your Agent
# Create a dedicated agent account
waap-cli signup --email youremail+uni-rebalancer@example.com --password '12345678!'
# Or log in to an existing one
waap-cli login --email youremail+uni-rebalancer@example.com --password '12345678!'
# Get the wallet address — fund it with ETH (for gas) and the pool tokens
waap-cli whoamiEnvironment Variables
Create a .env file (never commit this to source control):
# Required
AGENT_POSITION_ID=847291 # Your Uniswap v3 NFT token ID
AGENT_MAX_DEPOSIT_USD=10000 # Hard cap on total value managed
# Optional — sensible defaults provided
AGENT_RANGE_BPS=500 # +-250 bps = +-2.5% tick range width
AGENT_MAX_SLIPPAGE_BPS=200 # 2% max slippage on decreaseLiquidity
RPC_URL=https://mainnet.base.org # Base RPC endpoint
AGENT_POLL_INTERVAL_MS=900000 # 15 minutes between range checks
AGENT_DRY_RUN=1 # Set to '0' to go live — anything else = dry runThe Recipe Workflow
1. Reading the Position On-Chain
The agent reads the current state of the Uniswap v3 position NFT using the NonfungiblePositionManager contract:
import { createPublicClient, http, parseAbi } from 'viem';
import { base } from 'viem/chains';
const NPM_ADDRESS = '0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1';
const npmAbi = parseAbi([
'function positions(uint256 tokenId) view returns (uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1)',
]);
const client = createPublicClient({ chain: base, transport: http(process.env.RPC_URL) });
async function readPosition(tokenId) {
const result = await client.readContract({
address: NPM_ADDRESS,
abi: npmAbi,
functionName: 'positions',
args: [BigInt(tokenId)],
});
const [, , token0, token1, fee, tickLower, tickUpper, liquidity] = result;
return { token0, token1, fee, tickLower, tickUpper, liquidity };
}2. Monitoring Pool Price (Current Tick vs Range)
The agent reads the pool’s slot0 to get the current tick, then compares it to the position’s tickLower and tickUpper:
const poolAbi = parseAbi([
'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
]);
const factoryAbi = parseAbi([
'function getPool(address tokenA, address tokenB, uint24 fee) view returns (address pool)',
]);
const FACTORY_ADDRESS = '0x33128a8fC17869897dcE68Ed026d694621f6FDfD';
async function readPoolState(position) {
// Look up the pool address from the factory
const poolAddress = await client.readContract({
address: FACTORY_ADDRESS,
abi: factoryAbi,
functionName: 'getPool',
args: [position.token0, position.token1, position.fee],
});
const slot0 = await client.readContract({
address: poolAddress,
abi: poolAbi,
functionName: 'slot0',
});
const currentTick = Number(slot0[1]);
return { poolAddress, currentTick };
}
function isInRange(position, currentTick) {
return currentTick >= position.tickLower && currentTick <= position.tickUpper;
}3. Rebalance Trigger Logic
The rebalance is triggered when the current tick falls outside the position’s range. The agent computes a new target range centered on the current tick:
function computeTargetRange(currentTick, tickSpacing, rangeBps) {
const half = Math.floor(rangeBps / 2);
const align = (t) => Math.round(t / tickSpacing) * tickSpacing;
const tickLower = align(currentTick - half);
const tickUpper = Math.max(align(currentTick + half), tickLower + tickSpacing);
return { tickLower, tickUpper };
}4. Remove Liquidity with Slippage Protection
When the price is out of range, the agent removes all liquidity from the position. Slippage protection prevents sandwich attacks from draining value during the unwind:
import { encodeFunctionData } from 'viem';
import { execa } from 'execa';
const npmAbiWrite = parseAbi([
'function decreaseLiquidity((uint256 tokenId, uint128 liquidity, uint256 amount0Min, uint256 amount1Min, uint256 deadline)) returns (uint256 amount0, uint256 amount1)',
'function collect((uint256 tokenId, address recipient, uint128 amount0Max, uint128 amount1Max)) returns (uint256 amount0, uint256 amount1)',
'function burn(uint256 tokenId)',
]);
async function removeLiquidity(tokenId, liquidity, amount0Min, amount1Min) {
const deadline = BigInt(Math.floor(Date.now() / 1000) + 600);
const data = encodeFunctionData({
abi: npmAbiWrite,
functionName: 'decreaseLiquidity',
args: [{
tokenId: BigInt(tokenId),
liquidity,
amount0Min,
amount1Min,
deadline,
}],
});
// WaaP CLI signs and submits the transaction through the 2PC enclave
const { stdout } = await execa('waap-cli', [
'send-tx',
'--to', NPM_ADDRESS,
'--value', '0',
'--data', data,
'--chain', 'evm:8453', // Base chain ID
]);
const match = stdout.match(/0x[a-fA-F0-9]{64}/);
console.log('decreaseLiquidity submitted:', match?.[0]);
}5. Fee Collection
After removing liquidity, the agent collects any accrued swap fees from the position:
async function collectFees(tokenId, recipientAddress) {
const MAX_UINT128 = 2n ** 128n - 1n;
const data = encodeFunctionData({
abi: npmAbiWrite,
functionName: 'collect',
args: [{
tokenId: BigInt(tokenId),
recipient: recipientAddress,
amount0Max: MAX_UINT128,
amount1Max: MAX_UINT128,
}],
});
const { stdout } = await execa('waap-cli', [
'send-tx',
'--to', NPM_ADDRESS,
'--value', '0',
'--data', data,
'--chain', 'evm:8453',
]);
const match = stdout.match(/0x[a-fA-F0-9]{64}/);
console.log('Fees collected:', match?.[0]);
}6. Putting It All Together — The Poll Loop
import 'dotenv/config';
const POSITION_ID = process.env.AGENT_POSITION_ID;
const RANGE_BPS = Number(process.env.AGENT_RANGE_BPS ?? 500);
const POLL_MS = Number(process.env.AGENT_POLL_INTERVAL_MS ?? 15 * 60 * 1000);
const DRY_RUN = process.env.AGENT_DRY_RUN !== '0';
async function getWalletAddress() {
const { stdout } = await execa('waap-cli', ['whoami', '--json']);
const parsed = JSON.parse(stdout);
return parsed.evmWalletAddress;
}
async function tick(owner) {
const position = await readPosition(POSITION_ID);
if (position.liquidity === 0n) {
console.log('Position has 0 liquidity — nothing to manage');
return;
}
const { poolAddress, currentTick } = await readPoolState(position);
console.log(`Current tick: ${currentTick}, range: [${position.tickLower}, ${position.tickUpper}]`);
if (isInRange(position, currentTick)) {
console.log('In range — no rebalance needed');
return;
}
console.log('OUT OF RANGE — triggering rebalance');
if (DRY_RUN) {
console.log('[DRY_RUN] Would remove liquidity and collect fees');
return;
}
// Step 1: Remove all liquidity with slippage protection
await removeLiquidity(POSITION_ID, position.liquidity, 0n, 0n);
// Step 2: Collect fees and recovered tokens
await collectFees(POSITION_ID, owner);
console.log('Rebalance complete. Tokens drained to operator wallet.');
}
async function main() {
const owner = await getWalletAddress();
console.log(`Agent wallet: ${owner}`);
console.log(`Position: ${POSITION_ID}, range width: +-${RANGE_BPS / 2} bps, dry run: ${DRY_RUN}`);
while (true) {
try {
await tick(owner);
} catch (err) {
console.error('Tick failed:', err.message);
}
await new Promise((r) => setTimeout(r, POLL_MS));
}
}
main().catch((err) => {
console.error('Fatal:', err);
process.exit(1);
});Run the agent:
# Start in dry-run mode first (default)
node agent.js
# Go live — submit real transactions
AGENT_DRY_RUN=0 node agent.jsSafety: Privileges and Spend Limits
WaaP supports Privileges — pre-approved spending scopes that restrict which contracts the agent can interact with and how much it can spend. For a rebalancer agent:
# Only allow transactions to the NonfungiblePositionManager contract on Base
waap-cli policy set --allowed-contracts 0x03a520b32C04BF3bEEf7BEb72E919cf822Ed34f1
# Set a daily spend limit to cap gas costs
waap-cli policy set --daily-spend-limit 50
# Require human approval for any transaction above a threshold
waap-cli policy set --approval-threshold 5000These constraints ensure the agent can only interact with the Uniswap contracts you specify, and cannot drain the wallet beyond the configured limits — even if the agent code is compromised.
Next Steps
Now that your agent can monitor positions and rebalance when out of range:
- Add auto-mint (Phase 2): After draining, compute the optimal ratio and mint a new position at the target range.
- Add a swap step: Use the Uniswap SwapRouter to rebalance the token ratio before re-minting.
- Connect a dashboard: The Agent Exchange dashboard provides a live view of position state, rebalance history, and fee earnings.
- Multi-position support: Manage multiple pools from a single agent loop.
- Alert integration: Send Telegram or Slack notifications on rebalance events.
Related
- WaaP CLI and Skills — CLI command reference and agent workflows
- Privileges — Pre-approved spending scopes for automated flows
- Send Transactions — Transaction lifecycle, async flow, and events