Skip to Content
Recipes

Phase 2 — Earn Fees

Upgrade your Phase 1 monitor into an agent that deposits real funds into the pool and earns a share of every trade. When the price moves too far, the agent automatically repositions.

Time: ~15 minutes | Requires: Phase 1 complete, funded WaaP wallet


What changes

  • Agent mode switches from monitor to active
  • Transactions are signed and sent via WaaP CLI (two-party signing)
  • The agent opens, manages, and repositions real liquidity positions
  • You need both SUI (for gas) and USDC in your wallet

Update .env

AGENT_MODE=active # was "monitor" in Phase 1

Fund your wallet with SUI for gas and USDC for the position:

# Check your balances waap-cli whoami

New code: WaaP CLI transaction signing

Add this function to agent.js. This is the key integration — instead of holding a private key, the agent delegates all signing to waap-cli:

import { execSync } from 'child_process'; import BN from 'bn.js'; import { ClmmPoolUtil } from '@cetusprotocol/cetus-sui-clmm-sdk'; const AGENT_ADDRESS = 'YOUR_AGENT_SUI_ADDRESS'; // from waap-cli whoami const USDC_TYPE = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'; sdk.senderAddress = AGENT_ADDRESS; function signAndSendTx(b64TxBytes) { const output = execSync( `waap-cli send-tx --tx-bytes "${b64TxBytes}" --chain sui:${process.env.NETWORK}`, { encoding: 'utf-8', timeout: 120000 } ); const match = output.match(/(?:Transaction submitted|TxHash):\s*(\S+)/); if (match) return match[1]; log('warn', 'Could not extract tx hash', { output }); return null; }

New code: get balances and positions

async function getBalance() { const result = await client.getBalance({ owner: AGENT_ADDRESS, coinType: '0x2::sui::SUI' }); return parseInt(result.totalBalance) / 1e9; } async function getUsdcBalance() { const result = await client.getBalance({ owner: AGENT_ADDRESS, coinType: USDC_TYPE }); return parseInt(result.totalBalance) / 1e6; } async function getPositions() { const objects = await client.getOwnedObjects({ owner: AGENT_ADDRESS, filter: { StructType: '0x1eabed72c53feb3805120a081dc15963c204dc8d091542592abaf7a35689b2fb::position::Position' }, options: { showContent: true }, }); return objects.data .map(o => { const fields = o.data?.content?.fields; if (!fields) return null; const liq = parseInt(fields.liquidity || '0'); if (liq === 0) return null; let tl = fields.tick_lower_index; let tu = fields.tick_upper_index; if (typeof tl === 'object') tl = tl.fields?.bits ?? tl; if (typeof tu === 'object') tu = tu.fields?.bits ?? tu; return { posId: o.data.objectId, liquidity: liq.toString(), tickLower: parseInt(tl), tickUpper: parseInt(tu), pool: fields.pool }; }) .filter(p => p && p.pool === POOL_ID); }

New code: open a position

