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
monitortoactive - 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 1Fund your wallet with SUI for gas and USDC for the position:
# Check your balances
waap-cli whoamiNew 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 passedliquidityinstead ofdelta_liquiditytoremoveLiquidityTransactionPayload. The Cetus SDK parameter name must be exactlydelta_liquidity.openPositionTransactionPayloadcreates an empty position — This method only creates the position object, it doesn’t deposit tokens. UsecreateAddLiquidityPayloadwithis_open: trueto open and fund in one transaction.Cannot read properties of undefined (reading 'forEach')— You omittedrewarder_coin_typesfrom 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. senderAddressnot set — You must setsdk.senderAddress = YOUR_ADDRESSbefore 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_snapshotlogs 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.