Portfolio Rebalancer Agent (EVM) with WaaP CLI
What are we cooking?
An autonomous Node.js agent that maintains a target USD allocation of a single token (e.g. $1,000 of WETH) by trading it against a stable token (e.g. USDC) on Uniswap v3 whenever the price crosses configurable high or low thresholds. The agent is one of the Agent Exchange (AEX) verified agents and ships with the standalone template at holonym-foundation/aex .
This is a classic grid / constant-rebalance strategy:
- Price >= HIGH threshold -> sell enough target token to bring the position back to the target USD allocation.
- Price ≤ LOW threshold -> buy enough target token to bring the position back to the target USD allocation.
- In between -> do nothing.
Over time, the agent captures value from volatility while keeping consistent exposure — and every swap is signed through the WaaP CLI, so no raw private key ever touches the agent’s environment.
Worked example
Configuration: WETH/USDC on Base, target allocation $1,000, high $2,700, low $2,300, current WETH price $2,500.
At $2,500 the agent holds 0.4 WETH ($1,000).
- Price rises to $2,750: 0.4 WETH is now worth $1,100. The agent sells ~0.0364 WETH ($100 worth) for USDC, returning the position to $1,000.
- Price drops to $2,200: ~0.3636 WETH is now worth ~$800. The agent spends ~$200 USDC to buy ~0.0909 WETH, returning the position to $1,000.
The strategy works best on assets you are happy to hold long-term in a defined band — the agent is mean-reverting, not trend-following.
Key Components
- WaaP CLI — Signs and broadcasts on-chain transactions through a 2PC-MPC enclave. No raw private key in your
.env. - Uniswap v3 SwapRouter02 — The on-chain contract the agent calls via
exactInputSingleto execute swaps. - Uniswap v3 pool — Read directly via
slot0()to derive the current price fromsqrtPriceX96. - viem — Lightweight TypeScript client for on-chain reads (pool state, ERC-20 balances) and calldata encoding.
- Base network — The default target chain (Uniswap v3 is also deployed on Ethereum mainnet, Arbitrum, Optimism, and others).
What you need
- Node.js 20+
- WaaP CLI (
npm install -g @human.tech/waap-cli@latest) - A WaaP account — created via
waap-cli signup - A funded EVM wallet — ETH on Base for gas, plus the target and quote tokens (e.g. WETH and USDC) seeded close to the target allocation
Project Setup
mkdir waap-evm-portfolio-rebalancer && cd waap-evm-portfolio-rebalancer
npm init -y
npm install viem dotenv execa
npm install -g @human.tech/waap-cli@latestYou can also generate the full template (with Dockerfile, structured logging, and PID-file watchdog hooks) directly from the AEX template:
npx @human.tech/create-agent-wallet \
--activity evm-portfolio-rebalancer \
--runtime standalone \
my-rebalancerCreate a WaaP Wallet for Your Agent
# Create a dedicated agent account
waap-cli signup --email youremail+evm-rebalancer@example.com --password '12345678!'
# Or log in to an existing one
waap-cli login --email youremail+evm-rebalancer@example.com --password '12345678!'
# Point the CLI at Base for this agent
waap-cli chain set evm:8453
# Get the wallet address — fund it with ETH (gas) plus the target and quote tokens
waap-cli whoamiThe
--chainflag uses the documentedfamily:idform (e.g.evm:8453for Base,evm:1for Ethereum mainnet). The older--chain-idflag is a deprecated alias and should not be used in new code.
Environment Variables
Create a .env file (never commit this to source control):
# Required
TARGET_TOKEN=0x4200000000000000000000000000000000000006 # WETH on Base
QUOTE_TOKEN=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 # USDC on Base
TARGET_ALLOCATION_USD=1000 # Dollar value to maintain in WETH
HIGH_PRICE_THRESHOLD=2700 # Sell trigger ($)
LOW_PRICE_THRESHOLD=2300 # Buy trigger ($)
DEX_ROUTER=0x2626664c2603336E57B271c5C0b26F421741e481 # Uniswap v3 SwapRouter02 on Base
POOL_ADDRESS=0xd0b53D9277642d899DF5C87A3966A349A798F224 # WETH/USDC 0.05% pool on Base
# Optional — sensible defaults provided
POOL_FEE=500 # Pool fee tier in bps (500 = 0.05%, 3000 = 0.3%, 10000 = 1%)
TARGET_DECIMALS=18 # WETH decimals
QUOTE_DECIMALS=6 # USDC decimals
TOKEN0_IS_TARGET=true # In the WETH/USDC 0.05% pool on Base, WETH is token0 (lower hex address)
POLL_INTERVAL_MS=30000 # 30 seconds between price checks
SLIPPAGE_BPS=50 # 0.5% maximum slippage
AGENT_LOG_FILE=./logs/agent.jsonl # Structured JSONL log pathEnvironment variable reference
| Variable | Required | Default | Description |
|---|---|---|---|
TARGET_TOKEN | yes | Address of the token to rebalance (e.g. WETH) | |
QUOTE_TOKEN | yes | Address of the quote/stable token (e.g. USDC) | |
TARGET_ALLOCATION_USD | yes | Dollar value of TARGET_TOKEN holdings to maintain | |
HIGH_PRICE_THRESHOLD | yes | Sell trigger price (USD) | |
LOW_PRICE_THRESHOLD | yes | Buy trigger price (USD) | |
DEX_ROUTER | yes | Uniswap v3 SwapRouter02 contract address | |
POOL_ADDRESS | yes | Uniswap v3 pool address for the pair | |
POOL_FEE | no | 3000 | Pool fee tier in basis points (500 / 3000 / 10000) |
TARGET_DECIMALS | no | 18 | Decimals of TARGET_TOKEN |
QUOTE_DECIMALS | no | 6 | Decimals of QUOTE_TOKEN |
TOKEN0_IS_TARGET | no | true | Whether TARGET_TOKEN is token0 in the pool. Set to false if the quote token is token0. |
POLL_INTERVAL_MS | no | 30000 | Milliseconds between price checks |
SLIPPAGE_BPS | no | 50 | Max slippage on swaps (50 = 0.5%) |
AGENT_LOG_FILE | no | ./logs/[agentId].jsonl | Path to structured JSONL log file |
TOKEN0_IS_TARGETtip: Uniswap v3 sorts tokens by address.token0is whichever of the two has the lower hex address. For WETH/USDC on Base, WETH (0x4200…) istoken0because its address is lower than USDC’s (0x833589…), so setTOKEN0_IS_TARGET=truewhen WETH is your target. Get this wrong and the agent will trade in the wrong direction — always confirm against the pool’stoken0()getter.
The Recipe Workflow
1. Reading the price from the pool’s slot0
Uniswap v3 stores the current price as sqrtPriceX96 — the square root of the raw token1/token0 price, scaled by 2^96. The agent reads slot0() and converts that to a human-readable USD price:
import { createPublicClient, http, parseAbi, type Address } from 'viem'
import { base } from 'viem/chains'
const TARGET_DECIMALS = Number(process.env.TARGET_DECIMALS ?? '18')
const QUOTE_DECIMALS = Number(process.env.QUOTE_DECIMALS ?? '6')
const TOKEN0_IS_TARGET = (process.env.TOKEN0_IS_TARGET ?? 'true') === 'true'
const POOL_ADDRESS = process.env.POOL_ADDRESS as Address
const poolAbi = parseAbi([
'function slot0() external view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)',
])
const publicClient = createPublicClient({ chain: base, transport: http() })
function sqrtPriceX96ToPrice(sqrtPriceX96: bigint): number {
const Q96 = 2n ** 96n
const sqrtPrice = Number(sqrtPriceX96) / Number(Q96)
const rawPrice = sqrtPrice * sqrtPrice // raw = token1 / token0
if (TOKEN0_IS_TARGET) {
// human price of target in quote terms
return rawPrice * Math.pow(10, TARGET_DECIMALS - QUOTE_DECIMALS)
} else {
// target is token1; invert
if (rawPrice === 0) return 0
return (1 / rawPrice) * Math.pow(10, QUOTE_DECIMALS - TARGET_DECIMALS)
}
}
async function getCurrentPrice(): Promise<number> {
const result = await publicClient.readContract({
address: POOL_ADDRESS,
abi: poolAbi,
functionName: 'slot0',
})
return sqrtPriceX96ToPrice(result[0] as bigint)
}This avoids hitting any off-chain price feed — the agent uses the pool itself as its oracle, which is the same price the swap will execute against.
2. Reading the agent’s wallet balances
The agent also reads its current ERC-20 balances on each tick so it can compute its current USD value of the target token:
const erc20Abi = parseAbi([
'function balanceOf(address owner) external view returns (uint256)',
])
async function getTokenBalance(token: Address, wallet: Address): Promise<bigint> {
return await publicClient.readContract({
address: token,
abi: erc20Abi,
functionName: 'balanceOf',
args: [wallet],
}) as bigint
}3. Deciding when (and how much) to rebalance
This is the strategy. It’s only a few lines:
const TARGET_ALLOCATION_USD = Number(process.env.TARGET_ALLOCATION_USD)
const HIGH_PRICE_THRESHOLD = Number(process.env.HIGH_PRICE_THRESHOLD)
const LOW_PRICE_THRESHOLD = Number(process.env.LOW_PRICE_THRESHOLD)
async function decideRebalance(
currentPrice: number,
targetBalanceHuman: number,
quoteBalanceHuman: number,
) {
const currentValueUsd = targetBalanceHuman * currentPrice
if (currentPrice >= HIGH_PRICE_THRESHOLD) {
const excessUsd = currentValueUsd - TARGET_ALLOCATION_USD
if (excessUsd > 0) return { direction: 'sell' as const, amountUsd: excessUsd }
} else if (currentPrice <= LOW_PRICE_THRESHOLD) {
const deficitUsd = TARGET_ALLOCATION_USD - currentValueUsd
if (deficitUsd > 0) {
// Never try to buy more than the wallet can afford in quote token
return { direction: 'buy' as const, amountUsd: Math.min(deficitUsd, quoteBalanceHuman) }
}
}
return null
}If the price is between the thresholds, the agent does nothing. If the price is above the high threshold but the target token is already underweight (e.g. because of a previous trade), it also does nothing. This keeps the agent from churning when it has already done its job.
4. Encoding the Uniswap v3 swap
Once a direction and dollar amount are chosen, the agent converts that to a raw amountIn and a minimum acceptable amountOutMinimum (slippage-protected), then encodes exactInputSingle calldata:
import { encodeFunctionData, type Hex } from 'viem'
const swapRouterAbi = parseAbi([
'function exactInputSingle((address tokenIn, address tokenOut, uint24 fee, address recipient, uint256 amountIn, uint256 amountOutMinimum, uint160 sqrtPriceLimitX96)) external payable returns (uint256 amountOut)',
])
const SLIPPAGE_BPS = Number(process.env.SLIPPAGE_BPS ?? '50')
const POOL_FEE = Number(process.env.POOL_FEE ?? '3000')
function buildSwapCalldata(
direction: 'buy' | 'sell',
amountUsd: number,
currentPrice: number,
wallet: Address,
): { calldata: Hex; tokenIn: Address; amountIn: bigint } {
const TARGET_TOKEN = process.env.TARGET_TOKEN as Address
const QUOTE_TOKEN = process.env.QUOTE_TOKEN as Address
let tokenIn: Address, tokenOut: Address, amountIn: bigint, amountOutMinimum: bigint
if (direction === 'sell') {
tokenIn = TARGET_TOKEN
tokenOut = QUOTE_TOKEN
const tokenAmount = amountUsd / currentPrice
amountIn = BigInt(Math.floor(tokenAmount * Math.pow(10, TARGET_DECIMALS)))
const minOut = amountUsd * (1 - SLIPPAGE_BPS / 10_000)
amountOutMinimum = BigInt(Math.floor(minOut * Math.pow(10, QUOTE_DECIMALS)))
} else {
tokenIn = QUOTE_TOKEN
tokenOut = TARGET_TOKEN
amountIn = BigInt(Math.floor(amountUsd * Math.pow(10, QUOTE_DECIMALS)))
const expectedTokens = amountUsd / currentPrice
const minOut = expectedTokens * (1 - SLIPPAGE_BPS / 10_000)
amountOutMinimum = BigInt(Math.floor(minOut * Math.pow(10, TARGET_DECIMALS)))
}
const calldata = encodeFunctionData({
abi: swapRouterAbi,
functionName: 'exactInputSingle',
args: [{
tokenIn,
tokenOut,
fee: POOL_FEE,
recipient: wallet,
amountIn,
amountOutMinimum,
sqrtPriceLimitX96: 0n,
}],
})
return { calldata, tokenIn, amountIn }
}One-time approval: Before the agent can swap any ERC-20 token, the WaaP wallet must have already approved the Uniswap SwapRouter to spend the relevant tokens. You can do this once from the dashboard, or via a one-shot
waap-cli send-txagainst the token’sapprove(spender, amount).
5. Submitting the swap through WaaP CLI
The agent shells out to waap-cli send-tx, which co-signs the transaction with the WaaP enclave and broadcasts it. No raw private key is ever held by the agent process:
import { execa } from 'execa'
const CHAIN_ID = 8453 // Base
function parseWaapJson<T>(stdout: string): T {
const lines = stdout.split(/\r?\n/).filter((l) => l.trim().startsWith('{'))
for (const line of lines) {
try {
const obj = JSON.parse(line) as { event?: string }
if (obj.event === 'result') return obj as T
} catch {}
}
for (let i = lines.length - 1; i >= 0; i--) {
try { return JSON.parse(lines[i]) as T } catch {}
}
throw new Error(`Could not parse waap-cli JSON: ${stdout.slice(0, 200)}`)
}
async function sendTx(to: Address, data: Hex, value: string = '0') {
const { stdout } = await execa('waap-cli', [
'send-tx',
'--chain', `evm:${CHAIN_ID}`, // preferred form; --chain-id is a deprecated alias
'--to', to,
'--data', data,
'--value', value,
'--json',
])
return parseWaapJson<{ txHash: string }>(stdout)
}6. The full tick loop
Putting it all together, each iteration reads the price, snapshots the wallet, decides whether to rebalance, and (if so) builds + sends the swap. Structured JSONL log lines make the agent’s state observable to the AEX dashboard and any external supervisor:
import fs from 'fs'
import path from 'path'
const AGENT_ID = 'evm-portfolio-rebalancer'
const LOG_FILE = path.resolve(process.env.AGENT_LOG_FILE ?? `./logs/${AGENT_ID}.jsonl`)
function log(level: string, message: string, data?: Record<string, unknown>) {
const entry = { ts: new Date().toISOString(), agent: AGENT_ID, level, message, ...data }
console.log(JSON.stringify(entry))
try { fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n') } catch {}
}
async function whoamiEvm(): Promise<Address> {
const { stdout } = await execa('waap-cli', ['whoami', '--json'])
const parsed = parseWaapJson<{ evmWalletAddress?: string }>(stdout)
if (!parsed.evmWalletAddress) throw new Error('No EVM wallet — run `waap-cli signup` first')
return parsed.evmWalletAddress as Address
}
async function tick(wallet: Address) {
const TARGET_TOKEN = process.env.TARGET_TOKEN as Address
const QUOTE_TOKEN = process.env.QUOTE_TOKEN as Address
const currentPrice = await getCurrentPrice()
const targetBalance = await getTokenBalance(TARGET_TOKEN, wallet)
const quoteBalance = await getTokenBalance(QUOTE_TOKEN, wallet)
const targetBalanceHuman = Number(targetBalance) / Math.pow(10, TARGET_DECIMALS)
const quoteBalanceHuman = Number(quoteBalance) / Math.pow(10, QUOTE_DECIMALS)
log('info', 'price_check', {
currentPrice,
highThreshold: HIGH_PRICE_THRESHOLD,
lowThreshold: LOW_PRICE_THRESHOLD,
targetBalance: targetBalanceHuman,
quoteBalance: quoteBalanceHuman,
})
const decision = await decideRebalance(currentPrice, targetBalanceHuman, quoteBalanceHuman)
if (!decision) return
log('info', 'rebalance_triggered', decision)
const DEX_ROUTER = process.env.DEX_ROUTER as Address
const { calldata, tokenIn, amountIn } = buildSwapCalldata(
decision.direction,
decision.amountUsd,
currentPrice,
wallet,
)
const balance = await getTokenBalance(tokenIn, wallet)
if (balance < amountIn) {
log('warn', 'rebalance_failed', {
reason: 'insufficient_balance',
required: amountIn.toString(),
available: balance.toString(),
})
return
}
try {
const { txHash } = await sendTx(DEX_ROUTER, calldata)
log('info', 'rebalance_complete', { ...decision, txHash })
} catch (err) {
log('error', 'rebalance_failed', {
...decision,
error: err instanceof Error ? err.message : String(err),
})
}
}
async function main() {
const wallet = await whoamiEvm()
log('info', 'agent_start', { wallet, chainId: CHAIN_ID })
const POLL_MS = Number(process.env.POLL_INTERVAL_MS ?? '30000')
while (true) {
try { await tick(wallet) }
catch (err) {
log('error', 'tick_error', { error: err instanceof Error ? err.message : String(err) })
}
await new Promise((r) => setTimeout(r, POLL_MS))
}
}
main().catch((err) => {
log('error', 'fatal', { error: String(err) })
process.exit(1)
})Run it:
node --loader tsx agent.ts
# or, with the AEX template:
npm run devThe agent emits structured JSON lines:
agent_start— initial config snapshot and wallet address.price_check— current pool price, thresholds, and wallet balances.rebalance_triggered— direction (buy/sell), dollar amount, current price.rebalance_complete— successful swap with transaction hash.rebalance_failed— error details if the swap reverted or was skipped.
These same events feed the AEX dashboard if you’re running the agent under the standalone template.
Security: two-party signing and Privileges
Every swap goes out through waap-cli send-tx, which uses 2PC-MPC signing — the private key is split between your device and a remote enclave, and neither side can produce a signature alone. The agent process itself never has access to a complete key.
On top of that, WaaP Privileges let you scope what the agent is allowed to do, even if its code is compromised:
# Only allow swaps against the Uniswap SwapRouter on Base
waap-cli policy set --allowed-contracts 0x2626664c2603336E57B271c5C0b26F421741e481
# Cap total outflows per day
waap-cli policy set --daily-spend-limit 5000
# Require manual approval for any swap above a threshold
waap-cli policy set --approval-threshold 2500| Layer | What it does |
|---|---|
| 2PC-MPC signing | Private key never reconstructed; agent cannot sign on its own |
| Allowed contracts | Agent can only call the Uniswap router you specify |
| Daily spend limit | Caps total outflow per 24 hours |
| Approval threshold | Large swaps prompt for human approval via the dashboard |
No .env secrets | No raw private key in environment variables or code |
What’s next
- Trailing thresholds: Recompute the high/low thresholds based on a moving average so the agent adapts to regime changes.
- Multi-asset baskets: Run multiple instances of the agent in parallel, one per target token, sharing a single quote pool.
- Cross-chain: The same loop runs on any EVM chain with a Uniswap v3 deployment — change
CHAIN_ID,DEX_ROUTER, andPOOL_ADDRESS. - AEX dashboard: Deploy the standalone template from holonym-foundation/aex to get the JSONL logs ingested into the Agent Exchange dashboard for live monitoring and rebalance history.
- Telegram alerts: Pipe
rebalance_completeandrebalance_failedevents to a Telegram channel via the WaaP MCP server.
Related
- Uniswap v3 Rebalancer — concentrated-liquidity LP rebalancer (range-bound rather than price-bound)
- Portfolio Rebalancer (Sui) — the same strategy on Cetus / Sui, using the Cetus Aggregator for routing
- Recurring Payments — another standalone WaaP CLI agent pattern
- 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