HERON
HERON is one public execution agent for Meteora DLMM liquidity on Solana. It watches every indexed pool, throws almost all of them away with arithmetic, and asks a model about only what arithmetic cannot settle. It never takes custody.
The product is the declining. On a normal scan more than half the leaderboard is removed for free, and the pools that top it by advertised APR are usually the first to go.
What it needs, and what it does not
No price oracle
Fee accrual, TVL, volume and bin state are facts about a pool, not prices that have to be licensed. Nothing here depends on a paid market-data feed.
No custody
Connecting a wallet reads your open positions. Nothing is signed, and withdrawal never depends on the agent being alive or honest.
Persistence
Meteora publishes fee_tvl_ratio over six windows. That figure is accumulated: a longer window has collected more fees, so the raw number climbs with window length no matter what the pool is doing. Plotted directly, every pool looks like it is accelerating.
Divide each window by its own hours and it becomes a rate:
rate(w) = fee_tvl_ratio(w) / hours(w)
persistence = rate(1h) / rate(24h)
| persistence | reading |
|---|---|
| > 1.5 | accelerating — volume is arriving now |
| ≈ 1.0 | steady |
| < 0.4 | decaying — the 24h headline is history |
The API's apy field overflows — it returns 1.8446744073709552e+19, which is u64::MAX. HERON never reads it. APR is computed from fee_tvl_ratio instead. farm_apy is suspect for the same reason.
Seven checks, all free
Every stage below is arithmetic. A pool that fails any of them never becomes a paid decision, and the reason is recorded so the brief can say what was declined and why.
- Not blacklistedMeteora's own flag, taken at face value.
- TVL ≥ $50,000Below this, position sizing is dominated by price impact rather than fee capture.
- No live freeze authorityA token whose issuer can freeze holders is not yield, it is a countdown. This is a hard block, not a warning.
- Implied APR ≥ 20%Computed from the 24h rate, never from the API's apr field.
- Persistence ≥ 0.40The fee engine must still be running.
- Turnover ≥ 1× TVL/dayThe pool has to be working, not merely large.
- Pool older than 12hA fresh pool has no term structure to read — every window covers the same few minutes.
One definition, three consumers. The CLI, the API and the daily brief all call the same gate(). An earlier version had the funnel and the gate disagreeing — the funnel said 28 pools survived while the brief said 34, because the gate was missing the turnover and age checks. They are now the same function.
The only part that costs money
What reaches the model is a genuine judgement call: the fee engine is running, the pool is deep enough, nothing is obviously rugged — but is the curve worth taking a position against, and how wide should the range be?
Three rules, enforced in code
- One call per candidateNo retry on a bad answer — a bad answer is a skip. A decision is also cached against the pool for fifteen minutes, so asking the same pool repeatedly replays the answer instead of buying it again. Fifteen minutes is shorter than the shortest window the screen reads, so a replayed decision is never older than the data behind it. A failure is not cached: a transient outage must not pin a pool to skip.
- A hard daily budgetEight calls. The attempt is counted, not the success, so a retry loop cannot be free. Spent means everything skips.
- Fail closedTimeout, malformed JSON, missing key, HTTP error, a reply that does not match the schema — every one resolves to skip.
The response schema is strict, and anything outside it is treated as a failure rather than coerced:
{
"action": "enter" | "watch" | "skip",
"size_pct": 0-100,
"range_bins": 0-60,
"reason": "string, max 200 chars"
}
An enter that deploys nothing, or has no range, is incoherent and fails the parse. reason is copy for the brief and is never parsed back into the execution path — a model that writes nonsense there cannot change what happens.
Inference runs through Virtuals Agent Compute, so the cost of deciding is paid from the agent's own wallet and is a provable expense line rather than an assertion:
POST https://compute.virtuals.io/v1/chat/completions
Authorization: Bearer $VIRTUALS_API_KEY
| model | in / Mtok | out / Mtok | 8 calls/day |
|---|---|---|---|
| anthropic-claude-sonnet-5 | $2 | $10 | $0.044 |
| anthropic-claude-opus-5 | $6 | $30 | $0.13 |
| deepseek-deepseek-v4-flash | $0.138 | $0.275 | $0.003 |
Without VIRTUALS_API_KEY the layer returns skip with source no-key and spends nothing. That is the intended behaviour, not an error.
A win rate that had to be earned
Meteora publishes no history. Every endpoint is a snapshot of now — /history, /chart, /metrics, /fee-history, /analytic and /snapshot were all probed and all return 404. A win rate therefore cannot be backfilled, computed from a replay, or inferred. It can only be recorded forward, which is what the scoreboard does.
Every scan writes its calls to an append-only ledger at data/calls.jsonl, one row per pool, deduplicated to one call per pool per six hours. Twelve hours later the pool is re-read and the call is scored:
at call time predicted = fee_tvl_ratio["1h"] / 1 %/hour
12 hours later realized = fee_tvl_ratio["12h"] / 12 %/hour
capture = realized / predicted
held = capture >= 0.5
The 12h window at settlement covers exactly the twelve hours the call was open, so capture is what an LP entering on that call would have collected against what the screen showed them. Held means you kept at least half of it.
Why declines are scored too
Scoring only the entries would measure nothing: a screen that admitted every pool would produce an identical entry win rate. So declines are settled by the same arithmetic, and the headline number is the difference between them.
- Entry win rate — of pools the gate passed, how many held.
- Decline accuracy — of pools the gate rejected, how many did in fact fade.
- Edge — entry win rate minus the rate the declines would have won at. If this is not positive, the screen is not sorting anything, and the product does not work. That number is allowed to come out badly.
What the ledger refuses to do
- A blank is not a zero. Until a call settles, the win rate renders as an em dash. Showing 0% would read as a result.
- An unreadable pool is void, not a loss. If a pool is delisted or cannot be re-read at settlement, the call is voided and never enters the win rate — a pool that vanished is not evidence the call was wrong.
- Settlement never rewrites. A settle row is appended alongside the call rather than mutating it, so the file stays a verifiable record and a crash mid-write can lose at most the last line.
- It is not a return. Capture measures fee rate against fee rate. It says nothing about impermanent loss, which is the larger risk in a DLMM position.
- The anchor is noisy, and that is deliberate.
predictedis one hour of fees, so a pool with a spiky hour will score a low capture even if it never really faded — the twelve-hour average is smoother than the reading it is compared against. That asymmetry is kept rather than corrected, because it is the disappointment an LP actually experiences: you enter on the number you were shown and collect the average. It does mean single calls are weak evidence and only the aggregate is worth reading.
Read-only, by construction
Connecting a wallet calls Meteora's portfolio endpoint and renders what it returns. No transaction is ever built or signed on this path.
GET https://dlmm.datapi.meteora.ag/portfolio/open
?user=<base58>&page=1&page_size=50
&sort_by=current_balances&sort_direction=desc
Two things about this endpoint are easy to get wrong:
- Money values are strings.
balances,unclaimedFees,pnlandtotalDepositall arrive as strings; onlybaseFeeandpoolPriceare numbers. - In-range is a set difference. There is no in-range count — it is
listPositionsminuspositionsOutOfRange. WhenoutOfRangeisnull, the range state is undeterminable and should be shown as unknown rather than as zero.
The docs also list pool_tvl and pool_volume_24h as sort_by values. The server rejects both with a 400 — the only accepted values are current_balances, unclaimed_fee and fee_per_tvl24h.
What this does not solve
- Impermanent loss is real. A DLMM position is two-sided exposure to whatever is in the pool. A high fee rate can be compensation for holding something that is falling.
- Unverified tokens dominate the high-yield tail. Requiring both sides of a pair to be verified currently leaves nothing at all. The strategy prices that risk rather than pretending it can exclude it.
- Issuer powers persist. Permanent delegate, pausable mints and transfer hooks can appear after you enter. The gate reads them per scan; it cannot read the future.
- A screen is not a backtest. The screen itself proves only which pools were not worth asking about. Whether the survivors actually paid is a separate, forward-recorded question — that is what the scoreboard exists to answer, and it answers it slowly, twelve hours at a time.
- Publishing a track record is a regulated act in some places. Charging on performance while publishing results changes the legal posture. Check your jurisdiction.
Local endpoints
The browser never talks to Meteora directly. Every request passes through the same screening code the CLI uses, so there is no second implementation to drift.
GET
/api/pools?minTvl=50000&limit=60&sort=fee_tvl_ratio_24h:desc
Screened pools plus the funnel. curve is the per-hour rate; curveRaw is the accumulated figure.
GET
/api/pool/<address>
One pool, same shape as a row in /api/pools.
GET
/api/decide/<address>
Runs the decision layer. A pool that has not cleared the gate returns source: "gate" and spends nothing.
GET
/api/positions?wallet=<base58>
Open positions. Rejects anything that is not base58 with a 400 before it reaches an upstream path.
GET
/api/track?limit=24
The scoreboard: entry win rate, decline accuracy, edge, median capture, and the recent calls. Reads the ledger only — it never fetches, so a blank means nothing has settled rather than that something is down.
GET
/api/budget
Decision calls used and remaining today.
GET
/api/health
Liveness.
No build step
Node 22.18 or newer runs the TypeScript directly — verified on 22.23 and 24.14. There are no runtime dependencies.
npm install # types only
npm run web # http://localhost:4173
npm run pools # the same screen, in the terminal
npm run pools:funnel # how much each stage throws away
npm run universe # 625 US-listed xStocks with Solana mints
To enable the decision layer:
export VIRTUALS_API_KEY=... # without it, every decision skips
export HERON_MODEL=anthropic-claude-sonnet-5
export HERON_DAILY_CALLS=8
One origin locally, two in production
The app is meant to sit on its own subdomain, with the marketing site, these docs and the scoreboard on the apex. It is the same build either way — pages are written with {{SITE}} and {{APP}} placeholders and the server substitutes them per response, so no URL is ever hardcoded to one deployment shape.
export HERON_APP_HOST=app.heron.fi # on this Host, "/" IS the app
export HERON_APP_ORIGIN=https://app.heron.fi
export HERON_SITE_ORIGIN=https://heron.fi
Unset, they collapse to /app and "", which is the single-origin layout you get by running npm run web. Clean URLs are the contract: nothing links to a .html file, and any .html address 301s to its clean path — /index.html becomes /app on the apex and / on the app subdomain.