Portfolio Rebalancer Agent (Sui) with WaaP CLI and the Cetus Aggregator
What are we cooking?
An autonomous Node.js agent that maintains a target USD allocation of a token on Sui (e.g. $500 of SUI denominated in USDC) by trading it whenever the price crosses configurable high or low thresholds. The agent monitors price directly from a Cetus concentrated-liquidity pool, and routes every swap through the Cetus Aggregator — the same routing engine that powers app.cetus.zone — so each rebalance executes at the best available price across all Cetus pools, sometimes splitting the order across multiple pools.
This is one of the verified Agent Exchange (AEX) agents and ships with the standalone template at holonym-foundation/aex .
The strategy is the same classic grid / mean-reversion shape used by the EVM portfolio rebalancer:
- Price >= HIGH threshold -> sell target token back down to the target allocation.
- Price ≤ LOW threshold -> buy more target token up to the target allocation.
- In between -> log and wait.
Every transaction is signed via waap-cli send-tx using 2PC-MPC — no raw private key ever lives in the agent’s environment.
Why the Cetus Aggregator matters
Cetus is the largest concentrated-liquidity DEX on Sui, and the same SUI/USDC pair can have multiple pools at different fee tiers and liquidity profiles. Hitting a single pool directly often leaves money on the table — the aggregator searches across all of them, evaluates split routes, and returns the path that maximizes output for the given input. This recipe builds the agent on top of the official @cetusprotocol/aggregator-sdk, so:
- Routing is identical to what the Cetus front-end uses.
- The aggregator handles slippage protection and coin merging automatically.
- New pools come online for free — the agent picks them up the next tick.
The aggregator is the developer-facing piece of the Cetus stack for non-LP usage; if you are building anything that swaps on Sui, this is the SDK to reach for.
Worked example: SUI/USDC
| Parameter | Value |
|---|---|
TARGET_TOKEN_TYPE | 0x2::sui::SUI |
QUOTE_TOKEN_TYPE | USDC type on Sui mainnet |
TARGET_ALLOCATION_USD | 500 |
HIGH_PRICE_THRESHOLD | 4.50 |
LOW_PRICE_THRESHOLD | 3.00 |
CETUS_POOL_ID | SUI/USDC pool on Cetus |
- When SUI hits $4.50, the agent sells enough SUI so that the remaining SUI position is worth $500.
- When SUI drops to $3.00, it buys SUI to bring the position back to $500.
- Between $3.00 and $4.50 the agent just logs and waits.
Key Components
- WaaP CLI — Signs and broadcasts Sui transactions via a 2PC-MPC enclave. No raw private key in your
.env. @mysten/sui— Sui RPC client andTransactionbuilder.@cetusprotocol/cetus-sui-clmm-sdk— Used to read pool state (current_sqrt_price, coin types) for price monitoring.@cetusprotocol/aggregator-sdk— Used to find and build the optimal swap route across all Cetus pools.- Sui mainnet — The default target chain (
NETWORK=testnetalso supported).
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 Sui wallet — at least 0.2 SUI for gas plus the target and quote tokens you want to manage
Funding tip: Send native SUI from an exchange directly to your Sui address. Do not bridge wrapped SUI from another chain — you need native SUI for gas, and most Sui pools (including Cetus) trade native SUI, not a bridged variant.
Project Setup
mkdir waap-sui-portfolio-rebalancer && cd waap-sui-portfolio-rebalancer
npm init -y
npm install \
@mysten/sui \
@cetusprotocol/cetus-sui-clmm-sdk \
@cetusprotocol/aggregator-sdk \
bn.js dotenv execa
npm install -g @human.tech/waap-cli@latestYou can also generate the full template (Dockerfile, structured logging, graceful shutdown) directly from the AEX template:
npx @human.tech/create-agent-wallet \
--activity sui-portfolio-rebalancer \
--runtime standalone \
my-rebalancerCreate a WaaP Wallet for Your Agent
# Create a dedicated agent account
waap-cli signup --email youremail+sui-rebalancer@example.com --password '12345678!'
# Or log in
waap-cli login --email youremail+sui-rebalancer@example.com --password '12345678!'
# Point the CLI at Sui mainnet (use sui:testnet during development)
waap-cli chain set sui:mainnet
# Get the Sui wallet address and fund it
waap-cli whoamiAlways use the
--chainformsui:mainnet(orsui:testnet). The deprecated--chain-idalias is not supported for Sui.
Environment Variables
Create a .env file (never commit this to source control):
# Required
TARGET_TOKEN_TYPE=0x2::sui::SUI
QUOTE_TOKEN_TYPE=0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC
TARGET_ALLOCATION_USD=500 # USD value to maintain in SUI
HIGH_PRICE_THRESHOLD=4.50 # Sell trigger ($/SUI)
LOW_PRICE_THRESHOLD=3.00 # Buy trigger ($/SUI)
CETUS_POOL_ID=0xb8d7d9e66a60c239e7a60110efcf8de6c705580ed924d0dde141f4a0e2c90105 # SUI/USDC pool on Cetus
# Optional — sensible defaults provided
NETWORK=mainnet # 'mainnet' or 'testnet'
POLL_INTERVAL_MS=60000 # 60 seconds between price checks
SLIPPAGE_BPS=50 # 0.5% max slippage
SUI_RPC= # Override the @mysten/sui fullnode URL
CETUS_AGGREGATOR_URL=https://api-sui.cetus.zone/router_v2 # Cetus aggregator endpoint
WAAP_AGENT_ADDRESS= # Override `waap-cli whoami` (for local dev)
AGENT_LOG_FILE=./logs/agent.jsonl # Structured JSONL log pathEnvironment variable reference
| Variable | Required | Default | Description |
|---|---|---|---|
TARGET_TOKEN_TYPE | yes | Sui type string for the target token (e.g. 0x2::sui::SUI) | |
QUOTE_TOKEN_TYPE | yes | Sui type string for the quote token | |
TARGET_ALLOCATION_USD | yes | USD value to maintain in the target token | |
HIGH_PRICE_THRESHOLD | yes | Sell trigger (USD price of target token) | |
LOW_PRICE_THRESHOLD | yes | Buy trigger (USD price of target token) | |
CETUS_POOL_ID | yes | Cetus pool object ID used for price reads | |
POLL_INTERVAL_MS | no | 60000 | Milliseconds between price checks |
SLIPPAGE_BPS | no | 50 | Max slippage in basis points |
NETWORK | no | mainnet | mainnet or testnet |
SUI_RPC | no | (mysten fullnode) | Sui RPC endpoint override |
CETUS_AGGREGATOR_URL | no | https://api-sui.cetus.zone/router_v2 | Cetus aggregator API endpoint |
WAAP_AGENT_ADDRESS | no | Override the Sui wallet address (bypass waap-cli whoami) | |
AGENT_LOG_FILE | no | ./logs/[agentId].jsonl | Path for JSON-line log file |
The
CETUS_POOL_IDis only used for price reads (the price oracle for the agent). Swap routing happens through the Cetus aggregator and may go through entirely different pools when that yields better execution.
The Recipe Workflow
1. Initialising the Sui, Cetus CLMM, and Aggregator clients
The agent uses three clients side by side: a plain Sui RPC client for balance reads, the Cetus CLMM SDK for pool state, and the Cetus Aggregator SDK for swap routing.
import 'dotenv/config'
import { SuiClient, getFullnodeUrl } from '@mysten/sui/client'
import { Transaction } from '@mysten/sui/transactions'
import { initCetusSDK } from '@cetusprotocol/cetus-sui-clmm-sdk'
import { AggregatorClient } from '@cetusprotocol/aggregator-sdk'
import BN from 'bn.js'
const NETWORK = (process.env.NETWORK ?? 'mainnet') as 'mainnet' | 'testnet'
const SUI_RPC = process.env.SUI_RPC ?? getFullnodeUrl(NETWORK)
const sui = new SuiClient({ url: SUI_RPC })
const cetus = initCetusSDK({ network: NETWORK })
// The Cetus aggregator finds the optimal swap route across all Cetus pools,
// splitting across multiple pools when that yields better execution than
// routing through a single pool directly.
const aggregator = new AggregatorClient({
signer: SUI_RPC, // RPC URL — signing is handled by waap-cli, not the aggregator
env: NETWORK === 'mainnet' ? 'mainnet' : 'testnet',
})Note the unusual signer: SUI_RPC pattern: the aggregator only needs a Sui endpoint to query state and build transactions; the actual signing is done out-of-band by waap-cli send-tx, so we never give the aggregator a real signer.
2. Reading the price from the Cetus pool’s current_sqrt_price
Cetus stores sqrt(price) * 2^64 as a u128 on each pool. To get a human-readable price of the target token in quote terms, we square it, divide out the scale, and adjust by the difference in coin decimals:
const CETUS_POOL_ID = process.env.CETUS_POOL_ID!
const TARGET_TOKEN_TYPE = process.env.TARGET_TOKEN_TYPE!
interface PoolState {
currentSqrtPrice: string
coinTypeA: string
coinTypeB: string
decimalsA: number
decimalsB: number
}
async function getPoolState(): Promise<PoolState> {
const pool = await cetus.Pool.getPool(CETUS_POOL_ID) as Record<string, unknown>
return {
currentSqrtPrice: String(pool.current_sqrt_price ?? '0'),
coinTypeA: String(pool.coinTypeA ?? ''),
coinTypeB: String(pool.coinTypeB ?? ''),
decimalsA: Number(pool.decimalsA ?? 9),
decimalsB: Number(pool.decimalsB ?? 6),
}
}
function calculatePrice(pool: PoolState): number {
const sqrtPriceX64 = BigInt(pool.currentSqrtPrice)
const Q64 = BigInt(1) << BigInt(64)
// price = (sqrtPriceX64^2 / 2^128) * 10^(decimalsA - decimalsB)
const sqrtSquared = sqrtPriceX64 * sqrtPriceX64
const shifted = Number(sqrtSquared) / Number(Q64 * Q64)
const priceAinB = shifted * Math.pow(10, pool.decimalsA - pool.decimalsB)
// If the target token is coinA we use the price directly; if coinB, invert
const targetSym = TARGET_TOKEN_TYPE.split('::').pop()!
const targetIsA = pool.coinTypeA.includes(targetSym)
return targetIsA ? priceAinB : (priceAinB > 0 ? 1 / priceAinB : 0)
}3. Reading balances
SuiClient.getBalance returns the total balance of a given coin type for an address. The agent converts both balances into floats using a small helper that maps common Sui coin types to their decimals:
function guessDecimals(coinType: string): number {
const lower = coinType.toLowerCase()
if (lower.includes('::sui::') || lower === '0x2::sui::sui') return 9
if (lower.includes('usdc') || lower.includes('usdt')) return 6
return 9
}
async function getTokenBalance(owner: string, coinType: string, decimals: number) {
const r = await sui.getBalance({ owner, coinType })
return Number(r.totalBalance) / Math.pow(10, decimals)
}4. Finding the best swap route via the Cetus Aggregator
This is the heart of the agent. Once a direction and dollar amount are chosen, the agent asks the aggregator for the best route, then asks it to write that route into a Transaction block:
const SLIPPAGE_BPS = Number(process.env.SLIPPAGE_BPS ?? '50')
const QUOTE_TOKEN_TYPE = process.env.QUOTE_TOKEN_TYPE!
async function executeSwap(
owner: string,
direction: 'buy' | 'sell',
amountUsd: number,
currentPrice: number,
): Promise<string | null> {
const targetDecimals = guessDecimals(TARGET_TOKEN_TYPE)
const quoteDecimals = guessDecimals(QUOTE_TOKEN_TYPE)
let fromCoin: string, toCoin: string, amountRaw: bigint
if (direction === 'sell') {
fromCoin = TARGET_TOKEN_TYPE
toCoin = QUOTE_TOKEN_TYPE
const tokenAmount = amountUsd / currentPrice
amountRaw = BigInt(Math.floor(tokenAmount * Math.pow(10, targetDecimals)))
} else {
fromCoin = QUOTE_TOKEN_TYPE
toCoin = TARGET_TOKEN_TYPE
amountRaw = BigInt(Math.floor(amountUsd * Math.pow(10, quoteDecimals)))
}
// 1. Ask the aggregator for the best route(s). It may return a single route
// or multiple routes that are intended to be executed together for split
// execution.
const routeResult = await aggregator.findRouters({
from: fromCoin,
target: toCoin,
amount: new BN(amountRaw.toString()),
byAmountIn: true,
})
if (!routeResult || !routeResult.routes || routeResult.routes.length === 0) {
throw new Error(`Cetus aggregator found no route from ${fromCoin} to ${toCoin}`)
}
// 2. Build a Sui Transaction and let the aggregator write the swap into it.
const tx = new Transaction()
tx.setSender(owner)
await aggregator.fastRouterSwap({
routers: routeResult.routes,
byAmountIn: true,
txb: tx,
slippage: SLIPPAGE_BPS / 100, // bps -> percent, e.g. 50 bps -> 0.5
isMergeTragetCoin: true,
refreshAllCoins: true,
})
// 3. Hand the built tx bytes to waap-cli for 2PC signing + broadcast.
const txBytes = Buffer.from(await tx.build({ client: sui })).toString('base64')
return signAndSendTx(txBytes)
}A few things worth pointing out:
byAmountIn: truemeans we’re fixing the input amount (we know exactly how much target token we want to sell, or how many dollars of quote we want to spend) and letting slippage absorb the output uncertainty.isMergeTragetCoin: trueconsolidates output coin objects on Sui — useful because Sui native objects fragment as you trade.- The aggregator never sees a real signer; it only needs to know the Sui RPC and the sender address to build the transaction.
5. Submitting the transaction through WaaP CLI
WaaP CLI takes the base64-encoded transaction bytes, co-signs them with the 2PC-MPC enclave, and submits to the Sui network:
import { execa } from 'execa'
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 signAndSendTx(b64TxBytes: string): Promise<string | null> {
const { stdout } = await execa(
'waap-cli',
['send-tx', '--tx-bytes', b64TxBytes, '--chain', `sui:${NETWORK}`, '--json'],
{ timeout: 120_000 },
)
try {
const parsed = parseWaapJson<{ txHash?: string; digest?: string }>(stdout)
return parsed.txHash ?? parsed.digest ?? null
} catch {
const m = stdout.match(/(?:Transaction submitted|TxHash|digest):\s*(\S+)/i)
return m ? m[1] : null
}
}
async function whoami(): Promise<string> {
const override = process.env.WAAP_AGENT_ADDRESS?.trim()
if (override) return override
const { stdout } = await execa('waap-cli', ['whoami', '--json'])
const parsed = parseWaapJson<{ suiWalletAddress?: string }>(stdout)
if (!parsed.suiWalletAddress) {
throw new Error('No Sui wallet — run `waap-cli signup` first')
}
return parsed.suiWalletAddress
}The agent passes --chain sui:mainnet (or sui:testnet) so the CLI knows which network to broadcast on.
6. The full tick loop
Each tick reads the pool’s price, snapshots wallet balances, decides whether to rebalance, and (if so) routes a swap through the Cetus aggregator. Structured JSONL log events feed the AEX dashboard:
import fs from 'node:fs'
import path from 'node:path'
const AGENT_ID = 'sui-portfolio-rebalancer'
const LOG_FILE = path.resolve(process.env.AGENT_LOG_FILE ?? `./logs/${AGENT_ID}.jsonl`)
const POLL_MS = Number(process.env.POLL_INTERVAL_MS ?? '60000')
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)
function log(level: string, message: string, data: Record<string, unknown> = {}) {
const entry = { ts: new Date().toISOString(), agent: AGENT_ID, level, message, ...data }
const line = JSON.stringify(entry)
console.log(line)
try { fs.appendFileSync(LOG_FILE, line + '\n') } catch {}
}
async function tick(owner: string) {
const pool = await getPoolState()
const price = calculatePrice(pool)
const targetDecimals = guessDecimals(TARGET_TOKEN_TYPE)
const quoteDecimals = guessDecimals(QUOTE_TOKEN_TYPE)
const targetBalance = await getTokenBalance(owner, TARGET_TOKEN_TYPE, targetDecimals)
const quoteBalance = await getTokenBalance(owner, QUOTE_TOKEN_TYPE, quoteDecimals)
const targetValueUsd = targetBalance * price
log('info', 'price_check', {
price: price.toFixed(6),
highThreshold: HIGH_PRICE_THRESHOLD,
lowThreshold: LOW_PRICE_THRESHOLD,
targetBalance: targetBalance.toFixed(6),
quoteBalance: quoteBalance.toFixed(2),
targetValueUsd: targetValueUsd.toFixed(2),
})
if (price >= HIGH_PRICE_THRESHOLD) {
const excessUsd = targetValueUsd - TARGET_ALLOCATION_USD
if (excessUsd <= 0) return
log('event', 'rebalance_triggered', { direction: 'sell', price, excessUsd })
try {
const txHash = await executeSwap(owner, 'sell', excessUsd, price)
log('event', 'rebalance_complete', { direction: 'sell', txHash, amountUsd: excessUsd })
} catch (err) {
log('error', 'rebalance_failed', { direction: 'sell', error: String(err) })
}
} else if (price <= LOW_PRICE_THRESHOLD) {
const deficitUsd = TARGET_ALLOCATION_USD - targetValueUsd
if (deficitUsd <= 0) return
const buyAmountUsd = Math.min(deficitUsd, quoteBalance)
if (buyAmountUsd < 0.01) {
log('warn', 'insufficient_quote_balance', { deficitUsd, quoteBalance })
return
}
log('event', 'rebalance_triggered', { direction: 'buy', price, buyAmountUsd })
try {
const txHash = await executeSwap(owner, 'buy', buyAmountUsd, price)
log('event', 'rebalance_complete', { direction: 'buy', txHash, amountUsd: buyAmountUsd })
} catch (err) {
log('error', 'rebalance_failed', { direction: 'buy', error: String(err) })
}
} else {
log('info', 'price_in_range', { price, low: LOW_PRICE_THRESHOLD, high: HIGH_PRICE_THRESHOLD })
}
}
async function main() {
const owner = await whoami()
cetus.senderAddress = owner
log('event', 'agent_start', {
wallet: owner,
network: NETWORK,
targetToken: TARGET_TOKEN_TYPE,
quoteToken: QUOTE_TOKEN_TYPE,
targetAllocationUsd: TARGET_ALLOCATION_USD,
highThreshold: HIGH_PRICE_THRESHOLD,
lowThreshold: LOW_PRICE_THRESHOLD,
poolId: CETUS_POOL_ID,
})
let stopping = false
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
process.on(sig, () => { log('info', 'shutdown_signal', { signal: sig }); stopping = true })
}
while (!stopping) {
try { await tick(owner) }
catch (err) { log('error', 'tick_failed', { error: String(err) }) }
if (stopping) break
await new Promise((r) => setTimeout(r, POLL_MS))
}
}
main().catch((err) => {
log('error', 'fatal', { error: String(err) })
process.exit(1)
})Run it:
npm run devThe agent emits these structured events:
agent_start— wallet, network, and config snapshot.price_check— pool sqrt price, derived USD price, and balances.rebalance_triggered— direction, amount, current price.rebalance_complete— successful swap withtxHash/digest.rebalance_failed— error details on a failed swap (no route, slippage, etc.).insufficient_quote_balance— agent wanted to buy but had no quote tokens.
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.
WaaP Privileges add a second layer that scopes what the agent can do on Sui, even if its code is compromised:
# Cap total daily outflow
waap-cli policy set --daily-spend-limit 2500
# Require human approval for swaps above a threshold
waap-cli policy set --approval-threshold 1000| Layer | What it does |
|---|---|
| 2PC-MPC signing | Private key never reconstructed; agent cannot sign on its own |
| 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
- Multiple pools per token: Configure the agent with several
CETUS_POOL_IDcandidates and let it use the deepest one for price reads each tick. Routing already uses the aggregator across all pools. - Volatility-adaptive thresholds: Recompute
HIGH_PRICE_THRESHOLDandLOW_PRICE_THRESHOLDfrom a rolling realised volatility window, the way the Cetus Yield Agent adapts its range. - Multi-token baskets: Run multiple instances side by side, one per target token, sharing the same USDC quote balance.
- AEX dashboard: Deploy the standalone template from holonym-foundation/aex to get the JSONL logs ingested into the Agent Exchange dashboard for live monitoring.
- Telegram alerts: Pipe
rebalance_completeandrebalance_failedevents into a Telegram channel via the WaaP MCP server.
Related
- Portfolio Rebalancer (EVM) — the same strategy on Uniswap v3 / Base
- Cetus Yield Agent — concentrated-liquidity LP rebalancer on Cetus (range-based rather than price-based)
- WaaP for Agents — CLI command reference and Sui-specific signing flows
- Cetus Aggregator SDK — upstream routing SDK used by this agent