Run a liquidator
Find unhealthy positions, repay their debt, and take the collateral at a discount. A starter script, and what it does under the hood.
Anyone can liquidate on Paboxo. There is no allowlist and no keeper role: when a position's debt exceeds its collateral value times the market's liquidation threshold, any address can repay part of that debt — up to the close factor — and receive the borrower's collateral plus the market's bonus.
Paboxo runs its own liquidator, but a lending market should not depend on one bot. This page is for developers who want to run another.
What you earn
You repay debt in USDG and receive collateral worth repaid × (1 + bonus) at oracle prices:
| Market | Bonus | You repay | You receive |
|---|---|---|---|
| NVDA / USDG | 8% | 1,000 USDG | $1,080 of NVDA |
| SPY / USDG | 6% | 1,000 USDG | $1,060 of SPY |
| WETH / USDG | 5% | 1,000 USDG | $1,050 of WETH |
| SPCX / USDG | 10% | 1,000 USDG | $1,100 of SPCX |
| TSLA / USDG | 10% | 1,000 USDG | $1,100 of TSLA |
| MSFT / USDG | 8% | 1,000 USDG | $1,080 of MSFT |
| AAPL / USDG | 8% | 1,000 USDG | $1,080 of AAPL |
| QQQ / USDG | 7% | 1,000 USDG | $1,070 of QQQ |
| GOOGL / USDG | 8% | 1,000 USDG | $1,080 of GOOGL |
| AMD / USDG | 10% | 1,000 USDG | $1,100 of AMD |
A share of the bonus can be routed to the protocol (liquidationProtocolShare). It is 0%
today, so the whole bonus is yours. Your costs are gas and the price you get when you sell the
collateral: you receive the token itself (NVDA, SPY, SPCX, TSLA, MSFT, AAPL, QQQ, GOOGL,
AMD or WETH),
not USDG. The full arithmetic,
including partial liquidations and bad debt, is on liquidation math.
Liquidating NVDA, SPY, SPCX, TSLA, MSFT, AAPL, QQQ, GOOGL or AMD means receiving Stock Tokens, which are not offered to US, Canadian, UK or Swiss persons. The contracts do not check where you are; the restriction still applies to you. Read restrictions before you run a liquidator on those markets.
Close factor
You cannot always repay the whole debt. One liquidation may repay at most:
| Health factor | Most you may repay |
|---|---|
| between 0.95 and 1 | the fraction (1.05 − hf) / (1.05 − LT × (1 + bonus)) of the debt, which brings the health factor back to 1.05 |
| 0.95 or below | the whole debt |
A request above the limit is capped, not reverted, and that includes LendingPool.liquidation(borrower).
A partial liquidation must also leave at least closeFactorMinBase() / 2 — $5 today — of debt
and of collateral, or it reverts MustNotLeaveDust(leftoverDebt, leftoverCollateral, minLeftover).
When restoring 1.05 would leave less than that, the whole position may go.
For a bot this comes down to three rules:
- Read
IsHealthy.maxLiquidatableShares(user, router)and repaymin(that, what you can afford). It already applies every rule above, and it is 0 for a healthy position. - Repaying exactly that maximum never reverts as dust. Only a smaller partial can, and only when the maximum is the whole debt; if you cannot afford it, repay at most what leaves $5 behind.
- Do not split. Several liquidations never repay more than one would: once the first lifts the
health factor above 1, the next one reverts
AssetNotLiquidatable.
So expect a slightly unhealthy position to pay a fraction of what a full liquidation would. On NVDA/USDG at a health factor of 0.975, one liquidation repays 21.55% of the debt.
The starter script
One TypeScript file with one dependency (viem). It finds every position with debt, prints its health factor and the estimated profit of liquidating it, and liquidates when you tell it to. Read it before you run it: it is short so that you can, and it is meant as the starting point of your own bot.
mkdir paboxo-liquidator && cd paboxo-liquidator
curl -O https://docs.paboxo.xyz/tools/liquidate.ts
npm init -y && npm i viem
npx tsx liquidate.ts scanscan reads only. Run on 19 October 2026, before anyone had borrowed, it printed:
mainnet (chain 4663): 0 positions with debtWith debt on the books it prints one line per position: market, borrower, health factor, debt, and
either healthy or LIQUIDATABLE with the repayment, what the liquidator receives, gas and the
estimated profit. A market whose prices are not Fresh, a closed Stock Token market on a Saturday
for instance, first prints liquidations blocked with the reason.
Commands
| Command | What it does |
|---|---|
scan | every position with debt, its health factor, and the estimated profit of the largest liquidation the close factor allows |
liquidate <MARKET> <borrower> | plan one liquidation; add --send to execute it, --shares N to repay less than the maximum |
watch | scan every --interval seconds (default 30) and liquidate what is due; add --send to execute, --min-profit (USD) to skip small ones |
Nothing is sent without --send. With it, the script reads PRIVATE_KEY, sizes the
liquidation to min(maxLiquidatableShares, the wallet's USDG balance) — stepping down to a size that
leaves $5 behind if a smaller partial would otherwise revert as dust — approves exactly that amount
(plus a 1% buffer for interest) to the market's pool, simulates the call from your address, and
only then sends it. When it repays the whole permitted maximum it passes type(uint256).max, so the
router caps the request at the maximum as of the block the transaction lands in.
Whatever part of the buffer the pool does not use stays approved to that pool, a few USDG, until
the next approval replaces it.
Configuration
All environment variables are optional, except PRIVATE_KEY with --send.
| Variable | Default | |
|---|---|---|
PRIVATE_KEY | the liquidator wallet. Use a wallet that only holds what it needs | |
RPC_URL | the public RPC | strongly recommended: the public endpoint is rate-limited and answers bursts with 429 or a Cloudflare challenge. Free dRPC refuses the eth_getLogs ranges DISCOVERY=logs needs |
DISCOVERY | indexer | logs rebuilds the borrower list from events instead of asking the indexer |
LOG_CHUNK | 50000 | blocks per eth_getLogs call with DISCOVERY=logs |
GAS_LIMIT | 600000 | gas units assumed when estimating profit |
DEBT_BUFFER_BPS | 100 | extra allowance over the estimated repayment |
Addresses are not in the script. It reads them from
/deployments/mainnet.json, which this site regenerates on every
Paboxo deployment. Your bot keeps working across a
redeploy without an update.
If the indexer is not answering, run with DISCOVERY=logs: the script then
rebuilds the borrower list from events since the deployment block.
How it works
If you would rather write your own bot, these are the five steps the script takes.
1. Find borrowers
There is no on-chain list of borrowers. Either ask the indexer for every position that owes something:
query ($after: String) {
positions(where: { borrowShares_gt: "0" }, limit: 500, after: $after) {
pageInfo { hasNextPage endCursor }
items { market user } # market = the router address
}
_meta { status } # the block the indexer has reached: refuse a stale list
}or rebuild the list from events, starting at deployBlock: PositionCreated(lendingPool, lendingPoolRouter, user, position) on PaboxoEmitter and BorrowDebt(user, …) on each
LendingPool. A stopped indexer still answers, with an old list, so check _meta.status against
the chain head before trusting it.
2. Check health
IsHealthy.checkLiquidatable(address user, address router)
returns (bool liquidatable, uint256 borrowValue, uint256 maxCollateralValue, uint256 bonus)borrowValue and maxCollateralValue are USD with 18 decimals; maxCollateralValue is already
multiplied by the liquidation threshold, so the health factor is maxCollateralValue / borrowValue. bonus is a WAD (0.08e18 = 8%). The borrower's debt in shares is
LendingPoolRouter.userBorrowShares(user), and the most one liquidation may repay is
IsHealthy.maxLiquidatableShares(address user, address router) returns (uint256 shares)It is an upper bound for the current block: the router accrues interest before it applies the cap.
3. Check the market is open
A liquidation reverts, whoever the borrower is, when:
- the router or pool is paused (
paused()on either); - the router requires fresh prices (
requireFreshForLiquidation(), on by default) andTokenDataStream.priceStatus(token)is not0(Fresh) for the collateral or for USDG. Stock Token feeds report1(MarketClosed) from Friday's close until the first price after the reopen; see market hours; - the Stock Token issuer has paused transfers (
paused()on the collateral token).
Check these first and you skip a reverted transaction. Expect a queue of liquidations in the first minutes after the market reopens.
4. Size and price it
Repay min(maxLiquidatableShares, what you can afford) borrow shares. The pool charges
ceil(shares × totalBorrowAssets / totalBorrowShares) USDG and gives you the fraction
repaidValue × (1 + bonus) / collateralValue of every token in the borrower's Position. When the
Position cannot cover repaid × (1 + bonus), you take all of it and repay only what it is worth;
the rest becomes bad debt. A partial below the maximum that would leave less than $5 of debt or
collateral reverts MustNotLeaveDust. liquidationOutcome() in the script is a line-for-line copy
of the router's arithmetic, close factor included, tested against the contract's own test vectors.
Interest accrues between your read and your transaction, so approve a little more than the estimate.
5. Send it
USDG.approve(lendingPool, amount); // exact amount, not unlimited
LendingPool.liquidate(address borrower, uint256 repayShares); // value: 0repayShares above what the borrower owes, or above the close factor, is capped, so
type(uint256).max repays exactly the permitted maximum — the whole debt only at a health factor of
0.95 or below.
Robinhood Chain does not charge a priority fee: send with maxPriorityFeePerGas = 0. Simulate
from your own address first; the reverts are named (PriceNotFresh, CollateralTransfersPaused,
EnforcedPause, AssetNotLiquidatable, MustNotLeaveDust), so a failed simulation tells you why.
The contract emits LiquidationExecuted(borrower, liquidator, repaidAssets, repaidShares, toLiquidatorWad, toProtocolWad, badDebt) on the pool.
Before you run it with money
- Dry-run first. Run
scanandwatchwithout--sendfor a while and compare what they would have done with what happened on chain. - You compete. Paboxo's own liquidator and anyone else running this script read the same chain. The first transaction to land wins; the others revert cheaply in simulation.
- You hold the collateral afterwards. Price moves between the liquidation and your sale are your risk. Selling right away on a DEX means paying its slippage, which on a thin pool can be most of a 6% bonus.
- The script is a starter. It does not sell collateral, retry with higher gas, run several wallets, or alert you. It is MIT-licensed: copy it, change it, run it commercially.