Liquidation bots keep Vanna Protocol solvent by calling AccountManager.liquidate() on undercollateralized accounts. Anyone can run one — no whitelist, no special permissions.
How Liquidation Works
When a margin account’s health factor drops to or below 1.1×:
- Any caller can call
AccountManager.liquidate(smart_account)
- The protocol repays all debt by drawing from the SmartAccount’s collateral
- Remaining collateral is swept to the trader
- The liquidator pays gas and receives no explicit bonus in the current version
Vanna V1 does not currently pay a liquidation bonus to the bot. The economic incentive is keeping the protocol healthy. A liquidation premium may be introduced in a future version.
Architecture of a Liquidation Bot
┌─────────────────────────────────────────────────────────┐
│ LIQUIDATION BOT │
│ │
│ 1. Discovery 2. Monitoring 3. Execution │
│ ───────────── ───────────────── ─────────────── │
│ Get all accounts Poll health factor Call liquidate() │
│ from Registry every N ledgers if HF ≤ 1.1× │
└─────────────────────────────────────────────────────────┘
Step 1: Discover All Margin Accounts
Fetch the full list of accounts from AccountManager events (Mercury) or read from Registry:
Via Mercury (recommended for production)
import { decodeScVal, fetchContractEvents } from './mercury-client';
const ACCOUNT_MANAGER = 'CAK2IJIO2SKZWUODY4G7ZRIUUIIMJUUAIXE3I5YTQ5QYNSS2RYJ3P4CV';
async function getAllSmartAccounts(): Promise<string[]> {
// Fetch all AccountCreationEvents
const events = await fetchContractEvents(ACCOUNT_MANAGER, { limit: 1000 });
const creationEvents = events.filter(
e => decodeScVal(e.topic1) === 'AccountCreationEvent'
);
const accounts = creationEvents.map(e => {
const data = decodeScVal(e.data) as { smart_account: string };
return data.smart_account;
});
// Remove duplicates (users may have created multiple accounts)
return [...new Set(accounts)];
}
Via Registry Contract
async function getAccountsForUser(userAddress: string): Promise<string[]> {
const result = await readContract(
REGISTRY,
'get_accounts',
[nativeToScVal(userAddress, { type: 'address' })],
);
return result as string[];
}
Step 2: Check Health Factor for Each Account
const RISK_ENGINE = 'CBL7RCG5H4VIZCNF7BRM2FQFXK7N5KRQKW7ZVEQZJKNXHA6FEU4OXK5I';
const WAD = 10n ** 18n;
const LIQUIDATION_THRESHOLD = 1_100_000_000_000_000_000n; // 1.1 × WAD
interface AccountHealth {
smartAccount: string;
collateralUsd: bigint;
debtUsd: bigint;
healthFactor: number | null;
isLiquidatable: boolean;
}
async function checkAccountHealth(smartAccount: string): Promise<AccountHealth> {
const [collateralScVal, debtScVal] = await Promise.all([
readContract(
RISK_ENGINE,
'get_current_total_balance',
[nativeToScVal(smartAccount, { type: 'address' })],
),
readContract(
RISK_ENGINE,
'get_current_total_borrows',
[nativeToScVal(smartAccount, { type: 'address' })],
),
]);
const collateralUsd = collateralScVal as bigint;
const debtUsd = debtScVal as bigint;
if (debtUsd === 0n) {
return {
smartAccount,
collateralUsd,
debtUsd,
healthFactor: null,
isLiquidatable: false,
};
}
const hfWad = (collateralUsd * WAD) / debtUsd;
const isLiquidatable = hfWad <= LIQUIDATION_THRESHOLD;
return {
smartAccount,
collateralUsd,
debtUsd,
healthFactor: Number(hfWad) / 1e18,
isLiquidatable,
};
}
Step 3: Execute Liquidation
async function liquidateAccount(
botAddress: string,
smartAccount: string,
): Promise<string> {
console.log(`Liquidating ${smartAccount}...`);
const txHash = await invokeContract(
botAddress,
ACCOUNT_MANAGER,
'liquidate',
[nativeToScVal(smartAccount, { type: 'address' })],
);
console.log(`Liquidation submitted: ${txHash}`);
return txHash;
}
Full Bot Loop
import { Horizon } from '@stellar/stellar-sdk';
const BOT_ADDRESS = 'G...'; // your bot's Stellar account
const POLL_INTERVAL_LEDGERS = 3; // check every ~15 seconds (one ledger ≈ 5s)
async function runLiquidationBot() {
const horizon = new Horizon.Server('https://horizon-testnet.stellar.org');
// Start: discover all accounts
let accounts = await getAllSmartAccounts();
console.log(`Monitoring ${accounts.length} accounts`);
// Subscribe to ledger closes
horizon.ledgers().cursor('now').stream({
onmessage: async (ledger) => {
if (ledger.sequence % POLL_INTERVAL_LEDGERS !== 0) return;
// Refresh account list periodically
if (ledger.sequence % 60 === 0) {
accounts = await getAllSmartAccounts();
}
// Check all accounts in parallel (batched to avoid rate limits)
const BATCH_SIZE = 10;
for (let i = 0; i < accounts.length; i += BATCH_SIZE) {
const batch = accounts.slice(i, i + BATCH_SIZE);
const healthChecks = await Promise.allSettled(
batch.map(checkAccountHealth)
);
for (const result of healthChecks) {
if (result.status === 'rejected') continue;
const health = result.value;
if (!health.isLiquidatable) continue;
console.log(
`Liquidatable account found: ${health.smartAccount}`,
`HF: ${health.healthFactor?.toFixed(3)}`,
`Debt: $${Number(health.debtUsd) / 1e18}`,
);
try {
const txHash = await liquidateAccount(BOT_ADDRESS, health.smartAccount);
console.log(`Liquidation successful: ${txHash}`);
} catch (err) {
console.error(`Liquidation failed for ${health.smartAccount}:`, err);
}
}
// Small delay between batches
await new Promise(r => setTimeout(r, 500));
}
},
onerror: console.error,
});
}
runLiquidationBot().catch(console.error);
Handling Race Conditions
Multiple bots may attempt to liquidate the same account simultaneously. The liquidate() function will succeed for one and fail for the others (the account is no longer unhealthy after the first liquidation).
Handle this gracefully:
try {
await liquidateAccount(BOT_ADDRESS, smartAccount);
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('AccountNotUnhealthy') || msg.includes('healthy')) {
console.log(`Account ${smartAccount} already liquidated`);
} else {
throw err;
}
}
Monitoring with Mercury Events
Set up event tracking to know when liquidations happen in real time:
async function watchLiquidationEvents() {
// On each ledger close, fetch new liquidation events since last cursor
let cursor: string | undefined;
setInterval(async () => {
const events = await fetchContractEvents(ACCOUNT_MANAGER, {
limit: 50,
cursor,
});
const liquidations = events.filter(
e => decodeScVal(e.topic1) === 'TraderLiquidateEvent'
);
for (const event of liquidations) {
const data = decodeScVal(event.data) as {
smart_account: string;
timestamp: bigint;
};
console.log(`Liquidation detected:`, data.smart_account, event.tx);
}
if (events.length > 0) {
cursor = events[events.length - 1].id;
}
}, 5_000); // every 5 seconds
}
Bot Account Setup
Your bot needs a funded Stellar testnet account with enough XLM to pay transaction fees:
# Create account via Friendbot
curl "https://friendbot.stellar.org?addr=<your-bot-address>"
Each liquidate() call costs ~0.001–0.01 XLM in fees. Keep the bot account funded.
Production Considerations
| Concern | Recommendation |
|---|
| Bot key security | Use a dedicated keypair stored in environment variables or a secrets manager |
| Rate limiting | Batch RPC calls, add delays between batches |
| Bot uptime | Run on a VPS or cloud instance with monitoring/alerting |
| Failed transactions | Implement retry with exponential backoff |
| Stale account list | Refresh from Mercury every N ledgers |