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; 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

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 inside WaaP’s enclave. No raw private key in your .env.
  • @mysten/sui: Sui gRPC client and Transaction builder.
  • @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=testnet is 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/sui and 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@latest

Create 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 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; 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 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, from 1 to 500. 50 = 0.5%. Applied against the pool price and again on-chain
NETWORKnomainnetmainnet. testnet is untested (see Key Components)
SUI_RPCnohttps://fullnode.<NETWORK>.sui.io:443Sui gRPC endpoint. Used for pool reads, balances and transaction builds
DRY_RUNnooff1 builds and logs each swap without sending it
CETUS_AGGREGATOR_URLnohttps://api-sui.cetus.zone/router_v3Cetus 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 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. checkQuote refuses a route whose quote is already under it, before anything is built. onChainSlippage then 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 with SLIPPAGE_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: true fixes 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 logs rebalance_failed with “below the minimum” on normal markets, raise it a little; the cap is 500 (5%).
  • DRY_RUN=1 stops 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 set LOW_PRICE_THRESHOLD and HIGH_PRICE_THRESHOLD either side of today’s price; and building needs the coins to exist, so an unfunded wallet logs rebalance_failed with 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 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: the swap confirmed on-chain, with its txHash.
  • 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: with DRY_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
LayerWhat it does
Enclave signingPrivate key never reconstructed; agent cannot sign on its own
Daily spend limitThe point at which the day’s aggregate spend starts requiring your approval. Not a cap: an approved transaction may take the total past it
PrivilegeAdvance 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 secretsNo 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_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: Agent Exchange  ingests the agent’s JSONL logs for live monitoring.
  • Telegram alerts: Pipe rebalance_complete and rebalance_failed events into a Telegram channel via the WaaP MCP server.
Last updated on