diff --git a/.gitignore b/.gitignore index 9e6258e..1d6737e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,8 +23,10 @@ packages/keeper-bots/src/subgraph-client/.graphclient # Build dist +out # VS Code *.code-workspace -.DS_Store \ No newline at end of file +.DS_Store +package-lock.json \ No newline at end of file diff --git a/packages/venus-labs-actions/.env.example b/packages/venus-labs-actions/.env.example new file mode 100644 index 0000000..58950b2 --- /dev/null +++ b/packages/venus-labs-actions/.env.example @@ -0,0 +1,3 @@ +KEEPER_PRIVATE_KEY="your_private_key_here" +BSC_TESTNET_RPC_URL="https://data-seed-prebsc-1-s1.binance.org:8545/" +BSC_MAINNET_RPC_URL="https://bsc-dataseed.binance.org/" diff --git a/packages/venus-labs-actions/README.md b/packages/venus-labs-actions/README.md new file mode 100644 index 0000000..ba8dd33 --- /dev/null +++ b/packages/venus-labs-actions/README.md @@ -0,0 +1,130 @@ +# Venus Labs Actions — DeviationSentinel Keeper + +Tenderly Web3 Action that monitors and responds to price deviations between the ResilientOracle and SentinelOracle on BSC Testnet. Runs every 5 minutes, detecting price gaps and triggering protective market actions (pausing borrows or supply) when thresholds are exceeded. + +## Architecture + +``` +Tenderly Scheduler (every 5 min — minimum supported interval) + → monitorAndHandle() + → Initialize wallet & RPC provider + → Verify keeper is whitelisted on DeviationSentinel + → Fetch all markets from Comptroller + → Filter to markets with monitoring enabled + → For each market (max 50 per run): + → checkPriceDeviation() on DeviationSentinel + → If deviation detected → handleDeviation() (pause borrow or supply) + → If no deviation & market paused → handleDeviation() (unpause) + → Log summary +``` + +> **Note:** The 5-minute interval is the minimum supported by Tenderly's periodic trigger. Valid intervals are: `5m | 10m | 15m | 30m | 1h | 3h | 6h | 12h | 1d`. For finer granularity, replace `interval` with a `cron` expression in `tenderly.yaml`: +> ```yaml +> periodic: +> cron: "*/2 * * * *" # every 2 minutes +> ``` +> See the [Tenderly Web3 Actions trigger reference](https://docs.tenderly.co/web3-actions/references/action-functions-events-and-triggers) for details. + +## Project Structure + +``` +venus-labs-actions/ +├── tenderly.yaml # Tenderly deployment config +├── scripts/ +│ └── setDeviationPrices.ts # Test utility: simulate price deviations +└── src/actions/ + ├── index.ts # Entry point (monitorAndHandle) + ├── config.ts # Network & action configuration + ├── contracts.ts # Contract ABIs & factories + ├── types.ts # TypeScript interfaces + ├── handlers/ + │ └── deviationHandler.ts # Core deviation detection & handling + └── utils/ + ├── gateway.ts # Wallet & provider setup + ├── logger.ts # Logging utility + └── marketHelper.ts # Market filtering & keeper verification +``` + +## Contracts (BSC Testnet) + +| Contract | Address | +| ------------------ | -------------------------------------------- | +| DeviationSentinel | `0x9245d72712548707809D66848e63B8E2B169F3c1` | +| Comptroller | `0x94d1820b2D1c7c7452A163983Dc888CEC546b77D` | +| SentinelOracle | `0xa4f2B03919BAAdCA80C31406412C7Ee059A579D3` | + +## Setup + +### 1. Install dependencies + +```bash +cd src/actions && npm install +``` + +### 2. Configure secrets + +**Local development:** Create a `.env` file (see `.env.example`): + +``` +KEEPER_PRIVATE_KEY= +BSC_TESTNET_RPC_URL=https://data-seed-prebsc-1-s1.binance.org:8545/ +BSC_MAINNET_RPC_URL=https://bsc-dataseed.binance.org/ +``` + +**Production (Tenderly):** Add `KEEPER_PRIVATE_KEY` and the relevant `BSC_*_RPC_URL` in Dashboard → Actions → Secrets. + +### 3. Whitelist the keeper + +The keeper wallet must be whitelisted on the DeviationSentinel contract by calling `setTrustedKeeper(keeperAddress)`. + +### 4. Build + +```bash +cd src/actions && npm run build +``` + +### 5. Deploy to Tenderly + +If the action code (`src/actions/index.ts`) is changed, you must run: + +```bash +tenderly actions deploy +``` + +This generates an `out/` directory (which is git-ignored and should not be committed). + +Follow the [Tenderly Web3 Actions docs](https://docs.tenderly.co/web3-actions/intro-to-web3-actions) for more details on the deployment process. + +## Testing with setDeviationPrices + +Simulate price deviations to trigger the keeper bot. The script reads configuration from the `.env` file. + +```bash +# BSC Testnet (default) +npx ts-node scripts/setDeviationPrices.ts + +# BSC Testnet (explicit) +npx ts-node scripts/setDeviationPrices.ts testnet + +# BSC Mainnet +npx ts-node scripts/setDeviationPrices.ts mainnet +``` + +This sets artificially low prices on the SentinelOracle: + +| Asset | Price Set | Expected ResilientOracle | Deviation | +| ----- | --------- | ------------------------ | --------- | +| ETH | $2,550 | ~$3,000 | ~15% | +| WBNB | $552 | ~$600 | ~8% | + +## Configuration + +Key constants in `config.ts`: + +| Parameter | Value | Description | +| -------------------------- | --------- | ------------------------------------ | +| `MAX_MARKETS_PER_RUN` | 50 | Max markets checked per execution | +| `GAS_LIMIT_BUFFER_PERCENT` | 20 | Buffer added to gas estimation | +| `GAS_PRICE_BUMP_PERCENT` | 10 | Gas price increase for reliability | +| `TX_TIMEOUT_MS` | 180,000 | Transaction confirmation timeout | +| `DEFAULT_GAS_LIMIT` | 5,000,000 | Fallback when estimation fails | diff --git a/packages/venus-labs-actions/scripts/setDeviationPrices.ts b/packages/venus-labs-actions/scripts/setDeviationPrices.ts new file mode 100644 index 0000000..a1c3774 --- /dev/null +++ b/packages/venus-labs-actions/scripts/setDeviationPrices.ts @@ -0,0 +1,473 @@ +/** + * End-to-end test for the DeviationSentinel keeper. + * + * Pass 1 — sentinel < oracle (cfModifiedAndSupplyPaused path): + * 0. Display on-chain deviation config & verify keeper is whitelisted + * 1. Snapshot current oracle & sentinel prices, CF/LT, and market pause state for each market + * 2. Set deviated prices on SentinelOracle (50% below oracle) + * 3. Verify deviation is detected + * 4. Poll until Tenderly action auto-pauses markets (cfModifiedAndSupplyPaused) + * 5. Verify paused state — cfModifiedAndSupplyPaused=true, borrowPaused=false, CF zeroed + * 6. Restore original prices on SentinelOracle + * 7. Verify no deviation + * 8. Poll until Tenderly action auto-unpauses markets + * 9. Verify unpaused & match CF/LT with step 1 snapshot + * + * Pass 2 — sentinel > oracle (borrowPaused path): + * 10. Re-snapshot current state (should be clean after pass 1) + * 11. Set inflated prices on SentinelOracle (200% above oracle) + * 12. Verify deviation is detected + * 13. Poll until Tenderly action auto-pauses markets (borrowPaused) + * 14. Verify paused state — borrowPaused=true, cfModifiedAndSupplyPaused=false + * 15. Restore original prices on SentinelOracle + * 16. Verify no deviation + * 17. Poll until Tenderly action auto-unpauses markets + * 18. Final state — verify unpaused & match CF/LT with step 10 snapshot + * + * Usage: + * npx ts-node scripts/setDeviationPrices.ts [testnet|mainnet] + * + * Environment variables (in .env): + * KEEPER_PRIVATE_KEY - Keeper wallet private key + * BSC_TESTNET_RPC_URL - BSC Testnet RPC endpoint + * BSC_MAINNET_RPC_URL - BSC Mainnet RPC endpoint + */ + +import { config } from "dotenv"; +import { resolve } from "path"; +import { JsonRpcProvider, Wallet, Contract, formatUnits } from "../src/actions/node_modules/ethers"; + +config({ path: resolve(__dirname, "../.env") }); + +// ── Network configuration ─────────────────────────────────────────── +type NetworkName = "testnet" | "mainnet"; + +interface NetworkConfig { + name: string; + rpcEnvVar: string; + addresses: { + sentinelOracle: string; + deviationSentinel: string; + comptroller: string; + }; + markets: { name: string; vToken: string; underlying: string }[]; +} + +// Admin functions on DeviationSentinel (callable by contract owner only): +// - setTokenConfig(address token, uint8 deviation, bool enabled): set deviation threshold per token +// - setTrustedKeeper(address keeper, bool trusted): whitelist/revoke keeper addresses +// These settings live on the DeviationSentinel contract at the address specified below. + +const NETWORK_CONFIGS: Record = { + testnet: { + name: "BSC Testnet", + rpcEnvVar: "BSC_TESTNET_RPC_URL", + // DeviationSentinel contract owner manages setTokenConfig / setTrustedKeeper + addresses: { + sentinelOracle: "0xa4f2B03919BAAdCA80C31406412C7Ee059A579D3", + deviationSentinel: "0x9245d72712548707809D66848e63B8E2B169F3c1", + comptroller: "0x94d1820b2D1c7c7452A163983Dc888CEC546b77D", + }, + markets: [ + { name: "vETH_Core", vToken: "0x162D005F0Fff510E54958Cfc5CF32A3180A84aab", underlying: "0x98f7A83361F7Ac8765CcEBAB1425da6b341958a7" }, + { name: "vWBNB_Core", vToken: "0xd9E77847ec815E56ae2B9E69596C69b6972b0B1C", underlying: "0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd" }, + ], + }, + mainnet: { + name: "BSC Mainnet", + rpcEnvVar: "BSC_MAINNET_RPC_URL", + addresses: { + // DeviationSentinel contract owner manages setTokenConfig / setTrustedKeeper + sentinelOracle: "0x0000000000000000000000000000000000000000", // TODO: set mainnet address + deviationSentinel: "0x0000000000000000000000000000000000000000", // TODO: set mainnet address + comptroller: "0x0000000000000000000000000000000000000000", // TODO: set mainnet address + }, + markets: [ + // TODO: add mainnet markets + ], + }, +}; + +function getNetworkConfig(): NetworkConfig { + const networkArg = process.argv[2]?.toLowerCase() as NetworkName | undefined; + const network: NetworkName = networkArg && networkArg in NETWORK_CONFIGS ? networkArg : "testnet"; + return NETWORK_CONFIGS[network]; +} + +const networkConfig = getNetworkConfig(); + +// ── ABIs ──────────────────────────────────────────────────────────── +const ABI = { + sentinelOracle: [ + "function setDirectPrice(address asset, uint256 price) external", + ], + deviationSentinel: [ + "function checkPriceDeviation(address market) external view returns (bool hasDeviation, uint256 oraclePrice, uint256 sentinelPrice, uint256 deviationPercent)", + "function handleDeviation(address market) external", + "function marketStates(address market) external view returns (bool borrowPaused, bool cfModifiedAndSupplyPaused)", + "function tokenConfigs(address token) external view returns (uint8 deviation, bool enabled)", + "function trustedKeepers(address keeper) external view returns (bool)", + ], + comptroller: [ + "function markets(address vToken) external view returns (bool isListed, uint256 collateralFactorMantissa, bool isComped, uint256 liquidationThresholdMantissa)", + ], +} as const; + +const MARKETS = networkConfig.markets; +const ADDRESSES = networkConfig.addresses; + +// ── Timing ────────────────────────────────────────────────────────── +const STEP_DELAY_MS = 5_000; +const POLL_TIMEOUT_MS = 7 * 60_000; + +// ── Helpers ───────────────────────────────────────────────────────── + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +function log(msg: string) { + console.log(`[${new Date().toISOString()}] ${msg}`); +} + +function section(step: number, title: string) { + const banner = "=".repeat(60); + console.log(`\n${banner}\n STEP ${step}: ${title}\n${banner}\n`); +} + +function fmtPrice(val: bigint): string { + return `$${formatUnits(val, 18)}`; +} + +function fmtPercent(val: bigint): string { + return formatUnits(val, 18); +} + +interface MarketSnapshot { + oraclePrice: bigint; + cf: bigint; + lt: bigint; +} + +// ── Core functions ────────────────────────────────────────────────── + +async function snapshotMarkets( + sentinel: Contract, + comptroller: Contract, +): Promise> { + const snapshots = new Map(); + for (const m of MARKETS) { + const [, oraclePrice, sentinelPrice] = await sentinel.checkPriceDeviation(m.vToken); + const [, cf, , lt] = await comptroller.markets(m.vToken); + const [borrowPaused, cfModifiedAndSupplyPaused] = await sentinel.marketStates(m.vToken); + snapshots.set(m.vToken, { oraclePrice, cf, lt }); + log(`--- ${m.name} ---`); + log(` Prices: oracle=${fmtPrice(oraclePrice)} sentinel=${fmtPrice(sentinelPrice)}`); + log(` Params: CF=${fmtPercent(cf)} LT=${fmtPercent(lt)}`); + log(` Sentinel MarketStates: borrowPaused=${borrowPaused} cfModifiedAndSupplyPaused=${cfModifiedAndSupplyPaused}`); + } + return snapshots; +} + +async function setDeviatedPrices( + oracleContract: Contract, + snapshots: Map, +) { + for (const m of MARKETS) { + const original = snapshots.get(m.vToken)!.oraclePrice; + const deviated = original / 2n; + log(`${m.name}: setting sentinel price to ${fmtPrice(deviated)} (50% of ${fmtPrice(original)})`); + const tx = await oracleContract.setDirectPrice(m.underlying, deviated); + const receipt = await tx.wait(1); + if (!receipt) throw new Error(`TX ${tx.hash} was dropped for ${m.name}`); + log(` TX ${tx.hash} confirmed in block ${receipt.blockNumber}`); + } +} + +async function setInflatedPrices( + oracleContract: Contract, + snapshots: Map, +) { + for (const m of MARKETS) { + const original = snapshots.get(m.vToken)!.oraclePrice; + const inflated = original * 2n; + log(`${m.name}: setting sentinel price to ${fmtPrice(inflated)} (200% of ${fmtPrice(original)})`); + const tx = await oracleContract.setDirectPrice(m.underlying, inflated); + const receipt = await tx.wait(1); + if (!receipt) throw new Error(`TX ${tx.hash} was dropped for ${m.name}`); + log(` TX ${tx.hash} confirmed in block ${receipt.blockNumber}`); + } +} + +async function restoreOriginalPrices( + oracleContract: Contract, + snapshots: Map, +) { + for (const m of MARKETS) { + const original = snapshots.get(m.vToken)!.oraclePrice; + log(`${m.name}: restoring sentinel price to ${fmtPrice(original)}`); + const tx = await oracleContract.setDirectPrice(m.underlying, original); + const receipt = await tx.wait(1); + if (!receipt) throw new Error(`TX ${tx.hash} was dropped for ${m.name}`); + log(` TX ${tx.hash} confirmed in block ${receipt.blockNumber}`); + } +} + +async function getMarketState(sentinel: Contract, comptroller: Contract, m: typeof MARKETS[number]) { + const [hasDeviation, oraclePrice, sentinelPrice, deviationPct] = + await sentinel.checkPriceDeviation(m.vToken); + const [borrowPaused, cfModifiedAndSupplyPaused] = await sentinel.marketStates(m.vToken); + const [, cf, , lt] = await comptroller.markets(m.vToken); + return { hasDeviation, oraclePrice, sentinelPrice, deviationPct, borrowPaused, cfModifiedAndSupplyPaused, cf, lt }; +} + +async function printMarketState(sentinel: Contract, comptroller: Contract) { + for (const m of MARKETS) { + const s = await getMarketState(sentinel, comptroller, m); + log(`--- ${m.name} ---`); + log(` Prices: oracle=${fmtPrice(s.oraclePrice)} sentinel=${fmtPrice(s.sentinelPrice)}`); + log(` Params: CF=${fmtPercent(s.cf)} LT=${fmtPercent(s.lt)}`); + log(` Sentinel MarketStates: borrowPaused=${s.borrowPaused} cfModifiedAndSupplyPaused=${s.cfModifiedAndSupplyPaused}`); + log(` Deviation: ${s.deviationPct}% | detected=${s.hasDeviation}`); + } +} + +type PauseType = "borrow" | "cfSupply" | "any"; + +async function waitForMarketState(sentinel: Contract, expectPaused: boolean, pauseType: PauseType = "any") { + const label = expectPaused ? "paused" : "unpaused"; + const start = Date.now(); + const MIN_INTERVAL_MS = 10_000; + const MAX_INTERVAL_MS = 60_000; + let interval = MIN_INTERVAL_MS; + + for (;;) { + const results = await Promise.all( + MARKETS.map(async (m) => { + const [borrowPaused, cfSupplyPaused] = await sentinel.marketStates(m.vToken); + let flag: boolean; + if (pauseType === "borrow") flag = borrowPaused; + else if (pauseType === "cfSupply") flag = cfSupplyPaused; + else flag = borrowPaused || cfSupplyPaused; + const ready = flag === expectPaused; + return { name: m.name, ready }; + }), + ); + + const pending = results.filter((r) => !r.ready); + if (pending.length === 0) { + log(`All markets are now ${label} (pauseType=${pauseType})`); + return; + } + + const elapsed = Math.round((Date.now() - start) / 1000); + if (elapsed * 1000 >= POLL_TIMEOUT_MS) { + const names = pending.map((r) => r.name).join(", "); + throw new Error(`Timeout after ${elapsed}s — still waiting on: ${names}`); + } + + const names = pending.map((r) => r.name).join(", "); + log(`Polling (${elapsed}s) — waiting for ${label}: ${names} [next check in ${interval / 1000}s]`); + await sleep(interval); + interval = Math.min(interval * 1.5, MAX_INTERVAL_MS); + } +} + +function verifyCFLT( + comptrollerData: { cf: bigint; lt: bigint }, + initial: MarketSnapshot, + name: string, +): boolean { + const cfOk = comptrollerData.cf === initial.cf; + const ltOk = comptrollerData.lt === initial.lt; + log(`${name}:`); + log(` CF: ${fmtPercent(initial.cf)} -> ${fmtPercent(comptrollerData.cf)} ${cfOk ? "MATCH" : "MISMATCH"}`); + log(` LT: ${fmtPercent(initial.lt)} -> ${fmtPercent(comptrollerData.lt)} ${ltOk ? "MATCH" : "MISMATCH"}`); + return cfOk && ltOk; +} + +// ── Step 0: Display deviation config ───────────────────────────────── +// Reads on-chain tokenConfigs for each market's underlying asset and +// verifies the keeper wallet is whitelisted via trustedKeepers. +// Thresholds are set on-chain via setTokenConfig(address token, uint8 deviation, bool enabled) +// by the DeviationSentinel contract owner/admin. + +async function displayDeviationConfig(sentinel: Contract, keeperAddress: string) { + section(0, "On-chain deviation config & keeper verification"); + + const isTrusted: boolean = await sentinel.trustedKeepers(keeperAddress); + log(`Keeper ${keeperAddress} whitelisted: ${isTrusted}`); + if (!isTrusted) { + throw new Error("Keeper wallet is NOT a trusted keeper on DeviationSentinel — aborting"); + } + + for (const m of MARKETS) { + const [deviation, enabled]: [number, boolean] = await sentinel.tokenConfigs(m.underlying); + log(`${m.name} (underlying ${m.underlying}): deviation=${deviation}%, enabled=${enabled}`); + } +} + +// ── Main ──────────────────────────────────────────────────────────── + +async function main() { + const privateKey = process.env.KEEPER_PRIVATE_KEY; + if (!privateKey) throw new Error("Set KEEPER_PRIVATE_KEY in .env"); + + const rpcUrl = process.env[networkConfig.rpcEnvVar]; + if (!rpcUrl) throw new Error(`Set ${networkConfig.rpcEnvVar} in .env`); + + if (MARKETS.length === 0) throw new Error(`No markets configured for ${networkConfig.name}`); + + const provider = new JsonRpcProvider(rpcUrl); + const wallet = new Wallet(privateKey, provider); + const oracleContract = new Contract(ADDRESSES.sentinelOracle, ABI.sentinelOracle, wallet); + const sentinel = new Contract(ADDRESSES.deviationSentinel, ABI.deviationSentinel, provider); + const comptroller = new Contract(ADDRESSES.comptroller, ABI.comptroller, provider); + + log(`Network: ${networkConfig.name}`); + log(`Keeper wallet: ${wallet.address}`); + + // Step 0 — Display deviation config & verify keeper + await displayDeviationConfig(sentinel, wallet.address); + await sleep(STEP_DELAY_MS); + + // Step 1 — Snapshot + section(1, "Snapshot current oracle & sentinel prices, CF/LT, and market pause state"); + const snapshots = await snapshotMarkets(sentinel, comptroller); + await sleep(STEP_DELAY_MS); + + try { + // Step 2 — Set deviated prices + section(2, "Set deviated prices on SentinelOracle (50% below)"); + await setDeviatedPrices(oracleContract, snapshots); + await sleep(STEP_DELAY_MS); + + // Step 3 — Verify deviation detected + section(3, "Verify deviation is detected"); + await printMarketState(sentinel, comptroller); + await sleep(STEP_DELAY_MS); + + // Step 4 — Wait for auto-pause (cfModifiedAndSupplyPaused) + section(4, "Poll until Tenderly action auto-pauses markets (cfModifiedAndSupplyPaused)"); + log("Tenderly runs every 5 min — polling until markets are paused..."); + await waitForMarketState(sentinel, true, "cfSupply"); + + // Step 5 — Confirm paused: cfModifiedAndSupplyPaused=true, borrowPaused=false + section(5, "Verify markets are PAUSED (cfModifiedAndSupplyPaused=true, borrowPaused=false)"); + await printMarketState(sentinel, comptroller); + for (const m of MARKETS) { + const s = await getMarketState(sentinel, comptroller, m); + if (!s.cfModifiedAndSupplyPaused) log(` WARNING: ${m.name} cfModifiedAndSupplyPaused is false (expected true)`); + if (s.borrowPaused) log(` WARNING: ${m.name} borrowPaused is true (expected false)`); + } + await sleep(STEP_DELAY_MS); + } finally { + // Step 6 — Restore prices (always runs, even if earlier steps fail) + section(6, "Restore SentinelOracle prices to match ResilientOracle"); + await restoreOriginalPrices(oracleContract, snapshots); + await sleep(STEP_DELAY_MS); + } + + // Step 7 — Verify no deviation + section(7, "Verify no deviation after price restoration"); + await printMarketState(sentinel, comptroller); + await sleep(STEP_DELAY_MS); + + // Step 8 — Wait for auto-unpause + section(8, "Poll until Tenderly action auto-unpauses markets"); + log("Tenderly runs every 5 min — polling until markets are unpaused..."); + await waitForMarketState(sentinel, false); + + // Step 9 — Verify unpaused & match CF/LT with step 1 + section(9, "Verify UNPAUSED & match CF/LT with step 1"); + await printMarketState(sentinel, comptroller); + + let pass1Restored = true; + for (const m of MARKETS) { + const [, cf, , lt] = await comptroller.markets(m.vToken); + const ok = verifyCFLT({ cf, lt }, snapshots.get(m.vToken)!, m.name); + if (!ok) pass1Restored = false; + } + + if (!pass1Restored) { + const banner = "=".repeat(60); + console.log(`\n${banner}`); + log("PASS 1 FAILED — some CF/LT values do NOT match step 1 snapshot"); + console.log(`${banner}\n`); + process.exit(1); + } + log("Pass 1 (sentinel < oracle) PASSED"); + + // ── Pass 2: sentinel > oracle (borrowPaused path) ────────────────── + + // Step 10 — Re-snapshot + section(10, "Re-snapshot current state for pass 2"); + const snapshots2 = await snapshotMarkets(sentinel, comptroller); + await sleep(STEP_DELAY_MS); + + try { + // Step 11 — Set inflated prices + section(11, "Set inflated prices on SentinelOracle (200% of oracle)"); + await setInflatedPrices(oracleContract, snapshots2); + await sleep(STEP_DELAY_MS); + + // Step 12 — Verify deviation detected + section(12, "Verify deviation is detected (sentinel > oracle)"); + await printMarketState(sentinel, comptroller); + await sleep(STEP_DELAY_MS); + + // Step 13 — Wait for auto-pause (borrowPaused) + section(13, "Poll until Tenderly action auto-pauses markets (borrowPaused)"); + log("Tenderly runs every 5 min — polling until markets are paused..."); + await waitForMarketState(sentinel, true, "borrow"); + + // Step 14 — Confirm paused: borrowPaused=true, cfModifiedAndSupplyPaused=false + section(14, "Verify markets are PAUSED (borrowPaused=true, cfModifiedAndSupplyPaused=false)"); + await printMarketState(sentinel, comptroller); + for (const m of MARKETS) { + const s = await getMarketState(sentinel, comptroller, m); + if (!s.borrowPaused) log(` WARNING: ${m.name} borrowPaused is false (expected true)`); + if (s.cfModifiedAndSupplyPaused) log(` WARNING: ${m.name} cfModifiedAndSupplyPaused is true (expected false)`); + } + await sleep(STEP_DELAY_MS); + } finally { + // Step 15 — Restore prices (always runs) + section(15, "Restore SentinelOracle prices to match ResilientOracle"); + await restoreOriginalPrices(oracleContract, snapshots2); + await sleep(STEP_DELAY_MS); + } + + // Step 16 — Verify no deviation + section(16, "Verify no deviation after price restoration"); + await printMarketState(sentinel, comptroller); + await sleep(STEP_DELAY_MS); + + // Step 17 — Wait for auto-unpause + section(17, "Poll until Tenderly action auto-unpauses markets"); + log("Tenderly runs every 5 min — polling until markets are unpaused..."); + await waitForMarketState(sentinel, false); + + // Step 18 — Final state: verify unpaused & match CF/LT with step 10 + section(18, "Final state — verify UNPAUSED & match CF/LT with step 10"); + await printMarketState(sentinel, comptroller); + + let allRestored = true; + for (const m of MARKETS) { + const [, cf, , lt] = await comptroller.markets(m.vToken); + const ok = verifyCFLT({ cf, lt }, snapshots2.get(m.vToken)!, m.name); + if (!ok) allRestored = false; + } + + const banner = "=".repeat(60); + console.log(`\n${banner}`); + if (allRestored) { + log("TEST PASSED — both passes complete, all markets unpaused, CF & LT restored"); + } else { + log("PASS 2 FAILED — some CF/LT values do NOT match step 10 snapshot"); + process.exit(1); + } + console.log(`${banner}\n`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/venus-labs-actions/src/actions/config.ts b/packages/venus-labs-actions/src/actions/config.ts new file mode 100644 index 0000000..5571680 --- /dev/null +++ b/packages/venus-labs-actions/src/actions/config.ts @@ -0,0 +1,20 @@ +// BSC Testnet Configuration +export const BSC_TESTNET_CONFIG = { + chainId: "97", + name: "BSC Testnet", + // Contract addresses - can be overridden via Tenderly secrets if needed + deviationSentinelAddress: "0x9245d72712548707809D66848e63B8E2B169F3c1", + comptrollerAddress: "0x94d1820b2D1c7c7452A163983Dc888CEC546b77D", +}; + +// Action configuration +export const ACTION_CONFIG = { + // Maximum number of markets to check per execution (prevent timeout) + MAX_MARKETS_PER_RUN: 50, + // Gas settings + GAS_LIMIT_BUFFER_PERCENT: 20, + GAS_PRICE_BUMP_PERCENT: 10, + DEFAULT_GAS_LIMIT: "5000000", + // Transaction timeout (ms) + TX_TIMEOUT_MS: 180000, // 3 minutes +}; diff --git a/packages/venus-labs-actions/src/actions/contracts.ts b/packages/venus-labs-actions/src/actions/contracts.ts new file mode 100644 index 0000000..bf686fe --- /dev/null +++ b/packages/venus-labs-actions/src/actions/contracts.ts @@ -0,0 +1,42 @@ +import { Contract, Signer, Provider } from "ethers"; + +// ABI fragments for contract interactions +export const DEVIATION_SENTINEL_ABI = [ + "function checkPriceDeviation(address market) external view returns (bool hasDeviation, uint256 oraclePrice, uint256 sentinelPrice, uint256 deviationPercent)", + "function handleDeviation(address market) external", + "function tokenConfigs(address token) external view returns (uint8 deviation, bool enabled)", + "function trustedKeepers(address keeper) external view returns (bool)", + "function marketStates(address market) external view returns (bool borrowPaused, bool cfModifiedAndSupplyPaused)", +]; + +export const VTOKEN_ABI = [ + "function underlying() external view returns (address)", + "function comptroller() external view returns (address)", + "function symbol() external view returns (string)", +]; + +export const COMPTROLLER_ABI = [ + "function getAllMarkets() external view returns (address[] memory)", +]; + +// Contract factory functions +export function getDeviationSentinelContract( + address: string, + signerOrProvider: Signer | Provider +): Contract { + return new Contract(address, DEVIATION_SENTINEL_ABI, signerOrProvider); +} + +export function getVTokenContract( + address: string, + provider: Provider +): Contract { + return new Contract(address, VTOKEN_ABI, provider); +} + +export function getComptrollerContract( + address: string, + provider: Provider +): Contract { + return new Contract(address, COMPTROLLER_ABI, provider); +} diff --git a/packages/venus-labs-actions/src/actions/handlers/deviationHandler.ts b/packages/venus-labs-actions/src/actions/handlers/deviationHandler.ts new file mode 100644 index 0000000..cb64925 --- /dev/null +++ b/packages/venus-labs-actions/src/actions/handlers/deviationHandler.ts @@ -0,0 +1,145 @@ +import { Contract, Wallet, Provider, formatUnits, ContractTransactionResponse, TransactionReceipt } from "ethers"; +import { ACTION_CONFIG } from "../config"; +import { Market, MonitoringResults } from "../types"; +import { logger } from "../utils/logger"; + +export async function checkAndHandleMarket( + market: Market, + deviationSentinel: Contract, + keeper: Wallet, + provider: Provider, + results: MonitoringResults +): Promise { + logger.marketCheck(market.symbol || market.vTokenAddress); + + try { + const deviationResult = await deviationSentinel.checkPriceDeviation(market.vTokenAddress); + + const hasDeviation = deviationResult.hasDeviation; + const oraclePrice = formatUnits(deviationResult.oraclePrice, 18); + const sentinelPrice = formatUnits(deviationResult.sentinelPrice, 18); + const deviationPercent = deviationResult.deviationPercent.toString(); + + logger.indent(`Oracle: $${oraclePrice} | Sentinel: $${sentinelPrice} | Deviation: ${deviationPercent}%`); + + const marketState = await deviationSentinel.marketStates(market.vTokenAddress); + + if (!hasDeviation) { + // No deviation — unpause if market was previously paused by DeviationSentinel + if (marketState.borrowPaused || marketState.cfModifiedAndSupplyPaused) { + logger.info("No deviation — triggering unpause for previously paused market"); + await executeHandleDeviation(market, deviationSentinel, keeper, provider, results); + results.unpaused++; + } else { + logger.indent("No deviation"); + } + results.checked++; + return; + } + + logger.warning(`DEVIATION DETECTED! ${deviationPercent}%`); + results.deviationsFound++; + + // Skip if market is already paused in the relevant direction to avoid wasting gas + const sentinelPriceBN = deviationResult.sentinelPrice; + const oraclePriceBN = deviationResult.oraclePrice; + const sentinelHigher = sentinelPriceBN > oraclePriceBN; + + if (sentinelHigher && marketState.borrowPaused) { + logger.indent("Borrow already paused — skipping"); + results.checked++; + return; + } + if (!sentinelHigher && marketState.cfModifiedAndSupplyPaused) { + logger.indent("Supply already paused & CF zeroed — skipping"); + results.checked++; + return; + } + + await executeHandleDeviation(market, deviationSentinel, keeper, provider, results); + } catch (error: any) { + logger.error(`Failed to check market ${market.symbol}`, error); + results.errors.push(`${market.symbol}: ${error?.message || "Unknown error"}`); + } + + results.checked++; +} + +async function executeHandleDeviation( + market: Market, + deviationSentinel: Contract, + keeper: Wallet, + provider: Provider, + results: MonitoringResults +): Promise { + try { + const deviationSentinelWithSigner = deviationSentinel.connect(keeper) as Contract; + + let gasLimit: bigint; + try { + const estimate = await deviationSentinelWithSigner.handleDeviation.estimateGas( + market.vTokenAddress + ); + gasLimit = estimate * BigInt(100 + ACTION_CONFIG.GAS_LIMIT_BUFFER_PERCENT) / 100n; + } catch (estimateError: any) { + if (estimateError?.message?.includes("execution reverted")) { + logger.indent("Market already handled"); + return; + } + gasLimit = BigInt(ACTION_CONFIG.DEFAULT_GAS_LIMIT); + } + + const feeData = await provider.getFeeData(); + const gasPrice = feeData.gasPrice!; + const adjustedGasPrice = gasPrice * BigInt(100 + ACTION_CONFIG.GAS_PRICE_BUMP_PERCENT) / 100n; + + logger.indent("Sending handleDeviation transaction..."); + const tx: ContractTransactionResponse = await deviationSentinelWithSigner.handleDeviation(market.vTokenAddress, { + gasLimit, + gasPrice: adjustedGasPrice, + }); + + logger.indent(`TX: ${tx.hash}`); + + const receipt = await Promise.race([ + tx.wait(1), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Transaction timeout")), ACTION_CONFIG.TX_TIMEOUT_MS) + ), + ]); + + if (receipt!.status === 0) { + throw new Error("Transaction reverted"); + } + + logger.success(`Handled in block ${receipt!.blockNumber} (Gas: ${receipt!.gasUsed.toString()})`); + results.actionsTriggered++; + + logTransactionEvents(receipt!, deviationSentinel); + } catch (error: any) { + if (error?.message?.includes("already") || error?.message?.includes("paused")) { + logger.indent("Market already handled"); + return; + } + throw error; + } +} + +function logTransactionEvents(receipt: TransactionReceipt, deviationSentinel: Contract): void { + if (!receipt.logs || receipt.logs.length === 0) return; + + const eventNames: string[] = []; + + for (const log of receipt.logs) { + try { + const parsed = deviationSentinel.interface.parseLog(log); + if (parsed) eventNames.push(parsed.name); + } catch { + // Not a DeviationSentinel event, skip + } + } + + if (eventNames.length > 0) { + logger.indent(`Events: ${eventNames.join(", ")}`); + } +} diff --git a/packages/venus-labs-actions/src/actions/index.ts b/packages/venus-labs-actions/src/actions/index.ts new file mode 100644 index 0000000..13b38fe --- /dev/null +++ b/packages/venus-labs-actions/src/actions/index.ts @@ -0,0 +1,97 @@ +import { ActionFn, Context, Event } from "@tenderly/actions"; +import { ACTION_CONFIG, BSC_TESTNET_CONFIG } from "./config"; +import { getDeviationSentinelContract } from "./contracts"; +import { checkAndHandleMarket } from "./handlers/deviationHandler"; +import { MonitoringResults } from "./types"; +import { initializeGateway } from "./utils/gateway"; +import { logger } from "./utils/logger"; +import { getMarketsWithMonitoringEnabled, verifyKeeperWhitelisted } from "./utils/marketHelper"; + +/** + * Main monitoring action - runs periodically to check all markets on BSC Testnet + * This is triggered automatically by Tenderly every 5 minutes + */ +export const monitorAndHandle: ActionFn = async (context: Context, event: Event) => { + try { + logger.section("DeviationSentinel Monitoring"); + logger.info(`Started at ${new Date().toISOString()}`); + logger.info(`Network: ${BSC_TESTNET_CONFIG.name} (Chain ID: ${BSC_TESTNET_CONFIG.chainId})`); + logger.info(`DeviationSentinel: ${BSC_TESTNET_CONFIG.deviationSentinelAddress}`); + + // Initialize gateway with Tenderly's provider for BSC Testnet + const gateway = await initializeGateway(context); + logger.info(`Keeper: ${gateway.keeper.address}`); + + // Get contract instance + const deviationSentinel = getDeviationSentinelContract( + BSC_TESTNET_CONFIG.deviationSentinelAddress, + gateway.provider + ); + + // Verify keeper is whitelisted + await verifyKeeperWhitelisted(deviationSentinel, gateway.keeper.address); + + // Get only markets with monitoring enabled + const marketsToCheck = await getMarketsWithMonitoringEnabled( + BSC_TESTNET_CONFIG.comptrollerAddress, + deviationSentinel, + gateway.provider + ); + + if (marketsToCheck.length === 0) { + logger.warning("No markets with monitoring enabled"); + return; + } + + // Limit markets per run to prevent timeout + const marketsInThisRun = marketsToCheck.slice(0, ACTION_CONFIG.MAX_MARKETS_PER_RUN); + if (marketsToCheck.length > ACTION_CONFIG.MAX_MARKETS_PER_RUN) { + logger.warning( + `Limiting to ${ACTION_CONFIG.MAX_MARKETS_PER_RUN} markets (${marketsToCheck.length} total)` + ); + } + + // Track results + const results: MonitoringResults = { + checked: 0, + deviationsFound: 0, + actionsTriggered: 0, + unpaused: 0, + skipped: 0, + errors: [], + }; + + // Check each market + for (const market of marketsInThisRun) { + try { + await checkAndHandleMarket( + market, + deviationSentinel, + gateway.keeper, + gateway.provider, + results + ); + } catch (error: any) { + logger.error(`Error checking ${market.symbol}`, error); + results.errors.push(`${market.symbol}: ${error?.message}`); + } + } + + // Log summary + logger.section("Summary"); + logger.info(`Markets checked: ${results.checked}`); + logger.info(`Deviations found: ${results.deviationsFound}`); + logger.info(`Actions triggered: ${results.actionsTriggered}`); + logger.info(`Markets unpaused: ${results.unpaused}`); + + if (results.errors.length > 0) { + logger.warning(`Errors: ${results.errors.length}`); + results.errors.forEach((err) => logger.indent(err)); + } + + logger.success("Monitoring complete"); + } catch (error: any) { + logger.error("Fatal error in monitoring", error); + throw error; + } +}; diff --git a/packages/venus-labs-actions/src/actions/package-lock.json b/packages/venus-labs-actions/src/actions/package-lock.json new file mode 100644 index 0000000..41e2ffc --- /dev/null +++ b/packages/venus-labs-actions/src/actions/package-lock.json @@ -0,0 +1,142 @@ +{ + "name": "deviation-sentinel-actions", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deviation-sentinel-actions", + "dependencies": { + "@tenderly/actions": "^0.2.0", + "ethers": "^6.16.0" + }, + "devDependencies": { + "typescript": "^4.3.5" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@tenderly/actions": { + "version": "0.2.211", + "resolved": "https://registry.npmjs.org/@tenderly/actions/-/actions-0.2.211.tgz", + "integrity": "sha512-PMn7ZlrNgEmK8n/9+J10OUO9tAQAwd8rHv4O2LJl9PPaxZOSzCUBoPKvfVicnMFHHFBlM9UEqQPjS4Wo3m78dw==" + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/packages/venus-labs-actions/src/actions/package.json b/packages/venus-labs-actions/src/actions/package.json new file mode 100755 index 0000000..729d7bb --- /dev/null +++ b/packages/venus-labs-actions/src/actions/package.json @@ -0,0 +1,14 @@ +{ + "name": "deviation-sentinel-actions", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^4.3.5" + }, + "dependencies": { + "@tenderly/actions": "^0.2.0", + "ethers": "^6.16.0" + }, + "private": true +} diff --git a/packages/venus-labs-actions/src/actions/tsconfig.json b/packages/venus-labs-actions/src/actions/tsconfig.json new file mode 100755 index 0000000..3dd096e --- /dev/null +++ b/packages/venus-labs-actions/src/actions/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "module": "commonjs", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "out", + "rootDir": "", + "sourceMap": true, + "strict": true, + "target": "es2020" + }, + "exclude": [ + "**/*.spec.ts" + ], + "include": [ + "**/*" + ] +} \ No newline at end of file diff --git a/packages/venus-labs-actions/src/actions/types.ts b/packages/venus-labs-actions/src/actions/types.ts new file mode 100644 index 0000000..5c5424f --- /dev/null +++ b/packages/venus-labs-actions/src/actions/types.ts @@ -0,0 +1,28 @@ +// Type definitions for DeviationSentinel monitoring + +export interface MonitoringResults { + checked: number; + deviationsFound: number; + actionsTriggered: number; + unpaused: number; + skipped: number; + errors: string[]; +} + +export interface TokenConfig { + deviation: number; + enabled: boolean; +} + +export interface DeviationCheckResult { + hasDeviation: boolean; + oraclePrice: string; + sentinelPrice: string; + deviationPercent: string; +} + +export interface Market { + vTokenAddress: string; + underlyingToken: string; + symbol?: string; +} diff --git a/packages/venus-labs-actions/src/actions/utils/gateway.ts b/packages/venus-labs-actions/src/actions/utils/gateway.ts new file mode 100644 index 0000000..04342dd --- /dev/null +++ b/packages/venus-labs-actions/src/actions/utils/gateway.ts @@ -0,0 +1,53 @@ +import { Context } from "@tenderly/actions"; +import { JsonRpcProvider, Wallet, Provider } from "ethers"; + +export interface Gateway { + provider: Provider; + keeper: Wallet; +} + +/** + * Initialize gateway for BSC Testnet + * Note: Tenderly's Node RPC doesn't support BSC Testnet yet, so we use public RPC + */ +export async function initializeGateway(context: Context): Promise { + // Get keeper private key from Tenderly secrets + let keeperPrivateKey: string; + try { + keeperPrivateKey = await context.secrets.get("KEEPER_PRIVATE_KEY"); + } catch (error) { + throw new Error( + "Failed to get KEEPER_PRIVATE_KEY from Tenderly secrets. " + + "Please add it in: Dashboard → Actions → Secrets → Add Secret. " + + "Name: KEEPER_PRIVATE_KEY, Value: " + ); + } + + if (!keeperPrivateKey || keeperPrivateKey.trim() === "") { + throw new Error( + "KEEPER_PRIVATE_KEY is empty. Please set it in Tenderly Dashboard → Actions → Secrets" + ); + } + + // BSC Testnet public RPC endpoints (can be overridden via secrets) + let rpcUrl = "https://data-seed-prebsc-1-s1.binance.org:8545/"; + try { + const customRpc = await context.secrets.get("BSC_TESTNET_RPC"); + if (customRpc && customRpc.trim() !== "") { + rpcUrl = customRpc; + } + } catch { + // BSC_TESTNET_RPC is optional, use default + } + + // Create provider for BSC Testnet + const provider = new JsonRpcProvider(rpcUrl); + + // Setup keeper wallet + const keeper = new Wallet(keeperPrivateKey, provider); + + return { + provider, + keeper, + }; +} diff --git a/packages/venus-labs-actions/src/actions/utils/logger.ts b/packages/venus-labs-actions/src/actions/utils/logger.ts new file mode 100644 index 0000000..d6cca6f --- /dev/null +++ b/packages/venus-labs-actions/src/actions/utils/logger.ts @@ -0,0 +1,49 @@ +/** + * Logger utility for Tenderly Web3 Actions + * All logs appear in Tenderly Dashboard → Actions → Executions → Logs + */ + +export class Logger { + private context: string; + + constructor(context: string = "") { + this.context = context; + } + + private formatMessage(message: string): string { + return this.context ? `[${this.context}] ${message}` : message; + } + + info(message: string): void { + console.log(this.formatMessage(message)); + } + + success(message: string): void { + console.log(`✓ ${this.formatMessage(message)}`); + } + + warning(message: string): void { + console.log(`⚠ ${this.formatMessage(message)}`); + } + + error(message: string, error?: any): void { + console.error(`✗ ${this.formatMessage(message)}`); + if (error?.message) { + console.error(` Error: ${error.message}`); + } + } + + section(title: string): void { + console.log(`\n=== ${title} ===`); + } + + marketCheck(market: string): void { + console.log(`\n--- ${market} ---`); + } + + indent(message: string): void { + console.log(` ${message}`); + } +} + +export const logger = new Logger(); diff --git a/packages/venus-labs-actions/src/actions/utils/marketHelper.ts b/packages/venus-labs-actions/src/actions/utils/marketHelper.ts new file mode 100644 index 0000000..af0c6dd --- /dev/null +++ b/packages/venus-labs-actions/src/actions/utils/marketHelper.ts @@ -0,0 +1,79 @@ +import { Contract, Provider } from "ethers"; +import { getComptrollerContract, getVTokenContract } from "../contracts"; +import { Market, TokenConfig } from "../types"; +import { logger } from "./logger"; + +/** + * Get all markets from comptroller and filter to only those with monitoring enabled + */ +export async function getMarketsWithMonitoringEnabled( + comptrollerAddress: string, + deviationSentinel: Contract, + provider: Provider +): Promise { + // Get all markets from comptroller + const comptroller = getComptrollerContract(comptrollerAddress, provider); + const allMarkets: string[] = await comptroller.getAllMarkets(); + + if (allMarkets.length === 0) { + logger.warning("No markets found in comptroller"); + return []; + } + + logger.info(`Found ${allMarkets.length} total markets`); + + // Filter to only markets with monitoring enabled + const enabledMarkets: Market[] = []; + + for (const marketAddress of allMarkets) { + try { + // Get underlying token + const vToken = getVTokenContract(marketAddress, provider); + const underlyingToken = await vToken.underlying(); + + // Check if monitoring is enabled for this token + const tokenConfig: TokenConfig = await deviationSentinel.tokenConfigs(underlyingToken); + + if (tokenConfig.deviation > 0 && tokenConfig.enabled) { + // Try to get symbol for logging + let symbol = "Unknown"; + try { + symbol = await vToken.symbol(); + } catch { + // Symbol not critical, continue without it + } + + enabledMarkets.push({ + vTokenAddress: marketAddress, + underlyingToken, + symbol, + }); + } + } catch (error: any) { + logger.warning(`Failed to check market ${marketAddress}: ${error?.message}`); + // Continue checking other markets + } + } + + logger.success(`${enabledMarkets.length} markets have monitoring enabled`); + + return enabledMarkets; +} + +/** + * Check if keeper is whitelisted in DeviationSentinel + */ +export async function verifyKeeperWhitelisted( + deviationSentinel: Contract, + keeperAddress: string +): Promise { + const isWhitelisted = await deviationSentinel.trustedKeepers(keeperAddress); + + if (!isWhitelisted) { + throw new Error( + `Keeper ${keeperAddress} is not whitelisted. Call setTrustedKeeper() first.` + ); + } + + logger.success("Keeper is whitelisted"); +} diff --git a/packages/venus-labs-actions/tenderly.yaml b/packages/venus-labs-actions/tenderly.yaml new file mode 100644 index 0000000..61eb4a8 --- /dev/null +++ b/packages/venus-labs-actions/tenderly.yaml @@ -0,0 +1,16 @@ +account_id: venus-labs +actions: + venus-labs/deviation-sentinel-monitoring: + runtime: v2 + sources: src/actions + specs: + # Main monitoring action - runs every 5 minutes on BSC Testnet + monitorAndHandle: + description: Monitor all markets with enabled deviation monitoring on BSC Testnet + function: index:monitorAndHandle + trigger: + type: periodic + periodic: + interval: 5m + execution_type: parallel +project_slug: deviation-sentinel-monitoring \ No newline at end of file