-
Notifications
You must be signed in to change notification settings - Fork 8
Liquidation Guide
This guide provides comprehensive documentation on how to perform liquidations using the EVAA SDK with both Pyth and Classic Master implementations.
- Overview
- Prerequisites
- Pyth Master Liquidation
- Classic Master Liquidation
- Health Factor Calculation
- Liquidation Amount Calculation
- Transaction Workflow
- Error Handling
- Best Practices
Liquidation is a mechanism to maintain protocol solvency by resolving undercollateralized positions. The EVAA SDK supports two types of liquidation:
- Pyth Master Liquidation: Uses Pyth Network oracles for real-time price data with automatic fee calculation
- Classic Master Liquidation: Uses classic oracle implementation that automatically switches to spot prices for liquidations
| Feature | Pyth Master | Classic Master |
|---|---|---|
| Price Source | Pyth Network feeds | Multiple oracle sources with aggregation |
| Liquidation Pricing | Real-time Pyth feeds |
Automatically uses spot prices via getPricesForLiquidate()
|
| Fee Calculation | Dynamic Pyth update fees | Fixed liquidation fees |
| Transaction Routing |
TON: Pyth Oracle address Jettons: EVAA Master address |
TON/Jetton: EVAA Master address |
| Price Validation | Pyth network validation | Multi-oracle consensus |
| Time Parameters |
TON: maxPublishTime/minPublishTime Jetton: publishGap/maxStaleness |
Fixed oracle staleness validation |
Pyth Master:
- TON Liquidations: Transaction MUST be sent to Pyth Oracle address
- Jetton Liquidations: Transaction MUST be sent to EVAA Master address (via jetton transfer)
Classic Master:
- All Liquidations: Transaction sent to EVAA Master address
Critical: Pyth oracle uses different time validation parameters depending on the liquidation type:
-
TON Liquidations: Uses
maxPublishTimeandminPublishTimefrom price collector -
Jetton Liquidations: Uses
publishGapandmaxStalenesswith constraints:publishGap > 0 && publishGap < maxStalenessmaxStaleness <= max oracle TTL in master config
Best Practice: Include both parameter sets for maximum compatibility across liquidation types.
graph TB
A[Monitor Position] --> B{Check Health Factor}
B --> |Healthy| A
B --> |Liquidatable| C[Calculate Liquidation Amounts]
C --> D[Get Price Data]
D --> E{Oracle Type}
E --> |Pyth| F[Get Pyth Prices]
E --> |Classic| G[Get Classic Prices]
F --> H[Create Liquidation Message]
G --> H
H --> I[Execute Transaction]
I --> J[Verify Success]
Before performing liquidations, ensure you have:
- Environment Setup:
import 'dotenv/config';
import { mnemonicToWalletKey } from '@ton/crypto';
import { Address, beginCell, toNano, TonClient, WalletContractV4 } from '@ton/ton';- Required Imports for Pyth:
import {
calculateHealthParams,
calculateLiquidationAmounts,
EvaaMasterPyth,
getUserJettonWallet,
JettonWallet,
JUSDC_MAINNET,
JUSDT_MAINNET,
MAINNET_POOL_CONFIG,
PYTH_ORACLE_MAINNET,
STTON_MAINNET,
TSTON_MAINNET,
USDE_MAINNET,
} from '../src';- Required Imports for Pyth:
import {
calculateHealthParams,
calculateLiquidationAmounts,
EvaaMasterPyth,
FEES,
getUserJettonWallet,
isTonAsset,
JettonWallet,
MAINNET_POOL_CONFIG,
PYTH_ORACLE_MAINNET,
PythOracle,
PythPrices,
STTON_MAINNET,
TSTON_MAINNET,
} from '../src';- Required Imports for Classic:
import {
calculateHealthParams,
calculateLiquidationAmounts,
ClassicCollector,
EvaaMasterClassic,
FEES,
getUserJettonWallet,
isTonAsset,
JettonWallet,
MAINNET_LP_POOL_CONFIG,
ORACLES_LP,
TON_MAINNET,
USDT_MAINNET,
} from '../src';- Environment Variables:
RPC_API_KEY_MAINNET=your_api_key
MAINNET_WALLET_MNEMONIC=your_wallet_mnemonicconst TON_CLIENT = new TonClient({
endpoint: 'https://toncenter.com/api/v2/jsonRPC',
apiKey: process.env.RPC_API_KEY_MAINNET,
});
async function liquidateWithPyth() {
// Initialize wallet
const WALLET_KEY_PAIR = await mnemonicToWalletKey(
process.env.MAINNET_WALLET_MNEMONIC!.split(' ')
);
const WALLET_CONTRACT = TON_CLIENT.open(
WalletContractV4.create({
workchain: 0,
publicKey: WALLET_KEY_PAIR.publicKey,
}),
);
// Initialize EVAA Master
const EVAA_MAINNET = TON_CLIENT.open(
new EvaaMasterPyth({
poolConfig: MAINNET_POOL_CONFIG,
debug: true
})
);
const WALLET_SENDER = {
address: WALLET_CONTRACT.address,
send: WALLET_CONTRACT.sender(WALLET_KEY_PAIR.secretKey).send,
};// Sync master contract
await EVAA_MAINNET.getSync();
// Open user contract
const borrowerAddress = Address.parse('borroweraddress');
const EVAA_USER_MAINNET = TON_CLIENT.open(EVAA_MAINNET.openUserContract(borrowerAddress, 0));
if (!EVAA_MAINNET.data?.assetsData || !EVAA_MAINNET.data?.assetsConfig) {
throw new Error('Assets data or config is not available');
}
await EVAA_USER_MAINNET.getSyncLite(EVAA_MAINNET.data?.assetsData, EVAA_MAINNET.data?.assetsConfig);// Get prices specifically for liquidation (uses real-time Pyth feeds)
if (!EVAA_USER_MAINNET.liteData) {
throw new Error('User lite data is not available');
}
const pc = await MAINNET_POOL_CONFIG.collector.getPricesForLiquidate(EVAA_USER_MAINNET.liteData.realPrincipals);
// Validate Pyth prices
if (pc instanceof PythPrices == false) {
throw new Error('Pyth prices are not available');
}const health = calculateHealthParams({
assetsData: EVAA_MAINNET.data.assetsData,
assetsConfig: EVAA_MAINNET.data.assetsConfig,
principals: EVAA_USER_MAINNET.liteData!.principals,
prices: pc.dict,
poolConfig: MAINNET_POOL_CONFIG,
});
// Check if liquidatable
if (!health.isLiquidatable) {
console.log('Position is not liquidatable');
return;
}const loanAsset = TSTON_MAINNET;
const collateralAsset = STTON_MAINNET;
const collateralAssetConfig = EVAA_MAINNET.data.assetsConfig.get(collateralAsset.assetId);
const { maxCollateralRewardAmount, maxLiquidationAmount } = calculateLiquidationAmounts(
loanAsset,
collateralAsset,
health.totalSupply,
health.totalDebt,
EVAA_USER_MAINNET.liteData!.principals,
pc.dict,
EVAA_MAINNET.data.assetsData,
EVAA_MAINNET.data.assetsConfig,
MAINNET_POOL_CONFIG.masterConstants,
);
if (!collateralAssetConfig) {
throw new Error('Collateral asset config is not available');
}
const minCollateralAmount = (maxCollateralRewardAmount * 97n) / 100n - collateralAssetConfig.dust; const liqMessage = EVAA_MAINNET.createLiquidationMessage({
asset: loanAsset,
borrowerAddress: borrowerAddress,
collateralAsset: collateralAsset.assetId,
queryID: 0n,
includeUserCode: true,
liquidationAmount: maxLiquidationAmount,
liquidatorAddress: WALLET_CONTRACT.address,
loanAsset: loanAsset.assetId,
minCollateralAmount,
payload: beginCell().endCell(),
customPayloadSaturationFlag: false,
customPayloadRecipient: WALLET_CONTRACT.address,
subaccountId: 0,
pyth: {
targetFeeds: pc.targetFeeds(),
pythAddress: PYTH_ORACLE_MAINNET,
priceData: pc.dataCell,
// For Jetton liquidations: publishGap and maxStaleness are used
publishGap: 10, // Must be: 0 < publishGap < maxStaleness
maxStaleness: 180, // Must be: maxStaleness <= max oracle TTL in master config
// For TON liquidations: maxPublishTime and minPublishTime are used
// You can pass both sets of parameters for compatibility
refAssets: pc.refAssets(),
maxPublishTime: pc.maxPublishTime,
minPublishTime: pc.minPublishTime,
},
});
// Calculate Pyth update fee dynamically
const pythOracle = TON_CLIENT.open(PythOracle.createFromAddress(PYTH_ORACLE_MAINNET));
const pythUpdateFee = await pythOracle.getUpdateFee(pc.binaryUpdate());
const liquidationFee = FEES.LIQUIDATION + pythUpdateFee;
// CRITICAL: Different transaction routing for Pyth
if (isTonAsset(loanAsset)) {
// TON liquidations: Send to Pyth Oracle address
WALLET_SENDER.send({
value: liquidationFee,
to: PYTH_ORACLE_MAINNET,
body: liqMessage,
});
} else {
// Jetton liquidations: Send via jetton transfer to EVAA Master
const jettonWallet = TON_CLIENT.open(
JettonWallet.createFromAddress(
getUserJettonWallet(WALLET_CONTRACT.address, loanAsset)
)
);
await jettonWallet.sendTransfer(
WALLET_SENDER,
liquidationFee + FEES.JETTON_FWD,
liqMessage
);
}
console.log('Liquidation sent');
}async function liquidateWithClassic() {
// Initialize wallet (same as Pyth example)
const WALLET_KEY_PAIR = await mnemonicToWalletKey(
process.env.MAINNET_WALLET_MNEMONIC!.split(' ')
);
const WALLET_CONTRACT = TON_CLIENT.open(
WalletContractV4.create({
workchain: 0,
publicKey: WALLET_KEY_PAIR.publicKey,
}),
);
// Initialize EVAA Master Classic
const EVAA_MAINNET = TON_CLIENT.open(
new EvaaMasterClassic({
poolConfig: MAINNET_LP_POOL_CONFIG,
debug: true
})
);
const WALLET_SENDER = {
address: WALLET_CONTRACT.address,
send: WALLET_CONTRACT.sender(WALLET_KEY_PAIR.secretKey).send,
};await EVAA_MAINNET.getSync();
const borrowerAddress = Address.parse('borrowaddress');
const EVAA_USER_MAINNET = TON_CLIENT.open(EVAA_MAINNET.openUserContract(borrowerAddress, 0));
if (!EVAA_MAINNET.data?.assetsData || !EVAA_MAINNET.data?.assetsConfig) {
throw new Error('Assets data or config is not available');
}
await EVAA_USER_MAINNET.getSyncLite(EVAA_MAINNET.data?.assetsData, EVAA_MAINNET.data?.assetsConfig);
if (!EVAA_USER_MAINNET.liteData) {
throw new Error('The user lite data is not available');
}
// Create Classic Collector for price data
const collector = new ClassicCollector({
poolAssetsConfig: MAINNET_LP_POOL_CONFIG.poolAssetsConfig,
minimalOracles: 3,
evaaOracles: ORACLES_LP,
});
// IMPORTANT: getPricesForLiquidate automatically uses SPOT prices for liquidations
// This ensures accurate, real-time pricing for liquidation calculations
const pc = await collector.getPricesForLiquidate(EVAA_USER_MAINNET.liteData.realPrincipals);const health = calculateHealthParams({
assetsData: EVAA_MAINNET.data.assetsData,
assetsConfig: EVAA_MAINNET.data.assetsConfig,
principals: EVAA_USER_MAINNET.liteData!.principals,
prices: pc.dict, // Using spot prices from getPricesForLiquidate
poolConfig: MAINNET_LP_POOL_CONFIG,
});
const loanAsset = USDT_MAINNET;
const collateralAsset = TON_MAINNET;
const collateralAssetConfig = EVAA_MAINNET.data.assetsConfig.get(collateralAsset.assetId);
const { maxCollateralRewardAmount, maxLiquidationAmount } = calculateLiquidationAmounts(
loanAsset,
collateralAsset,
health.totalSupply,
health.totalDebt,
EVAA_USER_MAINNET.liteData!.principals,
pc.dict, // Using spot prices from getPricesForLiquidate
EVAA_MAINNET.data.assetsData,
EVAA_MAINNET.data.assetsConfig,
MAINNET_LP_POOL_CONFIG.masterConstants,
);
if (!collateralAssetConfig) {
throw new Error('Collateral asset config is not available');
}
const minCollateralAmount = (maxCollateralRewardAmount * 97n) / 100n - collateralAssetConfig.dust; const liqMessage = EVAA_MAINNET.createLiquidationMessage({
asset: loanAsset,
borrowerAddress: borrowerAddress,
collateralAsset: collateralAsset.assetId,
queryID: 0n,
includeUserCode: true,
liquidationAmount: maxLiquidationAmount,
liquidatorAddress: WALLET_CONTRACT.address,
loanAsset: loanAsset.assetId,
minCollateralAmount,
payload: beginCell().endCell(),
customPayloadSaturationFlag: false,
customPayloadRecipient: WALLET_CONTRACT.address,
subaccountId: 0,
priceData: pc.dataCell, // Use spot price data from getPricesForLiquidate
});
// Classic Master: Both TON and Jetton transactions go to EVAA Master
const liquidationFee = FEES.LIQUIDATION;
if (isTonAsset(loanAsset)) {
// TON liquidations: Send directly to EVAA Master
WALLET_SENDER.send({
value: liquidationFee,
to: EVAA_MAINNET.address,
body: liqMessage,
});
} else {
// Jetton liquidations: Send via jetton transfer to EVAA Master
const jettonWallet = TON_CLIENT.open(
JettonWallet.createFromAddress(
getUserJettonWallet(WALLET_CONTRACT.address, loanAsset)
)
);
await jettonWallet.sendTransfer(
WALLET_SENDER,
liquidationFee + FEES.JETTON_FWD,
liqMessage
);
}
console.log('Liquidation sent');
}The health factor determines if a position is liquidatable:
flowchart TD
A[Start Health Calculation] --> B[Get User Principals]
B --> C[Loop Through Assets]
C --> D{Has Principal?}
D -->|No| E[Next Asset]
D -->|Yes| F[Calculate Present Value]
F --> G{Asset Type}
G -->|Supply| H[Add to Total Supply & Limit]
G -->|Borrow| I{Above Dust?}
I -->|Yes| J[Add to Total Debt]
I -->|No| E
H --> E
J --> E
E --> K{More Assets?}
K -->|Yes| C
K -->|No| L[Calculate Health Ratio]
L --> M{totalLimit < totalDebt?}
M -->|Yes| N[Liquidatable]
M -->|No| O[Healthy]
- Total Supply: Sum of all supplied assets weighted by collateral factors
- Total Debt: Sum of all borrowed assets above dust threshold
- Total Limit: Maximum borrowing capacity based on collateral
-
Liquidatable: When
totalLimit < totalDebt
const { maxCollateralRewardAmount, maxLiquidationAmount } = calculateLiquidationAmounts(
loanAsset, // Asset being repaid
collateralAsset, // Asset being seized
health.totalSupply, // Total collateral value
health.totalDebt, // Total debt value
userPrincipals, // User's asset balances
priceDict, // Current asset prices
assetsData, // Asset rate data
assetsConfig, // Asset configuration
masterConstants, // Protocol constants
);graph TD
A[Prepare Asset Info] --> B{Valid Assets?}
B -->|No| C[Return Zero]
B -->|Yes| D[Calculate Allowed Collateral]
D --> E{Bad Debt?}
E -->|Yes| F[Use Full Collateral]
E -->|No| G[Constrain to 1/3 or $100]
F --> H[Calculate Base Value]
G --> H
H --> I[Apply Liquidation Bonus]
H --> J[Apply Reserve Factor]
I --> K[Convert to Collateral Amount]
J --> L[Convert to Liquidation Amount]
K --> M[Return Results]
L --> M
sequenceDiagram
participant Bot as Liquidation Bot
participant Master as EVAA Master
participant User as User Contract
participant Oracle as Price Oracle
participant Wallet as Jetton Wallet
Bot->>Master: getSync()
Master-->>Bot: Pool data synced
Bot->>User: getSyncLite()
User-->>Bot: User data retrieved
Bot->>Oracle: getPrices()
Oracle-->>Bot: Current prices
Bot->>Bot: calculateHealthParams()
alt Position is liquidatable
Bot->>Bot: calculateLiquidationAmounts()
Bot->>Master: createLiquidationMessage()
Master-->>Bot: Liquidation message
Bot->>Wallet: sendTransfer()
Wallet-->>Bot: Transaction sent
else Position is healthy
Bot->>Bot: Continue monitoring
end
- Insufficient Balance:
const balance = await WALLET_CONTRACT.getBalance();
if (balance == 0n) {
console.log(`Wallet ${WALLET_CONTRACT.address} balance is 0, nothing to do`);
return;
}- Missing Asset Data:
if (!EVAA_MAINNET.data?.assetsData || !EVAA_MAINNET.data?.assetsConfig) {
throw new Error('Assets data or config is not available');
}- Invalid User Data:
if (!EVAA_USER_MAINNET.liteData?.realPrincipals) {
throw new Error('Real principals is not available');
}- Price Data Issues:
try {
const pc = await MAINNET_POOL_CONFIG.oracles.getPrices(MAINNET_POOL_CONFIG.poolAssetsConfig);
} catch (error) {
console.error('Failed to fetch price data:', error);
return;
}// Apply safety margin to minimum collateral
const minCollateralAmount = (maxCollateralRewardAmount * 97n) / 100n - collateralAssetConfig.dust;Important: Different Pyth parameters are used for different liquidation types:
// Pyth parameter usage by liquidation type:
pyth: {
// JETTON LIQUIDATIONS use these parameters:
publishGap: 10, // Constraint: 0 < publishGap < maxStaleness
maxStaleness: 180, // Constraint: maxStaleness <= max oracle TTL in master config
// TON LIQUIDATIONS use these parameters:
maxPublishTime: pc.maxPublishTime,
minPublishTime: pc.minPublishTime,
// Recommended: Pass both for compatibility
targetFeeds: pc.targetFeeds(),
pythAddress: PYTH_ORACLE_MAINNET,
priceData: pc.dataCell,
refAssets: pc.refAssets(),
}Parameter Constraints:
publishGap > 0 && publishGap < maxStalenessmaxStaleness <= max oracle TTL in master config
Safe Default Values:
publishGap: 10,
maxStaleness: 180,try {
await jettonWallet.sendTransfer(WALLET_SENDER, gasAmount, liqMessage);
console.log('Liquidation sent successfully');
} catch (error) {
console.error('Liquidation failed:', error);
// Implement retry logic or alerting
}// Pyth: Uses real-time feeds from Pyth Network
const pythPrices = await MAINNET_POOL_CONFIG.collector.getPricesForLiquidate(userRealPrincipals);
// Classic: Automatically uses SPOT prices (not TWAP) for liquidations
const classicPrices = await collector.getPricesForLiquidate(userRealPrincipals);// ALWAYS validate asset type and route correctly
if (isTonAsset(loanAsset)) {
if (isPythMaster) {
// Route to Pyth Oracle for TON liquidations
WALLET_SENDER.send({
value: liquidationFee,
to: PYTH_ORACLE_MAINNET,
body: liqMessage,
});
} else {
// Route to EVAA Master for Classic TON liquidations
WALLET_SENDER.send({
value: liquidationFee,
to: EVAA_MAINNET.address,
body: liqMessage,
});
}
} else {
// Both Pyth and Classic jetton liquidations use jetton transfer
await jettonWallet.sendTransfer(WALLET_SENDER, totalFee, liqMessage);
}This guide provides a comprehensive overview of liquidation implementation using both Pyth and Classic masters. The key differences include:
Price Data:
- Pyth: Real-time feeds from Pyth Network with dynamic fee calculation
-
Classic: Automatic spot pricing via
getPricesForLiquidate()(not TWAP)
Transaction Routing:
- Pyth TON: Must route to Pyth Oracle address
- Pyth Jetton: Routes to EVAA Master via jetton transfer
- Classic TON/Jetton: Routes to EVAA Master address
Fee Structure:
- Pyth: Dynamic fees based on Pyth update costs
- Classic: Fixed liquidation fees
Always ensure proper price data collection, transaction routing, and fee calculation for successful liquidations.