Gauge Staking Auto-Compound
Selects a pool on Aerodrome or Velodrome by reward APR, opens a position, stakes it in the pool's gauge to earn AERO or VELO rewards, then periodically claims the rewards, swaps them to USDC and adds the proceeds back to the position (compounding).
How It Works
- Pool selection: from
exchange.IO("lp.pools"), keep pools whose gauge is still emitting rewards (gaugeAlive), that are not empty, whose TVL is at leastMIN_TVL_USDand whose type matchesPOOL_KIND; sort them by reward APRrewardAprPctin descending order and take the first. The top 5 candidates are written to the log, with their reward APR and the fee APR they would earn unstaked. WhenPOOL_KINDisslipstream, a Slipstream concentrated liquidity pool is used (range ±RANGE_PCT%); when it isbasic, a basic pool is used (full range). - Open and stake: pass
stake: truewhen callingexchange.IO("lp.plan"), so that onceexchange.IO("lp.add")has opened the position, it is staked in the gauge directly. - Compounding: check every
CLAIM_HOURShours. When pending rewards are at leastMIN_CLAIM_USDUSD and exceed twice the gas cost of one compounding round, useexchange.IO("lp.claim")to claim the rewards and swap them to USDC (lp.claim(ids, true)). After the sell order is confirmed on-chain and the proceeds are known, add them to the position: for a basic pool uselp.increase(池子, 金额, {stake: true}); a staked Slipstream position cannot be increased directly, so runexchange.IO("lp.unstake")→exchange.IO("lp.increase")→exchange.IO("lp.stake")in sequence. - Status bar: shows the pool's reward APR and daily emissions (read each round via
exchange.IO("lp.pool")), whether the position is staked and in range, position value, pending rewards, cumulative claimed and compounded amounts, the gas estimate, and the annualized return based on rewards actually claimed.
Exchange Object
One Aerodrome / Velodrome exchange object: Aerodrome (Base chain, rewards in AERO) or Velodrome (OP Mainnet, rewards in VELO). Use a dedicated wallet funded with USDC and a small amount of ETH for gas.
Parameters
| Parameter | Default | Description |
|---|---|---|
SYMBOL | ETH_USDC | Trading pair |
BUDGET | 100 | Position budget (USDC) |
POOL_KIND | slipstream | slipstream or basic |
RANGE_PCT | 10 | Slipstream range half-width (%); a staked position earns no rewards once out of range, so a wider range is recommended |
MIN_TVL_USD | 100000 | Only pools with TVL at or above this value are considered |
CLAIM_HOURS | 24 | Interval between claiming and compounding (hours) |
MIN_CLAIM_USD | 2 | Rewards are not claimed while pending rewards are below this value (USD) |
GAS_USD | 0.05 | Estimated gas per on-chain transaction (USD) |
COMPOUND_TXS | 5 | Transactions per compounding round: claim, sell, unstake, increase, stake |
INTERVAL | 300 | Check interval (seconds) |
DRY_RUN | true | When true, only plans and simulates without sending transactions |
Risks
- The reward APR
rewardAprPctis an instantaneous value extrapolated from the current per-second emissions, and it changes with the reward token price, emissions and total staked amount. For Slipstream pools it is calculated from the staked liquidity at the tick of the current price, so it jumps whenever the price moves and can differ several-fold within minutes; when choosing a pool, it is best to check it several times over a period of time. AERO and VELO prices are highly volatile, so the timing of sales affects actual returns. - While staked, trading fees go to voters and the position earns only rewards. A Slipstream position earns no rewards once out of range and is also exposed to impermanent loss; a basic pool covers the full range, with impermanent loss following the constant product curve.
- Every claim, sell, unstake, increase and stake is an on-chain transaction (about
COMPOUND_TXSper compounding round), so paying gas is not worthwhile when rewards are too small.
DRY_RUN vs. Live Trading
When DRY_RUN is true (the default), no transactions are sent: the position is simulated with exchange.IO("lp.simulate") (an error from an insufficient wallet balance is expected), and the paper position accrues rewards at the pool's current rewardAprPct (assuming it always stays in range; the current value is taken each round, which amounts to a time-weighted average). When due, a quote is obtained with exchange.IO("route"), and the amount of USDC the rewards would swap to is added to the paper principal. Set DRY_RUN to false to switch to live trading, and adjust GAS_USD for the chain in use.
Examples
Complete strategy code (DRY_RUN = true: plan and simulate only, no transactions are sent).
javascript
/*
* Aerodrome / Velodrome gauge staking auto-compounder (example strategy)
*
* Liquidity positions on Aerodrome (Base) and Velodrome (OP Mainnet) can be staked in the pool's gauge to earn AERO / VELO
* emissions (while staked, trading fees go to voters and the position earns only the rewards). This strategy:
* 1. Picks from lp.pools, by reward APR (rewardAprPct), a pool whose gauge is still emitting rewards and whose TVL is sufficient:
* POOL_KIND = "slipstream" uses a Slipstream concentrated liquidity pool (±RANGE_PCT% range), "basic" uses a basic pool (full range);
* 2. Opens a position and stakes it right away (stake: true in lp.plan → lp.add);
* 3. Every CLAIM_HOURS hours, if the rewards are worth enough, claims them and sells them for USDC (lp.claim(ids, true)), then adds the proceeds back to the position (compounding):
* a staked Slipstream position cannot be increased directly, so it runs lp.unstake → lp.increase → lp.stake; for a basic pool, lp.increase(pool, amount, {stake: true});
* 4. The status bar shows the pool's reward APR, position value, pending rewards, total claimed and compounded amounts, and the annualized yield based on rewards actually claimed.
*
* DRY_RUN = true (default) sends no transactions: lp.simulate simulates opening the position (an error is expected when the wallet balance is insufficient), and a paper position accrues rewards at
* the pool's current rewardAprPct (assuming the price always stays in range); when due, IO("route") fetches a quote to work out how much USDC selling the rewards would yield,
* which is added to the paper principal (compounding).
*
* Risks:
* - Reward APR changes with the reward token price, emissions and total stake; rewardAprPct is only an instantaneous value extrapolated from the current per-second emissions; for Slipstream pools it
* is computed from the staked liquidity at the current price's tick, so it jumps as soon as the price moves and can differ several-fold within minutes (the paper accrual samples it once per loop, effectively a time average);
* AERO / VELO prices are volatile, and the timing of sales affects the actual return;
* - A Slipstream position earns no rewards once out of range and is exposed to impermanent loss; a basic pool is full range, with impermanent loss following the constant-product curve;
* - Every claim, sell, unstake, increase and stake is an on-chain transaction (about COMPOUND_TXS per compounding round); when rewards are small, gas costs outweigh them.
*
* Usage: add an Aerodrome or Velodrome exchange object (a dedicated wallet is recommended), and fund the wallet with USDC plus a little ETH for gas.
*/
// ---------------- Parameters ----------------
var SYMBOL = "ETH_USDC" // trading pair
var BUDGET = 100 // position budget (USDC)
var POOL_KIND = "slipstream" // slipstream or basic
var RANGE_PCT = 10 // Slipstream range half-width (%); a staked position earns no rewards once out of range, so keep it wide
var MIN_TVL_USD = 100000 // only consider pools with TVL at or above this
var CLAIM_HOURS = 24 // claim + compound interval (hours)
var MIN_CLAIM_USD = 2 // do not claim while pending rewards are below this
var GAS_USD = 0.05 // estimated gas per on-chain transaction (USD)
var COMPOUND_TXS = 5 // transactions per compounding round: claim, sell, unstake, increase, stake
var INTERVAL = 300 // check interval (seconds)
var DRY_RUN = true // when true, plan and simulate only, no transactions are sent
var KEY = DRY_RUN ? "gauge_compound_paper" : "gauge_compound"
var S = null
var REWARD = null
function fmt(x, d) {
return x === null || x === undefined || isNaN(x) ? "-" : Number(x).toFixed(d === undefined ? 2 : d)
}
function kindOf(p) { return /^slipstream/.test(p.protocol) ? "slipstream" : "basic" }
function rewardPrice() {
var t = exchange.GetTicker(REWARD + "_USDC")
if (!t) throw new Error("GetTicker " + REWARD + "_USDC failed: " + GetLastError())
return (t.Buy + t.Sell) / 2
}
function listPools() {
var pair = SYMBOL.split("_")
var pools = exchange.IO("lp.pools", pair[0], pair[1])
if (!pools) throw new Error("lp.pools failed: " + GetLastError())
return pools
}
// Current pool state (the gauge fields of lp.pool use the same calculation as lp.pools)
function poolRow(id) {
var row = exchange.IO("lp.pool", id)
if (!row) throw new Error("lp.pool failed: " + GetLastError())
return row
}
// Pick the pool by reward APR and list the candidates in the log
function choosePool() {
var ok = listPools().filter(function (p) {
return p.symbol === SYMBOL && p.gaugeAlive && !p.empty && p.tvlUsd >= MIN_TVL_USD && kindOf(p) === POOL_KIND
}).sort(function (a, b) { return b.rewardAprPct - a.rewardAprPct })
ok.slice(0, 5).forEach(function (p) {
Log("Candidate", p.protocol, p.id, "tick spacing", p.tickSpacing, "TVL $" + fmt(p.tvlUsd, 0), "staked share", fmt(p.stakedShare * 100, 1) + "%",
"reward APR", fmt(p.rewardAprPct, 2) + "%", "fee APR (unstaked)", fmt(p.fees24hUsd * 365 / p.tvlUsd * 100, 2) + "%")
})
if (!ok.length) throw new Error("No eligible " + POOL_KIND + " pool (TVL ≥ " + MIN_TVL_USD + ", gauge alive)")
return ok[0]
}
function enter(pool) {
var params = { symbol: SYMBOL, budget: BUDGET, pool: pool.id, stake: true }
if (POOL_KIND === "slipstream") { params.range = { pct: [-RANGE_PCT, RANGE_PCT] }; params.shape = "spot" }
var plan = exchange.IO("lp.plan", params)
if (!plan) throw new Error("lp.plan failed: " + GetLastError())
Log("Entry plan:", plan.protocol, plan.pool, "needs", fmt(plan.needBase, 6), "+", fmt(plan.needQuote, 4), plan.shortfall ? "(" + plan.shortfall + ")" : "")
if (DRY_RUN) {
var sim = exchange.IO("lp.simulate", plan)
Log(sim ? "lp.simulate passed" : "lp.simulate failed (expected when the wallet balance is insufficient): " + GetLastError())
return ["paper"]
}
var r = exchange.IO("lp.add", plan)
if (!r) throw new Error("lp.add failed: " + GetLastError())
var ids = r.positions ? r.positions.map(function (p) { return String(p.id) }) : [String(r.id)]
Log("Position opened and staked", r.txHash, "positions", ids.join(","), r.stake ? "" : "(stake result missing, please check)", "#00AA00")
exchange.IO("lp.track", ids)
return ids
}
// Current position: {value, rewards (reward token amount), rewardsValue, staked, inRange}
function readPosition(pool) {
if (DRY_RUN) {
var now = Date.now()
S.paperRewardsUsd += S.principal * (pool.rewardAprPct || 0) / 100 * (now - S.lastCheck) / (365 * 86400000)
S.lastCheck = now
var px = rewardPrice()
return { value: S.principal, rewards: S.paperRewardsUsd / px, rewardsValue: S.paperRewardsUsd, staked: true, inRange: true }
}
var ps = exchange.IO("lp.positions", SYMBOL)
if (!ps) throw new Error("lp.positions failed: " + GetLastError())
var out = { value: 0, rewards: 0, rewardsValue: 0, staked: true, inRange: false }
ps.filter(function (p) { return !p.closed && S.ids.indexOf(String(p.id)) >= 0 }).forEach(function (p) {
out.value += p.value; out.rewards += p.rewards; out.rewardsValue += p.rewardsValue
if (!p.staked) out.staked = false
if (p.inRange !== false) out.inRange = true // basic pools have no range
})
return out
}
// Wait for the reward sell order to land on-chain and return the USDC actually received (the sell order is a market swap; GetOrder returns the fill from the receipt)
function proceeds(orderId) {
for (var i = 0; i < 30; i++) {
var o = exchange.GetOrder(orderId)
if (o && o.Status === ORDER_STATE_CLOSED) return o.DealAmount * o.AvgPrice
if (o && o.Status === ORDER_STATE_CANCELED) return 0
Sleep(2000)
}
return 0
}
// Claim rewards → sell for USDC → add back to the position
function compound(cur) {
Log("Claiming", fmt(cur.rewards, 4), REWARD, "≈ $" + fmt(cur.rewardsValue, 4), "and compounding")
var got
if (DRY_RUN) {
var q = exchange.IO("route", REWARD + "_USDC", "sell", Number(cur.rewards.toFixed(6)))
if (!q || q.quote === null) { Log("Quote failed:", GetLastError()); return false }
got = q.quote
Log("DRY_RUN: selling would yield", fmt(got, 4), "USDC (route", q.best + "); it would be added back to the position via lp.unstake → lp.increase → lp.stake")
S.paperRewardsUsd = 0
S.principal += got
} else {
var r = exchange.IO("lp.claim", S.ids, true)
if (!r) { Log("Claim failed:", GetLastError(), "#FF0000"); return false }
S.claimed += r.rewards
S.lastClaim = Date.now()
if (!r.sell || r.sell.error) { Log("Rewards claimed, but the sale failed:", r.sell ? r.sell.error : "-", "#FF0000"); return true }
got = proceeds(r.sell.txHash)
if (got <= 1) { Log("Sale proceeds of", fmt(got, 4), "USDC are too small, not compounding"); return true }
var ok
if (POOL_KIND === "basic") {
ok = exchange.IO("lp.increase", S.ids[0], got, { stake: true })
} else {
ok = exchange.IO("lp.unstake", S.ids) && exchange.IO("lp.increase", S.ids[0], got) && exchange.IO("lp.stake", S.ids)
}
if (!ok) { Log("Compounding incomplete:", GetLastError(), "(the USDC stays in the wallet; the position may be left unstaked)", "#FF0000"); return true }
}
S.claimedUsd += got
S.compounded += got
S.gasUsd += COMPOUND_TXS * GAS_USD
S.lastClaim = Date.now()
return true
}
function main() {
var name = exchange.GetName()
if (name !== "Aerodrome" && name !== "Velodrome") throw new Error("Please add an Aerodrome or Velodrome exchange object; the current one is " + name)
REWARD = name === "Aerodrome" ? "AERO" : "VELO"
S = _G(KEY)
if (!S || S.symbol !== SYMBOL || S.dex !== name) {
var pool = choosePool()
S = { symbol: SYMBOL, dex: name, pool: pool.id, ids: [], principal: BUDGET, paperRewardsUsd: 0, claimed: 0, claimedUsd: 0, compounded: 0,
gasUsd: 0, start: Date.now(), lastClaim: Date.now(), lastCheck: Date.now() }
S.ids = enter(pool)
S.gasUsd += 3 * GAS_USD
_G(KEY, S)
} else {
Log("Restored from _G: pool", S.pool, "positions", S.ids.join(","))
if (!DRY_RUN) exchange.IO("lp.track", S.ids)
}
while (true) {
try {
var pool = poolRow(S.pool)
var cur = readPosition(pool)
var due = Date.now() - S.lastClaim >= CLAIM_HOURS * 3600000
if (due && cur.rewardsValue >= MIN_CLAIM_USD && cur.rewardsValue > 2 * COMPOUND_TXS * GAS_USD && compound(cur)) {
cur.rewards = 0; cur.rewardsValue = 0
}
_G(KEY, S)
var days = Math.max((Date.now() - S.start) / 86400000, 1e-6)
var realized = (S.claimedUsd + cur.rewardsValue) / BUDGET / days * 365 * 100
var table = { type: "table", title: SYMBOL + " gauge staking compounder (" + REWARD + ")" + (DRY_RUN ? " (DRY_RUN paper position)" : ""), cols: ["Item", "Value"], rows: [
["Pool", pool.protocol + " " + S.pool], ["Pool reward APR rewardAprPct", fmt(pool.rewardAprPct, 2) + "%"],
["Daily emissions", fmt(pool.rewardsPerDay, 0) + " " + REWARD + " ($" + fmt(pool.rewardsPerDayUsd, 0) + ")"],
["Positions", S.ids.join(",")], ["Staked / in range", (cur.staked ? "Yes" : "No") + " / " + (cur.inRange ? "Yes" : "No")],
["Position value", fmt(cur.value, 4)], ["Pending rewards", fmt(cur.rewards, 4) + " " + REWARD + " ($" + fmt(cur.rewardsValue, 4) + ")"],
["Total claimed / compounded (USDC)", fmt(S.claimedUsd, 4) + " / " + fmt(S.compounded, 4)], ["Estimated gas", fmt(S.gasUsd, 4)],
["Annualized yield from actual rewards", fmt(realized, 2) + "%"],
["Next claim", due ? "Due (waiting for rewards ≥ $" + MIN_CLAIM_USD + ")" : _D(S.lastClaim + CLAIM_HOURS * 3600000)]] }
LogStatus(_D(), "\n`" + JSON.stringify(table) + "`")
} catch (e) {
Log("Error in this round:", e.message || e, "#FF0000")
}
Sleep(INTERVAL * 1000)
}
}See Also