async function openPosition(pool) { const currentTick = pool.current_tick_index; const tickSpacing = pool.tickSpacing; const tickLower = Math.floor((currentTick - RANGE) / tickSpacing) * tickSpacing; const tickUpper = Math.ceil((currentTick + RANGE) / tickSpacing) * tickSpacing; const usdcBalance = await client.getBalance({ owner: AGENT_ADDRESS, coinType: USDC_TYPE }); const usdcAvailable = parseInt(usdcBalance.totalBalance); const usdcToUse = Math.floor(usdcAvailable * 0.40).toString(); // use 40%, keep reserves if (parseInt(usdcToUse) < 10000) { log('warn', 'Not enough USDC', { usdcAvailable }); return; } const curSqrtPrice = new BN(pool.current_sqrt_price); const liquidityInput = ClmmPoolUtil.estLiquidityAndcoinAmountFromOneAmounts( tickLower, tickUpper, new BN(usdcToUse), true, true, 0.1, curSqrtPrice, ); const payload = await sdk.Position.createAddLiquidityPayload({ pool_id: POOL_ID, coinTypeA: pool.coinTypeA, coinTypeB: pool.coinTypeB, tick_lower: tickLower.toString(), tick_upper: tickUpper.toString(), is_open: true, pos_id: '', max_amount_a: liquidityInput.tokenMaxA.toString(), max_amount_b: liquidityInput.tokenMaxB.toString(), delta_liquidity: liquidityInput.liquidityAmount.toString(), rewarder_coin_types: [], collect_fee: false, }); payload.setSender(AGENT_ADDRESS); const txBytes = Buffer.from(await payload.build({ client })).toString('base64'); const txHash = signAndSendTx(txBytes); log('event', 'position_opened', { txHash, tickLower, tickUpper, usdc: usdcToUse }); }

New code: reposition

async function rebalance(pool, position) { log('event', 'rebalance_start', { posId: position.posId }); // Step 1: Remove old position and collect fees const removeTx = await sdk.Position.removeLiquidityTransactionPayload({ pool_id: POOL_ID, pos_id: position.posId, coinTypeA: pool.coinTypeA, coinTypeB: pool.coinTypeB, delta_liquidity: position.liquidity, // Note: must be delta_liquidity, not liquidity min_amount_a: '0', min_amount_b: '0', collect_fee: true, rewarder_coin_types: [], }); removeTx.setSender(AGENT_ADDRESS); const removeTxBytes = Buffer.from(await removeTx.build({ client })).toString('base64'); signAndSendTx(removeTxBytes); await new Promise(r => setTimeout(r, 5000)); // wait for state to settle // Step 2: Open new position at current price const freshPool = await getPoolState(); await openPosition(freshPool); log('info', 'Rebalance complete'); }

Update the main loop

Replace the simulated position check with real position management:

async function runCycle() { const pool = await getPoolState(); const balance = await getBalance(); const currentTick = pool.current_tick_index; log('info', 'Cycle', { mode: 'active', tick: currentTick, balance: balance.toFixed(4) }); log('event', 'balance_snapshot', { balance }); const positions = await getPositions(); if (positions.length === 0) { log('info', 'No positions. Opening one...'); await openPosition(pool); } else { for (const pos of positions) { const center = Math.floor((pos.tickLower + pos.tickUpper) / 2); const drift = Math.abs(currentTick - center); const outOfRange = currentTick < pos.tickLower || currentTick > pos.tickUpper; if (outOfRange || drift > THRESHOLD) { log('info', 'Repositioning...'); await rebalance(pool, pos); } else { log('info', 'Position in range', { currentTick, drift, threshold: THRESHOLD }); } } } }

Running it long-term

If you run the agent 24/7 with a watchdog (cron job that restarts it if it dies), there are two traps to avoid:

Trap 1: Watchdog spawns duplicate instances. If your watchdog uses pgrep to check if the agent is running, the pattern may not match (depends on how the process was started). Use a PID file instead:

# In your start.sh watchdog script: PID_FILE="$AGENT_DIR/agent.pid" if [ -f "$PID_FILE" ] && kill -0 "$(cat $PID_FILE)" 2>/dev/null; then exit 0 # already running fi pkill -f "node.*agent" 2>/dev/null # clean up orphans cd "$AGENT_DIR" && nohup node agent.js >> "$LOG" 2>&1 & echo "$!" > "$PID_FILE"

Trap 2: Agent opens positions on every restart. If the agent starts, finds no positions (because on-chain state hasn’t propagated yet), and immediately opens one — each restart burns gas. Add a cooldown:

const STARTUP_COOLDOWN_MS = 60000; // 60 seconds const startupTime = Date.now(); // Before opening a position: if (Date.now() - startupTime < STARTUP_COOLDOWN_MS) { log('info', 'Startup cooldown, skipping position open'); return; }

We learned both of these the hard way — 112 duplicate instances drained 0.5 SUI in gas overnight.

Common errors

These are from real mainnet deployment:

  • Cannot convert undefined to a BigInt — You passed liquidity instead of delta_liquidity to removeLiquidityTransactionPayload. The Cetus SDK parameter name must be exactly delta_liquidity.
  • openPositionTransactionPayload creates an empty position — This method only creates the position object, it doesn’t deposit tokens. Use createAddLiquidityPayload with is_open: true to open and fund in one transaction.
  • Cannot read properties of undefined (reading 'forEach') — You omitted rewarder_coin_types from the payload. Always pass an empty array: rewarder_coin_types: [].
  • Insufficient balance for USDC — Don’t use 100% of your USDC. The pool requires tokens on both sides of the price range, proportional to the current price. Use 40-50% of your smaller balance and let the SDK calculate the other side.
  • Position opens at wrong ticks — Position ticks must align to the pool’s tickSpacing. Always snap: Math.floor(tick / tickSpacing) * tickSpacing. Unaligned ticks silently fail or create invalid positions.
  • senderAddress not set — You must set sdk.senderAddress = YOUR_ADDRESS before building any transaction payload. Without it, the SDK can’t construct the transaction.
  • Gas drain from duplicate agents — If your watchdog spawns duplicate instances (see “Running it long-term” above), each burns gas. Reserve 0.2+ SUI for gas and monitor the balance_snapshot logs for unexpected drops.

Next step

The agent now earns fees with a fixed earning zone width. Phase 3 — Adaptive Strategy makes the zone width respond to market volatility automatically.

Last updated on