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; see Agent Exchange .
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 inside WaaP’s enclave. 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 enforces a slippage limit on-chain and merges coins for you. The agent adds its own check against the pool price on top (step 4).
- 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 inside WaaP’s enclave. No raw private key in your
.env. @mysten/sui: Sui gRPC client andTransactionbuilder.@cetusprotocol/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 target chain.
NETWORK=testnetis untested: the aggregator’s default router serves mainnet routes, and the pool and USDC type in the example are mainnet objects.
What you need
- Node.js 22 or later (the pinned
@mysten/suiand Cetus aggregator SDK require it) - 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 pkg set type=module
# Tested together on 25 Sep 2026. The aggregator ships built against @mysten/sui 2.31.2;
# pinning the same version keeps a single copy, so its Transaction and yours are the same class.
npm install \
@mysten/sui@2.31.2 \
@cetusprotocol/sui-clmm-sdk@1.4.9 \
@cetusprotocol/aggregator-sdk@1.7.3 \
bn.js@5.2.5 dotenv@18.0.3 execa@9.6.1
npm install -D tsx@4.23.15 @types/bn.js@5.2.0
npm pkg set scripts.dev="tsx agent.ts"
npm install -g @human.tech/waap-cli@latestCreate a WaaP Wallet for Your Agent
# Create a dedicated agent account. The password must be 12 to 64 characters from
# A-Z a-z 0-9 - _ . , : / + = @ ; read it from a prompt so it stays out of shell history.
# signup waits until you approve the email it sends; if interrupted, re-run with --resume.
read -rs WAAP_PASSWORD
printf '%s\n' "$WAAP_PASSWORD" | waap-cli signup --email youremail+sui-rebalancer@example.com --password-stdin
# Or log in
printf '%s\n' "$WAAP_PASSWORD" | waap-cli login --email youremail+sui-rebalancer@example.com --password-stdin
# Get the Sui wallet address and fund it.
# This agent passes --chain sui:mainnet on every signing call; there is no default to set.
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; testnet is untested
POLL_INTERVAL_MS=60000 # 60 seconds between price checks
SLIPPAGE_BPS=50 # 0.5% max slippage (1–500)
SUI_RPC=https://fullnode.mainnet.sui.io:443 # Sui gRPC endpoint; defaults to Mysten's for NETWORK
DRY_RUN=1 # Build swaps and log them, send nothing. Remove to trade.
CETUS_AGGREGATOR_URL=https://api-sui.cetus.zone/router_v3 # Cetus aggregator endpoint (the SDK default)
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, from 1 to 500. 50 = 0.5%. Applied against the pool price and again on-chain |
NETWORK | no | mainnet | mainnet. testnet is untested (see Key Components) |
SUI_RPC | no | https://fullnode.<NETWORK>.sui.io:443 | Sui gRPC endpoint. Used for pool reads, balances and transaction builds |
DRY_RUN | no | off | 1 builds and logs each swap without sending it |
CETUS_AGGREGATOR_URL | no | https://api-sui.cetus.zone/router_v3 | 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 builds one Sui gRPC client from SUI_RPC and hands it to both Cetus SDKs, so every read and every transaction build goes to the same endpoint: the CLMM SDK reads pool state, and the aggregator finds and builds the swap route.
// agent.ts (part 1 of 6) — clients
import { config } from 'dotenv'
import { SuiGrpcClient } from '@mysten/sui/grpc'
import { Transaction } from '@mysten/sui/transactions'
import { normalizeStructTag } from '@mysten/sui/utils'
import CetusClmmSDK from '@cetusprotocol/sui-clmm-sdk'
import { AggregatorClient, Env } from '@cetusprotocol/aggregator-sdk'
import BN from 'bn.js'
import { checkQuote, onChainSlippage, slippageFraction } from './lib/swap-guard'
config({ quiet: true }) // keep stdout pure JSONL
function requireEnv(name: string): string {
const v = process.env[name]?.trim()
if (!v) throw new Error(`${name} is required — see Environment Variables`)
return v
}
const NETWORK = (process.env.NETWORK ?? 'mainnet') as 'mainnet' | 'testnet'
// A Sui gRPC endpoint. Every read and every transaction build goes through this one client.
const SUI_RPC = process.env.SUI_RPC ?? `https://fullnode.${NETWORK}.sui.io:443`
const sui = new SuiGrpcClient({ network: NETWORK, baseUrl: SUI_RPC })
const cetus = CetusClmmSDK.createSDK({ env: NETWORK, sui_client: sui })
// Created in main(), once the wallet address is known: the aggregator uses it to find the
// wallet's coins and to deliver the swap output. It never holds a key.
let aggregator: AggregatorClient
function initAggregator(owner: string) {
aggregator = new AggregatorClient({
signer: owner,
client: sui,
env: NETWORK === 'mainnet' ? Env.Mainnet : Env.Testnet,
endpoint: process.env.CETUS_AGGREGATOR_URL, // unset: the SDK's default router
})
}The aggregator’s signer is the agent’s Sui address, not a key. It uses the address to find the wallet’s coins and to send the swap output back to it. Signing happens separately, in waap-cli send-tx (step 5), so the aggregator never holds anything that can sign.
2. Reading the price from the Cetus pool’s current_sqrt_price
Cetus stores sqrt(price) * 2^64 on each pool. To get the price of the target token in quote terms, the agent squares it, divides out the scale, and adjusts by the difference in coin decimals, which it reads from each coin’s on-chain metadata. It also checks that the pool really is the target/quote pair, so a wrong CETUS_POOL_ID stops the agent instead of pricing the wrong market:
// agent.ts (part 2 of 6) — pool price
const CETUS_POOL_ID = requireEnv('CETUS_POOL_ID')
const TARGET_TOKEN_TYPE = normalizeStructTag(requireEnv('TARGET_TOKEN_TYPE'))
const QUOTE_TOKEN_TYPE = normalizeStructTag(requireEnv('QUOTE_TOKEN_TYPE'))
const decimalsCache = new Map<string, number>()
async function getDecimals(coinType: string): Promise<number> {
const cached = decimalsCache.get(coinType)
if (cached !== undefined) return cached
const { coinMetadata } = await sui.getCoinMetadata({ coinType })
if (!coinMetadata) throw new Error(`no coin metadata for ${coinType}`)
decimalsCache.set(coinType, coinMetadata.decimals)
return coinMetadata.decimals
}
/** Price of one target token in quote tokens, from the pool's current_sqrt_price. */
async function getPoolPrice(): Promise<number> {
const pool = await cetus.Pool.getPool(CETUS_POOL_ID, true)
const typeA = normalizeStructTag(pool.coin_type_a)
const typeB = normalizeStructTag(pool.coin_type_b)
const targetIsA = typeA === TARGET_TOKEN_TYPE
if (!targetIsA && typeB !== TARGET_TOKEN_TYPE) throw new Error(`pool ${CETUS_POOL_ID} does not hold ${TARGET_TOKEN_TYPE}`)
if ((targetIsA ? typeB : typeA) !== QUOTE_TOKEN_TYPE) throw new Error(`pool ${CETUS_POOL_ID} does not pair with ${QUOTE_TOKEN_TYPE}`)
// Cetus stores sqrt(price) * 2^64. price of A in B = (sqrtPriceX64 / 2^64)^2 * 10^(decimalsA - decimalsB)
const sqrt = Number(BigInt(pool.current_sqrt_price)) / 2 ** 64
const priceAinB = sqrt * sqrt * 10 ** ((await getDecimals(typeA)) - (await getDecimals(typeB)))
return targetIsA ? priceAinB : 1 / priceAinB
}3. Reading balances
getBalance returns the total balance of a coin type for an address, in the coin’s smallest unit:
// agent.ts (part 3 of 6) — balances
async function getTokenBalance(owner: string, coinType: string): Promise<number> {
const { balance } = await sui.getBalance({ owner, coinType })
return Number(balance.balance) / 10 ** (await getDecimals(coinType))
}4. Finding the best swap route via the Cetus Aggregator
This is the heart of the agent, and where the money is at stake, so the slippage math lives in its own small file first. Both Cetus SDKs take slippage as a fraction: 0.005 means 0.5%. SLIPPAGE_BPS=50 has to become 0.005, not 0.5. At 0.5 the SDK accepts a swap that returns half of what you should get.
// lib/swap-guard.ts — slippage and minimum-output checks. Pure functions, no SDK, so the
// money math can be read and tested on its own.
/** The Cetus SDKs take slippage as a fraction: 0.005 = 0.5%. 50 bps -> 0.005. */
export function slippageFraction(bps: number): number {
if (!Number.isInteger(bps) || bps < 1 || bps > 500) {
throw new Error(`SLIPPAGE_BPS must be a whole number from 1 to 500 (0.01%–5%), got ${bps}`)
}
return bps / 10_000
}
/** The least output (raw units) the agent will accept, given what the pool price says it should get. */
export function minAmountOut(expectedOutRaw: bigint, bps: number): bigint {
slippageFraction(bps)
return (expectedOutRaw * BigInt(10_000 - bps)) / 10_000n
}
/** Refuse a route whose quoted output is already worse than the pool price allows. */
export function checkQuote(quotedOutRaw: bigint, expectedOutRaw: bigint, bps: number): void {
const min = minAmountOut(expectedOutRaw, bps)
if (quotedOutRaw < min) {
throw new Error(`route quote ${quotedOutRaw} is below the minimum ${min} (pool price ${expectedOutRaw}, ${bps} bps)`)
}
}
/**
* The slippage to hand the aggregator for the on-chain limit. The SDK sets the transaction's
* minimum output to quote × (1 − slippage). Passing the full budget again would let the
* two checks compound to about twice the budget, so this narrows it until the on-chain
* minimum is no lower than minAmountOut. Rounded down: a smaller slippage only raises the floor.
*/
export function onChainSlippage(quotedOutRaw: bigint, expectedOutRaw: bigint, bps: number): number {
const min = minAmountOut(expectedOutRaw, bps)
const room = Number(quotedOutRaw - min) / Number(quotedOutRaw)
return Math.floor(Math.max(0, Math.min(slippageFraction(bps), room)) * 1e9) / 1e9
}Once a direction and dollar amount are chosen, the agent asks the aggregator for the best route, checks the quote against the pool price, and has the aggregator write the swap into a Transaction:
// agent.ts (part 4 of 6) — route and build the swap
const SLIPPAGE_BPS = Number(process.env.SLIPPAGE_BPS ?? '50')
const SLIPPAGE = slippageFraction(SLIPPAGE_BPS) // 50 bps -> 0.005; throws on a bad value at start-up
const DRY_RUN = process.env.DRY_RUN === '1'
async function executeSwap(owner: string, direction: 'buy' | 'sell', amountUsd: number, price: number): Promise<SendOutcome | null> {
const [from, target] = direction === 'sell' ? [TARGET_TOKEN_TYPE, QUOTE_TOKEN_TYPE] : [QUOTE_TOKEN_TYPE, TARGET_TOKEN_TYPE]
const fromDecimals = await getDecimals(from)
const targetDecimals = await getDecimals(target)
// Selling: amountUsd of target tokens in, quote out. Buying: amountUsd of quote in, target out.
const amountIn = direction === 'sell' ? amountUsd / price : amountUsd
const expectedOut = direction === 'sell' ? amountIn * price : amountIn / price
const amountInRaw = BigInt(Math.floor(amountIn * 10 ** fromDecimals))
const expectedOutRaw = BigInt(Math.floor(expectedOut * 10 ** targetDecimals))
const router = await aggregator.findRouters({ from, target, amount: new BN(amountInRaw.toString()), byAmountIn: true, slippage: SLIPPAGE })
if (!router || router.error || router.insufficientLiquidity || router.paths.length === 0) {
throw new Error(`Cetus aggregator found no route from ${from} to ${target}${router?.error ? `: ${router.error.msg}` : ''}`)
}
// The SDK's own limit is relative to the route's quote. This one is relative to the pool
// price, so a route that is already bad cannot pass on its own say-so.
const quotedOutRaw = BigInt(router.amountOut.toString())
checkQuote(quotedOutRaw, expectedOutRaw, SLIPPAGE_BPS)
const tx = new Transaction()
tx.setSender(owner)
await aggregator.fastRouterSwap({
router,
txb: tx,
slippage: onChainSlippage(quotedOutRaw, expectedOutRaw, SLIPPAGE_BPS),
refreshAllCoins: true,
})
// waap-cli takes Sui transaction-kind bytes and adds gas itself.
const txBytes = Buffer.from(await tx.build({ client: sui, onlyTransactionKind: true })).toString('base64')
if (DRY_RUN) {
log('info', 'dry_run_swap', { direction, amountInRaw: String(amountInRaw), expectedOutRaw: String(expectedOutRaw), quotedOutRaw: String(quotedOutRaw), txBytes: txBytes.length })
return null
}
return signAndSendTx(txBytes)
}A few things worth pointing out:
- Two checks guard the output, and both use the same floor: the pool-price output less
SLIPPAGE_BPS.checkQuoterefuses a route whose quote is already under it, before anything is built.onChainSlippagethen sets the transaction’s own minimum so it cannot go under it either, and the swap fails on-chain if the price moves after the quote. Worked example, selling 100 SUI at $4.50 withSLIPPAGE_BPS=50: the pool price says 450 USDC, so the floor is 447.75 USDC. A route quoting 447 is refused; a route quoting 450 is built with a minimum of 447.75. byAmountIn: truefixes the input amount (how much target token to sell, or how many dollars of quote to spend) and lets slippage absorb the output uncertainty.- Fees and price impact count against
SLIPPAGE_BPS. If the agent logsrebalance_failedwith “below the minimum” on normal markets, raise it a little; the cap is 500 (5%). DRY_RUN=1stops after the build. Run that way first. Two things to know: a dry run only builds a swap when the price is outside your thresholds, so for a first run setLOW_PRICE_THRESHOLDandHIGH_PRICE_THRESHOLDeither side of today’s price; and building needs the coins to exist, so an unfunded wallet logsrebalance_failedwith an insufficient-balance error.
5. Submitting the transaction through WaaP CLI
WaaP CLI takes the base64-encoded transaction-kind bytes, adds gas, co-signs with WaaP’s enclave, and submits to the Sui network:
// agent.ts (part 5 of 6) — sign and send through waap-cli
import { execa } from 'execa'
const WAAP_CLI = process.env.WAAP_CLI_BIN ?? 'waap-cli'
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)}`)
}
interface SendOutcome { txHash: string | null; status: 'confirmed' | 'failed' | 'unconfirmed' }
/** --wait reads the result back: only 'confirmed' means the swap landed. */
async function signAndSendTx(b64TxBytes: string): Promise<SendOutcome> {
const { stdout } = await execa(
WAAP_CLI,
['send-tx', '--tx', b64TxBytes, '--tx-format', 'base64', '--chain', `sui:${NETWORK}`, '--wait', '--json'],
{ timeout: 180_000 },
)
let parsed: { txHash?: string; status?: string } = {}
try { parsed = parseWaapJson(stdout) } catch {}
const txHash = parsed.txHash ?? null
if (txHash && parsed.status === 'confirmed') return { txHash, status: 'confirmed' }
if (txHash && parsed.status === 'failed') return { txHash, status: 'failed' }
return { txHash, status: 'unconfirmed' } // sent, but not known to have landed
}
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 so the CLI knows which network to broadcast on, and --wait so it hears whether the swap confirmed, failed, or is still unknown. WAAP_CLI_BIN lets you point it at a specific waap-cli build.
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:
// agent.ts (part 6 of 6) — the tick loop
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`)
// The default path is ./logs/, which npm init does not create. Create it before the first
// write — appendFileSync throws ENOENT on a missing directory, and the logger swallows it,
// so the symptom is silence rather than an error.
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true })
const POLL_MS = Number(process.env.POLL_INTERVAL_MS ?? '60000')
const TARGET_ALLOCATION_USD = Number(requireEnv('TARGET_ALLOCATION_USD'))
const HIGH_PRICE_THRESHOLD = Number(requireEnv('HIGH_PRICE_THRESHOLD'))
const LOW_PRICE_THRESHOLD = Number(requireEnv('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 {} // see mkdir below — without it
// the directory does not exist, this throws, the catch swallows it, and no log file appears
}
function logOutcome(direction: 'buy' | 'sell', amountUsd: number, r: SendOutcome | null) {
if (!r) return // dry run, already logged
if (r.status === 'confirmed') log('event', 'rebalance_complete', { direction, amountUsd, txHash: r.txHash })
else if (r.status === 'failed') log('error', 'rebalance_failed', { direction, amountUsd, txHash: r.txHash, error: 'reverted on-chain' })
// Balances are re-read each tick, so a swap that lands later is not repeated.
else log('warn', 'rebalance_unconfirmed', { direction, amountUsd, txHash: r.txHash })
}
async function tick(owner: string) {
const price = await getPoolPrice()
const targetBalance = await getTokenBalance(owner, TARGET_TOKEN_TYPE)
const quoteBalance = await getTokenBalance(owner, QUOTE_TOKEN_TYPE)
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 {
logOutcome('sell', excessUsd, await executeSwap(owner, 'sell', excessUsd, price))
} 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 {
logOutcome('buy', buyAmountUsd, await executeSwap(owner, 'buy', buyAmountUsd, price))
} 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()
initAggregator(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,
slippageBps: SLIPPAGE_BPS,
dryRun: DRY_RUN,
})
let stopping = false
let wake = () => {} // cuts the poll wait short on a stop signal
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
process.on(sig, () => { log('info', 'shutdown_signal', { signal: sig }); stopping = true; wake() })
}
while (!stopping) {
try { await tick(owner) }
catch (err) { log('error', 'tick_failed', { error: String(err) }) }
if (stopping) break
await new Promise<void>((r) => {
const t = setTimeout(r, POLL_MS)
wake = () => { clearTimeout(t); r() }
})
}
}
main().catch((err) => {
log('error', 'fatal', { error: String(err) })
process.exit(1)
})Each block above headed // agent.ts (part N of 6) goes into agent.ts, in order; the guard goes in lib/swap-guard.ts. Run it with DRY_RUN=1 in .env first:
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: the swap confirmed on-chain, with itstxHash.rebalance_unconfirmed: sent, but not yet known to have landed. The next tick re-reads balances, so it will not trade twice for the same gap.dry_run_swap: withDRY_RUN=1, the swap that would have been sent.rebalance_failed: error details on a failed swap (no route, quote below the minimum, etc.).insufficient_quote_balance: agent wanted to buy but had no quote tokens.
Security: enclave signing and Privileges
Every swap goes out through waap-cli send-tx. The signing key is held inside WaaP’s enclave, so the agent process never has access to a key at all. Each transaction is separately authorized by the Policy Engine against your spend limits and risk thresholds before it is signed.
WaaP Privileges let you delegate approval in advance for one job, so an agent can work unattended without you being asked every time:
# The point at which the day's total requires your approval.
# This is a threshold, not a cap — see the note below.
waap-cli policy set --daily-spend-limit 2500| Layer | What it does |
|---|---|
| Enclave signing | Private key never reconstructed; agent cannot sign on its own |
| Daily spend limit | The point at which the day’s aggregate spend starts requiring your approval. Not a cap: an approved transaction may take the total past it |
| Privilege | Advance approval for one job — chain, recipients, amount, expiry. Its USD budget is the one hard limit here: once spent, requests go back to asking you |
No .env secrets | No raw private key in environment variables or code |
A Privilege is delegated approval, not a wallet sandbox. If a Privilege is missing, expired, over budget or out of scope, WaaP does not refuse the transaction — it evaluates it under your ordinary account policy, and below your daily limit that means it is signed. Use a separate account when you need a hard spending boundary.
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: Agent Exchange ingests the agent’s JSONL logs 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