Skip to Content
RecipesPortfolio Rebalancer (Sui)

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

ParameterValue
TARGET_TOKEN_TYPE0x2::sui::SUI
QUOTE_TOKEN_TYPEUSDC type on Sui mainnet
TARGET_ALLOCATION_USD500
HIGH_PRICE_THRESHOLD4.50
LOW_PRICE_THRESHOLD3.00
CETUS_POOL_IDSUI/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 and Transaction builder.
  • @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=testnet also 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@latest

You 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-rebalancer

Create 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 whoami

Always use the --chain form sui:mainnet (or sui:testnet). The deprecated --chain-id alias 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 path

Environment variable reference

VariableRequiredDefaultDescription
TARGET_TOKEN_TYPEyesSui type string for the target token (e.g. 0x2::sui::SUI)
QUOTE_TOKEN_TYPEyesSui type string for the quote token
TARGET_ALLOCATION_USDyesUSD value to maintain in the target token
HIGH_PRICE_THRESHOLDyesSell trigger (USD price of target token)
LOW_PRICE_THRESHOLDyesBuy trigger (USD price of target token)
CETUS_POOL_IDyesCetus pool object ID used for price reads
POLL_INTERVAL_MSno60000Milliseconds between price checks
SLIPPAGE_BPSno50Max slippage in basis points
NETWORKnomainnetmainnet or testnet
SUI_RPCno(mysten fullnode)Sui RPC endpoint override
CETUS_AGGREGATOR_URLnohttps://api-sui.cetus.zone/router_v2Cetus aggregator API endpoint
WAAP_AGENT_ADDRESSnoOverride the Sui wallet address (bypass waap-cli whoami)
AGENT_LOG_FILEno./logs/[agentId].jsonlPath for JSON-line log file

The CETUS_POOL_ID is 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: true means 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: true consolidates 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 dev

The 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 with txHash / 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
LayerWhat it does
2PC-MPC signingPrivate key never reconstructed; agent cannot sign on its own
Daily spend limitCaps total outflow per 24 hours
Approval thresholdLarge swaps prompt for human approval via the dashboard
No .env secretsNo raw private key in environment variables or code

What’s next

  • Multiple pools per token: Configure the agent with several CETUS_POOL_ID candidates 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_THRESHOLD and LOW_PRICE_THRESHOLD from 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_complete and rebalance_failed events into a Telegram channel via the WaaP MCP server.
Last updated on