From e1c04d9d4a8a7ed0e836693162a100706efeb4f4 Mon Sep 17 00:00:00 2001 From: Nick Addison Date: Fri, 25 Oct 2024 10:40:34 +1100 Subject: [PATCH] Operational automation (#39) * Added minSellPrice and maxBuyPrice to setPrices hardhat task setPrice Hardhat task can now be done off Curve pool * Added setPrices Action * Fix smoke test --- .gitignore | 5 +- README.md | 6 ++ package.json | 3 +- src/js/actions/rollup.config.cjs | 14 ++- src/js/actions/setPrices.js | 42 +++++++++ src/js/tasks/lido.js | 78 +-------------- src/js/tasks/lidoPrices.js | 152 ++++++++++++++++++++++++++++++ src/js/tasks/markets.js | 2 +- src/js/tasks/tasks.js | 32 +++++-- src/js/utils/curve.js | 17 +++- test/smoke/LidoARMSmokeTest.t.sol | 2 +- 11 files changed, 262 insertions(+), 91 deletions(-) create mode 100644 src/js/actions/setPrices.js create mode 100644 src/js/tasks/lidoPrices.js diff --git a/.gitignore b/.gitignore index 8a07b5c..b9e9c58 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ lcov.info* # Defender Actions dist -build/deployments-fork*.json \ No newline at end of file +build/deployments-fork*.json + +# Reports. eg stats.html +*.html \ No newline at end of file diff --git a/README.md b/README.md index 4142234..6b7e264 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ npx hardhat setActionVars --id 563d8d0c-17dc-46d3-8955-e4824864869f npx hardhat setActionVars --id c010fb76-ea63-409d-9981-69322d27993a npx hardhat setActionVars --id 127171fd-7b85-497e-8335-fd7907c08386 npx hardhat setActionVars --id 84b5f134-8351-4402-8f6a-fb4376034bc4 +npx hardhat setActionVars --id ffcfc580-7b0a-42ed-a4f2-3f0a3add9779 # The Defender autotask client uses generic env var names so we'll set them first from the values in the .env file export API_KEY= @@ -250,6 +251,11 @@ npx defender-autotask update-code 563d8d0c-17dc-46d3-8955-e4824864869f ./dist/au npx defender-autotask update-code c010fb76-ea63-409d-9981-69322d27993a ./dist/autoRequestLidoWithdraw npx defender-autotask update-code 127171fd-7b85-497e-8335-fd7907c08386 ./dist/autoClaimLidoWithdraw npx defender-autotask update-code 84b5f134-8351-4402-8f6a-fb4376034bc4 ./dist/collectLidoFees +npx defender-autotask update-code ffcfc580-7b0a-42ed-a4f2-3f0a3add9779 ./dist/setPrices ``` `rollup` and `defender-autotask` can be installed globally to avoid the `npx` prefix. + +The Defender Actions need to be under 5MB in size. The [rollup-plugin-visualizer](https://www.npmjs.com/package/rollup-plugin-visualizer) can be used to visualize the size of an Action's dependencies. +A `stats.html` file is generated in the`src/js/actions` folder that can be opened in a browser to see the size of the Action's dependencies. +This will be for the last Action in the rollup config `src/js/actions/rollup.config.cjs`. diff --git a/package.json b/package.json index 1cad10a..081d523 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "ethers": "6.13.2", "graphql": "^16.9.0", "hardhat": "^2.22.9", - "rollup": "^4.9.1" + "rollup": "^4.9.1", + "rollup-plugin-visualizer": "^5.12.0" } } diff --git a/src/js/actions/rollup.config.cjs b/src/js/actions/rollup.config.cjs index 6274830..3359c4d 100644 --- a/src/js/actions/rollup.config.cjs +++ b/src/js/actions/rollup.config.cjs @@ -2,15 +2,19 @@ const resolve = require("@rollup/plugin-node-resolve"); const commonjs = require("@rollup/plugin-commonjs"); const json = require("@rollup/plugin-json"); const builtins = require("builtin-modules"); +const { visualizer } = require("rollup-plugin-visualizer"); const commonConfig = { plugins: [ resolve({ preferBuiltins: true }), commonjs(), json({ compact: true }), + // Generates a stats.html file in the actions folder. + // This is a visual of the Action dependencies for the last Action in the rollup config. + visualizer(), ], // Do not bundle these packages. - // ethers is required to be bundled even though it an Autotask package. + // ethers is required to be bundled as we need v6 and not v5 that is packaged with Defender Actions. external: [ ...builtins, "axios", @@ -68,4 +72,12 @@ module.exports = [ }, ...commonConfig, }, + { + input: "setPrices.js", + output: { + file: "dist/setPrices/index.js", + format: "cjs", + }, + ...commonConfig, + }, ]; diff --git a/src/js/actions/setPrices.js b/src/js/actions/setPrices.js new file mode 100644 index 0000000..75f05dc --- /dev/null +++ b/src/js/actions/setPrices.js @@ -0,0 +1,42 @@ +const { Defender } = require("@openzeppelin/defender-sdk"); +const { ethers } = require("ethers"); + +const { setPrices } = require("../tasks/lidoPrices"); +const { mainnet } = require("../utils/addresses"); +const lidoARMAbi = require("../../abis/LidoARM.json"); + +// Entrypoint for the Defender Action +const handler = async (event) => { + // Initialize defender relayer provider and signer + const client = new Defender(event); + const provider = client.relaySigner.getProvider({ ethersVersion: "v6" }); + const signer = await client.relaySigner.getSigner(provider, { + speed: "fastest", + ethersVersion: "v6", + }); + + console.log( + `DEBUG env var in handler before being set: "${process.env.DEBUG}"` + ); + + // References to contracts + const arm = new ethers.Contract(mainnet.lidoARM, lidoARMAbi, signer); + + try { + await setPrices({ + signer, + arm, + curve: true, + amount: 50, + tolerance: 0.2, + maxBuyPrice: 0.9997, + minSellPrice: 0.9999, + fee: 1, + blockTag: "latest", + }); + } catch (error) { + console.error(error); + } +}; + +module.exports = { handler }; diff --git a/src/js/tasks/lido.js b/src/js/tasks/lido.js index 492ce69..16d6d61 100644 --- a/src/js/tasks/lido.js +++ b/src/js/tasks/lido.js @@ -8,8 +8,6 @@ const { logUniswapSpotPrices, } = require("./markets"); const { getBlock } = require("../utils/block"); -const { abs } = require("../utils/maths"); -const { get1InchPrices } = require("../utils/1Inch"); const { getSigner } = require("../utils/signers"); const { logTxDetails } = require("../utils/txLogger"); const { @@ -20,78 +18,6 @@ const { resolveAddress, resolveAsset } = require("../utils/assets"); const log = require("../utils/logger")("task:lido"); -const setPrices = async (options) => { - const { signer, arm, fee, tolerance, buyPrice, midPrice, sellPrice, inch } = - options; - - // get current ARM stETH/WETH prices - const currentTradeRate0 = parseUnits("1", 72) / (await arm.traderate0()); - const currentTradeRate1 = await arm.traderate1(); - log(`current sell price : ${formatUnits(currentTradeRate0, 36)}`); - log(`current buy price : ${formatUnits(currentTradeRate1, 36)}`); - - let targetSellPrice; - let targetBuyPrice; - if (!buyPrice && !sellPrice && (midPrice || inch)) { - // get latest 1inch prices if no midPrice is provided - const referencePrices = midPrice - ? { - midPrice: parseUnits(midPrice.toString(), 18), - } - : await get1InchPrices(options.amount); - log(`mid price : ${formatUnits(referencePrices.midPrice)}`); - - const FeeScale = BigInt(1e6); - const feeRate = FeeScale - BigInt(fee * 100); - log(`fee : ${formatUnits(BigInt(fee * 1000000), 6)} bps`); - log(`fee rate : ${formatUnits(feeRate, 6)} bps`); - - targetSellPrice = - (referencePrices.midPrice * BigInt(1e18) * FeeScale) / feeRate; - targetBuyPrice = - (referencePrices.midPrice * BigInt(1e18) * feeRate) / FeeScale; - } else if (buyPrice && sellPrice) { - targetSellPrice = parseUnits(sellPrice.toString(), 18) * BigInt(1e18); - targetBuyPrice = parseUnits(buyPrice.toString(), 18) * BigInt(1e18); - } else { - throw new Error( - `Either both buy and sell prices should be provided or midPrice` - ); - } - - log(`target sell price : ${formatUnits(targetSellPrice, 36)}`); - log(`target buy price : ${formatUnits(targetBuyPrice, 36)}`); - - const diffBuyPrice = abs(targetBuyPrice - currentTradeRate1); - log(`buy price diff : ${formatUnits(diffBuyPrice, 36)}`); - - // tolerance option is in basis points - const toleranceScaled = parseUnits(tolerance.toString(), 36 - 4); - log(`tolerance : ${formatUnits(toleranceScaled, 36)}`); - - // decide if rates need to be updated - if (diffBuyPrice > toleranceScaled) { - // Note the prices of setPrices is from the AMM perspective and not the Trader - // hence the buy and sell prices are swapped - console.log(`About to update ARM prices`); - console.log(`sell: ${formatUnits(targetSellPrice, 36)}`); - console.log(`buy : ${formatUnits(targetBuyPrice, 36)}`); - - const tx = await arm - .connect(signer) - .setPrices(targetBuyPrice, targetSellPrice); - - await logTxDetails(tx, "setPrices", options.confirm); - } else { - console.log( - `No price update as price diff of ${formatUnits( - diffBuyPrice, - 36 - )} < tolerance ${formatUnits(toleranceScaled, 36)}` - ); - } -}; - async function setZapper() { const signer = await getSigner(); @@ -138,7 +64,8 @@ const submitLido = async ({ amount }) => { const snapLido = async ({ amount, block, curve, oneInch, uniswap, gas }) => { const blockTag = await getBlock(block); - const commonOptions = { amount, blockTag, pair: "stETH/ETH", gas }; + const signer = await getSigner(); + const commonOptions = { amount, blockTag, pair: "stETH/ETH", gas, signer }; const armAddress = await parseAddress("LIDO_ARM"); const lidoARM = await ethers.getContractAt("LidoARM", armAddress); @@ -349,6 +276,5 @@ module.exports = { submitLido, swapLido, snapLido, - setPrices, setZapper, }; diff --git a/src/js/tasks/lidoPrices.js b/src/js/tasks/lidoPrices.js new file mode 100644 index 0000000..af554a2 --- /dev/null +++ b/src/js/tasks/lidoPrices.js @@ -0,0 +1,152 @@ +const { formatUnits, parseUnits } = require("ethers"); + +const addresses = require("../utils/addresses"); + +const { abs } = require("../utils/maths"); +const { get1InchPrices } = require("../utils/1Inch"); +const { logTxDetails } = require("../utils/txLogger"); +const { getCurvePrices } = require("../utils/curve"); + +const log = require("../utils/logger")("task:lido"); + +const setPrices = async (options) => { + const { + signer, + arm, + fee, + tolerance, + buyPrice, + midPrice, + sellPrice, + minSellPrice, + maxBuyPrice, + curve, + inch, + } = options; + + // get current ARM stETH/WETH prices + const currentSellPrice = parseUnits("1", 72) / (await arm.traderate0()); + const currentBuyPrice = await arm.traderate1(); + log(`current sell price : ${formatUnits(currentSellPrice, 36)}`); + log(`current buy price : ${formatUnits(currentBuyPrice, 36)}`); + + let targetSellPrice; + let targetBuyPrice; + if (!buyPrice && !sellPrice && (midPrice || curve || inch)) { + // get latest 1inch prices if no midPrice is provided + const referencePrices = midPrice + ? { + midPrice: parseUnits(midPrice.toString(), 18), + } + : inch + ? await get1InchPrices(options.amount) + : await getCurvePrices({ + ...options, + poolAddress: addresses.mainnet.CurveStEthPool, + }); + log(`mid price : ${formatUnits(referencePrices.midPrice)}`); + + const FeeScale = BigInt(1e6); + const feeRate = FeeScale - BigInt(fee * 100); + log(`fee : ${formatUnits(BigInt(fee * 1000000), 6)} bps`); + log(`fee rate : ${formatUnits(feeRate, 6)} bps`); + + targetSellPrice = + (referencePrices.midPrice * BigInt(1e18) * FeeScale) / feeRate; + targetBuyPrice = + (referencePrices.midPrice * BigInt(1e18) * feeRate) / FeeScale; + + const minSellPriceBN = parseUnits(minSellPrice.toString(), 36); + const maxBuyPriceBN = parseUnits(maxBuyPrice.toString(), 36); + if (targetSellPrice < minSellPriceBN) { + log( + `target sell price ${formatUnits( + targetSellPrice, + 36 + )} is below min sell price ${minSellPrice} so will use min` + ); + targetSellPrice = minSellPriceBN; + } + if (targetBuyPrice > maxBuyPriceBN) { + log( + `target buy price ${formatUnits( + targetBuyPrice, + 36 + )} is above max buy price ${maxBuyPrice} so will use max` + ); + targetBuyPrice = maxBuyPriceBN; + } + + const crossPrice = await arm.crossPrice(); + if (targetSellPrice < crossPrice) { + log( + `target sell price ${formatUnits( + targetSellPrice, + 36 + )} is below cross price ${formatUnits( + crossPrice, + 36 + )} so will use cross price` + ); + targetSellPrice = crossPrice; + } + if (targetBuyPrice >= crossPrice) { + log( + `target buy price ${formatUnits( + targetBuyPrice, + 36 + )} is above cross price ${formatUnits( + crossPrice, + 36 + )} so will use cross price` + ); + targetBuyPrice = crossPrice - 1n; + } + } else if (buyPrice && sellPrice) { + targetSellPrice = parseUnits(sellPrice.toString(), 18) * BigInt(1e18); + targetBuyPrice = parseUnits(buyPrice.toString(), 18) * BigInt(1e18); + } else { + throw new Error( + `Either both buy and sell prices should be provided or midPrice` + ); + } + + log(`target sell price : ${formatUnits(targetSellPrice, 36)}`); + log(`target buy price : ${formatUnits(targetBuyPrice, 36)}`); + + const diffSellPrice = abs(targetSellPrice - currentSellPrice); + log(`sell price diff : ${formatUnits(diffSellPrice, 36)}`); + const diffBuyPrice = abs(targetBuyPrice - currentBuyPrice); + log(`buy price diff : ${formatUnits(diffBuyPrice, 36)}`); + + // tolerance option is in basis points + const toleranceScaled = parseUnits(tolerance.toString(), 36 - 4); + log(`tolerance : ${formatUnits(toleranceScaled, 36)}`); + + // decide if rates need to be updated + if (diffSellPrice > toleranceScaled || diffBuyPrice > toleranceScaled) { + console.log(`About to update ARM prices`); + console.log(`sell: ${formatUnits(targetSellPrice, 36)}`); + console.log(`buy : ${formatUnits(targetBuyPrice, 36)}`); + + const tx = await arm + .connect(signer) + .setPrices(targetBuyPrice, targetSellPrice); + + await logTxDetails(tx, "setPrices", options.confirm); + } else { + console.log( + `No price update as price diff of buy ${formatUnits( + diffBuyPrice, + 32 + )} and sell ${formatUnits(diffSellPrice, 32)} < tolerance ${formatUnits( + toleranceScaled, + 32 + )} basis points` + ); + } +}; + +module.exports = { + setPrices, +}; diff --git a/src/js/tasks/markets.js b/src/js/tasks/markets.js index b933c62..168ca49 100644 --- a/src/js/tasks/markets.js +++ b/src/js/tasks/markets.js @@ -230,7 +230,7 @@ const logUniswapSpotPrices = async (options, ammPrices) => { 20 )} ${pair}, diff ${formatUnits(sellRateDiff, 14)} bps to ARM${sellGasCosts}` ); - console.log(`spread : ${formatUnits(uniswap.spread, 14)} bps`); + console.log(`spread : ${formatUnits(uniswap.spread, 14)} bps`); return uniswap; }; diff --git a/src/js/tasks/tasks.js b/src/js/tasks/tasks.js index af80f97..4c127d3 100644 --- a/src/js/tasks/tasks.js +++ b/src/js/tasks/tasks.js @@ -12,9 +12,9 @@ const { snapLido, swapLido, lidoWithdrawStatus, - setPrices, setZapper, } = require("./lido"); +const { setPrices } = require("./lidoPrices"); const { requestLidoWithdrawals, claimLidoWithdrawals, @@ -589,16 +589,22 @@ subtask("setPrices", "Update Lido ARM's swap prices") types.float ) .addOptionalParam( - "sellPrice", - "The sell price if not using the midPrice.", + "minSellPrice", + "The min sell price when pricing off market. eg 1Inch or Curve", undefined, types.float ) .addOptionalParam( - "inch", - "Set prices off the current 1Inch mid price.", + "maxBuyPrice", + "The max buy price when pricing off market. eg 1Inch or Curve", undefined, - types.boolean + types.float + ) + .addOptionalParam( + "sellPrice", + "The sell price if not using the midPrice.", + undefined, + types.float ) .addOptionalParam( "fee", @@ -609,9 +615,21 @@ subtask("setPrices", "Update Lido ARM's swap prices") .addOptionalParam( "tolerance", "Allowed difference in basis points. eg 1 = 0.0001%", - 0.2, + 0.1, types.float ) + .addOptionalParam( + "curve", + "Set prices off the current Curve mid price.", + undefined, + types.boolean + ) + .addOptionalParam( + "inch", + "Set prices off the current 1Inch mid price.", + undefined, + types.boolean + ) .setAction(async (taskArgs) => { const signer = await getSigner(); diff --git a/src/js/utils/curve.js b/src/js/utils/curve.js index e8f6515..8a137d7 100644 --- a/src/js/utils/curve.js +++ b/src/js/utils/curve.js @@ -1,9 +1,18 @@ -const { parseUnits } = require("ethers"); +const { parseUnits, formatUnits } = require("ethers"); +const { ethers } = require("ethers"); const curvePoolAbi = require("../../abis/CurveStEthPool.json"); -const getCurvePrices = async ({ amount, poolAddress, blockTag, gas }) => { - const pool = await ethers.getContractAt(curvePoolAbi, poolAddress); +const log = require("../utils/logger")("task:curve"); + +const getCurvePrices = async ({ + amount, + poolAddress, + blockTag, + gas, + signer, +}) => { + const pool = new ethers.Contract(poolAddress, curvePoolAbi, signer); const amountBI = parseUnits(amount.toString(), 18); @@ -16,6 +25,7 @@ const getCurvePrices = async ({ amount, poolAddress, blockTag, gas }) => { ); // stETH/ETH rate = ETH amount / stETH amount const buyPrice = (amountBI * BigInt(1e18)) / buyToAmount; + log(`Curve buy price ${formatUnits(buyPrice)} stETH/ETH`); // Swap stETH for ETH const sellToAmount = await pool["get_dy(int128,int128,uint256)"]( @@ -26,6 +36,7 @@ const getCurvePrices = async ({ amount, poolAddress, blockTag, gas }) => { ); // stETH/WETH rate = WETH amount / stETH amount const sellPrice = (sellToAmount * BigInt(1e18)) / amountBI; + log(`Curve sell price ${formatUnits(sellPrice)} stETH/ETH`); const midPrice = (buyPrice + sellPrice) / 2n; const spread = buyPrice - sellPrice; diff --git a/test/smoke/LidoARMSmokeTest.t.sol b/test/smoke/LidoARMSmokeTest.t.sol index b558645..c428f5f 100644 --- a/test/smoke/LidoARMSmokeTest.t.sol +++ b/test/smoke/LidoARMSmokeTest.t.sol @@ -44,7 +44,7 @@ contract Fork_LidoARM_Smoke_Test is AbstractSmokeTest { assertEq(lidoARM.symbol(), "ARM-WETH-stETH", "Symbol"); assertEq(lidoARM.owner(), Mainnet.GOV_MULTISIG, "Owner"); assertEq(lidoARM.operator(), Mainnet.ARM_RELAYER, "Operator"); - assertEq(lidoARM.feeCollector(), Mainnet.ARM_BUYBACK, "Fee collector"); + assertEq(lidoARM.feeCollector(), Mainnet.STRATEGIST, "Fee collector"); assertEq((100 * uint256(lidoARM.fee())) / lidoARM.FEE_SCALE(), 20, "Performance fee as a percentage"); // LidoLiquidityManager assertEq(address(lidoARM.lidoWithdrawalQueue()), Mainnet.LIDO_WITHDRAWAL, "Lido withdrawal queue");