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 (enclave 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'; // Read the agent's address from the CLI rather than pasting it — a stale literal here silently // reads someone else's balances. Falls back to WAAP_AGENT_ADDRESS for local dev. import { execSync } from 'node:child_process'; // `whoami --json` is undocumented in --help but supported; it returns // {email, evmWalletAddress, suiWalletAddress, solanaWalletAddress}. const AGENT_ADDRESS = process.env.WAAP_AGENT_ADDRESS || JSON.parse(execSync('waap-cli whoami --json', { encoding: 'utf8' }).trim().split('\n').pop()) .suiWalletAddress; const USDC_TYPE = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'; sdk.senderAddress = AGENT_ADDRESS; function signAndSendTx(b64TxBytes) { const output = execSync( `waap-cli send-tx --tx "${b64TxBytes}" --tx-format base64 --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; }

Build with onlyTransactionKind: true.

waap-cli signs the transaction kind and supplies the sender and gas envelope itself. A default build({ client }) returns full TransactionData, which the signer rejects with INVALID_SUI_TRANSACTION: Unable to decode Sui transaction input. This applies to both send-tx and squid send-tx.

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, onlyTransactionKind: true })).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, onlyTransactionKind: true })).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'); }

Repositioning leaves the old position object behind.

removeLiquidityTransactionPayload empties a position but does not burn it. Every reposition leaves one liquidity = 0 position NFT in the wallet, each holding Sui storage. Verified on mainnet: three repositions left three dead objects.

The agent itself is unaffected — getPositions() above filters liquidity === 0, so it never acts on them. But they accumulate for as long as the agent runs, and at the default CHECK_INTERVAL_MS of five minutes a volatile market can produce a lot of them.

The SDK exposes closePositionTransactionPayload, but as of 2026-08-29 it aborts with MoveAbort code 7 in position::close_position against both an emptied position and a funded one, with collect_fee either way. Until that is resolved, prune the empty objects manually rather than from the agent loop.

Update the main loop

Replace the simulated position check with real position management.

Wire it into the loop from Phase 1 — this is the step that turns the agent from watching to trading. In runAgent(), change the one line:

// Phase 1 had: simulatePositionCheck(currentTick); // Phase 2 uses: await runCycle();

simulatePositionCheck() can be deleted once you have done this. If you paste the functions below without changing that line, the agent keeps running Phase 1’s read-only path and never places a trade — everything looks healthy in the logs and nothing happens.

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